}
-> = {
- aws: {
- label: "Amazon Web Services",
- icon: AWSProviderBadge,
- },
- azure: {
- label: "Microsoft Azure",
- icon: AzureProviderBadge,
- },
- gcp: {
- label: "Google Cloud Platform",
- icon: GCPProviderBadge,
- },
- kubernetes: {
- label: "Kubernetes",
- icon: KS8ProviderBadge,
- },
- m365: {
- label: "Microsoft 365",
- icon: M365ProviderBadge,
- },
- github: {
- label: "GitHub",
- icon: GitHubProviderBadge,
- },
- googleworkspace: {
- label: "Google Workspace",
- icon: GoogleWorkspaceProviderBadge,
- },
- iac: {
- label: "Infrastructure as Code",
- icon: IacProviderBadge,
- },
- image: {
- label: "Container Registry",
- icon: ImageProviderBadge,
- },
- oraclecloud: {
- label: "Oracle Cloud Infrastructure",
- icon: OracleCloudProviderBadge,
- },
- mongodbatlas: {
- label: "MongoDB Atlas",
- icon: MongoDBAtlasProviderBadge,
- },
- alibabacloud: {
- label: "Alibaba Cloud",
- icon: AlibabaCloudProviderBadge,
- },
- cloudflare: {
- label: "Cloudflare",
- icon: CloudflareProviderBadge,
- },
- openstack: {
- label: "OpenStack",
- icon: OpenStackProviderBadge,
- },
- vercel: {
- label: "Vercel",
- icon: VercelProviderBadge,
- },
- okta: {
- label: "Okta",
- icon: OktaProviderBadge,
- },
-};
-
/** Common props shared by both batch and instant modes. */
interface ProviderTypeSelectorBaseProps {
providers: ProviderProps[];
@@ -247,34 +94,38 @@ export const ProviderTypeSelector = ({
.map((p) => p.attributes.provider),
),
)
- .filter((type): type is ProviderType => type in PROVIDER_DATA)
+ .filter((type): type is ProviderType => type in PROVIDER_TYPE_DATA)
.sort((a, b) =>
- PROVIDER_DATA[a].label.localeCompare(PROVIDER_DATA[b].label),
+ PROVIDER_TYPE_DATA[a].label.localeCompare(PROVIDER_TYPE_DATA[b].label),
);
- const renderIcon = (providerType: ProviderType) => {
- const IconComponent = PROVIDER_DATA[providerType].icon;
- return (
- }>
-
-
- );
- };
-
const selectedLabel = () => {
if (selectedTypes.length === 0) return null;
if (selectedTypes.length === 1) {
const providerType = selectedTypes[0] as ProviderType;
return (
- {renderIcon(providerType)}
- {PROVIDER_DATA[providerType].label}
+
+
+
+
+ {PROVIDER_TYPE_DATA[providerType].label}
+
);
}
return (
-
- {selectedTypes.length} Provider Types selected
+
+ ({
+ key: type,
+ type,
+ tooltip: PROVIDER_TYPE_DATA[type].label,
+ }))}
+ />
+
+ {selectedTypes.length} Provider Types selected
+
);
};
@@ -329,12 +180,17 @@ export const ProviderTypeSelector = ({
- {renderIcon(providerType)}
- {PROVIDER_DATA[providerType].label}
+
+
+
+ {PROVIDER_TYPE_DATA[providerType].label}
))}
>
diff --git a/ui/components/findings/table/provider-icon-cell.test.tsx b/ui/components/findings/table/provider-icon-cell.test.tsx
new file mode 100644
index 0000000000..593122d33a
--- /dev/null
+++ b/ui/components/findings/table/provider-icon-cell.test.tsx
@@ -0,0 +1,45 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import type { ProviderType } from "@/types/providers";
+
+import { ProviderIconCell } from "./provider-icon-cell";
+
+// Render the lazy provider badges as plain text so we can assert on them. The
+// real PROVIDER_TYPE_DATA map (and its `in` guard) is exercised on purpose.
+vi.mock("@/components/icons/providers-badge", () => ({
+ AWSProviderBadge: () => AWS ,
+ AzureProviderBadge: () => Azure ,
+ GCPProviderBadge: () => GCP ,
+ KS8ProviderBadge: () => Kubernetes ,
+ M365ProviderBadge: () => M365 ,
+ GitHubProviderBadge: () => GitHub ,
+ GoogleWorkspaceProviderBadge: () => Google Workspace ,
+ IacProviderBadge: () => IaC ,
+ ImageProviderBadge: () => Image ,
+ OracleCloudProviderBadge: () => Oracle Cloud ,
+ MongoDBAtlasProviderBadge: () => MongoDB Atlas ,
+ AlibabaCloudProviderBadge: () => Alibaba Cloud ,
+ CloudflareProviderBadge: () => Cloudflare ,
+ OpenStackProviderBadge: () => OpenStack ,
+ VercelProviderBadge: () => Vercel ,
+ OktaProviderBadge: () => Okta ,
+}));
+
+describe("ProviderIconCell", () => {
+ it("renders the shared provider-type icon for a known provider", async () => {
+ render( );
+
+ expect(await screen.findByText("AWS")).toBeInTheDocument();
+ });
+
+ it("renders a '?' placeholder for a provider type missing from the map", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("?")).toBeInTheDocument();
+ });
+});
diff --git a/ui/components/findings/table/provider-icon-cell.tsx b/ui/components/findings/table/provider-icon-cell.tsx
index 05a35b3dc2..350e730e78 100644
--- a/ui/components/findings/table/provider-icon-cell.tsx
+++ b/ui/components/findings/table/provider-icon-cell.tsx
@@ -1,43 +1,10 @@
import {
- AlibabaCloudProviderBadge,
- AWSProviderBadge,
- AzureProviderBadge,
- CloudflareProviderBadge,
- GCPProviderBadge,
- GitHubProviderBadge,
- GoogleWorkspaceProviderBadge,
- IacProviderBadge,
- ImageProviderBadge,
- KS8ProviderBadge,
- M365ProviderBadge,
- MongoDBAtlasProviderBadge,
- OktaProviderBadge,
- OpenStackProviderBadge,
- OracleCloudProviderBadge,
- VercelProviderBadge,
-} from "@/components/icons/providers-badge";
+ PROVIDER_TYPE_DATA,
+ ProviderTypeIcon,
+} from "@/components/icons/providers-badge/provider-type-icon";
import { cn } from "@/lib/utils";
import { ProviderType } from "@/types";
-export const PROVIDER_ICONS = {
- aws: AWSProviderBadge,
- azure: AzureProviderBadge,
- gcp: GCPProviderBadge,
- kubernetes: KS8ProviderBadge,
- m365: M365ProviderBadge,
- github: GitHubProviderBadge,
- googleworkspace: GoogleWorkspaceProviderBadge,
- iac: IacProviderBadge,
- image: ImageProviderBadge,
- oraclecloud: OracleCloudProviderBadge,
- mongodbatlas: MongoDBAtlasProviderBadge,
- alibabacloud: AlibabaCloudProviderBadge,
- cloudflare: CloudflareProviderBadge,
- openstack: OpenStackProviderBadge,
- vercel: VercelProviderBadge,
- okta: OktaProviderBadge,
-} as const;
-
interface ProviderIconCellProps {
provider: ProviderType;
size?: number;
@@ -49,9 +16,9 @@ export const ProviderIconCell = ({
size = 26,
className = "size-8 rounded-md bg-white",
}: ProviderIconCellProps) => {
- const IconComponent = PROVIDER_ICONS[provider];
-
- if (!IconComponent) {
+ // Unknown provider types (present in the data but missing from the shared
+ // PROVIDER_TYPE_DATA map) render an explicit "?" rather than an empty icon.
+ if (!(provider in PROVIDER_TYPE_DATA)) {
return (
?
@@ -66,7 +33,7 @@ export const ProviderIconCell = ({
className,
)}
>
-
+
);
};
diff --git a/ui/components/icons/providers-badge/provider-type-icon.test.tsx b/ui/components/icons/providers-badge/provider-type-icon.test.tsx
new file mode 100644
index 0000000000..96b4f5f4d4
--- /dev/null
+++ b/ui/components/icons/providers-badge/provider-type-icon.test.tsx
@@ -0,0 +1,126 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import type { ProviderType } from "@/types/providers";
+
+import { ProviderTypeIcon, ProviderTypeIconStack } from "./provider-type-icon";
+
+// A provider type the API may return but this UI build does not know about.
+const UNKNOWN_TYPE = "future-provider" as unknown as ProviderType;
+
+// Render the lazy provider badges as plain text so we can assert on them.
+vi.mock("@/components/icons/providers-badge", () => ({
+ AWSProviderBadge: () => AWS ,
+ AzureProviderBadge: () => Azure ,
+ GCPProviderBadge: () => GCP ,
+ KS8ProviderBadge: () => Kubernetes ,
+ M365ProviderBadge: () => M365 ,
+ GitHubProviderBadge: () => GitHub ,
+ GoogleWorkspaceProviderBadge: () => Google Workspace ,
+ IacProviderBadge: () => IaC ,
+ ImageProviderBadge: () => Image ,
+ OracleCloudProviderBadge: () => Oracle Cloud ,
+ MongoDBAtlasProviderBadge: () => MongoDB Atlas ,
+ AlibabaCloudProviderBadge: () => Alibaba Cloud ,
+ CloudflareProviderBadge: () => Cloudflare ,
+ OpenStackProviderBadge: () => OpenStack ,
+ VercelProviderBadge: () => Vercel ,
+ OktaProviderBadge: () => Okta ,
+}));
+
+// Render the tooltip pieces inline so the hover content is queryable in jsdom.
+vi.mock("@/components/shadcn", () => ({
+ Badge: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}>,
+ TooltipTrigger: ({ children }: { children: React.ReactNode }) => (
+ <>{children}>
+ ),
+ TooltipContent: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+}));
+
+describe("ProviderTypeIcon", () => {
+ it("renders the badge for the given provider type", async () => {
+ render( );
+
+ expect(await screen.findByText("AWS")).toBeInTheDocument();
+ });
+
+ it("renders a sized placeholder instead of crashing for an unknown type", () => {
+ // Regression guard for #9991: an unknown provider type must not throw.
+ const { container } = render(
+ ,
+ );
+
+ expect(screen.queryByText("AWS")).not.toBeInTheDocument();
+ expect(container.querySelector("div")).toHaveStyle({
+ width: "24px",
+ height: "24px",
+ });
+ });
+});
+
+describe("ProviderTypeIconStack", () => {
+ it("renders one icon per item without deduping by type", async () => {
+ render(
+ ,
+ );
+
+ // Two AWS accounts -> two AWS icons (no dedupe).
+ expect(await screen.findAllByText("AWS")).toHaveLength(2);
+ });
+
+ it("shows each item's tooltip text on the icon", async () => {
+ render(
+ ,
+ );
+
+ expect(await screen.findByTestId("tooltip")).toHaveTextContent(
+ "account-uid-123",
+ );
+ });
+
+ it("collapses items beyond `max` into a +N badge", async () => {
+ render(
+ ,
+ );
+
+ expect(await screen.findByTestId("badge")).toHaveTextContent("+2");
+ // First icon is shown; items sliced beyond `max` never reach the DOM.
+ expect(await screen.findByText("AWS")).toBeInTheDocument();
+ expect(screen.queryByText("Okta")).not.toBeInTheDocument();
+ });
+
+ it("renders known icons and skips unknown types without crashing", async () => {
+ // Regression guard for #9991: an unknown type in the stack must not throw.
+ render(
+ ,
+ );
+
+ expect(await screen.findByText("AWS")).toBeInTheDocument();
+ });
+});
diff --git a/ui/components/icons/providers-badge/provider-type-icon.tsx b/ui/components/icons/providers-badge/provider-type-icon.tsx
new file mode 100644
index 0000000000..c3915f7d40
--- /dev/null
+++ b/ui/components/icons/providers-badge/provider-type-icon.tsx
@@ -0,0 +1,250 @@
+"use client";
+
+import { type ComponentType, lazy, Suspense } from "react";
+
+import {
+ Badge,
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/shadcn";
+import { cn } from "@/lib/utils";
+import type { ProviderType } from "@/types/providers";
+
+type IconProps = { width: number; height: number };
+
+const IconPlaceholder = ({ width, height }: IconProps) => (
+
+);
+
+// Lazy-load every provider badge so the ~16 SVGs ship in a single deferred
+// chunk instead of being eagerly bundled wherever a selector is imported.
+const AWSProviderBadge = lazy(() =>
+ import("@/components/icons/providers-badge").then((m) => ({
+ default: m.AWSProviderBadge,
+ })),
+);
+const AzureProviderBadge = lazy(() =>
+ import("@/components/icons/providers-badge").then((m) => ({
+ default: m.AzureProviderBadge,
+ })),
+);
+const GCPProviderBadge = lazy(() =>
+ import("@/components/icons/providers-badge").then((m) => ({
+ default: m.GCPProviderBadge,
+ })),
+);
+const KS8ProviderBadge = lazy(() =>
+ import("@/components/icons/providers-badge").then((m) => ({
+ default: m.KS8ProviderBadge,
+ })),
+);
+const M365ProviderBadge = lazy(() =>
+ import("@/components/icons/providers-badge").then((m) => ({
+ default: m.M365ProviderBadge,
+ })),
+);
+const GitHubProviderBadge = lazy(() =>
+ import("@/components/icons/providers-badge").then((m) => ({
+ default: m.GitHubProviderBadge,
+ })),
+);
+const GoogleWorkspaceProviderBadge = lazy(() =>
+ import("@/components/icons/providers-badge").then((m) => ({
+ default: m.GoogleWorkspaceProviderBadge,
+ })),
+);
+const IacProviderBadge = lazy(() =>
+ import("@/components/icons/providers-badge").then((m) => ({
+ default: m.IacProviderBadge,
+ })),
+);
+const ImageProviderBadge = lazy(() =>
+ import("@/components/icons/providers-badge").then((m) => ({
+ default: m.ImageProviderBadge,
+ })),
+);
+const OracleCloudProviderBadge = lazy(() =>
+ import("@/components/icons/providers-badge").then((m) => ({
+ default: m.OracleCloudProviderBadge,
+ })),
+);
+const MongoDBAtlasProviderBadge = lazy(() =>
+ import("@/components/icons/providers-badge").then((m) => ({
+ default: m.MongoDBAtlasProviderBadge,
+ })),
+);
+const AlibabaCloudProviderBadge = lazy(() =>
+ import("@/components/icons/providers-badge").then((m) => ({
+ default: m.AlibabaCloudProviderBadge,
+ })),
+);
+const CloudflareProviderBadge = lazy(() =>
+ import("@/components/icons/providers-badge").then((m) => ({
+ default: m.CloudflareProviderBadge,
+ })),
+);
+const OpenStackProviderBadge = lazy(() =>
+ import("@/components/icons/providers-badge").then((m) => ({
+ default: m.OpenStackProviderBadge,
+ })),
+);
+const VercelProviderBadge = lazy(() =>
+ import("@/components/icons/providers-badge").then((m) => ({
+ default: m.VercelProviderBadge,
+ })),
+);
+const OktaProviderBadge = lazy(() =>
+ import("@/components/icons/providers-badge").then((m) => ({
+ default: m.OktaProviderBadge,
+ })),
+);
+
+/**
+ * Single source of truth mapping each provider type to its human-readable
+ * label and (lazy) badge component. Shared by the account and provider-type
+ * selectors so both stay in sync on labels, icons, and sizing.
+ */
+export const PROVIDER_TYPE_DATA: Record<
+ ProviderType,
+ { label: string; icon: ComponentType }
+> = {
+ aws: { label: "Amazon Web Services", icon: AWSProviderBadge },
+ azure: { label: "Microsoft Azure", icon: AzureProviderBadge },
+ gcp: { label: "Google Cloud Platform", icon: GCPProviderBadge },
+ kubernetes: { label: "Kubernetes", icon: KS8ProviderBadge },
+ m365: { label: "Microsoft 365", icon: M365ProviderBadge },
+ github: { label: "GitHub", icon: GitHubProviderBadge },
+ googleworkspace: {
+ label: "Google Workspace",
+ icon: GoogleWorkspaceProviderBadge,
+ },
+ iac: { label: "Infrastructure as Code", icon: IacProviderBadge },
+ image: { label: "Container Registry", icon: ImageProviderBadge },
+ oraclecloud: {
+ label: "Oracle Cloud Infrastructure",
+ icon: OracleCloudProviderBadge,
+ },
+ mongodbatlas: { label: "MongoDB Atlas", icon: MongoDBAtlasProviderBadge },
+ alibabacloud: { label: "Alibaba Cloud", icon: AlibabaCloudProviderBadge },
+ cloudflare: { label: "Cloudflare", icon: CloudflareProviderBadge },
+ openstack: { label: "OpenStack", icon: OpenStackProviderBadge },
+ vercel: { label: "Vercel", icon: VercelProviderBadge },
+ okta: { label: "Okta", icon: OktaProviderBadge },
+};
+
+interface ProviderTypeIconProps {
+ type: ProviderType;
+ size?: number;
+}
+
+/**
+ * Renders a single provider-type badge with a sized placeholder fallback.
+ *
+ * Falls back to the placeholder for provider types missing from
+ * `PROVIDER_TYPE_DATA` (e.g. a brand-new provider the API knows but this UI
+ * build does not). The `type` is statically typed as `ProviderType`, so this
+ * only guards the runtime case — see #9991, which fixed the same crash class.
+ */
+export const ProviderTypeIcon = ({
+ type,
+ size = 18,
+}: ProviderTypeIconProps) => {
+ const data = PROVIDER_TYPE_DATA[type];
+ if (!data) return ;
+
+ const Icon = data.icon;
+ return (
+ }>
+
+
+ );
+};
+
+export interface ProviderTypeIconStackItem {
+ /** Stable React key (account id for accounts, provider type for types). */
+ key: string;
+ type: ProviderType;
+ /** Text shown on hover to disambiguate the icon (e.g. an account UID). */
+ tooltip?: string;
+}
+
+interface ProviderTypeIconStackProps {
+ items: ProviderTypeIconStackItem[];
+ max?: number;
+ size?: number;
+ className?: string;
+}
+
+/**
+ * Icon with a hover tooltip. `TooltipContent` (shadcn) already renders inside a
+ * Radix portal, so the tooltip is not clipped by the selector trigger and we do
+ * not need to portal it ourselves. `delayDuration` is set on the tooltip itself
+ * because shadcn's `Tooltip` wraps each instance in its own `TooltipProvider`
+ * (delay 0), which would otherwise override an ancestor provider's delay.
+ */
+const IconWithTooltip = ({
+ item,
+ size,
+}: {
+ item: ProviderTypeIconStackItem;
+ size: number;
+}) => {
+ const icon = (
+
+
+
+ );
+
+ if (!item.tooltip) return icon;
+
+ return (
+
+ {icon}
+ {item.tooltip}
+
+ );
+};
+
+/**
+ * Renders up to `max` provider-type icons followed by a `+N` badge for the
+ * remainder. Each icon shows its `tooltip` on hover. Items are rendered as
+ * passed (one per selection) — callers decide whether to dedupe.
+ */
+export const ProviderTypeIconStack = ({
+ items,
+ max = 3,
+ size = 18,
+ className,
+}: ProviderTypeIconStackProps) => {
+ const visible = items.slice(0, max);
+ const overflow = items.slice(max);
+ const overflowLabel = overflow
+ .map((item) => item.tooltip)
+ .filter(Boolean)
+ .join(", ");
+
+ return (
+
+
+ {visible.map((item) => (
+
+ ))}
+
+ {overflow.length > 0 && (
+
+
+
+ +{overflow.length}
+
+
+ {overflowLabel && (
+
+ {overflowLabel}
+
+ )}
+
+ )}
+
+ );
+};
diff --git a/ui/components/integrations/s3/s3-integration-form.tsx b/ui/components/integrations/s3/s3-integration-form.tsx
index 7b3af4b018..23be644570 100644
--- a/ui/components/integrations/s3/s3-integration-form.tsx
+++ b/ui/components/integrations/s3/s3-integration-form.tsx
@@ -8,7 +8,10 @@ import { useState } from "react";
import { Control, useForm } from "react-hook-form";
import { createIntegration, updateIntegration } from "@/actions/integrations";
-import { PROVIDER_ICONS } from "@/components/findings/table/provider-icon-cell";
+import {
+ PROVIDER_TYPE_DATA,
+ ProviderTypeIcon,
+} from "@/components/icons/providers-badge/provider-type-icon";
import { AWSRoleCredentialsForm } from "@/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form";
import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
import { useToast } from "@/components/ui";
@@ -279,11 +282,14 @@ export const S3IntegrationForm = ({
// Show configuration step (step 0 or editing configuration)
if (isEditingConfig || currentStep === 0) {
const providerOptions = providers.map((provider) => {
- const Icon = PROVIDER_ICONS[provider.attributes.provider];
+ const providerType = provider.attributes.provider;
return {
value: provider.id,
label: provider.attributes.alias || provider.attributes.uid,
- icon: Icon ? : undefined,
+ icon:
+ providerType in PROVIDER_TYPE_DATA ? (
+
+ ) : undefined,
description: provider.attributes.connection.connected
? "Connected"
: "Disconnected",
diff --git a/ui/components/integrations/security-hub/security-hub-integration-form.tsx b/ui/components/integrations/security-hub/security-hub-integration-form.tsx
index 8a62f62457..1c3b436832 100644
--- a/ui/components/integrations/security-hub/security-hub-integration-form.tsx
+++ b/ui/components/integrations/security-hub/security-hub-integration-form.tsx
@@ -10,7 +10,10 @@ import { useEffect, useState } from "react";
import { Control, useForm } from "react-hook-form";
import { createIntegration, updateIntegration } from "@/actions/integrations";
-import { PROVIDER_ICONS } from "@/components/findings/table/provider-icon-cell";
+import {
+ PROVIDER_TYPE_DATA,
+ ProviderTypeIcon,
+} from "@/components/icons/providers-badge/provider-type-icon";
import { AWSRoleCredentialsForm } from "@/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form";
import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
import { useToast } from "@/components/ui";
@@ -121,11 +124,14 @@ export const SecurityHubIntegrationForm = ({
? "Connected"
: "Disconnected";
- const Icon = PROVIDER_ICONS[provider.attributes.provider];
+ const providerType = provider.attributes.provider;
return {
value: provider.id,
label: provider.attributes.alias || provider.attributes.uid,
- icon: Icon ? : undefined,
+ icon:
+ providerType in PROVIDER_TYPE_DATA ? (
+
+ ) : undefined,
description: isDisabled
? `${connectionLabel} (Already in use)`
: connectionLabel,
diff --git a/ui/components/providers/no-providers-added.tsx b/ui/components/providers/no-providers-added.tsx
new file mode 100644
index 0000000000..0860f92d80
--- /dev/null
+++ b/ui/components/providers/no-providers-added.tsx
@@ -0,0 +1,92 @@
+"use client";
+
+import Link from "next/link";
+
+import { InfoIcon } from "@/components/icons/Icons";
+import { Button, Card, CardContent } from "@/components/shadcn";
+import { cn } from "@/lib/utils";
+
+const NO_PROVIDERS_ADDED_ACTION = {
+ BUTTON: "button",
+ LINK: "link",
+} as const;
+
+interface NoProvidersAddedBaseProps {
+ containerClassName?: string;
+}
+
+interface NoProvidersAddedButtonProps extends NoProvidersAddedBaseProps {
+ action: typeof NO_PROVIDERS_ADDED_ACTION.BUTTON;
+ onOpenWizard: () => void;
+ href?: never;
+}
+
+interface NoProvidersAddedLinkProps extends NoProvidersAddedBaseProps {
+ action: typeof NO_PROVIDERS_ADDED_ACTION.LINK;
+ href: string;
+ onOpenWizard?: never;
+}
+
+type NoProvidersAddedProps =
+ | NoProvidersAddedButtonProps
+ | NoProvidersAddedLinkProps;
+
+const renderCta = (props: NoProvidersAddedProps) => {
+ if (props.action === NO_PROVIDERS_ADDED_ACTION.LINK) {
+ return (
+
+ Get Started
+
+ );
+ }
+
+ return (
+
+ Get Started
+
+ );
+};
+
+export const NoProvidersAdded = (props: NoProvidersAddedProps) => {
+ return (
+
+
+
+
+
+
+ No Providers Configured
+
+
+
+
+ No providers have been configured. Start by setting up a provider.
+
+
+
+ {renderCta(props)}
+
+
+
+ );
+};
diff --git a/ui/components/providers/providers-accounts-view.test.tsx b/ui/components/providers/providers-accounts-view.test.tsx
index 9263277ea9..c8b3baf596 100644
--- a/ui/components/providers/providers-accounts-view.test.tsx
+++ b/ui/components/providers/providers-accounts-view.test.tsx
@@ -1,11 +1,36 @@
import { render, screen } from "@testing-library/react";
-import { describe, expect, it, vi } from "vitest";
+import userEvent from "@testing-library/user-event";
+import type { ReactNode } from "react";
+import { afterEach, describe, expect, it, vi } from "vitest";
import type { FilterOption, MetaDataProps, ProviderProps } from "@/types";
import type { ProvidersTableRow } from "@/types/providers-table";
+const { refreshMock, replaceMock, searchParamsValue } = vi.hoisted(() => ({
+ refreshMock: vi.fn(),
+ replaceMock: vi.fn(),
+ searchParamsValue: { current: "" },
+}));
+
+vi.mock("next/navigation", () => ({
+ usePathname: () => "/providers",
+ useRouter: () => ({
+ refresh: refreshMock,
+ replace: replaceMock,
+ }),
+ useSearchParams: () => new URLSearchParams(searchParamsValue.current),
+}));
+
+vi.mock("@/components/providers/table", () => ({
+ SkeletonTableProviders: () =>
,
+}));
+
vi.mock("@/components/providers/add-provider-button", () => ({
- AddProviderButton: () => Add provider ,
+ AddProviderButton: ({ onOpenWizard }: { onOpenWizard: () => void }) => (
+
+ Add Provider
+
+ ),
}));
vi.mock("@/components/providers/muted-findings-config-button", () => ({
@@ -15,7 +40,12 @@ vi.mock("@/components/providers/muted-findings-config-button", () => ({
}));
vi.mock("@/components/providers/providers-filters", () => ({
- ProvidersFilters: () => Filters
,
+ ProvidersFilters: ({ actions }: { actions: ReactNode }) => (
+
+ Filters
+ {actions}
+
+ ),
}));
vi.mock("@/components/providers/providers-accounts-table", () => ({
@@ -23,7 +53,21 @@ vi.mock("@/components/providers/providers-accounts-table", () => ({
}));
vi.mock("@/components/providers/wizard", () => ({
- ProviderWizardModal: () =>
,
+ ProviderWizardModal: ({
+ open,
+ onOpenChange,
+ }: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ }) =>
+ open ? (
+
+ Provider wizard
+ onOpenChange(false)}>
+ Close
+
+
+ ) : null,
}));
import { ProvidersAccountsView } from "./providers-accounts-view";
@@ -36,8 +80,55 @@ const metadata: MetaDataProps = {
version: "latest",
};
+const disconnectedProviders: ProviderProps[] = [
+ {
+ id: "provider-1",
+ type: "providers",
+ attributes: {
+ provider: "aws",
+ uid: "123456789012",
+ alias: "Production",
+ status: "completed",
+ resources: 0,
+ connection: {
+ connected: false,
+ last_checked_at: "2026-04-13T00:00:00Z",
+ },
+ scanner_args: {
+ only_logs: false,
+ excluded_checks: [],
+ aws_retries_max_attempts: 3,
+ },
+ inserted_at: "2026-04-13T00:00:00Z",
+ updated_at: "2026-04-13T00:00:00Z",
+ created_by: {
+ object: "user",
+ id: "user-1",
+ },
+ },
+ relationships: {
+ secret: {
+ data: null,
+ },
+ provider_groups: {
+ meta: {
+ count: 0,
+ },
+ data: [],
+ },
+ },
+ },
+];
+
describe("ProvidersAccountsView", () => {
- it("keeps the same vertical spacing between filters and table as other views", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ searchParamsValue.current = "";
+ window.history.replaceState({}, "", "/");
+ });
+
+ it("shows a full page empty state without filters or table when there are no providers", () => {
+ // Given/When
render(
{
/>,
);
+ // Then
+ expect(screen.getByText("No Providers Configured")).toBeInTheDocument();
+ expect(
+ screen.getByRole("region", { name: /no providers configured/i }),
+ ).toHaveClass("min-h-[calc(100dvh-28rem)]");
+ expect(screen.queryByTestId("providers-filters")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("providers-table")).not.toBeInTheDocument();
+ });
+
+ it("opens the provider wizard from the no providers CTA", async () => {
+ // Given
+ const user = userEvent.setup();
+
+ render(
+ ,
+ );
+
+ // When
+ await user.click(
+ screen.getByRole("button", { name: /open add provider modal/i }),
+ );
+
+ // Then
+ expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard");
+ });
+
+ it("opens the provider wizard from the URL without immediately clearing the one-shot intent", () => {
+ // Given
+ searchParamsValue.current = "tab=connected&addProvider=true";
+ window.history.replaceState(
+ {},
+ "",
+ "/providers?tab=connected&addProvider=true",
+ );
+ // Spy only after the URL setup so we measure what the component does on mount.
+ const replaceStateSpy = vi.spyOn(window.history, "replaceState");
+
+ render(
+ ,
+ );
+
+ // Then
+ expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard");
+ expect(replaceStateSpy).not.toHaveBeenCalled();
+ });
+
+ it("cleans the one-shot intent from the URL without refetching when the URL-opened wizard closes", async () => {
+ // Given
+ searchParamsValue.current = "tab=connected&addProvider=true";
+ const replaceStateSpy = vi.spyOn(window.history, "replaceState");
+ const user = userEvent.setup();
+
+ render(
+ ,
+ );
+
+ // When
+ await user.click(screen.getByRole("button", { name: /close/i }));
+
+ // Then
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ // The URL is cleaned via the History API (no RSC refetch). We must NOT
+ // refresh/replace here: re-running the /providers Server Component on close
+ // read as a full page reload. The provider-creation actions already
+ // revalidatePath("/providers"), so the table is fresh behind the modal.
+ expect(replaceStateSpy).toHaveBeenCalledWith(
+ null,
+ "",
+ "/providers?tab=connected",
+ );
+ expect(refreshMock).not.toHaveBeenCalled();
+ expect(replaceMock).not.toHaveBeenCalled();
+ });
+
+ it("does not touch the URL or refetch when a manually opened wizard closes", async () => {
+ // Given: no addProvider param in the URL, wizard opened via the CTA.
+ searchParamsValue.current = "";
+ const replaceStateSpy = vi.spyOn(window.history, "replaceState");
+ const user = userEvent.setup();
+
+ render(
+ ,
+ );
+
+ // When: open the wizard from the empty-state CTA, then close it.
+ await user.click(
+ screen.getByRole("button", { name: /open add provider modal/i }),
+ );
+ await user.click(screen.getByRole("button", { name: /close/i }));
+
+ // Then: nothing to clean and no refresh — the creation actions own the
+ // data refresh via revalidatePath.
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ expect(replaceStateSpy).not.toHaveBeenCalled();
+ expect(refreshMock).not.toHaveBeenCalled();
+ expect(replaceMock).not.toHaveBeenCalled();
+ });
+
+ it("keeps filters and table visible when providers are disconnected", () => {
+ // Given/When
+ render(
+ ,
+ );
+
+ // Then
expect(screen.getByTestId("providers-filters").parentElement).toHaveClass(
"flex",
"flex-col",
"gap-6",
);
expect(screen.getByTestId("providers-table")).toBeInTheDocument();
+ expect(
+ screen.queryByText("No Providers Configured"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("opens the provider wizard from the normal Add Provider button", async () => {
+ // Given
+ const user = userEvent.setup();
+
+ render(
+ ,
+ );
+
+ // When
+ await user.click(screen.getByRole("button", { name: /add provider/i }));
+
+ // Then
+ expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard");
});
});
diff --git a/ui/components/providers/providers-accounts-view.tsx b/ui/components/providers/providers-accounts-view.tsx
index b53155d8c0..62322e2ccd 100644
--- a/ui/components/providers/providers-accounts-view.tsx
+++ b/ui/components/providers/providers-accounts-view.tsx
@@ -1,9 +1,11 @@
"use client";
+import { usePathname, useSearchParams } from "next/navigation";
import { useState } from "react";
import { AddProviderButton } from "@/components/providers/add-provider-button";
import { MutedFindingsConfigButton } from "@/components/providers/muted-findings-config-button";
+import { NoProvidersAdded } from "@/components/providers/no-providers-added";
import { ProvidersAccountsTable } from "@/components/providers/providers-accounts-table";
import { ProvidersFilters } from "@/components/providers/providers-filters";
import { ProviderWizardModal } from "@/components/providers/wizard";
@@ -11,6 +13,10 @@ import type {
OrgWizardInitialData,
ProviderWizardInitialData,
} from "@/components/providers/wizard/types";
+import {
+ ADD_PROVIDER_SEARCH_PARAM,
+ ADD_PROVIDER_SEARCH_VALUE,
+} from "@/lib/providers-navigation";
import type { FilterOption, MetaDataProps, ProviderProps } from "@/types";
import type { ProvidersTableRow } from "@/types/providers-table";
@@ -29,7 +35,14 @@ export function ProvidersAccountsView({
providers,
rows,
}: ProvidersAccountsViewProps) {
- const [isProviderWizardOpen, setIsProviderWizardOpen] = useState(false);
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+ const hasNoProviders = providers.length === 0;
+ const shouldOpenProviderWizardFromUrl =
+ searchParams.get(ADD_PROVIDER_SEARCH_PARAM) === ADD_PROVIDER_SEARCH_VALUE;
+ const [isProviderWizardOpen, setIsProviderWizardOpen] = useState(
+ () => shouldOpenProviderWizardFromUrl,
+ );
const [providerWizardInitialData, setProviderWizardInitialData] = useState<
ProviderWizardInitialData | undefined
>(undefined);
@@ -52,38 +65,64 @@ export function ProvidersAccountsView({
const handleWizardOpenChange = (open: boolean) => {
setIsProviderWizardOpen(open);
- if (!open) {
- setProviderWizardInitialData(undefined);
- setOrgWizardInitialData(undefined);
+ if (open) return;
+
+ setProviderWizardInitialData(undefined);
+ setOrgWizardInitialData(undefined);
+
+ // Only clean the one-shot ?addProvider intent from the URL bar, via the
+ // History API so it does NOT trigger an RSC refetch. We must not refresh
+ // here: the provider-creation actions (addProvider / addCredentialsProvider
+ // / checkConnectionProvider) already revalidatePath("/providers"), so the
+ // table updates behind the modal. A router.refresh()/replace() on close
+ // re-ran the whole /providers Server Component, which read as a full reload.
+ if (searchParams.has(ADD_PROVIDER_SEARCH_PARAM)) {
+ const params = new URLSearchParams(searchParams.toString());
+ params.delete(ADD_PROVIDER_SEARCH_PARAM);
+ const query = params.toString();
+ window.history.replaceState(
+ null,
+ "",
+ query ? `${pathname}?${query}` : pathname,
+ );
}
};
return (
<>
-
-
-
- openProviderWizard()} />
- >
- }
+ {hasNoProviders ? (
+ openProviderWizard()}
/>
-
-
+ ) : (
+
+
+
+ openProviderWizard()} />
+ >
+ }
+ />
+
+
+ )}
>
);
diff --git a/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts
index 40644450c2..dea38e95a9 100644
--- a/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts
+++ b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts
@@ -50,6 +50,10 @@ interface UseProviderWizardControllerProps {
onOpenChange: (open: boolean) => void;
initialData?: ProviderWizardInitialData;
orgInitialData?: OrgWizardInitialData;
+ // When false, the caller skips the post-close router.refresh() and relies on
+ // the provider-creation actions' revalidatePath("/providers") to refresh the
+ // data. Defaults to true so standalone callers keep refreshing.
+ refreshOnClose?: boolean;
}
export function useProviderWizardController({
@@ -57,6 +61,7 @@ export function useProviderWizardController({
onOpenChange,
initialData,
orgInitialData,
+ refreshOnClose = true,
}: UseProviderWizardControllerProps) {
const router = useRouter();
const initialProviderId = initialData?.providerId ?? null;
@@ -185,7 +190,9 @@ export function useProviderWizardController({
setProviderTypeHint(null);
setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS);
onOpenChange(false);
- router.refresh();
+ if (refreshOnClose) {
+ router.refresh();
+ }
};
const handleDialogOpenChange = (nextOpen: boolean) => {
diff --git a/ui/components/providers/wizard/provider-wizard-modal.tsx b/ui/components/providers/wizard/provider-wizard-modal.tsx
index d731ca5786..2d0646d957 100644
--- a/ui/components/providers/wizard/provider-wizard-modal.tsx
+++ b/ui/components/providers/wizard/provider-wizard-modal.tsx
@@ -38,6 +38,7 @@ interface ProviderWizardModalProps {
onOpenChange: (open: boolean) => void;
initialData?: ProviderWizardInitialData;
orgInitialData?: OrgWizardInitialData;
+ refreshOnClose?: boolean;
}
export function ProviderWizardModal({
@@ -45,6 +46,7 @@ export function ProviderWizardModal({
onOpenChange,
initialData,
orgInitialData,
+ refreshOnClose,
}: ProviderWizardModalProps) {
const {
backToProviderFlow,
@@ -72,6 +74,7 @@ export function ProviderWizardModal({
onOpenChange,
initialData,
orgInitialData,
+ refreshOnClose,
});
const scrollHintRefreshToken = `${wizardVariant}-${currentStep}-${orgCurrentStep}-${orgSetupPhase}`;
const { containerRef, sentinelRef, showScrollHint } = useScrollHint({
diff --git a/ui/components/scans/no-providers-added.tsx b/ui/components/scans/no-providers-added.tsx
deleted file mode 100644
index a108009c2d..0000000000
--- a/ui/components/scans/no-providers-added.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-"use client";
-
-import { Button, Card, CardContent } from "@/components/shadcn";
-
-import { InfoIcon } from "../icons/Icons";
-
-interface NoProvidersAddedProps {
- onOpenWizard: () => void;
-}
-
-export const NoProvidersAdded = ({ onOpenWizard }: NoProvidersAddedProps) => (
-
-
-
-
-
-
- No Providers Configured
-
-
-
-
- No providers have been configured. Start by setting up a provider.
-
-
-
-
- Get Started
-
-
-
-
-);
diff --git a/ui/components/scans/scans-providers-empty-state.test.tsx b/ui/components/scans/scans-providers-empty-state.test.tsx
index dc6748a785..deee8318e3 100644
--- a/ui/components/scans/scans-providers-empty-state.test.tsx
+++ b/ui/components/scans/scans-providers-empty-state.test.tsx
@@ -1,73 +1,43 @@
import { render, screen } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import { afterEach, describe, expect, it, vi } from "vitest";
+import { describe, expect, it, vi } from "vitest";
+
+import { ADD_PROVIDER_HREF } from "@/lib/providers-navigation";
import { ScansProvidersEmptyState } from "./scans-providers-empty-state";
-const { replaceMock, searchParamsValue } = vi.hoisted(() => ({
- replaceMock: vi.fn(),
- searchParamsValue: { current: "" },
-}));
-
-vi.mock("next/navigation", () => ({
- usePathname: () => "/scans",
- useRouter: () => ({
- replace: replaceMock,
- }),
- useSearchParams: () => new URLSearchParams(searchParamsValue.current),
-}));
-
-vi.mock("@/components/providers/wizard", () => ({
- ProviderWizardModal: ({ open }: { open: boolean }) =>
- open ? Provider wizard
: null,
-}));
-
vi.mock("./no-providers-connected", () => ({
NoProvidersConnected: () => No Connected Providers
,
}));
describe("ScansProvidersEmptyState", () => {
- afterEach(() => {
- vi.clearAllMocks();
- searchParamsValue.current = "";
- });
-
- it("shows the add provider message and opens the provider wizard", async () => {
- const user = userEvent.setup();
-
+ it("shows the add provider message with a providers page CTA", () => {
+ // Given/When
render( );
- expect(screen.getByText("No Providers Configured")).toBeInTheDocument();
-
- await user.click(
- screen.getByRole("button", { name: /open add provider modal/i }),
- );
-
- expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard");
- });
-
- it("clears the launch scan URL intent before opening the provider wizard", async () => {
- // Given
- searchParamsValue.current = "tab=completed&launchScan=true";
- const user = userEvent.setup();
-
- render( );
-
- // When
- await user.click(
- screen.getByRole("button", { name: /open add provider modal/i }),
- );
-
// Then
- expect(replaceMock).toHaveBeenCalledWith("/scans?tab=completed", {
- scroll: false,
+ expect(screen.getByText("No Providers Configured")).toBeInTheDocument();
+ const cta = screen.getByRole("link", {
+ name: /open add provider modal/i,
});
- expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard");
+
+ expect(cta).toHaveAttribute("href", ADD_PROVIDER_HREF);
+ expect(cta.tagName).toBe("A");
+ });
+
+ it("does not render the provider wizard in Scans", () => {
+ // Given/When
+ render( );
+
+ // Then
+ expect(screen.getByText("No Providers Configured")).toBeInTheDocument();
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("shows the no connected providers message", () => {
+ // Given/When
render( );
+ // Then
expect(screen.getByText("No Connected Providers")).toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
diff --git a/ui/components/scans/scans-providers-empty-state.tsx b/ui/components/scans/scans-providers-empty-state.tsx
index 7837dcea2c..09258320f9 100644
--- a/ui/components/scans/scans-providers-empty-state.tsx
+++ b/ui/components/scans/scans-providers-empty-state.tsx
@@ -1,12 +1,6 @@
-"use client";
+import { NoProvidersAdded } from "@/components/providers/no-providers-added";
+import { ADD_PROVIDER_HREF } from "@/lib/providers-navigation";
-import { usePathname, useRouter, useSearchParams } from "next/navigation";
-import { useState } from "react";
-
-import { ProviderWizardModal } from "@/components/providers/wizard";
-import { LAUNCH_SCAN_SEARCH_PARAM } from "@/lib/scans-navigation";
-
-import { NoProvidersAdded } from "./no-providers-added";
import { NoProvidersConnected } from "./no-providers-connected";
interface ScansProvidersEmptyStateProps {
@@ -16,35 +10,13 @@ interface ScansProvidersEmptyStateProps {
export function ScansProvidersEmptyState({
thereIsNoProviders,
}: ScansProvidersEmptyStateProps) {
- const pathname = usePathname();
- const router = useRouter();
- const searchParams = useSearchParams();
- const [isProviderWizardOpen, setIsProviderWizardOpen] = useState(false);
-
- const openProviderWizard = () => {
- if (searchParams.has(LAUNCH_SCAN_SEARCH_PARAM)) {
- const params = new URLSearchParams(searchParams.toString());
- params.delete(LAUNCH_SCAN_SEARCH_PARAM);
- const query = params.toString();
- router.replace(query ? `${pathname}?${query}` : pathname, {
- scroll: false,
- });
- }
-
- setIsProviderWizardOpen(true);
- };
-
return (
<>
{thereIsNoProviders ? (
-
+
) : (
)}
-
>
);
}
diff --git a/ui/dependency-log.json b/ui/dependency-log.json
index e52d786972..76af7f296a 100644
--- a/ui/dependency-log.json
+++ b/ui/dependency-log.json
@@ -778,26 +778,26 @@
{
"section": "devDependencies",
"name": "@vitest/browser",
- "from": "4.1.6",
- "to": "4.0.18",
+ "from": "4.0.18",
+ "to": "4.1.8",
"strategy": "installed",
- "generatedAt": "2026-05-14T10:22:47.378Z"
+ "generatedAt": "2026-06-02T11:34:46.264Z"
},
{
"section": "devDependencies",
"name": "@vitest/browser-playwright",
- "from": "4.1.6",
- "to": "4.0.18",
+ "from": "4.0.18",
+ "to": "4.1.8",
"strategy": "installed",
- "generatedAt": "2026-05-14T10:22:47.378Z"
+ "generatedAt": "2026-06-02T11:34:46.264Z"
},
{
"section": "devDependencies",
"name": "@vitest/coverage-v8",
- "from": "4.1.6",
- "to": "4.0.18",
+ "from": "4.0.18",
+ "to": "4.1.8",
"strategy": "installed",
- "generatedAt": "2026-05-14T10:22:47.378Z"
+ "generatedAt": "2026-06-02T11:34:46.264Z"
},
{
"section": "devDependencies",
@@ -978,10 +978,10 @@
{
"section": "devDependencies",
"name": "vitest",
- "from": "4.1.6",
- "to": "4.0.18",
+ "from": "4.0.18",
+ "to": "4.1.8",
"strategy": "installed",
- "generatedAt": "2026-05-14T10:22:47.378Z"
+ "generatedAt": "2026-06-02T11:34:46.264Z"
},
{
"section": "devDependencies",
diff --git a/ui/lib/providers-navigation.ts b/ui/lib/providers-navigation.ts
new file mode 100644
index 0000000000..7428eed540
--- /dev/null
+++ b/ui/lib/providers-navigation.ts
@@ -0,0 +1,3 @@
+export const ADD_PROVIDER_SEARCH_PARAM = "addProvider";
+export const ADD_PROVIDER_SEARCH_VALUE = "true";
+export const ADD_PROVIDER_HREF = `/providers?${ADD_PROVIDER_SEARCH_PARAM}=${ADD_PROVIDER_SEARCH_VALUE}`;
diff --git a/ui/package.json b/ui/package.json
index 8466539961..a5c34094b0 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -133,9 +133,9 @@
"@typescript-eslint/eslint-plugin": "8.53.0",
"@typescript-eslint/parser": "8.53.0",
"@vitejs/plugin-react": "5.1.2",
- "@vitest/browser": "4.0.18",
- "@vitest/browser-playwright": "4.0.18",
- "@vitest/coverage-v8": "4.0.18",
+ "@vitest/browser": "4.1.8",
+ "@vitest/browser-playwright": "4.1.8",
+ "@vitest/coverage-v8": "4.1.8",
"babel-plugin-react-compiler": "1.0.0",
"dotenv": "16.6.1",
"dotenv-expand": "12.0.3",
@@ -158,7 +158,7 @@
"prettier-plugin-tailwindcss": "0.6.14",
"tailwindcss": "4.1.18",
"typescript": "5.5.4",
- "vitest": "4.0.18",
+ "vitest": "4.1.8",
"vitest-browser-react": "2.0.4"
},
"packageManager": "pnpm@11.1.3+sha512.c85357fe17ca12dd23dd7071822666dfd7e3cb76fe214e3370b5ea2fb34f2a231185509b63e717f3cd0acb38dd3f8d82bcd5e8172400ae678b70ea4fbed0896d",
diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml
index 3996bbb8a8..6d44c180a1 100644
--- a/ui/pnpm-lock.yaml
+++ b/ui/pnpm-lock.yaml
@@ -325,14 +325,14 @@ importers:
specifier: 5.1.2
version: 5.1.2(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
'@vitest/browser':
- specifier: 4.0.18
- version: 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18)
+ specifier: 4.1.8
+ version: 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8)
'@vitest/browser-playwright':
- specifier: 4.0.18
- version: 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18)
+ specifier: 4.1.8
+ version: 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8)
'@vitest/coverage-v8':
- specifier: 4.0.18
- version: 4.0.18(@vitest/browser@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18))(vitest@4.0.18)
+ specifier: 4.1.8
+ version: 4.1.8(@vitest/browser@4.1.8)(vitest@4.1.8)
babel-plugin-react-compiler:
specifier: 1.0.0
version: 1.0.0
@@ -400,11 +400,11 @@ importers:
specifier: 5.5.4
version: 5.5.4
vitest:
- specifier: 4.0.18
- version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0)
+ specifier: 4.1.8
+ version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
vitest-browser-react:
specifier: 2.0.4
- version: 2.0.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.0.18)
+ version: 2.0.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.8)
packages:
@@ -737,6 +737,9 @@ packages:
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
+ '@blazediff/core@1.9.1':
+ resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==}
+
'@braintree/sanitize-url@7.1.1':
resolution: {integrity: sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==}
@@ -4795,54 +4798,54 @@ packages:
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
- '@vitest/browser-playwright@4.0.18':
- resolution: {integrity: sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g==}
+ '@vitest/browser-playwright@4.1.8':
+ resolution: {integrity: sha512-SR7FqgegaexEg73xvf3ArtygXegagMdXnL0EZMpxrWvvhQxvicD/E8p0ib0J91riPRtQUViyh67Xjw3NqvyhVg==}
peerDependencies:
playwright: '*'
- vitest: 4.0.18
+ vitest: 4.1.8
- '@vitest/browser@4.0.18':
- resolution: {integrity: sha512-gVQqh7paBz3gC+ZdcCmNSWJMk70IUjDeVqi+5m5vYpEHsIwRgw3Y545jljtajhkekIpIp5Gg8oK7bctgY0E2Ng==}
+ '@vitest/browser@4.1.8':
+ resolution: {integrity: sha512-u21VzX07HzlJYpFgkxmjEXar/tG2UqWGgyGG/46SrrPc7rSdCTPw5vuowopO9CIqF8UCUQzDFdbVnNpw6N0BfQ==}
peerDependencies:
- vitest: 4.0.18
+ vitest: 4.1.8
- '@vitest/coverage-v8@4.0.18':
- resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==}
+ '@vitest/coverage-v8@4.1.8':
+ resolution: {integrity: sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==}
peerDependencies:
- '@vitest/browser': 4.0.18
- vitest: 4.0.18
+ '@vitest/browser': 4.1.8
+ vitest: 4.1.8
peerDependenciesMeta:
'@vitest/browser':
optional: true
- '@vitest/expect@4.0.18':
- resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==}
+ '@vitest/expect@4.1.8':
+ resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==}
- '@vitest/mocker@4.0.18':
- resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==}
+ '@vitest/mocker@4.1.8':
+ resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==}
peerDependencies:
msw: ^2.4.9
- vite: ^6.0.0 || ^7.0.0-0
+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
- '@vitest/pretty-format@4.0.18':
- resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==}
+ '@vitest/pretty-format@4.1.8':
+ resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==}
- '@vitest/runner@4.0.18':
- resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==}
+ '@vitest/runner@4.1.8':
+ resolution: {integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==}
- '@vitest/snapshot@4.0.18':
- resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==}
+ '@vitest/snapshot@4.1.8':
+ resolution: {integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==}
- '@vitest/spy@4.0.18':
- resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==}
+ '@vitest/spy@4.1.8':
+ resolution: {integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==}
- '@vitest/utils@4.0.18':
- resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==}
+ '@vitest/utils@4.1.8':
+ resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==}
'@webassemblyjs/ast@1.14.1':
resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==}
@@ -5048,8 +5051,8 @@ packages:
ast-types-flow@0.0.8:
resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==}
- ast-v8-to-istanbul@0.3.12:
- resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==}
+ ast-v8-to-istanbul@1.0.3:
+ resolution: {integrity: sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg==}
async-function@1.0.0:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
@@ -5650,9 +5653,6 @@ packages:
resolution: {integrity: sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==}
engines: {node: '>= 0.4'}
- es-module-lexer@1.7.0:
- resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
-
es-module-lexer@2.1.0:
resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==}
@@ -7246,10 +7246,6 @@ packages:
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
engines: {node: '>=12'}
- pixelmatch@7.1.0:
- resolution: {integrity: sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==}
- hasBin: true
-
pkce-challenge@5.0.1:
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
engines: {node: '>=16.20.0'}
@@ -7537,6 +7533,7 @@ packages:
recharts@2.15.4:
resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==}
engines: {node: '>=14'}
+ deprecated: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide
peerDependencies:
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
@@ -7829,8 +7826,8 @@ packages:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
- std-env@3.10.0:
- resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
+ std-env@4.1.0:
+ resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
stop-iteration-iterator@1.1.0:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
@@ -8347,20 +8344,23 @@ packages:
'@types/react-dom':
optional: true
- vitest@4.0.18:
- resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==}
+ vitest@4.1.8:
+ resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==}
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@opentelemetry/api': ^1.9.0
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
- '@vitest/browser-playwright': 4.0.18
- '@vitest/browser-preview': 4.0.18
- '@vitest/browser-webdriverio': 4.0.18
- '@vitest/ui': 4.0.18
+ '@vitest/browser-playwright': 4.1.8
+ '@vitest/browser-preview': 4.1.8
+ '@vitest/browser-webdriverio': 4.1.8
+ '@vitest/coverage-istanbul': 4.1.8
+ '@vitest/coverage-v8': 4.1.8
+ '@vitest/ui': 4.1.8
happy-dom: '*'
jsdom: '*'
+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
@@ -8374,6 +8374,10 @@ packages:
optional: true
'@vitest/browser-webdriverio':
optional: true
+ '@vitest/coverage-istanbul':
+ optional: true
+ '@vitest/coverage-v8':
+ optional: true
'@vitest/ui':
optional: true
happy-dom:
@@ -9319,6 +9323,8 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
+ '@blazediff/core@1.9.1': {}
+
'@braintree/sanitize-url@7.1.1': {}
'@cfworker/json-schema@4.1.1': {}
@@ -14261,29 +14267,29 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@vitest/browser-playwright@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18)':
+ '@vitest/browser-playwright@4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8)':
dependencies:
- '@vitest/browser': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18)
- '@vitest/mocker': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
+ '@vitest/browser': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8)
+ '@vitest/mocker': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
playwright: 1.56.1
tinyrainbow: 3.1.0
- vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0)
+ vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
transitivePeerDependencies:
- bufferutil
- msw
- utf-8-validate
- vite
- '@vitest/browser@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18)':
+ '@vitest/browser@4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8)':
dependencies:
- '@vitest/mocker': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
- '@vitest/utils': 4.0.18
+ '@blazediff/core': 1.9.1
+ '@vitest/mocker': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
+ '@vitest/utils': 4.1.8
magic-string: 0.30.21
- pixelmatch: 7.1.0
pngjs: 7.0.0
sirv: 3.0.2
tinyrainbow: 3.1.0
- vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0)
+ vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
ws: 8.20.1
transitivePeerDependencies:
- bufferutil
@@ -14291,60 +14297,62 @@ snapshots:
- utf-8-validate
- vite
- '@vitest/coverage-v8@4.0.18(@vitest/browser@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18))(vitest@4.0.18)':
+ '@vitest/coverage-v8@4.1.8(@vitest/browser@4.1.8)(vitest@4.1.8)':
dependencies:
'@bcoe/v8-coverage': 1.0.2
- '@vitest/utils': 4.0.18
- ast-v8-to-istanbul: 0.3.12
+ '@vitest/utils': 4.1.8
+ ast-v8-to-istanbul: 1.0.3
istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1
istanbul-reports: 3.2.0
magicast: 0.5.2
obug: 2.1.1
- std-env: 3.10.0
+ std-env: 4.1.0
tinyrainbow: 3.1.0
- vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0)
+ vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
optionalDependencies:
- '@vitest/browser': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18)
+ '@vitest/browser': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8)
- '@vitest/expect@4.0.18':
+ '@vitest/expect@4.1.8':
dependencies:
'@standard-schema/spec': 1.1.0
'@types/chai': 5.2.3
- '@vitest/spy': 4.0.18
- '@vitest/utils': 4.0.18
+ '@vitest/spy': 4.1.8
+ '@vitest/utils': 4.1.8
chai: 6.2.2
tinyrainbow: 3.1.0
- '@vitest/mocker@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))':
+ '@vitest/mocker@4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))':
dependencies:
- '@vitest/spy': 4.0.18
+ '@vitest/spy': 4.1.8
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
msw: 2.13.4(@types/node@24.10.8)(typescript@5.5.4)
vite: 7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0)
- '@vitest/pretty-format@4.0.18':
+ '@vitest/pretty-format@4.1.8':
dependencies:
tinyrainbow: 3.1.0
- '@vitest/runner@4.0.18':
+ '@vitest/runner@4.1.8':
dependencies:
- '@vitest/utils': 4.0.18
+ '@vitest/utils': 4.1.8
pathe: 2.0.3
- '@vitest/snapshot@4.0.18':
+ '@vitest/snapshot@4.1.8':
dependencies:
- '@vitest/pretty-format': 4.0.18
+ '@vitest/pretty-format': 4.1.8
+ '@vitest/utils': 4.1.8
magic-string: 0.30.21
pathe: 2.0.3
- '@vitest/spy@4.0.18': {}
+ '@vitest/spy@4.1.8': {}
- '@vitest/utils@4.0.18':
+ '@vitest/utils@4.1.8':
dependencies:
- '@vitest/pretty-format': 4.0.18
+ '@vitest/pretty-format': 4.1.8
+ convert-source-map: 2.0.0
tinyrainbow: 3.1.0
'@webassemblyjs/ast@1.14.1':
@@ -14604,7 +14612,7 @@ snapshots:
ast-types-flow@0.0.8: {}
- ast-v8-to-istanbul@0.3.12:
+ ast-v8-to-istanbul@1.0.3:
dependencies:
'@jridgewell/trace-mapping': 0.3.31
estree-walker: 3.0.3
@@ -15273,8 +15281,6 @@ snapshots:
iterator.prototype: 1.1.5
safe-array-concat: 1.1.3
- es-module-lexer@1.7.0: {}
-
es-module-lexer@2.1.0: {}
es-object-atoms@1.1.1:
@@ -17280,10 +17286,6 @@ snapshots:
picomatch@4.0.4: {}
- pixelmatch@7.1.0:
- dependencies:
- pngjs: 7.0.0
-
pkce-challenge@5.0.1: {}
pkg-types@1.3.1:
@@ -17980,7 +17982,7 @@ snapshots:
statuses@2.0.2: {}
- std-env@3.10.0: {}
+ std-env@4.1.0: {}
stop-iteration-iterator@1.1.0:
dependencies:
@@ -18487,31 +18489,31 @@ snapshots:
terser: 5.47.1
yaml: 2.9.0
- vitest-browser-react@2.0.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.0.18):
+ vitest-browser-react@2.0.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.8):
dependencies:
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
- vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0)
+ vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
optionalDependencies:
'@types/react': 19.2.8
'@types/react-dom': 19.2.3(@types/react@19.2.8)
- vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0):
+ vitest@4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0)):
dependencies:
- '@vitest/expect': 4.0.18
- '@vitest/mocker': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
- '@vitest/pretty-format': 4.0.18
- '@vitest/runner': 4.0.18
- '@vitest/snapshot': 4.0.18
- '@vitest/spy': 4.0.18
- '@vitest/utils': 4.0.18
- es-module-lexer: 1.7.0
+ '@vitest/expect': 4.1.8
+ '@vitest/mocker': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
+ '@vitest/pretty-format': 4.1.8
+ '@vitest/runner': 4.1.8
+ '@vitest/snapshot': 4.1.8
+ '@vitest/spy': 4.1.8
+ '@vitest/utils': 4.1.8
+ es-module-lexer: 2.1.0
expect-type: 1.3.0
magic-string: 0.30.21
obug: 2.1.1
pathe: 2.0.3
picomatch: 4.0.4
- std-env: 3.10.0
+ std-env: 4.1.0
tinybench: 2.9.0
tinyexec: 1.1.2
tinyglobby: 0.2.16
@@ -18521,20 +18523,11 @@ snapshots:
optionalDependencies:
'@opentelemetry/api': 1.9.0
'@types/node': 24.10.8
- '@vitest/browser-playwright': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18)
+ '@vitest/browser-playwright': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8)
+ '@vitest/coverage-v8': 4.1.8(@vitest/browser@4.1.8)(vitest@4.1.8)
jsdom: 27.4.0
transitivePeerDependencies:
- - jiti
- - less
- - lightningcss
- msw
- - sass
- - sass-embedded
- - stylus
- - sugarss
- - terser
- - tsx
- - yaml
w3c-keyname@2.2.8: {}
diff --git a/ui/tests/helpers.ts b/ui/tests/helpers.ts
index 2bbbdeaeec..dd4692ed57 100644
--- a/ui/tests/helpers.ts
+++ b/ui/tests/helpers.ts
@@ -132,6 +132,24 @@ export async function addAWSProvider(
await scansPage.verifyPageLoaded();
}
+/**
+ * Waits for the providers page to settle and reports whether the data table is
+ * present. With zero providers the page renders a full-page empty state
+ * ("No Providers Configured") instead of the table, so callers must not assume
+ * the table is always there.
+ */
+async function providersTableVisibleOrEmptyState(
+ page: ProvidersPage,
+): Promise {
+ const emptyState = page.page.getByRole("region", {
+ name: /no providers configured/i,
+ });
+ await expect(page.providersTable.or(emptyState)).toBeVisible({
+ timeout: 10000,
+ });
+ return page.providersTable.isVisible().catch(() => false);
+}
+
export async function deleteProviderIfExists(
page: ProvidersPage,
providerUID: string,
@@ -140,7 +158,11 @@ export async function deleteProviderIfExists(
// Navigate to providers page
await page.goto();
- await expect(page.providersTable).toBeVisible({ timeout: 10000 });
+ // With zero providers the page shows the empty state, not the table, so there
+ // is nothing to delete.
+ if (!(await providersTableVisibleOrEmptyState(page))) {
+ return;
+ }
const allRows = page.providersTable.locator("tbody tr");
@@ -180,7 +202,7 @@ export async function deleteProviderIfExists(
// Provider not found, nothing to delete
// Navigate back to providers page to ensure clean state
await page.goto();
- await expect(page.providersTable).toBeVisible({ timeout: 10000 });
+ await providersTableVisibleOrEmptyState(page);
return;
}
@@ -217,7 +239,8 @@ export async function deleteProviderIfExists(
// Wait for modal to close (this indicates deletion was initiated)
await expect(modal).not.toBeVisible({ timeout: 10000 });
- // Navigate back to providers page to ensure clean state
+ // Navigate back to providers page to ensure clean state. Deleting the last
+ // provider reveals the empty state instead of an empty table.
await page.goto();
- await expect(page.providersTable).toBeVisible({ timeout: 10000 });
+ await providersTableVisibleOrEmptyState(page);
}
diff --git a/ui/tests/providers/providers-page.ts b/ui/tests/providers/providers-page.ts
index 5b73269062..54545f965d 100644
--- a/ui/tests/providers/providers-page.ts
+++ b/ui/tests/providers/providers-page.ts
@@ -341,7 +341,10 @@ export class ProvidersPage extends BasePage {
name: /Adding A Provider|Update Provider Credentials/i,
});
- // Button to add a new provider
+ // Button to add a new provider. When providers exist this is the filter-bar
+ // "Add Provider" control; with zero providers the page renders the empty
+ // state whose CTA is labelled "Open Add Provider modal" (button on
+ // /providers, link on /scans). Only one of these is ever in the DOM at once.
this.addProviderButton = page
.getByRole("button", {
name: "Add Provider",
@@ -352,7 +355,9 @@ export class ProvidersPage extends BasePage {
name: "Add Provider",
exact: true,
}),
- );
+ )
+ .or(page.getByRole("button", { name: "Open Add Provider modal" }))
+ .or(page.getByRole("link", { name: "Open Add Provider modal" }));
// Table displaying existing providers
this.providersTable = page.getByRole("table");
From eb7949c884f66cb46892eedeb7bd6012bd4d4123 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pedro=20Mart=C3=ADn?=
Date: Wed, 3 Jun 2026 17:03:12 +0200
Subject: [PATCH 008/129] fix(ui): show delete user action only for the current
user (#11447)
Co-authored-by: Pepe Fagoaga
---
.../user-guide/tutorials/prowler-app-rbac.mdx | 6 +-
ui/CHANGELOG.md | 1 +
ui/app/(prowler)/users/page.tsx | 3 +
.../table/data-table-row-actions.test.tsx | 159 ++++++++++++++++++
.../users/table/data-table-row-actions.tsx | 59 ++++---
5 files changed, 203 insertions(+), 25 deletions(-)
create mode 100644 ui/components/users/table/data-table-row-actions.test.tsx
diff --git a/docs/user-guide/tutorials/prowler-app-rbac.mdx b/docs/user-guide/tutorials/prowler-app-rbac.mdx
index c6319591cf..cabea5bd35 100644
--- a/docs/user-guide/tutorials/prowler-app-rbac.mdx
+++ b/docs/user-guide/tutorials/prowler-app-rbac.mdx
@@ -47,7 +47,11 @@ Follow these steps to remove a user of your account:
1. Navigate to **Users** from the side menu.
2. Click the delete button of your current user.
-> **Note: Each user will be able to delete himself and not others, regardless of his permissions.**
+> **Note: Each user can only delete their own account, regardless of their permissions. For this reason, the delete button is only shown on your own row and not on other users' rows.**
+
+Deleting a user removes the **entire user account** from Prowler, not just its membership in your organization. Because a single account can belong to more than one tenant, allowing one administrator to delete it outright could affect organizations they don't manage and irreversibly remove another person's identity. To keep this destructive action under the control of the account owner, the API only permits a user to delete themselves (it rejects any other target with a `400` response), and the UI mirrors this by showing the delete button exclusively on your own row.
+
+To remove **another** user from your organization, use the [_Expel from organization_](/user-guide/tutorials/prowler-app-multi-tenant#expelling-a-user-from-an-organization) action instead. Expelling removes the user's membership, role grants, and active sessions for your tenant only, and deletes the underlying account just for that user if your organization was their last remaining membership. This action is reserved for tenant **owners**.
diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md
index db38de5fd3..8b2ba973aa 100644
--- a/ui/CHANGELOG.md
+++ b/ui/CHANGELOG.md
@@ -19,6 +19,7 @@ All notable changes to the **Prowler UI** are documented in this file.
### 🐞 Fixed
- Add Provider modal now closes without reloading the providers page [(#11424)](https://github.com/prowler-cloud/prowler/pull/11424)
+- Users page now shows the "Delete User" action only on the current user's row, matching the backend rule that a user can only delete their own account [(#11447)](https://github.com/prowler-cloud/prowler/pull/11447)
### 🔐 Security
diff --git a/ui/app/(prowler)/users/page.tsx b/ui/app/(prowler)/users/page.tsx
index 4b26c0baf2..fa1e838872 100644
--- a/ui/app/(prowler)/users/page.tsx
+++ b/ui/app/(prowler)/users/page.tsx
@@ -109,6 +109,9 @@ const SSRDataTable = async ({
roles,
canBeExpelled,
currentTenantId: canBeExpelled ? currentTenantId : undefined,
+ // Users may only delete their own account; gate the delete action so the
+ // UI matches the backend rule and never offers an action that would fail.
+ isCurrentUser: user.id === currentUserId,
};
});
diff --git a/ui/components/users/table/data-table-row-actions.test.tsx b/ui/components/users/table/data-table-row-actions.test.tsx
new file mode 100644
index 0000000000..39a6c95f8b
--- /dev/null
+++ b/ui/components/users/table/data-table-row-actions.test.tsx
@@ -0,0 +1,159 @@
+import { Row } from "@tanstack/react-table";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+
+// The forms pull in server actions (`@/actions/users/users`) that can't run in
+// jsdom, so stub them with identifiable markers to assert which modal opens.
+vi.mock("../forms", () => ({
+ DeleteForm: ({ userId }: { userId: string }) => (
+ delete-form:{userId}
+ ),
+ EditForm: ({ userId }: { userId: string }) => (
+ edit-form:{userId}
+ ),
+ ExpelUserForm: ({ userId }: { userId: string }) => (
+ expel-form:{userId}
+ ),
+}));
+
+import { DataTableRowActions } from "./data-table-row-actions";
+
+interface RowOptions {
+ id?: string;
+ isCurrentUser?: boolean;
+ canBeExpelled?: boolean;
+ currentTenantId?: string;
+}
+
+const createRow = ({
+ id = "user-1",
+ isCurrentUser,
+ canBeExpelled,
+ currentTenantId,
+}: RowOptions = {}) =>
+ ({
+ original: {
+ id,
+ attributes: {
+ name: "Jane Doe",
+ email: "jane@example.com",
+ company_name: "Acme",
+ role: { name: "admin" },
+ },
+ isCurrentUser,
+ canBeExpelled,
+ currentTenantId,
+ },
+ }) as unknown as Row<{ id: string }>;
+
+const openMenu = async (user: ReturnType) => {
+ await user.click(screen.getByRole("button", { name: "Open actions menu" }));
+};
+
+describe("DataTableRowActions (users)", () => {
+ it("always renders the Edit User action", async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await openMenu(user);
+
+ expect(screen.getByText("Edit User")).toBeInTheDocument();
+ });
+
+ it("shows Delete User only for the current user's row", async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await openMenu(user);
+
+ expect(screen.getByText("Delete User")).toBeInTheDocument();
+ expect(screen.getByText("Danger zone")).toBeInTheDocument();
+ });
+
+ it("does NOT show Delete User for another user's row", async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await openMenu(user);
+
+ expect(screen.queryByText("Delete User")).not.toBeInTheDocument();
+ });
+
+ it("does NOT show Delete User when isCurrentUser is undefined", async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await openMenu(user);
+
+ expect(screen.queryByText("Delete User")).not.toBeInTheDocument();
+ });
+
+ it("hides the Danger zone entirely when the user can neither be deleted nor expelled", async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ await openMenu(user);
+
+ // Only the non-destructive Edit action remains.
+ expect(screen.getByText("Edit User")).toBeInTheDocument();
+ expect(screen.queryByText("Danger zone")).not.toBeInTheDocument();
+ expect(screen.queryByText("Delete User")).not.toBeInTheDocument();
+ expect(
+ screen.queryByText("Expel from organization"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("shows Expel but not Delete User for an expellable, non-current user", async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ await openMenu(user);
+
+ expect(screen.getByText("Danger zone")).toBeInTheDocument();
+ expect(screen.getByText("Expel from organization")).toBeInTheDocument();
+ expect(screen.queryByText("Delete User")).not.toBeInTheDocument();
+ });
+
+ it("renders Delete User with destructive styling", async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await openMenu(user);
+
+ const menuItem = screen
+ .getByText("Delete User")
+ .closest("[role='menuitem']");
+ expect(menuItem).toBeInTheDocument();
+ expect(menuItem).toHaveClass("text-text-error-primary");
+ });
+
+ it("opens the delete confirmation modal when Delete User is selected", async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ await openMenu(user);
+ await user.click(screen.getByText("Delete User"));
+
+ expect(screen.getByText("Are you absolutely sure?")).toBeInTheDocument();
+ expect(screen.getByTestId("delete-form")).toHaveTextContent(
+ "delete-form:user-42",
+ );
+ });
+});
diff --git a/ui/components/users/table/data-table-row-actions.tsx b/ui/components/users/table/data-table-row-actions.tsx
index 3bc8e6a0c8..29c59566da 100644
--- a/ui/components/users/table/data-table-row-actions.tsx
+++ b/ui/components/users/table/data-table-row-actions.tsx
@@ -29,6 +29,7 @@ interface UserRowData {
attributes?: UserRowAttributes;
canBeExpelled?: boolean;
currentTenantId?: string;
+ isCurrentUser?: boolean;
}
interface DataTableRowActionsProps {
@@ -57,6 +58,10 @@ export function DataTableRowActions({
row.original.canBeExpelled === true && !!row.original.currentTenantId;
const currentTenantId = row.original.currentTenantId;
+ // A user can only delete their own account (enforced by the backend), so the
+ // delete action is shown exclusively for the current user's row.
+ const canDeleteUser = row.original.isCurrentUser === true;
+
return (
<>
({
setIsOpen={setIsEditOpen}
/>
-
-
-
+ {canDeleteUser && (
+
+
+
+ )}
{canExpelUser && currentTenantId && (
({
label="Edit User"
onSelect={() => setIsEditOpen(true)}
/>
-
- {canExpelUser && (
- }
- label="Expel from organization"
- destructive
- onSelect={() => setIsExpelOpen(true)}
- />
- )}
- }
- label="Delete User"
- destructive
- onSelect={() => setIsDeleteOpen(true)}
- />
-
+ {(canExpelUser || canDeleteUser) && (
+
+ {canExpelUser && (
+ }
+ label="Expel from organization"
+ destructive
+ onSelect={() => setIsExpelOpen(true)}
+ />
+ )}
+ {canDeleteUser && (
+ }
+ label="Delete User"
+ destructive
+ onSelect={() => setIsDeleteOpen(true)}
+ />
+ )}
+
+ )}
>
From bcd282d3d0d17655ea0d799605268d362e10f1dc Mon Sep 17 00:00:00 2001
From: Oleksandr_Sanin
Date: Thu, 4 Jun 2026 12:07:01 +0200
Subject: [PATCH 009/129] fix(gcp): honour org-level aggregated sinks in
logging_sink_created check (#11355)
Signed-off-by: Oleksandr Sanin
Co-authored-by: Hugo P.Brito
---
prowler/CHANGELOG.md | 8 +
.../gcp/services/logging/logging_service.py | 34 ++++
.../logging_sink_created.py | 61 ++++--
.../services/logging/logging_service_test.py | 73 ++++++-
.../logging_sink_created_test.py | 178 +++++++++++++++++-
5 files changed, 336 insertions(+), 18 deletions(-)
diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md
index d16f24ca99..db7c87f4c7 100644
--- a/prowler/CHANGELOG.md
+++ b/prowler/CHANGELOG.md
@@ -11,6 +11,14 @@ All notable changes to the **Prowler SDK** are documented in this file.
---
+## [5.29.3] (Prowler UNRELEASED)
+
+### 🐞 Fixed
+
+- GCP `logging_sink_created` now recognizes organization-level aggregated sinks with `includeChildren=True`, avoiding false failures for covered projects [(#11355)](https://github.com/prowler-cloud/prowler/pull/11355)
+
+---
+
## [5.29.1] (Prowler v5.29.1)
### 🐞 Fixed
diff --git a/prowler/providers/gcp/services/logging/logging_service.py b/prowler/providers/gcp/services/logging/logging_service.py
index 637c8782b2..2459895c4c 100644
--- a/prowler/providers/gcp/services/logging/logging_service.py
+++ b/prowler/providers/gcp/services/logging/logging_service.py
@@ -12,6 +12,7 @@ class Logging(GCPService):
self.sinks = []
self.metrics = []
self._get_sinks()
+ self._get_org_sinks()
self._get_metrics()
def _get_sinks(self):
@@ -39,6 +40,38 @@ class Logging(GCPService):
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
+ def _get_org_sinks(self):
+ """Fetch org-level sinks with includeChildren so child projects are not falsely failed."""
+ org_ids = set()
+ for project in self.projects.values():
+ if project.organization:
+ org_ids.add(project.organization.id)
+
+ for org_id in org_ids:
+ try:
+ request = self.client.sinks().list(parent=f"organizations/{org_id}")
+ while request is not None:
+ response = request.execute(num_retries=DEFAULT_RETRY_ATTEMPTS)
+
+ for sink in response.get("sinks", []):
+ self.sinks.append(
+ Sink(
+ name=sink["name"],
+ destination=sink["destination"],
+ filter=sink.get("filter", "all"),
+ project_id=f"organizations/{org_id}",
+ include_children=sink.get("includeChildren", False),
+ )
+ )
+
+ request = self.client.sinks().list_next(
+ previous_request=request, previous_response=response
+ )
+ except Exception as error:
+ logger.error(
+ f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+
def _get_metrics(self):
for project_id in self.project_ids:
try:
@@ -76,6 +109,7 @@ class Sink(BaseModel):
destination: str
filter: str
project_id: str
+ include_children: bool = False
class Metric(BaseModel):
diff --git a/prowler/providers/gcp/services/logging/logging_sink_created/logging_sink_created.py b/prowler/providers/gcp/services/logging/logging_sink_created/logging_sink_created.py
index 30104a050d..a7846e3dd8 100644
--- a/prowler/providers/gcp/services/logging/logging_sink_created/logging_sink_created.py
+++ b/prowler/providers/gcp/services/logging/logging_sink_created/logging_sink_created.py
@@ -5,26 +5,30 @@ from prowler.providers.gcp.services.logging.logging_client import logging_client
class logging_sink_created(Check):
def execute(self) -> Check_Report_GCP:
findings = []
+
+ # Map project_id -> sink for direct project-level sinks
projects_with_logging_sink = {}
for sink in logging_client.sinks:
- if sink.filter == "all":
+ if sink.filter == "all" and not sink.include_children:
projects_with_logging_sink[sink.project_id] = sink
+ # Collect org resource names that have a covering sink (includeChildren=True)
+ covering_org_sinks = {}
+ for sink in logging_client.sinks:
+ if sink.filter == "all" and sink.include_children:
+ covering_org_sinks[sink.project_id] = sink
+
for project in logging_client.project_ids:
- if project not in projects_with_logging_sink.keys():
- project_obj = logging_client.projects.get(project)
- report = Check_Report_GCP(
- metadata=self.metadata(),
- resource=project_obj,
- resource_id=project,
- project_id=project,
- location=logging_client.region,
- resource_name=(getattr(project_obj, "name", None) or "GCP Project"),
- )
- report.status = "FAIL"
- report.status_extended = f"There are no logging sinks to export copies of all the log entries in project {project}."
- findings.append(report)
- else:
+ project_obj = logging_client.projects.get(project)
+
+ # Determine whether this project is covered by an org-level sink
+ org = getattr(project_obj, "organization", None) if project_obj else None
+ org_resource = f"organizations/{org.id}" if org else None
+ covering_sink = (
+ covering_org_sinks.get(org_resource) if org_resource else None
+ )
+
+ if project in projects_with_logging_sink:
sink = projects_with_logging_sink[project]
sink_name = getattr(sink, "name", None) or "unknown"
report = Check_Report_GCP(
@@ -40,4 +44,31 @@ class logging_sink_created(Check):
report.status = "PASS"
report.status_extended = f"Sink {sink_name} is enabled exporting copies of all the log entries in project {project}."
findings.append(report)
+ elif covering_sink:
+ sink_name = getattr(covering_sink, "name", None) or "unknown"
+ report = Check_Report_GCP(
+ metadata=self.metadata(),
+ resource=covering_sink,
+ resource_id=sink_name,
+ project_id=project,
+ location=logging_client.region,
+ resource_name=(
+ sink_name if sink_name != "unknown" else "Logging Sink"
+ ),
+ )
+ report.status = "PASS"
+ report.status_extended = f"Sink {sink_name} at organization level is exporting copies of all the log entries in project {project}."
+ findings.append(report)
+ else:
+ report = Check_Report_GCP(
+ metadata=self.metadata(),
+ resource=project_obj,
+ resource_id=project,
+ project_id=project,
+ location=logging_client.region,
+ resource_name=(getattr(project_obj, "name", None) or "GCP Project"),
+ )
+ report.status = "FAIL"
+ report.status_extended = f"There are no logging sinks to export copies of all the log entries in project {project}."
+ findings.append(report)
return findings
diff --git a/tests/providers/gcp/services/logging/logging_service_test.py b/tests/providers/gcp/services/logging/logging_service_test.py
index 0396130c2f..49368d0289 100644
--- a/tests/providers/gcp/services/logging/logging_service_test.py
+++ b/tests/providers/gcp/services/logging/logging_service_test.py
@@ -1,4 +1,4 @@
-from unittest.mock import patch
+from unittest.mock import MagicMock, patch
from prowler.providers.gcp.services.logging.logging_service import Logging
from tests.providers.gcp.gcp_fixtures import (
@@ -66,3 +66,74 @@ class TestLoggingService:
== "resource.type=gae_app AND severity>=ERROR"
)
assert logging_client.metrics[1].project_id == GCP_PROJECT_ID
+
+ def test_org_sinks_fetched_when_project_has_organization(self):
+ """_get_org_sinks() appends org-level sinks when projects have an org."""
+ from prowler.providers.gcp.models import GCPOrganization, GCPProject
+
+ org_id = "999888777"
+ provider = set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID])
+ provider.projects = {
+ GCP_PROJECT_ID: GCPProject(
+ id=GCP_PROJECT_ID,
+ number="123456789012",
+ name="test",
+ labels={},
+ lifecycle_state="ACTIVE",
+ organization=GCPOrganization(id=org_id, name=f"organizations/{org_id}"),
+ )
+ }
+
+ mock_client = MagicMock()
+ mock_client.sinks().list().execute.return_value = {
+ "sinks": [
+ {
+ "name": "org-sink",
+ "destination": "storage.googleapis.com/org-bucket",
+ "filter": "all",
+ "includeChildren": True,
+ }
+ ]
+ }
+ mock_client.sinks().list_next.return_value = None
+ mock_client.projects().metrics().list().execute.return_value = {"metrics": []}
+ mock_client.projects().metrics().list_next.return_value = None
+
+ with (
+ patch(
+ "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__",
+ new=mock_is_api_active,
+ ),
+ patch(
+ "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__",
+ return_value=mock_client,
+ ),
+ ):
+ logging_svc = Logging(provider)
+
+ org_sinks = [
+ s for s in logging_svc.sinks if s.project_id == f"organizations/{org_id}"
+ ]
+ assert len(org_sinks) == 1
+ assert org_sinks[0].name == "org-sink"
+ assert org_sinks[0].include_children is True
+ assert org_sinks[0].filter == "all"
+
+ def test_org_sinks_skipped_when_no_organization(self):
+ """_get_org_sinks() adds nothing when projects have no organization."""
+ with (
+ patch(
+ "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__",
+ new=mock_is_api_active,
+ ),
+ patch(
+ "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__",
+ new=mock_api_client,
+ ),
+ ):
+ logging_svc = Logging(set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]))
+
+ org_sinks = [
+ s for s in logging_svc.sinks if s.project_id.startswith("organizations/")
+ ]
+ assert org_sinks == []
diff --git a/tests/providers/gcp/services/logging/logging_sink_created/logging_sink_created_test.py b/tests/providers/gcp/services/logging/logging_sink_created/logging_sink_created_test.py
index b9c6481d22..6ced615f65 100644
--- a/tests/providers/gcp/services/logging/logging_sink_created/logging_sink_created_test.py
+++ b/tests/providers/gcp/services/logging/logging_sink_created/logging_sink_created_test.py
@@ -1,6 +1,6 @@
from unittest.mock import MagicMock, patch
-from prowler.providers.gcp.models import GCPProject
+from prowler.providers.gcp.models import GCPOrganization, GCPProject
from tests.providers.gcp.gcp_fixtures import (
GCP_EU1_LOCATION,
GCP_PROJECT_ID,
@@ -268,6 +268,7 @@ class Test_logging_sink_created:
sink.name = None
sink.filter = "all"
sink.project_id = GCP_PROJECT_ID
+ sink.include_children = False
logging_client.project_ids = [GCP_PROJECT_ID]
logging_client.region = GCP_EU1_LOCATION
@@ -311,9 +312,10 @@ class Test_logging_sink_created:
)
# Create a MagicMock sink object without name attribute
- sink = MagicMock(spec=["filter", "project_id"])
+ sink = MagicMock(spec=["filter", "project_id", "include_children"])
sink.filter = "all"
sink.project_id = GCP_PROJECT_ID
+ sink.include_children = False
logging_client.project_ids = [GCP_PROJECT_ID]
logging_client.region = GCP_EU1_LOCATION
@@ -336,3 +338,175 @@ class Test_logging_sink_created:
assert result[0].resource_id == "unknown"
assert result[0].project_id == GCP_PROJECT_ID
assert result[0].location == GCP_EU1_LOCATION
+
+ def test_org_level_sink_with_include_children_passes(self):
+ """Projects covered by an org-level sink with includeChildren=True should PASS."""
+ logging_client = MagicMock()
+ org_id = "111222333"
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_gcp_provider(),
+ ),
+ patch(
+ "prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created.logging_client",
+ new=logging_client,
+ ),
+ ):
+ from prowler.providers.gcp.services.logging.logging_service import Sink
+ from prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created import (
+ logging_sink_created,
+ )
+
+ logging_client.project_ids = [GCP_PROJECT_ID]
+ logging_client.region = GCP_EU1_LOCATION
+ logging_client.sinks = [
+ Sink(
+ name="org-sink",
+ destination="storage.googleapis.com/org-bucket",
+ filter="all",
+ project_id=f"organizations/{org_id}",
+ include_children=True,
+ )
+ ]
+ logging_client.projects = {
+ GCP_PROJECT_ID: GCPProject(
+ id=GCP_PROJECT_ID,
+ number="123456789012",
+ name="test",
+ labels={},
+ lifecycle_state="ACTIVE",
+ organization=GCPOrganization(
+ id=org_id, name=f"organizations/{org_id}"
+ ),
+ )
+ }
+
+ check = logging_sink_created()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert (
+ result[0].status_extended
+ == f"Sink org-sink at organization level is exporting copies of all the log entries in project {GCP_PROJECT_ID}."
+ )
+ assert result[0].resource_id == "org-sink"
+ assert result[0].project_id == GCP_PROJECT_ID
+ assert result[0].location == GCP_EU1_LOCATION
+
+ def test_org_level_sink_without_include_children_fails(self):
+ """Projects NOT covered by includeChildren should still FAIL if no direct project sink."""
+ logging_client = MagicMock()
+ org_id = "111222333"
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_gcp_provider(),
+ ),
+ patch(
+ "prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created.logging_client",
+ new=logging_client,
+ ),
+ ):
+ from prowler.providers.gcp.services.logging.logging_service import Sink
+ from prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created import (
+ logging_sink_created,
+ )
+
+ logging_client.project_ids = [GCP_PROJECT_ID]
+ logging_client.region = GCP_EU1_LOCATION
+ logging_client.sinks = [
+ Sink(
+ name="org-sink-no-children",
+ destination="storage.googleapis.com/org-bucket",
+ filter="all",
+ project_id=f"organizations/{org_id}",
+ include_children=False,
+ )
+ ]
+ logging_client.projects = {
+ GCP_PROJECT_ID: GCPProject(
+ id=GCP_PROJECT_ID,
+ number="123456789012",
+ name="test",
+ labels={},
+ lifecycle_state="ACTIVE",
+ organization=GCPOrganization(
+ id=org_id, name=f"organizations/{org_id}"
+ ),
+ )
+ }
+
+ check = logging_sink_created()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == f"There are no logging sinks to export copies of all the log entries in project {GCP_PROJECT_ID}."
+ )
+ assert result[0].resource_id == GCP_PROJECT_ID
+ assert result[0].project_id == GCP_PROJECT_ID
+
+ def test_project_sink_takes_precedence_over_org_sink(self):
+ """A direct project sink should be reported even when an org-level sink also covers the project."""
+ logging_client = MagicMock()
+ org_id = "111222333"
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_gcp_provider(),
+ ),
+ patch(
+ "prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created.logging_client",
+ new=logging_client,
+ ),
+ ):
+ from prowler.providers.gcp.services.logging.logging_service import Sink
+ from prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created import (
+ logging_sink_created,
+ )
+
+ logging_client.project_ids = [GCP_PROJECT_ID]
+ logging_client.region = GCP_EU1_LOCATION
+ logging_client.sinks = [
+ Sink(
+ name="project-sink",
+ destination="storage.googleapis.com/project-bucket",
+ filter="all",
+ project_id=GCP_PROJECT_ID,
+ ),
+ Sink(
+ name="org-sink",
+ destination="storage.googleapis.com/org-bucket",
+ filter="all",
+ project_id=f"organizations/{org_id}",
+ include_children=True,
+ ),
+ ]
+ logging_client.projects = {
+ GCP_PROJECT_ID: GCPProject(
+ id=GCP_PROJECT_ID,
+ number="123456789012",
+ name="test",
+ labels={},
+ lifecycle_state="ACTIVE",
+ organization=GCPOrganization(
+ id=org_id, name=f"organizations/{org_id}"
+ ),
+ )
+ }
+
+ check = logging_sink_created()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert (
+ result[0].status_extended
+ == f"Sink project-sink is enabled exporting copies of all the log entries in project {GCP_PROJECT_ID}."
+ )
+ assert result[0].resource_id == "project-sink"
+ assert result[0].project_id == GCP_PROJECT_ID
From 3a3d9d61462983f5856e4b4bf418d898141b69ef Mon Sep 17 00:00:00 2001
From: "Pablo Fernandez Guerra (PFE)"
<148432447+pfe-nazaries@users.noreply.github.com>
Date: Fri, 5 Jun 2026 08:31:16 +0200
Subject: [PATCH 010/129] chore(ui): type process.env via ambient
NodeJS.ProcessEnv (#11328)
Co-authored-by: Pablo F.G
---
ui/__tests__/msw/handlers/attack-paths.ts | 2 +-
ui/types/env.d.ts | 125 ++++++++++++++++++++++
2 files changed, 126 insertions(+), 1 deletion(-)
create mode 100644 ui/types/env.d.ts
diff --git a/ui/__tests__/msw/handlers/attack-paths.ts b/ui/__tests__/msw/handlers/attack-paths.ts
index b17c6c0596..39076666dd 100644
--- a/ui/__tests__/msw/handlers/attack-paths.ts
+++ b/ui/__tests__/msw/handlers/attack-paths.ts
@@ -10,7 +10,7 @@ import type {
QueryResultAttributes,
} from "@/types/attack-paths";
-const API = process.env.NEXT_PUBLIC_API_BASE_URL!;
+const API = process.env.NEXT_PUBLIC_API_BASE_URL;
type JsonApiErrorBody = {
errors: Array<{ detail: string; status: string }>;
diff --git a/ui/types/env.d.ts b/ui/types/env.d.ts
new file mode 100644
index 0000000000..c0b5b10a66
--- /dev/null
+++ b/ui/types/env.d.ts
@@ -0,0 +1,125 @@
+declare global {
+ namespace NodeJS {
+ interface ProcessEnv {
+ // Runtime (Node / Next.js)
+ NODE_ENV: "development" | "production" | "test";
+ NEXT_RUNTIME?: "nodejs" | "edge";
+
+ // Public client config
+ NEXT_PUBLIC_API_BASE_URL: string;
+ NEXT_PUBLIC_API_DOCS_URL?: string;
+ NEXT_PUBLIC_IS_CLOUD_ENV?: "true" | "false";
+ NEXT_PUBLIC_PROWLER_RELEASE_VERSION?: string;
+ NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID?: string;
+ NEXT_PUBLIC_SENTRY_DSN?: string;
+ NEXT_PUBLIC_SENTRY_ENVIRONMENT?: string;
+
+ // Auth (NextAuth)
+ AUTH_URL: string;
+ AUTH_SECRET: string;
+ AUTH_TRUST_HOST?: "true" | "false";
+ NEXTAUTH_URL?: string;
+
+ // Sentry (server / build)
+ SENTRY_DSN?: string;
+ SENTRY_ENVIRONMENT?: string;
+ SENTRY_RELEASE?: string;
+ SENTRY_ORG?: string;
+ SENTRY_PROJECT?: string;
+ SENTRY_AUTH_TOKEN?: string;
+
+ // Social OAuth
+ SOCIAL_GOOGLE_OAUTH_CLIENT_ID?: string;
+ SOCIAL_GOOGLE_OAUTH_CLIENT_SECRET?: string;
+ SOCIAL_GOOGLE_OAUTH_CALLBACK_URL?: string;
+ SOCIAL_GITHUB_OAUTH_CLIENT_ID?: string;
+ SOCIAL_GITHUB_OAUTH_CLIENT_SECRET?: string;
+ SOCIAL_GITHUB_OAUTH_CALLBACK_URL?: string;
+
+ // Feature integrations
+ PROWLER_MCP_SERVER_URL?: string;
+ // JSON-encoded array, parsed in actions/feeds
+ RSS_FEED_SOURCES?: string;
+
+ // Environment detection
+ CI?: string;
+ DOCKER?: string;
+ KUBERNETES_SERVICE_HOST?: string;
+
+ // E2E test credentials (Playwright only)
+ E2E_ADMIN_USER?: string;
+ E2E_ADMIN_PASSWORD?: string;
+ E2E_NEW_USER_PASSWORD?: string;
+ E2E_MANAGE_CLOUD_PROVIDERS_USER?: string;
+ E2E_MANAGE_CLOUD_PROVIDERS_PASSWORD?: string;
+ E2E_INVITE_AND_MANAGE_USERS_USER?: string;
+ E2E_INVITE_AND_MANAGE_USERS_PASSWORD?: string;
+ E2E_UNLIMITED_VISIBILITY_USER?: string;
+ E2E_UNLIMITED_VISIBILITY_PASSWORD?: string;
+ E2E_MANAGE_INTEGRATIONS_USER?: string;
+ E2E_MANAGE_INTEGRATIONS_PASSWORD?: string;
+ E2E_MANAGE_ACCOUNT_USER?: string;
+ E2E_MANAGE_ACCOUNT_PASSWORD?: string;
+ E2E_MANAGE_SCANS_USER?: string;
+ E2E_MANAGE_SCANS_PASSWORD?: string;
+ E2E_ORGANIZATION_ID?: string;
+
+ // E2E AWS
+ E2E_AWS_PROVIDER_ACCOUNT_ID?: string;
+ E2E_AWS_PROVIDER_ACCESS_KEY?: string;
+ E2E_AWS_PROVIDER_SECRET_KEY?: string;
+ E2E_AWS_PROVIDER_ROLE_ARN?: string;
+ E2E_AWS_ORGANIZATION_ID?: string;
+ E2E_AWS_ORGANIZATION_ROLE_ARN?: string;
+
+ // E2E Azure
+ E2E_AZURE_SUBSCRIPTION_ID?: string;
+ E2E_AZURE_CLIENT_ID?: string;
+ E2E_AZURE_SECRET_ID?: string;
+ E2E_AZURE_TENANT_ID?: string;
+
+ // E2E Microsoft 365
+ E2E_M365_DOMAIN_ID?: string;
+ E2E_M365_CLIENT_ID?: string;
+ E2E_M365_TENANT_ID?: string;
+ E2E_M365_SECRET_ID?: string;
+ E2E_M365_CERTIFICATE_CONTENT?: string;
+
+ // E2E GCP
+ E2E_GCP_PROJECT_ID?: string;
+ E2E_GCP_BASE64_SERVICE_ACCOUNT_KEY?: string;
+
+ // E2E Kubernetes
+ E2E_KUBERNETES_CONTEXT?: string;
+ E2E_KUBERNETES_KUBECONFIG_PATH?: string;
+
+ // E2E GitHub
+ E2E_GITHUB_USERNAME?: string;
+ E2E_GITHUB_PERSONAL_ACCESS_TOKEN?: string;
+ E2E_GITHUB_APP_ID?: string;
+ E2E_GITHUB_BASE64_APP_PRIVATE_KEY?: string;
+ E2E_GITHUB_ORGANIZATION?: string;
+ E2E_GITHUB_ORGANIZATION_ACCESS_TOKEN?: string;
+
+ // E2E Oracle Cloud
+ E2E_OCI_TENANCY_ID?: string;
+ E2E_OCI_USER_ID?: string;
+ E2E_OCI_FINGERPRINT?: string;
+ E2E_OCI_KEY_CONTENT?: string;
+ E2E_OCI_REGION?: string;
+
+ // E2E Alibaba Cloud
+ E2E_ALIBABACLOUD_ACCOUNT_ID?: string;
+ E2E_ALIBABACLOUD_ACCESS_KEY_ID?: string;
+ E2E_ALIBABACLOUD_ACCESS_KEY_SECRET?: string;
+ E2E_ALIBABACLOUD_ROLE_ARN?: string;
+
+ // E2E Google Workspace
+ E2E_GOOGLEWORKSPACE_CUSTOMER_ID?: string;
+ E2E_GOOGLEWORKSPACE_SERVICE_ACCOUNT_JSON?: string;
+ E2E_GOOGLEWORKSPACE_DELEGATED_USER?: string;
+ }
+ }
+}
+
+export {};
From a5bc226f1141436e71a5c7e150df448fe772969d Mon Sep 17 00:00:00 2001
From: Aline Almeida
Date: Fri, 5 Jun 2026 12:07:30 +0200
Subject: [PATCH 011/129] fix(gcp): pass iam_service_account_unused for
disabled service accounts (#11467)
---
prowler/CHANGELOG.md | 1 +
.../providers/gcp/services/iam/iam_service.py | 2 +
.../iam_service_account_unused.py | 7 ++-
.../iam_service_account_unused_test.py | 57 +++++++++++++++++++
4 files changed, 66 insertions(+), 1 deletion(-)
diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md
index db7c87f4c7..ec2e68c30e 100644
--- a/prowler/CHANGELOG.md
+++ b/prowler/CHANGELOG.md
@@ -16,6 +16,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
### 🐞 Fixed
- GCP `logging_sink_created` now recognizes organization-level aggregated sinks with `includeChildren=True`, avoiding false failures for covered projects [(#11355)](https://github.com/prowler-cloud/prowler/pull/11355)
+- GCP `iam_service_account_unused` now passes disabled service accounts instead of failing them, since a disabled account cannot authenticate or be used [(#11467)](https://github.com/prowler-cloud/prowler/pull/11467)
---
diff --git a/prowler/providers/gcp/services/iam/iam_service.py b/prowler/providers/gcp/services/iam/iam_service.py
index 13e96276e7..987a86068c 100644
--- a/prowler/providers/gcp/services/iam/iam_service.py
+++ b/prowler/providers/gcp/services/iam/iam_service.py
@@ -37,6 +37,7 @@ class IAM(GCPService):
display_name=account.get("displayName", ""),
project_id=project_id,
uniqueId=account.get("uniqueId", ""),
+ disabled=account.get("disabled", False),
)
)
@@ -102,6 +103,7 @@ class ServiceAccount(BaseModel):
keys: list[Key] = []
project_id: str
uniqueId: str
+ disabled: bool = False
class AccessApproval(GCPService):
diff --git a/prowler/providers/gcp/services/iam/iam_service_account_unused/iam_service_account_unused.py b/prowler/providers/gcp/services/iam/iam_service_account_unused/iam_service_account_unused.py
index 12440aff25..912237b9e5 100644
--- a/prowler/providers/gcp/services/iam/iam_service_account_unused/iam_service_account_unused.py
+++ b/prowler/providers/gcp/services/iam/iam_service_account_unused/iam_service_account_unused.py
@@ -19,7 +19,12 @@ class iam_service_account_unused(Check):
resource_id=account.email,
location=iam_client.region,
)
- if account.uniqueId in sa_ids_used:
+ if account.disabled:
+ report.status = "PASS"
+ report.status_extended = (
+ f"Service Account {account.email} is disabled and cannot be used."
+ )
+ elif account.uniqueId in sa_ids_used:
report.status = "PASS"
report.status_extended = f"Service Account {account.email} was used over the last {max_unused_days} days."
else:
diff --git a/tests/providers/gcp/services/iam/iam_service_account_unused/iam_service_account_unused_test.py b/tests/providers/gcp/services/iam/iam_service_account_unused/iam_service_account_unused_test.py
index d76200734d..79c0eb23c3 100644
--- a/tests/providers/gcp/services/iam/iam_service_account_unused/iam_service_account_unused_test.py
+++ b/tests/providers/gcp/services/iam/iam_service_account_unused/iam_service_account_unused_test.py
@@ -179,3 +179,60 @@ class Test_iam_service_account_unused:
assert result[1].project_id == GCP_PROJECT_ID
assert result[1].location == GCP_US_CENTER1_LOCATION
assert result[1].resource == iam_client.service_accounts[1]
+
+ def test_iam_service_account_disabled(self):
+ iam_client = mock.MagicMock()
+ monitoring_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.iam.iam_service_account_unused.iam_service_account_unused.iam_client",
+ new=iam_client,
+ ),
+ mock.patch(
+ "prowler.providers.gcp.services.iam.iam_service_account_unused.iam_service_account_unused.monitoring_client",
+ new=monitoring_client,
+ ),
+ ):
+ from prowler.providers.gcp.services.iam.iam_service import ServiceAccount
+ from prowler.providers.gcp.services.iam.iam_service_account_unused.iam_service_account_unused import (
+ iam_service_account_unused,
+ )
+
+ iam_client.project_ids = [GCP_PROJECT_ID]
+ iam_client.region = GCP_US_CENTER1_LOCATION
+
+ iam_client.service_accounts = [
+ ServiceAccount(
+ name="projects/my-project/serviceAccounts/disabled-sa@my-project.iam.gserviceaccount.com",
+ email="disabled-sa@my-project.iam.gserviceaccount.com",
+ display_name="Disabled service account",
+ keys=[],
+ project_id=GCP_PROJECT_ID,
+ uniqueId="999888877776666",
+ disabled=True,
+ )
+ ]
+
+ # The account is absent from the usage metrics, so a non-disabled
+ # account here would FAIL. Being disabled must take precedence and
+ # PASS, since a disabled account cannot authenticate or be used.
+ monitoring_client.sa_api_metrics = set()
+ monitoring_client.audit_config = {"max_unused_account_days": 30}
+
+ check = iam_service_account_unused()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert (
+ result[0].status_extended
+ == f"Service Account {iam_client.service_accounts[0].email} is disabled and cannot be used."
+ )
+ assert result[0].resource_id == iam_client.service_accounts[0].email
+ assert result[0].project_id == GCP_PROJECT_ID
+ assert result[0].location == GCP_US_CENTER1_LOCATION
+ assert result[0].resource == iam_client.service_accounts[0]
From d4bbc8b5adbfa1139b152aad731597bf540feace Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pedro=20Mart=C3=ADn?=
Date: Fri, 5 Jun 2026 13:26:28 +0200
Subject: [PATCH 012/129] fix(jira): avoid 400 INVALID_INPUT on findings with
empty field (#11474)
---
prowler/CHANGELOG.md | 1 +
prowler/lib/outputs/jira/jira.py | 16 +++++-
tests/lib/outputs/jira/jira_test.py | 83 +++++++++++++++++++++++++++++
3 files changed, 99 insertions(+), 1 deletion(-)
diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md
index ec2e68c30e..f98b551bce 100644
--- a/prowler/CHANGELOG.md
+++ b/prowler/CHANGELOG.md
@@ -16,6 +16,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
### 🐞 Fixed
- GCP `logging_sink_created` now recognizes organization-level aggregated sinks with `includeChildren=True`, avoiding false failures for covered projects [(#11355)](https://github.com/prowler-cloud/prowler/pull/11355)
+- Jira integration no longer fails with `400 INVALID_INPUT` when a finding has empty fields [(#11474)](https://github.com/prowler-cloud/prowler/pull/11474)
- GCP `iam_service_account_unused` now passes disabled service accounts instead of failing them, since a disabled account cannot authenticate or be used [(#11467)](https://github.com/prowler-cloud/prowler/pull/11467)
---
diff --git a/prowler/lib/outputs/jira/jira.py b/prowler/lib/outputs/jira/jira.py
index ed8f7faab0..f7f31666e1 100644
--- a/prowler/lib/outputs/jira/jira.py
+++ b/prowler/lib/outputs/jira/jira.py
@@ -229,7 +229,9 @@ class MarkdownToADFConverter:
return node
def _paragraph_with_text(self, text: str) -> Dict:
- return {"type": "paragraph", "content": [self._create_text_node(text, None)]}
+ # ADF forbids empty text nodes; emit an empty paragraph instead.
+ content = [self._create_text_node(text, None)] if text else []
+ return {"type": "paragraph", "content": content}
@staticmethod
def _pop_mark(marks_stack: List[Dict], mark_type: str) -> None:
@@ -1118,6 +1120,18 @@ class Jira:
tenant_info: str = "",
) -> dict:
+ # ADF forbids empty text nodes, so Jira rejects them with 400 INVALID_INPUT.
+ def _safe(value: str) -> str:
+ return value if (value and value.strip()) else "-"
+
+ check_id = _safe(check_id)
+ check_title = _safe(check_title)
+ status_extended = _safe(status_extended)
+ provider = _safe(provider)
+ region = _safe(region)
+ resource_uid = _safe(resource_uid)
+ resource_name = _safe(resource_name)
+
table_rows = [
{
"type": "tableRow",
diff --git a/tests/lib/outputs/jira/jira_test.py b/tests/lib/outputs/jira/jira_test.py
index 03656891c8..788d6ba7c9 100644
--- a/tests/lib/outputs/jira/jira_test.py
+++ b/tests/lib/outputs/jira/jira_test.py
@@ -1004,6 +1004,89 @@ class TestJiraIntegration:
for mark in node.get("marks", [])
)
+ @staticmethod
+ def _find_empty_text_nodes(node) -> List[str]:
+ # ADF forbids empty text nodes; collect any to assert the document is valid.
+ empties: List[str] = []
+
+ def walk(current) -> None:
+ if isinstance(current, dict):
+ if current.get("type") == "text" and current.get("text", "") == "":
+ empties.append(current.get("text", ""))
+ for value in current.values():
+ walk(value)
+ elif isinstance(current, list):
+ for item in current:
+ walk(item)
+
+ walk(node)
+ return empties
+
+ def test_get_adf_description_empty_resource_name_has_no_empty_text_nodes(self):
+ # A resource without a name (e.g. an AWS-managed IAM policy) used to emit an
+ # empty ADF text node, making Jira reject the issue with 400 INVALID_INPUT.
+ adf_description = self.jira_integration.get_adf_description(
+ check_id="CHECK-1",
+ check_title="Sample check",
+ severity="CRITICAL",
+ severity_color="#FF0000",
+ status="FAIL",
+ status_color="#FF0000",
+ status_extended="Some status",
+ provider="aws",
+ region="eu-west-1",
+ resource_uid="arn:aws:iam::aws:policy/AdministratorAccess",
+ resource_name="",
+ recommendation_text="",
+ )
+
+ assert self._find_empty_text_nodes(adf_description) == []
+
+ table = adf_description["content"][1]
+ resource_name_row = self._find_table_row(table["content"], "Resource Name")
+ value_cell = resource_name_row["content"][1]
+ assert self._collect_text_from_cell(value_cell) == "-"
+
+ @pytest.mark.parametrize(
+ "field, header",
+ [
+ ("check_id", "Check Id"),
+ ("check_title", "Check Title"),
+ ("status_extended", "Status Extended"),
+ ("provider", "Provider"),
+ ("region", "Region"),
+ ("resource_uid", "Resource UID"),
+ ("resource_name", "Resource Name"),
+ ],
+ )
+ def test_get_adf_description_empty_plain_text_fields_render_placeholder(
+ self, field, header
+ ):
+ base_kwargs = dict(
+ check_id="CHECK-1",
+ check_title="Sample check",
+ severity="HIGH",
+ severity_color="#FF0000",
+ status="FAIL",
+ status_color="#00FF00",
+ status_extended="Some status",
+ provider="aws",
+ region="us-east-1",
+ resource_uid="resource-1",
+ resource_name="resource-name",
+ recommendation_text="",
+ )
+ base_kwargs[field] = ""
+
+ adf_description = self.jira_integration.get_adf_description(**base_kwargs)
+
+ assert self._find_empty_text_nodes(adf_description) == []
+
+ table = adf_description["content"][1]
+ row = self._find_table_row(table["content"], header)
+ value_cell = row["content"][1]
+ assert self._collect_text_from_cell(value_cell) == "-"
+
@patch.object(Jira, "get_access_token", return_value="valid_access_token")
@patch.object(
Jira, "get_available_issue_types", return_value=["Bug", "Task", "Story"]
From a7d180ea5bdaba6e1899265fdfd4ff4b76b490b4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pedro=20Mart=C3=ADn?=
Date: Fri, 5 Jun 2026 13:28:31 +0200
Subject: [PATCH 013/129] feat(dashboard): add AWS AI Security Framework
compliance view (#11475)
---
.../aws_ai_security_framework_aws.py | 27 +++++++++++++++++++
prowler/CHANGELOG.md | 1 +
2 files changed, 28 insertions(+)
create mode 100644 dashboard/compliance/aws_ai_security_framework_aws.py
diff --git a/dashboard/compliance/aws_ai_security_framework_aws.py b/dashboard/compliance/aws_ai_security_framework_aws.py
new file mode 100644
index 0000000000..ece9bdf9cb
--- /dev/null
+++ b/dashboard/compliance/aws_ai_security_framework_aws.py
@@ -0,0 +1,27 @@
+import warnings
+
+from dashboard.common_methods import get_section_containers_3_levels
+
+warnings.filterwarnings("ignore")
+
+
+def get_table(data):
+ aux = data[
+ [
+ "REQUIREMENTS_ATTRIBUTES_SECTION",
+ "REQUIREMENTS_ATTRIBUTES_SUBSECTION",
+ "NAME",
+ "CHECKID",
+ "STATUS",
+ "REGION",
+ "ACCOUNTID",
+ "RESOURCEID",
+ ]
+ ]
+
+ return get_section_containers_3_levels(
+ aux,
+ "REQUIREMENTS_ATTRIBUTES_SECTION",
+ "REQUIREMENTS_ATTRIBUTES_SUBSECTION",
+ "NAME",
+ )
diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md
index f98b551bce..5d180cb4e3 100644
--- a/prowler/CHANGELOG.md
+++ b/prowler/CHANGELOG.md
@@ -18,6 +18,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- GCP `logging_sink_created` now recognizes organization-level aggregated sinks with `includeChildren=True`, avoiding false failures for covered projects [(#11355)](https://github.com/prowler-cloud/prowler/pull/11355)
- Jira integration no longer fails with `400 INVALID_INPUT` when a finding has empty fields [(#11474)](https://github.com/prowler-cloud/prowler/pull/11474)
- GCP `iam_service_account_unused` now passes disabled service accounts instead of failing them, since a disabled account cannot authenticate or be used [(#11467)](https://github.com/prowler-cloud/prowler/pull/11467)
+- AWS AI Security Framework now renders in the dashboard instead of showing "No data found for this compliance", by adding the missing compliance view module [(#11470)](https://github.com/prowler-cloud/prowler/pull/11470)
---
From 6f172a5c19886ee43e9ba1b5c19d7ce11ff6aaa1 Mon Sep 17 00:00:00 2001
From: potato-20 <164017049+potato-20@users.noreply.github.com>
Date: Fri, 5 Jun 2026 17:56:07 +0530
Subject: [PATCH 014/129] feat(elbv2): add
elbv2_alb_drop_invalid_header_fields_enabled check (FSBP ELB.4) (#11471)
Co-authored-by: Hugo P.Brito
---
prowler/CHANGELOG.md | 1 +
...ndational_security_best_practices_aws.json | 4 +-
.../__init__.py | 0
...nvalid_header_fields_enabled.metadata.json | 40 +++
..._alb_drop_invalid_header_fields_enabled.py | 27 ++
...drop_invalid_header_fields_enabled_test.py | 254 ++++++++++++++++++
6 files changed, 325 insertions(+), 1 deletion(-)
create mode 100644 prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/__init__.py
create mode 100644 prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled.metadata.json
create mode 100644 prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled.py
create mode 100644 tests/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled_test.py
diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md
index 5d180cb4e3..6922846b0d 100644
--- a/prowler/CHANGELOG.md
+++ b/prowler/CHANGELOG.md
@@ -8,6 +8,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- `sagemaker_models_monitor_enabled` check for AWS provider, verifying that each SageMaker monitoring schedule is in the `Scheduled` state so data and model drift is actively detected [(#11278)](https://github.com/prowler-cloud/prowler/pull/11278)
- DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554) universal compliance framework with AWS provider coverage across the five DORA pillars [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131)
+- `elbv2_alb_drop_invalid_header_fields_enabled` check for AWS provider, verifying Application Load Balancers have `routing.http.drop_invalid_header_fields.enabled` set to `true` to mitigate HTTP desync attacks (AWS FSBP ELB.4) [(#11471)](https://github.com/prowler-cloud/prowler/pull/11471)
---
diff --git a/prowler/compliance/aws/aws_foundational_security_best_practices_aws.json b/prowler/compliance/aws/aws_foundational_security_best_practices_aws.json
index a64a421c8a..cea7ad1655 100644
--- a/prowler/compliance/aws/aws_foundational_security_best_practices_aws.json
+++ b/prowler/compliance/aws/aws_foundational_security_best_practices_aws.json
@@ -1863,7 +1863,9 @@
"Id": "ELB.4",
"Name": "Application load balancers should be configured to drop HTTP headers",
"Description": "This control evaluates AWS Application Load Balancers (ALB) to ensure they are configured to drop invalid HTTP headers. The control fails if the value of routing.http.drop_invalid_header_fields.enabled is set to false. By default, ALBs are not configured to drop invalid HTTP header values. Removing these header values prevents HTTP desync attacks.",
- "Checks": [],
+ "Checks": [
+ "elbv2_alb_drop_invalid_header_fields_enabled"
+ ],
"Attributes": [
{
"ItemId": "ELB.4",
diff --git a/prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/__init__.py b/prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled.metadata.json b/prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled.metadata.json
new file mode 100644
index 0000000000..0293501578
--- /dev/null
+++ b/prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled.metadata.json
@@ -0,0 +1,40 @@
+{
+ "Provider": "aws",
+ "CheckID": "elbv2_alb_drop_invalid_header_fields_enabled",
+ "CheckTitle": "Application Load Balancer should be configured to drop invalid HTTP header fields",
+ "CheckType": [
+ "Software and Configuration Checks/AWS Security Best Practices/Network Reachability",
+ "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
+ "TTPs/Initial Access",
+ "Effects/Data Exposure"
+ ],
+ "ServiceName": "elbv2",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "medium",
+ "ResourceType": "AwsElbv2LoadBalancer",
+ "ResourceGroup": "network",
+ "Description": "Ensure that Application Load Balancers (ALB) are configured to drop invalid HTTP header fields. The check fails when `routing.http.drop_invalid_header_fields.enabled` is not set to `true`. By default, ALBs do not remove HTTP headers that do not conform to RFC 7230.",
+ "Risk": "Forwarding non-RFC-compliant HTTP headers to backend targets enables HTTP desync (request smuggling):\n- **Confidentiality**: session/token theft, data exfiltration\n- **Integrity**: cache poisoning, request routing bypass, unauthorized actions\n- **Availability**: backend exhaustion.\nDropping invalid header fields removes a primary smuggling vector.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://docs.aws.amazon.com/elasticloadbalancing/latest/application/application-load-balancers.html#drop-invalid-header-fields",
+ "https://docs.aws.amazon.com/securityhub/latest/userguide/elb-controls.html#elb-4"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "aws elbv2 modify-load-balancer-attributes --load-balancer-arn --attributes Key=routing.http.drop_invalid_header_fields.enabled,Value=true",
+ "NativeIaC": "```yaml\n# CloudFormation: enable drop invalid header fields on an ALB\nResources:\n :\n Type: AWS::ElasticLoadBalancingV2::LoadBalancer\n Properties:\n Type: application\n Subnets:\n - \n - \n LoadBalancerAttributes:\n - Key: routing.http.drop_invalid_header_fields.enabled # Critical: drop non-RFC-compliant headers\n Value: true\n```",
+ "Other": "1. Open the Amazon EC2 console and choose Load Balancers.\n2. Select the Application Load Balancer.\n3. On the Attributes tab, choose Edit.\n4. Set 'Drop invalid header fields' to Enabled.\n5. Save changes.",
+ "Terraform": "```hcl\n# Terraform: enable drop invalid header fields on an ALB\nresource \"aws_lb\" \"\" {\n name = \"\"\n load_balancer_type = \"application\"\n subnets = [\"\", \"\"]\n drop_invalid_header_fields = true # Critical: drop non-RFC-compliant headers\n}\n```"
+ },
+ "Recommendation": {
+ "Text": "Enable 'drop invalid header fields' on Application Load Balancers so non-RFC-compliant HTTP headers are removed before requests reach backend targets, reducing exposure to HTTP desync and request smuggling. Apply defense in depth and validate requests at the application layer as well.",
+ "Url": "https://hub.prowler.com/check/elbv2_alb_drop_invalid_header_fields_enabled"
+ }
+ },
+ "Categories": [],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": ""
+}
diff --git a/prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled.py b/prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled.py
new file mode 100644
index 0000000000..86740a253a
--- /dev/null
+++ b/prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled.py
@@ -0,0 +1,27 @@
+from prowler.lib.check.models import Check, Check_Report_AWS
+from prowler.providers.aws.services.elbv2.elbv2_client import elbv2_client
+
+
+class elbv2_alb_drop_invalid_header_fields_enabled(Check):
+ def execute(self):
+ findings = []
+ for lb in elbv2_client.loadbalancersv2.values():
+ if lb.type == "application":
+ report = Check_Report_AWS(
+ metadata=self.metadata(),
+ resource=lb,
+ )
+ report.status = "PASS"
+ report.status_extended = (
+ f"ELBv2 ALB {lb.name} is configured to drop invalid "
+ "header fields."
+ )
+ if lb.drop_invalid_header_fields != "true":
+ report.status = "FAIL"
+ report.status_extended = (
+ f"ELBv2 ALB {lb.name} is not configured to drop "
+ "invalid header fields."
+ )
+ findings.append(report)
+
+ return findings
diff --git a/tests/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled_test.py b/tests/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled_test.py
new file mode 100644
index 0000000000..40d8d91f0d
--- /dev/null
+++ b/tests/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled_test.py
@@ -0,0 +1,254 @@
+from importlib import import_module
+from unittest import mock
+
+from boto3 import client, resource
+from moto import mock_aws
+
+from tests.providers.aws.utils import (
+ AWS_REGION_EU_WEST_1,
+ AWS_REGION_EU_WEST_1_AZA,
+ AWS_REGION_EU_WEST_1_AZB,
+ AWS_REGION_US_EAST_1,
+ set_mocked_aws_provider,
+)
+
+CHECK_MODULE = (
+ "prowler.providers.aws.services.elbv2."
+ "elbv2_alb_drop_invalid_header_fields_enabled."
+ "elbv2_alb_drop_invalid_header_fields_enabled"
+)
+ELBV2_CLIENT_PATCH = f"{CHECK_MODULE}.elbv2_client"
+GLOBAL_PROVIDER_PATCH = ".".join(
+ [
+ "prowler.providers.common.provider.Provider",
+ "get_global_provider",
+ ]
+)
+PASS_STATUS_EXTENDED = " ".join(
+ [
+ "ELBv2 ALB my-lb is configured to drop invalid",
+ "header fields.",
+ ]
+)
+FAIL_STATUS_EXTENDED = (
+ "ELBv2 ALB my-lb is not configured to drop invalid header fields."
+)
+
+
+def get_check_class():
+ return getattr(
+ import_module(CHECK_MODULE),
+ "elbv2_alb_drop_invalid_header_fields_enabled",
+ )
+
+
+class Test_elbv2_alb_drop_invalid_header_fields_enabled:
+ @mock_aws
+ def test_elb_no_balancers(self):
+ from prowler.providers.aws.services.elbv2.elbv2_service import ELBv2
+
+ with (
+ mock.patch(
+ GLOBAL_PROVIDER_PATCH,
+ return_value=set_mocked_aws_provider(
+ [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1]
+ ),
+ ),
+ mock.patch(
+ ELBV2_CLIENT_PATCH,
+ new=ELBv2(
+ set_mocked_aws_provider(
+ [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1],
+ create_default_organization=False,
+ )
+ ),
+ ),
+ ):
+ check = get_check_class()()
+ result = check.execute()
+
+ assert len(result) == 0
+
+ @mock_aws
+ def test_elbv2_dropping_invalid_header_fields(self):
+ conn = client("elbv2", region_name=AWS_REGION_EU_WEST_1)
+ ec2 = resource("ec2", region_name=AWS_REGION_EU_WEST_1)
+
+ security_group = ec2.create_security_group(
+ GroupName="a-security-group", Description="First One"
+ )
+ vpc = ec2.create_vpc(
+ CidrBlock="172.28.7.0/24",
+ InstanceTenancy="default",
+ )
+ subnet1 = ec2.create_subnet(
+ VpcId=vpc.id,
+ CidrBlock="172.28.7.192/26",
+ AvailabilityZone=AWS_REGION_EU_WEST_1_AZA,
+ )
+ subnet2 = ec2.create_subnet(
+ VpcId=vpc.id,
+ CidrBlock="172.28.7.0/26",
+ AvailabilityZone=AWS_REGION_EU_WEST_1_AZB,
+ )
+
+ lb = conn.create_load_balancer(
+ Name="my-lb",
+ Subnets=[subnet1.id, subnet2.id],
+ SecurityGroups=[security_group.id],
+ Scheme="internal",
+ Type="application",
+ )["LoadBalancers"][0]
+
+ conn.modify_load_balancer_attributes(
+ LoadBalancerArn=lb["LoadBalancerArn"],
+ Attributes=[
+ {
+ "Key": "routing.http.drop_invalid_header_fields.enabled",
+ "Value": "true",
+ },
+ ],
+ )
+
+ from prowler.providers.aws.services.elbv2.elbv2_service import ELBv2
+
+ with (
+ mock.patch(
+ GLOBAL_PROVIDER_PATCH,
+ return_value=set_mocked_aws_provider(
+ [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1]
+ ),
+ ),
+ mock.patch(
+ ELBV2_CLIENT_PATCH,
+ new=ELBv2(
+ set_mocked_aws_provider(
+ [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1],
+ create_default_organization=False,
+ )
+ ),
+ ),
+ ):
+ check = get_check_class()()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert result[0].status_extended == PASS_STATUS_EXTENDED
+ assert result[0].resource_id == "my-lb"
+ assert result[0].resource_arn == lb["LoadBalancerArn"]
+
+ @mock_aws
+ def test_elbv2_not_dropping_invalid_header_fields(self):
+ conn = client("elbv2", region_name=AWS_REGION_EU_WEST_1)
+ ec2 = resource("ec2", region_name=AWS_REGION_EU_WEST_1)
+
+ security_group = ec2.create_security_group(
+ GroupName="a-security-group", Description="First One"
+ )
+ vpc = ec2.create_vpc(
+ CidrBlock="172.28.7.0/24",
+ InstanceTenancy="default",
+ )
+ subnet1 = ec2.create_subnet(
+ VpcId=vpc.id,
+ CidrBlock="172.28.7.192/26",
+ AvailabilityZone=AWS_REGION_EU_WEST_1_AZA,
+ )
+ subnet2 = ec2.create_subnet(
+ VpcId=vpc.id,
+ CidrBlock="172.28.7.0/26",
+ AvailabilityZone=AWS_REGION_EU_WEST_1_AZB,
+ )
+
+ lb = conn.create_load_balancer(
+ Name="my-lb",
+ Subnets=[subnet1.id, subnet2.id],
+ SecurityGroups=[security_group.id],
+ Scheme="internal",
+ Type="application",
+ )["LoadBalancers"][0]
+
+ conn.modify_load_balancer_attributes(
+ LoadBalancerArn=lb["LoadBalancerArn"],
+ Attributes=[
+ {
+ "Key": "routing.http.drop_invalid_header_fields.enabled",
+ "Value": "false",
+ },
+ ],
+ )
+
+ from prowler.providers.aws.services.elbv2.elbv2_service import ELBv2
+
+ with (
+ mock.patch(
+ GLOBAL_PROVIDER_PATCH,
+ return_value=set_mocked_aws_provider(
+ [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1]
+ ),
+ ),
+ mock.patch(
+ ELBV2_CLIENT_PATCH,
+ new=ELBv2(
+ set_mocked_aws_provider(
+ [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1],
+ create_default_organization=False,
+ )
+ ),
+ ),
+ ):
+ check = get_check_class()()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert result[0].status_extended == FAIL_STATUS_EXTENDED
+ assert result[0].resource_id == "my-lb"
+ assert result[0].resource_arn == lb["LoadBalancerArn"]
+
+ @mock_aws
+ def test_elbv2_network_load_balancer_ignored(self):
+ conn = client("elbv2", region_name=AWS_REGION_EU_WEST_1)
+ ec2 = resource("ec2", region_name=AWS_REGION_EU_WEST_1)
+
+ vpc = ec2.create_vpc(
+ CidrBlock="172.28.7.0/24",
+ InstanceTenancy="default",
+ )
+ subnet1 = ec2.create_subnet(
+ VpcId=vpc.id,
+ CidrBlock="172.28.7.192/26",
+ AvailabilityZone=AWS_REGION_EU_WEST_1_AZA,
+ )
+
+ conn.create_load_balancer(
+ Name="my-nlb",
+ Subnets=[subnet1.id],
+ Scheme="internal",
+ Type="network",
+ )
+
+ from prowler.providers.aws.services.elbv2.elbv2_service import ELBv2
+
+ with (
+ mock.patch(
+ GLOBAL_PROVIDER_PATCH,
+ return_value=set_mocked_aws_provider(
+ [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1]
+ ),
+ ),
+ mock.patch(
+ ELBV2_CLIENT_PATCH,
+ new=ELBv2(
+ set_mocked_aws_provider(
+ [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1],
+ create_default_organization=False,
+ )
+ ),
+ ),
+ ):
+ check = get_check_class()()
+ result = check.execute()
+
+ assert len(result) == 0
From 5a2226c02ccc2bdd2da567f09762b2d3f681b3ca Mon Sep 17 00:00:00 2001
From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
Date: Mon, 8 Jun 2026 11:54:51 +0200
Subject: [PATCH 015/129] fix(ui): preserve active tab styling with tooltips
(#11493)
---
ui/CHANGELOG.md | 8 ++++++++
ui/components/shadcn/tabs/tabs.test.tsx | 27 +++++++++++++++++++++++++
ui/components/shadcn/tabs/tabs.tsx | 4 ++--
3 files changed, 37 insertions(+), 2 deletions(-)
create mode 100644 ui/components/shadcn/tabs/tabs.test.tsx
diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md
index 8b2ba973aa..66d908b8fd 100644
--- a/ui/CHANGELOG.md
+++ b/ui/CHANGELOG.md
@@ -10,6 +10,14 @@ All notable changes to the **Prowler UI** are documented in this file.
---
+## [1.29.3] (Prowler UNRELEASED)
+
+### 🐞 Fixed
+
+- Finding drawer tabs now keep the active tab text and underline styling when tooltip state changes [(#11493)](https://github.com/prowler-cloud/prowler/pull/11493)
+
+---
+
## [1.29.2] (Prowler v5.29.2)
### 🔄 Changed
diff --git a/ui/components/shadcn/tabs/tabs.test.tsx b/ui/components/shadcn/tabs/tabs.test.tsx
new file mode 100644
index 0000000000..8d9a54816d
--- /dev/null
+++ b/ui/components/shadcn/tabs/tabs.test.tsx
@@ -0,0 +1,27 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { Tabs, TabsList, TabsTrigger } from "./tabs";
+
+describe("TabsTrigger", () => {
+ it("keeps active styling available when rendered with a tooltip", () => {
+ render(
+
+
+
+ Overview
+
+
+ Remediation
+
+
+ ,
+ );
+
+ const activeTrigger = screen.getByRole("tab", { name: "Overview" });
+
+ expect(activeTrigger).toHaveAttribute("aria-selected", "true");
+ expect(activeTrigger).toHaveClass("aria-selected:text-slate-900");
+ expect(activeTrigger).toHaveClass("aria-selected:after:scale-x-100");
+ });
+});
diff --git a/ui/components/shadcn/tabs/tabs.tsx b/ui/components/shadcn/tabs/tabs.tsx
index 92ca5575c8..22379000d6 100644
--- a/ui/components/shadcn/tabs/tabs.tsx
+++ b/ui/components/shadcn/tabs/tabs.tsx
@@ -18,9 +18,9 @@ const TRIGGER_STYLES = {
border: "border-r border-[#E9E9F0] last:border-r-0 dark:border-[#171D30]",
text: "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white",
active:
- "data-[state=active]:text-slate-900 dark:data-[state=active]:text-white",
+ "data-[state=active]:text-slate-900 aria-selected:text-slate-900 dark:data-[state=active]:text-white dark:aria-selected:text-white",
underline:
- "after:absolute after:bottom-0 after:left-0 after:right-4 after:h-0.5 after:scale-x-0 after:bg-emerald-400 after:transition-transform data-[state=active]:after:scale-x-100 [&:not(:first-child)]:after:left-4 [&:last-child]:after:right-0",
+ "after:absolute after:bottom-0 after:left-0 after:right-4 after:h-0.5 after:scale-x-0 after:bg-emerald-400 after:transition-transform data-[state=active]:after:scale-x-100 aria-selected:after:scale-x-100 [&:not(:first-child)]:after:left-4 [&:last-child]:after:right-0",
focus:
"focus-visible:ring-2 focus-visible:ring-emerald-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white focus-visible:outline-none dark:focus-visible:ring-offset-slate-950",
icon: "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
From 28b045302f2c623d944e83e2a87f0da7f521c760 Mon Sep 17 00:00:00 2001
From: Josema Camacho
Date: Mon, 8 Jun 2026 13:30:18 +0200
Subject: [PATCH 016/129] fix(api): create Neo4j driver lazily so an outage
can't block API startup (#11491)
---
api/CHANGELOG.md | 8 +++
api/src/backend/api/apps.py | 40 ++-----------
api/src/backend/api/attack_paths/database.py | 22 ++++++--
api/src/backend/api/tests/test_apps.py | 56 ++++---------------
.../api/tests/test_attack_paths_database.py | 43 ++++++++++++--
5 files changed, 81 insertions(+), 88 deletions(-)
diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md
index 4f4d3426f6..e9dac993ce 100644
--- a/api/CHANGELOG.md
+++ b/api/CHANGELOG.md
@@ -21,6 +21,14 @@ All notable changes to the **Prowler API** are documented in this file.
---
+## [1.30.3] (Prowler v5.29.3)
+
+### 🐞 Fixed
+
+- API startup no longer crashes when Neo4j is unreachable, as the Neo4j driver now connects lazily on first use rather than during app initialization [(#11491)](https://github.com/prowler-cloud/prowler/pull/11491)
+
+---
+
## [1.30.1] (Prowler v5.29.1)
### 🐞 Fixed
diff --git a/api/src/backend/api/apps.py b/api/src/backend/api/apps.py
index 3209f75888..6c3a4ec2d9 100644
--- a/api/src/backend/api/apps.py
+++ b/api/src/backend/api/apps.py
@@ -1,12 +1,14 @@
import logging
import os
import sys
+
from pathlib import Path
+from django.apps import AppConfig
+from django.conf import settings
+
from config.custom_logging import BackendLogger
from config.env import env
-from django.apps import AppConfig
-from django.conf import settings
logger = logging.getLogger(BackendLogger.API)
@@ -30,7 +32,6 @@ class ApiConfig(AppConfig):
def ready(self):
from api import schema_extensions # noqa: F401
from api import signals # noqa: F401
- from api.attack_paths import database as graph_database
# Generate required cryptographic keys if not present, but only if:
# `"manage.py" not in sys.argv[0]`: If an external server (e.g., Gunicorn) is running the app
@@ -41,37 +42,8 @@ class ApiConfig(AppConfig):
):
self._ensure_crypto_keys()
- # Commands that don't need Neo4j
- SKIP_NEO4J_DJANGO_COMMANDS = [
- "makemigrations",
- "migrate",
- "pgpartition",
- "check",
- "help",
- "showmigrations",
- "check_and_fix_socialaccount_sites_migration",
- ]
-
- # Skip eager Neo4j init for tests, some Django commands, and Celery (prefork pool: driver must stay lazy, no post_fork hook)
- if getattr(settings, "TESTING", False) or (
- len(sys.argv) > 1
- and (
- (
- "manage.py" in sys.argv[0]
- and sys.argv[1] in SKIP_NEO4J_DJANGO_COMMANDS
- )
- or "celery" in sys.argv[0]
- )
- ):
- logger.info(
- "Skipping eager Neo4j init: tests, some Django commands, or Celery prefork pool (driver stays lazy)"
- )
-
- else:
- graph_database.init_driver()
-
- # Neo4j driver is initialized at API startup (see api.attack_paths.database)
- # It remains lazy for Celery workers and selected Django commands
+ # Neo4j driver is created lazily on first use (see api.attack_paths.database).
+ # App init never contacts Neo4j, so a Neo4j outage cannot block API startup.
def _ensure_crypto_keys(self):
"""
diff --git a/api/src/backend/api/attack_paths/database.py b/api/src/backend/api/attack_paths/database.py
index f5fddd0613..d5cc1698a7 100644
--- a/api/src/backend/api/attack_paths/database.py
+++ b/api/src/backend/api/attack_paths/database.py
@@ -1,22 +1,24 @@
import atexit
import logging
import threading
+
from contextlib import contextmanager
from typing import Any, Iterator
from uuid import UUID
import neo4j
import neo4j.exceptions
+
from config.env import env
from django.conf import settings
+
+from api.attack_paths.retryable_session import RetryableSession
from tasks.jobs.attack_paths.config import (
BATCH_SIZE,
PROVIDER_RESOURCE_LABEL,
get_provider_label,
)
-from api.attack_paths.retryable_session import RetryableSession
-
# Without this Celery goes crazy with Neo4j logging
logging.getLogger("neo4j").setLevel(logging.ERROR)
logging.getLogger("neo4j").propagate = False
@@ -28,6 +30,9 @@ READ_QUERY_TIMEOUT_SECONDS = env.int(
"ATTACK_PATHS_READ_QUERY_TIMEOUT_SECONDS", default=30
)
MAX_CUSTOM_QUERY_NODES = env.int("ATTACK_PATHS_MAX_CUSTOM_QUERY_NODES", default=250)
+# Shorter than CONN_ACQUISITION_TIMEOUT — the driver requires acquisition to be
+# the longer of the two (it may include opening a new connection).
+CONNECTION_TIMEOUT = env.int("NEO4J_CONNECTION_TIMEOUT", default=5)
CONN_ACQUISITION_TIMEOUT = env.int("NEO4J_CONN_ACQUISITION_TIMEOUT", default=15)
READ_EXCEPTION_CODES = [
"Neo.ClientError.Statement.AccessMode",
@@ -58,15 +63,24 @@ def init_driver() -> neo4j.Driver:
uri = get_uri()
config = settings.DATABASES["neo4j"]
- _driver = neo4j.GraphDatabase.driver(
+ driver = neo4j.GraphDatabase.driver(
uri,
auth=(config["USER"], config["PASSWORD"]),
keep_alive=True,
max_connection_lifetime=7200,
+ connection_timeout=CONNECTION_TIMEOUT,
connection_acquisition_timeout=CONN_ACQUISITION_TIMEOUT,
max_connection_pool_size=50,
)
- _driver.verify_connectivity()
+ # Publish the singleton only after connectivity is verified so a
+ # failed probe does not leave an unverified driver behind. Close the
+ # driver on failure so a repeatedly-probed outage cannot leak pools.
+ try:
+ driver.verify_connectivity()
+ except Exception:
+ driver.close()
+ raise
+ _driver = driver
# Register cleanup handler (only runs once since we're inside the _driver is None block)
atexit.register(close_driver)
diff --git a/api/src/backend/api/tests/test_apps.py b/api/src/backend/api/tests/test_apps.py
index 5889b4e2cb..2f5b55a6e2 100644
--- a/api/src/backend/api/tests/test_apps.py
+++ b/api/src/backend/api/tests/test_apps.py
@@ -182,23 +182,19 @@ def _make_app():
return ApiConfig("api", api)
-def test_ready_initializes_driver_for_api_process(monkeypatch):
+@pytest.mark.parametrize(
+ "argv",
+ [
+ ["gunicorn"],
+ ["celery", "-A", "api"],
+ ["manage.py", "migrate"],
+ ],
+ ids=["api", "celery", "manage_py"],
+)
+def test_ready_never_eagerly_initializes_neo4j_driver(monkeypatch, argv):
+ """ready() must never contact Neo4j; the driver is created lazily on first use."""
config = _make_app()
- _set_argv(monkeypatch, ["gunicorn"])
- _set_testing(monkeypatch, False)
-
- with (
- patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None),
- patch("api.attack_paths.database.init_driver") as init_driver,
- ):
- config.ready()
-
- init_driver.assert_called_once()
-
-
-def test_ready_skips_driver_for_celery(monkeypatch):
- config = _make_app()
- _set_argv(monkeypatch, ["celery", "-A", "api"])
+ _set_argv(monkeypatch, argv)
_set_testing(monkeypatch, False)
with (
@@ -208,31 +204,3 @@ def test_ready_skips_driver_for_celery(monkeypatch):
config.ready()
init_driver.assert_not_called()
-
-
-def test_ready_skips_driver_for_manage_py_skip_command(monkeypatch):
- config = _make_app()
- _set_argv(monkeypatch, ["manage.py", "migrate"])
- _set_testing(monkeypatch, False)
-
- with (
- patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None),
- patch("api.attack_paths.database.init_driver") as init_driver,
- ):
- config.ready()
-
- init_driver.assert_not_called()
-
-
-def test_ready_skips_driver_when_testing(monkeypatch):
- config = _make_app()
- _set_argv(monkeypatch, ["gunicorn"])
- _set_testing(monkeypatch, True)
-
- with (
- patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None),
- patch("api.attack_paths.database.init_driver") as init_driver,
- ):
- config.ready()
-
- init_driver.assert_not_called()
diff --git a/api/src/backend/api/tests/test_attack_paths_database.py b/api/src/backend/api/tests/test_attack_paths_database.py
index 8828d23911..3a29a1007d 100644
--- a/api/src/backend/api/tests/test_attack_paths_database.py
+++ b/api/src/backend/api/tests/test_attack_paths_database.py
@@ -1,15 +1,16 @@
"""
Tests for Neo4j database lazy initialization.
-The Neo4j driver connects on first use by default. API processes may
-eagerly initialize the driver during app startup, while Celery workers
-remain lazy. These tests validate the database module behavior itself.
+The Neo4j driver is created on first use for every process type; app startup
+never contacts Neo4j. These tests validate the database module behavior itself.
"""
import threading
+
from unittest.mock import MagicMock, patch
import neo4j
+import neo4j.exceptions
import pytest
import api.attack_paths.database as db_module
@@ -59,6 +60,32 @@ class TestLazyInitialization:
assert result is mock_driver
assert db_module._driver is mock_driver
+ @patch("api.attack_paths.database.settings")
+ @patch("api.attack_paths.database.neo4j.GraphDatabase.driver")
+ def test_init_driver_leaves_driver_none_when_verify_fails(
+ self, mock_driver_factory, mock_settings
+ ):
+ """A failed verify_connectivity() must not publish or leak the driver."""
+ mock_driver = MagicMock()
+ mock_driver.verify_connectivity.side_effect = (
+ neo4j.exceptions.ServiceUnavailable("down")
+ )
+ mock_driver_factory.return_value = mock_driver
+ mock_settings.DATABASES = {
+ "neo4j": {
+ "HOST": "localhost",
+ "PORT": 7687,
+ "USER": "neo4j",
+ "PASSWORD": "password",
+ }
+ }
+
+ with pytest.raises(neo4j.exceptions.ServiceUnavailable):
+ db_module.init_driver()
+
+ assert db_module._driver is None
+ mock_driver.close.assert_called_once()
+
@patch("api.attack_paths.database.settings")
@patch("api.attack_paths.database.neo4j.GraphDatabase.driver")
def test_init_driver_returns_cached_driver_on_subsequent_calls(
@@ -116,21 +143,23 @@ class TestConnectionAcquisitionTimeout:
@pytest.fixture(autouse=True)
def reset_module_state(self):
original_driver = db_module._driver
- original_timeout = db_module.CONN_ACQUISITION_TIMEOUT
+ original_acq_timeout = db_module.CONN_ACQUISITION_TIMEOUT
+ original_conn_timeout = db_module.CONNECTION_TIMEOUT
db_module._driver = None
yield
db_module._driver = original_driver
- db_module.CONN_ACQUISITION_TIMEOUT = original_timeout
+ db_module.CONN_ACQUISITION_TIMEOUT = original_acq_timeout
+ db_module.CONNECTION_TIMEOUT = original_conn_timeout
@patch("api.attack_paths.database.settings")
@patch("api.attack_paths.database.neo4j.GraphDatabase.driver")
def test_driver_receives_configured_timeout(
self, mock_driver_factory, mock_settings
):
- """init_driver() should pass CONN_ACQUISITION_TIMEOUT to the neo4j driver."""
+ """init_driver() should pass the configured timeouts to the neo4j driver."""
mock_driver_factory.return_value = MagicMock()
mock_settings.DATABASES = {
"neo4j": {
@@ -141,11 +170,13 @@ class TestConnectionAcquisitionTimeout:
}
}
db_module.CONN_ACQUISITION_TIMEOUT = 42
+ db_module.CONNECTION_TIMEOUT = 7
db_module.init_driver()
_, kwargs = mock_driver_factory.call_args
assert kwargs["connection_acquisition_timeout"] == 42
+ assert kwargs["connection_timeout"] == 7
class TestAtexitRegistration:
From 061fbaa7bb0815163a160d3bba60780a9c0b2ed7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?C=C3=A9sar=20Arroba?=
<19954079+cesararroba@users.noreply.github.com>
Date: Mon, 8 Jun 2026 13:45:06 +0200
Subject: [PATCH 017/129] feat(api): label Postgres connections with
application_name per component and alias (#11494)
---
api/CHANGELOG.md | 1 +
api/docker-entrypoint.sh | 9 +++
.../api/tests/test_db_connection_labels.py | 55 +++++++++++++++++++
api/src/backend/config/django/base.py | 17 ++++++
api/src/backend/config/django/devel.py | 2 +
api/src/backend/config/django/production.py | 2 +
6 files changed, 86 insertions(+)
create mode 100644 api/src/backend/api/tests/test_db_connection_labels.py
diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md
index e9dac993ce..af4b9e69bf 100644
--- a/api/CHANGELOG.md
+++ b/api/CHANGELOG.md
@@ -9,6 +9,7 @@ All notable changes to the **Prowler API** are documented in this file.
- Automatic recovery of allowlisted idempotent background tasks whose worker died during a deploy or crash: stuck scan and summary tasks are detected and re-run instead of staying pending forever, with a `reconcile_orphan_tasks` management command for on-demand recovery [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416)
- Jira integration no longer creates duplicate issues on a retried send; findings already ticketed are skipped [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416)
- DORA compliance framework support [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131)
+- Label Postgres connections with `application_name=":"` (component injected per process via `DJANGO_APP_COMPONENT`) so connections are attributable by component in `pg_stat_activity` [(#11494)](https://github.com/prowler-cloud/prowler/pull/11494)
### 🔄 Changed
diff --git a/api/docker-entrypoint.sh b/api/docker-entrypoint.sh
index e6313f459a..9b2964b479 100755
--- a/api/docker-entrypoint.sh
+++ b/api/docker-entrypoint.sh
@@ -68,6 +68,15 @@ manage_db_partitions() {
fi
}
+# Identify this process to Postgres (application_name=:) so
+# connections are attributable by component in pg_stat_activity. Web tiers
+# report "api"; everything else uses the launch subcommand.
+case "$1" in
+ prod|dev) DJANGO_APP_COMPONENT="api" ;;
+ *) DJANGO_APP_COMPONENT="$1" ;;
+esac
+export DJANGO_APP_COMPONENT
+
case "$1" in
dev)
apply_migrations
diff --git a/api/src/backend/api/tests/test_db_connection_labels.py b/api/src/backend/api/tests/test_db_connection_labels.py
new file mode 100644
index 0000000000..a39e7d9051
--- /dev/null
+++ b/api/src/backend/api/tests/test_db_connection_labels.py
@@ -0,0 +1,55 @@
+from config.django.base import label_postgres_connections
+
+
+class TestLabelPostgresConnections:
+ def test_labels_postgres_and_skips_neo4j(self, monkeypatch):
+ monkeypatch.setenv("DJANGO_APP_COMPONENT", "scan")
+ databases = {
+ "default": {"ENGINE": "psqlextra.backend"},
+ "neo4j": {"HOST": "neo4j", "PORT": "7687"},
+ }
+
+ label_postgres_connections(databases)
+
+ assert databases["default"]["OPTIONS"]["application_name"] == "scan:default"
+ assert "OPTIONS" not in databases["neo4j"]
+
+ def test_labels_plain_postgresql_backend(self, monkeypatch):
+ monkeypatch.setenv("DJANGO_APP_COMPONENT", "api")
+ databases = {"saas": {"ENGINE": "django.db.backends.postgresql"}}
+
+ label_postgres_connections(databases)
+
+ assert databases["saas"]["OPTIONS"]["application_name"] == "api:saas"
+
+ def test_defaults_component_to_api_when_unset(self, monkeypatch):
+ monkeypatch.delenv("DJANGO_APP_COMPONENT", raising=False)
+ databases = {"default": {"ENGINE": "psqlextra.backend"}}
+
+ label_postgres_connections(databases)
+
+ assert databases["default"]["OPTIONS"]["application_name"] == "api:default"
+
+ def test_preserves_existing_options(self, monkeypatch):
+ monkeypatch.setenv("DJANGO_APP_COMPONENT", "worker")
+ databases = {
+ "replica": {
+ "ENGINE": "psqlextra.backend",
+ "OPTIONS": {"sslmode": "require"},
+ }
+ }
+
+ label_postgres_connections(databases)
+
+ assert databases["replica"]["OPTIONS"] == {
+ "sslmode": "require",
+ "application_name": "worker:replica",
+ }
+
+ def test_truncates_application_name_to_63_bytes(self, monkeypatch):
+ monkeypatch.setenv("DJANGO_APP_COMPONENT", "c" * 80)
+ databases = {"default": {"ENGINE": "psqlextra.backend"}}
+
+ label_postgres_connections(databases)
+
+ assert len(databases["default"]["OPTIONS"]["application_name"]) == 63
diff --git a/api/src/backend/config/django/base.py b/api/src/backend/config/django/base.py
index 317fe4fbfb..402b71eb51 100644
--- a/api/src/backend/config/django/base.py
+++ b/api/src/backend/config/django/base.py
@@ -306,3 +306,20 @@ SESSION_COOKIE_SECURE = True
ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES = env.int(
"ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES", 2880
) # 48h
+
+
+def label_postgres_connections(databases):
+ """Tag each Postgres connection with ``application_name=":"``
+ so connections are attributable by component in ``pg_stat_activity`` (and any
+ tooling that surfaces ``application_name``). The component (api / worker /
+ scan / ...) is injected per process by the container entrypoint via
+ ``DJANGO_APP_COMPONENT``; the alias distinguishes which pool inside the
+ process owns the connection. The neo4j entry is skipped (not a Postgres
+ backend). Postgres truncates ``application_name`` at 63 bytes.
+ """
+ component = env.str("DJANGO_APP_COMPONENT", default="api")
+ for alias, config in databases.items():
+ engine = config.get("ENGINE", "")
+ if engine.startswith("psqlextra") or "postgresql" in engine:
+ name = f"{component}:{alias}"[:63]
+ config.setdefault("OPTIONS", {})["application_name"] = name
diff --git a/api/src/backend/config/django/devel.py b/api/src/backend/config/django/devel.py
index 9c83557b77..6921790ca3 100644
--- a/api/src/backend/config/django/devel.py
+++ b/api/src/backend/config/django/devel.py
@@ -54,6 +54,8 @@ DATABASES = {
DATABASES["default"] = DATABASES["prowler_user"]
+label_postgres_connections(DATABASES) # noqa: F405
+
REST_FRAMEWORK["DEFAULT_RENDERER_CLASSES"] = tuple( # noqa: F405
render_class
for render_class in REST_FRAMEWORK["DEFAULT_RENDERER_CLASSES"] # noqa: F405
diff --git a/api/src/backend/config/django/production.py b/api/src/backend/config/django/production.py
index 91bd50d0d1..cb651f6e76 100644
--- a/api/src/backend/config/django/production.py
+++ b/api/src/backend/config/django/production.py
@@ -58,3 +58,5 @@ DATABASES = {
}
DATABASES["default"] = DATABASES["prowler_user"]
+
+label_postgres_connections(DATABASES) # noqa: F405
From 466f1a3d734b6c8d2116abd823058f745e6e8eb9 Mon Sep 17 00:00:00 2001
From: Daniel Barranquero <74871504+danibarranqueroo@users.noreply.github.com>
Date: Mon, 8 Jun 2026 14:59:50 +0200
Subject: [PATCH 018/129] feat(okta): add user, systemlog, and idp services
with DISA STIG checks (#11496)
Co-authored-by: Hugo P.Brito
Co-authored-by: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com>
---
.../providers/okta/authentication.mdx | 14 +-
.../providers/okta/getting-started-okta.mdx | 23 +-
prowler/CHANGELOG.md | 1 +
.../providers/okta/lib/arguments/arguments.py | 3 +-
.../providers/okta/lib/service/pagination.py | 69 +++
.../providers/okta/lib/service/raw_fetch.py | 141 ++++++
prowler/providers/okta/okta_provider.py | 8 +-
.../application/application_service.py | 129 +----
.../providers/okta/services/idp/__init__.py | 0
.../providers/okta/services/idp/idp_client.py | 4 +
.../okta/services/idp/idp_service.py | 118 +++++
.../__init__.py | 0
...p_smart_card_dod_approved_ca.metadata.json | 37 ++
.../idp_smart_card_dod_approved_ca.py | 148 ++++++
.../okta/services/idp/lib/__init__.py | 0
.../okta/services/idp/lib/idp_helpers.py | 26 +
.../okta/services/signon/signon_service.py | 53 +-
.../okta/services/systemlog/__init__.py | 0
.../okta/services/systemlog/lib/__init__.py | 0
.../systemlog/lib/systemlog_helpers.py | 26 +
.../services/systemlog/systemlog_client.py | 4 +
.../services/systemlog/systemlog_service.py | 136 +++++
.../systemlog_streaming_enabled/__init__.py | 0
.../systemlog_streaming_enabled.metadata.json | 37 ++
.../systemlog_streaming_enabled.py | 88 ++++
.../providers/okta/services/user/__init__.py | 0
.../okta/services/user/lib/__init__.py | 0
.../okta/services/user/lib/user_helpers.py | 26 +
.../okta/services/user/user_client.py | 4 +
.../__init__.py | 0
...ivity_automation_35d_enabled.metadata.json | 36 ++
.../user_inactivity_automation_35d_enabled.py | 204 ++++++++
.../okta/services/user/user_service.py | 455 +++++++++++++++++
.../okta/lib/service/raw_fetch_test.py | 152 ++++++
tests/providers/okta/okta_fixtures.py | 10 +-
.../okta/services/idp/idp_fixtures.py | 44 ++
.../okta/services/idp/idp_service_test.py | 80 +++
.../idp_smart_card_dod_approved_ca_test.py | 125 +++++
.../services/signon/signon_service_test.py | 4 +-
.../services/systemlog/systemlog_fixtures.py | 27 +
.../systemlog/systemlog_service_test.py | 185 +++++++
.../systemlog_streaming_enabled_test.py | 73 +++
.../okta/services/user/user_fixtures.py | 55 ++
..._inactivity_automation_35d_enabled_test.py | 165 ++++++
.../okta/services/user/user_service_test.py | 477 ++++++++++++++++++
45 files changed, 3009 insertions(+), 178 deletions(-)
create mode 100644 prowler/providers/okta/lib/service/pagination.py
create mode 100644 prowler/providers/okta/lib/service/raw_fetch.py
create mode 100644 prowler/providers/okta/services/idp/__init__.py
create mode 100644 prowler/providers/okta/services/idp/idp_client.py
create mode 100644 prowler/providers/okta/services/idp/idp_service.py
create mode 100644 prowler/providers/okta/services/idp/idp_smart_card_dod_approved_ca/__init__.py
create mode 100644 prowler/providers/okta/services/idp/idp_smart_card_dod_approved_ca/idp_smart_card_dod_approved_ca.metadata.json
create mode 100644 prowler/providers/okta/services/idp/idp_smart_card_dod_approved_ca/idp_smart_card_dod_approved_ca.py
create mode 100644 prowler/providers/okta/services/idp/lib/__init__.py
create mode 100644 prowler/providers/okta/services/idp/lib/idp_helpers.py
create mode 100644 prowler/providers/okta/services/systemlog/__init__.py
create mode 100644 prowler/providers/okta/services/systemlog/lib/__init__.py
create mode 100644 prowler/providers/okta/services/systemlog/lib/systemlog_helpers.py
create mode 100644 prowler/providers/okta/services/systemlog/systemlog_client.py
create mode 100644 prowler/providers/okta/services/systemlog/systemlog_service.py
create mode 100644 prowler/providers/okta/services/systemlog/systemlog_streaming_enabled/__init__.py
create mode 100644 prowler/providers/okta/services/systemlog/systemlog_streaming_enabled/systemlog_streaming_enabled.metadata.json
create mode 100644 prowler/providers/okta/services/systemlog/systemlog_streaming_enabled/systemlog_streaming_enabled.py
create mode 100644 prowler/providers/okta/services/user/__init__.py
create mode 100644 prowler/providers/okta/services/user/lib/__init__.py
create mode 100644 prowler/providers/okta/services/user/lib/user_helpers.py
create mode 100644 prowler/providers/okta/services/user/user_client.py
create mode 100644 prowler/providers/okta/services/user/user_inactivity_automation_35d_enabled/__init__.py
create mode 100644 prowler/providers/okta/services/user/user_inactivity_automation_35d_enabled/user_inactivity_automation_35d_enabled.metadata.json
create mode 100644 prowler/providers/okta/services/user/user_inactivity_automation_35d_enabled/user_inactivity_automation_35d_enabled.py
create mode 100644 prowler/providers/okta/services/user/user_service.py
create mode 100644 tests/providers/okta/lib/service/raw_fetch_test.py
create mode 100644 tests/providers/okta/services/idp/idp_fixtures.py
create mode 100644 tests/providers/okta/services/idp/idp_service_test.py
create mode 100644 tests/providers/okta/services/idp/idp_smart_card_dod_approved_ca/idp_smart_card_dod_approved_ca_test.py
create mode 100644 tests/providers/okta/services/systemlog/systemlog_fixtures.py
create mode 100644 tests/providers/okta/services/systemlog/systemlog_service_test.py
create mode 100644 tests/providers/okta/services/systemlog/systemlog_streaming_enabled/systemlog_streaming_enabled_test.py
create mode 100644 tests/providers/okta/services/user/user_fixtures.py
create mode 100644 tests/providers/okta/services/user/user_inactivity_automation_35d_enabled/user_inactivity_automation_35d_enabled_test.py
create mode 100644 tests/providers/okta/services/user/user_service_test.py
diff --git a/docs/user-guide/providers/okta/authentication.mdx b/docs/user-guide/providers/okta/authentication.mdx
index c9154ee936..ca56a0535b 100644
--- a/docs/user-guide/providers/okta/authentication.mdx
+++ b/docs/user-guide/providers/okta/authentication.mdx
@@ -35,14 +35,18 @@ The bundled checks require the following read-only scopes:
- `okta.policies.read`
- `okta.brands.read`
- `okta.apps.read`
+- `okta.logStreams.read`
+- `okta.idps.read`
Additional scopes will be needed as more services and checks are added. These are the current ones needed:
| Scope | Used by |
|---|---|
-| `okta.policies.read` | Sign-on, password, and authentication policies |
+| `okta.policies.read` | Sign-on, password, authentication, and `USER_LIFECYCLE` (Workflow > Automations) policies |
| `okta.brands.read` | Sign-in page customizations (DOD Notice and Consent Banner check) |
| `okta.apps.read` | First-party app settings (Okta Admin Console session), integrated app inventory, and the Authentication Policies bound to Okta applications |
+| `okta.logStreams.read` | Log Stream configuration (`/api/v1/logStreams`) |
+| `okta.idps.read` | Identity Providers, including Smart Card (X509) IdPs (`/api/v1/idps`) |
### Required Admin Role
@@ -68,7 +72,9 @@ Okta filters the first-party apps (`saasure`, `okta_enduser`) out of `/api/v1/ap
A fifth check — `application_admin_console_session_idle_timeout_15min` (STIG V-273187) — also requires Super Administrator: it calls `GET /api/v1/first-party-app-settings/admin-console`, which returns `403 E0000006` for every role below Super Administrator.
-When the service app runs with Read-Only Administrator, the five checks listed in this section return **MANUAL** instead of PASS/FAIL — the rest of the scan keeps running.
+`user_inactivity_automation_35d_enabled` (STIG V-273188) reads `USER_LIFECYCLE` policies (`list_policies(type='USER_LIFECYCLE')`) using the `okta.policies.read` scope. The Read-Only Administrator role is enough to list them; no Super Administrator requirement.
+
+When the service app runs with Read-Only Administrator, the checks listed in this section return **MANUAL** instead of PASS/FAIL — the rest of the scan keeps running.
Read-Only Administrator stays the recommended default for the least-privilege framing that aligns with DISA STIG. Assign Super Administrator on a separate run when full coverage of the first-party app checks is needed.
@@ -158,8 +164,8 @@ export OKTA_PRIVATE_KEY_FILE="/secure/path/to/prowler-okta.pem"
# or
export OKTA_PRIVATE_KEY="$(cat /secure/path/to/prowler-okta.pem)"
-# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read"
-export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read"
+# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read,okta.logStreams.read,okta.idps.read"
+export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.logStreams.read,okta.idps.read"
uv run python prowler-cli.py okta
```
diff --git a/docs/user-guide/providers/okta/getting-started-okta.mdx b/docs/user-guide/providers/okta/getting-started-okta.mdx
index 66d3dd1808..6086afefb3 100644
--- a/docs/user-guide/providers/okta/getting-started-okta.mdx
+++ b/docs/user-guide/providers/okta/getting-started-okta.mdx
@@ -85,8 +85,8 @@ Follow the [Okta Authentication](/user-guide/providers/okta/authentication) guid
export OKTA_ORG_DOMAIN="acme.okta.com"
export OKTA_CLIENT_ID="0oa1234567890abcdef"
export OKTA_PRIVATE_KEY_FILE="/secure/path/to/prowler-okta.pem"
-# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read"
-export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read"
+# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read,okta.logStreams.read,okta.idps.read"
+export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.logStreams.read,okta.idps.read"
```
The private key file may contain either a PEM-encoded RSA key or a JWK JSON document.
@@ -143,10 +143,13 @@ prowler okta --config-file /path/to/config.yaml
Prowler for Okta includes security checks across the following services:
-| Service | Description |
-| --------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
-| **Sign-On** | Global session policy controls (idle timeout, lifetime, rule priority and ordering) |
-| **Application** | Okta Admin Console sign-on settings plus Authentication Policy controls for Okta applications (session idle, MFA, phishing resistance, network zones) |
+| Service | Description |
+| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **Sign-On** | Global session policy controls (idle timeout, lifetime, rule priority and ordering) |
+| **Application** | Okta Admin Console sign-on settings plus Authentication Policy controls for Okta applications (session idle, MFA, phishing resistance, network zones) |
+| **User** | User lifecycle automations (inactivity-based deprovisioning) |
+| **System Log** | Log Stream configuration that off-loads audit records to a central SIEM |
+| **Identity Provider** | Identity Providers, including Smart Card (X509) IdP status and certificate-chain visibility |
## Troubleshooting
@@ -158,11 +161,13 @@ This is stricter than simply finding the same timeout value somewhere else in th
### Default Scopes
-Prowler requests a fixed set of OAuth scopes on every token exchange. The defaults cover every bundled check across the Sign-On and Application services:
+Prowler requests a fixed set of OAuth scopes on every token exchange. The defaults cover every bundled check across the Sign-On, Application, User, System Log, and Identity Provider services:
- `okta.policies.read`
- `okta.brands.read`
- `okta.apps.read`
+- `okta.logStreams.read`
+- `okta.idps.read`
The service app must have these scopes granted in the **Okta API Scopes** tab. When the granted set is narrower than the requested set, the token request fails with an `invalid_scope` error and the scan stops at provider initialization.
@@ -170,10 +175,10 @@ When additional checks are enabled — or when running against a service app tha
```bash
# Environment variable — comma-separated
-export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.users.read"
+export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.logStreams.read,okta.idps.read,okta.users.read"
# CLI flag — space-separated
-prowler okta --okta-scopes okta.policies.read okta.brands.read okta.apps.read okta.users.read
+prowler okta --okta-scopes okta.policies.read okta.brands.read okta.apps.read okta.logStreams.read okta.idps.read okta.users.read
```
For the full catalog of OAuth scopes exposed by the Okta Management API, refer to the [Okta OAuth 2.0 scopes documentation](https://developer.okta.com/docs/api/oauth2/).
diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md
index 6922846b0d..45cf485716 100644
--- a/prowler/CHANGELOG.md
+++ b/prowler/CHANGELOG.md
@@ -9,6 +9,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- `sagemaker_models_monitor_enabled` check for AWS provider, verifying that each SageMaker monitoring schedule is in the `Scheduled` state so data and model drift is actively detected [(#11278)](https://github.com/prowler-cloud/prowler/pull/11278)
- DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554) universal compliance framework with AWS provider coverage across the five DORA pillars [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131)
- `elbv2_alb_drop_invalid_header_fields_enabled` check for AWS provider, verifying Application Load Balancers have `routing.http.drop_invalid_header_fields.enabled` set to `true` to mitigate HTTP desync attacks (AWS FSBP ELB.4) [(#11471)](https://github.com/prowler-cloud/prowler/pull/11471)
+- `user`, `systemlog` and `idp` service for Okta provider with `user_inactivity_automation_35d_enabled`, `systemlog_streaming_enabled` and `idp_smart_card_dod_approved_ca` checks [(#11496)](https://github.com/prowler-cloud/prowler/pull/11496)
---
diff --git a/prowler/providers/okta/lib/arguments/arguments.py b/prowler/providers/okta/lib/arguments/arguments.py
index 287f786b0f..4ed5cfa187 100644
--- a/prowler/providers/okta/lib/arguments/arguments.py
+++ b/prowler/providers/okta/lib/arguments/arguments.py
@@ -35,7 +35,8 @@ def init_parser(self):
nargs="+",
help=(
"OAuth scopes to request, space-separated "
- "(e.g. okta.policies.read okta.brands.read okta.apps.read). "
+ "(e.g. okta.policies.read okta.brands.read okta.apps.read "
+ "okta.logStreams.read okta.idps.read). "
"Defaults to the read scopes required by the bundled checks."
),
default=None,
diff --git a/prowler/providers/okta/lib/service/pagination.py b/prowler/providers/okta/lib/service/pagination.py
new file mode 100644
index 0000000000..a30bbf9477
--- /dev/null
+++ b/prowler/providers/okta/lib/service/pagination.py
@@ -0,0 +1,69 @@
+"""Shared pagination helpers for Okta SDK list calls.
+
+The Okta SDK exposes paginated list endpoints (`list_applications`,
+`list_policies`, `list_log_streams`, `list_identity_providers`, …) that
+return a tuple `(items, response, error)`. The next page is signalled
+through an RFC 5988 `Link: <…>; rel="next"` header carrying an opaque
+`after` cursor.
+
+These helpers are used by every Okta service that needs to drain a
+paginated endpoint. They live here so we don't keep copy-pasting them
+into each service module.
+"""
+
+from typing import Optional
+from urllib.parse import parse_qs, urlparse
+
+
+def next_after_cursor(resp) -> Optional[str]:
+ """Extract the `after` cursor from a `Link: ...; rel="next"` header.
+
+ Returns None when there is no next page. Header format follows RFC
+ 5988 and Okta's pagination guide.
+ """
+ if resp is None:
+ return None
+ headers = getattr(resp, "headers", None) or {}
+ link = headers.get("link") or headers.get("Link") or ""
+ if not link:
+ return None
+ for part in link.split(","):
+ if 'rel="next"' not in part:
+ continue
+ url_segment = part.split(";", 1)[0].strip().lstrip("<").rstrip(">")
+ cursor = parse_qs(urlparse(url_segment).query).get("after", [None])[0]
+ if cursor:
+ return cursor
+ return None
+
+
+async def paginate(fetch):
+ """Drain all pages of an SDK list call.
+
+ `fetch` is a callable that accepts the `after` cursor (or None for
+ the first page) and returns the SDK's standard `(items, resp, err)`
+ tuple — or the 2-tuple early-error shape `(items, err)`. Follows the
+ `Link: rel="next"` header until exhausted. The returned tuple is
+ `(all_items, error)` — error is non-None only when a page fails
+ to fetch.
+ """
+ all_items = []
+ result = await fetch(None)
+ err = result[-1]
+ if err is not None:
+ return [], err
+ items = result[0]
+ resp = result[1] if len(result) >= 3 else None
+ all_items.extend(items or [])
+ while True:
+ cursor = next_after_cursor(resp)
+ if not cursor:
+ break
+ result = await fetch(cursor)
+ err = result[-1]
+ if err is not None:
+ return all_items, err
+ items = result[0]
+ resp = result[1] if len(result) >= 3 else None
+ all_items.extend(items or [])
+ return all_items, None
diff --git a/prowler/providers/okta/lib/service/raw_fetch.py b/prowler/providers/okta/lib/service/raw_fetch.py
new file mode 100644
index 0000000000..42e8eede11
--- /dev/null
+++ b/prowler/providers/okta/lib/service/raw_fetch.py
@@ -0,0 +1,141 @@
+"""Raw-JSON HTTP fetch via the Okta SDK's request executor.
+
+Some Okta Management API endpoints are not yet exposed as typed methods
+on the SDK client (e.g. `/api/v1/automations`), or the typed path's
+pydantic deserialization rejects values the API actually returns (e.g.
+the `KnowledgeConstraint.types` lowercase issue we hit on
+`list_policy_rules`). In both cases we go around the typed layer:
+construct the request via `client._request_executor.create_request`,
+execute without a response type, and parse the body ourselves.
+
+`get_json` returns the parsed JSON payload (typically a list or dict)
+or raises with a descriptive log line on any of the failure modes —
+request build, transport, decode, parse. `get_json_paginated` drains
+list endpoints by following the `Link: rel="next"` cursor — without it,
+the raw fallback would silently truncate at the per-request `limit`.
+Callers are expected to project the JSON onto their own pydantic snapshot.
+"""
+
+import json
+from typing import Any, Optional
+from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
+
+from prowler.lib.logger import logger
+from prowler.providers.okta.lib.service.pagination import next_after_cursor
+
+
+async def get_json(
+ client,
+ path: str,
+ *,
+ accept: str = "application/json",
+ context: Optional[str] = None,
+) -> Optional[Any]:
+ """GET `path` via the SDK's request executor and return parsed JSON.
+
+ Returns the decoded JSON payload on success, or None when the
+ request, transport, or decode steps fail. Each failure path emits a
+ `logger.error` line tagged with `context` so the caller can grep
+ for it.
+ """
+ label = context or path
+ request, error = await client._request_executor.create_request(
+ method="GET",
+ url=path,
+ body=None,
+ headers={"Accept": accept},
+ )
+ if error is not None:
+ logger.error(f"Raw fetch (create_request) failed for {label}: {error}")
+ return None
+
+ _response, response_body, error = await client._request_executor.execute(request)
+ if error is not None:
+ logger.error(f"Raw fetch (execute) failed for {label}: {error}")
+ return None
+
+ if isinstance(response_body, (bytes, bytearray)):
+ try:
+ response_body = response_body.decode("utf-8")
+ except UnicodeDecodeError as decode_err:
+ logger.error(f"Could not decode response for {label}: {decode_err}")
+ return None
+ try:
+ return json.loads(response_body) if response_body else None
+ except json.JSONDecodeError as decode_err:
+ logger.error(f"Could not parse JSON for {label}: {decode_err}")
+ return None
+
+
+async def get_json_paginated(
+ client,
+ path: str,
+ *,
+ page_size: int = 200,
+ accept: str = "application/json",
+ context: Optional[str] = None,
+) -> Optional[list]:
+ """Drain all pages of a raw-JSON list endpoint.
+
+ Mirrors the typed `pagination.paginate` shape but operates on the
+ SDK's request executor directly. Follows the `Link: rel="next"`
+ header until exhausted, accumulating items across pages. Returns
+ the concatenated list, or None if any page fails to fetch or the
+ response is not a JSON array.
+
+ `page_size` is appended as `limit=N` to the first request; subsequent
+ requests use the URL Okta returns via the cursor.
+ """
+ label = context or path
+ all_items: list = []
+ current_path = _set_query(path, {"limit": str(page_size)})
+ while True:
+ request, error = await client._request_executor.create_request(
+ method="GET",
+ url=current_path,
+ body=None,
+ headers={"Accept": accept},
+ )
+ if error is not None:
+ logger.error(f"Raw fetch (create_request) failed for {label}: {error}")
+ return None
+
+ response, response_body, error = await client._request_executor.execute(request)
+ if error is not None:
+ logger.error(f"Raw fetch (execute) failed for {label}: {error}")
+ return None
+
+ if isinstance(response_body, (bytes, bytearray)):
+ try:
+ response_body = response_body.decode("utf-8")
+ except UnicodeDecodeError as decode_err:
+ logger.error(f"Could not decode response for {label}: {decode_err}")
+ return None
+ if not response_body:
+ break
+ try:
+ page = json.loads(response_body)
+ except json.JSONDecodeError as decode_err:
+ logger.error(f"Could not parse JSON for {label}: {decode_err}")
+ return None
+ if not isinstance(page, list):
+ logger.error(
+ f"Unexpected raw payload shape for {label}: "
+ f"{type(page).__name__}; expected list"
+ )
+ return None
+ all_items.extend(page)
+
+ cursor = next_after_cursor(response)
+ if not cursor:
+ break
+ current_path = _set_query(path, {"limit": str(page_size), "after": cursor})
+ return all_items
+
+
+def _set_query(path: str, params: dict) -> str:
+ """Return `path` with the given query params merged in (overriding existing)."""
+ parsed = urlparse(path)
+ qs = dict(parse_qsl(parsed.query))
+ qs.update({k: v for k, v in params.items() if v is not None})
+ return urlunparse(parsed._replace(query=urlencode(qs)))
diff --git a/prowler/providers/okta/okta_provider.py b/prowler/providers/okta/okta_provider.py
index 0a7b0b8e9d..678e9edbd4 100644
--- a/prowler/providers/okta/okta_provider.py
+++ b/prowler/providers/okta/okta_provider.py
@@ -32,7 +32,13 @@ from prowler.providers.okta.exceptions.exceptions import (
from prowler.providers.okta.lib.mutelist.mutelist import OktaMutelist
from prowler.providers.okta.models import OktaIdentityInfo, OktaSession
-DEFAULT_SCOPES = ["okta.policies.read", "okta.brands.read", "okta.apps.read"]
+DEFAULT_SCOPES = [
+ "okta.policies.read",
+ "okta.brands.read",
+ "okta.apps.read",
+ "okta.logStreams.read",
+ "okta.idps.read",
+]
# Accept only Okta-managed domains. Custom (vanity) domains are rejected on
# purpose — they're a recurring source of typos and silent misconfig and
# Prowler's audience overwhelmingly uses Okta-managed hosts. The TLDs below
diff --git a/prowler/providers/okta/services/application/application_service.py b/prowler/providers/okta/services/application/application_service.py
index de2fe0a658..fa6f483021 100644
--- a/prowler/providers/okta/services/application/application_service.py
+++ b/prowler/providers/okta/services/application/application_service.py
@@ -1,10 +1,13 @@
-import json
from typing import Optional
-from urllib.parse import parse_qs, urlparse
+from urllib.parse import urlparse
from pydantic import BaseModel, ValidationError
from prowler.lib.logger import logger
+from prowler.providers.okta.lib.service.pagination import paginate as _paginate_shared
+from prowler.providers.okta.lib.service.raw_fetch import (
+ get_json_paginated as _raw_get_json_paginated,
+)
from prowler.providers.okta.lib.service.service import OktaService
# These three keys are Okta-platform constants, not tenant-configurable:
@@ -28,29 +31,6 @@ DASHBOARD_APP_NAME = "okta_enduser"
ADMIN_CONSOLE_FIRST_PARTY_APP_KEY = "admin-console"
-def _next_after_cursor(resp) -> Optional[str]:
- """Extract the `after` cursor from a `Link: ...; rel="next"` header.
-
- Returns None when there is no next page. Header format follows RFC 5988
- and Okta's pagination guide. Mirrors the helper in `signon_service` —
- duplicated rather than shared until a third Okta service appears.
- """
- if resp is None:
- return None
- headers = getattr(resp, "headers", None) or {}
- link = headers.get("link") or headers.get("Link") or ""
- if not link:
- return None
- for part in link.split(","):
- if 'rel="next"' not in part:
- continue
- url_segment = part.split(";", 1)[0].strip().lstrip("<").rstrip(">")
- cursor = parse_qs(urlparse(url_segment).query).get("after", [None])[0]
- if cursor:
- return cursor
- return None
-
-
REQUIRED_SCOPES: dict[str, str] = {
"admin_console_app_settings": "okta.apps.read",
"built_in_apps": "okta.apps.read",
@@ -321,69 +301,24 @@ class Application(OktaService):
"""Raw-JSON fallback for `list_policy_rules`.
Bypasses the Okta SDK's typed deserialization by calling the
- request executor directly without a response type. The response
- body is then `json.loads`-ed and projected onto our own pydantic
- snapshot, which only validates the fields the STIG checks
- actually read. This keeps the checks evaluable on tenants where
- the Management API returns values the SDK validators reject.
+ request executor directly via the shared `get_json_paginated`
+ helper, which follows `Link: rel=next` so policies with more
+ rules than `rule_fetch_limit` are not silently truncated.
+ Projects the response onto our own pydantic snapshot which only
+ validates the fields the STIG checks actually read. This keeps
+ the checks evaluable on tenants where the Management API returns
+ values the SDK validators reject.
"""
- request, error = await self.client._request_executor.create_request(
- method="GET",
- url=f"/api/v1/policies/{policy_id}/rules?limit={rule_fetch_limit}",
- body=None,
- headers={"Accept": "application/json"},
+ rules_data = await _raw_get_json_paginated(
+ self.client,
+ f"/api/v1/policies/{policy_id}/rules",
+ page_size=rule_fetch_limit,
+ context=f"access policy {policy_id} rules",
)
- if error is not None:
- logger.error(
- f"Raw rules fetch (create_request) failed for {policy_id}: {error}"
- )
+ if rules_data is None:
return AuthenticationPolicy(
id=policy_id, name="", status="", is_default=False, rules=[]
)
-
- _response, response_body, error = await self.client._request_executor.execute(
- request
- )
- if error is not None:
- logger.error(f"Raw rules fetch (execute) failed for {policy_id}: {error}")
- return AuthenticationPolicy(
- id=policy_id, name="", status="", is_default=False, rules=[]
- )
-
- if isinstance(response_body, (bytes, bytearray)):
- try:
- response_body = response_body.decode("utf-8")
- except UnicodeDecodeError as decode_err:
- logger.error(
- f"Could not decode rules response for {policy_id}: {decode_err}"
- )
- return AuthenticationPolicy(
- id=policy_id, name="", status="", is_default=False, rules=[]
- )
- try:
- rules_data = json.loads(response_body) if response_body else []
- except json.JSONDecodeError as decode_err:
- logger.error(f"Could not parse rules JSON for {policy_id}: {decode_err}")
- return AuthenticationPolicy(
- id=policy_id, name="", status="", is_default=False, rules=[]
- )
-
- if not isinstance(rules_data, list):
- logger.error(
- f"Unexpected raw rules payload shape for {policy_id}: "
- f"got {type(rules_data).__name__}, expected list"
- )
- return AuthenticationPolicy(
- id=policy_id, name="", status="", is_default=False, rules=[]
- )
-
- if len(rules_data) >= rule_fetch_limit:
- logger.warning(
- f"Access policy {policy_id} returned {len(rules_data)} rules "
- f"via raw-JSON fallback — the per-policy fetch limit "
- f"({rule_fetch_limit}) was hit; any rules beyond this limit "
- "are not evaluated by Prowler."
- )
rules_out = [_raw_rule_to_model(rule) for rule in rules_data]
return AuthenticationPolicy(
id=policy_id, name="", status="", is_default=False, rules=rules_out
@@ -391,33 +326,7 @@ class Application(OktaService):
@staticmethod
async def _paginate(fetch):
- """Drain all pages of an SDK list call.
-
- `fetch` is a callable taking the `after` cursor (or None) and
- returning the SDK's `(items, resp, err)` tuple. Follows the
- `Link: rel="next"` header until exhausted. Mirrors the helper in
- `signon_service`.
- """
- all_items = []
- result = await fetch(None)
- err = result[-1]
- if err is not None:
- return [], err
- items = result[0]
- resp = result[1] if len(result) >= 3 else None
- all_items.extend(items or [])
- while True:
- cursor = _next_after_cursor(resp)
- if not cursor:
- break
- result = await fetch(cursor)
- err = result[-1]
- if err is not None:
- return all_items, err
- items = result[0]
- resp = result[1] if len(result) >= 3 else None
- all_items.extend(items or [])
- return all_items, None
+ return await _paginate_shared(fetch)
def _policy_id_from_href(href: Optional[str]) -> Optional[str]:
diff --git a/prowler/providers/okta/services/idp/__init__.py b/prowler/providers/okta/services/idp/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/okta/services/idp/idp_client.py b/prowler/providers/okta/services/idp/idp_client.py
new file mode 100644
index 0000000000..5bbf98c17a
--- /dev/null
+++ b/prowler/providers/okta/services/idp/idp_client.py
@@ -0,0 +1,4 @@
+from prowler.providers.common.provider import Provider
+from prowler.providers.okta.services.idp.idp_service import Idp
+
+idp_client = Idp(Provider.get_global_provider())
diff --git a/prowler/providers/okta/services/idp/idp_service.py b/prowler/providers/okta/services/idp/idp_service.py
new file mode 100644
index 0000000000..2ff106cb47
--- /dev/null
+++ b/prowler/providers/okta/services/idp/idp_service.py
@@ -0,0 +1,118 @@
+from typing import Optional
+
+from pydantic import BaseModel
+
+from prowler.lib.logger import logger
+from prowler.providers.okta.lib.service.pagination import paginate
+from prowler.providers.okta.lib.service.service import OktaService
+
+# Okta's API value for the "Smart Card" IdP shown in the Admin Console.
+# The UI label is "Smart Card IdP" but the `type` field on the API response
+# is `X509` (Mutual TLS) — that is the value we filter on.
+SMART_CARD_IDP_TYPE = "X509"
+
+REQUIRED_SCOPES: dict[str, str] = {
+ "identity_providers": "okta.idps.read",
+}
+
+
+class Idp(OktaService):
+ """Fetches Okta Identity Providers.
+
+ Populates `self.identity_providers` keyed by IdP id. Each entry
+ captures the minimum fields the bundled checks read: identity
+ (`id`, `name`), `type`, `status`, and — for `X509` Smart Card IdPs
+ — the certificate-chain `issuer` and `kid` exposed by Okta's
+ `protocol.credentials.trust` structure. Reading the issuer DN lets
+ the check surface it for out-of-band verification against the
+ DOD-approved CA list.
+
+ Required OAuth scopes (`REQUIRED_SCOPES`) are compared against the
+ access token's granted scopes (`provider.identity.granted_scopes`).
+ Missing scopes are recorded in `self.missing_scope` so the check
+ can emit an explicit MANUAL finding.
+ """
+
+ def __init__(self, provider):
+ super().__init__(__class__.__name__, provider)
+ granted = set(getattr(provider.identity, "granted_scopes", None) or [])
+ self.missing_scope: dict[str, Optional[str]] = {
+ resource: (scope if granted and scope not in granted else None)
+ for resource, scope in REQUIRED_SCOPES.items()
+ }
+
+ self.identity_providers: dict[str, OktaIdentityProvider] = (
+ {}
+ if self.missing_scope["identity_providers"]
+ else self._list_identity_providers()
+ )
+
+ def _list_identity_providers(self) -> dict:
+ logger.info("Idp - Listing Okta Identity Providers...")
+ try:
+ return self._run(self._fetch_identity_providers())
+ except Exception as error:
+ logger.error(
+ f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+ return {}
+
+ async def _fetch_identity_providers(self) -> dict:
+ result: dict[str, OktaIdentityProvider] = {}
+ all_idps, err = await paginate(
+ lambda after: self.client.list_identity_providers(after=after)
+ )
+ if err is not None:
+ logger.error(f"Error listing identity providers: {err}")
+ return result
+
+ for idp in all_idps:
+ idp_id = getattr(idp, "id", "") or ""
+ if not idp_id:
+ continue
+ issuer, kid = _trust_fields(idp)
+ result[idp_id] = OktaIdentityProvider(
+ id=idp_id,
+ name=getattr(idp, "name", "") or "",
+ type=_stringify_enum(getattr(idp, "type", None)) or "",
+ status=_stringify_enum(getattr(idp, "status", None)) or "",
+ trust_issuer=issuer,
+ trust_kid=kid,
+ )
+ return result
+
+
+def _trust_fields(idp) -> tuple[Optional[str], Optional[str]]:
+ """Extract `issuer` and `kid` from an `X509` IdP's protocol.credentials.trust.
+
+ The SDK exposes `IdentityProvider.protocol` as `IdentityProviderProtocol`,
+ a Pydantic v2 oneOf wrapper that holds the concrete protocol (ProtocolMtls
+ for X509 IdPs) on `actual_instance`. `credentials` is not proxied on the
+ wrapper, so reading it directly returns None — we have to unwrap first.
+ """
+ protocol = getattr(idp, "protocol", None)
+ if protocol is None:
+ return None, None
+ actual_protocol = getattr(protocol, "actual_instance", None) or protocol
+ credentials = getattr(actual_protocol, "credentials", None)
+ if credentials is None:
+ return None, None
+ trust = getattr(credentials, "trust", None)
+ if trust is None:
+ return None, None
+ return getattr(trust, "issuer", None), getattr(trust, "kid", None)
+
+
+def _stringify_enum(value) -> Optional[str]:
+ if value is None:
+ return None
+ return getattr(value, "value", None) or str(value)
+
+
+class OktaIdentityProvider(BaseModel):
+ id: str
+ name: str = ""
+ type: str = ""
+ status: str = ""
+ trust_issuer: Optional[str] = None
+ trust_kid: Optional[str] = None
diff --git a/prowler/providers/okta/services/idp/idp_smart_card_dod_approved_ca/__init__.py b/prowler/providers/okta/services/idp/idp_smart_card_dod_approved_ca/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/okta/services/idp/idp_smart_card_dod_approved_ca/idp_smart_card_dod_approved_ca.metadata.json b/prowler/providers/okta/services/idp/idp_smart_card_dod_approved_ca/idp_smart_card_dod_approved_ca.metadata.json
new file mode 100644
index 0000000000..e7e85294be
--- /dev/null
+++ b/prowler/providers/okta/services/idp/idp_smart_card_dod_approved_ca/idp_smart_card_dod_approved_ca.metadata.json
@@ -0,0 +1,37 @@
+{
+ "Provider": "okta",
+ "CheckID": "idp_smart_card_dod_approved_ca",
+ "CheckTitle": "Okta Smart Card (X509) Identity Provider uses a DOD-approved certificate authority",
+ "CheckType": [],
+ "ServiceName": "idp",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "medium",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "IAM",
+ "Description": "Every Okta Smart Card (X509) Identity Provider must be `ACTIVE` and its certificate chain must be issued by a DOD-approved CA. The check ships default issuer-DN patterns covering DOD PKI and ECA, and matches them against the chain's `issuer`. Override or extend via `okta_dod_approved_ca_issuer_patterns` in the audit config to recognise tenant-specific DOD CAs.",
+ "Risk": "An Okta Smart Card IdP whose certificate chain is not issued by a DOD-approved CA can be used to authenticate non-vetted identities.\n\n- **Trust on an unverified CA** allows impersonation of CAC/PIV holders\n- **Bypass of the federal PKI** required for DOD-grade identity assurance\n- **Acceptance of certificates** from a private or unaccredited issuer",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://help.okta.com/en-us/content/topics/security/idp-enable-smart-card.htm",
+ "https://developer.okta.com/docs/api/openapi/okta-management/management/tag/IdentityProvider/"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "",
+ "NativeIaC": "",
+ "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Identity Providers**.\n3. For each IdP whose **Type** is **Smart Card**, click **Actions** > **Configure**.\n4. Under **Certificate chain**, verify the certificate is from a DOD-approved Certificate Authority (DOD PKI, ECA, JITC, or equivalent).\n5. If the IdP is not **Active**, activate it once the chain is validated.",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Verify each Okta Smart Card (X509) Identity Provider is ACTIVE and its certificate chain is issued by a DOD-approved Certificate Authority. Document the issuer for audit evidence.",
+ "Url": "https://hub.prowler.com/check/idp_smart_card_dod_approved_ca"
+ }
+ },
+ "Categories": [
+ "identity-access"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": "Aligns with DISA STIG V-273207 / OKTA-APP-001920."
+}
diff --git a/prowler/providers/okta/services/idp/idp_smart_card_dod_approved_ca/idp_smart_card_dod_approved_ca.py b/prowler/providers/okta/services/idp/idp_smart_card_dod_approved_ca/idp_smart_card_dod_approved_ca.py
new file mode 100644
index 0000000000..5d19cca0dd
--- /dev/null
+++ b/prowler/providers/okta/services/idp/idp_smart_card_dod_approved_ca/idp_smart_card_dod_approved_ca.py
@@ -0,0 +1,148 @@
+import re
+
+from prowler.lib.check.models import Check, CheckReportOkta
+from prowler.providers.okta.services.idp.idp_client import idp_client
+from prowler.providers.okta.services.idp.idp_service import (
+ SMART_CARD_IDP_TYPE,
+ OktaIdentityProvider,
+)
+from prowler.providers.okta.services.idp.lib.idp_helpers import (
+ missing_idps_scope_finding,
+)
+
+# Default issuer-DN substring patterns recognised as DOD-approved Certificate
+# Authorities. The DOD PKI publishes canonical DN forms that include
+# `O=U.S. Government, OU=DoD` (for DoD Root, DoD ID, DoD EMAIL, DoD SW, DoD
+# JITC CAs) and `O=U.S. Government, OU=ECA` for the External Certificate
+# Authorities. Customers running an internal CA outside these patterns can
+# extend the list via the `okta_dod_approved_ca_issuer_patterns` audit-config
+# entry — see the per-check Notes in metadata.json.
+DEFAULT_DOD_CA_ISSUER_PATTERNS = (
+ # `OU=DoD` is the distinctive DISA DN component for every CA in the DoD
+ # PKI (Root, ID, EMAIL, SW, JITC). `OU=ECA` is the equivalent for the
+ # External Certificate Authorities. The trailing `\b` prevents accidental
+ # matches against superstrings like `OU=DoDExtra`.
+ r"\bOU=DoD\b",
+ r"\bOU=ECA\b",
+)
+
+
+class idp_smart_card_dod_approved_ca(Check):
+ """Verifies that Okta Smart Card (X509) IdPs are configured and use a DOD-approved CA.
+
+ PASS when the IdP is `ACTIVE` and its certificate chain's `issuer`
+ DN matches one of the configured DOD-approved CA patterns. MANUAL
+ when active but the issuer doesn't match (operator can verify
+ out-of-band or extend the pattern list). FAIL when no Smart Card
+ IdP is configured or when the configured IdP is inactive.
+ """
+
+ def execute(self) -> list[CheckReportOkta]:
+ findings: list[CheckReportOkta] = []
+ org_domain = idp_client.provider.identity.org_domain
+ audit_config = idp_client.audit_config or {}
+ configured_patterns = audit_config.get("okta_dod_approved_ca_issuer_patterns")
+ patterns = (
+ tuple(configured_patterns)
+ if configured_patterns
+ else DEFAULT_DOD_CA_ISSUER_PATTERNS
+ )
+
+ missing_scope = idp_client.missing_scope.get("identity_providers")
+ if missing_scope:
+ findings.append(
+ missing_idps_scope_finding(self.metadata(), org_domain, missing_scope)
+ )
+ return findings
+
+ smart_card_idps = [
+ idp
+ for idp in idp_client.identity_providers.values()
+ if (idp.type or "").upper() == SMART_CARD_IDP_TYPE
+ ]
+
+ if not smart_card_idps:
+ placeholder = OktaIdentityProvider(
+ id="okta-smart-card-idp-missing",
+ name="(no Smart Card IdP configured)",
+ type=SMART_CARD_IDP_TYPE,
+ status="MISSING",
+ )
+ report = CheckReportOkta(
+ metadata=self.metadata(), resource=placeholder, org_domain=org_domain
+ )
+ report.status = "FAIL"
+ report.status_extended = (
+ "No Smart Card (X509) Identity Providers are configured. "
+ "Configure a Smart Card IdP in the Admin Console "
+ "(Security > Identity Providers) with a certificate chain "
+ "issued by a DOD-approved CA. If CAC/PIV authentication is "
+ "not required for this tenant, mutelist this check with "
+ "that documented exception."
+ )
+ findings.append(report)
+ return findings
+
+ for idp in smart_card_idps:
+ report = CheckReportOkta(
+ metadata=self.metadata(), resource=idp, org_domain=org_domain
+ )
+ label = f"Okta Smart Card IdP '{idp.name}' (id={idp.id}, type={idp.type})"
+ chain_detail = _format_chain_detail(idp)
+
+ if (idp.status or "").upper() != "ACTIVE":
+ report.status = "FAIL"
+ report.status_extended = (
+ f"{label} is not ACTIVE (status={idp.status or 'unset'}). "
+ "Activate the IdP from Security > Identity Providers, then "
+ f"verify the certificate chain. {chain_detail}"
+ )
+ findings.append(report)
+ continue
+
+ matched_pattern = _matched_issuer_pattern(idp.trust_issuer, patterns)
+ if matched_pattern is not None:
+ report.status = "PASS"
+ report.status_extended = (
+ f"{label} is ACTIVE and its chain issuer matches a "
+ f"DOD-approved CA pattern (`{matched_pattern}`). "
+ f"{chain_detail}"
+ )
+ else:
+ report.status = "MANUAL"
+ report.status_extended = (
+ f"{label} is ACTIVE but its chain issuer does not match any "
+ "configured DOD-approved CA pattern. Verify out-of-band "
+ "that the certificate chain belongs to a DOD-approved "
+ "Certificate Authority, or extend "
+ "`okta_dod_approved_ca_issuer_patterns` in the audit "
+ f"config. {chain_detail}"
+ )
+ findings.append(report)
+ return findings
+
+
+def _matched_issuer_pattern(issuer, patterns):
+ if not issuer:
+ return None
+ for pattern in patterns:
+ try:
+ if re.search(pattern, issuer):
+ return pattern
+ except re.error:
+ # Skip malformed operator-supplied patterns rather than crashing
+ # the whole check.
+ continue
+ return None
+
+
+def _format_chain_detail(idp: OktaIdentityProvider) -> str:
+ if idp.trust_issuer or idp.trust_kid:
+ return (
+ f"Chain issuer: {idp.trust_issuer or 'unset'}; "
+ f"kid: {idp.trust_kid or 'unset'}."
+ )
+ return (
+ "Chain issuer and kid were not exposed by the API; inspect the IdP in "
+ "the Admin Console under Security > Identity Providers > Configure."
+ )
diff --git a/prowler/providers/okta/services/idp/lib/__init__.py b/prowler/providers/okta/services/idp/lib/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/okta/services/idp/lib/idp_helpers.py b/prowler/providers/okta/services/idp/lib/idp_helpers.py
new file mode 100644
index 0000000000..f1689f34a1
--- /dev/null
+++ b/prowler/providers/okta/services/idp/lib/idp_helpers.py
@@ -0,0 +1,26 @@
+"""Shared helpers for the OKTA idp STIG checks."""
+
+from prowler.lib.check.models import CheckReportOkta
+from prowler.providers.okta.services.idp.idp_service import OktaIdentityProvider
+
+
+def missing_idps_scope_finding(
+ metadata, org_domain: str, scope: str
+) -> CheckReportOkta:
+ """Build the MANUAL finding when the IdPs scope is not granted."""
+ placeholder = OktaIdentityProvider(
+ id="okta-idps-scope-missing",
+ name="(scope not granted)",
+ status="MISSING",
+ )
+ report = CheckReportOkta(
+ metadata=metadata, resource=placeholder, org_domain=org_domain
+ )
+ report.status = "MANUAL"
+ report.status_extended = (
+ "Could not retrieve Okta Identity Providers: the Okta service app is "
+ f"missing the required `{scope}` API scope. Grant it on the service "
+ "app's Okta API Scopes tab in the Okta Admin Console, then re-run the "
+ "check."
+ )
+ return report
diff --git a/prowler/providers/okta/services/signon/signon_service.py b/prowler/providers/okta/services/signon/signon_service.py
index 7c32363501..663e9cf187 100644
--- a/prowler/providers/okta/services/signon/signon_service.py
+++ b/prowler/providers/okta/services/signon/signon_service.py
@@ -1,34 +1,11 @@
from typing import Optional
-from urllib.parse import parse_qs, urlparse
from pydantic import BaseModel
from prowler.lib.logger import logger
+from prowler.providers.okta.lib.service.pagination import paginate as _paginate_shared
from prowler.providers.okta.lib.service.service import OktaService
-
-def _next_after_cursor(resp) -> Optional[str]:
- """Extract the `after` cursor from a `Link: ...; rel="next"` header.
-
- Returns None when there is no next page. Header format follows RFC 5988
- and Okta's pagination guide.
- """
- if resp is None:
- return None
- headers = getattr(resp, "headers", None) or {}
- link = headers.get("link") or headers.get("Link") or ""
- if not link:
- return None
- for part in link.split(","):
- if 'rel="next"' not in part:
- continue
- url_segment = part.split(";", 1)[0].strip().lstrip("<").rstrip(">")
- cursor = parse_qs(urlparse(url_segment).query).get("after", [None])[0]
- if cursor:
- return cursor
- return None
-
-
REQUIRED_SCOPES: dict[str, str] = {
"global_session_policies": "okta.policies.read",
"sign_in_pages": "okta.brands.read",
@@ -228,33 +205,7 @@ class Signon(OktaService):
@staticmethod
async def _paginate(fetch):
- """Drain all pages of an SDK list call.
-
- `fetch` is a callable that takes the `after` cursor (or None for
- the first page) and returns the SDK's standard `(items, resp, err)`
- tuple. We follow `Link: rel="next"` headers until exhausted.
- """
- all_items = []
- result = await fetch(None)
- # Defensive against the SDK's 2-tuple early-error path: error is last.
- err = result[-1]
- if err is not None:
- return [], err
- items = result[0]
- resp = result[1] if len(result) >= 3 else None
- all_items.extend(items or [])
- while True:
- cursor = _next_after_cursor(resp)
- if not cursor:
- break
- result = await fetch(cursor)
- err = result[-1]
- if err is not None:
- return all_items, err
- items = result[0]
- resp = result[1] if len(result) >= 3 else None
- all_items.extend(items or [])
- return all_items, None
+ return await _paginate_shared(fetch)
class GlobalSessionPolicyRule(BaseModel):
diff --git a/prowler/providers/okta/services/systemlog/__init__.py b/prowler/providers/okta/services/systemlog/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/okta/services/systemlog/lib/__init__.py b/prowler/providers/okta/services/systemlog/lib/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/okta/services/systemlog/lib/systemlog_helpers.py b/prowler/providers/okta/services/systemlog/lib/systemlog_helpers.py
new file mode 100644
index 0000000000..e82cedff5f
--- /dev/null
+++ b/prowler/providers/okta/services/systemlog/lib/systemlog_helpers.py
@@ -0,0 +1,26 @@
+"""Shared helpers for the OKTA systemlog STIG checks."""
+
+from prowler.lib.check.models import CheckReportOkta
+from prowler.providers.okta.services.systemlog.systemlog_service import LogStream
+
+
+def missing_log_streams_scope_finding(
+ metadata, org_domain: str, scope: str
+) -> CheckReportOkta:
+ """Build the MANUAL finding when the log-streams scope is not granted."""
+ placeholder = LogStream(
+ id="okta-log-streams-scope-missing",
+ name="(scope not granted)",
+ status="MISSING",
+ type="",
+ )
+ report = CheckReportOkta(
+ metadata=metadata, resource=placeholder, org_domain=org_domain
+ )
+ report.status = "MANUAL"
+ report.status_extended = (
+ "Could not retrieve Okta Log Streams: the Okta service app is missing "
+ f"the required `{scope}` API scope. Grant it on the service app's "
+ "Okta API Scopes tab in the Okta Admin Console, then re-run the check."
+ )
+ return report
diff --git a/prowler/providers/okta/services/systemlog/systemlog_client.py b/prowler/providers/okta/services/systemlog/systemlog_client.py
new file mode 100644
index 0000000000..3dc0a1a0af
--- /dev/null
+++ b/prowler/providers/okta/services/systemlog/systemlog_client.py
@@ -0,0 +1,4 @@
+from prowler.providers.common.provider import Provider
+from prowler.providers.okta.services.systemlog.systemlog_service import SystemLog
+
+systemlog_client = SystemLog(Provider.get_global_provider())
diff --git a/prowler/providers/okta/services/systemlog/systemlog_service.py b/prowler/providers/okta/services/systemlog/systemlog_service.py
new file mode 100644
index 0000000000..96c8b6d7ae
--- /dev/null
+++ b/prowler/providers/okta/services/systemlog/systemlog_service.py
@@ -0,0 +1,136 @@
+from typing import Optional
+
+from pydantic import BaseModel, ValidationError
+
+from prowler.lib.logger import logger
+from prowler.providers.okta.lib.service.pagination import paginate
+from prowler.providers.okta.lib.service.raw_fetch import (
+ get_json_paginated as raw_get_json_paginated,
+)
+from prowler.providers.okta.lib.service.service import OktaService
+
+REQUIRED_SCOPES: dict[str, str] = {
+ "log_streams": "okta.logStreams.read",
+}
+
+
+class SystemLog(OktaService):
+ """Fetches Okta Log Stream configurations.
+
+ Populates `self.log_streams` keyed by Log Stream id. Each entry
+ carries `name`, `status`, `type` — enough for the streaming-enabled
+ check to evaluate whether the tenant has off-loaded audit records
+ to an external SIEM/event bus.
+
+ Required OAuth scopes (`REQUIRED_SCOPES`) are compared against the
+ access token's granted scopes (`provider.identity.granted_scopes`).
+ Missing scopes are recorded in `self.missing_scope` so the check
+ can emit an explicit MANUAL finding instead of a misleading
+ "no resources returned".
+ """
+
+ def __init__(self, provider):
+ super().__init__(__class__.__name__, provider)
+ granted = set(getattr(provider.identity, "granted_scopes", None) or [])
+ self.missing_scope: dict[str, Optional[str]] = {
+ resource: (scope if granted and scope not in granted else None)
+ for resource, scope in REQUIRED_SCOPES.items()
+ }
+
+ self.log_streams: dict[str, LogStream] = (
+ {} if self.missing_scope["log_streams"] else self._list_log_streams()
+ )
+
+ def _list_log_streams(self) -> dict:
+ logger.info("SystemLog - Listing Okta Log Streams...")
+ try:
+ return self._run(self._fetch_log_streams())
+ except Exception as error:
+ logger.error(
+ f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+ return {}
+
+ async def _fetch_log_streams(self) -> dict:
+ result: dict[str, LogStream] = {}
+ try:
+ all_streams, err = await paginate(
+ lambda after: self.client.list_log_streams(after=after)
+ )
+ except ValidationError as ve:
+ # Upstream okta-sdk-python bug: e.g. `LogStreamSettingsAws`'s
+ # `eventSourceName` validator regex is `^[a-zA-Z0-9.\-_]$` —
+ # missing the `+` quantifier, so it rejects every
+ # multi-character name. Fall back to raw JSON so the check
+ # can still evaluate the tenant's actual log-stream state.
+ # Remove this workaround once okta-sdk-python fixes the
+ # validator (issue to be filed upstream).
+ logger.warning(
+ f"Okta SDK raised ValidationError parsing log streams "
+ f"({ve.error_count()} error(s)) — falling back to raw-JSON "
+ "parse. This is an okta-sdk-python deserialization bug."
+ )
+ return await self._fetch_log_streams_raw()
+
+ if err is not None:
+ logger.error(f"Error listing log streams: {err}")
+ return result
+
+ for stream in all_streams:
+ stream_id = getattr(stream, "id", "") or ""
+ if not stream_id:
+ continue
+ result[stream_id] = LogStream(
+ id=stream_id,
+ name=getattr(stream, "name", "") or "",
+ status=getattr(stream, "status", "") or "",
+ type=_stringify_enum(getattr(stream, "type", None)) or "",
+ )
+ return result
+
+ async def _fetch_log_streams_raw(self) -> dict:
+ """Raw-JSON fallback for `list_log_streams`.
+
+ Bypasses the SDK's typed deserialization via the shared
+ `get_json_paginated` helper (which follows the `Link: rel=next`
+ cursor so tenants with >200 streams are not silently truncated),
+ and projects the response onto our own pydantic snapshot which
+ only validates the four fields the check reads. Keeps the check
+ evaluable on tenants whose Log Stream settings happen to trip
+ an SDK enum/regex validator.
+ """
+ result: dict[str, LogStream] = {}
+ data = await raw_get_json_paginated(
+ self.client,
+ "/api/v1/logStreams",
+ page_size=200,
+ context="log streams",
+ )
+ if data is None:
+ return result
+ for item in data:
+ if not isinstance(item, dict):
+ continue
+ stream_id = item.get("id")
+ if not stream_id:
+ continue
+ result[stream_id] = LogStream(
+ id=stream_id,
+ name=item.get("name") or "",
+ status=(item.get("status") or "").upper(),
+ type=item.get("type") or "",
+ )
+ return result
+
+
+def _stringify_enum(value) -> Optional[str]:
+ if value is None:
+ return None
+ return getattr(value, "value", None) or str(value)
+
+
+class LogStream(BaseModel):
+ id: str
+ name: str = ""
+ status: str = ""
+ type: str = ""
diff --git a/prowler/providers/okta/services/systemlog/systemlog_streaming_enabled/__init__.py b/prowler/providers/okta/services/systemlog/systemlog_streaming_enabled/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/okta/services/systemlog/systemlog_streaming_enabled/systemlog_streaming_enabled.metadata.json b/prowler/providers/okta/services/systemlog/systemlog_streaming_enabled/systemlog_streaming_enabled.metadata.json
new file mode 100644
index 0000000000..27541ee2fe
--- /dev/null
+++ b/prowler/providers/okta/services/systemlog/systemlog_streaming_enabled/systemlog_streaming_enabled.metadata.json
@@ -0,0 +1,37 @@
+{
+ "Provider": "okta",
+ "CheckID": "systemlog_streaming_enabled",
+ "CheckTitle": "Okta off-loads audit records to a central log server via Log Streaming",
+ "CheckType": [],
+ "ServiceName": "systemlog",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "high",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "monitoring",
+ "Description": "Okta must off-load audit records to a central log server. At least one **Log Stream** (AWS EventBridge, Splunk Cloud, etc.) must be configured and `ACTIVE` in the tenant. Alternatively, an external SIEM pulling the System Log API can satisfy the requirement, but that pull-based path is verified manually.",
+ "Risk": "Audit records stored only inside the Okta tenant are exposed to accidental or incidental deletion or alteration.\n\n- **No central retention** of authentication events for incident investigations\n- **Single point of failure** for the audit trail\n- **No correlation** with other identity, network, and endpoint telemetry in the SIEM",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://help.okta.com/en-us/content/topics/reports/log-streaming/about-log-streams.htm",
+ "https://developer.okta.com/docs/api/openapi/okta-management/management/tag/LogStream/"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "",
+ "NativeIaC": "",
+ "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Reports** > **Log Streaming**.\n3. Click **Add Log Stream** and select **AWS EventBridge**, **Splunk Cloud**, or another supported destination.\n4. Complete the connection fields and save.\n5. Activate the stream and verify the destination receives events.\n6. If the destination SIEM is not natively supported, document the pull-based ingestion that uses the System Log API.",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Configure at least one ACTIVE Okta Log Stream that off-loads audit records to a central SIEM (AWS EventBridge, Splunk Cloud, or another supported destination). Document any alternative pull-based ingestion via the System Log API.",
+ "Url": "https://hub.prowler.com/check/systemlog_streaming_enabled"
+ }
+ },
+ "Categories": [
+ "logging"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": "Aligns with DISA STIG V-273202 / OKTA-APP-001430."
+}
diff --git a/prowler/providers/okta/services/systemlog/systemlog_streaming_enabled/systemlog_streaming_enabled.py b/prowler/providers/okta/services/systemlog/systemlog_streaming_enabled/systemlog_streaming_enabled.py
new file mode 100644
index 0000000000..61448aeaf5
--- /dev/null
+++ b/prowler/providers/okta/services/systemlog/systemlog_streaming_enabled/systemlog_streaming_enabled.py
@@ -0,0 +1,88 @@
+from prowler.lib.check.models import Check, CheckReportOkta
+from prowler.providers.okta.services.systemlog.lib.systemlog_helpers import (
+ missing_log_streams_scope_finding,
+)
+from prowler.providers.okta.services.systemlog.systemlog_client import systemlog_client
+from prowler.providers.okta.services.systemlog.systemlog_service import LogStream
+
+
+class systemlog_streaming_enabled(Check):
+ """Verifies that at least one Okta Log Stream is configured and active.
+
+ Off-loading audit records to a central SIEM (AWS EventBridge, Splunk
+ Cloud, etc.) is the standard mechanism for centralised retention.
+ An alternative path — pulling the System Log API into an external
+ SIEM — is allowed by the requirement, but cannot be verified
+ automatically; this check emits a MANUAL note in that case.
+ """
+
+ def execute(self) -> list[CheckReportOkta]:
+ findings: list[CheckReportOkta] = []
+ org_domain = systemlog_client.provider.identity.org_domain
+
+ missing_scope = systemlog_client.missing_scope.get("log_streams")
+ if missing_scope:
+ findings.append(
+ missing_log_streams_scope_finding(
+ self.metadata(), org_domain, missing_scope
+ )
+ )
+ return findings
+
+ active_streams = [
+ stream
+ for stream in systemlog_client.log_streams.values()
+ if not stream.status or stream.status.upper() == "ACTIVE"
+ ]
+
+ if not systemlog_client.log_streams:
+ placeholder = LogStream(
+ id="okta-log-streams-missing",
+ name="(no Log Streams configured)",
+ status="MISSING",
+ type="",
+ )
+ report = CheckReportOkta(
+ metadata=self.metadata(), resource=placeholder, org_domain=org_domain
+ )
+ report.status = "FAIL"
+ report.status_extended = (
+ "No Okta Log Streams are configured. Configure a Log Stream "
+ "(Reports > Log Streaming) to off-load audit records to a "
+ "central SIEM. If an external SIEM is already pulling logs "
+ "via the System Log API, mutelist this check with that "
+ "evidence."
+ )
+ findings.append(report)
+ return findings
+
+ if not active_streams:
+ placeholder = LogStream(
+ id="okta-log-streams-inactive",
+ name="(no active Log Streams)",
+ status="INACTIVE",
+ type="",
+ )
+ report = CheckReportOkta(
+ metadata=self.metadata(), resource=placeholder, org_domain=org_domain
+ )
+ report.status = "FAIL"
+ report.status_extended = (
+ f"{len(systemlog_client.log_streams)} Okta Log Stream(s) are "
+ "configured but none are ACTIVE. Activate a Log Stream to "
+ "off-load audit records to a central SIEM."
+ )
+ findings.append(report)
+ return findings
+
+ for stream in active_streams:
+ report = CheckReportOkta(
+ metadata=self.metadata(), resource=stream, org_domain=org_domain
+ )
+ report.status = "PASS"
+ report.status_extended = (
+ f"Okta Log Stream '{stream.name}' (type={stream.type or 'unset'}) "
+ "is ACTIVE and off-loads audit records to a central SIEM."
+ )
+ findings.append(report)
+ return findings
diff --git a/prowler/providers/okta/services/user/__init__.py b/prowler/providers/okta/services/user/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/okta/services/user/lib/__init__.py b/prowler/providers/okta/services/user/lib/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/okta/services/user/lib/user_helpers.py b/prowler/providers/okta/services/user/lib/user_helpers.py
new file mode 100644
index 0000000000..423801f2d8
--- /dev/null
+++ b/prowler/providers/okta/services/user/lib/user_helpers.py
@@ -0,0 +1,26 @@
+"""Shared helpers for the OKTA user STIG checks."""
+
+from prowler.lib.check.models import CheckReportOkta
+from prowler.providers.okta.services.user.user_service import UserAutomation
+
+
+def missing_user_scope_finding(
+ metadata, org_domain: str, scope: str
+) -> CheckReportOkta:
+ """Build the MANUAL finding when an OAuth scope is not granted."""
+ placeholder = UserAutomation(
+ id="okta-user-scope-missing",
+ name="(scope not granted)",
+ status="MISSING",
+ )
+ report = CheckReportOkta(
+ metadata=metadata, resource=placeholder, org_domain=org_domain
+ )
+ report.status = "MANUAL"
+ report.status_extended = (
+ f"Could not retrieve Okta user lifecycle automations: the Okta service "
+ f"app is missing the required `{scope}` API scope. Grant it on the "
+ "service app's Okta API Scopes tab in the Okta Admin Console, then "
+ "re-run the check."
+ )
+ return report
diff --git a/prowler/providers/okta/services/user/user_client.py b/prowler/providers/okta/services/user/user_client.py
new file mode 100644
index 0000000000..9a49fbdbac
--- /dev/null
+++ b/prowler/providers/okta/services/user/user_client.py
@@ -0,0 +1,4 @@
+from prowler.providers.common.provider import Provider
+from prowler.providers.okta.services.user.user_service import User
+
+user_client = User(Provider.get_global_provider())
diff --git a/prowler/providers/okta/services/user/user_inactivity_automation_35d_enabled/__init__.py b/prowler/providers/okta/services/user/user_inactivity_automation_35d_enabled/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/okta/services/user/user_inactivity_automation_35d_enabled/user_inactivity_automation_35d_enabled.metadata.json b/prowler/providers/okta/services/user/user_inactivity_automation_35d_enabled/user_inactivity_automation_35d_enabled.metadata.json
new file mode 100644
index 0000000000..64f24d95ae
--- /dev/null
+++ b/prowler/providers/okta/services/user/user_inactivity_automation_35d_enabled/user_inactivity_automation_35d_enabled.metadata.json
@@ -0,0 +1,36 @@
+{
+ "Provider": "okta",
+ "CheckID": "user_inactivity_automation_35d_enabled",
+ "CheckTitle": "Okta automation suspends or deactivates users after 35 days of inactivity",
+ "CheckType": [],
+ "ServiceName": "user",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "medium",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "IAM",
+ "Description": "An Okta **Workflows Automation** must disable inactive user accounts. The automation must be `ACTIVE`, on an `ACTIVE` schedule, evaluate `User Inactivity = 35 days` (or less), apply to a group covering every user, and trigger `Suspended` / `Deactivated` / `Deprovisioned`. Threshold override: `okta_user_inactivity_max_days`. N/A when user sourcing is delegated to Active Directory or LDAP.",
+ "Risk": "Inactive Okta accounts retained indefinitely give an attacker who exploits one undetected access to downstream applications.\n\n- **Account takeover via dormant identities** that no one is monitoring\n- **Lateral movement** through SSO sessions of forgotten users\n- **Stale entitlements** that survive role and policy reorganisations",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://help.okta.com/en-us/content/topics/automation-hooks/automations-main.htm"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "",
+ "NativeIaC": "",
+ "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Workflow** > **Automations** and click **Add Automation**.\n3. Name the automation (e.g., `User Inactivity`).\n4. Add a condition: select **User Inactivity in Okta** and enter `35` days.\n5. Configure the schedule to run daily and activate it.\n6. Apply the automation to a group that covers every user — typically `Everyone`.\n7. Add an action: **Change User lifecycle state in Okta** and choose `Suspended` (or `Deactivated`/`Deprovisioned`).\n8. Activate the automation.",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Create an active Okta Workflows automation that runs daily, evaluates `User Inactivity in Okta = 35 days`, applies to a group covering every user, and changes the user lifecycle state to Suspended/Deactivated. If user sourcing is delegated to Active Directory or LDAP, document that the connected directory enforces this requirement instead.",
+ "Url": "https://hub.prowler.com/check/user_inactivity_automation_35d_enabled"
+ }
+ },
+ "Categories": [
+ "identity-access"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": "Aligns with DISA STIG V-273188 / OKTA-APP-000090."
+}
diff --git a/prowler/providers/okta/services/user/user_inactivity_automation_35d_enabled/user_inactivity_automation_35d_enabled.py b/prowler/providers/okta/services/user/user_inactivity_automation_35d_enabled/user_inactivity_automation_35d_enabled.py
new file mode 100644
index 0000000000..79e77c3f73
--- /dev/null
+++ b/prowler/providers/okta/services/user/user_inactivity_automation_35d_enabled/user_inactivity_automation_35d_enabled.py
@@ -0,0 +1,204 @@
+from prowler.lib.check.models import Check, CheckReportOkta
+from prowler.providers.okta.services.user.lib.user_helpers import (
+ missing_user_scope_finding,
+)
+from prowler.providers.okta.services.user.user_client import user_client
+from prowler.providers.okta.services.user.user_service import UserAutomation
+
+DEFAULT_INACTIVITY_DAYS = 35
+SUSPENSION_LIFECYCLE_ACTIONS = {"SUSPENDED", "DEACTIVATED", "DEPROVISIONED"}
+
+
+class user_inactivity_automation_35d_enabled(Check):
+ """Verifies that Okta suspends/deactivates users after 35 days of inactivity.
+
+ A Workflows Automation must exist with:
+ - status ACTIVE,
+ - schedule active,
+ - condition `User Inactivity in Okta = 35 days`,
+ - action that changes the user state to Suspended / Deactivated,
+ - applied to a group covering every user (typically `Everyone`).
+
+ When user sourcing is delegated to an external directory (Active
+ Directory or LDAP), the requirement is N/A on the Okta side — the
+ connected directory is expected to enforce inactivity-based
+ deactivation instead. Threshold override:
+ `okta_user_inactivity_max_days` in the audit config.
+ """
+
+ def execute(self) -> list[CheckReportOkta]:
+ findings: list[CheckReportOkta] = []
+ audit_config = user_client.audit_config or {}
+ threshold_days = audit_config.get(
+ "okta_user_inactivity_max_days", DEFAULT_INACTIVITY_DAYS
+ )
+ org_domain = user_client.provider.identity.org_domain
+
+ for scope_key in ("automations", "identity_providers"):
+ missing_scope = user_client.missing_scope.get(scope_key)
+ if missing_scope:
+ findings.append(
+ missing_user_scope_finding(
+ self.metadata(), org_domain, missing_scope
+ )
+ )
+ return findings
+
+ # External-directory N/A path.
+ if user_client.external_directory_idps:
+ idp_names = ", ".join(
+ f"'{idp.name}' (type={idp.type})"
+ for idp in user_client.external_directory_idps.values()
+ )
+ placeholder = UserAutomation(
+ id="okta-user-inactivity-na-external-directory",
+ name="(external directory enforces inactivity)",
+ status="N/A",
+ )
+ report = CheckReportOkta(
+ metadata=self.metadata(),
+ resource=placeholder,
+ org_domain=org_domain,
+ )
+ report.status = "MANUAL"
+ report.status_extended = (
+ "User sourcing is delegated to an external directory "
+ f"({idp_names}). The 35-day inactivity disable requirement is "
+ "expected to be enforced by the connected directory rather "
+ "than by an Okta automation. Confirm out-of-band that the "
+ "external directory disables accounts after "
+ f"{threshold_days} days of inactivity."
+ )
+ findings.append(report)
+ return findings
+
+ compliant_automations = [
+ automation
+ for automation in user_client.automations.values()
+ if _is_compliant(automation, threshold_days)
+ ]
+
+ if not user_client.automations:
+ placeholder = UserAutomation(
+ id="okta-user-inactivity-no-automations",
+ name="(no automations configured)",
+ status="MISSING",
+ )
+ report = CheckReportOkta(
+ metadata=self.metadata(),
+ resource=placeholder,
+ org_domain=org_domain,
+ )
+ report.status = "FAIL"
+ report.status_extended = (
+ "No Okta Workflows automations are configured. Create an "
+ "automation that suspends or deactivates users after "
+ f"{threshold_days} days of inactivity, scoped to a group "
+ "covering every user (typically 'Everyone'), with an active "
+ "schedule."
+ )
+ findings.append(report)
+ return findings
+
+ if compliant_automations:
+ for automation in compliant_automations:
+ report = CheckReportOkta(
+ metadata=self.metadata(),
+ resource=automation,
+ org_domain=org_domain,
+ )
+ report.status = "PASS"
+ groups_label = ", ".join(automation.applies_to_groups)
+ report.status_extended = (
+ f"Okta automation '{automation.name}' is ACTIVE with an "
+ f"active schedule, triggers after "
+ f"{automation.inactivity_days} days of inactivity, and "
+ f"changes the user state to "
+ f"{automation.lifecycle_action or 'unset'}. "
+ f"Applied to group(s): {groups_label}. Verify that these "
+ "group(s) cover every user. Okta has no built-in "
+ "'Everyone' group ID, so tenant-wide coverage cannot be "
+ "asserted automatically."
+ )
+ findings.append(report)
+ return findings
+
+ # Automations exist but none satisfy the predicate — surface the
+ # closest candidate for the auditor.
+ candidate = _closest_candidate(user_client.automations.values())
+ report = CheckReportOkta(
+ metadata=self.metadata(),
+ resource=candidate
+ or UserAutomation(
+ id="okta-user-inactivity-noncompliant",
+ name="(no compliant automation)",
+ status="MISSING",
+ ),
+ org_domain=org_domain,
+ )
+ report.status = "FAIL"
+ report.status_extended = _failure_message(candidate, threshold_days)
+ findings.append(report)
+ return findings
+
+
+def _is_compliant(automation: UserAutomation, threshold_days: int) -> bool:
+ # `applies_to_groups` must be non-empty — Okta USER_LIFECYCLE policies
+ # do not implicitly cover every user; the scope is whatever group IDs
+ # the operator put in `people.groups.include`. An empty scope means
+ # the automation runs against nobody. Operator must still verify those
+ # group(s) cover the intended user population (surfaced in the PASS
+ # status_extended).
+ return bool(
+ automation.status.upper() == "ACTIVE"
+ and automation.schedule_status.upper() == "ACTIVE"
+ and automation.inactivity_days is not None
+ and automation.inactivity_days <= threshold_days
+ and (automation.lifecycle_action or "").upper() in SUSPENSION_LIFECYCLE_ACTIONS
+ and bool(automation.applies_to_groups)
+ )
+
+
+def _closest_candidate(automations):
+ automations = list(automations)
+ if not automations:
+ return None
+ automations.sort(
+ key=lambda a: (
+ 0 if a.status.upper() == "ACTIVE" else 1,
+ 0 if a.schedule_status.upper() == "ACTIVE" else 1,
+ (
+ abs(a.inactivity_days - DEFAULT_INACTIVITY_DAYS)
+ if a.inactivity_days is not None
+ else 10_000
+ ),
+ a.name,
+ )
+ )
+ return automations[0]
+
+
+def _failure_message(automation, threshold_days):
+ if automation is None:
+ return f"No Okta automation enforces {threshold_days}-day inactivity disable."
+ issues = []
+ if automation.status.upper() != "ACTIVE":
+ issues.append(f"status {automation.status or 'unset'}")
+ if automation.schedule_status.upper() != "ACTIVE":
+ issues.append(f"schedule {automation.schedule_status or 'unset'}")
+ if automation.inactivity_days is None:
+ issues.append("no inactivity condition")
+ elif automation.inactivity_days > threshold_days:
+ issues.append(
+ f"inactivity {automation.inactivity_days}d (max {threshold_days}d)"
+ )
+ action = (automation.lifecycle_action or "").upper()
+ if action not in SUSPENSION_LIFECYCLE_ACTIONS:
+ issues.append(f"action {automation.lifecycle_action or 'unset'}")
+ if not automation.applies_to_groups:
+ issues.append("no group scope")
+ detail = ", ".join(issues) if issues else "incomplete"
+ return (
+ f"Okta automation '{automation.name}' fails {threshold_days}d "
+ f"inactivity: {detail}."
+ )
diff --git a/prowler/providers/okta/services/user/user_service.py b/prowler/providers/okta/services/user/user_service.py
new file mode 100644
index 0000000000..c816bd38ee
--- /dev/null
+++ b/prowler/providers/okta/services/user/user_service.py
@@ -0,0 +1,455 @@
+from typing import Optional
+
+from pydantic import BaseModel, ValidationError
+
+from prowler.lib.logger import logger
+from prowler.providers.okta.lib.service.pagination import paginate
+from prowler.providers.okta.lib.service.raw_fetch import (
+ get_json_paginated as raw_get_json_paginated,
+)
+from prowler.providers.okta.lib.service.service import OktaService
+
+# External-directory IdP `type` values that delegate user sourcing to a
+# separate identity store. When any of these is present and ACTIVE, the
+# STIG's 35-day inactivity disable requirement is N/A on the Okta side —
+# the connected directory is expected to enforce it instead.
+EXTERNAL_DIRECTORY_IDP_TYPES = {"ACTIVE_DIRECTORY", "LDAP"}
+
+# Okta exposes "Workflow > Automations" as USER_LIFECYCLE policies with
+# inactivity rule conditions, not as a standalone `/api/v1/automations`
+# resource. The SDK's `UserPolicyRuleCondition.inactivity` and
+# `ScheduledUserLifecycleAction` models confirm this; the API rejects
+# every other `type` candidate.
+USER_LIFECYCLE_POLICY_TYPE = "USER_LIFECYCLE"
+
+REQUIRED_SCOPES: dict[str, str] = {
+ "automations": "okta.policies.read",
+ "identity_providers": "okta.idps.read",
+}
+
+
+class User(OktaService):
+ """Fetches Okta User Lifecycle Automations and external-directory IdPs.
+
+ Populates:
+ - `self.automations` — keyed by USER_LIFECYCLE policy rule id. Each
+ entry projects the fields the 35-day inactivity check evaluates:
+ identity (`id`, `name` — taken from the rule), `status`,
+ `schedule_status` (inherited from the parent policy), the
+ `inactivity_days` condition and `applies_to_groups` scope from the
+ parent policy, and the `lifecycle_action` from the rule.
+ - `self.external_directory_idps` — keyed by IdP id. Used to short
+ circuit the STIG to N/A when user sourcing is delegated to an
+ external directory (Active Directory, LDAP).
+
+ The Okta Admin Console's "Workflow > Automations" page is rendered
+ on top of `USER_LIFECYCLE` policies in the Management API
+ (`list_policies(type='USER_LIFECYCLE')` + `list_policy_rules(...)`).
+ There is no standalone `/api/v1/automations` GET endpoint; the SDK's
+ `InactivityPolicyRuleCondition`, `UserPolicyRuleCondition`, and
+ `ScheduledUserLifecycleAction` models all hang off the policy API.
+
+ Required OAuth scopes (`REQUIRED_SCOPES`) are compared against the
+ access token's granted scopes (`provider.identity.granted_scopes`).
+ Missing scopes are recorded in `self.missing_scope` so the check
+ can emit an explicit MANUAL finding.
+ """
+
+ def __init__(self, provider):
+ super().__init__(__class__.__name__, provider)
+ granted = set(getattr(provider.identity, "granted_scopes", None) or [])
+ self.missing_scope: dict[str, Optional[str]] = {
+ resource: (scope if granted and scope not in granted else None)
+ for resource, scope in REQUIRED_SCOPES.items()
+ }
+
+ self.automations: dict[str, UserAutomation] = (
+ {} if self.missing_scope["automations"] else self._list_automations()
+ )
+ self.external_directory_idps: dict[str, ExternalDirectoryIdp] = (
+ {}
+ if self.missing_scope["identity_providers"]
+ else self._list_external_directory_idps()
+ )
+
+ def _list_automations(self) -> dict:
+ logger.info("User - Listing USER_LIFECYCLE policies and rules...")
+ try:
+ return self._run(self._fetch_automations())
+ except Exception as error:
+ logger.error(
+ f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+ return {}
+
+ async def _fetch_automations(self) -> dict:
+ result: dict[str, UserAutomation] = {}
+ try:
+ all_policies, err = await paginate(
+ lambda after: self.client.list_policies(
+ type=USER_LIFECYCLE_POLICY_TYPE, after=after
+ )
+ )
+ except (ValueError, ValidationError) as ex:
+ # Upstream okta-sdk-python bug: `Policy.from_dict` uses a
+ # discriminator dispatch that maps `type` → concrete Policy
+ # subclass, and `USER_LIFECYCLE` is not in the map. The SDK
+ # raises ValueError ("failed to lookup discriminator value")
+ # even though the API returns a valid policy. Fall back to
+ # raw JSON. Remove once okta-sdk-python adds
+ # USER_LIFECYCLE → UserLifecyclePolicy to the mapping.
+ logger.warning(
+ f"Okta SDK raised {type(ex).__name__} parsing USER_LIFECYCLE "
+ "policies — falling back to raw-JSON parse. This is an "
+ "okta-sdk-python deserialization bug "
+ "(missing discriminator mapping)."
+ )
+ return await self._fetch_automations_raw()
+
+ if err is not None:
+ logger.error(f"Error listing USER_LIFECYCLE policies: {err}")
+ return result
+
+ for policy in all_policies:
+ policy_id = getattr(policy, "id", "") or ""
+ if not policy_id:
+ continue
+ policy_status = _stringify_enum(getattr(policy, "status", None)) or ""
+ policy_name = getattr(policy, "name", "") or ""
+ rules = await self._fetch_rules(policy_id)
+ if rules is None:
+ # Rule typed parsing tripped an SDK validator. Re-run the
+ # whole automation discovery via raw JSON so we don't lose
+ # the rule data for this — or any other — policy. Cheaper
+ # than mixing typed and raw projections.
+ logger.warning(
+ f"Rule typed parsing failed for USER_LIFECYCLE policy "
+ f"{policy_id} — re-running all automations via raw-JSON."
+ )
+ return await self._fetch_automations_raw()
+ if not rules:
+ # A policy with no rules exists in the Admin Console UI as
+ # an "Automation" the operator hasn't finished configuring
+ # (no conditions, no actions). Emit a placeholder so the
+ # check FAILs with a specific message naming every missing
+ # piece, instead of pretending the policy doesn't exist.
+ result[policy_id] = _shell_automation(
+ policy_id, policy_name, policy_status
+ )
+ continue
+ for rule in rules:
+ automation = _rule_to_automation(rule, policy)
+ if automation is None:
+ continue
+ result[automation.id] = automation
+ return result
+
+ async def _fetch_rules(self, policy_id: str) -> Optional[list]:
+ """Return the policy's typed rules, or None to signal raw fallback.
+
+ The Okta SDK's `list_policy_rules` shares the same brittle typed
+ deserialization as `list_policies` (strict pydantic validators
+ rejecting values the API actually returns). When that happens the
+ caller can't reuse any of the typed projection for this policy —
+ we return None as a sentinel and the caller re-runs the whole
+ discovery via `_fetch_automations_raw`. Returning `[]` would
+ otherwise misclassify the policy as an "unfinished automation"
+ and FAIL it.
+ """
+ rule_fetch_limit = 100
+ try:
+ result = await self.client.list_policy_rules(
+ policy_id, limit=str(rule_fetch_limit)
+ )
+ except (ValueError, ValidationError) as ex:
+ logger.warning(
+ f"Okta SDK raised {type(ex).__name__} parsing rules for "
+ f"USER_LIFECYCLE policy {policy_id} — signaling raw fallback."
+ )
+ return None
+ err = result[-1]
+ if err is not None:
+ logger.error(
+ f"Error listing rules for USER_LIFECYCLE policy {policy_id}: {err}"
+ )
+ return []
+ rules = list(result[0] or [])
+ if len(rules) >= rule_fetch_limit:
+ logger.warning(
+ f"USER_LIFECYCLE policy {policy_id} returned {len(rules)} rules — "
+ f"the per-policy fetch limit ({rule_fetch_limit}) was hit; any "
+ "rules beyond this limit are not evaluated."
+ )
+ return rules
+
+ async def _fetch_automations_raw(self) -> dict:
+ """Raw-JSON fallback for `list_policies(type='USER_LIFECYCLE')`.
+
+ Bypasses the SDK's typed deserialization via the shared
+ `get_json_paginated` helper, then drains each policy's rules
+ via the same path. Projects everything onto our `UserAutomation`
+ snapshot which only validates the fields the check reads.
+ """
+ result: dict[str, UserAutomation] = {}
+ policies_data = await raw_get_json_paginated(
+ self.client,
+ f"/api/v1/policies?type={USER_LIFECYCLE_POLICY_TYPE}",
+ page_size=200,
+ context="USER_LIFECYCLE policies",
+ )
+ if policies_data is None:
+ return result
+
+ for policy_dict in policies_data:
+ if not isinstance(policy_dict, dict):
+ continue
+ policy_id = policy_dict.get("id")
+ if not policy_id:
+ continue
+ policy_status = (policy_dict.get("status") or "").upper()
+ policy_name = policy_dict.get("name") or ""
+
+ rules_data = await raw_get_json_paginated(
+ self.client,
+ f"/api/v1/policies/{policy_id}/rules",
+ page_size=100,
+ context=f"USER_LIFECYCLE policy {policy_id} rules",
+ )
+ if not rules_data:
+ # No rules under the policy → emit placeholder. Same
+ # rationale as the typed path: surface the unfinished
+ # automation so the check can name what's missing.
+ result[policy_id] = _shell_automation(
+ policy_id, policy_name, policy_status
+ )
+ continue
+ for rule_dict in rules_data:
+ automation = _raw_rule_to_automation(
+ rule_dict, policy_dict, policy_id, policy_name, policy_status
+ )
+ if automation is None:
+ continue
+ result[automation.id] = automation
+ return result
+
+ def _list_external_directory_idps(self) -> dict:
+ logger.info("User - Listing Okta IdPs for external-directory detection...")
+ try:
+ return self._run(self._fetch_external_directory_idps())
+ except Exception as error:
+ logger.error(
+ f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+ return {}
+
+ async def _fetch_external_directory_idps(self) -> dict:
+ result: dict[str, ExternalDirectoryIdp] = {}
+ all_idps, err = await paginate(
+ lambda after: self.client.list_identity_providers(after=after)
+ )
+ if err is not None:
+ logger.error(f"Error listing identity providers: {err}")
+ return result
+
+ for idp in all_idps:
+ idp_type = _stringify_enum(getattr(idp, "type", None)) or ""
+ if idp_type.upper() not in EXTERNAL_DIRECTORY_IDP_TYPES:
+ continue
+ idp_status = _stringify_enum(getattr(idp, "status", None)) or ""
+ if idp_status.upper() != "ACTIVE":
+ continue
+ idp_id = getattr(idp, "id", "") or ""
+ if not idp_id:
+ continue
+ result[idp_id] = ExternalDirectoryIdp(
+ id=idp_id,
+ name=getattr(idp, "name", "") or "",
+ type=idp_type,
+ status=idp_status,
+ )
+ return result
+
+
+def _rule_to_automation(rule, policy) -> Optional["UserAutomation"]:
+ """Project a typed USER_LIFECYCLE policy + rule pair onto our snapshot.
+
+ Important: in the actual API response, an Okta "Automation" is split
+ across two resources — the **inactivity condition + group scope**
+ live on the *policy* (`policy.conditions.people.users.inactivity`,
+ `policy.conditions.people.groups.include`), and the **lifecycle
+ action** lives on the *rule* (`rule.actions.user_lifecycle.action`
+ on the typed model; `updateUserLifecycle.targetStatus` on raw JSON).
+ The rule's own `conditions` is typically empty. Projecting requires
+ both — kept aligned with `_raw_rule_to_automation` so the two paths
+ yield identical snapshots.
+ """
+ rule_id = getattr(rule, "id", "") or ""
+ if not rule_id:
+ return None
+
+ policy_id = getattr(policy, "id", "") or ""
+ policy_name = getattr(policy, "name", "") or ""
+ policy_status = (_stringify_enum(getattr(policy, "status", None)) or "").upper()
+
+ # Inactivity + groups live on the POLICY in the API response.
+ inactivity_days: Optional[int] = None
+ applies_to_groups: list[str] = []
+ conditions = getattr(policy, "conditions", None)
+ people = getattr(conditions, "people", None) if conditions else None
+ users = getattr(people, "users", None) if people else None
+ inactivity = getattr(users, "inactivity", None) if users else None
+ if inactivity is not None:
+ number = getattr(inactivity, "number", None)
+ unit = (_stringify_enum(getattr(inactivity, "unit", None)) or "").upper()
+ if isinstance(number, int) and unit in {"DAYS", "DAY"}:
+ inactivity_days = number
+ groups = getattr(people, "groups", None) if people else None
+ include_groups = getattr(groups, "include", None) if groups else None
+ if include_groups:
+ applies_to_groups = [str(g) for g in include_groups if g]
+
+ # Lifecycle action lives on the RULE.
+ actions = getattr(rule, "actions", None)
+ user_lifecycle = (
+ getattr(actions, "user_lifecycle", None) if actions else None
+ ) or (getattr(actions, "userLifecycle", None) if actions else None)
+ lifecycle_action: Optional[str] = None
+ if user_lifecycle is not None:
+ for attr in ("action", "status"):
+ value = _stringify_enum(getattr(user_lifecycle, attr, None))
+ if value:
+ lifecycle_action = value.upper()
+ break
+
+ rule_name = getattr(rule, "name", "") or policy_name or "(unnamed)"
+ rule_status = _stringify_enum(getattr(rule, "status", None)) or ""
+
+ return UserAutomation(
+ id=rule_id,
+ name=rule_name,
+ status=rule_status.upper(),
+ schedule_status=policy_status,
+ inactivity_days=inactivity_days,
+ lifecycle_action=lifecycle_action,
+ applies_to_groups=applies_to_groups,
+ policy_id=policy_id,
+ policy_name=policy_name,
+ )
+
+
+def _raw_rule_to_automation(
+ rule_dict,
+ policy_dict,
+ policy_id: str,
+ policy_name: str,
+ policy_status: str,
+) -> Optional["UserAutomation"]:
+ """Project a raw USER_LIFECYCLE policy+rule pair onto our snapshot.
+
+ Important: in the actual API response, an Okta "Automation" is split
+ across two resources — the **inactivity condition + group scope**
+ live on the *policy* (`policy.conditions.people.users.inactivity`,
+ `policy.conditions.people.groups.include`), and the **lifecycle
+ action** lives on the *rule*
+ (`rule.actions.updateUserLifecycle.targetStatus`). The rule's own
+ `conditions` is typically empty `{}`. Projecting requires both.
+
+ Schedule isn't exposed by the API on either resource. Okta runs an
+ automation on its UI-configured schedule iff the policy is ACTIVE,
+ so we treat `policy.status` as the schedule proxy.
+ """
+ if not isinstance(rule_dict, dict):
+ return None
+ rule_id = rule_dict.get("id")
+ if not rule_id:
+ return None
+
+ # Inactivity + groups live on the POLICY in the API response.
+ inactivity_days: Optional[int] = None
+ applies_to_groups: list[str] = []
+ if isinstance(policy_dict, dict):
+ policy_conditions = policy_dict.get("conditions") or {}
+ people = policy_conditions.get("people") or {}
+ users = people.get("users") or {}
+ inactivity = users.get("inactivity")
+ if isinstance(inactivity, dict):
+ number = inactivity.get("number")
+ unit = (inactivity.get("unit") or "").upper()
+ if isinstance(number, int) and unit in {"DAYS", "DAY"}:
+ inactivity_days = number
+ groups = people.get("groups") or {}
+ include_groups = groups.get("include")
+ if isinstance(include_groups, list):
+ applies_to_groups = [str(g) for g in include_groups if g]
+
+ # Lifecycle action lives on the RULE under
+ # `actions.updateUserLifecycle.targetStatus` (the API uses
+ # "updateUserLifecycle" rather than the SDK's `user_lifecycle`).
+ rule_actions = rule_dict.get("actions") or {}
+ update_user_lifecycle = rule_actions.get("updateUserLifecycle") or {}
+ lifecycle_action: Optional[str] = None
+ if isinstance(update_user_lifecycle, dict):
+ target = update_user_lifecycle.get("targetStatus")
+ if isinstance(target, str) and target:
+ lifecycle_action = target.upper()
+
+ return UserAutomation(
+ id=rule_id,
+ name=(rule_dict.get("name") or policy_name or "(unnamed)"),
+ status=(rule_dict.get("status") or "").upper(),
+ schedule_status=policy_status,
+ inactivity_days=inactivity_days,
+ lifecycle_action=lifecycle_action,
+ applies_to_groups=applies_to_groups,
+ policy_id=policy_id,
+ policy_name=policy_name,
+ )
+
+
+def _shell_automation(
+ policy_id: str, policy_name: str, policy_status: str
+) -> "UserAutomation":
+ """Placeholder UserAutomation for a USER_LIFECYCLE policy with no rules.
+
+ Surfaces the unfinished automation in `self.automations` so the check
+ can list every missing piece in its FAIL message (no inactivity
+ condition, no lifecycle action, status inactive, etc.) instead of
+ silently dropping the policy.
+ """
+ upper_status = (policy_status or "").upper()
+ return UserAutomation(
+ id=policy_id,
+ name=policy_name or "(unnamed automation)",
+ status=upper_status,
+ schedule_status=upper_status,
+ inactivity_days=None,
+ lifecycle_action=None,
+ applies_to_groups=[],
+ policy_id=policy_id,
+ policy_name=policy_name,
+ )
+
+
+def _stringify_enum(value) -> Optional[str]:
+ if value is None:
+ return None
+ return getattr(value, "value", None) or str(value)
+
+
+class UserAutomation(BaseModel):
+ id: str
+ name: str = ""
+ status: str = ""
+ schedule_status: str = ""
+ inactivity_days: Optional[int] = None
+ lifecycle_action: Optional[str] = None
+ applies_to_groups: list[str] = []
+ policy_id: str = ""
+ policy_name: str = ""
+
+
+class ExternalDirectoryIdp(BaseModel):
+ id: str
+ name: str = ""
+ type: str = ""
+ status: str = ""
diff --git a/tests/providers/okta/lib/service/raw_fetch_test.py b/tests/providers/okta/lib/service/raw_fetch_test.py
new file mode 100644
index 0000000000..173e78888a
--- /dev/null
+++ b/tests/providers/okta/lib/service/raw_fetch_test.py
@@ -0,0 +1,152 @@
+"""Tests for the raw-JSON HTTP helpers in
+`prowler.providers.okta.lib.service.raw_fetch`.
+
+Covers `get_json` (single-shot) and `get_json_paginated`
+(drains list endpoints via the `Link: rel="next"` cursor).
+"""
+
+import asyncio
+import json
+from unittest import mock
+
+from prowler.providers.okta.lib.service.raw_fetch import (
+ get_json,
+ get_json_paginated,
+)
+
+
+def _run(coro):
+ return asyncio.run(coro)
+
+
+def _mock_response(headers: dict = None):
+ r = mock.MagicMock()
+ r.headers = headers or {}
+ return r
+
+
+class Test_get_json:
+ def test_returns_parsed_json_on_success(self):
+ client = mock.MagicMock()
+
+ async def create(*_a, **_k):
+ return ({"url": "/x"}, None)
+
+ async def execute(_req):
+ return (_mock_response(), json.dumps({"hello": "world"}), None)
+
+ client._request_executor.create_request = create
+ client._request_executor.execute = execute
+
+ assert _run(get_json(client, "/x")) == {"hello": "world"}
+
+ def test_returns_none_on_create_request_error(self):
+ client = mock.MagicMock()
+
+ async def create(*_a, **_k):
+ return (None, Exception("boom"))
+
+ client._request_executor.create_request = create
+ assert _run(get_json(client, "/x")) is None
+
+ def test_returns_none_on_execute_error(self):
+ client = mock.MagicMock()
+
+ async def create(*_a, **_k):
+ return ({"url": "/x"}, None)
+
+ async def execute(_req):
+ return (_mock_response(), None, Exception("boom"))
+
+ client._request_executor.create_request = create
+ client._request_executor.execute = execute
+ assert _run(get_json(client, "/x")) is None
+
+
+class Test_get_json_paginated:
+ def test_drains_all_pages_following_link_rel_next(self):
+ # Two pages: first carries `Link: <…?after=cur1>; rel="next"`,
+ # second has no `next`, so iteration stops.
+ client = mock.MagicMock()
+
+ page1 = [{"id": "a"}, {"id": "b"}]
+ page2 = [{"id": "c"}]
+ page1_headers = {
+ "link": '; rel="next"'
+ }
+
+ seen_urls = []
+
+ async def create(**kwargs):
+ seen_urls.append(kwargs["url"])
+ return ({"url": kwargs["url"]}, None)
+
+ async def execute(request):
+ if "after=cur1" in request["url"]:
+ return (_mock_response({}), json.dumps(page2), None)
+ return (_mock_response(page1_headers), json.dumps(page1), None)
+
+ client._request_executor.create_request = create
+ client._request_executor.execute = execute
+
+ items = _run(get_json_paginated(client, "/api/v1/items", page_size=2))
+
+ assert items == [{"id": "a"}, {"id": "b"}, {"id": "c"}]
+ assert len(seen_urls) == 2
+ assert "limit=2" in seen_urls[0]
+ # The cursor was carried into the second request.
+ assert "after=cur1" in seen_urls[1]
+ assert "limit=2" in seen_urls[1]
+
+ def test_single_page_terminates_immediately(self):
+ client = mock.MagicMock()
+
+ async def create(**kwargs):
+ return ({"url": kwargs["url"]}, None)
+
+ async def execute(_req):
+ return (_mock_response({}), json.dumps([{"id": "only"}]), None)
+
+ client._request_executor.create_request = create
+ client._request_executor.execute = execute
+
+ assert _run(get_json_paginated(client, "/api/v1/items")) == [{"id": "only"}]
+
+ def test_returns_none_when_response_is_not_a_list(self):
+ client = mock.MagicMock()
+
+ async def create(**kwargs):
+ return ({"url": kwargs["url"]}, None)
+
+ async def execute(_req):
+ return (_mock_response({}), json.dumps({"error": "nope"}), None)
+
+ client._request_executor.create_request = create
+ client._request_executor.execute = execute
+
+ assert _run(get_json_paginated(client, "/api/v1/items")) is None
+
+ def test_preserves_existing_query_string_and_overrides_limit(self):
+ # Caller already passes `type=USER_LIFECYCLE` — pagination must
+ # merge `limit` without clobbering existing params.
+ client = mock.MagicMock()
+ seen = []
+
+ async def create(**kwargs):
+ seen.append(kwargs["url"])
+ return ({"url": kwargs["url"]}, None)
+
+ async def execute(_req):
+ return (_mock_response({}), "[]", None)
+
+ client._request_executor.create_request = create
+ client._request_executor.execute = execute
+
+ _run(
+ get_json_paginated(
+ client, "/api/v1/policies?type=USER_LIFECYCLE", page_size=50
+ )
+ )
+
+ assert "type=USER_LIFECYCLE" in seen[0]
+ assert "limit=50" in seen[0]
diff --git a/tests/providers/okta/okta_fixtures.py b/tests/providers/okta/okta_fixtures.py
index 23d770cf88..83c8812495 100644
--- a/tests/providers/okta/okta_fixtures.py
+++ b/tests/providers/okta/okta_fixtures.py
@@ -16,7 +16,13 @@ def set_mocked_okta_provider(
session = OktaSession(
org_domain=OKTA_ORG_DOMAIN,
client_id=OKTA_CLIENT_ID,
- scopes=["okta.policies.read", "okta.brands.read", "okta.apps.read"],
+ scopes=[
+ "okta.policies.read",
+ "okta.brands.read",
+ "okta.apps.read",
+ "okta.logStreams.read",
+ "okta.idps.read",
+ ],
private_key=OKTA_PRIVATE_KEY,
)
if identity is None:
@@ -27,6 +33,8 @@ def set_mocked_okta_provider(
"okta.policies.read",
"okta.brands.read",
"okta.apps.read",
+ "okta.logStreams.read",
+ "okta.idps.read",
],
)
diff --git a/tests/providers/okta/services/idp/idp_fixtures.py b/tests/providers/okta/services/idp/idp_fixtures.py
new file mode 100644
index 0000000000..8ebc449717
--- /dev/null
+++ b/tests/providers/okta/services/idp/idp_fixtures.py
@@ -0,0 +1,44 @@
+"""Shared helpers for `idp` service check tests."""
+
+from unittest import mock
+
+from prowler.providers.okta.services.idp.idp_service import OktaIdentityProvider
+from tests.providers.okta.okta_fixtures import set_mocked_okta_provider
+
+
+def build_idp_client(
+ identity_providers: dict = None,
+ missing_scope: dict = None,
+):
+ client = mock.MagicMock()
+ client.identity_providers = identity_providers or {}
+ client.provider = set_mocked_okta_provider()
+ client.audit_config = {}
+ client.missing_scope = missing_scope or {"identity_providers": None}
+ return client
+
+
+def smart_card_idp(
+ idp_id: str = "0oa-x509",
+ name: str = "CAC IdP",
+ status: str = "ACTIVE",
+ issuer: str = "CN=DOD ROOT CA 6",
+ kid: str = "kid-abc-123",
+):
+ return OktaIdentityProvider(
+ id=idp_id,
+ name=name,
+ type="X509",
+ status=status,
+ trust_issuer=issuer,
+ trust_kid=kid,
+ )
+
+
+def non_smart_card_idp(
+ idp_id: str = "0oa-saml",
+ name: str = "Corporate SAML",
+ type: str = "SAML2",
+ status: str = "ACTIVE",
+):
+ return OktaIdentityProvider(id=idp_id, name=name, type=type, status=status)
diff --git a/tests/providers/okta/services/idp/idp_service_test.py b/tests/providers/okta/services/idp/idp_service_test.py
new file mode 100644
index 0000000000..3c30c0d3eb
--- /dev/null
+++ b/tests/providers/okta/services/idp/idp_service_test.py
@@ -0,0 +1,80 @@
+import json
+from unittest import mock
+
+from okta.models.identity_provider_protocol import IdentityProviderProtocol
+
+from prowler.providers.okta.services.idp.idp_service import Idp, OktaIdentityProvider
+from tests.providers.okta.okta_fixtures import set_mocked_okta_provider
+
+
+def _resp(headers: dict = None):
+ r = mock.MagicMock()
+ r.headers = headers or {}
+ return r
+
+
+def _fake_idp(idp_id, name, type_, status="ACTIVE", issuer=None, kid=None):
+ # Build a real `IdentityProviderProtocol` when issuer/kid are provided
+ # so the test exercises the SDK's Pydantic v2 oneOf wrapper — credentials
+ # live on `actual_instance`, not directly on the wrapper. MagicMock
+ # auto-attribute-creation would otherwise hide a missed unwrap.
+ idp = mock.MagicMock()
+ idp.id = idp_id
+ idp.name = name
+ idp.type = type_
+ idp.status = status
+ if issuer is None and kid is None:
+ idp.protocol = None
+ else:
+ idp.protocol = IdentityProviderProtocol.from_json(
+ json.dumps(
+ {
+ "type": "MTLS",
+ "credentials": {"trust": {"issuer": issuer, "kid": kid}},
+ }
+ )
+ )
+ return idp
+
+
+def _patch_sdk(**methods):
+ return mock.patch(
+ "prowler.providers.okta.lib.service.service.OktaSDKClient",
+ return_value=mock.MagicMock(**methods),
+ )
+
+
+class Test_Idp_service:
+ def test_fetches_idps_with_trust_fields(self):
+ provider = set_mocked_okta_provider()
+ x509 = _fake_idp(
+ "0oa1",
+ "CAC",
+ "X509",
+ issuer="CN=DOD ROOT CA 6",
+ kid="kid-1",
+ )
+ saml = _fake_idp("0oa2", "Corp", "SAML2")
+
+ async def fake_list(*_a, **_k):
+ return ([x509, saml], _resp({}), None)
+
+ with _patch_sdk(list_identity_providers=fake_list):
+ service = Idp(provider)
+
+ assert set(service.identity_providers.keys()) == {"0oa1", "0oa2"}
+ assert isinstance(service.identity_providers["0oa1"], OktaIdentityProvider)
+ assert service.identity_providers["0oa1"].trust_issuer == "CN=DOD ROOT CA 6"
+ assert service.identity_providers["0oa1"].trust_kid == "kid-1"
+ assert service.identity_providers["0oa2"].trust_issuer is None
+
+ def test_returns_empty_on_api_error(self):
+ provider = set_mocked_okta_provider()
+
+ async def failing(*_a, **_k):
+ return ([], _resp({}), Exception("API failure"))
+
+ with _patch_sdk(list_identity_providers=failing):
+ service = Idp(provider)
+
+ assert service.identity_providers == {}
diff --git a/tests/providers/okta/services/idp/idp_smart_card_dod_approved_ca/idp_smart_card_dod_approved_ca_test.py b/tests/providers/okta/services/idp/idp_smart_card_dod_approved_ca/idp_smart_card_dod_approved_ca_test.py
new file mode 100644
index 0000000000..f6517e14a9
--- /dev/null
+++ b/tests/providers/okta/services/idp/idp_smart_card_dod_approved_ca/idp_smart_card_dod_approved_ca_test.py
@@ -0,0 +1,125 @@
+from unittest import mock
+
+from tests.providers.okta.okta_fixtures import set_mocked_okta_provider
+from tests.providers.okta.services.idp.idp_fixtures import (
+ build_idp_client,
+ non_smart_card_idp,
+ smart_card_idp,
+)
+
+CHECK_PATH = (
+ "prowler.providers.okta.services.idp."
+ "idp_smart_card_dod_approved_ca.idp_smart_card_dod_approved_ca.idp_client"
+)
+
+DOD_PKI_ISSUER = "CN=DoD ID CA-59, OU=PKI, OU=DoD, O=U.S. Government, C=US"
+ECA_ISSUER = "CN=ECA Root CA 4, OU=ECA, O=U.S. Government, C=US"
+NON_DOD_ISSUER = "CN=ACME Internal Root, O=Acme Corp, C=US"
+
+
+def _run_check(client):
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_okta_provider(),
+ ),
+ mock.patch(CHECK_PATH, new=client),
+ ):
+ from prowler.providers.okta.services.idp.idp_smart_card_dod_approved_ca.idp_smart_card_dod_approved_ca import (
+ idp_smart_card_dod_approved_ca,
+ )
+
+ return idp_smart_card_dod_approved_ca().execute()
+
+
+class Test_idp_smart_card_dod_approved_ca:
+ def test_pass_when_active_idp_chain_matches_dod_pki_pattern(self):
+ idp = smart_card_idp(name="CAC", issuer=DOD_PKI_ISSUER, kid="kid-x")
+ client = build_idp_client(identity_providers={idp.id: idp})
+ findings = _run_check(client)
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert "OU=DoD" in findings[0].status_extended
+ assert DOD_PKI_ISSUER in findings[0].status_extended
+
+ def test_pass_when_active_idp_chain_matches_eca_pattern(self):
+ idp = smart_card_idp(name="ECA Partner", issuer=ECA_ISSUER, kid="kid-e")
+ client = build_idp_client(identity_providers={idp.id: idp})
+ findings = _run_check(client)
+ assert findings[0].status == "PASS"
+ assert "OU=ECA" in findings[0].status_extended
+
+ def test_manual_when_active_but_issuer_does_not_match_any_pattern(self):
+ idp = smart_card_idp(name="Custom", issuer=NON_DOD_ISSUER, kid="kid-c")
+ client = build_idp_client(identity_providers={idp.id: idp})
+ findings = _run_check(client)
+ assert findings[0].status == "MANUAL"
+ assert NON_DOD_ISSUER in findings[0].status_extended
+ assert "okta_dod_approved_ca_issuer_patterns" in findings[0].status_extended
+
+ def test_pass_when_audit_config_pattern_matches(self):
+ idp = smart_card_idp(name="Custom DOD", issuer=NON_DOD_ISSUER, kid="kid-c")
+ client = build_idp_client(identity_providers={idp.id: idp})
+ client.audit_config = {
+ "okta_dod_approved_ca_issuer_patterns": [r"CN=ACME Internal Root"]
+ }
+ findings = _run_check(client)
+ assert findings[0].status == "PASS"
+
+ def test_audit_config_overrides_bundled_defaults(self):
+ # When the operator supplies a list, the bundled DEFAULT patterns
+ # are replaced (not merged) so customers can carve out a strict set.
+ idp = smart_card_idp(name="DoD", issuer=DOD_PKI_ISSUER, kid="kid-x")
+ client = build_idp_client(identity_providers={idp.id: idp})
+ client.audit_config = {
+ "okta_dod_approved_ca_issuer_patterns": [r"CN=YourTenantCustomDodCA"]
+ }
+ findings = _run_check(client)
+ assert findings[0].status == "MANUAL"
+
+ def test_malformed_audit_config_pattern_skipped(self):
+ # An invalid regex from the operator must not crash the whole check.
+ idp = smart_card_idp(name="CAC", issuer=DOD_PKI_ISSUER, kid="kid-x")
+ client = build_idp_client(identity_providers={idp.id: idp})
+ client.audit_config = {
+ "okta_dod_approved_ca_issuer_patterns": [r"[invalid(regex", r"OU=DoD"]
+ }
+ findings = _run_check(client)
+ assert findings[0].status == "PASS"
+
+ def test_fail_when_x509_idp_is_inactive(self):
+ idp = smart_card_idp(status="INACTIVE", issuer=DOD_PKI_ISSUER)
+ client = build_idp_client(identity_providers={idp.id: idp})
+ findings = _run_check(client)
+ assert findings[0].status == "FAIL"
+ assert "INACTIVE" in findings[0].status_extended
+
+ def test_fail_when_no_smart_card_idp_configured(self):
+ client = build_idp_client(identity_providers={"saml": non_smart_card_idp()})
+ findings = _run_check(client)
+ assert findings[0].status == "FAIL"
+ assert (
+ "No Smart Card (X509) Identity Providers are configured"
+ in findings[0].status_extended
+ )
+ assert "mutelist" in findings[0].status_extended
+
+ def test_manual_when_idps_scope_missing(self):
+ client = build_idp_client(
+ missing_scope={"identity_providers": "okta.idps.read"}
+ )
+ findings = _run_check(client)
+ assert findings[0].status == "MANUAL"
+ assert "okta.idps.read" in findings[0].status_extended
+
+ def test_multiple_x509_idps_yield_one_finding_each(self):
+ idp_a = smart_card_idp(idp_id="0oa-a", name="A", issuer=DOD_PKI_ISSUER)
+ idp_b = smart_card_idp(
+ idp_id="0oa-b", name="B", status="INACTIVE", issuer=DOD_PKI_ISSUER
+ )
+ client = build_idp_client(identity_providers={idp_a.id: idp_a, idp_b.id: idp_b})
+ findings = _run_check(client)
+ assert len(findings) == 2
+ # We don't strictly assert ordering — just that both are covered.
+ statuses = sorted(f.status for f in findings)
+ assert statuses == ["FAIL", "PASS"]
diff --git a/tests/providers/okta/services/signon/signon_service_test.py b/tests/providers/okta/services/signon/signon_service_test.py
index c408bc1221..0caeb0e680 100644
--- a/tests/providers/okta/services/signon/signon_service_test.py
+++ b/tests/providers/okta/services/signon/signon_service_test.py
@@ -1,12 +1,14 @@
from unittest import mock
+from prowler.providers.okta.lib.service.pagination import (
+ next_after_cursor as _next_after_cursor,
+)
from prowler.providers.okta.models import OktaIdentityInfo
from prowler.providers.okta.services.signon.signon_service import (
GlobalSessionPolicy,
GlobalSessionPolicyRule,
SignInPage,
Signon,
- _next_after_cursor,
)
from tests.providers.okta.okta_fixtures import (
OKTA_CLIENT_ID,
diff --git a/tests/providers/okta/services/systemlog/systemlog_fixtures.py b/tests/providers/okta/services/systemlog/systemlog_fixtures.py
new file mode 100644
index 0000000000..efc8289f43
--- /dev/null
+++ b/tests/providers/okta/services/systemlog/systemlog_fixtures.py
@@ -0,0 +1,27 @@
+"""Shared helpers for `systemlog` service check tests."""
+
+from unittest import mock
+
+from prowler.providers.okta.services.systemlog.systemlog_service import LogStream
+from tests.providers.okta.okta_fixtures import set_mocked_okta_provider
+
+
+def build_systemlog_client(
+ log_streams: dict = None,
+ missing_scope: dict = None,
+):
+ client = mock.MagicMock()
+ client.log_streams = log_streams or {}
+ client.provider = set_mocked_okta_provider()
+ client.audit_config = {}
+ client.missing_scope = missing_scope or {"log_streams": None}
+ return client
+
+
+def log_stream(
+ stream_id: str = "log-1",
+ name: str = "EventBridge stream",
+ status: str = "ACTIVE",
+ type: str = "AWS_EVENTBRIDGE",
+):
+ return LogStream(id=stream_id, name=name, status=status, type=type)
diff --git a/tests/providers/okta/services/systemlog/systemlog_service_test.py b/tests/providers/okta/services/systemlog/systemlog_service_test.py
new file mode 100644
index 0000000000..63dee95dcc
--- /dev/null
+++ b/tests/providers/okta/services/systemlog/systemlog_service_test.py
@@ -0,0 +1,185 @@
+import json
+from unittest import mock
+
+from prowler.providers.okta.models import OktaIdentityInfo
+from prowler.providers.okta.services.systemlog.systemlog_service import (
+ LogStream,
+ SystemLog,
+)
+from tests.providers.okta.okta_fixtures import (
+ OKTA_CLIENT_ID,
+ OKTA_ORG_DOMAIN,
+ set_mocked_okta_provider,
+)
+
+
+def _resp(headers: dict = None):
+ r = mock.MagicMock()
+ r.headers = headers or {}
+ return r
+
+
+def _fake_stream(
+ stream_id: str, name: str, status: str = "ACTIVE", type_: str = "AWS_EVENTBRIDGE"
+):
+ s = mock.MagicMock()
+ s.id = stream_id
+ s.name = name
+ s.status = status
+ s.type = type_
+ return s
+
+
+def _patch_sdk(**methods):
+ return mock.patch(
+ "prowler.providers.okta.lib.service.service.OktaSDKClient",
+ return_value=mock.MagicMock(**methods),
+ )
+
+
+class Test_SystemLog_service:
+ def test_fetches_active_streams(self):
+ provider = set_mocked_okta_provider()
+ s1 = _fake_stream("log-1", "EventBridge")
+ s2 = _fake_stream("log-2", "Splunk", type_="SPLUNK_CLOUD_LOGSTREAMING")
+
+ async def fake_list(*_a, **_k):
+ return ([s1, s2], _resp({}), None)
+
+ with _patch_sdk(list_log_streams=fake_list):
+ service = SystemLog(provider)
+
+ assert set(service.log_streams.keys()) == {"log-1", "log-2"}
+ assert isinstance(service.log_streams["log-1"], LogStream)
+ assert service.log_streams["log-2"].type == "SPLUNK_CLOUD_LOGSTREAMING"
+
+ def test_returns_empty_on_api_error(self):
+ provider = set_mocked_okta_provider()
+
+ async def failing(*_a, **_k):
+ return ([], _resp({}), Exception("E0000007"))
+
+ with _patch_sdk(list_log_streams=failing):
+ service = SystemLog(provider)
+
+ assert service.log_streams == {}
+
+ def test_skips_fetch_when_scope_missing(self):
+ identity = OktaIdentityInfo(
+ org_domain=OKTA_ORG_DOMAIN,
+ client_id=OKTA_CLIENT_ID,
+ granted_scopes=["okta.policies.read"], # no logStreams scope
+ )
+ provider = set_mocked_okta_provider(identity=identity)
+
+ called = False
+
+ async def fake_list(*_a, **_k):
+ nonlocal called
+ called = True
+ return ([], _resp({}), None)
+
+ with _patch_sdk(list_log_streams=fake_list):
+ service = SystemLog(provider)
+
+ assert called is False
+ assert service.log_streams == {}
+ assert service.missing_scope["log_streams"] == "okta.logStreams.read"
+
+
+class Test_SystemLog_service_sdk_validation_fallback:
+ """Verifies the raw-JSON fallback when the Okta SDK rejects API values.
+
+ The SDK's `LogStreamSettingsAws.eventSourceName` validator uses the
+ regex `^[a-zA-Z0-9.\\-_]$` — missing the `+` quantifier, so every
+ multi-character name raises pydantic `ValidationError`. Without the
+ fallback the whole stream list is lost; with it, the raw JSON path
+ still surfaces each stream's id/name/status/type.
+ """
+
+ def test_raw_fallback_projects_streams_when_sdk_raises(self):
+ from pydantic import ValidationError
+
+ provider = set_mocked_okta_provider()
+
+ raw_payload = [
+ {
+ "id": "log-1",
+ "name": "EventBridge prod",
+ "status": "ACTIVE",
+ "type": "AWS_EVENTBRIDGE",
+ },
+ {
+ "id": "log-2",
+ "name": "Splunk staging",
+ "status": "INACTIVE",
+ "type": "SPLUNK_CLOUD_LOGSTREAMING",
+ },
+ ]
+
+ async def failing_list_log_streams(*_a, **_k):
+ try:
+ # Trigger a real pydantic ValidationError so we exercise
+ # the exact exception type the SDK raises in production.
+ from okta.models.log_stream_settings_aws import LogStreamSettingsAws
+
+ LogStreamSettingsAws(
+ accountId="123456789012",
+ eventSourceName="MultiCharacter",
+ region="us-east-1",
+ )
+ except ValidationError as ve:
+ raise ve
+ return ([], _resp({}), None)
+
+ async def fake_raw_create(*_a, **_k):
+ return ({"url": "/api/v1/logStreams"}, None)
+
+ async def fake_raw_execute(_request):
+ return (None, json.dumps(raw_payload), None)
+
+ sdk = mock.MagicMock()
+ sdk.list_log_streams = failing_list_log_streams
+ sdk._request_executor.create_request = fake_raw_create
+ sdk._request_executor.execute = fake_raw_execute
+
+ with mock.patch(
+ "prowler.providers.okta.lib.service.service.OktaSDKClient",
+ return_value=sdk,
+ ):
+ service = SystemLog(provider)
+
+ assert set(service.log_streams.keys()) == {"log-1", "log-2"}
+ assert service.log_streams["log-1"].status == "ACTIVE"
+ assert service.log_streams["log-2"].status == "INACTIVE"
+ assert service.log_streams["log-2"].type == "SPLUNK_CLOUD_LOGSTREAMING"
+
+ def test_raw_fallback_handles_empty_list(self):
+ from pydantic import ValidationError
+
+ provider = set_mocked_okta_provider()
+
+ async def failing(*_a, **_k):
+ raise ValidationError.from_exception_data(
+ title="LogStreamSettingsAws",
+ line_errors=[],
+ )
+
+ async def fake_create(*_a, **_k):
+ return ({"url": "/api/v1/logStreams"}, None)
+
+ async def fake_execute(_req):
+ return (None, "[]", None)
+
+ sdk = mock.MagicMock()
+ sdk.list_log_streams = failing
+ sdk._request_executor.create_request = fake_create
+ sdk._request_executor.execute = fake_execute
+
+ with mock.patch(
+ "prowler.providers.okta.lib.service.service.OktaSDKClient",
+ return_value=sdk,
+ ):
+ service = SystemLog(provider)
+
+ assert service.log_streams == {}
diff --git a/tests/providers/okta/services/systemlog/systemlog_streaming_enabled/systemlog_streaming_enabled_test.py b/tests/providers/okta/services/systemlog/systemlog_streaming_enabled/systemlog_streaming_enabled_test.py
new file mode 100644
index 0000000000..64d2bcb8fc
--- /dev/null
+++ b/tests/providers/okta/services/systemlog/systemlog_streaming_enabled/systemlog_streaming_enabled_test.py
@@ -0,0 +1,73 @@
+from unittest import mock
+
+from tests.providers.okta.okta_fixtures import set_mocked_okta_provider
+from tests.providers.okta.services.systemlog.systemlog_fixtures import (
+ build_systemlog_client,
+ log_stream,
+)
+
+CHECK_PATH = (
+ "prowler.providers.okta.services.systemlog."
+ "systemlog_streaming_enabled.systemlog_streaming_enabled.systemlog_client"
+)
+
+
+def _run_check(client):
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_okta_provider(),
+ ),
+ mock.patch(CHECK_PATH, new=client),
+ ):
+ from prowler.providers.okta.services.systemlog.systemlog_streaming_enabled.systemlog_streaming_enabled import (
+ systemlog_streaming_enabled,
+ )
+
+ return systemlog_streaming_enabled().execute()
+
+
+class Test_systemlog_streaming_enabled:
+ def test_pass_when_active_stream_exists(self):
+ client = build_systemlog_client(
+ log_streams={"log-1": log_stream(name="EventBridge prod")}
+ )
+ findings = _run_check(client)
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert "EventBridge prod" in findings[0].status_extended
+
+ def test_pass_when_multiple_active_streams(self):
+ client = build_systemlog_client(
+ log_streams={
+ "log-1": log_stream(stream_id="log-1", name="A"),
+ "log-2": log_stream(stream_id="log-2", name="B"),
+ }
+ )
+ findings = _run_check(client)
+ assert len(findings) == 2
+ assert all(f.status == "PASS" for f in findings)
+
+ def test_fail_when_all_streams_inactive(self):
+ client = build_systemlog_client(
+ log_streams={"log-1": log_stream(name="A", status="INACTIVE")}
+ )
+ findings = _run_check(client)
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert "none are ACTIVE" in findings[0].status_extended
+
+ def test_fail_when_no_streams_configured(self):
+ client = build_systemlog_client(log_streams={})
+ findings = _run_check(client)
+ assert findings[0].status == "FAIL"
+ assert "No Okta Log Streams are configured" in findings[0].status_extended
+ assert "mutelist" in findings[0].status_extended
+
+ def test_manual_when_scope_missing(self):
+ client = build_systemlog_client(
+ missing_scope={"log_streams": "okta.logStreams.read"}
+ )
+ findings = _run_check(client)
+ assert findings[0].status == "MANUAL"
+ assert "okta.logStreams.read" in findings[0].status_extended
diff --git a/tests/providers/okta/services/user/user_fixtures.py b/tests/providers/okta/services/user/user_fixtures.py
new file mode 100644
index 0000000000..99282c1efa
--- /dev/null
+++ b/tests/providers/okta/services/user/user_fixtures.py
@@ -0,0 +1,55 @@
+"""Shared helpers for `user` service check tests."""
+
+from unittest import mock
+
+from prowler.providers.okta.services.user.user_service import (
+ ExternalDirectoryIdp,
+ UserAutomation,
+)
+from tests.providers.okta.okta_fixtures import set_mocked_okta_provider
+
+
+def build_user_client(
+ automations: dict = None,
+ external_directory_idps: dict = None,
+ audit_config: dict = None,
+ missing_scope: dict = None,
+):
+ client = mock.MagicMock()
+ client.automations = automations or {}
+ client.external_directory_idps = external_directory_idps or {}
+ client.provider = set_mocked_okta_provider()
+ client.audit_config = audit_config or {}
+ client.missing_scope = missing_scope or {
+ "automations": None,
+ "identity_providers": None,
+ }
+ return client
+
+
+def automation(
+ automation_id: str = "auto-1",
+ name: str = "User Inactivity",
+ status: str = "ACTIVE",
+ schedule_status: str = "ACTIVE",
+ inactivity_days: int = 35,
+ lifecycle_action: str = "SUSPENDED",
+ groups: list = None,
+):
+ # `groups is None` keeps the "Everyone-equivalent" default; passing
+ # `groups=[]` lets a test exercise the empty-scope FAIL path.
+ return UserAutomation(
+ id=automation_id,
+ name=name,
+ status=status,
+ schedule_status=schedule_status,
+ inactivity_days=inactivity_days,
+ lifecycle_action=lifecycle_action,
+ applies_to_groups=["everyone"] if groups is None else groups,
+ )
+
+
+def ad_idp(idp_id: str = "0oa-ad", name: str = "Corp AD"):
+ return ExternalDirectoryIdp(
+ id=idp_id, name=name, type="ACTIVE_DIRECTORY", status="ACTIVE"
+ )
diff --git a/tests/providers/okta/services/user/user_inactivity_automation_35d_enabled/user_inactivity_automation_35d_enabled_test.py b/tests/providers/okta/services/user/user_inactivity_automation_35d_enabled/user_inactivity_automation_35d_enabled_test.py
new file mode 100644
index 0000000000..99cb7db6a0
--- /dev/null
+++ b/tests/providers/okta/services/user/user_inactivity_automation_35d_enabled/user_inactivity_automation_35d_enabled_test.py
@@ -0,0 +1,165 @@
+from unittest import mock
+
+from tests.providers.okta.okta_fixtures import set_mocked_okta_provider
+from tests.providers.okta.services.user.user_fixtures import (
+ ad_idp,
+ automation,
+ build_user_client,
+)
+
+CHECK_PATH = (
+ "prowler.providers.okta.services.user."
+ "user_inactivity_automation_35d_enabled."
+ "user_inactivity_automation_35d_enabled.user_client"
+)
+
+
+def _run_check(client):
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_okta_provider(),
+ ),
+ mock.patch(CHECK_PATH, new=client),
+ ):
+ from prowler.providers.okta.services.user.user_inactivity_automation_35d_enabled.user_inactivity_automation_35d_enabled import (
+ user_inactivity_automation_35d_enabled,
+ )
+
+ return user_inactivity_automation_35d_enabled().execute()
+
+
+class Test_user_inactivity_automation_35d_enabled:
+ def test_pass_when_compliant_automation_present(self):
+ client = build_user_client(
+ automations={"auto-1": automation(name="Inactivity 35d")}
+ )
+ findings = _run_check(client)
+ assert findings[0].status == "PASS"
+ assert "Inactivity 35d" in findings[0].status_extended
+ assert "SUSPENDED" in findings[0].status_extended
+
+ def test_pass_message_names_groups_and_asks_for_coverage_verification(self):
+ # Okta has no built-in Everyone group ID and group names vary by
+ # tenant (e.g. "pepito"), so we can't assert tenant-wide coverage
+ # automatically — surface the group IDs and let the operator verify.
+ client = build_user_client(
+ automations={"auto-1": automation(groups=["grp-A", "grp-B"])}
+ )
+ findings = _run_check(client)
+ assert findings[0].status == "PASS"
+ assert "grp-A, grp-B" in findings[0].status_extended
+ assert "cover every user" in findings[0].status_extended
+
+ def test_fail_when_applies_to_no_group(self):
+ # An automation with empty `people.groups.include` runs against
+ # nobody — Okta does not implicitly cover every user.
+ client = build_user_client(automations={"auto-1": automation(groups=[])})
+ findings = _run_check(client)
+ assert findings[0].status == "FAIL"
+ assert "no group scope" in findings[0].status_extended
+
+ def test_pass_when_lower_threshold(self):
+ # Inactivity threshold lower than the default is still compliant.
+ client = build_user_client(
+ automations={"auto-1": automation(inactivity_days=14)}
+ )
+ findings = _run_check(client)
+ assert findings[0].status == "PASS"
+
+ def test_fail_when_threshold_too_high(self):
+ client = build_user_client(
+ automations={"auto-1": automation(inactivity_days=90)}
+ )
+ findings = _run_check(client)
+ assert findings[0].status == "FAIL"
+ assert "inactivity 90d (max 35d)" in findings[0].status_extended
+
+ def test_fail_when_status_inactive(self):
+ client = build_user_client(
+ automations={"auto-1": automation(status="INACTIVE")}
+ )
+ findings = _run_check(client)
+ assert findings[0].status == "FAIL"
+ assert "status INACTIVE" in findings[0].status_extended
+
+ def test_fail_when_schedule_inactive(self):
+ client = build_user_client(
+ automations={"auto-1": automation(schedule_status="INACTIVE")}
+ )
+ findings = _run_check(client)
+ assert findings[0].status == "FAIL"
+ assert "schedule INACTIVE" in findings[0].status_extended
+
+ def test_fail_when_wrong_lifecycle_action(self):
+ client = build_user_client(
+ automations={"auto-1": automation(lifecycle_action="ACTIVE")}
+ )
+ findings = _run_check(client)
+ assert findings[0].status == "FAIL"
+ assert "action ACTIVE" in findings[0].status_extended
+
+ def test_fail_when_no_automations(self):
+ client = build_user_client(automations={})
+ findings = _run_check(client)
+ assert findings[0].status == "FAIL"
+ assert "No Okta Workflows automations" in findings[0].status_extended
+
+ def test_fail_lists_every_missing_piece_for_unfinished_automation(self):
+ # Mirrors the real-world case where an admin clicks "Add Automation"
+ # in the UI but never configures conditions or actions. The service
+ # emits a placeholder UserAutomation so the check FAILs with a
+ # specific message instead of pretending the policy doesn't exist.
+ from prowler.providers.okta.services.user.user_service import UserAutomation
+
+ shell = UserAutomation(
+ id="pol-1",
+ name="TestCheck",
+ status="INACTIVE",
+ schedule_status="INACTIVE",
+ inactivity_days=None,
+ lifecycle_action=None,
+ applies_to_groups=[],
+ policy_id="pol-1",
+ policy_name="TestCheck",
+ )
+ client = build_user_client(automations={"pol-1": shell})
+ findings = _run_check(client)
+ assert findings[0].status == "FAIL"
+ msg = findings[0].status_extended
+ assert "TestCheck" in msg
+ assert "status INACTIVE" in msg
+ assert "schedule INACTIVE" in msg
+ assert "no inactivity condition" in msg
+ assert "action unset" in msg
+
+ def test_manual_na_when_external_directory_idp_present(self):
+ client = build_user_client(
+ automations={"auto-1": automation(inactivity_days=90)}, # non-compliant
+ external_directory_idps={"0oa-ad": ad_idp(name="Corp AD")},
+ )
+ findings = _run_check(client)
+ # External directory short-circuits to MANUAL N/A regardless of
+ # the automations state.
+ assert findings[0].status == "MANUAL"
+ assert "ACTIVE_DIRECTORY" in findings[0].status_extended
+ assert "Corp AD" in findings[0].status_extended
+
+ def test_manual_when_scope_missing(self):
+ client = build_user_client(
+ missing_scope={
+ "automations": "okta.policies.read",
+ "identity_providers": None,
+ }
+ )
+ findings = _run_check(client)
+ assert findings[0].status == "MANUAL"
+ assert "okta.policies.read" in findings[0].status_extended
+
+ def test_threshold_overridden_via_audit_config(self):
+ client = build_user_client(
+ automations={"auto-1": automation(inactivity_days=60)},
+ audit_config={"okta_user_inactivity_max_days": 90},
+ )
+ findings = _run_check(client)
+ assert findings[0].status == "PASS"
diff --git a/tests/providers/okta/services/user/user_service_test.py b/tests/providers/okta/services/user/user_service_test.py
new file mode 100644
index 0000000000..f4e0309b7b
--- /dev/null
+++ b/tests/providers/okta/services/user/user_service_test.py
@@ -0,0 +1,477 @@
+import json
+from unittest import mock
+
+from prowler.providers.okta.services.user.user_service import (
+ ExternalDirectoryIdp,
+ User,
+ UserAutomation,
+ _raw_rule_to_automation,
+ _rule_to_automation,
+)
+from tests.providers.okta.okta_fixtures import set_mocked_okta_provider
+
+
+def _resp(headers: dict = None):
+ r = mock.MagicMock()
+ r.headers = headers or {}
+ return r
+
+
+def _fake_policy(
+ policy_id,
+ name="Inactivity Policy",
+ status="ACTIVE",
+ inactivity_days=35,
+ inactivity_unit="DAYS",
+ groups=None,
+):
+ # In the actual API response, the inactivity condition and the
+ # group scope live on the *policy*, not on its rules — keep the
+ # typed fixture aligned with that shape so it mirrors raw JSON.
+ p = mock.MagicMock()
+ p.id = policy_id
+ p.name = name
+ p.status = status
+ if inactivity_days is None:
+ p.conditions.people.users.inactivity = None
+ else:
+ p.conditions.people.users.inactivity.number = inactivity_days
+ p.conditions.people.users.inactivity.unit = inactivity_unit
+ p.conditions.people.groups.include = ["everyone"] if groups is None else groups
+ return p
+
+
+def _fake_rule(
+ rule_id="rule-1",
+ name="Inactivity",
+ status="ACTIVE",
+ lifecycle_action="SUSPENDED",
+):
+ # A USER_LIFECYCLE policy rule carries only the lifecycle action;
+ # its `conditions` is typically empty.
+ r = mock.MagicMock()
+ r.id = rule_id
+ r.name = name
+ r.status = status
+ r.actions.user_lifecycle.action = lifecycle_action
+ return r
+
+
+def _fake_idp(idp_type, status="ACTIVE", idp_id="0oa-1", name="x"):
+ idp = mock.MagicMock()
+ idp.id = idp_id
+ idp.name = name
+ idp.type = idp_type
+ idp.status = status
+ return idp
+
+
+def _patch_sdk(**methods):
+ return mock.patch(
+ "prowler.providers.okta.lib.service.service.OktaSDKClient",
+ return_value=mock.MagicMock(**methods),
+ )
+
+
+class Test_rule_to_automation:
+ def test_parses_inactivity_and_lifecycle(self):
+ policy = _fake_policy("pol-1", name="Inactivity Policy")
+ rule = _fake_rule(rule_id="rule-1", name="Inactivity")
+ m = _rule_to_automation(rule, policy)
+ assert isinstance(m, UserAutomation)
+ assert m.id == "rule-1"
+ assert m.status == "ACTIVE"
+ assert m.schedule_status == "ACTIVE"
+ assert m.inactivity_days == 35
+ assert m.lifecycle_action == "SUSPENDED"
+ assert m.applies_to_groups == ["everyone"]
+ assert m.policy_id == "pol-1"
+ assert m.policy_name == "Inactivity Policy"
+
+ def test_returns_none_when_id_missing(self):
+ policy = _fake_policy("pol")
+ bad = _fake_rule()
+ bad.id = ""
+ assert _rule_to_automation(bad, policy) is None
+
+ def test_ignores_non_days_unit(self):
+ policy = _fake_policy("pol", inactivity_unit="WEEKS")
+ rule = _fake_rule()
+ m = _rule_to_automation(rule, policy)
+ assert m.inactivity_days is None
+
+ def test_reads_inactivity_and_groups_from_policy_not_rule(self):
+ # The typed path used to read inactivity/groups from the rule;
+ # an SDK update that started populating `policy.conditions`
+ # exposed the mismatch. Locking the policy-shaped projection in.
+ policy = _fake_policy("pol", inactivity_days=21, groups=["grp-x"])
+ rule = _fake_rule()
+ # Sanity: nothing inactivity-ish on the rule.
+ del rule.conditions
+ m = _rule_to_automation(rule, policy)
+ assert m.inactivity_days == 21
+ assert m.applies_to_groups == ["grp-x"]
+
+
+class Test_User_service:
+ def test_fetches_automations_via_policy_api(self):
+ provider = set_mocked_okta_provider()
+ policy = _fake_policy("pol-1")
+ rule = _fake_rule(rule_id="rule-1")
+
+ async def fake_list_policies(*_a, **_k):
+ return ([policy], _resp({}), None)
+
+ async def fake_list_rules(*_a, **_k):
+ return ([rule], _resp({}), None)
+
+ async def fake_list_idps(*_a, **_k):
+ return ([], _resp({}), None)
+
+ sdk = mock.MagicMock()
+ sdk.list_policies = fake_list_policies
+ sdk.list_policy_rules = fake_list_rules
+ sdk.list_identity_providers = fake_list_idps
+
+ with mock.patch(
+ "prowler.providers.okta.lib.service.service.OktaSDKClient",
+ return_value=sdk,
+ ):
+ service = User(provider)
+
+ assert "rule-1" in service.automations
+ assert service.automations["rule-1"].inactivity_days == 35
+ assert service.external_directory_idps == {}
+
+ def test_returns_empty_on_policies_api_error(self):
+ provider = set_mocked_okta_provider()
+
+ async def failing(*_a, **_k):
+ return ([], _resp({}), Exception("E0000007"))
+
+ async def fake_list_idps(*_a, **_k):
+ return ([], _resp({}), None)
+
+ sdk = mock.MagicMock()
+ sdk.list_policies = failing
+ sdk.list_identity_providers = fake_list_idps
+
+ with mock.patch(
+ "prowler.providers.okta.lib.service.service.OktaSDKClient",
+ return_value=sdk,
+ ):
+ service = User(provider)
+
+ assert service.automations == {}
+
+ def test_detects_external_directory_idp(self):
+ provider = set_mocked_okta_provider()
+
+ async def empty_policies(*_a, **_k):
+ return ([], _resp({}), None)
+
+ ad = _fake_idp("ACTIVE_DIRECTORY", idp_id="0oa-ad", name="Corp AD")
+ saml = _fake_idp("SAML2", idp_id="0oa-saml")
+
+ async def fake_list_idps(*_a, **_k):
+ return ([ad, saml], _resp({}), None)
+
+ sdk = mock.MagicMock()
+ sdk.list_policies = empty_policies
+ sdk.list_identity_providers = fake_list_idps
+
+ with mock.patch(
+ "prowler.providers.okta.lib.service.service.OktaSDKClient",
+ return_value=sdk,
+ ):
+ service = User(provider)
+
+ assert "0oa-ad" in service.external_directory_idps
+ assert "0oa-saml" not in service.external_directory_idps
+ assert isinstance(
+ service.external_directory_idps["0oa-ad"], ExternalDirectoryIdp
+ )
+
+
+class Test_raw_rule_to_automation:
+ def test_projects_inactivity_and_lifecycle(self):
+ # Real API shape: inactivity + groups live on the POLICY,
+ # lifecycle action lives on the RULE under
+ # `actions.updateUserLifecycle.targetStatus`.
+ policy = {
+ "id": "pol-1",
+ "name": "TestCheck",
+ "status": "ACTIVE",
+ "conditions": {
+ "people": {
+ "users": {"inactivity": {"number": 35, "unit": "DAYS"}},
+ "groups": {"include": ["everyone"]},
+ }
+ },
+ "type": "USER_LIFECYCLE",
+ }
+ rule = {
+ "id": "rule-1",
+ "name": "lifecycle-rule-1",
+ "status": "ACTIVE",
+ "conditions": {},
+ "actions": {
+ "updateUserLifecycle": {
+ "targetStatus": "SUSPENDED",
+ "quietPeriod": {"number": 0, "unit": "DAYS"},
+ }
+ },
+ }
+ m = _raw_rule_to_automation(rule, policy, "pol-1", "TestCheck", "ACTIVE")
+ assert isinstance(m, UserAutomation)
+ assert m.id == "rule-1"
+ assert m.status == "ACTIVE"
+ assert m.schedule_status == "ACTIVE"
+ assert m.inactivity_days == 35
+ assert m.lifecycle_action == "SUSPENDED"
+ assert m.applies_to_groups == ["everyone"]
+ assert m.policy_id == "pol-1"
+ assert m.policy_name == "TestCheck"
+
+ def test_returns_none_when_id_missing(self):
+ assert _raw_rule_to_automation({"name": "x"}, {}, "pol", "P", "ACTIVE") is None
+
+ def test_ignores_non_days_unit(self):
+ policy = {
+ "id": "pol",
+ "conditions": {
+ "people": {"users": {"inactivity": {"number": 5, "unit": "WEEKS"}}}
+ },
+ }
+ rule = {"id": "rule-2", "actions": {}}
+ m = _raw_rule_to_automation(rule, policy, "pol", "P", "ACTIVE")
+ assert m.inactivity_days is None
+
+ def test_missing_policy_dict_gives_empty_inactivity_and_groups(self):
+ rule = {
+ "id": "rule-3",
+ "actions": {"updateUserLifecycle": {"targetStatus": "SUSPENDED"}},
+ }
+ m = _raw_rule_to_automation(rule, None, "pol", "P", "ACTIVE")
+ assert m.inactivity_days is None
+ assert m.applies_to_groups == []
+ assert m.lifecycle_action == "SUSPENDED"
+
+
+class Test_User_service_sdk_discriminator_fallback:
+ """Verifies the raw-JSON fallback when the SDK can't deserialize USER_LIFECYCLE.
+
+ Okta SDK 3.4.2 ships a `Policy.from_dict` discriminator mapping that
+ omits `USER_LIFECYCLE`, so the typed call raises ValueError. Without
+ the fallback the whole automations list is lost; with it the raw
+ JSON path projects each rule onto a `UserAutomation` snapshot.
+ """
+
+ def test_raw_fallback_projects_user_lifecycle_policy_rules(self):
+ provider = set_mocked_okta_provider()
+
+ # Real API shape: inactivity + groups on POLICY, lifecycle
+ # action on RULE under `actions.updateUserLifecycle.targetStatus`.
+ policy_payload = [
+ {
+ "id": "pol-1",
+ "name": "TestCheck",
+ "status": "ACTIVE",
+ "type": "USER_LIFECYCLE",
+ "conditions": {
+ "people": {
+ "users": {"inactivity": {"number": 35, "unit": "DAYS"}},
+ "groups": {"include": ["everyone"]},
+ }
+ },
+ }
+ ]
+ rules_payload = [
+ {
+ "id": "rule-1",
+ "name": "lifecycle-rule-1",
+ "status": "ACTIVE",
+ "conditions": {},
+ "actions": {
+ "updateUserLifecycle": {
+ "targetStatus": "SUSPENDED",
+ "quietPeriod": {"number": 0, "unit": "DAYS"},
+ }
+ },
+ }
+ ]
+
+ async def failing_list_policies(*_a, **_k):
+ raise ValueError(
+ "Policy failed to lookup discriminator value from {...}. "
+ "Discriminator property name: type, mapping: {...}"
+ )
+
+ async def fake_list_idps(*_a, **_k):
+ return ([], _resp({}), None)
+
+ async def fake_raw_create(*_a, **kwargs):
+ url = kwargs.get("url", "") or ""
+ return ({"url": url}, None)
+
+ async def fake_raw_execute(request):
+ url = request.get("url", "")
+ if "/api/v1/policies/pol-1/rules" in url:
+ return (None, json.dumps(rules_payload), None)
+ if "/api/v1/policies" in url:
+ return (None, json.dumps(policy_payload), None)
+ return (None, "[]", None)
+
+ sdk = mock.MagicMock()
+ sdk.list_policies = failing_list_policies
+ sdk.list_identity_providers = fake_list_idps
+ sdk._request_executor.create_request = fake_raw_create
+ sdk._request_executor.execute = fake_raw_execute
+
+ with mock.patch(
+ "prowler.providers.okta.lib.service.service.OktaSDKClient",
+ return_value=sdk,
+ ):
+ service = User(provider)
+
+ assert "rule-1" in service.automations
+ a = service.automations["rule-1"]
+ assert a.inactivity_days == 35
+ assert a.lifecycle_action == "SUSPENDED"
+ assert a.schedule_status == "ACTIVE"
+ assert a.policy_id == "pol-1"
+ assert a.policy_name == "TestCheck"
+
+ def test_raw_fallback_emits_shell_for_policy_with_no_rules(self):
+ # Mirrors the real-world tenant state where an admin clicked
+ # "Add Automation" in the UI but never configured conditions or
+ # actions. The policy exists; it has zero rules. The raw fallback
+ # must surface the policy as a shell UserAutomation so the check
+ # FAILs with a specific message instead of dropping it.
+ provider = set_mocked_okta_provider()
+
+ async def failing_list_policies(*_a, **_k):
+ raise ValueError("missing discriminator mapping")
+
+ async def fake_list_idps(*_a, **_k):
+ return ([], _resp({}), None)
+
+ async def fake_raw_create(*_a, **kwargs):
+ return ({"url": kwargs.get("url", "") or ""}, None)
+
+ async def fake_raw_execute(request):
+ url = request.get("url", "")
+ if "/api/v1/policies/pol-empty/rules" in url:
+ return (None, "[]", None)
+ if "/api/v1/policies" in url:
+ return (
+ None,
+ json.dumps(
+ [
+ {
+ "id": "pol-empty",
+ "name": "TestCheck",
+ "status": "INACTIVE",
+ "type": "USER_LIFECYCLE",
+ }
+ ]
+ ),
+ None,
+ )
+ return (None, "[]", None)
+
+ sdk = mock.MagicMock()
+ sdk.list_policies = failing_list_policies
+ sdk.list_identity_providers = fake_list_idps
+ sdk._request_executor.create_request = fake_raw_create
+ sdk._request_executor.execute = fake_raw_execute
+
+ with mock.patch(
+ "prowler.providers.okta.lib.service.service.OktaSDKClient",
+ return_value=sdk,
+ ):
+ service = User(provider)
+
+ assert "pol-empty" in service.automations
+ shell = service.automations["pol-empty"]
+ assert shell.name == "TestCheck"
+ assert shell.status == "INACTIVE"
+ assert shell.schedule_status == "INACTIVE"
+ assert shell.inactivity_days is None
+ assert shell.lifecycle_action is None
+ assert shell.applies_to_groups == []
+ assert shell.policy_id == "pol-empty"
+
+ def test_rule_typed_failure_triggers_raw_fallback_for_all_policies(self):
+ # When the typed `list_policies` succeeds but the typed
+ # `list_policy_rules` fails for a policy, the previous behavior
+ # was to emit a shell automation — silently misclassifying a
+ # valid automation as "unfinished". Now `_fetch_rules` returns
+ # None as a sentinel and the caller re-runs the entire
+ # discovery via raw JSON so no rule data is lost.
+ provider = set_mocked_okta_provider()
+
+ typed_policy = _fake_policy(
+ "pol-1", name="TestCheck", inactivity_days=35, groups=["everyone"]
+ )
+
+ async def fake_list_policies(*_a, **_k):
+ return ([typed_policy], _resp({}), None)
+
+ async def failing_list_policy_rules(*_a, **_k):
+ raise ValueError("KnowledgeConstraint.types expected uppercase")
+
+ async def fake_list_idps(*_a, **_k):
+ return ([], _resp({}), None)
+
+ raw_policy_payload = [
+ {
+ "id": "pol-1",
+ "name": "TestCheck",
+ "status": "ACTIVE",
+ "type": "USER_LIFECYCLE",
+ "conditions": {
+ "people": {
+ "users": {"inactivity": {"number": 35, "unit": "DAYS"}},
+ "groups": {"include": ["everyone"]},
+ }
+ },
+ }
+ ]
+ raw_rules_payload = [
+ {
+ "id": "rule-1",
+ "name": "lifecycle-rule-1",
+ "status": "ACTIVE",
+ "actions": {"updateUserLifecycle": {"targetStatus": "SUSPENDED"}},
+ }
+ ]
+
+ async def fake_raw_create(*_a, **kwargs):
+ return ({"url": kwargs.get("url", "") or ""}, None)
+
+ async def fake_raw_execute(request):
+ url = request.get("url", "")
+ if "/api/v1/policies/pol-1/rules" in url:
+ return (None, json.dumps(raw_rules_payload), None)
+ if "/api/v1/policies" in url:
+ return (None, json.dumps(raw_policy_payload), None)
+ return (None, "[]", None)
+
+ sdk = mock.MagicMock()
+ sdk.list_policies = fake_list_policies
+ sdk.list_policy_rules = failing_list_policy_rules
+ sdk.list_identity_providers = fake_list_idps
+ sdk._request_executor.create_request = fake_raw_create
+ sdk._request_executor.execute = fake_raw_execute
+
+ with mock.patch(
+ "prowler.providers.okta.lib.service.service.OktaSDKClient",
+ return_value=sdk,
+ ):
+ service = User(provider)
+
+ # Raw-projected automation, not a shell.
+ assert "rule-1" in service.automations
+ assert service.automations["rule-1"].inactivity_days == 35
+ assert service.automations["rule-1"].lifecycle_action == "SUSPENDED"
From 1c9afc714e3580fb0d847f0fbb6384290cf8acb5 Mon Sep 17 00:00:00 2001
From: Aline Almeida
Date: Mon, 8 Jun 2026 16:46:48 +0200
Subject: [PATCH 019/129] fix(gcp): honour org-aggregated sinks in
metric-filter checks (#11488)
Co-authored-by: Hugo P.Brito
---
prowler/CHANGELOG.md | 1 +
...for_audit_configuration_changes_enabled.py | 22 ++-
...t_for_bucket_permission_changes_enabled.py | 20 +-
...r_compute_configuration_changes_enabled.py | 17 +-
...d_alert_for_custom_role_changes_enabled.py | 20 +-
...t_for_project_ownership_changes_enabled.py | 20 +-
..._instance_configuration_changes_enabled.py | 17 +-
...t_for_vpc_firewall_rule_changes_enabled.py | 20 +-
...d_alert_for_vpc_network_changes_enabled.py | 20 +-
...t_for_vpc_network_route_changes_enabled.py | 20 +-
.../gcp/services/logging/logging_service.py | 57 ++++++
...udit_configuration_changes_enabled_test.py | 173 ++++++++++++++++++
..._bucket_permission_changes_enabled_test.py | 170 +++++++++++++++++
...pute_configuration_changes_enabled_test.py | 170 +++++++++++++++++
...rt_for_custom_role_changes_enabled_test.py | 170 +++++++++++++++++
..._project_ownership_changes_enabled_test.py | 170 +++++++++++++++++
...ance_configuration_changes_enabled_test.py | 170 +++++++++++++++++
..._vpc_firewall_rule_changes_enabled_test.py | 170 +++++++++++++++++
...rt_for_vpc_network_changes_enabled_test.py | 170 +++++++++++++++++
..._vpc_network_route_changes_enabled_test.py | 170 +++++++++++++++++
.../services/logging/logging_service_test.py | 163 +++++++++++++++++
21 files changed, 1882 insertions(+), 48 deletions(-)
diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md
index 45cf485716..1389b684cf 100644
--- a/prowler/CHANGELOG.md
+++ b/prowler/CHANGELOG.md
@@ -18,6 +18,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
### 🐞 Fixed
- GCP `logging_sink_created` now recognizes organization-level aggregated sinks with `includeChildren=True`, avoiding false failures for covered projects [(#11355)](https://github.com/prowler-cloud/prowler/pull/11355)
+- GCP `logging_log_metric_filter_and_alert_*` checks now recognize organization-level aggregated sinks with `includeChildren=True`, no longer false-failing projects covered by a central bucket-scoped metric + alert [(#11488)](https://github.com/prowler-cloud/prowler/pull/11488)
- Jira integration no longer fails with `400 INVALID_INPUT` when a finding has empty fields [(#11474)](https://github.com/prowler-cloud/prowler/pull/11474)
- GCP `iam_service_account_unused` now passes disabled service accounts instead of failing them, since a disabled account cannot authenticate or be used [(#11467)](https://github.com/prowler-cloud/prowler/pull/11467)
- AWS AI Security Framework now renders in the dashboard instead of showing "No data found for this compliance", by adding the missing compliance view module [(#11470)](https://github.com/prowler-cloud/prowler/pull/11470)
diff --git a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.py b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.py
index 4654be4d29..84bf078dac 100644
--- a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.py
+++ b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.py
@@ -1,5 +1,8 @@
from prowler.lib.check.models import Check, Check_Report_GCP
from prowler.providers.gcp.services.logging.logging_client import logging_client
+from prowler.providers.gcp.services.logging.logging_service import (
+ get_projects_covered_by_aggregated_metric,
+)
from prowler.providers.gcp.services.monitoring.monitoring_client import (
monitoring_client,
)
@@ -10,12 +13,10 @@ class logging_log_metric_filter_and_alert_for_audit_configuration_changes_enable
):
def execute(self) -> Check_Report_GCP:
findings = []
+ metric_filter = 'protoPayload.methodName="SetIamPolicy" AND protoPayload.serviceData.policyDelta.auditConfigDeltas:*'
projects_with_metric = set()
for metric in logging_client.metrics:
- if (
- 'protoPayload.methodName="SetIamPolicy" AND protoPayload.serviceData.policyDelta.auditConfigDeltas:*'
- in metric.filter
- ):
+ if metric_filter in metric.filter:
report = Check_Report_GCP(
metadata=self.metadata(),
resource=metric,
@@ -33,6 +34,11 @@ class logging_log_metric_filter_and_alert_for_audit_configuration_changes_enable
break
findings.append(report)
+ # Credit projects whose logs are centrally monitored via an org-level
+ # aggregated sink to a bucket-scoped metric + alert (instead of failing them).
+ centrally_covered = get_projects_covered_by_aggregated_metric(
+ logging_client, monitoring_client, metric_filter
+ )
for project in logging_client.project_ids:
if project not in projects_with_metric:
report = Check_Report_GCP(
@@ -46,8 +52,12 @@ class logging_log_metric_filter_and_alert_for_audit_configuration_changes_enable
else "GCP Project"
),
)
- report.status = "FAIL"
- report.status_extended = f"There are no log metric filters or alerts associated in project {project}."
+ if project in centrally_covered:
+ report.status = "PASS"
+ report.status_extended = f"Log metric filter {centrally_covered[project]} found with an alert, covering project {project} via an organization-level aggregated sink."
+ else:
+ report.status = "FAIL"
+ report.status_extended = f"There are no log metric filters or alerts associated in project {project}."
findings.append(report)
return findings
diff --git a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.py b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.py
index 166f7b7ee8..e7d74f3f8e 100644
--- a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.py
+++ b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.py
@@ -1,5 +1,8 @@
from prowler.lib.check.models import Check, Check_Report_GCP
from prowler.providers.gcp.services.logging.logging_client import logging_client
+from prowler.providers.gcp.services.logging.logging_service import (
+ get_projects_covered_by_aggregated_metric,
+)
from prowler.providers.gcp.services.monitoring.monitoring_client import (
monitoring_client,
)
@@ -8,12 +11,10 @@ from prowler.providers.gcp.services.monitoring.monitoring_client import (
class logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled(Check):
def execute(self) -> Check_Report_GCP:
findings = []
+ metric_filter = 'resource.type="gcs_bucket" AND protoPayload.methodName="storage.setIamPermissions"'
projects_with_metric = set()
for metric in logging_client.metrics:
- if (
- 'resource.type="gcs_bucket" AND protoPayload.methodName="storage.setIamPermissions"'
- in metric.filter
- ):
+ if metric_filter in metric.filter:
metric_name = getattr(metric, "name", None) or "unknown"
report = Check_Report_GCP(
metadata=self.metadata(),
@@ -36,6 +37,9 @@ class logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled(
break
findings.append(report)
+ centrally_covered = get_projects_covered_by_aggregated_metric(
+ logging_client, monitoring_client, metric_filter
+ )
for project in logging_client.project_ids:
if project not in projects_with_metric:
project_obj = logging_client.projects.get(project)
@@ -46,8 +50,12 @@ class logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled(
location=logging_client.region,
resource_name=(getattr(project_obj, "name", None) or "GCP Project"),
)
- report.status = "FAIL"
- report.status_extended = f"There are no log metric filters or alerts associated in project {project}."
+ if project in centrally_covered:
+ report.status = "PASS"
+ report.status_extended = f"Log metric filter {centrally_covered[project]} found with an alert, covering project {project} via an organization-level aggregated sink."
+ else:
+ report.status = "FAIL"
+ report.status_extended = f"There are no log metric filters or alerts associated in project {project}."
findings.append(report)
return findings
diff --git a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.py b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.py
index 7902f9ed72..cf7cdb1679 100644
--- a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.py
+++ b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.py
@@ -1,5 +1,8 @@
from prowler.lib.check.models import Check, Check_Report_GCP
from prowler.providers.gcp.services.logging.logging_client import logging_client
+from prowler.providers.gcp.services.logging.logging_service import (
+ get_projects_covered_by_aggregated_metric,
+)
from prowler.providers.gcp.services.monitoring.monitoring_client import (
monitoring_client,
)
@@ -10,9 +13,10 @@ class logging_log_metric_filter_and_alert_for_compute_configuration_changes_enab
):
def execute(self) -> Check_Report_GCP:
findings = []
+ metric_filter = 'protoPayload.serviceName="compute.googleapis.com"'
projects_with_metric = set()
for metric in logging_client.metrics:
- if 'protoPayload.serviceName="compute.googleapis.com"' in metric.filter:
+ if metric_filter in metric.filter:
report = Check_Report_GCP(
metadata=self.metadata(),
resource=metric,
@@ -30,6 +34,9 @@ class logging_log_metric_filter_and_alert_for_compute_configuration_changes_enab
break
findings.append(report)
+ centrally_covered = get_projects_covered_by_aggregated_metric(
+ logging_client, monitoring_client, metric_filter
+ )
for project in logging_client.project_ids:
if project not in projects_with_metric:
report = Check_Report_GCP(
@@ -43,8 +50,12 @@ class logging_log_metric_filter_and_alert_for_compute_configuration_changes_enab
else "GCP Project"
),
)
- report.status = "FAIL"
- report.status_extended = f"There are no log metric filters or alerts associated for Compute Engine configuration changes in project {project}."
+ if project in centrally_covered:
+ report.status = "PASS"
+ report.status_extended = f"Log metric filter {centrally_covered[project]} found with an alert, covering project {project} via an organization-level aggregated sink."
+ else:
+ report.status = "FAIL"
+ report.status_extended = f"There are no log metric filters or alerts associated for Compute Engine configuration changes in project {project}."
findings.append(report)
return findings
diff --git a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.py b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.py
index 1e6584e5fb..f836dc25b2 100644
--- a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.py
+++ b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.py
@@ -1,5 +1,8 @@
from prowler.lib.check.models import Check, Check_Report_GCP
from prowler.providers.gcp.services.logging.logging_client import logging_client
+from prowler.providers.gcp.services.logging.logging_service import (
+ get_projects_covered_by_aggregated_metric,
+)
from prowler.providers.gcp.services.monitoring.monitoring_client import (
monitoring_client,
)
@@ -8,12 +11,10 @@ from prowler.providers.gcp.services.monitoring.monitoring_client import (
class logging_log_metric_filter_and_alert_for_custom_role_changes_enabled(Check):
def execute(self) -> Check_Report_GCP:
findings = []
+ metric_filter = 'resource.type="iam_role" AND (protoPayload.methodName="google.iam.admin.v1.CreateRole" OR protoPayload.methodName="google.iam.admin.v1.DeleteRole" OR protoPayload.methodName="google.iam.admin.v1.UpdateRole")'
projects_with_metric = set()
for metric in logging_client.metrics:
- if (
- 'resource.type="iam_role" AND (protoPayload.methodName="google.iam.admin.v1.CreateRole" OR protoPayload.methodName="google.iam.admin.v1.DeleteRole" OR protoPayload.methodName="google.iam.admin.v1.UpdateRole")'
- in metric.filter
- ):
+ if metric_filter in metric.filter:
report = Check_Report_GCP(
metadata=self.metadata(),
resource=metric,
@@ -31,6 +32,9 @@ class logging_log_metric_filter_and_alert_for_custom_role_changes_enabled(Check)
break
findings.append(report)
+ centrally_covered = get_projects_covered_by_aggregated_metric(
+ logging_client, monitoring_client, metric_filter
+ )
for project in logging_client.project_ids:
if project not in projects_with_metric:
report = Check_Report_GCP(
@@ -44,8 +48,12 @@ class logging_log_metric_filter_and_alert_for_custom_role_changes_enabled(Check)
else "GCP Project"
),
)
- report.status = "FAIL"
- report.status_extended = f"There are no log metric filters or alerts associated in project {project}."
+ if project in centrally_covered:
+ report.status = "PASS"
+ report.status_extended = f"Log metric filter {centrally_covered[project]} found with an alert, covering project {project} via an organization-level aggregated sink."
+ else:
+ report.status = "FAIL"
+ report.status_extended = f"There are no log metric filters or alerts associated in project {project}."
findings.append(report)
return findings
diff --git a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.py b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.py
index 8c8927ec32..b7bc619ea4 100644
--- a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.py
+++ b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.py
@@ -1,5 +1,8 @@
from prowler.lib.check.models import Check, Check_Report_GCP
from prowler.providers.gcp.services.logging.logging_client import logging_client
+from prowler.providers.gcp.services.logging.logging_service import (
+ get_projects_covered_by_aggregated_metric,
+)
from prowler.providers.gcp.services.monitoring.monitoring_client import (
monitoring_client,
)
@@ -8,12 +11,10 @@ from prowler.providers.gcp.services.monitoring.monitoring_client import (
class logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled(Check):
def execute(self) -> Check_Report_GCP:
findings = []
+ metric_filter = '(protoPayload.serviceName="cloudresourcemanager.googleapis.com") AND (ProjectOwnership OR projectOwnerInvitee) OR (protoPayload.serviceData.policyDelta.bindingDeltas.action="REMOVE" AND protoPayload.serviceData.policyDelta.bindingDeltas.role="roles/owner") OR (protoPayload.serviceData.policyDelta.bindingDeltas.action="ADD" AND protoPayload.serviceData.policyDelta.bindingDeltas.role="roles/owner")'
projects_with_metric = set()
for metric in logging_client.metrics:
- if (
- '(protoPayload.serviceName="cloudresourcemanager.googleapis.com") AND (ProjectOwnership OR projectOwnerInvitee) OR (protoPayload.serviceData.policyDelta.bindingDeltas.action="REMOVE" AND protoPayload.serviceData.policyDelta.bindingDeltas.role="roles/owner") OR (protoPayload.serviceData.policyDelta.bindingDeltas.action="ADD" AND protoPayload.serviceData.policyDelta.bindingDeltas.role="roles/owner")'
- in metric.filter
- ):
+ if metric_filter in metric.filter:
metric_name = getattr(metric, "name", None) or "unknown"
report = Check_Report_GCP(
metadata=self.metadata(),
@@ -36,6 +37,9 @@ class logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled(
break
findings.append(report)
+ centrally_covered = get_projects_covered_by_aggregated_metric(
+ logging_client, monitoring_client, metric_filter
+ )
for project in logging_client.project_ids:
if project not in projects_with_metric:
project_obj = logging_client.projects.get(project)
@@ -47,8 +51,12 @@ class logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled(
location=logging_client.region,
resource_name=(getattr(project_obj, "name", None) or "GCP Project"),
)
- report.status = "FAIL"
- report.status_extended = f"There are no log metric filters or alerts associated in project {project}."
+ if project in centrally_covered:
+ report.status = "PASS"
+ report.status_extended = f"Log metric filter {centrally_covered[project]} found with an alert, covering project {project} via an organization-level aggregated sink."
+ else:
+ report.status = "FAIL"
+ report.status_extended = f"There are no log metric filters or alerts associated in project {project}."
findings.append(report)
return findings
diff --git a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.py b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.py
index 3e499db10a..3c03ab0fde 100644
--- a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.py
+++ b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.py
@@ -1,5 +1,8 @@
from prowler.lib.check.models import Check, Check_Report_GCP
from prowler.providers.gcp.services.logging.logging_client import logging_client
+from prowler.providers.gcp.services.logging.logging_service import (
+ get_projects_covered_by_aggregated_metric,
+)
from prowler.providers.gcp.services.monitoring.monitoring_client import (
monitoring_client,
)
@@ -10,9 +13,10 @@ class logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes
):
def execute(self) -> Check_Report_GCP:
findings = []
+ metric_filter = 'protoPayload.methodName="cloudsql.instances.update"'
projects_with_metric = set()
for metric in logging_client.metrics:
- if 'protoPayload.methodName="cloudsql.instances.update"' in metric.filter:
+ if metric_filter in metric.filter:
report = Check_Report_GCP(
metadata=self.metadata(),
resource=metric,
@@ -30,6 +34,9 @@ class logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes
break
findings.append(report)
+ centrally_covered = get_projects_covered_by_aggregated_metric(
+ logging_client, monitoring_client, metric_filter
+ )
for project in logging_client.project_ids:
if project not in projects_with_metric:
report = Check_Report_GCP(
@@ -43,8 +50,12 @@ class logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes
else "GCP Project"
),
)
- report.status = "FAIL"
- report.status_extended = f"There are no log metric filters or alerts associated in project {project}."
+ if project in centrally_covered:
+ report.status = "PASS"
+ report.status_extended = f"Log metric filter {centrally_covered[project]} found with an alert, covering project {project} via an organization-level aggregated sink."
+ else:
+ report.status = "FAIL"
+ report.status_extended = f"There are no log metric filters or alerts associated in project {project}."
findings.append(report)
return findings
diff --git a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.py b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.py
index e2b7cdcc13..0e05838f05 100644
--- a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.py
+++ b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.py
@@ -1,5 +1,8 @@
from prowler.lib.check.models import Check, Check_Report_GCP
from prowler.providers.gcp.services.logging.logging_client import logging_client
+from prowler.providers.gcp.services.logging.logging_service import (
+ get_projects_covered_by_aggregated_metric,
+)
from prowler.providers.gcp.services.monitoring.monitoring_client import (
monitoring_client,
)
@@ -8,12 +11,10 @@ from prowler.providers.gcp.services.monitoring.monitoring_client import (
class logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled(Check):
def execute(self) -> Check_Report_GCP:
findings = []
+ metric_filter = 'resource.type="gce_firewall_rule" AND (protoPayload.methodName:"compute.firewalls.patch" OR protoPayload.methodName:"compute.firewalls.insert" OR protoPayload.methodName:"compute.firewalls.delete")'
projects_with_metric = set()
for metric in logging_client.metrics:
- if (
- 'resource.type="gce_firewall_rule" AND (protoPayload.methodName:"compute.firewalls.patch" OR protoPayload.methodName:"compute.firewalls.insert" OR protoPayload.methodName:"compute.firewalls.delete")'
- in metric.filter
- ):
+ if metric_filter in metric.filter:
report = Check_Report_GCP(
metadata=self.metadata(),
resource=metric,
@@ -31,6 +32,9 @@ class logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled(
break
findings.append(report)
+ centrally_covered = get_projects_covered_by_aggregated_metric(
+ logging_client, monitoring_client, metric_filter
+ )
for project in logging_client.project_ids:
if project not in projects_with_metric:
report = Check_Report_GCP(
@@ -44,8 +48,12 @@ class logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled(
else "GCP Project"
),
)
- report.status = "FAIL"
- report.status_extended = f"There are no log metric filters or alerts associated in project {project}."
+ if project in centrally_covered:
+ report.status = "PASS"
+ report.status_extended = f"Log metric filter {centrally_covered[project]} found with an alert, covering project {project} via an organization-level aggregated sink."
+ else:
+ report.status = "FAIL"
+ report.status_extended = f"There are no log metric filters or alerts associated in project {project}."
findings.append(report)
return findings
diff --git a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.py b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.py
index c8b15ce1ee..1330ad7a9a 100644
--- a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.py
+++ b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.py
@@ -1,5 +1,8 @@
from prowler.lib.check.models import Check, Check_Report_GCP
from prowler.providers.gcp.services.logging.logging_client import logging_client
+from prowler.providers.gcp.services.logging.logging_service import (
+ get_projects_covered_by_aggregated_metric,
+)
from prowler.providers.gcp.services.monitoring.monitoring_client import (
monitoring_client,
)
@@ -8,12 +11,10 @@ from prowler.providers.gcp.services.monitoring.monitoring_client import (
class logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled(Check):
def execute(self) -> Check_Report_GCP:
findings = []
+ metric_filter = 'resource.type="gce_network" AND (protoPayload.methodName:"compute.networks.insert" OR protoPayload.methodName:"compute.networks.patch" OR protoPayload.methodName:"compute.networks.delete" OR protoPayload.methodName:"compute.networks.removePeering" OR protoPayload.methodName:"compute.networks.addPeering")'
projects_with_metric = set()
for metric in logging_client.metrics:
- if (
- 'resource.type="gce_network" AND (protoPayload.methodName:"compute.networks.insert" OR protoPayload.methodName:"compute.networks.patch" OR protoPayload.methodName:"compute.networks.delete" OR protoPayload.methodName:"compute.networks.removePeering" OR protoPayload.methodName:"compute.networks.addPeering")'
- in metric.filter
- ):
+ if metric_filter in metric.filter:
report = Check_Report_GCP(
metadata=self.metadata(),
resource=metric,
@@ -31,6 +32,9 @@ class logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled(Check)
break
findings.append(report)
+ centrally_covered = get_projects_covered_by_aggregated_metric(
+ logging_client, monitoring_client, metric_filter
+ )
for project in logging_client.project_ids:
if project not in projects_with_metric:
report = Check_Report_GCP(
@@ -44,8 +48,12 @@ class logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled(Check)
else "GCP Project"
),
)
- report.status = "FAIL"
- report.status_extended = f"There are no log metric filters or alerts associated in project {project}."
+ if project in centrally_covered:
+ report.status = "PASS"
+ report.status_extended = f"Log metric filter {centrally_covered[project]} found with an alert, covering project {project} via an organization-level aggregated sink."
+ else:
+ report.status = "FAIL"
+ report.status_extended = f"There are no log metric filters or alerts associated in project {project}."
findings.append(report)
return findings
diff --git a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.py b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.py
index f840d75852..27f25879e8 100644
--- a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.py
+++ b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.py
@@ -1,5 +1,8 @@
from prowler.lib.check.models import Check, Check_Report_GCP
from prowler.providers.gcp.services.logging.logging_client import logging_client
+from prowler.providers.gcp.services.logging.logging_service import (
+ get_projects_covered_by_aggregated_metric,
+)
from prowler.providers.gcp.services.monitoring.monitoring_client import (
monitoring_client,
)
@@ -8,12 +11,10 @@ from prowler.providers.gcp.services.monitoring.monitoring_client import (
class logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled(Check):
def execute(self) -> Check_Report_GCP:
findings = []
+ metric_filter = 'resource.type="gce_route" AND (protoPayload.methodName:"compute.routes.delete" OR protoPayload.methodName:"compute.routes.insert")'
projects_with_metric = set()
for metric in logging_client.metrics:
- if (
- 'resource.type="gce_route" AND (protoPayload.methodName:"compute.routes.delete" OR protoPayload.methodName:"compute.routes.insert")'
- in metric.filter
- ):
+ if metric_filter in metric.filter:
report = Check_Report_GCP(
metadata=self.metadata(),
resource=metric,
@@ -31,6 +32,9 @@ class logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled(
break
findings.append(report)
+ centrally_covered = get_projects_covered_by_aggregated_metric(
+ logging_client, monitoring_client, metric_filter
+ )
for project in logging_client.project_ids:
if project not in projects_with_metric:
report = Check_Report_GCP(
@@ -44,8 +48,12 @@ class logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled(
else "GCP Project"
),
)
- report.status = "FAIL"
- report.status_extended = f"There are no log metric filters or alerts associated in project {project}."
+ if project in centrally_covered:
+ report.status = "PASS"
+ report.status_extended = f"Log metric filter {centrally_covered[project]} found with an alert, covering project {project} via an organization-level aggregated sink."
+ else:
+ report.status = "FAIL"
+ report.status_extended = f"There are no log metric filters or alerts associated in project {project}."
findings.append(report)
return findings
diff --git a/prowler/providers/gcp/services/logging/logging_service.py b/prowler/providers/gcp/services/logging/logging_service.py
index 2459895c4c..3d5b0d1e79 100644
--- a/prowler/providers/gcp/services/logging/logging_service.py
+++ b/prowler/providers/gcp/services/logging/logging_service.py
@@ -90,6 +90,7 @@ class Logging(GCPService):
type=metric["metricDescriptor"]["type"],
filter=metric["filter"],
project_id=project_id,
+ bucket_name=metric.get("bucketName", ""),
)
)
@@ -117,3 +118,59 @@ class Metric(BaseModel):
type: str
filter: str
project_id: str
+ bucket_name: str = ""
+
+
+def get_projects_covered_by_aggregated_metric(
+ logging_client, monitoring_client, metric_filter
+):
+ """Return {project_id: metric_name} for scanned projects whose logs are routed,
+ via an organization-level sink with includeChildren=True, to a bucket that holds
+ a bucket-scoped log metric matching ``metric_filter`` that has an alert policy.
+
+ The CIS GCP logging-metric checks are written per-project, but a common (and
+ recommended) topology centralizes monitoring: an org-level aggregated sink ships
+ every child project's logs into one bucket, where a single bucket-scoped metric
+ + alert covers them all. Without crediting that, those child projects are falsely
+ failed. Mirrors the org-sink handling already in ``logging_sink_created`` (#11355).
+ """
+ # Buckets that hold a matching, alerted, bucket-scoped metric -> metric name.
+ bucket_to_metric = {}
+ for metric in logging_client.metrics:
+ if not getattr(metric, "bucket_name", ""):
+ continue
+ if metric_filter not in metric.filter:
+ continue
+ if any(
+ metric.name in policy_filter
+ for alert_policy in monitoring_client.alert_policies
+ for policy_filter in alert_policy.filters
+ ):
+ bucket_to_metric[metric.bucket_name] = metric.name
+ if not bucket_to_metric:
+ return {}
+
+ # Org resources whose includeChildren sink targets one of those buckets.
+ org_to_metric = {}
+ for sink in logging_client.sinks:
+ if not getattr(sink, "include_children", False):
+ continue
+ if getattr(sink, "filter", "all") != "all":
+ continue
+ for bucket, metric_name in bucket_to_metric.items():
+ # sink.destination e.g. "logging.googleapis.com/projects/.../buckets/X";
+ # metric.bucket_name e.g. "projects/.../buckets/X".
+ if sink.destination.endswith(bucket):
+ org_to_metric[sink.project_id] = metric_name
+ break
+ if not org_to_metric:
+ return {}
+
+ # Scanned projects sitting under a covering organization.
+ covered = {}
+ for project_id in logging_client.project_ids:
+ project = logging_client.projects.get(project_id)
+ organization = getattr(project, "organization", None) if project else None
+ if organization and f"organizations/{organization.id}" in org_to_metric:
+ covered[project_id] = org_to_metric[f"organizations/{organization.id}"]
+ return covered
diff --git a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled_test.py b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled_test.py
index a38f97d81c..c0dc6b0a06 100644
--- a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled_test.py
+++ b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled_test.py
@@ -259,3 +259,176 @@ class Test_logging_log_metric_filter_and_alert_for_audit_configuration_changes_e
assert result[0].resource_name == "metric_name"
assert result[0].project_id == GCP_PROJECT_ID
assert result[0].location == GCP_EU1_LOCATION
+
+ def test_project_centrally_covered_via_org_aggregated_sink(self):
+ """A child project with NO local metric, but whose org has an aggregated
+ sink (includeChildren=True) routing its logs to a central bucket that has
+ a bucket-scoped metric + alert, should PASS (covered centrally) instead of
+ being falsely failed."""
+ logging_client = MagicMock()
+ monitoring_client = MagicMock()
+ org_id = "111222333"
+ central_bucket = (
+ "projects/central-logging-project/locations/eu/buckets/central-bucket"
+ )
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_gcp_provider(),
+ ),
+ patch(
+ "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.logging_client",
+ new=logging_client,
+ ),
+ patch(
+ "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.monitoring_client",
+ new=monitoring_client,
+ ),
+ ):
+ from prowler.providers.gcp.models import GCPOrganization
+ from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled import (
+ logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled,
+ )
+ from prowler.providers.gcp.services.logging.logging_service import (
+ Metric,
+ Sink,
+ )
+ from prowler.providers.gcp.services.monitoring.monitoring_service import (
+ AlertPolicy,
+ )
+
+ logging_client.region = GCP_EU1_LOCATION
+ logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"]
+ logging_client.projects = {
+ GCP_PROJECT_ID: GCPProject(
+ id=GCP_PROJECT_ID,
+ number="123456789012",
+ name="child",
+ labels={},
+ lifecycle_state="ACTIVE",
+ organization=GCPOrganization(
+ id=org_id, name=f"organizations/{org_id}"
+ ),
+ )
+ }
+ # Bucket-scoped central metric, in the scanned logging project.
+ logging_client.metrics = [
+ Metric(
+ name="central-audit-config-metric",
+ type="logging.googleapis.com/user/central-audit-config-metric",
+ filter='protoPayload.methodName="SetIamPolicy" AND protoPayload.serviceData.policyDelta.auditConfigDeltas:*',
+ project_id="central-logging-project",
+ bucket_name=central_bucket,
+ )
+ ]
+ # Org-level aggregated sink routing the child's logs to that bucket.
+ logging_client.sinks = [
+ Sink(
+ name="org-aggregated-sink",
+ destination=f"logging.googleapis.com/{central_bucket}",
+ filter="all",
+ project_id=f"organizations/{org_id}",
+ include_children=True,
+ )
+ ]
+ monitoring_client.alert_policies = [
+ AlertPolicy(
+ name="projects/central-logging-project/alertPolicies/ap",
+ display_name="central-alert",
+ enabled=True,
+ filters=[
+ 'metric.type = "logging.googleapis.com/user/central-audit-config-metric"'
+ ],
+ project_id="central-logging-project",
+ )
+ ]
+
+ check = (
+ logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled()
+ )
+ result = check.execute()
+
+ assert any(
+ r.project_id == GCP_PROJECT_ID
+ and r.status == "PASS"
+ and "aggregated sink" in r.status_extended
+ for r in result
+ ), [(r.project_id, r.status, r.status_extended) for r in result]
+
+ def test_aggregated_sink_metric_without_alert_still_fails(self):
+ """Guard: an org aggregated sink + a bucket-scoped metric matching the filter
+ but with NO alert must NOT credit the child project — it should still FAIL."""
+ logging_client = MagicMock()
+ monitoring_client = MagicMock()
+ org_id = "111222333"
+ central_bucket = (
+ "projects/central-logging-project/locations/eu/buckets/central-bucket"
+ )
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_gcp_provider(),
+ ),
+ patch(
+ "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.logging_client",
+ new=logging_client,
+ ),
+ patch(
+ "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.monitoring_client",
+ new=monitoring_client,
+ ),
+ ):
+ from prowler.providers.gcp.models import GCPOrganization
+ from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled import (
+ logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled,
+ )
+ from prowler.providers.gcp.services.logging.logging_service import (
+ Metric,
+ Sink,
+ )
+
+ logging_client.region = GCP_EU1_LOCATION
+ logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"]
+ logging_client.projects = {
+ GCP_PROJECT_ID: GCPProject(
+ id=GCP_PROJECT_ID,
+ number="123456789012",
+ name="child",
+ labels={},
+ lifecycle_state="ACTIVE",
+ organization=GCPOrganization(
+ id=org_id, name=f"organizations/{org_id}"
+ ),
+ )
+ }
+ logging_client.metrics = [
+ Metric(
+ name="central-metric",
+ type="logging.googleapis.com/user/central-metric",
+ filter='protoPayload.methodName="SetIamPolicy" AND protoPayload.serviceData.policyDelta.auditConfigDeltas:*',
+ project_id="central-logging-project",
+ bucket_name=central_bucket,
+ )
+ ]
+ logging_client.sinks = [
+ Sink(
+ name="org-aggregated-sink",
+ destination=f"logging.googleapis.com/{central_bucket}",
+ filter="all",
+ project_id=f"organizations/{org_id}",
+ include_children=True,
+ )
+ ]
+ monitoring_client.alert_policies = [] # no alert -> must NOT credit
+
+ check = (
+ logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled()
+ )
+ result = check.execute()
+
+ child = [r for r in result if r.project_id == GCP_PROJECT_ID]
+ assert child and all(r.status == "FAIL" for r in child), [
+ (r.project_id, r.status) for r in result
+ ]
diff --git a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled_test.py b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled_test.py
index e2b7b2d068..e9eeabf430 100644
--- a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled_test.py
+++ b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled_test.py
@@ -397,3 +397,173 @@ class Test_logging_log_metric_filter_and_alert_for_bucket_permission_changes_ena
assert result[0].resource_name == "GCP Project"
assert result[0].project_id == GCP_PROJECT_ID
assert result[0].location == GCP_EU1_LOCATION
+
+ def test_project_centrally_covered_via_org_aggregated_sink(self):
+ """A child project with NO local metric, but whose org has an aggregated
+ sink (includeChildren=True) routing its logs to a central bucket that has
+ a bucket-scoped metric + alert, should PASS (covered centrally)."""
+ logging_client = MagicMock()
+ monitoring_client = MagicMock()
+ org_id = "111222333"
+ central_bucket = (
+ "projects/central-logging-project/locations/eu/buckets/central-bucket"
+ )
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_gcp_provider(),
+ ),
+ patch(
+ "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.logging_client",
+ new=logging_client,
+ ),
+ patch(
+ "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.monitoring_client",
+ new=monitoring_client,
+ ),
+ ):
+ from prowler.providers.gcp.models import GCPOrganization, GCPProject
+ from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled import (
+ logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled,
+ )
+ from prowler.providers.gcp.services.logging.logging_service import (
+ Metric,
+ Sink,
+ )
+ from prowler.providers.gcp.services.monitoring.monitoring_service import (
+ AlertPolicy,
+ )
+
+ logging_client.region = GCP_EU1_LOCATION
+ logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"]
+ logging_client.projects = {
+ GCP_PROJECT_ID: GCPProject(
+ id=GCP_PROJECT_ID,
+ number="123456789012",
+ name="child",
+ labels={},
+ lifecycle_state="ACTIVE",
+ organization=GCPOrganization(
+ id=org_id, name=f"organizations/{org_id}"
+ ),
+ )
+ }
+ logging_client.metrics = [
+ Metric(
+ name="central-metric",
+ type="logging.googleapis.com/user/central-metric",
+ filter='resource.type="gcs_bucket" AND protoPayload.methodName="storage.setIamPermissions"',
+ project_id="central-logging-project",
+ bucket_name=central_bucket,
+ )
+ ]
+ logging_client.sinks = [
+ Sink(
+ name="org-aggregated-sink",
+ destination=f"logging.googleapis.com/{central_bucket}",
+ filter="all",
+ project_id=f"organizations/{org_id}",
+ include_children=True,
+ )
+ ]
+ monitoring_client.alert_policies = [
+ AlertPolicy(
+ name="projects/central-logging-project/alertPolicies/ap",
+ display_name="central-alert",
+ enabled=True,
+ filters=[
+ 'metric.type = "logging.googleapis.com/user/central-metric"'
+ ],
+ project_id="central-logging-project",
+ )
+ ]
+
+ check = (
+ logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled()
+ )
+ result = check.execute()
+
+ assert any(
+ r.project_id == GCP_PROJECT_ID
+ and r.status == "PASS"
+ and "aggregated sink" in r.status_extended
+ for r in result
+ ), [(r.project_id, r.status, r.status_extended) for r in result]
+
+ def test_aggregated_sink_metric_without_alert_still_fails(self):
+ """Guard: an org aggregated sink + a bucket-scoped metric matching the filter
+ but with NO alert must NOT credit the child project — it should still FAIL."""
+ logging_client = MagicMock()
+ monitoring_client = MagicMock()
+ org_id = "111222333"
+ central_bucket = (
+ "projects/central-logging-project/locations/eu/buckets/central-bucket"
+ )
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_gcp_provider(),
+ ),
+ patch(
+ "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.logging_client",
+ new=logging_client,
+ ),
+ patch(
+ "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.monitoring_client",
+ new=monitoring_client,
+ ),
+ ):
+ from prowler.providers.gcp.models import GCPOrganization, GCPProject
+ from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled import (
+ logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled,
+ )
+ from prowler.providers.gcp.services.logging.logging_service import (
+ Metric,
+ Sink,
+ )
+
+ logging_client.region = GCP_EU1_LOCATION
+ logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"]
+ logging_client.projects = {
+ GCP_PROJECT_ID: GCPProject(
+ id=GCP_PROJECT_ID,
+ number="123456789012",
+ name="child",
+ labels={},
+ lifecycle_state="ACTIVE",
+ organization=GCPOrganization(
+ id=org_id, name=f"organizations/{org_id}"
+ ),
+ )
+ }
+ logging_client.metrics = [
+ Metric(
+ name="central-metric",
+ type="logging.googleapis.com/user/central-metric",
+ filter='resource.type="gcs_bucket" AND protoPayload.methodName="storage.setIamPermissions"',
+ project_id="central-logging-project",
+ bucket_name=central_bucket,
+ )
+ ]
+ logging_client.sinks = [
+ Sink(
+ name="org-aggregated-sink",
+ destination=f"logging.googleapis.com/{central_bucket}",
+ filter="all",
+ project_id=f"organizations/{org_id}",
+ include_children=True,
+ )
+ ]
+ monitoring_client.alert_policies = [] # no alert -> must NOT credit
+
+ check = (
+ logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled()
+ )
+ result = check.execute()
+
+ child = [r for r in result if r.project_id == GCP_PROJECT_ID]
+ assert child and all(r.status == "FAIL" for r in child), [
+ (r.project_id, r.status) for r in result
+ ]
diff --git a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled_test.py b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled_test.py
index 563b4e49ac..5347e3e42e 100644
--- a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled_test.py
+++ b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled_test.py
@@ -346,3 +346,173 @@ class Test_logging_log_metric_filter_and_alert_for_compute_configuration_changes
fail_result = [r for r in result if r.status == "FAIL"][0]
assert fail_result.project_id == project_id_2
assert "no log metric filters" in fail_result.status_extended
+
+ def test_project_centrally_covered_via_org_aggregated_sink(self):
+ """A child project with NO local metric, but whose org has an aggregated
+ sink (includeChildren=True) routing its logs to a central bucket that has
+ a bucket-scoped metric + alert, should PASS (covered centrally)."""
+ logging_client = MagicMock()
+ monitoring_client = MagicMock()
+ org_id = "111222333"
+ central_bucket = (
+ "projects/central-logging-project/locations/eu/buckets/central-bucket"
+ )
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_gcp_provider(),
+ ),
+ patch(
+ "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.logging_client",
+ new=logging_client,
+ ),
+ patch(
+ "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.monitoring_client",
+ new=monitoring_client,
+ ),
+ ):
+ from prowler.providers.gcp.models import GCPOrganization, GCPProject
+ from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled import (
+ logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled,
+ )
+ from prowler.providers.gcp.services.logging.logging_service import (
+ Metric,
+ Sink,
+ )
+ from prowler.providers.gcp.services.monitoring.monitoring_service import (
+ AlertPolicy,
+ )
+
+ logging_client.region = GCP_EU1_LOCATION
+ logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"]
+ logging_client.projects = {
+ GCP_PROJECT_ID: GCPProject(
+ id=GCP_PROJECT_ID,
+ number="123456789012",
+ name="child",
+ labels={},
+ lifecycle_state="ACTIVE",
+ organization=GCPOrganization(
+ id=org_id, name=f"organizations/{org_id}"
+ ),
+ )
+ }
+ logging_client.metrics = [
+ Metric(
+ name="central-metric",
+ type="logging.googleapis.com/user/central-metric",
+ filter='protoPayload.serviceName="compute.googleapis.com"',
+ project_id="central-logging-project",
+ bucket_name=central_bucket,
+ )
+ ]
+ logging_client.sinks = [
+ Sink(
+ name="org-aggregated-sink",
+ destination=f"logging.googleapis.com/{central_bucket}",
+ filter="all",
+ project_id=f"organizations/{org_id}",
+ include_children=True,
+ )
+ ]
+ monitoring_client.alert_policies = [
+ AlertPolicy(
+ name="projects/central-logging-project/alertPolicies/ap",
+ display_name="central-alert",
+ enabled=True,
+ filters=[
+ 'metric.type = "logging.googleapis.com/user/central-metric"'
+ ],
+ project_id="central-logging-project",
+ )
+ ]
+
+ check = (
+ logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled()
+ )
+ result = check.execute()
+
+ assert any(
+ r.project_id == GCP_PROJECT_ID
+ and r.status == "PASS"
+ and "aggregated sink" in r.status_extended
+ for r in result
+ ), [(r.project_id, r.status, r.status_extended) for r in result]
+
+ def test_aggregated_sink_metric_without_alert_still_fails(self):
+ """Guard: an org aggregated sink + a bucket-scoped metric matching the filter
+ but with NO alert must NOT credit the child project — it should still FAIL."""
+ logging_client = MagicMock()
+ monitoring_client = MagicMock()
+ org_id = "111222333"
+ central_bucket = (
+ "projects/central-logging-project/locations/eu/buckets/central-bucket"
+ )
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_gcp_provider(),
+ ),
+ patch(
+ "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.logging_client",
+ new=logging_client,
+ ),
+ patch(
+ "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.monitoring_client",
+ new=monitoring_client,
+ ),
+ ):
+ from prowler.providers.gcp.models import GCPOrganization, GCPProject
+ from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled import (
+ logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled,
+ )
+ from prowler.providers.gcp.services.logging.logging_service import (
+ Metric,
+ Sink,
+ )
+
+ logging_client.region = GCP_EU1_LOCATION
+ logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"]
+ logging_client.projects = {
+ GCP_PROJECT_ID: GCPProject(
+ id=GCP_PROJECT_ID,
+ number="123456789012",
+ name="child",
+ labels={},
+ lifecycle_state="ACTIVE",
+ organization=GCPOrganization(
+ id=org_id, name=f"organizations/{org_id}"
+ ),
+ )
+ }
+ logging_client.metrics = [
+ Metric(
+ name="central-metric",
+ type="logging.googleapis.com/user/central-metric",
+ filter='protoPayload.serviceName="compute.googleapis.com"',
+ project_id="central-logging-project",
+ bucket_name=central_bucket,
+ )
+ ]
+ logging_client.sinks = [
+ Sink(
+ name="org-aggregated-sink",
+ destination=f"logging.googleapis.com/{central_bucket}",
+ filter="all",
+ project_id=f"organizations/{org_id}",
+ include_children=True,
+ )
+ ]
+ monitoring_client.alert_policies = [] # no alert -> must NOT credit
+
+ check = (
+ logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled()
+ )
+ result = check.execute()
+
+ child = [r for r in result if r.project_id == GCP_PROJECT_ID]
+ assert child and all(r.status == "FAIL" for r in child), [
+ (r.project_id, r.status) for r in result
+ ]
diff --git a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled_test.py b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled_test.py
index 4ec94be657..18d1807255 100644
--- a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled_test.py
+++ b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled_test.py
@@ -259,3 +259,173 @@ class Test_logging_log_metric_filter_and_alert_for_custom_role_changes_enabled:
assert result[0].resource_name == "metric_name"
assert result[0].project_id == GCP_PROJECT_ID
assert result[0].location == GCP_EU1_LOCATION
+
+ def test_project_centrally_covered_via_org_aggregated_sink(self):
+ """A child project with NO local metric, but whose org has an aggregated
+ sink (includeChildren=True) routing its logs to a central bucket that has
+ a bucket-scoped metric + alert, should PASS (covered centrally)."""
+ logging_client = MagicMock()
+ monitoring_client = MagicMock()
+ org_id = "111222333"
+ central_bucket = (
+ "projects/central-logging-project/locations/eu/buckets/central-bucket"
+ )
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_gcp_provider(),
+ ),
+ patch(
+ "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.logging_client",
+ new=logging_client,
+ ),
+ patch(
+ "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.monitoring_client",
+ new=monitoring_client,
+ ),
+ ):
+ from prowler.providers.gcp.models import GCPOrganization, GCPProject
+ from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled import (
+ logging_log_metric_filter_and_alert_for_custom_role_changes_enabled,
+ )
+ from prowler.providers.gcp.services.logging.logging_service import (
+ Metric,
+ Sink,
+ )
+ from prowler.providers.gcp.services.monitoring.monitoring_service import (
+ AlertPolicy,
+ )
+
+ logging_client.region = GCP_EU1_LOCATION
+ logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"]
+ logging_client.projects = {
+ GCP_PROJECT_ID: GCPProject(
+ id=GCP_PROJECT_ID,
+ number="123456789012",
+ name="child",
+ labels={},
+ lifecycle_state="ACTIVE",
+ organization=GCPOrganization(
+ id=org_id, name=f"organizations/{org_id}"
+ ),
+ )
+ }
+ logging_client.metrics = [
+ Metric(
+ name="central-metric",
+ type="logging.googleapis.com/user/central-metric",
+ filter='resource.type="iam_role" AND (protoPayload.methodName="google.iam.admin.v1.CreateRole" OR protoPayload.methodName="google.iam.admin.v1.DeleteRole" OR protoPayload.methodName="google.iam.admin.v1.UpdateRole")',
+ project_id="central-logging-project",
+ bucket_name=central_bucket,
+ )
+ ]
+ logging_client.sinks = [
+ Sink(
+ name="org-aggregated-sink",
+ destination=f"logging.googleapis.com/{central_bucket}",
+ filter="all",
+ project_id=f"organizations/{org_id}",
+ include_children=True,
+ )
+ ]
+ monitoring_client.alert_policies = [
+ AlertPolicy(
+ name="projects/central-logging-project/alertPolicies/ap",
+ display_name="central-alert",
+ enabled=True,
+ filters=[
+ 'metric.type = "logging.googleapis.com/user/central-metric"'
+ ],
+ project_id="central-logging-project",
+ )
+ ]
+
+ check = (
+ logging_log_metric_filter_and_alert_for_custom_role_changes_enabled()
+ )
+ result = check.execute()
+
+ assert any(
+ r.project_id == GCP_PROJECT_ID
+ and r.status == "PASS"
+ and "aggregated sink" in r.status_extended
+ for r in result
+ ), [(r.project_id, r.status, r.status_extended) for r in result]
+
+ def test_aggregated_sink_metric_without_alert_still_fails(self):
+ """Guard: an org aggregated sink + a bucket-scoped metric matching the filter
+ but with NO alert must NOT credit the child project — it should still FAIL."""
+ logging_client = MagicMock()
+ monitoring_client = MagicMock()
+ org_id = "111222333"
+ central_bucket = (
+ "projects/central-logging-project/locations/eu/buckets/central-bucket"
+ )
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_gcp_provider(),
+ ),
+ patch(
+ "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.logging_client",
+ new=logging_client,
+ ),
+ patch(
+ "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.monitoring_client",
+ new=monitoring_client,
+ ),
+ ):
+ from prowler.providers.gcp.models import GCPOrganization, GCPProject
+ from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled import (
+ logging_log_metric_filter_and_alert_for_custom_role_changes_enabled,
+ )
+ from prowler.providers.gcp.services.logging.logging_service import (
+ Metric,
+ Sink,
+ )
+
+ logging_client.region = GCP_EU1_LOCATION
+ logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"]
+ logging_client.projects = {
+ GCP_PROJECT_ID: GCPProject(
+ id=GCP_PROJECT_ID,
+ number="123456789012",
+ name="child",
+ labels={},
+ lifecycle_state="ACTIVE",
+ organization=GCPOrganization(
+ id=org_id, name=f"organizations/{org_id}"
+ ),
+ )
+ }
+ logging_client.metrics = [
+ Metric(
+ name="central-metric",
+ type="logging.googleapis.com/user/central-metric",
+ filter='resource.type="iam_role" AND (protoPayload.methodName="google.iam.admin.v1.CreateRole" OR protoPayload.methodName="google.iam.admin.v1.DeleteRole" OR protoPayload.methodName="google.iam.admin.v1.UpdateRole")',
+ project_id="central-logging-project",
+ bucket_name=central_bucket,
+ )
+ ]
+ logging_client.sinks = [
+ Sink(
+ name="org-aggregated-sink",
+ destination=f"logging.googleapis.com/{central_bucket}",
+ filter="all",
+ project_id=f"organizations/{org_id}",
+ include_children=True,
+ )
+ ]
+ monitoring_client.alert_policies = [] # no alert -> must NOT credit
+
+ check = (
+ logging_log_metric_filter_and_alert_for_custom_role_changes_enabled()
+ )
+ result = check.execute()
+
+ child = [r for r in result if r.project_id == GCP_PROJECT_ID]
+ assert child and all(r.status == "FAIL" for r in child), [
+ (r.project_id, r.status) for r in result
+ ]
diff --git a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled_test.py b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled_test.py
index 0ea0798e03..3adb48e567 100644
--- a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled_test.py
+++ b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled_test.py
@@ -392,3 +392,173 @@ class Test_logging_log_metric_filter_and_alert_for_project_ownership_changes_ena
assert result[0].resource_name == "GCP Project"
assert result[0].project_id == GCP_PROJECT_ID
assert result[0].location == GCP_EU1_LOCATION
+
+ def test_project_centrally_covered_via_org_aggregated_sink(self):
+ """A child project with NO local metric, but whose org has an aggregated
+ sink (includeChildren=True) routing its logs to a central bucket that has
+ a bucket-scoped metric + alert, should PASS (covered centrally)."""
+ logging_client = MagicMock()
+ monitoring_client = MagicMock()
+ org_id = "111222333"
+ central_bucket = (
+ "projects/central-logging-project/locations/eu/buckets/central-bucket"
+ )
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_gcp_provider(),
+ ),
+ patch(
+ "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.logging_client",
+ new=logging_client,
+ ),
+ patch(
+ "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.monitoring_client",
+ new=monitoring_client,
+ ),
+ ):
+ from prowler.providers.gcp.models import GCPOrganization, GCPProject
+ from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled import (
+ logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled,
+ )
+ from prowler.providers.gcp.services.logging.logging_service import (
+ Metric,
+ Sink,
+ )
+ from prowler.providers.gcp.services.monitoring.monitoring_service import (
+ AlertPolicy,
+ )
+
+ logging_client.region = GCP_EU1_LOCATION
+ logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"]
+ logging_client.projects = {
+ GCP_PROJECT_ID: GCPProject(
+ id=GCP_PROJECT_ID,
+ number="123456789012",
+ name="child",
+ labels={},
+ lifecycle_state="ACTIVE",
+ organization=GCPOrganization(
+ id=org_id, name=f"organizations/{org_id}"
+ ),
+ )
+ }
+ logging_client.metrics = [
+ Metric(
+ name="central-metric",
+ type="logging.googleapis.com/user/central-metric",
+ filter='(protoPayload.serviceName="cloudresourcemanager.googleapis.com") AND (ProjectOwnership OR projectOwnerInvitee) OR (protoPayload.serviceData.policyDelta.bindingDeltas.action="REMOVE" AND protoPayload.serviceData.policyDelta.bindingDeltas.role="roles/owner") OR (protoPayload.serviceData.policyDelta.bindingDeltas.action="ADD" AND protoPayload.serviceData.policyDelta.bindingDeltas.role="roles/owner")',
+ project_id="central-logging-project",
+ bucket_name=central_bucket,
+ )
+ ]
+ logging_client.sinks = [
+ Sink(
+ name="org-aggregated-sink",
+ destination=f"logging.googleapis.com/{central_bucket}",
+ filter="all",
+ project_id=f"organizations/{org_id}",
+ include_children=True,
+ )
+ ]
+ monitoring_client.alert_policies = [
+ AlertPolicy(
+ name="projects/central-logging-project/alertPolicies/ap",
+ display_name="central-alert",
+ enabled=True,
+ filters=[
+ 'metric.type = "logging.googleapis.com/user/central-metric"'
+ ],
+ project_id="central-logging-project",
+ )
+ ]
+
+ check = (
+ logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled()
+ )
+ result = check.execute()
+
+ assert any(
+ r.project_id == GCP_PROJECT_ID
+ and r.status == "PASS"
+ and "aggregated sink" in r.status_extended
+ for r in result
+ ), [(r.project_id, r.status, r.status_extended) for r in result]
+
+ def test_aggregated_sink_metric_without_alert_still_fails(self):
+ """Guard: an org aggregated sink + a bucket-scoped metric matching the filter
+ but with NO alert must NOT credit the child project — it should still FAIL."""
+ logging_client = MagicMock()
+ monitoring_client = MagicMock()
+ org_id = "111222333"
+ central_bucket = (
+ "projects/central-logging-project/locations/eu/buckets/central-bucket"
+ )
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_gcp_provider(),
+ ),
+ patch(
+ "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.logging_client",
+ new=logging_client,
+ ),
+ patch(
+ "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.monitoring_client",
+ new=monitoring_client,
+ ),
+ ):
+ from prowler.providers.gcp.models import GCPOrganization, GCPProject
+ from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled import (
+ logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled,
+ )
+ from prowler.providers.gcp.services.logging.logging_service import (
+ Metric,
+ Sink,
+ )
+
+ logging_client.region = GCP_EU1_LOCATION
+ logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"]
+ logging_client.projects = {
+ GCP_PROJECT_ID: GCPProject(
+ id=GCP_PROJECT_ID,
+ number="123456789012",
+ name="child",
+ labels={},
+ lifecycle_state="ACTIVE",
+ organization=GCPOrganization(
+ id=org_id, name=f"organizations/{org_id}"
+ ),
+ )
+ }
+ logging_client.metrics = [
+ Metric(
+ name="central-metric",
+ type="logging.googleapis.com/user/central-metric",
+ filter='(protoPayload.serviceName="cloudresourcemanager.googleapis.com") AND (ProjectOwnership OR projectOwnerInvitee) OR (protoPayload.serviceData.policyDelta.bindingDeltas.action="REMOVE" AND protoPayload.serviceData.policyDelta.bindingDeltas.role="roles/owner") OR (protoPayload.serviceData.policyDelta.bindingDeltas.action="ADD" AND protoPayload.serviceData.policyDelta.bindingDeltas.role="roles/owner")',
+ project_id="central-logging-project",
+ bucket_name=central_bucket,
+ )
+ ]
+ logging_client.sinks = [
+ Sink(
+ name="org-aggregated-sink",
+ destination=f"logging.googleapis.com/{central_bucket}",
+ filter="all",
+ project_id=f"organizations/{org_id}",
+ include_children=True,
+ )
+ ]
+ monitoring_client.alert_policies = [] # no alert -> must NOT credit
+
+ check = (
+ logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled()
+ )
+ result = check.execute()
+
+ child = [r for r in result if r.project_id == GCP_PROJECT_ID]
+ assert child and all(r.status == "FAIL" for r in child), [
+ (r.project_id, r.status) for r in result
+ ]
diff --git a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled_test.py b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled_test.py
index 1a8a1d0da3..9444f0cc61 100644
--- a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled_test.py
+++ b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled_test.py
@@ -259,3 +259,173 @@ class Test_logging_log_metric_filter_and_alert_for_sql_instance_configuration_ch
assert result[0].resource_name == "metric_name"
assert result[0].project_id == GCP_PROJECT_ID
assert result[0].location == GCP_EU1_LOCATION
+
+ def test_project_centrally_covered_via_org_aggregated_sink(self):
+ """A child project with NO local metric, but whose org has an aggregated
+ sink (includeChildren=True) routing its logs to a central bucket that has
+ a bucket-scoped metric + alert, should PASS (covered centrally)."""
+ logging_client = MagicMock()
+ monitoring_client = MagicMock()
+ org_id = "111222333"
+ central_bucket = (
+ "projects/central-logging-project/locations/eu/buckets/central-bucket"
+ )
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_gcp_provider(),
+ ),
+ patch(
+ "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.logging_client",
+ new=logging_client,
+ ),
+ patch(
+ "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.monitoring_client",
+ new=monitoring_client,
+ ),
+ ):
+ from prowler.providers.gcp.models import GCPOrganization, GCPProject
+ from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled import (
+ logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled,
+ )
+ from prowler.providers.gcp.services.logging.logging_service import (
+ Metric,
+ Sink,
+ )
+ from prowler.providers.gcp.services.monitoring.monitoring_service import (
+ AlertPolicy,
+ )
+
+ logging_client.region = GCP_EU1_LOCATION
+ logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"]
+ logging_client.projects = {
+ GCP_PROJECT_ID: GCPProject(
+ id=GCP_PROJECT_ID,
+ number="123456789012",
+ name="child",
+ labels={},
+ lifecycle_state="ACTIVE",
+ organization=GCPOrganization(
+ id=org_id, name=f"organizations/{org_id}"
+ ),
+ )
+ }
+ logging_client.metrics = [
+ Metric(
+ name="central-metric",
+ type="logging.googleapis.com/user/central-metric",
+ filter='protoPayload.methodName="cloudsql.instances.update"',
+ project_id="central-logging-project",
+ bucket_name=central_bucket,
+ )
+ ]
+ logging_client.sinks = [
+ Sink(
+ name="org-aggregated-sink",
+ destination=f"logging.googleapis.com/{central_bucket}",
+ filter="all",
+ project_id=f"organizations/{org_id}",
+ include_children=True,
+ )
+ ]
+ monitoring_client.alert_policies = [
+ AlertPolicy(
+ name="projects/central-logging-project/alertPolicies/ap",
+ display_name="central-alert",
+ enabled=True,
+ filters=[
+ 'metric.type = "logging.googleapis.com/user/central-metric"'
+ ],
+ project_id="central-logging-project",
+ )
+ ]
+
+ check = (
+ logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled()
+ )
+ result = check.execute()
+
+ assert any(
+ r.project_id == GCP_PROJECT_ID
+ and r.status == "PASS"
+ and "aggregated sink" in r.status_extended
+ for r in result
+ ), [(r.project_id, r.status, r.status_extended) for r in result]
+
+ def test_aggregated_sink_metric_without_alert_still_fails(self):
+ """Guard: an org aggregated sink + a bucket-scoped metric matching the filter
+ but with NO alert must NOT credit the child project — it should still FAIL."""
+ logging_client = MagicMock()
+ monitoring_client = MagicMock()
+ org_id = "111222333"
+ central_bucket = (
+ "projects/central-logging-project/locations/eu/buckets/central-bucket"
+ )
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_gcp_provider(),
+ ),
+ patch(
+ "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.logging_client",
+ new=logging_client,
+ ),
+ patch(
+ "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.monitoring_client",
+ new=monitoring_client,
+ ),
+ ):
+ from prowler.providers.gcp.models import GCPOrganization, GCPProject
+ from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled import (
+ logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled,
+ )
+ from prowler.providers.gcp.services.logging.logging_service import (
+ Metric,
+ Sink,
+ )
+
+ logging_client.region = GCP_EU1_LOCATION
+ logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"]
+ logging_client.projects = {
+ GCP_PROJECT_ID: GCPProject(
+ id=GCP_PROJECT_ID,
+ number="123456789012",
+ name="child",
+ labels={},
+ lifecycle_state="ACTIVE",
+ organization=GCPOrganization(
+ id=org_id, name=f"organizations/{org_id}"
+ ),
+ )
+ }
+ logging_client.metrics = [
+ Metric(
+ name="central-metric",
+ type="logging.googleapis.com/user/central-metric",
+ filter='protoPayload.methodName="cloudsql.instances.update"',
+ project_id="central-logging-project",
+ bucket_name=central_bucket,
+ )
+ ]
+ logging_client.sinks = [
+ Sink(
+ name="org-aggregated-sink",
+ destination=f"logging.googleapis.com/{central_bucket}",
+ filter="all",
+ project_id=f"organizations/{org_id}",
+ include_children=True,
+ )
+ ]
+ monitoring_client.alert_policies = [] # no alert -> must NOT credit
+
+ check = (
+ logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled()
+ )
+ result = check.execute()
+
+ child = [r for r in result if r.project_id == GCP_PROJECT_ID]
+ assert child and all(r.status == "FAIL" for r in child), [
+ (r.project_id, r.status) for r in result
+ ]
diff --git a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled_test.py b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled_test.py
index a9460e6b46..3d34a3c295 100644
--- a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled_test.py
+++ b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled_test.py
@@ -259,3 +259,173 @@ class Test_logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_ena
assert result[0].resource_name == "metric_name"
assert result[0].project_id == GCP_PROJECT_ID
assert result[0].location == GCP_EU1_LOCATION
+
+ def test_project_centrally_covered_via_org_aggregated_sink(self):
+ """A child project with NO local metric, but whose org has an aggregated
+ sink (includeChildren=True) routing its logs to a central bucket that has
+ a bucket-scoped metric + alert, should PASS (covered centrally)."""
+ logging_client = MagicMock()
+ monitoring_client = MagicMock()
+ org_id = "111222333"
+ central_bucket = (
+ "projects/central-logging-project/locations/eu/buckets/central-bucket"
+ )
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_gcp_provider(),
+ ),
+ patch(
+ "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.logging_client",
+ new=logging_client,
+ ),
+ patch(
+ "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.monitoring_client",
+ new=monitoring_client,
+ ),
+ ):
+ from prowler.providers.gcp.models import GCPOrganization, GCPProject
+ from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled import (
+ logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled,
+ )
+ from prowler.providers.gcp.services.logging.logging_service import (
+ Metric,
+ Sink,
+ )
+ from prowler.providers.gcp.services.monitoring.monitoring_service import (
+ AlertPolicy,
+ )
+
+ logging_client.region = GCP_EU1_LOCATION
+ logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"]
+ logging_client.projects = {
+ GCP_PROJECT_ID: GCPProject(
+ id=GCP_PROJECT_ID,
+ number="123456789012",
+ name="child",
+ labels={},
+ lifecycle_state="ACTIVE",
+ organization=GCPOrganization(
+ id=org_id, name=f"organizations/{org_id}"
+ ),
+ )
+ }
+ logging_client.metrics = [
+ Metric(
+ name="central-metric",
+ type="logging.googleapis.com/user/central-metric",
+ filter='resource.type="gce_firewall_rule" AND (protoPayload.methodName:"compute.firewalls.patch" OR protoPayload.methodName:"compute.firewalls.insert" OR protoPayload.methodName:"compute.firewalls.delete")',
+ project_id="central-logging-project",
+ bucket_name=central_bucket,
+ )
+ ]
+ logging_client.sinks = [
+ Sink(
+ name="org-aggregated-sink",
+ destination=f"logging.googleapis.com/{central_bucket}",
+ filter="all",
+ project_id=f"organizations/{org_id}",
+ include_children=True,
+ )
+ ]
+ monitoring_client.alert_policies = [
+ AlertPolicy(
+ name="projects/central-logging-project/alertPolicies/ap",
+ display_name="central-alert",
+ enabled=True,
+ filters=[
+ 'metric.type = "logging.googleapis.com/user/central-metric"'
+ ],
+ project_id="central-logging-project",
+ )
+ ]
+
+ check = (
+ logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled()
+ )
+ result = check.execute()
+
+ assert any(
+ r.project_id == GCP_PROJECT_ID
+ and r.status == "PASS"
+ and "aggregated sink" in r.status_extended
+ for r in result
+ ), [(r.project_id, r.status, r.status_extended) for r in result]
+
+ def test_aggregated_sink_metric_without_alert_still_fails(self):
+ """Guard: an org aggregated sink + a bucket-scoped metric matching the filter
+ but with NO alert must NOT credit the child project — it should still FAIL."""
+ logging_client = MagicMock()
+ monitoring_client = MagicMock()
+ org_id = "111222333"
+ central_bucket = (
+ "projects/central-logging-project/locations/eu/buckets/central-bucket"
+ )
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_gcp_provider(),
+ ),
+ patch(
+ "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.logging_client",
+ new=logging_client,
+ ),
+ patch(
+ "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.monitoring_client",
+ new=monitoring_client,
+ ),
+ ):
+ from prowler.providers.gcp.models import GCPOrganization, GCPProject
+ from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled import (
+ logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled,
+ )
+ from prowler.providers.gcp.services.logging.logging_service import (
+ Metric,
+ Sink,
+ )
+
+ logging_client.region = GCP_EU1_LOCATION
+ logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"]
+ logging_client.projects = {
+ GCP_PROJECT_ID: GCPProject(
+ id=GCP_PROJECT_ID,
+ number="123456789012",
+ name="child",
+ labels={},
+ lifecycle_state="ACTIVE",
+ organization=GCPOrganization(
+ id=org_id, name=f"organizations/{org_id}"
+ ),
+ )
+ }
+ logging_client.metrics = [
+ Metric(
+ name="central-metric",
+ type="logging.googleapis.com/user/central-metric",
+ filter='resource.type="gce_firewall_rule" AND (protoPayload.methodName:"compute.firewalls.patch" OR protoPayload.methodName:"compute.firewalls.insert" OR protoPayload.methodName:"compute.firewalls.delete")',
+ project_id="central-logging-project",
+ bucket_name=central_bucket,
+ )
+ ]
+ logging_client.sinks = [
+ Sink(
+ name="org-aggregated-sink",
+ destination=f"logging.googleapis.com/{central_bucket}",
+ filter="all",
+ project_id=f"organizations/{org_id}",
+ include_children=True,
+ )
+ ]
+ monitoring_client.alert_policies = [] # no alert -> must NOT credit
+
+ check = (
+ logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled()
+ )
+ result = check.execute()
+
+ child = [r for r in result if r.project_id == GCP_PROJECT_ID]
+ assert child and all(r.status == "FAIL" for r in child), [
+ (r.project_id, r.status) for r in result
+ ]
diff --git a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled_test.py b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled_test.py
index 9c59d56a81..a71a21ceb6 100644
--- a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled_test.py
+++ b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled_test.py
@@ -259,3 +259,173 @@ class Test_logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled:
assert result[0].resource_name == "metric_name"
assert result[0].project_id == GCP_PROJECT_ID
assert result[0].location == GCP_EU1_LOCATION
+
+ def test_project_centrally_covered_via_org_aggregated_sink(self):
+ """A child project with NO local metric, but whose org has an aggregated
+ sink (includeChildren=True) routing its logs to a central bucket that has
+ a bucket-scoped metric + alert, should PASS (covered centrally)."""
+ logging_client = MagicMock()
+ monitoring_client = MagicMock()
+ org_id = "111222333"
+ central_bucket = (
+ "projects/central-logging-project/locations/eu/buckets/central-bucket"
+ )
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_gcp_provider(),
+ ),
+ patch(
+ "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.logging_client",
+ new=logging_client,
+ ),
+ patch(
+ "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.monitoring_client",
+ new=monitoring_client,
+ ),
+ ):
+ from prowler.providers.gcp.models import GCPOrganization, GCPProject
+ from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled import (
+ logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled,
+ )
+ from prowler.providers.gcp.services.logging.logging_service import (
+ Metric,
+ Sink,
+ )
+ from prowler.providers.gcp.services.monitoring.monitoring_service import (
+ AlertPolicy,
+ )
+
+ logging_client.region = GCP_EU1_LOCATION
+ logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"]
+ logging_client.projects = {
+ GCP_PROJECT_ID: GCPProject(
+ id=GCP_PROJECT_ID,
+ number="123456789012",
+ name="child",
+ labels={},
+ lifecycle_state="ACTIVE",
+ organization=GCPOrganization(
+ id=org_id, name=f"organizations/{org_id}"
+ ),
+ )
+ }
+ logging_client.metrics = [
+ Metric(
+ name="central-metric",
+ type="logging.googleapis.com/user/central-metric",
+ filter='resource.type="gce_network" AND (protoPayload.methodName:"compute.networks.insert" OR protoPayload.methodName:"compute.networks.patch" OR protoPayload.methodName:"compute.networks.delete" OR protoPayload.methodName:"compute.networks.removePeering" OR protoPayload.methodName:"compute.networks.addPeering")',
+ project_id="central-logging-project",
+ bucket_name=central_bucket,
+ )
+ ]
+ logging_client.sinks = [
+ Sink(
+ name="org-aggregated-sink",
+ destination=f"logging.googleapis.com/{central_bucket}",
+ filter="all",
+ project_id=f"organizations/{org_id}",
+ include_children=True,
+ )
+ ]
+ monitoring_client.alert_policies = [
+ AlertPolicy(
+ name="projects/central-logging-project/alertPolicies/ap",
+ display_name="central-alert",
+ enabled=True,
+ filters=[
+ 'metric.type = "logging.googleapis.com/user/central-metric"'
+ ],
+ project_id="central-logging-project",
+ )
+ ]
+
+ check = (
+ logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled()
+ )
+ result = check.execute()
+
+ assert any(
+ r.project_id == GCP_PROJECT_ID
+ and r.status == "PASS"
+ and "aggregated sink" in r.status_extended
+ for r in result
+ ), [(r.project_id, r.status, r.status_extended) for r in result]
+
+ def test_aggregated_sink_metric_without_alert_still_fails(self):
+ """Guard: an org aggregated sink + a bucket-scoped metric matching the filter
+ but with NO alert must NOT credit the child project — it should still FAIL."""
+ logging_client = MagicMock()
+ monitoring_client = MagicMock()
+ org_id = "111222333"
+ central_bucket = (
+ "projects/central-logging-project/locations/eu/buckets/central-bucket"
+ )
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_gcp_provider(),
+ ),
+ patch(
+ "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.logging_client",
+ new=logging_client,
+ ),
+ patch(
+ "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.monitoring_client",
+ new=monitoring_client,
+ ),
+ ):
+ from prowler.providers.gcp.models import GCPOrganization, GCPProject
+ from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled import (
+ logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled,
+ )
+ from prowler.providers.gcp.services.logging.logging_service import (
+ Metric,
+ Sink,
+ )
+
+ logging_client.region = GCP_EU1_LOCATION
+ logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"]
+ logging_client.projects = {
+ GCP_PROJECT_ID: GCPProject(
+ id=GCP_PROJECT_ID,
+ number="123456789012",
+ name="child",
+ labels={},
+ lifecycle_state="ACTIVE",
+ organization=GCPOrganization(
+ id=org_id, name=f"organizations/{org_id}"
+ ),
+ )
+ }
+ logging_client.metrics = [
+ Metric(
+ name="central-metric",
+ type="logging.googleapis.com/user/central-metric",
+ filter='resource.type="gce_network" AND (protoPayload.methodName:"compute.networks.insert" OR protoPayload.methodName:"compute.networks.patch" OR protoPayload.methodName:"compute.networks.delete" OR protoPayload.methodName:"compute.networks.removePeering" OR protoPayload.methodName:"compute.networks.addPeering")',
+ project_id="central-logging-project",
+ bucket_name=central_bucket,
+ )
+ ]
+ logging_client.sinks = [
+ Sink(
+ name="org-aggregated-sink",
+ destination=f"logging.googleapis.com/{central_bucket}",
+ filter="all",
+ project_id=f"organizations/{org_id}",
+ include_children=True,
+ )
+ ]
+ monitoring_client.alert_policies = [] # no alert -> must NOT credit
+
+ check = (
+ logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled()
+ )
+ result = check.execute()
+
+ child = [r for r in result if r.project_id == GCP_PROJECT_ID]
+ assert child and all(r.status == "FAIL" for r in child), [
+ (r.project_id, r.status) for r in result
+ ]
diff --git a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled_test.py b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled_test.py
index 254c41bb5f..3a7f41a485 100644
--- a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled_test.py
+++ b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled_test.py
@@ -259,3 +259,173 @@ class Test_logging_log_metric_filter_and_alert_for_vpc_network_route_changes_ena
assert result[0].resource_name == "metric_name"
assert result[0].project_id == GCP_PROJECT_ID
assert result[0].location == GCP_EU1_LOCATION
+
+ def test_project_centrally_covered_via_org_aggregated_sink(self):
+ """A child project with NO local metric, but whose org has an aggregated
+ sink (includeChildren=True) routing its logs to a central bucket that has
+ a bucket-scoped metric + alert, should PASS (covered centrally)."""
+ logging_client = MagicMock()
+ monitoring_client = MagicMock()
+ org_id = "111222333"
+ central_bucket = (
+ "projects/central-logging-project/locations/eu/buckets/central-bucket"
+ )
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_gcp_provider(),
+ ),
+ patch(
+ "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.logging_client",
+ new=logging_client,
+ ),
+ patch(
+ "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.monitoring_client",
+ new=monitoring_client,
+ ),
+ ):
+ from prowler.providers.gcp.models import GCPOrganization, GCPProject
+ from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled import (
+ logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled,
+ )
+ from prowler.providers.gcp.services.logging.logging_service import (
+ Metric,
+ Sink,
+ )
+ from prowler.providers.gcp.services.monitoring.monitoring_service import (
+ AlertPolicy,
+ )
+
+ logging_client.region = GCP_EU1_LOCATION
+ logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"]
+ logging_client.projects = {
+ GCP_PROJECT_ID: GCPProject(
+ id=GCP_PROJECT_ID,
+ number="123456789012",
+ name="child",
+ labels={},
+ lifecycle_state="ACTIVE",
+ organization=GCPOrganization(
+ id=org_id, name=f"organizations/{org_id}"
+ ),
+ )
+ }
+ logging_client.metrics = [
+ Metric(
+ name="central-metric",
+ type="logging.googleapis.com/user/central-metric",
+ filter='resource.type="gce_route" AND (protoPayload.methodName:"compute.routes.delete" OR protoPayload.methodName:"compute.routes.insert")',
+ project_id="central-logging-project",
+ bucket_name=central_bucket,
+ )
+ ]
+ logging_client.sinks = [
+ Sink(
+ name="org-aggregated-sink",
+ destination=f"logging.googleapis.com/{central_bucket}",
+ filter="all",
+ project_id=f"organizations/{org_id}",
+ include_children=True,
+ )
+ ]
+ monitoring_client.alert_policies = [
+ AlertPolicy(
+ name="projects/central-logging-project/alertPolicies/ap",
+ display_name="central-alert",
+ enabled=True,
+ filters=[
+ 'metric.type = "logging.googleapis.com/user/central-metric"'
+ ],
+ project_id="central-logging-project",
+ )
+ ]
+
+ check = (
+ logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled()
+ )
+ result = check.execute()
+
+ assert any(
+ r.project_id == GCP_PROJECT_ID
+ and r.status == "PASS"
+ and "aggregated sink" in r.status_extended
+ for r in result
+ ), [(r.project_id, r.status, r.status_extended) for r in result]
+
+ def test_aggregated_sink_metric_without_alert_still_fails(self):
+ """Guard: an org aggregated sink + a bucket-scoped metric matching the filter
+ but with NO alert must NOT credit the child project — it should still FAIL."""
+ logging_client = MagicMock()
+ monitoring_client = MagicMock()
+ org_id = "111222333"
+ central_bucket = (
+ "projects/central-logging-project/locations/eu/buckets/central-bucket"
+ )
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_gcp_provider(),
+ ),
+ patch(
+ "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.logging_client",
+ new=logging_client,
+ ),
+ patch(
+ "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.monitoring_client",
+ new=monitoring_client,
+ ),
+ ):
+ from prowler.providers.gcp.models import GCPOrganization, GCPProject
+ from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled import (
+ logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled,
+ )
+ from prowler.providers.gcp.services.logging.logging_service import (
+ Metric,
+ Sink,
+ )
+
+ logging_client.region = GCP_EU1_LOCATION
+ logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"]
+ logging_client.projects = {
+ GCP_PROJECT_ID: GCPProject(
+ id=GCP_PROJECT_ID,
+ number="123456789012",
+ name="child",
+ labels={},
+ lifecycle_state="ACTIVE",
+ organization=GCPOrganization(
+ id=org_id, name=f"organizations/{org_id}"
+ ),
+ )
+ }
+ logging_client.metrics = [
+ Metric(
+ name="central-metric",
+ type="logging.googleapis.com/user/central-metric",
+ filter='resource.type="gce_route" AND (protoPayload.methodName:"compute.routes.delete" OR protoPayload.methodName:"compute.routes.insert")',
+ project_id="central-logging-project",
+ bucket_name=central_bucket,
+ )
+ ]
+ logging_client.sinks = [
+ Sink(
+ name="org-aggregated-sink",
+ destination=f"logging.googleapis.com/{central_bucket}",
+ filter="all",
+ project_id=f"organizations/{org_id}",
+ include_children=True,
+ )
+ ]
+ monitoring_client.alert_policies = [] # no alert -> must NOT credit
+
+ check = (
+ logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled()
+ )
+ result = check.execute()
+
+ child = [r for r in result if r.project_id == GCP_PROJECT_ID]
+ assert child and all(r.status == "FAIL" for r in child), [
+ (r.project_id, r.status) for r in result
+ ]
diff --git a/tests/providers/gcp/services/logging/logging_service_test.py b/tests/providers/gcp/services/logging/logging_service_test.py
index 49368d0289..72e18eddbb 100644
--- a/tests/providers/gcp/services/logging/logging_service_test.py
+++ b/tests/providers/gcp/services/logging/logging_service_test.py
@@ -137,3 +137,166 @@ class TestLoggingService:
s for s in logging_svc.sinks if s.project_id.startswith("organizations/")
]
assert org_sinks == []
+
+ def test_get_metrics_populates_bucket_name(self):
+ """_get_metrics() captures a metric's bucketName (for aggregated-sink crediting)."""
+ bucket = "projects/central-logging-project/locations/eu/buckets/central-bucket"
+ mock_client = MagicMock()
+ mock_client.sinks().list().execute.return_value = {"sinks": []}
+ mock_client.sinks().list_next.return_value = None
+ mock_client.projects().metrics().list().execute.return_value = {
+ "metrics": [
+ {
+ "name": "central-metric",
+ "metricDescriptor": {
+ "type": "logging.googleapis.com/user/central-metric"
+ },
+ "filter": "severity>=ERROR",
+ "bucketName": bucket,
+ }
+ ]
+ }
+ mock_client.projects().metrics().list_next.return_value = None
+
+ with (
+ patch(
+ "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__",
+ new=mock_is_api_active,
+ ),
+ patch(
+ "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__",
+ return_value=mock_client,
+ ),
+ ):
+ logging_svc = Logging(set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]))
+
+ metrics = [m for m in logging_svc.metrics if m.name == "central-metric"]
+ assert len(metrics) == 1
+ assert metrics[0].bucket_name == bucket
+
+
+class TestGetProjectsCoveredByAggregatedMetric:
+ """Unit tests for the aggregated-sink crediting helper: one positive case and the
+ guards that must NOT credit a project (so the metric-filter checks never false-pass).
+ """
+
+ FILTER = 'protoPayload.methodName="SetIamPolicy"'
+ ORG = "111222333"
+ BUCKET = "projects/central-logging-project/locations/eu/buckets/central-bucket"
+
+ def _clients(
+ self,
+ *,
+ include_children=True,
+ bucket_name=None,
+ sink_destination=None,
+ sink_filter="all",
+ with_alert=True,
+ project_org_id=None,
+ ):
+ from prowler.providers.gcp.models import GCPOrganization, GCPProject
+ from prowler.providers.gcp.services.logging.logging_service import Metric, Sink
+ from prowler.providers.gcp.services.monitoring.monitoring_service import (
+ AlertPolicy,
+ )
+
+ bucket_name = self.BUCKET if bucket_name is None else bucket_name
+ sink_destination = (
+ f"logging.googleapis.com/{self.BUCKET}"
+ if sink_destination is None
+ else sink_destination
+ )
+ project_org_id = self.ORG if project_org_id is None else project_org_id
+
+ logging_client = MagicMock()
+ logging_client.project_ids = [GCP_PROJECT_ID]
+ logging_client.projects = {
+ GCP_PROJECT_ID: GCPProject(
+ id=GCP_PROJECT_ID,
+ number="123456789012",
+ name="child",
+ labels={},
+ lifecycle_state="ACTIVE",
+ organization=GCPOrganization(
+ id=project_org_id, name=f"organizations/{project_org_id}"
+ ),
+ )
+ }
+ logging_client.metrics = [
+ Metric(
+ name="central-metric",
+ type="logging.googleapis.com/user/central-metric",
+ filter=self.FILTER,
+ project_id="central-logging-project",
+ bucket_name=bucket_name,
+ )
+ ]
+ logging_client.sinks = [
+ Sink(
+ name="org-sink",
+ destination=sink_destination,
+ filter=sink_filter,
+ project_id=f"organizations/{self.ORG}",
+ include_children=include_children,
+ )
+ ]
+ monitoring_client = MagicMock()
+ monitoring_client.alert_policies = (
+ [
+ AlertPolicy(
+ name="projects/central-logging-project/alertPolicies/ap",
+ display_name="central-alert",
+ enabled=True,
+ filters=[
+ 'metric.type = "logging.googleapis.com/user/central-metric"'
+ ],
+ project_id="central-logging-project",
+ )
+ ]
+ if with_alert
+ else []
+ )
+ return logging_client, monitoring_client
+
+ def _run(self, logging_client, monitoring_client):
+ from prowler.providers.gcp.services.logging.logging_service import (
+ get_projects_covered_by_aggregated_metric,
+ )
+
+ return get_projects_covered_by_aggregated_metric(
+ logging_client, monitoring_client, self.FILTER
+ )
+
+ def test_covered_when_all_conditions_met(self):
+ logging_client, monitoring_client = self._clients()
+ assert self._run(logging_client, monitoring_client) == {
+ GCP_PROJECT_ID: "central-metric"
+ }
+
+ def test_not_covered_without_alert(self):
+ logging_client, monitoring_client = self._clients(with_alert=False)
+ assert self._run(logging_client, monitoring_client) == {}
+
+ def test_not_covered_when_metric_not_bucket_scoped(self):
+ logging_client, monitoring_client = self._clients(bucket_name="")
+ assert self._run(logging_client, monitoring_client) == {}
+
+ def test_not_covered_when_sink_not_include_children(self):
+ logging_client, monitoring_client = self._clients(include_children=False)
+ assert self._run(logging_client, monitoring_client) == {}
+
+ def test_not_covered_when_sink_filter_is_restrictive(self):
+ logging_client, monitoring_client = self._clients(
+ sink_filter='resource.type="gce_instance"'
+ )
+ assert self._run(logging_client, monitoring_client) == {}
+
+ def test_not_covered_when_sink_destination_bucket_differs(self):
+ logging_client, monitoring_client = self._clients(
+ sink_destination="logging.googleapis.com/projects/x/locations/eu/buckets/other"
+ )
+ assert self._run(logging_client, monitoring_client) == {}
+
+ def test_not_covered_when_project_org_differs(self):
+ logging_client, monitoring_client = self._clients(project_org_id="999999999")
+ assert self._run(logging_client, monitoring_client) == {}
From 7692a1d76a1dabb74adf2eda13f47e4db2b23b09 Mon Sep 17 00:00:00 2001
From: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com>
Date: Mon, 8 Jun 2026 16:51:58 +0200
Subject: [PATCH 020/129] feat(okta): add network zone STIG check (#11463)
Co-authored-by: Daniel Barranquero
---
.../providers/okta/authentication.mdx | 10 +-
.../providers/okta/getting-started-okta.mdx | 14 +-
prowler/CHANGELOG.md | 1 +
prowler/providers/okta/okta_provider.py | 1 +
.../okta/services/network/__init__.py | 0
.../okta/services/network/lib/__init__.py | 0
.../network/lib/network_zone_helpers.py | 88 +++
.../__init__.py | 0
...one_block_anonymized_proxies.metadata.json | 37 ++
.../network_zone_block_anonymized_proxies.py | 77 +++
.../services/network/network_zone_client.py | 4 +
.../services/network/network_zone_service.py | 234 ++++++++
tests/providers/okta/okta_fixtures.py | 2 +
...work_zone_block_anonymized_proxies_test.py | 153 +++++
.../network_zone/network_zone_fixtures.py | 42 ++
.../network_zone/network_zone_service_test.py | 560 ++++++++++++++++++
16 files changed, 1213 insertions(+), 10 deletions(-)
create mode 100644 prowler/providers/okta/services/network/__init__.py
create mode 100644 prowler/providers/okta/services/network/lib/__init__.py
create mode 100644 prowler/providers/okta/services/network/lib/network_zone_helpers.py
create mode 100644 prowler/providers/okta/services/network/network_zone_block_anonymized_proxies/__init__.py
create mode 100644 prowler/providers/okta/services/network/network_zone_block_anonymized_proxies/network_zone_block_anonymized_proxies.metadata.json
create mode 100644 prowler/providers/okta/services/network/network_zone_block_anonymized_proxies/network_zone_block_anonymized_proxies.py
create mode 100644 prowler/providers/okta/services/network/network_zone_client.py
create mode 100644 prowler/providers/okta/services/network/network_zone_service.py
create mode 100644 tests/providers/okta/services/network_zone/network_zone_block_anonymized_proxies/network_zone_block_anonymized_proxies_test.py
create mode 100644 tests/providers/okta/services/network_zone/network_zone_fixtures.py
create mode 100644 tests/providers/okta/services/network_zone/network_zone_service_test.py
diff --git a/docs/user-guide/providers/okta/authentication.mdx b/docs/user-guide/providers/okta/authentication.mdx
index ca56a0535b..67eb72e153 100644
--- a/docs/user-guide/providers/okta/authentication.mdx
+++ b/docs/user-guide/providers/okta/authentication.mdx
@@ -35,6 +35,7 @@ The bundled checks require the following read-only scopes:
- `okta.policies.read`
- `okta.brands.read`
- `okta.apps.read`
+- `okta.networkZones.read`
- `okta.logStreams.read`
- `okta.idps.read`
@@ -45,6 +46,7 @@ Additional scopes will be needed as more services and checks are added. These ar
| `okta.policies.read` | Sign-on, password, authentication, and `USER_LIFECYCLE` (Workflow > Automations) policies |
| `okta.brands.read` | Sign-in page customizations (DOD Notice and Consent Banner check) |
| `okta.apps.read` | First-party app settings (Okta Admin Console session), integrated app inventory, and the Authentication Policies bound to Okta applications |
+| `okta.networkZones.read` | Network Zone inventory and anonymized-proxy blocklist checks |
| `okta.logStreams.read` | Log Stream configuration (`/api/v1/logStreams`) |
| `okta.idps.read` | Identity Providers, including Smart Card (X509) IdPs (`/api/v1/idps`) |
@@ -128,7 +130,7 @@ Okta displays the private key **only once**. If you close the modal without copy
### 5. Grant the required OAuth scopes
-On the app, open the **Okta API Scopes** tab and click **Grant** on every scope Prowler needs. The bundled checks require `okta.policies.read`, `okta.brands.read`, and `okta.apps.read`.
+On the app, open the **Okta API Scopes** tab and click **Grant** on every scope Prowler needs. The bundled checks require `okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.networkZones.read`, `okta.logStreams.read`, and `okta.idps.read`.

@@ -164,8 +166,8 @@ export OKTA_PRIVATE_KEY_FILE="/secure/path/to/prowler-okta.pem"
# or
export OKTA_PRIVATE_KEY="$(cat /secure/path/to/prowler-okta.pem)"
-# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read,okta.logStreams.read,okta.idps.read"
-export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.logStreams.read,okta.idps.read"
+# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read,okta.networkZones.read,okta.logStreams.read,okta.idps.read"
+export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.networkZones.read,okta.logStreams.read,okta.idps.read"
uv run python prowler-cli.py okta
```
@@ -206,7 +208,7 @@ Prowler validates credentials at startup by listing one sign-on policy. This err
Raised when the credential probe succeeds at the OAuth layer but the request is rejected because the service app lacks the required scope or admin role:
-- **`invalid_scope`** — one of the requested scopes (`okta.policies.read`, `okta.brands.read`, or `okta.apps.read`) is not granted on the service app. Grant the missing scope from **Okta API Scopes**.
+- **`invalid_scope`** — one of the requested scopes (`okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.networkZones.read`, `okta.logStreams.read`, and `okta.idps.read`) is not granted on the service app. Grant the missing scope from **Okta API Scopes**.
- **`Forbidden` / `not authorized`** — no admin role is assigned to the service app. Assign **Read-Only Administrator** (or **Super Administrator** for the first-party application checks) from **Admin roles**.
### Application-service checks return MANUAL on first-party apps
diff --git a/docs/user-guide/providers/okta/getting-started-okta.mdx b/docs/user-guide/providers/okta/getting-started-okta.mdx
index 6086afefb3..740cfbf729 100644
--- a/docs/user-guide/providers/okta/getting-started-okta.mdx
+++ b/docs/user-guide/providers/okta/getting-started-okta.mdx
@@ -12,7 +12,7 @@ Set up authentication for Okta with the [Okta Authentication](/user-guide/provid
- An Okta organization. The UI examples below use **Identity Engine** terminology such as **Global Session Policy**; Classic Engine exposes the equivalent sign-on policy concepts under older names.
- A **Super Administrator** account on that organization for the one-time service-app setup.
-- An **API Services** app integration in the Okta Admin Console with the `okta.policies.read`, `okta.brands.read`, and `okta.apps.read` scopes granted and an admin role assigned. **Read-Only Administrator** covers every `signon` check and runs the per-app network-zone check against the apps the service app can see (under Read-Only Administrator that is typically only the service app's own row — the rest of the org's app inventory stays invisible). **Super Administrator** is required additionally to evaluate the five first-party application checks (Okta Admin Console / Okta Dashboard idle timeout, MFA, phishing-resistant authentication) and to widen the network-zone check to the full app inventory — see [Okta Authentication](/user-guide/providers/okta/authentication#required-admin-role) for the full breakdown.
+- An **API Services** app integration in the Okta Admin Console with the `okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.networkZones.read`, `okta.logStreams.read`, and `okta.idps.read` scopes granted and an admin role assigned. **Read-Only Administrator** covers the Sign-On, Network, User, System Log, and Identity Provider checks, and runs the per-app application network-zone check against the apps the service app can see (under Read-Only Administrator that is typically only the service app's own row — the rest of the org's app inventory stays invisible). **Super Administrator** is required additionally to evaluate the five first-party application checks (Okta Admin Console / Okta Dashboard idle timeout, MFA, phishing-resistant authentication) and to widen the application network-zone check to the full app inventory — see [Okta Authentication](/user-guide/providers/okta/authentication#required-admin-role) for the full breakdown.
- Python 3.10+ and Prowler 5.27.0 or later installed locally.
@@ -85,8 +85,8 @@ Follow the [Okta Authentication](/user-guide/providers/okta/authentication) guid
export OKTA_ORG_DOMAIN="acme.okta.com"
export OKTA_CLIENT_ID="0oa1234567890abcdef"
export OKTA_PRIVATE_KEY_FILE="/secure/path/to/prowler-okta.pem"
-# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read,okta.logStreams.read,okta.idps.read"
-export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.logStreams.read,okta.idps.read"
+# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read,okta.networkZones.read,okta.logStreams.read,okta.idps.read"
+export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.networkZones.read,okta.logStreams.read,okta.idps.read"
```
The private key file may contain either a PEM-encoded RSA key or a JWK JSON document.
@@ -147,6 +147,7 @@ Prowler for Okta includes security checks across the following services:
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Sign-On** | Global session policy controls (idle timeout, lifetime, rule priority and ordering) |
| **Application** | Okta Admin Console sign-on settings plus Authentication Policy controls for Okta applications (session idle, MFA, phishing resistance, network zones) |
+| **Network** | Network Zone blocklists for anonymized proxy sources |
| **User** | User lifecycle automations (inactivity-based deprovisioning) |
| **System Log** | Log Stream configuration that off-loads audit records to a central SIEM |
| **Identity Provider** | Identity Providers, including Smart Card (X509) IdP status and certificate-chain visibility |
@@ -161,11 +162,12 @@ This is stricter than simply finding the same timeout value somewhere else in th
### Default Scopes
-Prowler requests a fixed set of OAuth scopes on every token exchange. The defaults cover every bundled check across the Sign-On, Application, User, System Log, and Identity Provider services:
+Prowler requests a fixed set of OAuth scopes on every token exchange. The defaults cover every bundled check across the Sign-On, Application, Network, User, System Log, and Identity Provider services:
- `okta.policies.read`
- `okta.brands.read`
- `okta.apps.read`
+- `okta.networkZones.read`
- `okta.logStreams.read`
- `okta.idps.read`
@@ -175,10 +177,10 @@ When additional checks are enabled — or when running against a service app tha
```bash
# Environment variable — comma-separated
-export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.logStreams.read,okta.idps.read,okta.users.read"
+export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.networkZones.read,okta.logStreams.read,okta.idps.read,okta.users.read"
# CLI flag — space-separated
-prowler okta --okta-scopes okta.policies.read okta.brands.read okta.apps.read okta.logStreams.read okta.idps.read okta.users.read
+prowler okta --okta-scopes okta.policies.read okta.brands.read okta.apps.read okta.networkZones.read okta.logStreams.read okta.idps.read okta.users.read
```
For the full catalog of OAuth scopes exposed by the Okta Management API, refer to the [Okta OAuth 2.0 scopes documentation](https://developer.okta.com/docs/api/oauth2/).
diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md
index 1389b684cf..fab236e9e1 100644
--- a/prowler/CHANGELOG.md
+++ b/prowler/CHANGELOG.md
@@ -8,6 +8,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- `sagemaker_models_monitor_enabled` check for AWS provider, verifying that each SageMaker monitoring schedule is in the `Scheduled` state so data and model drift is actively detected [(#11278)](https://github.com/prowler-cloud/prowler/pull/11278)
- DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554) universal compliance framework with AWS provider coverage across the five DORA pillars [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131)
+- Okta network zone check to detect whether anonymized proxy traffic is blocked [(#11463)](https://github.com/prowler-cloud/prowler/pull/11463)
- `elbv2_alb_drop_invalid_header_fields_enabled` check for AWS provider, verifying Application Load Balancers have `routing.http.drop_invalid_header_fields.enabled` set to `true` to mitigate HTTP desync attacks (AWS FSBP ELB.4) [(#11471)](https://github.com/prowler-cloud/prowler/pull/11471)
- `user`, `systemlog` and `idp` service for Okta provider with `user_inactivity_automation_35d_enabled`, `systemlog_streaming_enabled` and `idp_smart_card_dod_approved_ca` checks [(#11496)](https://github.com/prowler-cloud/prowler/pull/11496)
diff --git a/prowler/providers/okta/okta_provider.py b/prowler/providers/okta/okta_provider.py
index 678e9edbd4..4356adbc57 100644
--- a/prowler/providers/okta/okta_provider.py
+++ b/prowler/providers/okta/okta_provider.py
@@ -36,6 +36,7 @@ DEFAULT_SCOPES = [
"okta.policies.read",
"okta.brands.read",
"okta.apps.read",
+ "okta.networkZones.read",
"okta.logStreams.read",
"okta.idps.read",
]
diff --git a/prowler/providers/okta/services/network/__init__.py b/prowler/providers/okta/services/network/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/okta/services/network/lib/__init__.py b/prowler/providers/okta/services/network/lib/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/okta/services/network/lib/network_zone_helpers.py b/prowler/providers/okta/services/network/lib/network_zone_helpers.py
new file mode 100644
index 0000000000..bcd906b01a
--- /dev/null
+++ b/prowler/providers/okta/services/network/lib/network_zone_helpers.py
@@ -0,0 +1,88 @@
+from prowler.lib.check.models import CheckReportOkta
+from prowler.providers.okta.services.network.network_zone_service import (
+ NetworkZoneSummary,
+ OktaNetworkZone,
+)
+
+ANONYMIZER_CATEGORY_MARKERS = (
+ "ANONYM",
+ "PROXY",
+ "TOR",
+ "VPN",
+)
+
+
+def active_blocklist_zones(
+ network_zones: dict[str, OktaNetworkZone],
+) -> list[OktaNetworkZone]:
+ """Return active Network Zones configured for blocklist usage."""
+ return sorted(
+ [
+ zone
+ for zone in network_zones.values()
+ if zone.status.upper() == "ACTIVE" and zone.usage.upper() == "BLOCKLIST"
+ ],
+ key=lambda zone: (zone.name, zone.id),
+ )
+
+
+def is_ip_blocklist_with_entries(zone: OktaNetworkZone) -> bool:
+ """Return True when an IP blocklist zone contains gateway/proxy entries."""
+ return zone.type.upper() == "IP" and bool(zone.gateways or zone.proxies)
+
+
+def is_enhanced_dynamic_anonymizer_blocklist(zone: OktaNetworkZone) -> bool:
+ """Return True for active Enhanced Dynamic blocklists covering anonymizers."""
+ if zone.type.upper() != "DYNAMIC_V2":
+ return False
+ categories = [category.upper() for category in zone.ip_service_categories]
+ return any(
+ marker in category
+ for category in categories
+ for marker in ANONYMIZER_CATEGORY_MARKERS
+ )
+
+
+def compliant_anonymized_proxy_blocklist(
+ network_zones: dict[str, OktaNetworkZone],
+) -> tuple[OktaNetworkZone | None, str]:
+ """Find the Network Zone that satisfies anonymized-proxy blocklisting."""
+ for zone in active_blocklist_zones(network_zones):
+ if is_enhanced_dynamic_anonymizer_blocklist(zone):
+ return zone, "active Enhanced Dynamic Zone blocklist for anonymizers"
+ return None, ""
+
+
+def static_ip_blocklist_evidence(
+ network_zones: dict[str, OktaNetworkZone],
+) -> OktaNetworkZone | None:
+ """Return static IP blocklist evidence that requires human validation."""
+ for zone in active_blocklist_zones(network_zones):
+ if is_ip_blocklist_with_entries(zone):
+ return zone
+ return None
+
+
+_SCOPE_ADVICE = (
+ "Grant it on the Okta API Scopes tab of the service app in the Okta Admin "
+ "Console, then re-run the check."
+)
+
+
+def missing_network_zone_scope_finding(
+ metadata, org_domain: str, scope: str
+) -> CheckReportOkta:
+ """Build the MANUAL finding emitted when Network Zones cannot be listed."""
+ resource = NetworkZoneSummary(
+ id="network-zones-scope-missing",
+ name="(scope not granted)",
+ )
+ report = CheckReportOkta(
+ metadata=metadata, resource=resource, org_domain=org_domain
+ )
+ report.status = "MANUAL"
+ report.status_extended = (
+ f"Could not retrieve Okta Network Zones: the Okta service app "
+ f"is missing the required `{scope}` API scope. {_SCOPE_ADVICE}"
+ )
+ return report
diff --git a/prowler/providers/okta/services/network/network_zone_block_anonymized_proxies/__init__.py b/prowler/providers/okta/services/network/network_zone_block_anonymized_proxies/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/okta/services/network/network_zone_block_anonymized_proxies/network_zone_block_anonymized_proxies.metadata.json b/prowler/providers/okta/services/network/network_zone_block_anonymized_proxies/network_zone_block_anonymized_proxies.metadata.json
new file mode 100644
index 0000000000..d2a4791c17
--- /dev/null
+++ b/prowler/providers/okta/services/network/network_zone_block_anonymized_proxies/network_zone_block_anonymized_proxies.metadata.json
@@ -0,0 +1,37 @@
+{
+ "Provider": "okta",
+ "CheckID": "network_zone_block_anonymized_proxies",
+ "CheckTitle": "Okta uses active Network Zone blocklists for anonymized proxy sources",
+ "CheckType": [],
+ "ServiceName": "network",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "medium",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "network",
+ "Description": "**Okta Network Zone blocklists** should block anonymized proxy access before authentication. Enhanced Dynamic Zone anonymizer categories provide direct coverage; static IP blocklists show gateway/proxy blocking but cannot prove full anonymizer-provider coverage.",
+ "Risk": "**Anonymized proxy access** lets attackers hide source networks while attempting credential attacks, session establishment, or policy bypass from untrusted infrastructure.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/networkzone",
+ "https://help.okta.com/en-us/content/topics/security/network/about-enhanced-dynamic-zones.htm"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "",
+ "NativeIaC": "",
+ "Other": "Security > Networks: configure BlockedIpZone gateway/proxy IP entries or activate DefaultEnhancedDynamicZone / Enhanced Dynamic Zone blocklisting for anonymizers.",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "**Prefer an active Enhanced Dynamic Zone blocklist** for anonymizers. If you use a static IP Network Zone blocklist, keep gateway/proxy entries actively maintained because Prowler cannot prove full anonymizer-provider coverage from static entries alone.",
+ "Url": "https://hub.prowler.com/check/network_zone_block_anonymized_proxies"
+ }
+ },
+ "Categories": [
+ "identity-access"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": ""
+}
diff --git a/prowler/providers/okta/services/network/network_zone_block_anonymized_proxies/network_zone_block_anonymized_proxies.py b/prowler/providers/okta/services/network/network_zone_block_anonymized_proxies/network_zone_block_anonymized_proxies.py
new file mode 100644
index 0000000000..3da973b415
--- /dev/null
+++ b/prowler/providers/okta/services/network/network_zone_block_anonymized_proxies/network_zone_block_anonymized_proxies.py
@@ -0,0 +1,77 @@
+from prowler.lib.check.models import Check, CheckReportOkta
+from prowler.providers.okta.services.network.lib.network_zone_helpers import (
+ compliant_anonymized_proxy_blocklist,
+ missing_network_zone_scope_finding,
+ static_ip_blocklist_evidence,
+)
+from prowler.providers.okta.services.network.network_zone_client import (
+ network_zone_client,
+)
+from prowler.providers.okta.services.network.network_zone_service import (
+ NetworkZoneSummary,
+)
+
+
+class network_zone_block_anonymized_proxies(Check):
+ """Ensure Okta actively blocks anonymized proxy sources before auth."""
+
+ def execute(self) -> list[CheckReportOkta]:
+ """Evaluate whether an active blocklist covers anonymized proxies."""
+ org_domain = network_zone_client.provider.identity.org_domain
+ missing_scope = network_zone_client.missing_scope.get("network_zones")
+ if missing_scope:
+ return [
+ missing_network_zone_scope_finding(
+ self.metadata(), org_domain, missing_scope
+ )
+ ]
+
+ retrieval_error = getattr(network_zone_client, "retrieval_error", None)
+ if retrieval_error:
+ resource = NetworkZoneSummary(
+ id="network-zones-retrieval-error",
+ name="(retrieval failed)",
+ )
+ report = CheckReportOkta(
+ metadata=self.metadata(), resource=resource, org_domain=org_domain
+ )
+ report.status = "MANUAL"
+ report.status_extended = (
+ "Okta Network Zones could not be retrieved or validated. "
+ f"Reason: {retrieval_error}"
+ )
+ return [report]
+
+ matching_zone, reason = compliant_anonymized_proxy_blocklist(
+ network_zone_client.network_zones
+ )
+ manual_zone = (
+ None
+ if matching_zone
+ else static_ip_blocklist_evidence(network_zone_client.network_zones)
+ )
+
+ resource = matching_zone or manual_zone or NetworkZoneSummary()
+ report = CheckReportOkta(
+ metadata=self.metadata(), resource=resource, org_domain=org_domain
+ )
+ if matching_zone:
+ report.status = "PASS"
+ report.status_extended = (
+ f"Okta Network Zone {matching_zone.name} is an {reason}."
+ )
+ elif manual_zone:
+ report.status = "MANUAL"
+ report.status_extended = (
+ f"Okta Network Zone {manual_zone.name} is an active manual IP "
+ "blocklist with gateway or proxy IP entries; Prowler cannot "
+ "verify full anonymizer coverage for static entries."
+ )
+ else:
+ report.status = "FAIL"
+ report.status_extended = (
+ "No active Okta Network Zone blocklist was found that blocks "
+ "anonymized proxies. Existing zones do not actively block gateway "
+ "or proxy IPs, nor an Enhanced Dynamic Zone anonymizer category."
+ )
+ return [report]
diff --git a/prowler/providers/okta/services/network/network_zone_client.py b/prowler/providers/okta/services/network/network_zone_client.py
new file mode 100644
index 0000000000..6d4f603f80
--- /dev/null
+++ b/prowler/providers/okta/services/network/network_zone_client.py
@@ -0,0 +1,4 @@
+from prowler.providers.common.provider import Provider
+from prowler.providers.okta.services.network.network_zone_service import NetworkZone
+
+network_zone_client = NetworkZone(Provider.get_global_provider())
diff --git a/prowler/providers/okta/services/network/network_zone_service.py b/prowler/providers/okta/services/network/network_zone_service.py
new file mode 100644
index 0000000000..550c9b0d49
--- /dev/null
+++ b/prowler/providers/okta/services/network/network_zone_service.py
@@ -0,0 +1,234 @@
+from typing import Optional
+
+from pydantic import BaseModel, Field, ValidationError
+
+from prowler.lib.logger import logger
+from prowler.providers.okta.lib.service.pagination import paginate as _paginate_shared
+from prowler.providers.okta.lib.service.raw_fetch import (
+ get_json_paginated as _raw_get_json_paginated,
+)
+from prowler.providers.okta.lib.service.service import OktaService
+
+REQUIRED_SCOPES: dict[str, str] = {
+ "network_zones": "okta.networkZones.read",
+}
+
+
+def _value(value) -> str:
+ """Return plain string values from Okta SDK enums and raw strings."""
+ if value is None:
+ return ""
+ enum_value = getattr(value, "value", None)
+ if enum_value is not None:
+ return str(enum_value)
+ return str(value)
+
+
+class NetworkZone(OktaService):
+ """Fetches Okta Network Zones for STIG network-zone checks."""
+
+ def __init__(self, provider):
+ super().__init__(__class__.__name__, provider)
+ granted = set(getattr(provider.identity, "granted_scopes", None) or [])
+ self.missing_scope: dict[str, Optional[str]] = {
+ resource: (scope if granted and scope not in granted else None)
+ for resource, scope in REQUIRED_SCOPES.items()
+ }
+ self.retrieval_error: str | None = None
+ self.network_zones: dict[str, OktaNetworkZone] = (
+ {} if self.missing_scope["network_zones"] else self._list_network_zones()
+ )
+
+ def _set_retrieval_error(self, message: str) -> None:
+ self.retrieval_error = message
+ logger.error(message)
+
+ def _list_network_zones(self) -> dict[str, "OktaNetworkZone"]:
+ """List all Network Zones visible to the configured Okta service app."""
+ logger.info("NetworkZone - Listing Okta Network Zones...")
+ try:
+ return self._run(self._fetch_all())
+ except Exception as error:
+ line_number = getattr(error.__traceback__, "tb_lineno", "unknown")
+ self._set_retrieval_error(
+ f"{error.__class__.__name__}[{line_number}]: {error}"
+ )
+ return {}
+
+ async def _fetch_all(self) -> dict[str, "OktaNetworkZone"]:
+ result: dict[str, OktaNetworkZone] = {}
+ try:
+ all_zones, err = await _paginate_shared(
+ lambda after: self.client.list_network_zones(after=after, limit=200)
+ )
+ except (ValueError, ValidationError) as ex:
+ # Upstream Okta SDK ↔ Management API schema drift: the SDK
+ # generates `EnhancedDynamicNetworkZoneAllOfAsnsInclude` as an
+ # object-shaped pydantic model, but the API returns
+ # `asns.include` as a JSON array (typically `[]`), so pydantic
+ # rejects the whole zone with `model_type` errors. Fall back
+ # to a raw-JSON fetch so STIG evaluation isn't blocked by an
+ # upstream SDK bug. Same workaround shape as
+ # `application_service._fetch_access_policy_raw`. The wider
+ # `(ValueError, ValidationError)` catch matches the
+ # `user_service` precedent — the SDK raises either depending
+ # on whether the failure is a discriminator miss or a model
+ # mismatch.
+ logger.warning(
+ f"Okta SDK raised {type(ex).__name__} parsing Network Zones — "
+ "falling back to raw-JSON parse. This is an okta-sdk-python "
+ "deserialization bug; the workaround should be removed once "
+ "upstream fixes it."
+ )
+ return await self._fetch_all_raw()
+ if err is not None:
+ self._set_retrieval_error(f"Error listing Network Zones: {err}")
+ return result
+
+ for zone in all_zones:
+ zone_obj = self._build_zone(zone)
+ result[zone_obj.id] = zone_obj
+ return result
+
+ async def _fetch_all_raw(self) -> dict[str, "OktaNetworkZone"]:
+ """Raw-JSON fallback for `list_network_zones`.
+
+ Bypasses the SDK's typed deserialization via the shared
+ `get_json_paginated` helper, then projects each zone onto our
+ own pydantic snapshot — which only validates the fields the
+ STIG checks actually read.
+ """
+ result: dict[str, OktaNetworkZone] = {}
+ zones_data = await _raw_get_json_paginated(
+ self.client,
+ "/api/v1/zones",
+ page_size=200,
+ context="Network Zones",
+ )
+ if zones_data is None:
+ self._set_retrieval_error(
+ "Raw Network Zones fetch failed; see logs for details."
+ )
+ return result
+ for zone_dict in zones_data:
+ if not isinstance(zone_dict, dict):
+ continue
+ zone_obj = _raw_zone_to_model(zone_dict)
+ result[zone_obj.id] = zone_obj
+ return result
+
+ @staticmethod
+ def _build_zone(zone) -> "OktaNetworkZone":
+ zone_id = _value(getattr(zone, "id", None))
+ return OktaNetworkZone(
+ id=zone_id,
+ name=_value(getattr(zone, "name", None)) or zone_id,
+ status=_value(getattr(zone, "status", None)),
+ type=_value(getattr(zone, "type", None)),
+ usage=_value(getattr(zone, "usage", None)),
+ system=bool(getattr(zone, "system", False)),
+ gateways=_address_values(getattr(zone, "gateways", None)),
+ proxies=_address_values(getattr(zone, "proxies", None)),
+ asns=_condition_values(getattr(zone, "asns", None)),
+ locations=_condition_values(getattr(zone, "locations", None)),
+ ip_service_categories=_condition_values(
+ getattr(zone, "ip_service_categories", None)
+ ),
+ )
+
+
+def _raw_zone_to_model(zone_dict: dict) -> "OktaNetworkZone":
+ """Project a raw `/api/v1/zones` JSON zone onto our model.
+
+ Mirrors `NetworkZone._build_zone` but reads camelCase JSON keys
+ (`ipServiceCategories`) instead of the SDK's snake_case attributes.
+ Used by the raw-JSON fallback that activates when the Okta SDK's
+ strict pydantic validators reject zone payloads the Management API
+ returns (e.g. Enhanced Dynamic Zones with `asns.include: []`).
+ """
+ zone_id = str(zone_dict.get("id") or "")
+ categories = _condition_values(zone_dict.get("ipServiceCategories"))
+ # IP-typed zones return `gateways`/`proxies` as `[{type, value}]`
+ # arrays; Enhanced Dynamic Zones return `asns`/`locations` and
+ # `ipServiceCategories` as `{include, exclude}` objects. Keep the
+ # `list[str]` shape by extracting address values and included
+ # condition values from both SDK models and raw JSON.
+ return OktaNetworkZone(
+ id=zone_id,
+ name=str(zone_dict.get("name") or zone_id),
+ status=str(zone_dict.get("status") or ""),
+ type=str(zone_dict.get("type") or ""),
+ usage=str(zone_dict.get("usage") or ""),
+ system=bool(zone_dict.get("system", False)),
+ gateways=_address_values(zone_dict.get("gateways")),
+ proxies=_address_values(zone_dict.get("proxies")),
+ asns=_condition_values(zone_dict.get("asns")),
+ locations=_condition_values(zone_dict.get("locations")),
+ ip_service_categories=categories,
+ )
+
+
+def _address_values(raw) -> list[str]:
+ """Return string values from an Okta address-style JSON array.
+
+ Each entry in `gateways`/`proxies` is `{"type": ..., "value": ...}`;
+ `asns`/`locations` may be a `{include, exclude}` object on Enhanced
+ Dynamic Zones. Non-list inputs collapse to `[]` so the resulting
+ list satisfies the pydantic `list[str]` field.
+ """
+ if not isinstance(raw, list):
+ return []
+ out: list[str] = []
+ for entry in raw:
+ if isinstance(entry, dict):
+ value = entry.get("value")
+ elif entry is not None:
+ value = getattr(entry, "value", entry)
+ else:
+ value = None
+ if value is not None:
+ out.append(_value(value))
+ return out
+
+
+def _condition_values(raw) -> list[str]:
+ """Return string values from Okta include/exclude-style conditions."""
+ if raw is None:
+ return []
+ values = (
+ raw.get("include") if isinstance(raw, dict) else getattr(raw, "include", raw)
+ )
+ if values is None:
+ return []
+ if not isinstance(values, list):
+ values = [values]
+ normalized = []
+ for value in values:
+ if isinstance(value, dict):
+ value = value.get("value")
+ if value is not None:
+ normalized.append(_value(value))
+ return normalized
+
+
+class OktaNetworkZone(BaseModel):
+ """Normalized Okta Network Zone attributes used by checks."""
+
+ id: str
+ name: str
+ status: str = ""
+ type: str = ""
+ usage: str = ""
+ system: bool = False
+ gateways: list[str] = Field(default_factory=list)
+ proxies: list[str] = Field(default_factory=list)
+ asns: list[str] = Field(default_factory=list)
+ locations: list[str] = Field(default_factory=list)
+ ip_service_categories: list[str] = Field(default_factory=list)
+
+
+class NetworkZoneSummary(BaseModel):
+ """Synthetic resource for org-level Network Zone findings."""
+
+ id: str = "okta-network-zones"
+ name: str = "Okta Network Zones"
diff --git a/tests/providers/okta/okta_fixtures.py b/tests/providers/okta/okta_fixtures.py
index 83c8812495..0ecb4a9eef 100644
--- a/tests/providers/okta/okta_fixtures.py
+++ b/tests/providers/okta/okta_fixtures.py
@@ -20,6 +20,7 @@ def set_mocked_okta_provider(
"okta.policies.read",
"okta.brands.read",
"okta.apps.read",
+ "okta.networkZones.read",
"okta.logStreams.read",
"okta.idps.read",
],
@@ -33,6 +34,7 @@ def set_mocked_okta_provider(
"okta.policies.read",
"okta.brands.read",
"okta.apps.read",
+ "okta.networkZones.read",
"okta.logStreams.read",
"okta.idps.read",
],
diff --git a/tests/providers/okta/services/network_zone/network_zone_block_anonymized_proxies/network_zone_block_anonymized_proxies_test.py b/tests/providers/okta/services/network_zone/network_zone_block_anonymized_proxies/network_zone_block_anonymized_proxies_test.py
new file mode 100644
index 0000000000..89cc2fd8ed
--- /dev/null
+++ b/tests/providers/okta/services/network_zone/network_zone_block_anonymized_proxies/network_zone_block_anonymized_proxies_test.py
@@ -0,0 +1,153 @@
+from unittest import mock
+
+from tests.providers.okta.okta_fixtures import set_mocked_okta_provider
+from tests.providers.okta.services.network_zone.network_zone_fixtures import (
+ build_network_zone_client,
+ network_zone,
+)
+
+CHECK_PATH = (
+ "prowler.providers.okta.services.network."
+ "network_zone_block_anonymized_proxies."
+ "network_zone_block_anonymized_proxies.network_zone_client"
+)
+
+
+def _run_check(network_zone_client):
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_okta_provider(),
+ ),
+ mock.patch(CHECK_PATH, new=network_zone_client),
+ ):
+ from prowler.providers.okta.services.network.network_zone_block_anonymized_proxies.network_zone_block_anonymized_proxies import (
+ network_zone_block_anonymized_proxies,
+ )
+
+ return network_zone_block_anonymized_proxies().execute()
+
+
+class Test_network_zone_block_anonymized_proxies:
+ def test_no_zones_fails(self):
+ findings = _run_check(build_network_zone_client({}))
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert "No active Okta Network Zone blocklist" in findings[0].status_extended
+
+ def test_missing_network_zone_scope_is_manual(self):
+ findings = _run_check(
+ build_network_zone_client(
+ {},
+ missing_scope={"network_zones": "okta.networkZones.read"},
+ )
+ )
+ assert len(findings) == 1
+ assert findings[0].status == "MANUAL"
+ assert "okta.networkZones.read" in findings[0].status_extended
+
+ def test_sdk_network_zone_retrieval_error_is_manual(self):
+ findings = _run_check(
+ build_network_zone_client(
+ {},
+ retrieval_error="Error listing Network Zones: forbidden",
+ )
+ )
+ assert len(findings) == 1
+ assert findings[0].status == "MANUAL"
+ assert "could not be retrieved" in findings[0].status_extended
+ assert "forbidden" in findings[0].status_extended
+
+ def test_raw_network_zone_retrieval_error_is_manual(self):
+ findings = _run_check(
+ build_network_zone_client(
+ {},
+ retrieval_error="Raw Network Zones fetch (execute) failed: timeout",
+ )
+ )
+ assert len(findings) == 1
+ assert findings[0].status == "MANUAL"
+ assert "could not be retrieved" in findings[0].status_extended
+ assert "timeout" in findings[0].status_extended
+
+ def test_active_ip_blocklist_gateway_is_manual(self):
+ zone = network_zone(gateways=["198.51.100.10/32"])
+ findings = _run_check(build_network_zone_client({zone.id: zone}))
+ assert len(findings) == 1
+ assert findings[0].status == "MANUAL"
+ assert findings[0].resource_id == zone.id
+ assert "manual IP blocklist" in findings[0].status_extended
+ assert "cannot verify full anonymizer coverage" in findings[0].status_extended
+
+ def test_pass_with_active_enhanced_dynamic_anonymizer_blocklist(self):
+ zone = network_zone(
+ zone_id="nzo-enhanced",
+ name="DefaultEnhancedDynamicZone",
+ zone_type="DYNAMIC_V2",
+ system=True,
+ ip_service_categories=["ANONYMIZER"],
+ )
+ findings = _run_check(build_network_zone_client({zone.id: zone}))
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert "Enhanced Dynamic" in findings[0].status_extended
+
+ def test_pass_with_custom_enhanced_dynamic_anonymizer_blocklist(self):
+ zone = network_zone(
+ zone_id="nzo-custom-enhanced",
+ name="Custom Anonymous VPN Blocklist",
+ zone_type="DYNAMIC_V2",
+ system=False,
+ ip_service_categories=["VPN"],
+ )
+ findings = _run_check(build_network_zone_client({zone.id: zone}))
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert "Enhanced Dynamic" in findings[0].status_extended
+
+ def test_dynamic_blocklist_without_anonymizer_category_fails(self):
+ zone = network_zone(
+ zone_id="nzo-dynamic",
+ name="Dynamic Blocklist Without Anonymizers",
+ zone_type="DYNAMIC",
+ ip_service_categories=["RISKY_IPS"],
+ )
+ findings = _run_check(build_network_zone_client({zone.id: zone}))
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert "do not actively block" in findings[0].status_extended
+
+ def test_default_enhanced_dynamic_zone_without_categories_fails(self):
+ zone = network_zone(
+ zone_id="nzo-default-enhanced",
+ name="DefaultEnhancedDynamicZone",
+ zone_type="DYNAMIC_V2",
+ system=True,
+ ip_service_categories=[],
+ )
+ findings = _run_check(build_network_zone_client({zone.id: zone}))
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert "do not actively block" in findings[0].status_extended
+
+ def test_existing_zones_without_anonymized_proxy_blocklist_fail(self):
+ policy_zone = network_zone(
+ zone_id="nzo-policy",
+ name="Corporate Policy Zone",
+ usage="POLICY",
+ gateways=["10.0.0.0/8"],
+ )
+ inactive_blocklist = network_zone(
+ zone_id="nzo-inactive",
+ name="Inactive Blocklist",
+ status="INACTIVE",
+ gateways=["203.0.113.0/24"],
+ )
+ findings = _run_check(
+ build_network_zone_client(
+ {policy_zone.id: policy_zone, inactive_blocklist.id: inactive_blocklist}
+ )
+ )
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert "do not actively block" in findings[0].status_extended
diff --git a/tests/providers/okta/services/network_zone/network_zone_fixtures.py b/tests/providers/okta/services/network_zone/network_zone_fixtures.py
new file mode 100644
index 0000000000..09328a5d73
--- /dev/null
+++ b/tests/providers/okta/services/network_zone/network_zone_fixtures.py
@@ -0,0 +1,42 @@
+from unittest import mock
+
+from prowler.providers.okta.services.network.network_zone_service import OktaNetworkZone
+from tests.providers.okta.okta_fixtures import set_mocked_okta_provider
+
+
+def build_network_zone_client(
+ zones: dict = None,
+ missing_scope: dict = None,
+ retrieval_error: str | None = None,
+):
+ client = mock.MagicMock()
+ client.network_zones = zones or {}
+ client.missing_scope = missing_scope or {"network_zones": None}
+ client.retrieval_error = retrieval_error
+ client.provider = set_mocked_okta_provider()
+ return client
+
+
+def network_zone(
+ zone_id: str = "nzo-1",
+ name: str = "BlockedIpZone",
+ *,
+ status: str = "ACTIVE",
+ zone_type: str = "IP",
+ usage: str = "BLOCKLIST",
+ system: bool = False,
+ gateways: list[str] = None,
+ proxies: list[str] = None,
+ ip_service_categories: list[str] = None,
+):
+ return OktaNetworkZone(
+ id=zone_id,
+ name=name,
+ status=status,
+ type=zone_type,
+ usage=usage,
+ system=system,
+ gateways=gateways or [],
+ proxies=proxies or [],
+ ip_service_categories=ip_service_categories or [],
+ )
diff --git a/tests/providers/okta/services/network_zone/network_zone_service_test.py b/tests/providers/okta/services/network_zone/network_zone_service_test.py
new file mode 100644
index 0000000000..36790c1328
--- /dev/null
+++ b/tests/providers/okta/services/network_zone/network_zone_service_test.py
@@ -0,0 +1,560 @@
+import json
+from types import SimpleNamespace
+from unittest import mock
+
+from pydantic import ValidationError
+
+from prowler.providers.okta.models import OktaIdentityInfo
+from prowler.providers.okta.services.network.network_zone_service import (
+ NetworkZone,
+ OktaNetworkZone,
+ _raw_zone_to_model,
+ _value,
+)
+from tests.providers.okta.okta_fixtures import set_mocked_okta_provider
+
+
+def _resp(headers: dict = None):
+ return SimpleNamespace(headers=headers or {})
+
+
+def _sdk_zone(
+ zone_id: str,
+ name: str,
+ *,
+ status: str = "ACTIVE",
+ zone_type: str = "IP",
+ usage: str = "BLOCKLIST",
+ system: bool = False,
+ gateways: list[str] = None,
+ proxies: list[str] = None,
+ ip_service_categories: list[str] = None,
+):
+ return SimpleNamespace(
+ id=zone_id,
+ name=name,
+ status=status,
+ type=zone_type,
+ usage=usage,
+ system=system,
+ gateways=gateways or [],
+ proxies=proxies or [],
+ ip_service_categories=ip_service_categories or [],
+ )
+
+
+class _ValueObject:
+ def __init__(self, value: str):
+ self.value = value
+
+
+class Test_value_helper:
+ def test_value_returns_empty_string_for_none(self):
+ assert _value(None) == ""
+
+
+class Test_NetworkZone_service:
+ def test_fetches_ip_and_enhanced_dynamic_zones(self):
+ provider = set_mocked_okta_provider()
+ ip_zone = _sdk_zone(
+ "nzo-ip",
+ "Blocked IPs",
+ gateways=["203.0.113.10/32"],
+ )
+ enhanced_zone = _sdk_zone(
+ "nzo-enhanced",
+ "DefaultEnhancedDynamicZone",
+ zone_type="DYNAMIC_V2",
+ system=True,
+ ip_service_categories=["ANONYMIZER"],
+ )
+
+ async def fake_list_network_zones(*_a, **_k):
+ return ([ip_zone, enhanced_zone], _resp({}), None)
+
+ with mock.patch(
+ "prowler.providers.okta.lib.service.service.OktaSDKClient"
+ ) as mocked_client_cls:
+ mocked = mock.MagicMock()
+ mocked.list_network_zones = fake_list_network_zones
+ mocked_client_cls.return_value = mocked
+
+ service = NetworkZone(provider)
+
+ assert set(service.network_zones.keys()) == {"nzo-ip", "nzo-enhanced"}
+ assert isinstance(service.network_zones["nzo-ip"], OktaNetworkZone)
+ assert service.network_zones["nzo-ip"].gateways == ["203.0.113.10/32"]
+ assert service.network_zones["nzo-enhanced"].type == "DYNAMIC_V2"
+ assert service.network_zones["nzo-enhanced"].ip_service_categories == [
+ "ANONYMIZER"
+ ]
+
+ def test_paginates_network_zones(self):
+ provider = set_mocked_okta_provider()
+ page_1 = _sdk_zone("nzo-1", "First")
+ page_2 = _sdk_zone("nzo-2", "Second")
+ next_link = '; rel="next"'
+ calls = []
+
+ async def fake_list_network_zones(*_a, **kwargs):
+ calls.append(kwargs.get("after"))
+ if kwargs.get("after") is None:
+ return ([page_1], _resp({"link": next_link}), None)
+ return ([page_2], _resp({}), None)
+
+ with mock.patch(
+ "prowler.providers.okta.lib.service.service.OktaSDKClient"
+ ) as mocked_client_cls:
+ mocked = mock.MagicMock()
+ mocked.list_network_zones = fake_list_network_zones
+ mocked_client_cls.return_value = mocked
+ service = NetworkZone(provider)
+
+ assert calls == [None, "cursor-2"]
+ assert set(service.network_zones.keys()) == {"nzo-1", "nzo-2"}
+
+ def test_preserves_sdk_error_reason_on_api_error(self):
+ provider = set_mocked_okta_provider()
+
+ async def failing(*_a, **_k):
+ return ([], _resp({}), Exception("forbidden"))
+
+ with mock.patch(
+ "prowler.providers.okta.lib.service.service.OktaSDKClient"
+ ) as mocked_client_cls:
+ mocked = mock.MagicMock()
+ mocked.list_network_zones = failing
+ mocked_client_cls.return_value = mocked
+ service = NetworkZone(provider)
+
+ assert service.network_zones == {}
+ assert service.retrieval_error == "Error listing Network Zones: forbidden"
+
+ def test_build_zone_extracts_sdk_network_zone_address_values(self):
+ from okta.models.network_zone_address import NetworkZoneAddress
+
+ zone = _sdk_zone(
+ "nzo-ip",
+ "Blocked IPs",
+ gateways=[
+ NetworkZoneAddress(
+ type="CIDR",
+ value="203.0.113.10/32",
+ )
+ ],
+ proxies=[
+ NetworkZoneAddress(
+ type="CIDR",
+ value="198.51.100.10/32",
+ )
+ ],
+ )
+
+ built_zone = NetworkZone._build_zone(zone)
+
+ assert built_zone.gateways == ["203.0.113.10/32"]
+ assert built_zone.proxies == ["198.51.100.10/32"]
+
+ def test_build_zone_normalizes_sdk_value_objects_to_strings(self):
+ zone = _sdk_zone(
+ "nzo-sdk-values",
+ "SDK Values",
+ gateways=[_ValueObject("203.0.113.10/32")],
+ proxies=[_ValueObject("198.51.100.10/32")],
+ )
+ zone.asns = SimpleNamespace(include=[_ValueObject("64512")], exclude=[])
+ zone.locations = SimpleNamespace(include=[_ValueObject("US")], exclude=[])
+
+ built_zone = NetworkZone._build_zone(zone)
+
+ assert built_zone.gateways == ["203.0.113.10/32"]
+ assert built_zone.proxies == ["198.51.100.10/32"]
+ assert built_zone.asns == ["64512"]
+ assert built_zone.locations == ["US"]
+
+ def test_build_zone_extracts_sdk_enhanced_dynamic_category_values(self):
+ from okta.models.enhanced_dynamic_network_zone_all_of_ip_service_categories import (
+ EnhancedDynamicNetworkZoneAllOfIpServiceCategories,
+ )
+
+ zone = _sdk_zone(
+ "nzo-enhanced",
+ "Enhanced Anonymizers",
+ zone_type="DYNAMIC_V2",
+ system=False,
+ )
+ zone.ip_service_categories = EnhancedDynamicNetworkZoneAllOfIpServiceCategories(
+ include=["ALL_ANONYMIZERS"],
+ exclude=[],
+ )
+
+ built_zone = NetworkZone._build_zone(zone)
+
+ assert built_zone.ip_service_categories == ["ALL_ANONYMIZERS"]
+
+ def test_missing_network_zone_scope_skips_api_call(self):
+ provider = set_mocked_okta_provider(
+ identity=OktaIdentityInfo(
+ org_domain="acme.okta.com",
+ client_id="0oa1234567890abcdef",
+ granted_scopes=["okta.policies.read", "okta.brands.read"],
+ )
+ )
+
+ async def fail_if_called(*_a, **_k):
+ raise AssertionError("list_network_zones should not be called")
+
+ with mock.patch(
+ "prowler.providers.okta.lib.service.service.OktaSDKClient"
+ ) as mocked_client_cls:
+ mocked = mock.MagicMock()
+ mocked.list_network_zones = fail_if_called
+ mocked_client_cls.return_value = mocked
+ service = NetworkZone(provider)
+
+ assert service.missing_scope["network_zones"] == "okta.networkZones.read"
+ assert service.network_zones == {}
+
+
+class Test_NetworkZone_service_sdk_validation_fallback:
+ """Verifies the raw-JSON fallback for the Okta SDK Enhanced Dynamic
+ Zone deserialization bug.
+
+ The Okta Management API returns `asns.include` as a JSON array
+ (typically `[]`) but the SDK's `EnhancedDynamicNetworkZoneAllOfAsnsInclude`
+ is an object-shaped pydantic model — so listing zones raises
+ ValidationError. Without a fallback the whole fetch crashes and
+ every check FAILs as if no zones exist; with the fallback we parse
+ the raw JSON and STIG evaluation continues.
+ """
+
+ @staticmethod
+ def _trigger_real_validation_error() -> ValidationError:
+ try:
+ from okta.models.enhanced_dynamic_network_zone_all_of_asns_include import ( # noqa: E501
+ EnhancedDynamicNetworkZoneAllOfAsnsInclude,
+ )
+
+ EnhancedDynamicNetworkZoneAllOfAsnsInclude.from_dict([])
+ except ValidationError as ve:
+ return ve
+ raise AssertionError("Expected pydantic ValidationError from Okta SDK model")
+
+ def _build_service_with_raw_payload(
+ self, raw_zones_payload, response=None, body_factory=None
+ ):
+ response_body = (
+ body_factory(raw_zones_payload)
+ if body_factory
+ else json.dumps(raw_zones_payload)
+ )
+ return self._build_service_with_raw_response(response_body, response=response)
+
+ def _build_service_with_raw_response(
+ self, response_body, response=None, execute_error=None
+ ):
+ provider = set_mocked_okta_provider()
+ ve = self._trigger_real_validation_error()
+
+ async def failing_list_network_zones(*_a, **_k):
+ raise ve
+
+ async def fake_raw_create(*_a, **_k):
+ return ({"url": "/api/v1/zones"}, None)
+
+ async def fake_raw_execute(_request):
+ return (response, response_body, execute_error)
+
+ sdk_mock = mock.MagicMock()
+ sdk_mock.list_network_zones = failing_list_network_zones
+ sdk_mock._request_executor.create_request = fake_raw_create
+ sdk_mock._request_executor.execute = fake_raw_execute
+
+ with mock.patch(
+ "prowler.providers.okta.lib.service.service.OktaSDKClient",
+ return_value=sdk_mock,
+ ):
+ return NetworkZone(provider)
+
+ def test_raw_fallback_projects_ip_and_enhanced_dynamic_zones(self):
+ zones_payload = [
+ {
+ "id": "nzo-ip",
+ "name": "Blocked IPs",
+ "status": "ACTIVE",
+ "type": "IP",
+ "usage": "BLOCKLIST",
+ "system": False,
+ "gateways": [{"type": "CIDR", "value": "203.0.113.10/32"}],
+ "proxies": [],
+ },
+ {
+ "id": "nzo-enhanced",
+ "name": "DefaultEnhancedDynamicZone",
+ "status": "ACTIVE",
+ "type": "DYNAMIC_V2",
+ "usage": "BLOCKLIST",
+ "system": True,
+ "asns": {"include": [], "exclude": []},
+ "locations": {"include": [], "exclude": []},
+ "ipServiceCategories": [{"value": "ANONYMIZER"}],
+ },
+ ]
+
+ service = self._build_service_with_raw_payload(zones_payload)
+
+ assert set(service.network_zones.keys()) == {"nzo-ip", "nzo-enhanced"}
+ ip_zone = service.network_zones["nzo-ip"]
+ assert ip_zone.type == "IP"
+ assert ip_zone.gateways == ["203.0.113.10/32"]
+ enhanced = service.network_zones["nzo-enhanced"]
+ assert enhanced.type == "DYNAMIC_V2"
+ assert enhanced.system is True
+ assert enhanced.ip_service_categories == ["ANONYMIZER"]
+ assert enhanced.asns == []
+ assert enhanced.locations == []
+
+ def test_raw_fallback_handles_empty_payload(self):
+ service = self._build_service_with_raw_payload([])
+ assert service.network_zones == {}
+
+ def test_raw_fallback_handles_executor_error(self):
+ provider = set_mocked_okta_provider()
+ ve = self._trigger_real_validation_error()
+
+ async def failing_list_network_zones(*_a, **_k):
+ raise ve
+
+ async def fake_raw_create(*_a, **_k):
+ return (None, Exception("network down"))
+
+ sdk_mock = mock.MagicMock()
+ sdk_mock.list_network_zones = failing_list_network_zones
+ sdk_mock._request_executor.create_request = fake_raw_create
+ sdk_mock._request_executor.execute = mock.AsyncMock()
+
+ with mock.patch(
+ "prowler.providers.okta.lib.service.service.OktaSDKClient",
+ return_value=sdk_mock,
+ ):
+ service = NetworkZone(provider)
+
+ assert service.network_zones == {}
+ assert service.retrieval_error == (
+ "Raw Network Zones fetch failed; see logs for details."
+ )
+
+ def test_raw_fallback_handles_execute_error(self):
+ service = self._build_service_with_raw_response(
+ None,
+ execute_error=Exception("timeout"),
+ )
+
+ assert service.network_zones == {}
+ assert service.retrieval_error == (
+ "Raw Network Zones fetch failed; see logs for details."
+ )
+
+ def test_raw_fallback_decodes_bytes_response_body(self):
+ service = self._build_service_with_raw_payload(
+ [
+ {
+ "id": "nzo-bytes",
+ "name": "Bytes",
+ "status": "ACTIVE",
+ "type": "IP",
+ "usage": "BLOCKLIST",
+ }
+ ],
+ body_factory=lambda payload: json.dumps(payload).encode("utf-8"),
+ )
+
+ assert set(service.network_zones.keys()) == {"nzo-bytes"}
+
+ def test_raw_fallback_handles_invalid_utf8_response_body(self):
+ service = self._build_service_with_raw_response(b"\xff")
+
+ assert service.network_zones == {}
+ assert service.retrieval_error == (
+ "Raw Network Zones fetch failed; see logs for details."
+ )
+
+ def test_raw_fallback_handles_invalid_json_response_body(self):
+ service = self._build_service_with_raw_response("{")
+
+ assert service.network_zones == {}
+ assert service.retrieval_error == (
+ "Raw Network Zones fetch failed; see logs for details."
+ )
+
+ def test_raw_fallback_handles_unexpected_payload_shape(self):
+ service = self._build_service_with_raw_payload({"id": "nzo-not-a-list"})
+
+ assert service.network_zones == {}
+ assert service.retrieval_error == (
+ "Raw Network Zones fetch failed; see logs for details."
+ )
+
+ def test_raw_fallback_skips_non_dict_payload_items(self):
+ service = self._build_service_with_raw_payload(
+ [
+ "not-a-zone",
+ {
+ "id": "nzo-valid",
+ "name": "Valid",
+ "status": "ACTIVE",
+ "type": "IP",
+ "usage": "BLOCKLIST",
+ },
+ ]
+ )
+
+ assert set(service.network_zones.keys()) == {"nzo-valid"}
+
+ def test_raw_fallback_paginates_via_link_header(self):
+ next_link = '; rel="next"'
+ page_1 = [
+ {
+ "id": "nzo-1",
+ "name": "First",
+ "status": "ACTIVE",
+ "type": "IP",
+ "usage": "BLOCKLIST",
+ }
+ ]
+ page_2 = [
+ {
+ "id": "nzo-2",
+ "name": "Second",
+ "status": "ACTIVE",
+ "type": "IP",
+ "usage": "BLOCKLIST",
+ }
+ ]
+
+ provider = set_mocked_okta_provider()
+ ve = self._trigger_real_validation_error()
+ execute_calls = []
+
+ async def failing_list_network_zones(*_a, **_k):
+ raise ve
+
+ async def fake_raw_create(*_a, **kwargs):
+ return ({"url": kwargs.get("url", "")}, None)
+
+ async def fake_raw_execute(request):
+ execute_calls.append(request)
+ if len(execute_calls) == 1:
+ return (
+ SimpleNamespace(headers={"link": next_link}),
+ json.dumps(page_1),
+ None,
+ )
+ return (SimpleNamespace(headers={}), json.dumps(page_2), None)
+
+ sdk_mock = mock.MagicMock()
+ sdk_mock.list_network_zones = failing_list_network_zones
+ sdk_mock._request_executor.create_request = fake_raw_create
+ sdk_mock._request_executor.execute = fake_raw_execute
+
+ with mock.patch(
+ "prowler.providers.okta.lib.service.service.OktaSDKClient",
+ return_value=sdk_mock,
+ ):
+ service = NetworkZone(provider)
+
+ assert len(execute_calls) == 2
+ assert "after=cursor-2" in execute_calls[1]["url"]
+ assert set(service.network_zones.keys()) == {"nzo-1", "nzo-2"}
+
+
+class Test_raw_zone_to_model:
+ def test_extracts_address_values_and_categories(self):
+ zone = _raw_zone_to_model(
+ {
+ "id": "nzo-ip",
+ "name": "IPs",
+ "status": "ACTIVE",
+ "type": "IP",
+ "usage": "BLOCKLIST",
+ "system": False,
+ "gateways": [
+ {"type": "CIDR", "value": "203.0.113.0/24"},
+ {"type": "RANGE", "value": "198.51.100.5-198.51.100.10"},
+ ],
+ "proxies": [{"type": "CIDR", "value": "192.0.2.0/24"}],
+ "ipServiceCategories": [
+ {"value": "ANONYMIZER"},
+ {"value": "TOR_ANONYMIZER"},
+ ],
+ }
+ )
+ assert zone.gateways == [
+ "203.0.113.0/24",
+ "198.51.100.5-198.51.100.10",
+ ]
+ assert zone.proxies == ["192.0.2.0/24"]
+ assert zone.ip_service_categories == ["ANONYMIZER", "TOR_ANONYMIZER"]
+
+ def test_collapses_non_list_asns_and_locations_to_empty(self):
+ zone = _raw_zone_to_model(
+ {
+ "id": "nzo-enhanced",
+ "name": "Enhanced",
+ "type": "DYNAMIC_V2",
+ "asns": {"include": [], "exclude": []},
+ "locations": {"include": [], "exclude": []},
+ }
+ )
+ assert zone.asns == []
+ assert zone.locations == []
+ assert isinstance(zone, OktaNetworkZone)
+
+ def test_extracts_ip_service_categories_from_raw_include_condition(self):
+ zone = _raw_zone_to_model(
+ {
+ "id": "nzo-enhanced",
+ "name": "Enhanced",
+ "type": "DYNAMIC_V2",
+ "ipServiceCategories": {
+ "include": ["ALL_ANONYMIZERS"],
+ "exclude": [],
+ },
+ }
+ )
+ assert zone.ip_service_categories == ["ALL_ANONYMIZERS"]
+
+ def test_extracts_scalar_ip_service_category_condition(self):
+ zone = _raw_zone_to_model(
+ {
+ "id": "nzo-enhanced",
+ "name": "Enhanced",
+ "type": "DYNAMIC_V2",
+ "ipServiceCategories": {
+ "include": {"value": "VPN_ANONYMIZER"},
+ "exclude": [],
+ },
+ }
+ )
+ assert zone.ip_service_categories == ["VPN_ANONYMIZER"]
+
+ def test_ignores_none_address_entries_and_empty_condition_values(self):
+ zone = _raw_zone_to_model(
+ {
+ "id": "nzo-ip",
+ "gateways": [None],
+ "ipServiceCategories": {
+ "include": None,
+ "exclude": [],
+ },
+ }
+ )
+ assert zone.gateways == []
+ assert zone.ip_service_categories == []
+
+ def test_falls_back_name_to_id_when_missing(self):
+ zone = _raw_zone_to_model({"id": "nzo-1"})
+ assert zone.id == "nzo-1"
+ assert zone.name == "nzo-1"
+ assert zone.status == ""
+ assert zone.system is False
From 0ea2f6d67ee54a6e3e05f118fdb309dd4e18d117 Mon Sep 17 00:00:00 2001
From: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com>
Date: Mon, 8 Jun 2026 17:11:54 +0200
Subject: [PATCH 021/129] feat(okta): add API token STIG checks (#11464)
Co-authored-by: Daniel Barranquero
---
.../providers/okta/authentication.mdx | 16 +-
.../providers/okta/getting-started-okta.mdx | 18 +-
prowler/CHANGELOG.md | 1 +
prowler/providers/okta/okta_provider.py | 3 +
.../okta/services/apitoken/__init__.py | 0
.../services/apitoken/api_token_client.py | 4 +
.../services/apitoken/api_token_service.py | 327 ++++++++++
.../apitoken_not_super_admin/__init__.py | 0
.../apitoken_not_super_admin.metadata.json | 37 ++
.../apitoken_not_super_admin.py | 70 ++
.../__init__.py | 0
...n_restricted_to_network_zone.metadata.json | 37 ++
.../apitoken_restricted_to_network_zone.py | 55 ++
.../okta/services/apitoken/lib/__init__.py | 0
.../apitoken/lib/api_token_helpers.py | 140 ++++
.../okta/lib/service/pagination_test.py | 147 +++++
tests/providers/okta/okta_fixtures.py | 6 +
.../services/api_token/api_token_fixtures.py | 46 ++
.../api_token/api_token_service_test.py | 616 ++++++++++++++++++
.../apitoken_not_super_admin_test.py | 99 +++
...pitoken_restricted_to_network_zone_test.py | 114 ++++
21 files changed, 1724 insertions(+), 12 deletions(-)
create mode 100644 prowler/providers/okta/services/apitoken/__init__.py
create mode 100644 prowler/providers/okta/services/apitoken/api_token_client.py
create mode 100644 prowler/providers/okta/services/apitoken/api_token_service.py
create mode 100644 prowler/providers/okta/services/apitoken/apitoken_not_super_admin/__init__.py
create mode 100644 prowler/providers/okta/services/apitoken/apitoken_not_super_admin/apitoken_not_super_admin.metadata.json
create mode 100644 prowler/providers/okta/services/apitoken/apitoken_not_super_admin/apitoken_not_super_admin.py
create mode 100644 prowler/providers/okta/services/apitoken/apitoken_restricted_to_network_zone/__init__.py
create mode 100644 prowler/providers/okta/services/apitoken/apitoken_restricted_to_network_zone/apitoken_restricted_to_network_zone.metadata.json
create mode 100644 prowler/providers/okta/services/apitoken/apitoken_restricted_to_network_zone/apitoken_restricted_to_network_zone.py
create mode 100644 prowler/providers/okta/services/apitoken/lib/__init__.py
create mode 100644 prowler/providers/okta/services/apitoken/lib/api_token_helpers.py
create mode 100644 tests/providers/okta/lib/service/pagination_test.py
create mode 100644 tests/providers/okta/services/api_token/api_token_fixtures.py
create mode 100644 tests/providers/okta/services/api_token/api_token_service_test.py
create mode 100644 tests/providers/okta/services/api_token/apitoken_not_super_admin/apitoken_not_super_admin_test.py
create mode 100644 tests/providers/okta/services/api_token/apitoken_restricted_to_network_zone/apitoken_restricted_to_network_zone_test.py
diff --git a/docs/user-guide/providers/okta/authentication.mdx b/docs/user-guide/providers/okta/authentication.mdx
index 67eb72e153..74e8ef7805 100644
--- a/docs/user-guide/providers/okta/authentication.mdx
+++ b/docs/user-guide/providers/okta/authentication.mdx
@@ -36,6 +36,9 @@ The bundled checks require the following read-only scopes:
- `okta.brands.read`
- `okta.apps.read`
- `okta.networkZones.read`
+- `okta.apiTokens.read`
+- `okta.roles.read`
+- `okta.groups.read`
- `okta.logStreams.read`
- `okta.idps.read`
@@ -46,7 +49,10 @@ Additional scopes will be needed as more services and checks are added. These ar
| `okta.policies.read` | Sign-on, password, authentication, and `USER_LIFECYCLE` (Workflow > Automations) policies |
| `okta.brands.read` | Sign-in page customizations (DOD Notice and Consent Banner check) |
| `okta.apps.read` | First-party app settings (Okta Admin Console session), integrated app inventory, and the Authentication Policies bound to Okta applications |
-| `okta.networkZones.read` | Network Zone inventory and anonymized-proxy blocklist checks |
+| `okta.networkZones.read` | Network Zone inventory, anonymized-proxy blocklist checks, and API token Network Zone validation |
+| `okta.apiTokens.read` | API token metadata and token network conditions |
+| `okta.roles.read` | Admin role assignments for API token owners (both direct and group-inherited) |
+| `okta.groups.read` | Group memberships of API token owners, used to resolve admin roles inherited via group assignment (e.g. Super Admin granted through the default admin group) |
| `okta.logStreams.read` | Log Stream configuration (`/api/v1/logStreams`) |
| `okta.idps.read` | Identity Providers, including Smart Card (X509) IdPs (`/api/v1/idps`) |
@@ -130,7 +136,7 @@ Okta displays the private key **only once**. If you close the modal without copy
### 5. Grant the required OAuth scopes
-On the app, open the **Okta API Scopes** tab and click **Grant** on every scope Prowler needs. The bundled checks require `okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.networkZones.read`, `okta.logStreams.read`, and `okta.idps.read`.
+On the app, open the **Okta API Scopes** tab and click **Grant** on every scope Prowler needs. The bundled checks require `okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.networkZones.read`, `okta.apiTokens.read`, `okta.roles.read`, `okta.groups.read`, `okta.logStreams.read`, and `okta.idps.read`.

@@ -166,8 +172,8 @@ export OKTA_PRIVATE_KEY_FILE="/secure/path/to/prowler-okta.pem"
# or
export OKTA_PRIVATE_KEY="$(cat /secure/path/to/prowler-okta.pem)"
-# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read,okta.networkZones.read,okta.logStreams.read,okta.idps.read"
-export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.networkZones.read,okta.logStreams.read,okta.idps.read"
+# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read"
+export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read"
uv run python prowler-cli.py okta
```
@@ -208,7 +214,7 @@ Prowler validates credentials at startup by listing one sign-on policy. This err
Raised when the credential probe succeeds at the OAuth layer but the request is rejected because the service app lacks the required scope or admin role:
-- **`invalid_scope`** — one of the requested scopes (`okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.networkZones.read`, `okta.logStreams.read`, and `okta.idps.read`) is not granted on the service app. Grant the missing scope from **Okta API Scopes**.
+- **`invalid_scope`** — one of the requested scopes (`okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.networkZones.read`, `okta.apiTokens.read`, `okta.roles.read`, `okta.groups.read`, `okta.logStreams.read`, and `okta.idps.read`) is not granted on the service app. Grant the missing scope from **Okta API Scopes**.
- **`Forbidden` / `not authorized`** — no admin role is assigned to the service app. Assign **Read-Only Administrator** (or **Super Administrator** for the first-party application checks) from **Admin roles**.
### Application-service checks return MANUAL on first-party apps
diff --git a/docs/user-guide/providers/okta/getting-started-okta.mdx b/docs/user-guide/providers/okta/getting-started-okta.mdx
index 740cfbf729..32c08433b7 100644
--- a/docs/user-guide/providers/okta/getting-started-okta.mdx
+++ b/docs/user-guide/providers/okta/getting-started-okta.mdx
@@ -12,7 +12,7 @@ Set up authentication for Okta with the [Okta Authentication](/user-guide/provid
- An Okta organization. The UI examples below use **Identity Engine** terminology such as **Global Session Policy**; Classic Engine exposes the equivalent sign-on policy concepts under older names.
- A **Super Administrator** account on that organization for the one-time service-app setup.
-- An **API Services** app integration in the Okta Admin Console with the `okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.networkZones.read`, `okta.logStreams.read`, and `okta.idps.read` scopes granted and an admin role assigned. **Read-Only Administrator** covers the Sign-On, Network, User, System Log, and Identity Provider checks, and runs the per-app application network-zone check against the apps the service app can see (under Read-Only Administrator that is typically only the service app's own row — the rest of the org's app inventory stays invisible). **Super Administrator** is required additionally to evaluate the five first-party application checks (Okta Admin Console / Okta Dashboard idle timeout, MFA, phishing-resistant authentication) and to widen the application network-zone check to the full app inventory — see [Okta Authentication](/user-guide/providers/okta/authentication#required-admin-role) for the full breakdown.
+- An **API Services** app integration in the Okta Admin Console with the `okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.networkZones.read`, `okta.apiTokens.read`, `okta.roles.read`, `okta.groups.read`, `okta.logStreams.read`, and `okta.idps.read` scopes granted and an admin role assigned. **Read-Only Administrator** covers the Sign-On, Network, API Token, User, System Log, and Identity Provider checks, and runs the per-app application network-zone check against the apps the service app can see (under Read-Only Administrator that is typically only the service app's own row — the rest of the org's app inventory stays invisible). **Super Administrator** is required additionally to evaluate the five first-party application checks (Okta Admin Console / Okta Dashboard idle timeout, MFA, phishing-resistant authentication) and to widen the application network-zone check to the full app inventory — see [Okta Authentication](/user-guide/providers/okta/authentication#required-admin-role) for the full breakdown.
- Python 3.10+ and Prowler 5.27.0 or later installed locally.
@@ -85,8 +85,8 @@ Follow the [Okta Authentication](/user-guide/providers/okta/authentication) guid
export OKTA_ORG_DOMAIN="acme.okta.com"
export OKTA_CLIENT_ID="0oa1234567890abcdef"
export OKTA_PRIVATE_KEY_FILE="/secure/path/to/prowler-okta.pem"
-# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read,okta.networkZones.read,okta.logStreams.read,okta.idps.read"
-export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.networkZones.read,okta.logStreams.read,okta.idps.read"
+# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read"
+export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read"
```
The private key file may contain either a PEM-encoded RSA key or a JWK JSON document.
@@ -148,6 +148,7 @@ Prowler for Okta includes security checks across the following services:
| **Sign-On** | Global session policy controls (idle timeout, lifetime, rule priority and ordering) |
| **Application** | Okta Admin Console sign-on settings plus Authentication Policy controls for Okta applications (session idle, MFA, phishing resistance, network zones) |
| **Network** | Network Zone blocklists for anonymized proxy sources |
+| **API Token** | API token owner-role validation and Network Zone restrictions |
| **User** | User lifecycle automations (inactivity-based deprovisioning) |
| **System Log** | Log Stream configuration that off-loads audit records to a central SIEM |
| **Identity Provider** | Identity Providers, including Smart Card (X509) IdP status and certificate-chain visibility |
@@ -162,25 +163,28 @@ This is stricter than simply finding the same timeout value somewhere else in th
### Default Scopes
-Prowler requests a fixed set of OAuth scopes on every token exchange. The defaults cover every bundled check across the Sign-On, Application, Network, User, System Log, and Identity Provider services:
+Prowler requests a fixed set of OAuth scopes on every token exchange. The defaults cover every bundled check across the Sign-On, Application, Network, API Token, User, System Log, and Identity Provider services:
- `okta.policies.read`
- `okta.brands.read`
- `okta.apps.read`
- `okta.networkZones.read`
+- `okta.apiTokens.read`
+- `okta.roles.read`
+- `okta.groups.read`
- `okta.logStreams.read`
- `okta.idps.read`
-The service app must have these scopes granted in the **Okta API Scopes** tab. When the granted set is narrower than the requested set, the token request fails with an `invalid_scope` error and the scan stops at provider initialization.
+The service app must have these scopes granted in the **Okta API Scopes** tab. `okta.groups.read` is required so the API token Super Admin check can resolve admin roles inherited via group membership; without it the check falls back to direct-only role assignments and emits a best-effort caveat. When the granted set is narrower than the requested set, the token request fails with an `invalid_scope` error and the scan stops at provider initialization.
When additional checks are enabled — or when running against a service app that exposes a different scope set — override the default with `OKTA_SCOPES` (comma-separated string for the env var) or `--okta-scopes` (space-separated list for the CLI):
```bash
# Environment variable — comma-separated
-export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.networkZones.read,okta.logStreams.read,okta.idps.read,okta.users.read"
+export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read,okta.users.read"
# CLI flag — space-separated
-prowler okta --okta-scopes okta.policies.read okta.brands.read okta.apps.read okta.networkZones.read okta.logStreams.read okta.idps.read okta.users.read
+prowler okta --okta-scopes okta.policies.read okta.brands.read okta.apps.read okta.networkZones.read okta.apiTokens.read okta.roles.read okta.groups.read okta.logStreams.read okta.idps.read okta.users.read
```
For the full catalog of OAuth scopes exposed by the Okta Management API, refer to the [Okta OAuth 2.0 scopes documentation](https://developer.okta.com/docs/api/oauth2/).
diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md
index fab236e9e1..7893dd36b3 100644
--- a/prowler/CHANGELOG.md
+++ b/prowler/CHANGELOG.md
@@ -9,6 +9,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- `sagemaker_models_monitor_enabled` check for AWS provider, verifying that each SageMaker monitoring schedule is in the `Scheduled` state so data and model drift is actively detected [(#11278)](https://github.com/prowler-cloud/prowler/pull/11278)
- DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554) universal compliance framework with AWS provider coverage across the five DORA pillars [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131)
- Okta network zone check to detect whether anonymized proxy traffic is blocked [(#11463)](https://github.com/prowler-cloud/prowler/pull/11463)
+- Okta API token checks for super admin ownership and network zone restrictions [(#11464)](https://github.com/prowler-cloud/prowler/pull/11464)
- `elbv2_alb_drop_invalid_header_fields_enabled` check for AWS provider, verifying Application Load Balancers have `routing.http.drop_invalid_header_fields.enabled` set to `true` to mitigate HTTP desync attacks (AWS FSBP ELB.4) [(#11471)](https://github.com/prowler-cloud/prowler/pull/11471)
- `user`, `systemlog` and `idp` service for Okta provider with `user_inactivity_automation_35d_enabled`, `systemlog_streaming_enabled` and `idp_smart_card_dod_approved_ca` checks [(#11496)](https://github.com/prowler-cloud/prowler/pull/11496)
diff --git a/prowler/providers/okta/okta_provider.py b/prowler/providers/okta/okta_provider.py
index 4356adbc57..e5c968e947 100644
--- a/prowler/providers/okta/okta_provider.py
+++ b/prowler/providers/okta/okta_provider.py
@@ -37,6 +37,9 @@ DEFAULT_SCOPES = [
"okta.brands.read",
"okta.apps.read",
"okta.networkZones.read",
+ "okta.apiTokens.read",
+ "okta.roles.read",
+ "okta.groups.read",
"okta.logStreams.read",
"okta.idps.read",
]
diff --git a/prowler/providers/okta/services/apitoken/__init__.py b/prowler/providers/okta/services/apitoken/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/okta/services/apitoken/api_token_client.py b/prowler/providers/okta/services/apitoken/api_token_client.py
new file mode 100644
index 0000000000..fbe10d7c7f
--- /dev/null
+++ b/prowler/providers/okta/services/apitoken/api_token_client.py
@@ -0,0 +1,4 @@
+from prowler.providers.common.provider import Provider
+from prowler.providers.okta.services.apitoken.api_token_service import ApiToken
+
+api_token_client = ApiToken(Provider.get_global_provider())
diff --git a/prowler/providers/okta/services/apitoken/api_token_service.py b/prowler/providers/okta/services/apitoken/api_token_service.py
new file mode 100644
index 0000000000..5fafd71c35
--- /dev/null
+++ b/prowler/providers/okta/services/apitoken/api_token_service.py
@@ -0,0 +1,327 @@
+from typing import Optional
+
+from pydantic import BaseModel, Field, ValidationError
+
+from prowler.lib.logger import logger
+from prowler.providers.okta.lib.service.pagination import paginate as _paginate_shared
+from prowler.providers.okta.lib.service.raw_fetch import (
+ get_json_paginated as _raw_get_json_paginated,
+)
+from prowler.providers.okta.lib.service.service import OktaService
+
+REQUIRED_SCOPES: dict[str, str] = {
+ "api_tokens": "okta.apiTokens.read",
+ "network_zones": "okta.networkZones.read",
+ "user_roles": "okta.roles.read",
+ # Needed to resolve admin roles inherited via group membership.
+ # `/api/v1/users/{id}/roles` returns only direct role assignments;
+ # group-inherited Super Admin is invisible without `okta.groups.read`
+ # to enumerate the user's groups.
+ "user_groups": "okta.groups.read",
+}
+
+
+def _value(value) -> str:
+ """Return plain string values from Okta SDK enums and raw strings."""
+ if value is None:
+ return ""
+ enum_value = getattr(value, "value", None)
+ if enum_value is not None:
+ return str(enum_value)
+ return str(value)
+
+
+def _role_to_string(role) -> str:
+ """Pick the most specific role identifier from an SDK Role object.
+
+ `list_assigned_roles_for_user` and `list_group_assigned_roles` return
+ `ListGroupAssignedRoles200ResponseInner` — a oneOf wrapper that holds
+ the real `StandardRole`/`CustomRole` on `.actual_instance`. Reading
+ `.type`/`.label` from the wrapper returns None and the role silently
+ disappears, so unwrap first.
+ """
+ inner = getattr(role, "actual_instance", None) or role
+ return _value(getattr(inner, "type", None)) or _value(getattr(inner, "label", None))
+
+
+def _raw_value(item, key: str) -> str:
+ """Return a string value from an SDK model or raw dictionary."""
+ if isinstance(item, dict):
+ return _value(item.get(key))
+ return _value(getattr(item, key, None))
+
+
+class ApiToken(OktaService):
+ """Fetches Okta API token metadata, token owners' roles, and zones."""
+
+ def __init__(self, provider):
+ super().__init__(__class__.__name__, provider)
+ granted = set(getattr(provider.identity, "granted_scopes", None) or [])
+ self.missing_scope: dict[str, Optional[str]] = {
+ resource: (scope if granted and scope not in granted else None)
+ for resource, scope in REQUIRED_SCOPES.items()
+ }
+ # Per-resource caches keyed on the Okta resource id. API tokens
+ # commonly share owners (e.g. a service user holding multiple
+ # tokens) and admin groups frequently overlap across users, so we
+ # memoize the resolutions within a single service instance.
+ self._user_roles_cache: dict[str, list[str]] = {}
+ self._group_roles_cache: dict[str, list[str]] = {}
+ self.known_network_zone_ids: set[str] = (
+ set()
+ if self.missing_scope["api_tokens"] or self.missing_scope["network_zones"]
+ else self._list_known_network_zone_ids()
+ )
+ self.api_tokens: dict[str, OktaApiToken] = (
+ {} if self.missing_scope["api_tokens"] else self._list_api_tokens()
+ )
+
+ def _list_api_tokens(self) -> dict[str, "OktaApiToken"]:
+ """List active API token metadata and owner roles."""
+ logger.info("ApiToken - Listing Okta API tokens...")
+ try:
+ return self._run(self._fetch_api_tokens())
+ except Exception as error:
+ logger.error(
+ f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+ return {}
+
+ async def _fetch_api_tokens(self) -> dict[str, "OktaApiToken"]:
+ # `list_api_tokens` is non-paginated in the SDK (no `after`
+ # parameter); we inline the tuple unwrap rather than going
+ # through `paginate`. Same pattern application_service uses for
+ # `get_first_party_app_settings`.
+ result: dict[str, OktaApiToken] = {}
+ sdk_result = await self.client.list_api_tokens()
+ err = sdk_result[-1]
+ if err is not None:
+ logger.error(f"Error listing API tokens: {err}")
+ return result
+ items = sdk_result[0] or []
+
+ for token in items:
+ token_id = _value(getattr(token, "id", None))
+ user_id = _value(getattr(token, "user_id", None))
+ roles = (
+ await self._fetch_effective_user_role_types(user_id) if user_id else []
+ )
+ network = getattr(token, "network", None)
+ token_obj = OktaApiToken(
+ id=token_id,
+ name=_value(getattr(token, "name", None)) or token_id,
+ client_name=_value(getattr(token, "client_name", None)),
+ user_id=user_id,
+ network_connection=_value(getattr(network, "connection", None)),
+ network_includes=list(getattr(network, "include", None) or []),
+ network_excludes=list(getattr(network, "exclude", None) or []),
+ owner_roles=roles,
+ )
+ result[token_obj.id] = token_obj
+ return result
+
+ async def _fetch_effective_user_role_types(self, user_id: str) -> list[str]:
+ """Return direct + group-inherited admin role types for `user_id`.
+
+ Okta's `/api/v1/users/{userId}/roles` (the SDK's
+ `list_assigned_roles_for_user`) only returns roles assigned
+ *directly* to the user. Roles inherited via group membership are
+ invisible to that endpoint — but they are how Okta normally
+ grants Super Admin (e.g. the org creator joins the default
+ "Okta Super Admins" group). Without resolving group-inherited
+ roles, the Super Admin check would falsely PASS for any token
+ whose owner gets admin via a group.
+
+ Results are memoized per `user_id` so multiple tokens with the
+ same owner cost a single resolution.
+ """
+ if user_id in self._user_roles_cache:
+ return self._user_roles_cache[user_id]
+ direct = await self._fetch_direct_user_role_types(user_id)
+ inherited = await self._fetch_group_inherited_role_types(user_id)
+ # Dedupe while preserving first-seen order (direct first, then
+ # inherited) so the status_extended reads from most-specific.
+ seen: set[str] = set()
+ combined: list[str] = []
+ for role in (*direct, *inherited):
+ if role and role not in seen:
+ combined.append(role)
+ seen.add(role)
+ self._user_roles_cache[user_id] = combined
+ return combined
+
+ async def _fetch_direct_user_role_types(self, user_id: str) -> list[str]:
+ """Return roles assigned directly to the user (no group inheritance)."""
+ if self.missing_scope["user_roles"]:
+ return []
+ # `list_assigned_roles_for_user` is non-paginated in the SDK
+ # (no `after` parameter); inline the tuple unwrap.
+ sdk_result = await self.client.list_assigned_roles_for_user(user_id)
+ err = sdk_result[-1]
+ if err is not None:
+ logger.error(f"Error listing roles for token owner {user_id}: {err}")
+ return []
+ items = sdk_result[0] or []
+ roles = [_role_to_string(role) for role in items if _role_to_string(role)]
+ if roles or not items:
+ return roles
+
+ # Belt-and-suspenders: when the SDK's typed parse returns items
+ # but every projection ends up empty (a discriminator surface we
+ # don't yet handle, a future schema change, …), fall back to the
+ # raw JSON. The `_role_to_string` unwrap above already covers the
+ # known `ListGroupAssignedRoles200ResponseInner` oneOf wrapper
+ # bug — this fallback exists for whatever the next SDK quirk is.
+ return await self._fetch_user_role_types_raw(user_id)
+
+ async def _fetch_user_role_types_raw(self, user_id: str) -> list[str]:
+ """Return user role types from the raw response when typed models are empty.
+
+ Uses the shared `get_json_paginated` helper so any `Link: next`
+ header the API returns is followed (role lists are typically
+ small, but the SDK doesn't paginate this endpoint at all so the
+ only correct way to drain it lives here).
+ """
+ raw_items = await _raw_get_json_paginated(
+ self.client,
+ f"/api/v1/users/{user_id}/roles",
+ context=f"user roles for {user_id}",
+ )
+ if raw_items is None:
+ return []
+ roles = [
+ _value(role.get("type")) or _value(role.get("label"))
+ for role in raw_items
+ if isinstance(role, dict)
+ ]
+ return [role for role in roles if role]
+
+ async def _fetch_group_inherited_role_types(self, user_id: str) -> list[str]:
+ """Return roles inherited via the user's group memberships.
+
+ Each group's role list is itself memoized — admin groups are
+ commonly shared across many users.
+ """
+ if self.missing_scope["user_roles"] or self.missing_scope["user_groups"]:
+ return []
+ # Defensive try/except: tenants we've seen in the wild return 403
+ # on `/api/v1/users/{id}/groups` even when `okta.groups.read` is
+ # granted (admin-role on the service app gates the response
+ # separately). Treat any failure as "no inherited roles" so the
+ # caller still surfaces direct roles cleanly.
+ try:
+ sdk_result = await self.client.list_user_groups(user_id)
+ except Exception as error:
+ logger.error(
+ f"Error listing groups for token owner {user_id}: "
+ f"{error.__class__.__name__}: {error}"
+ )
+ return []
+ err = sdk_result[-1]
+ if err is not None:
+ logger.error(f"Error listing groups for token owner {user_id}: {err}")
+ return []
+ groups = sdk_result[0] or []
+ roles: list[str] = []
+ for group in groups:
+ group_id = _value(getattr(group, "id", None))
+ if not group_id:
+ continue
+ if group_id in self._group_roles_cache:
+ roles.extend(self._group_roles_cache[group_id])
+ continue
+ # Per-group try/except: one group's parse or auth failure
+ # must not erase admin-role coverage for other groups.
+ try:
+ group_roles = await self._fetch_group_role_types(group_id)
+ except Exception as error:
+ logger.error(
+ f"Error listing roles for group {group_id} "
+ f"(owner={user_id}): {error.__class__.__name__}: {error}"
+ )
+ group_roles = []
+ self._group_roles_cache[group_id] = group_roles
+ roles.extend(group_roles)
+ return roles
+
+ async def _fetch_group_role_types(self, group_id: str) -> list[str]:
+ """Return role types assigned to `group_id`."""
+ sdk_result = await self.client.list_group_assigned_roles(group_id)
+ err = sdk_result[-1]
+ if err is not None:
+ logger.error(f"Error listing roles for group {group_id}: {err}")
+ return []
+ items = sdk_result[0] or []
+ return [_role_to_string(role) for role in items if _role_to_string(role)]
+
+ def _list_known_network_zone_ids(self) -> set[str]:
+ """List known Network Zone ids and names for token condition validation."""
+ logger.info("ApiToken - Listing Network Zones for token restrictions...")
+ try:
+ return self._run(self._fetch_known_network_zone_ids())
+ except Exception as error:
+ logger.error(
+ f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+ return set()
+
+ async def _fetch_known_network_zone_ids(self) -> set[str]:
+ identifiers: set[str] = set()
+ items, err = await self._fetch_all_network_zones()
+ if err is not None:
+ logger.error(f"Error listing Network Zones for API token checks: {err}")
+ return identifiers
+ for zone in items:
+ zone_id = _raw_value(zone, "id")
+ zone_name = _raw_value(zone, "name")
+ if zone_id:
+ identifiers.add(zone_id)
+ if zone_name:
+ identifiers.add(zone_name)
+ return identifiers
+
+ async def _fetch_all_network_zones(self) -> tuple[list, object]:
+ """Drain all Network Zone pages for API token reference validation.
+
+ Catches the upstream Okta SDK ↔ Management API schema drift on
+ Enhanced Dynamic Zones (object-shaped pydantic model where the
+ API returns a JSON array) the same way `network_zone_service`
+ does. `(ValueError, ValidationError)` covers both discriminator
+ misses and model mismatches — matching the `user_service`
+ precedent.
+ """
+ try:
+ return await _paginate_shared(
+ lambda after: self.client.list_network_zones(after=after, limit=200)
+ )
+ except (ValueError, ValidationError) as ex:
+ logger.warning(
+ f"Okta SDK raised {type(ex).__name__} parsing Network Zones "
+ "for API token validation — falling back to raw-JSON parse."
+ )
+ return await self._fetch_all_network_zones_raw()
+
+ async def _fetch_all_network_zones_raw(self) -> tuple[list, object]:
+ """Drain Network Zone pages via the shared raw-JSON helper."""
+ items = await _raw_get_json_paginated(
+ self.client,
+ "/api/v1/zones",
+ page_size=200,
+ context="Network Zones for API token validation",
+ )
+ if items is None:
+ return [], Exception("raw Network Zones fetch failed; see logs")
+ return items, None
+
+
+class OktaApiToken(BaseModel):
+ """Normalized Okta API token metadata used by checks."""
+
+ id: str
+ name: str
+ client_name: str = ""
+ user_id: str = ""
+ network_connection: str = ""
+ network_includes: list[str] = Field(default_factory=list)
+ network_excludes: list[str] = Field(default_factory=list)
+ owner_roles: list[str] = Field(default_factory=list)
diff --git a/prowler/providers/okta/services/apitoken/apitoken_not_super_admin/__init__.py b/prowler/providers/okta/services/apitoken/apitoken_not_super_admin/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/okta/services/apitoken/apitoken_not_super_admin/apitoken_not_super_admin.metadata.json b/prowler/providers/okta/services/apitoken/apitoken_not_super_admin/apitoken_not_super_admin.metadata.json
new file mode 100644
index 0000000000..d15c5b4680
--- /dev/null
+++ b/prowler/providers/okta/services/apitoken/apitoken_not_super_admin/apitoken_not_super_admin.metadata.json
@@ -0,0 +1,37 @@
+{
+ "Provider": "okta",
+ "CheckID": "apitoken_not_super_admin",
+ "CheckTitle": "Okta API tokens are not owned by Super Admin users",
+ "CheckType": [],
+ "ServiceName": "apitoken",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "medium",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "IAM",
+ "Description": "**Okta API token ownership** should avoid Super Admin users because API tokens inherit the admin permissions of the user that created them.",
+ "Risk": "**Super Admin-owned API tokens** become high-impact secrets: if one is exposed, an attacker can perform broad organization administration with the token owner privileges.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://developer.okta.com/docs/api/openapi/okta-management/guides/roles",
+ "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/apitoken"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "",
+ "NativeIaC": "",
+ "Other": "Create a dedicated service account, assign only required admin roles, rotate the API token, and revoke Super Admin-owned tokens.",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "**Use dedicated Okta service accounts** for API tokens and assign only the least-privilege admin roles required; rotate and revoke tokens created by Super Admin users.",
+ "Url": "https://hub.prowler.com/check/apitoken_not_super_admin"
+ }
+ },
+ "Categories": [
+ "identity-access"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": ""
+}
diff --git a/prowler/providers/okta/services/apitoken/apitoken_not_super_admin/apitoken_not_super_admin.py b/prowler/providers/okta/services/apitoken/apitoken_not_super_admin/apitoken_not_super_admin.py
new file mode 100644
index 0000000000..0971af4aab
--- /dev/null
+++ b/prowler/providers/okta/services/apitoken/apitoken_not_super_admin/apitoken_not_super_admin.py
@@ -0,0 +1,70 @@
+from prowler.lib.check.models import Check, CheckReportOkta
+from prowler.providers.okta.services.apitoken.api_token_client import api_token_client
+from prowler.providers.okta.services.apitoken.lib.api_token_helpers import (
+ missing_api_token_scope_finding,
+ missing_user_roles_scope_for_token_finding,
+ owner_has_super_admin,
+)
+
+
+class apitoken_not_super_admin(Check):
+ """Ensure Okta API tokens are not owned by Super Admin users."""
+
+ def execute(self) -> list[CheckReportOkta]:
+ """Evaluate every active API token owner's assigned admin roles."""
+ org_domain = api_token_client.provider.identity.org_domain
+ missing_api_token_scope = api_token_client.missing_scope.get("api_tokens")
+ if missing_api_token_scope:
+ return [
+ missing_api_token_scope_finding(
+ self.metadata(),
+ org_domain,
+ missing_api_token_scope,
+ additional_required=["okta.roles.read", "okta.groups.read"],
+ )
+ ]
+
+ missing_user_roles_scope = api_token_client.missing_scope.get("user_roles")
+ # `okta.groups.read` is needed to resolve admin roles inherited via
+ # group membership. Without it we fall back to direct-only role
+ # assignments, which Okta returns for `/api/v1/users/{id}/roles` —
+ # commonly empty for trial accounts where Super Admin is granted
+ # through the default admin group. The finding stays evaluable but
+ # is flagged as best-effort so operators know to grant the scope.
+ missing_user_groups_scope = api_token_client.missing_scope.get("user_groups")
+ findings: list[CheckReportOkta] = []
+ for token in api_token_client.api_tokens.values():
+ report = CheckReportOkta(
+ metadata=self.metadata(), resource=token, org_domain=org_domain
+ )
+ if missing_user_roles_scope:
+ report = missing_user_roles_scope_for_token_finding(
+ self.metadata(), org_domain, token, missing_user_roles_scope
+ )
+ elif owner_has_super_admin(token):
+ report.status = "FAIL"
+ report.status_extended = (
+ f"API token {token.name} is owned by user {token.user_id} "
+ "with the Super Admin role. Use a dedicated service account "
+ "with least-privilege admin roles instead."
+ )
+ else:
+ roles = (
+ ", ".join(token.owner_roles)
+ if token.owner_roles
+ else "no admin roles returned"
+ )
+ caveat = (
+ " Group-inherited roles were not checked because the "
+ f"`{missing_user_groups_scope}` scope is missing — grant "
+ "it to detect Super Admin assigned via group membership."
+ if missing_user_groups_scope
+ else ""
+ )
+ report.status = "PASS"
+ report.status_extended = (
+ f"API token {token.name} owner {token.user_id} is not "
+ f"assigned Super Admin ({roles}).{caveat}"
+ )
+ findings.append(report)
+ return findings
diff --git a/prowler/providers/okta/services/apitoken/apitoken_restricted_to_network_zone/__init__.py b/prowler/providers/okta/services/apitoken/apitoken_restricted_to_network_zone/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/okta/services/apitoken/apitoken_restricted_to_network_zone/apitoken_restricted_to_network_zone.metadata.json b/prowler/providers/okta/services/apitoken/apitoken_restricted_to_network_zone/apitoken_restricted_to_network_zone.metadata.json
new file mode 100644
index 0000000000..4a088a0c1c
--- /dev/null
+++ b/prowler/providers/okta/services/apitoken/apitoken_restricted_to_network_zone/apitoken_restricted_to_network_zone.metadata.json
@@ -0,0 +1,37 @@
+{
+ "Provider": "okta",
+ "CheckID": "apitoken_restricted_to_network_zone",
+ "CheckTitle": "Okta API tokens are restricted to known Network Zones",
+ "CheckType": [],
+ "ServiceName": "apitoken",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "medium",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "IAM",
+ "Description": "**Okta API token network restrictions** should prevent token use from Any IP by tying each token to known Okta Network Zones.",
+ "Risk": "**API tokens allowed from Any IP** can be replayed from attacker-controlled infrastructure if the secret is exposed, removing an important network boundary.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/apitoken",
+ "https://help.okta.com/oie/en-us/content/topics/security/api.htm"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "",
+ "NativeIaC": "",
+ "Other": "Security > API > Tokens: edit each token Security section and select specific Network Zones instead of Any IP.",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "**Restrict every Okta API token to trusted IP-based Network Zones** and review token network conditions whenever service locations change.",
+ "Url": "https://hub.prowler.com/check/apitoken_restricted_to_network_zone"
+ }
+ },
+ "Categories": [
+ "identity-access"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": ""
+}
diff --git a/prowler/providers/okta/services/apitoken/apitoken_restricted_to_network_zone/apitoken_restricted_to_network_zone.py b/prowler/providers/okta/services/apitoken/apitoken_restricted_to_network_zone/apitoken_restricted_to_network_zone.py
new file mode 100644
index 0000000000..04cfcf2df7
--- /dev/null
+++ b/prowler/providers/okta/services/apitoken/apitoken_restricted_to_network_zone/apitoken_restricted_to_network_zone.py
@@ -0,0 +1,55 @@
+from prowler.lib.check.models import Check, CheckReportOkta
+from prowler.providers.okta.services.apitoken.api_token_client import api_token_client
+from prowler.providers.okta.services.apitoken.lib.api_token_helpers import (
+ definite_network_zone_restriction_failure,
+ missing_api_token_scope_finding,
+ missing_network_zone_scope_for_token_finding,
+ network_zone_restriction_status,
+)
+
+
+class apitoken_restricted_to_network_zone(Check):
+ """Ensure Okta API tokens are restricted to known Network Zones."""
+
+ def execute(self) -> list[CheckReportOkta]:
+ """Evaluate every active API token's network condition."""
+ org_domain = api_token_client.provider.identity.org_domain
+ missing_api_token_scope = api_token_client.missing_scope.get("api_tokens")
+ if missing_api_token_scope:
+ return [
+ missing_api_token_scope_finding(
+ self.metadata(),
+ org_domain,
+ missing_api_token_scope,
+ additional_required=["okta.networkZones.read"],
+ )
+ ]
+
+ missing_network_zone_scope = api_token_client.missing_scope.get("network_zones")
+ findings: list[CheckReportOkta] = []
+ for token in api_token_client.api_tokens.values():
+ if missing_network_zone_scope:
+ definite_failure = definite_network_zone_restriction_failure(token)
+ if definite_failure:
+ report = CheckReportOkta(
+ metadata=self.metadata(),
+ resource=token,
+ org_domain=org_domain,
+ )
+ report.status, report.status_extended = definite_failure
+ else:
+ report = missing_network_zone_scope_for_token_finding(
+ self.metadata(), org_domain, token, missing_network_zone_scope
+ )
+ else:
+ report = CheckReportOkta(
+ metadata=self.metadata(), resource=token, org_domain=org_domain
+ )
+ (
+ report.status,
+ report.status_extended,
+ ) = network_zone_restriction_status(
+ token, api_token_client.known_network_zone_ids
+ )
+ findings.append(report)
+ return findings
diff --git a/prowler/providers/okta/services/apitoken/lib/__init__.py b/prowler/providers/okta/services/apitoken/lib/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/okta/services/apitoken/lib/api_token_helpers.py b/prowler/providers/okta/services/apitoken/lib/api_token_helpers.py
new file mode 100644
index 0000000000..3a871714ae
--- /dev/null
+++ b/prowler/providers/okta/services/apitoken/lib/api_token_helpers.py
@@ -0,0 +1,140 @@
+from prowler.lib.check.models import CheckReportOkta
+from prowler.providers.okta.services.apitoken.api_token_service import OktaApiToken
+
+ANYWHERE_CONNECTIONS = {"", "ANYWHERE", "ANY_IP"}
+_SCOPE_ADVICE = (
+ "Grant it on the Okta API Scopes tab of the service app in the Okta Admin "
+ "Console, then re-run the check."
+)
+
+
+def network_zone_restriction_status(
+ token: OktaApiToken, known_network_zone_ids: set[str]
+) -> tuple[str, str]:
+ """Evaluate whether an API token is restricted to known Network Zones."""
+ connection = token.network_connection.upper()
+ if connection in ANYWHERE_CONNECTIONS:
+ return (
+ "FAIL",
+ f"API token {token.name} can be used from any IP address. "
+ "Restrict the token to one or more known Okta Network Zones.",
+ )
+
+ if not token.network_includes:
+ return (
+ "FAIL",
+ f"API token {token.name} does not allowlist a specific Okta "
+ "Network Zone. Excluded zones do not restrict the token to trusted "
+ "source networks.",
+ )
+
+ unknown_zones = [
+ zone for zone in token.network_includes if zone not in known_network_zone_ids
+ ]
+ if unknown_zones:
+ return (
+ "FAIL",
+ f"API token {token.name} references unknown Network Zone(s): "
+ f"{', '.join(unknown_zones)}.",
+ )
+
+ return (
+ "PASS",
+ f"API token {token.name} is restricted to known Okta Network Zone(s): "
+ f"{', '.join(token.network_includes)}.",
+ )
+
+
+def definite_network_zone_restriction_failure(
+ token: OktaApiToken,
+) -> tuple[str, str] | None:
+ """Return a definite network restriction failure that does not need zone lookup."""
+ connection = token.network_connection.upper()
+ if connection in ANYWHERE_CONNECTIONS or not token.network_includes:
+ return network_zone_restriction_status(token, set())
+ return None
+
+
+def owner_has_super_admin(token: OktaApiToken) -> bool:
+ """Return True when any token owner role is Super Admin."""
+ for role in token.owner_roles:
+ normalized = role.strip().replace(" ", "_").upper()
+ if normalized in {"SUPER_ADMIN", "SUPER_ADMINISTRATOR"}:
+ return True
+ return False
+
+
+def missing_api_token_scope_finding(
+ metadata,
+ org_domain: str,
+ scope: str,
+ additional_required: list[str] | None = None,
+) -> CheckReportOkta:
+ """Build the MANUAL finding emitted when API tokens cannot be listed.
+
+ `additional_required` lets the calling check name the secondary
+ scopes it also needs (e.g. `okta.roles.read` for the Super Admin
+ check, `okta.networkZones.read` for the zone-restriction check) so
+ the operator can grant everything in one go instead of re-running
+ once per missing scope.
+ """
+ resource = OktaApiToken(
+ id="api-tokens-scope-missing",
+ name="(scope not granted)",
+ )
+ report = CheckReportOkta(
+ metadata=metadata, resource=resource, org_domain=org_domain
+ )
+ report.status = "MANUAL"
+ if additional_required:
+ extras = f" This check also requires {_format_scope_list(additional_required)}."
+ advice = (
+ "Grant them on the service app's Okta API Scopes tab in the Okta "
+ "Admin Console, then re-run the check."
+ )
+ else:
+ extras = ""
+ advice = _SCOPE_ADVICE
+ report.status_extended = (
+ f"Could not retrieve Okta API token metadata: the Okta service app "
+ f"is missing the required `{scope}` API scope.{extras} {advice}"
+ )
+ return report
+
+
+def _format_scope_list(scopes: list[str]) -> str:
+ """Format a list of scope names as backticked, comma-joined text."""
+ formatted = [f"`{scope}`" for scope in scopes]
+ if len(formatted) == 1:
+ return formatted[0]
+ if len(formatted) == 2:
+ return " and ".join(formatted)
+ return ", ".join(formatted[:-1]) + f", and {formatted[-1]}"
+
+
+def missing_network_zone_scope_for_token_finding(
+ metadata, org_domain: str, token: OktaApiToken, scope: str
+) -> CheckReportOkta:
+ """Build the MANUAL finding emitted when token zones cannot be validated."""
+ report = CheckReportOkta(metadata=metadata, resource=token, org_domain=org_domain)
+ report.status = "MANUAL"
+ report.status_extended = (
+ f"Could not validate Network Zone restrictions for API token "
+ f"{token.name}: the Okta service app is missing the required "
+ f"`{scope}` API scope. {_SCOPE_ADVICE}"
+ )
+ return report
+
+
+def missing_user_roles_scope_for_token_finding(
+ metadata, org_domain: str, token: OktaApiToken, scope: str
+) -> CheckReportOkta:
+ """Build the MANUAL finding emitted when token owner roles cannot be listed."""
+ report = CheckReportOkta(metadata=metadata, resource=token, org_domain=org_domain)
+ report.status = "MANUAL"
+ report.status_extended = (
+ f"Could not retrieve admin roles for API token {token.name} owner "
+ f"{token.user_id}: the Okta service app is missing the required "
+ f"`{scope}` API scope. {_SCOPE_ADVICE}"
+ )
+ return report
diff --git a/tests/providers/okta/lib/service/pagination_test.py b/tests/providers/okta/lib/service/pagination_test.py
new file mode 100644
index 0000000000..a14baeb51e
--- /dev/null
+++ b/tests/providers/okta/lib/service/pagination_test.py
@@ -0,0 +1,147 @@
+"""Tests for the shared Okta pagination helpers in
+`prowler.providers.okta.lib.service.pagination`.
+
+Covers `next_after_cursor` (extracts the `after` query param from an
+RFC 5988 `Link: rel="next"` header) and `paginate` (drains all pages
+of an SDK list call by following the cursor).
+
+These tests were carved out of `network_zone_service_test.py` when its
+local pagination helpers were replaced by the shared module — they now
+cover code that six Okta services depend on.
+"""
+
+import asyncio
+from types import SimpleNamespace
+
+from prowler.providers.okta.lib.service.pagination import (
+ next_after_cursor,
+ paginate,
+)
+
+
+def _run(coro):
+ return asyncio.run(coro)
+
+
+def _resp(headers: dict = None):
+ return SimpleNamespace(headers=headers or {})
+
+
+class Test_next_after_cursor:
+ """Behaviours previously covered in `network_zone_service_test.py`
+ under `Test_network_zone_pagination` — relocated here when the
+ local helper was replaced by the shared module.
+ """
+
+ def test_returns_none_when_response_is_none(self):
+ assert next_after_cursor(None) is None
+
+ def test_returns_none_when_no_link_header(self):
+ assert next_after_cursor(_resp({})) is None
+
+ def test_extracts_next_after_cursor(self):
+ link = (
+ '; rel="self", '
+ '; rel="next"'
+ )
+ assert next_after_cursor(_resp({"Link": link})) == "next-page"
+
+ def test_reads_lowercase_link_header(self):
+ # aiohttp's `CIMultiDict` is case-insensitive in practice, but
+ # callers occasionally pass a dict, so we check both spellings.
+ link = '; rel="next"'
+ assert next_after_cursor(_resp({"link": link})) == "cursor-1"
+
+ def test_next_link_without_after_query_returns_none(self):
+ link = (
+ '; rel="self", '
+ '; rel="next"'
+ )
+ assert next_after_cursor(_resp({"Link": link})) is None
+
+ def test_no_next_segment_returns_none(self):
+ link = '; rel="self"'
+ assert next_after_cursor(_resp({"Link": link})) is None
+
+ def test_url_decodes_after_cursor(self):
+ # `parse_qs` decodes percent-encoded values — opaque cursors with
+ # `=` or `+` must round-trip through callers that re-encode.
+ link = (
+ "; " 'rel="next"'
+ )
+ assert next_after_cursor(_resp({"Link": link})) == "cursor=abc+1"
+
+
+class Test_paginate:
+ def test_returns_items_for_single_page_response(self):
+ async def fetch(_after):
+ return (["a", "b"], _resp({}), None)
+
+ items, err = _run(paginate(fetch))
+ assert items == ["a", "b"]
+ assert err is None
+
+ def test_drains_multiple_pages(self):
+ link = '; rel="next"'
+ seen_cursors: list = []
+
+ async def fetch(after):
+ seen_cursors.append(after)
+ if after is None:
+ return (["a"], _resp({"link": link}), None)
+ return (["b"], _resp({}), None)
+
+ items, err = _run(paginate(fetch))
+ assert items == ["a", "b"]
+ assert err is None
+ assert seen_cursors == [None, "p2"]
+
+ def test_returns_empty_when_first_page_is_empty(self):
+ async def fetch(_after):
+ return ([], _resp({}), None)
+
+ items, err = _run(paginate(fetch))
+ assert items == []
+ assert err is None
+
+ def test_returns_empty_and_error_when_first_page_fails(self):
+ async def fetch(_after):
+ return ([], _resp({}), Exception("forbidden"))
+
+ items, err = _run(paginate(fetch))
+ assert items == []
+ assert str(err) == "forbidden"
+
+ def test_returns_partial_items_when_subsequent_page_errors(self):
+ # Carved out of `network_zone_service_test.py`'s
+ # `test_pagination_returns_partial_items_when_second_page_errors`.
+ link = '; rel="next"'
+
+ async def fetch(after):
+ if after is None:
+ return (["page-1"], _resp({"link": link}), None)
+ return ([], _resp({}), Exception("page failed"))
+
+ items, err = _run(paginate(fetch))
+ assert items == ["page-1"]
+ assert str(err) == "page failed"
+
+ def test_accepts_early_error_two_tuple_shape(self):
+ # The Okta SDK returns `(items, err)` on request-build failures
+ # (no response) and `(items, resp, err)` on transport responses.
+ # `paginate` reads `result[-1]` for err so the 2-tuple shape is
+ # handled — verify explicitly.
+ async def fetch(_after):
+ return ([], Exception("create failed"))
+
+ items, err = _run(paginate(fetch))
+ assert items == []
+ assert str(err) == "create failed"
+
+ def test_treats_none_items_as_empty_list(self):
+ async def fetch(_after):
+ return (None, _resp({}), None)
+
+ items, err = _run(paginate(fetch))
+ assert items == []
+ assert err is None
diff --git a/tests/providers/okta/okta_fixtures.py b/tests/providers/okta/okta_fixtures.py
index 0ecb4a9eef..d1018a65eb 100644
--- a/tests/providers/okta/okta_fixtures.py
+++ b/tests/providers/okta/okta_fixtures.py
@@ -21,6 +21,9 @@ def set_mocked_okta_provider(
"okta.brands.read",
"okta.apps.read",
"okta.networkZones.read",
+ "okta.apiTokens.read",
+ "okta.roles.read",
+ "okta.groups.read",
"okta.logStreams.read",
"okta.idps.read",
],
@@ -35,6 +38,9 @@ def set_mocked_okta_provider(
"okta.brands.read",
"okta.apps.read",
"okta.networkZones.read",
+ "okta.apiTokens.read",
+ "okta.roles.read",
+ "okta.groups.read",
"okta.logStreams.read",
"okta.idps.read",
],
diff --git a/tests/providers/okta/services/api_token/api_token_fixtures.py b/tests/providers/okta/services/api_token/api_token_fixtures.py
new file mode 100644
index 0000000000..63a67c1605
--- /dev/null
+++ b/tests/providers/okta/services/api_token/api_token_fixtures.py
@@ -0,0 +1,46 @@
+from unittest import mock
+
+from prowler.providers.okta.services.apitoken.api_token_service import OktaApiToken
+from tests.providers.okta.okta_fixtures import set_mocked_okta_provider
+
+
+def build_api_token_client(
+ tokens: dict = None,
+ known_network_zone_ids: set[str] = None,
+ missing_scope: dict = None,
+):
+ client = mock.MagicMock()
+ client.api_tokens = tokens or {}
+ client.known_network_zone_ids = known_network_zone_ids or {"nzo-corp"}
+ client.missing_scope = missing_scope or {
+ "api_tokens": None,
+ "network_zones": None,
+ "user_roles": None,
+ "user_groups": None,
+ }
+ client.provider = set_mocked_okta_provider()
+ return client
+
+
+def api_token(
+ token_id: str = "00Tabcdefg1234567890",
+ name: str = "CI token",
+ *,
+ user_id: str = "00uabcdefg1234567890",
+ network_connection: str = "ZONE",
+ network_includes: list[str] = None,
+ network_excludes: list[str] = None,
+ owner_roles: list[str] = None,
+):
+ return OktaApiToken(
+ id=token_id,
+ name=name,
+ client_name="Okta API",
+ user_id=user_id,
+ network_connection=network_connection,
+ network_includes=(
+ network_includes if network_includes is not None else ["nzo-corp"]
+ ),
+ network_excludes=network_excludes or [],
+ owner_roles=owner_roles or ["READ_ONLY_ADMIN"],
+ )
diff --git a/tests/providers/okta/services/api_token/api_token_service_test.py b/tests/providers/okta/services/api_token/api_token_service_test.py
new file mode 100644
index 0000000000..0958bdaaad
--- /dev/null
+++ b/tests/providers/okta/services/api_token/api_token_service_test.py
@@ -0,0 +1,616 @@
+import json
+from types import SimpleNamespace
+from unittest import mock
+
+from prowler.providers.okta.models import OktaIdentityInfo
+from prowler.providers.okta.services.apitoken.api_token_service import ApiToken
+from tests.providers.okta.okta_fixtures import set_mocked_okta_provider
+
+
+def _resp(headers: dict = None):
+ return SimpleNamespace(headers=headers or {})
+
+
+def _sdk_token(
+ token_id: str = "00Tabcdefg1234567890",
+ name: str = "CI token",
+ *,
+ user_id: str = "00uabcdefg1234567890",
+ connection: str = "ZONE",
+ include: list[str] = None,
+ exclude: list[str] = None,
+):
+ return SimpleNamespace(
+ id=token_id,
+ name=name,
+ client_name="Okta API",
+ user_id=user_id,
+ network=SimpleNamespace(
+ connection=connection,
+ include=include if include is not None else ["nzo-corp"],
+ exclude=exclude or [],
+ ),
+ )
+
+
+def _sdk_role(role_type: str):
+ return SimpleNamespace(type=role_type, label=role_type.replace("_", " ").title())
+
+
+def _sdk_role_wrapped(role_type: str):
+ """Mimic `ListGroupAssignedRoles200ResponseInner` — a oneOf wrapper
+ holding the real StandardRole on `.actual_instance`. The Okta SDK
+ actually returns this shape; treating it like the bare role yields
+ `type=None, label=None` and the role silently vanishes from the
+ check.
+ """
+ inner = _sdk_role(role_type)
+ return SimpleNamespace(actual_instance=inner, type=None, label=None)
+
+
+def _sdk_zone(zone_id: str, name: str):
+ return SimpleNamespace(id=zone_id, name=name)
+
+
+def _sdk_group(group_id: str):
+ return SimpleNamespace(id=group_id)
+
+
+async def _empty_list(*_a, **_k):
+ return ([], _resp({}), None)
+
+
+class Test_ApiToken_service:
+ def test_fetches_tokens_roles_and_known_network_zones(self):
+ provider = set_mocked_okta_provider()
+ token = _sdk_token()
+
+ async def fake_list_api_tokens(*_a, **_k):
+ return ([token], _resp({}), None)
+
+ async def fake_list_assigned_roles_for_user(user_id, *_a, **_k):
+ assert user_id == token.user_id
+ return ([_sdk_role("READ_ONLY_ADMIN")], _resp({}), None)
+
+ async def fake_list_network_zones(*_a, **_k):
+ return ([_sdk_zone("nzo-corp", "Corporate")], _resp({}), None)
+
+ with mock.patch(
+ "prowler.providers.okta.lib.service.service.OktaSDKClient"
+ ) as mocked_client_cls:
+ mocked = mock.MagicMock()
+ mocked.list_api_tokens = fake_list_api_tokens
+ mocked.list_assigned_roles_for_user = fake_list_assigned_roles_for_user
+ mocked.list_user_groups = _empty_list
+ mocked.list_group_assigned_roles = _empty_list
+ mocked.list_network_zones = fake_list_network_zones
+ mocked_client_cls.return_value = mocked
+
+ service = ApiToken(provider)
+
+ assert set(service.api_tokens.keys()) == {token.id}
+ assert service.api_tokens[token.id].network_connection == "ZONE"
+ assert service.api_tokens[token.id].owner_roles == ["READ_ONLY_ADMIN"]
+ assert service.known_network_zone_ids == {"nzo-corp", "Corporate"}
+
+ def test_role_fetch_error_keeps_token_with_empty_roles(self):
+ provider = set_mocked_okta_provider()
+ token = _sdk_token()
+
+ async def fake_list_api_tokens(*_a, **_k):
+ return ([token], _resp({}), None)
+
+ async def fake_roles_error(*_a, **_k):
+ return ([], _resp({}), Exception("forbidden"))
+
+ async def fake_list_network_zones(*_a, **_k):
+ return ([], _resp({}), None)
+
+ with mock.patch(
+ "prowler.providers.okta.lib.service.service.OktaSDKClient"
+ ) as mocked_client_cls:
+ mocked = mock.MagicMock()
+ mocked.list_api_tokens = fake_list_api_tokens
+ mocked.list_assigned_roles_for_user = fake_roles_error
+ mocked.list_user_groups = _empty_list
+ mocked.list_group_assigned_roles = _empty_list
+ mocked.list_network_zones = fake_list_network_zones
+ mocked_client_cls.return_value = mocked
+ service = ApiToken(provider)
+
+ assert service.api_tokens[token.id].owner_roles == []
+
+ def test_falls_back_to_raw_roles_when_sdk_role_is_empty(self):
+ provider = set_mocked_okta_provider()
+ token = _sdk_token()
+
+ async def fake_list_api_tokens(*_a, **_k):
+ return ([token], _resp({}), None)
+
+ async def fake_list_assigned_roles_for_user(user_id, *_a, **_k):
+ assert user_id == token.user_id
+ return ([SimpleNamespace(type=None, label=None)], _resp({}), None)
+
+ async def fake_create_request(*_a, **_k):
+ return ("raw-role-request", None)
+
+ async def fake_execute(request, *_a, **_k):
+ assert request == "raw-role-request"
+ return (
+ _resp({}),
+ json.dumps(
+ [
+ {
+ "id": "ra-super-admin",
+ "type": "SUPER_ADMIN",
+ "label": "Super Administrator",
+ }
+ ]
+ ),
+ None,
+ )
+
+ async def fake_list_network_zones(*_a, **_k):
+ return ([_sdk_zone("nzo-corp", "Corporate")], _resp({}), None)
+
+ with mock.patch(
+ "prowler.providers.okta.lib.service.service.OktaSDKClient"
+ ) as mocked_client_cls:
+ mocked = mock.MagicMock()
+ mocked.list_api_tokens = fake_list_api_tokens
+ mocked.list_assigned_roles_for_user = fake_list_assigned_roles_for_user
+ mocked._list_assigned_roles_for_user_serialize.return_value = (
+ "GET",
+ "/api/v1/users/00uabcdefg1234567890/roles",
+ {},
+ None,
+ None,
+ )
+ mocked._request_executor.create_request = fake_create_request
+ mocked._request_executor.execute = fake_execute
+ mocked.list_network_zones = fake_list_network_zones
+ mocked_client_cls.return_value = mocked
+ service = ApiToken(provider)
+
+ assert service.api_tokens[token.id].owner_roles == ["SUPER_ADMIN"]
+
+ def test_paginates_known_network_zones_for_token_validation(self):
+ provider = set_mocked_okta_provider()
+ token = _sdk_token(include=["nzo-page-2"])
+ next_link = '; rel="next"'
+
+ async def fake_list_api_tokens(*_a, **_k):
+ return ([token], _resp({}), None)
+
+ async def fake_list_assigned_roles_for_user(*_a, **_k):
+ return ([_sdk_role("READ_ONLY_ADMIN")], _resp({}), None)
+
+ async def fake_list_network_zones(*_a, **kwargs):
+ if kwargs.get("after") is None:
+ return (
+ [_sdk_zone("nzo-page-1", "First")],
+ _resp({"link": next_link}),
+ None,
+ )
+ return ([_sdk_zone("nzo-page-2", "Second")], _resp({}), None)
+
+ with mock.patch(
+ "prowler.providers.okta.lib.service.service.OktaSDKClient"
+ ) as mocked_client_cls:
+ mocked = mock.MagicMock()
+ mocked.list_api_tokens = fake_list_api_tokens
+ mocked.list_assigned_roles_for_user = fake_list_assigned_roles_for_user
+ mocked.list_user_groups = _empty_list
+ mocked.list_group_assigned_roles = _empty_list
+ mocked.list_network_zones = fake_list_network_zones
+ mocked_client_cls.return_value = mocked
+ service = ApiToken(provider)
+
+ assert service.known_network_zone_ids == {
+ "nzo-page-1",
+ "First",
+ "nzo-page-2",
+ "Second",
+ }
+
+ def test_falls_back_to_raw_network_zones_when_sdk_listing_fails(self):
+ provider = set_mocked_okta_provider()
+ token = _sdk_token(include=["nzo-raw"])
+
+ async def fake_list_api_tokens(*_a, **_k):
+ return ([token], _resp({}), None)
+
+ async def fake_list_assigned_roles_for_user(*_a, **_k):
+ return ([_sdk_role("READ_ONLY_ADMIN")], _resp({}), None)
+
+ async def fake_list_network_zones(*_a, **_k):
+ raise ValueError("EnhancedDynamicNetworkZone SDK deserialization failed")
+
+ async def fake_create_request(*_a, **_k):
+ return ("raw-zones-request", None)
+
+ async def fake_execute(request, *_a, **_k):
+ assert request == "raw-zones-request"
+ return (
+ _resp({}),
+ json.dumps([{"id": "nzo-raw", "name": "Raw Corporate"}]),
+ None,
+ )
+
+ with mock.patch(
+ "prowler.providers.okta.lib.service.service.OktaSDKClient"
+ ) as mocked_client_cls:
+ mocked = mock.MagicMock()
+ mocked.list_api_tokens = fake_list_api_tokens
+ mocked.list_assigned_roles_for_user = fake_list_assigned_roles_for_user
+ mocked.list_network_zones = fake_list_network_zones
+ mocked._list_network_zones_serialize.return_value = (
+ "GET",
+ "/api/v1/zones",
+ {},
+ None,
+ None,
+ )
+ mocked._request_executor.create_request = fake_create_request
+ mocked._request_executor.execute = fake_execute
+ mocked_client_cls.return_value = mocked
+ service = ApiToken(provider)
+
+ assert service.known_network_zone_ids == {"nzo-raw", "Raw Corporate"}
+
+ def test_returns_empty_on_token_api_error(self):
+ provider = set_mocked_okta_provider()
+
+ async def failing(*_a, **_k):
+ return ([], _resp({}), Exception("forbidden"))
+
+ async def fake_list_network_zones(*_a, **_k):
+ return ([], _resp({}), None)
+
+ with mock.patch(
+ "prowler.providers.okta.lib.service.service.OktaSDKClient"
+ ) as mocked_client_cls:
+ mocked = mock.MagicMock()
+ mocked.list_api_tokens = failing
+ mocked.list_network_zones = fake_list_network_zones
+ mocked_client_cls.return_value = mocked
+ service = ApiToken(provider)
+
+ assert service.api_tokens == {}
+
+ def test_missing_api_token_scope_skips_dependent_api_calls(self):
+ provider = set_mocked_okta_provider(
+ identity=OktaIdentityInfo(
+ org_domain="acme.okta.com",
+ client_id="0oa1234567890abcdef",
+ granted_scopes=["okta.networkZones.read", "okta.roles.read"],
+ )
+ )
+
+ async def fail_if_called(*_a, **_k):
+ raise AssertionError("API calls should not run without apiTokens scope")
+
+ with mock.patch(
+ "prowler.providers.okta.lib.service.service.OktaSDKClient"
+ ) as mocked_client_cls:
+ mocked = mock.MagicMock()
+ mocked.list_api_tokens = fail_if_called
+ mocked.list_network_zones = fail_if_called
+ mocked.list_assigned_roles_for_user = fail_if_called
+ mocked_client_cls.return_value = mocked
+ service = ApiToken(provider)
+
+ assert service.missing_scope["api_tokens"] == "okta.apiTokens.read"
+ assert service.api_tokens == {}
+ assert service.known_network_zone_ids == set()
+
+ def test_missing_network_zone_scope_skips_zone_api_call(self):
+ provider = set_mocked_okta_provider(
+ identity=OktaIdentityInfo(
+ org_domain="acme.okta.com",
+ client_id="0oa1234567890abcdef",
+ granted_scopes=["okta.apiTokens.read", "okta.roles.read"],
+ )
+ )
+ token = _sdk_token()
+
+ async def fake_list_api_tokens(*_a, **_k):
+ return ([token], _resp({}), None)
+
+ async def fake_list_assigned_roles_for_user(*_a, **_k):
+ return ([_sdk_role("READ_ONLY_ADMIN")], _resp({}), None)
+
+ async def fail_if_called(*_a, **_k):
+ raise AssertionError("list_network_zones should not be called")
+
+ with mock.patch(
+ "prowler.providers.okta.lib.service.service.OktaSDKClient"
+ ) as mocked_client_cls:
+ mocked = mock.MagicMock()
+ mocked.list_api_tokens = fake_list_api_tokens
+ mocked.list_assigned_roles_for_user = fake_list_assigned_roles_for_user
+ mocked.list_network_zones = fail_if_called
+ mocked_client_cls.return_value = mocked
+ service = ApiToken(provider)
+
+ assert service.missing_scope["network_zones"] == "okta.networkZones.read"
+ assert service.known_network_zone_ids == set()
+ assert set(service.api_tokens.keys()) == {token.id}
+
+ def test_missing_role_scope_skips_role_api_call(self):
+ provider = set_mocked_okta_provider(
+ identity=OktaIdentityInfo(
+ org_domain="acme.okta.com",
+ client_id="0oa1234567890abcdef",
+ granted_scopes=["okta.apiTokens.read", "okta.networkZones.read"],
+ )
+ )
+ token = _sdk_token()
+
+ async def fake_list_api_tokens(*_a, **_k):
+ return ([token], _resp({}), None)
+
+ async def fail_if_called(*_a, **_k):
+ raise AssertionError("list_assigned_roles_for_user should not be called")
+
+ async def fake_list_network_zones(*_a, **_k):
+ return ([_sdk_zone("nzo-corp", "Corporate")], _resp({}), None)
+
+ with mock.patch(
+ "prowler.providers.okta.lib.service.service.OktaSDKClient"
+ ) as mocked_client_cls:
+ mocked = mock.MagicMock()
+ mocked.list_api_tokens = fake_list_api_tokens
+ mocked.list_assigned_roles_for_user = fail_if_called
+ mocked.list_network_zones = fake_list_network_zones
+ mocked_client_cls.return_value = mocked
+ service = ApiToken(provider)
+
+ assert service.missing_scope["user_roles"] == "okta.roles.read"
+ assert service.api_tokens[token.id].owner_roles == []
+
+
+class Test_ApiToken_service_group_inherited_roles:
+ """Verifies effective-role resolution combines direct + group-inherited.
+
+ Okta's `/api/v1/users/{userId}/roles` returns only directly-assigned
+ admin roles. Roles inherited via group membership — the common path
+ for Super Admin on trial tenants — are invisible to that endpoint.
+ The service must enumerate the user's groups and combine each
+ group's role assignments.
+ """
+
+ def test_group_inherited_super_admin_surfaces(self):
+ provider = set_mocked_okta_provider()
+ token = _sdk_token()
+
+ async def fake_list_api_tokens(*_a, **_k):
+ return ([token], _resp({}), None)
+
+ async def fake_direct_roles(*_a, **_k):
+ return ([], _resp({}), None)
+
+ async def fake_user_groups(user_id, *_a, **_k):
+ assert user_id == token.user_id
+ return (
+ [_sdk_group("0gp-admins"), _sdk_group("0gp-eng")],
+ _resp({}),
+ None,
+ )
+
+ async def fake_group_roles(group_id, *_a, **_k):
+ if group_id == "0gp-admins":
+ return ([_sdk_role("SUPER_ADMIN")], _resp({}), None)
+ return ([], _resp({}), None)
+
+ with mock.patch(
+ "prowler.providers.okta.lib.service.service.OktaSDKClient"
+ ) as mocked_client_cls:
+ mocked = mock.MagicMock()
+ mocked.list_api_tokens = fake_list_api_tokens
+ mocked.list_assigned_roles_for_user = fake_direct_roles
+ mocked.list_user_groups = fake_user_groups
+ mocked.list_group_assigned_roles = fake_group_roles
+ mocked.list_network_zones = _empty_list
+ mocked_client_cls.return_value = mocked
+ service = ApiToken(provider)
+
+ assert service.api_tokens[token.id].owner_roles == ["SUPER_ADMIN"]
+
+ def test_direct_plus_group_roles_combined_and_deduped(self):
+ provider = set_mocked_okta_provider()
+ token = _sdk_token()
+
+ async def fake_list_api_tokens(*_a, **_k):
+ return ([token], _resp({}), None)
+
+ async def fake_direct_roles(*_a, **_k):
+ return ([_sdk_role("READ_ONLY_ADMIN")], _resp({}), None)
+
+ async def fake_user_groups(*_a, **_k):
+ return ([_sdk_group("0gp-1")], _resp({}), None)
+
+ async def fake_group_roles(*_a, **_k):
+ # READ_ONLY_ADMIN already comes from the direct path; the
+ # dedupe should keep a single entry. SUPER_ADMIN is new.
+ return (
+ [_sdk_role("READ_ONLY_ADMIN"), _sdk_role("SUPER_ADMIN")],
+ _resp({}),
+ None,
+ )
+
+ with mock.patch(
+ "prowler.providers.okta.lib.service.service.OktaSDKClient"
+ ) as mocked_client_cls:
+ mocked = mock.MagicMock()
+ mocked.list_api_tokens = fake_list_api_tokens
+ mocked.list_assigned_roles_for_user = fake_direct_roles
+ mocked.list_user_groups = fake_user_groups
+ mocked.list_group_assigned_roles = fake_group_roles
+ mocked.list_network_zones = _empty_list
+ mocked_client_cls.return_value = mocked
+ service = ApiToken(provider)
+
+ assert service.api_tokens[token.id].owner_roles == [
+ "READ_ONLY_ADMIN",
+ "SUPER_ADMIN",
+ ]
+
+ def test_role_resolution_cached_per_user_and_group(self):
+ provider = set_mocked_okta_provider()
+ token_a = _sdk_token(token_id="00Ttoken-a", user_id="00uowner-1")
+ token_b = _sdk_token(token_id="00Ttoken-b", user_id="00uowner-1")
+ token_c = _sdk_token(token_id="00Ttoken-c", user_id="00uowner-2")
+
+ direct_calls: list[str] = []
+ groups_calls: list[str] = []
+ group_role_calls: list[str] = []
+
+ async def fake_list_api_tokens(*_a, **_k):
+ return ([token_a, token_b, token_c], _resp({}), None)
+
+ async def fake_direct_roles(user_id, *_a, **_k):
+ direct_calls.append(user_id)
+ return ([], _resp({}), None)
+
+ async def fake_user_groups(user_id, *_a, **_k):
+ groups_calls.append(user_id)
+ return ([_sdk_group("0gp-shared")], _resp({}), None)
+
+ async def fake_group_roles(group_id, *_a, **_k):
+ group_role_calls.append(group_id)
+ return ([_sdk_role("HELP_DESK_ADMIN")], _resp({}), None)
+
+ with mock.patch(
+ "prowler.providers.okta.lib.service.service.OktaSDKClient"
+ ) as mocked_client_cls:
+ mocked = mock.MagicMock()
+ mocked.list_api_tokens = fake_list_api_tokens
+ mocked.list_assigned_roles_for_user = fake_direct_roles
+ mocked.list_user_groups = fake_user_groups
+ mocked.list_group_assigned_roles = fake_group_roles
+ mocked.list_network_zones = _empty_list
+ mocked_client_cls.return_value = mocked
+ service = ApiToken(provider)
+
+ # Owner 00uowner-1 appears twice but is resolved once.
+ assert sorted(direct_calls) == ["00uowner-1", "00uowner-2"]
+ assert sorted(groups_calls) == ["00uowner-1", "00uowner-2"]
+ # Shared group resolved once even though both owners belong to it.
+ assert group_role_calls == ["0gp-shared"]
+ for token in (token_a, token_b, token_c):
+ assert service.api_tokens[token.id].owner_roles == ["HELP_DESK_ADMIN"]
+
+ def test_missing_groups_scope_falls_back_to_direct_only(self):
+ provider = set_mocked_okta_provider(
+ identity=OktaIdentityInfo(
+ org_domain="acme.okta.com",
+ client_id="0oa1234567890abcdef",
+ granted_scopes=[
+ "okta.apiTokens.read",
+ "okta.networkZones.read",
+ "okta.roles.read",
+ ],
+ )
+ )
+ token = _sdk_token()
+
+ async def fake_list_api_tokens(*_a, **_k):
+ return ([token], _resp({}), None)
+
+ async def fake_direct_roles(*_a, **_k):
+ return ([_sdk_role("READ_ONLY_ADMIN")], _resp({}), None)
+
+ async def fail_if_called(*_a, **_k):
+ raise AssertionError(
+ "list_user_groups must not be called without okta.groups.read"
+ )
+
+ with mock.patch(
+ "prowler.providers.okta.lib.service.service.OktaSDKClient"
+ ) as mocked_client_cls:
+ mocked = mock.MagicMock()
+ mocked.list_api_tokens = fake_list_api_tokens
+ mocked.list_assigned_roles_for_user = fake_direct_roles
+ mocked.list_user_groups = fail_if_called
+ mocked.list_group_assigned_roles = fail_if_called
+ mocked.list_network_zones = _empty_list
+ mocked_client_cls.return_value = mocked
+ service = ApiToken(provider)
+
+ assert service.missing_scope["user_groups"] == "okta.groups.read"
+ assert service.api_tokens[token.id].owner_roles == ["READ_ONLY_ADMIN"]
+
+ def test_wrapped_oneof_role_shape_is_unwrapped(self):
+ """Regression: the SDK returns each role as a oneOf wrapper with
+ the real StandardRole on `.actual_instance`. The previous
+ `_role_to_string` read `.type`/`.label` from the wrapper, got
+ None back, and produced an empty `owner_roles` — causing a
+ Super Admin token to silently PASS the check."""
+ provider = set_mocked_okta_provider()
+ token = _sdk_token()
+
+ async def fake_list_api_tokens(*_a, **_k):
+ return ([token], _resp({}), None)
+
+ async def fake_direct_roles(*_a, **_k):
+ return ([_sdk_role_wrapped("SUPER_ADMIN")], _resp({}), None)
+
+ async def fake_user_groups(*_a, **_k):
+ return ([_sdk_group("0gp-extra")], _resp({}), None)
+
+ async def fake_group_roles(*_a, **_k):
+ return ([_sdk_role_wrapped("APP_ADMIN")], _resp({}), None)
+
+ with mock.patch(
+ "prowler.providers.okta.lib.service.service.OktaSDKClient"
+ ) as mocked_client_cls:
+ mocked = mock.MagicMock()
+ mocked.list_api_tokens = fake_list_api_tokens
+ mocked.list_assigned_roles_for_user = fake_direct_roles
+ mocked.list_user_groups = fake_user_groups
+ mocked.list_group_assigned_roles = fake_group_roles
+ mocked.list_network_zones = _empty_list
+ mocked_client_cls.return_value = mocked
+ service = ApiToken(provider)
+
+ assert service.api_tokens[token.id].owner_roles == [
+ "SUPER_ADMIN",
+ "APP_ADMIN",
+ ]
+
+ def test_group_role_fetch_failure_does_not_drop_other_groups(self):
+ provider = set_mocked_okta_provider()
+ token = _sdk_token()
+
+ async def fake_list_api_tokens(*_a, **_k):
+ return ([token], _resp({}), None)
+
+ async def fake_direct_roles(*_a, **_k):
+ return ([], _resp({}), None)
+
+ async def fake_user_groups(*_a, **_k):
+ return (
+ [_sdk_group("0gp-broken"), _sdk_group("0gp-good")],
+ _resp({}),
+ None,
+ )
+
+ async def fake_group_roles(group_id, *_a, **_k):
+ if group_id == "0gp-broken":
+ raise RuntimeError("upstream parse failure")
+ return ([_sdk_role("SUPER_ADMIN")], _resp({}), None)
+
+ with mock.patch(
+ "prowler.providers.okta.lib.service.service.OktaSDKClient"
+ ) as mocked_client_cls:
+ mocked = mock.MagicMock()
+ mocked.list_api_tokens = fake_list_api_tokens
+ mocked.list_assigned_roles_for_user = fake_direct_roles
+ mocked.list_user_groups = fake_user_groups
+ mocked.list_group_assigned_roles = fake_group_roles
+ mocked.list_network_zones = _empty_list
+ mocked_client_cls.return_value = mocked
+ service = ApiToken(provider)
+
+ assert service.api_tokens[token.id].owner_roles == ["SUPER_ADMIN"]
diff --git a/tests/providers/okta/services/api_token/apitoken_not_super_admin/apitoken_not_super_admin_test.py b/tests/providers/okta/services/api_token/apitoken_not_super_admin/apitoken_not_super_admin_test.py
new file mode 100644
index 0000000000..6177d4b422
--- /dev/null
+++ b/tests/providers/okta/services/api_token/apitoken_not_super_admin/apitoken_not_super_admin_test.py
@@ -0,0 +1,99 @@
+from unittest import mock
+
+from tests.providers.okta.okta_fixtures import set_mocked_okta_provider
+from tests.providers.okta.services.api_token.api_token_fixtures import (
+ api_token,
+ build_api_token_client,
+)
+
+CHECK_PATH = (
+ "prowler.providers.okta.services.apitoken."
+ "apitoken_not_super_admin.apitoken_not_super_admin.api_token_client"
+)
+
+
+def _run_check(api_token_client):
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_okta_provider(),
+ ),
+ mock.patch(CHECK_PATH, new=api_token_client),
+ ):
+ from prowler.providers.okta.services.apitoken.apitoken_not_super_admin.apitoken_not_super_admin import (
+ apitoken_not_super_admin,
+ )
+
+ return apitoken_not_super_admin().execute()
+
+
+class Test_apitoken_not_super_admin:
+ def test_no_tokens_returns_no_findings(self):
+ findings = _run_check(build_api_token_client({}))
+ assert findings == []
+
+ def test_missing_api_token_scope_is_manual(self):
+ findings = _run_check(
+ build_api_token_client(
+ {},
+ missing_scope={"api_tokens": "okta.apiTokens.read"},
+ )
+ )
+ assert len(findings) == 1
+ assert findings[0].status == "MANUAL"
+ assert "okta.apiTokens.read" in findings[0].status_extended
+ assert "okta.roles.read" in findings[0].status_extended
+ assert "okta.groups.read" in findings[0].status_extended
+
+ def test_missing_user_roles_scope_is_manual(self):
+ token = api_token(owner_roles=[])
+ findings = _run_check(
+ build_api_token_client(
+ {token.id: token},
+ missing_scope={"user_roles": "okta.roles.read"},
+ )
+ )
+ assert len(findings) == 1
+ assert findings[0].status == "MANUAL"
+ assert findings[0].resource_id == token.id
+ assert "okta.roles.read" in findings[0].status_extended
+
+ def test_token_owner_without_super_admin_passes(self):
+ token = api_token(owner_roles=["READ_ONLY_ADMIN"])
+ findings = _run_check(build_api_token_client({token.id: token}))
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert findings[0].resource_id == token.id
+
+ def test_token_owner_with_super_admin_fails(self):
+ token = api_token(owner_roles=["SUPER_ADMIN"])
+ findings = _run_check(build_api_token_client({token.id: token}))
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert "Super Admin" in findings[0].status_extended
+
+ def test_missing_groups_scope_adds_best_effort_caveat_on_pass(self):
+ token = api_token(owner_roles=["READ_ONLY_ADMIN"])
+ findings = _run_check(
+ build_api_token_client(
+ {token.id: token},
+ missing_scope={"user_groups": "okta.groups.read"},
+ )
+ )
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert "Group-inherited roles were not checked" in findings[0].status_extended
+ assert "okta.groups.read" in findings[0].status_extended
+
+ def test_missing_groups_scope_does_not_caveat_when_owner_is_super_admin(self):
+ token = api_token(owner_roles=["SUPER_ADMIN"])
+ findings = _run_check(
+ build_api_token_client(
+ {token.id: token},
+ missing_scope={"user_groups": "okta.groups.read"},
+ )
+ )
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert "Super Admin" in findings[0].status_extended
+ assert "Group-inherited" not in findings[0].status_extended
diff --git a/tests/providers/okta/services/api_token/apitoken_restricted_to_network_zone/apitoken_restricted_to_network_zone_test.py b/tests/providers/okta/services/api_token/apitoken_restricted_to_network_zone/apitoken_restricted_to_network_zone_test.py
new file mode 100644
index 0000000000..9da6f92939
--- /dev/null
+++ b/tests/providers/okta/services/api_token/apitoken_restricted_to_network_zone/apitoken_restricted_to_network_zone_test.py
@@ -0,0 +1,114 @@
+from unittest import mock
+
+from tests.providers.okta.okta_fixtures import set_mocked_okta_provider
+from tests.providers.okta.services.api_token.api_token_fixtures import (
+ api_token,
+ build_api_token_client,
+)
+
+CHECK_PATH = (
+ "prowler.providers.okta.services.apitoken."
+ "apitoken_restricted_to_network_zone.apitoken_restricted_to_network_zone.api_token_client"
+)
+
+
+def _run_check(api_token_client):
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_okta_provider(),
+ ),
+ mock.patch(CHECK_PATH, new=api_token_client),
+ ):
+ from prowler.providers.okta.services.apitoken.apitoken_restricted_to_network_zone.apitoken_restricted_to_network_zone import (
+ apitoken_restricted_to_network_zone,
+ )
+
+ return apitoken_restricted_to_network_zone().execute()
+
+
+class Test_apitoken_restricted_to_network_zone:
+ def test_no_tokens_returns_no_findings(self):
+ findings = _run_check(build_api_token_client({}))
+ assert findings == []
+
+ def test_missing_api_token_scope_is_manual(self):
+ findings = _run_check(
+ build_api_token_client(
+ {},
+ missing_scope={"api_tokens": "okta.apiTokens.read"},
+ )
+ )
+ assert len(findings) == 1
+ assert findings[0].status == "MANUAL"
+ assert "okta.apiTokens.read" in findings[0].status_extended
+ assert "okta.networkZones.read" in findings[0].status_extended
+
+ def test_missing_network_zone_scope_is_manual(self):
+ token = api_token(network_connection="ZONE", network_includes=["nzo-corp"])
+ findings = _run_check(
+ build_api_token_client(
+ {token.id: token},
+ missing_scope={"network_zones": "okta.networkZones.read"},
+ )
+ )
+ assert len(findings) == 1
+ assert findings[0].status == "MANUAL"
+ assert findings[0].resource_id == token.id
+ assert "okta.networkZones.read" in findings[0].status_extended
+
+ def test_missing_network_zone_scope_still_fails_anywhere_token(self):
+ token = api_token(network_connection="ANYWHERE", network_includes=[])
+ findings = _run_check(
+ build_api_token_client(
+ {token.id: token},
+ missing_scope={"network_zones": "okta.networkZones.read"},
+ )
+ )
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert "from any IP" in findings[0].status_extended
+
+ def test_token_restricted_to_known_network_zone_passes(self):
+ token = api_token(network_connection="ZONE", network_includes=["nzo-corp"])
+ findings = _run_check(
+ build_api_token_client(
+ {token.id: token}, known_network_zone_ids={"nzo-corp"}
+ )
+ )
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert findings[0].resource_id == token.id
+
+ def test_token_with_only_excluded_network_zone_fails(self):
+ token = api_token(
+ network_connection="ZONE",
+ network_includes=[],
+ network_excludes=["nzo-blocked"],
+ )
+ findings = _run_check(
+ build_api_token_client(
+ {token.id: token}, known_network_zone_ids={"nzo-blocked"}
+ )
+ )
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert "does not allowlist" in findings[0].status_extended
+
+ def test_token_open_to_anywhere_fails(self):
+ token = api_token(network_connection="ANYWHERE", network_includes=[])
+ findings = _run_check(build_api_token_client({token.id: token}))
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert "from any IP" in findings[0].status_extended
+
+ def test_token_restricted_to_unknown_zone_fails(self):
+ token = api_token(network_connection="ZONE", network_includes=["nzo-missing"])
+ findings = _run_check(
+ build_api_token_client(
+ {token.id: token}, known_network_zone_ids={"nzo-corp"}
+ )
+ )
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert "unknown Network Zone" in findings[0].status_extended
From e3013d99183ad17e5c30330ade4c925689f00700 Mon Sep 17 00:00:00 2001
From: StylusFrost <43682773+StylusFrost@users.noreply.github.com>
Date: Mon, 8 Jun 2026 17:47:22 +0200
Subject: [PATCH 022/129] feat(sdk): Dynamic provider loading and compliance
framework (#10700)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: Pedro Martín
---
.github/workflows/sdk-tests.yml | 29 +-
prowler/CHANGELOG.md | 6 +-
prowler/__main__.py | 69 +-
prowler/config/config.py | 75 +-
prowler/lib/check/check.py | 60 +-
prowler/lib/check/checks_loader.py | 11 +-
prowler/lib/check/compliance_models.py | 44 +-
prowler/lib/check/models.py | 49 +-
prowler/lib/check/tool_wrapper.py | 62 +
prowler/lib/check/utils.py | 107 +-
prowler/lib/cli/parser.py | 59 +-
prowler/lib/outputs/compliance/compliance.py | 34 +-
.../lib/outputs/compliance/generic/generic.py | 82 +-
prowler/lib/outputs/finding.py | 5 +
prowler/lib/outputs/html/html.py | 12 +-
prowler/lib/outputs/outputs.py | 58 +-
prowler/lib/outputs/summary_table.py | 3 +
prowler/lib/scan/scan.py | 13 +-
prowler/providers/common/arguments.py | 47 +-
prowler/providers/common/builtin.py | 29 +
prowler/providers/common/models.py | 13 +
prowler/providers/common/provider.py | 315 ++-
tests/config/config_test.py | 52 +
.../fixtures/config_namespaced_external.yaml | 8 +
tests/lib/check/compliance_check_test.py | 4 +-
tests/lib/check/models_test.py | 32 +
tests/lib/check/tool_wrapper_test.py | 124 +
.../compliance/generic/generic_aws_test.py | 45 +
tests/lib/scan/scan_test.py | 8 +-
tests/providers/external/__init__.py | 0
.../external/test_dynamic_provider_loading.py | 2054 +++++++++++++++++
31 files changed, 3287 insertions(+), 222 deletions(-)
create mode 100644 prowler/lib/check/tool_wrapper.py
create mode 100644 prowler/providers/common/builtin.py
create mode 100644 tests/config/fixtures/config_namespaced_external.yaml
create mode 100644 tests/lib/check/tool_wrapper_test.py
create mode 100644 tests/providers/external/__init__.py
create mode 100644 tests/providers/external/test_dynamic_provider_loading.py
diff --git a/.github/workflows/sdk-tests.yml b/.github/workflows/sdk-tests.yml
index d0ef83c772..59316c0d95 100644
--- a/.github/workflows/sdk-tests.yml
+++ b/.github/workflows/sdk-tests.yml
@@ -540,7 +540,7 @@ jobs:
with:
flags: prowler-py${{ matrix.python-version }}-vercel
files: ./vercel_coverage.xml
-
+
# Scaleway Provider
- name: Check if Scaleway files changed
if: steps.check-changes.outputs.any_changed == 'true'
@@ -588,7 +588,34 @@ jobs:
with:
flags: prowler-py${{ matrix.python-version }}-stackit
files: ./stackit_coverage.xml
+
+ # External Provider (dynamic loading)
+ - name: Check if External Provider files changed
+ if: steps.check-changes.outputs.any_changed == 'true'
+ id: changed-external
+ uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5
+ with:
+ files: |
+ ./prowler/providers/common/**
+ ./prowler/config/**
+ ./prowler/lib/**
+ ./tests/providers/external/**
+ ./uv.lock
+ - name: Run External Provider tests
+ if: steps.changed-external.outputs.any_changed == 'true'
+ run: uv run pytest -n auto --cov=./prowler/providers/common --cov=./prowler/config --cov=./prowler/lib --cov-report=xml:external_coverage.xml tests/providers/external
+
+ - name: Upload External Provider coverage to Codecov
+ if: steps.changed-external.outputs.any_changed == 'true'
+
+ uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
+ env:
+ CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
+ with:
+ flags: prowler-py${{ matrix.python-version }}-external
+ files: ./external_coverage.xml
+
# Lib
- name: Check if Lib files changed
if: steps.check-changes.outputs.any_changed == 'true'
diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md
index 7893dd36b3..8c976009f6 100644
--- a/prowler/CHANGELOG.md
+++ b/prowler/CHANGELOG.md
@@ -10,15 +10,13 @@ All notable changes to the **Prowler SDK** are documented in this file.
- DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554) universal compliance framework with AWS provider coverage across the five DORA pillars [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131)
- Okta network zone check to detect whether anonymized proxy traffic is blocked [(#11463)](https://github.com/prowler-cloud/prowler/pull/11463)
- Okta API token checks for super admin ownership and network zone restrictions [(#11464)](https://github.com/prowler-cloud/prowler/pull/11464)
+- Support for external/custom providers, checks, and compliance frameworks without modifying core code [(#10700)](https://github.com/prowler-cloud/prowler/pull/10700)
- `elbv2_alb_drop_invalid_header_fields_enabled` check for AWS provider, verifying Application Load Balancers have `routing.http.drop_invalid_header_fields.enabled` set to `true` to mitigate HTTP desync attacks (AWS FSBP ELB.4) [(#11471)](https://github.com/prowler-cloud/prowler/pull/11471)
- `user`, `systemlog` and `idp` service for Okta provider with `user_inactivity_automation_35d_enabled`, `systemlog_streaming_enabled` and `idp_smart_card_dod_approved_ca` checks [(#11496)](https://github.com/prowler-cloud/prowler/pull/11496)
----
-
-## [5.29.3] (Prowler UNRELEASED)
-
### 🐞 Fixed
+- `load_and_validate_config_file` now unwraps namespaced config for every built-in and external provider, and no longer leaks the full file as the provider's config when the file is namespaced [(#10700)](https://github.com/prowler-cloud/prowler/pull/10700)
- GCP `logging_sink_created` now recognizes organization-level aggregated sinks with `includeChildren=True`, avoiding false failures for covered projects [(#11355)](https://github.com/prowler-cloud/prowler/pull/11355)
- GCP `logging_log_metric_filter_and_alert_*` checks now recognize organization-level aggregated sinks with `includeChildren=True`, no longer false-failing projects covered by a central bucket-scoped metric + alert [(#11488)](https://github.com/prowler-cloud/prowler/pull/11488)
- Jira integration no longer fails with `400 INVALID_INPUT` when a finding has empty fields [(#11474)](https://github.com/prowler-cloud/prowler/pull/11474)
diff --git a/prowler/__main__.py b/prowler/__main__.py
index ae1851bb17..6cbaf575d9 100644
--- a/prowler/__main__.py
+++ b/prowler/__main__.py
@@ -10,7 +10,6 @@ from colorama import Fore, Style
from colorama import init as colorama_init
from prowler.config.config import (
- EXTERNAL_TOOL_PROVIDERS,
cloud_api_base_url,
csv_file_suffix,
get_available_compliance_frameworks,
@@ -205,9 +204,10 @@ def prowler():
# We treat the compliance framework as another output format
if compliance_framework:
args.output_formats.extend(compliance_framework)
- # If no input compliance framework, set all, unless a specific service or check is input
- # Skip for IAC and LLM providers that don't use compliance frameworks
- elif default_execution and provider not in ["iac", "llm"]:
+ # If no input compliance framework, set all, unless a specific service or check is input.
+ # Skip for tool-wrapper providers (iac, llm, image, and any external plug-in
+ # declaring `is_external_tool_provider = True`) — they don't use compliance frameworks.
+ elif default_execution and not Provider.is_tool_wrapper_provider(provider):
args.output_formats.extend(get_available_compliance_frameworks(provider))
# Set Logger configuration
@@ -245,7 +245,7 @@ def prowler():
universal_frameworks = {}
# Skip compliance frameworks for external-tool providers
- if provider not in EXTERNAL_TOOL_PROVIDERS:
+ if not Provider.is_tool_wrapper_provider(provider):
bulk_compliance_frameworks = Compliance.get_bulk(provider)
# Complete checks metadata with the compliance framework specification
bulk_checks_metadata = update_checks_metadata_with_compliance(
@@ -313,7 +313,7 @@ def prowler():
sys.exit()
# Skip service and check loading for external-tool providers
- if provider not in EXTERNAL_TOOL_PROVIDERS:
+ if not Provider.is_tool_wrapper_provider(provider):
# Import custom checks from folder
if checks_folder:
custom_checks = parse_checks_from_folder(global_provider, checks_folder)
@@ -436,6 +436,20 @@ def prowler():
output_options = ScalewayOutputOptions(
args, bulk_checks_metadata, global_provider.identity
)
+ else:
+ # Dynamic fallback: any external/custom provider
+ try:
+ output_options = global_provider.get_output_options(
+ args, bulk_checks_metadata
+ )
+ except NotImplementedError:
+ # No provider-specific OutputOptions: use the generic default so the
+ # run still produces output instead of aborting.
+ from prowler.providers.common.models import default_output_options
+
+ output_options = default_output_options(
+ global_provider, args, bulk_checks_metadata
+ )
# Run the quick inventory for the provider if available
if hasattr(args, "quick_inventory") and args.quick_inventory:
@@ -445,7 +459,7 @@ def prowler():
# Execute checks
findings = []
- if provider in EXTERNAL_TOOL_PROVIDERS:
+ if Provider.is_tool_wrapper_provider(provider):
# For external-tool providers, run the scan directly
if provider == "llm":
@@ -455,12 +469,19 @@ def prowler():
findings = global_provider.run_scan(streaming_callback=streaming_callback)
else:
- # Original behavior for IAC and Image
- try:
+ if provider == "image":
+ try:
+ findings = global_provider.run()
+ except ImageBaseException as error:
+ logger.critical(f"{error}")
+ sys.exit(1)
+ else:
+ # IAC and external tool-wrapper providers registered via entry
+ # points. Unexpected failures propagate to the outer except
+ # Exception backstop further down in this file — keeping the
+ # branch free of an Image-specific catch that would otherwise
+ # mislead plug-in authors reading this code.
findings = global_provider.run()
- except ImageBaseException as error:
- logger.critical(f"{error}")
- sys.exit(1)
# Note: External tool providers don't support granular progress tracking since
# they run external tools as a black box and return all findings at once.
# Progress tracking would just be 0% → 100%.
@@ -1293,6 +1314,30 @@ def prowler():
)
generated_outputs["compliance"].append(generic_compliance)
generic_compliance.batch_write_data_to_file()
+ else:
+ # Dynamic fallback: any external/custom provider
+ try:
+ global_provider.generate_compliance_output(
+ finding_outputs,
+ bulk_compliance_frameworks,
+ input_compliance_frameworks,
+ output_options,
+ generated_outputs,
+ )
+ except NotImplementedError:
+ # Last resort: generic compliance
+ for compliance_name in input_compliance_frameworks:
+ filename = (
+ f"{output_options.output_directory}/compliance/"
+ f"{output_options.output_filename}_{compliance_name}.csv"
+ )
+ generic_compliance = GenericCompliance(
+ findings=finding_outputs,
+ compliance=bulk_compliance_frameworks[compliance_name],
+ file_path=filename,
+ )
+ generated_outputs["compliance"].append(generic_compliance)
+ generic_compliance.batch_write_data_to_file()
# AWS Security Hub Integration
if provider == "aws":
diff --git a/prowler/config/config.py b/prowler/config/config.py
index 4c76d98c1c..10e63c42df 100644
--- a/prowler/config/config.py
+++ b/prowler/config/config.py
@@ -1,3 +1,4 @@
+import importlib.metadata
import os
import pathlib
from datetime import datetime, timezone
@@ -85,13 +86,38 @@ class Provider(str, Enum):
actual_directory = pathlib.Path(os.path.dirname(os.path.realpath(__file__)))
+def _get_ep_compliance_dirs() -> dict:
+ """Discover compliance directories from entry points. Returns {provider: path}."""
+ dirs = {}
+ for ep in importlib.metadata.entry_points(group="prowler.compliance"):
+ try:
+ module = ep.load()
+ if hasattr(module, "__path__"):
+ dirs[ep.name] = module.__path__[0]
+ elif hasattr(module, "__file__"):
+ dirs[ep.name] = os.path.dirname(module.__file__)
+ except Exception as error:
+ logger.warning(
+ f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+ return dirs
+
+
def get_available_compliance_frameworks(provider=None):
available_compliance_frameworks = []
- providers = [p.value for p in Provider]
+ # Built-in compliance
+ compliance_base = f"{actual_directory}/../compliance"
if provider:
providers = [provider]
- for current_provider in providers:
- compliance_dir = f"{actual_directory}/../compliance/{current_provider}"
+ else:
+ # Scan compliance directory for all provider subdirectories
+ providers = []
+ if os.path.isdir(compliance_base):
+ for entry in os.scandir(compliance_base):
+ if entry.is_dir():
+ providers.append(entry.name)
+ for prov in providers:
+ compliance_dir = f"{compliance_base}/{prov}"
if not os.path.isdir(compliance_dir):
continue
with os.scandir(compliance_dir) as files:
@@ -100,7 +126,8 @@ def get_available_compliance_frameworks(provider=None):
available_compliance_frameworks.append(
file.name.removesuffix(".json")
)
- # Also scan top-level compliance/ for multi-provider (universal) JSONs.
+ # Built-in multi-provider frameworks at top-level compliance/ directory.
+ # Placed before external entry points so built-ins win on name collisions.
# When a specific provider was requested, only include the framework if it
# declares support for that provider; otherwise include all universal frameworks.
compliance_root = f"{actual_directory}/../compliance"
@@ -117,6 +144,18 @@ def get_available_compliance_frameworks(provider=None):
continue
if name not in available_compliance_frameworks:
available_compliance_frameworks.append(name)
+ # External compliance via entry points.
+ # Multi-provider support for external plug-ins is tracked in PROWLER-1444.
+ ep_dirs = _get_ep_compliance_dirs()
+ for prov, path in ep_dirs.items():
+ if provider and prov != provider:
+ continue
+ if os.path.isdir(path):
+ for file in os.scandir(path):
+ if file.is_file() and file.name.endswith(".json"):
+ name = file.name.removesuffix(".json")
+ if name not in available_compliance_frameworks:
+ available_compliance_frameworks.append(name)
return available_compliance_frameworks
@@ -228,18 +267,26 @@ def load_and_validate_config_file(provider: str, config_file_path: str) -> dict:
with open(config_file_path, "r", encoding=encoding_format_utf_8) as f:
config_file = yaml.safe_load(f)
- # Not to introduce a breaking change, allow the old format config file without any provider keys
- # and a new format with a key for each provider to include their configuration values within.
- if any(
- key in config_file
- for key in ["aws", "gcp", "azure", "kubernetes", "m365"]
+ # Namespaced format: each provider has its own top-level key.
+ # Works for every built-in and every external plugin without a hardcoded list.
+ # Flat legacy format is AWS-only (historical, pre-multicloud). We identify it
+ # by the absence of nested-dict top-level values (namespaced files always
+ # have dict values; the legacy AWS format only has primitives/lists).
+ if (
+ isinstance(config_file, dict)
+ and provider in config_file
+ and isinstance(config_file[provider], dict)
):
- config = config_file.get(provider, {})
+ config = config_file.get(provider, {}) or {}
+ elif (
+ isinstance(config_file, dict)
+ and config_file
+ and provider == "aws"
+ and not any(isinstance(v, dict) for v in config_file.values())
+ ):
+ config = config_file
else:
- config = config_file if config_file else {}
- # Not to break Azure, K8s and GCP does not support or use the old config format
- if provider in ["azure", "gcp", "kubernetes", "m365"]:
- config = {}
+ config = {}
return config
diff --git a/prowler/lib/check/check.py b/prowler/lib/check/check.py
index 53b832290f..300520f589 100644
--- a/prowler/lib/check/check.py
+++ b/prowler/lib/check/check.py
@@ -1,4 +1,6 @@
import importlib
+import importlib.metadata
+import importlib.util
import json
import os
import re
@@ -19,6 +21,7 @@ from prowler.lib.check.utils import recover_checks_from_provider
from prowler.lib.logger import logger
from prowler.lib.outputs.outputs import report
from prowler.lib.utils.utils import open_file, parse_json_file, print_boxes
+from prowler.providers.common.builtin import is_builtin_provider
from prowler.providers.common.models import Audit_Metadata
@@ -385,6 +388,45 @@ def import_check(check_path: str) -> ModuleType:
return lib
+def _resolve_check_module(
+ provider_type: str, service: str, check_name: str
+) -> ModuleType:
+ """Resolve and import a check module.
+
+ Built-in wins on CheckID collision. Plug-ins are first-class extenders
+ (they can add new checks under new CheckIDs) but cannot override
+ existing built-ins — a security tool prefers fail-loud predictability
+ over silent overrides. CheckMetadata.get_bulk() applies the same
+ precedence on the metadata side (first-write-wins) and emits a warning
+ when a plug-in tries to override, so the user knows their plug-in
+ duplicate is being ignored and can rename it.
+
+ Gates the built-in branch on `is_builtin_provider(provider_type)` —
+ calling `find_spec` on `prowler.providers.{provider_type}.services...`
+ directly would propagate `ModuleNotFoundError` for external providers
+ (their parent package `prowler.providers.{provider_type}` does not
+ exist) instead of returning None. The leaf helper encapsulates the
+ safe lookup, so external providers go straight to entry points. For
+ built-ins we still use `find_spec` to distinguish "check doesn't
+ exist" from "check exists but failed to import" (broken transitive
+ dep, etc.).
+ """
+ # Built-in first — built-in wins on CheckID collision
+ if is_builtin_provider(provider_type):
+ builtin_path = f"prowler.providers.{provider_type}.services.{service}.{check_name}.{check_name}"
+ if importlib.util.find_spec(builtin_path) is not None:
+ return import_check(builtin_path)
+
+ # Entry point lookup — only consulted when the built-in truly doesn't exist
+ for ep in importlib.metadata.entry_points(group=f"prowler.checks.{provider_type}"):
+ if ep.name == check_name:
+ return importlib.import_module(ep.value)
+
+ raise ModuleNotFoundError(
+ f"Check '{check_name}' not found for provider '{provider_type}'"
+ )
+
+
def run_fixer(check_findings: list) -> int:
"""
Run the fixer for the check if it exists and there are any FAIL findings
@@ -525,9 +567,10 @@ def execute_checks(
service = check_name.split("_")[0]
try:
try:
- # Import check module
- check_module_path = f"prowler.providers.{global_provider.type}.services.{service}.{check_name}.{check_name}"
- lib = import_check(check_module_path)
+ # Import check module (built-in or entry point)
+ lib = _resolve_check_module(
+ global_provider.type, service, check_name
+ )
# Recover functions from check
check_to_execute = getattr(lib, check_name)
check = check_to_execute()
@@ -605,9 +648,10 @@ def execute_checks(
)
try:
try:
- # Import check module
- check_module_path = f"prowler.providers.{global_provider.type}.services.{service}.{check_name}.{check_name}"
- lib = import_check(check_module_path)
+ # Import check module (built-in or entry point)
+ lib = _resolve_check_module(
+ global_provider.type, service, check_name
+ )
# Recover functions from check
check_to_execute = getattr(lib, check_name)
check = check_to_execute()
@@ -753,6 +797,10 @@ def execute(
is_finding_muted_args["org_domain"] = (
global_provider.identity.org_domain
)
+ elif not is_builtin_provider(global_provider.type):
+ # External/custom provider — delegate identity args
+ is_finding_muted_args = global_provider.get_mutelist_finding_args()
+
for finding in check_findings:
if global_provider.type == "cloudflare":
is_finding_muted_args["account_id"] = finding.account_id
diff --git a/prowler/lib/check/checks_loader.py b/prowler/lib/check/checks_loader.py
index 9ef672df6b..77840084ff 100644
--- a/prowler/lib/check/checks_loader.py
+++ b/prowler/lib/check/checks_loader.py
@@ -2,10 +2,10 @@ import sys
from colorama import Fore, Style
-from prowler.config.config import EXTERNAL_TOOL_PROVIDERS
from prowler.lib.check.check import parse_checks_from_file
from prowler.lib.check.compliance_models import Compliance
from prowler.lib.check.models import CheckMetadata, Severity
+from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider
from prowler.lib.logger import logger
@@ -26,8 +26,13 @@ def load_checks_to_execute(
) -> set:
"""Generate the list of checks to execute based on the cloud provider and the input arguments given"""
try:
- # Bypass check loading for providers that use external tools directly
- if provider in EXTERNAL_TOOL_PROVIDERS:
+ # Bypass check loading for tool-wrapper providers — they delegate
+ # scanning to an external tool and have no checks to recover.
+ # Single source of truth across __main__, the CheckMetadata validators,
+ # check discovery and this loader, covering both built-in tool wrappers
+ # (iac/llm/image) and external plug-ins that declare
+ # `is_external_tool_provider = True` via the contract.
+ if is_tool_wrapper_provider(provider):
return set()
# Local subsets
diff --git a/prowler/lib/check/compliance_models.py b/prowler/lib/check/compliance_models.py
index 82ac54e4d9..8cc588cc4c 100644
--- a/prowler/lib/check/compliance_models.py
+++ b/prowler/lib/check/compliance_models.py
@@ -1,3 +1,4 @@
+import importlib.metadata
import json
import os
import sys
@@ -434,26 +435,57 @@ class Compliance(BaseModel):
"""Bulk load all compliance frameworks specification into a dict"""
try:
bulk_compliance_frameworks = {}
+ # Built-in compliance from prowler/compliance/{provider}/
available_compliance_framework_modules = list_compliance_modules()
for compliance_framework in available_compliance_framework_modules:
- if provider in compliance_framework.name:
+ # Match the provider segment exactly, not as a substring, so
+ # e.g. `cloud` does not capture `cloudflare`.
+ if compliance_framework.name.split(".")[-1] == provider:
compliance_specification_dir_path = (
f"{compliance_framework.module_finder.path}/{provider}"
)
- # for compliance_framework in available_compliance_framework_modules:
for filename in os.listdir(compliance_specification_dir_path):
file_path = os.path.join(
compliance_specification_dir_path, filename
)
- # Check if it is a file and ti size is greater than 0
if os.path.isfile(file_path) and os.stat(file_path).st_size > 0:
- # Open Compliance file in JSON
- # cis_v1.4_aws.json --> cis_v1.4_aws
compliance_framework_name = filename.split(".json")[0]
- # Store the compliance info
bulk_compliance_frameworks[compliance_framework_name] = (
load_compliance_framework(file_path)
)
+
+ # External compliance via entry points
+ for ep in importlib.metadata.entry_points(group="prowler.compliance"):
+ if ep.name == provider:
+ try:
+ module = ep.load()
+ compliance_dir = (
+ module.__path__[0]
+ if hasattr(module, "__path__")
+ else os.path.dirname(module.__file__)
+ )
+ for filename in os.listdir(compliance_dir):
+ if filename.endswith(".json"):
+ file_path = os.path.join(compliance_dir, filename)
+ if (
+ os.path.isfile(file_path)
+ and os.stat(file_path).st_size > 0
+ ):
+ compliance_framework_name = filename.split(".json")[
+ 0
+ ]
+ if (
+ compliance_framework_name
+ not in bulk_compliance_frameworks
+ ):
+ bulk_compliance_frameworks[
+ compliance_framework_name
+ ] = load_compliance_framework(file_path)
+ except Exception as error:
+ logger.warning(
+ f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+
except Exception as e:
logger.error(f"{e.__class__.__name__}[{e.__traceback__.tb_lineno}] -- {e}")
diff --git a/prowler/lib/check/models.py b/prowler/lib/check/models.py
index d97be78cbe..f9155c68f7 100644
--- a/prowler/lib/check/models.py
+++ b/prowler/lib/check/models.py
@@ -11,10 +11,10 @@ from typing import Any, Dict, Optional, Set
from pydantic.v1 import BaseModel, Field, ValidationError, validator
from pydantic.v1.error_wrappers import ErrorWrapper
-from prowler.config.config import EXTERNAL_TOOL_PROVIDERS, Provider
from prowler.lib.check.compliance_models import Compliance
from prowler.lib.check.utils import recover_checks_from_provider
from prowler.lib.logger import logger
+from prowler.providers.common.provider import Provider as ProviderABC
# Valid ResourceGroup values as defined in the RFC
VALID_RESOURCE_GROUPS = frozenset(
@@ -259,7 +259,7 @@ class CheckMetadata(BaseModel):
)
if (
value_lower not in VALID_CATEGORIES
- and values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS
+ and not ProviderABC.is_tool_wrapper_provider(values.get("Provider"))
):
raise ValueError(
f"Invalid category: '{value_lower}'. Must be one of: {', '.join(sorted(VALID_CATEGORIES))}."
@@ -288,7 +288,9 @@ class CheckMetadata(BaseModel):
raise ValueError("ServiceName must be a non-empty string")
check_id = values.get("CheckID")
- if check_id and values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
+ if check_id and not ProviderABC.is_tool_wrapper_provider(
+ values.get("Provider")
+ ):
service_from_check_id = check_id.split("_")[0]
if service_name != service_from_check_id:
raise ValueError(
@@ -304,7 +306,9 @@ class CheckMetadata(BaseModel):
if not check_id:
raise ValueError("CheckID must be a non-empty string")
- if check_id and values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
+ if check_id and not ProviderABC.is_tool_wrapper_provider(
+ values.get("Provider")
+ ):
if "-" in check_id:
raise ValueError(
f"CheckID {check_id} contains a hyphen, which is not allowed"
@@ -313,8 +317,9 @@ class CheckMetadata(BaseModel):
return check_id
@validator("CheckTitle", pre=True, always=True)
+ @classmethod
def validate_check_title(cls, check_title, values): # noqa: F841
- if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
+ if not ProviderABC.is_tool_wrapper_provider(values.get("Provider")):
if len(check_title) > 150:
raise ValueError(
f"CheckTitle must not exceed 150 characters, got {len(check_title)} characters"
@@ -326,14 +331,18 @@ class CheckMetadata(BaseModel):
return check_title
@validator("RelatedUrl", pre=True, always=True)
+ @classmethod
def validate_related_url(cls, related_url, values): # noqa: F841
- if related_url and values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
+ if related_url and not ProviderABC.is_tool_wrapper_provider(
+ values.get("Provider")
+ ):
raise ValueError("RelatedUrl must be empty. This field is deprecated.")
return related_url
@validator("Remediation")
+ @classmethod
def validate_recommendation_url(cls, remediation, values): # noqa: F841
- if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
+ if not ProviderABC.is_tool_wrapper_provider(values.get("Provider")):
url = remediation.Recommendation.Url
if url and not url.startswith("https://hub.prowler.com/"):
raise ValueError(
@@ -346,7 +355,7 @@ class CheckMetadata(BaseModel):
provider = values.get("Provider", "").lower()
# Non-AWS providers must have an empty CheckType list
- if provider != "aws" and provider not in EXTERNAL_TOOL_PROVIDERS:
+ if provider != "aws" and not ProviderABC.is_tool_wrapper_provider(provider):
if check_type:
raise ValueError(
f"CheckType must be empty for non-AWS providers. Got {check_type} for provider '{provider}'."
@@ -371,8 +380,9 @@ class CheckMetadata(BaseModel):
return check_type
@validator("Description", pre=True, always=True)
+ @classmethod
def validate_description(cls, description, values): # noqa: F841
- if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
+ if not ProviderABC.is_tool_wrapper_provider(values.get("Provider")):
if len(description) > 400:
raise ValueError(
f"Description must not exceed 400 characters, got {len(description)} characters"
@@ -380,8 +390,9 @@ class CheckMetadata(BaseModel):
return description
@validator("Risk", pre=True, always=True)
+ @classmethod
def validate_risk(cls, risk, values): # noqa: F841
- if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
+ if not ProviderABC.is_tool_wrapper_provider(values.get("Provider")):
if len(risk) > 400:
raise ValueError(
f"Risk must not exceed 400 characters, got {len(risk)} characters"
@@ -433,6 +444,20 @@ class CheckMetadata(BaseModel):
metadata_file = f"{check_path}/{check_name}.metadata.json"
# Load metadata
check_metadata = load_check_metadata(metadata_file)
+ # Built-in wins on CheckID collision. Plug-in entry points are
+ # appended after built-ins by `recover_checks_from_provider`, so
+ # a duplicate CheckID here means an entry-point check is trying
+ # to override a built-in. Ignore the override (the built-in
+ # metadata stays) and surface it via a warning — matching the
+ # precedence enforced by `_resolve_check_module`.
+ if check_metadata.CheckID in bulk_check_metadata:
+ logger.warning(
+ f"Plug-in check metadata '{check_metadata.CheckID}' "
+ f"(loaded from '{metadata_file}') is being IGNORED — "
+ f"a built-in with the same CheckID exists. To use your "
+ f"plug-in, register it under a different CheckID."
+ )
+ continue
bulk_check_metadata[check_metadata.CheckID] = check_metadata
return bulk_check_metadata
@@ -470,7 +495,7 @@ class CheckMetadata(BaseModel):
# If the bulk checks metadata is not provided, get it
if not bulk_checks_metadata:
bulk_checks_metadata = {}
- available_providers = [p.value for p in Provider]
+ available_providers = ProviderABC.get_available_providers()
for provider_name in available_providers:
bulk_checks_metadata.update(CheckMetadata.get_bulk(provider_name))
if provider:
@@ -495,7 +520,7 @@ class CheckMetadata(BaseModel):
# Loaded here, as it is not always needed
if not bulk_compliance_frameworks:
bulk_compliance_frameworks = {}
- available_providers = [p.value for p in Provider]
+ available_providers = ProviderABC.get_available_providers()
for provider in available_providers:
bulk_compliance_frameworks = Compliance.get_bulk(provider=provider)
checks_from_compliance_framework = (
diff --git a/prowler/lib/check/tool_wrapper.py b/prowler/lib/check/tool_wrapper.py
new file mode 100644
index 0000000000..a1d606594e
--- /dev/null
+++ b/prowler/lib/check/tool_wrapper.py
@@ -0,0 +1,62 @@
+"""Standalone helper for tool-wrapper provider detection.
+
+A provider is a "tool wrapper" if it delegates scanning to an external tool
+(Trivy, promptfoo, etc.) instead of running checks/services through the
+standard Prowler engine. This module is the single source of truth for that
+classification across the codebase.
+
+Kept as a leaf module with no Prowler imports beyond the leaf
+`external_tool_providers` so it can be referenced from `prowler.lib.check.*`
+and `prowler.providers.common.provider` without forming an import cycle.
+"""
+
+import importlib.metadata
+
+from prowler.lib.check.external_tool_providers import EXTERNAL_TOOL_PROVIDERS
+from prowler.providers.common.builtin import is_builtin_provider
+
+# Module-level cache for entry-point classes consulted by this helper.
+# Independent of `Provider._ep_providers` to keep this module leaf — the cost
+# of a duplicate cache entry is negligible (one class object per external
+# provider, loaded lazily on first lookup).
+_ep_class_cache: dict = {}
+
+
+def _load_ep_class(provider: str):
+ """Return the entry-point provider class for `provider`, or None.
+
+ Caches the result in `_ep_class_cache`. Errors during entry-point loading
+ are swallowed (returning None) so a broken plug-in never crashes the
+ is-tool-wrapper check; it just falls through to "not a tool wrapper".
+ """
+ if provider in _ep_class_cache:
+ return _ep_class_cache[provider]
+ for ep in importlib.metadata.entry_points(group="prowler.providers"):
+ if ep.name == provider:
+ try:
+ cls = ep.load()
+ except Exception:
+ cls = None
+ _ep_class_cache[provider] = cls
+ return cls
+ _ep_class_cache[provider] = None
+ return None
+
+
+def is_tool_wrapper_provider(provider: str) -> bool:
+ """Return True if the provider delegates scanning to an external tool.
+
+ Combines the built-in `EXTERNAL_TOOL_PROVIDERS` frozenset (fast path for
+ iac/llm/image) with the `is_external_tool_provider` class attribute of
+ external plug-ins registered via entry points. This is the single source
+ of truth consulted by `__main__`, the `CheckMetadata` validators, the
+ check-loading utilities, and the checks loader.
+ """
+ if provider in EXTERNAL_TOOL_PROVIDERS:
+ return True
+ # Built-in wins: short-circuit before ep.load() so a same-name plug-in
+ # cannot flip a built-in onto the tool-wrapper path or run its code.
+ if is_builtin_provider(provider):
+ return False
+ cls = _load_ep_class(provider)
+ return bool(cls and getattr(cls, "is_external_tool_provider", False))
diff --git a/prowler/lib/check/utils.py b/prowler/lib/check/utils.py
index 0e4807078f..9c9a9c0523 100644
--- a/prowler/lib/check/utils.py
+++ b/prowler/lib/check/utils.py
@@ -1,9 +1,43 @@
import importlib
+import importlib.metadata
+import importlib.util
+import os
import sys
from pkgutil import walk_packages
-from prowler.lib.check.external_tool_providers import EXTERNAL_TOOL_PROVIDERS
+from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider
from prowler.lib.logger import logger
+from prowler.providers.common.builtin import is_builtin_provider
+
+
+def _recover_ep_checks(provider: str, service: str = None) -> list[tuple]:
+ """Discover external checks registered via entry points for a provider.
+
+ External plugins follow the same layout as built-ins:
+ `{plugin_root}.services.{service}.{check}.{check}`
+
+ When `service` is provided, only entry points whose dotted path contains
+ `.services.{service}.` are included — mirroring how built-in discovery
+ filters by the `prowler.providers.{provider}.services.{service}` package.
+
+ Uses find_spec to locate the check module without importing it,
+ avoiding service client initialization at discovery time.
+ """
+ checks = []
+ for ep in importlib.metadata.entry_points(group=f"prowler.checks.{provider}"):
+ try:
+ if service and f".services.{service}." not in ep.value:
+ continue
+
+ spec = importlib.util.find_spec(ep.value)
+ if spec and spec.origin:
+ check_path = os.path.dirname(spec.origin)
+ checks.append((ep.name, check_path))
+ except Exception as error:
+ logger.warning(
+ f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+ return checks
def recover_checks_from_provider(
@@ -15,29 +49,55 @@ def recover_checks_from_provider(
Returns a list of tuples with the following format (check_name, check_path)
"""
try:
- # Bypass check loading for providers that use external tools directly
- if provider in EXTERNAL_TOOL_PROVIDERS:
+ # Bypass check loading for tool-wrapper providers — they delegate
+ # scanning to an external tool and have no checks to recover.
+ # Single source of truth: combines the EXTERNAL_TOOL_PROVIDERS
+ # frozenset (built-ins) with the per-provider `is_external_tool_provider`
+ # class attribute (so external plug-ins opt in via the contract).
+ if is_tool_wrapper_provider(provider):
return []
checks = []
- modules = list_modules(provider, service)
- for module_name in modules:
- # Format: "prowler.providers.{provider}.services.{service}.{check_name}.{check_name}"
- check_module_name = module_name.name
- # We need to exclude common shared libraries in services
- if (
- check_module_name.count(".") == 6
- and ".lib." not in check_module_name
- and (not check_module_name.endswith("_fixer") or include_fixers)
- ):
- check_path = module_name.module_finder.path
- # Check name is the last part of the check_module_name
- check_name = check_module_name.split(".")[-1]
- check_info = (check_name, check_path)
- checks.append(check_info)
- except ModuleNotFoundError:
- logger.critical(f"Service {service} was not found for the {provider} provider.")
- sys.exit(1)
+ # Built-in checks from prowler.providers.{provider}.services. Gate
+ # the built-in branch on `is_builtin_provider(provider)` — calling
+ # `find_spec` directly on `prowler.providers.{provider}.services`
+ # would propagate `ModuleNotFoundError` when the parent package
+ # `prowler.providers.{provider}` does not exist (i.e. the provider
+ # is external), instead of returning None. The leaf helper
+ # encapsulates the safe lookup, so we only run the built-in
+ # discovery when the provider actually ships with the SDK; for
+ # external providers we go straight to entry points.
+ if is_builtin_provider(provider):
+ modules = list_modules(provider, service)
+ for module_name in modules:
+ # Format: "prowler.providers.{provider}.services.{service}.{check_name}.{check_name}"
+ check_module_name = module_name.name
+ # We need to exclude common shared libraries in services
+ if (
+ check_module_name.count(".") == 6
+ and ".lib." not in check_module_name
+ and (not check_module_name.endswith("_fixer") or include_fixers)
+ ):
+ check_path = module_name.module_finder.path
+ check_name = check_module_name.split(".")[-1]
+ check_info = (check_name, check_path)
+ checks.append(check_info)
+
+ # External checks registered via entry points — always consulted, with
+ # optional service filter. Previously gated by `if not service:`, which
+ # prevented external providers from being usable with --service.
+ checks.extend(_recover_ep_checks(provider, service))
+
+ # A service was requested but nothing matched in either built-ins or
+ # entry points — surface this as a clear error instead of silently
+ # returning an empty list.
+ if service and not checks:
+ logger.critical(
+ f"Service '{service}' was not found for the '{provider}' provider "
+ f"(neither as a built-in nor via external entry points)."
+ )
+ sys.exit(1)
+
except Exception as e:
logger.critical(f"{e.__class__.__name__}[{e.__traceback__.tb_lineno}]: {e}")
sys.exit(1)
@@ -64,8 +124,9 @@ def recover_checks_from_service(service_list: list, provider: str) -> set:
Returns a set of checks from the given services
"""
try:
- # Bypass check loading for providers that use external tools directly
- if provider in EXTERNAL_TOOL_PROVIDERS:
+ # Bypass check loading for tool-wrapper providers — symmetric with
+ # `recover_checks_from_provider` above, using the same source of truth.
+ if is_tool_wrapper_provider(provider):
return set()
checks = set()
diff --git a/prowler/lib/cli/parser.py b/prowler/lib/cli/parser.py
index 449c15a22c..8f05fc30cb 100644
--- a/prowler/lib/cli/parser.py
+++ b/prowler/lib/cli/parser.py
@@ -20,19 +20,61 @@ from prowler.providers.common.arguments import (
validate_provider_arguments,
validate_sarif_usage,
)
+from prowler.providers.common.provider import Provider
class ProwlerArgumentParser:
# Set the default parser
def __init__(self):
+ # Discover any providers not in the hardcoded list below
+ # TODO - First step to support current providers and the new external provider implementation
+ known_providers = {
+ "aws",
+ "azure",
+ "gcp",
+ "kubernetes",
+ "m365",
+ "github",
+ "googleworkspace",
+ "cloudflare",
+ "oraclecloud",
+ "openstack",
+ "alibabacloud",
+ "iac",
+ "llm",
+ "image",
+ "nhn",
+ "mongodbatlas",
+ "vercel",
+ "okta",
+ "scaleway",
+ "stackit",
+ }
+ all_providers = set(Provider.get_available_providers())
+ new_providers = sorted(all_providers - known_providers)
+
+ # Build extra strings for dynamically discovered providers
+ extra_providers_csv = ""
+ extra_providers_text = ""
+ if new_providers:
+ providers_help = Provider.get_providers_help_text()
+ extra_providers_csv = "," + ",".join(new_providers)
+ extra_lines = []
+ for name in new_providers:
+ help_text = providers_help.get(name, "")
+ if help_text:
+ extra_lines.append(f" {name:<20}{help_text}")
+ if extra_lines:
+ extra_providers_text = "\n" + "\n".join(extra_lines)
+
# CLI Arguments
self.parser = argparse.ArgumentParser(
prog="prowler",
formatter_class=RawTextHelpFormatter,
- usage="prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,dashboard,iac,image,llm} ...",
- epilog="""
+ usage=f"prowler [-h] [--version] {{aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,dashboard,iac,image,llm{extra_providers_csv}}} ...",
+ epilog=f"""
Available Cloud Providers:
- {aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,iac,llm,image,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel}
+ {{aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,iac,llm,image,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel{extra_providers_csv}}}
aws AWS Provider
azure Azure Provider
gcp GCP Provider
@@ -52,13 +94,14 @@ Available Cloud Providers:
nhn NHN Provider (Unofficial)
mongodbatlas MongoDB Atlas Provider
scaleway Scaleway Provider
- vercel Vercel Provider
+ vercel Vercel Provider{extra_providers_text}
+
Available components:
dashboard Local dashboard
To see the different available options on a specific component, run:
- prowler {provider|dashboard} -h|--help
+ prowler {{provider|dashboard}} -h|--help
Detailed documentation at https://docs.prowler.com
""",
@@ -117,8 +160,10 @@ Detailed documentation at https://docs.prowler.com
and (sys.argv[1] not in ("-v", "--version"))
):
# Since the provider is always the second argument, we are checking if
- # a flag, starting by "-", is supplied
- if "-" in sys.argv[1]:
+ # a flag is supplied. Use startswith("-") instead of "in" to avoid
+ # matching external provider names that contain hyphens
+ # (e.g. "local-acme-snowflake").
+ if sys.argv[1].startswith("-"):
sys.argv = self.__set_default_provider__(sys.argv)
# Provider aliases mapping
diff --git a/prowler/lib/outputs/compliance/compliance.py b/prowler/lib/outputs/compliance/compliance.py
index 28611a68a1..65ad8af0b3 100644
--- a/prowler/lib/outputs/compliance/compliance.py
+++ b/prowler/lib/outputs/compliance/compliance.py
@@ -253,14 +253,32 @@ def display_compliance_table(
compliance_overview,
)
else:
- get_generic_compliance_table(
- findings,
- bulk_checks_metadata,
- compliance_framework,
- output_filename,
- output_directory,
- compliance_overview,
- )
+ # Try provider-specific table first, fall back to generic
+ from prowler.providers.common.provider import Provider
+
+ provider = Provider.get_global_provider()
+ handled = False
+ if provider is not None:
+ try:
+ handled = provider.display_compliance_table(
+ findings,
+ bulk_checks_metadata,
+ compliance_framework,
+ output_filename,
+ output_directory,
+ compliance_overview,
+ )
+ except NotImplementedError:
+ handled = False
+ if not handled:
+ get_generic_compliance_table(
+ findings,
+ bulk_checks_metadata,
+ compliance_framework,
+ output_filename,
+ output_directory,
+ compliance_overview,
+ )
except Exception as error:
logger.critical(
f"{error.__class__.__name__}:{error.__traceback__.tb_lineno} -- {error}"
diff --git a/prowler/lib/outputs/compliance/generic/generic.py b/prowler/lib/outputs/compliance/generic/generic.py
index c7217db5b3..b774f09577 100644
--- a/prowler/lib/outputs/compliance/generic/generic.py
+++ b/prowler/lib/outputs/compliance/generic/generic.py
@@ -34,60 +34,48 @@ class GenericCompliance(ComplianceOutput):
Returns:
- None
"""
+
+ def compliance_row(requirement, attribute, finding=None):
+ # Read attribute fields defensively: GenericCompliance is the
+ # last-resort renderer for any framework, and provider-specific
+ # schemas (e.g. CIS, ENS, ISO27001) do not declare the universal
+ # Section/SubSection/SubGroup/Service/Type/Comment fields.
+ return GenericComplianceModel(
+ Provider=(finding.provider if finding else compliance.Provider.lower()),
+ Description=compliance.Description,
+ AccountId=finding.account_uid if finding else "",
+ Region=finding.region if finding else "",
+ AssessmentDate=str(timestamp),
+ Requirements_Id=requirement.Id,
+ Requirements_Description=requirement.Description,
+ Requirements_Attributes_Section=getattr(attribute, "Section", None),
+ Requirements_Attributes_SubSection=getattr(
+ attribute, "SubSection", None
+ ),
+ Requirements_Attributes_SubGroup=getattr(attribute, "SubGroup", None),
+ Requirements_Attributes_Service=getattr(attribute, "Service", None),
+ Requirements_Attributes_Type=getattr(attribute, "Type", None),
+ Requirements_Attributes_Comment=getattr(attribute, "Comment", None),
+ Status=finding.status if finding else "MANUAL",
+ StatusExtended=(finding.status_extended if finding else "Manual check"),
+ ResourceId=finding.resource_uid if finding else "manual_check",
+ ResourceName=finding.resource_name if finding else "Manual check",
+ CheckId=finding.check_id if finding else "manual",
+ Muted=finding.muted if finding else False,
+ Framework=compliance.Framework,
+ Name=compliance.Name,
+ )
+
for finding in findings:
for requirement in compliance.Requirements:
# Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift).
if finding.check_id in requirement.Checks:
for attribute in requirement.Attributes:
- compliance_row = GenericComplianceModel(
- Provider=finding.provider,
- Description=compliance.Description,
- AccountId=finding.account_uid,
- Region=finding.region,
- AssessmentDate=str(timestamp),
- Requirements_Id=requirement.Id,
- Requirements_Description=requirement.Description,
- Requirements_Attributes_Section=attribute.Section,
- Requirements_Attributes_SubSection=attribute.SubSection,
- Requirements_Attributes_SubGroup=attribute.SubGroup,
- Requirements_Attributes_Service=attribute.Service,
- Requirements_Attributes_Type=attribute.Type,
- Requirements_Attributes_Comment=attribute.Comment,
- Status=finding.status,
- StatusExtended=finding.status_extended,
- ResourceId=finding.resource_uid,
- ResourceName=finding.resource_name,
- CheckId=finding.check_id,
- Muted=finding.muted,
- Framework=compliance.Framework,
- Name=compliance.Name,
+ self._data.append(
+ compliance_row(requirement, attribute, finding)
)
- self._data.append(compliance_row)
# Add manual requirements to the compliance output
for requirement in compliance.Requirements:
if not requirement.Checks:
for attribute in requirement.Attributes:
- compliance_row = GenericComplianceModel(
- Provider=compliance.Provider.lower(),
- Description=compliance.Description,
- AccountId="",
- Region="",
- AssessmentDate=str(timestamp),
- Requirements_Id=requirement.Id,
- Requirements_Description=requirement.Description,
- Requirements_Attributes_Section=attribute.Section,
- Requirements_Attributes_SubSection=attribute.SubSection,
- Requirements_Attributes_SubGroup=attribute.SubGroup,
- Requirements_Attributes_Service=attribute.Service,
- Requirements_Attributes_Type=attribute.Type,
- Requirements_Attributes_Comment=attribute.Comment,
- Status="MANUAL",
- StatusExtended="Manual check",
- ResourceId="manual_check",
- ResourceName="Manual check",
- CheckId="manual",
- Muted=False,
- Framework=compliance.Framework,
- Name=compliance.Name,
- )
- self._data.append(compliance_row)
+ self._data.append(compliance_row(requirement, attribute))
diff --git a/prowler/lib/outputs/finding.py b/prowler/lib/outputs/finding.py
index 950460e4e3..9f772d17c4 100644
--- a/prowler/lib/outputs/finding.py
+++ b/prowler/lib/outputs/finding.py
@@ -517,6 +517,11 @@ class Finding(BaseModel):
check_output, "fixed_version", ""
)
+ else:
+ # Dynamic fallback: any external/custom provider
+ provider_data = provider.get_finding_output_data(check_output)
+ output_data.update(provider_data)
+
# check_output Unique ID
# TODO: move this to a function
# TODO: in Azure, GCP and K8s there are findings without resource_name
diff --git a/prowler/lib/outputs/html/html.py b/prowler/lib/outputs/html/html.py
index 33f7ef4934..52afd071ae 100644
--- a/prowler/lib/outputs/html/html.py
+++ b/prowler/lib/outputs/html/html.py
@@ -1608,11 +1608,13 @@ class HTML(Output):
# Azure_provider --> azure
# Kubernetes_provider --> kubernetes
- # Dynamically get the Provider quick inventory handler
- provider_html_assessment_summary_function = (
- f"get_{provider.type}_assessment_summary"
- )
- return getattr(HTML, provider_html_assessment_summary_function)(provider)
+ # Try static method first, fall back to provider method
+ method_name = f"get_{provider.type}_assessment_summary"
+ if hasattr(HTML, method_name):
+ return getattr(HTML, method_name)(provider)
+ else:
+ # Dynamic fallback: any external/custom provider
+ return provider.get_html_assessment_summary()
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}"
diff --git a/prowler/lib/outputs/outputs.py b/prowler/lib/outputs/outputs.py
index e4d935e3cf..a1f37a9dfc 100644
--- a/prowler/lib/outputs/outputs.py
+++ b/prowler/lib/outputs/outputs.py
@@ -7,45 +7,52 @@ from prowler.lib.outputs.common import Status
from prowler.lib.outputs.finding import Finding
-def stdout_report(finding, color, verbose, status, fix):
+def stdout_report(finding, color, verbose, status, fix, provider=None):
if finding.check_metadata.Provider == "aws":
details = finding.region
- if finding.check_metadata.Provider == "azure":
+ elif finding.check_metadata.Provider == "azure":
details = finding.location
- if finding.check_metadata.Provider == "gcp":
+ elif finding.check_metadata.Provider == "gcp":
details = finding.location.lower()
- if finding.check_metadata.Provider == "kubernetes":
+ elif finding.check_metadata.Provider == "kubernetes":
details = finding.namespace.lower()
- if finding.check_metadata.Provider == "github":
+ elif finding.check_metadata.Provider == "github":
details = finding.owner
- if finding.check_metadata.Provider == "m365":
+ elif finding.check_metadata.Provider == "m365":
details = finding.location
- if finding.check_metadata.Provider == "mongodbatlas":
+ elif finding.check_metadata.Provider == "mongodbatlas":
details = finding.location
- if finding.check_metadata.Provider == "nhn":
+ elif finding.check_metadata.Provider == "nhn":
details = finding.location
- if finding.check_metadata.Provider == "stackit":
+ elif finding.check_metadata.Provider == "stackit":
details = finding.location
- if finding.check_metadata.Provider == "llm":
+ elif finding.check_metadata.Provider == "llm":
details = finding.check_metadata.CheckID
- if finding.check_metadata.Provider == "iac":
+ elif finding.check_metadata.Provider == "iac":
details = finding.check_metadata.CheckID
- if finding.check_metadata.Provider == "oraclecloud":
+ elif finding.check_metadata.Provider == "oraclecloud":
details = finding.region
- if finding.check_metadata.Provider == "alibabacloud":
+ elif finding.check_metadata.Provider == "alibabacloud":
details = finding.region
- if finding.check_metadata.Provider == "openstack":
+ elif finding.check_metadata.Provider == "openstack":
details = finding.region
- if finding.check_metadata.Provider == "cloudflare":
+ elif finding.check_metadata.Provider == "cloudflare":
details = finding.zone_name
- if finding.check_metadata.Provider == "googleworkspace":
+ elif finding.check_metadata.Provider == "googleworkspace":
details = finding.location
- if finding.check_metadata.Provider == "vercel":
+ elif finding.check_metadata.Provider == "vercel":
details = finding.region
- if finding.check_metadata.Provider == "okta":
+ elif finding.check_metadata.Provider == "okta":
details = finding.region
- if finding.check_metadata.Provider == "scaleway":
+ elif finding.check_metadata.Provider == "scaleway":
details = finding.region
+ else:
+ # Dynamic fallback: any external/custom provider
+ if provider is None:
+ from prowler.providers.common.provider import Provider
+
+ provider = Provider.get_global_provider()
+ details = provider.get_stdout_detail(finding)
if (verbose or fix) and (not status or finding.status in status):
if finding.muted:
@@ -65,12 +72,15 @@ def report(check_findings, provider, output_options):
if hasattr(output_options, "verbose"):
verbose = output_options.verbose
if check_findings:
- # TO-DO Generic Function
if provider.type == "aws":
check_findings.sort(key=lambda x: x.region)
-
- if provider.type == "azure":
+ elif provider.type == "azure":
check_findings.sort(key=lambda x: x.subscription)
+ else:
+ # Dynamic fallback: any external/custom provider
+ sort_key = provider.get_finding_sort_key()
+ if sort_key and isinstance(sort_key, str):
+ check_findings.sort(key=lambda x: getattr(x, sort_key, ""))
for finding in check_findings:
# Print findings by stdout
@@ -81,12 +91,16 @@ def report(check_findings, provider, output_options):
if hasattr(output_options, "fixer"):
fixer = output_options.fixer
color = set_report_color(finding.status, finding.muted)
+ # Pass the local `provider` through so the dynamic else inside
+ # `stdout_report` does not have to consult the global singleton
+ # — defeating the whole purpose of the new parameter.
stdout_report(
finding,
color,
verbose,
status,
fixer,
+ provider=provider,
)
else: # No service resources in the whole account
diff --git a/prowler/lib/outputs/summary_table.py b/prowler/lib/outputs/summary_table.py
index e76728ccc8..43d4a547c1 100644
--- a/prowler/lib/outputs/summary_table.py
+++ b/prowler/lib/outputs/summary_table.py
@@ -121,6 +121,9 @@ def display_summary_table(
elif provider.type == "scaleway":
entity_type = "Organization"
audited_entities = provider.identity.organization_id
+ else:
+ # Dynamic fallback: any external/custom provider
+ entity_type, audited_entities = provider.get_summary_entity()
# Check if there are findings and that they are not all MANUAL
if findings and not all(finding.status == "MANUAL" for finding in findings):
diff --git a/prowler/lib/scan/scan.py b/prowler/lib/scan/scan.py
index 2ce7263e2b..4bef660d33 100644
--- a/prowler/lib/scan/scan.py
+++ b/prowler/lib/scan/scan.py
@@ -4,8 +4,8 @@ from types import SimpleNamespace
from typing import Generator
from prowler.lib.check.check import (
+ _resolve_check_module,
execute,
- import_check,
list_services,
update_audit_metadata,
)
@@ -426,9 +426,14 @@ class Scan:
# Recover service from check name
service = get_service_name_from_check_name(check_name)
try:
- # Import check module
- check_module_path = f"prowler.providers.{self._provider.type}.services.{service}.{check_name}.{check_name}"
- lib = import_check(check_module_path)
+ # Import check module (built-in or entry point) —
+ # delegates to `_resolve_check_module` so external
+ # providers registered via entry points are resolved
+ # correctly (their checks do not live under
+ # `prowler.providers.{type}.services...`).
+ lib = _resolve_check_module(
+ self._provider.type, service, check_name
+ )
# Recover functions from check
check_to_execute = getattr(lib, check_name)
check = check_to_execute()
diff --git a/prowler/providers/common/arguments.py b/prowler/providers/common/arguments.py
index 9745bab2ca..9e23274a90 100644
--- a/prowler/providers/common/arguments.py
+++ b/prowler/providers/common/arguments.py
@@ -16,18 +16,41 @@ def init_providers_parser(self):
# We need to call the arguments parser for each provider
providers = Provider.get_available_providers()
for provider in providers:
- try:
- getattr(
- import_module(
- f"{providers_path}.{provider}.{provider_arguments_lib_path}"
- ),
- init_provider_arguments_function,
- )(self)
- except Exception as error:
- logger.critical(
- f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
- )
- sys.exit(1)
+ # Discriminate built-in vs external upfront via find_spec, so an
+ # ImportError from a transitive dependency missing inside a built-in
+ # arguments module surfaces clearly instead of being silently
+ # re-routed to the entry-point path (which only has external providers).
+ if Provider.is_builtin(provider):
+ try:
+ getattr(
+ import_module(
+ f"{providers_path}.{provider}.{provider_arguments_lib_path}"
+ ),
+ init_provider_arguments_function,
+ )(self)
+ except ImportError as e:
+ logger.critical(
+ f"Failed to load arguments for built-in provider '{provider}'. "
+ f"Missing dependency: {e}. "
+ f"Ensure all required dependencies are installed."
+ )
+ logger.debug("Full traceback:", exc_info=True)
+ sys.exit(1)
+ except Exception as error:
+ logger.critical(
+ f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+ sys.exit(1)
+ else:
+ # External provider — init_parser classmethod via entry point
+ cls = Provider._load_ep_provider(provider)
+ if cls and hasattr(cls, "init_parser"):
+ try:
+ cls.init_parser(self)
+ except Exception as error:
+ logger.warning(
+ f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
def validate_provider_arguments(arguments: Namespace) -> tuple[bool, str]:
diff --git a/prowler/providers/common/builtin.py b/prowler/providers/common/builtin.py
new file mode 100644
index 0000000000..d60b5483d9
--- /dev/null
+++ b/prowler/providers/common/builtin.py
@@ -0,0 +1,29 @@
+"""Leaf helper for built-in provider detection.
+
+Lives in its own module — with no imports back into `prowler.lib.check` — so
+that callers in `prowler.lib.check.*` can ask "is this provider built-in?"
+without creating an import cycle through `prowler.providers.common.provider`
+(which transitively imports `prowler.config.config` and from there
+`prowler.lib.check.compliance_models` / `prowler.lib.check.external_tool_providers`).
+
+Same rationale as `prowler.lib.check.tool_wrapper`: extracting the predicate
+to a leaf module is the canonical way to break the cycle in this codebase.
+"""
+
+import importlib.util
+
+
+def is_builtin_provider(provider: str) -> bool:
+ """Return True if the provider's own package ships with the SDK.
+
+ Wraps `importlib.util.find_spec` in `try/except (ImportError, ValueError)`
+ because `find_spec` propagates `ModuleNotFoundError` when a parent package
+ in the dotted path does not exist (instead of returning `None`). The
+ try/except is what makes the call safe for external providers, whose
+ package does not live under `prowler.providers.{provider}`.
+ """
+ try:
+ spec = importlib.util.find_spec(f"prowler.providers.{provider}")
+ return spec is not None
+ except (ImportError, ValueError):
+ return False
diff --git a/prowler/providers/common/models.py b/prowler/providers/common/models.py
index ea70252f0a..120cc1a374 100644
--- a/prowler/providers/common/models.py
+++ b/prowler/providers/common/models.py
@@ -4,6 +4,7 @@ from os.path import isdir
from pydantic.v1 import BaseModel
+from prowler.config.config import output_file_timestamp
from prowler.providers.common.provider import Provider
@@ -69,3 +70,15 @@ class Connection:
is_connected: bool = False
error: Exception = None
+
+
+def default_output_options(provider, arguments, bulk_checks_metadata):
+ """Generic OutputOptions fallback for external providers that do not
+ implement get_output_options, so the run still produces output instead of
+ aborting. Honors arguments.output_filename and otherwise derives a name
+ from the provider type."""
+ output_options = ProviderOutputOptions(arguments, bulk_checks_metadata)
+ output_options.output_filename = getattr(arguments, "output_filename", None) or (
+ f"prowler-output-{provider.type}-{output_file_timestamp}"
+ )
+ return output_options
diff --git a/prowler/providers/common/provider.py b/prowler/providers/common/provider.py
index d11b015d54..8bc7567795 100644
--- a/prowler/providers/common/provider.py
+++ b/prowler/providers/common/provider.py
@@ -1,4 +1,6 @@
import importlib
+import importlib.metadata
+import importlib.util
import os
import pkgutil
import sys
@@ -136,6 +138,106 @@ class Provider(ABC):
"""
return set()
+ # --- Dynamic provider contract methods (not @abstractmethod for incremental migration) ---
+
+ _cli_help_text: str = ""
+
+ @classmethod
+ def from_cli_args(cls, arguments: Namespace, fixer_config: dict) -> "Provider":
+ """Instantiate the provider from CLI arguments and return the instance.
+
+ The caller wires the returned instance into the global provider slot
+ via Provider.set_global_provider(). Implementations that already call
+ set_global_provider(self) from __init__ are also supported — the call
+ site tolerates a None return in that case.
+ """
+ raise NotImplementedError(f"{cls.__name__} has not implemented from_cli_args()")
+
+ def get_output_options(self, arguments, _bulk_checks_metadata):
+ """Create the provider-specific OutputOptions."""
+ raise NotImplementedError(
+ f"{self.__class__.__name__} has not implemented get_output_options()"
+ )
+
+ def get_stdout_detail(self, _finding) -> str:
+ """Return the detail string for stdout reporting (region, location, etc.)."""
+ raise NotImplementedError(
+ f"{self.__class__.__name__} has not implemented get_stdout_detail()"
+ )
+
+ def get_finding_sort_key(self) -> Optional[str]:
+ """Return the attribute name to sort findings by, or None for no sorting."""
+ return None
+
+ def get_summary_entity(self) -> tuple:
+ """Return (entity_type, audited_entities) for the summary table."""
+ return (self.type, getattr(self.identity, "account_id", ""))
+
+ def get_finding_output_data(self, _check_output) -> dict:
+ """Return provider-specific fields for Finding.generate_output()."""
+ raise NotImplementedError(
+ f"{self.__class__.__name__} has not implemented get_finding_output_data()"
+ )
+
+ def get_html_assessment_summary(self) -> str:
+ """Return the HTML assessment summary card for this provider."""
+ raise NotImplementedError(
+ f"{self.__class__.__name__} has not implemented get_html_assessment_summary()"
+ )
+
+ def generate_compliance_output(
+ self,
+ _findings,
+ _bulk_compliance_frameworks,
+ _input_compliance_frameworks,
+ _output_options,
+ _generated_outputs,
+ ) -> None:
+ """Generate compliance CSV output for this provider's frameworks."""
+ raise NotImplementedError(
+ f"{self.__class__.__name__} has not implemented generate_compliance_output()"
+ )
+
+ def get_mutelist_finding_args(self) -> dict:
+ """Return extra kwargs for mutelist.is_finding_muted() besides 'finding'.
+
+ External providers must return a dict with the identity key their
+ Mutelist subclass expects, e.g. ``{"account_id": self.identity.account_id}``.
+ The ``finding`` kwarg is added automatically by the caller.
+ """
+ raise NotImplementedError(
+ f"{self.__class__.__name__} has not implemented get_mutelist_finding_args()"
+ )
+
+ def display_compliance_table(
+ self,
+ _findings: list,
+ _bulk_checks_metadata: dict,
+ _compliance_framework: str,
+ _output_filename: str,
+ _output_directory: str,
+ _compliance_overview: bool,
+ ) -> bool:
+ """Render a custom compliance table in the terminal.
+
+ External providers can override this to display a detailed
+ compliance table (e.g., per-section breakdown). Return True
+ if the table was rendered, False to fall back to the generic table.
+ """
+ raise NotImplementedError(
+ f"{self.__class__.__name__} has not implemented display_compliance_table()"
+ )
+
+ # Class-level flag: True for providers that delegate scanning to an external
+ # tool (e.g. Trivy, promptfoo) and bypass standard check/service loading and
+ # metadata validation. Subclasses override as `is_external_tool_provider = True`.
+ # Kept as a class attribute (not a property) so it can be read from the class
+ # without instantiation — the metadata validators in lib.check.models need to
+ # decide whether to relax validation before any provider instance exists.
+ is_external_tool_provider: bool = False
+
+ # --- End dynamic provider contract methods ---
+
@staticmethod
def get_excluded_regions_from_env() -> set:
"""Parse the PROWLER_AWS_DISALLOWED_REGIONS environment variable.
@@ -159,20 +261,74 @@ class Provider(ABC):
@staticmethod
def init_global_provider(arguments: Namespace) -> None:
try:
- provider_class_path = (
- f"{providers_path}.{arguments.provider}.{arguments.provider}_provider"
- )
- provider_class_name = f"{arguments.provider.capitalize()}Provider"
- provider_class = getattr(
- import_module(provider_class_path), provider_class_name
+ # Discriminate built-in vs external upfront via find_spec, so an
+ # ImportError from a transitive dependency missing inside a
+ # built-in's own import chain surfaces clearly instead of being
+ # silently re-routed to the entry-point path.
+ provider_class = None
+ if Provider.is_builtin(arguments.provider):
+ # Built-in wins on provider-name collision. Plug-ins are
+ # first-class extenders (they can register new provider
+ # names) but cannot override existing built-ins — a security
+ # tool prefers fail-loud predictability over silent
+ # overrides. Surface the override so the user knows their
+ # plug-in is being ignored and can rename it.
+ # Match by name only — never ep.load() a shadowing plug-in.
+ if any(
+ ep.name == arguments.provider
+ for ep in importlib.metadata.entry_points(group="prowler.providers")
+ ):
+ logger.warning(
+ f"Plug-in provider '{arguments.provider}' registered "
+ f"via entry points is being IGNORED — a built-in with "
+ f"the same name exists. To use your plug-in, register "
+ f"it under a different name."
+ )
+ provider_class_path = f"{providers_path}.{arguments.provider}.{arguments.provider}_provider"
+ provider_class_name = f"{arguments.provider.capitalize()}Provider"
+ try:
+ provider_class = getattr(
+ import_module(provider_class_path), provider_class_name
+ )
+ except ImportError as e:
+ logger.critical(
+ f"Failed to load built-in provider '{arguments.provider}'. "
+ f"Missing dependency: {e}. "
+ f"Ensure all required dependencies are installed."
+ )
+ logger.debug("Full traceback:", exc_info=True)
+ sys.exit(1)
+ except AttributeError:
+ # Module exists but doesn't define the expected class —
+ # treat as external and try entry points.
+ provider_class = Provider._load_ep_provider(arguments.provider)
+ else:
+ provider_class = Provider._load_ep_provider(arguments.provider)
+
+ if provider_class is None:
+ raise ImportError(
+ f"Provider '{arguments.provider}' not found as built-in or entry point"
+ )
+
+ # Kept for downstream forks that may extend the dispatch below
+ # with their own custom built-in branches and reference this name.
+ # The upstream chain dispatches by `arguments.provider` directly.
+ provider_class_name = (
+ f"{arguments.provider.capitalize()}Provider" # noqa: F841
)
fixer_config = load_and_validate_config_file(
arguments.provider, arguments.fixer_config
)
+ # Dispatch by exact provider name (equality, not substring) so
+ # external plug-ins whose names contain a built-in substring
+ # (e.g. `awsx`, `azure_gov`, `iac_v2`) cannot be silently routed
+ # to the wrong built-in branch. Anything that doesn't match a
+ # built-in falls through to the dynamic else and uses the
+ # contract's `from_cli_args`.
if not isinstance(Provider._global, provider_class):
- if "aws" in provider_class_name.lower():
+ if arguments.provider == "aws":
excluded_regions = (
set(arguments.excluded_region)
if getattr(arguments, "excluded_region", None)
@@ -196,7 +352,7 @@ class Provider(ABC):
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
- elif "azure" in provider_class_name.lower():
+ elif arguments.provider == "azure":
provider_class(
az_cli_auth=arguments.az_cli_auth,
sp_env_auth=arguments.sp_env_auth,
@@ -209,7 +365,7 @@ class Provider(ABC):
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
- elif "gcp" in provider_class_name.lower():
+ elif arguments.provider == "gcp":
provider_class(
retries_max_attempts=arguments.gcp_retries_max_attempts,
organization_id=arguments.organization_id,
@@ -223,7 +379,7 @@ class Provider(ABC):
fixer_config=fixer_config,
skip_api_check=arguments.skip_api_check,
)
- elif "kubernetes" in provider_class_name.lower():
+ elif arguments.provider == "kubernetes":
provider_class(
kubeconfig_file=arguments.kubeconfig_file,
context=arguments.context,
@@ -233,7 +389,7 @@ class Provider(ABC):
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
- elif "m365" in provider_class_name.lower():
+ elif arguments.provider == "m365":
provider_class(
region=arguments.region,
config_path=arguments.config_file,
@@ -247,7 +403,7 @@ class Provider(ABC):
init_modules=arguments.init_modules,
fixer_config=fixer_config,
)
- elif "nhn" in provider_class_name.lower():
+ elif arguments.provider == "nhn":
provider_class(
username=arguments.nhn_username,
password=arguments.nhn_password,
@@ -256,7 +412,7 @@ class Provider(ABC):
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
- elif "stackit" in provider_class_name.lower():
+ elif arguments.provider == "stackit":
provider_class(
project_id=arguments.stackit_project_id,
service_account_key_path=getattr(
@@ -275,7 +431,7 @@ class Provider(ABC):
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
- elif "github" in provider_class_name.lower():
+ elif arguments.provider == "github":
orgs = []
repos = []
@@ -307,13 +463,13 @@ class Provider(ABC):
exclude_workflows=getattr(arguments, "exclude_workflows", []),
fixer_config=fixer_config,
)
- elif "googleworkspace" in provider_class_name.lower():
+ elif arguments.provider == "googleworkspace":
provider_class(
config_path=arguments.config_file,
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
- elif "cloudflare" in provider_class_name.lower():
+ elif arguments.provider == "cloudflare":
provider_class(
filter_zones=arguments.region,
filter_accounts=arguments.account_id,
@@ -321,7 +477,7 @@ class Provider(ABC):
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
- elif "iac" in provider_class_name.lower():
+ elif arguments.provider == "iac":
provider_class(
scan_path=arguments.scan_path,
scan_repository_url=arguments.scan_repository_url,
@@ -334,13 +490,13 @@ class Provider(ABC):
oauth_app_token=arguments.oauth_app_token,
provider_uid=arguments.provider_uid,
)
- elif "llm" in provider_class_name.lower():
+ elif arguments.provider == "llm":
provider_class(
max_concurrency=arguments.max_concurrency,
config_path=arguments.config_file,
fixer_config=fixer_config,
)
- elif "image" in provider_class_name.lower():
+ elif arguments.provider == "image":
provider_class(
images=arguments.images,
image_list_file=arguments.image_list_file,
@@ -358,7 +514,7 @@ class Provider(ABC):
registry_insecure=arguments.registry_insecure,
registry_list_images=arguments.registry_list_images,
)
- elif "mongodbatlas" in provider_class_name.lower():
+ elif arguments.provider == "mongodbatlas":
provider_class(
atlas_public_key=arguments.atlas_public_key,
atlas_private_key=arguments.atlas_private_key,
@@ -367,7 +523,7 @@ class Provider(ABC):
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
- elif "oraclecloud" in provider_class_name.lower():
+ elif arguments.provider == "oraclecloud":
provider_class(
oci_config_file=arguments.oci_config_file,
profile=arguments.profile,
@@ -378,7 +534,7 @@ class Provider(ABC):
fixer_config=fixer_config,
use_instance_principal=arguments.use_instance_principal,
)
- elif "openstack" in provider_class_name.lower():
+ elif arguments.provider == "openstack":
provider_class(
clouds_yaml_file=getattr(arguments, "clouds_yaml_file", None),
clouds_yaml_content=getattr(
@@ -403,7 +559,7 @@ class Provider(ABC):
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
- elif "alibabacloud" in provider_class_name.lower():
+ elif arguments.provider == "alibabacloud":
provider_class(
role_arn=arguments.role_arn,
role_session_name=arguments.role_session_name,
@@ -415,14 +571,14 @@ class Provider(ABC):
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
- elif "vercel" in provider_class_name.lower():
+ elif arguments.provider == "vercel":
provider_class(
projects=getattr(arguments, "project", None),
config_path=arguments.config_file,
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
- elif "okta" in provider_class_name.lower():
+ elif arguments.provider == "okta":
provider_class(
okta_org_domain=getattr(arguments, "okta_org_domain", ""),
okta_client_id=getattr(arguments, "okta_client_id", ""),
@@ -435,7 +591,7 @@ class Provider(ABC):
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
- elif "scaleway" in provider_class_name.lower():
+ elif arguments.provider == "scaleway":
# Credentials are read from the SCW_ACCESS_KEY /
# SCW_SECRET_KEY env vars by the provider itself; there
# are no credential CLI flags to avoid leaking secrets.
@@ -447,6 +603,18 @@ class Provider(ABC):
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
+ else:
+ # Dynamic fallback: any external/custom provider.
+ # Honor the from_cli_args type hint (-> Provider): if the
+ # implementation returns an instance, wire it as the global
+ # provider here. Implementations that call
+ # set_global_provider(self) from __init__ return None and
+ # remain supported (the condition below is a no-op for them).
+ provider_instance = provider_class.from_cli_args(
+ arguments, fixer_config
+ )
+ if provider_instance is not None:
+ Provider.set_global_provider(provider_instance)
except TypeError as error:
logger.critical(
@@ -459,17 +627,102 @@ class Provider(ABC):
)
sys.exit(1)
+ # Cache for entry-point provider classes {name: class}
+ _ep_providers: dict = {}
+
@staticmethod
def get_available_providers() -> list[str]:
"""get_available_providers returns a list of the available providers"""
- providers = []
- # Dynamically import the package based on its string path
+ providers = set()
+ # Built-in providers from local package
prowler_providers = importlib.import_module(providers_path)
- # Iterate over all modules found in the prowler_providers package
for _, provider, ispkg in pkgutil.iter_modules(prowler_providers.__path__):
if provider != "common" and ispkg:
- providers.append(provider)
- return providers
+ providers.add(provider)
+ # External providers registered via entry points
+ for ep in importlib.metadata.entry_points(group="prowler.providers"):
+ providers.add(ep.name)
+ return sorted(providers)
+
+ @staticmethod
+ def is_tool_wrapper_provider(provider: str) -> bool:
+ """Return True if the provider delegates scanning to an external tool.
+
+ Delegates to `prowler.lib.check.tool_wrapper.is_tool_wrapper_provider`,
+ the leaf module that holds the actual logic. Kept on `Provider` as a
+ convenience entry point for callers that already import `Provider`.
+ """
+ from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider as _impl
+
+ return _impl(provider)
+
+ @staticmethod
+ def is_builtin(provider: str) -> bool:
+ """Return True if the provider's own package is importable as a built-in.
+
+ Delegates to `prowler.providers.common.builtin.is_builtin_provider`,
+ the leaf module that holds the actual check. Kept on `Provider` as a
+ convenience entry point for callers that already import `Provider`.
+ Call sites in `prowler.lib.check.*` should import from the leaf
+ directly to avoid the import cycle through this module.
+ """
+ from prowler.providers.common.builtin import is_builtin_provider as _impl
+
+ return _impl(provider)
+
+ @staticmethod
+ def _load_ep_provider(name: str):
+ """Load an external provider class from entry points, with cache.
+
+ Caches both hits and misses so repeated lookups for unknown names do
+ not re-iterate entry_points(). Symmetric with
+ tool_wrapper._ep_class_cache.
+ """
+ if name in Provider._ep_providers:
+ return Provider._ep_providers[name]
+ for ep in importlib.metadata.entry_points(group="prowler.providers"):
+ if ep.name == name:
+ try:
+ cls = ep.load()
+ Provider._ep_providers[name] = cls
+ return cls
+ except Exception as error:
+ logger.warning(
+ f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+ Provider._ep_providers[name] = None
+ return None
+
+ @staticmethod
+ def get_providers_help_text() -> dict:
+ """Returns a dict of {provider_name: cli_help_text} for all available providers."""
+ help_text = {}
+ for name in Provider.get_available_providers():
+ try:
+ # Try built-in first
+ module_path = f"{providers_path}.{name}.{name}_provider"
+ module = import_module(module_path)
+ cls = None
+ for attr_name in dir(module):
+ attr = getattr(module, attr_name)
+ if (
+ isinstance(attr, type)
+ and issubclass(attr, Provider)
+ and attr is not Provider
+ ):
+ cls = attr
+ break
+ help_text[name] = getattr(cls, "_cli_help_text", "") if cls else ""
+ except ImportError:
+ # External provider — load via entry point
+ cls = Provider._load_ep_provider(name)
+ help_text[name] = getattr(cls, "_cli_help_text", "") if cls else ""
+ except Exception as error:
+ logger.warning(
+ f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+ help_text[name] = ""
+ return help_text
@staticmethod
def update_provider_config(audit_config: dict, variable: str, value: str):
diff --git a/tests/config/config_test.py b/tests/config/config_test.py
index 1af6e32df2..2a7aecd330 100644
--- a/tests/config/config_test.py
+++ b/tests/config/config_test.py
@@ -471,6 +471,32 @@ class Test_Config:
all_frameworks = get_available_compliance_frameworks()
assert "csa_ccm_4.0" in all_frameworks
+ @mock.patch("prowler.config.config._get_ep_compliance_dirs")
+ def test_get_available_compliance_frameworks_dedupes_ep_collisions_with_builtins(
+ self, mock_dirs
+ ):
+ """Entry-point compliance frameworks that collide with a built-in
+ name must appear only once in the available frameworks list.
+ Built-in wins silently — same policy as the universal frameworks
+ loop and as Compliance.get_bulk."""
+ import json
+ import tempfile
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ # cis_2.0_aws ships as a built-in under prowler/compliance/aws/
+ json_path = os.path.join(tmpdir, "cis_2.0_aws.json")
+ with open(json_path, "w") as f:
+ json.dump({"Framework": "CIS", "Provider": "aws"}, f)
+
+ mock_dirs.return_value = {"aws": tmpdir}
+
+ frameworks = get_available_compliance_frameworks("aws")
+
+ assert frameworks.count("cis_2.0_aws") == 1, (
+ f"Expected cis_2.0_aws to appear exactly once, got "
+ f"{frameworks.count('cis_2.0_aws')} occurrences in: {frameworks}"
+ )
+
def test_load_and_validate_config_file_aws(self):
path = pathlib.Path(os.path.dirname(os.path.realpath(__file__)))
config_test_file = f"{path}/fixtures/config.yaml"
@@ -508,6 +534,32 @@ class Test_Config:
assert load_and_validate_config_file("azure", config_test_file) == {}
assert load_and_validate_config_file("kubernetes", config_test_file) == {}
+ def test_load_and_validate_config_file_namespaced_non_listed_provider(self):
+ path = pathlib.Path(os.path.dirname(os.path.realpath(__file__)))
+ config_test_file = f"{path}/fixtures/config_namespaced_external.yaml"
+ # github is a built-in not in the legacy hardcoded list; namespaced format must unwrap it.
+ assert load_and_validate_config_file("github", config_test_file) == {
+ "token": "abc",
+ "org": "prowler-cloud",
+ }
+
+ def test_load_and_validate_config_file_namespaced_external_provider(self):
+ path = pathlib.Path(os.path.dirname(os.path.realpath(__file__)))
+ config_test_file = f"{path}/fixtures/config_namespaced_external.yaml"
+ # External plug-in provider: namespaced format must unwrap its block.
+ assert load_and_validate_config_file("custom_plugin", config_test_file) == {
+ "setting": "value",
+ "nested": {"key": 42},
+ }
+
+ def test_load_and_validate_config_file_namespaced_missing_provider(self):
+ path = pathlib.Path(os.path.dirname(os.path.realpath(__file__)))
+ config_test_file = f"{path}/fixtures/config_namespaced_external.yaml"
+ # Provider with no section in a namespaced file must return empty config,
+ # not the full file (prevents cross-provider config leakage).
+ assert load_and_validate_config_file("aws", config_test_file) == {}
+ assert load_and_validate_config_file("gcp", config_test_file) == {}
+
def test_load_and_validate_config_file_invalid_config_file_path(self, caplog):
provider = "aws"
config_file_path = "invalid/path/to/fixer_config.yaml"
diff --git a/tests/config/fixtures/config_namespaced_external.yaml b/tests/config/fixtures/config_namespaced_external.yaml
new file mode 100644
index 0000000000..ec9f75c698
--- /dev/null
+++ b/tests/config/fixtures/config_namespaced_external.yaml
@@ -0,0 +1,8 @@
+# Namespaced config covering a non-listed built-in (github) and an external plugin.
+github:
+ token: abc
+ org: prowler-cloud
+custom_plugin:
+ setting: value
+ nested:
+ key: 42
diff --git a/tests/lib/check/compliance_check_test.py b/tests/lib/check/compliance_check_test.py
index 73f93e3b38..631c134302 100644
--- a/tests/lib/check/compliance_check_test.py
+++ b/tests/lib/check/compliance_check_test.py
@@ -540,7 +540,9 @@ class TestCompliance:
):
object = mock.Mock()
object.path = "/path/to/compliance"
- object.name = "framework1_aws"
+ # list_compliance_modules yields dotted module names; get_bulk matches
+ # the last segment exactly against the provider.
+ object.name = "prowler.compliance.aws"
mock_list_modules.return_value = [object]
mock_listdir.return_value = ["framework1_aws.json"]
diff --git a/tests/lib/check/models_test.py b/tests/lib/check/models_test.py
index 71fd1f718b..f17e359441 100644
--- a/tests/lib/check/models_test.py
+++ b/tests/lib/check/models_test.py
@@ -95,6 +95,38 @@ class TestCheckMetada:
"/path/to/accessanalyzer_enabled/accessanalyzer_enabled.metadata.json"
)
+ @mock.patch("prowler.lib.check.models.logger")
+ @mock.patch("prowler.lib.check.models.load_check_metadata")
+ @mock.patch("prowler.lib.check.models.recover_checks_from_provider")
+ def test_get_bulk_builtin_wins_on_check_id_collision(
+ self, mock_recover_checks, mock_load_metadata, mock_logger
+ ):
+ """Regression guard: when an entry-point plug-in re-registers a
+ built-in CheckID, the BUILT-IN metadata wins (first-write-wins) and
+ the plug-in is IGNORED. The override is surfaced via a warning so
+ the user knows their plug-in duplicate is being skipped and can
+ rename it. Matches the precedence in `_resolve_check_module`. See
+ PR #10700 review (HugoPBrito)."""
+ # Built-in first, plug-in last (matches recover_checks_from_provider order)
+ mock_recover_checks.return_value = [
+ ("accessanalyzer_enabled", "/builtin/accessanalyzer_enabled"),
+ ("accessanalyzer_enabled", "/plugin/accessanalyzer_enabled"),
+ ]
+
+ builtin_metadata = mock.MagicMock(CheckID="accessanalyzer_enabled")
+ plugin_metadata = mock.MagicMock(CheckID="accessanalyzer_enabled")
+ mock_load_metadata.side_effect = [builtin_metadata, plugin_metadata]
+
+ result = CheckMetadata.get_bulk(provider="aws")
+
+ # Built-in wins (first-write-wins on CheckID), plug-in is ignored
+ assert result["accessanalyzer_enabled"] is builtin_metadata
+ # Override is surfaced via warning naming the plug-in metadata file
+ mock_logger.warning.assert_called_once()
+ warning_msg = mock_logger.warning.call_args.args[0]
+ assert "accessanalyzer_enabled" in warning_msg
+ assert "/plugin/accessanalyzer_enabled" in warning_msg
+
@mock.patch("prowler.lib.check.models.load_check_metadata")
@mock.patch("prowler.lib.check.models.recover_checks_from_provider")
def test_list(self, mock_recover_checks, mock_load_metadata):
diff --git a/tests/lib/check/tool_wrapper_test.py b/tests/lib/check/tool_wrapper_test.py
new file mode 100644
index 0000000000..5f4f0c7f88
--- /dev/null
+++ b/tests/lib/check/tool_wrapper_test.py
@@ -0,0 +1,124 @@
+"""Unit tests for prowler.lib.check.tool_wrapper.
+
+Covers the leaf helper directly (Provider.is_tool_wrapper_provider delegates
+to it). Tests the frozenset fast path, the entry-point fallback for external
+plug-ins, the broken-plug-in path, the no-match path, and the module-level
+cache.
+"""
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+
+@pytest.fixture(autouse=True)
+def _clear_ep_class_cache():
+ """Reset the leaf module's cache between tests so they stay independent."""
+ from prowler.lib.check import tool_wrapper
+
+ tool_wrapper._ep_class_cache.clear()
+ yield
+ tool_wrapper._ep_class_cache.clear()
+
+
+def _make_entry_point(name, cls):
+ """Create a mock entry point whose `load()` returns `cls`."""
+ ep = MagicMock()
+ ep.name = name
+ ep.load.return_value = cls
+ return ep
+
+
+class TestIsToolWrapperProvider:
+ """is_tool_wrapper_provider: frozenset + entry-point fallback."""
+
+ @pytest.mark.parametrize("name", ["iac", "llm", "image"])
+ def test_returns_true_for_builtin_tool_wrappers(self, name):
+ from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider
+
+ assert is_tool_wrapper_provider(name) is True
+
+ @pytest.mark.parametrize("name", ["aws", "azure", "gcp", "github", "kubernetes"])
+ def test_returns_false_for_regular_builtins(self, name):
+ from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider
+
+ assert is_tool_wrapper_provider(name) is False
+
+ @patch("prowler.lib.check.tool_wrapper.importlib.metadata.entry_points")
+ def test_returns_true_for_external_plugin_with_flag(self, mock_eps):
+ from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider
+
+ cls = MagicMock(is_external_tool_provider=True)
+ mock_eps.return_value = [_make_entry_point("custom_wrapper", cls)]
+
+ assert is_tool_wrapper_provider("custom_wrapper") is True
+
+ @patch("prowler.lib.check.tool_wrapper.importlib.metadata.entry_points")
+ def test_returns_false_for_external_plugin_without_flag(self, mock_eps):
+ from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider
+
+ cls = MagicMock(is_external_tool_provider=False)
+ mock_eps.return_value = [_make_entry_point("vanilla_external", cls)]
+
+ assert is_tool_wrapper_provider("vanilla_external") is False
+
+ @patch("prowler.lib.check.tool_wrapper.importlib.metadata.entry_points")
+ def test_returns_false_for_unknown_provider(self, mock_eps):
+ from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider
+
+ mock_eps.return_value = []
+
+ assert is_tool_wrapper_provider("does-not-exist") is False
+
+ @patch("prowler.lib.check.tool_wrapper.importlib.metadata.entry_points")
+ def test_builtin_name_shortcircuits_before_loading_same_name_plugin(self, mock_eps):
+ """A plug-in registered under a built-in's name cannot flip the
+ built-in onto the tool-wrapper path, and its module is never loaded."""
+ from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider
+
+ malicious = _make_entry_point("aws", MagicMock(is_external_tool_provider=True))
+ mock_eps.return_value = [malicious]
+
+ # `aws` is a built-in, so classification short-circuits to False...
+ assert is_tool_wrapper_provider("aws") is False
+ # ...and the shadowing plug-in's code is never executed via ep.load().
+ malicious.load.assert_not_called()
+
+
+class TestLoadEpClass:
+ """_load_ep_class: cache, broken plug-ins, no-match."""
+
+ @patch("prowler.lib.check.tool_wrapper.importlib.metadata.entry_points")
+ def test_caches_result_across_calls(self, mock_eps):
+ from prowler.lib.check.tool_wrapper import _load_ep_class
+
+ cls = MagicMock(is_external_tool_provider=True)
+ mock_eps.return_value = [_make_entry_point("cached_one", cls)]
+
+ first = _load_ep_class("cached_one")
+ second = _load_ep_class("cached_one")
+
+ assert first is cls
+ assert second is cls
+ # entry_points consulted only on the first call
+ assert mock_eps.call_count == 1
+
+ @patch("prowler.lib.check.tool_wrapper.importlib.metadata.entry_points")
+ def test_returns_none_for_broken_plugin(self, mock_eps):
+ from prowler.lib.check.tool_wrapper import _load_ep_class
+
+ broken_ep = MagicMock()
+ broken_ep.name = "broken"
+ broken_ep.load.side_effect = ImportError("plug-in is broken")
+ mock_eps.return_value = [broken_ep]
+
+ assert _load_ep_class("broken") is None
+
+ @patch("prowler.lib.check.tool_wrapper.importlib.metadata.entry_points")
+ def test_returns_none_when_no_entry_point_matches(self, mock_eps):
+ from prowler.lib.check.tool_wrapper import _load_ep_class
+
+ cls = MagicMock()
+ mock_eps.return_value = [_make_entry_point("other_provider", cls)]
+
+ assert _load_ep_class("missing_provider") is None
diff --git a/tests/lib/outputs/compliance/generic/generic_aws_test.py b/tests/lib/outputs/compliance/generic/generic_aws_test.py
index 335d1860ea..335a9ad17d 100644
--- a/tests/lib/outputs/compliance/generic/generic_aws_test.py
+++ b/tests/lib/outputs/compliance/generic/generic_aws_test.py
@@ -9,6 +9,7 @@ from prowler.lib.check.compliance_models import (
Compliance,
Compliance_Requirement,
Generic_Compliance_Requirement_Attribute,
+ ISO27001_2013_Requirement_Attribute,
)
from prowler.lib.outputs.compliance.generic.generic import GenericCompliance
from prowler.lib.outputs.compliance.generic.models import GenericComplianceModel
@@ -198,3 +199,47 @@ class TestAWSGenericCompliance:
), f"Expected 1 row driven by framework JSON, got {len(rows)}"
assert rows[0].Requirements_Id == "req_in_framework"
assert rows[0].CheckId == "service_check_in_framework"
+
+ def test_transform_tolerates_framework_specific_attribute_schema(self):
+ """GenericCompliance is the documented last-resort renderer, so it must not
+ crash on a framework whose attribute schema lacks the universal fields
+ (Section, SubSection, SubGroup, Service, Type, Comment). ISO27001 declares
+ none of them; missing fields must render as None instead of raising
+ AttributeError and dropping the whole CSV."""
+ framework_name = "ISO27001-2013-External"
+ compliance = Compliance(
+ Framework=framework_name,
+ Name=framework_name,
+ Provider="external",
+ Version="",
+ Description="Framework shipping a provider-specific attribute schema",
+ Requirements=[
+ Compliance_Requirement(
+ Id="A.5.1.1",
+ Description="Policies for information security",
+ Attributes=[
+ ISO27001_2013_Requirement_Attribute(
+ Category="Information security policies",
+ Objetive_ID="A.5.1",
+ Objetive_Name="Management direction",
+ Check_Summary="Policy is defined",
+ )
+ ],
+ Checks=["service_test_check_id"],
+ )
+ ],
+ )
+
+ findings = [generate_finding_output(check_id="service_test_check_id")]
+
+ output = GenericCompliance(findings, compliance)
+
+ rows = [row for row in output.data if row.Status != "MANUAL"]
+ assert len(rows) == 1
+ assert rows[0].Requirements_Id == "A.5.1.1"
+ assert rows[0].Requirements_Attributes_Section is None
+ assert rows[0].Requirements_Attributes_SubSection is None
+ assert rows[0].Requirements_Attributes_SubGroup is None
+ assert rows[0].Requirements_Attributes_Service is None
+ assert rows[0].Requirements_Attributes_Type is None
+ assert rows[0].Requirements_Attributes_Comment is None
diff --git a/tests/lib/scan/scan_test.py b/tests/lib/scan/scan_test.py
index b0fe82b7b2..8037668b10 100644
--- a/tests/lib/scan/scan_test.py
+++ b/tests/lib/scan/scan_test.py
@@ -51,7 +51,7 @@ def mock_provider():
def mock_execute():
with mock.patch("prowler.lib.scan.scan.execute", autospec=True) as mock_exec:
findings = [finding]
- mock_exec.side_effect = lambda *args, **kwargs: findings
+ mock_exec.side_effect = lambda *_args, **_kwargs: findings
yield mock_exec
@@ -264,10 +264,10 @@ class TestScan:
@patch("prowler.lib.scan.scan.update_checks_metadata_with_compliance")
@patch("prowler.lib.scan.scan.Compliance.get_bulk")
@patch("prowler.lib.scan.scan.CheckMetadata.get_bulk")
- @patch("prowler.lib.scan.scan.import_check")
+ @patch("prowler.lib.scan.scan._resolve_check_module")
def test_scan(
self,
- mock_import_check,
+ mock_resolve_check_module,
mock_get_bulk,
mock_compliance_get_bulk,
mock_update_checks_metadata,
@@ -285,7 +285,7 @@ class TestScan:
mock_check_instance.CheckTitle = "Check if IAM Access Analyzer is enabled"
mock_check_instance.Categories = []
- mock_import_check.return_value = MagicMock(
+ mock_resolve_check_module.return_value = MagicMock(
accessanalyzer_enabled=mock_check_class
)
diff --git a/tests/providers/external/__init__.py b/tests/providers/external/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/providers/external/test_dynamic_provider_loading.py b/tests/providers/external/test_dynamic_provider_loading.py
new file mode 100644
index 0000000000..e60720e32e
--- /dev/null
+++ b/tests/providers/external/test_dynamic_provider_loading.py
@@ -0,0 +1,2054 @@
+"""
+Tests for dynamic provider loading via entry points.
+
+Covers: provider discovery, check discovery, check execution,
+CLI argument registration, compliance frameworks, parser integration,
+and all dispatch fallbacks for external providers.
+"""
+
+from argparse import Namespace
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from prowler.providers.common.provider import Provider
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _make_entry_point(name, value, group):
+ """Create a mock entry point."""
+ ep = MagicMock()
+ ep.name = name
+ ep.value = value
+ ep.group = group
+ return ep
+
+
+class FakeExternalProvider(Provider):
+ """Minimal Provider subclass for testing the dynamic contract."""
+
+ _type = "fakeexternal"
+ _cli_help_text = "Fake External Provider"
+
+ def __init__(self):
+ Provider.set_global_provider(self)
+
+ @property
+ def type(self):
+ return self._type
+
+ @property
+ def identity(self):
+ return MagicMock(host_id="fake-host-1")
+
+ @property
+ def session(self):
+ return MagicMock()
+
+ @property
+ def audit_config(self):
+ return {}
+
+ def setup_session(self):
+ return MagicMock()
+
+ def print_credentials(self):
+ pass
+
+ @classmethod
+ def from_cli_args(cls, _arguments, _fixer_config):
+ cls()
+
+ def get_output_options(self, _arguments, _bulk_checks_metadata):
+ return MagicMock(output_directory="/tmp", output_filename="fake")
+
+ def get_stdout_detail(self, finding):
+ return "fake-detail"
+
+ def get_finding_sort_key(self):
+ return "region"
+
+ def get_summary_entity(self):
+ return ("Fake Host", "fake-host-1")
+
+ def get_finding_output_data(self, check_output):
+ return {
+ "auth_method": "fake",
+ "account_uid": "fake-account",
+ "account_name": "fake",
+ "resource_name": "fake-resource",
+ "resource_uid": "fake-uid",
+ "region": "local",
+ }
+
+ def get_mutelist_finding_args(self):
+ return {"host_id": self.identity.host_id}
+
+ def display_compliance_table(
+ self,
+ findings,
+ _bulk_checks_metadata,
+ _compliance_framework,
+ _output_filename,
+ output_directory, # referenced via name elsewhere in tests
+ _compliance_overview,
+ ):
+ return True
+
+ def get_html_assessment_summary(self):
+ return "Fake Assessment
"
+
+ def generate_compliance_output(
+ self,
+ findings,
+ _bulk_compliance_frameworks,
+ _input_compliance_frameworks,
+ output_options,
+ generated_outputs,
+ ):
+ generated_outputs["compliance"].append("fake-compliance-output")
+
+ @classmethod
+ def init_parser(cls, parser_instance):
+ pass
+
+
+class FakeToolWrapperProvider(Provider):
+ """External provider that declares itself a tool wrapper."""
+
+ _type = "faketoolwrapper"
+ is_external_tool_provider = True
+
+ @property
+ def type(self):
+ return self._type
+
+ @property
+ def identity(self):
+ return MagicMock()
+
+ @property
+ def session(self):
+ return MagicMock()
+
+ @property
+ def audit_config(self):
+ return {}
+
+ def setup_session(self):
+ return MagicMock()
+
+ def print_credentials(self):
+ pass
+
+
+class FakePureContractProvider(Provider):
+ """External provider that honors the from_cli_args type hint literally:
+ returns an instance without calling Provider.set_global_provider() from
+ __init__. Used to verify the call site wires the returned instance."""
+
+ _type = "fakepure"
+
+ @property
+ def type(self):
+ return self._type
+
+ @property
+ def identity(self):
+ return MagicMock(host_id="fake-pure-1")
+
+ @property
+ def session(self):
+ return MagicMock()
+
+ @property
+ def audit_config(self):
+ return {}
+
+ def setup_session(self):
+ return MagicMock()
+
+ def print_credentials(self):
+ pass
+
+ @classmethod
+ def from_cli_args(cls, _arguments, _fixer_config):
+ # Literal contract: return the instance, no side-effect in __init__.
+ return cls()
+
+
+class FakeProviderNoHelpText(Provider):
+ """Provider without _cli_help_text."""
+
+ _type = "nohelptext"
+
+ @property
+ def type(self):
+ return self._type
+
+ @property
+ def identity(self):
+ return MagicMock()
+
+ @property
+ def session(self):
+ return MagicMock()
+
+ @property
+ def audit_config(self):
+ return {}
+
+ def setup_session(self):
+ return MagicMock()
+
+ def print_credentials(self):
+ pass
+
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture(autouse=True)
+def _clear_ep_cache():
+ """Clear the entry point provider cache before each test."""
+ Provider._ep_providers = {}
+ yield
+ Provider._ep_providers = {}
+
+
+@pytest.fixture
+def fake_provider():
+ """Create and register a FakeExternalProvider."""
+ p = FakeExternalProvider()
+ yield p
+ Provider._global = None
+
+
+# ===========================================================================
+# 1. Provider Discovery & Loading
+# ===========================================================================
+
+
+class TestProviderDiscovery:
+ """Tests 1-7: get_available_providers, _load_ep_provider, get_providers_help_text."""
+
+ @patch("prowler.providers.common.provider.importlib.metadata.entry_points")
+ def test_get_available_providers_merges_builtin_and_entrypoint(self, mock_ep):
+ """Test 1: get_available_providers returns both built-in and entry point providers."""
+ mock_ep.return_value = [
+ _make_entry_point("fakeexternal", "pkg.provider:Cls", "prowler.providers"),
+ ]
+
+ providers = Provider.get_available_providers()
+
+ # Built-in providers from actual prowler package
+ assert "aws" in providers
+ # External provider from entry point
+ assert "fakeexternal" in providers
+ assert "common" not in providers
+
+ @patch("prowler.providers.common.provider.importlib.metadata.entry_points")
+ def test_get_available_providers_deduplicates(self, mock_ep):
+ """Test 2: Same provider name in built-in and entry point appears once."""
+ # "aws" exists as built-in AND as entry point
+ mock_ep.return_value = [
+ _make_entry_point("aws", "pkg.provider:Cls", "prowler.providers"),
+ ]
+
+ providers = Provider.get_available_providers()
+
+ assert providers.count("aws") == 1
+
+ @patch("prowler.providers.common.provider.importlib.metadata.entry_points")
+ def test_load_ep_provider_loads_class(self, mock_ep):
+ """Test 3: _load_ep_provider loads the class from entry point."""
+ mock_ep.return_value = [
+ _make_entry_point(
+ "fakeexternal", "pkg:FakeExternalProvider", "prowler.providers"
+ ),
+ ]
+ mock_ep.return_value[0].load.return_value = FakeExternalProvider
+
+ cls = Provider._load_ep_provider("fakeexternal")
+
+ assert cls is FakeExternalProvider
+
+ @patch("prowler.providers.common.provider.importlib.metadata.entry_points")
+ def test_load_ep_provider_returns_none_for_unknown(self, mock_ep):
+ """Test 4: _load_ep_provider returns None for unknown provider."""
+ mock_ep.return_value = []
+
+ cls = Provider._load_ep_provider("nonexistent")
+
+ assert cls is None
+
+ @patch("prowler.providers.common.provider.importlib.metadata.entry_points")
+ def test_load_ep_provider_caches_result(self, mock_ep):
+ """Test 5: _load_ep_provider caches the loaded class."""
+ mock_ep.return_value = [
+ _make_entry_point("fakeexternal", "pkg:Cls", "prowler.providers"),
+ ]
+ mock_ep.return_value[0].load.return_value = FakeExternalProvider
+
+ cls1 = Provider._load_ep_provider("fakeexternal")
+ cls2 = Provider._load_ep_provider("fakeexternal")
+
+ assert cls1 is cls2
+ # load() should only be called once due to caching
+ mock_ep.return_value[0].load.assert_called_once()
+
+ @patch("prowler.providers.common.provider.importlib.metadata.entry_points")
+ def test_load_ep_provider_caches_misses(self, mock_ep):
+ """A miss (unknown provider) must also be cached so repeated lookups
+ do not re-iterate entry_points(). Aligns with tool_wrapper._ep_class_cache,
+ which already caches None on miss."""
+ mock_ep.return_value = []
+
+ first = Provider._load_ep_provider("nonexistent")
+ second = Provider._load_ep_provider("nonexistent")
+
+ assert first is None
+ assert second is None
+ # entry_points() should only be called once across the two lookups
+ mock_ep.assert_called_once()
+
+ @patch("prowler.providers.common.provider.Provider._load_ep_provider")
+ @patch("prowler.providers.common.provider.Provider.get_available_providers")
+ def test_get_providers_help_text_reads_cli_help_text(
+ self, mock_providers, mock_load
+ ):
+ """Test 6: get_providers_help_text reads _cli_help_text from entry point provider."""
+ mock_providers.return_value = ["fakeexternal"]
+ mock_load.return_value = FakeExternalProvider
+
+ help_text = Provider.get_providers_help_text()
+
+ assert help_text["fakeexternal"] == "Fake External Provider"
+
+ @patch("prowler.providers.common.provider.Provider._load_ep_provider")
+ @patch("prowler.providers.common.provider.Provider.get_available_providers")
+ def test_get_providers_help_text_empty_without_cli_help_text(
+ self, mock_providers, mock_load
+ ):
+ """Test 7: get_providers_help_text returns empty string without _cli_help_text."""
+ mock_providers.return_value = ["nohelptext"]
+ mock_load.return_value = FakeProviderNoHelpText
+
+ help_text = Provider.get_providers_help_text()
+
+ assert help_text["nohelptext"] == ""
+
+
+class TestIsToolWrapperProvider:
+ """Tests for Provider.is_tool_wrapper_provider — the helper that combines the
+ built-in EXTERNAL_TOOL_PROVIDERS frozenset with the is_external_tool_provider
+ class attribute of entry-point providers."""
+
+ def test_returns_true_for_builtin_tool_wrappers(self):
+ # iac/llm/image are in the EXTERNAL_TOOL_PROVIDERS frozenset (fast path)
+ assert Provider.is_tool_wrapper_provider("iac") is True
+ assert Provider.is_tool_wrapper_provider("llm") is True
+ assert Provider.is_tool_wrapper_provider("image") is True
+
+ def test_returns_false_for_regular_builtin_providers(self):
+ # Regular built-ins must not be classified as tool wrappers
+ assert Provider.is_tool_wrapper_provider("aws") is False
+ assert Provider.is_tool_wrapper_provider("gcp") is False
+ assert Provider.is_tool_wrapper_provider("github") is False
+
+ @patch("prowler.providers.common.provider.importlib.metadata.entry_points")
+ def test_returns_true_for_external_provider_declaring_flag(self, mock_ep):
+ # External plugin explicitly declares is_external_tool_provider = True
+ mock_ep.return_value = [
+ _make_entry_point("faketoolwrapper", "pkg:Cls", "prowler.providers"),
+ ]
+ mock_ep.return_value[0].load.return_value = FakeToolWrapperProvider
+
+ assert Provider.is_tool_wrapper_provider("faketoolwrapper") is True
+
+ @patch("prowler.providers.common.provider.importlib.metadata.entry_points")
+ def test_returns_false_for_external_provider_without_flag(self, mock_ep):
+ # External plugin without the flag (default False) is treated as regular
+ mock_ep.return_value = [
+ _make_entry_point("fakeexternal", "pkg:Cls", "prowler.providers"),
+ ]
+ mock_ep.return_value[0].load.return_value = FakeExternalProvider
+
+ assert Provider.is_tool_wrapper_provider("fakeexternal") is False
+
+ @patch("prowler.providers.common.provider.importlib.metadata.entry_points")
+ def test_returns_false_for_unknown_provider(self, mock_ep):
+ mock_ep.return_value = []
+
+ assert Provider.is_tool_wrapper_provider("does-not-exist") is False
+
+ @patch("prowler.providers.common.provider.importlib.metadata.entry_points")
+ def test_returns_false_for_none_provider(self, mock_ep):
+ # Pydantic validators may pass None when values.get("Provider") is missing
+ mock_ep.return_value = []
+
+ assert Provider.is_tool_wrapper_provider(None) is False
+
+
+class TestIsBuiltinProvider:
+ """Tests for Provider.is_builtin — the helper that discriminates built-in
+ providers from external ones before attempting the import, so transitive
+ dependency failures in built-ins don't get silently re-routed to entry points."""
+
+ def test_returns_true_for_builtin_provider(self):
+ assert Provider.is_builtin("aws") is True
+ assert Provider.is_builtin("github") is True
+
+ def test_returns_false_for_unknown_provider(self):
+ assert Provider.is_builtin("nonexistent_xyz") is False
+
+ @patch("prowler.providers.common.provider.importlib.util.find_spec")
+ def test_returns_false_when_find_spec_raises(self, mock_find_spec):
+ # Certain namespace package edge cases raise ValueError/ImportError —
+ # helper should swallow and return False rather than propagate.
+ mock_find_spec.side_effect = ValueError("namespace package edge case")
+
+ assert Provider.is_builtin("some_provider") is False
+
+
+class TestInitProvidersParserBuiltinDependencyFailure:
+ """Tests the critical behavior fix: when a built-in provider's arguments
+ module exists but its imports fail (e.g. boto3 not installed), we must
+ fail loudly with a clear message — not silently fall through to entry
+ points as if the provider were external."""
+
+ @patch("prowler.providers.common.arguments.Provider.is_builtin")
+ @patch("prowler.providers.common.arguments.import_module")
+ def test_builtin_with_missing_transitive_dep_fails_loudly(
+ self, mock_import, mock_is_builtin
+ ):
+ from prowler.providers.common.arguments import init_providers_parser
+
+ mock_is_builtin.return_value = True
+ mock_import.side_effect = ImportError("No module named 'boto3'")
+
+ parser = MagicMock()
+ parser._providers = ["aws"]
+
+ with (
+ patch(
+ "prowler.providers.common.arguments.Provider.get_available_providers",
+ return_value=["aws"],
+ ),
+ pytest.raises(SystemExit),
+ ):
+ init_providers_parser(parser)
+
+ @patch("prowler.providers.common.arguments.Provider.is_builtin")
+ @patch("prowler.providers.common.arguments.Provider._load_ep_provider")
+ def test_external_provider_does_not_touch_builtin_path(
+ self, mock_load_ep, mock_is_builtin
+ ):
+ from prowler.providers.common.arguments import init_providers_parser
+
+ mock_is_builtin.return_value = False
+ ext_cls = MagicMock()
+ ext_cls.init_parser = MagicMock()
+ mock_load_ep.return_value = ext_cls
+
+ parser = MagicMock()
+
+ with patch(
+ "prowler.providers.common.arguments.Provider.get_available_providers",
+ return_value=["fakeexternal"],
+ ):
+ init_providers_parser(parser)
+
+ ext_cls.init_parser.assert_called_once_with(parser)
+
+
+class TestInitGlobalProviderBuiltinDependencyFailure:
+ """Same contract as TestInitProvidersParserBuiltinDependencyFailure but
+ for the provider class import path in init_global_provider."""
+
+ @patch("prowler.providers.common.provider.Provider.is_builtin")
+ @patch("prowler.providers.common.provider.import_module")
+ def test_builtin_with_missing_transitive_dep_fails_loudly(
+ self, mock_import, mock_is_builtin
+ ):
+ mock_is_builtin.return_value = True
+ mock_import.side_effect = ImportError("No module named 'boto3'")
+
+ args = Namespace(
+ provider="aws",
+ fixer_config="config.yaml",
+ config_file="config.yaml",
+ )
+
+ Provider._global = None
+ with pytest.raises(SystemExit):
+ Provider.init_global_provider(args)
+ Provider._global = None
+
+ @patch("prowler.providers.common.provider.importlib.metadata.entry_points")
+ def test_load_ep_provider_handles_load_exception(self, mock_ep):
+ """_load_ep_provider returns None when ep.load() raises."""
+ ep = _make_entry_point("broken", "pkg:Cls", "prowler.providers")
+ ep.load.side_effect = Exception("Import failed")
+ mock_ep.return_value = [ep]
+
+ cls = Provider._load_ep_provider("broken")
+
+ assert cls is None
+
+ @patch("prowler.providers.common.provider.import_module")
+ @patch("prowler.providers.common.provider.Provider.get_available_providers")
+ def test_get_providers_help_text_builtin_path(self, mock_providers, mock_import):
+ """get_providers_help_text reads _cli_help_text from a built-in provider module."""
+ import types
+
+ mock_providers.return_value = ["fakebuiltin"]
+
+ mock_cls = type(
+ "FakeBuiltinProvider", (Provider,), {"_cli_help_text": "Built-in Help"}
+ )
+ mock_module = types.ModuleType("fake_module")
+ mock_module.FakeBuiltinProvider = mock_cls
+ mock_import.return_value = mock_module
+
+ help_text = Provider.get_providers_help_text()
+
+ assert help_text["fakebuiltin"] == "Built-in Help"
+
+ @patch("prowler.providers.common.provider.import_module")
+ @patch("prowler.providers.common.provider.Provider.get_available_providers")
+ def test_get_providers_help_text_generic_exception(
+ self, mock_providers, mock_import
+ ):
+ """get_providers_help_text handles generic exceptions with empty string."""
+ mock_providers.return_value = ["broken"]
+ mock_import.side_effect = RuntimeError("Unexpected error")
+
+ help_text = Provider.get_providers_help_text()
+
+ assert help_text["broken"] == ""
+
+
+# ===========================================================================
+# 2. Provider Initialization
+# ===========================================================================
+
+
+class TestProviderInitialization:
+ """Tests 8-9: init_global_provider fallback to entry point."""
+
+ @patch("prowler.providers.common.provider.load_and_validate_config_file")
+ @patch("prowler.providers.common.provider.Provider._load_ep_provider")
+ @patch("prowler.providers.common.provider.import_module")
+ def test_init_global_provider_fallback_to_entry_point(
+ self, mock_import, mock_load_ep, mock_config
+ ):
+ """Test 8: init_global_provider falls back to entry point when built-in fails."""
+ mock_import.side_effect = ImportError("No built-in")
+ mock_load_ep.return_value = FakeExternalProvider
+ mock_config.return_value = {}
+
+ args = Namespace(
+ provider="fakeexternal",
+ fixer_config="config.yaml",
+ config_file="config.yaml",
+ )
+
+ Provider._global = None
+ Provider.init_global_provider(args)
+
+ assert isinstance(Provider._global, FakeExternalProvider)
+ Provider._global = None
+
+ @patch("prowler.providers.common.provider.load_and_validate_config_file")
+ @patch("prowler.providers.common.provider.Provider._load_ep_provider")
+ @patch("prowler.providers.common.provider.import_module")
+ def test_init_global_provider_exits_for_unknown_provider(
+ self, mock_import, mock_load_ep, mock_config
+ ):
+ """Test 9: init_global_provider exits when provider not found anywhere."""
+ mock_import.side_effect = ImportError("No built-in")
+ mock_load_ep.return_value = None
+ mock_config.return_value = {}
+
+ args = Namespace(
+ provider="nonexistent",
+ fixer_config="config.yaml",
+ config_file="config.yaml",
+ )
+
+ with pytest.raises(SystemExit):
+ Provider.init_global_provider(args)
+
+ @patch("prowler.providers.common.provider.load_and_validate_config_file")
+ @patch("prowler.providers.common.provider.Provider._load_ep_provider")
+ @patch("prowler.providers.common.provider.import_module")
+ def test_init_global_provider_wires_instance_returned_by_from_cli_args(
+ self, mock_import, mock_load_ep, mock_config
+ ):
+ """A provider that implements from_cli_args as a pure function (returns
+ the instance without calling set_global_provider from __init__) is
+ correctly wired as the global provider by init_global_provider."""
+ mock_import.side_effect = ImportError("No built-in")
+ mock_load_ep.return_value = FakePureContractProvider
+ mock_config.return_value = {}
+
+ args = Namespace(
+ provider="fakepure",
+ fixer_config="config.yaml",
+ config_file="config.yaml",
+ )
+
+ Provider._global = None
+ Provider.init_global_provider(args)
+
+ assert isinstance(Provider._global, FakePureContractProvider)
+ Provider._global = None
+
+ @pytest.mark.parametrize(
+ "plugin_name",
+ [
+ "awsx",
+ "aws_lite",
+ "azure_gov",
+ "gcp_org",
+ "github_enterprise",
+ "iac_v2",
+ ],
+ )
+ @patch("prowler.providers.common.provider.load_and_validate_config_file")
+ @patch("prowler.providers.common.provider.Provider._load_ep_provider")
+ @patch("prowler.providers.common.provider.import_module")
+ def test_init_global_provider_external_with_builtin_substring_uses_from_cli_args(
+ self, mock_import, mock_load_ep, mock_config, plugin_name
+ ):
+ """Regression guard for the substring footgun in the dispatch chain.
+
+ An external plug-in whose name contains a built-in substring
+ (e.g. `awsx`, `aws_lite`, `azure_gov`, `gcp_org`, `github_enterprise`,
+ `iac_v2`) MUST be routed to the dynamic else and instantiated via
+ `from_cli_args` — not silently captured by the built-in branch whose
+ name happens to be a substring of the plug-in name. See PR #10700
+ review.
+ """
+ mock_import.side_effect = ImportError("No built-in")
+ mock_load_ep.return_value = FakeExternalProvider
+ mock_config.return_value = {}
+
+ # Namespace deliberately omits the kwargs of any built-in branch
+ # (no `aws_retries_max_attempts`, `az_cli_auth`, `personal_access_token`,
+ # etc.). If equality dispatch is broken and the plug-in is misrouted to
+ # a built-in branch, attribute access will raise and the global never
+ # gets wired.
+ args = Namespace(
+ provider=plugin_name,
+ fixer_config="config.yaml",
+ config_file="config.yaml",
+ )
+
+ Provider._global = None
+ Provider.init_global_provider(args)
+
+ assert isinstance(Provider._global, FakeExternalProvider)
+ Provider._global = None
+
+ @patch("prowler.providers.common.provider.logger")
+ @patch("prowler.providers.common.provider.load_and_validate_config_file")
+ @patch("prowler.providers.common.provider.importlib.metadata.entry_points")
+ @patch("prowler.providers.common.provider.import_module")
+ @patch("prowler.providers.common.provider.Provider.is_builtin")
+ def test_init_global_provider_warns_when_plugin_shadowed_by_builtin(
+ self, mock_is_builtin, mock_import, mock_entry_points, mock_config, mock_logger
+ ):
+ """Regression guard: when a plug-in registers a provider name that
+ collides with a built-in, the BUILT-IN wins and a warning is emitted
+ naming the shadowed plug-in. Shadow detection matches by entry-point
+ name only — the plug-in is never `ep.load()`-ed just to warn, so its
+ module code cannot run during a built-in run. See PR #10700 review
+ (HugoPBrito, Alan-TheGentleman).
+ """
+ # Simulate a built-in `aws` that exists, AND a plug-in registered
+ # under the same `aws` name via entry points.
+ mock_is_builtin.return_value = True
+ shadow_ep = MagicMock()
+ shadow_ep.name = "aws" # plug-in shadowing the built-in name
+ mock_entry_points.return_value = [shadow_ep]
+ mock_import.return_value = MagicMock(
+ AwsProvider=MagicMock(side_effect=lambda **_kw: None)
+ )
+ mock_config.return_value = {}
+
+ args = Namespace(
+ provider="aws",
+ fixer_config="config.yaml",
+ config_file="config.yaml",
+ aws_retries_max_attempts=3,
+ role=None,
+ session_duration=None,
+ external_id=None,
+ role_session_name=None,
+ mfa=None,
+ profile=None,
+ region=None,
+ excluded_region=None,
+ organizations_role=None,
+ scan_unused_services=False,
+ resource_tag=None,
+ resource_arn=None,
+ mutelist_file=None,
+ )
+
+ Provider._global = None
+ try:
+ Provider.init_global_provider(args)
+ except BaseException:
+ # The AwsProvider mock is fake and the dispatch may sys.exit on
+ # the simulated failure; we only care about the warning emitted
+ # before the dispatch happens.
+ pass
+ finally:
+ Provider._global = None
+
+ # Warning was emitted naming the shadowed plug-in
+ warning_msgs = [
+ call.args[0]
+ for call in mock_logger.warning.call_args_list
+ if call.args and "Plug-in provider 'aws'" in call.args[0]
+ ]
+ assert warning_msgs, "expected a warning about the shadowed plug-in 'aws'"
+ assert "IGNORED" in warning_msgs[0]
+ # Shadow detected by name only — plug-in code never executed to warn
+ shadow_ep.load.assert_not_called()
+
+
+# ===========================================================================
+# 3. Check Discovery
+# ===========================================================================
+
+
+class TestCheckDiscovery:
+ """Tests 10-14: _recover_ep_checks, recover_checks_from_provider."""
+
+ @patch("prowler.lib.check.utils.importlib.metadata.entry_points")
+ @patch("prowler.lib.check.utils.importlib.util.find_spec")
+ def test_recover_ep_checks_discovers_checks(self, mock_spec, mock_ep):
+ """Test 10: _recover_ep_checks discovers checks from entry points."""
+ from prowler.lib.check.utils import _recover_ep_checks
+
+ mock_ep.return_value = [
+ _make_entry_point("my_check", "pkg.checks.my_check", "prowler.checks.fake"),
+ ]
+ mock_spec_obj = MagicMock()
+ mock_spec_obj.origin = "/path/to/pkg/checks/my_check.py"
+ mock_spec.return_value = mock_spec_obj
+
+ checks = _recover_ep_checks("fake")
+
+ assert len(checks) == 1
+ assert checks[0][0] == "my_check"
+ assert checks[0][1] == "/path/to/pkg/checks"
+
+ @patch("prowler.lib.check.utils.importlib.metadata.entry_points")
+ def test_recover_ep_checks_empty_without_entry_points(self, mock_ep):
+ """Test 11: _recover_ep_checks returns empty list with no entry points."""
+ from prowler.lib.check.utils import _recover_ep_checks
+
+ mock_ep.return_value = []
+
+ checks = _recover_ep_checks("fake")
+
+ assert checks == []
+
+ @patch("prowler.lib.check.utils.importlib.metadata.entry_points")
+ @patch("prowler.lib.check.utils.importlib.util.find_spec")
+ def test_recover_ep_checks_handles_broken_entry_point(self, mock_spec, mock_ep):
+ """Test 12: _recover_ep_checks handles failed entry points gracefully."""
+ from prowler.lib.check.utils import _recover_ep_checks
+
+ mock_ep.return_value = [
+ _make_entry_point("broken_check", "pkg.broken", "prowler.checks.fake"),
+ ]
+ mock_spec.side_effect = Exception("Module not found")
+
+ checks = _recover_ep_checks("fake")
+
+ assert checks == []
+
+ @patch("prowler.lib.check.utils._recover_ep_checks")
+ @patch("prowler.lib.check.utils.importlib.util.find_spec")
+ def test_recover_checks_handles_external_provider_without_services(
+ self, mock_find_spec, mock_ep_checks
+ ):
+ """Test 13: recover_checks_from_provider doesn't crash for external providers.
+
+ With find_spec returning None (built-in package doesn't exist), discovery
+ falls through to entry points cleanly — no ModuleNotFoundError catch
+ needed.
+ """
+ from prowler.lib.check.utils import recover_checks_from_provider
+
+ mock_find_spec.return_value = None # not a built-in
+ mock_ep_checks.return_value = [("ext_check", "/path/to/check")]
+
+ checks = recover_checks_from_provider("fakeexternal")
+
+ assert len(checks) == 1
+ assert checks[0][0] == "ext_check"
+
+ @patch("prowler.lib.check.utils._recover_ep_checks")
+ @patch("prowler.lib.check.utils.list_modules")
+ @patch("prowler.lib.check.utils.importlib.util.find_spec")
+ def test_recover_checks_combines_builtin_and_entry_points(
+ self, mock_find_spec, mock_list_modules, mock_ep_checks
+ ):
+ """Test 14: recover_checks_from_provider combines built-in and entry point checks."""
+ from prowler.lib.check.utils import recover_checks_from_provider
+
+ mock_find_spec.return_value = MagicMock() # built-in package exists
+
+ # Simulate a built-in module
+ builtin_module = MagicMock()
+ builtin_module.name = "prowler.providers.aws.services.ec2.check_a.check_a"
+ builtin_module.module_finder.path = "/builtin/path"
+ mock_list_modules.return_value = [builtin_module]
+
+ mock_ep_checks.return_value = [("check_b", "/external/path")]
+
+ checks = recover_checks_from_provider("aws")
+
+ check_names = [c[0] for c in checks]
+ assert "check_a" in check_names
+ assert "check_b" in check_names
+
+ @patch("prowler.lib.check.utils.importlib.metadata.entry_points")
+ @patch("prowler.lib.check.utils.importlib.util.find_spec")
+ def test_recover_ep_checks_filters_by_service(self, mock_spec, mock_ep):
+ """Service filter keeps only entry points whose dotted path includes
+ `.services.{service}.` — mirroring the built-in package filter."""
+ from prowler.lib.check.utils import _recover_ep_checks
+
+ mock_ep.return_value = [
+ _make_entry_point(
+ "container_has_no_root_user",
+ "prowler_artifacts_dockerdesktop.services.container.container_has_no_root_user.container_has_no_root_user",
+ "prowler.checks.dockerdesktop",
+ ),
+ _make_entry_point(
+ "image_is_signed",
+ "prowler_artifacts_dockerdesktop.services.image.image_is_signed.image_is_signed",
+ "prowler.checks.dockerdesktop",
+ ),
+ ]
+ mock_spec_obj = MagicMock()
+ mock_spec_obj.origin = "/some/path/check.py"
+ mock_spec.return_value = mock_spec_obj
+
+ checks = _recover_ep_checks("dockerdesktop", service="container")
+
+ assert len(checks) == 1
+ assert checks[0][0] == "container_has_no_root_user"
+
+ @patch("prowler.lib.check.utils.importlib.metadata.entry_points")
+ @patch("prowler.lib.check.utils.importlib.util.find_spec")
+ def test_recover_ep_checks_without_service_returns_all(self, mock_spec, mock_ep):
+ """Without a service filter, all entry points for the provider are returned."""
+ from prowler.lib.check.utils import _recover_ep_checks
+
+ mock_ep.return_value = [
+ _make_entry_point(
+ "container_has_no_root_user",
+ "prowler_artifacts_dockerdesktop.services.container.container_has_no_root_user.container_has_no_root_user",
+ "prowler.checks.dockerdesktop",
+ ),
+ _make_entry_point(
+ "image_is_signed",
+ "prowler_artifacts_dockerdesktop.services.image.image_is_signed.image_is_signed",
+ "prowler.checks.dockerdesktop",
+ ),
+ ]
+ mock_spec_obj = MagicMock()
+ mock_spec_obj.origin = "/some/path/check.py"
+ mock_spec.return_value = mock_spec_obj
+
+ checks = _recover_ep_checks("dockerdesktop")
+
+ assert len(checks) == 2
+
+ @patch("prowler.lib.check.utils._recover_ep_checks")
+ @patch("prowler.lib.check.utils.importlib.util.find_spec")
+ def test_recover_checks_external_provider_with_service(
+ self, mock_find_spec, mock_ep_checks
+ ):
+ """External provider with --service: built-in package doesn't exist,
+ but entry points are still consulted and return the requested service's
+ checks. No premature sys.exit."""
+ from prowler.lib.check.utils import recover_checks_from_provider
+
+ mock_find_spec.return_value = None # not a built-in
+ mock_ep_checks.return_value = [("container_check", "/ext/path")]
+
+ checks = recover_checks_from_provider("dockerdesktop", service="container")
+
+ assert len(checks) == 1
+ assert checks[0][0] == "container_check"
+ mock_ep_checks.assert_called_once_with("dockerdesktop", "container")
+
+ @patch("prowler.lib.check.utils._recover_ep_checks")
+ @patch("prowler.lib.check.utils.importlib.util.find_spec")
+ def test_recover_checks_unknown_service_fails_cleanly(
+ self, mock_find_spec, mock_ep_checks
+ ):
+ """A typo or unknown service (not in built-ins nor in entry points)
+ fails with a clear error message, not a silent empty result."""
+ from prowler.lib.check.utils import recover_checks_from_provider
+
+ mock_find_spec.return_value = None
+ mock_ep_checks.return_value = []
+
+ with pytest.raises(SystemExit):
+ recover_checks_from_provider("aws", service="typo_service")
+
+ @patch("prowler.lib.check.utils._recover_ep_checks")
+ @patch("prowler.lib.check.utils.importlib.util.find_spec")
+ def test_recover_checks_builtin_with_new_external_service(
+ self, mock_find_spec, mock_ep_checks
+ ):
+ """Built-in provider with a new service added via entry points:
+ the built-in package for that specific service doesn't exist (find_spec
+ returns None), but entry points pick it up. The gate `if not service:`
+ that previously skipped entry points when --service was passed is removed."""
+ from prowler.lib.check.utils import recover_checks_from_provider
+
+ mock_find_spec.return_value = None # built-in for new_aws_service doesn't exist
+ mock_ep_checks.return_value = [("new_check", "/ext/path")]
+
+ checks = recover_checks_from_provider("aws", service="new_aws_service")
+
+ assert len(checks) == 1
+ assert checks[0][0] == "new_check"
+ mock_ep_checks.assert_called_once_with("aws", "new_aws_service")
+
+ @patch("prowler.lib.check.utils._recover_ep_checks")
+ @patch("prowler.lib.check.utils.list_modules")
+ @patch("prowler.lib.check.utils.importlib.util.find_spec")
+ def test_recover_checks_surfaces_error_when_builtin_service_import_fails(
+ self, mock_find_spec, mock_list_modules, mock_ep_checks
+ ):
+ """Regression guard: when a built-in service's package exists but one
+ of its modules fails to import (e.g. a broken transitive dependency),
+ the error must surface via the global exception handler — not be
+ silently swallowed and replaced by an entry-point plug-in that happens
+ to share a name. See PR #10700 review (HugoPBrito)."""
+ from prowler.lib.check.utils import recover_checks_from_provider
+
+ mock_find_spec.return_value = MagicMock() # built-in service exists
+ mock_list_modules.side_effect = ImportError("missing transitive dep: foo")
+
+ # Even if a plug-in registers checks for the same service, it must NOT
+ # silently take over — the import error wins.
+ mock_ep_checks.return_value = [("evil_check", "/evil/path")]
+
+ with pytest.raises(SystemExit):
+ recover_checks_from_provider("aws", service="ec2")
+
+
+# ===========================================================================
+# 4. Check Execution
+# ===========================================================================
+
+
+class TestCheckExecution:
+ """Tests 15-17: _resolve_check_module."""
+
+ @patch("prowler.lib.check.check.importlib.util.find_spec")
+ @patch("prowler.lib.check.check.import_check")
+ def test_resolve_check_module_builtin_first(self, mock_import, mock_find_spec):
+ """Test 15: _resolve_check_module resolves built-in checks first."""
+ from prowler.lib.check.check import _resolve_check_module
+
+ mock_module = MagicMock()
+ mock_import.return_value = mock_module
+ mock_find_spec.return_value = MagicMock() # built-in package exists
+
+ result = _resolve_check_module("aws", "ec2", "my_check")
+
+ assert result is mock_module
+ mock_import.assert_called_once_with(
+ "prowler.providers.aws.services.ec2.my_check.my_check"
+ )
+
+ @patch("prowler.lib.check.check.importlib.util.find_spec")
+ @patch("prowler.lib.check.check.import_check")
+ def test_resolve_check_module_fallback_to_entry_point(
+ self, mock_import_check, mock_find_spec
+ ):
+ """Test 16: _resolve_check_module falls back to entry point when built-in is absent."""
+ from prowler.lib.check.check import _resolve_check_module
+
+ mock_find_spec.return_value = None # built-in does not exist
+
+ mock_ext_module = MagicMock()
+ ep = _make_entry_point(
+ "my_check", "ext_pkg.checks.my_check", "prowler.checks.fake"
+ )
+
+ with (
+ patch("importlib.metadata.entry_points", return_value=[ep]),
+ patch("importlib.import_module", return_value=mock_ext_module) as mock_imp,
+ ):
+ result = _resolve_check_module("fake", "svc", "my_check")
+
+ assert result is mock_ext_module
+ mock_imp.assert_called_with("ext_pkg.checks.my_check")
+ mock_import_check.assert_not_called()
+
+ @patch("prowler.lib.check.check.importlib.util.find_spec")
+ @patch("prowler.lib.check.check.import_check")
+ def test_resolve_check_module_builtin_wins_over_entry_point(
+ self, mock_import_check, mock_find_spec
+ ):
+ """Regression guard: when both a built-in and an entry-point check
+ exist with the same CheckID, the BUILT-IN wins. Plug-ins extend
+ Prowler with new checks but cannot silently override existing
+ built-ins — a security tool prefers fail-loud predictability over
+ permissive overrides. CheckMetadata.get_bulk applies the same
+ precedence (first-write-wins) and emits a warning. See PR #10700
+ review (HugoPBrito)."""
+ from prowler.lib.check.check import _resolve_check_module
+
+ mock_find_spec.return_value = MagicMock() # built-in exists
+ builtin_module = MagicMock()
+ mock_import_check.return_value = builtin_module
+
+ # Plug-in registers same CheckID — must NOT take precedence
+ ep = _make_entry_point(
+ "ec2_instance_public_ip",
+ "plug_pkg.checks.ec2_instance_public_ip",
+ "prowler.checks.aws",
+ )
+
+ with (
+ patch("importlib.metadata.entry_points", return_value=[ep]),
+ patch("importlib.import_module") as mock_imp,
+ ):
+ result = _resolve_check_module("aws", "ec2", "ec2_instance_public_ip")
+
+ assert result is builtin_module
+ mock_import_check.assert_called_once_with(
+ "prowler.providers.aws.services.ec2.ec2_instance_public_ip.ec2_instance_public_ip"
+ )
+ # Plug-in must NOT be loaded when a built-in with the same CheckID exists
+ mock_imp.assert_not_called()
+
+ @patch("prowler.lib.check.check.importlib.metadata.entry_points")
+ @patch("prowler.lib.check.check.importlib.util.find_spec")
+ def test_resolve_check_module_raises_when_not_found(self, mock_find_spec, mock_ep):
+ """Test 17: _resolve_check_module raises ModuleNotFoundError when both fail."""
+ from prowler.lib.check.check import _resolve_check_module
+
+ mock_find_spec.return_value = None
+ mock_ep.return_value = []
+
+ with pytest.raises(ModuleNotFoundError, match="not found"):
+ _resolve_check_module("fake", "svc", "nonexistent_check")
+
+ @patch("prowler.lib.check.check.importlib.util.find_spec")
+ @patch("prowler.lib.check.check.import_check")
+ def test_resolve_check_module_surfaces_error_when_builtin_import_fails(
+ self, mock_import_check, mock_find_spec
+ ):
+ """Regression guard: when no plug-in entry-point overrides the
+ check, a built-in whose module exists but fails to import (e.g.
+ broken transitive dependency) MUST surface the real error instead
+ of being silently treated as 'not found'. See PR #10700 review
+ (HugoPBrito)."""
+ from prowler.lib.check.check import _resolve_check_module
+
+ mock_find_spec.return_value = MagicMock() # built-in module exists
+ mock_import_check.side_effect = ImportError("missing transitive dep: foo")
+
+ # No plug-in override — the built-in's import failure must propagate
+ with patch("importlib.metadata.entry_points", return_value=[]):
+ with pytest.raises(ImportError, match="missing transitive dep"):
+ _resolve_check_module("aws", "ec2", "ec2_instance_public_ip")
+
+
+# ===========================================================================
+# 5. CLI Arguments
+# ===========================================================================
+
+
+class TestCLIArguments:
+ """Tests 18-19: init_providers_parser fallback."""
+
+ @patch("prowler.providers.common.arguments.Provider._load_ep_provider")
+ @patch("prowler.providers.common.arguments.Provider.get_available_providers")
+ @patch("prowler.providers.common.arguments.import_module")
+ def test_init_providers_parser_fallback_to_init_parser(
+ self, mock_import, mock_providers, mock_load_ep
+ ):
+ """Test 18: init_providers_parser falls back to cls.init_parser for external providers."""
+ from prowler.providers.common.arguments import init_providers_parser
+
+ mock_providers.return_value = ["fakeexternal"]
+ mock_import.side_effect = ImportError("No built-in arguments module")
+ mock_load_ep.return_value = FakeExternalProvider
+
+ parser_instance = MagicMock()
+
+ # Should not raise
+ init_providers_parser(parser_instance)
+
+ @patch("prowler.providers.common.arguments.Provider._load_ep_provider")
+ @patch("prowler.providers.common.arguments.Provider.get_available_providers")
+ @patch("prowler.providers.common.arguments.import_module")
+ def test_init_providers_parser_no_crash_without_init_parser(
+ self, mock_import, mock_providers, mock_load_ep
+ ):
+ """Test 19: init_providers_parser doesn't crash if provider has no init_parser."""
+ from prowler.providers.common.arguments import init_providers_parser
+
+ mock_providers.return_value = ["nohelptext"]
+ mock_import.side_effect = ImportError("No built-in")
+ # FakeProviderNoHelpText has no init_parser
+ mock_load_ep.return_value = FakeProviderNoHelpText
+
+ parser_instance = MagicMock()
+
+ # Should not raise
+ init_providers_parser(parser_instance)
+
+ @patch("prowler.providers.common.arguments.Provider._load_ep_provider")
+ @patch("prowler.providers.common.arguments.Provider.get_available_providers")
+ @patch("prowler.providers.common.arguments.import_module")
+ def test_init_providers_parser_handles_init_parser_exception(
+ self, mock_import, mock_providers, mock_load_ep
+ ):
+ """init_providers_parser handles exception when init_parser raises."""
+ from prowler.providers.common.arguments import init_providers_parser
+
+ mock_providers.return_value = ["fakeexternal"]
+ mock_import.side_effect = ImportError("No built-in")
+
+ broken_cls = MagicMock()
+ broken_cls.init_parser.side_effect = RuntimeError("Parser init failed")
+ mock_load_ep.return_value = broken_cls
+
+ parser_instance = MagicMock()
+
+ # Should not raise
+ init_providers_parser(parser_instance)
+
+
+# ===========================================================================
+# 6. Compliance
+# ===========================================================================
+
+
+class TestCompliance:
+ """Tests 20-23: compliance discovery and loading."""
+
+ @patch("prowler.config.config.importlib.metadata.entry_points")
+ def test_get_ep_compliance_dirs_discovers_dirs(self, mock_ep):
+ """Test 20: _get_ep_compliance_dirs discovers compliance directories."""
+ from prowler.config.config import _get_ep_compliance_dirs
+
+ mock_module = MagicMock()
+ mock_module.__path__ = ["/path/to/compliance"]
+ ep = _make_entry_point("fakeexternal", "pkg.compliance", "prowler.compliance")
+ ep.load.return_value = mock_module
+ mock_ep.return_value = [ep]
+
+ dirs = _get_ep_compliance_dirs()
+
+ assert dirs["fakeexternal"] == "/path/to/compliance"
+
+ @patch("prowler.config.config.importlib.metadata.entry_points")
+ def test_get_ep_compliance_dirs_file_fallback(self, mock_ep):
+ """_get_ep_compliance_dirs uses __file__ when module has no __path__."""
+ from prowler.config.config import _get_ep_compliance_dirs
+
+ mock_module = MagicMock(spec=[])
+ mock_module.__file__ = "/path/to/compliance/__init__.py"
+ del mock_module.__path__
+ ep = _make_entry_point("ext", "pkg.compliance", "prowler.compliance")
+ ep.load.return_value = mock_module
+ mock_ep.return_value = [ep]
+
+ dirs = _get_ep_compliance_dirs()
+
+ assert dirs["ext"] == "/path/to/compliance"
+
+ @patch("prowler.config.config.importlib.metadata.entry_points")
+ def test_get_ep_compliance_dirs_handles_load_exception(self, mock_ep):
+ """_get_ep_compliance_dirs handles ep.load() exception gracefully."""
+ from prowler.config.config import _get_ep_compliance_dirs
+
+ ep = _make_entry_point("broken", "pkg.compliance", "prowler.compliance")
+ ep.load.side_effect = Exception("Load failed")
+ mock_ep.return_value = [ep]
+
+ dirs = _get_ep_compliance_dirs()
+
+ assert dirs == {}
+
+ @patch("prowler.config.config._get_ep_compliance_dirs")
+ def test_get_available_compliance_includes_external(self, mock_dirs):
+ """Test 21: get_available_compliance_frameworks includes external compliance."""
+ import json
+ import os
+ import tempfile
+
+ from prowler.config.config import get_available_compliance_frameworks
+
+ # Create a temp dir with a compliance JSON
+ with tempfile.TemporaryDirectory() as tmpdir:
+ json_path = os.path.join(tmpdir, "custom_1.0_ext.json")
+ with open(json_path, "w") as f:
+ json.dump({"Framework": "Custom", "Provider": "ext"}, f)
+
+ mock_dirs.return_value = {"ext": tmpdir}
+
+ frameworks = get_available_compliance_frameworks("ext")
+
+ assert "custom_1.0_ext" in frameworks
+
+ @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points")
+ @patch("prowler.lib.check.compliance_models.list_compliance_modules")
+ def test_compliance_get_bulk_loads_external(self, mock_list_modules, mock_ep):
+ """Test 22: Compliance.get_bulk loads external compliance JSON."""
+ import json
+ import os
+ import tempfile
+
+ from prowler.lib.check.compliance_models import Compliance
+
+ mock_list_modules.return_value = []
+
+ # Create a valid compliance JSON
+ with tempfile.TemporaryDirectory() as tmpdir:
+ json_data = {
+ "Framework": "Custom",
+ "Name": "Custom Framework",
+ "Version": "1.0",
+ "Provider": "fakeexternal",
+ "Description": "Test framework",
+ "Requirements": [],
+ }
+ json_path = os.path.join(tmpdir, "custom_1.0_fakeexternal.json")
+ with open(json_path, "w") as f:
+ json.dump(json_data, f)
+
+ mock_module = MagicMock()
+ mock_module.__path__ = [tmpdir]
+ ep = _make_entry_point(
+ "fakeexternal", "pkg.compliance", "prowler.compliance"
+ )
+ ep.load.return_value = mock_module
+ mock_ep.return_value = [ep]
+
+ bulk = Compliance.get_bulk("fakeexternal")
+
+ assert "custom_1.0_fakeexternal" in bulk
+ assert bulk["custom_1.0_fakeexternal"].Framework == "Custom"
+
+ @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points")
+ @patch("prowler.lib.check.compliance_models.list_compliance_modules")
+ def test_compliance_get_bulk_file_fallback(self, mock_list_modules, mock_ep):
+ """Compliance.get_bulk uses __file__ when external module has no __path__."""
+ import json
+ import os
+ import tempfile
+
+ from prowler.lib.check.compliance_models import Compliance
+
+ mock_list_modules.return_value = []
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ json_data = {
+ "Framework": "Custom",
+ "Name": "Custom File Fallback",
+ "Version": "1.0",
+ "Provider": "fakeexternal",
+ "Description": "Test",
+ "Requirements": [],
+ }
+ json_path = os.path.join(tmpdir, "custom_file_fakeexternal.json")
+ with open(json_path, "w") as f:
+ json.dump(json_data, f)
+
+ mock_module = MagicMock(spec=[])
+ mock_module.__file__ = os.path.join(tmpdir, "__init__.py")
+ del mock_module.__path__
+ ep = _make_entry_point(
+ "fakeexternal", "pkg.compliance", "prowler.compliance"
+ )
+ ep.load.return_value = mock_module
+ mock_ep.return_value = [ep]
+
+ bulk = Compliance.get_bulk("fakeexternal")
+
+ assert "custom_file_fakeexternal" in bulk
+
+ @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points")
+ @patch("prowler.lib.check.compliance_models.list_compliance_modules")
+ def test_compliance_get_bulk_handles_external_exception(
+ self, mock_list_modules, mock_ep
+ ):
+ """Compliance.get_bulk handles exception when loading external compliance."""
+ from prowler.lib.check.compliance_models import Compliance
+
+ mock_list_modules.return_value = []
+
+ ep = _make_entry_point("fakeexternal", "pkg.compliance", "prowler.compliance")
+ ep.load.side_effect = Exception("Load failed")
+ mock_ep.return_value = [ep]
+
+ bulk = Compliance.get_bulk("fakeexternal")
+
+ assert bulk == {}
+
+ @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points")
+ @patch("prowler.lib.check.compliance_models.list_compliance_modules")
+ def test_compliance_get_bulk_builtin_wins_on_duplicate(
+ self, mock_list_modules, mock_ep
+ ):
+ """Test 23: Compliance.get_bulk built-in wins on duplicate framework names."""
+ import json
+ import os
+ import tempfile
+
+ from prowler.lib.check.compliance_models import Compliance
+
+ mock_list_modules.return_value = []
+ mock_ep.return_value = []
+
+ # If both exist with same key, built-in (loaded first) should win
+ # Since we have no built-in modules mocked, just verify external loads
+ # The actual dedup logic: `if name not in bulk_compliance_frameworks`
+ with tempfile.TemporaryDirectory() as tmpdir:
+ json_data = {
+ "Framework": "CIS",
+ "Name": "CIS Test",
+ "Version": "1.0",
+ "Provider": "fakeexternal",
+ "Description": "Test",
+ "Requirements": [],
+ }
+ with open(os.path.join(tmpdir, "dup_framework.json"), "w") as f:
+ json.dump(json_data, f)
+
+ mock_module = MagicMock()
+ mock_module.__path__ = [tmpdir]
+ ep = _make_entry_point(
+ "fakeexternal", "pkg.compliance", "prowler.compliance"
+ )
+ ep.load.return_value = mock_module
+ mock_ep.return_value = [ep]
+
+ bulk = Compliance.get_bulk("fakeexternal")
+
+ assert "dup_framework" in bulk
+
+ @pytest.mark.parametrize(
+ "provider, framework_segments",
+ [
+ # `cloud` is a substring of THREE built-in modules at once.
+ ("cloud", ["alibabacloud", "cloudflare", "oraclecloud"]),
+ ("git", ["github"]),
+ ("work", ["googleworkspace"]),
+ ("open", ["openstack"]),
+ ],
+ )
+ @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points")
+ @patch("prowler.lib.check.compliance_models.list_compliance_modules")
+ def test_compliance_get_bulk_matches_provider_segment_exactly(
+ self, mock_list_modules, mock_ep, provider, framework_segments
+ ):
+ """Regression: a provider whose name is a substring of one or more
+ framework modules must NOT load them. The old `provider in name`
+ check captured overlapping built-ins (e.g. `cloud` matched
+ alibabacloud, cloudflare and oraclecloud). See PR #10700 review
+ (Alan-TheGentleman).
+ """
+ import json
+ import os
+ import tempfile
+
+ from prowler.lib.check.compliance_models import Compliance
+
+ mock_ep.return_value = []
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ # The substring path the old code would have read from.
+ os.mkdir(os.path.join(tmpdir, provider))
+ json_data = {
+ "Framework": "Custom",
+ "Name": f"Should not load for '{provider}'",
+ "Version": "1.0",
+ "Provider": provider,
+ "Description": "Test",
+ "Requirements": [],
+ }
+ with open(os.path.join(tmpdir, provider, "wrong.json"), "w") as f:
+ json.dump(json_data, f)
+
+ modules = []
+ for segment in framework_segments:
+ module = MagicMock()
+ module.name = f"prowler.compliance.{segment}"
+ module.module_finder.path = tmpdir
+ modules.append(module)
+ mock_list_modules.return_value = modules
+
+ bulk = Compliance.get_bulk(provider)
+
+ # Exact-segment match: the provider is not any of these modules.
+ assert "wrong" not in bulk
+ assert bulk == {}
+
+
+# ===========================================================================
+# 7. Parser
+# ===========================================================================
+
+
+class TestParser:
+ """Tests 24-27: parser dynamic discovery."""
+
+ @patch("prowler.lib.cli.parser.Provider.get_providers_help_text")
+ @patch("prowler.lib.cli.parser.Provider.get_available_providers")
+ def test_parser_discovers_new_providers(self, mock_providers, mock_help):
+ """Test 24: Parser discovers providers not in known_providers."""
+ from prowler.lib.cli.parser import ProwlerArgumentParser
+
+ mock_providers.return_value = [
+ "aws",
+ "azure",
+ "gcp",
+ "kubernetes",
+ "m365",
+ "github",
+ "googleworkspace",
+ "cloudflare",
+ "oraclecloud",
+ "openstack",
+ "alibabacloud",
+ "iac",
+ "llm",
+ "image",
+ "nhn",
+ "mongodbatlas",
+ "fakeexternal",
+ ]
+ mock_help.return_value = {"fakeexternal": "Fake External Provider"}
+
+ parser = ProwlerArgumentParser()
+
+ assert "fakeexternal" in parser.parser.format_usage()
+
+ @patch("prowler.lib.cli.parser.Provider.get_providers_help_text")
+ @patch("prowler.lib.cli.parser.Provider.get_available_providers")
+ def test_parser_appends_to_epilog_with_help_text(self, mock_providers, mock_help):
+ """Test 25: Parser appends new providers to epilog with _cli_help_text."""
+ from prowler.lib.cli.parser import ProwlerArgumentParser
+
+ mock_providers.return_value = [
+ "aws",
+ "azure",
+ "gcp",
+ "kubernetes",
+ "m365",
+ "github",
+ "googleworkspace",
+ "cloudflare",
+ "oraclecloud",
+ "openstack",
+ "alibabacloud",
+ "iac",
+ "llm",
+ "image",
+ "nhn",
+ "mongodbatlas",
+ "fakeexternal",
+ ]
+ mock_help.return_value = {"fakeexternal": "Fake External Provider"}
+
+ parser = ProwlerArgumentParser()
+ epilog = parser.parser.epilog
+
+ assert "fakeexternal" in epilog
+ assert "Fake External Provider" in epilog
+
+ @patch("prowler.lib.cli.parser.Provider.get_providers_help_text")
+ @patch("prowler.lib.cli.parser.Provider.get_available_providers")
+ def test_parser_skips_epilog_entry_without_help_text(
+ self, mock_providers, mock_help
+ ):
+ """Test 26: Parser doesn't add epilog entry if _cli_help_text is empty."""
+ from prowler.lib.cli.parser import ProwlerArgumentParser
+
+ mock_providers.return_value = [
+ "aws",
+ "azure",
+ "gcp",
+ "kubernetes",
+ "m365",
+ "github",
+ "googleworkspace",
+ "cloudflare",
+ "oraclecloud",
+ "openstack",
+ "alibabacloud",
+ "iac",
+ "llm",
+ "image",
+ "nhn",
+ "mongodbatlas",
+ "nohelptext",
+ ]
+ mock_help.return_value = {"nohelptext": ""}
+
+ parser = ProwlerArgumentParser()
+ epilog = parser.parser.epilog
+
+ # Should appear in usage/csv but NOT in the descriptive epilog listing
+ assert "nohelptext" in parser.parser.format_usage()
+ # No line with "nohelptext Something" in epilog
+ epilog_lines = [
+ line.strip() for line in epilog.splitlines() if "nohelptext" in line
+ ]
+ assert len(epilog_lines) == 0 or all(
+ "nohelptext" in line and line.strip() == "nohelptext" or "{" in line
+ for line in epilog_lines
+ )
+
+ @patch("prowler.lib.cli.parser.Provider.get_providers_help_text")
+ @patch("prowler.lib.cli.parser.Provider.get_available_providers")
+ def test_parser_does_not_duplicate_known_providers(self, mock_providers, mock_help):
+ """Test 27: Parser doesn't duplicate providers already in the known list."""
+ from prowler.lib.cli.parser import ProwlerArgumentParser
+
+ # No new providers
+ mock_providers.return_value = [
+ "aws",
+ "azure",
+ "gcp",
+ "kubernetes",
+ "m365",
+ "github",
+ "googleworkspace",
+ "cloudflare",
+ "oraclecloud",
+ "openstack",
+ "alibabacloud",
+ "iac",
+ "llm",
+ "image",
+ "nhn",
+ "mongodbatlas",
+ ]
+ mock_help.return_value = {}
+
+ parser = ProwlerArgumentParser()
+ usage = parser.parser.format_usage()
+
+ # aws should appear exactly once in usage
+ assert usage.count("aws") == 1
+
+
+# ===========================================================================
+# 8. Dispatch Fallbacks
+# ===========================================================================
+
+
+class TestDispatchFallbacks:
+ """Tests 28-34: all else clause fallbacks for external providers."""
+
+ def test_stdout_report_calls_get_stdout_detail(self, fake_provider):
+ """Test 28: stdout_report else clause calls provider.get_stdout_detail."""
+ from prowler.lib.outputs.outputs import stdout_report
+
+ finding = MagicMock()
+ finding.check_metadata.Provider = "fakeexternal"
+ finding.status = "FAIL"
+ finding.muted = False
+ finding.status_extended = "test"
+
+ with patch("builtins.print") as mock_print:
+ stdout_report(
+ finding, "\033[31m", True, ["FAIL"], False, provider=fake_provider
+ )
+
+ mock_print.assert_called_once()
+ printed = mock_print.call_args[0][0]
+ assert "fake-detail" in printed
+
+ def test_stdout_report_resolves_provider_when_none(self, fake_provider):
+ """stdout_report resolves provider via get_global_provider when not passed."""
+ from prowler.lib.outputs.outputs import stdout_report
+
+ finding = MagicMock()
+ finding.check_metadata.Provider = "fakeexternal"
+ finding.status = "FAIL"
+ finding.muted = False
+ finding.status_extended = "test"
+
+ with patch("builtins.print") as mock_print:
+ stdout_report(finding, "\033[31m", True, ["FAIL"], False)
+
+ mock_print.assert_called_once()
+ printed = mock_print.call_args[0][0]
+ assert "fake-detail" in printed
+
+ def test_report_propagates_provider_to_stdout_report(self):
+ """Regression guard: report() must pass its local `provider` through to
+ stdout_report so the dynamic else does not fall back to the global
+ singleton. With Provider._global cleared, the call chain still has to
+ work for an external provider — proving the argument is being used
+ instead of `Provider.get_global_provider()`. See PR #10700 review
+ (HugoPBrito)."""
+ from prowler.lib.outputs.outputs import report
+ from prowler.providers.common.provider import Provider
+
+ local_provider = FakeExternalProvider.__new__(FakeExternalProvider)
+
+ finding = MagicMock()
+ finding.status = "PASS"
+ finding.muted = False
+ finding.region = "x"
+ finding.check_metadata.Provider = "fakeexternal"
+ finding.status_extended = "test"
+
+ output_options = MagicMock()
+ output_options.verbose = True
+ output_options.status = []
+ output_options.fixer = False
+
+ # Clear the global singleton so any unintended fallback would crash.
+ previous_global = Provider._global
+ Provider._global = None
+ try:
+ with patch("builtins.print") as mock_print:
+ report([finding], local_provider, output_options)
+
+ # report() prints the finding line plus an empty separator when
+ # verbose is set; we only care that the finding was rendered using
+ # the local provider's `get_stdout_detail` ("fake-detail").
+ printed = "".join(
+ call.args[0] for call in mock_print.call_args_list if call.args
+ )
+ assert "fake-detail" in printed
+ finally:
+ Provider._global = previous_global
+
+ def test_report_sort_calls_get_finding_sort_key(self, fake_provider):
+ """Test 29: report else clause calls provider.get_finding_sort_key."""
+ from prowler.lib.outputs.outputs import report
+
+ finding1 = MagicMock()
+ finding1.status = "PASS"
+ finding1.muted = False
+ finding1.region = "b-region"
+ finding1.check_metadata.Provider = "fakeexternal"
+ finding1.status_extended = "test1"
+
+ finding2 = MagicMock()
+ finding2.status = "PASS"
+ finding2.muted = False
+ finding2.region = "a-region"
+ finding2.check_metadata.Provider = "fakeexternal"
+ finding2.status_extended = "test2"
+
+ output_options = MagicMock()
+ output_options.verbose = False
+ output_options.status = []
+
+ findings = [finding1, finding2]
+ report(findings, fake_provider, output_options)
+
+ # Should be sorted by region (get_finding_sort_key returns "region")
+ assert findings[0].region == "a-region"
+ assert findings[1].region == "b-region"
+
+ def test_display_summary_table_calls_get_summary_entity(self, fake_provider):
+ """Test 30: display_summary_table else clause calls provider.get_summary_entity."""
+ from prowler.lib.outputs.summary_table import display_summary_table
+
+ finding = MagicMock()
+ finding.status = "FAIL"
+ finding.muted = False
+ finding.check_metadata.ServiceName = "test_service"
+ finding.check_metadata.Provider = "fakeexternal"
+ finding.check_metadata.Severity = "high"
+
+ output_options = MagicMock()
+ output_options.output_directory = "/tmp"
+ output_options.output_filename = "test"
+ output_options.output_modes = []
+
+ with patch("builtins.print") as mock_print:
+ display_summary_table([finding], fake_provider, output_options)
+
+ printed_text = " ".join(str(c) for c in mock_print.call_args_list)
+ assert "Fake Host" in printed_text or "fake-host-1" in printed_text
+
+ def test_generate_output_calls_get_finding_output_data(self, fake_provider):
+ """Test 31: finding.generate_output else clause calls provider.get_finding_output_data."""
+ from prowler.lib.check.models import (
+ CheckMetadata,
+ Code,
+ Recommendation,
+ Remediation,
+ )
+ from prowler.lib.outputs.finding import Finding
+
+ metadata = CheckMetadata(
+ Provider="fakeexternal",
+ CheckID="test_check",
+ CheckTitle="Test check title",
+ CheckType=[],
+ ServiceName="test",
+ SubServiceName="",
+ ResourceIdTemplate="",
+ Severity="high",
+ ResourceType="Test",
+ ResourceGroup="",
+ Description="Test description",
+ Risk="Test risk",
+ RelatedUrl="",
+ Remediation=Remediation(
+ Code=Code(CLI="", NativeIaC="", Other="", Terraform=""),
+ Recommendation=Recommendation(
+ Text="Fix it", Url="https://hub.prowler.com/check/test_check"
+ ),
+ ),
+ Categories=[],
+ DependsOn=[],
+ RelatedTo=[],
+ Notes="",
+ )
+
+ check_output = MagicMock()
+ check_output.check_metadata = metadata
+ check_output.status = "FAIL"
+ check_output.status_extended = "test failed"
+ check_output.muted = False
+ check_output.resource = {}
+ check_output.resource_details = ""
+ check_output.resource_tags = {}
+ check_output.compliance = {}
+
+ output_options = MagicMock()
+ output_options.unix_timestamp = False
+ output_options.bulk_checks_metadata = {}
+
+ finding = Finding.generate_output(fake_provider, check_output, output_options)
+
+ assert finding.auth_method == "fake"
+ assert finding.account_uid == "fake-account"
+ assert finding.resource_name == "fake-resource"
+ assert finding.region == "local"
+
+ def test_output_options_calls_get_output_options(self, fake_provider):
+ """Test 32: __main__.py else clause calls provider.get_output_options."""
+ result = fake_provider.get_output_options(MagicMock(), {})
+
+ assert result is not None
+ assert hasattr(result, "output_directory")
+
+ def test_html_assessment_calls_get_html_assessment_summary(self, fake_provider):
+ """Test 33: html.py fallback calls provider.get_html_assessment_summary."""
+ from prowler.lib.outputs.html.html import HTML
+
+ result = HTML.get_assessment_summary(fake_provider)
+
+ assert "Fake Assessment
" in result
+
+ def test_compliance_output_calls_generate_compliance_output(self, fake_provider):
+ """Test 34: __main__.py else clause calls provider.generate_compliance_output."""
+ generated_outputs = {"compliance": []}
+
+ fake_provider.generate_compliance_output(
+ [],
+ {},
+ set(),
+ MagicMock(),
+ generated_outputs,
+ )
+
+ assert "fake-compliance-output" in generated_outputs["compliance"]
+
+
+# ===========================================================================
+# 9. Base Contract Defaults
+# ===========================================================================
+
+
+class TestBaseContractDefaults:
+ """Tests for Provider base class default implementations."""
+
+ def test_from_cli_args_raises_not_implemented(self):
+ """Base Provider.from_cli_args raises NotImplementedError."""
+ with pytest.raises(NotImplementedError):
+ FakeProviderNoHelpText.from_cli_args(MagicMock(), {})
+
+ def test_get_output_options_raises_not_implemented(self):
+ """Base Provider.get_output_options raises NotImplementedError; the
+ generic default is applied at the call site via default_output_options."""
+ provider = FakeProviderNoHelpText()
+ with pytest.raises(NotImplementedError):
+ provider.get_output_options(MagicMock(), {})
+
+ def test_default_output_options_builds_generic_default(self):
+ """default_output_options returns a generic ProviderOutputOptions so an
+ external provider without get_output_options still produces output
+ instead of aborting the run."""
+ from prowler.config.config import output_file_timestamp
+ from prowler.providers.common.models import (
+ ProviderOutputOptions,
+ default_output_options,
+ )
+
+ provider = FakeProviderNoHelpText()
+ arguments = Namespace(
+ status=None,
+ output_formats=None,
+ output_directory=None,
+ output_filename=None,
+ verbose=None,
+ only_logs=None,
+ unix_timestamp=None,
+ shodan=None,
+ fixer=None,
+ )
+
+ output_options = default_output_options(provider, arguments, {})
+
+ assert isinstance(output_options, ProviderOutputOptions)
+ assert (
+ output_options.output_filename
+ == f"prowler-output-{provider.type}-{output_file_timestamp}"
+ )
+
+ def test_default_output_options_honors_explicit_filename(self):
+ """A user-supplied output_filename is preserved by default_output_options."""
+ from prowler.providers.common.models import default_output_options
+
+ provider = FakeProviderNoHelpText()
+ arguments = Namespace(
+ status=None,
+ output_formats=None,
+ output_directory=None,
+ output_filename="custom-name",
+ verbose=None,
+ only_logs=None,
+ unix_timestamp=None,
+ shodan=None,
+ fixer=None,
+ )
+
+ output_options = default_output_options(provider, arguments, {})
+
+ assert output_options.output_filename == "custom-name"
+
+ def test_get_stdout_detail_raises_not_implemented(self):
+ """Base Provider.get_stdout_detail raises NotImplementedError."""
+ provider = FakeProviderNoHelpText()
+ with pytest.raises(NotImplementedError):
+ provider.get_stdout_detail(MagicMock())
+
+ def test_get_finding_sort_key_returns_none(self):
+ """Base Provider.get_finding_sort_key returns None."""
+ provider = FakeProviderNoHelpText()
+ assert provider.get_finding_sort_key() is None
+
+ def test_get_summary_entity_returns_type_and_account_default(self):
+ """Base Provider.get_summary_entity returns (type, account_id) so the
+ summary table is not silently dropped for providers that don't override
+ it."""
+ from types import SimpleNamespace
+ from unittest.mock import PropertyMock
+
+ provider = FakeProviderNoHelpText()
+ with patch.object(
+ type(provider),
+ "identity",
+ new_callable=PropertyMock,
+ return_value=SimpleNamespace(account_id="acc-123"),
+ ):
+ entity_type, audited_entities = provider.get_summary_entity()
+
+ assert entity_type == provider.type
+ assert audited_entities == "acc-123"
+
+ def test_get_summary_entity_defaults_account_to_empty_string(self):
+ """When the identity has no account_id, audited_entities falls back to ''."""
+ from types import SimpleNamespace
+ from unittest.mock import PropertyMock
+
+ provider = FakeProviderNoHelpText()
+ with patch.object(
+ type(provider),
+ "identity",
+ new_callable=PropertyMock,
+ return_value=SimpleNamespace(),
+ ):
+ entity_type, audited_entities = provider.get_summary_entity()
+
+ assert entity_type == provider.type
+ assert audited_entities == ""
+
+ def test_get_finding_output_data_raises_not_implemented(self):
+ """Base Provider.get_finding_output_data raises NotImplementedError."""
+ provider = FakeProviderNoHelpText()
+ with pytest.raises(NotImplementedError):
+ provider.get_finding_output_data(MagicMock())
+
+ def test_get_html_assessment_summary_raises_not_implemented(self):
+ """Base Provider.get_html_assessment_summary raises NotImplementedError."""
+ provider = FakeProviderNoHelpText()
+ with pytest.raises(NotImplementedError):
+ provider.get_html_assessment_summary()
+
+ def test_generate_compliance_output_raises_not_implemented(self):
+ """Base Provider.generate_compliance_output raises NotImplementedError."""
+ provider = FakeProviderNoHelpText()
+ with pytest.raises(NotImplementedError):
+ provider.generate_compliance_output([], {}, set(), MagicMock(), {})
+
+ def test_get_mutelist_finding_args_raises_not_implemented(self):
+ """Base Provider.get_mutelist_finding_args raises NotImplementedError."""
+ provider = FakeProviderNoHelpText()
+ with pytest.raises(NotImplementedError):
+ provider.get_mutelist_finding_args()
+
+ def test_display_compliance_table_raises_not_implemented(self):
+ """Base Provider.display_compliance_table raises NotImplementedError."""
+ provider = FakeProviderNoHelpText()
+ with pytest.raises(NotImplementedError):
+ provider.display_compliance_table([], {}, "fw", "out", "/tmp", False)
+
+ def test_is_external_tool_provider_defaults_to_false(self):
+ """Base Provider.is_external_tool_provider returns False."""
+ provider = FakeProviderNoHelpText()
+ assert provider.is_external_tool_provider is False
+
+
+# ===========================================================================
+# 10. Mutelist Dispatch for External Providers
+# ===========================================================================
+
+
+class TestMutelistDispatch:
+ """Tests for mutelist integration with external providers."""
+
+ def test_get_mutelist_finding_args_returns_identity(self, fake_provider):
+ """External provider returns identity kwargs for mutelist."""
+ args = fake_provider.get_mutelist_finding_args()
+
+ assert args == {"host_id": "fake-host-1"}
+
+ def test_mutelist_dispatch_calls_external_provider(self, fake_provider):
+ """execute() uses get_mutelist_finding_args for unknown provider types."""
+ from prowler.lib.check.check import execute
+
+ # Create a mock check that returns one finding
+ finding = MagicMock()
+ finding.status = "FAIL"
+ finding.muted = False
+ finding.check_metadata.Provider = "fakeexternal"
+
+ check = MagicMock()
+ check.execute.return_value = [finding]
+ check.CheckID = "fake_check"
+ check.ServiceName = "fake_service"
+ check.Severity.value = "high"
+
+ # Setup mutelist on the provider
+ fake_provider.mutelist = MagicMock()
+ fake_provider.mutelist.mutelist = {"Accounts": {}}
+ fake_provider.mutelist.is_finding_muted.return_value = True
+
+ output_options = MagicMock()
+ output_options.status = []
+ output_options.unix_timestamp = False
+
+ execute(check, fake_provider, None, output_options)
+
+ # is_finding_muted should have been called with host_id + finding
+ fake_provider.mutelist.is_finding_muted.assert_called_once_with(
+ host_id="fake-host-1", finding=finding
+ )
+
+
+# ===========================================================================
+# 11. Compliance Table Dispatch for External Providers
+# ===========================================================================
+
+
+class TestComplianceTableDispatch:
+ """Tests for compliance table display with external providers."""
+
+ def test_display_compliance_table_delegates_to_provider(self, fake_provider):
+ """display_compliance_table uses provider method for unknown frameworks."""
+ from prowler.lib.outputs.compliance.compliance import (
+ display_compliance_table,
+ )
+
+ fake_provider.display_compliance_table = MagicMock(return_value=True)
+
+ display_compliance_table(
+ [], {}, "custom_1.0_fakeexternal", "out", "/tmp", False
+ )
+
+ fake_provider.display_compliance_table.assert_called_once_with(
+ [],
+ {},
+ "custom_1.0_fakeexternal",
+ "out",
+ "/tmp",
+ False,
+ )
+
+ def test_display_compliance_table_falls_back_to_generic(self, fake_provider):
+ """display_compliance_table falls back to generic when provider returns False."""
+ from prowler.lib.outputs.compliance.compliance import (
+ display_compliance_table,
+ )
+
+ fake_provider.display_compliance_table = MagicMock(return_value=False)
+
+ with patch(
+ "prowler.lib.outputs.compliance.compliance.get_generic_compliance_table"
+ ) as mock_generic:
+ display_compliance_table(
+ [], {}, "custom_1.0_fakeexternal", "out", "/tmp", False
+ )
+
+ mock_generic.assert_called_once()
+
+ def test_display_compliance_table_falls_back_on_not_implemented(self):
+ """display_compliance_table falls back to generic when NotImplementedError."""
+ # Use a provider that doesn't implement display_compliance_table
+ provider = FakeProviderNoHelpText()
+ Provider.set_global_provider(provider)
+
+ with patch(
+ "prowler.lib.outputs.compliance.compliance.get_generic_compliance_table"
+ ) as mock_generic:
+ from prowler.lib.outputs.compliance.compliance import (
+ display_compliance_table,
+ )
+
+ display_compliance_table(
+ [], {}, "unknown_1.0_nohelptext", "out", "/tmp", False
+ )
+
+ mock_generic.assert_called_once()
+ Provider._global = None
From 662e7e9e184aa31f75fb35b0a51c4eb287e73423 Mon Sep 17 00:00:00 2001
From: Pepe Fagoaga
Date: Tue, 9 Jun 2026 08:13:12 +0200
Subject: [PATCH 023/129] chore(changelog): prepare for v5.29.3 (#11505)
---
prowler/CHANGELOG.md | 9 ++++++++-
ui/CHANGELOG.md | 2 +-
2 files changed, 9 insertions(+), 2 deletions(-)
diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md
index 8c976009f6..d28520a220 100644
--- a/prowler/CHANGELOG.md
+++ b/prowler/CHANGELOG.md
@@ -13,15 +13,22 @@ All notable changes to the **Prowler SDK** are documented in this file.
- Support for external/custom providers, checks, and compliance frameworks without modifying core code [(#10700)](https://github.com/prowler-cloud/prowler/pull/10700)
- `elbv2_alb_drop_invalid_header_fields_enabled` check for AWS provider, verifying Application Load Balancers have `routing.http.drop_invalid_header_fields.enabled` set to `true` to mitigate HTTP desync attacks (AWS FSBP ELB.4) [(#11471)](https://github.com/prowler-cloud/prowler/pull/11471)
- `user`, `systemlog` and `idp` service for Okta provider with `user_inactivity_automation_35d_enabled`, `systemlog_streaming_enabled` and `idp_smart_card_dod_approved_ca` checks [(#11496)](https://github.com/prowler-cloud/prowler/pull/11496)
+- AWS AI Security Framework support in the CLI dashboard [(#11475)](https://github.com/prowler-cloud/prowler/pull/11475)
### 🐞 Fixed
- `load_and_validate_config_file` now unwraps namespaced config for every built-in and external provider, and no longer leaks the full file as the provider's config when the file is namespaced [(#10700)](https://github.com/prowler-cloud/prowler/pull/10700)
+
+---
+
+## [5.29.3] (Prowler v5.29.3)
+
+### 🐞 Fixed
+
- GCP `logging_sink_created` now recognizes organization-level aggregated sinks with `includeChildren=True`, avoiding false failures for covered projects [(#11355)](https://github.com/prowler-cloud/prowler/pull/11355)
- GCP `logging_log_metric_filter_and_alert_*` checks now recognize organization-level aggregated sinks with `includeChildren=True`, no longer false-failing projects covered by a central bucket-scoped metric + alert [(#11488)](https://github.com/prowler-cloud/prowler/pull/11488)
- Jira integration no longer fails with `400 INVALID_INPUT` when a finding has empty fields [(#11474)](https://github.com/prowler-cloud/prowler/pull/11474)
- GCP `iam_service_account_unused` now passes disabled service accounts instead of failing them, since a disabled account cannot authenticate or be used [(#11467)](https://github.com/prowler-cloud/prowler/pull/11467)
-- AWS AI Security Framework now renders in the dashboard instead of showing "No data found for this compliance", by adding the missing compliance view module [(#11470)](https://github.com/prowler-cloud/prowler/pull/11470)
---
diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md
index 66d908b8fd..cbcc66fe7d 100644
--- a/ui/CHANGELOG.md
+++ b/ui/CHANGELOG.md
@@ -10,7 +10,7 @@ All notable changes to the **Prowler UI** are documented in this file.
---
-## [1.29.3] (Prowler UNRELEASED)
+## [1.29.3] (Prowler v5.29.3)
### 🐞 Fixed
From 1f7caa63945a44f6f03340136c61e9b09bce4ff5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Adri=C3=A1n=20Pe=C3=B1a?=
Date: Tue, 9 Jun 2026 09:16:48 +0200
Subject: [PATCH 024/129] feat(api): make orphan-task recovery configurable and
drop the Jira idempotency table (#11472)
---
api/CHANGELOG.md | 4 +-
api/docs/orphan-task-recovery.md | 55 ++--
.../commands/reconcile_orphan_tasks.py | 12 +-
.../migrations/0094_scan_recovery_count.py | 17 --
...95_reconcile_orphan_tasks_periodic_task.py | 2 +-
.../api/migrations/0096_jiraissuedispatch.py | 64 -----
api/src/backend/api/models.py | 32 ---
api/src/backend/config/django/base.py | 12 +
api/src/backend/tasks/jobs/deletion.py | 9 -
api/src/backend/tasks/jobs/integrations.py | 151 ++++-------
api/src/backend/tasks/jobs/orphan_recovery.py | 206 ++++++---------
api/src/backend/tasks/jobs/scan.py | 21 +-
api/src/backend/tasks/tasks.py | 16 +-
api/src/backend/tasks/tests/test_deletion.py | 40 +--
.../backend/tasks/tests/test_integrations.py | 179 +------------
.../tasks/tests/test_orphan_recovery.py | 242 ++++++++++--------
api/src/backend/tasks/tests/test_scan.py | 128 ---------
17 files changed, 349 insertions(+), 841 deletions(-)
delete mode 100644 api/src/backend/api/migrations/0094_scan_recovery_count.py
delete mode 100644 api/src/backend/api/migrations/0096_jiraissuedispatch.py
diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md
index af4b9e69bf..15466fd5fe 100644
--- a/api/CHANGELOG.md
+++ b/api/CHANGELOG.md
@@ -6,15 +6,13 @@ All notable changes to the **Prowler API** are documented in this file.
### 🚀 Added
-- Automatic recovery of allowlisted idempotent background tasks whose worker died during a deploy or crash: stuck scan and summary tasks are detected and re-run instead of staying pending forever, with a `reconcile_orphan_tasks` management command for on-demand recovery [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416)
-- Jira integration no longer creates duplicate issues on a retried send; findings already ticketed are skipped [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416)
+- Opt-in automatic recovery of allowlisted idempotent background tasks whose worker died during a deploy or crash: when enabled via `DJANGO_TASK_RECOVERY_ENABLED` (off by default), stuck summary and deletion tasks are detected and re-run instead of staying pending forever (scan and Jira tasks are excluded), with a `reconcile_orphan_tasks` management command for on-demand recovery [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416)
- DORA compliance framework support [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131)
- Label Postgres connections with `application_name=":"` (component injected per process via `DJANGO_APP_COMPONENT`) so connections are attributable by component in `pg_stat_activity` [(#11494)](https://github.com/prowler-cloud/prowler/pull/11494)
### 🔄 Changed
- Allowlisted idempotent background tasks are no longer lost when a worker is stopped or crashes mid-task; tasks with external side effects are marked terminal instead of blindly re-running [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416)
-- A recovered scan rewrites its findings, summaries, attack surface, and compliance data instead of appending to the previous run, so recovery never leaves stale or duplicate materialized rows [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416)
### 🐞 Fixed
diff --git a/api/docs/orphan-task-recovery.md b/api/docs/orphan-task-recovery.md
index 38b1546bae..a47b4f36a9 100644
--- a/api/docs/orphan-task-recovery.md
+++ b/api/docs/orphan-task-recovery.md
@@ -1,10 +1,11 @@
# Orphan Celery task recovery
When a worker is terminated mid-task (a deploy, an OOM kill, a node eviction), the
-task it was running can be left non-terminal forever: the `Scan` stays `EXECUTING`,
-the `TaskResult` stays `STARTED`, and nothing re-runs it. This page describes the
-mechanisms that detect and recover allowlisted idempotent orphans so users never
-see a stuck scan and pending-task alerts do not fire.
+task it was running can be left non-terminal forever: the `TaskResult` stays
+`STARTED` and nothing re-runs it. This page describes the mechanisms that detect and
+recover allowlisted idempotent orphans so pending-task alerts do not fire. Scan tasks
+are not auto-recovered (re-running a scan is not safe to do automatically); the
+watchdog covers the summary/aggregation and deletion tasks.
## How recovery works
@@ -13,29 +14,35 @@ see a stuck scan and pending-task alerts do not fire.
(`worker_prefetch_multiplier = 1`), and an abruptly-lost worker re-queues its task
(`task_reject_on_worker_lost`). On `SIGTERM` the worker is given a soft-shutdown
window (`worker_soft_shutdown_timeout`) to finish or re-queue in-flight work
- before it is force-killed.
+ before it is force-killed. `scan-perform`, `scan-perform-scheduled` and
+ `integration-jira` opt out of redelivery with `acks_late=False`, so a crash drops
+ them rather than re-running and duplicating findings or Jira issues. Other
+ non-recovered side-effect tasks keep `acks_late=True`, so the broker can still
+ re-deliver them after a worker loss: the S3 upload rebuilds from worker-local files
+ that did not survive the crash and so no-ops, but Security Hub re-reads findings from
+ the DB and re-sends them to AWS.
2. **Periodic watchdog.** A Beat task, `reconcile-orphan-tasks`, runs every couple of
minutes (a `django_celery_beat` periodic task created by migration). For each
in-flight task result with an allowlisted idempotent task name, it pings the
worker recorded on the task's `TaskResult`:
- worker responds -> the task is still running, leave it alone;
- - worker is gone (and the scan started before a short grace window) -> it is a
+ - worker is gone (and the task started before a short grace window) -> it is a
real orphan: the stale task is revoked and marked terminal (clearing the
- pending/started alert), and the scan is re-enqueued from scratch.
+ pending/started alert), and the task is re-enqueued from its stored name and
+ kwargs.
- The re-run is safe because only tasks with proven idempotency are allowlisted.
- Scan persistence, for example, clears the scan's prior findings and materialized
- summary/compliance rows before re-writing them. Jira sends are allowlisted too:
- each finding is reserved in a dispatch table before the external call, so a re-run
- skips already-ticketed findings (the worst case is one finding missed if a worker
- is hard-killed mid-send, never a duplicate issue). Other external side effects stay
- terminal: the S3 upload rebuilds from worker-local files that do not survive a
- crash, and report/Security Hub recovery is out of scope.
+ The re-run is safe because only tasks with proven idempotency are allowlisted: the
+ summary/aggregation tasks clear and re-write their own rows, and deletions are
+ idempotent. Scan tasks and external side effects are excluded: re-running a scan is
+ not safe to do automatically, Jira sends would create duplicate issues, the S3
+ upload rebuilds from worker-local files that do not survive a crash, and
+ report/Security Hub recovery is out of scope.
-3. **Recovery cap.** Each automatic re-enqueue increments `Scan.recovery_count`.
- After `--max-attempts` recoveries (default 3) the scan is marked `FAILED` instead
- of re-enqueued, so a task that repeatedly kills its worker cannot loop forever.
+3. **Recovery cap.** A per-task Valkey counter limits how often the same task is
+ re-enqueued. After `--max-attempts` recoveries (default 3) the orphan is marked
+ terminal instead of re-enqueued, so a task that repeatedly kills its worker cannot
+ loop forever.
A Postgres advisory lock ensures that, even with multiple API/worker replicas, only
one reconciliation runs at a time; the others no-op.
@@ -63,6 +70,18 @@ All settings have safe defaults; override via environment variables.
| `DJANGO_CELERY_TASK_SOFT_TIME_LIMIT` | hard - 600 | Soft limit; raises `SoftTimeLimitExceeded` for cleanup. |
| `DJANGO_CELERY_LONG_TASK_TIME_LIMIT` | `172800` (48h) | Hard limit for scans and provider/tenant deletions, which can legitimately run for more than a day. |
| `DJANGO_CELERY_LONG_TASK_SOFT_TIME_LIMIT` | long hard - 600 | Soft limit for the long-running tasks above. |
+| `DJANGO_TASK_RECOVERY_ENABLED` | `false` | Master switch for orphan-task recovery, disabled by default (opt-in); set to `true` to enable. When off, no orphan is detected, marked terminal, or re-enqueued (attack-paths stale cleanup still runs). |
+| `DJANGO_TASK_RECOVERY_SUMMARIES_ENABLED` | `true` | Auto re-enqueue orphaned scan summary/aggregation tasks. |
+| `DJANGO_TASK_RECOVERY_DELETIONS_ENABLED` | `true` | Auto re-enqueue orphaned provider/tenant deletion tasks. |
+
+Recovery is opt-in: with the master flag off (the default) the sweep does nothing.
+Once enabled, the per-group flags default to on, so every group recovers unless you
+turn one off; a task whose group flag is off is marked terminal instead of
+re-enqueued.
+
+Turning recovery off only disables this watchdog sweep; it does not change Celery's
+broker-level redelivery (`task_acks_late`/`task_reject_on_worker_lost`), which still
+re-delivers tasks that keep `acks_late=True` on worker loss, independently of this flag.
`task_acks_late` and `task_reject_on_worker_lost` are enabled in `config/celery.py`.
diff --git a/api/src/backend/api/management/commands/reconcile_orphan_tasks.py b/api/src/backend/api/management/commands/reconcile_orphan_tasks.py
index cdfe6b3fda..8ba8f5b342 100644
--- a/api/src/backend/api/management/commands/reconcile_orphan_tasks.py
+++ b/api/src/backend/api/management/commands/reconcile_orphan_tasks.py
@@ -20,7 +20,7 @@ class Command(BaseCommand):
"--max-attempts",
type=int,
default=3,
- help="Give up re-running a task after this many recovery attempts (scans are marked FAILED).",
+ help="Give up re-running a task after this many recovery attempts; it is then left terminal instead of re-enqueued.",
)
parser.add_argument(
"--dry-run",
@@ -39,6 +39,16 @@ class Command(BaseCommand):
self.stdout.write("Reconcile skipped: another run holds the lock.")
return
+ if result.get("enabled") is False:
+ message = (
+ "Task recovery is disabled (DJANGO_TASK_RECOVERY_ENABLED is off); "
+ "no orphans were recovered."
+ )
+ if result.get("attack_paths") is not None:
+ message += " Attack-paths stale cleanup still ran."
+ self.stdout.write(message)
+ return
+
self.stdout.write(
self.style.SUCCESS(
"Orphan reconcile complete: "
diff --git a/api/src/backend/api/migrations/0094_scan_recovery_count.py b/api/src/backend/api/migrations/0094_scan_recovery_count.py
deleted file mode 100644
index 01f7af42df..0000000000
--- a/api/src/backend/api/migrations/0094_scan_recovery_count.py
+++ /dev/null
@@ -1,17 +0,0 @@
-# Generated by Django 5.1.15 on 2026-05-30 17:38
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
- dependencies = [
- ("api", "0093_okta_provider"),
- ]
-
- operations = [
- migrations.AddField(
- model_name="scan",
- name="recovery_count",
- field=models.IntegerField(default=0),
- ),
- ]
diff --git a/api/src/backend/api/migrations/0095_reconcile_orphan_tasks_periodic_task.py b/api/src/backend/api/migrations/0095_reconcile_orphan_tasks_periodic_task.py
index ab511a11b1..9d67404258 100644
--- a/api/src/backend/api/migrations/0095_reconcile_orphan_tasks_periodic_task.py
+++ b/api/src/backend/api/migrations/0095_reconcile_orphan_tasks_periodic_task.py
@@ -40,7 +40,7 @@ def delete_periodic_task(apps, schema_editor):
class Migration(migrations.Migration):
dependencies = [
- ("api", "0094_scan_recovery_count"),
+ ("api", "0093_okta_provider"),
("django_celery_beat", "0019_alter_periodictasks_options"),
]
diff --git a/api/src/backend/api/migrations/0096_jiraissuedispatch.py b/api/src/backend/api/migrations/0096_jiraissuedispatch.py
deleted file mode 100644
index f5a1a9d9a0..0000000000
--- a/api/src/backend/api/migrations/0096_jiraissuedispatch.py
+++ /dev/null
@@ -1,64 +0,0 @@
-import uuid
-
-import django.db.models.deletion
-from django.db import migrations, models
-
-import api.rls
-
-
-class Migration(migrations.Migration):
- dependencies = [
- ("api", "0095_reconcile_orphan_tasks_periodic_task"),
- ]
-
- operations = [
- migrations.CreateModel(
- name="JiraIssueDispatch",
- fields=[
- (
- "id",
- models.UUIDField(
- default=uuid.uuid4,
- editable=False,
- primary_key=True,
- serialize=False,
- ),
- ),
- ("inserted_at", models.DateTimeField(auto_now_add=True)),
- ("finding_id", models.UUIDField()),
- (
- "integration",
- models.ForeignKey(
- on_delete=django.db.models.deletion.CASCADE,
- related_name="jira_dispatches",
- to="api.integration",
- ),
- ),
- (
- "tenant",
- models.ForeignKey(
- on_delete=django.db.models.deletion.CASCADE, to="api.tenant"
- ),
- ),
- ],
- options={
- "db_table": "jira_issue_dispatches",
- "abstract": False,
- },
- ),
- migrations.AddConstraint(
- model_name="jiraissuedispatch",
- constraint=models.UniqueConstraint(
- fields=("tenant_id", "integration_id", "finding_id"),
- name="unique_jira_issue_dispatch",
- ),
- ),
- migrations.AddConstraint(
- model_name="jiraissuedispatch",
- constraint=api.rls.RowLevelSecurityConstraint(
- "tenant_id",
- name="rls_on_jiraissuedispatch",
- statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
- ),
- ),
- ]
diff --git a/api/src/backend/api/models.py b/api/src/backend/api/models.py
index 8adbc2cf9e..3d9a26698e 100644
--- a/api/src/backend/api/models.py
+++ b/api/src/backend/api/models.py
@@ -666,9 +666,6 @@ class Scan(RowLevelSecurityProtectedModel):
state = StateEnumField(choices=StateChoices.choices, default=StateChoices.AVAILABLE)
unique_resource_count = models.IntegerField(default=0)
progress = models.IntegerField(default=0)
- # Incremented by the scan-specific orphan-recovery path each time this scan is
- # re-pointed to a fresh task; for observability (the retry cap is a Valkey counter).
- recovery_count = models.IntegerField(default=0)
scanner_args = models.JSONField(default=dict)
duration = models.IntegerField(null=True, blank=True)
scheduled_at = models.DateTimeField(null=True, blank=True)
@@ -2001,35 +1998,6 @@ class IntegrationProviderRelationship(RowLevelSecurityProtectedModel):
]
-class JiraIssueDispatch(RowLevelSecurityProtectedModel):
- """Tracks findings already sent to a Jira integration.
-
- Lets the Jira task be re-run safely (e.g. by orphan recovery): findings with
- an existing dispatch row are skipped, so no duplicate issues are created.
- """
-
- id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
- inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
- integration = models.ForeignKey(
- Integration, on_delete=models.CASCADE, related_name="jira_dispatches"
- )
- finding_id = models.UUIDField()
-
- class Meta(RowLevelSecurityProtectedModel.Meta):
- db_table = "jira_issue_dispatches"
- constraints = [
- models.UniqueConstraint(
- fields=["tenant_id", "integration_id", "finding_id"],
- name="unique_jira_issue_dispatch",
- ),
- RowLevelSecurityConstraint(
- field="tenant_id",
- name="rls_on_%(class)s",
- statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
- ),
- ]
-
-
class SAMLToken(models.Model):
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
diff --git a/api/src/backend/config/django/base.py b/api/src/backend/config/django/base.py
index 402b71eb51..38cf047ac2 100644
--- a/api/src/backend/config/django/base.py
+++ b/api/src/backend/config/django/base.py
@@ -307,6 +307,18 @@ ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES = env.int(
"ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES", 2880
) # 48h
+# Orphan task recovery feature flags. The master switch is OFF by default, so task
+# recovery is opt-in; enable it with DJANGO_TASK_RECOVERY_ENABLED=true. The per-group
+# toggles default to enabled, so once the master is on every group recovers unless a
+# group is explicitly turned off.
+TASK_RECOVERY_ENABLED = env.bool("DJANGO_TASK_RECOVERY_ENABLED", False)
+TASK_RECOVERY_SUMMARIES_ENABLED = env.bool(
+ "DJANGO_TASK_RECOVERY_SUMMARIES_ENABLED", True
+)
+TASK_RECOVERY_DELETIONS_ENABLED = env.bool(
+ "DJANGO_TASK_RECOVERY_DELETIONS_ENABLED", True
+)
+
def label_postgres_connections(databases):
"""Tag each Postgres connection with ``application_name=":"``
diff --git a/api/src/backend/tasks/jobs/deletion.py b/api/src/backend/tasks/jobs/deletion.py
index 7540f72c7e..f9ead01897 100644
--- a/api/src/backend/tasks/jobs/deletion.py
+++ b/api/src/backend/tasks/jobs/deletion.py
@@ -11,7 +11,6 @@ from api.db_utils import batch_delete, rls_transaction
from api.models import (
AttackPathsScan,
Finding,
- JiraIssueDispatch,
Provider,
ProviderComplianceScore,
Resource,
@@ -81,14 +80,6 @@ def delete_provider(tenant_id: str, pk: str):
deletion_steps = [
("Scan Summaries", ScanSummary.all_objects.filter(scan__provider=instance)),
- (
- "Jira Issue Dispatches",
- JiraIssueDispatch.objects.filter(
- finding_id__in=Finding.all_objects.filter(
- scan__provider=instance
- ).values_list("id", flat=True)
- ),
- ),
("Findings", Finding.all_objects.filter(scan__provider=instance)),
("Resources", Resource.all_objects.filter(provider=instance)),
("Scans", Scan.all_objects.filter(provider=instance)),
diff --git a/api/src/backend/tasks/jobs/integrations.py b/api/src/backend/tasks/jobs/integrations.py
index 55c1205169..5ca94057da 100644
--- a/api/src/backend/tasks/jobs/integrations.py
+++ b/api/src/backend/tasks/jobs/integrations.py
@@ -9,7 +9,7 @@ from tasks.utils import batched
from api.db_router import READ_REPLICA_ALIAS, MainRouter
from api.db_utils import REPLICA_MAX_ATTEMPTS, REPLICA_RETRY_BASE_DELAY, rls_transaction
-from api.models import Finding, Integration, JiraIssueDispatch, Provider
+from api.models import Finding, Integration, Provider
from api.utils import initialize_prowler_integration, initialize_prowler_provider
from prowler.lib.outputs.asff.asff import ASFF
from prowler.lib.outputs.compliance.generic.generic import GenericCompliance
@@ -482,115 +482,66 @@ def send_findings_to_jira(
with rls_transaction(tenant_id):
integration = Integration.objects.get(id=integration_id)
jira_integration = initialize_prowler_integration(integration)
- # Idempotency: findings already ticketed for this integration must not be
- # sent again on a re-run (e.g. orphan recovery), to avoid duplicate issues
- already_sent = {
- str(fid)
- for fid in JiraIssueDispatch.objects.filter(
- integration_id=integration_id, finding_id__in=finding_ids
- ).values_list("finding_id", flat=True)
- }
num_tickets_created = 0
- skipped_count = 0
for finding_id in finding_ids:
- if str(finding_id) in already_sent:
- skipped_count += 1
- continue
-
- # Reserve the finding BEFORE the external call. The unique constraint on
- # (tenant, integration, finding) makes the dispatch row the single source of
- # truth, so a concurrent run or a retry that raced past the bulk pre-check
- # cannot create a duplicate issue: created=False means another run already
- # claimed it. The reservation is released below if the send does not succeed.
with rls_transaction(tenant_id):
- _, created = JiraIssueDispatch.objects.get_or_create(
- tenant_id=tenant_id,
- integration_id=integration_id,
- finding_id=finding_id,
+ finding_instance = (
+ Finding.all_objects.select_related("scan__provider")
+ .prefetch_related("resources")
+ .get(id=finding_id)
)
- if not created:
- skipped_count += 1
- continue
- sent = False
- try:
- with rls_transaction(tenant_id):
- finding_instance = (
- Finding.all_objects.select_related("scan__provider")
- .prefetch_related("resources")
- .get(id=finding_id)
- )
+ # Extract resource information
+ resource = (
+ finding_instance.resources.first()
+ if finding_instance.resources.exists()
+ else None
+ )
+ resource_uid = resource.uid if resource else ""
+ resource_name = resource.name if resource else ""
+ resource_tags = {}
+ if resource and hasattr(resource, "tags"):
+ resource_tags = resource.get_tags(tenant_id)
- # Extract resource information
- resource = (
- finding_instance.resources.first()
- if finding_instance.resources.exists()
- else None
- )
- resource_uid = resource.uid if resource else ""
- resource_name = resource.name if resource else ""
- resource_tags = {}
- if resource and hasattr(resource, "tags"):
- resource_tags = resource.get_tags(tenant_id)
+ # Get region
+ region = resource.region if resource and resource.region else ""
- # Get region
- region = resource.region if resource and resource.region else ""
+ # Extract remediation information from check_metadata
+ check_metadata = finding_instance.check_metadata
+ remediation = check_metadata.get("remediation", {})
+ recommendation = remediation.get("recommendation", {})
+ remediation_code = remediation.get("code", {})
- # Extract remediation information from check_metadata
- check_metadata = finding_instance.check_metadata
- remediation = check_metadata.get("remediation", {})
- recommendation = remediation.get("recommendation", {})
- remediation_code = remediation.get("code", {})
-
- # Send the individual finding to Jira
- sent = bool(
- jira_integration.send_finding(
- check_id=finding_instance.check_id,
- check_title=check_metadata.get("checktitle", ""),
- severity=finding_instance.severity,
- status=finding_instance.status,
- status_extended=finding_instance.status_extended or "",
- provider=finding_instance.scan.provider.provider,
- region=region,
- resource_uid=resource_uid,
- resource_name=resource_name,
- risk=check_metadata.get("risk", ""),
- recommendation_text=recommendation.get("text", ""),
- recommendation_url=recommendation.get("url", ""),
- remediation_code_native_iac=remediation_code.get(
- "nativeiac", ""
- ),
- remediation_code_terraform=remediation_code.get(
- "terraform", ""
- ),
- remediation_code_cli=remediation_code.get("cli", ""),
- remediation_code_other=remediation_code.get("other", ""),
- resource_tags=resource_tags,
- compliance=finding_instance.compliance or {},
- project_key=project_key,
- issue_type=issue_type,
- )
- )
- finally:
- if not sent:
- # Release the reservation so a later run can retry this finding: it
- # was not ticketed (send failed or raised), so the row must not block
- # a future legitimate send.
- with rls_transaction(tenant_id):
- JiraIssueDispatch.objects.filter(
- tenant_id=tenant_id,
- integration_id=integration_id,
- finding_id=finding_id,
- ).delete()
-
- if sent:
- num_tickets_created += 1
- else:
- logger.error(f"Failed to send finding {finding_id} to Jira")
+ # Send the individual finding to Jira
+ result = jira_integration.send_finding(
+ check_id=finding_instance.check_id,
+ check_title=check_metadata.get("checktitle", ""),
+ severity=finding_instance.severity,
+ status=finding_instance.status,
+ status_extended=finding_instance.status_extended or "",
+ provider=finding_instance.scan.provider.provider,
+ region=region,
+ resource_uid=resource_uid,
+ resource_name=resource_name,
+ risk=check_metadata.get("risk", ""),
+ recommendation_text=recommendation.get("text", ""),
+ recommendation_url=recommendation.get("url", ""),
+ remediation_code_native_iac=remediation_code.get("nativeiac", ""),
+ remediation_code_terraform=remediation_code.get("terraform", ""),
+ remediation_code_cli=remediation_code.get("cli", ""),
+ remediation_code_other=remediation_code.get("other", ""),
+ resource_tags=resource_tags,
+ compliance=finding_instance.compliance or {},
+ project_key=project_key,
+ issue_type=issue_type,
+ )
+ if result:
+ num_tickets_created += 1
+ else:
+ logger.error(f"Failed to send finding {finding_id} to Jira")
return {
"created_count": num_tickets_created,
- "failed_count": len(finding_ids) - num_tickets_created - skipped_count,
- "skipped_count": skipped_count,
+ "failed_count": len(finding_ids) - num_tickets_created,
}
diff --git a/api/src/backend/tasks/jobs/orphan_recovery.py b/api/src/backend/tasks/jobs/orphan_recovery.py
index d884c3fc8b..1bf5c95df2 100644
--- a/api/src/backend/tasks/jobs/orphan_recovery.py
+++ b/api/src/backend/tasks/jobs/orphan_recovery.py
@@ -37,35 +37,52 @@ ORPHAN_RECOVERY_LOCK_KEY = 0x70726F77 # "prow"
# Non-terminal states that mean "a worker had this and may have died with it".
IN_FLIGHT_STATES = (states.STARTED, states.RECEIVED)
-# Scan tasks are recovered by re-running scan-perform on the EXISTING scan row,
-# not by re-enqueuing the original task: re-enqueuing scan-perform-scheduled would
-# hit its "a scan is already executing" guard and no-op, leaving the scan stuck.
-_SCAN_TASKS = ("scan-perform", "scan-perform-scheduled")
-
-# Tasks with proven idempotency are auto re-enqueued. Scans/summaries clear and
-# rewrite their own rows. integration-jira is safe too: each finding is reserved in
-# JiraIssueDispatch before the external call, so a re-run skips already-ticketed
-# findings (worst case one finding missed on a mid-send crash, never a duplicate).
-# Other external side effects stay terminal: integration-s3 rebuilds its upload from
-# worker-local files that do not survive a crash, and report/Security Hub recovery is
-# out of scope.
-REENQUEUEABLE_TASKS = {
- *_SCAN_TASKS,
- "provider-deletion",
- "tenant-deletion",
- "scan-summary",
- "scan-compliance-overviews",
- "scan-provider-compliance-scores",
- "scan-daily-severity",
- "scan-finding-group-summaries",
- "scan-reset-ephemeral-resources",
- "integration-jira",
+# Tasks with proven idempotency are eligible for auto re-enqueue, grouped so each
+# group can be toggled independently by a feature flag (see config.django.base).
+# Summaries clear and rewrite their own rows and deletions are idempotent. Tasks with
+# external side effects are never eligible: integration-jira would create duplicate
+# issues, integration-s3 rebuilds its upload from worker-local files that do not
+# survive a crash, and report/Security Hub recovery is out of scope.
+RECOVERY_TASK_GROUPS = {
+ "summaries": {
+ "scan-summary",
+ "scan-compliance-overviews",
+ "scan-provider-compliance-scores",
+ "scan-daily-severity",
+ "scan-finding-group-summaries",
+ "scan-reset-ephemeral-resources",
+ },
+ "deletions": {"provider-deletion", "tenant-deletion"},
}
-# Tasks excluded from generic recovery: attack-paths scans are handled by their own
-# stale-cleanup (which also drops the temp Neo4j db), and the maintenance tasks must
-# not self-recover (they run again on their own schedule).
+
+def reenqueueable_tasks() -> set[str]:
+ """Task names eligible for auto re-enqueue, honoring the per-group feature flags.
+
+ A group whose flag is disabled is dropped, so its orphaned tasks are marked
+ terminal instead of re-enqueued.
+ """
+ from django.conf import settings
+
+ group_enabled = {
+ "summaries": settings.TASK_RECOVERY_SUMMARIES_ENABLED,
+ "deletions": settings.TASK_RECOVERY_DELETIONS_ENABLED,
+ }
+ return {
+ task
+ for group, tasks in RECOVERY_TASK_GROUPS.items()
+ if group_enabled[group]
+ for task in tasks
+ }
+
+
+# Tasks the watchdog ignores entirely (not even marked terminal): scan tasks are not
+# auto-recovered, since re-running a scan is not safe to do automatically; attack-paths
+# scans are handled by their own stale-cleanup (which also drops the temp Neo4j db);
+# and the maintenance tasks must not self-recover (they run again on their own schedule).
_SKIP_RECOVERY = {
+ "scan-perform",
+ "scan-perform-scheduled",
"attack-paths-scan-perform",
"attack-paths-cleanup-stale-scans",
"reconcile-orphan-tasks",
@@ -166,15 +183,22 @@ def reconcile_orphans(
logger.info("Orphan reconcile skipped: another run holds the lock")
return {"acquired": False}
- # Populate the task registry so we can re-enqueue any task by name.
- import tasks.tasks # noqa: F401
+ from django.conf import settings
- result = _reconcile_task_results(
- grace_minutes=grace_minutes,
- max_attempts=max_attempts,
- window_hours=window_hours,
- dry_run=dry_run,
- )
+ if settings.TASK_RECOVERY_ENABLED:
+ # Populate the task registry so we can re-enqueue any task by name.
+ import tasks.tasks # noqa: F401
+
+ result = _reconcile_task_results(
+ grace_minutes=grace_minutes,
+ max_attempts=max_attempts,
+ window_hours=window_hours,
+ dry_run=dry_run,
+ )
+ result["enabled"] = True
+ else:
+ logger.info("Orphan task recovery disabled by feature flag")
+ result = {"recovered": [], "failed": [], "skipped": [], "enabled": False}
if not dry_run:
from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans
@@ -264,34 +288,27 @@ def _recover_task(task_result, max_attempts: int, window_hours: int) -> str:
task_result.date_done = now
task_result.save(update_fields=["status", "date_done"])
- attempt = _recovery_attempt_count(name, kwargs_repr, window_hours)
- if name not in REENQUEUEABLE_TASKS or attempt > max_attempts:
- reason = (
- f"{name} is not allowlisted for auto recovery"
- if name not in REENQUEUEABLE_TASKS
- else f"recovery cap reached ({attempt}/{max_attempts})"
- )
- _fail_domain_row(task_result.task_id, name, now)
+ if name not in reenqueueable_tasks():
logger.warning(
- "Orphan %s (%s) not re-enqueued: %s", task_result.task_id, name, reason
+ "Orphan %s (%s) not re-enqueued: not allowlisted for auto recovery",
+ task_result.task_id,
+ name,
)
return "failed"
- # Scan tasks: re-run the EXISTING scan row directly via scan-perform, so the
- # scheduled-scan "already executing" guard cannot turn recovery into a no-op.
- # Falls through to the generic path only if no scan is linked yet (e.g. a
- # scheduled task that died before creating one), where re-running it creates one.
- if name in _SCAN_TASKS:
- scan = _scan_for_task(task_result.task_id)
- if scan is not None:
- if not _reenqueue_scan(task_result.task_id, scan):
- return "failed"
- logger.info(
- "Re-enqueued orphaned scan %s (was task %s)",
- scan.id,
- task_result.task_id,
- )
- return "recovered"
+ # Count the attempt only once the task is allowlisted, so a task sitting in a
+ # disabled group does not burn its recovery budget while the flag is off (and is
+ # not already over the cap the moment the group is re-enabled).
+ attempt = _recovery_attempt_count(name, kwargs_repr, window_hours)
+ if attempt > max_attempts:
+ logger.warning(
+ "Orphan %s (%s) not re-enqueued: recovery cap reached (%d/%d)",
+ task_result.task_id,
+ name,
+ attempt,
+ max_attempts,
+ )
+ return "failed"
task_obj = current_app.tasks.get(name)
if task_obj is None:
@@ -311,7 +328,6 @@ def _recover_task(task_result, max_attempts: int, window_hours: int) -> str:
task_result.task_id,
name,
)
- _fail_domain_row(task_result.task_id, name, now)
return "failed"
new_task_id = str(uuid4())
task_obj.apply_async(
@@ -323,75 +339,3 @@ def _recover_task(task_result, max_attempts: int, window_hours: int) -> str:
"Re-enqueued orphan %s (%s) as %s", task_result.task_id, name, new_task_id
)
return "recovered"
-
-
-def _scan_for_task(task_id: str):
- """Return the Scan linked to a Celery task id, or None (read across tenants)."""
- from api.db_router import MainRouter
- from api.models import Scan
-
- return Scan.all_objects.using(MainRouter.admin_db).filter(task_id=task_id).first()
-
-
-def _reenqueue_scan(old_task_id: str, scan) -> bool:
- """Re-run an orphaned scan via scan-perform on the existing row.
-
- Pre-provisions the new task linkage (TaskResult + api.Task) and relinks the
- Scan before enqueuing, so the FK is valid and a worker can never outrun the DB.
- The relink is conditional on the scan still pointing at the old task, so a stale
- orphan can never clobber a newer linkage.
- """
- from django_celery_results.models import TaskResult
-
- from api.db_utils import rls_transaction
- from api.models import Scan
- from api.models import Task as APITask
- from tasks.tasks import perform_scan_task
-
- tenant_id = str(scan.tenant_id)
- new_task_id = str(uuid4())
- with rls_transaction(tenant_id):
- locked_scan = Scan.all_objects.select_for_update().filter(id=scan.id).first()
- if locked_scan is None or str(locked_scan.task_id) != old_task_id:
- logger.info(
- "Scan %s no longer points at task %s; skipping recovery re-enqueue",
- scan.id,
- old_task_id,
- )
- return False
- task_result_new, _ = TaskResult.objects.get_or_create(
- task_id=new_task_id,
- defaults={"status": states.PENDING, "task_name": "scan-perform"},
- )
- APITask.objects.update_or_create(
- id=new_task_id,
- tenant_id=tenant_id,
- defaults={"task_runner_task": task_result_new},
- )
- locked_scan.task_id = new_task_id
- locked_scan.recovery_count = (locked_scan.recovery_count or 0) + 1
- locked_scan.save(update_fields=["task_id", "recovery_count", "updated_at"])
-
- perform_scan_task.apply_async(
- kwargs={
- "tenant_id": tenant_id,
- "scan_id": str(scan.id),
- "provider_id": str(scan.provider_id),
- },
- task_id=new_task_id,
- )
- return True
-
-
-def _fail_domain_row(old_task_id: str, name: str, now: datetime) -> None:
- """Mark a scan terminal when its task is capped/denylisted instead of re-run."""
- from api.db_utils import rls_transaction
- from api.models import Scan, StateChoices
-
- if name in _SCAN_TASKS:
- scan = _scan_for_task(old_task_id)
- if scan is not None:
- with rls_transaction(str(scan.tenant_id)):
- Scan.all_objects.filter(id=scan.id, task_id=old_task_id).update(
- state=StateChoices.FAILED, completed_at=now
- )
diff --git a/api/src/backend/tasks/jobs/scan.py b/api/src/backend/tasks/jobs/scan.py
index 298a2b225a..db5018db90 100644
--- a/api/src/backend/tasks/jobs/scan.py
+++ b/api/src/backend/tasks/jobs/scan.py
@@ -118,19 +118,6 @@ ATTACK_SURFACE_PROVIDER_COMPATIBILITY = {
_ATTACK_SURFACE_MAPPING_CACHE: dict[str, dict] = {}
-def _clear_scan_rerun_state(tenant_id: str, scan_id: str) -> None:
- """Remove rows derived from a previous execution of this scan."""
- with rls_transaction(tenant_id):
- Finding.all_objects.filter(scan_id=scan_id).delete()
- ResourceScanSummary.objects.filter(scan_id=scan_id).delete()
- ScanCategorySummary.objects.filter(scan_id=scan_id).delete()
- ScanGroupSummary.objects.filter(scan_id=scan_id).delete()
- ScanSummary.objects.filter(scan_id=scan_id).delete()
- AttackSurfaceOverview.objects.filter(scan_id=scan_id).delete()
- ComplianceRequirementOverview.objects.filter(scan_id=scan_id).delete()
- ComplianceOverviewSummary.objects.filter(scan_id=scan_id).delete()
-
-
def aggregate_category_counts(
categories: list[str],
severity: str,
@@ -489,10 +476,9 @@ def _create_compliance_summaries(
)
)
- # Idempotent re-run: clear this scan's prior summaries before re-inserting, so
- # a recovered scan's summary always reflects its own (re-derived) requirement
- # rows rather than keeping a stale row (bulk_create ignore_conflicts alone would
- # keep the old one).
+ # Idempotent re-run: clear this scan's prior summaries before re-inserting, so a
+ # recovered scan-compliance-overviews run reflects its own re-derived rows instead
+ # of keeping a stale one (bulk_create ignore_conflicts alone would keep the old).
with rls_transaction(tenant_id):
ComplianceOverviewSummary.objects.filter(scan_id=scan_id).delete()
if summary_objects:
@@ -1039,7 +1025,6 @@ def perform_prowler_scan(
scan_instance.state = StateChoices.EXECUTING
scan_instance.started_at = datetime.now(tz=timezone.utc)
scan_instance.save(update_fields=["state", "started_at", "updated_at"])
- _clear_scan_rerun_state(tenant_id, scan_id)
# Find the mutelist processor if it exists
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
diff --git a/api/src/backend/tasks/tasks.py b/api/src/backend/tasks/tasks.py
index 8f6b9bda0e..e617339973 100644
--- a/api/src/backend/tasks/tasks.py
+++ b/api/src/backend/tasks/tasks.py
@@ -260,7 +260,9 @@ def delete_provider_task(provider_id: str, tenant_id: str):
return delete_provider(tenant_id=tenant_id, pk=provider_id)
-@shared_task(base=RLSTask, name="scan-perform", queue="scans")
+# acks_late=False: a re-run would duplicate findings and the task is not auto-recovered,
+# so a crashed scan is dropped rather than redelivered by the broker (as before #11416).
+@shared_task(base=RLSTask, name="scan-perform", queue="scans", acks_late=False)
@handle_provider_deletion
def perform_scan_task(
tenant_id: str, scan_id: str, provider_id: str, checks_to_execute: list[str] = None
@@ -304,7 +306,14 @@ def perform_scan_task(
return result
-@shared_task(base=RLSTask, bind=True, name="scan-perform-scheduled", queue="scans")
+# acks_late=False: like scan-perform; a dropped run is re-fired by Beat on the next tick.
+@shared_task(
+ base=RLSTask,
+ bind=True,
+ name="scan-perform-scheduled",
+ queue="scans",
+ acks_late=False,
+)
@handle_provider_deletion
def perform_scheduled_scan_task(self, tenant_id: str, provider_id: str):
"""
@@ -1151,10 +1160,13 @@ def security_hub_integration_task(
return upload_security_hub_integration(tenant_id, provider_id, scan_id)
+# acks_late=False: Jira sends are not deduplicated and the task is not auto-recovered,
+# so a crashed send is dropped rather than redelivered (avoids duplicate Jira issues).
@shared_task(
base=RLSTask,
name="integration-jira",
queue="integrations",
+ acks_late=False,
)
def jira_integration_task(
tenant_id: str,
diff --git a/api/src/backend/tasks/tests/test_deletion.py b/api/src/backend/tasks/tests/test_deletion.py
index e6cd51aca8..0ed8c5ddb2 100644
--- a/api/src/backend/tasks/tests/test_deletion.py
+++ b/api/src/backend/tasks/tests/test_deletion.py
@@ -1,12 +1,11 @@
from unittest.mock import call, patch
-from uuid import uuid4
import pytest
from django.core.exceptions import ObjectDoesNotExist
from tasks.jobs.deletion import delete_provider, delete_tenant
from api.attack_paths import database as graph_database
-from api.models import JiraIssueDispatch, Provider, Tenant, TenantComplianceSummary
+from api.models import Provider, Tenant, TenantComplianceSummary
@pytest.mark.django_db
@@ -35,43 +34,6 @@ class TestDeleteProvider:
str(instance.id),
)
- def test_delete_provider_removes_jira_dispatches(
- self,
- providers_fixture,
- findings_fixture,
- integrations_fixture,
- ):
- """Deleting a provider removes JiraIssueDispatch rows for its findings only."""
- instance = providers_fixture[0]
- tenant_id = str(instance.tenant_id)
- finding = findings_fixture[0]
- integration = integrations_fixture[0]
-
- # Dispatch for one of the provider's findings: must be removed with it.
- JiraIssueDispatch.objects.create(
- tenant_id=tenant_id,
- integration=integration,
- finding_id=finding.id,
- )
- # Dispatch for an unrelated finding: must survive the provider deletion.
- unrelated = JiraIssueDispatch.objects.create(
- tenant_id=tenant_id,
- integration=integration,
- finding_id=uuid4(),
- )
-
- with (
- patch(
- "tasks.jobs.deletion.graph_database.get_database_name",
- return_value="tenant-db",
- ),
- patch("tasks.jobs.deletion.graph_database.drop_subgraph"),
- ):
- delete_provider(tenant_id, instance.id)
-
- assert not JiraIssueDispatch.objects.filter(finding_id=finding.id).exists()
- assert JiraIssueDispatch.objects.filter(pk=unrelated.pk).exists()
-
def test_delete_provider_does_not_exist(self, tenants_fixture):
with (
patch(
diff --git a/api/src/backend/tasks/tests/test_integrations.py b/api/src/backend/tasks/tests/test_integrations.py
index ba8d52c193..e246405cdd 100644
--- a/api/src/backend/tasks/tests/test_integrations.py
+++ b/api/src/backend/tasks/tests/test_integrations.py
@@ -1640,74 +1640,14 @@ class TestJiraIntegration:
@patch("tasks.jobs.integrations.Finding")
@patch("tasks.jobs.integrations.Integration")
@patch("tasks.jobs.integrations.initialize_prowler_integration")
- @patch("tasks.jobs.integrations.JiraIssueDispatch")
- def test_send_findings_to_jira_skips_already_dispatched(
- self,
- mock_jira_dispatch,
- mock_initialize_integration,
- mock_integration_model,
- mock_finding_model,
- mock_rls_transaction,
- ):
- """A re-run skips findings already ticketed (no duplicate Jira issues)."""
- mock_rls_transaction.return_value.__enter__ = MagicMock()
- mock_rls_transaction.return_value.__exit__ = MagicMock()
- mock_integration_model.objects.get.return_value = MagicMock()
- # finding-1 was already dispatched in a prior run; finding-2 is new.
- mock_jira_dispatch.objects.filter.return_value.values_list.return_value = [
- "finding-1"
- ]
- mock_jira_dispatch.objects.get_or_create.return_value = (MagicMock(), True)
-
- mock_jira_integration = MagicMock()
- mock_jira_integration.send_finding.return_value = True
- mock_initialize_integration.return_value = mock_jira_integration
-
- finding2 = MagicMock()
- finding2.id = "finding-2"
- finding2.check_id = "check_002"
- finding2.severity = "low"
- finding2.status = "FAIL"
- finding2.status_extended = ""
- finding2.compliance = {}
- finding2.resources.exists.return_value = False
- finding2.resources.first.return_value = None
- finding2.scan.provider.provider = "aws"
- finding2.check_metadata = {
- "checktitle": "C2",
- "risk": "",
- "remediation": {"recommendation": {}, "code": {}},
- }
- mock_finding_model.all_objects.select_related.return_value.prefetch_related.return_value.get.return_value = finding2
-
- result = send_findings_to_jira(
- "tenant-123", "integration-456", "PROJ", "Task", ["finding-1", "finding-2"]
- )
-
- # finding-1 skipped (already sent); only finding-2 sent -> no duplicate.
- assert result == {"created_count": 1, "failed_count": 0, "skipped_count": 1}
- mock_jira_integration.send_finding.assert_called_once()
- assert (
- mock_jira_integration.send_finding.call_args.kwargs["check_id"]
- == "check_002"
- )
-
- @patch("tasks.jobs.integrations.rls_transaction")
- @patch("tasks.jobs.integrations.Finding")
- @patch("tasks.jobs.integrations.Integration")
- @patch("tasks.jobs.integrations.initialize_prowler_integration")
- @patch("tasks.jobs.integrations.JiraIssueDispatch")
def test_send_findings_to_jira_success(
self,
- mock_jira_dispatch,
mock_initialize_integration,
mock_integration_model,
mock_finding_model,
mock_rls_transaction,
):
"""Test successful sending of findings to Jira using send_finding method"""
- mock_jira_dispatch.objects.filter.return_value.values_list.return_value = []
- mock_jira_dispatch.objects.get_or_create.return_value = (MagicMock(), True)
tenant_id = "tenant-123"
integration_id = "integration-456"
project_key = "PROJ"
@@ -1799,7 +1739,7 @@ class TestJiraIntegration:
)
# Assertions
- assert result == {"created_count": 2, "failed_count": 0, "skipped_count": 0}
+ assert result == {"created_count": 2, "failed_count": 0}
# Verify Jira integration was initialized
mock_initialize_integration.assert_called_once_with(integration)
@@ -1831,10 +1771,8 @@ class TestJiraIntegration:
@patch("tasks.jobs.integrations.Integration")
@patch("tasks.jobs.integrations.initialize_prowler_integration")
@patch("tasks.jobs.integrations.logger")
- @patch("tasks.jobs.integrations.JiraIssueDispatch")
def test_send_findings_to_jira_partial_failure(
self,
- mock_jira_dispatch,
mock_logger,
mock_initialize_integration,
mock_integration_model,
@@ -1842,8 +1780,6 @@ class TestJiraIntegration:
mock_rls_transaction,
):
"""Test partial failure when sending findings to Jira"""
- mock_jira_dispatch.objects.filter.return_value.values_list.return_value = []
- mock_jira_dispatch.objects.get_or_create.return_value = (MagicMock(), True)
tenant_id = "tenant-123"
integration_id = "integration-456"
project_key = "PROJ"
@@ -1897,35 +1833,23 @@ class TestJiraIntegration:
)
# Assertions
- assert result == {"created_count": 2, "failed_count": 1, "skipped_count": 0}
+ assert result == {"created_count": 2, "failed_count": 1}
# Verify error was logged for the failed finding
mock_logger.error.assert_called_with("Failed to send finding finding-2 to Jira")
- # The failed finding's reservation is released so a later run can retry it.
- mock_jira_dispatch.objects.filter.assert_any_call(
- tenant_id=tenant_id,
- integration_id=integration_id,
- finding_id="finding-2",
- )
- mock_jira_dispatch.objects.filter.return_value.delete.assert_called_once()
-
@patch("tasks.jobs.integrations.rls_transaction")
@patch("tasks.jobs.integrations.Finding")
@patch("tasks.jobs.integrations.Integration")
@patch("tasks.jobs.integrations.initialize_prowler_integration")
- @patch("tasks.jobs.integrations.JiraIssueDispatch")
def test_send_findings_to_jira_no_resources(
self,
- mock_jira_dispatch,
mock_initialize_integration,
mock_integration_model,
mock_finding_model,
mock_rls_transaction,
):
"""Test sending findings to Jira when finding has no resources"""
- mock_jira_dispatch.objects.filter.return_value.values_list.return_value = []
- mock_jira_dispatch.objects.get_or_create.return_value = (MagicMock(), True)
tenant_id = "tenant-123"
integration_id = "integration-456"
project_key = "PROJ"
@@ -1983,7 +1907,7 @@ class TestJiraIntegration:
)
# Assertions
- assert result == {"created_count": 1, "failed_count": 0, "skipped_count": 0}
+ assert result == {"created_count": 1, "failed_count": 0}
# Verify send_finding was called with empty resource fields
call_kwargs = mock_jira_integration.send_finding.call_args.kwargs
@@ -1996,18 +1920,14 @@ class TestJiraIntegration:
@patch("tasks.jobs.integrations.Finding")
@patch("tasks.jobs.integrations.Integration")
@patch("tasks.jobs.integrations.initialize_prowler_integration")
- @patch("tasks.jobs.integrations.JiraIssueDispatch")
def test_send_findings_to_jira_with_empty_check_metadata(
self,
- mock_jira_dispatch,
mock_initialize_integration,
mock_integration_model,
mock_finding_model,
mock_rls_transaction,
):
"""Test sending findings to Jira when check_metadata is empty or missing fields"""
- mock_jira_dispatch.objects.filter.return_value.values_list.return_value = []
- mock_jira_dispatch.objects.get_or_create.return_value = (MagicMock(), True)
tenant_id = "tenant-123"
integration_id = "integration-456"
project_key = "PROJ"
@@ -2050,7 +1970,7 @@ class TestJiraIntegration:
)
# Assertions
- assert result == {"created_count": 1, "failed_count": 0, "skipped_count": 0}
+ assert result == {"created_count": 1, "failed_count": 0}
# Verify send_finding was called with default/empty values
call_kwargs = mock_jira_integration.send_finding.call_args.kwargs
@@ -2063,94 +1983,3 @@ class TestJiraIntegration:
assert call_kwargs["remediation_code_cli"] == ""
assert call_kwargs["remediation_code_other"] == ""
assert call_kwargs["compliance"] == {}
-
- @patch("tasks.jobs.integrations.rls_transaction")
- @patch("tasks.jobs.integrations.Finding")
- @patch("tasks.jobs.integrations.Integration")
- @patch("tasks.jobs.integrations.initialize_prowler_integration")
- @patch("tasks.jobs.integrations.JiraIssueDispatch")
- def test_send_findings_to_jira_reserves_before_sending(
- self,
- mock_jira_dispatch,
- mock_initialize_integration,
- mock_integration_model,
- mock_finding_model,
- mock_rls_transaction,
- ):
- """The dispatch row is reserved before the external Jira call (reserve-then-act)."""
- mock_rls_transaction.return_value.__enter__ = MagicMock()
- mock_rls_transaction.return_value.__exit__ = MagicMock()
- mock_integration_model.objects.get.return_value = MagicMock()
- mock_jira_dispatch.objects.filter.return_value.values_list.return_value = []
-
- order = []
- mock_jira_dispatch.objects.get_or_create.side_effect = lambda **kw: (
- order.append(("reserve", kw)) or (MagicMock(), True)
- )
-
- mock_jira_integration = MagicMock()
- mock_jira_integration.send_finding.side_effect = lambda **kw: (
- order.append(("send", kw)) or True
- )
- mock_initialize_integration.return_value = mock_jira_integration
-
- finding = MagicMock()
- finding.id = "finding-1"
- finding.check_id = "check_001"
- finding.severity = "low"
- finding.status = "FAIL"
- finding.status_extended = ""
- finding.compliance = {}
- finding.resources.exists.return_value = False
- finding.resources.first.return_value = None
- finding.scan.provider.provider = "aws"
- finding.check_metadata = {
- "checktitle": "C1",
- "risk": "",
- "remediation": {"recommendation": {}, "code": {}},
- }
- mock_finding_model.all_objects.select_related.return_value.prefetch_related.return_value.get.return_value = finding
-
- result = send_findings_to_jira(
- "tenant-123", "integration-456", "PROJ", "Task", ["finding-1"]
- )
-
- assert result == {"created_count": 1, "failed_count": 0, "skipped_count": 0}
- # Reservation must precede the external send.
- assert [entry[0] for entry in order] == ["reserve", "send"]
- # A successful send keeps the reservation (no rollback delete).
- mock_jira_dispatch.objects.filter.return_value.delete.assert_not_called()
-
- @patch("tasks.jobs.integrations.rls_transaction")
- @patch("tasks.jobs.integrations.Finding")
- @patch("tasks.jobs.integrations.Integration")
- @patch("tasks.jobs.integrations.initialize_prowler_integration")
- @patch("tasks.jobs.integrations.JiraIssueDispatch")
- def test_send_findings_to_jira_skips_when_already_reserved(
- self,
- mock_jira_dispatch,
- mock_initialize_integration,
- mock_integration_model,
- mock_finding_model,
- mock_rls_transaction,
- ):
- """A finding that races past the bulk pre-check but loses the reservation
- (created=False) is skipped without a second issue, leaving the row intact."""
- mock_rls_transaction.return_value.__enter__ = MagicMock()
- mock_rls_transaction.return_value.__exit__ = MagicMock()
- mock_integration_model.objects.get.return_value = MagicMock()
- mock_jira_dispatch.objects.filter.return_value.values_list.return_value = []
- # Another concurrent run already created the dispatch row.
- mock_jira_dispatch.objects.get_or_create.return_value = (MagicMock(), False)
-
- mock_jira_integration = MagicMock()
- mock_initialize_integration.return_value = mock_jira_integration
-
- result = send_findings_to_jira(
- "tenant-123", "integration-456", "PROJ", "Task", ["finding-1"]
- )
-
- assert result == {"created_count": 0, "failed_count": 0, "skipped_count": 1}
- mock_jira_integration.send_finding.assert_not_called()
- # The reservation belongs to the run that won the race; do not delete it.
- mock_jira_dispatch.objects.filter.return_value.delete.assert_not_called()
diff --git a/api/src/backend/tasks/tests/test_orphan_recovery.py b/api/src/backend/tasks/tests/test_orphan_recovery.py
index b426297bbd..abfa920b8e 100644
--- a/api/src/backend/tasks/tests/test_orphan_recovery.py
+++ b/api/src/backend/tasks/tests/test_orphan_recovery.py
@@ -4,17 +4,17 @@ from uuid import uuid4
import pytest
from celery import states
+from django.test import override_settings
from django_celery_results.models import TaskResult
-from api.models import Scan, StateChoices
-from api.models import Task as APITask
from tasks.jobs.orphan_recovery import (
_decode_celery_field,
_reconcile_task_results,
_recovery_attempt_count,
- _reenqueue_scan,
advisory_lock,
is_worker_alive,
+ reconcile_orphans,
+ reenqueueable_tasks,
)
@@ -130,9 +130,83 @@ class TestReconcileTaskResults:
assert tr.task_id in result["failed"]
mock_task.apply_async.assert_not_called()
- def test_jira_integration_task_is_reenqueued(self, tenants_fixture):
- """integration-jira is re-enqueued: its JiraIssueDispatch reservation makes a
- re-run skip already-ticketed findings, so recovery cannot duplicate issues."""
+ @override_settings(TASK_RECOVERY_SUMMARIES_ENABLED=False)
+ def test_disabled_group_task_is_not_reenqueued(self, tenants_fixture):
+ """A task whose group feature flag is off stays terminal, not re-enqueued."""
+ tr = _orphan_result(
+ name="scan-summary",
+ kwargs={
+ "tenant_id": str(tenants_fixture[0].id),
+ "scan_id": str(uuid4()),
+ },
+ worker="dead@gone",
+ created_minutes_ago=60,
+ )
+ p_alive, p_revoke, p_app, mock_task = self._patches(alive=False)
+ with (
+ p_alive,
+ p_revoke,
+ p_app,
+ patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=1),
+ ):
+ result = _reconcile_task_results(
+ grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False
+ )
+
+ assert tr.task_id in result["failed"]
+ mock_task.apply_async.assert_not_called()
+
+ @override_settings(TASK_RECOVERY_SUMMARIES_ENABLED=False)
+ def test_disabled_group_task_does_not_consume_recovery_attempt(
+ self, tenants_fixture
+ ):
+ """A disabled-group task is failed without incrementing its Valkey attempt
+ counter, so re-enabling the group does not start it at the cap."""
+ tr = _orphan_result(
+ name="scan-summary",
+ kwargs={"tenant_id": str(tenants_fixture[0].id), "scan_id": str(uuid4())},
+ worker="dead@gone",
+ created_minutes_ago=60,
+ )
+ p_alive, p_revoke, p_app, mock_task = self._patches(alive=False)
+ with (
+ p_alive,
+ p_revoke,
+ p_app,
+ patch("tasks.jobs.orphan_recovery._recovery_attempt_count") as mock_count,
+ ):
+ result = _reconcile_task_results(
+ grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False
+ )
+
+ assert tr.task_id in result["failed"]
+ mock_count.assert_not_called()
+
+ def test_scan_task_is_skipped_entirely(self, tenants_fixture):
+ """Scan tasks are excluded from recovery: the watchdog never touches them."""
+ tr = _orphan_result(
+ name="scan-perform",
+ kwargs={
+ "tenant_id": str(tenants_fixture[0].id),
+ "scan_id": str(uuid4()),
+ },
+ worker="dead@gone",
+ created_minutes_ago=60,
+ )
+ p_alive, p_revoke, p_app, mock_task = self._patches(alive=False)
+ with p_alive, p_revoke, p_app:
+ result = _reconcile_task_results(
+ grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False
+ )
+
+ assert tr.task_id not in result["recovered"]
+ assert tr.task_id not in result["failed"]
+ assert tr.task_id not in result["skipped"]
+ mock_task.apply_async.assert_not_called()
+
+ def test_jira_integration_task_is_not_reenqueued(self, tenants_fixture):
+ """integration-jira stays terminal: re-running it would create duplicate Jira
+ issues, so an orphaned send is failed instead of re-enqueued."""
tenant = tenants_fixture[0]
kwargs = {
"tenant_id": str(tenant.id),
@@ -158,13 +232,10 @@ class TestReconcileTaskResults:
grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False
)
- assert tr.task_id in result["recovered"]
+ assert tr.task_id in result["failed"]
tr.refresh_from_db()
assert tr.status == states.REVOKED # stale result cleared (no pending alert)
- mock_task.apply_async.assert_called_once()
- call = mock_task.apply_async.call_args.kwargs
- assert call["kwargs"] == kwargs
- assert call["task_id"] != tr.task_id # fresh task id
+ mock_task.apply_async.assert_not_called()
def test_skips_live_worker(self, tenants_fixture):
tr = _orphan_result(
@@ -246,98 +317,6 @@ class TestReconcileTaskResults:
mock_task.apply_async.assert_not_called()
-@pytest.mark.django_db
-class TestScanRecovery:
- """Scans are recovered by re-running scan-perform on the EXISTING scan row,
- so even a scheduled-scan orphan (whose own task would no-op on its guard) is
- actually re-executed."""
-
- def _scan_orphan(self, tenant, provider, name):
- old_id = str(uuid4())
- tr = TaskResult.objects.create(
- task_id=old_id,
- status=states.STARTED,
- task_name=name,
- worker="dead@gone",
- task_kwargs=repr(
- {"tenant_id": str(tenant.id), "provider_id": str(provider.id)}
- ),
- task_args=repr([]),
- )
- TaskResult.objects.filter(pk=tr.pk).update(
- date_created=datetime.now(tz=timezone.utc) - timedelta(minutes=60)
- )
- APITask.objects.create(id=old_id, tenant_id=tenant.id, task_runner_task=tr)
- scan = Scan.objects.create(
- name="scan-orphan",
- provider=provider,
- trigger=Scan.TriggerChoices.SCHEDULED,
- state=StateChoices.EXECUTING,
- tenant_id=tenant.id,
- task_id=old_id,
- recovery_count=0,
- )
- return old_id, scan
-
- @pytest.mark.parametrize("name", ["scan-perform", "scan-perform-scheduled"])
- def test_scan_recovered_via_scan_perform(
- self, tenants_fixture, providers_fixture, name
- ):
- tenant, provider = tenants_fixture[0], providers_fixture[0]
- old_id, scan = self._scan_orphan(tenant, provider, name)
-
- with (
- patch("tasks.jobs.orphan_recovery.is_worker_alive", return_value=False),
- patch("tasks.jobs.orphan_recovery.revoke_task"),
- patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=1),
- patch("tasks.tasks.perform_scan_task") as mock_scan_task,
- ):
- result = _reconcile_task_results(
- grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False
- )
-
- assert old_id in result["recovered"]
- scan.refresh_from_db()
- assert str(scan.task_id) != old_id # relinked to a fresh task
- assert scan.recovery_count == 1
- assert TaskResult.objects.get(task_id=old_id).status == states.REVOKED
- # Recovered by re-running scan-perform on the existing scan row (so the
- # scheduled guard cannot no-op it), regardless of the original task name.
- mock_scan_task.apply_async.assert_called_once()
- assert mock_scan_task.apply_async.call_args.kwargs["kwargs"]["scan_id"] == str(
- scan.id
- )
-
- def test_reenqueue_skips_when_scan_already_repointed(
- self, tenants_fixture, providers_fixture
- ):
- # The scan already points at a newer task, so a stale orphan must not relink
- # it or launch a second concurrent run against the same scan row.
- tenant, provider = tenants_fixture[0], providers_fixture[0]
- newer_id = str(uuid4())
- tr = TaskResult.objects.create(
- task_id=newer_id, status=states.STARTED, task_name="scan-perform"
- )
- APITask.objects.create(id=newer_id, tenant_id=tenant.id, task_runner_task=tr)
- scan = Scan.objects.create(
- name="scan-orphan",
- provider=provider,
- trigger=Scan.TriggerChoices.SCHEDULED,
- state=StateChoices.EXECUTING,
- tenant_id=tenant.id,
- task_id=newer_id,
- recovery_count=0,
- )
-
- with patch("tasks.tasks.perform_scan_task") as mock_scan_task:
- recovered = _reenqueue_scan(str(uuid4()), scan)
-
- assert recovered is False
- mock_scan_task.apply_async.assert_not_called()
- scan.refresh_from_db()
- assert scan.recovery_count == 0
-
-
@pytest.mark.django_db
class TestOrphanRecoveryHelpers:
def test_advisory_lock_acquires_and_releases(self):
@@ -370,3 +349,60 @@ class TestOrphanRecoveryHelpers:
with patch("redis.from_url", return_value=redis_client):
assert _recovery_attempt_count("probe-task", kwargs_repr, 6) == 1
assert _recovery_attempt_count("probe-task", kwargs_repr, 6) == 2
+
+
+class TestRecoveryFeatureFlags:
+ def test_all_groups_enabled_by_default(self):
+ tasks = reenqueueable_tasks()
+ assert "scan-summary" in tasks
+ assert {"provider-deletion", "tenant-deletion"} <= tasks
+
+ @override_settings(TASK_RECOVERY_SUMMARIES_ENABLED=False)
+ def test_summaries_group_flag_excludes_summary_tasks(self):
+ tasks = reenqueueable_tasks()
+ assert "scan-summary" not in tasks
+ assert "scan-compliance-overviews" not in tasks
+ assert "provider-deletion" in tasks
+
+ @override_settings(TASK_RECOVERY_DELETIONS_ENABLED=False)
+ def test_deletions_group_flag_excludes_deletion_tasks(self):
+ tasks = reenqueueable_tasks()
+ assert "provider-deletion" not in tasks
+ assert "tenant-deletion" not in tasks
+ assert "scan-summary" in tasks
+
+
+@pytest.mark.django_db
+class TestRecoveryMasterFlag:
+ @override_settings(TASK_RECOVERY_ENABLED=False)
+ def test_master_flag_disables_task_recovery(self):
+ with (
+ patch(
+ "tasks.jobs.orphan_recovery._reconcile_task_results"
+ ) as mock_reconcile,
+ patch(
+ "tasks.jobs.attack_paths.cleanup.cleanup_stale_attack_paths_scans",
+ return_value={},
+ ),
+ ):
+ result = reconcile_orphans(grace_minutes=2, max_attempts=3, dry_run=False)
+
+ mock_reconcile.assert_not_called()
+ assert result["acquired"] is True
+ assert result["enabled"] is False
+
+ @override_settings(TASK_RECOVERY_ENABLED=True)
+ def test_master_flag_enabled_runs_task_recovery(self):
+ with (
+ patch(
+ "tasks.jobs.orphan_recovery._reconcile_task_results",
+ return_value={"recovered": [], "failed": [], "skipped": []},
+ ) as mock_reconcile,
+ patch(
+ "tasks.jobs.attack_paths.cleanup.cleanup_stale_attack_paths_scans",
+ return_value={},
+ ),
+ ):
+ reconcile_orphans(grace_minutes=2, max_attempts=3, dry_run=False)
+
+ mock_reconcile.assert_called_once()
diff --git a/api/src/backend/tasks/tests/test_scan.py b/api/src/backend/tasks/tests/test_scan.py
index 2ff7e44e4d..8d3d0be93a 100644
--- a/api/src/backend/tasks/tests/test_scan.py
+++ b/api/src/backend/tasks/tests/test_scan.py
@@ -32,15 +32,12 @@ from tasks.utils import CustomEncoder
from api.db_router import MainRouter
from api.exceptions import ProviderConnectionError
from api.models import (
- AttackSurfaceOverview,
Finding,
MuteRule,
Provider,
Resource,
ResourceScanSummary,
Scan,
- ScanCategorySummary,
- ScanGroupSummary,
ScanSummary,
StateChoices,
StatusChoices,
@@ -232,131 +229,6 @@ class TestPerformScan:
# Assert that failed_findings_count is 0 (finding is PASS and muted)
assert scan_resource.failed_findings_count == 0
- def test_perform_prowler_scan_idempotent_on_rerun(
- self,
- tenants_fixture,
- scans_fixture,
- providers_fixture,
- ):
- """Re-running a scan for the same scan_id must not duplicate findings."""
- with (
- patch("api.db_utils.rls_transaction"),
- patch(
- "tasks.jobs.scan.initialize_prowler_provider"
- ) as mock_initialize_prowler_provider,
- patch("tasks.jobs.scan.ProwlerScan") as mock_prowler_scan_class,
- patch(
- "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE",
- new_callable=dict,
- ),
- patch("api.compliance.PROWLER_CHECKS", new_callable=dict) as mock_checks,
- ):
- mock_checks["aws"] = {"check1": {"compliance1"}}
-
- tenant = tenants_fixture[0]
- scan = scans_fixture[0]
- provider = providers_fixture[0]
- provider.provider = Provider.ProviderChoices.AWS
- provider.save()
-
- tenant_id = str(tenant.id)
- scan_id = str(scan.id)
- provider_id = str(provider.id)
-
- stale_resource = Resource.objects.create(
- tenant_id=tenant.id,
- provider=provider,
- uid="stale_resource_uid",
- name="stale",
- region="stale-region",
- service="stale-service",
- type="stale-type",
- )
- ResourceScanSummary.objects.create(
- tenant_id=tenant.id,
- scan_id=scan.id,
- resource_id=stale_resource.id,
- service="stale-service",
- region="stale-region",
- resource_type="stale-type",
- )
- ScanCategorySummary.objects.create(
- tenant_id=tenant.id,
- scan=scan,
- category="stale-category",
- severity=Severity.medium,
- total_findings=1,
- )
- ScanGroupSummary.objects.create(
- tenant_id=tenant.id,
- scan=scan,
- resource_group="stale-group",
- severity=Severity.medium,
- total_findings=1,
- )
- ScanSummary.objects.create(
- tenant_id=tenant.id,
- scan=scan,
- check_id="stale_check",
- service="stale-service",
- severity=Severity.medium,
- region="stale-region",
- total=1,
- )
- AttackSurfaceOverview.objects.create(
- tenant_id=tenant.id,
- scan=scan,
- attack_surface_type=AttackSurfaceOverview.AttackSurfaceTypeChoices.SECRETS,
- total_findings=1,
- )
-
- finding = MagicMock()
- finding.uid = "dup_probe_finding"
- finding.status = StatusChoices.PASS
- finding.status_extended = "x"
- finding.severity = Severity.medium
- finding.check_id = "check1"
- finding.get_metadata.return_value = {"key": "value"}
- finding.resource_uid = "resource_uid"
- finding.resource_name = "resource_name"
- finding.region = "region"
- finding.service_name = "service_name"
- finding.resource_type = "resource_type"
- finding.resource_tags = {}
- finding.muted = False
- finding.raw = {}
- finding.resource_metadata = {}
- finding.resource_details = {}
- finding.partition = "partition"
- finding.compliance = {}
-
- mock_scan_instance = MagicMock()
- mock_scan_instance.scan.return_value = [(100, [finding])]
- mock_prowler_scan_class.return_value = mock_scan_instance
-
- mock_provider_instance = MagicMock()
- mock_provider_instance.get_regions.return_value = ["region"]
- mock_initialize_prowler_provider.return_value = mock_provider_instance
-
- # Run the same scan twice (simulating an orphan-recovery re-run).
- perform_prowler_scan(tenant_id, scan_id, provider_id, ["check1"])
- perform_prowler_scan(tenant_id, scan_id, provider_id, ["check1"])
-
- # Neither findings nor resources are duplicated by the re-run: findings are
- # scope-deleted before re-insert; resources are upserted by (tenant, provider, uid).
- assert Finding.objects.filter(scan=scan).count() == 1
- assert Resource.objects.filter(provider=provider).count() == 2
- assert ResourceScanSummary.objects.filter(scan_id=scan.id).count() == 1
- assert not ResourceScanSummary.objects.filter(
- scan_id=scan.id, resource_id=stale_resource.id
- ).exists()
- assert not ScanCategorySummary.objects.filter(scan=scan).exists()
- assert not ScanGroupSummary.objects.filter(scan=scan).exists()
- assert not ScanSummary.objects.filter(
- scan=scan, check_id="stale_check"
- ).exists()
- assert not AttackSurfaceOverview.objects.filter(scan=scan).exists()
-
@patch("tasks.jobs.scan.ProwlerScan")
@patch(
"tasks.jobs.scan.initialize_prowler_provider",
From 62955dd16b4a32fa6d1fb6aee29e732c979fee79 Mon Sep 17 00:00:00 2001
From: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com>
Date: Tue, 9 Jun 2026 10:17:23 +0200
Subject: [PATCH 025/129] feat(okta): add authenticator STIG checks (#11465)
Co-authored-by: Daniel Barranquero
---
.../providers/okta/authentication.mdx | 10 +-
.../providers/okta/getting-started-okta.mdx | 14 +-
prowler/CHANGELOG.md | 1 +
prowler/providers/okta/okta_provider.py | 1 +
.../okta/services/authenticator/__init__.py | 0
.../authenticator/authenticator_client.py | 6 +
.../__init__.py | 0
...r_okta_verify_fips_compliant.metadata.json | 38 +++
...uthenticator_okta_verify_fips_compliant.py | 65 +++++
.../__init__.py | 0
...ssword_common_password_check.metadata.json | 37 +++
...nticator_password_common_password_check.py | 60 +++++
.../__init__.py | 0
...assword_complexity_lowercase.metadata.json | 37 +++
...enticator_password_complexity_lowercase.py | 60 +++++
.../__init__.py | 0
...r_password_complexity_number.metadata.json | 37 +++
...uthenticator_password_complexity_number.py | 60 +++++
.../__init__.py | 0
...r_password_complexity_symbol.metadata.json | 37 +++
...uthenticator_password_complexity_symbol.py | 60 +++++
.../__init__.py | 0
...assword_complexity_uppercase.metadata.json | 37 +++
...enticator_password_complexity_uppercase.py | 60 +++++
.../__init__.py | 0
...enticator_password_history_5.metadata.json | 37 +++
.../authenticator_password_history_5.py | 60 +++++
.../__init__.py | 0
...password_lockout_threshold_3.metadata.json | 37 +++
...henticator_password_lockout_threshold_3.py | 60 +++++
.../__init__.py | 0
...tor_password_maximum_age_60d.metadata.json | 37 +++
.../authenticator_password_maximum_age_60d.py | 60 +++++
.../__init__.py | 0
...tor_password_minimum_age_24h.metadata.json | 37 +++
.../authenticator_password_minimum_age_24h.py | 60 +++++
.../__init__.py | 0
...r_password_minimum_length_15.metadata.json | 37 +++
...uthenticator_password_minimum_length_15.py | 60 +++++
.../authenticator/authenticator_service.py | 236 ++++++++++++++++++
.../__init__.py | 0
...henticator_smart_card_active.metadata.json | 37 +++
.../authenticator_smart_card_active.py | 56 +++++
.../services/authenticator/lib/__init__.py | 0
.../lib/authenticator_helpers.py | 49 ++++
.../lib/password_policy_helpers.py | 80 ++++++
tests/providers/okta/okta_fixtures.py | 2 +
.../authenticator/authenticator_fixtures.py | 76 ++++++
...ticator_okta_verify_fips_compliant_test.py | 74 ++++++
...tor_password_common_password_check_test.py | 60 +++++
...ator_password_complexity_lowercase_test.py | 49 ++++
...ticator_password_complexity_number_test.py | 49 ++++
...ticator_password_complexity_symbol_test.py | 49 ++++
...ator_password_complexity_uppercase_test.py | 49 ++++
.../authenticator_password_history_5_test.py | 49 ++++
...cator_password_lockout_threshold_3_test.py | 49 ++++
...enticator_password_maximum_age_60d_test.py | 49 ++++
...enticator_password_minimum_age_24h_test.py | 49 ++++
...ticator_password_minimum_length_15_test.py | 62 +++++
.../authenticator_service_test.py | 188 ++++++++++++++
.../authenticator_smart_card_active_test.py | 74 ++++++
61 files changed, 2481 insertions(+), 10 deletions(-)
create mode 100644 prowler/providers/okta/services/authenticator/__init__.py
create mode 100644 prowler/providers/okta/services/authenticator/authenticator_client.py
create mode 100644 prowler/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/__init__.py
create mode 100644 prowler/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant.metadata.json
create mode 100644 prowler/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant.py
create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_common_password_check/__init__.py
create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check.metadata.json
create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check.py
create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/__init__.py
create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase.metadata.json
create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase.py
create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_complexity_number/__init__.py
create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number.metadata.json
create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number.py
create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_complexity_symbol/__init__.py
create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol.metadata.json
create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol.py
create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/__init__.py
create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase.metadata.json
create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase.py
create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_history_5/__init__.py
create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5.metadata.json
create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5.py
create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/__init__.py
create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3.metadata.json
create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3.py
create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/__init__.py
create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d.metadata.json
create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d.py
create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/__init__.py
create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h.metadata.json
create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h.py
create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_minimum_length_15/__init__.py
create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15.metadata.json
create mode 100644 prowler/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15.py
create mode 100644 prowler/providers/okta/services/authenticator/authenticator_service.py
create mode 100644 prowler/providers/okta/services/authenticator/authenticator_smart_card_active/__init__.py
create mode 100644 prowler/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active.metadata.json
create mode 100644 prowler/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active.py
create mode 100644 prowler/providers/okta/services/authenticator/lib/__init__.py
create mode 100644 prowler/providers/okta/services/authenticator/lib/authenticator_helpers.py
create mode 100644 prowler/providers/okta/services/authenticator/lib/password_policy_helpers.py
create mode 100644 tests/providers/okta/services/authenticator/authenticator_fixtures.py
create mode 100644 tests/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant_test.py
create mode 100644 tests/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check_test.py
create mode 100644 tests/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase_test.py
create mode 100644 tests/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number_test.py
create mode 100644 tests/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol_test.py
create mode 100644 tests/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase_test.py
create mode 100644 tests/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5_test.py
create mode 100644 tests/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3_test.py
create mode 100644 tests/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d_test.py
create mode 100644 tests/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h_test.py
create mode 100644 tests/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15_test.py
create mode 100644 tests/providers/okta/services/authenticator/authenticator_service_test.py
create mode 100644 tests/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active_test.py
diff --git a/docs/user-guide/providers/okta/authentication.mdx b/docs/user-guide/providers/okta/authentication.mdx
index 74e8ef7805..2e08cac8af 100644
--- a/docs/user-guide/providers/okta/authentication.mdx
+++ b/docs/user-guide/providers/okta/authentication.mdx
@@ -35,6 +35,7 @@ The bundled checks require the following read-only scopes:
- `okta.policies.read`
- `okta.brands.read`
- `okta.apps.read`
+- `okta.authenticators.read`
- `okta.networkZones.read`
- `okta.apiTokens.read`
- `okta.roles.read`
@@ -49,6 +50,7 @@ Additional scopes will be needed as more services and checks are added. These ar
| `okta.policies.read` | Sign-on, password, authentication, and `USER_LIFECYCLE` (Workflow > Automations) policies |
| `okta.brands.read` | Sign-in page customizations (DOD Notice and Consent Banner check) |
| `okta.apps.read` | First-party app settings (Okta Admin Console session), integrated app inventory, and the Authentication Policies bound to Okta applications |
+| `okta.authenticators.read` | Okta authenticator configuration, including Okta Verify and Smart Card IdP |
| `okta.networkZones.read` | Network Zone inventory, anonymized-proxy blocklist checks, and API token Network Zone validation |
| `okta.apiTokens.read` | API token metadata and token network conditions |
| `okta.roles.read` | Admin role assignments for API token owners (both direct and group-inherited) |
@@ -136,7 +138,7 @@ Okta displays the private key **only once**. If you close the modal without copy
### 5. Grant the required OAuth scopes
-On the app, open the **Okta API Scopes** tab and click **Grant** on every scope Prowler needs. The bundled checks require `okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.networkZones.read`, `okta.apiTokens.read`, `okta.roles.read`, `okta.groups.read`, `okta.logStreams.read`, and `okta.idps.read`.
+On the app, open the **Okta API Scopes** tab and click **Grant** on every scope Prowler needs. The bundled checks require `okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.authenticators.read`, `okta.networkZones.read`, `okta.apiTokens.read`, `okta.roles.read`, `okta.groups.read`, `okta.logStreams.read`, and `okta.idps.read`.

@@ -172,8 +174,8 @@ export OKTA_PRIVATE_KEY_FILE="/secure/path/to/prowler-okta.pem"
# or
export OKTA_PRIVATE_KEY="$(cat /secure/path/to/prowler-okta.pem)"
-# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read"
-export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read"
+# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read,okta.authenticators.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read"
+export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.authenticators.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read"
uv run python prowler-cli.py okta
```
@@ -214,7 +216,7 @@ Prowler validates credentials at startup by listing one sign-on policy. This err
Raised when the credential probe succeeds at the OAuth layer but the request is rejected because the service app lacks the required scope or admin role:
-- **`invalid_scope`** — one of the requested scopes (`okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.networkZones.read`, `okta.apiTokens.read`, `okta.roles.read`, `okta.groups.read`, `okta.logStreams.read`, and `okta.idps.read`) is not granted on the service app. Grant the missing scope from **Okta API Scopes**.
+- **`invalid_scope`** — one of the requested scopes (`okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.authenticators.read`, `okta.networkZones.read`, `okta.apiTokens.read`, `okta.roles.read`, `okta.groups.read`, `okta.logStreams.read`, and `okta.idps.read`) is not granted on the service app. Grant the missing scope from **Okta API Scopes**.
- **`Forbidden` / `not authorized`** — no admin role is assigned to the service app. Assign **Read-Only Administrator** (or **Super Administrator** for the first-party application checks) from **Admin roles**.
### Application-service checks return MANUAL on first-party apps
diff --git a/docs/user-guide/providers/okta/getting-started-okta.mdx b/docs/user-guide/providers/okta/getting-started-okta.mdx
index 32c08433b7..e04e0d4a13 100644
--- a/docs/user-guide/providers/okta/getting-started-okta.mdx
+++ b/docs/user-guide/providers/okta/getting-started-okta.mdx
@@ -12,7 +12,7 @@ Set up authentication for Okta with the [Okta Authentication](/user-guide/provid
- An Okta organization. The UI examples below use **Identity Engine** terminology such as **Global Session Policy**; Classic Engine exposes the equivalent sign-on policy concepts under older names.
- A **Super Administrator** account on that organization for the one-time service-app setup.
-- An **API Services** app integration in the Okta Admin Console with the `okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.networkZones.read`, `okta.apiTokens.read`, `okta.roles.read`, `okta.groups.read`, `okta.logStreams.read`, and `okta.idps.read` scopes granted and an admin role assigned. **Read-Only Administrator** covers the Sign-On, Network, API Token, User, System Log, and Identity Provider checks, and runs the per-app application network-zone check against the apps the service app can see (under Read-Only Administrator that is typically only the service app's own row — the rest of the org's app inventory stays invisible). **Super Administrator** is required additionally to evaluate the five first-party application checks (Okta Admin Console / Okta Dashboard idle timeout, MFA, phishing-resistant authentication) and to widen the application network-zone check to the full app inventory — see [Okta Authentication](/user-guide/providers/okta/authentication#required-admin-role) for the full breakdown.
+- An **API Services** app integration in the Okta Admin Console with the `okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.authenticators.read`, `okta.networkZones.read`, `okta.apiTokens.read`, `okta.roles.read`, `okta.groups.read`, `okta.logStreams.read`, and `okta.idps.read` scopes granted and an admin role assigned. **Read-Only Administrator** covers the Sign-On, Network, API Token, User, System Log, and Identity Provider checks, and runs the per-app application network-zone check against the apps the service app can see (under Read-Only Administrator that is typically only the service app's own row — the rest of the org's app inventory stays invisible). **Super Administrator** is required additionally to evaluate the five first-party application checks (Okta Admin Console / Okta Dashboard idle timeout, MFA, phishing-resistant authentication) and to widen the application network-zone check to the full app inventory — see [Okta Authentication](/user-guide/providers/okta/authentication#required-admin-role) for the full breakdown.
- Python 3.10+ and Prowler 5.27.0 or later installed locally.
@@ -85,8 +85,8 @@ Follow the [Okta Authentication](/user-guide/providers/okta/authentication) guid
export OKTA_ORG_DOMAIN="acme.okta.com"
export OKTA_CLIENT_ID="0oa1234567890abcdef"
export OKTA_PRIVATE_KEY_FILE="/secure/path/to/prowler-okta.pem"
-# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read"
-export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read"
+# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read,okta.authenticators.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read"
+export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.authenticators.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read"
```
The private key file may contain either a PEM-encoded RSA key or a JWK JSON document.
@@ -147,6 +147,7 @@ Prowler for Okta includes security checks across the following services:
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Sign-On** | Global session policy controls (idle timeout, lifetime, rule priority and ordering) |
| **Application** | Okta Admin Console sign-on settings plus Authentication Policy controls for Okta applications (session idle, MFA, phishing resistance, network zones) |
+| **Authenticator** | Password Policy controls plus Okta Verify FIPS and Smart Card IdP authenticator status |
| **Network** | Network Zone blocklists for anonymized proxy sources |
| **API Token** | API token owner-role validation and Network Zone restrictions |
| **User** | User lifecycle automations (inactivity-based deprovisioning) |
@@ -163,11 +164,12 @@ This is stricter than simply finding the same timeout value somewhere else in th
### Default Scopes
-Prowler requests a fixed set of OAuth scopes on every token exchange. The defaults cover every bundled check across the Sign-On, Application, Network, API Token, User, System Log, and Identity Provider services:
+Prowler requests a fixed set of OAuth scopes on every token exchange. The defaults cover every bundled check across the Sign-On, Application, Authenticator, Network, API Token, User, System Log, and Identity Provider services:
- `okta.policies.read`
- `okta.brands.read`
- `okta.apps.read`
+- `okta.authenticators.read`
- `okta.networkZones.read`
- `okta.apiTokens.read`
- `okta.roles.read`
@@ -181,10 +183,10 @@ When additional checks are enabled — or when running against a service app tha
```bash
# Environment variable — comma-separated
-export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read,okta.users.read"
+export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.authenticators.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read,okta.users.read"
# CLI flag — space-separated
-prowler okta --okta-scopes okta.policies.read okta.brands.read okta.apps.read okta.networkZones.read okta.apiTokens.read okta.roles.read okta.groups.read okta.logStreams.read okta.idps.read okta.users.read
+prowler okta --okta-scopes okta.policies.read okta.brands.read okta.apps.read okta.authenticators.read okta.networkZones.read okta.apiTokens.read okta.roles.read okta.groups.read okta.logStreams.read okta.idps.read okta.users.read
```
For the full catalog of OAuth scopes exposed by the Okta Management API, refer to the [Okta OAuth 2.0 scopes documentation](https://developer.okta.com/docs/api/oauth2/).
diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md
index d28520a220..af0b2f6496 100644
--- a/prowler/CHANGELOG.md
+++ b/prowler/CHANGELOG.md
@@ -8,6 +8,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- `sagemaker_models_monitor_enabled` check for AWS provider, verifying that each SageMaker monitoring schedule is in the `Scheduled` state so data and model drift is actively detected [(#11278)](https://github.com/prowler-cloud/prowler/pull/11278)
- DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554) universal compliance framework with AWS provider coverage across the five DORA pillars [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131)
+- Okta authenticator and password policy checks for STIG-aligned hardening requirements [(#11465)](https://github.com/prowler-cloud/prowler/pull/11465)
- Okta network zone check to detect whether anonymized proxy traffic is blocked [(#11463)](https://github.com/prowler-cloud/prowler/pull/11463)
- Okta API token checks for super admin ownership and network zone restrictions [(#11464)](https://github.com/prowler-cloud/prowler/pull/11464)
- Support for external/custom providers, checks, and compliance frameworks without modifying core code [(#10700)](https://github.com/prowler-cloud/prowler/pull/10700)
diff --git a/prowler/providers/okta/okta_provider.py b/prowler/providers/okta/okta_provider.py
index e5c968e947..8859507ec7 100644
--- a/prowler/providers/okta/okta_provider.py
+++ b/prowler/providers/okta/okta_provider.py
@@ -36,6 +36,7 @@ DEFAULT_SCOPES = [
"okta.policies.read",
"okta.brands.read",
"okta.apps.read",
+ "okta.authenticators.read",
"okta.networkZones.read",
"okta.apiTokens.read",
"okta.roles.read",
diff --git a/prowler/providers/okta/services/authenticator/__init__.py b/prowler/providers/okta/services/authenticator/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/okta/services/authenticator/authenticator_client.py b/prowler/providers/okta/services/authenticator/authenticator_client.py
new file mode 100644
index 0000000000..3657a2821e
--- /dev/null
+++ b/prowler/providers/okta/services/authenticator/authenticator_client.py
@@ -0,0 +1,6 @@
+from prowler.providers.common.provider import Provider
+from prowler.providers.okta.services.authenticator.authenticator_service import (
+ Authenticator,
+)
+
+authenticator_client = Authenticator(Provider.get_global_provider())
diff --git a/prowler/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant.metadata.json
new file mode 100644
index 0000000000..363b95f84a
--- /dev/null
+++ b/prowler/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant.metadata.json
@@ -0,0 +1,38 @@
+{
+ "Provider": "okta",
+ "CheckID": "authenticator_okta_verify_fips_compliant",
+ "CheckTitle": "Okta Verify authenticator is active and restricts enrollment to FIPS-compliant devices",
+ "CheckType": [],
+ "ServiceName": "authenticator",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "medium",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "IAM",
+ "Description": "The **Okta Verify authenticator** (`okta_verify`) must be present, in the `ACTIVE` status, and configured to require FIPS-compliant devices for enrollment (`settings.compliance.fips == REQUIRED`).\n\nMissing, inactive, and active-but-non-FIPS authenticators surface as distinct FAIL findings so the operator can act on the specific gap.",
+ "Risk": "Without FIPS-required enrollment, users can authenticate with devices whose cryptographic modules are not FIPS-validated.\n\n- **Regulatory exposure** under frameworks that mandate FIPS-validated cryptography\n- **Inconsistent assurance** across the user population\n- **Weak baseline** if the authenticator is active but the FIPS flag is `OPTIONAL` or unset",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/authenticator",
+ "https://help.okta.com/en-us/content/topics/mobile/ov-admin-config.htm",
+ "https://help.okta.com/oie/en-us/content/topics/identity-engine/authenticators/configure-okta-verify-options.htm"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "",
+ "NativeIaC": "",
+ "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authenticators**.\n3. Activate the **Okta Verify** authenticator if it is not already `ACTIVE`.\n4. Open the authenticator's settings and set *FIPS Compliance* to **Users enrolling in Okta Verify can use FIPS compliant devices only**.\n5. Save the authenticator.",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Ensure the **Okta Verify** authenticator is:\n- Present in the org (`key = okta_verify`)\n- In the `ACTIVE` status\n- Configured so *FIPS Compliance* is **Required** (`settings.compliance.fips == REQUIRED`)\n\nIf the organization does not require FIPS-validated authenticators, mute the check rather than disabling the FIPS toggle on a partial population.",
+ "Url": "https://hub.prowler.com/check/authenticator_okta_verify_fips_compliant"
+ }
+ },
+ "Categories": [
+ "identity-access"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": "Aligns with DISA STIG V-273205 / OKTA-APP-001700."
+}
diff --git a/prowler/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant.py b/prowler/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant.py
new file mode 100644
index 0000000000..bf3c64b4b8
--- /dev/null
+++ b/prowler/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant.py
@@ -0,0 +1,65 @@
+from prowler.lib.check.models import Check, CheckReportOkta
+from prowler.providers.okta.services.authenticator.authenticator_client import (
+ authenticator_client,
+)
+from prowler.providers.okta.services.authenticator.lib.authenticator_helpers import (
+ find_authenticator_by_key,
+ missing_authenticator_resource,
+ missing_authenticators_scope_finding,
+)
+
+
+class authenticator_okta_verify_fips_compliant(Check):
+ """STIG V-273205 / OKTA-APP-001700.
+
+ The check requires Okta to restrict Okta Verify enrollment to FIPS-compliant devices.
+ """
+
+ def execute(self) -> list[CheckReportOkta]:
+ """Evaluate Okta Verify FIPS compliance settings."""
+ org_domain = authenticator_client.provider.identity.org_domain
+ missing_scope = authenticator_client.missing_scope.get("authenticators")
+ if missing_scope:
+ return [
+ missing_authenticators_scope_finding(
+ self.metadata(),
+ org_domain,
+ "okta_verify",
+ "Okta Verify authenticator",
+ missing_scope,
+ )
+ ]
+
+ authenticator = find_authenticator_by_key(
+ authenticator_client.authenticators, "okta_verify"
+ )
+ resource = authenticator or missing_authenticator_resource(
+ "okta_verify", "Okta Verify authenticator"
+ )
+ report = CheckReportOkta(
+ metadata=self.metadata(), resource=resource, org_domain=org_domain
+ )
+ if not authenticator:
+ report.status = "FAIL"
+ report.status_extended = "Okta Verify authenticator is missing."
+ elif authenticator.status.upper() != "ACTIVE":
+ report.status = "FAIL"
+ report.status_extended = (
+ f"Okta Verify authenticator is not active; current status is "
+ f"{authenticator.status}."
+ )
+ elif authenticator.fips.upper() == "REQUIRED":
+ report.status = "PASS"
+ report.status_extended = (
+ "Okta Verify authenticator requires FIPS-compliant devices "
+ "for enrollment."
+ )
+ else:
+ current_fips = authenticator.fips or "unset"
+ report.status = "FAIL"
+ report.status_extended = (
+ "Okta Verify authenticator is active but does not require "
+ "FIPS-compliant devices for enrollment (current value: "
+ f"{current_fips})."
+ )
+ return [report]
diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_common_password_check/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_password_common_password_check/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check.metadata.json
new file mode 100644
index 0000000000..f61ccef8a1
--- /dev/null
+++ b/prowler/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check.metadata.json
@@ -0,0 +1,37 @@
+{
+ "Provider": "okta",
+ "CheckID": "authenticator_password_common_password_check",
+ "CheckTitle": "Every active Okta Password Policy rejects common passwords",
+ "CheckType": [],
+ "ServiceName": "authenticator",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "medium",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "IAM",
+ "Description": "Every **active Okta Password Policy** must reject passwords found in Okta's common-password dictionary (the *Restrict use of common passwords* setting).\n\nOkta evaluates Password Policies by group assignment; a custom policy with the check disabled can govern users. The check emits one finding per active policy.",
+ "Risk": "Without dictionary checking, users can pick passwords known to attackers from public breaches.\n\n- **Credential stuffing** succeeds with the same passwords compromised elsewhere\n- **Trivial guessing** stays viable for top-N lists (`123456`, `password`, …)\n- **Inconsistent baselines** leave users on legacy policies that allow common passwords",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/policy",
+ "https://help.okta.com/en-us/content/topics/security/policies/configure-password-policies.htm"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "",
+ "NativeIaC": "",
+ "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication** > **Password**.\n3. For every active policy, click **Edit** and enable **Restrict use of common passwords**.\n4. Save the policy.\n5. Repeat for every active Password Policy returned by the check.",
+ "Terraform": "```hcl\nresource \"okta_policy_password\" \"\" {\n name = \"\"\n status = \"ACTIVE\"\n password_dictionary_lookup = true # Critical: enables the common-password dictionary check\n}\n```"
+ },
+ "Recommendation": {
+ "Text": "Configure every active **Okta Password Policy** so:\n- *Restrict use of common passwords* is **enabled**\n- Group assignments do not route users to legacy policies that leave the check disabled\n\nReview each active Password Policy individually — the check evaluates them one at a time.",
+ "Url": "https://hub.prowler.com/check/authenticator_password_common_password_check"
+ }
+ },
+ "Categories": [
+ "identity-access"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": "Aligns with DISA STIG V-273208 / OKTA-APP-002980."
+}
diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check.py b/prowler/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check.py
new file mode 100644
index 0000000000..d90d88afeb
--- /dev/null
+++ b/prowler/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check.py
@@ -0,0 +1,60 @@
+from prowler.lib.check.models import Check, CheckReportOkta
+from prowler.providers.okta.services.authenticator.authenticator_client import (
+ authenticator_client,
+)
+from prowler.providers.okta.services.authenticator.lib.password_policy_helpers import (
+ active_password_policies,
+ missing_password_policies_scope_finding,
+ no_active_password_policies_finding,
+ password_policy_label,
+)
+
+
+class authenticator_password_common_password_check(Check):
+ """STIG V-273208 / OKTA-APP-002980.
+
+ Every active Okta Password Policy must reject passwords found in the common-password dictionary.
+ The check emits one finding per active policy so a weaker
+ custom policy cannot hide behind a compliant default.
+ """
+
+ def execute(self) -> list[CheckReportOkta]:
+ """Evaluate all active Okta Password Policies."""
+ findings = []
+ org_domain = authenticator_client.provider.identity.org_domain
+ requirement = "common-password dictionary checks"
+ missing_scope = authenticator_client.missing_scope.get("password_policies")
+
+ if missing_scope:
+ return [
+ missing_password_policies_scope_finding(
+ self.metadata(), org_domain, missing_scope, requirement
+ )
+ ]
+
+ policies = active_password_policies(authenticator_client.password_policies)
+ if not policies:
+ return [
+ no_active_password_policies_finding(
+ self.metadata(), org_domain, requirement
+ )
+ ]
+
+ for policy in policies:
+ report = CheckReportOkta(
+ metadata=self.metadata(), resource=policy, org_domain=org_domain
+ )
+ if policy.common_password_check is True:
+ report.status = "PASS"
+ report.status_extended = (
+ f"{password_policy_label(policy)} enforces {requirement} "
+ f"(common password check enabled: {policy.common_password_check})."
+ )
+ else:
+ report.status = "FAIL"
+ report.status_extended = (
+ f"{password_policy_label(policy)} does not enforce {requirement} "
+ f"(common password check enabled: {policy.common_password_check})."
+ )
+ findings.append(report)
+ return findings
diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase.metadata.json
new file mode 100644
index 0000000000..42a74b6e71
--- /dev/null
+++ b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase.metadata.json
@@ -0,0 +1,37 @@
+{
+ "Provider": "okta",
+ "CheckID": "authenticator_password_complexity_lowercase",
+ "CheckTitle": "Every active Okta Password Policy requires at least one lowercase character",
+ "CheckType": [],
+ "ServiceName": "authenticator",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "low",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "IAM",
+ "Description": "Every **active Okta Password Policy** must require at least one **lowercase** character in the user's password.\n\nOkta evaluates Password Policies by group assignment, so a permissive custom policy that drops lowercase complexity can govern users even when the default policy is compliant. The check emits one finding per active policy so weaker custom policies do not hide behind a compliant default.",
+ "Risk": "Without lowercase complexity, the effective alphabet shrinks and passwords become easier to enumerate.\n\n- **Brute force** succeeds against a smaller character space\n- **Wordlist attacks** match more candidates without case mixing\n- **Inconsistent baselines** leave users on legacy policies with weaker complexity than the default",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/policy",
+ "https://help.okta.com/en-us/content/topics/security/policies/configure-password-policies.htm"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "",
+ "NativeIaC": "",
+ "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication** > **Password**.\n3. For every active policy, click **Edit** and enable **Lower case letter** under *Complexity*.\n4. Save the policy.\n5. Repeat for every active Password Policy returned by the check.",
+ "Terraform": "```hcl\nresource \"okta_policy_password\" \"\" {\n name = \"\"\n status = \"ACTIVE\"\n password_min_lowercase = 1 # Critical: STIG-aligned complexity\n}\n```"
+ },
+ "Recommendation": {
+ "Text": "Configure every active **Okta Password Policy** so:\n- *Minimum number of lowercase characters* is `1` or more\n- Group assignments do not route users to legacy policies that disable lowercase complexity\n\nReview each active Password Policy individually — the check evaluates them one at a time.",
+ "Url": "https://hub.prowler.com/check/authenticator_password_complexity_lowercase"
+ }
+ },
+ "Categories": [
+ "identity-access"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": "Aligns with DISA STIG V-273197 / OKTA-APP-000680."
+}
diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase.py b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase.py
new file mode 100644
index 0000000000..e058e27b7b
--- /dev/null
+++ b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase.py
@@ -0,0 +1,60 @@
+from prowler.lib.check.models import Check, CheckReportOkta
+from prowler.providers.okta.services.authenticator.authenticator_client import (
+ authenticator_client,
+)
+from prowler.providers.okta.services.authenticator.lib.password_policy_helpers import (
+ active_password_policies,
+ missing_password_policies_scope_finding,
+ no_active_password_policies_finding,
+ password_policy_label,
+)
+
+
+class authenticator_password_complexity_lowercase(Check):
+ """STIG V-273197 / OKTA-APP-000680.
+
+ Every active Okta Password Policy must require at least one lowercase character.
+ The check emits one finding per active policy so a weaker
+ custom policy cannot hide behind a compliant default.
+ """
+
+ def execute(self) -> list[CheckReportOkta]:
+ """Evaluate all active Okta Password Policies."""
+ findings = []
+ org_domain = authenticator_client.provider.identity.org_domain
+ requirement = "at least one lowercase character"
+ missing_scope = authenticator_client.missing_scope.get("password_policies")
+
+ if missing_scope:
+ return [
+ missing_password_policies_scope_finding(
+ self.metadata(), org_domain, missing_scope, requirement
+ )
+ ]
+
+ policies = active_password_policies(authenticator_client.password_policies)
+ if not policies:
+ return [
+ no_active_password_policies_finding(
+ self.metadata(), org_domain, requirement
+ )
+ ]
+
+ for policy in policies:
+ report = CheckReportOkta(
+ metadata=self.metadata(), resource=policy, org_domain=org_domain
+ )
+ if policy.min_lower_case is not None and policy.min_lower_case >= 1:
+ report.status = "PASS"
+ report.status_extended = (
+ f"{password_policy_label(policy)} enforces {requirement} "
+ f"(minimum lowercase characters: {policy.min_lower_case})."
+ )
+ else:
+ report.status = "FAIL"
+ report.status_extended = (
+ f"{password_policy_label(policy)} does not enforce {requirement} "
+ f"(minimum lowercase characters: {policy.min_lower_case})."
+ )
+ findings.append(report)
+ return findings
diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_number/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_number/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number.metadata.json
new file mode 100644
index 0000000000..fb3d4a920b
--- /dev/null
+++ b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number.metadata.json
@@ -0,0 +1,37 @@
+{
+ "Provider": "okta",
+ "CheckID": "authenticator_password_complexity_number",
+ "CheckTitle": "Every active Okta Password Policy requires at least one numeric character",
+ "CheckType": [],
+ "ServiceName": "authenticator",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "low",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "IAM",
+ "Description": "Every **active Okta Password Policy** must require at least one **numeric** character in the user's password.\n\nOkta evaluates Password Policies by group assignment, so a permissive custom policy that drops numeric complexity can govern users even when the default policy is compliant. The check emits one finding per active policy so weaker custom policies do not hide behind a compliant default.",
+ "Risk": "Without numeric complexity, the effective alphabet shrinks and dictionary words remain viable as passwords.\n\n- **Brute force** succeeds against a smaller character space\n- **Wordlist attacks** match plain dictionary words without numeric padding\n- **Inconsistent baselines** leave users on legacy policies with weaker complexity than the default",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/policy",
+ "https://help.okta.com/en-us/content/topics/security/policies/configure-password-policies.htm"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "",
+ "NativeIaC": "",
+ "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication** > **Password**.\n3. For every active policy, click **Edit** and enable **Number** under *Complexity*.\n4. Save the policy.\n5. Repeat for every active Password Policy returned by the check.",
+ "Terraform": "```hcl\nresource \"okta_policy_password\" \"\" {\n name = \"\"\n status = \"ACTIVE\"\n password_min_number = 1 # Critical: STIG-aligned complexity\n}\n```"
+ },
+ "Recommendation": {
+ "Text": "Configure every active **Okta Password Policy** so:\n- *Minimum number of numeric characters* is `1` or more\n- Group assignments do not route users to legacy policies that disable numeric complexity\n\nReview each active Password Policy individually — the check evaluates them one at a time.",
+ "Url": "https://hub.prowler.com/check/authenticator_password_complexity_number"
+ }
+ },
+ "Categories": [
+ "identity-access"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": "Aligns with DISA STIG V-273198 / OKTA-APP-000690."
+}
diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number.py b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number.py
new file mode 100644
index 0000000000..78ffe6878e
--- /dev/null
+++ b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number.py
@@ -0,0 +1,60 @@
+from prowler.lib.check.models import Check, CheckReportOkta
+from prowler.providers.okta.services.authenticator.authenticator_client import (
+ authenticator_client,
+)
+from prowler.providers.okta.services.authenticator.lib.password_policy_helpers import (
+ active_password_policies,
+ missing_password_policies_scope_finding,
+ no_active_password_policies_finding,
+ password_policy_label,
+)
+
+
+class authenticator_password_complexity_number(Check):
+ """STIG V-273198 / OKTA-APP-000690.
+
+ Every active Okta Password Policy must require at least one numeric character.
+ The check emits one finding per active policy so a weaker
+ custom policy cannot hide behind a compliant default.
+ """
+
+ def execute(self) -> list[CheckReportOkta]:
+ """Evaluate all active Okta Password Policies."""
+ findings = []
+ org_domain = authenticator_client.provider.identity.org_domain
+ requirement = "at least one numeric character"
+ missing_scope = authenticator_client.missing_scope.get("password_policies")
+
+ if missing_scope:
+ return [
+ missing_password_policies_scope_finding(
+ self.metadata(), org_domain, missing_scope, requirement
+ )
+ ]
+
+ policies = active_password_policies(authenticator_client.password_policies)
+ if not policies:
+ return [
+ no_active_password_policies_finding(
+ self.metadata(), org_domain, requirement
+ )
+ ]
+
+ for policy in policies:
+ report = CheckReportOkta(
+ metadata=self.metadata(), resource=policy, org_domain=org_domain
+ )
+ if policy.min_number is not None and policy.min_number >= 1:
+ report.status = "PASS"
+ report.status_extended = (
+ f"{password_policy_label(policy)} enforces {requirement} "
+ f"(minimum numeric characters: {policy.min_number})."
+ )
+ else:
+ report.status = "FAIL"
+ report.status_extended = (
+ f"{password_policy_label(policy)} does not enforce {requirement} "
+ f"(minimum numeric characters: {policy.min_number})."
+ )
+ findings.append(report)
+ return findings
diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_symbol/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_symbol/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol.metadata.json
new file mode 100644
index 0000000000..1268780551
--- /dev/null
+++ b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol.metadata.json
@@ -0,0 +1,37 @@
+{
+ "Provider": "okta",
+ "CheckID": "authenticator_password_complexity_symbol",
+ "CheckTitle": "Every active Okta Password Policy requires at least one symbol character",
+ "CheckType": [],
+ "ServiceName": "authenticator",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "low",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "IAM",
+ "Description": "Every **active Okta Password Policy** must require at least one **symbol** character in the user's password.\n\nOkta evaluates Password Policies by group assignment, so a permissive custom policy that drops symbol complexity can govern users even when the default policy is compliant. The check emits one finding per active policy so weaker custom policies do not hide behind a compliant default.",
+ "Risk": "Without symbol complexity, the effective alphabet shrinks and passwords stay closer to natural-language phrases.\n\n- **Brute force** succeeds against a smaller character space\n- **Wordlist attacks** match dictionary words without symbol padding\n- **Inconsistent baselines** leave users on legacy policies with weaker complexity than the default",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/policy",
+ "https://help.okta.com/en-us/content/topics/security/policies/configure-password-policies.htm"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "",
+ "NativeIaC": "",
+ "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication** > **Password**.\n3. For every active policy, click **Edit** and enable **Symbol** under *Complexity*.\n4. Save the policy.\n5. Repeat for every active Password Policy returned by the check.",
+ "Terraform": "```hcl\nresource \"okta_policy_password\" \"\" {\n name = \"\"\n status = \"ACTIVE\"\n password_min_symbol = 1 # Critical: STIG-aligned complexity\n}\n```"
+ },
+ "Recommendation": {
+ "Text": "Configure every active **Okta Password Policy** so:\n- *Minimum number of symbol characters* is `1` or more\n- Group assignments do not route users to legacy policies that disable symbol complexity\n\nReview each active Password Policy individually — the check evaluates them one at a time.",
+ "Url": "https://hub.prowler.com/check/authenticator_password_complexity_symbol"
+ }
+ },
+ "Categories": [
+ "identity-access"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": "Aligns with DISA STIG V-273199 / OKTA-APP-000700."
+}
diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol.py b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol.py
new file mode 100644
index 0000000000..208a0f51d3
--- /dev/null
+++ b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol.py
@@ -0,0 +1,60 @@
+from prowler.lib.check.models import Check, CheckReportOkta
+from prowler.providers.okta.services.authenticator.authenticator_client import (
+ authenticator_client,
+)
+from prowler.providers.okta.services.authenticator.lib.password_policy_helpers import (
+ active_password_policies,
+ missing_password_policies_scope_finding,
+ no_active_password_policies_finding,
+ password_policy_label,
+)
+
+
+class authenticator_password_complexity_symbol(Check):
+ """STIG V-273199 / OKTA-APP-000700.
+
+ Every active Okta Password Policy must require at least one symbol character.
+ The check emits one finding per active policy so a weaker
+ custom policy cannot hide behind a compliant default.
+ """
+
+ def execute(self) -> list[CheckReportOkta]:
+ """Evaluate all active Okta Password Policies."""
+ findings = []
+ org_domain = authenticator_client.provider.identity.org_domain
+ requirement = "at least one symbol character"
+ missing_scope = authenticator_client.missing_scope.get("password_policies")
+
+ if missing_scope:
+ return [
+ missing_password_policies_scope_finding(
+ self.metadata(), org_domain, missing_scope, requirement
+ )
+ ]
+
+ policies = active_password_policies(authenticator_client.password_policies)
+ if not policies:
+ return [
+ no_active_password_policies_finding(
+ self.metadata(), org_domain, requirement
+ )
+ ]
+
+ for policy in policies:
+ report = CheckReportOkta(
+ metadata=self.metadata(), resource=policy, org_domain=org_domain
+ )
+ if policy.min_symbol is not None and policy.min_symbol >= 1:
+ report.status = "PASS"
+ report.status_extended = (
+ f"{password_policy_label(policy)} enforces {requirement} "
+ f"(minimum symbol characters: {policy.min_symbol})."
+ )
+ else:
+ report.status = "FAIL"
+ report.status_extended = (
+ f"{password_policy_label(policy)} does not enforce {requirement} "
+ f"(minimum symbol characters: {policy.min_symbol})."
+ )
+ findings.append(report)
+ return findings
diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase.metadata.json
new file mode 100644
index 0000000000..7e885364ae
--- /dev/null
+++ b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase.metadata.json
@@ -0,0 +1,37 @@
+{
+ "Provider": "okta",
+ "CheckID": "authenticator_password_complexity_uppercase",
+ "CheckTitle": "Every active Okta Password Policy requires at least one uppercase character",
+ "CheckType": [],
+ "ServiceName": "authenticator",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "low",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "IAM",
+ "Description": "Every **active Okta Password Policy** must require at least one **uppercase** character in the user's password.\n\nOkta evaluates Password Policies by group assignment, so a permissive custom policy that drops uppercase complexity can govern users even when the default policy is compliant. The check emits one finding per active policy so weaker custom policies do not hide behind a compliant default.",
+ "Risk": "Without uppercase complexity, the effective alphabet shrinks and passwords become easier to enumerate.\n\n- **Brute force** succeeds against a smaller character space\n- **Wordlist attacks** match more candidates without case mixing\n- **Inconsistent baselines** leave users on legacy policies with weaker complexity than the default",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/policy",
+ "https://help.okta.com/en-us/content/topics/security/policies/configure-password-policies.htm"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "",
+ "NativeIaC": "",
+ "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication** > **Password**.\n3. For every active policy, click **Edit** and enable **Upper case letter** under *Complexity*.\n4. Save the policy.\n5. Repeat for every active Password Policy returned by the check.",
+ "Terraform": "```hcl\nresource \"okta_policy_password\" \"\" {\n name = \"\"\n status = \"ACTIVE\"\n password_min_uppercase = 1 # Critical: STIG-aligned complexity\n}\n```"
+ },
+ "Recommendation": {
+ "Text": "Configure every active **Okta Password Policy** so:\n- *Minimum number of uppercase characters* is `1` or more\n- Group assignments do not route users to legacy policies that disable uppercase complexity\n\nReview each active Password Policy individually — the check evaluates them one at a time.",
+ "Url": "https://hub.prowler.com/check/authenticator_password_complexity_uppercase"
+ }
+ },
+ "Categories": [
+ "identity-access"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": "Aligns with DISA STIG V-273196 / OKTA-APP-000670."
+}
diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase.py b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase.py
new file mode 100644
index 0000000000..1419027980
--- /dev/null
+++ b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase.py
@@ -0,0 +1,60 @@
+from prowler.lib.check.models import Check, CheckReportOkta
+from prowler.providers.okta.services.authenticator.authenticator_client import (
+ authenticator_client,
+)
+from prowler.providers.okta.services.authenticator.lib.password_policy_helpers import (
+ active_password_policies,
+ missing_password_policies_scope_finding,
+ no_active_password_policies_finding,
+ password_policy_label,
+)
+
+
+class authenticator_password_complexity_uppercase(Check):
+ """STIG V-273196 / OKTA-APP-000670.
+
+ Every active Okta Password Policy must require at least one uppercase character.
+ The check emits one finding per active policy so a weaker
+ custom policy cannot hide behind a compliant default.
+ """
+
+ def execute(self) -> list[CheckReportOkta]:
+ """Evaluate all active Okta Password Policies."""
+ findings = []
+ org_domain = authenticator_client.provider.identity.org_domain
+ requirement = "at least one uppercase character"
+ missing_scope = authenticator_client.missing_scope.get("password_policies")
+
+ if missing_scope:
+ return [
+ missing_password_policies_scope_finding(
+ self.metadata(), org_domain, missing_scope, requirement
+ )
+ ]
+
+ policies = active_password_policies(authenticator_client.password_policies)
+ if not policies:
+ return [
+ no_active_password_policies_finding(
+ self.metadata(), org_domain, requirement
+ )
+ ]
+
+ for policy in policies:
+ report = CheckReportOkta(
+ metadata=self.metadata(), resource=policy, org_domain=org_domain
+ )
+ if policy.min_upper_case is not None and policy.min_upper_case >= 1:
+ report.status = "PASS"
+ report.status_extended = (
+ f"{password_policy_label(policy)} enforces {requirement} "
+ f"(minimum uppercase characters: {policy.min_upper_case})."
+ )
+ else:
+ report.status = "FAIL"
+ report.status_extended = (
+ f"{password_policy_label(policy)} does not enforce {requirement} "
+ f"(minimum uppercase characters: {policy.min_upper_case})."
+ )
+ findings.append(report)
+ return findings
diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_history_5/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_password_history_5/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5.metadata.json
new file mode 100644
index 0000000000..a7191b0364
--- /dev/null
+++ b/prowler/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5.metadata.json
@@ -0,0 +1,37 @@
+{
+ "Provider": "okta",
+ "CheckID": "authenticator_password_history_5",
+ "CheckTitle": "Every active Okta Password Policy remembers at least 5 previous passwords",
+ "CheckType": [],
+ "ServiceName": "authenticator",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "low",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "IAM",
+ "Description": "Every **active Okta Password Policy** must keep at least the last `5` passwords in history so users cannot immediately recycle a recently-used password.\n\nOkta evaluates Password Policies by group assignment, so a permissive custom policy that lowers the history depth can govern users even when the default policy is compliant. The check emits one finding per active policy.",
+ "Risk": "A short password history lets users cycle back to compromised or trivially-guessable values shortly after a forced rotation.\n\n- **Reuse of breached passwords** within the same account\n- **Defeats forced rotation** by letting users return to a previous password\n- **Inconsistent baselines** leave users on legacy policies with shorter history than the default",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/policy",
+ "https://help.okta.com/en-us/content/topics/security/policies/configure-password-policies.htm"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "",
+ "NativeIaC": "",
+ "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication** > **Password**.\n3. For every active policy, click **Edit** and set *Enforce password history for last* to `5` passwords or more.\n4. Save the policy.\n5. Repeat for every active Password Policy returned by the check.",
+ "Terraform": "```hcl\nresource \"okta_policy_password\" \"\" {\n name = \"\"\n status = \"ACTIVE\"\n password_history_count = 5 # Critical: STIG-aligned history depth\n}\n```"
+ },
+ "Recommendation": {
+ "Text": "Configure every active **Okta Password Policy** so:\n- *Enforce password history for last* is `5` passwords or more\n- Group assignments do not route users to legacy policies with shorter history\n\nReview each active Password Policy individually — the check evaluates them one at a time.",
+ "Url": "https://hub.prowler.com/check/authenticator_password_history_5"
+ }
+ },
+ "Categories": [
+ "identity-access"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": "Aligns with DISA STIG V-273209 / OKTA-APP-003010."
+}
diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5.py b/prowler/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5.py
new file mode 100644
index 0000000000..b2eb6d16ba
--- /dev/null
+++ b/prowler/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5.py
@@ -0,0 +1,60 @@
+from prowler.lib.check.models import Check, CheckReportOkta
+from prowler.providers.okta.services.authenticator.authenticator_client import (
+ authenticator_client,
+)
+from prowler.providers.okta.services.authenticator.lib.password_policy_helpers import (
+ active_password_policies,
+ missing_password_policies_scope_finding,
+ no_active_password_policies_finding,
+ password_policy_label,
+)
+
+
+class authenticator_password_history_5(Check):
+ """STIG V-273209 / OKTA-APP-003010.
+
+ Every active Okta Password Policy must remember at least the last 5 previous passwords.
+ The check emits one finding per active policy so a weaker
+ custom policy cannot hide behind a compliant default.
+ """
+
+ def execute(self) -> list[CheckReportOkta]:
+ """Evaluate all active Okta Password Policies."""
+ findings = []
+ org_domain = authenticator_client.provider.identity.org_domain
+ requirement = "password history of at least 5 previous passwords"
+ missing_scope = authenticator_client.missing_scope.get("password_policies")
+
+ if missing_scope:
+ return [
+ missing_password_policies_scope_finding(
+ self.metadata(), org_domain, missing_scope, requirement
+ )
+ ]
+
+ policies = active_password_policies(authenticator_client.password_policies)
+ if not policies:
+ return [
+ no_active_password_policies_finding(
+ self.metadata(), org_domain, requirement
+ )
+ ]
+
+ for policy in policies:
+ report = CheckReportOkta(
+ metadata=self.metadata(), resource=policy, org_domain=org_domain
+ )
+ if policy.history_count is not None and policy.history_count >= 5:
+ report.status = "PASS"
+ report.status_extended = (
+ f"{password_policy_label(policy)} enforces {requirement} "
+ f"(password history count: {policy.history_count})."
+ )
+ else:
+ report.status = "FAIL"
+ report.status_extended = (
+ f"{password_policy_label(policy)} does not enforce {requirement} "
+ f"(password history count: {policy.history_count})."
+ )
+ findings.append(report)
+ return findings
diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3.metadata.json
new file mode 100644
index 0000000000..e287ed38f3
--- /dev/null
+++ b/prowler/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3.metadata.json
@@ -0,0 +1,37 @@
+{
+ "Provider": "okta",
+ "CheckID": "authenticator_password_lockout_threshold_3",
+ "CheckTitle": "Every active Okta Password Policy locks accounts after 3 or fewer failed attempts",
+ "CheckType": [],
+ "ServiceName": "authenticator",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "medium",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "IAM",
+ "Description": "Every **active Okta Password Policy** must lock the account after at most `3` consecutive failed sign-in attempts.\n\nOkta evaluates Password Policies by group assignment, so a permissive custom policy with a higher threshold (or no lockout) can govern users even when the default is compliant. The check emits one finding per active policy.",
+ "Risk": "A high lockout threshold (or no threshold) leaves accounts exposed to online password guessing.\n\n- **Online brute force** retains enough attempts to enumerate common passwords\n- **Credential stuffing** can iterate through breached credentials at scale\n- **Inconsistent baselines** leave users on legacy policies with weaker thresholds than the default",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/policy",
+ "https://help.okta.com/en-us/content/topics/security/policies/configure-password-policies.htm"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "",
+ "NativeIaC": "",
+ "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication** > **Password**.\n3. For every active policy, click **Edit** and set *Lock out user after X unsuccessful attempts* to `3` or fewer.\n4. Save the policy.\n5. Repeat for every active Password Policy returned by the check.",
+ "Terraform": "```hcl\nresource \"okta_policy_password\" \"\" {\n name = \"\"\n status = \"ACTIVE\"\n password_max_lockout_attempts = 3 # Critical: STIG-aligned lockout threshold\n}\n```"
+ },
+ "Recommendation": {
+ "Text": "Configure every active **Okta Password Policy** so:\n- *Lock out user after X unsuccessful attempts* is `3` or fewer\n- Group assignments do not route users to legacy policies with higher thresholds or no lockout\n\nReview each active Password Policy individually — the check evaluates them one at a time.",
+ "Url": "https://hub.prowler.com/check/authenticator_password_lockout_threshold_3"
+ }
+ },
+ "Categories": [
+ "identity-access"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": "Aligns with DISA STIG V-273189 / OKTA-APP-000170. Not applicable when Okta delegates password sourcing to an external directory (AD/LDAP) — mute the check in that case; the directory enforces lockout instead."
+}
diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3.py b/prowler/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3.py
new file mode 100644
index 0000000000..8026725c8a
--- /dev/null
+++ b/prowler/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3.py
@@ -0,0 +1,60 @@
+from prowler.lib.check.models import Check, CheckReportOkta
+from prowler.providers.okta.services.authenticator.authenticator_client import (
+ authenticator_client,
+)
+from prowler.providers.okta.services.authenticator.lib.password_policy_helpers import (
+ active_password_policies,
+ missing_password_policies_scope_finding,
+ no_active_password_policies_finding,
+ password_policy_label,
+)
+
+
+class authenticator_password_lockout_threshold_3(Check):
+ """STIG V-273189 / OKTA-APP-000170.
+
+ Every active Okta Password Policy must lock accounts after no more than 3 consecutive failed login attempts.
+ The check emits one finding per active policy so a weaker
+ custom policy cannot hide behind a compliant default.
+ """
+
+ def execute(self) -> list[CheckReportOkta]:
+ """Evaluate all active Okta Password Policies."""
+ findings = []
+ org_domain = authenticator_client.provider.identity.org_domain
+ requirement = "password lockout after 3 or fewer failed attempts"
+ missing_scope = authenticator_client.missing_scope.get("password_policies")
+
+ if missing_scope:
+ return [
+ missing_password_policies_scope_finding(
+ self.metadata(), org_domain, missing_scope, requirement
+ )
+ ]
+
+ policies = active_password_policies(authenticator_client.password_policies)
+ if not policies:
+ return [
+ no_active_password_policies_finding(
+ self.metadata(), org_domain, requirement
+ )
+ ]
+
+ for policy in policies:
+ report = CheckReportOkta(
+ metadata=self.metadata(), resource=policy, org_domain=org_domain
+ )
+ if policy.max_attempts is not None and policy.max_attempts <= 3:
+ report.status = "PASS"
+ report.status_extended = (
+ f"{password_policy_label(policy)} enforces {requirement} "
+ f"(maximum failed attempts: {policy.max_attempts})."
+ )
+ else:
+ report.status = "FAIL"
+ report.status_extended = (
+ f"{password_policy_label(policy)} does not enforce {requirement} "
+ f"(maximum failed attempts: {policy.max_attempts})."
+ )
+ findings.append(report)
+ return findings
diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d.metadata.json
new file mode 100644
index 0000000000..449904303e
--- /dev/null
+++ b/prowler/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d.metadata.json
@@ -0,0 +1,37 @@
+{
+ "Provider": "okta",
+ "CheckID": "authenticator_password_maximum_age_60d",
+ "CheckTitle": "Every active Okta Password Policy enforces a maximum password age of 60 days or less",
+ "CheckType": [],
+ "ServiceName": "authenticator",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "low",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "IAM",
+ "Description": "Every **active Okta Password Policy** must require users to change their password at least every `60` days. The check rejects both unlimited expiration (`0`, which Okta treats as *never expires*) and any value greater than `60`.\n\nOkta evaluates Password Policies by group assignment; a permissive custom policy with a longer window can govern users. The check emits one finding per active policy.",
+ "Risk": "Long-lived passwords give a compromised credential indefinite usefulness.\n\n- **Stolen passwords** stay valid until the user happens to change them\n- **Breach detection lag** means a leaked password can be in use for months unnoticed\n- **No expiration** is the same risk as an infinite expiration window",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/policy",
+ "https://help.okta.com/en-us/content/topics/security/policies/configure-password-policies.htm"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "",
+ "NativeIaC": "",
+ "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication** > **Password**.\n3. For every active policy, click **Edit** and set *Enforce password expiration* to `60` days or less. Do **not** leave it disabled or set to `0` — that disables expiration entirely.\n4. Save the policy.\n5. Repeat for every active Password Policy returned by the check.",
+ "Terraform": "```hcl\nresource \"okta_policy_password\" \"\" {\n name = \"\"\n status = \"ACTIVE\"\n password_max_age_days = 60 # Critical: STIG-aligned maximum age; do not use 0 (disables expiration)\n}\n```"
+ },
+ "Recommendation": {
+ "Text": "Configure every active **Okta Password Policy** so:\n- *Enforce password expiration* is `60` days or less\n- Never set the value to `0`, which disables expiration\n- Group assignments do not route users to legacy policies with longer windows or disabled expiration\n\nReview each active Password Policy individually — the check evaluates them one at a time.",
+ "Url": "https://hub.prowler.com/check/authenticator_password_maximum_age_60d"
+ }
+ },
+ "Categories": [
+ "identity-access"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": "Aligns with DISA STIG V-273201 / OKTA-APP-000745."
+}
diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d.py b/prowler/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d.py
new file mode 100644
index 0000000000..2dbc5d9c07
--- /dev/null
+++ b/prowler/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d.py
@@ -0,0 +1,60 @@
+from prowler.lib.check.models import Check, CheckReportOkta
+from prowler.providers.okta.services.authenticator.authenticator_client import (
+ authenticator_client,
+)
+from prowler.providers.okta.services.authenticator.lib.password_policy_helpers import (
+ active_password_policies,
+ missing_password_policies_scope_finding,
+ no_active_password_policies_finding,
+ password_policy_label,
+)
+
+
+class authenticator_password_maximum_age_60d(Check):
+ """STIG V-273201 / OKTA-APP-000745.
+
+ Every active Okta Password Policy must enforce a 60-day maximum password age.
+ The check emits one finding per active policy so a weaker
+ custom policy cannot hide behind a compliant default.
+ """
+
+ def execute(self) -> list[CheckReportOkta]:
+ """Evaluate all active Okta Password Policies."""
+ findings = []
+ org_domain = authenticator_client.provider.identity.org_domain
+ requirement = "maximum password age of 60 days or less"
+ missing_scope = authenticator_client.missing_scope.get("password_policies")
+
+ if missing_scope:
+ return [
+ missing_password_policies_scope_finding(
+ self.metadata(), org_domain, missing_scope, requirement
+ )
+ ]
+
+ policies = active_password_policies(authenticator_client.password_policies)
+ if not policies:
+ return [
+ no_active_password_policies_finding(
+ self.metadata(), org_domain, requirement
+ )
+ ]
+
+ for policy in policies:
+ report = CheckReportOkta(
+ metadata=self.metadata(), resource=policy, org_domain=org_domain
+ )
+ if policy.max_age_days is not None and 0 < policy.max_age_days <= 60:
+ report.status = "PASS"
+ report.status_extended = (
+ f"{password_policy_label(policy)} enforces {requirement} "
+ f"(maximum age days: {policy.max_age_days})."
+ )
+ else:
+ report.status = "FAIL"
+ report.status_extended = (
+ f"{password_policy_label(policy)} does not enforce {requirement} "
+ f"(maximum age days: {policy.max_age_days})."
+ )
+ findings.append(report)
+ return findings
diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h.metadata.json
new file mode 100644
index 0000000000..8adbf3b5a0
--- /dev/null
+++ b/prowler/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h.metadata.json
@@ -0,0 +1,37 @@
+{
+ "Provider": "okta",
+ "CheckID": "authenticator_password_minimum_age_24h",
+ "CheckTitle": "Every active Okta Password Policy enforces a minimum password age of 24 hours",
+ "CheckType": [],
+ "ServiceName": "authenticator",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "low",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "IAM",
+ "Description": "Every **active Okta Password Policy** must prevent users from changing their password again for at least `24` hours (`1440` minutes) after the previous change.\n\nA minimum age stops users from cycling through their entire history in one session to land back on a previously-known password. The check emits one finding per active policy.",
+ "Risk": "Without a minimum password age, users can sidestep history and rotation requirements in minutes.\n\n- **Defeats password history** by walking through new passwords back to a previous one\n- **Defeats forced rotation** by returning to the prior password after the mandatory change\n- **Inconsistent baselines** leave users on legacy policies with shorter minimums than the default",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/policy",
+ "https://help.okta.com/en-us/content/topics/security/policies/configure-password-policies.htm"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "",
+ "NativeIaC": "",
+ "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication** > **Password**.\n3. For every active policy, click **Edit** and set *Minimum password age* to `24` hours (`1440` minutes) or more.\n4. Save the policy.\n5. Repeat for every active Password Policy returned by the check.",
+ "Terraform": "```hcl\nresource \"okta_policy_password\" \"\" {\n name = \"\"\n status = \"ACTIVE\"\n password_min_age_minutes = 1440 # Critical: STIG-aligned 24h minimum age\n}\n```"
+ },
+ "Recommendation": {
+ "Text": "Configure every active **Okta Password Policy** so:\n- *Minimum password age* is `1440` minutes (`24` hours) or more\n- Group assignments do not route users to legacy policies with shorter minimums or no minimum age\n\nReview each active Password Policy individually — the check evaluates them one at a time.",
+ "Url": "https://hub.prowler.com/check/authenticator_password_minimum_age_24h"
+ }
+ },
+ "Categories": [
+ "identity-access"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": "Aligns with DISA STIG V-273200 / OKTA-APP-000740."
+}
diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h.py b/prowler/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h.py
new file mode 100644
index 0000000000..93ce511458
--- /dev/null
+++ b/prowler/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h.py
@@ -0,0 +1,60 @@
+from prowler.lib.check.models import Check, CheckReportOkta
+from prowler.providers.okta.services.authenticator.authenticator_client import (
+ authenticator_client,
+)
+from prowler.providers.okta.services.authenticator.lib.password_policy_helpers import (
+ active_password_policies,
+ missing_password_policies_scope_finding,
+ no_active_password_policies_finding,
+ password_policy_label,
+)
+
+
+class authenticator_password_minimum_age_24h(Check):
+ """STIG V-273200 / OKTA-APP-000740.
+
+ Every active Okta Password Policy must enforce a 24-hour minimum password age.
+ The check emits one finding per active policy so a weaker
+ custom policy cannot hide behind a compliant default.
+ """
+
+ def execute(self) -> list[CheckReportOkta]:
+ """Evaluate all active Okta Password Policies."""
+ findings = []
+ org_domain = authenticator_client.provider.identity.org_domain
+ requirement = "minimum password age of at least 24 hours"
+ missing_scope = authenticator_client.missing_scope.get("password_policies")
+
+ if missing_scope:
+ return [
+ missing_password_policies_scope_finding(
+ self.metadata(), org_domain, missing_scope, requirement
+ )
+ ]
+
+ policies = active_password_policies(authenticator_client.password_policies)
+ if not policies:
+ return [
+ no_active_password_policies_finding(
+ self.metadata(), org_domain, requirement
+ )
+ ]
+
+ for policy in policies:
+ report = CheckReportOkta(
+ metadata=self.metadata(), resource=policy, org_domain=org_domain
+ )
+ if policy.min_age_minutes is not None and policy.min_age_minutes >= 1440:
+ report.status = "PASS"
+ report.status_extended = (
+ f"{password_policy_label(policy)} enforces {requirement} "
+ f"(minimum age minutes: {policy.min_age_minutes})."
+ )
+ else:
+ report.status = "FAIL"
+ report.status_extended = (
+ f"{password_policy_label(policy)} does not enforce {requirement} "
+ f"(minimum age minutes: {policy.min_age_minutes})."
+ )
+ findings.append(report)
+ return findings
diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_minimum_length_15/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_password_minimum_length_15/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15.metadata.json
new file mode 100644
index 0000000000..d7409c6bb1
--- /dev/null
+++ b/prowler/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15.metadata.json
@@ -0,0 +1,37 @@
+{
+ "Provider": "okta",
+ "CheckID": "authenticator_password_minimum_length_15",
+ "CheckTitle": "Every active Okta Password Policy enforces a minimum length of 15 characters",
+ "CheckType": [],
+ "ServiceName": "authenticator",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "medium",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "IAM",
+ "Description": "Every **active Okta Password Policy** must require at least `15` characters in the user's password.\n\nOkta evaluates Password Policies by group assignment, so a permissive custom policy with a shorter minimum length can govern users even when the default policy is compliant. The check emits one finding per active policy so weaker custom policies do not hide behind a compliant default.",
+ "Risk": "Short password minimums weaken brute-force and credential-stuffing resistance for every user assigned to the affected policy.\n\n- **Brute force** completes in feasible time against short passwords\n- **Credential stuffing** succeeds with reused passwords from public breaches\n- **Inconsistent baselines** leave users on legacy policies with weaker assurance than the default",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/policy",
+ "https://help.okta.com/en-us/content/topics/security/policies/configure-password-policies.htm"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "",
+ "NativeIaC": "",
+ "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication** > **Password**.\n3. For every active policy, click **Edit** and set *Minimum password length* to `15` or more.\n4. Save the policy.\n5. Repeat for every active Password Policy returned by the check.",
+ "Terraform": "```hcl\nresource \"okta_policy_password\" \"\" {\n name = \"\"\n status = \"ACTIVE\"\n password_min_length = 15 # Critical: STIG-aligned minimum\n}\n```"
+ },
+ "Recommendation": {
+ "Text": "Configure every active **Okta Password Policy** so:\n- *Minimum password length* is `15` characters or more\n- Group assignments do not route users to legacy policies with shorter minimums\n\nReview each active Password Policy individually — the check evaluates them one at a time.",
+ "Url": "https://hub.prowler.com/check/authenticator_password_minimum_length_15"
+ }
+ },
+ "Categories": [
+ "identity-access"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": "Aligns with DISA STIG V-273195 / OKTA-APP-000650."
+}
diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15.py b/prowler/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15.py
new file mode 100644
index 0000000000..35e0a02b8b
--- /dev/null
+++ b/prowler/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15.py
@@ -0,0 +1,60 @@
+from prowler.lib.check.models import Check, CheckReportOkta
+from prowler.providers.okta.services.authenticator.authenticator_client import (
+ authenticator_client,
+)
+from prowler.providers.okta.services.authenticator.lib.password_policy_helpers import (
+ active_password_policies,
+ missing_password_policies_scope_finding,
+ no_active_password_policies_finding,
+ password_policy_label,
+)
+
+
+class authenticator_password_minimum_length_15(Check):
+ """STIG V-273195 / OKTA-APP-000650.
+
+ Every active Okta Password Policy must enforce a minimum password length of 15 characters.
+ The check emits one finding per active policy so a weaker
+ custom policy cannot hide behind a compliant default.
+ """
+
+ def execute(self) -> list[CheckReportOkta]:
+ """Evaluate all active Okta Password Policies."""
+ findings = []
+ org_domain = authenticator_client.provider.identity.org_domain
+ requirement = "minimum password length of at least 15 characters"
+ missing_scope = authenticator_client.missing_scope.get("password_policies")
+
+ if missing_scope:
+ return [
+ missing_password_policies_scope_finding(
+ self.metadata(), org_domain, missing_scope, requirement
+ )
+ ]
+
+ policies = active_password_policies(authenticator_client.password_policies)
+ if not policies:
+ return [
+ no_active_password_policies_finding(
+ self.metadata(), org_domain, requirement
+ )
+ ]
+
+ for policy in policies:
+ report = CheckReportOkta(
+ metadata=self.metadata(), resource=policy, org_domain=org_domain
+ )
+ if policy.min_length is not None and policy.min_length >= 15:
+ report.status = "PASS"
+ report.status_extended = (
+ f"{password_policy_label(policy)} enforces {requirement} "
+ f"(minimum length: {policy.min_length})."
+ )
+ else:
+ report.status = "FAIL"
+ report.status_extended = (
+ f"{password_policy_label(policy)} does not enforce {requirement} "
+ f"(minimum length: {policy.min_length})."
+ )
+ findings.append(report)
+ return findings
diff --git a/prowler/providers/okta/services/authenticator/authenticator_service.py b/prowler/providers/okta/services/authenticator/authenticator_service.py
new file mode 100644
index 0000000000..86b4084dfb
--- /dev/null
+++ b/prowler/providers/okta/services/authenticator/authenticator_service.py
@@ -0,0 +1,236 @@
+from typing import Optional
+
+from pydantic import BaseModel
+
+from prowler.lib.logger import logger
+from prowler.providers.okta.lib.service.pagination import paginate as _paginate_shared
+from prowler.providers.okta.lib.service.service import OktaService
+
+REQUIRED_SCOPES: dict[str, str] = {
+ "password_policies": "okta.policies.read",
+ "authenticators": "okta.authenticators.read",
+}
+
+
+def _value(value) -> str:
+ """Return plain string values from Okta SDK enums and raw strings."""
+ if value is None:
+ return ""
+ enum_value = getattr(value, "value", None)
+ if enum_value is not None:
+ return str(enum_value)
+ return str(value)
+
+
+def _int_or_none(value) -> Optional[int]:
+ if value is None:
+ return None
+ try:
+ return int(value)
+ except (TypeError, ValueError):
+ return None
+
+
+def _bool_or_none(value) -> Optional[bool]:
+ """Coerce common Okta boolean shapes into a real `Optional[bool]`.
+
+ The Okta SDK typed `bool` fields are already real booleans, but the
+ raw-JSON fallback paths in sibling services have surfaced both
+ JSON-style booleans (`true`/`false` as Python `bool` after `json.loads`)
+ and string-flavored ones (`"true"`/`"false"`). `bool("false")` is
+ `True` — so naive coercion silently flips the meaning. Reject that
+ explicitly.
+ """
+ if value is None:
+ return None
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, str):
+ normalized = value.strip().lower()
+ if normalized in {"true", "1", "yes"}:
+ return True
+ if normalized in {"false", "0", "no", ""}:
+ return False
+ return None
+ return bool(value)
+
+
+class Authenticator(OktaService):
+ """Fetches Okta Password Policies and Authenticators for STIG checks.
+
+ Populates:
+ - `self.password_policies` — keyed by policy id. Each `PasswordPolicy`
+ carries the projected fields the 10 password-policy checks read
+ (length, complexity, age, history, lockout, common-password
+ dictionary). The complete typed SDK response is collapsed into a
+ flat dataclass so the checks never reach back into the SDK shape.
+ - `self.authenticators` — keyed by authenticator id. Used by the
+ two non-password checks (Smart Card IdP, Okta Verify FIPS).
+
+ Before each fetch the service compares its required OAuth scope
+ (see `REQUIRED_SCOPES`) against the access token's granted scopes
+ (`provider.identity.granted_scopes`). When a scope is known to be
+ missing, the fetch is skipped and recorded in `self.missing_scope`
+ so each check can emit an explicit MANUAL finding instead of a
+ misleading "no resources returned". Empty granted_scopes means
+ "unknown" — the service attempts the fetch and lets the SDK fail
+ loudly.
+ """
+
+ def __init__(self, provider):
+ super().__init__(__class__.__name__, provider)
+ granted = set(getattr(provider.identity, "granted_scopes", None) or [])
+ self.missing_scope: dict[str, Optional[str]] = {
+ resource: (scope if granted and scope not in granted else None)
+ for resource, scope in REQUIRED_SCOPES.items()
+ }
+ self.password_policies: dict[str, PasswordPolicy] = (
+ {}
+ if self.missing_scope["password_policies"]
+ else self._list_password_policies()
+ )
+ self.authenticators: dict[str, OktaAuthenticator] = (
+ {} if self.missing_scope["authenticators"] else self._list_authenticators()
+ )
+
+ def _list_password_policies(self) -> dict[str, "PasswordPolicy"]:
+ """List PASSWORD policies with normalized password settings."""
+ logger.info("Authenticator - Listing Okta PASSWORD policies...")
+ try:
+ return self._run(self._fetch_password_policies())
+ except Exception as error:
+ logger.error(
+ f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+ return {}
+
+ async def _fetch_password_policies(self) -> dict[str, "PasswordPolicy"]:
+ result: dict[str, PasswordPolicy] = {}
+ items, err = await _paginate_shared(
+ lambda after: self.client.list_policies(
+ type="PASSWORD", after=after, limit="200"
+ )
+ )
+ if err is not None:
+ logger.error(f"Error listing PASSWORD policies: {err}")
+ return result
+
+ for policy in items:
+ policy_obj = self._build_password_policy(policy)
+ result[policy_obj.id] = policy_obj
+ return result
+
+ def _list_authenticators(self) -> dict[str, "OktaAuthenticator"]:
+ """List org authenticators with normalized settings."""
+ logger.info("Authenticator - Listing Okta authenticators...")
+ try:
+ return self._run(self._fetch_authenticators())
+ except Exception as error:
+ logger.error(
+ f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+ return {}
+
+ async def _fetch_authenticators(self) -> dict[str, "OktaAuthenticator"]:
+ # `list_authenticators` is non-paginated in the SDK (no `after`
+ # parameter); inline the tuple unwrap rather than going through
+ # `paginate`. Same shape `application_service` uses for
+ # `get_first_party_app_settings`.
+ result: dict[str, OktaAuthenticator] = {}
+ sdk_result = await self.client.list_authenticators()
+ err = sdk_result[-1]
+ if err is not None:
+ logger.error(f"Error listing authenticators: {err}")
+ return result
+ items = sdk_result[0] or []
+
+ for authenticator in items:
+ auth_obj = self._build_authenticator(authenticator)
+ result[auth_obj.id] = auth_obj
+ return result
+
+ @staticmethod
+ def _build_password_policy(policy) -> "PasswordPolicy":
+ settings = getattr(policy, "settings", None)
+ password_settings = getattr(settings, "password", None) if settings else None
+ lockout = (
+ getattr(password_settings, "lockout", None) if password_settings else None
+ )
+ complexity = (
+ getattr(password_settings, "complexity", None)
+ if password_settings
+ else None
+ )
+ dictionary = getattr(complexity, "dictionary", None) if complexity else None
+ common = getattr(dictionary, "common", None) if dictionary else None
+ age = getattr(password_settings, "age", None) if password_settings else None
+ policy_id = _value(getattr(policy, "id", None))
+ return PasswordPolicy(
+ id=policy_id,
+ name=_value(getattr(policy, "name", None)) or policy_id,
+ status=_value(getattr(policy, "status", None)),
+ priority=_int_or_none(getattr(policy, "priority", None)),
+ is_default=bool(getattr(policy, "system", False)),
+ max_attempts=_int_or_none(getattr(lockout, "max_attempts", None)),
+ min_length=_int_or_none(getattr(complexity, "min_length", None)),
+ min_upper_case=_int_or_none(getattr(complexity, "min_upper_case", None)),
+ min_lower_case=_int_or_none(getattr(complexity, "min_lower_case", None)),
+ min_number=_int_or_none(getattr(complexity, "min_number", None)),
+ min_symbol=_int_or_none(getattr(complexity, "min_symbol", None)),
+ min_age_minutes=_int_or_none(getattr(age, "min_age_minutes", None)),
+ max_age_days=_int_or_none(getattr(age, "max_age_days", None)),
+ history_count=_int_or_none(getattr(age, "history_count", None)),
+ common_password_check=_bool_or_none(getattr(common, "exclude", None)),
+ )
+
+ @staticmethod
+ def _build_authenticator(authenticator) -> "OktaAuthenticator":
+ settings = getattr(authenticator, "settings", None)
+ compliance = getattr(settings, "compliance", None) if settings else None
+ auth_id = _value(getattr(authenticator, "id", None))
+ return OktaAuthenticator(
+ id=auth_id,
+ key=_value(getattr(authenticator, "key", None)),
+ name=_value(getattr(authenticator, "name", None)) or auth_id,
+ status=_value(getattr(authenticator, "status", None)),
+ type=_value(getattr(authenticator, "type", None)),
+ fips=_value(getattr(compliance, "fips", None)),
+ )
+
+
+class PasswordPolicy(BaseModel):
+ """Normalized Okta Password Policy settings used by checks."""
+
+ id: str
+ name: str
+ status: str = ""
+ priority: Optional[int] = None
+ is_default: bool = False
+ max_attempts: Optional[int] = None
+ min_length: Optional[int] = None
+ min_upper_case: Optional[int] = None
+ min_lower_case: Optional[int] = None
+ min_number: Optional[int] = None
+ min_symbol: Optional[int] = None
+ min_age_minutes: Optional[int] = None
+ max_age_days: Optional[int] = None
+ history_count: Optional[int] = None
+ common_password_check: Optional[bool] = None
+
+
+class OktaAuthenticator(BaseModel):
+ """Normalized Okta Authenticator settings used by checks."""
+
+ id: str
+ key: str
+ name: str
+ status: str = ""
+ type: str = ""
+ fips: str = ""
+
+
+class AuthenticatorSummary(BaseModel):
+ """Synthetic resource for org-level authenticator findings."""
+
+ id: str
+ name: str
diff --git a/prowler/providers/okta/services/authenticator/authenticator_smart_card_active/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_smart_card_active/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active.metadata.json
new file mode 100644
index 0000000000..b8e93ae548
--- /dev/null
+++ b/prowler/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active.metadata.json
@@ -0,0 +1,37 @@
+{
+ "Provider": "okta",
+ "CheckID": "authenticator_smart_card_active",
+ "CheckTitle": "Okta Smart Card IdP authenticator is configured and active",
+ "CheckType": [],
+ "ServiceName": "authenticator",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "medium",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "IAM",
+ "Description": "The **Smart Card IdP authenticator** (`smart_card_idp`) must exist in the org and be in the `ACTIVE` status so certificate-based authentication is available to users and apps that require it.\n\nThe check resolves the authenticator by its built-in `key`. Missing and inactive authenticators surface as distinct FAIL findings.",
+ "Risk": "Without an active Smart Card authenticator, users cannot satisfy mandated certificate-based authentication and may be forced onto weaker fallback paths.\n\n- **Mandated CAC/PIV use** cannot be enforced\n- **Compliance gaps** for environments that require X.509 certificate authentication\n- **Fallback to password-only** sign-in for affected groups",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/authenticator",
+ "https://help.okta.com/oie/en-us/Content/Topics/identity-engine/authenticators/smart-card-authenticator.htm"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "",
+ "NativeIaC": "",
+ "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authenticators**.\n3. If the **Smart Card IdP** authenticator is not listed, click **Add Authenticator** and add it.\n4. Open the authenticator and switch its status to **ACTIVE**.\n5. Bind it to the Authentication Policies that require certificate-based auth.",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Ensure the **Smart Card IdP** authenticator is:\n- Present in the org (`key = smart_card_idp`)\n- In the `ACTIVE` status\n- Referenced by every Authentication Policy that requires certificate-based authentication\n\nIf certificate-based authentication is not in scope for the organization, mute the check rather than disabling the authenticator.",
+ "Url": "https://hub.prowler.com/check/authenticator_smart_card_active"
+ }
+ },
+ "Categories": [
+ "identity-access"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": "Aligns with DISA STIG V-273204 / OKTA-APP-001670."
+}
diff --git a/prowler/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active.py b/prowler/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active.py
new file mode 100644
index 0000000000..4341db8612
--- /dev/null
+++ b/prowler/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active.py
@@ -0,0 +1,56 @@
+from prowler.lib.check.models import Check, CheckReportOkta
+from prowler.providers.okta.services.authenticator.authenticator_client import (
+ authenticator_client,
+)
+from prowler.providers.okta.services.authenticator.lib.authenticator_helpers import (
+ find_authenticator_by_key,
+ missing_authenticator_resource,
+ missing_authenticators_scope_finding,
+)
+
+
+class authenticator_smart_card_active(Check):
+ """STIG V-273204 / OKTA-APP-001670.
+
+ The check requires Okta to configure and activate the Smart Card (PIV) authenticator.
+ """
+
+ def execute(self) -> list[CheckReportOkta]:
+ """Evaluate the Smart Card IdP authenticator status."""
+ org_domain = authenticator_client.provider.identity.org_domain
+ missing_scope = authenticator_client.missing_scope.get("authenticators")
+ if missing_scope:
+ return [
+ missing_authenticators_scope_finding(
+ self.metadata(),
+ org_domain,
+ "smart_card_idp",
+ "Smart Card IdP authenticator",
+ missing_scope,
+ )
+ ]
+
+ authenticator = find_authenticator_by_key(
+ authenticator_client.authenticators, "smart_card_idp"
+ )
+ resource = authenticator or missing_authenticator_resource(
+ "smart_card_idp", "Smart Card IdP authenticator"
+ )
+ report = CheckReportOkta(
+ metadata=self.metadata(), resource=resource, org_domain=org_domain
+ )
+ if authenticator and authenticator.status.upper() == "ACTIVE":
+ report.status = "PASS"
+ report.status_extended = "Smart Card IdP authenticator is ACTIVE."
+ elif authenticator:
+ report.status = "FAIL"
+ report.status_extended = (
+ f"Smart Card IdP authenticator is not active; current status is "
+ f"{authenticator.status}."
+ )
+ else:
+ report.status = "FAIL"
+ report.status_extended = (
+ "Smart Card IdP authenticator is not active or missing."
+ )
+ return [report]
diff --git a/prowler/providers/okta/services/authenticator/lib/__init__.py b/prowler/providers/okta/services/authenticator/lib/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/okta/services/authenticator/lib/authenticator_helpers.py b/prowler/providers/okta/services/authenticator/lib/authenticator_helpers.py
new file mode 100644
index 0000000000..851daec5a8
--- /dev/null
+++ b/prowler/providers/okta/services/authenticator/lib/authenticator_helpers.py
@@ -0,0 +1,49 @@
+from prowler.lib.check.models import CheckReportOkta
+from prowler.providers.okta.services.authenticator.authenticator_service import (
+ AuthenticatorSummary,
+ OktaAuthenticator,
+)
+
+_SCOPE_ADVICE = (
+ "Grant it on the Okta API Scopes tab of the service app in the Okta Admin "
+ "Console, then re-run the check."
+)
+
+
+def find_authenticator_by_key(
+ authenticators: dict[str, OktaAuthenticator], key: str
+) -> OktaAuthenticator | None:
+ """Return the first authenticator with the requested key.
+
+ Okta enforces unique authenticator `key` values per org for built-in
+ types (`okta_verify`, `smart_card_idp`, etc.), so the "first match"
+ is the only match in practice. If a future Okta release relaxes
+ that and returns duplicates, only the first is evaluated — STIG
+ semantics need refining at that point.
+ """
+ for authenticator in authenticators.values():
+ if authenticator.key == key:
+ return authenticator
+ return None
+
+
+def missing_authenticator_resource(key: str, name: str) -> AuthenticatorSummary:
+ """Build a synthetic resource for a missing authenticator."""
+ return AuthenticatorSummary(id=f"{key}-missing", name=name)
+
+
+def missing_authenticators_scope_finding(
+ metadata, org_domain: str, key: str, name: str, scope: str
+) -> CheckReportOkta:
+ """Build the MANUAL finding emitted when authenticators cannot be listed."""
+ resource = AuthenticatorSummary(id=f"{key}-scope-missing", name=name)
+ report = CheckReportOkta(
+ metadata=metadata, resource=resource, org_domain=org_domain
+ )
+ report.status = "MANUAL"
+ report.status_extended = (
+ f"Could not retrieve Okta authenticators to evaluate {name}: the Okta "
+ f"service app is missing the required `{scope}` API scope. "
+ f"{_SCOPE_ADVICE}"
+ )
+ return report
diff --git a/prowler/providers/okta/services/authenticator/lib/password_policy_helpers.py b/prowler/providers/okta/services/authenticator/lib/password_policy_helpers.py
new file mode 100644
index 0000000000..08aa64a3a6
--- /dev/null
+++ b/prowler/providers/okta/services/authenticator/lib/password_policy_helpers.py
@@ -0,0 +1,80 @@
+from prowler.lib.check.models import CheckReportOkta
+from prowler.providers.okta.services.authenticator.authenticator_service import (
+ PasswordPolicy,
+)
+
+_SCOPE_ADVICE = (
+ "Grant it on the Okta API Scopes tab of the service app in the Okta Admin "
+ "Console, then re-run the check."
+)
+
+
+def active_password_policies(
+ password_policies: dict[str, PasswordPolicy],
+) -> list[PasswordPolicy]:
+ """Return active password policies sorted by priority.
+
+ Treats `policy.status == ""` as ACTIVE: the typed Okta SDK
+ occasionally returns policies without a `status` field populated
+ (the SDK enum doesn't cover every server-side value Okta has
+ shipped). Dropping those would silently hide real policies — we
+ 'd rather evaluate them and let the per-field comparator decide.
+ """
+ return sorted(
+ [
+ policy
+ for policy in password_policies.values()
+ if not policy.status or policy.status.upper() == "ACTIVE"
+ ],
+ key=lambda policy: (
+ policy.priority if policy.priority is not None else float("inf"),
+ policy.name,
+ ),
+ )
+
+
+def password_policy_label(policy: PasswordPolicy) -> str:
+ kind = "default" if policy.is_default else "custom"
+ priority = policy.priority if policy.priority is not None else "unset"
+ return f"Password Policy {policy.name} (priority {priority}, {kind})"
+
+
+def no_active_password_policies_finding(
+ metadata, org_domain: str, requirement: str
+) -> CheckReportOkta:
+ """Build the FAIL finding emitted when no active password policies exist."""
+ placeholder = PasswordPolicy(
+ id="password-policies-missing",
+ name="(no active password policies)",
+ status="MISSING",
+ )
+ report = CheckReportOkta(
+ metadata=metadata, resource=placeholder, org_domain=org_domain
+ )
+ report.status = "FAIL"
+ report.status_extended = (
+ "No active Okta Password Policies were returned by the API. "
+ f"The organization must enforce: {requirement}."
+ )
+ return report
+
+
+def missing_password_policies_scope_finding(
+ metadata, org_domain: str, scope: str, requirement: str
+) -> CheckReportOkta:
+ """Build the MANUAL finding emitted when Password Policies cannot be listed."""
+ placeholder = PasswordPolicy(
+ id="password-policies-scope-missing",
+ name="(scope not granted)",
+ status="UNKNOWN",
+ )
+ report = CheckReportOkta(
+ metadata=metadata, resource=placeholder, org_domain=org_domain
+ )
+ report.status = "MANUAL"
+ report.status_extended = (
+ f"Could not retrieve Okta Password Policies to evaluate {requirement}: "
+ f"the Okta service app is missing the required `{scope}` API scope. "
+ f"{_SCOPE_ADVICE}"
+ )
+ return report
diff --git a/tests/providers/okta/okta_fixtures.py b/tests/providers/okta/okta_fixtures.py
index d1018a65eb..5c3d0e43c3 100644
--- a/tests/providers/okta/okta_fixtures.py
+++ b/tests/providers/okta/okta_fixtures.py
@@ -20,6 +20,7 @@ def set_mocked_okta_provider(
"okta.policies.read",
"okta.brands.read",
"okta.apps.read",
+ "okta.authenticators.read",
"okta.networkZones.read",
"okta.apiTokens.read",
"okta.roles.read",
@@ -37,6 +38,7 @@ def set_mocked_okta_provider(
"okta.policies.read",
"okta.brands.read",
"okta.apps.read",
+ "okta.authenticators.read",
"okta.networkZones.read",
"okta.apiTokens.read",
"okta.roles.read",
diff --git a/tests/providers/okta/services/authenticator/authenticator_fixtures.py b/tests/providers/okta/services/authenticator/authenticator_fixtures.py
new file mode 100644
index 0000000000..f15603221b
--- /dev/null
+++ b/tests/providers/okta/services/authenticator/authenticator_fixtures.py
@@ -0,0 +1,76 @@
+from unittest import mock
+
+from prowler.providers.okta.services.authenticator.authenticator_service import (
+ OktaAuthenticator,
+ PasswordPolicy,
+)
+from tests.providers.okta.okta_fixtures import set_mocked_okta_provider
+
+
+def build_authenticator_client(
+ password_policies: dict = None,
+ authenticators: dict = None,
+ missing_scope: dict = None,
+):
+ client = mock.MagicMock()
+ client.password_policies = password_policies or {}
+ client.authenticators = authenticators or {}
+ client.missing_scope = missing_scope or {
+ "password_policies": None,
+ "authenticators": None,
+ }
+ client.provider = set_mocked_okta_provider()
+ return client
+
+
+def password_policy(
+ policy_id: str = "pol-password",
+ name: str = "Default Password Policy",
+ *,
+ status: str = "ACTIVE",
+ priority: int = 1,
+ max_attempts: int = 3,
+ min_length: int = 15,
+ min_upper_case: int = 1,
+ min_lower_case: int = 1,
+ min_number: int = 1,
+ min_symbol: int = 1,
+ min_age_minutes: int = 1440,
+ max_age_days: int = 60,
+ history_count: int = 5,
+ common_password_check: bool = True,
+):
+ return PasswordPolicy(
+ id=policy_id,
+ name=name,
+ status=status,
+ priority=priority,
+ max_attempts=max_attempts,
+ min_length=min_length,
+ min_upper_case=min_upper_case,
+ min_lower_case=min_lower_case,
+ min_number=min_number,
+ min_symbol=min_symbol,
+ min_age_minutes=min_age_minutes,
+ max_age_days=max_age_days,
+ history_count=history_count,
+ common_password_check=common_password_check,
+ )
+
+
+def authenticator(
+ auth_id: str = "aut-okta-verify",
+ key: str = "okta_verify",
+ name: str = "Okta Verify",
+ *,
+ status: str = "ACTIVE",
+ fips: str = "REQUIRED",
+):
+ return OktaAuthenticator(
+ id=auth_id,
+ key=key,
+ name=name,
+ status=status,
+ type="app",
+ fips=fips,
+ )
diff --git a/tests/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant_test.py b/tests/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant_test.py
new file mode 100644
index 0000000000..467dc44611
--- /dev/null
+++ b/tests/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant_test.py
@@ -0,0 +1,74 @@
+from unittest import mock
+
+from tests.providers.okta.okta_fixtures import set_mocked_okta_provider
+from tests.providers.okta.services.authenticator.authenticator_fixtures import (
+ authenticator,
+ build_authenticator_client,
+)
+
+CHECK_PATH = (
+ "prowler.providers.okta.services.authenticator."
+ "authenticator_okta_verify_fips_compliant."
+ "authenticator_okta_verify_fips_compliant.authenticator_client"
+)
+
+
+def _run_check(authenticator_client):
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_okta_provider(),
+ ),
+ mock.patch(CHECK_PATH, new=authenticator_client),
+ ):
+ from prowler.providers.okta.services.authenticator.authenticator_okta_verify_fips_compliant.authenticator_okta_verify_fips_compliant import (
+ authenticator_okta_verify_fips_compliant,
+ )
+
+ return authenticator_okta_verify_fips_compliant().execute()
+
+
+class Test_authenticator_okta_verify_fips_compliant:
+ def test_okta_verify_fips_required_passes(self):
+ okta_verify = authenticator(key="okta_verify", fips="REQUIRED")
+ findings = _run_check(
+ build_authenticator_client(authenticators={okta_verify.id: okta_verify})
+ )
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert findings[0].resource_id == okta_verify.id
+
+ def test_okta_verify_without_fips_required_fails(self):
+ okta_verify = authenticator(key="okta_verify", fips="OPTIONAL")
+ findings = _run_check(
+ build_authenticator_client(authenticators={okta_verify.id: okta_verify})
+ )
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert "FIPS" in findings[0].status_extended
+
+ def test_missing_okta_verify_fails(self):
+ findings = _run_check(build_authenticator_client(authenticators={}))
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert "Okta Verify authenticator is missing." in findings[0].status_extended
+
+ def test_inactive_okta_verify_surfaces_current_status(self):
+ okta_verify = authenticator(key="okta_verify", status="INACTIVE")
+ findings = _run_check(
+ build_authenticator_client(authenticators={okta_verify.id: okta_verify})
+ )
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert "INACTIVE" in findings[0].status_extended
+
+ def test_missing_authenticators_scope_is_manual(self):
+ findings = _run_check(
+ build_authenticator_client(
+ authenticators={},
+ missing_scope={"authenticators": "okta.authenticators.read"},
+ )
+ )
+ assert len(findings) == 1
+ assert findings[0].status == "MANUAL"
+ assert "okta.authenticators.read" in findings[0].status_extended
diff --git a/tests/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check_test.py b/tests/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check_test.py
new file mode 100644
index 0000000000..c0e49deb8c
--- /dev/null
+++ b/tests/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check_test.py
@@ -0,0 +1,60 @@
+from unittest import mock
+
+from tests.providers.okta.okta_fixtures import set_mocked_okta_provider
+from tests.providers.okta.services.authenticator.authenticator_fixtures import (
+ build_authenticator_client,
+ password_policy,
+)
+
+CHECK_PATH = (
+ "prowler.providers.okta.services.authenticator."
+ "authenticator_password_common_password_check.authenticator_password_common_password_check.authenticator_client"
+)
+
+
+def _run_check(authenticator_client):
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_okta_provider(),
+ ),
+ mock.patch(CHECK_PATH, new=authenticator_client),
+ ):
+ from prowler.providers.okta.services.authenticator.authenticator_password_common_password_check.authenticator_password_common_password_check import (
+ authenticator_password_common_password_check,
+ )
+
+ return authenticator_password_common_password_check().execute()
+
+
+class Test_authenticator_password_common_password_check:
+ def test_no_active_password_policies_fails(self):
+ findings = _run_check(build_authenticator_client({}))
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert "No active Okta Password Policies" in findings[0].status_extended
+
+ def test_missing_password_policies_scope_is_manual(self):
+ findings = _run_check(
+ build_authenticator_client(
+ {},
+ missing_scope={"password_policies": "okta.policies.read"},
+ )
+ )
+ assert len(findings) == 1
+ assert findings[0].status == "MANUAL"
+ assert "okta.policies.read" in findings[0].status_extended
+
+ def test_compliant_password_policy_passes(self):
+ policy = password_policy(common_password_check=True)
+ findings = _run_check(build_authenticator_client({policy.id: policy}))
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert findings[0].resource_id == policy.id
+
+ def test_non_compliant_password_policy_fails(self):
+ policy = password_policy(common_password_check=False)
+ findings = _run_check(build_authenticator_client({policy.id: policy}))
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert findings[0].resource_id == policy.id
diff --git a/tests/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase_test.py b/tests/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase_test.py
new file mode 100644
index 0000000000..8483f46552
--- /dev/null
+++ b/tests/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase_test.py
@@ -0,0 +1,49 @@
+from unittest import mock
+
+from tests.providers.okta.okta_fixtures import set_mocked_okta_provider
+from tests.providers.okta.services.authenticator.authenticator_fixtures import (
+ build_authenticator_client,
+ password_policy,
+)
+
+CHECK_PATH = (
+ "prowler.providers.okta.services.authenticator."
+ "authenticator_password_complexity_lowercase.authenticator_password_complexity_lowercase.authenticator_client"
+)
+
+
+def _run_check(authenticator_client):
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_okta_provider(),
+ ),
+ mock.patch(CHECK_PATH, new=authenticator_client),
+ ):
+ from prowler.providers.okta.services.authenticator.authenticator_password_complexity_lowercase.authenticator_password_complexity_lowercase import (
+ authenticator_password_complexity_lowercase,
+ )
+
+ return authenticator_password_complexity_lowercase().execute()
+
+
+class Test_authenticator_password_complexity_lowercase:
+ def test_no_active_password_policies_fails(self):
+ findings = _run_check(build_authenticator_client({}))
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert "No active Okta Password Policies" in findings[0].status_extended
+
+ def test_compliant_password_policy_passes(self):
+ policy = password_policy(min_lower_case=1)
+ findings = _run_check(build_authenticator_client({policy.id: policy}))
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert findings[0].resource_id == policy.id
+
+ def test_non_compliant_password_policy_fails(self):
+ policy = password_policy(min_lower_case=0)
+ findings = _run_check(build_authenticator_client({policy.id: policy}))
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert findings[0].resource_id == policy.id
diff --git a/tests/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number_test.py b/tests/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number_test.py
new file mode 100644
index 0000000000..cac8f8b444
--- /dev/null
+++ b/tests/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number_test.py
@@ -0,0 +1,49 @@
+from unittest import mock
+
+from tests.providers.okta.okta_fixtures import set_mocked_okta_provider
+from tests.providers.okta.services.authenticator.authenticator_fixtures import (
+ build_authenticator_client,
+ password_policy,
+)
+
+CHECK_PATH = (
+ "prowler.providers.okta.services.authenticator."
+ "authenticator_password_complexity_number.authenticator_password_complexity_number.authenticator_client"
+)
+
+
+def _run_check(authenticator_client):
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_okta_provider(),
+ ),
+ mock.patch(CHECK_PATH, new=authenticator_client),
+ ):
+ from prowler.providers.okta.services.authenticator.authenticator_password_complexity_number.authenticator_password_complexity_number import (
+ authenticator_password_complexity_number,
+ )
+
+ return authenticator_password_complexity_number().execute()
+
+
+class Test_authenticator_password_complexity_number:
+ def test_no_active_password_policies_fails(self):
+ findings = _run_check(build_authenticator_client({}))
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert "No active Okta Password Policies" in findings[0].status_extended
+
+ def test_compliant_password_policy_passes(self):
+ policy = password_policy(min_number=1)
+ findings = _run_check(build_authenticator_client({policy.id: policy}))
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert findings[0].resource_id == policy.id
+
+ def test_non_compliant_password_policy_fails(self):
+ policy = password_policy(min_number=0)
+ findings = _run_check(build_authenticator_client({policy.id: policy}))
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert findings[0].resource_id == policy.id
diff --git a/tests/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol_test.py b/tests/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol_test.py
new file mode 100644
index 0000000000..93ed18a36d
--- /dev/null
+++ b/tests/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol_test.py
@@ -0,0 +1,49 @@
+from unittest import mock
+
+from tests.providers.okta.okta_fixtures import set_mocked_okta_provider
+from tests.providers.okta.services.authenticator.authenticator_fixtures import (
+ build_authenticator_client,
+ password_policy,
+)
+
+CHECK_PATH = (
+ "prowler.providers.okta.services.authenticator."
+ "authenticator_password_complexity_symbol.authenticator_password_complexity_symbol.authenticator_client"
+)
+
+
+def _run_check(authenticator_client):
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_okta_provider(),
+ ),
+ mock.patch(CHECK_PATH, new=authenticator_client),
+ ):
+ from prowler.providers.okta.services.authenticator.authenticator_password_complexity_symbol.authenticator_password_complexity_symbol import (
+ authenticator_password_complexity_symbol,
+ )
+
+ return authenticator_password_complexity_symbol().execute()
+
+
+class Test_authenticator_password_complexity_symbol:
+ def test_no_active_password_policies_fails(self):
+ findings = _run_check(build_authenticator_client({}))
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert "No active Okta Password Policies" in findings[0].status_extended
+
+ def test_compliant_password_policy_passes(self):
+ policy = password_policy(min_symbol=1)
+ findings = _run_check(build_authenticator_client({policy.id: policy}))
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert findings[0].resource_id == policy.id
+
+ def test_non_compliant_password_policy_fails(self):
+ policy = password_policy(min_symbol=0)
+ findings = _run_check(build_authenticator_client({policy.id: policy}))
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert findings[0].resource_id == policy.id
diff --git a/tests/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase_test.py b/tests/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase_test.py
new file mode 100644
index 0000000000..74ab626e52
--- /dev/null
+++ b/tests/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase_test.py
@@ -0,0 +1,49 @@
+from unittest import mock
+
+from tests.providers.okta.okta_fixtures import set_mocked_okta_provider
+from tests.providers.okta.services.authenticator.authenticator_fixtures import (
+ build_authenticator_client,
+ password_policy,
+)
+
+CHECK_PATH = (
+ "prowler.providers.okta.services.authenticator."
+ "authenticator_password_complexity_uppercase.authenticator_password_complexity_uppercase.authenticator_client"
+)
+
+
+def _run_check(authenticator_client):
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_okta_provider(),
+ ),
+ mock.patch(CHECK_PATH, new=authenticator_client),
+ ):
+ from prowler.providers.okta.services.authenticator.authenticator_password_complexity_uppercase.authenticator_password_complexity_uppercase import (
+ authenticator_password_complexity_uppercase,
+ )
+
+ return authenticator_password_complexity_uppercase().execute()
+
+
+class Test_authenticator_password_complexity_uppercase:
+ def test_no_active_password_policies_fails(self):
+ findings = _run_check(build_authenticator_client({}))
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert "No active Okta Password Policies" in findings[0].status_extended
+
+ def test_compliant_password_policy_passes(self):
+ policy = password_policy(min_upper_case=1)
+ findings = _run_check(build_authenticator_client({policy.id: policy}))
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert findings[0].resource_id == policy.id
+
+ def test_non_compliant_password_policy_fails(self):
+ policy = password_policy(min_upper_case=0)
+ findings = _run_check(build_authenticator_client({policy.id: policy}))
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert findings[0].resource_id == policy.id
diff --git a/tests/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5_test.py b/tests/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5_test.py
new file mode 100644
index 0000000000..e6741c53c2
--- /dev/null
+++ b/tests/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5_test.py
@@ -0,0 +1,49 @@
+from unittest import mock
+
+from tests.providers.okta.okta_fixtures import set_mocked_okta_provider
+from tests.providers.okta.services.authenticator.authenticator_fixtures import (
+ build_authenticator_client,
+ password_policy,
+)
+
+CHECK_PATH = (
+ "prowler.providers.okta.services.authenticator."
+ "authenticator_password_history_5.authenticator_password_history_5.authenticator_client"
+)
+
+
+def _run_check(authenticator_client):
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_okta_provider(),
+ ),
+ mock.patch(CHECK_PATH, new=authenticator_client),
+ ):
+ from prowler.providers.okta.services.authenticator.authenticator_password_history_5.authenticator_password_history_5 import (
+ authenticator_password_history_5,
+ )
+
+ return authenticator_password_history_5().execute()
+
+
+class Test_authenticator_password_history_5:
+ def test_no_active_password_policies_fails(self):
+ findings = _run_check(build_authenticator_client({}))
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert "No active Okta Password Policies" in findings[0].status_extended
+
+ def test_compliant_password_policy_passes(self):
+ policy = password_policy(history_count=5)
+ findings = _run_check(build_authenticator_client({policy.id: policy}))
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert findings[0].resource_id == policy.id
+
+ def test_non_compliant_password_policy_fails(self):
+ policy = password_policy(history_count=4)
+ findings = _run_check(build_authenticator_client({policy.id: policy}))
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert findings[0].resource_id == policy.id
diff --git a/tests/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3_test.py b/tests/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3_test.py
new file mode 100644
index 0000000000..35c7026e11
--- /dev/null
+++ b/tests/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3_test.py
@@ -0,0 +1,49 @@
+from unittest import mock
+
+from tests.providers.okta.okta_fixtures import set_mocked_okta_provider
+from tests.providers.okta.services.authenticator.authenticator_fixtures import (
+ build_authenticator_client,
+ password_policy,
+)
+
+CHECK_PATH = (
+ "prowler.providers.okta.services.authenticator."
+ "authenticator_password_lockout_threshold_3.authenticator_password_lockout_threshold_3.authenticator_client"
+)
+
+
+def _run_check(authenticator_client):
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_okta_provider(),
+ ),
+ mock.patch(CHECK_PATH, new=authenticator_client),
+ ):
+ from prowler.providers.okta.services.authenticator.authenticator_password_lockout_threshold_3.authenticator_password_lockout_threshold_3 import (
+ authenticator_password_lockout_threshold_3,
+ )
+
+ return authenticator_password_lockout_threshold_3().execute()
+
+
+class Test_authenticator_password_lockout_threshold_3:
+ def test_no_active_password_policies_fails(self):
+ findings = _run_check(build_authenticator_client({}))
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert "No active Okta Password Policies" in findings[0].status_extended
+
+ def test_compliant_password_policy_passes(self):
+ policy = password_policy(max_attempts=3)
+ findings = _run_check(build_authenticator_client({policy.id: policy}))
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert findings[0].resource_id == policy.id
+
+ def test_non_compliant_password_policy_fails(self):
+ policy = password_policy(max_attempts=4)
+ findings = _run_check(build_authenticator_client({policy.id: policy}))
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert findings[0].resource_id == policy.id
diff --git a/tests/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d_test.py b/tests/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d_test.py
new file mode 100644
index 0000000000..938b29a290
--- /dev/null
+++ b/tests/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d_test.py
@@ -0,0 +1,49 @@
+from unittest import mock
+
+from tests.providers.okta.okta_fixtures import set_mocked_okta_provider
+from tests.providers.okta.services.authenticator.authenticator_fixtures import (
+ build_authenticator_client,
+ password_policy,
+)
+
+CHECK_PATH = (
+ "prowler.providers.okta.services.authenticator."
+ "authenticator_password_maximum_age_60d.authenticator_password_maximum_age_60d.authenticator_client"
+)
+
+
+def _run_check(authenticator_client):
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_okta_provider(),
+ ),
+ mock.patch(CHECK_PATH, new=authenticator_client),
+ ):
+ from prowler.providers.okta.services.authenticator.authenticator_password_maximum_age_60d.authenticator_password_maximum_age_60d import (
+ authenticator_password_maximum_age_60d,
+ )
+
+ return authenticator_password_maximum_age_60d().execute()
+
+
+class Test_authenticator_password_maximum_age_60d:
+ def test_no_active_password_policies_fails(self):
+ findings = _run_check(build_authenticator_client({}))
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert "No active Okta Password Policies" in findings[0].status_extended
+
+ def test_compliant_password_policy_passes(self):
+ policy = password_policy(max_age_days=60)
+ findings = _run_check(build_authenticator_client({policy.id: policy}))
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert findings[0].resource_id == policy.id
+
+ def test_non_compliant_password_policy_fails(self):
+ policy = password_policy(max_age_days=61)
+ findings = _run_check(build_authenticator_client({policy.id: policy}))
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert findings[0].resource_id == policy.id
diff --git a/tests/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h_test.py b/tests/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h_test.py
new file mode 100644
index 0000000000..0fbe562330
--- /dev/null
+++ b/tests/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h_test.py
@@ -0,0 +1,49 @@
+from unittest import mock
+
+from tests.providers.okta.okta_fixtures import set_mocked_okta_provider
+from tests.providers.okta.services.authenticator.authenticator_fixtures import (
+ build_authenticator_client,
+ password_policy,
+)
+
+CHECK_PATH = (
+ "prowler.providers.okta.services.authenticator."
+ "authenticator_password_minimum_age_24h.authenticator_password_minimum_age_24h.authenticator_client"
+)
+
+
+def _run_check(authenticator_client):
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_okta_provider(),
+ ),
+ mock.patch(CHECK_PATH, new=authenticator_client),
+ ):
+ from prowler.providers.okta.services.authenticator.authenticator_password_minimum_age_24h.authenticator_password_minimum_age_24h import (
+ authenticator_password_minimum_age_24h,
+ )
+
+ return authenticator_password_minimum_age_24h().execute()
+
+
+class Test_authenticator_password_minimum_age_24h:
+ def test_no_active_password_policies_fails(self):
+ findings = _run_check(build_authenticator_client({}))
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert "No active Okta Password Policies" in findings[0].status_extended
+
+ def test_compliant_password_policy_passes(self):
+ policy = password_policy(min_age_minutes=1440)
+ findings = _run_check(build_authenticator_client({policy.id: policy}))
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert findings[0].resource_id == policy.id
+
+ def test_non_compliant_password_policy_fails(self):
+ policy = password_policy(min_age_minutes=1439)
+ findings = _run_check(build_authenticator_client({policy.id: policy}))
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert findings[0].resource_id == policy.id
diff --git a/tests/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15_test.py b/tests/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15_test.py
new file mode 100644
index 0000000000..2608c42998
--- /dev/null
+++ b/tests/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15_test.py
@@ -0,0 +1,62 @@
+from unittest import mock
+
+from tests.providers.okta.okta_fixtures import set_mocked_okta_provider
+from tests.providers.okta.services.authenticator.authenticator_fixtures import (
+ build_authenticator_client,
+ password_policy,
+)
+
+CHECK_PATH = (
+ "prowler.providers.okta.services.authenticator."
+ "authenticator_password_minimum_length_15.authenticator_password_minimum_length_15.authenticator_client"
+)
+
+
+def _run_check(authenticator_client):
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_okta_provider(),
+ ),
+ mock.patch(CHECK_PATH, new=authenticator_client),
+ ):
+ from prowler.providers.okta.services.authenticator.authenticator_password_minimum_length_15.authenticator_password_minimum_length_15 import (
+ authenticator_password_minimum_length_15,
+ )
+
+ return authenticator_password_minimum_length_15().execute()
+
+
+class Test_authenticator_password_minimum_length_15:
+ def test_no_active_password_policies_fails(self):
+ findings = _run_check(build_authenticator_client({}))
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert "No active Okta Password Policies" in findings[0].status_extended
+
+ def test_compliant_password_policy_passes(self):
+ policy = password_policy(min_length=15)
+ findings = _run_check(build_authenticator_client({policy.id: policy}))
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert findings[0].resource_id == policy.id
+
+ def test_non_compliant_password_policy_fails(self):
+ policy = password_policy(min_length=14)
+ findings = _run_check(build_authenticator_client({policy.id: policy}))
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert findings[0].resource_id == policy.id
+
+ def test_multiple_active_policies_emit_one_finding_each(self):
+ compliant = password_policy(policy_id="pol-good", name="Strict", min_length=15)
+ weak = password_policy(
+ policy_id="pol-weak", name="Weak", min_length=8, priority=2
+ )
+ findings = _run_check(
+ build_authenticator_client({compliant.id: compliant, weak.id: weak})
+ )
+ assert len(findings) == 2
+ by_name = {finding.resource_name: finding for finding in findings}
+ assert by_name["Strict"].status == "PASS"
+ assert by_name["Weak"].status == "FAIL"
diff --git a/tests/providers/okta/services/authenticator/authenticator_service_test.py b/tests/providers/okta/services/authenticator/authenticator_service_test.py
new file mode 100644
index 0000000000..d66af92ca2
--- /dev/null
+++ b/tests/providers/okta/services/authenticator/authenticator_service_test.py
@@ -0,0 +1,188 @@
+from types import SimpleNamespace
+from unittest import mock
+
+from prowler.providers.okta.models import OktaIdentityInfo
+from prowler.providers.okta.services.authenticator.authenticator_service import (
+ Authenticator,
+ OktaAuthenticator,
+ PasswordPolicy,
+)
+from tests.providers.okta.okta_fixtures import set_mocked_okta_provider
+
+
+def _resp(headers: dict = None):
+ return SimpleNamespace(headers=headers or {})
+
+
+def _sdk_password_policy(
+ policy_id: str = "pol-password", name: str = "Default", common_exclude=True
+):
+ return SimpleNamespace(
+ id=policy_id,
+ name=name,
+ priority=1,
+ status="ACTIVE",
+ system=True,
+ settings=SimpleNamespace(
+ password=SimpleNamespace(
+ lockout=SimpleNamespace(max_attempts=3),
+ complexity=SimpleNamespace(
+ min_length=15,
+ min_upper_case=1,
+ min_lower_case=1,
+ min_number=1,
+ min_symbol=1,
+ dictionary=SimpleNamespace(
+ common=SimpleNamespace(exclude=common_exclude)
+ ),
+ ),
+ age=SimpleNamespace(
+ min_age_minutes=1440,
+ max_age_days=60,
+ history_count=5,
+ ),
+ )
+ ),
+ )
+
+
+def _sdk_authenticator(
+ auth_id: str = "aut-okta-verify",
+ key: str = "okta_verify",
+ status: str = "ACTIVE",
+ fips: str = "REQUIRED",
+):
+ return SimpleNamespace(
+ id=auth_id,
+ key=key,
+ name="Okta Verify" if key == "okta_verify" else "Smart Card IdP",
+ status=status,
+ type="app",
+ settings=SimpleNamespace(compliance=SimpleNamespace(fips=fips)),
+ )
+
+
+class Test_Authenticator_service:
+ def test_fetches_password_policies_and_authenticators(self):
+ provider = set_mocked_okta_provider()
+ policy = _sdk_password_policy()
+ okta_verify = _sdk_authenticator()
+
+ async def fake_list_policies(*_a, **_k):
+ return ([policy], _resp({}), None)
+
+ async def fake_list_authenticators(*_a, **_k):
+ return ([okta_verify], _resp({}), None)
+
+ with mock.patch(
+ "prowler.providers.okta.lib.service.service.OktaSDKClient"
+ ) as mocked_client_cls:
+ mocked = mock.MagicMock()
+ mocked.list_policies = fake_list_policies
+ mocked.list_authenticators = fake_list_authenticators
+ mocked_client_cls.return_value = mocked
+
+ service = Authenticator(provider)
+
+ assert isinstance(service.password_policies[policy.id], PasswordPolicy)
+ assert service.password_policies[policy.id].min_length == 15
+ assert service.password_policies[policy.id].common_password_check is True
+ assert isinstance(service.authenticators[okta_verify.id], OktaAuthenticator)
+ assert service.authenticators[okta_verify.id].fips == "REQUIRED"
+
+ def test_common_password_exclude_false_is_not_compliant(self):
+ provider = set_mocked_okta_provider()
+ policy = _sdk_password_policy(common_exclude=False)
+
+ async def fake_list_policies(*_a, **_k):
+ return ([policy], _resp({}), None)
+
+ async def fake_list_authenticators(*_a, **_k):
+ return ([], _resp({}), None)
+
+ with mock.patch(
+ "prowler.providers.okta.lib.service.service.OktaSDKClient"
+ ) as mocked_client_cls:
+ mocked = mock.MagicMock()
+ mocked.list_policies = fake_list_policies
+ mocked.list_authenticators = fake_list_authenticators
+ mocked_client_cls.return_value = mocked
+ service = Authenticator(provider)
+
+ assert service.password_policies[policy.id].common_password_check is False
+
+ def test_paginates_password_policies(self):
+ provider = set_mocked_okta_provider()
+ page_1 = _sdk_password_policy("pol-1", "First")
+ page_2 = _sdk_password_policy("pol-2", "Second")
+ quote = chr(34)
+ next_link = (
+ "; "
+ f"rel={quote}next{quote}"
+ )
+ calls = []
+
+ async def fake_list_policies(*_a, **kwargs):
+ calls.append(kwargs.get("after"))
+ if kwargs.get("after") is None:
+ return ([page_1], _resp({"link": next_link}), None)
+ return ([page_2], _resp({}), None)
+
+ async def fake_list_authenticators(*_a, **_k):
+ return ([], _resp({}), None)
+
+ with mock.patch(
+ "prowler.providers.okta.lib.service.service.OktaSDKClient"
+ ) as mocked_client_cls:
+ mocked = mock.MagicMock()
+ mocked.list_policies = fake_list_policies
+ mocked.list_authenticators = fake_list_authenticators
+ mocked_client_cls.return_value = mocked
+ service = Authenticator(provider)
+
+ assert calls == [None, "cursor-2"]
+ assert set(service.password_policies.keys()) == {"pol-1", "pol-2"}
+
+ def test_missing_scopes_skip_dependent_api_calls(self):
+ provider = set_mocked_okta_provider(
+ identity=OktaIdentityInfo(
+ org_domain="acme.okta.com",
+ client_id="0oa1234567890abcdef",
+ granted_scopes=["okta.apps.read"],
+ )
+ )
+
+ async def fail_if_called(*_a, **_k):
+ raise AssertionError("Authenticator API calls should not run")
+
+ with mock.patch(
+ "prowler.providers.okta.lib.service.service.OktaSDKClient"
+ ) as mocked_client_cls:
+ mocked = mock.MagicMock()
+ mocked.list_policies = fail_if_called
+ mocked.list_authenticators = fail_if_called
+ mocked_client_cls.return_value = mocked
+ service = Authenticator(provider)
+
+ assert service.missing_scope["password_policies"] == "okta.policies.read"
+ assert service.missing_scope["authenticators"] == "okta.authenticators.read"
+ assert service.password_policies == {}
+ assert service.authenticators == {}
+
+ def test_returns_empty_collections_on_api_errors(self):
+ provider = set_mocked_okta_provider()
+
+ async def failing(*_a, **_k):
+ return ([], _resp({}), Exception("forbidden"))
+
+ with mock.patch(
+ "prowler.providers.okta.lib.service.service.OktaSDKClient"
+ ) as mocked_client_cls:
+ mocked = mock.MagicMock()
+ mocked.list_policies = failing
+ mocked.list_authenticators = failing
+ mocked_client_cls.return_value = mocked
+ service = Authenticator(provider)
+
+ assert service.password_policies == {}
+ assert service.authenticators == {}
diff --git a/tests/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active_test.py b/tests/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active_test.py
new file mode 100644
index 0000000000..c85527f0be
--- /dev/null
+++ b/tests/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active_test.py
@@ -0,0 +1,74 @@
+from unittest import mock
+
+from tests.providers.okta.okta_fixtures import set_mocked_okta_provider
+from tests.providers.okta.services.authenticator.authenticator_fixtures import (
+ authenticator,
+ build_authenticator_client,
+)
+
+CHECK_PATH = (
+ "prowler.providers.okta.services.authenticator."
+ "authenticator_smart_card_active.authenticator_smart_card_active.authenticator_client"
+)
+
+
+def _run_check(authenticator_client):
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_okta_provider(),
+ ),
+ mock.patch(CHECK_PATH, new=authenticator_client),
+ ):
+ from prowler.providers.okta.services.authenticator.authenticator_smart_card_active.authenticator_smart_card_active import (
+ authenticator_smart_card_active,
+ )
+
+ return authenticator_smart_card_active().execute()
+
+
+class Test_authenticator_smart_card_active:
+ def test_smart_card_active_passes(self):
+ smart_card = authenticator(
+ auth_id="aut-smart-card",
+ key="smart_card_idp",
+ name="Smart Card IdP",
+ status="ACTIVE",
+ )
+ findings = _run_check(
+ build_authenticator_client(authenticators={smart_card.id: smart_card})
+ )
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert findings[0].resource_id == smart_card.id
+
+ def test_missing_smart_card_fails(self):
+ findings = _run_check(build_authenticator_client(authenticators={}))
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert "not active" in findings[0].status_extended
+
+ def test_missing_authenticators_scope_is_manual(self):
+ findings = _run_check(
+ build_authenticator_client(
+ authenticators={},
+ missing_scope={"authenticators": "okta.authenticators.read"},
+ )
+ )
+ assert len(findings) == 1
+ assert findings[0].status == "MANUAL"
+ assert "okta.authenticators.read" in findings[0].status_extended
+
+ def test_inactive_smart_card_fails(self):
+ smart_card = authenticator(
+ auth_id="aut-smart-card",
+ key="smart_card_idp",
+ name="Smart Card IdP",
+ status="INACTIVE",
+ )
+ findings = _run_check(
+ build_authenticator_client(authenticators={smart_card.id: smart_card})
+ )
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert "INACTIVE" in findings[0].status_extended
From 7e60e8f8dae30c90248459765828c8f62fcf8b9d Mon Sep 17 00:00:00 2001
From: Ashishraymajhi
Date: Tue, 9 Jun 2026 14:59:03 +0530
Subject: [PATCH 026/129] feat(m365): add
entra_service_prinicipal_privileged_role_no_owners_check (#11189)
Co-authored-by: Daniel Barranquero
---
prowler/CHANGELOG.md | 1 +
.../m365/services/entra/entra_service.py | 61 +++-
.../__init__.py | 0
...al_privileged_role_no_owners.metadata.json | 40 +++
...ice_principal_privileged_role_no_owners.py | 71 +++++
...rincipal_privileged_role_no_owners_test.py | 299 ++++++++++++++++++
6 files changed, 471 insertions(+), 1 deletion(-)
create mode 100644 prowler/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/__init__.py
create mode 100644 prowler/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/entra_service_principal_privileged_role_no_owners.metadata.json
create mode 100644 prowler/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/entra_service_principal_privileged_role_no_owners.py
create mode 100644 tests/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/entra_service_principal_privileged_role_no_owners_test.py
diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md
index af0b2f6496..6b02ecdf7d 100644
--- a/prowler/CHANGELOG.md
+++ b/prowler/CHANGELOG.md
@@ -15,6 +15,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- `elbv2_alb_drop_invalid_header_fields_enabled` check for AWS provider, verifying Application Load Balancers have `routing.http.drop_invalid_header_fields.enabled` set to `true` to mitigate HTTP desync attacks (AWS FSBP ELB.4) [(#11471)](https://github.com/prowler-cloud/prowler/pull/11471)
- `user`, `systemlog` and `idp` service for Okta provider with `user_inactivity_automation_35d_enabled`, `systemlog_streaming_enabled` and `idp_smart_card_dod_approved_ca` checks [(#11496)](https://github.com/prowler-cloud/prowler/pull/11496)
- AWS AI Security Framework support in the CLI dashboard [(#11475)](https://github.com/prowler-cloud/prowler/pull/11475)
+- `entra_service_principal_privileged_role_no_owners` check for M365 provider, failing when a service principal with a permanent Tier 0 directory role has owners on the service principal or its parent app registration [(#11070)](https://github.com/prowler-cloud/prowler/issues/11070)
### 🐞 Fixed
diff --git a/prowler/providers/m365/services/entra/entra_service.py b/prowler/providers/m365/services/entra/entra_service.py
index a09ee32da0..c3ea05acee 100644
--- a/prowler/providers/m365/services/entra/entra_service.py
+++ b/prowler/providers/m365/services/entra/entra_service.py
@@ -1197,6 +1197,10 @@ OAuthAppInfo
service_principals_by_app_id = {
sp.app_id: sp for sp in service_principals.values() if sp.app_id
}
+ # Remember each SP's parent application object ID so the owner
+ # lookup below can address it directly without re-walking
+ # /applications.
+ application_object_id_by_sp_id: Dict[str, str] = {}
app_response = await self.client.applications.get()
while app_response:
for app in getattr(app_response, "value", []) or []:
@@ -1207,6 +1211,10 @@ OAuthAppInfo
if target_sp is None:
continue
+ app_object_id = getattr(app, "id", None)
+ if app_object_id:
+ application_object_id_by_sp_id[target_sp.id] = app_object_id
+
for cred in getattr(app, "password_credentials", []) or []:
target_sp.password_credentials.append(
PasswordCredential(
@@ -1257,6 +1265,49 @@ OAuthAppInfo
next_link
).get()
+ # Resolve owners only for service principals that hold a permanent
+ # Tier 0 directory role. Owner ownership of the SP object or its
+ # parent app registration is a credential-rotation escalation path
+ # outside PIM and Conditional Access; fetching owners for every
+ # consented SP would multiply Graph traffic for no benefit.
+ for sp in service_principals.values():
+ if not sp.directory_role_template_ids:
+ continue
+ try:
+ sp_owners_response = (
+ await self.client.service_principals.by_service_principal_id(
+ sp.id
+ ).owners.get()
+ )
+ sp.sp_owner_ids = [
+ getattr(owner, "id", None)
+ for owner in (getattr(sp_owners_response, "value", []) or [])
+ if getattr(owner, "id", None)
+ ]
+ except Exception as error:
+ logger.error(
+ f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+
+ app_object_id = application_object_id_by_sp_id.get(sp.id)
+ if not app_object_id:
+ continue
+ try:
+ app_owners_response = (
+ await self.client.applications.by_application_id(
+ app_object_id
+ ).owners.get()
+ )
+ sp.app_owner_ids = [
+ getattr(owner, "id", None)
+ for owner in (getattr(app_owners_response, "value", []) or [])
+ if getattr(owner, "id", None)
+ ]
+ except Exception as error:
+ logger.error(
+ f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
@@ -1436,7 +1487,7 @@ class PlatformConditions(BaseModel):
@validator("include_platforms", "exclude_platforms", pre=True)
@classmethod
- def normalize_platforms(cls, values):
+ def normalize_platforms(cls, values): # noqa: vulture
if not values:
return []
@@ -1832,6 +1883,12 @@ class ServicePrincipal(BaseModel):
key_credentials: List of key credentials (certificates).
directory_role_template_ids: List of directory role template IDs permanently
assigned to this service principal.
+ sp_owner_ids: Principal IDs that own the service principal object.
+ Populated only for service principals that hold a permanent Tier 0
+ directory role assignment, to keep Graph traffic bounded.
+ app_owner_ids: Principal IDs that own the parent app registration.
+ Populated only for service principals that hold a permanent Tier 0
+ directory role assignment.
"""
id: str
@@ -1841,6 +1898,8 @@ class ServicePrincipal(BaseModel):
password_credentials: List[PasswordCredential] = []
key_credentials: List[KeyCredential] = []
directory_role_template_ids: List[str] = []
+ sp_owner_ids: List[str] = []
+ app_owner_ids: List[str] = []
class AppRegistration(BaseModel):
diff --git a/prowler/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/__init__.py b/prowler/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/entra_service_principal_privileged_role_no_owners.metadata.json b/prowler/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/entra_service_principal_privileged_role_no_owners.metadata.json
new file mode 100644
index 0000000000..4822d8956d
--- /dev/null
+++ b/prowler/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/entra_service_principal_privileged_role_no_owners.metadata.json
@@ -0,0 +1,40 @@
+{
+ "Provider": "m365",
+ "CheckID": "entra_service_principal_privileged_role_no_owners",
+ "CheckTitle": "Service principals with privileged Entra directory roles must have no owners",
+ "CheckType": [],
+ "ServiceName": "entra",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "critical",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "IAM",
+ "Description": "Microsoft Entra **service principals** holding permanent **Control Plane (Tier 0)** directory roles (such as **Global Administrator** or **Privileged Role Administrator**) are evaluated for the presence of **owners** on either the service principal itself or its parent **app registration**.",
+ "Risk": "An **owner** of a service principal or its parent app registration can **rotate credentials** and sign in as the service principal, inheriting its **Tier 0** role outside **PIM** and **Conditional Access** controls. This is a documented privilege escalation path impacting **confidentiality**, **integrity**, and **availability** of the tenant's control plane.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://learn.microsoft.com/en-us/graph/api/serviceprincipal-list-owners?view=graph-rest-1.0",
+ "https://learn.microsoft.com/en-us/graph/api/application-list-owners?view=graph-rest-1.0",
+ "https://learn.microsoft.com/en-us/graph/api/rbacapplication-list-roleassignments?view=graph-rest-1.0"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "",
+ "NativeIaC": "",
+ "Other": "1. Sign in to the Microsoft Entra admin center (https://entra.microsoft.com)\n2. Go to Identity > Applications > Enterprise applications > select the service principal\n3. Under Owners, remove all owners\n4. Repeat for the parent App Registration under Identity > Applications > App registrations\n5. Use PIM eligible assignments instead of permanent role assignments where possible",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Remove all owners from service principals that hold privileged Entra directory roles. Manage privileged service principals exclusively via PIM-eligible role assignments and break-glass controls. Ensure no human account can silently inherit control-plane privileges through ownership.",
+ "Url": "https://hub.prowler.com/check/entra_service_principal_privileged_role_no_owners"
+ }
+ },
+ "Categories": [
+ "identity-access"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [
+ "entra_service_principal_no_secrets_for_permanent_tier0_roles"
+ ],
+ "Notes": "Only service principals with permanent Tier 0 directory role assignments are evaluated. Microsoft first-party service principals and multi-tenant ISV apps consented from other publishers are excluded by the service layer."
+}
diff --git a/prowler/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/entra_service_principal_privileged_role_no_owners.py b/prowler/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/entra_service_principal_privileged_role_no_owners.py
new file mode 100644
index 0000000000..f3af26e23f
--- /dev/null
+++ b/prowler/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/entra_service_principal_privileged_role_no_owners.py
@@ -0,0 +1,71 @@
+"""Check for service principals with privileged roles that have owners."""
+
+from typing import List
+
+from prowler.lib.check.models import Check, CheckReportM365
+from prowler.providers.m365.services.entra.entra_client import entra_client
+
+
+class entra_service_principal_privileged_role_no_owners(Check):
+ """Service principal with a permanent Tier 0 directory role has no owners.
+
+ Owners of a service principal or its parent app registration can rotate
+ credentials and sign in as the service principal, inheriting its privileged
+ directory role outside PIM approval flows and Conditional Access policies
+ targeting user accounts.
+
+ - PASS: The service principal does not hold a permanent Tier 0 directory
+ role, or it does but has zero owners on both the service principal and
+ its parent app registration.
+ - FAIL: The service principal holds a permanent Tier 0 directory role and
+ has at least one owner on either the service principal or its parent
+ app registration.
+ """
+
+ def execute(self) -> List[CheckReportM365]:
+ """Execute the privileged service principal owner check.
+
+ Returns:
+ A list of reports, one per service principal owned by the audited
+ tenant.
+ """
+ findings = []
+ for sp in entra_client.service_principals.values():
+ report = CheckReportM365(
+ metadata=self.metadata(),
+ resource=sp,
+ resource_name=sp.name,
+ resource_id=sp.id,
+ )
+
+ if not sp.directory_role_template_ids:
+ report.status = "PASS"
+ report.status_extended = (
+ f"Service principal '{sp.name}' has no permanent Tier 0 "
+ f"directory role assignments."
+ )
+ findings.append(report)
+ continue
+
+ unique_owners = set(sp.sp_owner_ids) | set(sp.app_owner_ids)
+ tier0_role_count = len(sp.directory_role_template_ids)
+
+ if unique_owners:
+ report.status = "FAIL"
+ report.status_extended = (
+ f"Service principal '{sp.name}' holds {tier0_role_count} "
+ f"permanent Tier 0 directory role(s) and has "
+ f"{len(unique_owners)} owner(s) "
+ f"({len(sp.sp_owner_ids)} on the service principal, "
+ f"{len(sp.app_owner_ids)} on the parent app registration)."
+ )
+ else:
+ report.status = "PASS"
+ report.status_extended = (
+ f"Service principal '{sp.name}' holds {tier0_role_count} "
+ f"permanent Tier 0 directory role(s) and has no owners on "
+ f"either the service principal or its parent app registration."
+ )
+
+ findings.append(report)
+ return findings
diff --git a/tests/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/entra_service_principal_privileged_role_no_owners_test.py b/tests/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/entra_service_principal_privileged_role_no_owners_test.py
new file mode 100644
index 0000000000..e62f9eaa08
--- /dev/null
+++ b/tests/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/entra_service_principal_privileged_role_no_owners_test.py
@@ -0,0 +1,299 @@
+from unittest import mock
+from uuid import uuid4
+
+from prowler.providers.m365.services.entra.entra_service import ServicePrincipal
+from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider
+
+GLOBAL_ADMIN_ROLE = "62e90394-69f5-4237-9190-012177145e10"
+PRIV_ROLE_ADMIN = "e8611ab8-c189-46e8-94e1-60213ab1f814"
+
+
+class Test_entra_service_principal_privileged_role_no_owners:
+ """Tests for the entra_service_principal_privileged_role_no_owners check."""
+
+ def test_no_service_principals(self):
+ """No service principals configured: expected no findings."""
+ entra_client = mock.MagicMock
+ entra_client.audited_tenant = "audited_tenant"
+ entra_client.audited_domain = DOMAIN
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_m365_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners.entra_client",
+ new=entra_client,
+ ),
+ ):
+ from prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners import (
+ entra_service_principal_privileged_role_no_owners,
+ )
+
+ entra_client.service_principals = {}
+
+ check = entra_service_principal_privileged_role_no_owners()
+ result = check.execute()
+
+ assert len(result) == 0
+
+ def test_service_principal_no_tier0_roles(self):
+ """Service principal without Tier 0 roles: expected PASS."""
+ entra_client = mock.MagicMock
+ entra_client.audited_tenant = "audited_tenant"
+ entra_client.audited_domain = DOMAIN
+
+ sp_id = str(uuid4())
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_m365_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners.entra_client",
+ new=entra_client,
+ ),
+ ):
+ from prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners import (
+ entra_service_principal_privileged_role_no_owners,
+ )
+
+ entra_client.service_principals = {
+ sp_id: ServicePrincipal(
+ id=sp_id,
+ name="NonPrivilegedApp",
+ app_id=str(uuid4()),
+ directory_role_template_ids=[],
+ sp_owner_ids=[],
+ app_owner_ids=[],
+ )
+ }
+
+ check = entra_service_principal_privileged_role_no_owners()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert result[0].resource_id == sp_id
+ assert result[0].resource_name == "NonPrivilegedApp"
+ assert (
+ "no permanent Tier 0 directory role assignments"
+ in result[0].status_extended
+ )
+
+ def test_service_principal_tier0_no_owners(self):
+ """Privileged SP with no owners on SP or app: expected PASS."""
+ entra_client = mock.MagicMock
+ entra_client.audited_tenant = "audited_tenant"
+ entra_client.audited_domain = DOMAIN
+
+ sp_id = str(uuid4())
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_m365_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners.entra_client",
+ new=entra_client,
+ ),
+ ):
+ from prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners import (
+ entra_service_principal_privileged_role_no_owners,
+ )
+
+ entra_client.service_principals = {
+ sp_id: ServicePrincipal(
+ id=sp_id,
+ name="SecureApp",
+ app_id=str(uuid4()),
+ directory_role_template_ids=[GLOBAL_ADMIN_ROLE],
+ sp_owner_ids=[],
+ app_owner_ids=[],
+ )
+ }
+
+ check = entra_service_principal_privileged_role_no_owners()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert result[0].resource_id == sp_id
+ assert result[0].resource_name == "SecureApp"
+ assert "no owners" in result[0].status_extended
+
+ def test_service_principal_tier0_with_sp_owners(self):
+ """Privileged SP with owners on SP only: expected FAIL."""
+ entra_client = mock.MagicMock
+ entra_client.audited_tenant = "audited_tenant"
+ entra_client.audited_domain = DOMAIN
+
+ sp_id = str(uuid4())
+ owner_id = str(uuid4())
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_m365_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners.entra_client",
+ new=entra_client,
+ ),
+ ):
+ from prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners import (
+ entra_service_principal_privileged_role_no_owners,
+ )
+
+ entra_client.service_principals = {
+ sp_id: ServicePrincipal(
+ id=sp_id,
+ name="RiskyApp",
+ app_id=str(uuid4()),
+ directory_role_template_ids=[GLOBAL_ADMIN_ROLE],
+ sp_owner_ids=[owner_id],
+ app_owner_ids=[],
+ )
+ }
+
+ check = entra_service_principal_privileged_role_no_owners()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert result[0].resource_id == sp_id
+ assert "1 owner(s)" in result[0].status_extended
+ assert "1 on the service principal" in result[0].status_extended
+ assert "0 on the parent app registration" in result[0].status_extended
+
+ def test_service_principal_tier0_with_app_owners(self):
+ """Privileged SP with owners on parent app only: expected FAIL."""
+ entra_client = mock.MagicMock
+ entra_client.audited_tenant = "audited_tenant"
+ entra_client.audited_domain = DOMAIN
+
+ sp_id = str(uuid4())
+ app_owner_id = str(uuid4())
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_m365_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners.entra_client",
+ new=entra_client,
+ ),
+ ):
+ from prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners import (
+ entra_service_principal_privileged_role_no_owners,
+ )
+
+ entra_client.service_principals = {
+ sp_id: ServicePrincipal(
+ id=sp_id,
+ name="AppRegOwnerRisk",
+ app_id=str(uuid4()),
+ directory_role_template_ids=[GLOBAL_ADMIN_ROLE],
+ sp_owner_ids=[],
+ app_owner_ids=[app_owner_id],
+ )
+ }
+
+ check = entra_service_principal_privileged_role_no_owners()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "1 owner(s)" in result[0].status_extended
+ assert "0 on the service principal" in result[0].status_extended
+ assert "1 on the parent app registration" in result[0].status_extended
+
+ def test_service_principal_tier0_with_both_owners(self):
+ """Privileged SP with distinct owners on both SP and app: expected FAIL."""
+ entra_client = mock.MagicMock
+ entra_client.audited_tenant = "audited_tenant"
+ entra_client.audited_domain = DOMAIN
+
+ sp_id = str(uuid4())
+ sp_owner_id = str(uuid4())
+ app_owner_id = str(uuid4())
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_m365_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners.entra_client",
+ new=entra_client,
+ ),
+ ):
+ from prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners import (
+ entra_service_principal_privileged_role_no_owners,
+ )
+
+ entra_client.service_principals = {
+ sp_id: ServicePrincipal(
+ id=sp_id,
+ name="HighRiskApp",
+ app_id=str(uuid4()),
+ directory_role_template_ids=[GLOBAL_ADMIN_ROLE, PRIV_ROLE_ADMIN],
+ sp_owner_ids=[sp_owner_id],
+ app_owner_ids=[app_owner_id],
+ )
+ }
+
+ check = entra_service_principal_privileged_role_no_owners()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "2 permanent Tier 0 directory role(s)" in result[0].status_extended
+ assert "2 owner(s)" in result[0].status_extended
+
+ def test_service_principal_tier0_same_owner_on_sp_and_app(self):
+ """Same principal owns both SP and parent app: owner count deduplicated."""
+ entra_client = mock.MagicMock
+ entra_client.audited_tenant = "audited_tenant"
+ entra_client.audited_domain = DOMAIN
+
+ sp_id = str(uuid4())
+ shared_owner_id = str(uuid4())
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_m365_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners.entra_client",
+ new=entra_client,
+ ),
+ ):
+ from prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners import (
+ entra_service_principal_privileged_role_no_owners,
+ )
+
+ entra_client.service_principals = {
+ sp_id: ServicePrincipal(
+ id=sp_id,
+ name="DualOwnedApp",
+ app_id=str(uuid4()),
+ directory_role_template_ids=[GLOBAL_ADMIN_ROLE],
+ sp_owner_ids=[shared_owner_id],
+ app_owner_ids=[shared_owner_id],
+ )
+ }
+
+ check = entra_service_principal_privileged_role_no_owners()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "1 owner(s)" in result[0].status_extended
+ assert "1 on the service principal" in result[0].status_extended
+ assert "1 on the parent app registration" in result[0].status_extended
From b2d74711d99b390ea89cb54338550064f01b62eb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?C=C3=A9sar=20Arroba?=
<19954079+cesararroba@users.noreply.github.com>
Date: Tue, 9 Jun 2026 13:01:46 +0200
Subject: [PATCH 027/129] chore(deps): bump dulwich to 1.2.5 and pyjwt to
2.13.0 for osv-scanner (#11499)
---
api/CHANGELOG.md | 4 ++
api/pyproject.toml | 16 ++++++--
api/src/backend/config/django/testing.py | 5 +++
api/uv.lock | 51 +++++++++++++-----------
osv-scanner.toml | 5 ++-
prowler/CHANGELOG.md | 4 ++
pyproject.toml | 4 +-
uv.lock | 50 ++++++++++++-----------
8 files changed, 86 insertions(+), 53 deletions(-)
diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md
index 15466fd5fe..dc404079ec 100644
--- a/api/CHANGELOG.md
+++ b/api/CHANGELOG.md
@@ -18,6 +18,10 @@ All notable changes to the **Prowler API** are documented in this file.
- Workers now shut down gracefully on deploy or restart, finishing or re-queueing in-flight tasks instead of being force-killed and leaving them stuck [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416)
+### 🔐 Security
+
+- `dulwich` from 0.23.0 to 1.2.5 and `pyjwt` from 2.12.1 to 2.13.0, patching `GHSA-897w-fcg9-f6xj` (arbitrary file write) and `PYSEC-2026-179` (HMAC/JWK key confusion) flagged by osv-scanner in `api/uv.lock` [(#11499)](https://github.com/prowler-cloud/prowler/pull/11499)
+
---
## [1.30.3] (Prowler v5.29.3)
diff --git a/api/pyproject.toml b/api/pyproject.toml
index 5d996878fa..5e18ffac20 100644
--- a/api/pyproject.toml
+++ b/api/pyproject.toml
@@ -226,7 +226,7 @@ constraint-dependencies = [
"drf-simple-apikey==2.2.1",
"drf-spectacular==0.27.2",
"drf-spectacular-jsonapi==0.5.1",
- "dulwich==0.23.0",
+ "dulwich==1.2.5",
"duo-client==5.5.0",
"durationpy==0.10",
"email-validator==2.2.0",
@@ -354,7 +354,7 @@ constraint-dependencies = [
"pydantic-core==2.41.5",
"pygithub==2.8.0",
"pygments==2.20.0",
- "pyjwt==2.12.1",
+ "pyjwt==2.13.0",
"pylint==3.2.5",
"pymsalruntime==0.18.1",
"pynacl==1.6.2",
@@ -443,7 +443,17 @@ constraint-dependencies = [
# The microsoft-kiota-http security bump to 1.9.9 (GHSA-7j59-v9qr-6fq9) requires
# microsoft-kiota-abstractions>=1.9.9, which a constraint cannot satisfy against the
# SDK's hard pin; override it to the patched, kiota-aligned version.
+#
+# prowler@master hard-pins dulwich==0.23.0 and pyjwt==2.12.1 in [project.dependencies].
+# dulwich 1.2.5 patches GHSA-897w-fcg9-f6xj (arbitrary file write) and pyjwt 2.13.0
+# patches PYSEC-2026-179 (HMAC/JWK key-confusion); a constraint cannot satisfy these
+# against the SDK's hard pins, so override them to the patched versions until the SDK
+# bump propagates to the pinned master rev. pyjwt keeps the [crypto] extra because an
+# override replaces the whole requirement; bare pyjwt would drop it from the consumers
+# that request pyjwt[crypto] and leave cryptography (needed for RS256) only transitive.
override-dependencies = [
"okta==3.4.2",
- "microsoft-kiota-abstractions==1.9.9"
+ "microsoft-kiota-abstractions==1.9.9",
+ "dulwich==1.2.5",
+ "pyjwt[crypto]==2.13.0"
]
diff --git a/api/src/backend/config/django/testing.py b/api/src/backend/config/django/testing.py
index 75779f5a68..a1e5c29fb3 100644
--- a/api/src/backend/config/django/testing.py
+++ b/api/src/backend/config/django/testing.py
@@ -34,3 +34,8 @@ DRF_API_KEY = {
# JWT
SIMPLE_JWT["ALGORITHM"] = "HS256" # noqa: F405
+# pyjwt >= 2.13.0 rejects an empty HMAC signing key, so HS256 tests need a real
+# key (>= 32 bytes also avoids the InsecureKeyLengthWarning). Production uses RS256.
+SIMPLE_JWT["SIGNING_KEY"] = env.str( # noqa: F405
+ "DJANGO_TOKEN_SIGNING_KEY", "insecure-testing-jwt-signing-key-do-not-use-in-prod"
+)
diff --git a/api/uv.lock b/api/uv.lock
index d90eff5fdf..9689a9cd0a 100644
--- a/api/uv.lock
+++ b/api/uv.lock
@@ -163,7 +163,7 @@ constraints = [
{ name = "drf-simple-apikey", specifier = "==2.2.1" },
{ name = "drf-spectacular", specifier = "==0.27.2" },
{ name = "drf-spectacular-jsonapi", specifier = "==0.5.1" },
- { name = "dulwich", specifier = "==0.23.0" },
+ { name = "dulwich", specifier = "==1.2.5" },
{ name = "duo-client", specifier = "==5.5.0" },
{ name = "durationpy", specifier = "==0.10" },
{ name = "email-validator", specifier = "==2.2.0" },
@@ -291,7 +291,7 @@ constraints = [
{ name = "pydantic-core", specifier = "==2.41.5" },
{ name = "pygithub", specifier = "==2.8.0" },
{ name = "pygments", specifier = "==2.20.0" },
- { name = "pyjwt", specifier = "==2.12.1" },
+ { name = "pyjwt", specifier = "==2.13.0" },
{ name = "pylint", specifier = "==3.2.5" },
{ name = "pymsalruntime", specifier = "==0.18.1" },
{ name = "pynacl", specifier = "==1.6.2" },
@@ -374,8 +374,10 @@ constraints = [
{ name = "zstd", specifier = "==1.5.7.3" },
]
overrides = [
+ { name = "dulwich", specifier = "==1.2.5" },
{ name = "microsoft-kiota-abstractions", specifier = "==1.9.9" },
{ name = "okta", specifier = "==3.4.2" },
+ { name = "pyjwt", extras = ["crypto"], specifier = "==2.13.0" },
]
[[package]]
@@ -393,7 +395,7 @@ version = "1.2.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
- { name = "pyjwt" },
+ { name = "pyjwt", extra = ["crypto"] },
{ name = "python-dateutil" },
{ name = "requests" },
]
@@ -1074,7 +1076,7 @@ dependencies = [
{ name = "pkginfo" },
{ name = "psutil", marker = "sys_platform != 'cygwin'" },
{ name = "py-deviceid" },
- { name = "pyjwt" },
+ { name = "pyjwt", extra = ["crypto"] },
{ name = "pyopenssl" },
{ name = "requests", extra = ["socks"] },
]
@@ -2457,7 +2459,7 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
{ name = "djangorestframework" },
- { name = "pyjwt" },
+ { name = "pyjwt", extra = ["crypto"] },
]
sdist = { url = "https://files.pythonhosted.org/packages/a8/27/2874a325c11112066139769f7794afae238a07ce6adf96259f08fd37a9d7/djangorestframework_simplejwt-5.5.1.tar.gz", hash = "sha256:e72c5572f51d7803021288e2057afcbd03f17fe11d484096f40a460abc76e87f", size = 101265, upload-time = "2025-07-21T16:52:25.026Z" }
wheels = [
@@ -2576,24 +2578,27 @@ wheels = [
[[package]]
name = "dulwich"
-version = "0.23.0"
+version = "1.2.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
+ { name = "typing-extensions", marker = "python_full_version < '3.12'" },
{ name = "urllib3" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/4b/ac/ba58cf420640c7bc77ae8e1b31e174d83c9117750c63cf9ea3b5e202e5c4/dulwich-0.23.0.tar.gz", hash = "sha256:0aa6c2489dd5e978b27e9b75983b7331a66c999f0efc54ebe37cab808ed322ae", size = 575116, upload-time = "2025-06-21T17:56:47.494Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/7f/85/ceb8ecff5cdeee4ceeebb86b599476dee559041dacc6c2c50cc0d4711549/dulwich-1.2.5.tar.gz", hash = "sha256:0395b2c8924c3424bafe2d9c1edd5348cc4b21ce9c1d6655bf01f9a5c47164c8", size = 1253230, upload-time = "2026-05-28T22:27:55.17Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ae/11/f6bbba8583f69cf19ef4bd7f5fde1a6b5ccaf8b6951781cec8db247116f4/dulwich-0.23.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d68498fdda13ab00791b483daab3bcfe9f9721c037aa458695e6ad81640c57cc", size = 972658, upload-time = "2025-06-21T17:56:13.505Z" },
- { url = "https://files.pythonhosted.org/packages/2b/9d/2720e0ab58666378a33c752a61543f936cd6b06dfe5d84a2215ddc0914b0/dulwich-0.23.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:cb7bb930b12471a1cfcea4b3d25a671dc0ad32573f0ad25684684298959a1527", size = 1049813, upload-time = "2025-06-21T17:56:14.884Z" },
- { url = "https://files.pythonhosted.org/packages/e5/f3/81d8075141dfcc0a0449c2093596e58d3e11444e3af54e819eca63b84dd0/dulwich-0.23.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a2abbce32fd2bc7902bcc5f69b10bf22576810de21651baaa864b78fd7aec261", size = 1051639, upload-time = "2025-06-21T17:56:16.437Z" },
- { url = "https://files.pythonhosted.org/packages/4f/0d/c06ccb227b096aef5906142fe78b5c79f9070a0ea6152fc219941186d540/dulwich-0.23.0-cp311-cp311-win32.whl", hash = "sha256:9e3151f10ce2a9ff91bca64c74345217f53bdd947dc958032343822009832f7a", size = 642918, upload-time = "2025-06-21T17:56:18.373Z" },
- { url = "https://files.pythonhosted.org/packages/d7/1c/1e99aa34c9aead9e641b2d9934f0a3d00257f75027cf5cdecc8a1a6c18ae/dulwich-0.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:3ae9f1d9dc92d4e9a3f89ba2c55221f7b6442c5dd93b3f6f539a3c9eb3f37bdd", size = 659010, upload-time = "2025-06-21T17:56:19.947Z" },
- { url = "https://files.pythonhosted.org/packages/4a/d7/1e6fba0235babe912e8467b036062e37d11672cbbeb0d8074f9d4559057b/dulwich-0.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:52cdef66a7994d29528ca79ca59452518bbba3fd56a9c61c61f6c467c1c7956e", size = 960292, upload-time = "2025-06-21T17:56:21.308Z" },
- { url = "https://files.pythonhosted.org/packages/4b/6a/23f0c487ec03f2752600cab4a8e0dedb38186246c475bf3fa90a8db830d5/dulwich-0.23.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:d473888a6ab9ed5d4a4c3f053cbe5b77f72d54b6efdf5688fed76094316e571e", size = 1047892, upload-time = "2025-06-21T17:56:22.989Z" },
- { url = "https://files.pythonhosted.org/packages/c7/e2/8f3d216be5fd0ee1180d917b59b34b54b9896384cf139f319b5d3a8f16b4/dulwich-0.23.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:19fcf20224c641a61c774da92f098fbaae9938c7e17a52841e64092adf7e78f9", size = 1048699, upload-time = "2025-06-21T17:56:24.602Z" },
- { url = "https://files.pythonhosted.org/packages/8f/c4/18e6223cd4ad1ae9334eb4e6aa5952fd8f5c3d75762918eb90c209fec4ba/dulwich-0.23.0-cp312-cp312-win32.whl", hash = "sha256:7fc8b76b704ef35cd001e993e3aa4e1d666a2064bf467c07c560f12b2959dcaf", size = 641268, upload-time = "2025-06-21T17:56:26.18Z" },
- { url = "https://files.pythonhosted.org/packages/b8/9c/65bfbbac62d8a2967e13f6a1512371c5eb6b906a61fb6dead992669cad0e/dulwich-0.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:cb0566b888b578325350b4d67c61a0de35d417e9877560e3a6df88cae4576a59", size = 657837, upload-time = "2025-06-21T17:56:27.821Z" },
- { url = "https://files.pythonhosted.org/packages/35/31/49318ee9db4b402e6d8b9b01bd4cae9298f59e1bb9bd56cf4a94e48fa069/dulwich-0.23.0-py3-none-any.whl", hash = "sha256:d8da6694ca332bb48775e35ee2215aa4673821164a91b83062f699c69f7cd135", size = 313776, upload-time = "2025-06-21T17:56:46.221Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/4a/654ae1671610fdf6b65a64586ad67ddd8550d4d08a632b2a4b9614754b6d/dulwich-1.2.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:556593fd11637f80f6018bee1916b1a84f5b420423b470ebb3f1a782ad6ef081", size = 1399277, upload-time = "2026-05-28T22:27:00.801Z" },
+ { url = "https://files.pythonhosted.org/packages/85/d8/06ee3bc8eded4bd7adf8adf0c9ea5f19bf96f7e5e626bfaf7311cde4208a/dulwich-1.2.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a70477c991e96cfe8fdd7c866e7251faf71b38bfeb51d6f27554c9cce1caabf3", size = 1382310, upload-time = "2026-05-28T22:27:02.216Z" },
+ { url = "https://files.pythonhosted.org/packages/07/17/a03adf50b9095f9f5d863393f21d585dea39bdc4fdf60788ff3a9407a512/dulwich-1.2.5-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:9008ef25cabd379cda4fa86000fc38ca14b72afe17db798a8c85c0b2b7ce4d1e", size = 1470993, upload-time = "2026-05-28T22:27:04.075Z" },
+ { url = "https://files.pythonhosted.org/packages/60/58/1dc352d2a5e80befe4338af7208febb44bcfd7496b0dde5ac6dacb07b031/dulwich-1.2.5-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a5549f4afc973e0a15ea6b0244d57f848d3f3ee13dac557eb311024aebebf128", size = 1497820, upload-time = "2026-05-28T22:27:05.549Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/a8/e058959a87e7df7753b112ef66a43ccbc57338c1bbdc23a0edf3833396df/dulwich-1.2.5-cp311-cp311-win32.whl", hash = "sha256:5108acead814d1de8b6262d6d8fb90af7e82f5a4d83788b6b48e39d01800a92f", size = 1066549, upload-time = "2026-05-28T22:27:06.832Z" },
+ { url = "https://files.pythonhosted.org/packages/33/91/ff0b444f686718635348986bd73dfce42e947912417893de35de399b878b/dulwich-1.2.5-cp311-cp311-win_amd64.whl", hash = "sha256:5e067b7feceb7034bc99e7c7143a704f1d97d4be7027d9a0aa5a83c0657ff091", size = 1079481, upload-time = "2026-05-28T22:27:08.33Z" },
+ { url = "https://files.pythonhosted.org/packages/19/22/4f75770bbe5521cac61c4820ef46d4fbf8c2175d3519ba3d0378d4ba798e/dulwich-1.2.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:701a9ecf7a8a44f5e2459e46befa93530cf36a8b1ae3140aefc007db1d7d0207", size = 1396522, upload-time = "2026-05-28T22:27:09.997Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/b1/c07c347681c0cf6acd4b189bf6e8d6207c71a1347b7a1e865eb40faa46b9/dulwich-1.2.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f90d68bfa97c4ca71de7507984365aefe27b6d248cb28dc99644d0f3ae8c60b", size = 1334826, upload-time = "2026-05-28T22:27:11.582Z" },
+ { url = "https://files.pythonhosted.org/packages/13/80/6818eb7ce492e18ab2efa92ab901d173b4b0b159e5681c1424f329600c40/dulwich-1.2.5-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:00b54a1d56ddbacdd8eadd6d4787a51b3a05fefa30eadbf9165fd283a00b90ed", size = 1416616, upload-time = "2026-05-28T22:27:13.195Z" },
+ { url = "https://files.pythonhosted.org/packages/14/a7/9790e60d19870f6554f7583722bb324c1355784316f20aeda1c0b5b1491a/dulwich-1.2.5-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:d8f7ea8f47e38e5b0de3fab97e07e9c9161ffddc90b3964512cab2b7749df4e6", size = 1441354, upload-time = "2026-05-28T22:27:14.683Z" },
+ { url = "https://files.pythonhosted.org/packages/91/44/0ea8a69c24aa1254ff5996d682eae2eab287d471b937dcdb26d9ea9720b4/dulwich-1.2.5-cp312-cp312-win32.whl", hash = "sha256:8929134acf4ff967203df7600b38535f9b5b590462067a7e30dbce01acb97af9", size = 1017058, upload-time = "2026-05-28T22:27:16.121Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/9c/2fcddda7faec3bae52db7c64bfcb5dc756f597f33fae90e8d4e4b4d3b39b/dulwich-1.2.5-cp312-cp312-win_amd64.whl", hash = "sha256:9693d2c9e226b2ea855c1dc3a87e2f4d972f7523fc0f7924e5997e9f4c23d97f", size = 1031731, upload-time = "2026-05-28T22:27:17.633Z" },
+ { url = "https://files.pythonhosted.org/packages/07/4b/4a18a59ad230581cd0ef460e96001f90762e566dc2dfdba22aa358eb5a0e/dulwich-1.2.5-py3-none-any.whl", hash = "sha256:1679b376433a0fc7f36586afda1d4ed7427afa7a79d4bf17e5014474eea69fa4", size = 686745, upload-time = "2026-05-28T22:27:53.695Z" },
]
[[package]]
@@ -4031,7 +4036,7 @@ dependencies = [
{ name = "pycryptodomex" },
{ name = "pydantic" },
{ name = "pydash" },
- { name = "pyjwt" },
+ { name = "pyjwt", extra = ["crypto"] },
{ name = "python-dateutil" },
{ name = "pyyaml" },
{ name = "requests" },
@@ -4873,11 +4878,11 @@ wheels = [
[[package]]
name = "pyjwt"
-version = "2.12.1"
+version = "2.13.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" },
]
[package.optional-dependencies]
@@ -5785,7 +5790,7 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "httpx" },
- { name = "pyjwt" },
+ { name = "pyjwt", extra = ["crypto"] },
]
sdist = { url = "https://files.pythonhosted.org/packages/3c/2f/99fb8718274116c5c146c745755620fd5c5943f78ca52ca9b17e94348286/workos-6.0.4.tar.gz", hash = "sha256:b0bfe8fd212b8567422c4ea3732eb33608794033eb3a69900c6b04db183c32d6", size = 172217, upload-time = "2026-04-16T03:09:28.583Z" }
wheels = [
diff --git a/osv-scanner.toml b/osv-scanner.toml
index 5d029efdf6..59408b8709 100644
--- a/osv-scanner.toml
+++ b/osv-scanner.toml
@@ -12,8 +12,9 @@ reason = """
CVE-2025-45768 is disputed by the pyjwt maintainers. The advisory describes
weak encryption, but the underlying issue is that callers may pick a short
HMAC secret — key-length enforcement is the application's responsibility, not
-a defect in the library. We are on pyjwt 2.12.1 (latest at pin time) and
-enforce key strength in our own auth code, so this advisory does not apply.
+a defect in the library. We are on pyjwt 2.13.0 (which now also emits an
+InsecureKeyLengthWarning for short HMAC secrets) and enforce key strength in
+our own auth code, so this advisory does not apply.
Re-evaluate when a non-disputed advisory or upstream fix lands.
"""
diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md
index 6b02ecdf7d..62106c6594 100644
--- a/prowler/CHANGELOG.md
+++ b/prowler/CHANGELOG.md
@@ -32,6 +32,10 @@ All notable changes to the **Prowler SDK** are documented in this file.
- Jira integration no longer fails with `400 INVALID_INPUT` when a finding has empty fields [(#11474)](https://github.com/prowler-cloud/prowler/pull/11474)
- GCP `iam_service_account_unused` now passes disabled service accounts instead of failing them, since a disabled account cannot authenticate or be used [(#11467)](https://github.com/prowler-cloud/prowler/pull/11467)
+### 🔐 Security
+
+- `dulwich` from 0.23.0 to 1.2.5 and `pyjwt` from 2.12.1 to 2.13.0, patching `GHSA-897w-fcg9-f6xj` (arbitrary file write) and `PYSEC-2026-179` (HMAC/JWK key confusion) flagged by osv-scanner [(#11499)](https://github.com/prowler-cloud/prowler/pull/11499)
+
---
## [5.29.1] (Prowler v5.29.1)
diff --git a/pyproject.toml b/pyproject.toml
index a87e200e4b..f029f3d6f7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -73,7 +73,7 @@ dependencies = [
"dash-bootstrap-components==2.0.3",
"defusedxml==0.7.1",
"detect-secrets==1.5.0",
- "dulwich==0.23.0",
+ "dulwich==1.2.5",
"google-api-python-client==2.163.0",
"google-auth-httplib2==0.2.0",
"jsonschema==4.23.0",
@@ -314,7 +314,7 @@ constraint-dependencies = [
"pydash==8.0.6",
"pyflakes==3.2.0",
"pygments==2.20.0",
- "pyjwt==2.12.1",
+ "pyjwt==2.13.0",
"pylint==3.3.4",
"pynacl==1.6.2",
"pyopenssl==26.2.0",
diff --git a/uv.lock b/uv.lock
index d385009f63..e42e3ba8af 100644
--- a/uv.lock
+++ b/uv.lock
@@ -166,7 +166,7 @@ constraints = [
{ name = "pydash", specifier = "==8.0.6" },
{ name = "pyflakes", specifier = "==3.2.0" },
{ name = "pygments", specifier = "==2.20.0" },
- { name = "pyjwt", specifier = "==2.12.1" },
+ { name = "pyjwt", specifier = "==2.13.0" },
{ name = "pylint", specifier = "==3.3.4" },
{ name = "pynacl", specifier = "==1.6.2" },
{ name = "pyopenssl", specifier = "==26.2.0" },
@@ -1774,29 +1774,33 @@ wheels = [
[[package]]
name = "dulwich"
-version = "0.23.0"
+version = "1.2.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
+ { name = "typing-extensions", marker = "python_full_version < '3.12'" },
{ name = "urllib3" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/4b/ac/ba58cf420640c7bc77ae8e1b31e174d83c9117750c63cf9ea3b5e202e5c4/dulwich-0.23.0.tar.gz", hash = "sha256:0aa6c2489dd5e978b27e9b75983b7331a66c999f0efc54ebe37cab808ed322ae", size = 575116, upload-time = "2025-06-21T17:56:47.494Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/7f/85/ceb8ecff5cdeee4ceeebb86b599476dee559041dacc6c2c50cc0d4711549/dulwich-1.2.5.tar.gz", hash = "sha256:0395b2c8924c3424bafe2d9c1edd5348cc4b21ce9c1d6655bf01f9a5c47164c8", size = 1253230, upload-time = "2026-05-28T22:27:55.17Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ba/8d/d725f0c9ddb218c7d9e3e02ee4545e998b57e1d7c12f5ab3e2d61f577410/dulwich-0.23.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c13b0d5a9009cde23ecb8cb201df6e23e2a7a82c5e2d6ba6443fbb322c9befc6", size = 973413, upload-time = "2025-06-21T17:56:04.641Z" },
- { url = "https://files.pythonhosted.org/packages/97/82/0316022bd64b3525acfebc88b6b7506d04b0402b7dbfb746cd15529b9ea8/dulwich-0.23.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:a68faf8612bf93de1285048d6ad13160f0fb3c5596a86e694e78f4e212886fa5", size = 1050614, upload-time = "2025-06-21T17:56:07.084Z" },
- { url = "https://files.pythonhosted.org/packages/65/a0/e3f71d6d74809cd9245d3d2921448fd32a8417f74b4e912e82cef0cf5098/dulwich-0.23.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:d971566826f16ec67c70641c1fbdb337323aa5b533799bc5a4641f4750e73b36", size = 1052830, upload-time = "2025-06-21T17:56:08.682Z" },
- { url = "https://files.pythonhosted.org/packages/c1/38/8dd887d9b64f47f8097e207ed7e8d5dd640a19aa763e632d97174961585f/dulwich-0.23.0-cp310-cp310-win32.whl", hash = "sha256:27d970adf539806dfc4fe3e4c9e8dc6ebf0318977a56e24d22f13413535a51ba", size = 642779, upload-time = "2025-06-21T17:56:10.391Z" },
- { url = "https://files.pythonhosted.org/packages/5b/ca/a345085526ac3b7aaa891ca4ec7ad9375cd8d017e42d4dbf20a443231275/dulwich-0.23.0-cp310-cp310-win_amd64.whl", hash = "sha256:025178533e884ffdb0d9d8db4b8870745d438cbfecb782fd1b56c3b6438e86cf", size = 658637, upload-time = "2025-06-21T17:56:12.093Z" },
- { url = "https://files.pythonhosted.org/packages/ae/11/f6bbba8583f69cf19ef4bd7f5fde1a6b5ccaf8b6951781cec8db247116f4/dulwich-0.23.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d68498fdda13ab00791b483daab3bcfe9f9721c037aa458695e6ad81640c57cc", size = 972658, upload-time = "2025-06-21T17:56:13.505Z" },
- { url = "https://files.pythonhosted.org/packages/2b/9d/2720e0ab58666378a33c752a61543f936cd6b06dfe5d84a2215ddc0914b0/dulwich-0.23.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:cb7bb930b12471a1cfcea4b3d25a671dc0ad32573f0ad25684684298959a1527", size = 1049813, upload-time = "2025-06-21T17:56:14.884Z" },
- { url = "https://files.pythonhosted.org/packages/e5/f3/81d8075141dfcc0a0449c2093596e58d3e11444e3af54e819eca63b84dd0/dulwich-0.23.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a2abbce32fd2bc7902bcc5f69b10bf22576810de21651baaa864b78fd7aec261", size = 1051639, upload-time = "2025-06-21T17:56:16.437Z" },
- { url = "https://files.pythonhosted.org/packages/4f/0d/c06ccb227b096aef5906142fe78b5c79f9070a0ea6152fc219941186d540/dulwich-0.23.0-cp311-cp311-win32.whl", hash = "sha256:9e3151f10ce2a9ff91bca64c74345217f53bdd947dc958032343822009832f7a", size = 642918, upload-time = "2025-06-21T17:56:18.373Z" },
- { url = "https://files.pythonhosted.org/packages/d7/1c/1e99aa34c9aead9e641b2d9934f0a3d00257f75027cf5cdecc8a1a6c18ae/dulwich-0.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:3ae9f1d9dc92d4e9a3f89ba2c55221f7b6442c5dd93b3f6f539a3c9eb3f37bdd", size = 659010, upload-time = "2025-06-21T17:56:19.947Z" },
- { url = "https://files.pythonhosted.org/packages/4a/d7/1e6fba0235babe912e8467b036062e37d11672cbbeb0d8074f9d4559057b/dulwich-0.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:52cdef66a7994d29528ca79ca59452518bbba3fd56a9c61c61f6c467c1c7956e", size = 960292, upload-time = "2025-06-21T17:56:21.308Z" },
- { url = "https://files.pythonhosted.org/packages/4b/6a/23f0c487ec03f2752600cab4a8e0dedb38186246c475bf3fa90a8db830d5/dulwich-0.23.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:d473888a6ab9ed5d4a4c3f053cbe5b77f72d54b6efdf5688fed76094316e571e", size = 1047892, upload-time = "2025-06-21T17:56:22.989Z" },
- { url = "https://files.pythonhosted.org/packages/c7/e2/8f3d216be5fd0ee1180d917b59b34b54b9896384cf139f319b5d3a8f16b4/dulwich-0.23.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:19fcf20224c641a61c774da92f098fbaae9938c7e17a52841e64092adf7e78f9", size = 1048699, upload-time = "2025-06-21T17:56:24.602Z" },
- { url = "https://files.pythonhosted.org/packages/8f/c4/18e6223cd4ad1ae9334eb4e6aa5952fd8f5c3d75762918eb90c209fec4ba/dulwich-0.23.0-cp312-cp312-win32.whl", hash = "sha256:7fc8b76b704ef35cd001e993e3aa4e1d666a2064bf467c07c560f12b2959dcaf", size = 641268, upload-time = "2025-06-21T17:56:26.18Z" },
- { url = "https://files.pythonhosted.org/packages/b8/9c/65bfbbac62d8a2967e13f6a1512371c5eb6b906a61fb6dead992669cad0e/dulwich-0.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:cb0566b888b578325350b4d67c61a0de35d417e9877560e3a6df88cae4576a59", size = 657837, upload-time = "2025-06-21T17:56:27.821Z" },
- { url = "https://files.pythonhosted.org/packages/35/31/49318ee9db4b402e6d8b9b01bd4cae9298f59e1bb9bd56cf4a94e48fa069/dulwich-0.23.0-py3-none-any.whl", hash = "sha256:d8da6694ca332bb48775e35ee2215aa4673821164a91b83062f699c69f7cd135", size = 313776, upload-time = "2025-06-21T17:56:46.221Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/af/60929b502d6541cb015beb9f1da82600aac64d5f705d0188aaf44a7aa77f/dulwich-1.2.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:46db47394ba8a95748ae739f5d3a5a3e1724a2f857bf2437bc71bfc0baaed91d", size = 1400236, upload-time = "2026-05-28T22:26:50.998Z" },
+ { url = "https://files.pythonhosted.org/packages/04/f8/25de359a9249cc05a58c2500babfe2adff174931f2fa3fe97c700ca16626/dulwich-1.2.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:66aded7d364341b55941973a1562323f25bd205f0809692b687ec36ccd31242c", size = 1382996, upload-time = "2026-05-28T22:26:53.545Z" },
+ { url = "https://files.pythonhosted.org/packages/04/f7/640fee144007262096173f5fafd04cc7e5a0d72b0ceeeb9c9a51d99abc43/dulwich-1.2.5-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:dd9569bc26174a3437d749114d36c81fc6c7478b55370ae50125e34e9629e4fe", size = 1471811, upload-time = "2026-05-28T22:26:55.044Z" },
+ { url = "https://files.pythonhosted.org/packages/51/2a/348c1f0baa0c42dc79a9f503463ddb00452c234ec5c9e20b43530d78528e/dulwich-1.2.5-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:827366331603150de5976d72dd456a3fd5fc91e856471dc1d10fd64758c05f02", size = 1498006, upload-time = "2026-05-28T22:26:56.531Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/f8/95f51bcbc2ce86beea49a67f61228be86bf614a24aa714a8c59e0abdd153/dulwich-1.2.5-cp310-cp310-win32.whl", hash = "sha256:6c683c0f4a062894b6826c61102d415dae86ade61a10003c82ccc2b91858d5fb", size = 1066964, upload-time = "2026-05-28T22:26:58.049Z" },
+ { url = "https://files.pythonhosted.org/packages/68/c1/ffa02a1623c3d668de8e66b654187bb8dc24c085224644e5554537ee4642/dulwich-1.2.5-cp310-cp310-win_amd64.whl", hash = "sha256:a6620963196c49212c511cd909f367dacf771f199a27d116f357cc671ea956c7", size = 1079537, upload-time = "2026-05-28T22:26:59.319Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/4a/654ae1671610fdf6b65a64586ad67ddd8550d4d08a632b2a4b9614754b6d/dulwich-1.2.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:556593fd11637f80f6018bee1916b1a84f5b420423b470ebb3f1a782ad6ef081", size = 1399277, upload-time = "2026-05-28T22:27:00.801Z" },
+ { url = "https://files.pythonhosted.org/packages/85/d8/06ee3bc8eded4bd7adf8adf0c9ea5f19bf96f7e5e626bfaf7311cde4208a/dulwich-1.2.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a70477c991e96cfe8fdd7c866e7251faf71b38bfeb51d6f27554c9cce1caabf3", size = 1382310, upload-time = "2026-05-28T22:27:02.216Z" },
+ { url = "https://files.pythonhosted.org/packages/07/17/a03adf50b9095f9f5d863393f21d585dea39bdc4fdf60788ff3a9407a512/dulwich-1.2.5-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:9008ef25cabd379cda4fa86000fc38ca14b72afe17db798a8c85c0b2b7ce4d1e", size = 1470993, upload-time = "2026-05-28T22:27:04.075Z" },
+ { url = "https://files.pythonhosted.org/packages/60/58/1dc352d2a5e80befe4338af7208febb44bcfd7496b0dde5ac6dacb07b031/dulwich-1.2.5-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a5549f4afc973e0a15ea6b0244d57f848d3f3ee13dac557eb311024aebebf128", size = 1497820, upload-time = "2026-05-28T22:27:05.549Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/a8/e058959a87e7df7753b112ef66a43ccbc57338c1bbdc23a0edf3833396df/dulwich-1.2.5-cp311-cp311-win32.whl", hash = "sha256:5108acead814d1de8b6262d6d8fb90af7e82f5a4d83788b6b48e39d01800a92f", size = 1066549, upload-time = "2026-05-28T22:27:06.832Z" },
+ { url = "https://files.pythonhosted.org/packages/33/91/ff0b444f686718635348986bd73dfce42e947912417893de35de399b878b/dulwich-1.2.5-cp311-cp311-win_amd64.whl", hash = "sha256:5e067b7feceb7034bc99e7c7143a704f1d97d4be7027d9a0aa5a83c0657ff091", size = 1079481, upload-time = "2026-05-28T22:27:08.33Z" },
+ { url = "https://files.pythonhosted.org/packages/19/22/4f75770bbe5521cac61c4820ef46d4fbf8c2175d3519ba3d0378d4ba798e/dulwich-1.2.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:701a9ecf7a8a44f5e2459e46befa93530cf36a8b1ae3140aefc007db1d7d0207", size = 1396522, upload-time = "2026-05-28T22:27:09.997Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/b1/c07c347681c0cf6acd4b189bf6e8d6207c71a1347b7a1e865eb40faa46b9/dulwich-1.2.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f90d68bfa97c4ca71de7507984365aefe27b6d248cb28dc99644d0f3ae8c60b", size = 1334826, upload-time = "2026-05-28T22:27:11.582Z" },
+ { url = "https://files.pythonhosted.org/packages/13/80/6818eb7ce492e18ab2efa92ab901d173b4b0b159e5681c1424f329600c40/dulwich-1.2.5-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:00b54a1d56ddbacdd8eadd6d4787a51b3a05fefa30eadbf9165fd283a00b90ed", size = 1416616, upload-time = "2026-05-28T22:27:13.195Z" },
+ { url = "https://files.pythonhosted.org/packages/14/a7/9790e60d19870f6554f7583722bb324c1355784316f20aeda1c0b5b1491a/dulwich-1.2.5-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:d8f7ea8f47e38e5b0de3fab97e07e9c9161ffddc90b3964512cab2b7749df4e6", size = 1441354, upload-time = "2026-05-28T22:27:14.683Z" },
+ { url = "https://files.pythonhosted.org/packages/91/44/0ea8a69c24aa1254ff5996d682eae2eab287d471b937dcdb26d9ea9720b4/dulwich-1.2.5-cp312-cp312-win32.whl", hash = "sha256:8929134acf4ff967203df7600b38535f9b5b590462067a7e30dbce01acb97af9", size = 1017058, upload-time = "2026-05-28T22:27:16.121Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/9c/2fcddda7faec3bae52db7c64bfcb5dc756f597f33fae90e8d4e4b4d3b39b/dulwich-1.2.5-cp312-cp312-win_amd64.whl", hash = "sha256:9693d2c9e226b2ea855c1dc3a87e2f4d972f7523fc0f7924e5997e9f4c23d97f", size = 1031731, upload-time = "2026-05-28T22:27:17.633Z" },
+ { url = "https://files.pythonhosted.org/packages/07/4b/4a18a59ad230581cd0ef460e96001f90762e566dc2dfdba22aa358eb5a0e/dulwich-1.2.5-py3-none-any.whl", hash = "sha256:1679b376433a0fc7f36586afda1d4ed7427afa7a79d4bf17e5014474eea69fa4", size = 686745, upload-time = "2026-05-28T22:27:53.695Z" },
]
[[package]]
@@ -3403,7 +3407,7 @@ requires-dist = [
{ name = "dash-bootstrap-components", specifier = "==2.0.3" },
{ name = "defusedxml", specifier = "==0.7.1" },
{ name = "detect-secrets", specifier = "==1.5.0" },
- { name = "dulwich", specifier = "==0.23.0" },
+ { name = "dulwich", specifier = "==1.2.5" },
{ name = "google-api-python-client", specifier = "==2.163.0" },
{ name = "google-auth-httplib2", specifier = "==0.2.0" },
{ name = "h2", specifier = "==4.3.0" },
@@ -3711,14 +3715,14 @@ wheels = [
[[package]]
name = "pyjwt"
-version = "2.12.1"
+version = "2.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" },
]
[package.optional-dependencies]
From 6c559fbb8dc5e1a7e492315e40fda1344732c66a Mon Sep 17 00:00:00 2001
From: StylusFrost <43682773+StylusFrost@users.noreply.github.com>
Date: Tue, 9 Jun 2026 13:45:34 +0200
Subject: [PATCH 028/129] feat(sdk): discover external universal compliance
frameworks via entry points (#11490)
---
prowler/CHANGELOG.md | 1 +
prowler/config/config.py | 29 ++++-
prowler/lib/check/compliance_models.py | 57 +++++++--
.../check/universal_compliance_models_test.py | 121 ++++++++++++++++++
.../external/test_dynamic_provider_loading.py | 85 ++++++++++++
5 files changed, 279 insertions(+), 14 deletions(-)
diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md
index 62106c6594..dd35527c8e 100644
--- a/prowler/CHANGELOG.md
+++ b/prowler/CHANGELOG.md
@@ -14,6 +14,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- Support for external/custom providers, checks, and compliance frameworks without modifying core code [(#10700)](https://github.com/prowler-cloud/prowler/pull/10700)
- `elbv2_alb_drop_invalid_header_fields_enabled` check for AWS provider, verifying Application Load Balancers have `routing.http.drop_invalid_header_fields.enabled` set to `true` to mitigate HTTP desync attacks (AWS FSBP ELB.4) [(#11471)](https://github.com/prowler-cloud/prowler/pull/11471)
- `user`, `systemlog` and `idp` service for Okta provider with `user_inactivity_automation_35d_enabled`, `systemlog_streaming_enabled` and `idp_smart_card_dod_approved_ca` checks [(#11496)](https://github.com/prowler-cloud/prowler/pull/11496)
+- External multi-provider compliance frameworks can be registered via the `prowler.compliance.universal` entry point group [(#11490)](https://github.com/prowler-cloud/prowler/pull/11490)
- AWS AI Security Framework support in the CLI dashboard [(#11475)](https://github.com/prowler-cloud/prowler/pull/11475)
- `entra_service_principal_privileged_role_no_owners` check for M365 provider, failing when a service principal with a permanent Tier 0 directory role has owners on the service principal or its parent app registration [(#11070)](https://github.com/prowler-cloud/prowler/issues/11070)
diff --git a/prowler/config/config.py b/prowler/config/config.py
index 10e63c42df..71c59d82a3 100644
--- a/prowler/config/config.py
+++ b/prowler/config/config.py
@@ -144,8 +144,7 @@ def get_available_compliance_frameworks(provider=None):
continue
if name not in available_compliance_frameworks:
available_compliance_frameworks.append(name)
- # External compliance via entry points.
- # Multi-provider support for external plug-ins is tracked in PROWLER-1444.
+ # External per-provider compliance via entry points.
ep_dirs = _get_ep_compliance_dirs()
for prov, path in ep_dirs.items():
if provider and prov != provider:
@@ -156,6 +155,32 @@ def get_available_compliance_frameworks(provider=None):
name = file.name.removesuffix(".json")
if name not in available_compliance_frameworks:
available_compliance_frameworks.append(name)
+ # External multi-provider frameworks via the dedicated universal group;
+ # filtered by supports_provider when a provider is given.
+ for ep in importlib.metadata.entry_points(group="prowler.compliance.universal"):
+ try:
+ module = ep.load()
+ path = (
+ module.__path__[0]
+ if hasattr(module, "__path__")
+ else os.path.dirname(module.__file__)
+ )
+ except Exception as error:
+ logger.warning(
+ f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+ continue
+ if not os.path.isdir(path):
+ continue
+ for file in os.scandir(path):
+ if file.is_file() and file.name.endswith(".json"):
+ name = file.name.removesuffix(".json")
+ if provider:
+ framework = load_compliance_framework_universal(file.path)
+ if framework is None or not framework.supports_provider(provider):
+ continue
+ if name not in available_compliance_frameworks:
+ available_compliance_frameworks.append(name)
return available_compliance_frameworks
diff --git a/prowler/lib/check/compliance_models.py b/prowler/lib/check/compliance_models.py
index 8cc588cc4c..3610893008 100644
--- a/prowler/lib/check/compliance_models.py
+++ b/prowler/lib/check/compliance_models.py
@@ -478,9 +478,15 @@ class Compliance(BaseModel):
compliance_framework_name
not in bulk_compliance_frameworks
):
- bulk_compliance_frameworks[
- compliance_framework_name
- ] = load_compliance_framework(file_path)
+ # External JSON: tolerate non-legacy
+ # schemas (skip + warn) instead of aborting.
+ framework = load_compliance_framework(
+ file_path, fatal=False
+ )
+ if framework is not None:
+ bulk_compliance_frameworks[
+ compliance_framework_name
+ ] = framework
except Exception as error:
logger.warning(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
@@ -494,18 +500,26 @@ class Compliance(BaseModel):
# Testing Pending
def load_compliance_framework(
- compliance_specification_file: str,
-) -> Compliance:
- """load_compliance_framework loads and parse a Compliance Framework Specification"""
+ compliance_specification_file: str, fatal: bool = True
+) -> Optional[Compliance]:
+ """load_compliance_framework loads and parse a Compliance Framework Specification.
+
+ With ``fatal=True`` (built-in JSONs) an invalid file aborts the run; with
+ ``fatal=False`` (external JSONs) it is skipped with a warning and ``None``
+ is returned.
+ """
try:
- compliance_framework = Compliance.parse_file(compliance_specification_file)
+ return Compliance.parse_file(compliance_specification_file)
except ValidationError as error:
- logger.critical(
- f"Compliance Framework Specification from {compliance_specification_file} is not valid: {error}"
+ if fatal:
+ logger.critical(
+ f"Compliance Framework Specification from {compliance_specification_file} is not valid: {error}"
+ )
+ sys.exit(1)
+ logger.warning(
+ f"Skipping invalid compliance framework {compliance_specification_file}: {error}"
)
- sys.exit(1)
- else:
- return compliance_framework
+ return None
# ─── Universal Compliance Schema Models (Phase 1-3) ─────────────────────────
@@ -982,6 +996,25 @@ def get_bulk_compliance_frameworks_universal(provider: str) -> dict:
if compliance_root and os.path.isdir(compliance_root):
_load_jsons_from_dir(compliance_root, provider, bulk)
+ # External multi-provider frameworks via the dedicated universal entry
+ # point group, kept separate from the per-provider `prowler.compliance`
+ # group so the legacy loader never parses a universal JSON. Built-ins
+ # (already in bulk) win on a name collision.
+ for ep in importlib.metadata.entry_points(group="prowler.compliance.universal"):
+ try:
+ module = ep.load()
+ ep_dir = (
+ module.__path__[0]
+ if hasattr(module, "__path__")
+ else os.path.dirname(module.__file__)
+ )
+ if os.path.isdir(ep_dir):
+ _load_jsons_from_dir(ep_dir, provider, bulk)
+ except Exception as error:
+ logger.warning(
+ f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+
except Exception as e:
logger.error(f"{e.__class__.__name__}[{e.__traceback__.tb_lineno}] -- {e}")
return bulk
diff --git a/tests/lib/check/universal_compliance_models_test.py b/tests/lib/check/universal_compliance_models_test.py
index 5a3e4aae56..c8f614488c 100644
--- a/tests/lib/check/universal_compliance_models_test.py
+++ b/tests/lib/check/universal_compliance_models_test.py
@@ -1,5 +1,7 @@
import json
import os
+import tempfile
+from unittest.mock import MagicMock, patch
import pytest
from pydantic.v1 import ValidationError
@@ -23,6 +25,7 @@ from prowler.lib.check.compliance_models import (
TableLabels,
UniversalComplianceRequirement,
adapt_legacy_to_universal,
+ get_bulk_compliance_frameworks_universal,
load_compliance_framework_universal,
)
from tests.lib.outputs.compliance.fixtures import (
@@ -1116,3 +1119,121 @@ class TestAttributesMetadataValidation:
],
attributes_metadata=self._metadata(enum=["high", "low"]),
)
+
+
+class TestGetBulkUniversalEntryPoints:
+ """Entry-point discovery for universal (multi-provider) compliance frameworks."""
+
+ @staticmethod
+ def _write_universal_json(directory, filename, framework, display_name):
+ data = {
+ "framework": framework,
+ "name": display_name,
+ "version": "1.0",
+ "description": "External multi-provider framework",
+ "requirements": [
+ {
+ "id": "1",
+ "name": "Requirement 1",
+ "description": "desc",
+ "checks": {"fakeexternal": ["check_a"]},
+ }
+ ],
+ }
+ with open(os.path.join(directory, filename), "w") as f:
+ json.dump(data, f)
+
+ @staticmethod
+ def _entry_point(path):
+ module = MagicMock()
+ module.__path__ = [path]
+ ep = MagicMock()
+ ep.name = "fakeexternal"
+ ep.group = "prowler.compliance.universal"
+ ep.load.return_value = module
+ return ep
+
+ @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points")
+ @patch("prowler.lib.check.compliance_models.list_compliance_modules")
+ def test_includes_external_universal_framework(self, mock_list_modules, mock_ep):
+ mock_list_modules.return_value = []
+ with tempfile.TemporaryDirectory() as ep_dir:
+ self._write_universal_json(
+ ep_dir, "customuniversal_1.0.json", "CustomUniversal", "Custom"
+ )
+ mock_ep.return_value = [self._entry_point(ep_dir)]
+
+ bulk = get_bulk_compliance_frameworks_universal("fakeexternal")
+
+ mock_ep.assert_called_with(group="prowler.compliance.universal")
+ assert "customuniversal_1.0" in bulk
+ assert bulk["customuniversal_1.0"].framework == "CustomUniversal"
+
+ @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points")
+ @patch("prowler.lib.check.compliance_models.list_compliance_modules")
+ def test_builtin_wins_over_external_on_name_collision(
+ self, mock_list_modules, mock_ep
+ ):
+ with (
+ tempfile.TemporaryDirectory() as root,
+ tempfile.TemporaryDirectory() as ep_dir,
+ ):
+ builtin_sub = os.path.join(root, "builtinprov")
+ os.makedirs(builtin_sub)
+ self._write_universal_json(
+ builtin_sub, "shared_1.0.json", "SharedFramework", "Built-in"
+ )
+ builtin_module = MagicMock()
+ builtin_module.module_finder.path = root
+ builtin_module.name = "prowler.compliance.builtinprov"
+ mock_list_modules.return_value = [builtin_module]
+
+ self._write_universal_json(
+ ep_dir, "shared_1.0.json", "SharedFramework", "External"
+ )
+ mock_ep.return_value = [self._entry_point(ep_dir)]
+
+ bulk = get_bulk_compliance_frameworks_universal("fakeexternal")
+
+ assert "shared_1.0" in bulk
+ assert bulk["shared_1.0"].name == "Built-in"
+
+ @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points")
+ @patch("prowler.lib.check.compliance_models.list_compliance_modules")
+ def test_loads_all_frameworks_in_a_single_entry_point_path(
+ self, mock_list_modules, mock_ep
+ ):
+ """All JSONs in one entry-point directory are added, not collapsed to one."""
+ mock_list_modules.return_value = []
+ with tempfile.TemporaryDirectory() as ep_dir:
+ self._write_universal_json(ep_dir, "fw_a_1.0.json", "FwA", "Framework A")
+ self._write_universal_json(ep_dir, "fw_b_1.0.json", "FwB", "Framework B")
+ mock_ep.return_value = [self._entry_point(ep_dir)]
+
+ bulk = get_bulk_compliance_frameworks_universal("fakeexternal")
+
+ assert "fw_a_1.0" in bulk
+ assert "fw_b_1.0" in bulk
+
+ @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points")
+ @patch("prowler.lib.check.compliance_models.list_compliance_modules")
+ def test_merges_frameworks_from_multiple_packages_same_provider(
+ self, mock_list_modules, mock_ep
+ ):
+ """Two packages under the same provider name are both discovered."""
+ mock_list_modules.return_value = []
+ with (
+ tempfile.TemporaryDirectory() as dir_a,
+ tempfile.TemporaryDirectory() as dir_b,
+ ):
+ self._write_universal_json(dir_a, "pkg_a_1.0.json", "PkgA", "Package A")
+ self._write_universal_json(dir_b, "pkg_b_1.0.json", "PkgB", "Package B")
+ mock_ep.return_value = [
+ self._entry_point(dir_a),
+ self._entry_point(dir_b),
+ ]
+
+ bulk = get_bulk_compliance_frameworks_universal("fakeexternal")
+
+ assert "pkg_a_1.0" in bulk
+ assert "pkg_b_1.0" in bulk
diff --git a/tests/providers/external/test_dynamic_provider_loading.py b/tests/providers/external/test_dynamic_provider_loading.py
index e60720e32e..eefde19545 100644
--- a/tests/providers/external/test_dynamic_provider_loading.py
+++ b/tests/providers/external/test_dynamic_provider_loading.py
@@ -1218,6 +1218,48 @@ class TestCompliance:
assert "custom_1.0_ext" in frameworks
+ @patch("prowler.config.config.importlib.metadata.entry_points")
+ def test_get_available_compliance_includes_external_universal(self, mock_ep):
+ """External universal frameworks under prowler.compliance.universal are
+ listed, for a provider and for the provider=None case that feeds
+ --compliance choices."""
+ import json
+ import os
+ import tempfile
+
+ from prowler.config.config import get_available_compliance_frameworks
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ framework = {
+ "framework": "CustomUniversal",
+ "name": "Custom Universal",
+ "version": "1.0",
+ "description": "Multi-provider",
+ "requirements": [
+ {
+ "id": "1",
+ "name": "r",
+ "description": "d",
+ "checks": {"aws": ["c"]},
+ }
+ ],
+ }
+ with open(os.path.join(tmpdir, "customuniversal_1.0.json"), "w") as f:
+ json.dump(framework, f)
+
+ module = MagicMock()
+ module.__path__ = [tmpdir]
+ ep = _make_entry_point(
+ "anyname", "pkg.compliance", "prowler.compliance.universal"
+ )
+ ep.load.return_value = module
+ mock_ep.side_effect = lambda group: (
+ [ep] if group == "prowler.compliance.universal" else []
+ )
+
+ assert "customuniversal_1.0" in get_available_compliance_frameworks("aws")
+ assert "customuniversal_1.0" in get_available_compliance_frameworks(None)
+
@patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points")
@patch("prowler.lib.check.compliance_models.list_compliance_modules")
def test_compliance_get_bulk_loads_external(self, mock_list_modules, mock_ep):
@@ -1257,6 +1299,49 @@ class TestCompliance:
assert "custom_1.0_fakeexternal" in bulk
assert bulk["custom_1.0_fakeexternal"].Framework == "Custom"
+ @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points")
+ @patch("prowler.lib.check.compliance_models.list_compliance_modules")
+ def test_compliance_get_bulk_skips_non_legacy_external_json(
+ self, mock_list_modules, mock_ep
+ ):
+ """A universal-schema JSON registered under prowler.compliance is skipped,
+ not aborting the run via sys.exit."""
+ import json
+ import os
+ import tempfile
+
+ from prowler.lib.check.compliance_models import Compliance
+
+ mock_list_modules.return_value = []
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ json_data = {
+ "framework": "Universal",
+ "name": "Universal Framework",
+ "version": "1.0",
+ "description": "Multi-provider",
+ "requirements": [
+ {
+ "id": "1",
+ "name": "r",
+ "description": "d",
+ "checks": {"aws": ["c"]},
+ }
+ ],
+ }
+ with open(os.path.join(tmpdir, "universal_1.0.json"), "w") as f:
+ json.dump(json_data, f)
+
+ mock_module = MagicMock()
+ mock_module.__path__ = [tmpdir]
+ ep = _make_entry_point("aws", "pkg.compliance", "prowler.compliance")
+ ep.load.return_value = mock_module
+ mock_ep.return_value = [ep]
+
+ bulk = Compliance.get_bulk("aws")
+
+ assert "universal_1.0" not in bulk
+
@patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points")
@patch("prowler.lib.check.compliance_models.list_compliance_modules")
def test_compliance_get_bulk_file_fallback(self, mock_list_modules, mock_ep):
From b994b0b14e430f4c78364633dee7fd1fa72fe776 Mon Sep 17 00:00:00 2001
From: Pepe Fagoaga
Date: Tue, 9 Jun 2026 13:53:21 +0200
Subject: [PATCH 029/129] chore(ui): rename customer support to support desk
(#11508)
---
ui/CHANGELOG.md | 4 ++++
ui/components/ui/sidebar/submenu-item.tsx | 16 ++++++-------
ui/lib/menu-list.ts | 29 +++++++++++++----------
3 files changed, 29 insertions(+), 20 deletions(-)
diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md
index cbcc66fe7d..49e6ddf9f0 100644
--- a/ui/CHANGELOG.md
+++ b/ui/CHANGELOG.md
@@ -8,6 +8,10 @@ All notable changes to the **Prowler UI** are documented in this file.
- DORA compliance framework support [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131)
+### 🔄 Changed
+
+- Renamed "Customer Support" to "Support Desk" in the side menu, showing it only in Prowler Cloud/Enterprise, while "Community Support" now shows only in Prowler OSS [(#11508)](https://github.com/prowler-cloud/prowler/pull/11508)
+
---
## [1.29.3] (Prowler v5.29.3)
diff --git a/ui/components/ui/sidebar/submenu-item.tsx b/ui/components/ui/sidebar/submenu-item.tsx
index 1b0b0aeaf3..2767381ff0 100644
--- a/ui/components/ui/sidebar/submenu-item.tsx
+++ b/ui/components/ui/sidebar/submenu-item.tsx
@@ -45,13 +45,13 @@ export const SubmenuItem = ({
- {label}
+ {label}
@@ -75,7 +75,7 @@ export const SubmenuItem = ({
>
-
- {label}
+
+ {label}
{highlight && (
)}
@@ -100,7 +100,7 @@ export const SubmenuItem = ({
return (
@@ -113,8 +113,8 @@ export const SubmenuItem = ({
-
- {label}
+
+ {label}
{highlight && (
{
label: "API reference",
icon: APIdocIcon,
},
- {
- href: "https://customer.support.prowler.com/servicedesk/customer/portal/9/create/102",
- target: "_blank",
- label: "Customer Support",
- icon: MessageCircleQuestion,
- },
- {
- href: "https://github.com/prowler-cloud/prowler/issues",
- target: "_blank",
- label: "Community Support",
- icon: GithubIcon,
- },
+ ...(isCloudEnv
+ ? [
+ {
+ href: "https://customer.support.prowler.com/servicedesk/customer/portal/9/create/102",
+ target: "_blank",
+ label: "Support Desk",
+ icon: MessageCircleQuestion,
+ },
+ ]
+ : [
+ {
+ href: "https://github.com/prowler-cloud/prowler/issues",
+ target: "_blank",
+ label: "Community Support",
+ icon: GithubIcon,
+ },
+ ]),
],
defaultOpen: false,
},
From 355b7071aa3531ba5f50f188a39d630d73daa8c4 Mon Sep 17 00:00:00 2001
From: Alan Buscaglia
Date: Tue, 9 Jun 2026 14:41:13 +0200
Subject: [PATCH 030/129] docs: add skills installation and usage guide
(#11513)
---
docs/developer-guide/ai-skills.mdx | 111 +++++++++++++++++++----------
1 file changed, 75 insertions(+), 36 deletions(-)
diff --git a/docs/developer-guide/ai-skills.mdx b/docs/developer-guide/ai-skills.mdx
index 6a0787dac8..ceb154d027 100644
--- a/docs/developer-guide/ai-skills.mdx
+++ b/docs/developer-guide/ai-skills.mdx
@@ -8,7 +8,77 @@ This guide explains the AI Skills system that provides on-demand context and pat
**What are AI Skills?** Skills are structured instructions that help AI agents (Claude Code, Cursor, Copilot, etc.) understand Prowler's conventions, patterns, and best practices.
-## Architecture Overview
+Skills live in the [`skills/`](https://github.com/prowler-cloud/prowler/tree/master/skills) directory of the Prowler OSS repository. Each skill is a folder containing a `SKILL.md` file with its patterns and metadata.
+
+## Installation
+
+To enable skills for the supported AI coding assistants, run the setup script from the repository root:
+
+```bash
+./skills/setup.sh
+```
+
+The script creates symlinks so each tool finds the skills in its expected location:
+
+| Tool | Created by setup |
+|------|------------------|
+| Claude Code | `.claude/skills/` symlink and `CLAUDE.md` |
+| Gemini CLI | `.gemini/skills/` symlink and `GEMINI.md` |
+| Codex (OpenAI) | `.codex/skills/` symlink (uses `AGENTS.md` natively) |
+| GitHub Copilot | `.github/copilot-instructions.md` symlink to `AGENTS.md` |
+
+After running the setup, restart the AI coding assistant to load the skills.
+
+## Using Skills
+
+AI agents discover skills automatically and load them when a request matches a skill trigger. To load a skill manually during a session, point the agent to the skill's `SKILL.md` file:
+
+```text
+Read skills/{skill-name}/SKILL.md
+```
+
+For the full list of available skills, their triggers, and the Auto-invoke mappings, see the [`skills/README.md`](https://github.com/prowler-cloud/prowler/blob/master/skills/README.md) and [`AGENTS.md`](https://github.com/prowler-cloud/prowler/blob/master/AGENTS.md) in the repository.
+
+## Available Skills
+
+| Type | Skills |
+|------|--------|
+| **Generic** | typescript, react-19, nextjs-16, tailwind-4, pytest, playwright, django-drf, zod-4, zustand-5, ai-sdk-5, vitest, tdd |
+| **Prowler** | prowler, prowler-sdk-check, prowler-api, prowler-ui, prowler-mcp, prowler-provider, prowler-compliance, prowler-compliance-review, prowler-docs, prowler-pr, prowler-ci, prowler-attack-paths-query |
+| **Testing** | prowler-test-sdk, prowler-test-api, prowler-test-ui |
+| **Meta** | skill-creator, skill-sync |
+
+
+This table is a snapshot. The repository is the source of truth: see [`skills/README.md`](https://github.com/prowler-cloud/prowler/blob/master/skills/README.md) for the current, complete list.
+
+
+## Skill Structure
+
+Each skill follows the [Agent Skills spec](https://agentskills.io):
+
+```text
+skills/{skill-name}/
+├── SKILL.md # Patterns, rules, decision trees
+├── assets/ # Code templates, schemas
+└── references/ # Links to local docs (single source of truth)
+```
+
+## Key Design Decisions
+
+1. **Self-contained skills** - Critical patterns inline for fast loading
+2. **Local doc references** - No web URLs, points to `docs/developer-guide/*.mdx`
+3. **Single source of truth** - Skills reference docs, no duplication
+4. **On-demand loading** - AI loads only what's needed for the task
+
+## Creating New Skills
+
+Use the `skill-creator` meta-skill to create new skills that follow the Agent Skills spec. See [`AGENTS.md`](https://github.com/prowler-cloud/prowler/blob/master/AGENTS.md) for the full list of available skills and their triggers.
+
+## How Skills Work
+
+The diagrams below explain the internals of the skill system. They are useful for understanding the design, but are not required to install or use skills.
+
+### Architecture Overview
```mermaid
graph LR
@@ -28,7 +98,7 @@ graph LR
style F fill:#1a4d2e,stroke:#66bb6a,color:#fff
```
-## How It Works
+### Request Lifecycle
```mermaid
sequenceDiagram
@@ -68,7 +138,7 @@ sequenceDiagram
A->>U: Creates check with correct patterns
```
-## Before vs After
+### With and Without Skills
```mermaid
graph TD
@@ -96,7 +166,7 @@ graph TD
style AFTER fill:#1a4d1a,stroke:#66bb6a,color:#fff
```
-## Complete Architecture
+### Full Component Map
```mermaid
flowchart TB
@@ -110,7 +180,7 @@ flowchart TB
subgraph GENERIC["Generic Skills"]
G1["typescript"]
G2["react-19"]
- G3["nextjs-15"]
+ G3["nextjs-16"]
G4["tailwind-4"]
G5["pytest"]
G6["playwright"]
@@ -186,34 +256,3 @@ flowchart TB
style STRUCTURE fill:#5c3d1a,stroke:#ffb74d,color:#fff
style DOCS fill:#1a3d4d,stroke:#4dd0e1,color:#fff
```
-
-## Skills Included
-
-| Type | Skills |
-|------|--------|
-| **Generic** | typescript, react-19, nextjs-15, tailwind-4, pytest, playwright, django-drf, zod-4, zustand-5, ai-sdk-5 |
-| **Prowler** | prowler, prowler-sdk-check, prowler-api, prowler-ui, prowler-mcp, prowler-provider, prowler-compliance, prowler-compliance-review, prowler-docs, prowler-pr, prowler-ci |
-| **Testing** | prowler-test-sdk, prowler-test-api, prowler-test-ui |
-| **Meta** | skill-creator, skill-sync |
-
-## Skill Structure
-
-Each skill follows the [Agent Skills spec](https://agentskills.io):
-
-```
-skills/{skill-name}/
-├── SKILL.md # Patterns, rules, decision trees
-├── assets/ # Code templates, schemas
-└── references/ # Links to local docs (single source of truth)
-```
-
-## Key Design Decisions
-
-1. **Self-contained skills** - Critical patterns inline for fast loading
-2. **Local doc references** - No web URLs, points to `docs/developer-guide/*.mdx`
-3. **Single source of truth** - Skills reference docs, no duplication
-4. **On-demand loading** - AI loads only what's needed for the task
-
-## Creating New Skills
-
-Use the `skill-creator` meta-skill to create new skills that follow the Agent Skills spec. See `AGENTS.md` for the full list of available skills and their triggers.
From 58efb719fa567e87b6211510995547184693032a Mon Sep 17 00:00:00 2001
From: Alan Buscaglia
Date: Tue, 9 Jun 2026 14:41:18 +0200
Subject: [PATCH 031/129] docs(skills): correct setup symlink paths in README
(#11514)
---
skills/README.md | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/skills/README.md b/skills/README.md
index 47bf7e5562..cfc65527a9 100644
--- a/skills/README.md
+++ b/skills/README.md
@@ -23,12 +23,12 @@ Run the setup script to configure skills for all supported AI coding assistants:
This creates symlinks so each tool finds skills in its expected location:
-| Tool | Symlink Created |
-|------|-----------------|
-| Claude Code / OpenCode | `.claude/skills/` |
-| Codex (OpenAI) | `.codex/skills/` |
-| GitHub Copilot | `.github/skills/` |
-| Gemini CLI | `.gemini/skills/` |
+| Tool | Created by setup |
+|------|------------------|
+| Claude Code | `.claude/skills/` symlink and `CLAUDE.md` |
+| Gemini CLI | `.gemini/skills/` symlink and `GEMINI.md` |
+| Codex (OpenAI) | `.codex/skills/` symlink (uses `AGENTS.md` natively) |
+| GitHub Copilot | `.github/copilot-instructions.md` symlink to `AGENTS.md` |
After running setup, restart your AI coding assistant to load the skills.
From d9f90e50b83510f94fb6ef6580842f2452480d71 Mon Sep 17 00:00:00 2001
From: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com>
Date: Tue, 9 Jun 2026 15:23:35 +0200
Subject: [PATCH 032/129] fix(m365): paginate admincenter group enumeration
(#11510)
---
prowler/CHANGELOG.md | 1 +
.../admincenter/admincenter_service.py | 28 ++++++-----
.../admincenter/admincenter_service_test.py | 46 +++++++++++++++++++
3 files changed, 64 insertions(+), 11 deletions(-)
diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md
index dd35527c8e..69a91b7128 100644
--- a/prowler/CHANGELOG.md
+++ b/prowler/CHANGELOG.md
@@ -21,6 +21,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
### 🐞 Fixed
- `load_and_validate_config_file` now unwraps namespaced config for every built-in and external provider, and no longer leaks the full file as the provider's config when the file is namespaced [(#10700)](https://github.com/prowler-cloud/prowler/pull/10700)
+- M365 Admin Center group enumeration now follows Microsoft Graph pagination so group-scoped checks include groups beyond the first page [(#11510)](https://github.com/prowler-cloud/prowler/pull/11510)
---
diff --git a/prowler/providers/m365/services/admincenter/admincenter_service.py b/prowler/providers/m365/services/admincenter/admincenter_service.py
index 45be16d11a..3899dced67 100644
--- a/prowler/providers/m365/services/admincenter/admincenter_service.py
+++ b/prowler/providers/m365/services/admincenter/admincenter_service.py
@@ -175,17 +175,23 @@ class AdminCenter(M365Service):
try:
groups_list = await self.client.groups.get()
groups.update({})
- for group in groups_list.value:
- groups.update(
- {
- group.id: Group(
- id=group.id,
- name=getattr(group, "display_name", ""),
- visibility=getattr(group, "visibility", ""),
- group_types=getattr(group, "group_types", []) or [],
- )
- }
- )
+ while groups_list:
+ for group in getattr(groups_list, "value", []) or []:
+ groups.update(
+ {
+ group.id: Group(
+ id=group.id,
+ name=getattr(group, "display_name", ""),
+ visibility=getattr(group, "visibility", ""),
+ group_types=getattr(group, "group_types", []) or [],
+ )
+ }
+ )
+
+ next_link = getattr(groups_list, "odata_next_link", None)
+ if not next_link:
+ break
+ groups_list = await self.client.groups.with_url(next_link).get()
except Exception as error:
logger.error(
diff --git a/tests/providers/m365/services/admincenter/admincenter_service_test.py b/tests/providers/m365/services/admincenter/admincenter_service_test.py
index ce0a2a3050..4eedba05e1 100644
--- a/tests/providers/m365/services/admincenter/admincenter_service_test.py
+++ b/tests/providers/m365/services/admincenter/admincenter_service_test.py
@@ -252,3 +252,49 @@ def test_admincenter__get_groups_maps_group_types():
assert groups["id-2"].group_types == []
assert groups["id-3"].group_types == []
assert groups["id-3"].visibility == "Public"
+
+
+def test_admincenter__get_groups_handles_pagination():
+ admincenter_service = AdminCenter.__new__(AdminCenter)
+
+ groups_response_page_one = SimpleNamespace(
+ value=[
+ SimpleNamespace(
+ id="id-1",
+ display_name="First Unified Group",
+ visibility="Private",
+ group_types=["Unified"],
+ )
+ ],
+ odata_next_link="next-link",
+ )
+ groups_response_page_two = SimpleNamespace(
+ value=[
+ SimpleNamespace(
+ id="id-2",
+ display_name="Second Unified Group",
+ visibility="Public",
+ group_types=["Unified"],
+ )
+ ],
+ odata_next_link=None,
+ )
+
+ groups_with_url_builder = SimpleNamespace(
+ get=AsyncMock(return_value=groups_response_page_two)
+ )
+ with_url_mock = MagicMock(return_value=groups_with_url_builder)
+ groups_builder = SimpleNamespace(
+ get=AsyncMock(return_value=groups_response_page_one),
+ with_url=with_url_mock,
+ )
+ admincenter_service.client = SimpleNamespace(groups=groups_builder)
+
+ groups = asyncio.run(admincenter_service._get_groups())
+
+ assert len(groups) == 2
+ assert groups_builder.get.await_count == 1
+ with_url_mock.assert_called_once_with("next-link")
+ assert groups["id-1"].name == "First Unified Group"
+ assert groups["id-2"].name == "Second Unified Group"
+ assert groups["id-2"].visibility == "Public"
From b3caee88e4453b2f8873560814e108b399573ed0 Mon Sep 17 00:00:00 2001
From: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com>
Date: Tue, 9 Jun 2026 15:42:06 +0200
Subject: [PATCH 033/129] fix(m365): skip future hires in MFA capable check
(#11511)
---
prowler/CHANGELOG.md | 1 +
.../m365/services/entra/entra_service.py | 4 +
.../entra_users_mfa_capable.py | 13 +-
.../entra_users_mfa_capable_test.py | 128 ++++++++++++++++++
.../entra/microsoft365_entra_service_test.py | 5 +
5 files changed, 150 insertions(+), 1 deletion(-)
diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md
index 69a91b7128..69fe6276b7 100644
--- a/prowler/CHANGELOG.md
+++ b/prowler/CHANGELOG.md
@@ -21,6 +21,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
### 🐞 Fixed
- `load_and_validate_config_file` now unwraps namespaced config for every built-in and external provider, and no longer leaks the full file as the provider's config when the file is namespaced [(#10700)](https://github.com/prowler-cloud/prowler/pull/10700)
+- `entra_users_mfa_capable` no longer flags pre-provisioned users with future `employeeHireDate`; future-hire date comparisons now tolerate naive datetimes [(#11511)](https://github.com/prowler-cloud/prowler/pull/11511)
- M365 Admin Center group enumeration now follows Microsoft Graph pagination so group-scoped checks include groups beyond the first page [(#11510)](https://github.com/prowler-cloud/prowler/pull/11510)
---
diff --git a/prowler/providers/m365/services/entra/entra_service.py b/prowler/providers/m365/services/entra/entra_service.py
index c3ea05acee..20f2dbf3a3 100644
--- a/prowler/providers/m365/services/entra/entra_service.py
+++ b/prowler/providers/m365/services/entra/entra_service.py
@@ -830,6 +830,7 @@ class Entra(M365Service):
"userType",
"accountEnabled",
"onPremisesSyncEnabled",
+ "employeeHireDate",
],
)
)
@@ -890,6 +891,7 @@ class Entra(M365Service):
"authentication_methods", []
),
user_type=getattr(user, "user_type", None),
+ employee_hire_date=getattr(user, "employee_hire_date", None),
)
next_link = getattr(users_response, "odata_next_link", None)
@@ -1725,6 +1727,7 @@ class User(BaseModel):
user_type: The user account type as reported by Microsoft Graph
(typically 'Member' or 'Guest'). ``None`` when Microsoft Graph does not
return the property; checks must not assume a default in that case.
+ employee_hire_date: The user's hire date as reported by Microsoft Graph.
"""
id: str
@@ -1735,6 +1738,7 @@ class User(BaseModel):
account_enabled: bool = True
authentication_methods: List[str] = []
user_type: Optional[str] = None
+ employee_hire_date: Optional[datetime] = None
class InvitationsFrom(Enum):
diff --git a/prowler/providers/m365/services/entra/entra_users_mfa_capable/entra_users_mfa_capable.py b/prowler/providers/m365/services/entra/entra_users_mfa_capable/entra_users_mfa_capable.py
index 9485676d30..d3e75cef7f 100644
--- a/prowler/providers/m365/services/entra/entra_users_mfa_capable/entra_users_mfa_capable.py
+++ b/prowler/providers/m365/services/entra/entra_users_mfa_capable/entra_users_mfa_capable.py
@@ -1,3 +1,4 @@
+from datetime import datetime, timezone
from typing import List
from prowler.lib.check.models import Check, CheckReportM365
@@ -12,7 +13,8 @@ class entra_users_mfa_capable(Check):
Microsoft 365 Foundations Benchmark recommendation 5.2.3.4
("Ensure all member users are 'MFA capable'").
- Guest users and disabled accounts are excluded from the evaluation.
+ Guest users, disabled accounts, and future hires are excluded from the
+ evaluation.
- PASS: The member user is MFA capable.
- FAIL: The member user is not MFA capable, or MFA capability cannot be
@@ -38,6 +40,15 @@ class entra_users_mfa_capable(Check):
for user in entra_client.users.values():
if user.user_type == "Guest" or not user.account_enabled:
continue
+ if user.employee_hire_date:
+ employee_hire_date = user.employee_hire_date
+ if (
+ employee_hire_date.tzinfo is None
+ or employee_hire_date.utcoffset() is None
+ ):
+ employee_hire_date = employee_hire_date.replace(tzinfo=timezone.utc)
+ if employee_hire_date > datetime.now(timezone.utc):
+ continue
report = CheckReportM365(
metadata=self.metadata(),
diff --git a/tests/providers/m365/services/entra/entra_users_mfa_capable/entra_users_mfa_capable_test.py b/tests/providers/m365/services/entra/entra_users_mfa_capable/entra_users_mfa_capable_test.py
index 8bf0c88f77..86e8e38f22 100644
--- a/tests/providers/m365/services/entra/entra_users_mfa_capable/entra_users_mfa_capable_test.py
+++ b/tests/providers/m365/services/entra/entra_users_mfa_capable/entra_users_mfa_capable_test.py
@@ -1,3 +1,4 @@
+from datetime import datetime, timedelta, timezone
from unittest import mock
from uuid import uuid4
@@ -326,6 +327,133 @@ class Test_entra_users_mfa_capable:
assert len(result) == 0
+ def test_future_hire_member_user_not_checked(self):
+ """Future-hire member user is not active yet: expected no results."""
+ entra_client = mock.MagicMock
+ entra_client.audited_tenant = "audited_tenant"
+ entra_client.audited_domain = DOMAIN
+ entra_client.user_registration_details_error = None
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_m365_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.m365.services.entra.entra_users_mfa_capable.entra_users_mfa_capable.entra_client",
+ new=entra_client,
+ ),
+ ):
+ from prowler.providers.m365.services.entra.entra_users_mfa_capable.entra_users_mfa_capable import (
+ entra_users_mfa_capable,
+ )
+
+ user_id = str(uuid4())
+ entra_client.users = {
+ user_id: User(
+ id=user_id,
+ name="Future Hire",
+ on_premises_sync_enabled=False,
+ directory_roles_ids=[],
+ is_mfa_capable=False,
+ account_enabled=True,
+ user_type="Member",
+ employee_hire_date=datetime.now(timezone.utc) + timedelta(days=1),
+ )
+ }
+
+ check = entra_users_mfa_capable()
+ result = check.execute()
+
+ assert len(result) == 0
+
+ def test_naive_future_hire_member_user_not_checked(self):
+ """Naive future-hire datetimes are treated as UTC and skipped."""
+ entra_client = mock.MagicMock
+ entra_client.audited_tenant = "audited_tenant"
+ entra_client.audited_domain = DOMAIN
+ entra_client.user_registration_details_error = None
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_m365_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.m365.services.entra.entra_users_mfa_capable.entra_users_mfa_capable.entra_client",
+ new=entra_client,
+ ),
+ ):
+ from prowler.providers.m365.services.entra.entra_users_mfa_capable.entra_users_mfa_capable import (
+ entra_users_mfa_capable,
+ )
+
+ user_id = str(uuid4())
+ entra_client.users = {
+ user_id: User(
+ id=user_id,
+ name="Future Hire",
+ on_premises_sync_enabled=False,
+ directory_roles_ids=[],
+ is_mfa_capable=False,
+ account_enabled=True,
+ user_type="Member",
+ employee_hire_date=(
+ datetime.now(timezone.utc) + timedelta(days=1)
+ ).replace(tzinfo=None),
+ )
+ }
+
+ check = entra_users_mfa_capable()
+ result = check.execute()
+
+ assert len(result) == 0
+
+ def test_current_hire_member_user_is_checked(self):
+ """Current-hire member user is active now: expected evaluation."""
+ entra_client = mock.MagicMock
+ entra_client.audited_tenant = "audited_tenant"
+ entra_client.audited_domain = DOMAIN
+ entra_client.user_registration_details_error = None
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_m365_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.m365.services.entra.entra_users_mfa_capable.entra_users_mfa_capable.entra_client",
+ new=entra_client,
+ ),
+ ):
+ from prowler.providers.m365.services.entra.entra_users_mfa_capable.entra_users_mfa_capable import (
+ entra_users_mfa_capable,
+ )
+
+ user_id = str(uuid4())
+ entra_client.users = {
+ user_id: User(
+ id=user_id,
+ name="Current Hire",
+ on_premises_sync_enabled=False,
+ directory_roles_ids=[],
+ is_mfa_capable=False,
+ account_enabled=True,
+ user_type="Member",
+ employee_hire_date=datetime.now(timezone.utc),
+ )
+ }
+
+ check = entra_users_mfa_capable()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert result[0].status_extended == "User Current Hire is not MFA capable."
+ assert result[0].resource == entra_client.users[user_id]
+ assert result[0].resource_name == "Current Hire"
+ assert result[0].resource_id == user_id
+
def test_member_and_guest_users(self):
"""Mix of member and guest users: only member users should be checked."""
entra_client = mock.MagicMock
diff --git a/tests/providers/m365/services/entra/microsoft365_entra_service_test.py b/tests/providers/m365/services/entra/microsoft365_entra_service_test.py
index f2ee0b2c84..4643ea93cc 100644
--- a/tests/providers/m365/services/entra/microsoft365_entra_service_test.py
+++ b/tests/providers/m365/services/entra/microsoft365_entra_service_test.py
@@ -411,6 +411,7 @@ class Test_Entra_Service:
id="user-1",
display_name="User 1",
on_premises_sync_enabled=True,
+ employee_hire_date=datetime(2026, 6, 10, tzinfo=timezone.utc),
),
SimpleNamespace(
id="user-2",
@@ -535,6 +536,7 @@ class Test_Entra_Service:
"userType",
"accountEnabled",
"onPremisesSyncEnabled",
+ "employeeHireDate",
}
with_url_mock.assert_called_once_with("next-link")
assert users["user-1"].directory_roles_ids == ["role-template-1"]
@@ -548,6 +550,9 @@ class Test_Entra_Service:
assert users["user-1"].authentication_methods == ["fido2SecurityKey"]
assert users["user-6"].authentication_methods == ["mobilePhone"]
assert users["user-2"].authentication_methods == []
+ assert users["user-1"].employee_hire_date == datetime(
+ 2026, 6, 10, tzinfo=timezone.utc
+ )
def test__get_users_uses_graph_account_enabled_for_disabled_guests(self):
"""Regression test for https://github.com/prowler-cloud/prowler/issues/10921.
From e710ebff1c9a77b8eff4bedd1cf83db4df14693f Mon Sep 17 00:00:00 2001
From: Jasmine
Date: Tue, 9 Jun 2026 22:24:25 +0800
Subject: [PATCH 034/129] feat(m365): add
`exchange_mailbox_primary_smtp_custom_domain` check (#11215)
Co-authored-by: Jasmine Sullivan <20147180@tafe.wa.edu.au>
Co-authored-by: Daniel Barranquero
---
prowler/CHANGELOG.md | 1 +
.../m365/lib/powershell/m365_powershell.py | 26 ++
.../__init__.py | 0
...mary_smtp_uses_custom_domain.metadata.json | 39 +++
...mailbox_primary_smtp_uses_custom_domain.py | 79 +++++
.../services/exchange/exchange_service.py | 74 +++++
...ox_primary_smtp_uses_custom_domain_test.py | 300 ++++++++++++++++++
.../exchange/exchange_service_test.py | 98 ++++++
8 files changed, 617 insertions(+)
create mode 100644 prowler/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/__init__.py
create mode 100644 prowler/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/exchange_mailbox_primary_smtp_uses_custom_domain.metadata.json
create mode 100644 prowler/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/exchange_mailbox_primary_smtp_uses_custom_domain.py
create mode 100644 tests/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/exchange_mailbox_primary_smtp_uses_custom_domain_test.py
diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md
index 69fe6276b7..42c794a49f 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.
- External multi-provider compliance frameworks can be registered via the `prowler.compliance.universal` entry point group [(#11490)](https://github.com/prowler-cloud/prowler/pull/11490)
- AWS AI Security Framework support in the CLI dashboard [(#11475)](https://github.com/prowler-cloud/prowler/pull/11475)
- `entra_service_principal_privileged_role_no_owners` check for M365 provider, failing when a service principal with a permanent Tier 0 directory role has owners on the service principal or its parent app registration [(#11070)](https://github.com/prowler-cloud/prowler/issues/11070)
+- `exchange_mailbox_primary_smtp_uses_custom_domain` check for M365 provider [(#11215)](https://github.com/prowler-cloud/prowler/pull/11215)
### 🐞 Fixed
diff --git a/prowler/providers/m365/lib/powershell/m365_powershell.py b/prowler/providers/m365/lib/powershell/m365_powershell.py
index 1bc8aa4075..4dae094d90 100644
--- a/prowler/providers/m365/lib/powershell/m365_powershell.py
+++ b/prowler/providers/m365/lib/powershell/m365_powershell.py
@@ -950,6 +950,32 @@ class M365PowerShell(PowerShellSession):
"Get-TeamsProtectionPolicy | ConvertTo-Json -Depth 10", json_parse=True
)
+ def get_mailboxes(self) -> dict:
+ """
+ Get Exchange Online Recipient-Facing Mailboxes.
+
+ Retrieves all recipient-facing mailboxes from Exchange Online with the
+ properties needed to evaluate primary SMTP domain policy.
+
+ Returns:
+ dict: Mailbox information in JSON format.
+
+ Example:
+ >>> get_mailboxes()
+ [
+ {
+ "Identity": "user1@contoso.com",
+ "DisplayName": "User One",
+ "PrimarySmtpAddress": "user1@contoso.com",
+ "RecipientTypeDetails": "UserMailbox"
+ }
+ ]
+ """
+ return self.execute(
+ "Get-EXOMailbox -ResultSize Unlimited | Select-Object Identity, DisplayName, PrimarySmtpAddress, RecipientTypeDetails | ConvertTo-Json -Depth 10",
+ json_parse=True,
+ )
+
def get_shared_mailboxes(self) -> dict:
"""
Get Exchange Online Shared Mailboxes.
diff --git a/prowler/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/__init__.py b/prowler/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/exchange_mailbox_primary_smtp_uses_custom_domain.metadata.json b/prowler/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/exchange_mailbox_primary_smtp_uses_custom_domain.metadata.json
new file mode 100644
index 0000000000..66ce055152
--- /dev/null
+++ b/prowler/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/exchange_mailbox_primary_smtp_uses_custom_domain.metadata.json
@@ -0,0 +1,39 @@
+{
+ "Provider": "m365",
+ "CheckID": "exchange_mailbox_primary_smtp_uses_custom_domain",
+ "CheckTitle": "Mailbox primary SMTP address must use a custom domain",
+ "CheckType": [],
+ "ServiceName": "exchange",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "low",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "collaboration",
+ "Description": "**Exchange Online mailboxes** should use a custom domain as their primary SMTP address, not the default **\\*.onmicrosoft.com** routing domain assigned by Microsoft on tenant creation. This check verifies that the **PrimarySmtpAddress** of every user-facing mailbox does not end with `.onmicrosoft.com`.",
+ "Risk": "Mailboxes still using **.onmicrosoft.com** as their primary SMTP address leak the internal **tenant identifier** in every From: header, helping attackers fingerprint the tenant for spear-phishing. They also bypass **DMARC/DKIM** hardening that organisations deploy on their custom domains and are frequently treated as low-trust by recipient anti-phishing engines.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://learn.microsoft.com/en-us/microsoft-365/admin/setup/add-domain",
+ "https://learn.microsoft.com/en-us/microsoft-365/admin/setup/domains-faq",
+ "https://learn.microsoft.com/en-us/powershell/module/exchange/get-mailbox",
+ "https://learn.microsoft.com/en-us/exchange/recipients-in-exchange-online/manage-user-mailboxes/manage-user-mailboxes"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "Get-Mailbox -ResultSize Unlimited | Where-Object { $_.PrimarySmtpAddress -like '*.onmicrosoft.com' } | ForEach-Object { Set-Mailbox -Identity $_.Identity -PrimarySmtpAddress '@' }",
+ "NativeIaC": "",
+ "Other": "1. Navigate to the Microsoft 365 admin center (https://admin.microsoft.com/)\n2. Go to Users > Active users and select the affected user\n3. Under the Aliases section, add the custom domain email address\n4. Set the custom domain address as the primary SMTP address\n5. Save changes and repeat for all affected mailboxes",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Update the primary SMTP address of all affected mailboxes to use a custom domain. Ensure your custom domain is verified in the Microsoft 365 admin center before making this change.",
+ "Url": "https://hub.prowler.com/check/exchange_mailbox_primary_smtp_uses_custom_domain"
+ }
+ },
+ "Categories": [
+ "email-security"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": ""
+}
diff --git a/prowler/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/exchange_mailbox_primary_smtp_uses_custom_domain.py b/prowler/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/exchange_mailbox_primary_smtp_uses_custom_domain.py
new file mode 100644
index 0000000000..d34d091145
--- /dev/null
+++ b/prowler/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/exchange_mailbox_primary_smtp_uses_custom_domain.py
@@ -0,0 +1,79 @@
+from typing import List
+
+from prowler.lib.check.models import Check, CheckReportM365
+from prowler.providers.m365.services.exchange.exchange_client import exchange_client
+
+
+class exchange_mailbox_primary_smtp_uses_custom_domain(Check):
+ """
+ Verify that every Exchange Online mailbox uses a custom domain as its
+ primary SMTP address, not the default .onmicrosoft.com routing domain.
+
+ The .onmicrosoft.com domain is assigned by Microsoft on tenant creation
+ and is not intended for ongoing mail. Mailboxes still using it leak the
+ internal tenant identifier in every From: header (aiding spear-phishing),
+ bypass DMARC/DKIM hardening on custom domains and are often treated as
+ low-trust by recipient anti-phishing engines.
+
+ - PASS: Primary SMTP address does not use the .onmicrosoft.com domain.
+ - FAIL: Primary SMTP address uses the .onmicrosoft.com domain.
+ - MANUAL: Exchange Online PowerShell unavailable; check cannot run.
+ """
+
+ def execute(self) -> List[CheckReportM365]:
+ """
+ Execute the check against all recipient-facing Exchange Online mailboxes.
+
+ Returns:
+ List[CheckReportM365]: A report for each mailbox with its SMTP
+ domain status, or a single MANUAL report if PowerShell was
+ unavailable.
+ """
+ findings = []
+
+ # mailboxes is None when Exchange Online PowerShell could not be
+ # reached or the cmdlet raised. An empty list means PowerShell ran
+ # but the tenant has no recipient-facing mailboxes (no findings).
+ if exchange_client.mailboxes is None:
+ report = CheckReportM365(
+ metadata=self.metadata(),
+ resource={},
+ resource_name="Exchange Online Mailboxes",
+ resource_id="exchange_mailboxes",
+ )
+ report.status = "MANUAL"
+ report.status_extended = (
+ "Exchange Online PowerShell is unavailable. "
+ "Enable EXO PowerShell access to run this check."
+ )
+ findings.append(report)
+ return findings
+
+ for mailbox in exchange_client.mailboxes:
+ report = CheckReportM365(
+ metadata=self.metadata(),
+ resource=mailbox,
+ resource_name=mailbox.name or mailbox.identity,
+ resource_id=mailbox.identity,
+ )
+
+ if mailbox.primary_smtp_address.endswith(".onmicrosoft.com"):
+ report.status = "FAIL"
+ report.status_extended = (
+ f"Mailbox {mailbox.identity} "
+ f"({mailbox.recipient_type_details}) has primary SMTP "
+ f"address {mailbox.primary_smtp_address} using the "
+ f".onmicrosoft.com domain instead of a custom domain."
+ )
+ else:
+ report.status = "PASS"
+ report.status_extended = (
+ f"Mailbox {mailbox.identity} "
+ f"({mailbox.recipient_type_details}) has primary SMTP "
+ f"address {mailbox.primary_smtp_address} using a "
+ f"custom domain."
+ )
+
+ findings.append(report)
+
+ return findings
diff --git a/prowler/providers/m365/services/exchange/exchange_service.py b/prowler/providers/m365/services/exchange/exchange_service.py
index 9b3ee47da9..3ce6528aa9 100644
--- a/prowler/providers/m365/services/exchange/exchange_service.py
+++ b/prowler/providers/m365/services/exchange/exchange_service.py
@@ -8,6 +8,15 @@ from prowler.lib.logger import logger
from prowler.providers.m365.lib.service.service import M365Service
from prowler.providers.m365.m365_provider import M365Provider
+SYSTEM_MAILBOX_TYPES = {
+ "DiscoveryMailbox",
+ "ArbitrationMailbox",
+ "AuditLogMailbox",
+ "MonitoringMailbox",
+ "AuxAuditLogMailbox",
+ "SystemMailbox",
+}
+
class Exchange(M365Service):
"""
@@ -34,6 +43,7 @@ class Exchange(M365Service):
self.role_assignment_policies = []
self.mailbox_audit_properties = []
self.shared_mailboxes = []
+ self.mailboxes = None
if self.powershell:
if self.powershell.connect_exchange_online():
@@ -46,6 +56,7 @@ class Exchange(M365Service):
self.role_assignment_policies = self._get_role_assignment_policies()
self.mailbox_audit_properties = self._get_mailbox_audit_properties()
self.shared_mailboxes = self._get_shared_mailboxes()
+ self.mailboxes = self._get_mailboxes()
self.powershell.close()
# Fetch license count via Graph API
@@ -355,6 +366,50 @@ class Exchange(M365Service):
)
return shared_mailboxes
+ def _get_mailboxes(self) -> Optional[list["Mailbox"]]:
+ """
+ Get all recipient-facing mailboxes from Exchange Online.
+
+ Retrieves mailboxes of types UserMailbox, SharedMailbox, RoomMailbox
+ and EquipmentMailbox. System-managed mailbox types are excluded as
+ they are controlled by Microsoft and are not subject to domain policy.
+
+ Returns:
+ list[Mailbox]: List of mailboxes with their primary SMTP address
+ and recipient type details. Returns ``None`` when the
+ underlying PowerShell cmdlet raises, so callers can
+ distinguish "PowerShell unavailable" from "empty tenant".
+ """
+ logger.info("Microsoft365 - Getting mailboxes...")
+ mailboxes = []
+ try:
+ mailboxes_data = self.powershell.get_mailboxes()
+ if not mailboxes_data:
+ return mailboxes
+ # PowerShell can return a single dict instead of a list when only
+ # one result is returned; normalize to a list for uniform handling.
+ if isinstance(mailboxes_data, dict):
+ mailboxes_data = [mailboxes_data]
+ for mailbox in mailboxes_data:
+ if mailbox:
+ recipient_type = mailbox.get("RecipientTypeDetails", "")
+ if recipient_type in SYSTEM_MAILBOX_TYPES:
+ continue
+ mailboxes.append(
+ Mailbox(
+ identity=mailbox.get("Identity", ""),
+ name=mailbox.get("DisplayName", ""),
+ primary_smtp_address=mailbox.get("PrimarySmtpAddress", ""),
+ recipient_type_details=recipient_type,
+ )
+ )
+ return mailboxes
+ except Exception as error:
+ logger.error(
+ f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+ return None
+
class Organization(BaseModel):
"""
@@ -497,3 +552,22 @@ class SharedMailbox(BaseModel):
user_principal_name: str
external_directory_object_id: str
identity: str
+
+
+class Mailbox(BaseModel):
+ """
+ Model for an Exchange Online recipient-facing mailbox.
+
+ Attributes:
+ identity: The unique identity of the mailbox in Exchange.
+ name: Display name of the mailbox.
+ primary_smtp_address: The primary SMTP address used for outbound mail
+ and the From: header. This is the address the check evaluates.
+ recipient_type_details: The mailbox type (e.g., UserMailbox,
+ SharedMailbox, RoomMailbox, EquipmentMailbox).
+ """
+
+ identity: str
+ name: str
+ primary_smtp_address: str
+ recipient_type_details: str
diff --git a/tests/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/exchange_mailbox_primary_smtp_uses_custom_domain_test.py b/tests/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/exchange_mailbox_primary_smtp_uses_custom_domain_test.py
new file mode 100644
index 0000000000..9ec073a922
--- /dev/null
+++ b/tests/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/exchange_mailbox_primary_smtp_uses_custom_domain_test.py
@@ -0,0 +1,300 @@
+from unittest import mock
+
+from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider
+
+
+class Test_exchange_mailbox_primary_smtp_uses_custom_domain:
+
+ def test_powershell_unavailable_manual(self):
+ """MANUAL: Exchange Online PowerShell unavailable (mailboxes is None)."""
+ exchange_client = mock.MagicMock()
+ exchange_client.audited_tenant = "audited_tenant"
+ exchange_client.audited_domain = DOMAIN
+ exchange_client.mailboxes = None
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_m365_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
+ ),
+ mock.patch(
+ "prowler.providers.m365.services.exchange.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_client",
+ new=exchange_client,
+ ),
+ ):
+ from prowler.providers.m365.services.exchange.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain import (
+ exchange_mailbox_primary_smtp_uses_custom_domain,
+ )
+
+ check = exchange_mailbox_primary_smtp_uses_custom_domain()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "MANUAL"
+ assert "PowerShell" in result[0].status_extended
+ assert result[0].resource_name == "Exchange Online Mailboxes"
+ assert result[0].resource_id == "exchange_mailboxes"
+
+ def test_empty_tenant_no_findings(self):
+ """Empty tenant (no mailboxes) produces zero findings, not MANUAL."""
+ exchange_client = mock.MagicMock()
+ exchange_client.audited_tenant = "audited_tenant"
+ exchange_client.audited_domain = DOMAIN
+ exchange_client.mailboxes = []
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_m365_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
+ ),
+ mock.patch(
+ "prowler.providers.m365.services.exchange.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_client",
+ new=exchange_client,
+ ),
+ ):
+ from prowler.providers.m365.services.exchange.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain import (
+ exchange_mailbox_primary_smtp_uses_custom_domain,
+ )
+
+ check = exchange_mailbox_primary_smtp_uses_custom_domain()
+ result = check.execute()
+
+ assert result == []
+
+ def test_custom_domain_passes(self):
+ """PASS: Mailbox primary SMTP uses a custom domain."""
+ exchange_client = mock.MagicMock()
+ exchange_client.audited_tenant = "audited_tenant"
+ exchange_client.audited_domain = DOMAIN
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_m365_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
+ ),
+ mock.patch(
+ "prowler.providers.m365.services.exchange.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_client",
+ new=exchange_client,
+ ),
+ ):
+ from prowler.providers.m365.services.exchange.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain import (
+ exchange_mailbox_primary_smtp_uses_custom_domain,
+ )
+ from prowler.providers.m365.services.exchange.exchange_service import (
+ Mailbox,
+ )
+
+ exchange_client.mailboxes = [
+ Mailbox(
+ identity="user1@contoso.com",
+ name="User One",
+ primary_smtp_address="user1@contoso.com",
+ recipient_type_details="UserMailbox",
+ )
+ ]
+
+ check = exchange_mailbox_primary_smtp_uses_custom_domain()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert "custom domain" in result[0].status_extended
+ assert result[0].resource_name == "User One"
+ assert result[0].resource_id == "user1@contoso.com"
+ assert result[0].location == "global"
+
+ def test_onmicrosoft_domain_fails(self):
+ """FAIL: Mailbox primary SMTP uses .onmicrosoft.com."""
+ exchange_client = mock.MagicMock()
+ exchange_client.audited_tenant = "audited_tenant"
+ exchange_client.audited_domain = DOMAIN
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_m365_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
+ ),
+ mock.patch(
+ "prowler.providers.m365.services.exchange.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_client",
+ new=exchange_client,
+ ),
+ ):
+ from prowler.providers.m365.services.exchange.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain import (
+ exchange_mailbox_primary_smtp_uses_custom_domain,
+ )
+ from prowler.providers.m365.services.exchange.exchange_service import (
+ Mailbox,
+ )
+
+ exchange_client.mailboxes = [
+ Mailbox(
+ identity="user1@contoso.onmicrosoft.com",
+ name="User One",
+ primary_smtp_address="user1@contoso.onmicrosoft.com",
+ recipient_type_details="UserMailbox",
+ )
+ ]
+
+ check = exchange_mailbox_primary_smtp_uses_custom_domain()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert ".onmicrosoft.com" in result[0].status_extended
+ assert result[0].resource_name == "User One"
+ assert result[0].resource_id == "user1@contoso.onmicrosoft.com"
+ assert result[0].location == "global"
+
+ def test_mixed_mailboxes(self):
+ """Test multiple mailboxes with mixed domain status."""
+ exchange_client = mock.MagicMock()
+ exchange_client.audited_tenant = "audited_tenant"
+ exchange_client.audited_domain = DOMAIN
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_m365_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
+ ),
+ mock.patch(
+ "prowler.providers.m365.services.exchange.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_client",
+ new=exchange_client,
+ ),
+ ):
+ from prowler.providers.m365.services.exchange.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain import (
+ exchange_mailbox_primary_smtp_uses_custom_domain,
+ )
+ from prowler.providers.m365.services.exchange.exchange_service import (
+ Mailbox,
+ )
+
+ exchange_client.mailboxes = [
+ Mailbox(
+ identity="user1@contoso.com",
+ name="User One",
+ primary_smtp_address="user1@contoso.com",
+ recipient_type_details="UserMailbox",
+ ),
+ Mailbox(
+ identity="shared@contoso.onmicrosoft.com",
+ name="Shared Mailbox",
+ primary_smtp_address="shared@contoso.onmicrosoft.com",
+ recipient_type_details="SharedMailbox",
+ ),
+ ]
+
+ check = exchange_mailbox_primary_smtp_uses_custom_domain()
+ result = check.execute()
+
+ assert len(result) == 2
+ assert result[0].status == "PASS"
+ assert (
+ result[0].status_extended
+ == "Mailbox user1@contoso.com (UserMailbox) has primary SMTP address user1@contoso.com using a custom domain."
+ )
+ assert result[1].status == "FAIL"
+ assert (
+ result[1].status_extended
+ == "Mailbox shared@contoso.onmicrosoft.com (SharedMailbox) has primary SMTP address shared@contoso.onmicrosoft.com using the .onmicrosoft.com domain instead of a custom domain."
+ )
+
+ def test_room_mailbox_custom_domain(self):
+ """PASS: Room mailbox using a custom domain."""
+ exchange_client = mock.MagicMock()
+ exchange_client.audited_tenant = "audited_tenant"
+ exchange_client.audited_domain = DOMAIN
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_m365_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
+ ),
+ mock.patch(
+ "prowler.providers.m365.services.exchange.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_client",
+ new=exchange_client,
+ ),
+ ):
+ from prowler.providers.m365.services.exchange.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain import (
+ exchange_mailbox_primary_smtp_uses_custom_domain,
+ )
+ from prowler.providers.m365.services.exchange.exchange_service import (
+ Mailbox,
+ )
+
+ exchange_client.mailboxes = [
+ Mailbox(
+ identity="boardroom@contoso.com",
+ name="Board Room",
+ primary_smtp_address="boardroom@contoso.com",
+ recipient_type_details="RoomMailbox",
+ )
+ ]
+
+ check = exchange_mailbox_primary_smtp_uses_custom_domain()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert result[0].resource_id == "boardroom@contoso.com"
+ assert result[0].location == "global"
+
+ def test_equipment_mailbox_onmicrosoft(self):
+ """FAIL: Equipment mailbox using .onmicrosoft.com."""
+ exchange_client = mock.MagicMock()
+ exchange_client.audited_tenant = "audited_tenant"
+ exchange_client.audited_domain = DOMAIN
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_m365_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
+ ),
+ mock.patch(
+ "prowler.providers.m365.services.exchange.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_client",
+ new=exchange_client,
+ ),
+ ):
+ from prowler.providers.m365.services.exchange.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain import (
+ exchange_mailbox_primary_smtp_uses_custom_domain,
+ )
+ from prowler.providers.m365.services.exchange.exchange_service import (
+ Mailbox,
+ )
+
+ exchange_client.mailboxes = [
+ Mailbox(
+ identity="projector@contoso.onmicrosoft.com",
+ name="Projector",
+ primary_smtp_address="projector@contoso.onmicrosoft.com",
+ recipient_type_details="EquipmentMailbox",
+ )
+ ]
+
+ check = exchange_mailbox_primary_smtp_uses_custom_domain()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert result[0].resource_id == "projector@contoso.onmicrosoft.com"
+ assert result[0].location == "global"
diff --git a/tests/providers/m365/services/exchange/exchange_service_test.py b/tests/providers/m365/services/exchange/exchange_service_test.py
index 8eed77ca99..8d812e3bb3 100644
--- a/tests/providers/m365/services/exchange/exchange_service_test.py
+++ b/tests/providers/m365/services/exchange/exchange_service_test.py
@@ -495,6 +495,104 @@ class Test_Exchange_Service:
exchange_client.powershell.close()
+ @patch(
+ "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.get_mailboxes",
+ return_value=[
+ {
+ "Identity": "user1@contoso.com",
+ "DisplayName": "User One",
+ "PrimarySmtpAddress": "user1@contoso.com",
+ "RecipientTypeDetails": "UserMailbox",
+ },
+ {
+ "Identity": "room@contoso.com",
+ "DisplayName": "Boardroom",
+ "PrimarySmtpAddress": "room@contoso.com",
+ "RecipientTypeDetails": "RoomMailbox",
+ },
+ {
+ "Identity": "DiscoverySearchMailbox{D919BA05}",
+ "DisplayName": "Discovery Search Mailbox",
+ "PrimarySmtpAddress": "DiscoverySearchMailbox@contoso.onmicrosoft.com",
+ "RecipientTypeDetails": "DiscoveryMailbox",
+ },
+ {
+ "Identity": "SystemMailbox{1f05a927}",
+ "DisplayName": "Microsoft Exchange",
+ "PrimarySmtpAddress": "SystemMailbox@contoso.onmicrosoft.com",
+ "RecipientTypeDetails": "SystemMailbox",
+ },
+ ],
+ )
+ def test_get_mailboxes_excludes_system_types(self, _mock_get_mailboxes):
+ with (
+ mock.patch(
+ "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online",
+ return_value=True,
+ ),
+ ):
+ exchange_client = Exchange(
+ set_mocked_m365_provider(
+ identity=M365IdentityInfo(tenant_domain=DOMAIN)
+ )
+ )
+ mailboxes = exchange_client.mailboxes
+ assert mailboxes is not None
+ assert len(mailboxes) == 2
+ identities = {m.identity for m in mailboxes}
+ assert identities == {"user1@contoso.com", "room@contoso.com"}
+ assert all(
+ m.recipient_type_details not in {"DiscoveryMailbox", "SystemMailbox"}
+ for m in mailboxes
+ )
+ exchange_client.powershell.close()
+
+ @patch(
+ "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.get_mailboxes",
+ return_value={
+ "Identity": "user1@contoso.com",
+ "DisplayName": "User One",
+ "PrimarySmtpAddress": "user1@contoso.com",
+ "RecipientTypeDetails": "UserMailbox",
+ },
+ )
+ def test_get_mailboxes_single_dict(self, _mock_get_mailboxes):
+ with (
+ mock.patch(
+ "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online",
+ return_value=True,
+ ),
+ ):
+ exchange_client = Exchange(
+ set_mocked_m365_provider(
+ identity=M365IdentityInfo(tenant_domain=DOMAIN)
+ )
+ )
+ mailboxes = exchange_client.mailboxes
+ assert mailboxes is not None
+ assert len(mailboxes) == 1
+ assert mailboxes[0].identity == "user1@contoso.com"
+ exchange_client.powershell.close()
+
+ @patch(
+ "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.get_mailboxes",
+ side_effect=Exception("Get-EXOMailbox failed"),
+ )
+ def test_get_mailboxes_returns_none_on_exception(self, _mock_get_mailboxes):
+ with (
+ mock.patch(
+ "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online",
+ return_value=True,
+ ),
+ ):
+ exchange_client = Exchange(
+ set_mocked_m365_provider(
+ identity=M365IdentityInfo(tenant_domain=DOMAIN)
+ )
+ )
+ assert exchange_client.mailboxes is None
+ exchange_client.powershell.close()
+
def test_get_total_paid_licenses_none(self):
with (
mock.patch(
From 9a50dffaa0db93d80f8a586e994dc7131d73b0ea Mon Sep 17 00:00:00 2001
From: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com>
Date: Tue, 9 Jun 2026 16:52:49 +0200
Subject: [PATCH 035/129] feat(gcp): split kms_key_rotation_enabled into
enabled and max-90-days checks (#11516)
---
prowler/CHANGELOG.md | 2 +
prowler/compliance/gcp/ccc_gcp.json | 2 +-
prowler/compliance/gcp/cis_2.0_gcp.json | 2 +-
prowler/compliance/gcp/cis_3.0_gcp.json | 2 +-
prowler/compliance/gcp/cis_4.0_gcp.json | 2 +-
.../gcp/prowler_threatscore_gcp.json | 2 +-
.../kms_key_rotation_enabled.metadata.json | 16 +-
.../kms_key_rotation_enabled.py | 34 +-
.../kms_key_rotation_max_90_days/__init__.py | 0
...kms_key_rotation_max_90_days.metadata.json | 38 +
.../kms_key_rotation_max_90_days.py | 44 ++
.../kms_key_rotation_enabled_test.py | 468 +----------
.../kms_key_rotation_max_90_days_test.py | 728 ++++++++++++++++++
13 files changed, 841 insertions(+), 499 deletions(-)
create mode 100644 prowler/providers/gcp/services/kms/kms_key_rotation_max_90_days/__init__.py
create mode 100644 prowler/providers/gcp/services/kms/kms_key_rotation_max_90_days/kms_key_rotation_max_90_days.metadata.json
create mode 100644 prowler/providers/gcp/services/kms/kms_key_rotation_max_90_days/kms_key_rotation_max_90_days.py
create mode 100644 tests/providers/gcp/services/kms/kms_key_rotation_max_90_days/kms_key_rotation_max_90_days_test.py
diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md
index 42c794a49f..dcb250d316 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.
- External multi-provider compliance frameworks can be registered via the `prowler.compliance.universal` entry point group [(#11490)](https://github.com/prowler-cloud/prowler/pull/11490)
- AWS AI Security Framework support in the CLI dashboard [(#11475)](https://github.com/prowler-cloud/prowler/pull/11475)
- `entra_service_principal_privileged_role_no_owners` check for M365 provider, failing when a service principal with a permanent Tier 0 directory role has owners on the service principal or its parent app registration [(#11070)](https://github.com/prowler-cloud/prowler/issues/11070)
+- `kms_key_rotation_max_90_days` check for GCP provider, verifying KMS customer-managed keys are rotated every 90 days or less in line with the CIS Benchmark [(#11516)](https://github.com/prowler-cloud/prowler/pull/11516)
- `exchange_mailbox_primary_smtp_uses_custom_domain` check for M365 provider [(#11215)](https://github.com/prowler-cloud/prowler/pull/11215)
### 🐞 Fixed
@@ -24,6 +25,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- `load_and_validate_config_file` now unwraps namespaced config for every built-in and external provider, and no longer leaks the full file as the provider's config when the file is namespaced [(#10700)](https://github.com/prowler-cloud/prowler/pull/10700)
- `entra_users_mfa_capable` no longer flags pre-provisioned users with future `employeeHireDate`; future-hire date comparisons now tolerate naive datetimes [(#11511)](https://github.com/prowler-cloud/prowler/pull/11511)
- M365 Admin Center group enumeration now follows Microsoft Graph pagination so group-scoped checks include groups beyond the first page [(#11510)](https://github.com/prowler-cloud/prowler/pull/11510)
+- GCP `kms_key_rotation_enabled` check now only verifies that automatic key rotation is enabled (any interval) instead of enforcing a 90-day period, resolving the mismatch between the check and its documentation; the CIS, Prowler ThreatScore, and CCC requirements that mandate a 90-day maximum were remapped to the new `kms_key_rotation_max_90_days` check [(#11516)](https://github.com/prowler-cloud/prowler/pull/11516)
---
diff --git a/prowler/compliance/gcp/ccc_gcp.json b/prowler/compliance/gcp/ccc_gcp.json
index df6b15bc5c..67a75841ef 100644
--- a/prowler/compliance/gcp/ccc_gcp.json
+++ b/prowler/compliance/gcp/ccc_gcp.json
@@ -889,7 +889,7 @@
}
],
"Checks": [
- "kms_key_rotation_enabled"
+ "kms_key_rotation_max_90_days"
]
},
{
diff --git a/prowler/compliance/gcp/cis_2.0_gcp.json b/prowler/compliance/gcp/cis_2.0_gcp.json
index d391bcc271..dd9eb62a96 100644
--- a/prowler/compliance/gcp/cis_2.0_gcp.json
+++ b/prowler/compliance/gcp/cis_2.0_gcp.json
@@ -150,7 +150,7 @@
"Id": "1.10",
"Description": "Google Cloud Key Management Service stores cryptographic keys in a hierarchical structure designed for useful and elegant access control management. The format for the rotation schedule depends on the client library that is used. For the gcloud command-line tool, the next rotation time must be in `ISO` or `RFC3339` format, and the rotation period must be in the form `INTEGERUNIT`, where units can be one of seconds (s), minutes (m), hours (h) or days (d).",
"Checks": [
- "kms_key_rotation_enabled"
+ "kms_key_rotation_max_90_days"
],
"Attributes": [
{
diff --git a/prowler/compliance/gcp/cis_3.0_gcp.json b/prowler/compliance/gcp/cis_3.0_gcp.json
index b8d796c35a..958a2a9bc3 100644
--- a/prowler/compliance/gcp/cis_3.0_gcp.json
+++ b/prowler/compliance/gcp/cis_3.0_gcp.json
@@ -201,7 +201,7 @@
"Id": "1.10",
"Description": "Ensure KMS Encryption Keys Are Rotated Within a Period of 90 Days",
"Checks": [
- "kms_key_rotation_enabled"
+ "kms_key_rotation_max_90_days"
],
"Attributes": [
{
diff --git a/prowler/compliance/gcp/cis_4.0_gcp.json b/prowler/compliance/gcp/cis_4.0_gcp.json
index 5cd97a87da..d7a3c5d7a2 100644
--- a/prowler/compliance/gcp/cis_4.0_gcp.json
+++ b/prowler/compliance/gcp/cis_4.0_gcp.json
@@ -201,7 +201,7 @@
"Id": "1.10",
"Description": "Ensure KMS Encryption Keys Are Rotated Within a Period of 90 Days",
"Checks": [
- "kms_key_rotation_enabled"
+ "kms_key_rotation_max_90_days"
],
"Attributes": [
{
diff --git a/prowler/compliance/gcp/prowler_threatscore_gcp.json b/prowler/compliance/gcp/prowler_threatscore_gcp.json
index 46fc776fbe..923a1acfc2 100644
--- a/prowler/compliance/gcp/prowler_threatscore_gcp.json
+++ b/prowler/compliance/gcp/prowler_threatscore_gcp.json
@@ -117,7 +117,7 @@
"Id": "1.2.4",
"Description": "Ensure KMS Encryption Keys Are Rotated Within a Period of 90 Days",
"Checks": [
- "kms_key_rotation_enabled"
+ "kms_key_rotation_max_90_days"
],
"Attributes": [
{
diff --git a/prowler/providers/gcp/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled.metadata.json b/prowler/providers/gcp/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled.metadata.json
index 5efe894b04..7312ffefeb 100644
--- a/prowler/providers/gcp/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled.metadata.json
+++ b/prowler/providers/gcp/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled.metadata.json
@@ -1,14 +1,14 @@
{
"Provider": "gcp",
"CheckID": "kms_key_rotation_enabled",
- "CheckTitle": "KMS key is rotated at least annually",
+ "CheckTitle": "KMS key has automatic rotation enabled",
"CheckType": [],
"ServiceName": "kms",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "low",
"ResourceType": "cloudkms.googleapis.com/CryptoKey",
- "Description": "Google Cloud KMS customer-managed keys have **automatic rotation** enabled or a rotation interval `365` days.\n\nThe evaluation reviews each key's rotation settings to confirm periodic creation of new key versions.",
+ "Description": "Google Cloud KMS customer-managed keys have **automatic rotation** enabled, regardless of the rotation interval.\n\nThe evaluation reviews each key's rotation settings to confirm that a rotation period is configured so new key versions are created periodically.",
"Risk": "Without timely rotation, a stolen key can decrypt an expanding volume of data, eroding **confidentiality**. Prolonged key lifetimes widen windows for misuse, impact **integrity** of protected workloads, and make emergency rollover harder, risking **availability** disruptions.",
"RelatedUrl": "",
"AdditionalURLs": [
@@ -17,13 +17,13 @@
],
"Remediation": {
"Code": {
- "CLI": "gcloud kms keys update --keyring= --location= --rotation-period=365d --next-rotation-time=",
+ "CLI": "gcloud kms keys update --keyring= --location= --rotation-period= --next-rotation-time=",
"NativeIaC": "",
- "Other": "1. In Google Cloud Console, go to Security > Key Management > Key rings\n2. Open the key ring and select the key\n3. Click Edit rotation schedule (or Set rotation schedule)\n4. Set Rotation period to 365 days or less\n5. Set Next rotation date/time\n6. Click Save",
- "Terraform": "```hcl\nresource \"google_kms_crypto_key\" \"\" {\n name = \"\"\n key_ring = \"\"\n purpose = \"ENCRYPT_DECRYPT\"\n\n rotation_period = \"31536000s\" # Critical: sets automatic rotation to 365 days (<= 365 ensures PASS)\n}\n```"
+ "Other": "1. In Google Cloud Console, go to Security > Key Management > Key rings\n2. Open the key ring and select the key\n3. Click Edit rotation schedule (or Set rotation schedule)\n4. Set a Rotation period\n5. Set Next rotation date/time\n6. Click Save",
+ "Terraform": "```hcl\nresource \"google_kms_crypto_key\" \"\" {\n name = \"\"\n key_ring = \"\"\n purpose = \"ENCRYPT_DECRYPT\"\n\n rotation_period = \"7776000s\" # Critical: enables automatic rotation (any period ensures PASS)\n}\n```"
},
"Recommendation": {
- "Text": "Enable **auto-rotation** for customer-managed keys with an interval `365` days.\n\nAdopt a **key lifecycle** policy: enforce **least privilege** on key usage, apply **separation of duties** between key admins and users, monitor key access, and rehearse emergency rotation to minimize blast radius.",
+ "Text": "Enable **auto-rotation** for customer-managed keys by configuring a rotation period.\n\nAdopt a **key lifecycle** policy: enforce **least privilege** on key usage, apply **separation of duties** between key admins and users, monitor key access, and rehearse emergency rotation to minimize blast radius.",
"Url": "https://hub.prowler.com/check/kms_key_rotation_enabled"
}
},
@@ -31,6 +31,8 @@
"encryption"
],
"DependsOn": [],
- "RelatedTo": [],
+ "RelatedTo": [
+ "kms_key_rotation_max_90_days"
+ ],
"Notes": ""
}
diff --git a/prowler/providers/gcp/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled.py b/prowler/providers/gcp/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled.py
index 577a7c9f99..ae924ca380 100644
--- a/prowler/providers/gcp/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled.py
+++ b/prowler/providers/gcp/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled.py
@@ -1,5 +1,3 @@
-import datetime
-
from prowler.lib.check.models import Check, Check_Report_GCP
from prowler.providers.gcp.services.kms.kms_client import kms_client
@@ -9,36 +7,16 @@ class kms_key_rotation_enabled(Check):
findings = []
for key in kms_client.crypto_keys:
report = Check_Report_GCP(metadata=self.metadata(), resource=key)
- now = datetime.datetime.now()
- condition_next_rotation_time = False
- if key.next_rotation_time:
- try:
- next_rotation_time = datetime.datetime.strptime(
- key.next_rotation_time, "%Y-%m-%dT%H:%M:%S.%fZ"
- )
- except ValueError:
- next_rotation_time = datetime.datetime.strptime(
- key.next_rotation_time, "%Y-%m-%dT%H:%M:%SZ"
- )
- condition_next_rotation_time = (
- abs((next_rotation_time - now).days) <= 90
- )
- condition_rotation_period = False
if key.rotation_period:
- condition_rotation_period = (
- int(key.rotation_period[:-1]) // (24 * 3600) <= 90
- )
- if condition_rotation_period and condition_next_rotation_time:
report.status = "PASS"
- report.status_extended = f"Key {key.name} is rotated every 90 days or less and the next rotation time is in less than 90 days."
+ report.status_extended = (
+ f"Key {key.name} has automatic rotation enabled."
+ )
else:
report.status = "FAIL"
- if condition_rotation_period:
- report.status_extended = f"Key {key.name} is rotated every 90 days or less but the next rotation time is in more than 90 days."
- elif condition_next_rotation_time:
- report.status_extended = f"Key {key.name} is not rotated every 90 days or less but the next rotation time is in less than 90 days."
- else:
- report.status_extended = f"Key {key.name} is not rotated every 90 days or less and the next rotation time is in more than 90 days."
+ report.status_extended = (
+ f"Key {key.name} does not have automatic rotation enabled."
+ )
findings.append(report)
return findings
diff --git a/prowler/providers/gcp/services/kms/kms_key_rotation_max_90_days/__init__.py b/prowler/providers/gcp/services/kms/kms_key_rotation_max_90_days/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/gcp/services/kms/kms_key_rotation_max_90_days/kms_key_rotation_max_90_days.metadata.json b/prowler/providers/gcp/services/kms/kms_key_rotation_max_90_days/kms_key_rotation_max_90_days.metadata.json
new file mode 100644
index 0000000000..f1597fee53
--- /dev/null
+++ b/prowler/providers/gcp/services/kms/kms_key_rotation_max_90_days/kms_key_rotation_max_90_days.metadata.json
@@ -0,0 +1,38 @@
+{
+ "Provider": "gcp",
+ "CheckID": "kms_key_rotation_max_90_days",
+ "CheckTitle": "KMS key is rotated every 90 days or less",
+ "CheckType": [],
+ "ServiceName": "kms",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "low",
+ "ResourceType": "cloudkms.googleapis.com/CryptoKey",
+ "Description": "Google Cloud KMS customer-managed keys are rotated with an interval of `90` days or less, in line with the CIS Benchmark.\n\nThe evaluation reviews each key's rotation settings to confirm that both the rotation period and the next rotation time stay within 90 days.",
+ "Risk": "Without timely rotation, a stolen key can decrypt an expanding volume of data, eroding **confidentiality**. Prolonged key lifetimes widen windows for misuse, impact **integrity** of protected workloads, and make emergency rollover harder, risking **availability** disruptions.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/gcp/CloudKMS/rotate-kms-encryption-keys.html",
+ "https://cloud.google.com/iam/docs/manage-access-service-accounts"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "gcloud kms keys update --keyring= --location= --rotation-period=90d --next-rotation-time=",
+ "NativeIaC": "",
+ "Other": "1. In Google Cloud Console, go to Security > Key Management > Key rings\n2. Open the key ring and select the key\n3. Click Edit rotation schedule (or Set rotation schedule)\n4. Set Rotation period to 90 days or less\n5. Set Next rotation date/time\n6. Click Save",
+ "Terraform": "```hcl\nresource \"google_kms_crypto_key\" \"\" {\n name = \"\"\n key_ring = \"\"\n purpose = \"ENCRYPT_DECRYPT\"\n\n rotation_period = \"7776000s\" # Critical: sets automatic rotation to 90 days (<= 90 ensures PASS)\n}\n```"
+ },
+ "Recommendation": {
+ "Text": "Enable **auto-rotation** for customer-managed keys with an interval of `90` days or less.\n\nAdopt a **key lifecycle** policy: enforce **least privilege** on key usage, apply **separation of duties** between key admins and users, monitor key access, and rehearse emergency rotation to minimize blast radius.",
+ "Url": "https://hub.prowler.com/check/kms_key_rotation_max_90_days"
+ }
+ },
+ "Categories": [
+ "encryption"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [
+ "kms_key_rotation_enabled"
+ ],
+ "Notes": ""
+}
diff --git a/prowler/providers/gcp/services/kms/kms_key_rotation_max_90_days/kms_key_rotation_max_90_days.py b/prowler/providers/gcp/services/kms/kms_key_rotation_max_90_days/kms_key_rotation_max_90_days.py
new file mode 100644
index 0000000000..cbca4f0e91
--- /dev/null
+++ b/prowler/providers/gcp/services/kms/kms_key_rotation_max_90_days/kms_key_rotation_max_90_days.py
@@ -0,0 +1,44 @@
+import datetime
+
+from prowler.lib.check.models import Check, Check_Report_GCP
+from prowler.providers.gcp.services.kms.kms_client import kms_client
+
+
+class kms_key_rotation_max_90_days(Check):
+ def execute(self) -> Check_Report_GCP:
+ findings = []
+ for key in kms_client.crypto_keys:
+ report = Check_Report_GCP(metadata=self.metadata(), resource=key)
+ now = datetime.datetime.now()
+ condition_next_rotation_time = False
+ if key.next_rotation_time:
+ try:
+ next_rotation_time = datetime.datetime.strptime(
+ key.next_rotation_time, "%Y-%m-%dT%H:%M:%S.%fZ"
+ )
+ except ValueError:
+ next_rotation_time = datetime.datetime.strptime(
+ key.next_rotation_time, "%Y-%m-%dT%H:%M:%SZ"
+ )
+ condition_next_rotation_time = (
+ abs((next_rotation_time - now).days) <= 90
+ )
+ condition_rotation_period = False
+ if key.rotation_period:
+ condition_rotation_period = (
+ int(key.rotation_period[:-1]) // (24 * 3600) <= 90
+ )
+ if condition_rotation_period and condition_next_rotation_time:
+ report.status = "PASS"
+ report.status_extended = f"Key {key.name} is rotated every 90 days or less and the next rotation time is in less than 90 days."
+ else:
+ report.status = "FAIL"
+ if condition_rotation_period:
+ report.status_extended = f"Key {key.name} is rotated every 90 days or less but the next rotation time is in more than 90 days."
+ elif condition_next_rotation_time:
+ report.status_extended = f"Key {key.name} is not rotated every 90 days or less but the next rotation time is in less than 90 days."
+ else:
+ report.status_extended = f"Key {key.name} is not rotated every 90 days or less and the next rotation time is in more than 90 days."
+ findings.append(report)
+
+ return findings
diff --git a/tests/providers/gcp/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled_test.py b/tests/providers/gcp/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled_test.py
index 5776c858f5..6600921c9f 100644
--- a/tests/providers/gcp/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled_test.py
+++ b/tests/providers/gcp/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled_test.py
@@ -1,4 +1,3 @@
-import datetime
from unittest import mock
from tests.providers.gcp.gcp_fixtures import (
@@ -34,7 +33,7 @@ class Test_kms_key_rotation_enabled:
result = check.execute()
assert len(result) == 0
- def test_kms_key_no_next_rotation_time_and_no_rotation_period(self):
+ def test_kms_key_without_rotation_period(self):
kms_client = mock.MagicMock()
with (
@@ -86,14 +85,14 @@ class Test_kms_key_rotation_enabled:
assert result[0].status == "FAIL"
assert (
result[0].status_extended
- == f"Key {kms_client.crypto_keys[0].name} is not rotated every 90 days or less and the next rotation time is in more than 90 days."
+ == f"Key {kms_client.crypto_keys[0].name} does not have automatic rotation enabled."
)
assert result[0].resource_id == kms_client.crypto_keys[0].id
assert result[0].resource_name == kms_client.crypto_keys[0].name
assert result[0].location == kms_client.crypto_keys[0].location
assert result[0].project_id == kms_client.crypto_keys[0].project_id
- def test_kms_key_no_next_rotation_time_and_big_rotation_period(self):
+ def test_kms_key_with_long_rotation_period(self):
kms_client = mock.MagicMock()
with (
@@ -135,471 +134,26 @@ class Test_kms_key_rotation_enabled:
project_id=GCP_PROJECT_ID,
key_ring=keyring.name,
location=keylocation.name,
+ # Rotation period greater than 90 days still counts as enabled
rotation_period="8776000s",
members=["user:jane@example.com"],
)
]
- check = kms_key_rotation_enabled()
- result = check.execute()
- assert len(result) == 1
- assert result[0].status == "FAIL"
- assert (
- result[0].status_extended
- == f"Key {kms_client.crypto_keys[0].name} is not rotated every 90 days or less and the next rotation time is in more than 90 days."
- )
- assert result[0].resource_id == kms_client.crypto_keys[0].id
- assert result[0].resource_name == kms_client.crypto_keys[0].name
- assert result[0].location == kms_client.crypto_keys[0].location
- assert result[0].project_id == kms_client.crypto_keys[0].project_id
-
- def test_kms_key_no_next_rotation_time_and_appropriate_rotation_period(self):
- kms_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.kms.kms_key_rotation_enabled.kms_key_rotation_enabled.kms_client",
- new=kms_client,
- ),
- ):
- from prowler.providers.gcp.services.kms.kms_key_rotation_enabled.kms_key_rotation_enabled import (
- kms_key_rotation_enabled,
- )
- from prowler.providers.gcp.services.kms.kms_service import (
- CriptoKey,
- KeyLocation,
- KeyRing,
- )
-
- kms_client.project_ids = [GCP_PROJECT_ID]
- kms_client.region = GCP_US_CENTER1_LOCATION
-
- keyring = KeyRing(
- name="projects/123/locations/us-central1/keyRings/keyring1",
- project_id=GCP_PROJECT_ID,
- )
-
- keylocation = KeyLocation(
- name=GCP_US_CENTER1_LOCATION,
- project_id=GCP_PROJECT_ID,
- )
-
- kms_client.crypto_keys = [
- CriptoKey(
- name="key1",
- id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1",
- project_id=GCP_PROJECT_ID,
- key_ring=keyring.name,
- location=keylocation.name,
- rotation_period="7776000s",
- members=["user:jane@example.com"],
- )
- ]
-
- check = kms_key_rotation_enabled()
- result = check.execute()
- assert len(result) == 1
- assert result[0].status == "FAIL"
- assert (
- result[0].status_extended
- == f"Key {kms_client.crypto_keys[0].name} is rotated every 90 days or less but the next rotation time is in more than 90 days."
- )
- assert result[0].resource_id == kms_client.crypto_keys[0].id
- assert result[0].resource_name == kms_client.crypto_keys[0].name
- assert result[0].location == kms_client.crypto_keys[0].location
- assert result[0].project_id == kms_client.crypto_keys[0].project_id
-
- def test_kms_key_no_rotation_period_and_big_next_rotation_time(self):
- kms_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.kms.kms_key_rotation_enabled.kms_key_rotation_enabled.kms_client",
- new=kms_client,
- ),
- ):
- from prowler.providers.gcp.services.kms.kms_key_rotation_enabled.kms_key_rotation_enabled import (
- kms_key_rotation_enabled,
- )
- from prowler.providers.gcp.services.kms.kms_service import (
- CriptoKey,
- KeyLocation,
- KeyRing,
- )
-
- kms_client.project_ids = [GCP_PROJECT_ID]
- kms_client.region = GCP_US_CENTER1_LOCATION
-
- keyring = KeyRing(
- name="projects/123/locations/us-central1/keyRings/keyring1",
- project_id=GCP_PROJECT_ID,
- )
-
- keylocation = KeyLocation(
- name=GCP_US_CENTER1_LOCATION,
- project_id=GCP_PROJECT_ID,
- )
-
- kms_client.crypto_keys = [
- CriptoKey(
- name="key1",
- id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1",
- project_id=GCP_PROJECT_ID,
- key_ring=keyring.name,
- location=keylocation.name,
- # Next rotation time of now + 100 days
- next_rotation_time=(
- datetime.datetime.now() - datetime.timedelta(days=+100)
- ).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
- members=["user:jane@example.com"],
- )
- ]
-
- check = kms_key_rotation_enabled()
- result = check.execute()
- assert len(result) == 1
- assert result[0].status == "FAIL"
- assert (
- result[0].status_extended
- == f"Key {kms_client.crypto_keys[0].name} is not rotated every 90 days or less and the next rotation time is in more than 90 days."
- )
- assert result[0].resource_id == kms_client.crypto_keys[0].id
- assert result[0].resource_name == kms_client.crypto_keys[0].name
- assert result[0].location == kms_client.crypto_keys[0].location
- assert result[0].project_id == kms_client.crypto_keys[0].project_id
-
- def test_kms_key_no_rotation_period_and_appropriate_next_rotation_time(self):
- kms_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.kms.kms_key_rotation_enabled.kms_key_rotation_enabled.kms_client",
- new=kms_client,
- ),
- ):
- from prowler.providers.gcp.services.kms.kms_key_rotation_enabled.kms_key_rotation_enabled import (
- kms_key_rotation_enabled,
- )
- from prowler.providers.gcp.services.kms.kms_service import (
- CriptoKey,
- KeyLocation,
- KeyRing,
- )
-
- kms_client.project_ids = [GCP_PROJECT_ID]
- kms_client.region = GCP_US_CENTER1_LOCATION
-
- keyring = KeyRing(
- name="projects/123/locations/us-central1/keyRings/keyring1",
- project_id=GCP_PROJECT_ID,
- )
-
- keylocation = KeyLocation(
- name=GCP_US_CENTER1_LOCATION,
- project_id=GCP_PROJECT_ID,
- )
-
- kms_client.crypto_keys = [
- CriptoKey(
- name="key1",
- id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1",
- project_id=GCP_PROJECT_ID,
- key_ring=keyring.name,
- location=keylocation.name,
- # Next rotation time of now + 30 days
- next_rotation_time=(
- datetime.datetime.now() - datetime.timedelta(days=+30)
- ).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
- members=["user:jane@example.com"],
- )
- ]
-
- check = kms_key_rotation_enabled()
- result = check.execute()
- assert len(result) == 1
- assert result[0].status == "FAIL"
- assert (
- result[0].status_extended
- == f"Key {kms_client.crypto_keys[0].name} is not rotated every 90 days or less but the next rotation time is in less than 90 days."
- )
- assert result[0].resource_id == kms_client.crypto_keys[0].id
- assert result[0].resource_name == kms_client.crypto_keys[0].name
- assert result[0].location == kms_client.crypto_keys[0].location
- assert result[0].project_id == kms_client.crypto_keys[0].project_id
-
- def test_kms_key_rotation_period_greater_90_days_and_big_next_rotation_time(self):
- kms_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.kms.kms_key_rotation_enabled.kms_key_rotation_enabled.kms_client",
- new=kms_client,
- ),
- ):
- from prowler.providers.gcp.services.kms.kms_key_rotation_enabled.kms_key_rotation_enabled import (
- kms_key_rotation_enabled,
- )
- from prowler.providers.gcp.services.kms.kms_service import (
- CriptoKey,
- KeyLocation,
- KeyRing,
- )
-
- kms_client.project_ids = [GCP_PROJECT_ID]
- kms_client.region = GCP_US_CENTER1_LOCATION
-
- keyring = KeyRing(
- name="projects/123/locations/us-central1/keyRings/keyring1",
- project_id=GCP_PROJECT_ID,
- )
-
- keylocation = KeyLocation(
- name=GCP_US_CENTER1_LOCATION,
- project_id=GCP_PROJECT_ID,
- )
-
- kms_client.crypto_keys = [
- CriptoKey(
- name="key1",
- id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1",
- project_id=GCP_PROJECT_ID,
- rotation_period="8776000s",
- # Next rotation time of now + 100 days
- next_rotation_time=(
- datetime.datetime.now() - datetime.timedelta(days=+100)
- ).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
- key_ring=keyring.name,
- location=keylocation.name,
- members=["user:jane@example.com"],
- )
- ]
-
- check = kms_key_rotation_enabled()
- result = check.execute()
- assert len(result) == 1
- assert result[0].status == "FAIL"
- assert (
- result[0].status_extended
- == f"Key {kms_client.crypto_keys[0].name} is not rotated every 90 days or less and the next rotation time is in more than 90 days."
- )
- assert result[0].resource_id == kms_client.crypto_keys[0].id
- assert result[0].resource_name == kms_client.crypto_keys[0].name
- assert result[0].location == kms_client.crypto_keys[0].location
- assert result[0].project_id == kms_client.crypto_keys[0].project_id
-
- def test_kms_key_rotation_period_greater_90_days_and_appropriate_next_rotation_time(
- self,
- ):
- kms_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.kms.kms_key_rotation_enabled.kms_key_rotation_enabled.kms_client",
- new=kms_client,
- ),
- ):
- from prowler.providers.gcp.services.kms.kms_key_rotation_enabled.kms_key_rotation_enabled import (
- kms_key_rotation_enabled,
- )
- from prowler.providers.gcp.services.kms.kms_service import (
- CriptoKey,
- KeyLocation,
- KeyRing,
- )
-
- kms_client.project_ids = [GCP_PROJECT_ID]
- kms_client.region = GCP_US_CENTER1_LOCATION
-
- keyring = KeyRing(
- name="projects/123/locations/us-central1/keyRings/keyring1",
- project_id=GCP_PROJECT_ID,
- )
-
- keylocation = KeyLocation(
- name=GCP_US_CENTER1_LOCATION,
- project_id=GCP_PROJECT_ID,
- )
-
- kms_client.crypto_keys = [
- CriptoKey(
- name="key1",
- id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1",
- project_id=GCP_PROJECT_ID,
- rotation_period="8776000s",
- # Next rotation time of now + 30 days
- next_rotation_time=(
- datetime.datetime.now() - datetime.timedelta(days=+30)
- ).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
- key_ring=keyring.name,
- location=keylocation.name,
- members=["user:jane@example.com"],
- )
- ]
-
- check = kms_key_rotation_enabled()
- result = check.execute()
- assert len(result) == 1
- assert result[0].status == "FAIL"
- assert (
- result[0].status_extended
- == f"Key {kms_client.crypto_keys[0].name} is not rotated every 90 days or less but the next rotation time is in less than 90 days."
- )
- assert result[0].resource_id == kms_client.crypto_keys[0].id
- assert result[0].resource_name == kms_client.crypto_keys[0].name
- assert result[0].location == kms_client.crypto_keys[0].location
- assert result[0].project_id == kms_client.crypto_keys[0].project_id
-
- def test_kms_key_rotation_period_less_90_days_and_big_next_rotation_time(self):
- kms_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.kms.kms_key_rotation_enabled.kms_key_rotation_enabled.kms_client",
- new=kms_client,
- ),
- ):
- from prowler.providers.gcp.services.kms.kms_key_rotation_enabled.kms_key_rotation_enabled import (
- kms_key_rotation_enabled,
- )
- from prowler.providers.gcp.services.kms.kms_service import (
- CriptoKey,
- KeyLocation,
- KeyRing,
- )
-
- kms_client.project_ids = [GCP_PROJECT_ID]
- kms_client.region = GCP_US_CENTER1_LOCATION
-
- keyring = KeyRing(
- name="projects/123/locations/us-central1/keyRings/keyring1",
- project_id=GCP_PROJECT_ID,
- )
-
- keylocation = KeyLocation(
- name=GCP_US_CENTER1_LOCATION,
- project_id=GCP_PROJECT_ID,
- )
-
- kms_client.crypto_keys = [
- CriptoKey(
- name="key1",
- id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1",
- project_id=GCP_PROJECT_ID,
- rotation_period="7776000s",
- # Next rotation time of now + 100 days
- next_rotation_time=(
- datetime.datetime.now() - datetime.timedelta(days=+100)
- ).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
- key_ring=keyring.name,
- location=keylocation.name,
- members=["user:jane@example.com"],
- )
- ]
-
- check = kms_key_rotation_enabled()
- result = check.execute()
- assert len(result) == 1
- assert result[0].status == "FAIL"
- assert (
- result[0].status_extended
- == f"Key {kms_client.crypto_keys[0].name} is rotated every 90 days or less but the next rotation time is in more than 90 days."
- )
- assert result[0].resource_id == kms_client.crypto_keys[0].id
- assert result[0].resource_name == kms_client.crypto_keys[0].name
- assert result[0].location == kms_client.crypto_keys[0].location
- assert result[0].project_id == kms_client.crypto_keys[0].project_id
-
- def test_kms_key_rotation_period_less_90_days_and_appropriate_next_rotation_time(
- self,
- ):
- kms_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.kms.kms_key_rotation_enabled.kms_key_rotation_enabled.kms_client",
- new=kms_client,
- ),
- ):
- from prowler.providers.gcp.services.kms.kms_key_rotation_enabled.kms_key_rotation_enabled import (
- kms_key_rotation_enabled,
- )
- from prowler.providers.gcp.services.kms.kms_service import (
- CriptoKey,
- KeyLocation,
- KeyRing,
- )
-
- kms_client.project_ids = [GCP_PROJECT_ID]
- kms_client.region = GCP_US_CENTER1_LOCATION
-
- keyring = KeyRing(
- name="projects/123/locations/us-central1/keyRings/keyring1",
- project_id=GCP_PROJECT_ID,
- )
-
- keylocation = KeyLocation(
- name=GCP_US_CENTER1_LOCATION,
- project_id=GCP_PROJECT_ID,
- )
-
- kms_client.crypto_keys = [
- CriptoKey(
- name="key1",
- id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1",
- project_id=GCP_PROJECT_ID,
- rotation_period="7776000s",
- # Next rotation time of now + 30 days
- next_rotation_time=(
- datetime.datetime.now() - datetime.timedelta(days=+30)
- ).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
- key_ring=keyring.name,
- location=keylocation.name,
- members=["user:jane@example.com"],
- )
- ]
-
check = kms_key_rotation_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
- == f"Key {kms_client.crypto_keys[0].name} is rotated every 90 days or less and the next rotation time is in less than 90 days."
+ == f"Key {kms_client.crypto_keys[0].name} has automatic rotation enabled."
)
assert result[0].resource_id == kms_client.crypto_keys[0].id
assert result[0].resource_name == kms_client.crypto_keys[0].name
assert result[0].location == kms_client.crypto_keys[0].location
assert result[0].project_id == kms_client.crypto_keys[0].project_id
- def test_kms_key_rotation_with_fractional_seconds(self):
+ def test_kms_key_with_short_rotation_period(self):
kms_client = mock.MagicMock()
with (
@@ -639,13 +193,9 @@ class Test_kms_key_rotation_enabled:
name="key1",
id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1",
project_id=GCP_PROJECT_ID,
- rotation_period="7776000s",
- # Next rotation time of now + 100 days
- next_rotation_time=(
- datetime.datetime.now() - datetime.timedelta(days=+100)
- ).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
key_ring=keyring.name,
location=keylocation.name,
+ rotation_period="7776000s",
members=["user:jane@example.com"],
)
]
@@ -653,10 +203,10 @@ class Test_kms_key_rotation_enabled:
check = kms_key_rotation_enabled()
result = check.execute()
assert len(result) == 1
- assert result[0].status == "FAIL"
+ assert result[0].status == "PASS"
assert (
result[0].status_extended
- == f"Key {kms_client.crypto_keys[0].name} is rotated every 90 days or less but the next rotation time is in more than 90 days."
+ == f"Key {kms_client.crypto_keys[0].name} has automatic rotation enabled."
)
assert result[0].resource_id == kms_client.crypto_keys[0].id
assert result[0].resource_name == kms_client.crypto_keys[0].name
diff --git a/tests/providers/gcp/services/kms/kms_key_rotation_max_90_days/kms_key_rotation_max_90_days_test.py b/tests/providers/gcp/services/kms/kms_key_rotation_max_90_days/kms_key_rotation_max_90_days_test.py
new file mode 100644
index 0000000000..e200b9674d
--- /dev/null
+++ b/tests/providers/gcp/services/kms/kms_key_rotation_max_90_days/kms_key_rotation_max_90_days_test.py
@@ -0,0 +1,728 @@
+import datetime
+from unittest import mock
+
+from tests.providers.gcp.gcp_fixtures import (
+ GCP_PROJECT_ID,
+ GCP_US_CENTER1_LOCATION,
+ set_mocked_gcp_provider,
+)
+
+
+class Test_kms_key_rotation_max_90_days:
+ def test_kms_no_key(self):
+ kms_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.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days.kms_client",
+ new=kms_client,
+ ),
+ ):
+ from prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days import (
+ kms_key_rotation_max_90_days,
+ )
+
+ kms_client.project_ids = [GCP_PROJECT_ID]
+ kms_client.region = GCP_US_CENTER1_LOCATION
+ kms_client.crypto_keys = []
+
+ check = kms_key_rotation_max_90_days()
+ result = check.execute()
+ assert len(result) == 0
+
+ def test_kms_key_no_next_rotation_time_and_no_rotation_period(self):
+ kms_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.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days.kms_client",
+ new=kms_client,
+ ),
+ ):
+ from prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days import (
+ kms_key_rotation_max_90_days,
+ )
+ from prowler.providers.gcp.services.kms.kms_service import (
+ CriptoKey,
+ KeyLocation,
+ KeyRing,
+ )
+
+ kms_client.project_ids = [GCP_PROJECT_ID]
+ kms_client.region = GCP_US_CENTER1_LOCATION
+
+ keyring = KeyRing(
+ name="projects/123/locations/us-central1/keyRings/keyring1",
+ project_id=GCP_PROJECT_ID,
+ )
+
+ keylocation = KeyLocation(
+ name=GCP_US_CENTER1_LOCATION,
+ project_id=GCP_PROJECT_ID,
+ )
+
+ kms_client.crypto_keys = [
+ CriptoKey(
+ name="key1",
+ id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1",
+ project_id=GCP_PROJECT_ID,
+ key_ring=keyring.name,
+ location=keylocation.name,
+ members=["user:jane@example.com"],
+ )
+ ]
+
+ check = kms_key_rotation_max_90_days()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == f"Key {kms_client.crypto_keys[0].name} is not rotated every 90 days or less and the next rotation time is in more than 90 days."
+ )
+ assert result[0].resource_id == kms_client.crypto_keys[0].id
+ assert result[0].resource_name == kms_client.crypto_keys[0].name
+ assert result[0].location == kms_client.crypto_keys[0].location
+ assert result[0].project_id == kms_client.crypto_keys[0].project_id
+
+ def test_kms_key_no_next_rotation_time_and_big_rotation_period(self):
+ kms_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.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days.kms_client",
+ new=kms_client,
+ ),
+ ):
+ from prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days import (
+ kms_key_rotation_max_90_days,
+ )
+ from prowler.providers.gcp.services.kms.kms_service import (
+ CriptoKey,
+ KeyLocation,
+ KeyRing,
+ )
+
+ kms_client.project_ids = [GCP_PROJECT_ID]
+ kms_client.region = GCP_US_CENTER1_LOCATION
+
+ keyring = KeyRing(
+ name="projects/123/locations/us-central1/keyRings/keyring1",
+ project_id=GCP_PROJECT_ID,
+ )
+
+ keylocation = KeyLocation(
+ name=GCP_US_CENTER1_LOCATION,
+ project_id=GCP_PROJECT_ID,
+ )
+
+ kms_client.crypto_keys = [
+ CriptoKey(
+ name="key1",
+ id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1",
+ project_id=GCP_PROJECT_ID,
+ key_ring=keyring.name,
+ location=keylocation.name,
+ rotation_period="8776000s",
+ members=["user:jane@example.com"],
+ )
+ ]
+
+ check = kms_key_rotation_max_90_days()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == f"Key {kms_client.crypto_keys[0].name} is not rotated every 90 days or less and the next rotation time is in more than 90 days."
+ )
+ assert result[0].resource_id == kms_client.crypto_keys[0].id
+ assert result[0].resource_name == kms_client.crypto_keys[0].name
+ assert result[0].location == kms_client.crypto_keys[0].location
+ assert result[0].project_id == kms_client.crypto_keys[0].project_id
+
+ def test_kms_key_no_next_rotation_time_and_appropriate_rotation_period(self):
+ kms_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.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days.kms_client",
+ new=kms_client,
+ ),
+ ):
+ from prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days import (
+ kms_key_rotation_max_90_days,
+ )
+ from prowler.providers.gcp.services.kms.kms_service import (
+ CriptoKey,
+ KeyLocation,
+ KeyRing,
+ )
+
+ kms_client.project_ids = [GCP_PROJECT_ID]
+ kms_client.region = GCP_US_CENTER1_LOCATION
+
+ keyring = KeyRing(
+ name="projects/123/locations/us-central1/keyRings/keyring1",
+ project_id=GCP_PROJECT_ID,
+ )
+
+ keylocation = KeyLocation(
+ name=GCP_US_CENTER1_LOCATION,
+ project_id=GCP_PROJECT_ID,
+ )
+
+ kms_client.crypto_keys = [
+ CriptoKey(
+ name="key1",
+ id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1",
+ project_id=GCP_PROJECT_ID,
+ key_ring=keyring.name,
+ location=keylocation.name,
+ rotation_period="7776000s",
+ members=["user:jane@example.com"],
+ )
+ ]
+
+ check = kms_key_rotation_max_90_days()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == f"Key {kms_client.crypto_keys[0].name} is rotated every 90 days or less but the next rotation time is in more than 90 days."
+ )
+ assert result[0].resource_id == kms_client.crypto_keys[0].id
+ assert result[0].resource_name == kms_client.crypto_keys[0].name
+ assert result[0].location == kms_client.crypto_keys[0].location
+ assert result[0].project_id == kms_client.crypto_keys[0].project_id
+
+ def test_kms_key_no_rotation_period_and_big_next_rotation_time(self):
+ kms_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.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days.kms_client",
+ new=kms_client,
+ ),
+ ):
+ from prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days import (
+ kms_key_rotation_max_90_days,
+ )
+ from prowler.providers.gcp.services.kms.kms_service import (
+ CriptoKey,
+ KeyLocation,
+ KeyRing,
+ )
+
+ kms_client.project_ids = [GCP_PROJECT_ID]
+ kms_client.region = GCP_US_CENTER1_LOCATION
+
+ keyring = KeyRing(
+ name="projects/123/locations/us-central1/keyRings/keyring1",
+ project_id=GCP_PROJECT_ID,
+ )
+
+ keylocation = KeyLocation(
+ name=GCP_US_CENTER1_LOCATION,
+ project_id=GCP_PROJECT_ID,
+ )
+
+ kms_client.crypto_keys = [
+ CriptoKey(
+ name="key1",
+ id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1",
+ project_id=GCP_PROJECT_ID,
+ key_ring=keyring.name,
+ location=keylocation.name,
+ # Next rotation time of now + 100 days
+ next_rotation_time=(
+ datetime.datetime.now() - datetime.timedelta(days=+100)
+ ).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
+ members=["user:jane@example.com"],
+ )
+ ]
+
+ check = kms_key_rotation_max_90_days()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == f"Key {kms_client.crypto_keys[0].name} is not rotated every 90 days or less and the next rotation time is in more than 90 days."
+ )
+ assert result[0].resource_id == kms_client.crypto_keys[0].id
+ assert result[0].resource_name == kms_client.crypto_keys[0].name
+ assert result[0].location == kms_client.crypto_keys[0].location
+ assert result[0].project_id == kms_client.crypto_keys[0].project_id
+
+ def test_kms_key_no_rotation_period_and_appropriate_next_rotation_time(self):
+ kms_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.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days.kms_client",
+ new=kms_client,
+ ),
+ ):
+ from prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days import (
+ kms_key_rotation_max_90_days,
+ )
+ from prowler.providers.gcp.services.kms.kms_service import (
+ CriptoKey,
+ KeyLocation,
+ KeyRing,
+ )
+
+ kms_client.project_ids = [GCP_PROJECT_ID]
+ kms_client.region = GCP_US_CENTER1_LOCATION
+
+ keyring = KeyRing(
+ name="projects/123/locations/us-central1/keyRings/keyring1",
+ project_id=GCP_PROJECT_ID,
+ )
+
+ keylocation = KeyLocation(
+ name=GCP_US_CENTER1_LOCATION,
+ project_id=GCP_PROJECT_ID,
+ )
+
+ kms_client.crypto_keys = [
+ CriptoKey(
+ name="key1",
+ id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1",
+ project_id=GCP_PROJECT_ID,
+ key_ring=keyring.name,
+ location=keylocation.name,
+ # Next rotation time of now + 30 days
+ next_rotation_time=(
+ datetime.datetime.now() - datetime.timedelta(days=+30)
+ ).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
+ members=["user:jane@example.com"],
+ )
+ ]
+
+ check = kms_key_rotation_max_90_days()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == f"Key {kms_client.crypto_keys[0].name} is not rotated every 90 days or less but the next rotation time is in less than 90 days."
+ )
+ assert result[0].resource_id == kms_client.crypto_keys[0].id
+ assert result[0].resource_name == kms_client.crypto_keys[0].name
+ assert result[0].location == kms_client.crypto_keys[0].location
+ assert result[0].project_id == kms_client.crypto_keys[0].project_id
+
+ def test_kms_key_rotation_period_greater_90_days_and_big_next_rotation_time(self):
+ kms_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.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days.kms_client",
+ new=kms_client,
+ ),
+ ):
+ from prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days import (
+ kms_key_rotation_max_90_days,
+ )
+ from prowler.providers.gcp.services.kms.kms_service import (
+ CriptoKey,
+ KeyLocation,
+ KeyRing,
+ )
+
+ kms_client.project_ids = [GCP_PROJECT_ID]
+ kms_client.region = GCP_US_CENTER1_LOCATION
+
+ keyring = KeyRing(
+ name="projects/123/locations/us-central1/keyRings/keyring1",
+ project_id=GCP_PROJECT_ID,
+ )
+
+ keylocation = KeyLocation(
+ name=GCP_US_CENTER1_LOCATION,
+ project_id=GCP_PROJECT_ID,
+ )
+
+ kms_client.crypto_keys = [
+ CriptoKey(
+ name="key1",
+ id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1",
+ project_id=GCP_PROJECT_ID,
+ rotation_period="8776000s",
+ # Next rotation time of now + 100 days
+ next_rotation_time=(
+ datetime.datetime.now() - datetime.timedelta(days=+100)
+ ).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
+ key_ring=keyring.name,
+ location=keylocation.name,
+ members=["user:jane@example.com"],
+ )
+ ]
+
+ check = kms_key_rotation_max_90_days()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == f"Key {kms_client.crypto_keys[0].name} is not rotated every 90 days or less and the next rotation time is in more than 90 days."
+ )
+ assert result[0].resource_id == kms_client.crypto_keys[0].id
+ assert result[0].resource_name == kms_client.crypto_keys[0].name
+ assert result[0].location == kms_client.crypto_keys[0].location
+ assert result[0].project_id == kms_client.crypto_keys[0].project_id
+
+ def test_kms_key_rotation_period_greater_90_days_and_appropriate_next_rotation_time(
+ self,
+ ):
+ kms_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.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days.kms_client",
+ new=kms_client,
+ ),
+ ):
+ from prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days import (
+ kms_key_rotation_max_90_days,
+ )
+ from prowler.providers.gcp.services.kms.kms_service import (
+ CriptoKey,
+ KeyLocation,
+ KeyRing,
+ )
+
+ kms_client.project_ids = [GCP_PROJECT_ID]
+ kms_client.region = GCP_US_CENTER1_LOCATION
+
+ keyring = KeyRing(
+ name="projects/123/locations/us-central1/keyRings/keyring1",
+ project_id=GCP_PROJECT_ID,
+ )
+
+ keylocation = KeyLocation(
+ name=GCP_US_CENTER1_LOCATION,
+ project_id=GCP_PROJECT_ID,
+ )
+
+ kms_client.crypto_keys = [
+ CriptoKey(
+ name="key1",
+ id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1",
+ project_id=GCP_PROJECT_ID,
+ rotation_period="8776000s",
+ # Next rotation time of now + 30 days
+ next_rotation_time=(
+ datetime.datetime.now() - datetime.timedelta(days=+30)
+ ).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
+ key_ring=keyring.name,
+ location=keylocation.name,
+ members=["user:jane@example.com"],
+ )
+ ]
+
+ check = kms_key_rotation_max_90_days()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == f"Key {kms_client.crypto_keys[0].name} is not rotated every 90 days or less but the next rotation time is in less than 90 days."
+ )
+ assert result[0].resource_id == kms_client.crypto_keys[0].id
+ assert result[0].resource_name == kms_client.crypto_keys[0].name
+ assert result[0].location == kms_client.crypto_keys[0].location
+ assert result[0].project_id == kms_client.crypto_keys[0].project_id
+
+ def test_kms_key_rotation_period_less_90_days_and_big_next_rotation_time(self):
+ kms_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.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days.kms_client",
+ new=kms_client,
+ ),
+ ):
+ from prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days import (
+ kms_key_rotation_max_90_days,
+ )
+ from prowler.providers.gcp.services.kms.kms_service import (
+ CriptoKey,
+ KeyLocation,
+ KeyRing,
+ )
+
+ kms_client.project_ids = [GCP_PROJECT_ID]
+ kms_client.region = GCP_US_CENTER1_LOCATION
+
+ keyring = KeyRing(
+ name="projects/123/locations/us-central1/keyRings/keyring1",
+ project_id=GCP_PROJECT_ID,
+ )
+
+ keylocation = KeyLocation(
+ name=GCP_US_CENTER1_LOCATION,
+ project_id=GCP_PROJECT_ID,
+ )
+
+ kms_client.crypto_keys = [
+ CriptoKey(
+ name="key1",
+ id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1",
+ project_id=GCP_PROJECT_ID,
+ rotation_period="7776000s",
+ # Next rotation time of now + 100 days
+ next_rotation_time=(
+ datetime.datetime.now() - datetime.timedelta(days=+100)
+ ).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
+ key_ring=keyring.name,
+ location=keylocation.name,
+ members=["user:jane@example.com"],
+ )
+ ]
+
+ check = kms_key_rotation_max_90_days()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == f"Key {kms_client.crypto_keys[0].name} is rotated every 90 days or less but the next rotation time is in more than 90 days."
+ )
+ assert result[0].resource_id == kms_client.crypto_keys[0].id
+ assert result[0].resource_name == kms_client.crypto_keys[0].name
+ assert result[0].location == kms_client.crypto_keys[0].location
+ assert result[0].project_id == kms_client.crypto_keys[0].project_id
+
+ def test_kms_key_rotation_period_less_90_days_and_appropriate_next_rotation_time(
+ self,
+ ):
+ kms_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.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days.kms_client",
+ new=kms_client,
+ ),
+ ):
+ from prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days import (
+ kms_key_rotation_max_90_days,
+ )
+ from prowler.providers.gcp.services.kms.kms_service import (
+ CriptoKey,
+ KeyLocation,
+ KeyRing,
+ )
+
+ kms_client.project_ids = [GCP_PROJECT_ID]
+ kms_client.region = GCP_US_CENTER1_LOCATION
+
+ keyring = KeyRing(
+ name="projects/123/locations/us-central1/keyRings/keyring1",
+ project_id=GCP_PROJECT_ID,
+ )
+
+ keylocation = KeyLocation(
+ name=GCP_US_CENTER1_LOCATION,
+ project_id=GCP_PROJECT_ID,
+ )
+
+ kms_client.crypto_keys = [
+ CriptoKey(
+ name="key1",
+ id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1",
+ project_id=GCP_PROJECT_ID,
+ rotation_period="7776000s",
+ # Next rotation time of now + 30 days
+ next_rotation_time=(
+ datetime.datetime.now() - datetime.timedelta(days=+30)
+ ).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
+ key_ring=keyring.name,
+ location=keylocation.name,
+ members=["user:jane@example.com"],
+ )
+ ]
+
+ check = kms_key_rotation_max_90_days()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert (
+ result[0].status_extended
+ == f"Key {kms_client.crypto_keys[0].name} is rotated every 90 days or less and the next rotation time is in less than 90 days."
+ )
+ assert result[0].resource_id == kms_client.crypto_keys[0].id
+ assert result[0].resource_name == kms_client.crypto_keys[0].name
+ assert result[0].location == kms_client.crypto_keys[0].location
+ assert result[0].project_id == kms_client.crypto_keys[0].project_id
+
+ def test_kms_key_rotation_with_fractional_seconds(self):
+ kms_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.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days.kms_client",
+ new=kms_client,
+ ),
+ ):
+ from prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days import (
+ kms_key_rotation_max_90_days,
+ )
+ from prowler.providers.gcp.services.kms.kms_service import (
+ CriptoKey,
+ KeyLocation,
+ KeyRing,
+ )
+
+ kms_client.project_ids = [GCP_PROJECT_ID]
+ kms_client.region = GCP_US_CENTER1_LOCATION
+
+ keyring = KeyRing(
+ name="projects/123/locations/us-central1/keyRings/keyring1",
+ project_id=GCP_PROJECT_ID,
+ )
+
+ keylocation = KeyLocation(
+ name=GCP_US_CENTER1_LOCATION,
+ project_id=GCP_PROJECT_ID,
+ )
+
+ kms_client.crypto_keys = [
+ CriptoKey(
+ name="key1",
+ id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1",
+ project_id=GCP_PROJECT_ID,
+ rotation_period="7776000s",
+ # Next rotation time of now + 100 days
+ next_rotation_time=(
+ datetime.datetime.now() - datetime.timedelta(days=+100)
+ ).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
+ key_ring=keyring.name,
+ location=keylocation.name,
+ members=["user:jane@example.com"],
+ )
+ ]
+
+ check = kms_key_rotation_max_90_days()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == f"Key {kms_client.crypto_keys[0].name} is rotated every 90 days or less but the next rotation time is in more than 90 days."
+ )
+ assert result[0].resource_id == kms_client.crypto_keys[0].id
+ assert result[0].resource_name == kms_client.crypto_keys[0].name
+ assert result[0].location == kms_client.crypto_keys[0].location
+ assert result[0].project_id == kms_client.crypto_keys[0].project_id
+
+ def test_kms_key_next_rotation_time_without_fractional_seconds(self):
+ kms_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.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days.kms_client",
+ new=kms_client,
+ ),
+ ):
+ from prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days import (
+ kms_key_rotation_max_90_days,
+ )
+ from prowler.providers.gcp.services.kms.kms_service import (
+ CriptoKey,
+ KeyLocation,
+ KeyRing,
+ )
+
+ kms_client.project_ids = [GCP_PROJECT_ID]
+ kms_client.region = GCP_US_CENTER1_LOCATION
+
+ keyring = KeyRing(
+ name="projects/123/locations/us-central1/keyRings/keyring1",
+ project_id=GCP_PROJECT_ID,
+ )
+
+ keylocation = KeyLocation(
+ name=GCP_US_CENTER1_LOCATION,
+ project_id=GCP_PROJECT_ID,
+ )
+
+ kms_client.crypto_keys = [
+ CriptoKey(
+ name="key1",
+ id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1",
+ project_id=GCP_PROJECT_ID,
+ rotation_period="7776000s",
+ # Next rotation time without fractional seconds, within 90 days
+ next_rotation_time=(
+ datetime.datetime.now() - datetime.timedelta(days=30)
+ ).strftime("%Y-%m-%dT%H:%M:%SZ"),
+ key_ring=keyring.name,
+ location=keylocation.name,
+ members=["user:jane@example.com"],
+ )
+ ]
+
+ check = kms_key_rotation_max_90_days()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert (
+ result[0].status_extended
+ == f"Key {kms_client.crypto_keys[0].name} is rotated every 90 days or less and the next rotation time is in less than 90 days."
+ )
+ assert result[0].resource_id == kms_client.crypto_keys[0].id
+ assert result[0].resource_name == kms_client.crypto_keys[0].name
+ assert result[0].location == kms_client.crypto_keys[0].location
+ assert result[0].project_id == kms_client.crypto_keys[0].project_id
From a21cb64a945692eddca7c573d50bea653ad27357 Mon Sep 17 00:00:00 2001
From: Alan Buscaglia
Date: Wed, 10 Jun 2026 10:34:50 +0200
Subject: [PATCH 036/129] fix(ui): extend integration poll timeouts to 60s
(#11519)
---
ui/CHANGELOG.md | 1 +
ui/actions/integrations/integrations.ts | 2 +-
ui/actions/integrations/jira-dispatch.ts | 2 +-
3 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md
index 49e6ddf9f0..a3d12493d6 100644
--- a/ui/CHANGELOG.md
+++ b/ui/CHANGELOG.md
@@ -54,6 +54,7 @@ All notable changes to the **Prowler UI** are documented in this file.
- Compliance page now loads the most recent scan when opened from the sidebar instead of showing the "no compliance data available" alert [(#11374)](https://github.com/prowler-cloud/prowler/pull/11374)
- Invitation links now show specific expired, no-longer-valid, and invalid-token messages based on API error responses [(#11376)](https://github.com/prowler-cloud/prowler/pull/11376)
+- Jira dispatch and provider connection-test polling no longer show a false timeout for longer-running tasks; both poll windows now extend to 60 seconds [(#11519)](https://github.com/prowler-cloud/prowler/pull/11519)
### 🔐 Security
diff --git a/ui/actions/integrations/integrations.ts b/ui/actions/integrations/integrations.ts
index 40084a0da1..8945842fe4 100644
--- a/ui/actions/integrations/integrations.ts
+++ b/ui/actions/integrations/integrations.ts
@@ -286,7 +286,7 @@ const pollTaskUntilComplete = async (
taskId: string,
): Promise => {
const settled = await pollTaskUntilSettled(taskId, {
- maxAttempts: 10,
+ maxAttempts: 20,
delayMs: 3000,
});
diff --git a/ui/actions/integrations/jira-dispatch.ts b/ui/actions/integrations/jira-dispatch.ts
index 72bfc1176d..9e3b25679f 100644
--- a/ui/actions/integrations/jira-dispatch.ts
+++ b/ui/actions/integrations/jira-dispatch.ts
@@ -148,7 +148,7 @@ export const pollJiraDispatchTask = async (
{ success: true; message: string } | { success: false; error: string }
> => {
const res = await pollTaskUntilSettled(taskId, {
- maxAttempts: 10,
+ maxAttempts: 30,
delayMs: 2000,
});
if (!res.ok) {
From 4a5a49b5bb0c57af8bf6ceec9bdf40fa56d7ee59 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pedro=20Mart=C3=ADn?=
Date: Wed, 10 Jun 2026 10:55:31 +0200
Subject: [PATCH 037/129] fix(api): store and refresh Resource.name on every
scan (#11476)
Co-authored-by: Josema Camacho
---
api/CHANGELOG.md | 1 +
api/src/backend/tasks/jobs/scan.py | 9 +++
api/src/backend/tasks/tests/test_scan.py | 73 ++++++++++++++++++++++++
3 files changed, 83 insertions(+)
diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md
index dc404079ec..ab40743b36 100644
--- a/api/CHANGELOG.md
+++ b/api/CHANGELOG.md
@@ -17,6 +17,7 @@ All notable changes to the **Prowler API** are documented in this file.
### 🐞 Fixed
- Workers now shut down gracefully on deploy or restart, finishing or re-queueing in-flight tasks instead of being force-killed and leaving them stuck [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416)
+- Resource `name` is now stored and refreshed on every scan, so resources no longer keep an empty name [(#11476)](https://github.com/prowler-cloud/prowler/pull/11476)
### 🔐 Security
diff --git a/api/src/backend/tasks/jobs/scan.py b/api/src/backend/tasks/jobs/scan.py
index db5018db90..ded3ee57dc 100644
--- a/api/src/backend/tasks/jobs/scan.py
+++ b/api/src/backend/tasks/jobs/scan.py
@@ -269,6 +269,7 @@ def _store_resources(
provider=provider_instance,
uid=finding.resource_uid,
defaults={
+ "name": finding.resource_name,
"region": finding.region,
"service": finding.service_name,
"type": finding.resource_type,
@@ -276,6 +277,7 @@ def _store_resources(
)
if not created:
+ resource_instance.name = finding.resource_name
resource_instance.region = finding.region
resource_instance.service = finding.service_name
resource_instance.type = finding.resource_type
@@ -704,6 +706,12 @@ def _process_finding_micro_batch(
if finding.region and resource_instance.region != finding.region:
resource_instance.region = finding.region
updated = True
+ if (
+ finding.resource_name
+ and resource_instance.name != finding.resource_name
+ ):
+ resource_instance.name = finding.resource_name
+ updated = True
if resource_instance.service != finding.service_name:
resource_instance.service = finding.service_name
updated = True
@@ -945,6 +953,7 @@ def _process_finding_micro_batch(
Resource.objects.bulk_update(
resources_to_bulk_update,
[
+ "name",
"metadata",
"details",
"partition",
diff --git a/api/src/backend/tasks/tests/test_scan.py b/api/src/backend/tasks/tests/test_scan.py
index 8d3d0be93a..6961659acf 100644
--- a/api/src/backend/tasks/tests/test_scan.py
+++ b/api/src/backend/tasks/tests/test_scan.py
@@ -315,6 +315,7 @@ class TestPerformScan:
provider=provider_instance,
uid=finding.resource_uid,
defaults={
+ "name": finding.resource_name,
"region": finding.region,
"service": finding.service_name,
"type": finding.resource_type,
@@ -348,6 +349,7 @@ class TestPerformScan:
resource_instance = MagicMock()
resource_instance.uid = finding.resource_uid
+ resource_instance.name = "old_name"
resource_instance.region = "us-west-1"
resource_instance.service = "old_service"
resource_instance.type = "old_type"
@@ -366,6 +368,7 @@ class TestPerformScan:
provider=provider_instance,
uid=finding.resource_uid,
defaults={
+ "name": finding.resource_name,
"region": finding.region,
"service": finding.service_name,
"type": finding.resource_type,
@@ -373,6 +376,7 @@ class TestPerformScan:
)
# Check that resource fields were updated
+ assert resource_instance.name == finding.resource_name
assert resource_instance.region == finding.region
assert resource_instance.service == finding.service_name
assert resource_instance.type == finding.resource_type
@@ -1565,6 +1569,75 @@ class TestProcessFindingMicroBatch:
assert resource_cache[finding.resource_uid].service == finding.service_name
assert tag_cache.keys() == {("team", "devsec")}
+ def test_process_finding_micro_batch_refreshes_empty_resource_name(
+ self, tenants_fixture, scans_fixture
+ ):
+ tenant = tenants_fixture[0]
+ scan = scans_fixture[0]
+ provider = scan.provider
+
+ # Old resource stored before names were persisted: empty name.
+ existing_resource = Resource.objects.create(
+ tenant_id=tenant.id,
+ provider=provider,
+ uid="arn:aws:s3:::my-bucket",
+ name="",
+ region="us-east-1",
+ service="s3",
+ type="bucket",
+ )
+
+ finding = FakeFinding(
+ uid="finding-empty-name",
+ status=StatusChoices.PASS,
+ status_extended="passing",
+ severity=Severity.low,
+ check_id="s3_bucket_public_access",
+ resource_uid=existing_resource.uid,
+ resource_name="my-bucket",
+ region="us-east-1",
+ service_name="s3",
+ resource_type="bucket",
+ partition="aws",
+ raw={"status": "PASS"},
+ metadata={"source": "prowler"},
+ )
+
+ resource_cache = {existing_resource.uid: existing_resource}
+ tag_cache = {}
+ last_status_cache = {}
+ resource_failed_findings_cache = {existing_resource.uid: 0}
+ unique_resources: set[tuple[str, str]] = set()
+ scan_resource_cache: set[tuple[str, str, str, str]] = set()
+ mute_rules_cache = {}
+ scan_categories_cache: dict[tuple[str, str], dict[str, int]] = {}
+ scan_resource_groups_cache: dict[tuple[str, str], dict[str, int]] = {}
+ group_resources_cache: dict[str, set] = {}
+
+ with (
+ patch("tasks.jobs.scan.rls_transaction", new=noop_rls_transaction),
+ patch("api.db_utils.rls_transaction", new=noop_rls_transaction),
+ ):
+ _process_finding_micro_batch(
+ str(tenant.id),
+ [finding],
+ scan,
+ provider,
+ resource_cache,
+ tag_cache,
+ last_status_cache,
+ resource_failed_findings_cache,
+ unique_resources,
+ scan_resource_cache,
+ mute_rules_cache,
+ scan_categories_cache,
+ scan_resource_groups_cache,
+ group_resources_cache,
+ )
+
+ existing_resource.refresh_from_db()
+ assert existing_resource.name == finding.resource_name
+
def test_process_finding_micro_batch_skips_long_uid(
self, tenants_fixture, scans_fixture
):
From 01b49f07430c2a1e4b9b82dca2e52d2e173428eb Mon Sep 17 00:00:00 2001
From: StylusFrost <43682773+StylusFrost@users.noreply.github.com>
Date: Wed, 10 Jun 2026 11:16:39 +0200
Subject: [PATCH 038/129] feat(dashboard): render dynamic-provider compliance
frameworks (#11503)
Co-authored-by: pedrooot
---
dashboard/common_methods.py | 180 ++++++++++++++++
dashboard/compliance/generic.py | 44 ++++
dashboard/lib/layouts.py | 2 +-
dashboard/pages/compliance.py | 88 +++++---
dashboard/pages/overview.py | 2 +-
tests/dashboard/__init__.py | 0
tests/dashboard/common_methods_test.py | 81 +++++++
tests/dashboard/compliance/__init__.py | 0
tests/dashboard/compliance/generic_test.py | 204 ++++++++++++++++++
tests/dashboard/pages/__init__.py | 0
.../pages/compliance_dispatch_test.py | 179 +++++++++++++++
tests/dashboard/pages/conftest.py | 7 +
tests/dashboard/pages/scope_columns_test.py | 60 ++++++
13 files changed, 814 insertions(+), 33 deletions(-)
create mode 100644 dashboard/compliance/generic.py
create mode 100644 tests/dashboard/__init__.py
create mode 100644 tests/dashboard/common_methods_test.py
create mode 100644 tests/dashboard/compliance/__init__.py
create mode 100644 tests/dashboard/compliance/generic_test.py
create mode 100644 tests/dashboard/pages/__init__.py
create mode 100644 tests/dashboard/pages/compliance_dispatch_test.py
create mode 100644 tests/dashboard/pages/conftest.py
create mode 100644 tests/dashboard/pages/scope_columns_test.py
diff --git a/dashboard/common_methods.py b/dashboard/common_methods.py
index a2f9ffe89b..b9f59513a5 100644
--- a/dashboard/common_methods.py
+++ b/dashboard/common_methods.py
@@ -1538,6 +1538,186 @@ def get_section_container_iso(data, section_1, section_2):
return html.Div(section_containers, className="compliance-data-layout")
+def _status_bar(success, failed, classname):
+ """Build the stacked PASS/FAIL bar shown next to an accordion title."""
+ fig = go.Figure(
+ data=[
+ go.Bar(
+ name="Failed",
+ x=[failed],
+ y=[""],
+ orientation="h",
+ marker=dict(color="#e77676"),
+ width=[0.8],
+ ),
+ go.Bar(
+ name="Success",
+ x=[success],
+ y=[""],
+ orientation="h",
+ marker=dict(color="#45cc6e"),
+ width=[0.8],
+ ),
+ ]
+ )
+ fig.update_layout(
+ barmode="stack",
+ margin=dict(l=10, r=10, t=10, b=10),
+ paper_bgcolor="rgba(0,0,0,0)",
+ plot_bgcolor="rgba(0,0,0,0)",
+ showlegend=False,
+ width=350,
+ height=30,
+ xaxis=dict(showticklabels=False, showgrid=False, zeroline=False),
+ yaxis=dict(showticklabels=False, showgrid=False, zeroline=False),
+ annotations=[
+ dict(
+ x=success + failed,
+ y=0,
+ xref="x",
+ yref="y",
+ text=str(success),
+ showarrow=False,
+ font=dict(color="#45cc6e", size=14),
+ xanchor="left",
+ yanchor="middle",
+ ),
+ dict(
+ x=0,
+ y=0,
+ xref="x",
+ yref="y",
+ text=str(failed),
+ showarrow=False,
+ font=dict(color="#e77676", size=14),
+ xanchor="right",
+ yanchor="middle",
+ ),
+ ],
+ )
+ fig.add_annotation(
+ x=failed,
+ y=0.3,
+ text="|",
+ showarrow=False,
+ xanchor="center",
+ yanchor="middle",
+ font=dict(size=20),
+ )
+ return dcc.Graph(figure=fig, config={"staticPlot": True}, className=classname)
+
+
+def get_section_containers_generic(data, section_col, id_col):
+ """Two-level view: section -> requirement id (+ description) -> checks.
+
+ Sorts lexicographically so arbitrary requirement IDs never crash the
+ version-aware sort used by the CIS renderer.
+ """
+ data["STATUS"] = data["STATUS"].apply(map_status_to_icon)
+ data[section_col] = data[section_col].astype(str)
+ data[id_col] = data[id_col].astype(str)
+ data.sort_values(by=[section_col, id_col], inplace=True)
+
+ counts_section = data.groupby([section_col, "STATUS"]).size().unstack(fill_value=0)
+ counts_id = (
+ data.groupby([section_col, id_col, "STATUS"]).size().unstack(fill_value=0)
+ )
+
+ def count(counts, key, emoji):
+ return counts.loc[key, emoji] if emoji in counts.columns else 0
+
+ has_description = "REQUIREMENTS_DESCRIPTION" in data.columns
+ table_cols = ["CHECKID", "STATUS", "REGION", "ACCOUNTID", "RESOURCEID"]
+
+ section_containers = []
+ for section in data[section_col].unique():
+ graph_div = html.Div(
+ _status_bar(
+ count(counts_section, section, pass_emoji),
+ count(counts_section, section, fail_emoji),
+ "info-bar",
+ ),
+ className="graph-section",
+ )
+
+ internal_items = []
+ for req_id in data[data[section_col] == section][id_col].unique():
+ specific_data = data[
+ (data[section_col] == section) & (data[id_col] == req_id)
+ ]
+ data_table = dash_table.DataTable(
+ data=specific_data.to_dict("records"),
+ columns=[
+ {"name": i, "id": i}
+ for i in table_cols
+ if i in specific_data.columns
+ ],
+ style_table={"overflowX": "auto"},
+ style_as_list_view=True,
+ style_cell={"textAlign": "left", "padding": "5px"},
+ )
+ graph_div_req = html.Div(
+ _status_bar(
+ count(counts_id, (section, req_id), pass_emoji),
+ count(counts_id, (section, req_id), fail_emoji),
+ "info-bar-child",
+ ),
+ className="graph-section-req",
+ )
+
+ title = req_id
+ if has_description:
+ title = (
+ f"{req_id} - {specific_data['REQUIREMENTS_DESCRIPTION'].iloc[0]}"
+ )
+ if len(title) > 130:
+ title = title[:130] + " ..."
+
+ internal_items.append(
+ html.Div(
+ [
+ graph_div_req,
+ dbc.Accordion(
+ [
+ dbc.AccordionItem(
+ title=title,
+ children=[
+ html.Div(
+ [data_table],
+ className="inner-accordion-content",
+ )
+ ],
+ )
+ ],
+ start_collapsed=True,
+ flush=True,
+ ),
+ ],
+ className="accordion-inner--child",
+ )
+ )
+
+ section_containers.append(
+ html.Div(
+ [
+ graph_div,
+ dbc.Accordion(
+ [
+ dbc.AccordionItem(
+ title=f"{section}", children=internal_items
+ )
+ ],
+ start_collapsed=True,
+ flush=True,
+ ),
+ ],
+ className="accordion-inner",
+ )
+ )
+
+ return html.Div(section_containers, className="compliance-data-layout")
+
+
def get_section_containers_format4(data, section_1):
data["STATUS"] = data["STATUS"].apply(map_status_to_icon)
diff --git a/dashboard/compliance/generic.py b/dashboard/compliance/generic.py
new file mode 100644
index 0000000000..f7d68bb52a
--- /dev/null
+++ b/dashboard/compliance/generic.py
@@ -0,0 +1,44 @@
+import warnings
+
+from dashboard.common_methods import (
+ get_section_containers_format4,
+ get_section_containers_generic,
+)
+
+warnings.filterwarnings("ignore")
+
+
+def get_table(data):
+ # Discover REQUIREMENTS_ATTRIBUTES_* columns at runtime.
+ attr_cols = [c for c in data.columns if c.startswith("REQUIREMENTS_ATTRIBUTES_")]
+
+ # Section column (in priority order):
+ # 1. REQUIREMENTS_ATTRIBUTES_SECTION — most common convention
+ # 2. First discovered attribute column — covers novel schemas
+ # 3. None — no section, group flat by requirement id
+ if "REQUIREMENTS_ATTRIBUTES_SECTION" in attr_cols:
+ section_col = "REQUIREMENTS_ATTRIBUTES_SECTION"
+ elif attr_cols:
+ section_col = attr_cols[0]
+ else:
+ section_col = None
+
+ base_cols = [
+ "REQUIREMENTS_ID",
+ "REQUIREMENTS_DESCRIPTION",
+ "STATUS",
+ "CHECKID",
+ "REGION",
+ "ACCOUNTID",
+ "RESOURCEID",
+ ]
+
+ # Two levels (section -> requirement id) when a section distinct from the
+ # id exists; otherwise group flat by requirement id.
+ if section_col and section_col != "REQUIREMENTS_ID":
+ needed = [section_col] + base_cols
+ aux = data[[c for c in needed if c in data.columns]].copy()
+ return get_section_containers_generic(aux, section_col, "REQUIREMENTS_ID")
+
+ aux = data[[c for c in base_cols if c in data.columns]].copy()
+ return get_section_containers_format4(aux, "REQUIREMENTS_ID")
diff --git a/dashboard/lib/layouts.py b/dashboard/lib/layouts.py
index 930432b6c4..3fb230f314 100644
--- a/dashboard/lib/layouts.py
+++ b/dashboard/lib/layouts.py
@@ -156,7 +156,7 @@ def create_layout_compliance(
html.Img(src="assets/favicon.ico", className="w-5 mr-3"),
html.Span("Subscribe to Prowler Cloud"),
],
- href="https://prowler.pro/",
+ href="https://cloud.prowler.com/",
target="_blank",
className="text-prowler-stone-900 inline-flex px-4 py-2 text-xs font-bold uppercase transition-all rounded-lg text-gray-900 hover:bg-prowler-stone-900/10 border-solid border-1 hover:border-prowler-stone-900/10 hover:border-solid hover:border-1 border-prowler-stone-900/10",
),
diff --git a/dashboard/pages/compliance.py b/dashboard/pages/compliance.py
index c1da9f611e..773dd095da 100644
--- a/dashboard/pages/compliance.py
+++ b/dashboard/pages/compliance.py
@@ -215,6 +215,58 @@ else:
)
+def _ensure_scope_columns(data):
+ """Guarantee ACCOUNTID and REGION exist.
+
+ Scope columns always sit between DESCRIPTION and ASSESSMENTDATE, so derive
+ them positionally for any provider (e.g. Okta's ORGANIZATIONDOMAIN) and
+ fall back to "-" to avoid a KeyError.
+ """
+ cols = list(data.columns)
+ scope = []
+ if "DESCRIPTION" in cols and "ASSESSMENTDATE" in cols:
+ start, end = cols.index("DESCRIPTION") + 1, cols.index("ASSESSMENTDATE")
+ scope = [c for c in cols[start:end] if c not in ("ACCOUNTID", "REGION")]
+
+ if "ACCOUNTID" not in data.columns:
+ if scope:
+ data.rename(columns={scope.pop(0): "ACCOUNTID"}, inplace=True)
+ else:
+ data["ACCOUNTID"] = "-"
+ if "REGION" not in data.columns:
+ if scope:
+ data.rename(columns={scope.pop(0): "REGION"}, inplace=True)
+ else:
+ data["REGION"] = "-"
+ return data
+
+
+def _dispatch_compliance_renderer(data, analytics_input):
+ """Resolve the compliance renderer module and return (table, deduped_data).
+
+ Tries to import the framework-specific builtin module. On
+ ModuleNotFoundError (dynamic/external provider with no dedicated module),
+ falls back to the generic renderer. Any other ImportError is re-raised.
+ get_table() is called OUTSIDE the try block so errors inside the renderer
+ surface as real exceptions rather than being swallowed.
+ """
+ current = analytics_input.replace(".", "_")
+ target = f"dashboard.compliance.{current}"
+ try:
+ module = importlib.import_module(target)
+ except ModuleNotFoundError as exc:
+ if exc.name != target:
+ raise
+ from dashboard.compliance import generic as module
+ dedup_columns = ["CHECKID", "STATUS", "RESOURCEID", "STATUSEXTENDED"]
+ if "MUTED" in data.columns:
+ dedup_columns.insert(2, "MUTED")
+ data = data.drop_duplicates(subset=dedup_columns)
+ if "threatscore" in analytics_input:
+ data = get_threatscore_mean_by_pillar(data)
+ return module.get_table(data), data
+
+
@callback(
[
Output("output", "children"),
@@ -292,7 +344,7 @@ def display_data(
data.rename(columns={"TENANCYID": "ACCOUNTID"}, inplace=True)
# Filter the chosen level of the CIS
- if is_level_1:
+ if is_level_1 and "REQUIREMENTS_ATTRIBUTES_PROFILE" in data.columns:
data = data[data["REQUIREMENTS_ATTRIBUTES_PROFILE"].str.contains("Level 1")]
# Rename the column PROJECTID to ACCOUNTID for GCP
@@ -314,6 +366,9 @@ def display_data(
data.rename(columns={"SUBSCRIPTION": "ACCOUNTID"}, inplace=True)
data["REGION"] = "-"
+ # Normalize scope columns for any remaining (e.g. dynamic) provider.
+ data = _ensure_scope_columns(data)
+
# Filter ACCOUNT
if account_filter == ["All"]:
updated_cloud_account_values = data["ACCOUNTID"].unique()
@@ -409,36 +464,7 @@ def display_data(
# Check cases where the compliance start with AWS_
if "aws_" in analytics_input:
analytics_input = analytics_input + "_aws"
- try:
- current = analytics_input.replace(".", "_")
- compliance_module = importlib.import_module(
- f"dashboard.compliance.{current}"
- )
- # Build subset list based on available columns
- dedup_columns = ["CHECKID", "STATUS", "RESOURCEID", "STATUSEXTENDED"]
- if "MUTED" in data.columns:
- dedup_columns.insert(2, "MUTED")
- data = data.drop_duplicates(subset=dedup_columns)
-
- if "threatscore" in analytics_input:
- data = get_threatscore_mean_by_pillar(data)
-
- table = compliance_module.get_table(data)
- except ModuleNotFoundError:
- table = html.Div(
- [
- html.H5(
- "No data found for this compliance",
- className="card-title",
- style={"text-align": "left", "color": "black"},
- )
- ],
- style={
- "width": "99%",
- "margin-right": "0.8%",
- "margin-bottom": "10px",
- },
- )
+ table, data = _dispatch_compliance_renderer(data, analytics_input)
df = data.copy()
# Remove Muted rows
diff --git a/dashboard/pages/overview.py b/dashboard/pages/overview.py
index 665aa8e195..e705f15e9f 100644
--- a/dashboard/pages/overview.py
+++ b/dashboard/pages/overview.py
@@ -1538,7 +1538,7 @@ def filter_data(
html.Img(src="assets/favicon.ico", className="w-5 mr-3"),
html.Span("Subscribe to Prowler Cloud"),
],
- href="https://prowler.pro/",
+ href="https://cloud.prowler.com/",
target="_blank",
className="text-prowler-stone-900 inline-flex px-4 py-2 text-xs font-bold uppercase transition-all rounded-lg text-gray-900 hover:bg-prowler-stone-900/10 border-solid border-1 hover:border-prowler-stone-900/10 hover:border-solid hover:border-1 border-prowler-stone-900/10",
),
diff --git a/tests/dashboard/__init__.py b/tests/dashboard/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/dashboard/common_methods_test.py b/tests/dashboard/common_methods_test.py
new file mode 100644
index 0000000000..b2137589c5
--- /dev/null
+++ b/tests/dashboard/common_methods_test.py
@@ -0,0 +1,81 @@
+import pandas as pd
+from dash import dash_table
+
+from dashboard.common_methods import get_section_containers_generic
+
+
+def _datatable_column_ids(component):
+ """Collect the column ids of every DataTable in a Dash component tree."""
+ if isinstance(component, dash_table.DataTable):
+ return [[c["id"] for c in component.columns]]
+ children = getattr(component, "children", None)
+ if children is None:
+ return []
+ if not isinstance(children, (list, tuple)):
+ children = [children]
+ return [cols for child in children for cols in _datatable_column_ids(child)]
+
+
+def _df(**extra):
+ data = {
+ "REQUIREMENTS_ID": ["req1"],
+ "STATUS": ["PASS"],
+ "CHECKID": ["check1"],
+ "REGION": ["us-east-1"],
+ "ACCOUNTID": ["123"],
+ "RESOURCEID": ["res1"],
+ }
+ data.update(extra)
+ return pd.DataFrame(data)
+
+
+class TestGetSectionContainersGeneric:
+ def test_one_container_per_section(self):
+ """One outer container per distinct section value."""
+ df = pd.DataFrame(
+ {
+ "REQUIREMENTS_ATTRIBUTES_SECTION": ["Sec A", "Sec A", "Sec B"],
+ "REQUIREMENTS_ID": ["req1", "req2", "req3"],
+ "STATUS": ["PASS", "FAIL", "PASS"],
+ "CHECKID": ["c1", "c2", "c3"],
+ "REGION": ["-"] * 3,
+ "ACCOUNTID": ["123"] * 3,
+ "RESOURCEID": ["r1", "r2", "r3"],
+ }
+ )
+ result = get_section_containers_generic(
+ df, "REQUIREMENTS_ATTRIBUTES_SECTION", "REQUIREMENTS_ID"
+ )
+ assert len(result.children) == 2
+
+ def test_inner_title_includes_id_and_description(self):
+ """Inner accordion title is ' - '."""
+ df = _df(
+ REQUIREMENTS_ATTRIBUTES_SECTION=["Sec A"],
+ REQUIREMENTS_DESCRIPTION=["Ensure MFA"],
+ )
+ rendered = str(
+ get_section_containers_generic(
+ df, "REQUIREMENTS_ATTRIBUTES_SECTION", "REQUIREMENTS_ID"
+ )
+ )
+ assert "req1 - Ensure MFA" in rendered
+
+ def test_arbitrary_ids_do_not_crash(self):
+ """Non-numeric ids are sorted lexicographically without raising."""
+ df = pd.DataFrame(
+ {
+ "REQUIREMENTS_ATTRIBUTES_SECTION": ["Sec A"] * 3,
+ "REQUIREMENTS_ID": ["AC-2(1)", "foo-bar", "step.1.2"],
+ "STATUS": ["PASS", "FAIL", "PASS"],
+ "CHECKID": ["c1", "c2", "c3"],
+ "REGION": ["-"] * 3,
+ "ACCOUNTID": ["123"] * 3,
+ "RESOURCEID": ["r1", "r2", "r3"],
+ }
+ )
+ result = get_section_containers_generic(
+ df, "REQUIREMENTS_ATTRIBUTES_SECTION", "REQUIREMENTS_ID"
+ )
+ tables = _datatable_column_ids(result)
+ assert tables and all("CHECKID" in cols for cols in tables)
diff --git a/tests/dashboard/compliance/__init__.py b/tests/dashboard/compliance/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/dashboard/compliance/generic_test.py b/tests/dashboard/compliance/generic_test.py
new file mode 100644
index 0000000000..4e36833ada
--- /dev/null
+++ b/tests/dashboard/compliance/generic_test.py
@@ -0,0 +1,204 @@
+import pandas as pd
+from dash import dash_table, html
+
+from dashboard.compliance.generic import get_table
+
+
+def _make_minimal_df(**extra_cols):
+ """Create a minimal valid DataFrame for get_table tests."""
+ data = {
+ "REQUIREMENTS_ID": ["req1"],
+ "STATUS": ["PASS"],
+ "CHECKID": ["check1"],
+ "REGION": ["us-east-1"],
+ "ACCOUNTID": ["123456789"],
+ "RESOURCEID": ["res1"],
+ }
+ data.update(extra_cols)
+ return pd.DataFrame(data)
+
+
+def _datatable_column_ids(component):
+ """Collect the column ids of every DataTable in a Dash component tree."""
+ if isinstance(component, dash_table.DataTable):
+ return [[c["id"] for c in component.columns]]
+ children = getattr(component, "children", None)
+ if children is None:
+ return []
+ if not isinstance(children, (list, tuple)):
+ children = [children]
+ return [cols for child in children for cols in _datatable_column_ids(child)]
+
+
+class TestGetTable:
+ def test_groups_by_section(self):
+ """SC-001a: df with REQUIREMENTS_ATTRIBUTES_SECTION returns Div grouped by section."""
+ data = pd.DataFrame(
+ {
+ "REQUIREMENTS_ATTRIBUTES_SECTION": [
+ "Section A",
+ "Section A",
+ "Section A",
+ "Section B",
+ "Section B",
+ ],
+ "REQUIREMENTS_ID": [
+ "ctrl-alpha",
+ "ctrl-alpha",
+ "ctrl-alpha",
+ "ctrl-beta",
+ "ctrl-beta",
+ ],
+ "STATUS": ["PASS", "FAIL", "PASS", "FAIL", "FAIL"],
+ "CHECKID": ["check1", "check2", "check3", "check4", "check5"],
+ "REGION": ["us-east-1"] * 5,
+ "ACCOUNTID": ["123"] * 5,
+ "RESOURCEID": ["res1", "res2", "res3", "res4", "res5"],
+ }
+ )
+ result = get_table(data)
+ assert isinstance(result, html.Div)
+ assert result.className == "compliance-data-layout"
+ assert len(result.children) == 2 # one container per distinct section
+
+ def test_flat_fallback_no_attributes(self):
+ """SC-001b: No REQUIREMENTS_ATTRIBUTES_* cols → grouped by REQUIREMENTS_ID."""
+ data = pd.DataFrame(
+ {
+ "REQUIREMENTS_ID": ["req1", "req1", "req2"],
+ "STATUS": ["PASS", "FAIL", "FAIL"],
+ "CHECKID": ["check1", "check2", "check3"],
+ "REGION": ["us-east-1"] * 3,
+ "ACCOUNTID": ["123"] * 3,
+ "RESOURCEID": ["res1", "res2", "res3"],
+ }
+ )
+ result = get_table(data)
+ assert isinstance(result, html.Div)
+ assert result.className == "compliance-data-layout"
+ # 2 distinct REQUIREMENTS_ID values → 2 group containers
+ assert len(result.children) == 2
+
+ def test_arbitrary_ids_no_crash(self):
+ """ADR-2 / R1 regression guard: non-numeric REQUIREMENTS_IDs must not raise ValueError.
+
+ get_section_containers_cis sorts by version_tuple which calls int() on each
+ dotted/dashed segment and crashes on IDs like 'AC-2(1)'. Selecting format4
+ (no version sort) is the fix. This test is a permanent guard against regression.
+ """
+ data = pd.DataFrame(
+ {
+ "REQUIREMENTS_ID": ["AC-2(1)", "foo-bar", "step.1.2"],
+ "STATUS": ["PASS", "FAIL", "PASS"],
+ "CHECKID": ["check1", "check2", "check3"],
+ "REGION": ["us-east-1"] * 3,
+ "ACCOUNTID": ["123"] * 3,
+ "RESOURCEID": ["res1", "res2", "res3"],
+ }
+ )
+ # Must not raise ValueError
+ result = get_table(data)
+ assert isinstance(result, html.Div)
+
+ def test_discovers_multiple_attribute_columns(self):
+ """SC-005a: Multiple REQUIREMENTS_ATTRIBUTES_* cols present → no AttributeError;
+ component tree is non-empty."""
+ data = pd.DataFrame(
+ {
+ "REQUIREMENTS_ATTRIBUTES_SECTION": ["Sec A", "Sec B"],
+ "REQUIREMENTS_ATTRIBUTES_CATEGORY": ["Cat 1", "Cat 2"],
+ "REQUIREMENTS_ATTRIBUTES_CONTROL_ID": ["C1", "C2"],
+ "REQUIREMENTS_ID": ["req1", "req2"],
+ "STATUS": ["PASS", "FAIL"],
+ "CHECKID": ["check1", "check2"],
+ "REGION": ["us-east-1"] * 2,
+ "ACCOUNTID": ["123"] * 2,
+ "RESOURCEID": ["res1", "res2"],
+ }
+ )
+ result = get_table(data)
+ assert isinstance(result, html.Div)
+ assert result.children # non-empty component tree
+
+ def test_novel_attribute_column_names(self):
+ """SC-005b: Novel attr col names without a SECTION col → first attr col used as
+ grouping; returns a valid html.Div without any code change required."""
+ data = pd.DataFrame(
+ {
+ "REQUIREMENTS_ATTRIBUTES_DOMAIN": ["Domain A", "Domain B"],
+ "REQUIREMENTS_ATTRIBUTES_SUBDOMAIN": ["Sub 1", "Sub 2"],
+ "REQUIREMENTS_ID": ["req1", "req2"],
+ "STATUS": ["PASS", "FAIL"],
+ "CHECKID": ["check1", "check2"],
+ "REGION": ["us-east-1"] * 2,
+ "ACCOUNTID": ["123"] * 2,
+ "RESOURCEID": ["res1", "res2"],
+ }
+ )
+ result = get_table(data)
+ assert isinstance(result, html.Div)
+ assert len(result.children) > 0
+
+ def test_manual_only_requirements(self):
+ """SC-008a: All rows have STATUS='MANUAL' → returns html.Div with non-empty
+ children; result is not the 'No data found' string."""
+ data = pd.DataFrame(
+ {
+ "REQUIREMENTS_ATTRIBUTES_SECTION": ["Sec A", "Sec B"],
+ "REQUIREMENTS_ID": ["req1", "req2"],
+ "STATUS": ["MANUAL", "MANUAL"],
+ "CHECKID": ["check1", "check2"],
+ "REGION": ["us-east-1"] * 2,
+ "ACCOUNTID": ["123"] * 2,
+ "RESOURCEID": ["res1", "res2"],
+ }
+ )
+ result = get_table(data)
+ assert isinstance(result, html.Div)
+ assert not isinstance(result, str)
+ assert result.children # non-empty
+
+ def test_empty_dataframe(self):
+ """SC-009a: Zero rows with correct column schema → valid html.Div; no exception."""
+ data = pd.DataFrame(
+ {
+ "REQUIREMENTS_ATTRIBUTES_SECTION": pd.Series([], dtype=str),
+ "REQUIREMENTS_ID": pd.Series([], dtype=str),
+ "STATUS": pd.Series([], dtype=str),
+ "CHECKID": pd.Series([], dtype=str),
+ "REGION": pd.Series([], dtype=str),
+ "ACCOUNTID": pd.Series([], dtype=str),
+ "RESOURCEID": pd.Series([], dtype=str),
+ }
+ )
+ result = get_table(data)
+ assert isinstance(result, html.Div)
+
+ def test_get_table_returns_html_div(self):
+ """SC-012a: Smoke test — isinstance(get_table(df), html.Div) is True."""
+ data = _make_minimal_df(
+ REQUIREMENTS_ATTRIBUTES_SECTION=["Sec A"],
+ )
+ result = get_table(data)
+ assert isinstance(result, html.Div)
+
+
+class TestNestedRendering:
+ def test_section_and_requirement_id_are_separate_levels(self):
+ """Section is the outer level; requirement id + description the inner."""
+ data = _make_minimal_df(
+ REQUIREMENTS_ATTRIBUTES_SECTION=["3 Compute Services"],
+ REQUIREMENTS_DESCRIPTION=["Ensure only MFA enabled identities"],
+ )
+ rendered = str(get_table(data))
+ assert "3 Compute Services" in rendered
+ assert "req1 - Ensure only MFA enabled identities" in rendered
+
+ def test_checks_table_is_nested_under_requirement(self):
+ """The checks table sits at the innermost level."""
+ data = _make_minimal_df(
+ REQUIREMENTS_ATTRIBUTES_SECTION=["Sec A"],
+ REQUIREMENTS_DESCRIPTION=["Some requirement"],
+ )
+ tables = _datatable_column_ids(get_table(data))
+ assert tables and all("CHECKID" in cols for cols in tables)
diff --git a/tests/dashboard/pages/__init__.py b/tests/dashboard/pages/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/dashboard/pages/compliance_dispatch_test.py b/tests/dashboard/pages/compliance_dispatch_test.py
new file mode 100644
index 0000000000..78bd88ce68
--- /dev/null
+++ b/tests/dashboard/pages/compliance_dispatch_test.py
@@ -0,0 +1,179 @@
+from unittest.mock import MagicMock, patch
+
+import pandas as pd
+import pytest
+from dash import html
+
+from dashboard.pages.compliance import _dispatch_compliance_renderer
+
+
+def _make_dispatch_df(**extra_cols):
+ """Minimal DataFrame with the columns required by the dedup step."""
+ data = {
+ "REQUIREMENTS_ID": ["req1", "req2"],
+ "REQUIREMENTS_ATTRIBUTES_SECTION": ["Sec A", "Sec A"],
+ "STATUS": ["PASS", "FAIL"],
+ "CHECKID": ["check1", "check2"],
+ "RESOURCEID": ["res1", "res2"],
+ "STATUSEXTENDED": ["", ""],
+ "REGION": ["us-east-1", "us-east-1"],
+ "ACCOUNTID": ["123456789", "123456789"],
+ }
+ data.update(extra_cols)
+ return pd.DataFrame(data)
+
+
+class TestDispatchComplianceRenderer:
+ def test_builtin_name_uses_builtin_module(self):
+ """SC-002a: analytics_input='cis_4_0_aws' resolves real builtin module;
+ returns (html.Div, DataFrame) 2-tuple."""
+ data = pd.DataFrame(
+ {
+ "REQUIREMENTS_ID": ["1.1", "1.2"],
+ "REQUIREMENTS_DESCRIPTION": ["Description 1", "Description 2"],
+ "REQUIREMENTS_ATTRIBUTES_SECTION": ["Section A", "Section A"],
+ "CHECKID": ["check1", "check2"],
+ "STATUS": ["PASS", "FAIL"],
+ "REGION": ["us-east-1", "us-east-1"],
+ "ACCOUNTID": ["123456789", "123456789"],
+ "RESOURCEID": ["res1", "res2"],
+ "STATUSEXTENDED": ["Pass", "Fail"],
+ }
+ )
+ table, result_data = _dispatch_compliance_renderer(data, "cis_4_0_aws")
+ assert isinstance(table, html.Div)
+ assert isinstance(result_data, pd.DataFrame)
+
+ def test_unknown_name_falls_back_to_generic(self):
+ """SC-003a: Unknown analytics_input raises ModuleNotFoundError → generic
+ fallback is called with the deduped dataframe."""
+ data = _make_dispatch_df()
+ sentinel = MagicMock(
+ return_value=html.Div([], className="compliance-data-layout")
+ )
+
+ with patch("dashboard.compliance.generic.get_table", sentinel):
+ table, result_data = _dispatch_compliance_renderer(data, "myfw_dynprovider")
+
+ sentinel.assert_called_once()
+ assert isinstance(table, html.Div)
+ assert isinstance(result_data, pd.DataFrame)
+
+ def test_import_error_is_not_swallowed(self):
+ """SC-003b: ImportError (NOT ModuleNotFoundError) is re-raised; except clause
+ is exact — only ModuleNotFoundError routes to generic."""
+ data = _make_dispatch_df()
+
+ with patch(
+ "dashboard.pages.compliance.importlib.import_module",
+ side_effect=ImportError("custom error"),
+ ):
+ with pytest.raises(ImportError, match="custom error"):
+ _dispatch_compliance_renderer(data, "anything")
+
+ def test_get_table_error_in_generic_surfaces(self):
+ """SC-004a: ValueError from generic.get_table propagates (not swallowed);
+ get_table is called OUTSIDE the try block."""
+ data = _make_dispatch_df()
+
+ with patch(
+ "dashboard.compliance.generic.get_table",
+ side_effect=ValueError("boom"),
+ ):
+ with pytest.raises(ValueError, match="boom"):
+ _dispatch_compliance_renderer(data, "myfw_dynprovider")
+
+ def test_get_table_error_in_builtin_surfaces(self):
+ """REQ-004 / ADR-1: RuntimeError from a builtin get_table propagates;
+ proving get_table is called outside the try block."""
+ data = _make_dispatch_df()
+ mock_module = MagicMock()
+ mock_module.get_table.side_effect = RuntimeError("table error")
+
+ with patch(
+ "dashboard.pages.compliance.importlib.import_module",
+ return_value=mock_module,
+ ):
+ with pytest.raises(RuntimeError, match="table error"):
+ _dispatch_compliance_renderer(data, "some_builtin_fw")
+
+ def test_dedup_applied_before_get_table(self):
+ """ADR-1: Duplicate rows (identical CHECKID/STATUS/RESOURCEID/STATUSEXTENDED)
+ are dropped; returned data has the deduplicated row count."""
+ # Row 0 and row 1 are identical in all dedup-key columns; row 2 is unique.
+ data = pd.DataFrame(
+ {
+ "REQUIREMENTS_ATTRIBUTES_SECTION": ["Sec A", "Sec A", "Sec B"],
+ "REQUIREMENTS_ID": ["req1", "req1", "req2"],
+ "STATUS": ["PASS", "PASS", "FAIL"],
+ "CHECKID": ["check1", "check1", "check2"],
+ "RESOURCEID": ["res1", "res1", "res2"],
+ "STATUSEXTENDED": ["", "", ""],
+ "REGION": ["us-east-1"] * 3,
+ "ACCOUNTID": ["123"] * 3,
+ }
+ )
+ mock_module = MagicMock()
+ mock_module.get_table.return_value = html.Div([])
+
+ with patch(
+ "dashboard.pages.compliance.importlib.import_module",
+ return_value=mock_module,
+ ):
+ table, result_data = _dispatch_compliance_renderer(data, "some_fw")
+
+ assert len(result_data) == 2 # one duplicate removed
+
+ def test_muted_column_added_to_dedup_when_present(self):
+ """ADR-1 edge case: When MUTED column is present, it is included in the dedup
+ subset at index 2; rows differing only in MUTED are kept as distinct rows."""
+ # Both rows share CHECKID/STATUS/RESOURCEID/STATUSEXTENDED but differ in MUTED.
+ # With MUTED in dedup_columns, both rows are kept (2 rows after dedup).
+ # Without MUTED in dedup_columns, they would be collapsed to 1 row.
+ data = pd.DataFrame(
+ {
+ "REQUIREMENTS_ATTRIBUTES_SECTION": ["Sec A", "Sec A"],
+ "REQUIREMENTS_ID": ["req1", "req1"],
+ "STATUS": ["PASS", "PASS"],
+ "CHECKID": ["check1", "check1"],
+ "RESOURCEID": ["res1", "res1"],
+ "STATUSEXTENDED": ["", ""],
+ "MUTED": ["True", "False"],
+ "REGION": ["us-east-1", "us-east-1"],
+ "ACCOUNTID": ["123", "123"],
+ }
+ )
+ mock_module = MagicMock()
+ mock_module.get_table.return_value = html.Div([])
+
+ with patch(
+ "dashboard.pages.compliance.importlib.import_module",
+ return_value=mock_module,
+ ):
+ table, result_data = _dispatch_compliance_renderer(data, "some_fw")
+
+ # MUTED at idx 2 means these two rows have different dedup keys → both kept
+ assert len(result_data) == 2
+
+ def test_returns_table_and_data_tuple(self):
+ """ADR-1 interface contract: _dispatch_compliance_renderer returns a
+ 2-tuple (table, deduped_data)."""
+ data = pd.DataFrame(
+ {
+ "REQUIREMENTS_ID": ["1.1", "1.2"],
+ "REQUIREMENTS_DESCRIPTION": ["Desc 1", "Desc 2"],
+ "REQUIREMENTS_ATTRIBUTES_SECTION": ["Section A", "Section A"],
+ "CHECKID": ["check1", "check2"],
+ "STATUS": ["PASS", "FAIL"],
+ "REGION": ["us-east-1", "us-east-1"],
+ "ACCOUNTID": ["123456789", "123456789"],
+ "RESOURCEID": ["res1", "res2"],
+ "STATUSEXTENDED": ["", ""],
+ }
+ )
+ result = _dispatch_compliance_renderer(data, "cis_4_0_aws")
+ assert isinstance(result, tuple)
+ assert len(result) == 2
+ table, deduped_data = result
+ assert isinstance(table, html.Div)
+ assert isinstance(deduped_data, pd.DataFrame)
diff --git a/tests/dashboard/pages/conftest.py b/tests/dashboard/pages/conftest.py
new file mode 100644
index 0000000000..a5c674c7de
--- /dev/null
+++ b/tests/dashboard/pages/conftest.py
@@ -0,0 +1,7 @@
+import dash
+
+# Initialize a minimal Dash app so that dashboard page modules can call
+# dash.register_page() during import without raising PageError.
+# This module-level initialization runs during pytest collection, before
+# any test file in this directory is imported.
+_test_app = dash.Dash("prowler_test_app", use_pages=True, pages_folder="")
diff --git a/tests/dashboard/pages/scope_columns_test.py b/tests/dashboard/pages/scope_columns_test.py
new file mode 100644
index 0000000000..a8aea34221
--- /dev/null
+++ b/tests/dashboard/pages/scope_columns_test.py
@@ -0,0 +1,60 @@
+import pandas as pd
+
+from dashboard.pages.compliance import _ensure_scope_columns
+
+
+def _df(columns):
+ """Build a one-row DataFrame preserving the given column order."""
+ return pd.DataFrame({col: ["x"] for col in columns})
+
+
+class TestEnsureScopeColumns:
+ def test_aws_account_and_region_preserved(self):
+ """A provider that already emits ACCOUNTID and REGION is left untouched."""
+ df = _df(["PROVIDER", "DESCRIPTION", "ACCOUNTID", "REGION", "ASSESSMENTDATE"])
+ result = _ensure_scope_columns(df)
+ assert "ACCOUNTID" in result.columns
+ assert "REGION" in result.columns
+ assert result["ACCOUNTID"].iloc[0] == "x"
+
+ def test_okta_single_scope_column_becomes_accountid(self):
+ """Okta's ORGANIZATIONDOMAIN becomes ACCOUNTID; REGION falls back."""
+ df = _df(["PROVIDER", "DESCRIPTION", "ORGANIZATIONDOMAIN", "ASSESSMENTDATE"])
+ df["ORGANIZATIONDOMAIN"] = ["trial-123.okta.com"]
+ result = _ensure_scope_columns(df)
+ assert "ACCOUNTID" in result.columns
+ assert "ORGANIZATIONDOMAIN" not in result.columns
+ assert result["ACCOUNTID"].iloc[0] == "trial-123.okta.com"
+ assert result["REGION"].iloc[0] == "-"
+
+ def test_two_unknown_scope_columns_map_to_account_and_region(self):
+ """Two scope columns map positionally to ACCOUNTID and REGION."""
+ df = _df(["PROVIDER", "DESCRIPTION", "TENANCYID", "LOCATION", "ASSESSMENTDATE"])
+ df["TENANCYID"] = ["tenant-1"]
+ df["LOCATION"] = ["eu-west-1"]
+ result = _ensure_scope_columns(df)
+ assert result["ACCOUNTID"].iloc[0] == "tenant-1"
+ assert result["REGION"].iloc[0] == "eu-west-1"
+
+ def test_no_scope_columns_fall_back_to_dash(self):
+ """No scope columns → both ACCOUNTID and REGION fall back to '-'."""
+ df = _df(["PROVIDER", "DESCRIPTION", "ASSESSMENTDATE"])
+ result = _ensure_scope_columns(df)
+ assert result["ACCOUNTID"].iloc[0] == "-"
+ assert result["REGION"].iloc[0] == "-"
+
+ def test_missing_anchors_still_fall_back_to_dash(self):
+ """Without DESCRIPTION/ASSESSMENTDATE anchors, both fall back to '-'."""
+ df = _df(["PROVIDER", "FOO", "BAR"])
+ result = _ensure_scope_columns(df)
+ assert result["ACCOUNTID"].iloc[0] == "-"
+ assert result["REGION"].iloc[0] == "-"
+
+ def test_existing_accountid_does_not_consume_region_scope(self):
+ """An existing ACCOUNTID is kept; the leftover scope becomes REGION."""
+ df = _df(["PROVIDER", "DESCRIPTION", "ACCOUNTID", "LOCATION", "ASSESSMENTDATE"])
+ df["ACCOUNTID"] = ["acc-1"]
+ df["LOCATION"] = ["us-east-2"]
+ result = _ensure_scope_columns(df)
+ assert result["ACCOUNTID"].iloc[0] == "acc-1"
+ assert result["REGION"].iloc[0] == "us-east-2"
From 61cd4aea3fb74ad33433987435b19f0fb2ef93cc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pedro=20Mart=C3=ADn?=
Date: Wed, 10 Jun 2026 11:22:42 +0200
Subject: [PATCH 039/129] feat(compliance): add Okta IDaaS STIG V1R2 framework
(#11428)
Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
Co-authored-by: Daniel Barranquero
---
api/CHANGELOG.md | 1 +
api/src/backend/tasks/jobs/export.py | 6 +
prowler/CHANGELOG.md | 1 +
prowler/__main__.py | 30 +
prowler/compliance/okta/__init__.py | 0
.../okta/okta_idaas_stig_v1r2_okta.json | 638 ++++++++++++++++++
prowler/lib/check/compliance_models.py | 21 +
prowler/lib/outputs/compliance/compliance.py | 12 +
.../compliance/okta_idaas_stig/__init__.py | 0
.../compliance/okta_idaas_stig/models.py | 32 +
.../okta_idaas_stig/okta_idaas_stig.py | 98 +++
.../okta_idaas_stig/okta_idaas_stig_okta.py | 95 +++
.../display_compliance_table_test.py | 9 +
tests/lib/outputs/compliance/fixtures.py | 45 ++
.../okta_idaas_stig_okta_test.py | 139 ++++
ui/CHANGELOG.md | 1 +
.../asd-essential-eight-details.tsx | 6 +-
.../aws-well-architected-details.tsx | 6 +-
.../compliance-custom-details/ccc-details.tsx | 14 +-
.../compliance-custom-details/cis-details.tsx | 4 +-
.../compliance-custom-details/csa-details.tsx | 20 +-
.../dora-details.tsx | 6 +-
.../compliance-custom-details/ens-details.tsx | 4 +-
.../generic-details.tsx | 6 +-
.../mitre-details.tsx | 8 +-
.../okta-idaas-stig-details.tsx | 65 ++
.../shared-components.tsx | 58 +-
.../threat-details.tsx | 10 +-
.../icons/compliance/IconCompliance.test.ts | 6 +
.../icons/compliance/IconCompliance.tsx | 2 +
ui/components/icons/compliance/okta.svg | 1 +
ui/components/shadcn/badge/badge.test.tsx | 30 +
ui/components/shadcn/badge/badge.tsx | 1 +
ui/lib/compliance/ccc.tsx | 9 +-
ui/lib/compliance/compliance-mapper.test.ts | 6 +
ui/lib/compliance/compliance-mapper.ts | 14 +
ui/lib/compliance/csa.tsx | 6 +-
ui/lib/compliance/okta-idaas-stig.tsx | 163 +++++
ui/types/compliance.ts | 44 ++
39 files changed, 1521 insertions(+), 96 deletions(-)
create mode 100644 prowler/compliance/okta/__init__.py
create mode 100644 prowler/compliance/okta/okta_idaas_stig_v1r2_okta.json
create mode 100644 prowler/lib/outputs/compliance/okta_idaas_stig/__init__.py
create mode 100644 prowler/lib/outputs/compliance/okta_idaas_stig/models.py
create mode 100644 prowler/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig.py
create mode 100644 prowler/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_okta.py
create mode 100644 tests/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_okta_test.py
create mode 100644 ui/components/compliance/compliance-custom-details/okta-idaas-stig-details.tsx
create mode 100644 ui/components/icons/compliance/okta.svg
create mode 100644 ui/components/shadcn/badge/badge.test.tsx
create mode 100644 ui/lib/compliance/okta-idaas-stig.tsx
diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md
index ab40743b36..2033dcc420 100644
--- a/api/CHANGELOG.md
+++ b/api/CHANGELOG.md
@@ -9,6 +9,7 @@ All notable changes to the **Prowler API** are documented in this file.
- Opt-in automatic recovery of allowlisted idempotent background tasks whose worker died during a deploy or crash: when enabled via `DJANGO_TASK_RECOVERY_ENABLED` (off by default), stuck summary and deletion tasks are detected and re-run instead of staying pending forever (scan and Jira tasks are excluded), with a `reconcile_orphan_tasks` management command for on-demand recovery [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416)
- DORA compliance framework support [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131)
- Label Postgres connections with `application_name=":"` (component injected per process via `DJANGO_APP_COMPONENT`) so connections are attributable by component in `pg_stat_activity` [(#11494)](https://github.com/prowler-cloud/prowler/pull/11494)
+- DISA Okta IDaaS STIG V1R2 compliance framework export support for the Okta provider [(#11428)](https://github.com/prowler-cloud/prowler/pull/11428)
### 🔄 Changed
diff --git a/api/src/backend/tasks/jobs/export.py b/api/src/backend/tasks/jobs/export.py
index 185acb7f99..96bd03ee6c 100644
--- a/api/src/backend/tasks/jobs/export.py
+++ b/api/src/backend/tasks/jobs/export.py
@@ -58,6 +58,9 @@ from prowler.lib.outputs.compliance.mitre_attack.mitre_attack_azure import (
AzureMitreAttack,
)
from prowler.lib.outputs.compliance.mitre_attack.mitre_attack_gcp import GCPMitreAttack
+from prowler.lib.outputs.compliance.okta_idaas_stig.okta_idaas_stig_okta import (
+ OktaIDaaSSTIG,
+)
from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore_alibaba import (
ProwlerThreatScoreAlibaba,
)
@@ -152,6 +155,9 @@ COMPLIANCE_CLASS_MAP = {
ProwlerThreatScoreAlibaba,
),
],
+ "okta": [
+ (lambda name: name.startswith("okta_idaas_stig"), OktaIDaaSSTIG),
+ ],
}
diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md
index dcb250d316..c9542d8345 100644
--- a/prowler/CHANGELOG.md
+++ b/prowler/CHANGELOG.md
@@ -6,6 +6,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
### 🚀 Added
+- DISA Okta IDaaS STIG V1R2 compliance framework for the Okta provider, with a dedicated CSV output formatter and terminal summary table [(#11428)](https://github.com/prowler-cloud/prowler/pull/11428)
- `sagemaker_models_monitor_enabled` check for AWS provider, verifying that each SageMaker monitoring schedule is in the `Scheduled` state so data and model drift is actively detected [(#11278)](https://github.com/prowler-cloud/prowler/pull/11278)
- DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554) universal compliance framework with AWS provider coverage across the five DORA pillars [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131)
- Okta authenticator and password policy checks for STIG-aligned hardening requirements [(#11465)](https://github.com/prowler-cloud/prowler/pull/11465)
diff --git a/prowler/__main__.py b/prowler/__main__.py
index 6cbaf575d9..703a9e49a0 100644
--- a/prowler/__main__.py
+++ b/prowler/__main__.py
@@ -102,6 +102,9 @@ from prowler.lib.outputs.compliance.mitre_attack.mitre_attack_azure import (
AzureMitreAttack,
)
from prowler.lib.outputs.compliance.mitre_attack.mitre_attack_gcp import GCPMitreAttack
+from prowler.lib.outputs.compliance.okta_idaas_stig.okta_idaas_stig_okta import (
+ OktaIDaaSSTIG,
+)
from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore_alibaba import (
ProwlerThreatScoreAlibaba,
)
@@ -1314,6 +1317,33 @@ def prowler():
)
generated_outputs["compliance"].append(generic_compliance)
generic_compliance.batch_write_data_to_file()
+ elif provider == "okta":
+ for compliance_name in input_compliance_frameworks:
+ if compliance_name.startswith("okta_idaas_stig"):
+ # Generate Okta IDaaS STIG Finding Object
+ filename = (
+ f"{output_options.output_directory}/compliance/"
+ f"{output_options.output_filename}_{compliance_name}.csv"
+ )
+ okta_idaas_stig = OktaIDaaSSTIG(
+ findings=finding_outputs,
+ compliance=bulk_compliance_frameworks[compliance_name],
+ file_path=filename,
+ )
+ generated_outputs["compliance"].append(okta_idaas_stig)
+ okta_idaas_stig.batch_write_data_to_file()
+ else:
+ filename = (
+ f"{output_options.output_directory}/compliance/"
+ f"{output_options.output_filename}_{compliance_name}.csv"
+ )
+ generic_compliance = GenericCompliance(
+ findings=finding_outputs,
+ compliance=bulk_compliance_frameworks[compliance_name],
+ file_path=filename,
+ )
+ generated_outputs["compliance"].append(generic_compliance)
+ generic_compliance.batch_write_data_to_file()
else:
# Dynamic fallback: any external/custom provider
try:
diff --git a/prowler/compliance/okta/__init__.py b/prowler/compliance/okta/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/compliance/okta/okta_idaas_stig_v1r2_okta.json b/prowler/compliance/okta/okta_idaas_stig_v1r2_okta.json
new file mode 100644
index 0000000000..bac3d9132e
--- /dev/null
+++ b/prowler/compliance/okta/okta_idaas_stig_v1r2_okta.json
@@ -0,0 +1,638 @@
+{
+ "Framework": "Okta-IDaaS-STIG",
+ "Name": "DISA Okta Identity as a Service (IDaaS) STIG V1R2",
+ "Version": "1R2",
+ "Provider": "Okta",
+ "Description": "Defense Information Systems Agency (DISA) Security Technical Implementation Guide (STIG) for Okta Identity as a Service (IDaaS), Version 1 Release 2 (Benchmark Date: 05 Jan 2026).",
+ "Requirements": [
+ {
+ "Id": "OKTA-APP-000020",
+ "Name": "Okta must log out a session after a 15-minute period of inactivity.",
+ "Description": "A session timeout lock is a temporary action taken when a user stops work and moves away from the immediate physical vicinity of the information system but does not log out because of the temporary nature of the absence. Rather than relying on the user to manually lock their application session prior to vacating the vicinity, applications must be able to identify when a user's application session has idled and take action to initiate the session lock. The session lock is implemented at the point where session activity can be determined and/or controlled. This is typically at the operating system level and results in a system lock. However, it may be at the application level where the application interface window is secured instead. Satisfies: SRG-APP-000003, SRG-APP-000190",
+ "Checks": [
+ "signon_global_session_idle_timeout_15min"
+ ],
+ "Attributes": [
+ {
+ "Section": "CAT II (Medium)",
+ "Severity": "medium",
+ "RuleID": "SV-273186r1098825_rule",
+ "StigID": "OKTA-APP-000020",
+ "CCI": [
+ "CCI-000057",
+ "CCI-001133"
+ ],
+ "CheckText": "From the Admin Console: 1. Select Security >> Global Session Policy. 2. In the Default Policy, verify a rule is configured at Priority 1 that is not named \"Default Rule\". 3. Click the edit icon next to the Priority 1 rule. 4. Verify the \"Maximum Okta global session idle time\" is set to 15 minutes. If \"Maximum Okta global session idle time\" is not set to 15 minutes, this is a finding.",
+ "FixText": "From the Admin Console: 1. Go to Security >> Global Session Policy. 2. Select the Default Policy. 3. In the Rules table, make these updates: - Click \"Add rule\". - Set \"Maximum Okta global session idle time\" to 15 minutes."
+ }
+ ]
+ },
+ {
+ "Id": "OKTA-APP-000025",
+ "Name": "The Okta Admin Console must log out a session after a 15-minute period of inactivity.",
+ "Description": "A session timeout lock is a temporary action taken when a user stops work and moves away from the immediate physical vicinity of the information system but does not log out because of the temporary nature of the absence. Rather than relying on the user to manually lock their application session prior to vacating the vicinity, applications must be able to identify when a user's application session has idled and take action to initiate the session lock. The session lock is implemented at the point where session activity can be determined and/or controlled. This is typically at the operating system level and results in a system lock. However, it may be at the application level where the application interface window is secured instead.",
+ "Checks": [
+ "application_admin_console_session_idle_timeout_15min"
+ ],
+ "Attributes": [
+ {
+ "Section": "CAT II (Medium)",
+ "Severity": "medium",
+ "RuleID": "SV-273187r1098828_rule",
+ "StigID": "OKTA-APP-000025",
+ "CCI": [
+ "CCI-000057"
+ ],
+ "CheckText": "From the Admin Console: 1. Select Applications >> Applications >> Okta Admin Console. 2. In the Sign On tab, under \"Okta Admin Console session\", verify the \"Maximum app session idle time\" is set to 15 minutes. If the \"Maximum app session idle time\" is not set to 15 minutes, this is a finding.",
+ "FixText": "From the Admin Console: 1. Select Applications >> Applications >> Okta Admin Console. 2. In the Sign On tab, under \"Okta Admin Console session\", set the \"Maximum app session idle time\" to 15 minutes."
+ }
+ ]
+ },
+ {
+ "Id": "OKTA-APP-000090",
+ "Name": "Okta must automatically disable accounts after a 35-day period of account inactivity.",
+ "Description": "Attackers that are able to exploit an inactive account can potentially obtain and maintain undetected access to an application. Owners of inactive accounts will not notice if unauthorized access to their user account has been obtained. Applications must track periods of user inactivity and disable accounts after 35 days of inactivity. Such a process greatly reduces the risk that accounts will be hijacked, leading to a data compromise. To address access requirements, many application developers choose to integrate their applications with enterprise-level authentication/access mechanisms that meet or exceed access control policy requirements. Such integration allows the application developer to off-load those access control functions and focus on core application features and functionality. This policy does not apply to emergency accounts or infrequently used accounts. Infrequently used accounts are local login administrator accounts used by system administrators when network or normal login/access is not available. Emergency accounts are administrator accounts created in response to crisis situations. Satisfies: SRG-APP-000025, SRG-APP-000163, SRG-APP-000700",
+ "Checks": [
+ "user_inactivity_automation_35d_enabled"
+ ],
+ "Attributes": [
+ {
+ "Section": "CAT II (Medium)",
+ "Severity": "medium",
+ "RuleID": "SV-273188r1098831_rule",
+ "StigID": "OKTA-APP-000090",
+ "CCI": [
+ "CCI-000017",
+ "CCI-000795",
+ "CCI-003627"
+ ],
+ "CheckText": "If Okta Services rely on external directory services for user sourcing, this is not applicable, and the connected directory services must perform this function. Go to Workflows >> Automations and verify that an Automation has been created to disable accounts after 35 days of inactivity. If the Okta configuration does not automatically disable accounts after a 35-day period of account inactivity, this is a finding.",
+ "FixText": "From the Admin Console: 1. Go to Workflow >> Automations and select \"Add Automation\". 2. Create a name for the Automation (e.g., \"User Inactivity\"). 3. Click \"Add Condition\" and select \"User Inactivity in Okta\". 4. In the duration field, enter 35 days and click \"Save\". 5 Click the edit button next to \"Select Schedule\". 6. Configure the \"Schedule\" field for \"Run Daily\" and set the \"Time\" field to an organizationally defined time to run this automation. Click \"Save\". 7. Click the edit button next to \"Select group membership\". 8. In the \"Applies to\" field, select the group \"Everyone\" by typing it into the field. Click \"Save\". 9. Click \"Add Action\" and select \"Change User lifecycle state in Okta\". 10. In the \"Change user state to\" field, select \"Suspended\" and click \"Save\". 11. Click the \"Inactive\" button near the top of the section screen and select \"Activate\"."
+ }
+ ]
+ },
+ {
+ "Id": "OKTA-APP-000170",
+ "Name": "Okta must enforce the limit of three consecutive invalid login attempts by a user during a 15-minute time period.",
+ "Description": "By limiting the number of failed login attempts, the risk of unauthorized system access via user password guessing, otherwise known as brute forcing, is reduced. Limits are imposed by locking the account. Satisfies: SRG-APP-000065, SRG-APP-000345",
+ "Checks": [
+ "authenticator_password_lockout_threshold_3"
+ ],
+ "Attributes": [
+ {
+ "Section": "CAT II (Medium)",
+ "Severity": "medium",
+ "RuleID": "SV-273189r1098834_rule",
+ "StigID": "OKTA-APP-000170",
+ "CCI": [
+ "CCI-000044",
+ "CCI-002238"
+ ],
+ "CheckText": "If Okta Services rely on external directory services for user sourcing, this check is not applicable, and the connected directory services must perform this function. From the Admin Console: 1. Go to Security >> Authenticators. 2. Click the \"Actions\" button next to \"Password\" and select \"Edit\". 3. For each Password Policy, verify the \"Lock Out\" section has the following values: - \"Lock out after 3 unsuccessful attempts\" is checked. - The value is set to \"3\". If Okta Services are not configured to automatically lock user accounts after three consecutive invalid login attempts, this is a finding.",
+ "FixText": "From the Admin Console: 1. Go to Security >> Authenticators. 2. Click the \"Actions\" button next to \"Password\" and select \"Edit\". 3. For each Password Policy, ensure the \"Lock Out\" section has the following values: - \"Lock out after 3 unsuccessful attempts\" is checked. - The value is set to \"3\"."
+ }
+ ]
+ },
+ {
+ "Id": "OKTA-APP-000180",
+ "Name": "The Okta Dashboard application must be configured to allow authentication only via non-phishable authenticators.",
+ "Description": "Requiring the use of non-phishable authenticators protects against brute force/password dictionary attacks. This provides a better level of security while removing the need to lock out accounts after three attempts in 15 minutes.",
+ "Checks": [
+ "application_dashboard_phishing_resistant_authentication"
+ ],
+ "Attributes": [
+ {
+ "Section": "CAT II (Medium)",
+ "Severity": "medium",
+ "RuleID": "SV-273190r1099763_rule",
+ "StigID": "OKTA-APP-000180",
+ "CCI": [
+ "CCI-000044"
+ ],
+ "CheckText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Dashboard\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"Possession factor constraints are\" section, verify the \"Phishing resistant\" box is checked. This will ensure that only phishing-resistant factors are used to access the Okta Dashboard. If in the \"Possession factor constraints are\" section the \"Phishing resistant\" box is not checked, this is a finding.",
+ "FixText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Dashboard\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"Possession factor constraints are\" section, ensure the \"Phishing resistant\" box is checked."
+ }
+ ]
+ },
+ {
+ "Id": "OKTA-APP-000190",
+ "Name": "The Okta Admin Console application must be configured to allow authentication only via non-phishable authenticators.",
+ "Description": "Requiring the use of non-phishable authenticators protects against brute force/password dictionary attacks. This provides a better level of security while removing the need to lock out accounts after three attempts in 15 minutes.",
+ "Checks": [
+ "application_admin_console_phishing_resistant_authentication"
+ ],
+ "Attributes": [
+ {
+ "Section": "CAT II (Medium)",
+ "Severity": "medium",
+ "RuleID": "SV-273191r1099764_rule",
+ "StigID": "OKTA-APP-000190",
+ "CCI": [
+ "CCI-000044"
+ ],
+ "CheckText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Admin Console\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"Possession factor constraints are\" section, verify the \"Phishing resistant\" box is checked. This will ensure that only phishing-resistant factors are used to access the Okta Dashboard. If in the \"Possession factor constraints are\" section the \"Phishing resistant\" box is not checked, this is a finding.",
+ "FixText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Admin Console\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"Possession factor constraints are\" section, ensure the \"Phishing resistant\" box is checked."
+ }
+ ]
+ },
+ {
+ "Id": "OKTA-APP-000200",
+ "Name": "Okta must display the Standard Mandatory DOD Notice and Consent Banner before granting access to the application.",
+ "Description": "Display of the DOD-approved use notification before granting access to the application ensures that privacy and security notification verbiage used is consistent with applicable federal laws, Executive Orders, directives, policies, regulations, standards, and guidance. System use notifications are required only for access via login interfaces with human users and are not required when such human interfaces do not exist. The banner must be formatted in accordance with DTM-08-060. Use the following verbiage for applications that can accommodate banners of 1300 characters: \"You are accessing a U.S. Government (USG) Information System (IS) that is provided for USG-authorized use only. By using this IS (which includes any device attached to this IS), you consent to the following conditions: -The USG routinely intercepts and monitors communications on this IS for purposes including, but not limited to, penetration testing, COMSEC monitoring, network operations and defense, personnel misconduct (PM), law enforcement (LE), and counterintelligence (CI) investigations. -At any time, the USG may inspect and seize data stored on this IS. -Communications using, or data stored on, this IS are not private, are subject to routine monitoring, interception, and search, and may be disclosed or used for any USG-authorized purpose. -This IS includes security measures (e.g., authentication and access controls) to protect USG interests--not for your personal benefit or privacy. -Notwithstanding the above, using this IS does not constitute consent to PM, LE or CI investigative searching or monitoring of the content of privileged communications, or work product, related to personal representation or services by attorneys, psychotherapists, or clergy, and their assistants. Such communications and work product are private and confidential. See User Agreement for details.\" Use the following verbiage for operating systems that have severe limitations on the number of characters that can be displayed in the banner: \"I've read & consent to terms in IS user agreem't.\" Satisfies: SRG-APP-000068, SRG-APP-000069, SRG-APP-000070",
+ "Checks": [
+ "signon_dod_warning_banner_configured"
+ ],
+ "Attributes": [
+ {
+ "Section": "CAT II (Medium)",
+ "Severity": "medium",
+ "RuleID": "SV-273192r1098843_rule",
+ "StigID": "OKTA-APP-000200",
+ "CCI": [
+ "CCI-000048",
+ "CCI-000050",
+ "CCI-001384",
+ "CCI-001385",
+ "CCI-001386",
+ "CCI-001387",
+ "CCI-001388"
+ ],
+ "CheckText": "Attempt to log in to the Okta tenant and verify the DOD-approved warning banner is in place. If the required warning banner is not present and complete, this is a finding.",
+ "FixText": "Follow the supplemental instructions in the \"Okta DOD Warning Banner Configuration Guide\" provided with this STIG package."
+ }
+ ]
+ },
+ {
+ "Id": "OKTA-APP-000560",
+ "Name": "The Okta Admin Console application must be configured to use multifactor authentication.",
+ "Description": "Without the use of multifactor authentication, the ease of access to privileged functions is greatly increased. Multifactor authentication requires using two or more factors to achieve authentication. Factors include: (i) something a user knows (e.g., password/PIN); (ii) something a user has (e.g., cryptographic identification device, token); or (iii) something a user is (e.g., biometric). A privileged account is defined as an information system account with authorizations of a privileged user. Network access is defined as access to an information system by a user (or a process acting on behalf of a user) communicating through a network (e.g., local area network, wide area network, or the internet). Satisfies: SRG-APP-000149, SRG-APP-000154",
+ "Checks": [
+ "application_admin_console_mfa_required"
+ ],
+ "Attributes": [
+ {
+ "Section": "CAT I (High)",
+ "Severity": "high",
+ "RuleID": "SV-273193r1098846_rule",
+ "StigID": "OKTA-APP-000560",
+ "CCI": [
+ "CCI-000765",
+ "CCI-004046"
+ ],
+ "CheckText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Admin Console\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"User must authenticate with\" field, verify that either \"Password/IdP + Another factor\" or \"Any 2 factor types\" is selected. If either of these settings is incorrect, this is a finding.",
+ "FixText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Admin Console\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"User must authenticate with\" field, select either \"Password/IdP + Another factor\" or \"Any 2 factor types\"."
+ }
+ ]
+ },
+ {
+ "Id": "OKTA-APP-000570",
+ "Name": "The Okta Dashboard application must be configured to use multifactor authentication.",
+ "Description": "To ensure accountability and prevent unauthenticated access, nonprivileged users must use multifactor authentication to prevent potential misuse and compromise of the system. Multifactor authentication uses two or more factors to achieve authentication. Factors include: (i) Something you know (e.g., password/PIN); (ii) Something you have (e.g., cryptographic identification device, token); or (iii) Something you are (e.g., biometric). A nonprivileged account is any information system account with authorizations of a nonprivileged user. Network access is any access to an application by a user (or process acting on behalf of a user) where the access is obtained through a network connection. Applications integrating with the DOD Active Directory and using the DOD CAC are examples of compliant multifactor authentication solutions. Satisfies: SRG-APP-000150, SRG-APP-000155",
+ "Checks": [
+ "application_dashboard_mfa_required"
+ ],
+ "Attributes": [
+ {
+ "Section": "CAT I (High)",
+ "Severity": "high",
+ "RuleID": "SV-273194r1098849_rule",
+ "StigID": "OKTA-APP-000570",
+ "CCI": [
+ "CCI-000766",
+ "CCI-004046"
+ ],
+ "CheckText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Dashboard\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"User must authenticate with\" field, verify that either \"Password/IdP + Another factor\" or \"Any 2 factor types\" is selected. If either of these settings is incorrect, this is a finding.",
+ "FixText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Dashboard\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"User must authenticate with\" field, select either \"Password/IdP + Another factor\" or \"Any 2 factor types\"."
+ }
+ ]
+ },
+ {
+ "Id": "OKTA-APP-000650",
+ "Name": "Okta must enforce a minimum 15-character password length.",
+ "Description": "Password complexity, or strength, is a measure of the effectiveness of a password in resisting attempts at guessing and brute-force attacks. Password length is one factor of several that helps to determine strength and how long it takes to crack a password. The shorter the password, the lower the number of possible combinations that need to be tested before the password is compromised. Use of more characters in a password helps to exponentially increase the time and/or resources required to compromise the password.",
+ "Checks": [
+ "authenticator_password_minimum_length_15"
+ ],
+ "Attributes": [
+ {
+ "Section": "CAT II (Medium)",
+ "Severity": "medium",
+ "RuleID": "SV-273195r1098852_rule",
+ "StigID": "OKTA-APP-000650",
+ "CCI": [
+ "CCI-004066"
+ ],
+ "CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy, verify the \"Minimum Length\" field is set to at least \"15\" characters. If any policy is not set to at least \"15\", this is a finding.",
+ "FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set the \"Minimum Length\" field to at least \"15\" characters."
+ }
+ ]
+ },
+ {
+ "Id": "OKTA-APP-000670",
+ "Name": "Okta must enforce password complexity by requiring that at least one uppercase character be used.",
+ "Description": "Use of a complex password helps to increase the time and resources required to compromise the password. Password complexity, or strength, is a measure of the effectiveness of a password in resisting attempts at guessing and brute-force attacks. Password complexity is one factor of several that determine how long it takes to crack a password. The more complex the password is, the greater the number of possible combinations that need to be tested before the password is compromised.",
+ "Checks": [
+ "authenticator_password_complexity_uppercase"
+ ],
+ "Attributes": [
+ {
+ "Section": "CAT II (Medium)",
+ "Severity": "medium",
+ "RuleID": "SV-273196r1098855_rule",
+ "StigID": "OKTA-APP-000670",
+ "CCI": [
+ "CCI-004066"
+ ],
+ "CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy, verify \"Upper case letter\" is checked. For each policy, if \"Upper case letter\" is not checked, this is a finding.",
+ "FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set \"Upper case letter\" to checked."
+ }
+ ]
+ },
+ {
+ "Id": "OKTA-APP-000680",
+ "Name": "Okta must enforce password complexity by requiring that at least one lowercase character be used.",
+ "Description": "Use of a complex password helps to increase the time and resources required to compromise the password. Password complexity, or strength, is a measure of the effectiveness of a password in resisting attempts at guessing and brute-force attacks. Password complexity is one factor of several that determine how long it takes to crack a password. The more complex the password, the greater the number of possible combinations that need to be tested before the password is compromised.",
+ "Checks": [
+ "authenticator_password_complexity_lowercase"
+ ],
+ "Attributes": [
+ {
+ "Section": "CAT II (Medium)",
+ "Severity": "medium",
+ "RuleID": "SV-273197r1098858_rule",
+ "StigID": "OKTA-APP-000680",
+ "CCI": [
+ "CCI-004066"
+ ],
+ "CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy, verify \"Lower case letter\" is checked. For each policy, if \"Lower case letter\" is not checked, this is a finding.",
+ "FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set \"Lower case letter\" to checked."
+ }
+ ]
+ },
+ {
+ "Id": "OKTA-APP-000690",
+ "Name": "Okta must enforce password complexity by requiring that at least one numeric character be used.",
+ "Description": "Use of a complex password helps to increase the time and resources required to compromise the password. Password complexity, or strength, is a measure of the effectiveness of a password in resisting attempts at guessing and brute-force attacks. Password complexity is one factor of several that determine how long it takes to crack a password. The more complex the password, the greater the number of possible combinations that need to be tested before the password is compromised.",
+ "Checks": [
+ "authenticator_password_complexity_number"
+ ],
+ "Attributes": [
+ {
+ "Section": "CAT II (Medium)",
+ "Severity": "medium",
+ "RuleID": "SV-273198r1098861_rule",
+ "StigID": "OKTA-APP-000690",
+ "CCI": [
+ "CCI-004066"
+ ],
+ "CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy, verify \"Number (0-9)\" is checked. For each policy, if \"Number (0-9)\" is not checked, this is a finding.",
+ "FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set \"Number (0-9)\" to checked."
+ }
+ ]
+ },
+ {
+ "Id": "OKTA-APP-000700",
+ "Name": "Okta must enforce password complexity by requiring that at least one special character be used.",
+ "Description": "Use of a complex password helps to increase the time and resources required to compromise the password. Password complexity, or strength, is a measure of the effectiveness of a password in resisting attempts at guessing and brute-force attacks. Password complexity is one factor in determining how long it takes to crack a password. The more complex the password, the greater the number of possible combinations that need to be tested before the password is compromised. Special characters are not alphanumeric. Examples include: ~ ! @ # $ % ^ *.",
+ "Checks": [
+ "authenticator_password_complexity_symbol"
+ ],
+ "Attributes": [
+ {
+ "Section": "CAT II (Medium)",
+ "Severity": "medium",
+ "RuleID": "SV-273199r1098864_rule",
+ "StigID": "OKTA-APP-000700",
+ "CCI": [
+ "CCI-004066"
+ ],
+ "CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy, verify \"Symbol (e.g., !@#$%^&*)\" is checked. For each policy, if \"Symbol (e.g., !@#$%^&*)\" is not checked, this is a finding.",
+ "FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set \"Symbol (e.g., !@#$%^&*)\" to checked."
+ }
+ ]
+ },
+ {
+ "Id": "OKTA-APP-000740",
+ "Name": "Okta must enforce 24 hours/one day as the minimum password lifetime.",
+ "Description": "Enforcing a minimum password lifetime helps prevent repeated password changes to defeat the password reuse or history enforcement requirement. Restricting this setting limits the user's ability to change their password. Passwords must be changed at specific policy-based intervals; however, if the application allows the user to immediately and continually change their password, it could be changed repeatedly in a short period of time to defeat the organization's policy regarding password reuse. Satisfies: SRG-APP-000173, SRG-APP-000870",
+ "Checks": [
+ "authenticator_password_minimum_age_24h"
+ ],
+ "Attributes": [
+ {
+ "Section": "CAT II (Medium)",
+ "Severity": "medium",
+ "RuleID": "SV-273200r1098867_rule",
+ "StigID": "OKTA-APP-000740",
+ "CCI": [
+ "CCI-004066"
+ ],
+ "CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy, verify \"Minimum password age is XX hours\" is set to at least \"24\". For each policy, if \"Minimum password age is XX hours\" is not set to at least \"24\", this is a finding.",
+ "FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set \"Minimum password age is XX hours\" to at least \"24\"."
+ }
+ ]
+ },
+ {
+ "Id": "OKTA-APP-000745",
+ "Name": "Okta must enforce a 60-day maximum password lifetime restriction.",
+ "Description": "Any password, no matter how complex, can eventually be cracked. Therefore, passwords must be changed at specific intervals. One method of minimizing this risk is to use complex passwords and periodically change them. If the application does not limit the lifetime of passwords and force users to change their passwords, there is the risk that the system and/or application passwords could be compromised. This requirement does not include emergency administration accounts, which are meant for access to the application in case of failure. These accounts are not required to have maximum password lifetime restrictions.",
+ "Checks": [
+ "authenticator_password_maximum_age_60d"
+ ],
+ "Attributes": [
+ {
+ "Section": "CAT II (Medium)",
+ "Severity": "medium",
+ "RuleID": "SV-273201r1098870_rule",
+ "StigID": "OKTA-APP-000745",
+ "CCI": [
+ "CCI-004066"
+ ],
+ "CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy, verify \"Password expires after XX days\" is set to \"60\". For each policy, if \"Password expires after XX days\" is not set to \"60\", this is a finding.",
+ "FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set \"Password expires after XX days\" to \"60\"."
+ }
+ ]
+ },
+ {
+ "Id": "OKTA-APP-001430",
+ "Name": "Okta must off-load audit records onto a central log server.",
+ "Description": "Information stored in one location is vulnerable to accidental or incidental deletion or alteration. Off-loading is a common process in information systems with limited audit storage capacity. Satisfies: SRG-APP-000358, SRG-APP-000080, SRG-APP-000125",
+ "Checks": [
+ "systemlog_streaming_enabled"
+ ],
+ "Attributes": [
+ {
+ "Section": "CAT I (High)",
+ "Severity": "high",
+ "RuleID": "SV-273202r1099766_rule",
+ "StigID": "OKTA-APP-001430",
+ "CCI": [
+ "CCI-001851",
+ "CCI-000166",
+ "CCI-001348"
+ ],
+ "CheckText": "From the Admin Console: 1. Go to Reports >> Log Streaming. 2. Verify that a Log Stream connection is configured and active. Alternately, interview the information system security manager (ISSM) and verify that an external Security Information and Event Management (SIEM) system is pulling Okta logs via an Application Programming Interface (API). If either of these is not configured, this is a finding.",
+ "FixText": "From the Admin Console: 1. Go to Reports >> Log Streaming. 2. Select either \"AWS EventBridge\" or \"Splunk Cloud\" and click \"Next\". 3. Complete the necessary fields and click \"Save\". If Log Streaming is not an option because the SIEM required is not an option, customers can use the Okta Log API to export system logs in real time."
+ }
+ ]
+ },
+ {
+ "Id": "OKTA-APP-001665",
+ "Name": "Okta must be configured to limit the global session lifetime to 18 hours.",
+ "Description": "Without reauthentication, users may access resources or perform tasks for which they do not have authorization. When applications provide the capability to change security roles or escalate the functional capability of the application, it is critical the user reauthenticate. In addition to the reauthentication requirements associated with session locks, organizations may require reauthentication of individuals and/or devices in other situations, including (but not limited to) the following circumstances. (i) When authenticators change; (ii) When roles change; (iii) When security categories of information systems change; (iv) When the execution of privileged functions occurs; (v) After a fixed period of time; or (vi) Periodically. Within the DOD, the minimum circumstances requiring reauthentication are privilege escalation and role changes.",
+ "Checks": [
+ "signon_global_session_lifetime_18h"
+ ],
+ "Attributes": [
+ {
+ "Section": "CAT II (Medium)",
+ "Severity": "medium",
+ "RuleID": "SV-273203r1099958_rule",
+ "StigID": "OKTA-APP-001665",
+ "CCI": [
+ "CCI-002038"
+ ],
+ "CheckText": "From the Admin Console: 1. Select Security >> Global Session Policy. 2. In the Default Policy, verify a rule is configured at Priority 1 that is not named \"Default Rule\". 3. Click the \"Edit\" icon next to the Priority 1 rule. 4. Verify \"Maximum Okta global session lifetime\" is set to 18 hours. If the above is not set, this is a finding.",
+ "FixText": "From the Admin Console: 1. Go to Security >> Global Session Policy. 2. Select the Default Policy. 3. In the Rules table, make these updates: - Click \"Add rule\". - Set \"Maximum Okta global session lifetime\" to 18 hours."
+ }
+ ]
+ },
+ {
+ "Id": "OKTA-APP-001670",
+ "Name": "Okta must be configured to accept Personal Identity Verification (PIV) credentials.",
+ "Description": "The use of PIV credentials facilitates standardization and reduces the risk of unauthorized access. DOD has mandated the use of the common access card (CAC) to support identity management and personal authentication for systems covered under HSPD 12, as well as a primary component of layered protection for national security systems. Satisfies: SRG-APP-000391, SRG-APP-000402, SRG-APP-000403",
+ "Checks": [
+ "authenticator_smart_card_active"
+ ],
+ "Attributes": [
+ {
+ "Section": "CAT II (Medium)",
+ "Severity": "medium",
+ "RuleID": "SV-273204r1098879_rule",
+ "StigID": "OKTA-APP-001670",
+ "CCI": [
+ "CCI-001953",
+ "CCI-002009",
+ "CCI-002010"
+ ],
+ "CheckText": "From the Admin Console: 1. Go to Security >> Authenticators. 2. Verify that \"Smart Card Authenticator\" is listed and has \"Status\" listed as \"Active\". If \"Smart Card Authenticator\" is not listed or is not listed as \"Active\", this is a finding.",
+ "FixText": "From the Admin Console: 1. Go to Security >> Authenticators. 2. In the \"Setup\" tab, click \"Add authenticator\". 3. Select the configured Smart Card Identity Provider and finish configuration."
+ }
+ ]
+ },
+ {
+ "Id": "OKTA-APP-001700",
+ "Name": "The Okta Verify application must be configured to connect only to FIPS-compliant devices.",
+ "Description": "Without device-to-device authentication, communications with malicious devices may be established. Bidirectional authentication provides stronger safeguards to validate the identity of other devices for connections that are of greater risk. Currently, DOD requires the use of AES for bidirectional authentication because it is the only FIPS-validated AES cipher block algorithm. For distributed architectures (e.g., service-oriented architectures), the decisions regarding the validation of authentication claims may be made by services separate from the services acting on those decisions. In such situations, it is necessary to provide authentication decisions (as opposed to the actual authenticators) to the services that need to act on those decisions. A local connection is any connection with a device communicating without the use of a network. A network connection is any connection with a device that communicates through a network (e.g., local area or wide area network; the internet). A remote connection is any connection with a device communicating through an external network (e.g., the internet). Because of the challenges of applying this requirement on a large scale, organizations are encouraged to apply the requirement only to those limited number (and type) of devices that truly need to support this capability.",
+ "Checks": [
+ "authenticator_okta_verify_fips_compliant"
+ ],
+ "Attributes": [
+ {
+ "Section": "CAT II (Medium)",
+ "Severity": "medium",
+ "RuleID": "SV-273205r1098882_rule",
+ "StigID": "OKTA-APP-001700",
+ "CCI": [
+ "CCI-001967"
+ ],
+ "CheckText": "From the Admin Console: 1. Go to Security >> Authenticators. 2. From the \"Setup\" tab, select \"Edit Okta Verify\". 3. Review the \"FIPS Compliance\" field. If FIPS-compliant authentication is not enabled, this is a finding.",
+ "FixText": "From the Admin Console: 1. Go to Security >> Authenticators. 2. From the \"Setup\" tab, select \"Edit Okta Verify\". 3. In the \"FIPS Compliance\" field, choose whether users enrolling in Okta Verify can use FIPS-compliant devices only or any device. 4. Click \"Save\" after making any changes."
+ }
+ ]
+ },
+ {
+ "Id": "OKTA-APP-001710",
+ "Name": "Okta must be configured to disable persistent global session cookies.",
+ "Description": "If cached authentication information is out of date, the validity of the authentication information may be questionable. Satisfies: SRG-APP-000400, SRG-APP-000157",
+ "Checks": [
+ "signon_global_session_cookies_not_persistent"
+ ],
+ "Attributes": [
+ {
+ "Section": "CAT II (Medium)",
+ "Severity": "medium",
+ "RuleID": "SV-273206r1098885_rule",
+ "StigID": "OKTA-APP-001710",
+ "CCI": [
+ "CCI-002007",
+ "CCI-001942"
+ ],
+ "CheckText": "From the Admin Console: 1. Select Security >> Global Session Policy. 2. In the Default Policy, verify a rule is configured at Priority 1 that is not named \"Default Rule\". 3. Click the \"Edit\" icon next to the Priority 1 rule. 4. Verify \"Okta global session cookies persist across browser sessions\" is set to \"Disabled\". If the above it not set, this is a finding.",
+ "FixText": "From the Admin Console: 1. Go to Security >> Global Session Policy. 2. Select the Default Policy. 3. In the \"Rules\" table, make these updates: - Click \"Add rule\". - Set \"Okta global session cookies persist across browser sessions\" to Disable."
+ }
+ ]
+ },
+ {
+ "Id": "OKTA-APP-001920",
+ "Name": "Okta must be configured to use only DOD-approved certificate authorities.",
+ "Description": "Untrusted Certificate Authorities (CA) can issue certificates, but they may be issued by organizations or individuals that seek to compromise DOD systems or by organizations with insufficient security controls. If the CA used for verifying the certificate is not DOD approved, trust of this CA has not been established. The DOD will accept only PKI certificates obtained from a DOD-approved internal or external CA. Reliance on CAs for the establishment of secure sessions includes, for example, the use of Transport Layer Security (TLS) certificates. This requirement focuses on communications protection for the application session rather than for the network packet. This requirement applies to applications that use communications sessions. This includes, but is not limited to, web-based applications and Service-Oriented Architectures (SOA). Satisfies: SRG-APP-000427, SRG-APP-000910",
+ "Checks": [
+ "idp_smart_card_dod_approved_ca"
+ ],
+ "Attributes": [
+ {
+ "Section": "CAT II (Medium)",
+ "Severity": "medium",
+ "RuleID": "SV-273207r1098888_rule",
+ "StigID": "OKTA-APP-001920",
+ "CCI": [
+ "CCI-002470",
+ "CCI-004909"
+ ],
+ "CheckText": "From the Admin Console: 1. Select Security >> Identity Providers (IdPs). 2. Review the list of IdPs with \"Type\" as \"Smart Card\". If the IdP is not listed as \"Active\", this is a finding. 3. Select Actions >> Configure. 4. Under \"Certificate chain\", verify the certificate is from a DOD-approved CA. If the certificate is not from a DOD-approved CA, this is a finding.",
+ "FixText": "From the Admin Console: 1. Go to Security >> Identity Providers. 2. Click \"Add identity provider.\" 3. Click \"Smart Card IdP\". Click \"Next\". 4. Enter the name of the identity provider. 5. Build a certificate chain: - Click \"Browse\" to open a file explorer. Select the certificate file to add and click \"Open\". - To add another certificate, click \"Add Another\" and repeat step 1. - Click \"Build certificate chain\". On success, the chain and its certificates are shown. If the build failed, correct any issues and try again. - Click \"Reset certificate chain\" if replacing the current chain with a new one. 6. In \"IdP username\", select the \"idpuser.subjectAltNameUpn\" attribute. This is the attribute that stores the Electronic Data Interchange Personnel Identifier (EDIPI) on the CAC. 7. In the \"Match Against\" field, select the Okta Profile Attribute in which the EDIPI is to be stored."
+ }
+ ]
+ },
+ {
+ "Id": "OKTA-APP-002980",
+ "Name": "Okta must validate passwords against a list of commonly used, expected, or compromised passwords.",
+ "Description": "Password-based authentication applies to passwords regardless of whether they are used in single-factor or multifactor authentication. Long passwords or passphrases are preferable over shorter passwords. Enforced composition rules provide marginal security benefits while decreasing usability. However, organizations may choose to establish certain rules for password generation (e.g., minimum character length for long passwords) under certain circumstances and can enforce this requirement in IA-5(1)(h). Account recovery can occur, for example, in situations when a password is forgotten. Cryptographically protected passwords include salted one-way cryptographic hashes of passwords. The list of commonly used, compromised, or expected passwords includes passwords obtained from previous breach corpuses, dictionary words, and repetitive or sequential characters. The list includes context-specific words, such as the name of the service, username, and derivatives thereof.",
+ "Checks": [
+ "authenticator_password_common_password_check"
+ ],
+ "Attributes": [
+ {
+ "Section": "CAT II (Medium)",
+ "Severity": "medium",
+ "RuleID": "SV-273208r1099769_rule",
+ "StigID": "OKTA-APP-002980",
+ "CCI": [
+ "CCI-004058"
+ ],
+ "CheckText": "From the Admin Console: 1. Navigate to Security >> Authenticators. 2. Click the \"Actions\" button next to the Password authenticator and select \"Edit\". 3. Under the \"Password Settings\" section, verify the \"Common Password Check\" box is checked. If \"Common Password Check\" is not selected, this is a finding.",
+ "FixText": "From the Admin Console: 1. Navigate to Security >> Authenticators. 2. Click the \"Actions\" button next to the Password authenticator and select \"Edit\". 3. Under the \"Password Settings\" section, check the \"Common Password Check\" box."
+ }
+ ]
+ },
+ {
+ "Id": "OKTA-APP-003010",
+ "Name": "Okta must prohibit password reuse for a minimum of five generations.",
+ "Description": "Password-based authentication applies to passwords regardless of whether they are used in single-factor or multifactor authentication. Long passwords or passphrases are preferable over shorter passwords. Enforced composition rules provide marginal security benefits while decreasing usability. However, organizations may choose to establish certain rules for password generation (e.g., minimum character length for long passwords) under certain circumstances and can enforce this requirement in IA-5(1)(h). Account recovery can occur, for example, in situations when a password is forgotten. Cryptographically protected passwords include salted one-way cryptographic hashes of passwords. The list of commonly used, compromised, or expected passwords includes passwords obtained from previous breach corpuses, dictionary words, and repetitive or sequential characters. The list includes context-specific words, such as the name of the service, username, and derivatives thereof.",
+ "Checks": [
+ "authenticator_password_history_5"
+ ],
+ "Attributes": [
+ {
+ "Section": "CAT II (Medium)",
+ "Severity": "medium",
+ "RuleID": "SV-273209r1098894_rule",
+ "StigID": "OKTA-APP-003010",
+ "CCI": [
+ "CCI-004061"
+ ],
+ "CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password row\" and select \"Edit\". 3. For each listed policy, verify \"Enforce password history for last XX passwords\" is set to \"5\". If any policy is not set to at least \"5\", this is a finding.",
+ "FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set \"Enforce password history for last XX passwords\" to \"5\"."
+ }
+ ]
+ },
+ {
+ "Id": "OKTA-APP-003240",
+ "Name": "Okta API tokens must be configured with Network Zones to restrict authorization from known networks.",
+ "Description": "An access token is a piece of data that represents the authorization granted to a user or NPE to access specific systems or information resources. Access tokens enable controlled access to services and resources. Properly managing the lifecycle of access tokens, including their issuance, validation, and revocation, is crucial to maintaining confidentiality of data and systems. Restricting token validity to a specific audience, e.g., an application or security domain, and restricting token validity lifetimes are important practices. Access tokens are revoked or invalidated if they are compromised, lost, or are no longer needed to mitigate the risks associated with stolen or misused tokens. API tokens have the potential to be replicated or stolen (just like a password). Because of this, it is important to only allow API tokens to authenticate from known IP ranges as this limits an adversary's ability to use a token to gain access.",
+ "Checks": [
+ "apitoken_restricted_to_network_zone"
+ ],
+ "Attributes": [
+ {
+ "Section": "CAT II (Medium)",
+ "Severity": "medium",
+ "RuleID": "SV-279689r1155066_rule",
+ "StigID": "OKTA-APP-003240",
+ "CCI": [
+ "CCI-005165",
+ "CCI-000366"
+ ],
+ "CheckText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"API\" item. 2. Click the \"Tokens\" tab. 3. For each token listed, click the token name link. 4. In the \"Security\" section, verify the \"Token can be used from\" setting is mapped to a known network zone for the application calling the API. If a network zone for each API access token is not defined, this is a finding.",
+ "FixText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"API\" item. 2. Click the \"Tokens\" tab. 3. For each token listed, click the token name link. 4. In the \"Security\" section, click \"Edit\". 5. Set the \"Token can be used from\" setting to the known network zone for the application calling the API. 6. Click \"Save\"."
+ }
+ ]
+ },
+ {
+ "Id": "OKTA-APP-003241",
+ "Name": "Okta API tokens must be created under new dedicated user accounts.",
+ "Description": "An access token is a piece of data that represents the authorization granted to a user or NPE to access specific systems or information resources. Access tokens enable controlled access to services and resources. Properly managing the lifecycle of access tokens, including their issuance, validation, and revocation, is crucial to maintaining confidentiality of data and systems. Restricting token validity to a specific audience, e.g., an application or security domain, and restricting token validity lifetimes are important practices. Access tokens are revoked or invalidated if they are compromised, lost, or are no longer needed to mitigate the risks associated with stolen or misused tokens. When API tokens are created, they inherit the permissions of the user that created them. Therefore, API tokens should only be created from dedicated accounts and permissions must be constrained to least privilege for that dedicated user account and token. No API tokens should be created using a Super Admin account.",
+ "Checks": [
+ "apitoken_not_super_admin"
+ ],
+ "Attributes": [
+ {
+ "Section": "CAT II (Medium)",
+ "Severity": "medium",
+ "RuleID": "SV-279690r1155069_rule",
+ "StigID": "OKTA-APP-003241",
+ "CCI": [
+ "CCI-005165",
+ "CCI-000366"
+ ],
+ "CheckText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"API\" item. 2. Click the \"Tokens\" tab. 3. For each token listed, verify that the Role listed is not \"Super Admin\", and that the account has been specifically created for that token. 4. Click the account name to be token to the user profile for that user. 5. Verify the user only has an administrator role (standard or customer) applied that is correctly scoped as required and documented in the Okta Access Control policy. If the token is using a Super Administrator account, or one that is not properly scoped per the Access Control policy, this is a finding. Note: If a Super Admin token is required for system operation, then this permanent finding.",
+ "FixText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"API\" item. 2. Click the \"Tokens\" tab. 3. For each token listed that has \"Super Admin\" or an improperly scoped Admin account, delete the token and create a new one with the appropriately scoped permissions. 4. Verify the application performing the API calls with the new token has been updated."
+ }
+ ]
+ },
+ {
+ "Id": "OKTA-APP-003242",
+ "Name": "The Okta Global Session policy must be configured to allow or deny IP based access in accordance with the Access Control policy for Okta.",
+ "Description": "To mitigate the risk of unauthorized access to sensitive information by entities that have been issued certificates by DOD-approved PKIs, all DOD systems (e.g., networks, web servers, and web portals) must be properly configured to incorporate access control methods that do not rely solely on the possession of a certificate for access. Successful authentication must not automatically give an entity access to an asset or security boundary. Authorization procedures and controls must be implemented to ensure each authenticated entity also has a validated and current authorization. Authorization is the process of determining whether an entity, once authenticated, is permitted to access a specific asset. Information systems use access control policies and enforcement mechanisms to implement this requirement. Access Control policies include identity-based policies, role-based policies, and attribute-based policies. Access enforcement mechanisms include access control lists, access control matrices, and cryptography. These policies and mechanisms must be employed by the application to control access between users (or processes acting on behalf of users) and objects (e.g., devices, files, records, processes, programs, and domains) in the information system. The Okta Global Session Policy is applied at the organization level and before any application-specific authentication policies are processed. The Okta authorization package should contain an access control policy that defines IP ranges from which to either allow or deny access. This list (either as an explicit allow or explicit deny) can be implemented in the Global Session Policy.",
+ "Checks": [
+ "signon_global_session_policy_network_zone_enforced"
+ ],
+ "Attributes": [
+ {
+ "Section": "CAT II (Medium)",
+ "Severity": "medium",
+ "RuleID": "SV-279691r1155072_rule",
+ "StigID": "OKTA-APP-003242",
+ "CCI": [
+ "CCI-000213"
+ ],
+ "CheckText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"Global Session Policy\" item. 2. In the \"Policy Settings\" section, verify the \"IF User's IP is\" setting is correctly set to either allow or deny based on the organization defined policy. If the Okta Global Session Policy is not configured to restrict access to specific IP ranges, this is a finding.",
+ "FixText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"Global Session Policy\" item. 2. In the Policy Settings section, configure the \"IF User's IP is\" setting to correctly set the appropriate network to either allow or deny based on the Access Control Policy."
+ }
+ ]
+ },
+ {
+ "Id": "OKTA-APP-003243",
+ "Name": "Okta must be configured with Network Zones defined to block anonymized proxies according to organizationally defined policy.",
+ "Description": "A mechanism to detect and prevent unauthorized communication flow must be configured or provided as part of the system design. If information flow is not enforced based on approved authorizations, the system may become compromised. Information flow control regulates where information is allowed to travel within a system and between interconnected systems. The flow of all application information must be monitored and controlled so it does not introduce any unacceptable risk to the systems or data. Application-specific examples of enforcement occurs in systems that employ rule sets or establish configuration settings that restrict information system services, or provide a message filtering capability based on message content (e.g., implementing key word searches or using document characteristics). Applications providing information flow control must be able to enforce approved authorizations for controlling the flow of information between interconnected systems in accordance with applicable policy. Working with the organizational CSSP, the ISSM should obtain a list of known anonymizer proxies that exist on the commercial internet. If this is not available from the CSSP, then the Okta-provided \"Enhanced dynamic zone blocklist\" should be activated.",
+ "Checks": [
+ "network_zone_block_anonymized_proxies"
+ ],
+ "Attributes": [
+ {
+ "Section": "CAT II (Medium)",
+ "Severity": "medium",
+ "RuleID": "SV-279692r1155075_rule",
+ "StigID": "OKTA-APP-003243",
+ "CCI": [
+ "CCI-001414"
+ ],
+ "CheckText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"Networks' item. 2. If the CSSP has provided a list of anonymizers to block, verify the \"IP Block list\" is configured with them. a. Click the pencil icon next to IP Block list. b. Verify the \"Gateway IPs\" section contains all of the IP ranges in the provided list. 3. If the CSSP is not able to provide a list, then implement the Okta managed list. a. Verify the \"Enhanced dynamic zone blocklist\" is set to \"Active\". If Network Zones are not configured to block anonymous proxies, this is a finding.",
+ "FixText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"Networks\" item. 2. If the CSSP has provided a list of anonymizers to block, add the IP ranges to the \"IP Block list\". a. Click the pencil icon next to IP Block list. b. Add the IP ranges to the \"Gateway IPs\" section and click \"Save\". 3. If the CSSP is not able to provide a list, then implement the Okta managed list. a. Set the \"Enhanced dynamic zone blocklist\" to \"Active\"."
+ }
+ ]
+ },
+ {
+ "Id": "OKTA-APP-003244",
+ "Name": "For each application integrated with Okta, network zones must be defined in its authentication policy.",
+ "Description": "A mechanism to detect and prevent unauthorized communication flow must be configured or provided as part of the system design. If information flow is not enforced based on approved authorizations, the system may become compromised. Information flow control regulates where information is allowed to travel within a system and between interconnected systems. The flow of all application information must be monitored and controlled so it does not introduce any unacceptable risk to the systems or data. Application-specific examples of enforcement occurs in systems that employ rule sets or establish configuration settings that restrict information system services, or provide a message filtering capability based on message content (e.g., implementing key word searches or using document characteristics). Applications providing information flow control must be able to enforce approved authorizations for controlling the flow of information between interconnected systems in accordance with applicable policy. Each application in Okta should have a well defined access control policy that takes into account the end user network. This should be documented in the Access Control policy for each application. As an example, access to an application may be restricted to a specific location by policy. In this case, a network defining that specific location should be created.",
+ "Checks": [
+ "application_authentication_policy_network_zone_enforced"
+ ],
+ "Attributes": [
+ {
+ "Section": "CAT II (Medium)",
+ "Severity": "medium",
+ "RuleID": "SV-279693r1155078_rule",
+ "StigID": "OKTA-APP-003244",
+ "CCI": [
+ "CCI-001414"
+ ],
+ "CheckText": "For each application integrated into Okta: 1. From the Admin console, open the \"Security\" menu, and then select \"Networks\". 2. Verify the list of networks includes all necessary allow or block lists. If any application is not configured with network zones, this is a finding.",
+ "FixText": "For each application, starting at the admin console: 1. Open the \"Applications\" group from the Menu, and then click the \"Applications\" menu item. 2. Click the application name. 3. Click the \"Sign On\" tab. 4. Scroll to the \"User Authentication\" section, and then click \"Edit\". 5. Select the appropriate Authentication policy from the pull down, and then click \"Save\". 6. Click \"View Policy Details\". 7. For each nondefault rule: a. Select \"Edit\" from the Actions menu. b. In the \"IF\" section, verify the \"User is\" setting has the appropriate allow or deny range has been selected based on the Access Control policy for the application. c. Scroll down to the bottom and click \"Save\". 8. For the Catch-All rule: a. Select \"Edit\" from the Actions menu. b. Scroll down to the \"Then\" section. c. For the \"Access is\" setting, select \"Denied\", and then click \"Save\"."
+ }
+ ]
+ }
+ ]
+}
diff --git a/prowler/lib/check/compliance_models.py b/prowler/lib/check/compliance_models.py
index 3610893008..f883bf60b1 100644
--- a/prowler/lib/check/compliance_models.py
+++ b/prowler/lib/check/compliance_models.py
@@ -283,6 +283,26 @@ class CSA_CCM_Requirement_Attribute(BaseModel):
ScopeApplicability: list[dict]
+class STIG_Requirement_Attribute_Severity(str, Enum):
+ """DISA STIG Requirement Attribute Severity (maps to CAT I/II/III)"""
+
+ high = "high"
+ medium = "medium"
+ low = "low"
+
+
+class STIG_Requirement_Attribute(BaseModel):
+ """DISA STIG Requirement Attribute"""
+
+ Section: str
+ Severity: STIG_Requirement_Attribute_Severity
+ RuleID: str
+ StigID: str
+ CCI: Optional[list[str]] = None
+ CheckText: Optional[str] = None
+ FixText: Optional[str] = None
+
+
# Base Compliance Model
# TODO: move this to compliance folder
class Compliance_Requirement(BaseModel):
@@ -303,6 +323,7 @@ class Compliance_Requirement(BaseModel):
CCC_Requirement_Attribute,
C5Germany_Requirement_Attribute,
CSA_CCM_Requirement_Attribute,
+ STIG_Requirement_Attribute,
# Generic_Compliance_Requirement_Attribute must be the last one since it is the fallback for generic compliance framework
Generic_Compliance_Requirement_Attribute,
]
diff --git a/prowler/lib/outputs/compliance/compliance.py b/prowler/lib/outputs/compliance/compliance.py
index 65ad8af0b3..4e4bd78232 100644
--- a/prowler/lib/outputs/compliance/compliance.py
+++ b/prowler/lib/outputs/compliance/compliance.py
@@ -18,6 +18,9 @@ from prowler.lib.outputs.compliance.kisa_ismsp.kisa_ismsp import get_kisa_ismsp_
from prowler.lib.outputs.compliance.mitre_attack.mitre_attack import (
get_mitre_attack_table,
)
+from prowler.lib.outputs.compliance.okta_idaas_stig.okta_idaas_stig import (
+ get_okta_idaas_stig_table,
+)
from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore import (
get_prowler_threatscore_table,
)
@@ -252,6 +255,15 @@ def display_compliance_table(
output_directory,
compliance_overview,
)
+ elif compliance_framework.startswith("okta_idaas_stig"):
+ get_okta_idaas_stig_table(
+ findings,
+ bulk_checks_metadata,
+ compliance_framework,
+ output_filename,
+ output_directory,
+ compliance_overview,
+ )
else:
# Try provider-specific table first, fall back to generic
from prowler.providers.common.provider import Provider
diff --git a/prowler/lib/outputs/compliance/okta_idaas_stig/__init__.py b/prowler/lib/outputs/compliance/okta_idaas_stig/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/lib/outputs/compliance/okta_idaas_stig/models.py b/prowler/lib/outputs/compliance/okta_idaas_stig/models.py
new file mode 100644
index 0000000000..674d9656b7
--- /dev/null
+++ b/prowler/lib/outputs/compliance/okta_idaas_stig/models.py
@@ -0,0 +1,32 @@
+from typing import Optional
+
+from pydantic.v1 import BaseModel
+
+
+class OktaIDaaSSTIGModel(BaseModel):
+ """
+ OktaIDaaSSTIGModel generates a finding's output in DISA Okta IDaaS STIG Compliance format.
+ """
+
+ Provider: str
+ Description: str
+ OrganizationDomain: str
+ AssessmentDate: str
+ Requirements_Id: str
+ Requirements_Name: str
+ Requirements_Description: str
+ Requirements_Attributes_Section: str
+ Requirements_Attributes_Severity: str
+ Requirements_Attributes_RuleID: str
+ Requirements_Attributes_StigID: str
+ Requirements_Attributes_CCI: Optional[list[str]] = None
+ Requirements_Attributes_CheckText: Optional[str] = None
+ Requirements_Attributes_FixText: Optional[str] = None
+ Status: str
+ StatusExtended: str
+ ResourceId: str
+ ResourceName: str
+ CheckId: str
+ Muted: bool
+ Framework: str
+ Name: str
diff --git a/prowler/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig.py b/prowler/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig.py
new file mode 100644
index 0000000000..5c76055a06
--- /dev/null
+++ b/prowler/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig.py
@@ -0,0 +1,98 @@
+from colorama import Fore, Style
+from tabulate import tabulate
+
+from prowler.config.config import orange_color
+
+
+def get_okta_idaas_stig_table(
+ findings: list,
+ bulk_checks_metadata: dict,
+ compliance_framework: str,
+ output_filename: str,
+ output_directory: str,
+ compliance_overview: bool,
+):
+ section_table = {
+ "Provider": [],
+ "Section": [],
+ "Status": [],
+ "Muted": [],
+ }
+ pass_count = []
+ fail_count = []
+ muted_count = []
+ sections = {}
+ for index, finding in enumerate(findings):
+ check = bulk_checks_metadata[finding.check_metadata.CheckID]
+ check_compliances = check.Compliance
+ for compliance in check_compliances:
+ if compliance.Framework == "Okta-IDaaS-STIG":
+ for requirement in compliance.Requirements:
+ for attribute in requirement.Attributes:
+ section = attribute.Section
+
+ if section not in sections:
+ sections[section] = {"FAIL": 0, "PASS": 0, "Muted": 0}
+
+ if finding.muted:
+ if index not in muted_count:
+ muted_count.append(index)
+ sections[section]["Muted"] += 1
+ else:
+ if finding.status == "FAIL" and index not in fail_count:
+ fail_count.append(index)
+ sections[section]["FAIL"] += 1
+ elif finding.status == "PASS" and index not in pass_count:
+ pass_count.append(index)
+ sections[section]["PASS"] += 1
+
+ sections = dict(sorted(sections.items()))
+ for section in sections:
+ section_table["Provider"].append(compliance.Provider)
+ section_table["Section"].append(section)
+ if sections[section]["FAIL"] > 0:
+ section_table["Status"].append(
+ f"{Fore.RED}FAIL({sections[section]['FAIL']}){Style.RESET_ALL}"
+ )
+ else:
+ if sections[section]["PASS"] > 0:
+ section_table["Status"].append(
+ f"{Fore.GREEN}PASS({sections[section]['PASS']}){Style.RESET_ALL}"
+ )
+ else:
+ section_table["Status"].append(f"{Fore.GREEN}PASS{Style.RESET_ALL}")
+ section_table["Muted"].append(
+ f"{orange_color}{sections[section]['Muted']}{Style.RESET_ALL}"
+ )
+
+ if (
+ len(fail_count) + len(pass_count) + len(muted_count) > 1
+ ): # If there are no resources, don't print the compliance table
+ print(
+ f"\nCompliance Status of {Fore.YELLOW}{compliance_framework.upper()}{Style.RESET_ALL} Framework:"
+ )
+ total_findings_count = len(fail_count) + len(pass_count) + len(muted_count)
+ overview_table = [
+ [
+ f"{Fore.RED}{round(len(fail_count) / total_findings_count * 100, 2)}% ({len(fail_count)}) FAIL{Style.RESET_ALL}",
+ f"{Fore.GREEN}{round(len(pass_count) / total_findings_count * 100, 2)}% ({len(pass_count)}) PASS{Style.RESET_ALL}",
+ f"{orange_color}{round(len(muted_count) / total_findings_count * 100, 2)}% ({len(muted_count)}) MUTED{Style.RESET_ALL}",
+ ]
+ ]
+ print(tabulate(overview_table, tablefmt="rounded_grid"))
+ if not compliance_overview:
+ if len(fail_count) > 0 and len(section_table["Section"]) > 0:
+ print(
+ f"\nFramework {Fore.YELLOW}{compliance_framework.upper()}{Style.RESET_ALL} Results:"
+ )
+ print(
+ tabulate(
+ section_table,
+ tablefmt="rounded_grid",
+ headers="keys",
+ )
+ )
+ print(f"\nDetailed results of {compliance_framework.upper()} are in:")
+ print(
+ f" - CSV: {output_directory}/compliance/{output_filename}_{compliance_framework}.csv\n"
+ )
diff --git a/prowler/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_okta.py b/prowler/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_okta.py
new file mode 100644
index 0000000000..25f71b4def
--- /dev/null
+++ b/prowler/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_okta.py
@@ -0,0 +1,95 @@
+from prowler.config.config import timestamp
+from prowler.lib.check.compliance_models import Compliance
+from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput
+from prowler.lib.outputs.compliance.okta_idaas_stig.models import OktaIDaaSSTIGModel
+from prowler.lib.outputs.finding import Finding
+
+
+class OktaIDaaSSTIG(ComplianceOutput):
+ """
+ This class represents the Okta IDaaS STIG compliance output.
+
+ Attributes:
+ - _data (list): A list to store transformed data from findings.
+ - _file_descriptor (TextIOWrapper): A file descriptor to write data to a file.
+
+ Methods:
+ - transform: Transforms findings into Okta IDaaS STIG compliance format.
+ """
+
+ def transform(
+ self,
+ findings: list[Finding],
+ compliance: Compliance,
+ _compliance_name: str,
+ ) -> None:
+ """
+ Transforms a list of findings into Okta IDaaS STIG compliance format.
+
+ Parameters:
+ - findings (list): A list of findings.
+ - compliance (Compliance): A compliance model.
+ - _compliance_name (str): The name of the compliance model (unused).
+
+ Returns:
+ - None
+ """
+ for finding in findings:
+ for requirement in compliance.Requirements:
+ # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift).
+ if finding.check_id in requirement.Checks:
+ for attribute in requirement.Attributes:
+ compliance_row = OktaIDaaSSTIGModel(
+ Provider=finding.provider,
+ Description=compliance.Description,
+ OrganizationDomain=finding.account_name,
+ AssessmentDate=str(timestamp),
+ Requirements_Id=requirement.Id,
+ Requirements_Name=requirement.Name,
+ Requirements_Description=requirement.Description,
+ Requirements_Attributes_Section=attribute.Section,
+ Requirements_Attributes_Severity=attribute.Severity.value,
+ Requirements_Attributes_RuleID=attribute.RuleID,
+ Requirements_Attributes_StigID=attribute.StigID,
+ Requirements_Attributes_CCI=attribute.CCI,
+ Requirements_Attributes_CheckText=attribute.CheckText,
+ Requirements_Attributes_FixText=attribute.FixText,
+ Status=finding.status,
+ StatusExtended=finding.status_extended,
+ ResourceId=finding.resource_uid,
+ ResourceName=finding.resource_name,
+ CheckId=finding.check_id,
+ Muted=finding.muted,
+ Framework=compliance.Framework,
+ Name=compliance.Name,
+ )
+ self._data.append(compliance_row)
+ # Add manual requirements to the compliance output
+ for requirement in compliance.Requirements:
+ if not requirement.Checks:
+ for attribute in requirement.Attributes:
+ compliance_row = OktaIDaaSSTIGModel(
+ Provider=compliance.Provider.lower(),
+ Description=compliance.Description,
+ OrganizationDomain="",
+ AssessmentDate=str(timestamp),
+ Requirements_Id=requirement.Id,
+ Requirements_Name=requirement.Name,
+ Requirements_Description=requirement.Description,
+ Requirements_Attributes_Section=attribute.Section,
+ Requirements_Attributes_Severity=attribute.Severity.value,
+ Requirements_Attributes_RuleID=attribute.RuleID,
+ Requirements_Attributes_StigID=attribute.StigID,
+ Requirements_Attributes_CCI=attribute.CCI,
+ Requirements_Attributes_CheckText=attribute.CheckText,
+ Requirements_Attributes_FixText=attribute.FixText,
+ Status="MANUAL",
+ StatusExtended="Manual check",
+ ResourceId="manual_check",
+ ResourceName="Manual check",
+ CheckId="manual",
+ Muted=False,
+ Framework=compliance.Framework,
+ Name=compliance.Name,
+ )
+ self._data.append(compliance_row)
diff --git a/tests/lib/outputs/compliance/display_compliance_table_test.py b/tests/lib/outputs/compliance/display_compliance_table_test.py
index a55789a7fd..bd854d6d7d 100644
--- a/tests/lib/outputs/compliance/display_compliance_table_test.py
+++ b/tests/lib/outputs/compliance/display_compliance_table_test.py
@@ -103,6 +103,15 @@ class TestDispatchStartswith:
display_compliance_table(compliance_framework=framework_name, **_COMMON)
mock_fn.assert_called_once()
+ @pytest.mark.parametrize(
+ "framework_name",
+ ["okta_idaas_stig_v1r2_okta"],
+ )
+ @patch(f"{MODULE}.get_okta_idaas_stig_table")
+ def test_okta_idaas_stig_dispatch(self, mock_fn, framework_name):
+ display_compliance_table(compliance_framework=framework_name, **_COMMON)
+ mock_fn.assert_called_once()
+
@pytest.mark.parametrize(
"framework_name",
[
diff --git a/tests/lib/outputs/compliance/fixtures.py b/tests/lib/outputs/compliance/fixtures.py
index f085460abd..a8fc7aa7e5 100644
--- a/tests/lib/outputs/compliance/fixtures.py
+++ b/tests/lib/outputs/compliance/fixtures.py
@@ -16,6 +16,7 @@ from prowler.lib.check.compliance_models import (
Mitre_Requirement_Attribute_Azure,
Mitre_Requirement_Attribute_GCP,
Prowler_ThreatScore_Requirement_Attribute,
+ STIG_Requirement_Attribute,
)
CIS_1_4_AWS = Compliance(
@@ -1258,3 +1259,47 @@ ASD_ESSENTIAL_EIGHT_AWS = Compliance(
),
],
)
+
+OKTA_IDAAS_STIG_OKTA = Compliance(
+ Framework="Okta-IDaaS-STIG",
+ Name="DISA Okta Identity as a Service (IDaaS) STIG V1R2",
+ Version="1R2",
+ Provider="Okta",
+ Description="Defense Information Systems Agency (DISA) Security Technical Implementation Guide (STIG) for Okta Identity as a Service (IDaaS).",
+ Requirements=[
+ Compliance_Requirement(
+ Id="OKTA-APP-000020",
+ Name="Okta must log out a session after a 15-minute period of inactivity.",
+ Description="A session timeout lock is a temporary action taken when a user stops work and moves away from the immediate vicinity of the information system.",
+ Attributes=[
+ STIG_Requirement_Attribute(
+ Section="CAT II (Medium)",
+ Severity="medium",
+ RuleID="SV-273186r1098825_rule",
+ StigID="OKTA-APP-000020",
+ CCI=["CCI-000057", "CCI-001133"],
+ CheckText="Verify the Global Session Policy logs out a session after 15 minutes of inactivity.",
+ FixText="From the Admin Console configure the Global Session Policy idle timeout to 15 minutes.",
+ )
+ ],
+ Checks=["signon_global_session_idle_timeout_15min"],
+ ),
+ Compliance_Requirement(
+ Id="OKTA-APP-000650",
+ Name="Okta must enforce a minimum 15-character password length.",
+ Description="The shorter the password, the lower the number of possible combinations that need to be tested before the password is compromised.",
+ Attributes=[
+ STIG_Requirement_Attribute(
+ Section="CAT II (Medium)",
+ Severity="medium",
+ RuleID="SV-273209r1098894_rule",
+ StigID="OKTA-APP-000650",
+ CCI=["CCI-000205"],
+ CheckText="Verify the password policy enforces a minimum length of 15 characters.",
+ FixText="From the Admin Console set the minimum password length to 15 characters.",
+ )
+ ],
+ Checks=[],
+ ),
+ ],
+)
diff --git a/tests/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_okta_test.py b/tests/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_okta_test.py
new file mode 100644
index 0000000000..fa616c906e
--- /dev/null
+++ b/tests/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_okta_test.py
@@ -0,0 +1,139 @@
+from datetime import datetime
+from io import StringIO
+from unittest import mock
+
+from freezegun import freeze_time
+from mock import patch
+
+from prowler.lib.outputs.compliance.okta_idaas_stig.models import OktaIDaaSSTIGModel
+from prowler.lib.outputs.compliance.okta_idaas_stig.okta_idaas_stig_okta import (
+ OktaIDaaSSTIG,
+)
+from tests.lib.outputs.compliance.fixtures import OKTA_IDAAS_STIG_OKTA
+from tests.lib.outputs.fixtures.fixtures import generate_finding_output
+
+OKTA_ORG_DOMAIN = "dev-12345.okta.com"
+
+
+class TestOktaIDaaSSTIG:
+ def test_output_transform(self):
+ findings = [
+ generate_finding_output(
+ provider="okta",
+ account_uid=OKTA_ORG_DOMAIN,
+ account_name=OKTA_ORG_DOMAIN,
+ region="global",
+ service_name="signon",
+ check_id="signon_global_session_idle_timeout_15min",
+ resource_uid="okta-global-session-policy",
+ resource_name="Default Policy",
+ compliance={"Okta-IDaaS-STIG-1R2": ["OKTA-APP-000020"]},
+ )
+ ]
+
+ output = OktaIDaaSSTIG(findings, OKTA_IDAAS_STIG_OKTA)
+ output_data = output.data[0]
+ assert isinstance(output_data, OktaIDaaSSTIGModel)
+ assert output_data.Provider == "okta"
+ assert output_data.Framework == OKTA_IDAAS_STIG_OKTA.Framework
+ assert output_data.Name == OKTA_IDAAS_STIG_OKTA.Name
+ assert output_data.OrganizationDomain == OKTA_ORG_DOMAIN
+ assert output_data.Description == OKTA_IDAAS_STIG_OKTA.Description
+ assert output_data.Requirements_Id == OKTA_IDAAS_STIG_OKTA.Requirements[0].Id
+ assert (
+ output_data.Requirements_Name == OKTA_IDAAS_STIG_OKTA.Requirements[0].Name
+ )
+ assert (
+ output_data.Requirements_Description
+ == OKTA_IDAAS_STIG_OKTA.Requirements[0].Description
+ )
+ assert (
+ output_data.Requirements_Attributes_Section
+ == OKTA_IDAAS_STIG_OKTA.Requirements[0].Attributes[0].Section
+ )
+ assert (
+ output_data.Requirements_Attributes_Severity
+ == OKTA_IDAAS_STIG_OKTA.Requirements[0].Attributes[0].Severity.value
+ )
+ assert (
+ output_data.Requirements_Attributes_RuleID
+ == OKTA_IDAAS_STIG_OKTA.Requirements[0].Attributes[0].RuleID
+ )
+ assert (
+ output_data.Requirements_Attributes_StigID
+ == OKTA_IDAAS_STIG_OKTA.Requirements[0].Attributes[0].StigID
+ )
+ assert (
+ output_data.Requirements_Attributes_CCI
+ == OKTA_IDAAS_STIG_OKTA.Requirements[0].Attributes[0].CCI
+ )
+ assert (
+ output_data.Requirements_Attributes_CheckText
+ == OKTA_IDAAS_STIG_OKTA.Requirements[0].Attributes[0].CheckText
+ )
+ assert (
+ output_data.Requirements_Attributes_FixText
+ == OKTA_IDAAS_STIG_OKTA.Requirements[0].Attributes[0].FixText
+ )
+ assert output_data.Status == "PASS"
+ assert output_data.StatusExtended == ""
+ assert output_data.ResourceId == "okta-global-session-policy"
+ assert output_data.ResourceName == "Default Policy"
+ assert output_data.CheckId == "signon_global_session_idle_timeout_15min"
+ assert output_data.Muted is False
+ # Test manual check
+ output_data_manual = output.data[1]
+ assert output_data_manual.Provider == "okta"
+ assert output_data_manual.Framework == OKTA_IDAAS_STIG_OKTA.Framework
+ assert output_data_manual.Name == OKTA_IDAAS_STIG_OKTA.Name
+ assert output_data_manual.OrganizationDomain == ""
+ assert (
+ output_data_manual.Requirements_Id
+ == OKTA_IDAAS_STIG_OKTA.Requirements[1].Id
+ )
+ assert (
+ output_data_manual.Requirements_Attributes_Severity
+ == OKTA_IDAAS_STIG_OKTA.Requirements[1].Attributes[0].Severity.value
+ )
+ assert (
+ output_data_manual.Requirements_Attributes_StigID
+ == OKTA_IDAAS_STIG_OKTA.Requirements[1].Attributes[0].StigID
+ )
+ assert output_data_manual.Status == "MANUAL"
+ assert output_data_manual.StatusExtended == "Manual check"
+ assert output_data_manual.ResourceId == "manual_check"
+ assert output_data_manual.ResourceName == "Manual check"
+ assert output_data_manual.CheckId == "manual"
+ assert output_data_manual.Muted is False
+
+ @freeze_time("2025-01-01 00:00:00")
+ @mock.patch(
+ "prowler.lib.outputs.compliance.okta_idaas_stig.okta_idaas_stig_okta.timestamp",
+ "2025-01-01 00:00:00",
+ )
+ def test_batch_write_data_to_file(self):
+ mock_file = StringIO()
+ findings = [
+ generate_finding_output(
+ provider="okta",
+ account_uid=OKTA_ORG_DOMAIN,
+ account_name=OKTA_ORG_DOMAIN,
+ region="global",
+ service_name="signon",
+ check_id="signon_global_session_idle_timeout_15min",
+ resource_uid="okta-global-session-policy",
+ resource_name="Default Policy",
+ compliance={"Okta-IDaaS-STIG-1R2": ["OKTA-APP-000020"]},
+ )
+ ]
+ output = OktaIDaaSSTIG(findings, OKTA_IDAAS_STIG_OKTA)
+ output._file_descriptor = mock_file
+
+ with patch.object(mock_file, "close", return_value=None):
+ output.batch_write_data_to_file()
+
+ mock_file.seek(0)
+ content = mock_file.read()
+ expected_csv = f"PROVIDER;DESCRIPTION;ORGANIZATIONDOMAIN;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_NAME;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_SECTION;REQUIREMENTS_ATTRIBUTES_SEVERITY;REQUIREMENTS_ATTRIBUTES_RULEID;REQUIREMENTS_ATTRIBUTES_STIGID;REQUIREMENTS_ATTRIBUTES_CCI;REQUIREMENTS_ATTRIBUTES_CHECKTEXT;REQUIREMENTS_ATTRIBUTES_FIXTEXT;STATUS;STATUSEXTENDED;RESOURCEID;RESOURCENAME;CHECKID;MUTED;FRAMEWORK;NAME\r\nokta;Defense Information Systems Agency (DISA) Security Technical Implementation Guide (STIG) for Okta Identity as a Service (IDaaS).;{OKTA_ORG_DOMAIN};{datetime.now()};OKTA-APP-000020;Okta must log out a session after a 15-minute period of inactivity.;A session timeout lock is a temporary action taken when a user stops work and moves away from the immediate vicinity of the information system.;CAT II (Medium);medium;SV-273186r1098825_rule;OKTA-APP-000020;['CCI-000057', 'CCI-001133'];Verify the Global Session Policy logs out a session after 15 minutes of inactivity.;From the Admin Console configure the Global Session Policy idle timeout to 15 minutes.;PASS;;okta-global-session-policy;Default Policy;signon_global_session_idle_timeout_15min;False;Okta-IDaaS-STIG;DISA Okta Identity as a Service (IDaaS) STIG V1R2\r\nokta;Defense Information Systems Agency (DISA) Security Technical Implementation Guide (STIG) for Okta Identity as a Service (IDaaS).;;{datetime.now()};OKTA-APP-000650;Okta must enforce a minimum 15-character password length.;The shorter the password, the lower the number of possible combinations that need to be tested before the password is compromised.;CAT II (Medium);medium;SV-273209r1098894_rule;OKTA-APP-000650;['CCI-000205'];Verify the password policy enforces a minimum length of 15 characters.;From the Admin Console set the minimum password length to 15 characters.;MANUAL;Manual check;manual_check;Manual check;manual;False;Okta-IDaaS-STIG;DISA Okta Identity as a Service (IDaaS) STIG V1R2\r\n"
+
+ assert content == expected_csv
diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md
index a3d12493d6..dc8c0fa708 100644
--- a/ui/CHANGELOG.md
+++ b/ui/CHANGELOG.md
@@ -6,6 +6,7 @@ All notable changes to the **Prowler UI** are documented in this file.
### 🚀 Added
+- DISA Okta IDaaS STIG V1R2 compliance framework support with its dedicated mapper, details panel, and icon [(#11428)](https://github.com/prowler-cloud/prowler/pull/11428)
- DORA compliance framework support [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131)
### 🔄 Changed
diff --git a/ui/components/compliance/compliance-custom-details/asd-essential-eight-details.tsx b/ui/components/compliance/compliance-custom-details/asd-essential-eight-details.tsx
index 363eba2b10..2cb022a174 100644
--- a/ui/components/compliance/compliance-custom-details/asd-essential-eight-details.tsx
+++ b/ui/components/compliance/compliance-custom-details/asd-essential-eight-details.tsx
@@ -85,7 +85,7 @@ export const ASDEssentialEightCustomDetails = ({
)}
@@ -93,7 +93,7 @@ export const ASDEssentialEightCustomDetails = ({
)}
@@ -101,7 +101,7 @@ export const ASDEssentialEightCustomDetails = ({
)}
diff --git a/ui/components/compliance/compliance-custom-details/aws-well-architected-details.tsx b/ui/components/compliance/compliance-custom-details/aws-well-architected-details.tsx
index 96ed16a8f1..00aa51e4c5 100644
--- a/ui/components/compliance/compliance-custom-details/aws-well-architected-details.tsx
+++ b/ui/components/compliance/compliance-custom-details/aws-well-architected-details.tsx
@@ -52,7 +52,7 @@ export const AWSWellArchitectedCustomDetails = ({
)}
@@ -60,7 +60,7 @@ export const AWSWellArchitectedCustomDetails = ({
)}
@@ -68,7 +68,7 @@ export const AWSWellArchitectedCustomDetails = ({
)}
diff --git a/ui/components/compliance/compliance-custom-details/ccc-details.tsx b/ui/components/compliance/compliance-custom-details/ccc-details.tsx
index 309358264d..e3db174280 100644
--- a/ui/components/compliance/compliance-custom-details/ccc-details.tsx
+++ b/ui/components/compliance/compliance-custom-details/ccc-details.tsx
@@ -1,4 +1,4 @@
-import { cn } from "@/lib";
+import { Badge } from "@/components/shadcn/badge/badge";
import { CCC_MAPPING_SECTIONS, CCC_TEXT_SECTIONS } from "@/lib/compliance/ccc";
import { Requirement } from "@/types/compliance";
@@ -46,7 +46,7 @@ export const CCCCustomDetails = ({ requirement }: CCCDetailsProps) => {
)}
@@ -68,15 +68,9 @@ export const CCCCustomDetails = ({ requirement }: CCCDetailsProps) => {