From 68471d2a0edee282ec4562adbccc9619cdc43851 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20De=20la=20Torre=20Vico?= Date: Wed, 12 Aug 2026 10:19:20 +0200 Subject: [PATCH 01/28] feat(ui): add Manage Lighthouse AI role permission (#12412) --- ui/actions/auth/auth.test.ts | 54 ++++++++++++- ui/actions/auth/auth.ts | 2 + ui/actions/roles/roles.test.ts | 53 +++++++++++++ ui/actions/roles/roles.ts | 4 + ui/auth.config.ts | 1 + ...-ai-configuration-role-permission.added.md | 1 + .../workflow/forms/add-role-form.test.tsx | 77 +++++++++++++++++++ .../roles/workflow/forms/add-role-form.tsx | 5 ++ .../workflow/forms/edit-role-form.test.tsx | 64 +++++++++++++++ .../roles/workflow/forms/edit-role-form.tsx | 2 + .../users/profile/role-item.test.tsx | 23 ++++++ ui/hooks/use-auth.ts | 1 + ui/lib/helper.ts | 6 ++ ui/lib/permissions.test.ts | 31 ++++++++ ui/lib/permissions.ts | 5 ++ ui/lib/role-permissions.ts | 6 +- ui/types/components.ts | 2 + ui/types/formSchemas.ts | 1 + ui/types/users.ts | 22 ++++-- 19 files changed, 350 insertions(+), 10 deletions(-) create mode 100644 ui/changelog.d/lighthouse-ai-configuration-role-permission.added.md diff --git a/ui/actions/auth/auth.test.ts b/ui/actions/auth/auth.test.ts index f9fd0f5b64..9b8b44e05b 100644 --- a/ui/actions/auth/auth.test.ts +++ b/ui/actions/auth/auth.test.ts @@ -21,7 +21,36 @@ vi.mock("@/lib/sentry-breadcrumbs", () => ({ addAuthEvent: vi.fn(), })); -import { createNewUser } from "./auth"; +import { createNewUser, getUserByMe } from "./auth"; + +const userMeResponse = (roleAttributes: Record) => ({ + data: { + type: "users", + id: "019b1234-5678-7abc-9def-0123456789ab", + attributes: { + name: "Jane Doe", + email: "jane@example.com", + company_name: "Prowler", + date_joined: "2026-01-01T00:00:00.000Z", + }, + }, + included: [ + { + type: "roles", + id: "role-1", + attributes: { name: "Cloud admin", ...roleAttributes }, + }, + ], +}); + +const mockUserMe = (roleAttributes: Record) => { + fetchMock.mockResolvedValue( + new Response(JSON.stringify(userMeResponse(roleAttributes)), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); +}; describe("auth actions", () => { beforeEach(() => { @@ -102,4 +131,27 @@ describe("auth actions", () => { expect(requestUrl.searchParams.get("promo_code")).toBe("black-hat-2026"); expect(requestUrl.searchParams.get("utm_source")).toBe("blackhat"); }); + + it("should carry manage_lighthouse_ai_configuration into the session permissions", async () => { + // Given + mockUserMe({ manage_lighthouse_ai_configuration: true }); + + // When + const result = await getUserByMe("access-token"); + + // Then + expect(result.permissions.manage_lighthouse_ai_configuration).toBe(true); + }); + + it("should default manage_lighthouse_ai_configuration to false when the role omits it", async () => { + // Given + mockUserMe({ manage_users: true }); + + // When + const result = await getUserByMe("access-token"); + + // Then + expect(result.permissions.manage_lighthouse_ai_configuration).toBe(false); + expect(result.permissions.manage_users).toBe(true); + }); }); diff --git a/ui/actions/auth/auth.ts b/ui/actions/auth/auth.ts index 37eb3f6b5c..646c3d1426 100644 --- a/ui/actions/auth/auth.ts +++ b/ui/actions/auth/auth.ts @@ -181,6 +181,8 @@ export const getUserByMe = async (accessToken: string) => { manage_integrations: userRole.attributes.manage_integrations || false, manage_billing: userRole.attributes.manage_billing || false, manage_alerts: userRole.attributes.manage_alerts || false, + manage_lighthouse_ai_configuration: + userRole.attributes.manage_lighthouse_ai_configuration || false, unlimited_visibility: userRole.attributes.unlimited_visibility || false, }; diff --git a/ui/actions/roles/roles.test.ts b/ui/actions/roles/roles.test.ts index 3546649604..3b253cce58 100644 --- a/ui/actions/roles/roles.test.ts +++ b/ui/actions/roles/roles.test.ts @@ -49,6 +49,7 @@ const makeRoleFormData = () => { formData.set("manage_integrations", "false"); formData.set("manage_scans", "false"); formData.set("manage_alerts", "true"); + formData.set("manage_lighthouse_ai_configuration", "true"); formData.set("unlimited_visibility", "false"); return formData; }; @@ -106,4 +107,56 @@ describe("role actions", () => { // Then expect(lastRequestBody().data.attributes.manage_alerts).toBe(true); }); + + it("includes manage_lighthouse_ai_configuration when creating a role in Prowler Cloud", async () => { + // Given + vi.stubEnv("UI_CLOUD_ENABLED", "true"); + + // When + await addRole(makeRoleFormData()); + + // Then + expect( + lastRequestBody().data.attributes.manage_lighthouse_ai_configuration, + ).toBe(true); + }); + + it("omits manage_lighthouse_ai_configuration when creating a role outside Prowler Cloud", async () => { + // Given + vi.stubEnv("UI_CLOUD_ENABLED", "false"); + + // When + await addRole(makeRoleFormData()); + + // Then + expect(lastRequestBody().data.attributes).not.toHaveProperty( + "manage_lighthouse_ai_configuration", + ); + }); + + it("includes manage_lighthouse_ai_configuration when updating a role in Prowler Cloud", async () => { + // Given + vi.stubEnv("UI_CLOUD_ENABLED", "true"); + + // When + await updateRole(makeRoleFormData(), "role-1"); + + // Then + expect( + lastRequestBody().data.attributes.manage_lighthouse_ai_configuration, + ).toBe(true); + }); + + it("omits manage_lighthouse_ai_configuration when updating a role outside Prowler Cloud", async () => { + // Given + vi.stubEnv("UI_CLOUD_ENABLED", "false"); + + // When + await updateRole(makeRoleFormData(), "role-1"); + + // Then + expect(lastRequestBody().data.attributes).not.toHaveProperty( + "manage_lighthouse_ai_configuration", + ); + }); }); diff --git a/ui/actions/roles/roles.ts b/ui/actions/roles/roles.ts index cfe3837414..972d6d12e8 100644 --- a/ui/actions/roles/roles.ts +++ b/ui/actions/roles/roles.ts @@ -114,6 +114,8 @@ export const addRole = async (formData: FormData) => { formData.get("manage_billing") === "true"; payload.data.attributes.manage_alerts = formData.get("manage_alerts") === "true"; + payload.data.attributes.manage_lighthouse_ai_configuration = + formData.get("manage_lighthouse_ai_configuration") === "true"; } // Add provider groups relationships only if there are items @@ -171,6 +173,8 @@ export const updateRole = async (formData: FormData, roleId: string) => { formData.get("manage_billing") === "true"; payload.data.attributes.manage_alerts = formData.get("manage_alerts") === "true"; + payload.data.attributes.manage_lighthouse_ai_configuration = + formData.get("manage_lighthouse_ai_configuration") === "true"; } // Add provider groups relationships only if there are items diff --git a/ui/auth.config.ts b/ui/auth.config.ts index eefc73b2a4..9d7544e371 100644 --- a/ui/auth.config.ts +++ b/ui/auth.config.ts @@ -54,6 +54,7 @@ const DEFAULT_PERMISSIONS: RolePermissionAttributes = { manage_integrations: false, manage_billing: false, manage_alerts: false, + manage_lighthouse_ai_configuration: false, unlimited_visibility: false, }; diff --git a/ui/changelog.d/lighthouse-ai-configuration-role-permission.added.md b/ui/changelog.d/lighthouse-ai-configuration-role-permission.added.md new file mode 100644 index 0000000000..f245a9c30f --- /dev/null +++ b/ui/changelog.d/lighthouse-ai-configuration-role-permission.added.md @@ -0,0 +1 @@ +Manage Lighthouse AI role permission in the role forms and role details, so permission to change the Lighthouse AI configuration can be granted or restricted independently of other permissions (Prowler Cloud only) diff --git a/ui/components/roles/workflow/forms/add-role-form.test.tsx b/ui/components/roles/workflow/forms/add-role-form.test.tsx index e39683e7dc..5f285c0c35 100644 --- a/ui/components/roles/workflow/forms/add-role-form.test.tsx +++ b/ui/components/roles/workflow/forms/add-role-form.test.tsx @@ -2,6 +2,8 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { addRole } from "@/actions/roles/roles"; + import { AddRoleForm } from "./add-role-form"; const routerMocks = vi.hoisted(() => ({ @@ -60,6 +62,12 @@ vi.mock("@/lib", () => ({ label: "Manage Alerts", description: "Allows creating and managing custom alerts", }, + { + field: "manage_lighthouse_ai_configuration", + label: "Manage Lighthouse AI", + description: + "Allows configuring Lighthouse AI, including its provider credentials, default model and business context", + }, { field: "manage_billing", label: "Manage Billing", @@ -88,9 +96,31 @@ beforeAll(() => { window.ResizeObserver = ResizeObserverMock; }); +const submitRoleForm = async ( + user: ReturnType, + { grantLighthouseAi }: { grantLighthouseAi: boolean }, +) => { + await user.type(screen.getByPlaceholderText("Enter role name"), "New role"); + + if (grantLighthouseAi) { + await user.click( + screen.getByRole("checkbox", { name: "Manage Lighthouse AI" }), + ); + } + + await user.click(screen.getByRole("button", { name: "Add Role" })); +}; + +const submittedFormData = () => { + const formData = vi.mocked(addRole).mock.calls.at(-1)?.[0]; + if (!formData) throw new Error("addRole was not called"); + return formData; +}; + describe("AddRoleForm", () => { afterEach(() => { routerMocks.push.mockClear(); + vi.mocked(addRole).mockClear(); vi.unstubAllEnvs(); }); @@ -103,6 +133,7 @@ describe("AddRoleForm", () => { // Then expect(screen.getByText("Manage Alerts")).toBeInTheDocument(); + expect(screen.getByText("Manage Lighthouse AI")).toBeInTheDocument(); expect(screen.getByText("Manage Billing")).toBeInTheDocument(); }); @@ -115,9 +146,55 @@ describe("AddRoleForm", () => { // Then expect(screen.queryByText("Manage Alerts")).not.toBeInTheDocument(); + expect(screen.queryByText("Manage Lighthouse AI")).not.toBeInTheDocument(); expect(screen.queryByText("Manage Billing")).not.toBeInTheDocument(); }); + it("submits manage_lighthouse_ai_configuration when granted in Prowler Cloud", async () => { + // Given + vi.stubEnv("UI_CLOUD_ENABLED", "true"); + const user = userEvent.setup(); + render(); + + // When + await submitRoleForm(user, { grantLighthouseAi: true }); + + // Then + expect(submittedFormData().get("manage_lighthouse_ai_configuration")).toBe( + "true", + ); + }); + + it("submits manage_lighthouse_ai_configuration as false when not granted in Prowler Cloud", async () => { + // Given + vi.stubEnv("UI_CLOUD_ENABLED", "true"); + const user = userEvent.setup(); + render(); + + // When + await submitRoleForm(user, { grantLighthouseAi: false }); + + // Then + expect(submittedFormData().get("manage_lighthouse_ai_configuration")).toBe( + "false", + ); + }); + + it("omits manage_lighthouse_ai_configuration from the submission outside Prowler Cloud", async () => { + // Given + vi.stubEnv("UI_CLOUD_ENABLED", "false"); + const user = userEvent.setup(); + render(); + + // When + await submitRoleForm(user, { grantLighthouseAi: false }); + + // Then + expect(submittedFormData().has("manage_lighthouse_ai_configuration")).toBe( + false, + ); + }); + it("navigates back to roles when cancel is clicked", async () => { // Given const user = userEvent.setup(); diff --git a/ui/components/roles/workflow/forms/add-role-form.tsx b/ui/components/roles/workflow/forms/add-role-form.tsx index eb16ea4ebe..19c19e2d48 100644 --- a/ui/components/roles/workflow/forms/add-role-form.tsx +++ b/ui/components/roles/workflow/forms/add-role-form.tsx @@ -27,6 +27,7 @@ export const AddRoleForm = ({ groups }: { groups: RoleGroupOption[] }) => { ...(isCloudEnvironment && { manage_billing: false, manage_alerts: false, + manage_lighthouse_ai_configuration: false, }), }; @@ -51,6 +52,10 @@ export const AddRoleForm = ({ groups }: { groups: RoleGroupOption[] }) => { if (isCloudEnvironment) { formData.append("manage_billing", String(values.manage_billing)); formData.append("manage_alerts", String(values.manage_alerts)); + formData.append( + "manage_lighthouse_ai_configuration", + String(values.manage_lighthouse_ai_configuration), + ); } if (values.groups && values.groups.length > 0) { diff --git a/ui/components/roles/workflow/forms/edit-role-form.test.tsx b/ui/components/roles/workflow/forms/edit-role-form.test.tsx index 0bc69c8ffd..f5ed520ee0 100644 --- a/ui/components/roles/workflow/forms/edit-role-form.test.tsx +++ b/ui/components/roles/workflow/forms/edit-role-form.test.tsx @@ -2,6 +2,8 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { updateRole } from "@/actions/roles/roles"; + import { EditRoleForm } from "./edit-role-form"; const routerMocks = vi.hoisted(() => ({ @@ -60,6 +62,12 @@ vi.mock("@/lib", () => ({ label: "Manage Alerts", description: "Allows creating and managing custom alerts", }, + { + field: "manage_lighthouse_ai_configuration", + label: "Manage Lighthouse AI", + description: + "Allows configuring Lighthouse AI, including its provider credentials, default model and business context", + }, { field: "manage_billing", label: "Manage Billing", @@ -118,12 +126,68 @@ const renderEditRoleForm = (options?: Parameters[0]) => , ); +const submittedFormData = () => { + const formData = vi.mocked(updateRole).mock.calls.at(-1)?.[0]; + if (!formData) throw new Error("updateRole was not called"); + return formData; +}; + describe("EditRoleForm", () => { afterEach(() => { routerMocks.push.mockClear(); + vi.mocked(updateRole).mockClear(); vi.unstubAllEnvs(); }); + it("submits manage_lighthouse_ai_configuration when granted in Prowler Cloud", async () => { + // Given + vi.stubEnv("UI_CLOUD_ENABLED", "true"); + const user = userEvent.setup(); + renderEditRoleForm(); + + // When + await user.click( + screen.getByRole("checkbox", { name: "Manage Lighthouse AI" }), + ); + await user.click(screen.getByRole("button", { name: "Update Role" })); + + // Then + expect(submittedFormData().get("manage_lighthouse_ai_configuration")).toBe( + "true", + ); + expect(vi.mocked(updateRole).mock.calls.at(-1)?.[1]).toBe("role-1"); + }); + + it("submits manage_lighthouse_ai_configuration as false when not granted in Prowler Cloud", async () => { + // Given + vi.stubEnv("UI_CLOUD_ENABLED", "true"); + const user = userEvent.setup(); + renderEditRoleForm(); + + // When + await user.click(screen.getByRole("button", { name: "Update Role" })); + + // Then + expect(submittedFormData().get("manage_lighthouse_ai_configuration")).toBe( + "false", + ); + }); + + it("omits manage_lighthouse_ai_configuration from the submission outside Prowler Cloud", async () => { + // Given + vi.stubEnv("UI_CLOUD_ENABLED", "false"); + const user = userEvent.setup(); + renderEditRoleForm(); + + // When + await user.click(screen.getByRole("button", { name: "Update Role" })); + + // Then + expect(submittedFormData().has("manage_lighthouse_ai_configuration")).toBe( + false, + ); + }); + it("shows the subtle Unlimited Visibility description inside Visibility", () => { // Given / When renderEditRoleForm(); diff --git a/ui/components/roles/workflow/forms/edit-role-form.tsx b/ui/components/roles/workflow/forms/edit-role-form.tsx index f64c7aee3c..2621b3c150 100644 --- a/ui/components/roles/workflow/forms/edit-role-form.tsx +++ b/ui/components/roles/workflow/forms/edit-role-form.tsx @@ -60,6 +60,8 @@ export const EditRoleForm = ({ if (isCloudEnvironment) { updatedFields.manage_billing = values.manage_billing; updatedFields.manage_alerts = values.manage_alerts; + updatedFields.manage_lighthouse_ai_configuration = + values.manage_lighthouse_ai_configuration; } if ( diff --git a/ui/components/users/profile/role-item.test.tsx b/ui/components/users/profile/role-item.test.tsx index 2df8d92ae4..6a4d781d51 100644 --- a/ui/components/users/profile/role-item.test.tsx +++ b/ui/components/users/profile/role-item.test.tsx @@ -23,6 +23,7 @@ const roleDetail = { manage_integrations: false, manage_billing: false, manage_alerts: true, + manage_lighthouse_ai_configuration: true, unlimited_visibility: false, }, } satisfies RoleDetail; @@ -54,6 +55,28 @@ describe("RoleItem", () => { expect(screen.queryByText("Manage Alerts")).not.toBeInTheDocument(); }); + it("shows Manage Lighthouse AI in Prowler Cloud role details", () => { + // Given + vi.stubEnv("UI_CLOUD_ENABLED", "true"); + + // When + render(); + + // Then + expect(screen.getByText("Manage Lighthouse AI")).toBeInTheDocument(); + }); + + it("hides Manage Lighthouse AI outside Prowler Cloud role details", () => { + // Given + vi.stubEnv("UI_CLOUD_ENABLED", "false"); + + // When + render(); + + // Then + expect(screen.queryByText("Manage Lighthouse AI")).not.toBeInTheDocument(); + }); + it("displays the permission state as a badge", () => { // Given vi.stubEnv("UI_CLOUD_ENABLED", "true"); diff --git a/ui/hooks/use-auth.ts b/ui/hooks/use-auth.ts index ce0debb2b2..0946fc7677 100644 --- a/ui/hooks/use-auth.ts +++ b/ui/hooks/use-auth.ts @@ -14,6 +14,7 @@ export function useAuth() { manage_integrations: false, manage_billing: false, manage_alerts: false, + manage_lighthouse_ai_configuration: false, unlimited_visibility: false, }; diff --git a/ui/lib/helper.ts b/ui/lib/helper.ts index 7a70ce4906..35087a00d9 100644 --- a/ui/lib/helper.ts +++ b/ui/lib/helper.ts @@ -469,6 +469,12 @@ export const permissionFormFields: PermissionInfo[] = [ label: "Manage Alerts", description: "Allows creating and managing custom alerts", }, + { + field: "manage_lighthouse_ai_configuration", + label: "Manage Lighthouse AI", + description: + "Allows configuring Lighthouse AI, including its provider credentials, default model and business context", + }, { field: "manage_billing", diff --git a/ui/lib/permissions.test.ts b/ui/lib/permissions.test.ts index 00e409097d..e9fb55a236 100644 --- a/ui/lib/permissions.test.ts +++ b/ui/lib/permissions.test.ts @@ -12,6 +12,7 @@ const attributes = { manage_integrations: false, manage_billing: false, manage_alerts: true, + manage_lighthouse_ai_configuration: true, unlimited_visibility: false, } satisfies RolePermissionAttributes; @@ -47,4 +48,34 @@ describe("getRolePermissions", () => { permissions.some((permission) => permission.key === "manage_alerts"), ).toBe(false); }); + + it("includes Manage Lighthouse AI in Prowler Cloud when role attributes provide it", () => { + // Given + vi.stubEnv("UI_CLOUD_ENABLED", "true"); + + // When + const permissions = getRolePermissions(attributes); + + // Then + expect(permissions).toContainEqual({ + key: "manage_lighthouse_ai_configuration", + label: "Manage Lighthouse AI", + enabled: true, + }); + }); + + it("hides Manage Lighthouse AI outside Prowler Cloud", () => { + // Given + vi.stubEnv("UI_CLOUD_ENABLED", "false"); + + // When + const permissions = getRolePermissions(attributes); + + // Then + expect( + permissions.some( + (permission) => permission.key === "manage_lighthouse_ai_configuration", + ), + ).toBe(false); + }); }); diff --git a/ui/lib/permissions.ts b/ui/lib/permissions.ts index f34f6d6c0e..8f641814c1 100644 --- a/ui/lib/permissions.ts +++ b/ui/lib/permissions.ts @@ -67,6 +67,11 @@ export const getRolePermissions = (attributes: RolePermissionAttributes) => { label: "Manage Alerts", enabled: attributes.manage_alerts ?? false, }, + { + key: "manage_lighthouse_ai_configuration", + label: "Manage Lighthouse AI", + enabled: attributes.manage_lighthouse_ai_configuration ?? false, + }, ] : []), { diff --git a/ui/lib/role-permissions.ts b/ui/lib/role-permissions.ts index 87b024ebee..0ffba41963 100644 --- a/ui/lib/role-permissions.ts +++ b/ui/lib/role-permissions.ts @@ -1,6 +1,10 @@ import { permissionFormFields } from "@/lib"; -const hiddenOutsideCloudFields = ["manage_billing", "manage_alerts"]; +const hiddenOutsideCloudFields = [ + "manage_billing", + "manage_alerts", + "manage_lighthouse_ai_configuration", +]; export const getVisiblePermissionFormFields = (isCloudEnvironment: boolean) => permissionFormFields.filter( diff --git a/ui/types/components.ts b/ui/types/components.ts index 165ba8e8a6..4053984241 100644 --- a/ui/types/components.ts +++ b/ui/types/components.ts @@ -419,6 +419,7 @@ export interface InvitationProps { manage_integrations?: boolean; manage_scans?: boolean; manage_alerts?: boolean; + manage_lighthouse_ai_configuration?: boolean; permission_state?: PermissionState; }; }; @@ -444,6 +445,7 @@ export interface Role { manage_integrations: boolean; manage_scans: boolean; manage_alerts?: boolean; + manage_lighthouse_ai_configuration?: boolean; unlimited_visibility: boolean; permission_state: PermissionState; inserted_at: string; diff --git a/ui/types/formSchemas.ts b/ui/types/formSchemas.ts index 145e4b3b65..dfdf406d16 100644 --- a/ui/types/formSchemas.ts +++ b/ui/types/formSchemas.ts @@ -55,6 +55,7 @@ export const roleFormSchema = z.object({ manage_integrations: z.boolean().default(false), manage_scans: z.boolean().default(false), manage_alerts: z.boolean().default(false), + manage_lighthouse_ai_configuration: z.boolean().default(false), unlimited_visibility: z.boolean().default(false), groups: z.array(z.string()).optional(), }); diff --git a/ui/types/users.ts b/ui/types/users.ts index 324bbda319..c9b43494b3 100644 --- a/ui/types/users.ts +++ b/ui/types/users.ts @@ -82,15 +82,20 @@ export interface RoleData { id: string; } +export const PERMISSION_KEY = { + MANAGE_USERS: "manage_users", + MANAGE_ACCOUNT: "manage_account", + MANAGE_PROVIDERS: "manage_providers", + MANAGE_SCANS: "manage_scans", + MANAGE_INTEGRATIONS: "manage_integrations", + MANAGE_BILLING: "manage_billing", + MANAGE_ALERTS: "manage_alerts", + MANAGE_LIGHTHOUSE_AI_CONFIGURATION: "manage_lighthouse_ai_configuration", + UNLIMITED_VISIBILITY: "unlimited_visibility", +} as const; + export type PermissionKey = - | "manage_users" - | "manage_account" - | "manage_providers" - | "manage_scans" - | "manage_integrations" - | "manage_billing" - | "manage_alerts" - | "unlimited_visibility"; + (typeof PERMISSION_KEY)[keyof typeof PERMISSION_KEY]; export type RolePermissionAttributes = Pick< RoleDetail["attributes"], @@ -117,6 +122,7 @@ export interface RoleDetail { manage_integrations: boolean; manage_billing?: boolean; manage_alerts?: boolean; + manage_lighthouse_ai_configuration?: boolean; unlimited_visibility: boolean; permission_state?: string; inserted_at?: string; From a28487cbff1d19ea928d027b66329140b0e92cd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Wed, 12 Aug 2026 03:16:14 -0700 Subject: [PATCH 02/28] fix(ci): suppress .NET runtime CVE temporarily (#12426) --- .grype.yaml | 13 +++++++++++++ .trivyignore.yaml | 14 ++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/.grype.yaml b/.grype.yaml index 7c36171f65..8fe74513c9 100644 --- a/.grype.yaml +++ b/.grype.yaml @@ -33,6 +33,19 @@ ignore: package: name: Microsoft.Bcl.Memory + # The .NET runtime bundled inside the PowerShell tarball the Dockerfile pins. + # CVE-2026-62901 is the same temporary exception documented in .trivyignore.yaml: + # fixed in .NET 9.0.19 / 10.0.11 (2026-08-11), but no published PowerShell release + # ships a patched runtime yet (7.5.9 bundles 9.0.18; 7.6.4 bundles 10.0.x < 10.0.11). + # pwsh runs only local M365 module cmdlets; nothing listens for inbound WebSocket + # connections. Remove with the Trivy exception by 2026-09-15. + - vulnerability: CVE-2026-62901 + package: + name: Microsoft.NETCore.App.Runtime.linux-x64 + - vulnerability: CVE-2026-62901 + package: + name: Microsoft.NETCore.App.Runtime.linux-arm64 + # The CPython interpreter, compiled into the official base image. # TEMPORARY, unlike the entries above: moving to Python 3.13 clears seven of these, and diff --git a/.trivyignore.yaml b/.trivyignore.yaml index f3a1b73b12..76e45691a9 100644 --- a/.trivyignore.yaml +++ b/.trivyignore.yaml @@ -118,6 +118,20 @@ vulnerabilities: - "pkg:npm/ip-address" expired_at: 2027-01-31 + # CVE-2026-62901 is a DoS in System.Net.WebSockets (unchecked input for loop condition, + # CWE-606), fixed in .NET 9.0.19 / 10.0.11 (published 2026-08-11). The vulnerable runtime + # ships inside the PowerShell tarball the Dockerfile pins: 7.5.9 is the latest 7.5.x and + # bundles .NET 9.0.18; 7.6.4 bundles .NET 10.0.x < 10.0.11, so no published PowerShell + # release contains the fix yet. Prowler only invokes pwsh locally to run M365 module + # cmdlets; the image does not accept inbound WebSocket connections, so the DoS path is + # not reachable from the network. Remove this temporary suppression as soon as a + # PowerShell release shipping .NET 9.0.19+ is available. + - id: CVE-2026-62901 + purls: + - "pkg:nuget/Microsoft.NETCore.App.Runtime.linux-x64" + - "pkg:nuget/Microsoft.NETCore.App.Runtime.linux-arm64" + expired_at: 2026-09-15 + # Modules compiled into the Trivy binary the images ship. The binary is pinned by version # and verified by checksum in the Dockerfile; only a rebuild by its vendor moves these. # CVE-2026-71556 affects go-git worktree operations that can follow symlinks outside a From 37ebd9b6fd996eab0713a186424ee32b47a40e86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Wed, 12 Aug 2026 03:29:43 -0700 Subject: [PATCH 03/28] fix(cmmc): remove stale config_requirements (#12425) --- prowler/compliance/cmmc_2.0.json | 46 +------------------------------- 1 file changed, 1 insertion(+), 45 deletions(-) diff --git a/prowler/compliance/cmmc_2.0.json b/prowler/compliance/cmmc_2.0.json index a3d7b67ea6..7c5783af13 100644 --- a/prowler/compliance/cmmc_2.0.json +++ b/prowler/compliance/cmmc_2.0.json @@ -265,20 +265,6 @@ "Operator": "lte", "Value": 90, "Provider": "aws" - }, - { - "Check": "iam_user_accesskey_unused", - "ConfigKey": "max_unused_access_keys_days", - "Operator": "lte", - "Value": 45, - "Provider": "aws" - }, - { - "Check": "iam_user_console_access_unused", - "ConfigKey": "max_console_access_days", - "Operator": "lte", - "Value": 45, - "Provider": "aws" } ] }, @@ -1064,20 +1050,6 @@ "Operator": "lte", "Value": 90, "Provider": "aws" - }, - { - "Check": "iam_user_accesskey_unused", - "ConfigKey": "max_unused_access_keys_days", - "Operator": "lte", - "Value": 45, - "Provider": "aws" - }, - { - "Check": "iam_user_console_access_unused", - "ConfigKey": "max_console_access_days", - "Operator": "lte", - "Value": 45, - "Provider": "aws" } ] }, @@ -2008,23 +1980,7 @@ "alibabacloud": [], "oraclecloud": [], "m365": [] - }, - "config_requirements": [ - { - "Check": "guardduty_is_enabled", - "ConfigKey": "mute_non_default_regions", - "Operator": "eq", - "Value": false, - "Provider": "aws" - }, - { - "Check": "securityhub_enabled", - "ConfigKey": "mute_non_default_regions", - "Operator": "eq", - "Value": false, - "Provider": "aws" - } - ] + } }, { "id": "AU.L2-3.3.5", From de64df11b9b18a31c625e2805b219f0b0d7074da Mon Sep 17 00:00:00 2001 From: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:41:44 +0100 Subject: [PATCH 04/28] fix(api): normalize social account names (#12413) Co-authored-by: Josema Camacho --- .../social-login-user-name.fixed.md | 1 + api/src/backend/api/adapters.py | 31 +++++- api/src/backend/api/tests/test_adapters.py | 104 ++++++++++++++++++ 3 files changed, 132 insertions(+), 4 deletions(-) create mode 100644 api/changelog.d/social-login-user-name.fixed.md diff --git a/api/changelog.d/social-login-user-name.fixed.md b/api/changelog.d/social-login-user-name.fixed.md new file mode 100644 index 0000000000..db60622d00 --- /dev/null +++ b/api/changelog.d/social-login-user-name.fixed.md @@ -0,0 +1 @@ +Social login derives a valid user name when identity providers omit the profile name diff --git a/api/src/backend/api/adapters.py b/api/src/backend/api/adapters.py index 55ac440f59..6806289fdb 100644 --- a/api/src/backend/api/adapters.py +++ b/api/src/backend/api/adapters.py @@ -12,11 +12,37 @@ from api.models import ( UserRoleRelationship, ) from api.utils import accept_invitation_for_user +from django.core.exceptions import ValidationError from django.db import transaction from django.http import HttpResponseForbidden class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter): + @staticmethod + def _get_social_account_name(extra_data: dict, email: str) -> str: + name_field = User._meta.get_field("name") + for value in ( + extra_data.get("name"), + extra_data.get("login"), + extra_data.get("username"), + email, + ): + if not isinstance(value, str): + continue + + candidate = value.strip()[: name_field.max_length].rstrip() + if not candidate: + continue + + try: + name_field.run_validators(candidate) + except ValidationError: + continue + + return candidate + + raise ValueError("Social account does not provide a valid user identity.") + @staticmethod def get_user_by_email(email: str): try: @@ -116,11 +142,8 @@ class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter): if provider != "saml": # Handle other providers (e.g., GitHub, Google) + user.name = self._get_social_account_name(extra, user.email) user.save(using=MainRouter.admin_db) - social_account_name = extra.get("name") - if social_account_name: - user.name = social_account_name - user.save(using=MainRouter.admin_db) invitation_token = self._get_invitation_token(request) if invitation_token: diff --git a/api/src/backend/api/tests/test_adapters.py b/api/src/backend/api/tests/test_adapters.py index 7d8e06bb0e..3fd14afbf7 100644 --- a/api/src/backend/api/tests/test_adapters.py +++ b/api/src/backend/api/tests/test_adapters.py @@ -111,6 +111,110 @@ def _verify_local_email(user): ) +def test_social_account_name_falls_back_to_login_for_blank_name(): + adapter = ProwlerSocialAccountAdapter() + + name = adapter._get_social_account_name( + {"name": " ", "login": "octocat"}, + "verified@example.com", + ) + + assert name == "octocat" + + +@pytest.mark.parametrize("provider_name", [None, "", " ", 123, ["name"]]) +def test_social_account_name_ignores_unusable_provider_names(provider_name): + adapter = ProwlerSocialAccountAdapter() + + name = adapter._get_social_account_name( + {"name": provider_name, "login": "octocat"}, + "verified@example.com", + ) + + assert name == "octocat" + + +def test_social_account_name_uses_login_when_name_is_missing(): + adapter = ProwlerSocialAccountAdapter() + + name = adapter._get_social_account_name( + {"login": "octocat"}, + "verified@example.com", + ) + + assert name == "octocat" + + +def test_social_account_name_falls_back_to_username_then_email(): + adapter = ProwlerSocialAccountAdapter() + + username_name = adapter._get_social_account_name( + {"name": "ab", "login": None, "username": " monalisa "}, + "verified@example.com", + ) + email_name = adapter._get_social_account_name({}, " verified@example.com ") + + assert username_name == "monalisa" + assert email_name == "verified@example.com" + + +def test_social_account_name_trims_and_limits_provider_name(): + adapter = ProwlerSocialAccountAdapter() + max_length = User._meta.get_field("name").max_length + + trimmed_name = adapter._get_social_account_name( + {"name": " Ada Lovelace "}, + "verified@example.com", + ) + limited_name = adapter._get_social_account_name( + {"name": "a" * (max_length + 1)}, + "verified@example.com", + ) + + assert trimmed_name == "Ada Lovelace" + assert limited_name == "a" * max_length + + +def test_social_account_name_rejects_missing_identity(): + adapter = ProwlerSocialAccountAdapter() + + with pytest.raises( + ValueError, + match="Social account does not provide a valid user identity", + ): + adapter._get_social_account_name({}, "") + + +def test_save_user_applies_normalized_social_account_name(rf): + adapter = ProwlerSocialAccountAdapter() + request = rf.post("/") + request.session = {} + sociallogin = MagicMock(spec=SocialLogin) + sociallogin.provider = MagicMock() + sociallogin.provider.id = "github" + sociallogin.account = MagicMock() + sociallogin.account.extra_data = {"name": None, "login": " octocat "} + user = User(email="verified@example.com") + user.save = MagicMock() + invitation = SimpleNamespace(tenant_id="tenant-id") + + with ( + patch("api.adapters.super") as mock_super, + patch("api.adapters.transaction.atomic"), + patch("api.adapters.write_db_alias"), + patch.object(adapter, "_get_invitation_token", return_value="token"), + patch( + "api.adapters.accept_invitation_for_user", + return_value=(invitation, True), + ), + ): + mock_super.return_value.save_user.return_value = user + saved_user = adapter.save_user(request, sociallogin) + + assert saved_user.name == "octocat" + assert request.prowler_invitation_token == "token" + + @pytest.mark.django_db class TestProwlerSocialAccountAdapter: def test_get_user_by_email_returns_user(self, create_test_user): From 02df22ca19581365dbfea6e6c6f28c58c4511f47 Mon Sep 17 00:00:00 2001 From: lydiavilchez <114735608+lydiavilchez@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:58:31 +0200 Subject: [PATCH 05/28] fix(html): escape provider identity fields in report header (#12424) Co-authored-by: pedrooot Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: pedrooot <56402503+pedrooot@users.noreply.github.com> --- .../html-report-header-xss.security.md | 1 + prowler/lib/outputs/html/html.py | 215 +++++++++---- tests/lib/outputs/html/html_test.py | 285 ++++++++++++++++++ 3 files changed, 436 insertions(+), 65 deletions(-) create mode 100644 prowler/changelog.d/html-report-header-xss.security.md diff --git a/prowler/changelog.d/html-report-header-xss.security.md b/prowler/changelog.d/html-report-header-xss.security.md new file mode 100644 index 0000000000..723c78cdc6 --- /dev/null +++ b/prowler/changelog.d/html-report-header-xss.security.md @@ -0,0 +1 @@ +HTML report header now HTML-escapes every provider identity field across all 23 providers, closing a stored XSS in the header block (Secur0, CWE-79) that was left unaddressed by the earlier finding-row fix in #12221 diff --git a/prowler/lib/outputs/html/html.py b/prowler/lib/outputs/html/html.py index 1dcbfb5416..60428bede7 100644 --- a/prowler/lib/outputs/html/html.py +++ b/prowler/lib/outputs/html/html.py @@ -463,6 +463,11 @@ class HTML(Output): audited_regions = "All Regions" else: audited_regions = ", ".join(provider.identity.audited_regions) + account = escape(str(provider.identity.account)) + profile = escape(str(profile)) + audited_regions = escape(str(audited_regions)) + user_id = escape(str(provider.identity.user_id)) + identity_arn = escape(str(provider.identity.identity_arn)) return f"""
@@ -471,7 +476,7 @@ class HTML(Output):
  • - AWS Account: {provider.identity.account} + AWS Account: {account}
  • AWS-CLI Profile: {profile} @@ -489,10 +494,10 @@ class HTML(Output):
  • - User Id: {provider.identity.user_id} + User Id: {user_id}
  • - Caller Identity ARN: {provider.identity.identity_arn} + Caller Identity ARN: {identity_arn}
@@ -530,6 +535,11 @@ class HTML(Output): ) else: html_identity = provider.identity.identity_id + tenant_ids = escape(" ".join(provider.identity.tenant_ids)) + tenant_domain = escape(str(provider.identity.tenant_domain)) + subscriptions = escape(" ".join(printed_subscriptions)) + identity_type = escape(str(provider.identity.identity_type)) + html_identity = escape(str(html_identity)) return f"""
@@ -538,13 +548,13 @@ class HTML(Output):
  • - Azure Tenant IDs: {" ".join(provider.identity.tenant_ids)} + Azure Tenant IDs: {tenant_ids}
  • - Azure Tenant Domain: {provider.identity.tenant_domain} + Azure Tenant Domain: {tenant_domain}
  • - Azure Subscriptions: {" ".join(printed_subscriptions)} + Azure Subscriptions: {subscriptions}
@@ -556,7 +566,7 @@ class HTML(Output):