From 9c4a8782e40fbf3cafa99a1462a9c5abf701195e Mon Sep 17 00:00:00 2001 From: Pepe Fagoaga Date: Fri, 3 Oct 2025 09:26:45 +0200 Subject: [PATCH 01/32] fix(conflict-checker): fail on conflict (#8840) --- .github/workflows/pr-conflict-checker.yml | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/.github/workflows/pr-conflict-checker.yml b/.github/workflows/pr-conflict-checker.yml index 0358f2cbe1..77280d5136 100644 --- a/.github/workflows/pr-conflict-checker.yml +++ b/.github/workflows/pr-conflict-checker.yml @@ -9,14 +9,15 @@ on: branches: - "master" - "v5.*" - pull_request_target: - types: - - opened - - synchronize - - reopened - branches: - - "master" - - "v5.*" + # Leaving this commented until we find a way to run it for forks but in Prowler's context + # pull_request_target: + # types: + # - opened + # - synchronize + # - reopened + # branches: + # - "master" + # - "v5.*" jobs: conflict-checker: @@ -166,3 +167,9 @@ jobs: ✅ **Conflict Markers Resolved** All conflict markers have been successfully resolved in this pull request. + + - name: Fail workflow if conflicts detected + if: steps.conflict-check.outputs.has_conflicts == 'true' + run: | + echo "::error::Workflow failed due to conflict markers in files: ${{ steps.conflict-check.outputs.conflict_files }}" + exit 1 From 2408dbf855c912e4c6ebf58317653032f9c6a65b Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Fri, 3 Oct 2025 09:57:46 +0200 Subject: [PATCH 02/32] chore(ui): upgrade zod v4, zustand v5, and ai sdk v5 (#8801) --- ui/CHANGELOG.md | 3 + ui/actions/auth/auth.ts | 21 +- ui/app/api/lighthouse/analyst/route.ts | 48 +- ui/auth.config.ts | 8 +- ui/components/auth/oss/auth-divider.tsx | 11 + ui/components/auth/oss/auth-footer-link.tsx | 22 + ui/components/auth/oss/auth-form.tsx | 423 +----------------- ui/components/auth/oss/auth-layout.tsx | 37 ++ ui/components/auth/oss/sign-in-form.tsx | 194 ++++++++ ui/components/auth/oss/sign-up-form.tsx | 252 +++++++++++ .../security-hub-integration-form.tsx | 9 +- .../workflow/forms/send-invitation-form.tsx | 4 +- ui/components/lighthouse/actions.tsx | 81 ++++ ui/components/lighthouse/chat.tsx | 282 ++++++++---- ui/components/lighthouse/chatbot-config.tsx | 103 +++-- ui/components/lighthouse/loader.tsx | 52 +++ .../manage-groups/forms/add-group-form.tsx | 2 +- .../manage-groups/forms/edit-group-form.tsx | 2 +- .../workflow/forms/test-connection-form.tsx | 2 +- .../roles/workflow/forms/add-role-form.tsx | 2 +- .../roles/workflow/forms/edit-role-form.tsx | 2 +- ui/dependency-log.json | 32 +- ui/hooks/use-credentials-form.ts | 12 +- ui/lib/lighthouse/tools/checks.ts | 19 +- ui/lib/lighthouse/tools/compliances.ts | 29 +- ui/lib/lighthouse/tools/findings.ts | 21 +- ui/lib/lighthouse/tools/overview.ts | 31 +- ui/lib/lighthouse/tools/providers.ts | 17 +- ui/lib/lighthouse/tools/resources.ts | 37 +- ui/lib/lighthouse/tools/roles.ts | 16 +- ui/lib/lighthouse/tools/scans.ts | 16 +- ui/lib/lighthouse/tools/users.ts | 16 +- ui/lib/lighthouse/utils.ts | 39 +- ui/package-lock.json | 344 ++++++++------ ui/package.json | 10 +- ui/styles/globals.css | 7 +- ui/tests/auth-login.spec.ts | 15 + ui/tests/helpers.ts | 1 + ui/types/authFormSchema.ts | 110 +++-- ui/types/formSchemas.ts | 37 +- ui/types/integrations.ts | 4 +- ui/types/lighthouse/findings.ts | 6 +- ui/types/lighthouse/scans.ts | 6 +- 43 files changed, 1469 insertions(+), 916 deletions(-) create mode 100644 ui/components/auth/oss/auth-divider.tsx create mode 100644 ui/components/auth/oss/auth-footer-link.tsx create mode 100644 ui/components/auth/oss/auth-layout.tsx create mode 100644 ui/components/auth/oss/sign-in-form.tsx create mode 100644 ui/components/auth/oss/sign-up-form.tsx create mode 100644 ui/components/lighthouse/actions.tsx create mode 100644 ui/components/lighthouse/loader.tsx diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index c38413d989..fbbbb8b643 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -15,6 +15,9 @@ All notable changes to the **Prowler UI** are documented in this file. ### 🔄 Changed +- Upgraded Zod to version 4.1.11 with comprehensive migration of deprecated syntax [(#8801)](https://github.com/prowler-cloud/prowler/pull/8801) +- Upgraded Zustand to version 5.0.8 (no code changes required) [(#8801)](https://github.com/prowler-cloud/prowler/pull/8801) +- Upgraded AI SDK to version 5.0.59 with new transport and message structure [(#8801)](https://github.com/prowler-cloud/prowler/pull/8801) - Upgraded React to version 19.1.1 with async components support [(#8748)](https://github.com/prowler-cloud/prowler/pull/8748) - Upgraded Next.js to version 15.5.3 with enhanced App Router [(#8748)](https://github.com/prowler-cloud/prowler/pull/8748) - Updated from NextUI to HeroUI [(#8748)](https://github.com/prowler-cloud/prowler/pull/8748) diff --git a/ui/actions/auth/auth.ts b/ui/actions/auth/auth.ts index 5d478ff5ba..5555e6d9e9 100644 --- a/ui/actions/auth/auth.ts +++ b/ui/actions/auth/auth.ts @@ -1,23 +1,14 @@ "use server"; import { AuthError } from "next-auth"; -import { z } from "zod"; import { signIn, signOut } from "@/auth.config"; import { apiBaseUrl } from "@/lib"; -import { authFormSchema } from "@/types"; - -const formSchemaSignIn = authFormSchema("sign-in"); -const formSchemaSignUp = authFormSchema("sign-up"); - -const defaultValues: z.infer = { - email: "", - password: "", -}; +import type { SignInFormData, SignUpFormData } from "@/types"; export async function authenticate( prevState: unknown, - formData: z.infer, + formData: SignInFormData, ) { try { await signIn("credentials", { @@ -34,7 +25,6 @@ export async function authenticate( return { message: "Credentials error", errors: { - ...defaultValues, credentials: "Invalid email or password", }, }; @@ -46,7 +36,6 @@ export async function authenticate( return { message: "Unknown error", errors: { - ...defaultValues, unknown: "Unknown error", }, }; @@ -55,9 +44,7 @@ export async function authenticate( } } -export const createNewUser = async ( - formData: z.infer, -) => { +export const createNewUser = async (formData: SignUpFormData) => { const url = new URL(`${apiBaseUrl}/users`); if (formData.invitationToken) { @@ -104,7 +91,7 @@ export const createNewUser = async ( } }; -export const getToken = async (formData: z.infer) => { +export const getToken = async (formData: SignInFormData) => { const url = new URL(`${apiBaseUrl}/tokens`); const bodyData = { diff --git a/ui/app/api/lighthouse/analyst/route.ts b/ui/app/api/lighthouse/analyst/route.ts index 45dd19d180..c2cf83333b 100644 --- a/ui/app/api/lighthouse/analyst/route.ts +++ b/ui/app/api/lighthouse/analyst/route.ts @@ -1,12 +1,10 @@ -import { LangChainAdapter, Message } from "ai"; +import { toUIMessageStream } from "@ai-sdk/langchain"; +import { createUIMessageStreamResponse, UIMessage } from "ai"; import { getLighthouseConfig } from "@/actions/lighthouse/lighthouse"; import { getErrorMessage } from "@/lib/helper"; import { getCurrentDataSection } from "@/lib/lighthouse/data"; -import { - convertLangChainMessageToVercelMessage, - convertVercelMessageToLangChainMessage, -} from "@/lib/lighthouse/utils"; +import { convertVercelMessageToLangChainMessage } from "@/lib/lighthouse/utils"; import { initLighthouseWorkflow } from "@/lib/lighthouse/workflow"; export async function POST(req: Request) { @@ -14,7 +12,7 @@ export async function POST(req: Request) { const { messages, }: { - messages: Message[]; + messages: UIMessage[]; } = await req.json(); if (!messages) { @@ -32,14 +30,19 @@ export async function POST(req: Request) { const currentData = await getCurrentDataSection(); // Add context messages at the beginning - const contextMessages: Message[] = []; + const contextMessages: UIMessage[] = []; // Add business context if available if (businessContext) { contextMessages.push({ id: "business-context", role: "assistant", - content: `Business Context Information:\n${businessContext}`, + parts: [ + { + type: "text", + text: `Business Context Information:\n${businessContext}`, + }, + ], }); } @@ -48,7 +51,12 @@ export async function POST(req: Request) { contextMessages.push({ id: "current-data", role: "assistant", - content: currentData, + parts: [ + { + type: "text", + text: currentData, + }, + ], }); } @@ -61,7 +69,7 @@ export async function POST(req: Request) { { messages: processedMessages .filter( - (message: Message) => + (message: UIMessage) => message.role === "user" || message.role === "assistant", ) .map(convertVercelMessageToLangChainMessage), @@ -75,12 +83,12 @@ export async function POST(req: Request) { const stream = new ReadableStream({ async start(controller) { try { - for await (const { event, data, tags } of agentStream) { + for await (const streamEvent of agentStream) { + const { event, data, tags } = streamEvent; if (event === "on_chat_model_stream") { if (data.chunk.content && !!tags && tags.includes("supervisor")) { - const chunk = data.chunk; - const aiMessage = convertLangChainMessageToVercelMessage(chunk); - controller.enqueue(aiMessage); + // Pass the raw LangChain stream event - toUIMessageStream will handle conversion + controller.enqueue(streamEvent); } } } @@ -88,17 +96,17 @@ export async function POST(req: Request) { } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - controller.enqueue({ - id: "error-" + Date.now(), - role: "assistant", - content: `[LIGHTHOUSE_ANALYST_ERROR]: ${errorMessage}`, - }); + // For errors, send a plain string that toUIMessageStream will convert to text chunks + controller.enqueue(`[LIGHTHOUSE_ANALYST_ERROR]: ${errorMessage}`); controller.close(); } }, }); - return LangChainAdapter.toDataStreamResponse(stream); + // Convert LangChain stream to UI message stream and return as SSE response + return createUIMessageStreamResponse({ + stream: toUIMessageStream(stream), + }); } catch (error) { console.error("Error in POST request:", error); return Response.json( diff --git a/ui/auth.config.ts b/ui/auth.config.ts index 5e8e5897df..f2b9db8811 100644 --- a/ui/auth.config.ts +++ b/ui/auth.config.ts @@ -74,14 +74,18 @@ export const authConfig = { async authorize(credentials) { const parsedCredentials = z .object({ - email: z.string().email(), + email: z.email(), password: z.string().min(12), }) .safeParse(credentials); if (!parsedCredentials.success) return null; - const tokenResponse = await getToken(parsedCredentials.data); + const { email, password } = parsedCredentials.data; + const tokenResponse = await getToken({ + email, + password, + }); if (!tokenResponse) return null; const userMeResponse = await getUserByMe(tokenResponse.accessToken); diff --git a/ui/components/auth/oss/auth-divider.tsx b/ui/components/auth/oss/auth-divider.tsx new file mode 100644 index 0000000000..2951a1a01c --- /dev/null +++ b/ui/components/auth/oss/auth-divider.tsx @@ -0,0 +1,11 @@ +import { Divider } from "@heroui/divider"; + +export const AuthDivider = () => { + return ( +
+ +

OR

+ +
+ ); +}; diff --git a/ui/components/auth/oss/auth-footer-link.tsx b/ui/components/auth/oss/auth-footer-link.tsx new file mode 100644 index 0000000000..bc39e297d8 --- /dev/null +++ b/ui/components/auth/oss/auth-footer-link.tsx @@ -0,0 +1,22 @@ +import { CustomLink } from "@/components/ui/custom/custom-link"; + +interface AuthFooterLinkProps { + text: string; + linkText: string; + href: string; +} + +export const AuthFooterLink = ({ + text, + linkText, + href, +}: AuthFooterLinkProps) => { + return ( +

+ {text}  + + {linkText} + +

+ ); +}; diff --git a/ui/components/auth/oss/auth-form.tsx b/ui/components/auth/oss/auth-form.tsx index ed711bbd8e..595aea4e18 100644 --- a/ui/components/auth/oss/auth-form.tsx +++ b/ui/components/auth/oss/auth-form.tsx @@ -1,36 +1,9 @@ -"use client"; - -import { Button } from "@heroui/button"; -import { Checkbox } from "@heroui/checkbox"; -import { Divider } from "@heroui/divider"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { Icon } from "@iconify/react"; -import { useRouter, useSearchParams } from "next/navigation"; -import { useEffect } from "react"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; - -import { authenticate, createNewUser } from "@/actions/auth"; -import { initiateSamlAuth } from "@/actions/integrations/saml"; -import { PasswordRequirementsMessage } from "@/components/auth/oss/password-validator"; -import { SocialButtons } from "@/components/auth/oss/social-buttons"; -import { ProwlerExtended } from "@/components/icons"; -import { ThemeSwitch } from "@/components/ThemeSwitch"; -import { useToast } from "@/components/ui"; -import { CustomButton, CustomInput } from "@/components/ui/custom"; -import { CustomLink } from "@/components/ui/custom/custom-link"; -import { - Form, - FormControl, - FormField, - FormMessage, -} from "@/components/ui/form"; -import { ApiError, authFormSchema } from "@/types"; +import { SignInForm } from "@/components/auth/oss/sign-in-form"; +import { SignUpForm } from "@/components/auth/oss/sign-up-form"; export const AuthForm = ({ type, invitationToken, - isCloudEnv, googleAuthUrl, githubAuthUrl, isGoogleOAuthEnabled, @@ -38,387 +11,29 @@ export const AuthForm = ({ }: { type: string; invitationToken?: string | null; - isCloudEnv?: boolean; googleAuthUrl?: string; githubAuthUrl?: string; isGoogleOAuthEnabled?: boolean; isGithubOAuthEnabled?: boolean; }) => { - const formSchema = authFormSchema(type); - const router = useRouter(); - const searchParams = useSearchParams(); - const { toast } = useToast(); - - useEffect(() => { - const samlError = searchParams.get("sso_saml_failed"); - - if (samlError) { - // Add a delay to the toast to ensure it is rendered - setTimeout(() => { - toast({ - variant: "destructive", - title: "SAML Authentication Error", - description: - "An error occurred while attempting to login via your Identity Provider (IdP). Please check your IdP configuration.", - }); - }, 100); - } - }, [searchParams, toast]); - - const form = useForm>({ - resolver: zodResolver(formSchema), - mode: "onSubmit", - reValidateMode: "onSubmit", - defaultValues: { - email: "", - password: "", - isSamlMode: false, - ...(type === "sign-up" && { - name: "", - company: "", - confirmPassword: "", - ...(invitationToken && { invitationToken }), - }), - }, - }); - - const isLoading = form.formState.isSubmitting; - const isSamlMode = form.watch("isSamlMode"); - - const onSubmit = async (data: z.infer) => { - if (type === "sign-in") { - if (data.isSamlMode) { - const email = data.email.toLowerCase(); - if (isSamlMode) { - form.setValue("password", ""); - } - - const result = await initiateSamlAuth(email); - - if (result.success && result.redirectUrl) { - window.location.href = result.redirectUrl; - } else { - toast({ - variant: "destructive", - title: "SAML Authentication Error", - description: - result.error || "An error occurred during SAML authentication.", - }); - } - return; - } - - const result = await authenticate(null, { - email: data.email.toLowerCase(), - password: data.password, - }); - if (result?.message === "Success") { - router.push("/"); - } else if (result?.errors && "credentials" in result.errors) { - const message = - result.errors.credentials ?? "Invalid email or password"; - - form.setError("email", { type: "server", message }); - form.setError("password", { type: "server", message }); - } else if (result?.message === "User email is not verified") { - router.push("/email-verification"); - } else { - toast({ - variant: "destructive", - title: "Oops! Something went wrong", - description: "An unexpected error occurred. Please try again.", - }); - } - } - - if (type === "sign-up") { - const newUser = await createNewUser(data); - - if (!newUser.errors) { - toast({ - title: "Success!", - description: "The user was registered successfully.", - }); - form.reset(); - - if (isCloudEnv) { - router.push("/email-verification"); - } else { - router.push("/sign-in"); - } - } else { - newUser.errors.forEach((error: ApiError) => { - const errorMessage = error.detail; - const pointer = error.source?.pointer; - switch (pointer) { - case "/data/attributes/name": - form.setError("name", { type: "server", message: errorMessage }); - break; - case "/data/attributes/email": - form.setError("email", { type: "server", message: errorMessage }); - break; - case "/data/attributes/company_name": - form.setError("company", { - type: "server", - message: errorMessage, - }); - break; - case "/data/attributes/password": - form.setError("password", { - type: "server", - message: errorMessage, - }); - break; - case "/data": - form.setError("invitationToken", { - type: "server", - message: errorMessage, - }); - break; - default: - toast({ - variant: "destructive", - title: "Oops! Something went wrong", - description: errorMessage, - }); - } - }); - } - } - }; + if (type === "sign-in") { + return ( + + ); + } return ( -
- {/* Auth Form */} -
- {/* Background Pattern */} -
- -
- {/* Prowler Logo */} -
- -
-
-

- {type === "sign-in" - ? isSamlMode - ? "Sign in with SAML SSO" - : "Sign in" - : "Sign up"} -

- -
- -
- - {type === "sign-up" && ( - <> - - - - )} - - {!isSamlMode && ( - <> - - {type === "sign-up" && ( - - )} - - )} - {/* {type === "sign-in" && ( -
- - Remember me - - - Forgot password? - -
- )} */} - {type === "sign-up" && ( - <> - - {invitationToken && ( - - )} - - {process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true" && ( - ( - <> - - field.onChange(e.target.checked)} - > - I agree with the  - - Terms of Service - -  of Prowler - - - - - )} - /> - )} - - )} - - - {isLoading ? ( - Loading - ) : ( - {type === "sign-in" ? "Log in" : "Sign up"} - )} - - - - - {!invitationToken && type === "sign-in" && ( - <> -
- -

OR

- -
-
- {!isSamlMode && ( - - )} - -
- - )} - {!invitationToken && type === "sign-up" && ( - <> -
- -

OR

- -
-
- -
- - )} - {type === "sign-in" ? ( -

- Need to create an account?  - - Sign up - -

- ) : ( -

- Already have an account?  - - Log in - -

- )} -
-
-
+ ); }; diff --git a/ui/components/auth/oss/auth-layout.tsx b/ui/components/auth/oss/auth-layout.tsx new file mode 100644 index 0000000000..3fe26234ab --- /dev/null +++ b/ui/components/auth/oss/auth-layout.tsx @@ -0,0 +1,37 @@ +import { ReactNode } from "react"; + +import { ProwlerExtended } from "@/components/icons"; +import { ThemeSwitch } from "@/components/ThemeSwitch"; + +interface AuthLayoutProps { + title: string; + children: ReactNode; +} + +export const AuthLayout = ({ title, children }: AuthLayoutProps) => { + return ( +
+
+ {/* Background Pattern */} +
+ + {/* Auth Form Container */} +
+ {/* Prowler Logo */} +
+ +
+ + {/* Header with Title and Theme Toggle */} +
+

{title}

+ +
+ + {/* Content */} + {children} +
+
+
+ ); +}; diff --git a/ui/components/auth/oss/sign-in-form.tsx b/ui/components/auth/oss/sign-in-form.tsx new file mode 100644 index 0000000000..0f5a109040 --- /dev/null +++ b/ui/components/auth/oss/sign-in-form.tsx @@ -0,0 +1,194 @@ +"use client"; + +import { Button } from "@heroui/button"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Icon } from "@iconify/react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useEffect } from "react"; +import { useForm } from "react-hook-form"; + +import { authenticate } from "@/actions/auth"; +import { initiateSamlAuth } from "@/actions/integrations/saml"; +import { AuthDivider } from "@/components/auth/oss/auth-divider"; +import { AuthFooterLink } from "@/components/auth/oss/auth-footer-link"; +import { AuthLayout } from "@/components/auth/oss/auth-layout"; +import { SocialButtons } from "@/components/auth/oss/social-buttons"; +import { useToast } from "@/components/ui"; +import { CustomButton, CustomInput } from "@/components/ui/custom"; +import { Form } from "@/components/ui/form"; +import { SignInFormData, signInSchema } from "@/types"; + +export const SignInForm = ({ + googleAuthUrl, + githubAuthUrl, + isGoogleOAuthEnabled, + isGithubOAuthEnabled, +}: { + googleAuthUrl?: string; + githubAuthUrl?: string; + isGoogleOAuthEnabled?: boolean; + isGithubOAuthEnabled?: boolean; +}) => { + const router = useRouter(); + const searchParams = useSearchParams(); + const { toast } = useToast(); + + useEffect(() => { + const samlError = searchParams.get("sso_saml_failed"); + + if (samlError) { + setTimeout(() => { + toast({ + variant: "destructive", + title: "SAML Authentication Error", + description: + "An error occurred while attempting to login via your Identity Provider (IdP). Please check your IdP configuration.", + }); + }, 100); + } + }, [searchParams, toast]); + + const form = useForm({ + resolver: zodResolver(signInSchema), + mode: "onSubmit", + reValidateMode: "onSubmit", + defaultValues: { + email: "", + password: "", + isSamlMode: false, + }, + }); + + const isLoading = form.formState.isSubmitting; + const isSamlMode = form.watch("isSamlMode"); + + const onSubmit = async (data: SignInFormData) => { + if (data.isSamlMode) { + const email = data.email.toLowerCase(); + if (isSamlMode) { + form.setValue("password", ""); + } + + const result = await initiateSamlAuth(email); + + if (result.success && result.redirectUrl) { + window.location.href = result.redirectUrl; + } else { + toast({ + variant: "destructive", + title: "SAML Authentication Error", + description: + result.error || "An error occurred during SAML authentication.", + }); + } + return; + } + + const result = await authenticate(null, { + email: data.email.toLowerCase(), + password: data.password, + }); + + if (result?.message === "Success") { + router.push("/"); + } else if (result?.errors && "credentials" in result.errors) { + const message = result.errors.credentials ?? "Invalid email or password"; + + form.setError("email", { type: "server", message }); + form.setError("password", { type: "server", message }); + } else if (result?.message === "User email is not verified") { + router.push("/email-verification"); + } else { + toast({ + variant: "destructive", + title: "Oops! Something went wrong", + description: "An unexpected error occurred. Please try again.", + }); + } + }; + + const title = isSamlMode ? "Sign in with SAML SSO" : "Sign in"; + + return ( + +
+ + + {!isSamlMode && ( + + )} + + + {isLoading ? Loading : Log in} + + + + + + +
+ {!isSamlMode && ( + + )} + +
+ + +
+ ); +}; diff --git a/ui/components/auth/oss/sign-up-form.tsx b/ui/components/auth/oss/sign-up-form.tsx new file mode 100644 index 0000000000..917bd2c9e4 --- /dev/null +++ b/ui/components/auth/oss/sign-up-form.tsx @@ -0,0 +1,252 @@ +"use client"; + +import { Checkbox } from "@heroui/checkbox"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; + +import { createNewUser } from "@/actions/auth"; +import { AuthDivider } from "@/components/auth/oss/auth-divider"; +import { AuthFooterLink } from "@/components/auth/oss/auth-footer-link"; +import { AuthLayout } from "@/components/auth/oss/auth-layout"; +import { PasswordRequirementsMessage } from "@/components/auth/oss/password-validator"; +import { SocialButtons } from "@/components/auth/oss/social-buttons"; +import { useToast } from "@/components/ui"; +import { CustomButton, CustomInput } from "@/components/ui/custom"; +import { CustomLink } from "@/components/ui/custom/custom-link"; +import { + Form, + FormControl, + FormField, + FormMessage, +} from "@/components/ui/form"; +import { ApiError, SignUpFormData, signUpSchema } from "@/types"; + +const AUTH_ERROR_PATHS = { + NAME: "/data/attributes/name", + EMAIL: "/data/attributes/email", + PASSWORD: "/data/attributes/password", + COMPANY_NAME: "/data/attributes/company_name", + INVITATION_TOKEN: "/data", +} as const; + +export const SignUpForm = ({ + invitationToken, + googleAuthUrl, + githubAuthUrl, + isGoogleOAuthEnabled, + isGithubOAuthEnabled, +}: { + invitationToken?: string | null; + googleAuthUrl?: string; + githubAuthUrl?: string; + isGoogleOAuthEnabled?: boolean; + isGithubOAuthEnabled?: boolean; +}) => { + const router = useRouter(); + const { toast } = useToast(); + + const form = useForm({ + resolver: zodResolver(signUpSchema), + mode: "onSubmit", + reValidateMode: "onSubmit", + defaultValues: { + email: "", + password: "", + isSamlMode: false, + name: "", + company: "", + confirmPassword: "", + ...(invitationToken && { invitationToken }), + }, + }); + + const isLoading = form.formState.isSubmitting; + + const onSubmit = async (data: SignUpFormData) => { + const newUser = await createNewUser(data); + + if (!newUser.errors) { + toast({ + title: "Success!", + description: "The user was registered successfully.", + }); + form.reset(); + + if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") { + router.push("/email-verification"); + } else { + router.push("/sign-in"); + } + } else { + newUser.errors.forEach((error: ApiError) => { + const errorMessage = error.detail; + const pointer = error.source?.pointer; + switch (pointer) { + case AUTH_ERROR_PATHS.NAME: + form.setError("name", { type: "server", message: errorMessage }); + break; + case AUTH_ERROR_PATHS.EMAIL: + form.setError("email", { type: "server", message: errorMessage }); + break; + case AUTH_ERROR_PATHS.COMPANY_NAME: + form.setError("company", { + type: "server", + message: errorMessage, + }); + break; + case AUTH_ERROR_PATHS.PASSWORD: + form.setError("password", { + type: "server", + message: errorMessage, + }); + break; + case AUTH_ERROR_PATHS.INVITATION_TOKEN: + form.setError("invitationToken", { + type: "server", + message: errorMessage, + }); + break; + default: + toast({ + variant: "destructive", + title: "Oops! Something went wrong", + description: errorMessage, + }); + } + }); + } + }; + + return ( + +
+ + + + + + + + {invitationToken && ( + + )} + + {process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true" && ( + ( + <> + + field.onChange(e.target.checked)} + > + I agree with the  + + Terms of Service + +  of Prowler + + + + + )} + /> + )} + + + {isLoading ? Loading : Sign up} + + + + + {!invitationToken && ( + <> + +
+ +
+ + )} + + +
+ ); +}; 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 d3f9f7ccd4..0e33be2ca3 100644 --- a/ui/components/integrations/security-hub/security-hub-integration-form.tsx +++ b/ui/components/integrations/security-hub/security-hub-integration-form.tsx @@ -81,10 +81,13 @@ export const SecurityHubIntegrationForm = ({ integration_type: "aws_security_hub" as const, provider_id: integration?.relationships?.providers?.data?.[0]?.id || "", send_only_fails: - integration?.attributes.configuration.send_only_fails ?? true, + (integration?.attributes.configuration.send_only_fails as + | boolean + | undefined) ?? true, archive_previous_findings: - integration?.attributes.configuration.archive_previous_findings ?? - false, + (integration?.attributes.configuration.archive_previous_findings as + | boolean + | undefined) ?? false, use_custom_credentials: false, enabled: integration?.attributes.enabled ?? true, credentials_type: "access-secret-key" as const, diff --git a/ui/components/invitations/workflow/forms/send-invitation-form.tsx b/ui/components/invitations/workflow/forms/send-invitation-form.tsx index c068ee47ad..6ab173a4f6 100644 --- a/ui/components/invitations/workflow/forms/send-invitation-form.tsx +++ b/ui/components/invitations/workflow/forms/send-invitation-form.tsx @@ -14,8 +14,8 @@ import { Form } from "@/components/ui/form"; import { ApiError } from "@/types"; const sendInvitationFormSchema = z.object({ - email: z.string().email("Please enter a valid email"), - roleId: z.string().nonempty("Role is required"), + email: z.email({ error: "Please enter a valid email" }), + roleId: z.string().min(1, "Role is required"), }); export type FormValues = z.infer; diff --git a/ui/components/lighthouse/actions.tsx b/ui/components/lighthouse/actions.tsx new file mode 100644 index 0000000000..efeafbaa77 --- /dev/null +++ b/ui/components/lighthouse/actions.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { Button } from "@heroui/button"; +import type { PressEvent } from "@react-types/shared"; + +import { cn } from "@/lib/utils"; + +interface ActionsProps extends React.HTMLAttributes { + className?: string; + children?: React.ReactNode; + ref?: React.Ref; +} + +const Actions = ({ className, children, ref, ...props }: ActionsProps) => { + return ( +
+ {children} +
+ ); +}; + +interface ActionProps { + /** + * Action label text + */ + label: string; + /** + * Optional icon component (Lucide React icon recommended) + */ + icon?: React.ReactNode; + /** + * Click handler + */ + onClick?: (e: PressEvent) => void; + /** + * Visual variant + * @default "light" + */ + variant?: "solid" | "bordered" | "light" | "flat" | "faded" | "shadow"; + className?: string; + isDisabled?: boolean; + ref?: React.Ref; +} + +const Action = ({ + label, + icon, + onClick, + variant = "light", + className, + isDisabled = false, + ref, + ...props +}: ActionProps) => { + return ( + + ); +}; + +export { Action, Actions }; diff --git a/ui/components/lighthouse/chat.tsx b/ui/components/lighthouse/chat.tsx index 7015850c4d..6c38ec410f 100644 --- a/ui/components/lighthouse/chat.tsx +++ b/ui/components/lighthouse/chat.tsx @@ -1,10 +1,15 @@ "use client"; import { useChat } from "@ai-sdk/react"; +import { DefaultChatTransport } from "ai"; +import { Copy, Play, Plus, RotateCcw, Square } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; +import { Action, Actions } from "@/components/lighthouse/actions"; +import { Loader } from "@/components/lighthouse/loader"; import { MemoizedMarkdown } from "@/components/lighthouse/memoized-markdown"; +import { useToast } from "@/components/ui"; import { CustomButton, CustomTextarea } from "@/components/ui/custom"; import { CustomLink } from "@/components/ui/custom/custom-link"; import { Form } from "@/components/ui/form"; @@ -26,53 +31,58 @@ interface ChatFormData { export const Chat = ({ hasConfig, isActive }: ChatProps) => { const [errorMessage, setErrorMessage] = useState(null); + const { toast } = useToast(); - const { - messages, - handleSubmit, - handleInputChange, - append, - status, - error, - setMessages, - } = useChat({ - api: "/api/lighthouse/analyst", - credentials: "same-origin", - experimental_throttle: 100, - sendExtraMessageFields: true, - onFinish: (message) => { - // There is no specific way to output the error message from langgraph supervisor - // Hence, all error messages are sent as normal messages with the prefix [LIGHTHOUSE_ANALYST_ERROR]: - // Detect error messages sent from backend using specific prefix and display the error - if (message.content?.startsWith("[LIGHTHOUSE_ANALYST_ERROR]:")) { - const errorText = message.content - .replace("[LIGHTHOUSE_ANALYST_ERROR]:", "") - .trim(); - setErrorMessage(errorText); - // Remove error message from chat history - setMessages((prev) => - prev.filter( - (m) => !m.content?.startsWith("[LIGHTHOUSE_ANALYST_ERROR]:"), - ), + const { messages, sendMessage, status, error, setMessages, regenerate } = + useChat({ + transport: new DefaultChatTransport({ + api: "/api/lighthouse/analyst", + credentials: "same-origin", + }), + experimental_throttle: 100, + onFinish: ({ message }) => { + // There is no specific way to output the error message from langgraph supervisor + // Hence, all error messages are sent as normal messages with the prefix [LIGHTHOUSE_ANALYST_ERROR]: + // Detect error messages sent from backend using specific prefix and display the error + const firstTextPart = message.parts.find((p) => p.type === "text"); + if ( + firstTextPart && + "text" in firstTextPart && + firstTextPart.text.startsWith("[LIGHTHOUSE_ANALYST_ERROR]:") + ) { + const errorText = firstTextPart.text + .replace("[LIGHTHOUSE_ANALYST_ERROR]:", "") + .trim(); + setErrorMessage(errorText); + // Remove error message from chat history + setMessages((prev) => + prev.filter((m) => { + const textPart = m.parts.find((p) => p.type === "text"); + return !( + textPart && + "text" in textPart && + textPart.text.startsWith("[LIGHTHOUSE_ANALYST_ERROR]:") + ); + }), + ); + } + }, + onError: (error) => { + console.error("Chat error:", error); + + if ( + error?.message?.includes("") && + error?.message?.includes("403 Forbidden") + ) { + setErrorMessage("403 Forbidden"); + return; + } + + setErrorMessage( + error?.message || "An error occurred. Please retry your message.", ); - } - }, - onError: (error) => { - console.error("Chat error:", error); - - if ( - error?.message?.includes("") && - error?.message?.includes("403 Forbidden") - ) { - setErrorMessage("403 Forbidden"); - return; - } - - setErrorMessage( - error?.message || "An error occurred. Please retry your message.", - ); - }, - }); + }, + }); const form = useForm({ defaultValues: { @@ -98,7 +108,10 @@ export const Chat = ({ hasConfig, isActive }: ChatProps) => { .pop(); if (lastUserMessage) { - form.setValue("message", lastUserMessage.content); + const textPart = lastUserMessage.parts.find((p) => p.type === "text"); + if (textPart && "text" in textPart) { + form.setValue("message", textPart.text); + } // Remove the last user message from history since it's now in the input return currentMessages.slice(0, -1); } @@ -108,14 +121,6 @@ export const Chat = ({ hasConfig, isActive }: ChatProps) => { } }, [errorMessage, form, setMessages]); - // Sync form value with chat input - useEffect(() => { - const syntheticEvent = { - target: { value: messageValue }, - } as React.ChangeEvent; - handleInputChange(syntheticEvent); - }, [messageValue, handleInputChange]); - // Reset form when message is sent useEffect(() => { if (status === "submitted") { @@ -123,11 +128,25 @@ export const Chat = ({ hasConfig, isActive }: ChatProps) => { } }, [status, form]); + // Auto-scroll to bottom when new messages arrive or when streaming + useEffect(() => { + if (messagesContainerRef.current) { + messagesContainerRef.current.scrollTop = + messagesContainerRef.current.scrollHeight; + } + }, [messages, status]); + const onFormSubmit = form.handleSubmit((data) => { + // Block submission while streaming or submitted + if (status === "streaming" || status === "submitted") { + return; + } + if (data.message.trim()) { // Clear error on new submission setErrorMessage(null); - handleSubmit(); + sendMessage({ text: data.message }); + form.reset(); } }); @@ -136,7 +155,12 @@ export const Chat = ({ hasConfig, isActive }: ChatProps) => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); - if (messageValue?.trim()) { + // Block enter key while streaming or submitted + if ( + messageValue?.trim() && + status !== "streaming" && + status !== "submitted" + ) { onFormSubmit(); } } @@ -144,7 +168,7 @@ export const Chat = ({ hasConfig, isActive }: ChatProps) => { document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); - }, [messageValue, onFormSubmit]); + }, [messageValue, onFormSubmit, status]); const suggestedActions: SuggestedAction[] = [ { @@ -173,8 +197,32 @@ export const Chat = ({ hasConfig, isActive }: ChatProps) => { // Determine if chat should be disabled const shouldDisableChat = !hasConfig || !isActive; + const handleNewChat = () => { + setMessages([]); + setErrorMessage(null); + form.reset({ message: "" }); + }; + return (
+ {/* Header with New Chat button */} + {messages.length > 0 && ( +
+
+ } + onPress={handleNewChat} + className="gap-1" + > + New Chat + +
+
+ )} + {shouldDisableChat && (
@@ -257,9 +305,8 @@ export const Chat = ({ hasConfig, isActive }: ChatProps) => { key={`suggested-action-${index}`} ariaLabel={`Send message: ${action.action}`} onPress={() => { - append({ - role: "user", - content: action.action, + sendMessage({ + text: action.action, }); }} className="hover:bg-muted flex h-auto w-full flex-col items-start justify-start rounded-xl border bg-gray-50 px-4 py-3.5 text-left font-sans text-sm dark:bg-gray-900" @@ -273,7 +320,7 @@ export const Chat = ({ hasConfig, isActive }: ChatProps) => {
) : (
{messages.map((message, idx) => { @@ -283,47 +330,98 @@ export const Chat = ({ hasConfig, isActive }: ChatProps) => { .pop(); const isLatestUserMsg = message.role === "user" && lastUserIdx === idx; + const isLastMessage = idx === messages.length - 1; + const messageText = message.parts + .filter((p) => p.type === "text") + .map((p) => ("text" in p ? p.text : "")) + .join(""); + + // Check if this is the streaming assistant message (last message, assistant role, while streaming) + const isStreamingAssistant = + isLastMessage && + message.role === "assistant" && + status === "streaming"; + + // Use a composite key to ensure uniqueness even if IDs are duplicated temporarily + const uniqueKey = `${message.id}-${idx}-${message.role}`; + return ( -
+
- + {/* Show loader before text appears or while streaming empty content */} + {isStreamingAssistant && !messageText ? ( + + ) : ( +
+ +
+ )}
+ + {/* Actions for assistant messages */} + {message.role === "assistant" && + isLastMessage && + messageText && + status !== "streaming" && ( +
+ + } + onClick={() => { + navigator.clipboard.writeText(messageText); + toast({ + title: "Copied", + description: "Message copied to clipboard", + }); + }} + /> + } + onClick={() => regenerate()} + /> + +
+ )}
); })} - {status === "submitted" && ( -
-
-
Thinking...
+ {/* Show loader only if no assistant message exists yet */} + {(status === "submitted" || status === "streaming") && + messages.length > 0 && + messages[messages.length - 1].role === "user" && ( +
+
+ +
-
- )} + )}
)}
@@ -346,12 +444,22 @@ export const Chat = ({ hasConfig, isActive }: ChatProps) => { - {status === "submitted" ? : } + {status === "streaming" || status === "submitted" ? ( + + ) : ( + + )}
diff --git a/ui/components/lighthouse/chatbot-config.tsx b/ui/components/lighthouse/chatbot-config.tsx index 979484299b..12844fc9af 100644 --- a/ui/components/lighthouse/chatbot-config.tsx +++ b/ui/components/lighthouse/chatbot-config.tsx @@ -1,9 +1,9 @@ "use client"; import { Select, SelectItem } from "@heroui/select"; -import { Spacer } from "@heroui/spacer"; import { zodResolver } from "@hookform/resolvers/zod"; import { SaveIcon } from "lucide-react"; +import { useRouter } from "next/navigation"; import { useState } from "react"; import { Controller, useForm } from "react-hook-form"; import * as z from "zod"; @@ -21,8 +21,8 @@ import { import { Form } from "@/components/ui/form"; const chatbotConfigSchema = z.object({ - model: z.string().nonempty("Model selection is required"), - apiKey: z.string().nonempty("API Key is required").optional(), + model: z.string().min(1, "Model selection is required"), + apiKey: z.string().min(1, "API Key is required"), businessContext: z .string() .max(1000, "Business context cannot exceed 1000 characters") @@ -40,6 +40,7 @@ export const ChatbotConfig = ({ initialValues, configExists: initialConfigExists, }: ChatbotConfigClientProps) => { + const router = useRouter(); const { toast } = useToast(); const [isLoading, setIsLoading] = useState(false); const [configExists, setConfigExists] = useState(initialConfigExists); @@ -52,6 +53,16 @@ export const ChatbotConfig = ({ const onSubmit = async (data: FormValues) => { if (isLoading) return; + + // Validate API key: required for new config, or if changing an existing masked key + if (!configExists && (!data.apiKey || data.apiKey.trim().length === 0)) { + form.setError("apiKey", { + type: "manual", + message: "API Key is required", + }); + return; + } + setIsLoading(true); try { const configData: any = { @@ -74,6 +85,8 @@ export const ChatbotConfig = ({ configExists ? "updated" : "created" } successfully`, }); + // Navigate to lighthouse chat page after successful save + router.push("/lighthouse"); } else { throw new Error("Failed to save configuration"); } @@ -101,48 +114,52 @@ export const ChatbotConfig = ({ onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6" > - ( - field.onChange(e.target.value)} + variant="bordered" + size="md" + isRequired + > + + GPT-4o (Recommended) + + + GPT-4o Mini + + GPT-5 + + GPT-5 Mini + + + )} + /> +
+ +
+ field.onChange(e.target.value)} + placeholder="Enter your API key" variant="bordered" - size="md" isRequired - > - - GPT-4o (Recommended) - - - GPT-4o Mini - - GPT-5 - GPT-5 Mini - - )} - /> - - - - - - + isInvalid={!!form.formState.errors.apiKey} + /> +
+
- -
{ + /** + * Size of the loader spinner + * @default "default" + */ + size?: "sm" | "default" | "lg"; + /** + * Optional loading text to display + */ + text?: string; + className?: string; + ref?: React.Ref; +} + +const loaderSizes = { + sm: 16, + default: 24, + lg: 32, +}; + +const Loader = ({ + size = "default", + text, + className, + ref, + ...props +}: LoaderProps) => { + return ( +
+ + {text && {text}} + {text || "Loading..."} +
+ ); +}; + +export { Loader }; diff --git a/ui/components/manage-groups/forms/add-group-form.tsx b/ui/components/manage-groups/forms/add-group-form.tsx index 626b2a8078..6424ac8235 100644 --- a/ui/components/manage-groups/forms/add-group-form.tsx +++ b/ui/components/manage-groups/forms/add-group-form.tsx @@ -16,7 +16,7 @@ import { Form } from "@/components/ui/form"; import { ApiError } from "@/types"; const addGroupSchema = z.object({ - name: z.string().nonempty("Provider group name is required"), + name: z.string().min(1, "Provider group name is required"), providers: z.array(z.string()).optional(), roles: z.array(z.string()).optional(), }); diff --git a/ui/components/manage-groups/forms/edit-group-form.tsx b/ui/components/manage-groups/forms/edit-group-form.tsx index d73098f596..63a1dd7a00 100644 --- a/ui/components/manage-groups/forms/edit-group-form.tsx +++ b/ui/components/manage-groups/forms/edit-group-form.tsx @@ -18,7 +18,7 @@ import { Form } from "@/components/ui/form"; import { ApiError } from "@/types"; const editGroupSchema = z.object({ - name: z.string().nonempty("Provider group name is required"), + name: z.string().min(1, "Provider group name is required"), providers: z.array(z.object({ id: z.string(), name: z.string() })).optional(), roles: z.array(z.object({ id: z.string(), name: z.string() })).optional(), }); diff --git a/ui/components/providers/workflow/forms/test-connection-form.tsx b/ui/components/providers/workflow/forms/test-connection-form.tsx index aeaec43f6a..d897508b00 100644 --- a/ui/components/providers/workflow/forms/test-connection-form.tsx +++ b/ui/components/providers/workflow/forms/test-connection-form.tsx @@ -25,7 +25,7 @@ import { ApiError, testConnectionFormSchema } from "@/types"; import { ProviderInfo } from "../.."; -type FormValues = z.infer; +type FormValues = z.input; export const TestConnectionForm = ({ searchParams, diff --git a/ui/components/roles/workflow/forms/add-role-form.tsx b/ui/components/roles/workflow/forms/add-role-form.tsx index 17d1d9bbfd..66a266226c 100644 --- a/ui/components/roles/workflow/forms/add-role-form.tsx +++ b/ui/components/roles/workflow/forms/add-role-form.tsx @@ -22,7 +22,7 @@ import { Form } from "@/components/ui/form"; import { getErrorMessage, permissionFormFields } from "@/lib"; import { addRoleFormSchema, ApiError } from "@/types"; -type FormValues = z.infer; +type FormValues = z.input; export const AddRoleForm = ({ groups, diff --git a/ui/components/roles/workflow/forms/edit-role-form.tsx b/ui/components/roles/workflow/forms/edit-role-form.tsx index 9a85eeca04..9f5c279657 100644 --- a/ui/components/roles/workflow/forms/edit-role-form.tsx +++ b/ui/components/roles/workflow/forms/edit-role-form.tsx @@ -22,7 +22,7 @@ import { Form } from "@/components/ui/form"; import { getErrorMessage, permissionFormFields } from "@/lib"; import { ApiError, editRoleFormSchema } from "@/types"; -type FormValues = z.infer; +type FormValues = z.input; export const EditRoleForm = ({ roleId, diff --git a/ui/dependency-log.json b/ui/dependency-log.json index 24150505e1..826a3075c2 100644 --- a/ui/dependency-log.json +++ b/ui/dependency-log.json @@ -1,4 +1,20 @@ [ + { + "section": "dependencies", + "name": "@ai-sdk/langchain", + "from": "1.0.59", + "to": "1.0.59", + "strategy": "installed", + "generatedAt": "2025-10-01T11:13:12.025Z" + }, + { + "section": "dependencies", + "name": "@ai-sdk/react", + "from": "2.0.59", + "to": "2.0.59", + "strategy": "installed", + "generatedAt": "2025-10-01T11:13:12.025Z" + }, { "section": "dependencies", "name": "@heroui/react", @@ -11,9 +27,9 @@ "section": "dependencies", "name": "@hookform/resolvers", "from": "3.10.0", - "to": "3.10.0", + "to": "5.2.2", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.548Z" + "generatedAt": "2025-10-01T15:09:44.056Z" }, { "section": "dependencies", @@ -171,9 +187,9 @@ "section": "dependencies", "name": "ai", "from": "4.3.16", - "to": "4.3.16", + "to": "5.0.59", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.548Z" + "generatedAt": "2025-10-01T10:03:22.788Z" }, { "section": "dependencies", @@ -395,17 +411,17 @@ "section": "dependencies", "name": "zod", "from": "3.25.73", - "to": "3.25.73", + "to": "4.1.11", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.548Z" + "generatedAt": "2025-10-01T09:40:25.207Z" }, { "section": "dependencies", "name": "zustand", "from": "4.5.7", - "to": "4.5.7", + "to": "5.0.8", "strategy": "installed", - "generatedAt": "2025-09-10T11:50:17.548Z" + "generatedAt": "2025-10-01T09:40:25.207Z" }, { "section": "devDependencies", diff --git a/ui/hooks/use-credentials-form.ts b/ui/hooks/use-credentials-form.ts index 5067a8c6c0..268d714d71 100644 --- a/ui/hooks/use-credentials-form.ts +++ b/ui/hooks/use-credentials-form.ts @@ -14,12 +14,6 @@ import { ProviderType, } from "@/types"; -type CredentialsFormData = { - providerId: string; - providerType: ProviderType; - [key: string]: any; -}; - type UseCredentialsFormProps = { providerType: ProviderType; providerId: string; @@ -56,7 +50,7 @@ export const useCredentialsForm = ({ const formSchema = getFormSchema(); // Get default values based on provider type and via parameter - const getDefaultValues = (): CredentialsFormData => { + const getDefaultValues = () => { const baseDefaults = { [ProviderCredentialFields.PROVIDER_ID]: providerId, [ProviderCredentialFields.PROVIDER_TYPE]: providerType, @@ -152,7 +146,7 @@ export const useCredentialsForm = ({ } }; - const form = useForm({ + const form = useForm({ resolver: zodResolver(formSchema), defaultValues: getDefaultValues(), }); @@ -170,7 +164,7 @@ export const useCredentialsForm = ({ }; // Form submit handler - const handleSubmit = async (values: CredentialsFormData) => { + const handleSubmit = async (values: Record) => { const formData = new FormData(); // Filter out empty values first, then append all remaining values diff --git a/ui/lib/lighthouse/tools/checks.ts b/ui/lib/lighthouse/tools/checks.ts index dbb1cccf6f..3dbf81df1e 100644 --- a/ui/lib/lighthouse/tools/checks.ts +++ b/ui/lib/lighthouse/tools/checks.ts @@ -1,4 +1,5 @@ import { tool } from "@langchain/core/tools"; +import { z } from "zod"; import { getLighthouseCheckDetails, @@ -7,12 +8,13 @@ import { import { checkDetailsSchema, checkSchema } from "@/types/lighthouse"; export const getProviderChecksTool = tool( - async ({ providerType, service, severity, compliances }) => { + async (input) => { + const typedInput = input as z.infer; const checks = await getLighthouseProviderChecks({ - providerType, - service: service || [], - severity: severity || [], - compliances: compliances || [], + providerType: typedInput.providerType, + service: typedInput.service || [], + severity: typedInput.severity || [], + compliances: typedInput.compliances || [], }); return checks; }, @@ -25,8 +27,11 @@ export const getProviderChecksTool = tool( ); export const getProviderCheckDetailsTool = tool( - async ({ checkId }: { checkId: string }) => { - const check = await getLighthouseCheckDetails({ checkId }); + async (input) => { + const typedInput = input as z.infer; + const check = await getLighthouseCheckDetails({ + checkId: typedInput.checkId, + }); return check; }, { diff --git a/ui/lib/lighthouse/tools/compliances.ts b/ui/lib/lighthouse/tools/compliances.ts index 7baeff6dea..a78165299d 100644 --- a/ui/lib/lighthouse/tools/compliances.ts +++ b/ui/lib/lighthouse/tools/compliances.ts @@ -1,4 +1,5 @@ import { tool } from "@langchain/core/tools"; +import { z } from "zod"; import { getLighthouseComplianceFrameworks } from "@/actions/lighthouse/complianceframeworks"; import { @@ -12,14 +13,15 @@ import { } from "@/types/lighthouse"; export const getCompliancesOverviewTool = tool( - async ({ scanId, fields, filters, page, pageSize, sort }) => { + async (input) => { + const typedInput = input as z.infer; return await getLighthouseCompliancesOverview({ - scanId, - fields, - filters, - page, - pageSize, - sort, + scanId: typedInput.scanId, + fields: typedInput.fields, + filters: typedInput.filters, + page: typedInput.page, + pageSize: typedInput.pageSize, + sort: typedInput.sort, }); }, { @@ -31,8 +33,9 @@ export const getCompliancesOverviewTool = tool( ); export const getComplianceFrameworksTool = tool( - async ({ providerType }) => { - return await getLighthouseComplianceFrameworks(providerType); + async (input) => { + const typedInput = input as z.infer; + return await getLighthouseComplianceFrameworks(typedInput.providerType); }, { name: "getComplianceFrameworks", @@ -43,8 +46,12 @@ export const getComplianceFrameworksTool = tool( ); export const getComplianceOverviewTool = tool( - async ({ complianceId, fields }) => { - return await getLighthouseComplianceOverview({ complianceId, fields }); + async (input) => { + const typedInput = input as z.infer; + return await getLighthouseComplianceOverview({ + complianceId: typedInput.complianceId, + fields: typedInput.fields, + }); }, { name: "getComplianceOverview", diff --git a/ui/lib/lighthouse/tools/findings.ts b/ui/lib/lighthouse/tools/findings.ts index e81e9ca0b6..5243416be1 100644 --- a/ui/lib/lighthouse/tools/findings.ts +++ b/ui/lib/lighthouse/tools/findings.ts @@ -1,11 +1,19 @@ import { tool } from "@langchain/core/tools"; +import { z } from "zod"; import { getFindings, getMetadataInfo } from "@/actions/findings"; import { getFindingsSchema, getMetadataInfoSchema } from "@/types/lighthouse"; export const getFindingsTool = tool( - async ({ page, pageSize, query, sort, filters }) => { - return await getFindings({ page, pageSize, query, sort, filters }); + async (input) => { + const typedInput = input as z.infer; + return await getFindings({ + page: typedInput.page, + pageSize: typedInput.pageSize, + query: typedInput.query, + sort: typedInput.sort, + filters: typedInput.filters, + }); }, { name: "getFindings", @@ -16,8 +24,13 @@ export const getFindingsTool = tool( ); export const getMetadataInfoTool = tool( - async ({ query, sort, filters }) => { - return await getMetadataInfo({ query, sort, filters }); + async (input) => { + const typedInput = input as z.infer; + return await getMetadataInfo({ + query: typedInput.query, + sort: typedInput.sort, + filters: typedInput.filters, + }); }, { name: "getMetadataInfo", diff --git a/ui/lib/lighthouse/tools/overview.ts b/ui/lib/lighthouse/tools/overview.ts index 8087648aef..26920724a0 100644 --- a/ui/lib/lighthouse/tools/overview.ts +++ b/ui/lib/lighthouse/tools/overview.ts @@ -1,4 +1,5 @@ import { tool } from "@langchain/core/tools"; +import { z } from "zod"; import { getFindingsBySeverity, @@ -12,8 +13,14 @@ import { } from "@/types/lighthouse"; export const getProvidersOverviewTool = tool( - async ({ page, query, sort, filters }) => { - return await getProvidersOverview({ page, query, sort, filters }); + async (input) => { + const typedInput = input as z.infer; + return await getProvidersOverview({ + page: typedInput.page, + query: typedInput.query, + sort: typedInput.sort, + filters: typedInput.filters, + }); }, { name: "getProvidersOverview", @@ -24,8 +31,14 @@ export const getProvidersOverviewTool = tool( ); export const getFindingsByStatusTool = tool( - async ({ page, query, sort, filters }) => { - return await getFindingsByStatus({ page, query, sort, filters }); + async (input) => { + const typedInput = input as z.infer; + return await getFindingsByStatus({ + page: typedInput.page, + query: typedInput.query, + sort: typedInput.sort, + filters: typedInput.filters, + }); }, { name: "getFindingsByStatus", @@ -36,8 +49,14 @@ export const getFindingsByStatusTool = tool( ); export const getFindingsBySeverityTool = tool( - async ({ page, query, sort, filters }) => { - return await getFindingsBySeverity({ page, query, sort, filters }); + async (input) => { + const typedInput = input as z.infer; + return await getFindingsBySeverity({ + page: typedInput.page, + query: typedInput.query, + sort: typedInput.sort, + filters: typedInput.filters, + }); }, { name: "getFindingsBySeverity", diff --git a/ui/lib/lighthouse/tools/providers.ts b/ui/lib/lighthouse/tools/providers.ts index 99c95f7765..5ebcd337a8 100644 --- a/ui/lib/lighthouse/tools/providers.ts +++ b/ui/lib/lighthouse/tools/providers.ts @@ -1,15 +1,17 @@ import { tool } from "@langchain/core/tools"; +import { z } from "zod"; import { getProvider, getProviders } from "@/actions/providers"; import { getProviderSchema, getProvidersSchema } from "@/types/lighthouse"; export const getProvidersTool = tool( - async ({ page, query, sort, filters }) => { + async (input) => { + const typedInput = input as z.infer; return await getProviders({ - page: page, - query: query, - sort: sort, - filters: filters, + page: typedInput.page, + query: typedInput.query, + sort: typedInput.sort, + filters: typedInput.filters, }); }, { @@ -21,9 +23,10 @@ export const getProvidersTool = tool( ); export const getProviderTool = tool( - async ({ id }) => { + async (input) => { + const typedInput = input as z.infer; const formData = new FormData(); - formData.append("id", id); + formData.append("id", typedInput.id); return await getProvider(formData); }, { diff --git a/ui/lib/lighthouse/tools/resources.ts b/ui/lib/lighthouse/tools/resources.ts index 408ad5fc6d..dec041981f 100644 --- a/ui/lib/lighthouse/tools/resources.ts +++ b/ui/lib/lighthouse/tools/resources.ts @@ -1,4 +1,5 @@ import { tool } from "@langchain/core/tools"; +import { z } from "zod"; import { getLighthouseLatestResources, @@ -7,9 +8,19 @@ import { } from "@/actions/lighthouse/resources"; import { getResourceSchema, getResourcesSchema } from "@/types/lighthouse"; +const parseResourcesInput = (input: unknown) => + input as z.infer; + export const getResourcesTool = tool( - async ({ page, query, sort, filters, fields }) => { - return await getLighthouseResources({ page, query, sort, filters, fields }); + async (input) => { + const typedInput = parseResourcesInput(input); + return await getLighthouseResources({ + page: typedInput.page, + query: typedInput.query, + sort: typedInput.sort, + filters: typedInput.filters, + fields: typedInput.fields, + }); }, { name: "getResources", @@ -20,8 +31,13 @@ export const getResourcesTool = tool( ); export const getResourceTool = tool( - async ({ id, fields, include }) => { - return await getLighthouseResourceById({ id, fields, include }); + async (input) => { + const typedInput = input as z.infer; + return await getLighthouseResourceById({ + id: typedInput.id, + fields: typedInput.fields, + include: typedInput.include, + }); }, { name: "getResource", @@ -32,13 +48,14 @@ export const getResourceTool = tool( ); export const getLatestResourcesTool = tool( - async ({ page, query, sort, filters, fields }) => { + async (input) => { + const typedInput = parseResourcesInput(input); return await getLighthouseLatestResources({ - page, - query, - sort, - filters, - fields, + page: typedInput.page, + query: typedInput.query, + sort: typedInput.sort, + filters: typedInput.filters, + fields: typedInput.fields, }); }, { diff --git a/ui/lib/lighthouse/tools/roles.ts b/ui/lib/lighthouse/tools/roles.ts index 05d9f6b9a3..57432618bb 100644 --- a/ui/lib/lighthouse/tools/roles.ts +++ b/ui/lib/lighthouse/tools/roles.ts @@ -1,11 +1,18 @@ import { tool } from "@langchain/core/tools"; +import { z } from "zod"; import { getRoleInfoById, getRoles } from "@/actions/roles"; import { getRoleSchema, getRolesSchema } from "@/types/lighthouse"; export const getRolesTool = tool( - async ({ page, query, sort, filters }) => { - return await getRoles({ page, query, sort, filters }); + async (input) => { + const typedInput = input as z.infer; + return await getRoles({ + page: typedInput.page, + query: typedInput.query, + sort: typedInput.sort, + filters: typedInput.filters, + }); }, { name: "getRoles", @@ -15,8 +22,9 @@ export const getRolesTool = tool( ); export const getRoleTool = tool( - async ({ id }) => { - return await getRoleInfoById(id); + async (input) => { + const typedInput = input as z.infer; + return await getRoleInfoById(typedInput.id); }, { name: "getRole", diff --git a/ui/lib/lighthouse/tools/scans.ts b/ui/lib/lighthouse/tools/scans.ts index dc6165492e..4a5a8ed34e 100644 --- a/ui/lib/lighthouse/tools/scans.ts +++ b/ui/lib/lighthouse/tools/scans.ts @@ -1,11 +1,18 @@ import { tool } from "@langchain/core/tools"; +import { z } from "zod"; import { getScan, getScans } from "@/actions/scans"; import { getScanSchema, getScansSchema } from "@/types/lighthouse"; export const getScansTool = tool( - async ({ page, query, sort, filters }) => { - const scans = await getScans({ page, query, sort, filters }); + async (input) => { + const typedInput = input as z.infer; + const scans = await getScans({ + page: typedInput.page, + query: typedInput.query, + sort: typedInput.sort, + filters: typedInput.filters, + }); return scans; }, @@ -18,8 +25,9 @@ export const getScansTool = tool( ); export const getScanTool = tool( - async ({ id }) => { - return await getScan(id); + async (input) => { + const typedInput = input as z.infer; + return await getScan(typedInput.id); }, { name: "getScan", diff --git a/ui/lib/lighthouse/tools/users.ts b/ui/lib/lighthouse/tools/users.ts index 0785805245..43078cb12b 100644 --- a/ui/lib/lighthouse/tools/users.ts +++ b/ui/lib/lighthouse/tools/users.ts @@ -4,9 +4,17 @@ import { z } from "zod"; import { getUserInfo, getUsers } from "@/actions/users/users"; import { getUsersSchema } from "@/types/lighthouse"; +const emptySchema = z.object({}); + export const getUsersTool = tool( - async ({ page, query, sort, filters }) => { - return await getUsers({ page, query, sort, filters }); + async (input) => { + const typedInput = input as z.infer; + return await getUsers({ + page: typedInput.page, + query: typedInput.query, + sort: typedInput.sort, + filters: typedInput.filters, + }); }, { name: "getUsers", @@ -17,13 +25,13 @@ export const getUsersTool = tool( ); export const getMyProfileInfoTool = tool( - async () => { + async (_input) => { return await getUserInfo(); }, { name: "getMyProfileInfo", description: "Fetches detailed information about the current authenticated user.", - schema: z.object({}), + schema: emptySchema, }, ); diff --git a/ui/lib/lighthouse/utils.ts b/ui/lib/lighthouse/utils.ts index 67fa900fd5..8ab416a7b9 100644 --- a/ui/lib/lighthouse/utils.ts +++ b/ui/lib/lighthouse/utils.ts @@ -4,7 +4,7 @@ import { ChatMessage, HumanMessage, } from "@langchain/core/messages"; -import type { Message } from "ai"; +import type { UIMessage } from "ai"; import type { ModelParams } from "@/types/lighthouse"; @@ -15,37 +15,22 @@ import type { ModelParams } from "@/types/lighthouse"; * @returns The converted LangChain message. */ export const convertVercelMessageToLangChainMessage = ( - message: Message, + message: UIMessage, ): BaseMessage => { + // Extract text content from message parts + const content = + message.parts + ?.filter((p) => p.type === "text") + .map((p) => ("text" in p ? p.text : "")) + .join("") || ""; + switch (message.role) { case "user": - return new HumanMessage({ content: message.content }); + return new HumanMessage({ content }); case "assistant": - return new AIMessage({ content: message.content }); + return new AIMessage({ content }); default: - return new ChatMessage({ content: message.content, role: message.role }); - } -}; - -/** - * Converts a LangChain message to a Vercel message. - * @param message - The message to convert. - * @returns The converted Vercel message. - */ -export const convertLangChainMessageToVercelMessage = ( - message: BaseMessage, -) => { - switch (message._getType()) { - case "human": - return { content: message.content, role: "user" }; - case "ai": - return { - content: message.content, - role: "assistant", - tool_calls: (message as AIMessage).tool_calls, - }; - default: - return { content: message.content, role: message._getType() }; + return new ChatMessage({ content, role: message.role }); } }; diff --git a/ui/package-lock.json b/ui/package-lock.json index a99b417746..bd629b22fb 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -9,8 +9,10 @@ "version": "0.0.1", "hasInstallScript": true, "dependencies": { + "@ai-sdk/langchain": "1.0.59", + "@ai-sdk/react": "2.0.59", "@heroui/react": "2.8.4", - "@hookform/resolvers": "3.10.0", + "@hookform/resolvers": "5.2.2", "@langchain/core": "0.3.77", "@langchain/langgraph": "0.4.9", "@langchain/langgraph-supervisor": "0.0.20", @@ -30,7 +32,7 @@ "@tailwindcss/typography": "0.5.16", "@tanstack/react-table": "8.21.3", "@types/js-yaml": "4.0.9", - "ai": "4.3.16", + "ai": "5.0.59", "alert": "6.0.2", "class-variance-authority": "0.7.1", "clsx": "2.1.1", @@ -58,8 +60,8 @@ "tailwind-merge": "3.3.1", "tailwindcss-animate": "1.0.7", "uuid": "11.1.0", - "zod": "3.25.73", - "zustand": "4.5.7" + "zod": "4.1.11", + "zustand": "5.0.8" }, "devDependencies": { "@iconify/react": "5.2.1", @@ -94,10 +96,38 @@ "typescript": "5.5.4" } }, + "node_modules/@ai-sdk/gateway": { + "version": "1.0.32", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-1.0.32.tgz", + "integrity": "sha512-TQRIM63EI/ccJBc7RxeB8nq/CnGNnyl7eu5stWdLwL41stkV5skVeZJe0QRvFbaOrwCkgUVE0yrUqJi4tgDC1A==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "2.0.0", + "@ai-sdk/provider-utils": "3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/langchain": { + "version": "1.0.59", + "resolved": "https://registry.npmjs.org/@ai-sdk/langchain/-/langchain-1.0.59.tgz", + "integrity": "sha512-bElhcuSSIxJ3ffgtS1wlYO8q0WD+eYkR32+Tfmcj1ni0lpoIFYEmFqQvunKiMPvtwNYqhFg6OEclcqsz2qBobA==", + "license": "Apache-2.0", + "dependencies": { + "ai": "5.0.59" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@ai-sdk/provider": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-1.1.3.tgz", - "integrity": "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-2.0.0.tgz", + "integrity": "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA==", "license": "Apache-2.0", "dependencies": { "json-schema": "^0.4.0" @@ -107,30 +137,30 @@ } }, "node_modules/@ai-sdk/provider-utils": { - "version": "2.2.8", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-2.2.8.tgz", - "integrity": "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA==", + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.10.tgz", + "integrity": "sha512-T1gZ76gEIwffep6MWI0QNy9jgoybUHE7TRaHB5k54K8mF91ciGFlbtCGxDYhMH3nCRergKwYFIDeFF0hJSIQHQ==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "1.1.3", - "nanoid": "^3.3.8", - "secure-json-parse": "^2.7.0" + "@ai-sdk/provider": "2.0.0", + "@standard-schema/spec": "^1.0.0", + "eventsource-parser": "^3.0.5" }, "engines": { "node": ">=18" }, "peerDependencies": { - "zod": "^3.23.8" + "zod": "^3.25.76 || ^4.1.8" } }, "node_modules/@ai-sdk/react": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-1.2.12.tgz", - "integrity": "sha512-jK1IZZ22evPZoQW3vlkZ7wvjYGYF+tRBKXtrcolduIkQ/m/sOAVcVeVDUDvh1T91xCnWCdUGCPZg2avZ90mv3g==", + "version": "2.0.59", + "resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-2.0.59.tgz", + "integrity": "sha512-whuMGkiRugJIQNJEIpt3gv53EsvQ6ub7Qh19ujbUcvXZKwoCCZlEGmUqEJqvPVRm95d4uYXFxEk0wqpxOpsm6g==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider-utils": "2.2.8", - "@ai-sdk/ui-utils": "1.2.11", + "@ai-sdk/provider-utils": "3.0.10", + "ai": "5.0.59", "swr": "^2.2.5", "throttleit": "2.1.0" }, @@ -139,7 +169,7 @@ }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", - "zod": "^3.23.8" + "zod": "^3.25.76 || ^4.1.8" }, "peerDependenciesMeta": { "zod": { @@ -147,23 +177,6 @@ } } }, - "node_modules/@ai-sdk/ui-utils": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@ai-sdk/ui-utils/-/ui-utils-1.2.11.tgz", - "integrity": "sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w==", - "license": "Apache-2.0", - "dependencies": { - "@ai-sdk/provider": "1.1.3", - "@ai-sdk/provider-utils": "2.2.8", - "zod-to-json-schema": "^3.24.1" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "zod": "^3.23.8" - } - }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -2737,12 +2750,15 @@ } }, "node_modules/@hookform/resolvers": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz", - "integrity": "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", + "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==", "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, "peerDependencies": { - "react-hook-form": "^7.0.0" + "react-hook-form": "^7.55.0" } }, "node_modules/@humanwhocodes/config-array": { @@ -3550,6 +3566,24 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/@langchain/core/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@langchain/core/node_modules/zod-to-json-schema": { + "version": "3.24.6", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz", + "integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.24.1" + } + }, "node_modules/@langchain/langgraph": { "version": "0.4.9", "resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-0.4.9.tgz", @@ -3673,6 +3707,15 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/@langchain/langgraph-supervisor/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@langchain/langgraph/node_modules/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", @@ -3686,6 +3729,15 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/@langchain/langgraph/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@langchain/openai": { "version": "0.5.18", "resolved": "https://registry.npmjs.org/@langchain/openai/-/openai-0.5.18.tgz", @@ -3703,6 +3755,36 @@ "@langchain/core": ">=0.3.58 <0.4.0" } }, + "node_modules/@langchain/openai/node_modules/openai": { + "version": "5.23.2", + "resolved": "https://registry.npmjs.org/openai/-/openai-5.23.2.tgz", + "integrity": "sha512-MQBzmTulj+MM5O8SKEk/gL8a7s5mktS9zUtAkU257WjvobGc9nKcBuVwjyEEcb9SI8a8Y2G/mzn3vm9n1Jlleg==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/@langchain/openai/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.18.1", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.18.1.tgz", @@ -3726,6 +3808,24 @@ "node": ">=18" } }, + "node_modules/@modelcontextprotocol/sdk/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/zod-to-json-schema": { + "version": "3.24.6", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz", + "integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.24.1" + } + }, "node_modules/@mswjs/interceptors": { "version": "0.39.7", "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.39.7.tgz", @@ -7075,6 +7175,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.17", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", @@ -7590,12 +7702,6 @@ "@types/ms": "*" } }, - "node_modules/@types/diff-match-patch": { - "version": "1.0.36", - "resolved": "https://registry.npmjs.org/@types/diff-match-patch/-/diff-match-patch-1.0.36.tgz", - "integrity": "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==", - "license": "MIT" - }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -8218,29 +8324,21 @@ } }, "node_modules/ai": { - "version": "4.3.16", - "resolved": "https://registry.npmjs.org/ai/-/ai-4.3.16.tgz", - "integrity": "sha512-KUDwlThJ5tr2Vw0A1ZkbDKNME3wzWhuVfAOwIvFUzl1TPVDFAXDFTXio3p+jaKneB+dKNCvFFlolYmmgHttG1g==", + "version": "5.0.59", + "resolved": "https://registry.npmjs.org/ai/-/ai-5.0.59.tgz", + "integrity": "sha512-SuAFxKXt2Ha9FiXB3gaOITkOg9ek/3QNVatGVExvTT4gNXc+hJpuNe1dmuwf6Z5Op4fzc8wdbsrYP27ZCXBzlw==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "1.1.3", - "@ai-sdk/provider-utils": "2.2.8", - "@ai-sdk/react": "1.2.12", - "@ai-sdk/ui-utils": "1.2.11", - "@opentelemetry/api": "1.9.0", - "jsondiffpatch": "0.6.0" + "@ai-sdk/gateway": "1.0.32", + "@ai-sdk/provider": "2.0.0", + "@ai-sdk/provider-utils": "3.0.10", + "@opentelemetry/api": "1.9.0" }, "engines": { "node": ">=18" }, "peerDependencies": { - "react": "^18 || ^19 || ^19.0.0-rc", - "zod": "^3.23.8" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - } + "zod": "^3.25.76 || ^4.1.8" } }, "node_modules/ajv": { @@ -9681,12 +9779,6 @@ "node": ">=0.3.1" } }, - "node_modules/diff-match-patch": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz", - "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==", - "license": "Apache-2.0" - }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -12640,35 +12732,6 @@ "node": ">=6" } }, - "node_modules/jsondiffpatch": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/jsondiffpatch/-/jsondiffpatch-0.6.0.tgz", - "integrity": "sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ==", - "license": "MIT", - "dependencies": { - "@types/diff-match-patch": "^1.0.36", - "chalk": "^5.3.0", - "diff-match-patch": "^1.0.5" - }, - "bin": { - "jsondiffpatch": "bin/jsondiffpatch.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, - "node_modules/jsondiffpatch/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/jsonfile": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", @@ -12726,9 +12789,9 @@ } }, "node_modules/langsmith": { - "version": "0.3.69", - "resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.3.69.tgz", - "integrity": "sha512-YKzu92YAP2o+d+1VmR38xqFX0RIRLKYj1IqdflVEY83X0FoiVlrWO3xDLXgnu7vhZ2N2M6jx8VO9fVF8yy9gHA==", + "version": "0.3.71", + "resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.3.71.tgz", + "integrity": "sha512-xl00JZso7J3OaurUQ+seT2qRJ34OGZXYAvCYj3vNC3TB+JOcdcYZ1uLvENqOloKB8VCiADh1eZ0FG3Cj/cy2FQ==", "license": "MIT", "dependencies": { "@types/uuid": "^10.0.0", @@ -15030,27 +15093,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/openai": { - "version": "5.23.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-5.23.0.tgz", - "integrity": "sha512-Cfq155NHzI7VWR67LUNJMIgPZy2oSh7Fld/OKhxq648BiUjELAvcge7g30xJ6vAfwwXf6TVK0KKuN+3nmIJG/A==", - "license": "Apache-2.0", - "bin": { - "openai": "bin/cli" - }, - "peerDependencies": { - "ws": "^8.18.0", - "zod": "^3.23.8" - }, - "peerDependenciesMeta": { - "ws": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -16538,12 +16580,6 @@ "compute-scroll-into-view": "^3.0.2" } }, - "node_modules/secure-json-parse": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", - "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==", - "license": "BSD-3-Clause" - }, "node_modules/semver": { "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", @@ -16875,6 +16911,24 @@ "node": ">=6" } }, + "node_modules/shadcn/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/shadcn/node_modules/zod-to-json-schema": { + "version": "3.24.6", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz", + "integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.24.1" + } + }, "node_modules/sharp": { "version": "0.33.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", @@ -18669,38 +18723,27 @@ } }, "node_modules/zod": { - "version": "3.25.73", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.73.tgz", - "integrity": "sha512-fuIKbQAWQl22Ba5d1quwEETQYjqnpKVyZIWAhbnnHgnDd3a+z4YgEfkI5SZ2xMELnLAXo/Flk2uXgysZNf0uaA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.11.tgz", + "integrity": "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } }, - "node_modules/zod-to-json-schema": { - "version": "3.24.6", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz", - "integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==", - "license": "ISC", - "peerDependencies": { - "zod": "^3.24.1" - } - }, "node_modules/zustand": { - "version": "4.5.7", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", - "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", + "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==", "license": "MIT", - "dependencies": { - "use-sync-external-store": "^1.2.2" - }, "engines": { - "node": ">=12.7.0" + "node": ">=12.20.0" }, "peerDependencies": { - "@types/react": ">=16.8", + "@types/react": ">=18.0.0", "immer": ">=9.0.6", - "react": ">=16.8" + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" }, "peerDependenciesMeta": { "@types/react": { @@ -18711,6 +18754,9 @@ }, "react": { "optional": true + }, + "use-sync-external-store": { + "optional": true } } }, diff --git a/ui/package.json b/ui/package.json index 57122050b6..ea80ad0f32 100644 --- a/ui/package.json +++ b/ui/package.json @@ -23,8 +23,10 @@ "test:e2e:install": "playwright install" }, "dependencies": { + "@ai-sdk/langchain": "1.0.59", + "@ai-sdk/react": "2.0.59", "@heroui/react": "2.8.4", - "@hookform/resolvers": "3.10.0", + "@hookform/resolvers": "5.2.2", "@langchain/core": "0.3.77", "@langchain/langgraph": "0.4.9", "@langchain/langgraph-supervisor": "0.0.20", @@ -44,7 +46,7 @@ "@tailwindcss/typography": "0.5.16", "@tanstack/react-table": "8.21.3", "@types/js-yaml": "4.0.9", - "ai": "4.3.16", + "ai": "5.0.59", "alert": "6.0.2", "class-variance-authority": "0.7.1", "clsx": "2.1.1", @@ -72,8 +74,8 @@ "tailwind-merge": "3.3.1", "tailwindcss-animate": "1.0.7", "uuid": "11.1.0", - "zod": "3.25.73", - "zustand": "4.5.7" + "zod": "4.1.11", + "zustand": "5.0.8" }, "devDependencies": { "@iconify/react": "5.2.1", diff --git a/ui/styles/globals.css b/ui/styles/globals.css index 257b10f2e7..9ba7fc7a21 100644 --- a/ui/styles/globals.css +++ b/ui/styles/globals.css @@ -35,7 +35,12 @@ @layer utilities { /* Hide scrollbar */ .no-scrollbar { - scrollbar-width: none; + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* IE and Edge */ + } + + .no-scrollbar::-webkit-scrollbar { + display: none; /* Chrome, Safari, Opera */ } .checkbox-update { diff --git a/ui/tests/auth-login.spec.ts b/ui/tests/auth-login.spec.ts index 500c642028..84dbc9b68f 100644 --- a/ui/tests/auth-login.spec.ts +++ b/ui/tests/auth-login.spec.ts @@ -48,7 +48,10 @@ test.describe("Login Flow", () => { test("should handle empty form submission", async ({ page }) => { // Submit empty form await submitLoginForm(page); + + // Should show both email and password validation errors await verifyLoginError(page, ERROR_MESSAGES.INVALID_EMAIL); + await verifyLoginError(page, ERROR_MESSAGES.PASSWORD_REQUIRED); // Verify we're still on login page await expect(page).toHaveURL(URLS.LOGIN); @@ -63,6 +66,18 @@ test.describe("Login Flow", () => { await expect(page).toHaveURL(URLS.LOGIN); }); + test("should require password when email is filled", async ({ page }) => { + // Fill only email, leave password empty + await page.getByLabel("Email").fill(TEST_CREDENTIALS.VALID.email); + await submitLoginForm(page); + + // Should show password required error + await verifyLoginError(page, ERROR_MESSAGES.PASSWORD_REQUIRED); + + // Verify we're still on login page + await expect(page).toHaveURL(URLS.LOGIN); + }); + test("should toggle SAML SSO mode", async ({ page }) => { // Toggle to SAML mode await toggleSamlMode(page); diff --git a/ui/tests/helpers.ts b/ui/tests/helpers.ts index 22c45b4ba5..51a4297427 100644 --- a/ui/tests/helpers.ts +++ b/ui/tests/helpers.ts @@ -3,6 +3,7 @@ import { Page, expect } from "@playwright/test"; export const ERROR_MESSAGES = { INVALID_CREDENTIALS: "Invalid email or password", INVALID_EMAIL: "Please enter a valid email address.", + PASSWORD_REQUIRED: "Password is required.", } as const; export const URLS = { diff --git a/ui/types/authFormSchema.ts b/ui/types/authFormSchema.ts index d16c027790..72ba742413 100644 --- a/ui/types/authFormSchema.ts +++ b/ui/types/authFormSchema.ts @@ -64,54 +64,66 @@ export const validatePassword = () => { ); }; +const baseAuthSchema = z.object({ + email: z + .email({ message: "Please enter a valid email address." }) + .trim() + .toLowerCase(), + password: z.string(), + isSamlMode: z.boolean().optional(), +}); + +export const signInSchema = baseAuthSchema + .extend({ + password: z.string().min(1, { message: "Password is required." }), + }) + .refine( + (data) => { + // If SAML mode, password is not required + if (data.isSamlMode) return true; + // Otherwise, password must be filled + return data.password.length > 0; + }, + { + message: "Password is required.", + path: ["password"], + }, + ); + +export const signUpSchema = baseAuthSchema + .extend({ + name: z + .string() + .min(3, { + message: "The name must be at least 3 characters.", + }) + .max(20), + password: validatePassword(), + confirmPassword: z.string().min(1, { + message: "Please confirm your password.", + }), + company: z.string().optional(), + invitationToken: z.string().optional(), + termsAndConditions: + process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true" + ? z.boolean().refine((value) => value === true, { + message: "You must accept the terms and conditions.", + }) + : z.boolean().optional(), + }) + .refine( + (data) => { + if (data.isSamlMode) return true; + return data.password === data.confirmPassword; + }, + { + message: "The password must match", + path: ["confirmPassword"], + }, + ); + export const authFormSchema = (type: string) => - z - .object({ - // Sign Up - company: - type === "sign-in" ? z.string().optional() : z.string().optional(), - name: - type === "sign-in" - ? z.string().optional() - : z - .string() - .min(3, { - message: "The name must be at least 3 characters.", - }) - .max(20), - confirmPassword: - type === "sign-in" - ? z.string().optional() - : z.string().min(1, { - message: "Please confirm your password.", - }), - invitationToken: - type === "sign-in" ? z.string().optional() : z.string().optional(), + type === "sign-in" ? signInSchema : signUpSchema; - termsAndConditions: - type === "sign-in" || process.env.NEXT_PUBLIC_IS_CLOUD_ENV !== "true" - ? z.boolean().optional() - : z.boolean().refine((value) => value === true, { - message: "You must accept the terms and conditions.", - }), - - // Fields for Sign In and Sign Up - // Trim and normalize email, and provide consistent message - email: z - .string() - .trim() - .toLowerCase() - .email({ message: "Please enter a valid email address." }), - password: type === "sign-in" ? z.string() : validatePassword(), - isSamlMode: z.boolean().optional(), - }) - .refine( - (data) => { - if (data.isSamlMode) return true; - return type === "sign-in" || data.password === data.confirmPassword; - }, - { - message: "The password must match", - path: ["confirmPassword"], - }, - ); +export type SignInFormData = z.infer; +export type SignUpFormData = z.infer; diff --git a/ui/types/formSchemas.ts b/ui/types/formSchemas.ts index 5b1891379f..10900a3f26 100644 --- a/ui/types/formSchemas.ts +++ b/ui/types/formSchemas.ts @@ -72,7 +72,7 @@ export const awsCredentialsTypeSchema = z.object({ export const addProviderFormSchema = z .object({ providerType: z.enum(PROVIDER_TYPES, { - required_error: "Please select a provider type", + error: "Please select a provider type", }), }) .and( @@ -125,53 +125,53 @@ export const addCredentialsFormSchema = ( ? { [ProviderCredentialFields.AWS_ACCESS_KEY_ID]: z .string() - .nonempty("AWS Access Key ID is required"), + .min(1, "AWS Access Key ID is required"), [ProviderCredentialFields.AWS_SECRET_ACCESS_KEY]: z .string() - .nonempty("AWS Secret Access Key is required"), + .min(1, "AWS Secret Access Key is required"), [ProviderCredentialFields.AWS_SESSION_TOKEN]: z.string().optional(), } : providerType === "azure" ? { [ProviderCredentialFields.CLIENT_ID]: z .string() - .nonempty("Client ID is required"), + .min(1, "Client ID is required"), [ProviderCredentialFields.CLIENT_SECRET]: z .string() - .nonempty("Client Secret is required"), + .min(1, "Client Secret is required"), [ProviderCredentialFields.TENANT_ID]: z .string() - .nonempty("Tenant ID is required"), + .min(1, "Tenant ID is required"), } : providerType === "gcp" ? { [ProviderCredentialFields.CLIENT_ID]: z .string() - .nonempty("Client ID is required"), + .min(1, "Client ID is required"), [ProviderCredentialFields.CLIENT_SECRET]: z .string() - .nonempty("Client Secret is required"), + .min(1, "Client Secret is required"), [ProviderCredentialFields.REFRESH_TOKEN]: z .string() - .nonempty("Refresh Token is required"), + .min(1, "Refresh Token is required"), } : providerType === "kubernetes" ? { [ProviderCredentialFields.KUBECONFIG_CONTENT]: z .string() - .nonempty("Kubeconfig Content is required"), + .min(1, "Kubeconfig Content is required"), } : providerType === "m365" ? { [ProviderCredentialFields.CLIENT_ID]: z .string() - .nonempty("Client ID is required"), + .min(1, "Client ID is required"), [ProviderCredentialFields.CLIENT_SECRET]: z .string() - .nonempty("Client Secret is required"), + .min(1, "Client Secret is required"), [ProviderCredentialFields.TENANT_ID]: z .string() - .nonempty("Tenant ID is required"), + .min(1, "Tenant ID is required"), [ProviderCredentialFields.USER]: z.string().optional(), [ProviderCredentialFields.PASSWORD]: z.string().optional(), } @@ -259,7 +259,7 @@ export const addCredentialsRoleFormSchema = (providerType: string) => [ProviderCredentialFields.PROVIDER_TYPE]: z.string(), [ProviderCredentialFields.ROLE_ARN]: z .string() - .nonempty("AWS Role ARN is required"), + .min(1, "AWS Role ARN is required"), [ProviderCredentialFields.EXTERNAL_ID]: z.string().optional(), [ProviderCredentialFields.AWS_ACCESS_KEY_ID]: z.string().optional(), [ProviderCredentialFields.AWS_SECRET_ACCESS_KEY]: z @@ -347,8 +347,8 @@ export const editProviderFormSchema = (currentAlias: string) => }); export const editInviteFormSchema = z.object({ - invitationId: z.string().uuid(), - invitationEmail: z.string().email(), + invitationId: z.uuid(), + invitationEmail: z.email(), expires_at: z.string().optional(), role: z.string().optional(), }); @@ -360,10 +360,7 @@ export const editUserFormSchema = () => .min(3, { message: "The name must have at least 3 characters." }) .max(150, { message: "The name cannot exceed 150 characters." }) .optional(), - email: z - .string() - .email({ message: "Please enter a valid email address." }) - .optional(), + email: z.email({ error: "Please enter a valid email address." }).optional(), password: z .string() .min(1, { message: "The password cannot be empty." }) diff --git a/ui/types/integrations.ts b/ui/types/integrations.ts index f055158827..4f579847e3 100644 --- a/ui/types/integrations.ts +++ b/ui/types/integrations.ts @@ -289,7 +289,7 @@ export const editSecurityHubIntegrationFormSchema = export const jiraIntegrationFormSchema = z.object({ integration_type: z.literal("jira"), domain: z.string().min(1, "Domain is required"), - user_mail: z.string().email("Invalid email format"), + user_mail: z.email({ error: "Invalid email format" }), api_token: z.string().min(1, "API token is required"), enabled: z.boolean().default(true), }); @@ -297,7 +297,7 @@ export const jiraIntegrationFormSchema = z.object({ export const editJiraIntegrationFormSchema = z.object({ integration_type: z.literal("jira"), domain: z.string().min(1, "Domain is required").optional(), - user_mail: z.string().email("Invalid email format").optional(), + user_mail: z.email({ error: "Invalid email format" }).optional(), api_token: z.string().min(1, "API token is required").optional(), }); diff --git a/ui/types/lighthouse/findings.ts b/ui/types/lighthouse/findings.ts index feca1ab9de..df9ef40a81 100644 --- a/ui/types/lighthouse/findings.ts +++ b/ui/types/lighthouse/findings.ts @@ -40,9 +40,9 @@ export const getFindingsSchema = z.object({ query: z .string() .describe("The query to search for. Default is empty string."), - sort: z - .string(sortFieldsEnum) - .describe("The sort order to use. Default is empty string."), + sort: sortFieldsEnum.describe( + "The sort order to use. Default is empty string.", + ), filters: z .object({ "filter[check_id]": z diff --git a/ui/types/lighthouse/scans.ts b/ui/types/lighthouse/scans.ts index 00aff8f251..87c84a3064 100644 --- a/ui/types/lighthouse/scans.ts +++ b/ui/types/lighthouse/scans.ts @@ -32,9 +32,9 @@ export const getScansSchema = z.object({ query: z .string() .describe("The query to search for. Default is empty string."), - sort: z - .string(getScansSortEnum) - .describe("The sort order to use. Default is empty string."), + sort: getScansSortEnum.describe( + "The sort order to use. Default is empty string.", + ), filters: z .object({ // Date filters From 04177db648aefa6a2c0d1d4a1a26320a26646677 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20De=20la=20Torre=20Vico?= Date: Fri, 3 Oct 2025 11:49:33 +0200 Subject: [PATCH 03/32] chore(aws): enhance metadata for `apigateway` service (#8788) Co-authored-by: HugoPBrito --- prowler/CHANGELOG.md | 1 + ..._restapi_authorizers_enabled.metadata.json | 39 +++++++++------ ...eway_restapi_cache_encrypted.metadata.json | 34 ++++++++----- ...i_client_certificate_enabled.metadata.json | 44 ++++++++++------- ...eway_restapi_logging_enabled.metadata.json | 49 ++++++++++++------- .../apigateway_restapi_public.metadata.json | 40 +++++++++------ ...stapi_public_with_authorizer.metadata.json | 49 ++++++++++++------- ...eway_restapi_tracing_enabled.metadata.json | 32 +++++++----- ...way_restapi_waf_acl_attached.metadata.json | 42 +++++++++------- 9 files changed, 201 insertions(+), 129 deletions(-) diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 6ba03f03c9..7b83df9abf 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -24,6 +24,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - Update AWS ACM service metadata to new format [(#8716)](https://github.com/prowler-cloud/prowler/pull/8716) - HTML output now properly renders markdown syntax in Risk and Recommendation fields [(#8727)](https://github.com/prowler-cloud/prowler/pull/8727) - Update `moto` dependency from 5.0.28 to 5.1.11 [(#7100)](https://github.com/prowler-cloud/prowler/pull/7100) +- Update AWS API Gateway service metadata to new format [(#8788)](https://github.com/prowler-cloud/prowler/pull/8788) - Update AWS Athena service metadata to new format [(#8790)](https://github.com/prowler-cloud/prowler/pull/8790) diff --git a/prowler/providers/aws/services/apigateway/apigateway_restapi_authorizers_enabled/apigateway_restapi_authorizers_enabled.metadata.json b/prowler/providers/aws/services/apigateway/apigateway_restapi_authorizers_enabled/apigateway_restapi_authorizers_enabled.metadata.json index 9d009b0dde..773559669d 100644 --- a/prowler/providers/aws/services/apigateway/apigateway_restapi_authorizers_enabled/apigateway_restapi_authorizers_enabled.metadata.json +++ b/prowler/providers/aws/services/apigateway/apigateway_restapi_authorizers_enabled/apigateway_restapi_authorizers_enabled.metadata.json @@ -1,35 +1,42 @@ { "Provider": "aws", "CheckID": "apigateway_restapi_authorizers_enabled", - "CheckTitle": "Check if API Gateway has configured authorizers at api or method level.", - "CheckAliases": [ - "apigateway_authorizers_enabled" - ], + "CheckTitle": "API Gateway REST API has an authorizer at API level or all methods are authorized", "CheckType": [ - "IAM" + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "TTPs/Initial Access" ], "ServiceName": "apigateway", - "SubServiceName": "rest_api", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "medium", "ResourceType": "AwsApiGatewayRestApi", - "Description": "Check if API Gateway has configured authorizers at api or method level.", - "Risk": "If no authorizer is enabled anyone can use the service.", + "Description": "**API Gateway REST APIs** are evaluated for **access control**: an **API-level authorizer** is present, or all resource methods use an authorization mechanism. Methods marked `NONE` indicate unauthenticated access.", + "Risk": "**Unauthenticated API methods** enable:\n- Arbitrary reads exposing data (**confidentiality**)\n- Unauthorized actions against backends (**integrity**)\n- Abuse and high traffic causing cost spikes or outages (**availability**)\n\nAttackers can enumerate endpoints and invoke integrations without tokens.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-use-lambda-authorizer.html" + ], "Remediation": { "Code": { "CLI": "", - "NativeIaC": "https://docs.prowler.com/checks/aws/public-policies/public_6-api-gateway-authorizer-set#cloudformation", - "Other": "", - "Terraform": "https://docs.prowler.com/checks/aws/public-policies/public_6-api-gateway-authorizer-set#terraform" + "NativeIaC": "```yaml\n# CloudFormation: set method authorization so it's not public\nResources:\n :\n Type: AWS::ApiGateway::Method\n Properties:\n RestApiId: \n ResourceId: \n HttpMethod: GET\n AuthorizationType: AWS_IAM # Critical: authorizes the method (not NONE)\n```", + "Other": "1. In the AWS Console, go to API Gateway > APIs (REST) and select your API\n2. Open Resources, select a resource, then select a method (e.g., GET)\n3. Click Method Request\n4. Set Authorization to AWS_IAM (or an existing Cognito/Lambda authorizer)\n5. Repeat for every method so none show Authorization = NONE\n6. Deploy the API to apply changes", + "Terraform": "```hcl\n# Terraform: set method authorization so it's not public\nresource \"aws_api_gateway_method\" \"\" {\n rest_api_id = \"\"\n resource_id = \"\"\n http_method = \"GET\"\n authorization = \"AWS_IAM\" # Critical: authorizes the method (not NONE)\n}\n```" }, "Recommendation": { - "Text": "Implement Amazon Cognito or a Lambda function to control access to your API.", - "Url": "https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-use-lambda-authorizer.html" + "Text": "Require **authentication** on every method: use **Cognito user pools**, **Lambda authorizers**, or **IAM**; avoid `NONE`.\n- Enforce **least privilege** with scoped policies\n- Use **private endpoints** or resource policies for internal APIs\n- Add **rate limiting** and **WAF** for defense in depth", + "Url": "https://hub.prowler.com/check/apigateway_restapi_authorizers_enabled" } }, - "Categories": [], + "Categories": [ + "identity-access" + ], "DependsOn": [], "RelatedTo": [], - "Notes": "" + "Notes": "", + "CheckAliases": [ + "apigateway_authorizers_enabled" + ] } diff --git a/prowler/providers/aws/services/apigateway/apigateway_restapi_cache_encrypted/apigateway_restapi_cache_encrypted.metadata.json b/prowler/providers/aws/services/apigateway/apigateway_restapi_cache_encrypted/apigateway_restapi_cache_encrypted.metadata.json index 4a0c67df92..4ea4850563 100644 --- a/prowler/providers/aws/services/apigateway/apigateway_restapi_cache_encrypted/apigateway_restapi_cache_encrypted.metadata.json +++ b/prowler/providers/aws/services/apigateway/apigateway_restapi_cache_encrypted/apigateway_restapi_cache_encrypted.metadata.json @@ -1,28 +1,38 @@ { "Provider": "aws", "CheckID": "apigateway_restapi_cache_encrypted", - "CheckTitle": "Check if API Gateway REST API cache data is encrypted at rest.", + "CheckTitle": "API Gateway REST API stage cache data is encrypted at rest", "CheckType": [ - "Software and Configuration Checks/AWS Security Best Practices" + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices" ], "ServiceName": "apigateway", "SubServiceName": "", - "ResourceIdTemplate": "arn:aws:apigateway:region:account-id:/restapis/restapi-id/stages/stage-name", + "ResourceIdTemplate": "", "Severity": "medium", "ResourceType": "AwsApiGatewayStage", - "Description": "This control checks whether all methods in API Gateway REST API stages that have cache enabled are encrypted. The control fails if any method in an API Gateway REST API stage is configured to cache and the cache is not encrypted.", - "Risk": "Without encryption, cached data in API Gateway REST APIs may be vulnerable to unauthorized access, potentially exposing sensitive information to users not authenticated to AWS.", - "RelatedUrl": "https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-caching.html#enable-api-gateway-caching", + "Description": "API Gateway REST API stages with caching have **cache data encrypted at rest**. The evaluation targets stages where caching is enabled and verifies that stored responses are protected via the `Encrypt cache data` setting.", + "Risk": "Unencrypted cache contents can expose response payloads, tokens, or PII if cache storage, backups, or admin tooling are accessed outside normal controls, harming **confidentiality** and enabling replay or session hijacking.\n\nDisclosure also reveals API patterns, aiding **lateral movement** and targeted abuse.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.clouddefense.ai/compliance-rules/nist-800-53-5/au/apigateway-stage-cache-encryption-at-rest-enabled", + "https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-caching.html#enable-api-gateway-caching", + "https://support.icompaas.com/support/solutions/articles/62000233641-ensure-api-gateway-rest-api-cache-data-is-encrypted-at-rest", + "https://docs.fortifyfox.com/docs/aws-foundational-security-best-practices/apigateway/api-gw-cache-encrypted/index.html", + "https://docs.aws.amazon.com/securityhub/latest/userguide/apigateway-controls.html#apigateway-5", + "https://www.clouddefense.ai/compliance-rules/aws-fs-practices/apigateway/foundational-security-apigateway-5", + "https://www.cloudanix.com/docs/aws/audit/apigatewaymonitoring/rules/apigateway_enable_encryption_api_cache" + ], "Remediation": { "Code": { - "CLI": "aws apigateway update-stage --rest-api-id --stage-name --patch-operations op=replace,path=///caching/enabled,value=true op=replace,path=///caching/dataEncrypted,value=true", - "NativeIaC": "", - "Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/apigateway-controls.html#apigateway-5", - "Terraform": "" + "CLI": "aws apigateway update-stage --rest-api-id --stage-name --patch-operations op=replace,path=/*/*/caching/dataEncrypted,value=true", + "NativeIaC": "```yaml\n# CloudFormation: enable encryption for all cached methods in a stage\nResources:\n :\n Type: AWS::ApiGateway::Stage\n Properties:\n StageName: \n RestApiId: \n DeploymentId: \n MethodSettings:\n - ResourcePath: /*\n HttpMethod: \"*\"\n CacheDataEncrypted: true # Critical: encrypt cached responses at rest for all methods\n```", + "Other": "1. Open the AWS Console and go to API Gateway\n2. Select your REST API, then click Stages and choose the affected stage\n3. In Method overrides (or Cache settings), enable Encrypt cache data\n4. Save changes", + "Terraform": "```hcl\n# Enable encryption for all cached methods in the stage\nresource \"aws_api_gateway_stage\" \"\" {\n rest_api_id = \"\"\n stage_name = \"\"\n deployment_id = \"\"\n\n method_settings {\n resource_path = \"/*\"\n http_method = \"*\"\n cache_data_encrypted = true # Critical: encrypt cached responses at rest\n }\n}\n```" }, "Recommendation": { - "Text": "Ensure that API Gateway REST API cache data is encrypted at rest by enabling the 'Encrypt cache data' setting in the API Gateway stage configuration.", - "Url": "https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-caching.html#enable-api-gateway-caching" + "Text": "- Enable **encryption at rest** for any cached stage (`Encrypt cache data`).\n- Apply **least privilege** to stage administration and cache invalidation.\n- Avoid caching sensitive endpoints; use short TTLs and scheduled cache flushes for **defense in depth**.", + "Url": "https://hub.prowler.com/check/apigateway_restapi_cache_encrypted" } }, "Categories": [ diff --git a/prowler/providers/aws/services/apigateway/apigateway_restapi_client_certificate_enabled/apigateway_restapi_client_certificate_enabled.metadata.json b/prowler/providers/aws/services/apigateway/apigateway_restapi_client_certificate_enabled/apigateway_restapi_client_certificate_enabled.metadata.json index 36f095f0e8..601608d459 100644 --- a/prowler/providers/aws/services/apigateway/apigateway_restapi_client_certificate_enabled/apigateway_restapi_client_certificate_enabled.metadata.json +++ b/prowler/providers/aws/services/apigateway/apigateway_restapi_client_certificate_enabled/apigateway_restapi_client_certificate_enabled.metadata.json @@ -1,35 +1,43 @@ { "Provider": "aws", "CheckID": "apigateway_restapi_client_certificate_enabled", - "CheckTitle": "Check if API Gateway Stage has client certificate enabled to access your backend endpoint.", - "CheckAliases": [ - "apigateway_client_certificate_enabled" - ], + "CheckTitle": "API Gateway REST API stage has client certificate enabled", "CheckType": [ - "Data Protection" + "Software and Configuration Checks/AWS Security Best Practices/Encryption in Transit", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/PCI-DSS", + "Software and Configuration Checks/Industry and Regulatory Standards/NIST 800-53 Controls (USA)" ], "ServiceName": "apigateway", - "SubServiceName": "rest_api", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AwsApiGatewayRestApi", - "Description": "Check if API Gateway Stage has client certificate enabled to access your backend endpoint.", - "Risk": "Possible man in the middle attacks and other similar risks.", + "ResourceType": "AwsApiGatewayStage", + "Description": "**API Gateway stage** has a **client certificate** configured so HTTP/S integrations can perform **mutual TLS** and authenticate API Gateway to the backend", + "Risk": "Without client authentication to the backend, requests cannot be proven to originate from API Gateway. Direct calls to the backend may bypass gateway policies, enabling unauthorized access and data tampering. This degrades **integrity** and **confidentiality** and reduces auditability.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://aws.amazon.com/blogs/compute/introducing-mutual-tls-authentication-for-amazon-api-gateway/" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "aws apigateway update-stage --rest-api-id --stage-name --patch-operations op=replace,path=/clientCertificateId,value=", + "NativeIaC": "```yaml\n# CloudFormation: attach a client certificate to a REST API stage\nResources:\n ClientCert:\n Type: AWS::ApiGateway::ClientCertificate\n\n ApiStage:\n Type: AWS::ApiGateway::Stage\n Properties:\n StageName: \n RestApiId: \n DeploymentId: \n ClientCertificateId: !Ref ClientCert # Critical: enables client certificate on the stage\n```", + "Other": "1. In the AWS Console, go to API Gateway > REST APIs and select your API\n2. In the left menu, click Client Certificates and create one (Generate)\n3. In the left menu, click Stages and select the target stage\n4. In Settings, find Client certificate and select the created certificate\n5. Click Save Changes", + "Terraform": "```hcl\n# Terraform: attach a client certificate to a REST API stage\nresource \"aws_api_gateway_client_certificate\" \"example\" {}\n\nresource \"aws_api_gateway_stage\" \"\" {\n stage_name = \"\"\n rest_api_id = \"\"\n deployment_id = \"\"\n client_certificate_id = aws_api_gateway_client_certificate.example.id # Critical: enables client certificate on the stage\n}\n```" }, "Recommendation": { - "Text": "Enable client certificate. Mutual TLS is recommended and commonly used for business-to-business (B2B) applications. It is used in standards such as Open Banking. API Gateway now provides integrated mutual TLS authentication at no additional cost.", - "Url": "https://aws.amazon.com/blogs/compute/introducing-mutual-tls-authentication-for-amazon-api-gateway/" + "Text": "Enable **mutual TLS** from API Gateway to the backend with a **client certificate**, and configure the backend to trust only that identity. Apply **zero trust** and **least privilege**: block public access to the backend, restrict networks, rotate certificates, and monitor authentication failures.", + "Url": "https://hub.prowler.com/check/apigateway_restapi_client_certificate_enabled" } }, - "Categories": [], + "Categories": [ + "encryption" + ], "DependsOn": [], "RelatedTo": [], - "Notes": "" + "Notes": "", + "CheckAliases": [ + "apigateway_client_certificate_enabled" + ] } diff --git a/prowler/providers/aws/services/apigateway/apigateway_restapi_logging_enabled/apigateway_restapi_logging_enabled.metadata.json b/prowler/providers/aws/services/apigateway/apigateway_restapi_logging_enabled/apigateway_restapi_logging_enabled.metadata.json index 7edad6be41..6939e82f51 100644 --- a/prowler/providers/aws/services/apigateway/apigateway_restapi_logging_enabled/apigateway_restapi_logging_enabled.metadata.json +++ b/prowler/providers/aws/services/apigateway/apigateway_restapi_logging_enabled/apigateway_restapi_logging_enabled.metadata.json @@ -1,38 +1,49 @@ { "Provider": "aws", "CheckID": "apigateway_restapi_logging_enabled", - "CheckTitle": "Check if API Gateway Stage has logging enabled.", - "CheckAliases": [ - "apigateway_logging_enabled" - ], + "CheckTitle": "API Gateway REST API stage has logging enabled", "CheckType": [ - "Logging and Monitoring" + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "TTPs/Defense Evasion" ], "ServiceName": "apigateway", - "SubServiceName": "rest_api", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AwsApiGatewayRestApi", - "Description": "Check if API Gateway Stage has logging enabled.", - "Risk": "If not enabled, monitoring of service use is not possible. Real-time monitoring of API calls can be achieved by directing CloudTrail Logs to CloudWatch Logs and establishing corresponding metric filters and alarms.", + "ResourceType": "AwsApiGatewayStage", + "Description": "**API Gateway REST API stages** with **stage logging** enabled to emit execution or access logs to CloudWatch", + "Risk": "Without stage logging, API activity lacks visibility, hindering detection of abuse and incident response.\nAttackers can probe endpoints, exfiltrate data, or tamper integrations without traces, impacting confidentiality, integrity, and availability and blocking forensic investigation.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/apigateway/latest/developerguide/security-monitoring.html", + "https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-logging.html", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/APIGateway/cloudwatch-logs.html", + "https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-logging.html", + "https://repost.aws/knowledge-center/api-gateway-cloudwatch-logs", + "https://repost.aws/knowledge-center/api-gateway-missing-cloudwatch-logs", + "https://docs.aws.amazon.com/apigateway/latest/developerguide/view-cloudwatch-log-events-in-cloudwatch-console.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "https://docs.prowler.com/checks/aws/logging-policies/ensure-api-gateway-stage-have-logging-level-defined-as-appropiate#terraform" + "CLI": "aws apigateway update-stage --rest-api-id --stage-name --patch-operations op=replace,path='/*/*/logging/loglevel',value=ERROR", + "NativeIaC": "```yaml\n# CloudFormation: enable execution logging on a REST API stage\nResources:\n :\n Type: AWS::ApiGateway::Stage\n Properties:\n StageName: \n RestApiId: \n DeploymentId: \n MethodSettings:\n - ResourcePath: \"/*\"\n HttpMethod: \"*\"\n LoggingLevel: ERROR # CRITICAL: turns on execution logging for all methods\n```", + "Other": "1. In the API Gateway console, open Settings and set CloudWatch log role ARN if prompted\n2. Go to APIs > select your REST API > Stages > select the stage\n3. Click Logs and tracing > CloudWatch Logs > choose Errors only (or Errors and info)\n4. Save changes", + "Terraform": "```hcl\n# Enable execution logging for all methods in a REST API stage\nresource \"aws_api_gateway_method_settings\" \"\" {\n rest_api_id = \"\"\n stage_name = \"\"\n method_path = \"*/*\"\n settings {\n logging_level = \"ERROR\" # CRITICAL: enables stage execution logging\n }\n}\n```" }, "Recommendation": { - "Text": "Monitoring is an important part of maintaining the reliability, availability and performance of API Gateway and your AWS solutions. You should collect monitoring data from all of the parts of your AWS solution. CloudTrail provides a record of actions taken by a user, role, or an AWS service in API Gateway. Using the information collected by CloudTrail, you can determine the request that was made to API Gateway, the IP address from which the request was made, who made the request, etc.", - "Url": "https://docs.aws.amazon.com/apigateway/latest/developerguide/security-monitoring.html" + "Text": "Enable **CloudWatch Logs** for all API Gateway stages, using `ERROR` or `INFO` as appropriate. Include request IDs (e.g., `$context.requestId`). Enforce **least privilege** on logs, set **retention** and **alerts** for anomalies. Avoid sensitive data in logs and use **defense in depth** with tracing.", + "Url": "https://hub.prowler.com/check/apigateway_restapi_logging_enabled" } }, "Categories": [ - "forensics-ready", - "logging" + "logging", + "forensics-ready" ], "DependsOn": [], "RelatedTo": [], - "Notes": "" + "Notes": "", + "CheckAliases": [ + "apigateway_logging_enabled" + ] } diff --git a/prowler/providers/aws/services/apigateway/apigateway_restapi_public/apigateway_restapi_public.metadata.json b/prowler/providers/aws/services/apigateway/apigateway_restapi_public/apigateway_restapi_public.metadata.json index ee69b7b303..918fc24778 100644 --- a/prowler/providers/aws/services/apigateway/apigateway_restapi_public/apigateway_restapi_public.metadata.json +++ b/prowler/providers/aws/services/apigateway/apigateway_restapi_public/apigateway_restapi_public.metadata.json @@ -1,31 +1,36 @@ { "Provider": "aws", "CheckID": "apigateway_restapi_public", - "CheckTitle": "Check if API Gateway endpoint is public or private.", - "CheckAliases": [ - "apigateway_public" - ], + "CheckTitle": "API Gateway REST API endpoint is private", "CheckType": [ - "Infrastructure Security" + "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" ], "ServiceName": "apigateway", - "SubServiceName": "rest_api", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "medium", "ResourceType": "AwsApiGatewayRestApi", - "Description": "Check if API Gateway endpoint is public or private.", - "Risk": "If accessible from internet without restrictions opens up attack / abuse surface for any malicious user.", + "Description": "**Amazon API Gateway REST APIs** are evaluated for endpoint exposure: **internet-accessible** endpoints versus **private VPC-only** access via interface VPC endpoints (`AWS PrivateLink`).", + "Risk": "Internet exposure increases attack surface:\n- **Confidentiality**: misconfigured or anonymous methods can leak data\n- **Integrity**: unauthorized calls can change backend state\n- **Availability/cost**: bots or DDoS can exhaust capacity and spike spend", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-private-apis.html", + "https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-resource-policies-examples.html#apigateway-resource-policies-source-vpc-example", + "https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-control-access-to-api.html", + "https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-resource-policies.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "aws apigateway update-rest-api --rest-api-id --patch-operations op=replace,path=/endpointConfiguration/types/0,value=PRIVATE", + "NativeIaC": "```yaml\nResources:\n :\n Type: AWS::ApiGateway::RestApi\n Properties:\n Name: \n EndpointConfiguration:\n Types:\n - PRIVATE # Critical: sets the REST API endpoint to Private, removing public access\n```", + "Other": "1. Open the AWS console and go to API Gateway\n2. Under REST APIs, select your API\n3. In the left menu, click Settings\n4. Set Endpoint Type to Private\n5. Click Save changes", + "Terraform": "```hcl\nresource \"aws_api_gateway_rest_api\" \"\" {\n name = \"\"\n\n endpoint_configuration {\n types = [\"PRIVATE\"] # Critical: makes the REST API private\n }\n}\n```" }, "Recommendation": { - "Text": "Verify that any public Api Gateway is protected and audited. Detective controls for common risks should be implemented.", - "Url": "https://d1.awsstatic.com/whitepapers/api-gateway-security.pdf?svrd_sip6" + "Text": "Prefer **private** REST APIs reachable via interface VPC endpoints (`PRIVATE`).\n\n*If public access is required*, apply **least privilege** and **defense in depth**:\n- Restrict with resource policies (`aws:SourceVpc`/`aws:SourceVpce`)\n- Enforce strong auth (IAM, Cognito, or authorizers)\n- Add AWS WAF, throttling, usage plans, and comprehensive logging", + "Url": "https://hub.prowler.com/check/apigateway_restapi_public" } }, "Categories": [ @@ -33,5 +38,8 @@ ], "DependsOn": [], "RelatedTo": [], - "Notes": "" + "Notes": "", + "CheckAliases": [ + "apigateway_public" + ] } diff --git a/prowler/providers/aws/services/apigateway/apigateway_restapi_public_with_authorizer/apigateway_restapi_public_with_authorizer.metadata.json b/prowler/providers/aws/services/apigateway/apigateway_restapi_public_with_authorizer/apigateway_restapi_public_with_authorizer.metadata.json index dc68710d45..c393f8a530 100644 --- a/prowler/providers/aws/services/apigateway/apigateway_restapi_public_with_authorizer/apigateway_restapi_public_with_authorizer.metadata.json +++ b/prowler/providers/aws/services/apigateway/apigateway_restapi_public_with_authorizer/apigateway_restapi_public_with_authorizer.metadata.json @@ -1,37 +1,50 @@ { "Provider": "aws", "CheckID": "apigateway_restapi_public_with_authorizer", - "CheckTitle": "Check if API Gateway public endpoint has an authorizer configured.", - "CheckAliases": [ - "apigateway_public_with_authorizer" - ], + "CheckTitle": "API Gateway REST API with a public endpoint has an authorizer configured", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "TTPs/Initial Access/Unauthorized Access", + "Effects/Data Exposure" ], "ServiceName": "apigateway", - "SubServiceName": "rest_api", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "medium", "ResourceType": "AwsApiGatewayRestApi", - "Description": "Check if API Gateway public endpoint has an authorizer configured.", - "Risk": "If accessible from internet without restrictions opens up attack / abuse surface for any malicious user.", - "RelatedUrl": "https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-api-endpoint-types.html", + "Description": "**API Gateway REST APIs** exposed to the Internet are evaluated for an attached **authorizer** that enforces caller identity (Lambda authorizer or Cognito user pool) on method invocations.\n\nFocus is on whether public endpoints require authenticated requests rather than accepting anonymous calls.", + "Risk": "Without an **authorizer** on a public API, anonymous callers can:\n- Read or alter data (confidentiality/integrity)\n- Trigger backend actions, impacting systems\n- Abuse traffic, degrading availability and inflating costs\n\nEndpoint enumeration also enables broader discovery and lateral movement.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://support.icompaas.com/support/solutions/articles/62000233640-check-if-api-gateway-public-endpoint-has-an-authorizer-configured", + "https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-api-endpoint-types.html", + "https://api7.ai/blog/secure-rest-api-in-aws-api-gateway", + "https://supertokens.com/blog/lambda-authorizers", + "https://clerk.com/blog/how-to-secure-api-gateway-using-jwt-and-lambda-authorizers-with-clerk", + "https://aws.plainenglish.io/6-rest-api-security-best-practices-you-can-achieve-with-amazon-api-gateway-2-authentication-62b5171989bd", + "https://stackoverflow.com/questions/68512642/how-to-configure-aws-api-gateway-without-authorizer", + "https://auth0.com/docs/customize/integrations/aws/aws-api-gateway-custom-authorizers" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "aws apigateway create-authorizer --rest-api-id --name --type TOKEN --authorizer-uri arn:aws:apigateway::lambda:path/2015-03-31/functions/arn:aws:lambda:::function:/invocations --identity-source 'method.request.header.Authorization'", + "NativeIaC": "```yaml\n# CloudFormation: Create a minimal Lambda TOKEN authorizer for a public REST API\nResources:\n :\n Type: AWS::ApiGateway::Authorizer\n Properties:\n Name: \n RestApiId: \n Type: TOKEN # Critical: adds an authorizer to the REST API\n IdentitySource: method.request.header.Authorization # Critical: header to read token from\n AuthorizerUri: arn:aws:apigateway::lambda:path/2015-03-31/functions/arn:aws:lambda:::function//invocations # Critical: Lambda authorizer function URI\n```", + "Other": "1. In the AWS Console, open API Gateway and select your REST API\n2. In the left pane, click Authorizers > Create authorizer\n3. Choose Lambda (TOKEN) or Cognito User Pool\n4. For Lambda: select the function and set Identity source to method.request.header.Authorization; for Cognito: select the user pool\n5. Click Create authorizer to add it to the API", + "Terraform": "```hcl\n# Terraform: Minimal Lambda TOKEN authorizer for API Gateway REST API\nresource \"aws_api_gateway_authorizer\" \"\" {\n name = \"\"\n rest_api_id = \"\"\n type = \"TOKEN\" # Critical: enables a Lambda authorizer on the REST API\n identity_source = \"method.request.header.Authorization\" # Critical: header to read token\n authorizer_uri = \"arn:aws:apigateway::lambda:path/2015-03-31/functions/arn:aws:lambda:::function//invocations\" # Critical: Lambda authorizer function URI\n}\n```" }, "Recommendation": { - "Text": "Verify that any public API Gateway is protected and audited. Detective controls for common risks should be implemented.", - "Url": "https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-api-endpoint-types.html" + "Text": "Enforce **authentication** on all Internet-facing APIs by attaching an **authorizer** (Cognito user pool or Lambda) that validates tokens and scopes.\n\nApply defense in depth:\n- Restrictive resource policies and IP controls\n- WAF, throttling, quotas, rate limits\n- Least-privilege backend access and comprehensive logging", + "Url": "https://hub.prowler.com/check/apigateway_restapi_public_with_authorizer" } }, "Categories": [ - "internet-exposed" + "internet-exposed", + "identity-access" ], "DependsOn": [], "RelatedTo": [], - "Notes": "" + "Notes": "", + "CheckAliases": [ + "apigateway_public_with_authorizer" + ] } diff --git a/prowler/providers/aws/services/apigateway/apigateway_restapi_tracing_enabled/apigateway_restapi_tracing_enabled.metadata.json b/prowler/providers/aws/services/apigateway/apigateway_restapi_tracing_enabled/apigateway_restapi_tracing_enabled.metadata.json index 862c2e489d..6d247656ba 100644 --- a/prowler/providers/aws/services/apigateway/apigateway_restapi_tracing_enabled/apigateway_restapi_tracing_enabled.metadata.json +++ b/prowler/providers/aws/services/apigateway/apigateway_restapi_tracing_enabled/apigateway_restapi_tracing_enabled.metadata.json @@ -1,31 +1,39 @@ { "Provider": "aws", "CheckID": "apigateway_restapi_tracing_enabled", - "CheckTitle": "Check if AWS X-Ray tracing is enabled for API Gateway REST API stages.", + "CheckTitle": "API Gateway REST API stage has X-Ray tracing enabled", "CheckType": [ - "Software and Configuration Checks/AWS Security Best Practices" + "Software and Configuration Checks/AWS Security Best Practices/Runtime Behavior Analysis", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices" ], "ServiceName": "apigateway", "SubServiceName": "", - "ResourceIdTemplate": "arn:aws:apigateway:region:account-id:/restapis/restapi-id/stages/stage-name", + "ResourceIdTemplate": "", "Severity": "low", "ResourceType": "AwsApiGatewayStage", - "Description": "This control checks whether AWS X-Ray active tracing is enabled for your Amazon API Gateway REST API stages.", - "Risk": "Without X-Ray active tracing, it may be difficult to quickly identify and respond to performance issues that could lead to decreased availability or degradation of the API's performance.", - "RelatedUrl": "https://docs.aws.amazon.com/xray/latest/devguide/xray-services-apigateway.html", + "Description": "**API Gateway REST API stages** have **AWS X-Ray active tracing** enabled to sample incoming requests and produce distributed traces across connected services.", + "Risk": "Without X-Ray tracing, you lose end-to-end visibility, hindering detection of timeouts, errors, and anomalous latency.\n\nThis delays incident response and root-cause analysis, increasing MTTR and risking partial outages (availability) and undetected integration failures (integrity).", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/securityhub/latest/userguide/apigateway-controls.html#apigateway-3", + "https://docs.aws.amazon.com/xray/latest/devguide/xray-services-apigateway.html", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/APIGateway/tracing.html" + ], "Remediation": { "Code": { "CLI": "aws apigateway update-stage --rest-api-id --stage-name --patch-operations op=replace,path=/tracingEnabled,value=true", - "NativeIaC": "", - "Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/apigateway-controls.html#apigateway-3", - "Terraform": "" + "NativeIaC": "```yaml\n# CloudFormation: Enable X-Ray tracing on an API Gateway REST API stage\nResources:\n :\n Type: AWS::ApiGateway::Stage\n Properties:\n RestApiId: \n DeploymentId: \n StageName: \n TracingEnabled: true # Critical: enables AWS X-Ray tracing for this stage\n```", + "Other": "1. Open the AWS Console and go to API Gateway\n2. Select your REST API and choose Stages\n3. Select the target stage\n4. Open the Logs/Tracing tab, check Enable X-Ray Tracing\n5. Click Save", + "Terraform": "```hcl\n# Enable X-Ray tracing on an API Gateway REST API stage\nresource \"aws_api_gateway_stage\" \"example\" {\n rest_api_id = \"\"\n deployment_id = \"\"\n stage_name = \"\"\n xray_tracing_enabled = true # Critical: enables AWS X-Ray tracing for this stage\n}\n```" }, "Recommendation": { - "Text": "Enable AWS X-Ray tracing for API Gateway REST API stages to monitor and analyze performance in real time.", - "Url": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/APIGateway/tracing.html" + "Text": "Enable **X-Ray active tracing** on all API Gateway stages and propagate trace context through downstream services.\n\nUse prudent sampling, correlate traces with logs/metrics, and alert on errors/latency. Apply **least privilege** to X-Ray access and use **defense in depth** for observability.", + "Url": "https://hub.prowler.com/check/apigateway_restapi_tracing_enabled" } }, - "Categories": [], + "Categories": [ + "logging" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/aws/services/apigateway/apigateway_restapi_waf_acl_attached/apigateway_restapi_waf_acl_attached.metadata.json b/prowler/providers/aws/services/apigateway/apigateway_restapi_waf_acl_attached/apigateway_restapi_waf_acl_attached.metadata.json index ba097a6df1..adac215660 100644 --- a/prowler/providers/aws/services/apigateway/apigateway_restapi_waf_acl_attached/apigateway_restapi_waf_acl_attached.metadata.json +++ b/prowler/providers/aws/services/apigateway/apigateway_restapi_waf_acl_attached/apigateway_restapi_waf_acl_attached.metadata.json @@ -1,35 +1,41 @@ { "Provider": "aws", "CheckID": "apigateway_restapi_waf_acl_attached", - "CheckTitle": "Check if API Gateway Stage has a WAF ACL attached.", - "CheckAliases": [ - "apigateway_waf_acl_attached" - ], + "CheckTitle": "API Gateway stage has a WAF Web ACL attached", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices" ], "ServiceName": "apigateway", - "SubServiceName": "rest_api", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "SubServiceName": "", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AwsApiGatewayRestApi", - "Description": "Check if API Gateway Stage has a WAF ACL attached.", - "Risk": "Potential attacks and / or abuse of service, more even for even for internet reachable services.", + "ResourceType": "AwsApiGatewayStage", + "Description": "**Amazon API Gateway (REST API)** stages are assessed for an associated **AWS WAF web ACL**. The finding reflects whether a `web ACL` is linked at the stage level.", + "Risk": "Absent a **WAF web ACL**, APIs are exposed to application-layer threats that impact CIA:\n- Confidentiality: data exfiltration via injection\n- Integrity: parameter tampering and path traversal\n- Availability: L7 floods, bot abuse, resource exhaustion\n*Public endpoints face heightened risk.*", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/apigateway/latest/developerguide/security-monitoring.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "aws wafv2 associate-web-acl --web-acl-arn --resource-arn arn:aws:apigateway:::/restapis//stages/", + "NativeIaC": "```yaml\n# CloudFormation: Attach a WAFv2 Web ACL to an API Gateway REST API stage\nResources:\n :\n Type: AWS::WAFv2::WebACLAssociation\n Properties:\n ResourceArn: arn:aws:apigateway:::/restapis//stages/ # CRITICAL: target API Gateway stage\n WebACLArn: # CRITICAL: Web ACL to attach\n```", + "Other": "1. Open the AWS Console and go to WAF & Shield\n2. Select Web ACLs (Scope: Regional), choose your Web ACL\n3. Click Add AWS resource\n4. Select API Gateway, choose the REST API and the specific Stage\n5. Click Add/Associate to attach the Web ACL", + "Terraform": "```hcl\n# Attach a WAFv2 Web ACL to an API Gateway REST API stage\nresource \"aws_wafv2_web_acl_association\" \"\" {\n resource_arn = \"arn:aws:apigateway:::/restapis//stages/\" # CRITICAL: target API Gateway stage\n web_acl_arn = \"\" # CRITICAL: Web ACL to attach\n}\n```" }, "Recommendation": { - "Text": "Use AWS WAF to protect your API Gateway API from common web exploits, such as SQL injection and cross-site scripting (XSS) attacks. These could affect API availability and performance, compromise security or consume excessive resources.", - "Url": "https://docs.aws.amazon.com/apigateway/latest/developerguide/security-monitoring.html" + "Text": "Attach an **AWS WAF web ACL** to each exposed stage and apply **defense in depth**:\n- Use managed rule groups and tailored allow/deny lists\n- Apply rate limiting to throttle abuse\n- Enforce least-privilege network exposure\n- Continuously tune rules using logs and metrics\n*Validate changes to reduce false positives.*", + "Url": "https://hub.prowler.com/check/apigateway_restapi_waf_acl_attached" } }, - "Categories": [], + "Categories": [ + "threat-detection" + ], "DependsOn": [], "RelatedTo": [], - "Notes": "" + "Notes": "", + "CheckAliases": [ + "apigateway_waf_acl_attached" + ] } From 9a4fc784dba6f109390e8a78cd687493e308ced6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Fern=C3=A1ndez=20Poyatos?= Date: Fri, 3 Oct 2025 13:42:43 +0200 Subject: [PATCH 04/32] feat(api-keys): Add API Key support for the Prowler API (#8805) --- api/CHANGELOG.md | 1 + api/poetry.lock | 30 +- api/pyproject.toml | 3 +- api/src/backend/api/apps.py | 9 +- api/src/backend/api/authentication.py | 76 ++ api/src/backend/api/base_views.py | 4 +- api/src/backend/api/db_utils.py | 8 +- api/src/backend/api/exceptions.py | 27 +- api/src/backend/api/filters.py | 18 + api/src/backend/api/middleware.py | 10 +- .../backend/api/migrations/0048_api_key.py | 124 +++ api/src/backend/api/models.py | 63 ++ api/src/backend/api/schema_extensions.py | 16 + api/src/backend/api/signals.py | 28 +- api/src/backend/api/specs/v1.yaml | 875 +++++++++++++++--- .../tests/integration/test_authentication.py | 710 +++++++++++++- api/src/backend/api/tests/test_db_utils.py | 26 + api/src/backend/api/tests/test_middleware.py | 2 + api/src/backend/api/tests/test_views.py | 748 ++++++++++++++- api/src/backend/api/v1/serializers.py | 101 ++ api/src/backend/api/v1/urls.py | 2 + api/src/backend/api/v1/views.py | 91 ++ api/src/backend/config/custom_logging.py | 7 + api/src/backend/config/django/base.py | 13 +- api/src/backend/config/django/testing.py | 7 + api/src/backend/conftest.py | 51 + 26 files changed, 2909 insertions(+), 141 deletions(-) create mode 100644 api/src/backend/api/authentication.py create mode 100644 api/src/backend/api/migrations/0048_api_key.py create mode 100644 api/src/backend/api/schema_extensions.py diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 7a7d4e6ee0..9958941369 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to the **Prowler API** are documented in this file. ### Added - Default JWT keys are generated and stored if they are missing from configuration [(#8655)](https://github.com/prowler-cloud/prowler/pull/8655) - `compliance_name` for each compliance [(#7920)](https://github.com/prowler-cloud/prowler/pull/7920) +- API Key support [(#8805)](https://github.com/prowler-cloud/prowler/pull/8805) ### Changed - Now the MANAGE_ACCOUNT permission is required to modify or read user permissions instead of MANAGE_USERS [(#8281)](https://github.com/prowler-cloud/prowler/pull/8281) diff --git a/api/poetry.lock b/api/poetry.lock index 6e2a59834f..255a33d89f 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. [[package]] name = "about-time" @@ -1924,6 +1924,27 @@ files = [ Django = ">=4.2" djangorestframework = ">=3.15.0" +[[package]] +name = "drf-simple-apikey" +version = "2.2.1" +description = "API Key authentication and permissions for Django REST." +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "drf_simple_apikey-2.2.1-py2.py3-none-any.whl", hash = "sha256:2a60b35676d14f907c47dee179dd0fa7425a84c34d6ff5b48d08d3b87ff32809"}, + {file = "drf_simple_apikey-2.2.1.tar.gz", hash = "sha256:e5a52804bbac12c8db80c10a3d51a8514fc59fc8385b5e751099a2bc944ad25d"}, +] + +[package.dependencies] +cryptography = ">=38.0.4" +django = ">=4.2" +djangorestframework = ">=3.14.0" + +[package.extras] +test = ["coverage", "pytest", "pytest-django"] +tooling = ["black (==22.3.0)", "bump2version", "pylint"] + [[package]] name = "drf-spectacular" version = "0.27.2" @@ -5296,7 +5317,6 @@ files = [ {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f66efbc1caa63c088dead1c4170d148eabc9b80d95fb75b6c92ac0aad2437d76"}, {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:22353049ba4181685023b25b5b51a574bce33e7f51c759371a7422dcae5402a6"}, {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:932205970b9f9991b34f55136be327501903f7c66830e9760a8ffb15b07f05cd"}, - {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a52d48f4e7bf9005e8f0a89209bf9a73f7190ddf0489eee5eb51377385f59f2a"}, {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-win32.whl", hash = "sha256:3eac5a91891ceb88138c113f9db04f3cebdae277f5d44eaa3651a4f573e6a5da"}, {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-win_amd64.whl", hash = "sha256:ab007f2f5a87bd08ab1499bdf96f3d5c6ad4dcfa364884cb4549aa0154b13a28"}, {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:4a6679521a58256a90b0d89e03992c15144c5f3858f40d7c18886023d7943db6"}, @@ -5305,7 +5325,6 @@ files = [ {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:811ea1594b8a0fb466172c384267a4e5e367298af6b228931f273b111f17ef52"}, {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:cf12567a7b565cbf65d438dec6cfbe2917d3c1bdddfce84a9930b7d35ea59642"}, {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7dd5adc8b930b12c8fc5b99e2d535a09889941aa0d0bd06f4749e9a9397c71d2"}, - {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1492a6051dab8d912fc2adeef0e8c72216b24d57bd896ea607cb90bb0c4981d3"}, {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-win32.whl", hash = "sha256:bd0a08f0bab19093c54e18a14a10b4322e1eacc5217056f3c063bd2f59853ce4"}, {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-win_amd64.whl", hash = "sha256:a274fb2cb086c7a3dea4322ec27f4cb5cc4b6298adb583ab0e211a4682f241eb"}, {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:20b0f8dc160ba83b6dcc0e256846e1a02d044e13f7ea74a3d1d56ede4e48c632"}, @@ -5314,7 +5333,6 @@ files = [ {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:749c16fcc4a2b09f28843cda5a193e0283e47454b63ec4b81eaa2242f50e4ccd"}, {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bf165fef1f223beae7333275156ab2022cffe255dcc51c27f066b4370da81e31"}, {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:32621c177bbf782ca5a18ba4d7af0f1082a3f6e517ac2a18b3974d4edf349680"}, - {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b82a7c94a498853aa0b272fd5bc67f29008da798d4f93a2f9f289feb8426a58d"}, {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-win32.whl", hash = "sha256:e8c4ebfcfd57177b572e2040777b8abc537cdef58a2120e830124946aa9b42c5"}, {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-win_amd64.whl", hash = "sha256:0467c5965282c62203273b838ae77c0d29d7638c8a4e3a1c8bdd3602c10904e4"}, {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4c8c5d82f50bb53986a5e02d1b3092b03622c02c2eb78e29bec33fd9593bae1a"}, @@ -5323,7 +5341,6 @@ files = [ {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96777d473c05ee3e5e3c3e999f5d23c6f4ec5b0c38c098b3a5229085f74236c6"}, {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:3bc2a80e6420ca8b7d3590791e2dfc709c88ab9152c00eeb511c9875ce5778bf"}, {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e188d2699864c11c36cdfdada94d781fd5d6b0071cd9c427bceb08ad3d7c70e1"}, - {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f6f3eac23941b32afccc23081e1f50612bdbe4e982012ef4f5797986828cd01"}, {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-win32.whl", hash = "sha256:6442cb36270b3afb1b4951f060eccca1ce49f3d087ca1ca4563a6eb479cb3de6"}, {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-win_amd64.whl", hash = "sha256:e5b8daf27af0b90da7bb903a876477a9e6d7270be6146906b276605997c7e9a3"}, {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:fc4b630cd3fa2cf7fce38afa91d7cfe844a9f75d7f0f36393fa98815e911d987"}, @@ -5332,7 +5349,6 @@ files = [ {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e2f1c3765db32be59d18ab3953f43ab62a761327aafc1594a2a1fbe038b8b8a7"}, {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d85252669dc32f98ebcd5d36768f5d4faeaeaa2d655ac0473be490ecdae3c285"}, {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e143ada795c341b56de9418c58d028989093ee611aa27ffb9b7f609c00d813ed"}, - {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2c59aa6170b990d8d2719323e628aaf36f3bfbc1c26279c0eeeb24d05d2d11c7"}, {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-win32.whl", hash = "sha256:beffaed67936fbbeffd10966a4eb53c402fafd3d6833770516bf7314bc6ffa12"}, {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-win_amd64.whl", hash = "sha256:040ae85536960525ea62868b642bdb0c2cc6021c9f9d507810c0c604e66f5a7b"}, {file = "ruamel.yaml.clib-0.2.12.tar.gz", hash = "sha256:6c8fbb13ec503f99a91901ab46e0b07ae7941cd527393187039aec586fdfd36f"}, @@ -6238,4 +6254,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.11,<3.13" -content-hash = "91058a14382b76136a82f45624a30aece7a6d77c8b36c290bb4c40ea60c8850b" +content-hash = "ea50c84bc5c6e46b101614b78f85d3408337cc61836fa04ae4b4597512c98055" diff --git a/api/pyproject.toml b/api/pyproject.toml index 22242aa92d..1d8ba7c179 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -32,7 +32,8 @@ dependencies = [ "openai (>=1.82.0,<2.0.0)", "xmlsec==1.3.14", "h2 (==4.3.0)", - "markdown (>=3.9,<4.0)" + "markdown (>=3.9,<4.0)", + "drf-simple-apikey (==2.2.1)" ] description = "Prowler's API (Django/DRF)" license = "Apache-2.0" diff --git a/api/src/backend/api/apps.py b/api/src/backend/api/apps.py index d855de3cce..add97cf376 100644 --- a/api/src/backend/api/apps.py +++ b/api/src/backend/api/apps.py @@ -1,14 +1,12 @@ import logging import os - -from pathlib import Path import sys - -from django.apps import AppConfig -from django.conf import settings +from pathlib import Path 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,6 +28,7 @@ class ApiConfig(AppConfig): name = "api" def ready(self): + from api import schema_extensions # noqa: F401 from api import signals # noqa: F401 from api.compliance import load_prowler_compliance diff --git a/api/src/backend/api/authentication.py b/api/src/backend/api/authentication.py new file mode 100644 index 0000000000..a15e543642 --- /dev/null +++ b/api/src/backend/api/authentication.py @@ -0,0 +1,76 @@ +from typing import Optional, Tuple +from uuid import UUID + +from cryptography.fernet import InvalidToken +from django.utils import timezone +from drf_simple_apikey.backends import APIKeyAuthentication as BaseAPIKeyAuth +from drf_simple_apikey.crypto import get_crypto +from rest_framework.authentication import BaseAuthentication +from rest_framework.exceptions import AuthenticationFailed +from rest_framework.request import Request +from rest_framework_simplejwt.authentication import JWTAuthentication + +from api.models import TenantAPIKey, TenantAPIKeyManager + + +class TenantAPIKeyAuthentication(BaseAPIKeyAuth): + model = TenantAPIKey + + def __init__(self): + self.key_crypto = get_crypto() + + def authenticate(self, request: Request): + prefixed_key = self.get_key(request) + + # Split prefix from key (format: pk_xxxxxxxx.encrypted_key) + try: + prefix, key = prefixed_key.split(TenantAPIKeyManager.separator, 1) + except ValueError: + raise AuthenticationFailed("Invalid API Key.") + + try: + entity, _ = self._authenticate_credentials(request, key) + except InvalidToken: + raise AuthenticationFailed("Invalid API Key.") + + # Get the API key instance to update last_used_at and retrieve tenant info + # We need to decrypt again to get the pk (already validated by _authenticate_credentials) + payload = self.key_crypto.decrypt(key) + api_key_pk = payload["_pk"] + + # Convert string UUID back to UUID object for lookup + if isinstance(api_key_pk, str): + api_key_pk = UUID(api_key_pk) + + try: + api_key_instance = TenantAPIKey.objects.get(id=api_key_pk, prefix=prefix) + except TenantAPIKey.DoesNotExist: + raise AuthenticationFailed("Invalid API Key.") + + # Update last_used_at + api_key_instance.last_used_at = timezone.now() + api_key_instance.save(update_fields=["last_used_at"]) + + return entity, { + "tenant_id": str(api_key_instance.tenant_id), + "sub": str(api_key_instance.entity.id), + "api_key_prefix": prefix, + } + + +class CombinedJWTOrAPIKeyAuthentication(BaseAuthentication): + jwt_auth = JWTAuthentication() + api_key_auth = TenantAPIKeyAuthentication() + + def authenticate(self, request: Request) -> Optional[Tuple[object, dict]]: + auth_header = request.headers.get("Authorization", "") + + # Prioritize JWT authentication if both are present + if auth_header.startswith("Bearer "): + return self.jwt_auth.authenticate(request) + + if auth_header.startswith("Api-Key "): + return self.api_key_auth.authenticate(request) + + # Default fallback + return self.jwt_auth.authenticate(request) diff --git a/api/src/backend/api/base_views.py b/api/src/backend/api/base_views.py index 605afe332d..36455c56b7 100644 --- a/api/src/backend/api/base_views.py +++ b/api/src/backend/api/base_views.py @@ -5,8 +5,8 @@ from rest_framework.exceptions import NotAuthenticated from rest_framework.filters import SearchFilter from rest_framework_json_api import filters from rest_framework_json_api.views import ModelViewSet -from rest_framework_simplejwt.authentication import JWTAuthentication +from api.authentication import CombinedJWTOrAPIKeyAuthentication from api.db_router import MainRouter from api.db_utils import POSTGRES_USER_VAR, rls_transaction from api.filters import CustomDjangoFilterBackend @@ -15,7 +15,7 @@ from api.rbac.permissions import HasPermissions class BaseViewSet(ModelViewSet): - authentication_classes = [JWTAuthentication] + authentication_classes = [CombinedJWTOrAPIKeyAuthentication] required_permissions = [] permission_classes = [permissions.IsAuthenticated, HasPermissions] filter_backends = [ diff --git a/api/src/backend/api/db_utils.py b/api/src/backend/api/db_utils.py index d7ec718f11..337f8444fe 100644 --- a/api/src/backend/api/db_utils.py +++ b/api/src/backend/api/db_utils.py @@ -61,7 +61,7 @@ def rls_transaction(value: str, parameter: str = POSTGRES_TENANT_VAR): with transaction.atomic(): with connection.cursor() as cursor: try: - # just in case the value is an UUID object + # just in case the value is a UUID object uuid.UUID(str(value)) except ValueError: raise ValidationError("Must be a valid UUID") @@ -434,6 +434,12 @@ def drop_index_on_partitions( schema_editor.execute(sql) +def generate_api_key_prefix(): + """Generate a random 8-character prefix for API keys (e.g., 'pk_abc123de').""" + random_chars = generate_random_token(length=8) + return f"pk_{random_chars}" + + # Postgres enum definition for member role diff --git a/api/src/backend/api/exceptions.py b/api/src/backend/api/exceptions.py index b622118674..d1cf7d78ce 100644 --- a/api/src/backend/api/exceptions.py +++ b/api/src/backend/api/exceptions.py @@ -1,6 +1,10 @@ from django.core.exceptions import ValidationError as django_validation_error from rest_framework import status -from rest_framework.exceptions import APIException +from rest_framework.exceptions import ( + APIException, + AuthenticationFailed, + NotAuthenticated, +) from rest_framework_json_api.exceptions import exception_handler from rest_framework_json_api.serializers import ValidationError from rest_framework_simplejwt.exceptions import InvalidToken, TokenError @@ -68,15 +72,18 @@ def custom_exception_handler(exc, context): exc = ValidationError(exc.message_dict) else: exc = ValidationError(detail=exc.messages[0], code=exc.code) - elif isinstance(exc, (TokenError, InvalidToken)): - if ( - hasattr(exc, "detail") - and isinstance(exc.detail, dict) - and "messages" in exc.detail - ): - exc.detail["messages"] = [ - message_item["message"] for message_item in exc.detail["messages"] - ] + # Force 401 status for AuthenticationFailed exceptions regardless of the authentication backend + elif isinstance(exc, (AuthenticationFailed, NotAuthenticated, TokenError)): + exc.status_code = status.HTTP_401_UNAUTHORIZED + if isinstance(exc, (TokenError, InvalidToken)): + if ( + hasattr(exc, "detail") + and isinstance(exc.detail, dict) + and "messages" in exc.detail + ): + exc.detail["messages"] = [ + message_item["message"] for message_item in exc.detail["messages"] + ] return exception_handler(exc, context) diff --git a/api/src/backend/api/filters.py b/api/src/backend/api/filters.py index 468066131f..b1307c0563 100644 --- a/api/src/backend/api/filters.py +++ b/api/src/backend/api/filters.py @@ -43,6 +43,7 @@ from api.models import ( StateChoices, StatusChoices, Task, + TenantAPIKey, User, ) from api.rls import Tenant @@ -880,3 +881,20 @@ class IntegrationJiraFindingsFilter(FilterSet): } ) return super().filter_queryset(queryset) + + +class TenantApiKeyFilter(FilterSet): + inserted_at = DateFilter(field_name="created", lookup_expr="date") + inserted_at__gte = DateFilter(field_name="created", lookup_expr="gte") + inserted_at__lte = DateFilter(field_name="created", lookup_expr="lte") + expires_at = DateFilter(field_name="expiry_date", lookup_expr="date") + expires_at__gte = DateFilter(field_name="expiry_date", lookup_expr="gte") + expires_at__lte = DateFilter(field_name="expiry_date", lookup_expr="lte") + + class Meta: + model = TenantAPIKey + fields = { + "prefix": ["exact", "icontains"], + "revoked": ["exact"], + "name": ["exact", "icontains"], + } diff --git a/api/src/backend/api/middleware.py b/api/src/backend/api/middleware.py index 743eff5eb7..63f2fc630b 100644 --- a/api/src/backend/api/middleware.py +++ b/api/src/backend/api/middleware.py @@ -8,9 +8,14 @@ def extract_auth_info(request) -> dict: if getattr(request, "auth", None) is not None: tenant_id = request.auth.get("tenant_id", "N/A") user_id = request.auth.get("sub", "N/A") + api_key_prefix = request.auth.get("api_key_prefix", "N/A") else: - tenant_id, user_id = "N/A", "N/A" - return {"tenant_id": tenant_id, "user_id": user_id} + tenant_id, user_id, api_key_prefix = "N/A", "N/A", "N/A" + return { + "tenant_id": tenant_id, + "user_id": user_id, + "api_key_prefix": api_key_prefix, + } class APILoggingMiddleware: @@ -38,6 +43,7 @@ class APILoggingMiddleware: extra={ "user_id": auth_info["user_id"], "tenant_id": auth_info["tenant_id"], + "api_key_prefix": auth_info["api_key_prefix"], "method": request.method, "path": request.path, "query_params": request.GET.dict(), diff --git a/api/src/backend/api/migrations/0048_api_key.py b/api/src/backend/api/migrations/0048_api_key.py new file mode 100644 index 0000000000..ff491b0385 --- /dev/null +++ b/api/src/backend/api/migrations/0048_api_key.py @@ -0,0 +1,124 @@ +# Generated by Django 5.1.12 on 2025-09-30 13:10 + +import uuid + +import django.db.models.deletion +import drf_simple_apikey.models +from django.conf import settings +from django.db import migrations, models + +import api.db_utils +import api.rls + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0047_remove_integration_unique_configuration_per_tenant"), + ] + + operations = [ + migrations.CreateModel( + name="TenantAPIKey", + fields=[ + ("name", models.CharField(blank=True, max_length=255, null=True)), + ( + "expiry_date", + models.DateTimeField( + default=drf_simple_apikey.models._expiry_date, + help_text="Once API key expires, entities cannot use it anymore.", + verbose_name="Expires", + ), + ), + ( + "revoked", + models.BooleanField( + blank=True, + default=False, + help_text="If the API key is revoked, entities cannot use it anymore. (This cannot be undone.)", + ), + ), + ("created", models.DateTimeField(auto_now=True)), + ( + "whitelisted_ips", + models.JSONField( + blank=True, + help_text="List of allowed IP addresses for this API key.", + null=True, + ), + ), + ( + "blacklisted_ips", + models.JSONField( + blank=True, + help_text="List of denied IP addresses for this API key.", + null=True, + ), + ), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "prefix", + models.CharField( + default=api.db_utils.generate_api_key_prefix, + editable=False, + help_text="Unique prefix to identify the API key", + max_length=11, + unique=True, + ), + ), + ( + "last_used_at", + models.DateTimeField( + blank=True, + help_text="Last time this API key was used for authentication", + null=True, + ), + ), + ( + "entity", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="user_api_keys", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "tenant", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="api.tenant" + ), + ), + ], + options={ + "db_table": "api_keys", + "abstract": False, + "indexes": [ + models.Index( + fields=["tenant_id", "prefix"], + name="api_keys_tenant_prefix_idx", + ) + ], + "constraints": [ + models.UniqueConstraint( + fields=("tenant_id", "prefix"), name="unique_api_key_prefixes" + ) + ], + }, + ), + migrations.AddConstraint( + model_name="tenantapikey", + constraint=api.rls.BaseSecurityConstraint( + name="statements_on_tenantapikey", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), + ), + ] diff --git a/api/src/backend/api/models.py b/api/src/backend/api/models.py index 9766afc8d1..0577f22e51 100644 --- a/api/src/backend/api/models.py +++ b/api/src/backend/api/models.py @@ -22,6 +22,8 @@ from django.db.models import Q from django.utils.translation import gettext_lazy as _ from django_celery_beat.models import PeriodicTask from django_celery_results.models import TaskResult +from drf_simple_apikey.crypto import get_crypto +from drf_simple_apikey.models import AbstractAPIKey, AbstractAPIKeyManager from psqlextra.manager import PostgresManager from psqlextra.models import PostgresPartitionedModel from psqlextra.types import PostgresPartitioningMethod @@ -42,6 +44,7 @@ from api.db_utils import ( StateEnumField, StatusEnumField, enum_to_choices, + generate_api_key_prefix, generate_random_token, one_week_from_now, ) @@ -125,6 +128,17 @@ class ActiveProviderPartitionedManager(PostgresManager, ActiveProviderManager): return super().get_queryset().filter(self.active_provider_filter()) +class TenantAPIKeyManager(AbstractAPIKeyManager): + separator = "." + + def assign_api_key(self, obj) -> str: + payload = {"_pk": str(obj.pk), "_exp": obj.expiry_date.timestamp()} + key = get_crypto().generate(payload) + + prefixed_key = f"{obj.prefix}{self.separator}{key}" + return prefixed_key + + class User(AbstractBaseUser): id = models.UUIDField(primary_key=True, default=uuid4, editable=False) name = models.CharField(max_length=150, validators=[MinLengthValidator(3)]) @@ -204,6 +218,55 @@ class Membership(models.Model): resource_name = "memberships" +class TenantAPIKey(AbstractAPIKey, RowLevelSecurityProtectedModel): + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + prefix = models.CharField( + max_length=11, + unique=True, + default=generate_api_key_prefix, + editable=False, + help_text="Unique prefix to identify the API key", + ) + last_used_at = models.DateTimeField( + null=True, + blank=True, + help_text="Last time this API key was used for authentication", + ) + entity = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="user_api_keys", + ) + + objects = TenantAPIKeyManager() + + class Meta(RowLevelSecurityProtectedModel.Meta): + db_table = "api_keys" + + constraints = [ + RowLevelSecurityConstraint( + field="tenant_id", + name="rls_on_%(class)s", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), + models.UniqueConstraint( + fields=("tenant_id", "prefix"), + name="unique_api_key_prefixes", + ), + ] + + indexes = [ + models.Index( + fields=["tenant_id", "prefix"], name="api_keys_tenant_prefix_idx" + ), + ] + + class JSONAPIMeta: + resource_name = "api-keys" + + class Provider(RowLevelSecurityProtectedModel): objects = ActiveProviderManager() all_objects = models.Manager() diff --git a/api/src/backend/api/schema_extensions.py b/api/src/backend/api/schema_extensions.py new file mode 100644 index 0000000000..76346f6217 --- /dev/null +++ b/api/src/backend/api/schema_extensions.py @@ -0,0 +1,16 @@ +from drf_spectacular.extensions import OpenApiAuthenticationExtension +from drf_spectacular.openapi import AutoSchema + + +class CombinedJWTOrAPIKeyAuthenticationScheme(OpenApiAuthenticationExtension): + target_class = "api.authentication.CombinedJWTOrAPIKeyAuthentication" + name = "JWT or API Key" + + def get_security_definition(self, auto_schema: AutoSchema): # noqa: F841 + return { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", + "description": "Supports both JWT Bearer tokens and API Key authentication. " + "Use `Bearer ` for JWT or `Api-Key ` for API keys.", + } diff --git a/api/src/backend/api/signals.py b/api/src/backend/api/signals.py index d9dc0a72d9..aac89ea33e 100644 --- a/api/src/backend/api/signals.py +++ b/api/src/backend/api/signals.py @@ -1,12 +1,12 @@ from celery import states from celery.signals import before_task_publish from config.celery import celery_app -from django.db.models.signals import post_delete +from django.db.models.signals import post_delete, pre_delete from django.dispatch import receiver from django_celery_results.backends.database import DatabaseBackend from api.db_utils import delete_related_daily_task -from api.models import Provider +from api.models import Membership, Provider, TenantAPIKey, User def create_task_result_on_publish(sender=None, headers=None, **kwargs): # noqa: F841 @@ -32,3 +32,27 @@ before_task_publish.connect( def delete_provider_scan_task(sender, instance, **kwargs): # noqa: F841 # Delete the associated periodic task when the provider is deleted delete_related_daily_task(instance.id) + + +@receiver(pre_delete, sender=User) +def revoke_user_api_keys(sender, instance, **kwargs): # noqa: F841 + """ + Revoke all API keys associated with a user before deletion. + + The entity field will be set to NULL by on_delete=SET_NULL, + but we explicitly revoke the keys to prevent further use. + """ + TenantAPIKey.objects.filter(entity=instance).update(revoked=True) + + +@receiver(post_delete, sender=Membership) +def revoke_membership_api_keys(sender, instance, **kwargs): # noqa: F841 + """ + Revoke all API keys when a user is removed from a tenant. + + When a membership is deleted, all API keys created by that user + in that tenant should be revoked to prevent further access. + """ + TenantAPIKey.objects.filter( + entity=instance.user, tenant_id=instance.tenant.id + ).update(revoked=True) diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index 483db23cd6..9908f6979e 100644 --- a/api/src/backend/api/specs/v1.yaml +++ b/api/src/backend/api/specs/v1.yaml @@ -7,6 +7,257 @@ info: This file is auto-generated. paths: + /api/v1/api-keys: + get: + operationId: api_keys_list + description: Retrieve a list of API keys for the tenant, with filtering support. + summary: List API keys + parameters: + - in: query + name: fields[api-keys] + schema: + type: array + items: + type: string + enum: + - name + - prefix + - expires_at + - revoked + - inserted_at + - last_used_at + - entity + description: endpoint return only specific fields in the response on a per-type + basis by including a fields[TYPE] query parameter. + explode: false + - in: query + name: filter[expires_at] + schema: + type: string + format: date + - in: query + name: filter[expires_at__gte] + schema: + type: string + format: date + - in: query + name: filter[expires_at__lte] + schema: + type: string + format: date + - in: query + name: filter[inserted_at] + schema: + type: string + format: date + - in: query + name: filter[inserted_at__gte] + schema: + type: string + format: date + - in: query + name: filter[inserted_at__lte] + schema: + type: string + format: date + - in: query + name: filter[name] + schema: + type: string + - in: query + name: filter[name__icontains] + schema: + type: string + - in: query + name: filter[prefix] + schema: + type: string + - in: query + name: filter[prefix__icontains] + schema: + type: string + - in: query + name: filter[revoked] + schema: + type: boolean + - name: filter[search] + required: false + in: query + description: A search term. + schema: + type: string + - name: page[number] + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page[size] + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - name: sort + required: false + in: query + description: '[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)' + schema: + type: array + items: + type: string + enum: + - name + - -name + - prefix + - -prefix + - revoked + - -revoked + - inserted_at + - -inserted_at + - expires_at + - -expires_at + explode: false + tags: + - API Keys + security: + - JWT or API Key: [] + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/PaginatedTenantApiKeyList' + description: '' + post: + operationId: api_keys_create + description: Create a new API key for the tenant. + summary: Create a new API key + tags: + - API Keys + requestBody: + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/TenantApiKeyCreateRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/TenantApiKeyCreateRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/TenantApiKeyCreateRequest' + required: true + security: + - JWT or API Key: [] + responses: + '201': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/TenantApiKeyCreateResponse' + description: '' + /api/v1/api-keys/{id}: + get: + operationId: api_keys_retrieve + description: Fetch detailed information about a specific API key by its ID. + summary: Retrieve API key details + parameters: + - in: query + name: fields[api-keys] + schema: + type: array + items: + type: string + enum: + - name + - prefix + - expires_at + - revoked + - inserted_at + - last_used_at + - entity + description: endpoint return only specific fields in the response on a per-type + basis by including a fields[TYPE] query parameter. + explode: false + - in: path + name: id + schema: + type: string + format: uuid + description: A UUID string identifying this tenant api key. + required: true + tags: + - API Keys + security: + - JWT or API Key: [] + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/TenantApiKeyResponse' + description: '' + patch: + operationId: api_keys_partial_update + description: Modify certain fields of an existing API key without affecting + other settings. + summary: Partially update an API key + parameters: + - in: path + name: id + schema: + type: string + format: uuid + description: A UUID string identifying this tenant api key. + required: true + tags: + - API Keys + requestBody: + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/PatchedTenantApiKeyUpdateRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedTenantApiKeyUpdateRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedTenantApiKeyUpdateRequest' + required: true + security: + - JWT or API Key: [] + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/TenantApiKeyUpdateResponse' + description: '' + /api/v1/api-keys/{id}/revoke: + delete: + operationId: api_keys_revoke_destroy + description: Revoke an API key by its ID. This action is irreversible and will + prevent the key from being used. + summary: Revoke an API key + parameters: + - in: path + name: id + schema: + type: string + format: uuid + description: A UUID string identifying this tenant api key. + required: true + tags: + - API Keys + security: + - JWT or API Key: [] + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/OpenApiResponseResponse' + description: API key was successfully revoked /api/v1/compliance-overviews: get: operationId: compliance_overviews_list @@ -135,7 +386,7 @@ paths: tags: - Compliance Overview security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -201,7 +452,7 @@ paths: tags: - Compliance Overview security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -237,7 +488,7 @@ paths: tags: - Compliance Overview security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -386,7 +637,7 @@ paths: tags: - Compliance Overview security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -892,7 +1143,7 @@ paths: tags: - Finding security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -954,7 +1205,7 @@ paths: tags: - Finding security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -1395,7 +1646,7 @@ paths: tags: - Finding security: - - jwtAuth: [] + - JWT or API Key: [] deprecated: true responses: '200': @@ -1817,7 +2068,7 @@ paths: tags: - Finding security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -2263,7 +2514,7 @@ paths: tags: - Finding security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -2659,7 +2910,7 @@ paths: tags: - Finding security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -2811,7 +3062,7 @@ paths: tags: - Integration security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -2839,7 +3090,7 @@ paths: $ref: '#/components/schemas/IntegrationCreateRequest' required: true security: - - jwtAuth: [] + - JWT or API Key: [] responses: '201': content: @@ -2889,7 +3140,7 @@ paths: $ref: '#/components/schemas/IntegrationJiraDispatchRequest' required: true security: - - jwtAuth: [] + - JWT or API Key: [] responses: '202': content: @@ -2959,7 +3210,7 @@ paths: tags: - Integration security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -2995,7 +3246,7 @@ paths: $ref: '#/components/schemas/PatchedIntegrationUpdateRequest' required: true security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -3018,7 +3269,7 @@ paths: tags: - Integration security: - - jwtAuth: [] + - JWT or API Key: [] responses: '204': description: No response body @@ -3038,7 +3289,7 @@ paths: tags: - Integration security: - - jwtAuth: [] + - JWT or API Key: [] responses: '202': content: @@ -3082,7 +3333,7 @@ paths: $ref: '#/components/schemas/InvitationAcceptRequest' required: true security: - - jwtAuth: [] + - JWT or API Key: [] responses: '201': content: @@ -3155,7 +3406,7 @@ paths: tags: - Lighthouse AI security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -3182,7 +3433,7 @@ paths: $ref: '#/components/schemas/LighthouseConfigCreateRequest' required: true security: - - jwtAuth: [] + - JWT or API Key: [] responses: '201': content: @@ -3216,7 +3467,7 @@ paths: $ref: '#/components/schemas/PatchedLighthouseConfigUpdateRequest' required: true security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -3237,7 +3488,7 @@ paths: tags: - Lighthouse AI security: - - jwtAuth: [] + - JWT or API Key: [] responses: '204': description: No response body @@ -3256,7 +3507,7 @@ paths: tags: - Lighthouse AI security: - - jwtAuth: [] + - JWT or API Key: [] responses: '202': content: @@ -3446,7 +3697,7 @@ paths: tags: - Overview security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -3616,7 +3867,7 @@ paths: tags: - Overview security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -3650,7 +3901,7 @@ paths: tags: - Overview security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -3797,7 +4048,7 @@ paths: tags: - Overview security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -3893,7 +4144,7 @@ paths: tags: - Processor security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -3921,7 +4172,7 @@ paths: $ref: '#/components/schemas/ProcessorCreateRequest' required: true security: - - jwtAuth: [] + - JWT or API Key: [] responses: '201': content: @@ -3960,7 +4211,7 @@ paths: tags: - Processor security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -3996,7 +4247,7 @@ paths: $ref: '#/components/schemas/PatchedProcessorUpdateRequest' required: true security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -4019,7 +4270,7 @@ paths: tags: - Processor security: - - jwtAuth: [] + - JWT or API Key: [] responses: '204': description: No response body @@ -4149,7 +4400,7 @@ paths: tags: - Provider Group security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -4177,7 +4428,7 @@ paths: $ref: '#/components/schemas/ProviderGroupCreateRequest' required: true security: - - jwtAuth: [] + - JWT or API Key: [] responses: '201': content: @@ -4218,7 +4469,7 @@ paths: tags: - Provider Group security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -4254,7 +4505,7 @@ paths: $ref: '#/components/schemas/PatchedProviderGroupUpdateRequest' required: true security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -4277,7 +4528,7 @@ paths: tags: - Provider Group security: - - jwtAuth: [] + - JWT or API Key: [] responses: '204': description: No response body @@ -4302,7 +4553,7 @@ paths: $ref: '#/components/schemas/ProviderGroupMembershipRequest' required: true security: - - jwtAuth: [] + - JWT or API Key: [] responses: '204': description: Relationship created successfully @@ -4328,7 +4579,7 @@ paths: $ref: '#/components/schemas/PatchedProviderGroupMembershipRequest' required: true security: - - jwtAuth: [] + - JWT or API Key: [] responses: '204': description: Relationship updated successfully @@ -4340,7 +4591,7 @@ paths: tags: - Provider Group security: - - jwtAuth: [] + - JWT or API Key: [] responses: '204': description: Relationship deleted successfully @@ -4535,7 +4786,7 @@ paths: tags: - Provider security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -4563,7 +4814,7 @@ paths: $ref: '#/components/schemas/ProviderCreateRequest' required: true security: - - jwtAuth: [] + - JWT or API Key: [] responses: '201': content: @@ -4617,7 +4868,7 @@ paths: tags: - Provider security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -4653,7 +4904,7 @@ paths: $ref: '#/components/schemas/PatchedProviderUpdateRequest' required: true security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -4676,7 +4927,7 @@ paths: tags: - Provider security: - - jwtAuth: [] + - JWT or API Key: [] responses: '202': content: @@ -4716,7 +4967,7 @@ paths: tags: - Provider security: - - jwtAuth: [] + - JWT or API Key: [] responses: '202': content: @@ -4822,7 +5073,7 @@ paths: tags: - Provider security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -4850,7 +5101,7 @@ paths: $ref: '#/components/schemas/ProviderSecretCreateRequest' required: true security: - - jwtAuth: [] + - JWT or API Key: [] responses: '201': content: @@ -4889,7 +5140,7 @@ paths: tags: - Provider security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -4924,7 +5175,7 @@ paths: $ref: '#/components/schemas/PatchedProviderSecretUpdateRequest' required: true security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -4946,7 +5197,7 @@ paths: tags: - Provider security: - - jwtAuth: [] + - JWT or API Key: [] responses: '204': description: No response body @@ -5263,7 +5514,7 @@ paths: tags: - Resource security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -5323,7 +5574,7 @@ paths: tags: - Resource security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -5583,7 +5834,7 @@ paths: tags: - Resource security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -5870,7 +6121,7 @@ paths: tags: - Resource security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -6109,7 +6360,7 @@ paths: tags: - Resource security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -6279,7 +6530,7 @@ paths: tags: - Role security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -6306,7 +6557,7 @@ paths: $ref: '#/components/schemas/RoleCreateRequest' required: true security: - - jwtAuth: [] + - JWT or API Key: [] responses: '201': content: @@ -6354,7 +6605,7 @@ paths: tags: - Role security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -6392,7 +6643,7 @@ paths: $ref: '#/components/schemas/PatchedRoleUpdateRequest' required: true security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -6416,7 +6667,7 @@ paths: tags: - Role security: - - jwtAuth: [] + - JWT or API Key: [] responses: '204': description: No response body @@ -6441,7 +6692,7 @@ paths: $ref: '#/components/schemas/RoleProviderGroupRelationshipRequest' required: true security: - - jwtAuth: [] + - JWT or API Key: [] responses: '204': description: Relationship created successfully @@ -6467,7 +6718,7 @@ paths: $ref: '#/components/schemas/PatchedRoleProviderGroupRelationshipRequest' required: true security: - - jwtAuth: [] + - JWT or API Key: [] responses: '204': description: Relationship updated successfully @@ -6479,7 +6730,7 @@ paths: tags: - Role security: - - jwtAuth: [] + - JWT or API Key: [] responses: '204': description: Relationship deleted successfully @@ -6545,7 +6796,7 @@ paths: tags: - SAML security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -6573,7 +6824,7 @@ paths: $ref: '#/components/schemas/SAMLConfigurationRequest' required: true security: - - jwtAuth: [] + - JWT or API Key: [] responses: '201': content: @@ -6612,7 +6863,7 @@ paths: tags: - SAML security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -6648,7 +6899,7 @@ paths: $ref: '#/components/schemas/PatchedSAMLConfigurationRequest' required: true security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -6672,7 +6923,7 @@ paths: tags: - SAML security: - - jwtAuth: [] + - JWT or API Key: [] responses: '204': description: No response body @@ -6958,7 +7209,7 @@ paths: tags: - Scan security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -6990,7 +7241,7 @@ paths: $ref: '#/components/schemas/ScanCreateRequest' required: true security: - - jwtAuth: [] + - JWT or API Key: [] responses: '202': content: @@ -7065,7 +7316,7 @@ paths: tags: - Scan security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -7101,7 +7352,7 @@ paths: $ref: '#/components/schemas/PatchedScanUpdateRequest' required: true security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -7144,7 +7395,7 @@ paths: tags: - Scan security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': description: CSV file containing the compliance report @@ -7177,7 +7428,7 @@ paths: tags: - Scan security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': description: Report obtained successfully @@ -7209,7 +7460,7 @@ paths: $ref: '#/components/schemas/ScheduleDailyCreateRequest' required: true security: - - jwtAuth: [] + - JWT or API Key: [] responses: '202': content: @@ -7324,7 +7575,7 @@ paths: tags: - Task security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -7365,7 +7616,7 @@ paths: tags: - Task security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -7389,7 +7640,7 @@ paths: tags: - Task security: - - jwtAuth: [] + - JWT or API Key: [] responses: '202': content: @@ -7511,7 +7762,7 @@ paths: tags: - Tenant security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -7539,7 +7790,7 @@ paths: $ref: '#/components/schemas/TenantRequest' required: true security: - - jwtAuth: [] + - JWT or API Key: [] responses: '201': content: @@ -7575,7 +7826,7 @@ paths: tags: - Tenant security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -7611,7 +7862,7 @@ paths: $ref: '#/components/schemas/PatchedTenantRequest' required: true security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -7634,7 +7885,7 @@ paths: tags: - Tenant security: - - jwtAuth: [] + - JWT or API Key: [] responses: '204': description: No response body @@ -7707,7 +7958,7 @@ paths: tags: - Tenant security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -7739,7 +7990,7 @@ paths: tags: - Tenant security: - - jwtAuth: [] + - JWT or API Key: [] responses: '204': description: No response body @@ -7918,7 +8169,7 @@ paths: tags: - Invitation security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -7947,7 +8198,7 @@ paths: $ref: '#/components/schemas/InvitationCreateRequest' required: true security: - - jwtAuth: [] + - JWT or API Key: [] responses: '201': content: @@ -7989,7 +8240,7 @@ paths: tags: - Invitation security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -8024,7 +8275,7 @@ paths: $ref: '#/components/schemas/PatchedInvitationUpdateRequest' required: true security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -8046,7 +8297,7 @@ paths: tags: - Invitation security: - - jwtAuth: [] + - JWT or API Key: [] responses: '204': description: No response body @@ -8071,7 +8322,7 @@ paths: $ref: '#/components/schemas/TokenRequest' required: true security: - - jwtAuth: [] + - JWT or API Key: [] - {} responses: '200': @@ -8101,7 +8352,7 @@ paths: $ref: '#/components/schemas/TokenRefreshRequest' required: true security: - - jwtAuth: [] + - JWT or API Key: [] - {} responses: '200': @@ -8131,7 +8382,7 @@ paths: $ref: '#/components/schemas/TokenSwitchTenantRequest' required: true security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -8263,7 +8514,7 @@ paths: tags: - User security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -8298,7 +8549,7 @@ paths: $ref: '#/components/schemas/UserCreateRequest' required: true security: - - jwtAuth: [] + - JWT or API Key: [] - {} responses: '201': @@ -8351,7 +8602,7 @@ paths: tags: - User security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -8386,7 +8637,7 @@ paths: $ref: '#/components/schemas/PatchedUserUpdateRequest' required: true security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -8409,7 +8660,7 @@ paths: tags: - User security: - - jwtAuth: [] + - JWT or API Key: [] responses: '204': description: No response body @@ -8434,7 +8685,7 @@ paths: $ref: '#/components/schemas/UserRoleRelationshipRequest' required: true security: - - jwtAuth: [] + - JWT or API Key: [] responses: '204': description: Relationship created successfully @@ -8461,7 +8712,7 @@ paths: $ref: '#/components/schemas/PatchedUserRoleRelationshipRequest' required: true security: - - jwtAuth: [] + - JWT or API Key: [] responses: '204': description: Relationship updated successfully @@ -8476,7 +8727,7 @@ paths: tags: - User security: - - jwtAuth: [] + - JWT or API Key: [] responses: '204': description: Relationship deleted successfully @@ -8580,7 +8831,7 @@ paths: tags: - User security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -8625,7 +8876,7 @@ paths: tags: - User security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -8670,7 +8921,7 @@ paths: tags: - User security: - - jwtAuth: [] + - JWT or API Key: [] responses: '200': content: @@ -10615,7 +10866,7 @@ components: type: object properties: data: - $ref: '#/components/schemas/ComplianceOverviewMetadata' + $ref: '#/components/schemas/TenantApiKey' required: - data OverviewFinding: @@ -10971,6 +11222,15 @@ components: $ref: '#/components/schemas/Task' required: - data + PaginatedTenantApiKeyList: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/TenantApiKey' + required: + - data PaginatedTenantList: type: object properties: @@ -12094,6 +12354,83 @@ components: minLength: 3 required: - data + PatchedTenantApiKeyUpdateRequest: + type: object + properties: + data: + type: object + required: + - type + - id + additionalProperties: false + properties: + type: + type: string + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + enum: + - api-keys + id: + type: string + format: uuid + attributes: + type: object + properties: + name: + type: string + nullable: true + maxLength: 255 + prefix: + type: string + readOnly: true + minLength: 1 + description: Unique prefix to identify the API key + expires_at: + type: string + format: date-time + readOnly: true + inserted_at: + type: string + format: date-time + readOnly: true + last_used_at: + type: string + format: date-time + readOnly: true + nullable: true + description: Last time this API key was used for authentication + relationships: + type: object + properties: + entity: + type: object + properties: + data: + type: object + properties: + id: + type: string + format: uuid + type: + type: string + enum: + - users + title: Resource Type Name + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share + common attributes and relationships. + required: + - id + - type + required: + - data + description: The identifier of the related object. + title: Resource Identifier + readOnly: true + nullable: true + required: + - data PatchedTenantRequest: type: object properties: @@ -15234,6 +15571,325 @@ components: description: A related resource object from type memberships title: memberships readOnly: true + TenantApiKey: + type: object + required: + - type + - id + additionalProperties: false + properties: + type: + type: string + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + enum: + - api-keys + id: + type: string + format: uuid + attributes: + type: object + properties: + name: + type: string + nullable: true + maxLength: 255 + prefix: + type: string + readOnly: true + description: Unique prefix to identify the API key + expires_at: + type: string + format: date-time + readOnly: true + revoked: + type: boolean + description: If the API key is revoked, entities cannot use it anymore. + (This cannot be undone.) + inserted_at: + type: string + format: date-time + readOnly: true + last_used_at: + type: string + format: date-time + nullable: true + description: Last time this API key was used for authentication + relationships: + type: object + properties: + entity: + type: object + properties: + data: + type: object + properties: + id: + type: string + format: uuid + type: + type: string + enum: + - users + title: Resource Type Name + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common + attributes and relationships. + required: + - id + - type + required: + - data + description: The identifier of the related object. + title: Resource Identifier + nullable: true + TenantApiKeyCreate: + type: object + required: + - type + additionalProperties: false + properties: + type: + type: string + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + enum: + - api-keys + attributes: + type: object + properties: + name: + type: string + nullable: true + maxLength: 255 + prefix: + type: string + readOnly: true + description: Unique prefix to identify the API key + expires_at: + type: string + format: date-time + revoked: + type: boolean + readOnly: true + description: If the API key is revoked, entities cannot use it anymore. + (This cannot be undone.) + inserted_at: + type: string + format: date-time + readOnly: true + last_used_at: + type: string + format: date-time + readOnly: true + nullable: true + description: Last time this API key was used for authentication + api_key: + type: string + readOnly: true + relationships: + type: object + properties: + entity: + type: object + properties: + data: + type: object + properties: + id: + type: string + format: uuid + type: + type: string + enum: + - users + title: Resource Type Name + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common + attributes and relationships. + required: + - id + - type + required: + - data + description: The identifier of the related object. + title: Resource Identifier + readOnly: true + nullable: true + TenantApiKeyCreateRequest: + type: object + properties: + data: + type: object + required: + - type + additionalProperties: false + properties: + type: + type: string + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + enum: + - api-keys + attributes: + type: object + properties: + name: + type: string + nullable: true + maxLength: 255 + prefix: + type: string + readOnly: true + minLength: 1 + description: Unique prefix to identify the API key + expires_at: + type: string + format: date-time + revoked: + type: boolean + readOnly: true + description: If the API key is revoked, entities cannot use it anymore. + (This cannot be undone.) + inserted_at: + type: string + format: date-time + readOnly: true + last_used_at: + type: string + format: date-time + readOnly: true + nullable: true + description: Last time this API key was used for authentication + api_key: + type: string + readOnly: true + relationships: + type: object + properties: + entity: + type: object + properties: + data: + type: object + properties: + id: + type: string + format: uuid + type: + type: string + enum: + - users + title: Resource Type Name + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share + common attributes and relationships. + required: + - id + - type + required: + - data + description: The identifier of the related object. + title: Resource Identifier + readOnly: true + nullable: true + required: + - data + TenantApiKeyCreateResponse: + type: object + properties: + data: + $ref: '#/components/schemas/TenantApiKeyCreate' + required: + - data + TenantApiKeyResponse: + type: object + properties: + data: + $ref: '#/components/schemas/TenantApiKey' + required: + - data + TenantApiKeyUpdate: + type: object + required: + - type + - id + additionalProperties: false + properties: + type: + type: string + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + enum: + - api-keys + id: + type: string + format: uuid + attributes: + type: object + properties: + name: + type: string + nullable: true + maxLength: 255 + prefix: + type: string + readOnly: true + description: Unique prefix to identify the API key + expires_at: + type: string + format: date-time + readOnly: true + inserted_at: + type: string + format: date-time + readOnly: true + last_used_at: + type: string + format: date-time + readOnly: true + nullable: true + description: Last time this API key was used for authentication + relationships: + type: object + properties: + entity: + type: object + properties: + data: + type: object + properties: + id: + type: string + format: uuid + type: + type: string + enum: + - users + title: Resource Type Name + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common + attributes and relationships. + required: + - id + - type + required: + - data + description: The identifier of the related object. + title: Resource Identifier + readOnly: true + nullable: true + TenantApiKeyUpdateResponse: + type: object + properties: + data: + $ref: '#/components/schemas/TenantApiKeyUpdate' + required: + - data TenantRequest: type: object properties: @@ -15776,10 +16432,12 @@ components: required: - data securitySchemes: - jwtAuth: + JWT or API Key: type: http scheme: bearer bearerFormat: JWT + description: Supports both JWT Bearer tokens and API Key authentication. Use + `Bearer ` for JWT or `Api-Key ` for API keys. tags: - name: User description: Endpoints for managing user accounts. @@ -15832,3 +16490,6 @@ tags: - name: SAML description: Endpoints for Single Sign-On authentication management via SAML for seamless user authentication. +- name: API Keys + description: Endpoints for API keys management. These can be used as an alternative + to JWT authorization. diff --git a/api/src/backend/api/tests/integration/test_authentication.py b/api/src/backend/api/tests/integration/test_authentication.py index 24d8e16f76..2a71d3585a 100644 --- a/api/src/backend/api/tests/integration/test_authentication.py +++ b/api/src/backend/api/tests/integration/test_authentication.py @@ -1,9 +1,14 @@ +import time +from datetime import datetime, timedelta, timezone +from uuid import uuid4 + import pytest from conftest import TEST_PASSWORD, get_api_tokens, get_authorization_header from django.urls import reverse +from drf_simple_apikey.crypto import get_crypto from rest_framework.test import APIClient -from api.models import Membership, User +from api.models import Membership, Role, TenantAPIKey, User, UserRoleRelationship @pytest.mark.django_db @@ -298,3 +303,706 @@ class TestTokenSwitchTenant: assert invalid_tenant_response.json()["errors"][0]["detail"] == ( "Tenant does not exist or user is not a " "member." ) + + +@pytest.mark.django_db +class TestAPIKeyAuthentication: + def test_successful_authentication_with_api_key( + self, create_test_user, tenants_fixture, api_keys_fixture + ): + """Verify API key can authenticate and access protected endpoints.""" + client = APIClient() + api_key = api_keys_fixture[0] + + # Use API key to authenticate and access protected endpoint + api_key_headers = get_api_key_header(api_key._raw_key) + response = client.get(reverse("provider-list"), headers=api_key_headers) + + assert response.status_code == 200 + assert "data" in response.json() + + def test_api_key_one_time_display_on_creation( + self, create_test_user_rbac, tenants_fixture + ): + """Verify full key only returned on creation, subsequent retrieval shows prefix only.""" + client = APIClient() + + # Authenticate with JWT to create API key + access_token, _ = get_api_tokens( + client, create_test_user_rbac.email, TEST_PASSWORD + ) + jwt_headers = get_authorization_header(access_token) + + # Create API key + api_key_name = "Test One-Time Key" + create_response = client.post( + reverse("api-key-list"), + data={ + "data": { + "type": "api-keys", + "attributes": { + "name": api_key_name, + }, + } + }, + format="vnd.api+json", + headers=jwt_headers, + ) + + assert create_response.status_code == 201 + created_data = create_response.json()["data"] + api_key_id = created_data["id"] + + # Verify full key is present in creation response + assert "api_key" in created_data["attributes"] + full_key = created_data["attributes"]["api_key"] + assert full_key.startswith("pk_") + assert "." in full_key + + # Retrieve the same API key + retrieve_response = client.get( + reverse("api-key-detail", kwargs={"pk": api_key_id}), + headers=jwt_headers, + ) + + assert retrieve_response.status_code == 200 + retrieved_data = retrieve_response.json()["data"] + + # Verify full key is NOT present in retrieval response + assert "api_key" not in retrieved_data["attributes"] + # Only prefix should be visible + assert "prefix" in retrieved_data["attributes"] + assert retrieved_data["attributes"]["prefix"].startswith("pk_") + + def test_last_used_at_tracking( + self, create_test_user, tenants_fixture, api_keys_fixture + ): + """Verify last_used_at timestamp updates on each authentication.""" + client = APIClient() + api_key = api_keys_fixture[0] + + # Verify initially last_used_at is None + assert api_key.last_used_at is None + + # Use API key to authenticate + api_key_headers = get_api_key_header(api_key._raw_key) + first_response = client.get(reverse("provider-list"), headers=api_key_headers) + assert first_response.status_code == 200 + + # Reload from database and check last_used_at is set + api_key.refresh_from_db() + first_used_at = api_key.last_used_at + assert first_used_at is not None + + # Use the same key again after a small delay + time.sleep(0.1) + + second_response = client.get(reverse("provider-list"), headers=api_key_headers) + assert second_response.status_code == 200 + + # Reload and verify last_used_at was updated + api_key.refresh_from_db() + second_used_at = api_key.last_used_at + assert second_used_at is not None + assert second_used_at > first_used_at + + +@pytest.mark.django_db +class TestAPIKeyErrors: + def test_invalid_api_key_format_missing_separator( + self, create_test_user, tenants_fixture + ): + """Malformed key without . separator.""" + client = APIClient() + + # Create malformed key without separator + malformed_key = "pk_12345678abcdefgh" + api_key_headers = get_api_key_header(malformed_key) + + response = client.get(reverse("provider-list"), headers=api_key_headers) + + assert response.status_code == 401 + assert "Invalid API Key." in response.json()["errors"][0]["detail"] + + def test_invalid_api_key_format_malformed(self, create_test_user, tenants_fixture): + """Completely invalid format.""" + client = APIClient() + + # Various malformed keys + malformed_keys = [ + "invalid_key", + "Bearer some_token", + "", + "pk_.", + ".encrypted_part", + ] + + for malformed_key in malformed_keys: + api_key_headers = get_api_key_header(malformed_key) + response = client.get(reverse("provider-list"), headers=api_key_headers) + + assert response.status_code == 401 + assert "Invalid API Key." in response.json()["errors"][0]["detail"] + + def test_expired_api_key_rejected(self, create_test_user, tenants_fixture): + """Key past expiry date returns 401.""" + client = APIClient() + + # Create API key with past expiry date + expired_key, raw_key = TenantAPIKey.objects.create_api_key( + name="Expired Key", + tenant_id=tenants_fixture[0].id, + entity=create_test_user, + expiry_date=datetime.now(timezone.utc) - timedelta(days=1), + ) + + api_key_headers = get_api_key_header(raw_key) + response = client.get(reverse("provider-list"), headers=api_key_headers) + + assert response.status_code == 401 + assert "API Key has already expired." in response.json()["errors"][0]["detail"] + + def test_revoked_api_key_rejected( + self, create_test_user, tenants_fixture, api_keys_fixture + ): + """Revoked key returns 401.""" + client = APIClient() + + # Use the revoked key from fixture + revoked_key = api_keys_fixture[2] + assert revoked_key.revoked is True + + api_key_headers = get_api_key_header(revoked_key._raw_key) + response = client.get(reverse("provider-list"), headers=api_key_headers) + + assert response.status_code == 401 + assert "API Key has been revoked." in response.json()["errors"][0]["detail"] + + def test_non_existent_api_key(self, create_test_user, tenants_fixture): + """Key UUID doesn't exist in database.""" + client = APIClient() + + # Create a valid-looking key with non-existent UUID + crypto = get_crypto() + fake_uuid = str(uuid4()) + fake_expiry = (datetime.now(timezone.utc) + timedelta(days=30)).timestamp() + payload = {"_pk": fake_uuid, "_exp": fake_expiry} + encrypted_payload = crypto.generate(payload) + + fake_key = f"pk_fakepfx.{encrypted_payload}" + api_key_headers = get_api_key_header(fake_key) + + response = client.get(reverse("provider-list"), headers=api_key_headers) + + assert response.status_code == 401 + assert ( + "No entity matching this api key." in response.json()["errors"][0]["detail"] + ) + + def test_corrupted_payload(self, create_test_user, tenants_fixture): + """Tampered/corrupted encrypted payload.""" + client = APIClient() + + # Create key with corrupted encrypted portion + corrupted_key = "pk_12345678.corrupted_encrypted_data_here" + api_key_headers = get_api_key_header(corrupted_key) + + response = client.get(reverse("provider-list"), headers=api_key_headers) + + assert response.status_code == 401 + assert "Invalid API Key." in response.json()["errors"][0]["detail"] + + +@pytest.mark.django_db +class TestAPIKeyTenantIsolation: + def test_api_key_tenant_isolation( + self, create_test_user, tenants_fixture, api_keys_fixture + ): + """User in tenant A cannot use API key from tenant B.""" + client = APIClient() + + # Create a second user in a different tenant + second_user = User.objects.create_user( + name="second_user", + email="second_user@prowler.com", + password="Test_password@1", + ) + second_tenant = tenants_fixture[1] + Membership.objects.create(user=second_user, tenant=second_tenant) + + # Create and assign role to second_user + second_role = Role.objects.create( + tenant_id=second_tenant.id, + name="Second Tenant Role", + unlimited_visibility=True, + manage_account=True, + ) + UserRoleRelationship.objects.create( + user=second_user, + role=second_role, + tenant_id=second_tenant.id, + ) + + # Create API key for second user in second tenant + second_key, raw_key = TenantAPIKey.objects.create_api_key( + name="Second Tenant Key", + tenant_id=second_tenant.id, + entity=second_user, + ) + + # First user's API key from first tenant + first_key = api_keys_fixture[0] + tenants_fixture[0] + + # Verify both keys are from different tenants + assert first_key.tenant_id != second_key.tenant_id + + # Each key should only access resources in its own tenant + # This is enforced by RLS at the database level + first_headers = get_api_key_header(first_key._raw_key) + second_headers = get_api_key_header(raw_key) + + # Both should work for their respective tenants + first_response = client.get(reverse("provider-list"), headers=first_headers) + assert first_response.status_code == 200 + + second_response = client.get(reverse("provider-list"), headers=second_headers) + assert second_response.status_code == 200 + + # Verify tenant context is correct in each response + # The responses should contain only data for their respective tenants + + def test_api_key_filters_by_tenant( + self, create_test_user, tenants_fixture, api_keys_fixture + ): + """List endpoint only shows keys for current tenant.""" + client = APIClient() + + # Create JWT token for first tenant + access_token, _ = get_api_tokens(client, create_test_user.email, TEST_PASSWORD) + jwt_headers = get_authorization_header(access_token) + + # List API keys + list_response = client.get(reverse("api-key-list"), headers=jwt_headers) + + assert list_response.status_code == 200 + keys_data = list_response.json()["data"] + + # Verify all returned keys belong to the current tenant + tenants_fixture[0].id + for key_data in keys_data: + # We can't directly see tenant_id in response, but all keys should be from fixtures + # which are created in first tenant + assert key_data["type"] == "api-keys" + + # Count should match the number of non-revoked keys in api_keys_fixture for this tenant + # api_keys_fixture creates 3 keys (1 normal, 1 with expiry, 1 revoked) + assert len(keys_data) == 3 + + def test_api_key_revoked_when_user_removed_from_tenant(self, tenants_fixture): + """When user membership is deleted, all user's API keys for that tenant are revoked.""" + client = APIClient() + tenant = tenants_fixture[0] + + # Create a fresh user for this test + test_user = User.objects.create_user( + name="test_membership_removal", + email="membership_removal@prowler.com", + password=TEST_PASSWORD, + ) + + # Create membership between user and tenant + Membership.objects.create( + user=test_user, + tenant=tenant, + role=Membership.RoleChoices.OWNER, + ) + + # Create role with manage_account permission + role = Role.objects.create( + tenant_id=tenant.id, + name="Membership Removal Role", + unlimited_visibility=True, + manage_account=True, + ) + + # Assign role to user + UserRoleRelationship.objects.create( + user=test_user, + role=role, + tenant_id=tenant.id, + ) + + # Create API key for this user in this tenant + api_key, raw_key = TenantAPIKey.objects.create_api_key( + name="Test Key for Membership Removal", + tenant_id=tenant.id, + entity=test_user, + ) + + # Verify API key works initially + api_key_headers = get_api_key_header(raw_key) + initial_response = client.get(reverse("provider-list"), headers=api_key_headers) + assert initial_response.status_code == 200 + + # Store API key ID for later verification + api_key_id = api_key.id + + # Remove user from tenant by deleting membership + Membership.objects.filter(user=test_user, tenant=tenant).delete() + + # Reload API key from database + api_key.refresh_from_db() + + # Verify API key still exists in database + assert TenantAPIKey.objects.filter(id=api_key_id).exists() + + # Verify API key is now revoked + assert api_key.revoked is True + + # Verify authentication with this API key now fails with 401 + auth_response = client.get(reverse("provider-list"), headers=api_key_headers) + assert auth_response.status_code == 401 + + # Verify error message indicates revocation + response_json = auth_response.json() + assert "errors" in response_json + error_detail = response_json["errors"][0]["detail"] + assert "revoked" in error_detail.lower() + + +@pytest.mark.django_db +class TestAPIKeyLifecycle: + def test_create_api_key(self, create_test_user_rbac, tenants_fixture): + """Create via POST with name and optional expiry.""" + client = APIClient() + + # Authenticate with JWT + access_token, _ = get_api_tokens( + client, create_test_user_rbac.email, TEST_PASSWORD + ) + jwt_headers = get_authorization_header(access_token) + + # Create API key without expiry + key_name = "Test Lifecycle Key" + create_response = client.post( + reverse("api-key-list"), + data={ + "data": { + "type": "api-keys", + "attributes": { + "name": key_name, + }, + } + }, + format="vnd.api+json", + headers=jwt_headers, + ) + + assert create_response.status_code == 201 + created_data = create_response.json()["data"] + + assert created_data["attributes"]["name"] == key_name + assert "api_key" in created_data["attributes"] + assert "prefix" in created_data["attributes"] + assert created_data["attributes"]["revoked"] is False + + # Create API key with expiry + future_expiry = (datetime.now(timezone.utc) + timedelta(days=90)).isoformat() + create_with_expiry_response = client.post( + reverse("api-key-list"), + data={ + "data": { + "type": "api-keys", + "attributes": { + "name": "Key with Expiry", + "expires_at": future_expiry, + }, + } + }, + format="vnd.api+json", + headers=jwt_headers, + ) + + assert create_with_expiry_response.status_code == 201 + expiry_data = create_with_expiry_response.json()["data"] + assert expiry_data["attributes"]["expires_at"] is not None + + def test_update_api_key_name_only( + self, create_test_user, tenants_fixture, api_keys_fixture + ): + """PATCH only allows name changes.""" + client = APIClient() + + # Authenticate with JWT + access_token, _ = get_api_tokens(client, create_test_user.email, TEST_PASSWORD) + jwt_headers = get_authorization_header(access_token) + + api_key = api_keys_fixture[0] + api_key.name + new_name = "Updated API Key Name" + + # Update name + update_response = client.patch( + reverse("api-key-detail", kwargs={"pk": api_key.id}), + data={ + "data": { + "type": "api-keys", + "id": str(api_key.id), + "attributes": { + "name": new_name, + }, + } + }, + format="vnd.api+json", + headers=jwt_headers, + ) + + assert update_response.status_code == 200 + updated_data = update_response.json()["data"] + assert updated_data["attributes"]["name"] == new_name + + # Verify name was actually updated in database + api_key.refresh_from_db() + assert api_key.name == new_name + + # Verify other fields remain unchanged + assert api_key.prefix == updated_data["attributes"]["prefix"] + assert api_key.revoked is False + + def test_delete_api_key(self, create_test_user, tenants_fixture, api_keys_fixture): + """DELETE revokes key (sets revoked=True).""" + client = APIClient() + + # Authenticate with JWT + access_token, _ = get_api_tokens(client, create_test_user.email, TEST_PASSWORD) + jwt_headers = get_authorization_header(access_token) + + api_key = api_keys_fixture[1] + api_key_id = api_key.id + + # Revoke API key using the revoke endpoint + revoke_response = client.delete( + reverse("api-key-revoke", kwargs={"pk": api_key_id}), + headers=jwt_headers, + ) + + assert revoke_response.status_code == 200 + + # Verify key still exists but is revoked + api_key.refresh_from_db() + assert api_key.revoked is True + + # Verify revoked key can no longer authenticate + api_key_headers = get_api_key_header(api_key._raw_key) + auth_response = client.get(reverse("provider-list"), headers=api_key_headers) + + assert auth_response.status_code == 401 + + def test_multiple_keys_per_user(self, create_test_user_rbac, tenants_fixture): + """User can have multiple active keys.""" + client = APIClient() + + # Authenticate with JWT + access_token, _ = get_api_tokens( + client, create_test_user_rbac.email, TEST_PASSWORD + ) + jwt_headers = get_authorization_header(access_token) + + # Create multiple API keys + key_names = ["Key One", "Key Two", "Key Three"] + created_keys = [] + + for name in key_names: + create_response = client.post( + reverse("api-key-list"), + data={ + "data": { + "type": "api-keys", + "attributes": { + "name": name, + }, + } + }, + format="vnd.api+json", + headers=jwt_headers, + ) + + assert create_response.status_code == 201 + created_keys.append(create_response.json()["data"]) + + # Verify all keys were created + assert len(created_keys) == 3 + + # List all keys and verify count + list_response = client.get(reverse("api-key-list"), headers=jwt_headers) + assert list_response.status_code == 200 + + # Should include the 3 new keys plus the ones from api_keys_fixture + keys_list = list_response.json()["data"] + assert len(keys_list) >= 3 + + # Verify each created key can authenticate independently + for key_data in created_keys: + full_key = key_data["attributes"]["api_key"] + api_key_headers = get_api_key_header(full_key) + auth_response = client.get( + reverse("provider-list"), headers=api_key_headers + ) + assert auth_response.status_code == 200 + + def test_api_key_becomes_invalid_when_user_deleted(self, tenants_fixture): + """When user is deleted, API key entity is set to None and authentication fails.""" + client = APIClient() + tenant = tenants_fixture[0] + + # Create a fresh user for this test to avoid affecting other tests + test_user = User.objects.create_user( + name="test_deletion_user", + email="deletion_test@prowler.com", + password=TEST_PASSWORD, + ) + Membership.objects.create( + user=test_user, + tenant=tenant, + role=Membership.RoleChoices.OWNER, + ) + + # Create role for the user + role = Role.objects.create( + tenant_id=tenant.id, + name="Deletion Test Role", + unlimited_visibility=True, + manage_account=True, + ) + UserRoleRelationship.objects.create( + user=test_user, + role=role, + tenant_id=tenant.id, + ) + + # Create API key for this user + api_key, raw_key = TenantAPIKey.objects.create_api_key( + name="Test Key for Deletion", + tenant_id=tenant.id, + entity=test_user, + ) + + # Verify the API key works initially + api_key_headers = get_api_key_header(raw_key) + initial_response = client.get(reverse("provider-list"), headers=api_key_headers) + assert initial_response.status_code == 200 + + # Store the API key ID for later verification + api_key_id = api_key.id + + # Delete the user + test_user.delete() + + # Reload the API key from database + api_key.refresh_from_db() + + # Verify the API key still exists in database (not cascade deleted) + assert TenantAPIKey.objects.filter(id=api_key_id).exists() + + # Verify entity field is now None (CASCADE behavior is SET_NULL) + assert api_key.entity is None + + # Verify authentication with this API key now fails + auth_response = client.get(reverse("provider-list"), headers=api_key_headers) + + # Must return 401 Unauthorized, not 500 Internal Server Error + assert auth_response.status_code == 401, ( + f"Expected 401 but got {auth_response.status_code}: " + f"{auth_response.json()}" + ) + + # Verify error message is present + response_json = auth_response.json() + assert "errors" in response_json + error_detail = response_json["errors"][0]["detail"] + # The error should indicate authentication failed due to invalid/orphaned key + assert ( + "API Key" in error_detail + or "Invalid" in error_detail + or "entity" in error_detail.lower() + ) + + +@pytest.mark.django_db +class TestCombinedAuthentication: + def test_jwt_takes_priority_over_api_key( + self, create_test_user, tenants_fixture, api_keys_fixture + ): + """When Bearer token present, JWT is used.""" + client = APIClient() + + # Get JWT token + access_token, _ = get_api_tokens(client, create_test_user.email, TEST_PASSWORD) + + # Create headers with both Bearer (JWT) and API key would conflict + # But we'll test that Bearer takes priority by setting Authorization to Bearer + jwt_headers = {"Authorization": f"Bearer {access_token}"} + + response = client.get(reverse("provider-list"), headers=jwt_headers) + + assert response.status_code == 200 + + # The authentication should have used JWT, not API key + # We can verify this worked as JWT authentication + + def test_api_key_header_format_validation( + self, create_test_user, tenants_fixture, api_keys_fixture + ): + """Verify Authorization: Api-Key format.""" + client = APIClient() + + api_key = api_keys_fixture[0] + + # Correct format + correct_headers = {"Authorization": f"Api-Key {api_key._raw_key}"} + correct_response = client.get(reverse("provider-list"), headers=correct_headers) + assert correct_response.status_code == 200 + + # Wrong format - using Bearer instead of Api-Key + wrong_format_headers = {"Authorization": f"Bearer {api_key._raw_key}"} + wrong_response = client.get( + reverse("provider-list"), headers=wrong_format_headers + ) + # Should fail because it tries to parse as JWT + assert wrong_response.status_code == 401 + + # Wrong format - missing Api-Key prefix + no_prefix_headers = {"Authorization": api_key._raw_key} + no_prefix_response = client.get( + reverse("provider-list"), headers=no_prefix_headers + ) + assert no_prefix_response.status_code == 401 + + def test_concurrent_api_key_usage( + self, create_test_user, tenants_fixture, api_keys_fixture + ): + """Same key can be used multiple times concurrently.""" + client = APIClient() + + api_key = api_keys_fixture[0] + api_key_headers = get_api_key_header(api_key._raw_key) + + # Make multiple concurrent requests with the same key + responses = [] + for _ in range(5): + response = client.get(reverse("provider-list"), headers=api_key_headers) + responses.append(response) + + # All requests should succeed + for response in responses: + assert response.status_code == 200 + + # Verify last_used_at was updated + api_key.refresh_from_db() + assert api_key.last_used_at is not None + + +def get_api_key_header(api_key: str) -> dict: + """Helper to create API key authorization header.""" + return {"Authorization": f"Api-Key {api_key}"} diff --git a/api/src/backend/api/tests/test_db_utils.py b/api/src/backend/api/tests/test_db_utils.py index f4b8bb88af..b4ec73e715 100644 --- a/api/src/backend/api/tests/test_db_utils.py +++ b/api/src/backend/api/tests/test_db_utils.py @@ -11,6 +11,7 @@ from api.db_utils import ( batch_delete, create_objects_in_batches, enum_to_choices, + generate_api_key_prefix, generate_random_token, one_week_from_now, update_objects_in_batches, @@ -313,3 +314,28 @@ class TestUpdateObjectsInBatches: qs = Provider.objects.filter(tenant=tenant, uid__endswith="_upd") assert qs.count() == total + + +class TestGenerateApiKeyPrefix: + def test_prefix_format(self): + """Test that generated prefix starts with 'pk_'.""" + prefix = generate_api_key_prefix() + assert prefix.startswith("pk_") + + def test_prefix_length(self): + """Test that prefix has correct length (pk_ + 8 random chars = 11).""" + prefix = generate_api_key_prefix() + assert len(prefix) == 11 + + def test_prefix_uniqueness(self): + """Test that multiple generations produce unique prefixes.""" + prefixes = {generate_api_key_prefix() for _ in range(100)} + assert len(prefixes) == 100 + + def test_prefix_character_set(self): + """Test that random part uses only allowed characters.""" + allowed_chars = "23456789ABCDEFGHJKMNPQRSTVWXYZ" + for _ in range(50): + prefix = generate_api_key_prefix() + random_part = prefix[3:] # Strip 'pk_' + assert all(char in allowed_chars for char in random_part) diff --git a/api/src/backend/api/tests/test_middleware.py b/api/src/backend/api/tests/test_middleware.py index 1bd8a07351..07165987de 100644 --- a/api/src/backend/api/tests/test_middleware.py +++ b/api/src/backend/api/tests/test_middleware.py @@ -24,6 +24,7 @@ def test_api_logging_middleware_logging(mock_logger): mock_extract_auth_info.return_value = { "user_id": "user123", "tenant_id": "tenant456", + "api_key_prefix": "pk_test", } with patch("api.middleware.logging.getLogger") as mock_get_logger: @@ -44,6 +45,7 @@ def test_api_logging_middleware_logging(mock_logger): expected_extra = { "user_id": "user123", "tenant_id": "tenant456", + "api_key_prefix": "pk_test", "method": "GET", "path": "/test-path", "query_params": {"param1": "value1", "param2": "value2"}, diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index aa2ac98820..ccfb1ff01d 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -12,8 +12,8 @@ from uuid import uuid4 import jwt import pytest -from allauth.socialaccount.models import SocialAccount, SocialApp from allauth.account.models import EmailAddress +from allauth.socialaccount.models import SocialAccount, SocialApp from botocore.exceptions import ClientError, NoCredentialsError from conftest import ( API_JSON_CONTENT_TYPE, @@ -48,6 +48,7 @@ from api.models import ( Scan, StateChoices, Task, + TenantAPIKey, User, UserRoleRelationship, ) @@ -7405,3 +7406,748 @@ class TestProcessorViewSet: content_type="application/vnd.api+json", ) assert response.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.django_db +class TestTenantApiKeyViewSet: + """Tests for TenantAPIKey endpoints.""" + + def test_api_keys_list(self, authenticated_client, api_keys_fixture): + """Test listing all API keys for the tenant.""" + response = authenticated_client.get(reverse("api-key-list")) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == len(api_keys_fixture) + # Verify keys are ordered by -created (newest first) + assert data[0]["attributes"]["name"] == TenantAPIKey.objects.first().name + + def test_api_keys_list_empty(self, authenticated_client, tenants_fixture): + """Test listing API keys when none exist returns empty list.""" + response = authenticated_client.get(reverse("api-key-list")) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 0 + assert isinstance(data, list) + + def test_api_keys_list_default_ordering( + self, authenticated_client, api_keys_fixture + ): + """Test that API keys are ordered by -created (newest first) by default.""" + response = authenticated_client.get(reverse("api-key-list")) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + + # Verify ordering by comparing inserted_at timestamps + # (newest should be first since ordering = ["-created"]) + if len(data) >= 2: + first_date = data[0]["attributes"]["inserted_at"] + second_date = data[1]["attributes"]["inserted_at"] + assert first_date >= second_date + + def test_api_keys_list_pagination_page_size( + self, authenticated_client, api_keys_fixture + ): + """Test pagination with custom page size.""" + page_size = 1 + response = authenticated_client.get( + reverse("api-key-list"), {"page[size]": page_size} + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == page_size + assert response.json()["meta"]["pagination"]["page"] == 1 + assert response.json()["meta"]["pagination"]["pages"] == 3 + + def test_api_keys_list_pagination_page_number( + self, authenticated_client, api_keys_fixture + ): + """Test pagination with specific page number.""" + page_size = 1 + page_number = 2 + response = authenticated_client.get( + reverse("api-key-list"), + {"page[size]": page_size, "page[number]": page_number}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == page_size + assert response.json()["meta"]["pagination"]["page"] == page_number + + def test_api_keys_list_pagination_invalid_page(self, authenticated_client): + """Test pagination with invalid page number returns 404.""" + response = authenticated_client.get( + reverse("api-key-list"), {"page[number]": 999} + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_api_keys_retrieve(self, authenticated_client, api_keys_fixture): + """Test retrieving a single API key by ID.""" + api_key = api_keys_fixture[0] + response = authenticated_client.get( + reverse("api-key-detail", kwargs={"pk": api_key.id}) + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert data["id"] == str(api_key.id) + assert data["attributes"]["name"] == api_key.name + assert data["attributes"]["prefix"] == api_key.prefix + assert data["attributes"]["revoked"] == api_key.revoked + assert "expires_at" in data["attributes"] + assert "inserted_at" in data["attributes"] + assert "last_used_at" in data["attributes"] + # Verify api_key field is NOT in response (only on creation) + assert "api_key" not in data["attributes"] + + def test_api_keys_retrieve_invalid(self, authenticated_client): + """Test retrieving non-existent API key returns 404.""" + response = authenticated_client.get( + reverse( + "api-key-detail", + kwargs={"pk": "f498b103-c760-4785-9a3e-e23fafbb7b02"}, + ) + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_api_keys_retrieve_field_mapping( + self, authenticated_client, api_keys_fixture + ): + """Test that field names are correctly mapped (expires_at, inserted_at).""" + api_key = api_keys_fixture[0] + response = authenticated_client.get( + reverse("api-key-detail", kwargs={"pk": api_key.id}) + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"]["attributes"] + + # Verify field mapping: expires_at -> expiry_date + assert "expires_at" in data + assert "expiry_date" not in data + + # Verify field mapping: inserted_at -> created + assert "inserted_at" in data + assert "created" not in data + + @pytest.mark.parametrize( + "api_key_payload", + ( + [ + {"name": "New API Key"}, + {"name": ""}, + {}, + ] + ), + ) + def test_api_keys_create_valid( + self, authenticated_client, create_test_user, api_key_payload + ): + data = { + "data": { + "type": "api-keys", + "attributes": api_key_payload, + } + } + response = authenticated_client.post( + reverse("api-key-list"), + data=json.dumps(data), + content_type="application/vnd.api+json", + ) + assert response.status_code == status.HTTP_201_CREATED + response_data = response.json()["data"] + assert "prefix" in response_data["attributes"] + assert "api_key" in response_data["attributes"] + assert response_data["attributes"]["api_key"] is not None + # Verify the raw API key is returned (only on creation) + assert ( + response_data["attributes"]["prefix"] + in response_data["attributes"]["api_key"] + ) + # Verify entity is set to current user + assert response_data["relationships"]["entity"]["data"]["id"] == str( + create_test_user.id + ) + + @pytest.mark.parametrize( + "api_key_payload, error_pointer", + ( + [ + ( + {"name": "Invalid Expiry", "expires_at": "not-a-date"}, + "expires_at", + ), + ] + ), + ) + def test_api_keys_create_invalid( + self, + authenticated_client, + create_test_user, + api_key_payload, + error_pointer, + ): + data = { + "data": { + "type": "api-keys", + "attributes": api_key_payload, + } + } + response = authenticated_client.post( + reverse("api-key-list"), + data=json.dumps(data), + content_type="application/vnd.api+json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "errors" in response.json() + assert ( + response.json()["errors"][0]["source"]["pointer"] + == f"/data/attributes/{error_pointer}" + ) + + def test_api_keys_create_multiple_unique_prefixes( + self, authenticated_client, api_keys_fixture + ): + """Test creating multiple API keys generates unique prefixes.""" + prefixes = set() + for i in range(3): + data = { + "data": { + "type": "api-keys", + "attributes": { + "name": f"Unique Key {i}", + }, + } + } + response = authenticated_client.post( + reverse("api-key-list"), + data=json.dumps(data), + content_type="application/vnd.api+json", + ) + assert response.status_code == status.HTTP_201_CREATED + prefix = response.json()["data"]["attributes"]["prefix"] + prefixes.add(prefix) + # Verify all prefixes are unique + assert len(prefixes) == 3 + + def test_api_keys_create_invalid_content_type( + self, authenticated_client, create_test_user + ): + """Test creating an API key with wrong content type returns 415.""" + data = {"name": "Test Key"} + response = authenticated_client.post( + reverse("api-key-list"), + data=data, + content_type="application/json", + ) + assert response.status_code == status.HTTP_415_UNSUPPORTED_MEDIA_TYPE + + def test_api_keys_create_malformed_json( + self, authenticated_client, create_test_user + ): + """Test creating an API key with malformed JSON returns 400.""" + response = authenticated_client.post( + reverse("api-key-list"), + data="not valid json", + content_type="application/vnd.api+json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_api_keys_create_invalid_structure( + self, authenticated_client, create_test_user + ): + """Test creating an API key with invalid JSON:API structure.""" + data = {"invalid": "structure"} + response = authenticated_client.post( + reverse("api-key-list"), + data=json.dumps(data), + content_type="application/vnd.api+json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "errors" in response.json() + + def test_api_keys_revoke(self, authenticated_client, api_keys_fixture): + """Test revoking an API key.""" + api_key = api_keys_fixture[0] # Not revoked + assert api_key.revoked is False + + response = authenticated_client.delete( + reverse("api-key-revoke", kwargs={"pk": api_key.id}) + ) + assert response.status_code == status.HTTP_200_OK + response_data = response.json()["data"] + assert response_data["attributes"]["revoked"] is True + + # Verify in database + api_key.refresh_from_db() + assert api_key.revoked is True + + def test_api_keys_revoke_already_revoked( + self, authenticated_client, api_keys_fixture + ): + """Test revoking an already revoked API key returns validation error.""" + api_key = api_keys_fixture[2] # Already revoked + api_key.refresh_from_db() + assert api_key.revoked is True + + response = authenticated_client.delete( + reverse("api-key-revoke", kwargs={"pk": api_key.id}) + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "already revoked" in response.json()["errors"][0]["detail"] + + def test_api_keys_revoke_nonexistent(self, authenticated_client): + """Test revoking non-existent API key returns 404.""" + response = authenticated_client.delete( + reverse( + "api-key-revoke", + kwargs={"pk": "f498b103-c760-4785-9a3e-e23fafbb7b02"}, + ) + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_api_keys_destroy_not_allowed(self, authenticated_client, api_keys_fixture): + """Test that DELETE (destroy) endpoint is disabled.""" + api_key = api_keys_fixture[0] + response = authenticated_client.delete( + reverse("api-key-detail", kwargs={"pk": api_key.id}) + ) + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED + + def test_api_keys_put_not_allowed(self, authenticated_client, api_keys_fixture): + """Test that PUT is not allowed.""" + api_key = api_keys_fixture[0] + data = { + "data": { + "type": "api-keys", + "id": str(api_key.id), + "attributes": { + "name": "Updated Name", + }, + } + } + response = authenticated_client.put( + reverse("api-key-detail", kwargs={"pk": api_key.id}), + data=json.dumps(data), + content_type="application/vnd.api+json", + ) + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED + + @pytest.mark.parametrize( + "filter_name, filter_value, expected_min_count", + ( + [ + ("name", "Test API Key 1", 1), + ("name__icontains", "test", 2), + ("revoked", "true", 1), + ("revoked", "false", 2), + ("inserted_at", TODAY, 1), + ("inserted_at__gte", "2024-01-01", 3), + ("inserted_at__lte", "2099-12-31", 3), + ("expires_at__gte", today_after_n_days(50), 1), + ] + ), + ) + def test_api_keys_filters( + self, + authenticated_client, + api_keys_fixture, + filter_name, + filter_value, + expected_min_count, + ): + response = authenticated_client.get( + reverse("api-key-list"), + {f"filter[{filter_name}]": filter_value}, + ) + + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) >= expected_min_count + + def test_api_keys_filter_combined(self, authenticated_client, api_keys_fixture): + """Test combining multiple filters.""" + response = authenticated_client.get( + reverse("api-key-list"), + { + "filter[revoked]": "false", + "filter[name__icontains]": "test", + }, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert all(item["attributes"]["revoked"] is False for item in data) + assert all("test" in item["attributes"]["name"].lower() for item in data) + + @pytest.mark.parametrize( + "filter_name", + ( + [ + "invalid_field", + "nonexistent", + ] + ), + ) + def test_api_keys_filters_invalid(self, authenticated_client, filter_name): + response = authenticated_client.get( + reverse("api-key-list"), + {f"filter[{filter_name}]": "whatever"}, + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_api_keys_filter_invalid_date_format(self, authenticated_client): + """Test filtering with invalid date format returns 400.""" + response = authenticated_client.get( + reverse("api-key-list"), + {"filter[inserted_at]": "not-a-date"}, + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_api_keys_filter_empty_result(self, authenticated_client, api_keys_fixture): + """Test filter that returns no results.""" + response = authenticated_client.get( + reverse("api-key-list"), + {"filter[name]": "NonExistent Key Name"}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 0 + assert isinstance(data, list) + + @pytest.mark.parametrize( + "sort_field", + ( + [ + "name", + "prefix", + "revoked", + "inserted_at", + "expires_at", + "-name", + "-inserted_at", + ] + ), + ) + def test_api_keys_sort(self, authenticated_client, api_keys_fixture, sort_field): + response = authenticated_client.get( + reverse("api-key-list"), {"sort": sort_field} + ) + assert response.status_code == status.HTTP_200_OK + + def test_api_keys_sort_invalid(self, authenticated_client): + """Test invalid sort parameter returns 400.""" + response = authenticated_client.get( + reverse("api-key-list"), + {"sort": "invalid_field"}, + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_api_keys_rbac_manage_account_required( + self, authenticated_client_rbac_manage_users_only, api_keys_fixture + ): + """Test that users without MANAGE_ACCOUNT permission are denied.""" + response = authenticated_client_rbac_manage_users_only.get( + reverse("api-key-list") + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_api_keys_rbac_manage_account_allowed( + self, authenticated_client_rbac_manage_account, tenants_fixture + ): + """Test that users with MANAGE_ACCOUNT permission can access API keys.""" + response = authenticated_client_rbac_manage_account.get(reverse("api-key-list")) + assert response.status_code == status.HTTP_200_OK + + def test_api_keys_rbac_create_requires_permission( + self, authenticated_client_rbac_manage_users_only + ): + """Test that creating API keys requires MANAGE_ACCOUNT permission.""" + data = { + "data": { + "type": "api-keys", + "attributes": { + "name": "Test Key", + }, + } + } + response = authenticated_client_rbac_manage_users_only.post( + reverse("api-key-list"), + data=json.dumps(data), + content_type="application/vnd.api+json", + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_api_keys_rbac_revoke_requires_permission( + self, authenticated_client_rbac_manage_users_only, api_keys_fixture + ): + """Test that revoking API keys requires MANAGE_ACCOUNT permission.""" + api_key = api_keys_fixture[0] + response = authenticated_client_rbac_manage_users_only.delete( + reverse("api-key-revoke", kwargs={"pk": api_key.id}) + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_api_keys_tenant_isolation( + self, authenticated_client, api_keys_fixture, tenants_fixture + ): + """Test that API keys are isolated by tenant (RLS enforcement).""" + # Create a second tenant with different user + + tenant2 = Tenant.objects.create(name="Another Tenant") + user2 = User.objects.create_user( + name="Another User", + email="another@example.com", + password=TEST_PASSWORD, + ) + Membership.objects.create( + user=user2, + tenant=tenant2, + role=Membership.RoleChoices.OWNER, + ) + + # Create API key for tenant2 + TenantAPIKey.objects.create_api_key( + name="Tenant 2 Key", + tenant_id=tenant2.id, + entity=user2, + ) + + # Authenticate as user from tenant 1 + response = authenticated_client.get(reverse("api-key-list")) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + + # Should only see keys from tenant 1 + assert len(data) == len(api_keys_fixture) + assert all(item["attributes"]["name"] != "Tenant 2 Key" for item in data) + + def test_api_keys_tenant_isolation_retrieve( + self, authenticated_client, tenants_fixture + ): + """Test that retrieving API key from another tenant returns 404.""" + # Create a second tenant with API key + tenant2 = Tenant.objects.create(name="Another Tenant") + user2 = User.objects.create_user( + name="Another User", + email="another2@example.com", + password=TEST_PASSWORD, + ) + Membership.objects.create( + user=user2, + tenant=tenant2, + role=Membership.RoleChoices.OWNER, + ) + + api_key2, _ = TenantAPIKey.objects.create_api_key( + name="Tenant 2 Key", + tenant_id=tenant2.id, + entity=user2, + ) + + # Try to retrieve tenant2's API key as tenant1 user + response = authenticated_client.get( + reverse("api-key-detail", kwargs={"pk": api_key2.id}) + ) + # Should return 404 due to RLS filtering + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_api_keys_tenant_isolation_revoke( + self, authenticated_client, tenants_fixture + ): + """Test that revoking API key from another tenant returns 404.""" + # Create a second tenant with API key + tenant2 = Tenant.objects.create(name="Another Tenant") + user2 = User.objects.create_user( + name="Another User", + email="another3@example.com", + password=TEST_PASSWORD, + ) + Membership.objects.create( + user=user2, + tenant=tenant2, + role=Membership.RoleChoices.OWNER, + ) + + api_key2, _ = TenantAPIKey.objects.create_api_key( + name="Tenant 2 Key", + tenant_id=tenant2.id, + entity=user2, + ) + + # Try to revoke tenant2's API key as tenant1 user + response = authenticated_client.delete( + reverse("api-key-revoke", kwargs={"pk": api_key2.id}) + ) + # Should return 404 due to RLS filtering + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_api_keys_read_only_fields_on_create( + self, authenticated_client, create_test_user + ): + """Test that read-only fields are ignored during creation.""" + # Note: Fields not in serializer (like 'prefix', 'revoked') will cause 400 + # So we only test that the response has correct read-only values + data = { + "data": { + "type": "api-keys", + "attributes": { + "name": "Test Read-Only", + }, + } + } + response = authenticated_client.post( + reverse("api-key-list"), + data=json.dumps(data), + content_type="application/vnd.api+json", + ) + assert response.status_code == status.HTTP_201_CREATED + response_data = response.json()["data"] + + # Verify read-only fields have correct default/auto-generated values + # Prefix should be auto-generated (not empty, not None) + assert response_data["attributes"]["prefix"] is not None + assert len(response_data["attributes"]["prefix"]) > 0 + + # Revoked should be False (default) + assert response_data["attributes"]["revoked"] is False + + # Entity should be set to current user (auto-assigned) + assert response_data["relationships"]["entity"]["data"]["id"] == str( + create_test_user.id + ) + + def test_api_keys_entity_relationship_included( + self, authenticated_client, api_keys_fixture + ): + """Test that entity (user) relationship is included correctly.""" + api_key = api_keys_fixture[0] + response = authenticated_client.get( + reverse("api-key-detail", kwargs={"pk": api_key.id}) + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert "entity" in data["relationships"] + assert data["relationships"]["entity"]["data"]["type"] == "users" + assert data["relationships"]["entity"]["data"]["id"] == str(api_key.entity.id) + + def test_api_keys_entity_auto_assigned_on_create( + self, authenticated_client, create_test_user + ): + """Test that entity is automatically assigned to current user on creation.""" + data = { + "data": { + "type": "api-keys", + "attributes": { + "name": "Auto Entity Key", + }, + } + } + response = authenticated_client.post( + reverse("api-key-list"), + data=json.dumps(data), + content_type="application/vnd.api+json", + ) + assert response.status_code == status.HTTP_201_CREATED + response_data = response.json()["data"] + + # Entity should be set to authenticated user + assert response_data["relationships"]["entity"]["data"]["id"] == str( + create_test_user.id + ) + + # Verify in database + api_key_id = response_data["id"] + api_key = TenantAPIKey.objects.get(id=api_key_id) + assert api_key.entity.id == create_test_user.id + + def test_api_keys_list_response_structure( + self, authenticated_client, api_keys_fixture + ): + """Test that list response follows JSON:API structure.""" + response = authenticated_client.get(reverse("api-key-list")) + assert response.status_code == status.HTTP_200_OK + response_data = response.json() + + # Verify top-level structure + assert "data" in response_data + assert "meta" in response_data + assert isinstance(response_data["data"], list) + + # Verify pagination meta + assert "pagination" in response_data["meta"] + assert "count" in response_data["meta"]["pagination"] + assert "page" in response_data["meta"]["pagination"] + assert "pages" in response_data["meta"]["pagination"] + + def test_api_keys_retrieve_response_structure( + self, authenticated_client, api_keys_fixture + ): + """Test that retrieve response follows JSON:API structure.""" + api_key = api_keys_fixture[0] + response = authenticated_client.get( + reverse("api-key-detail", kwargs={"pk": api_key.id}) + ) + assert response.status_code == status.HTTP_200_OK + response_data = response.json() + + # Verify top-level structure + assert "data" in response_data + data = response_data["data"] + + # Verify resource object structure + assert "type" in data + assert data["type"] == "api-keys" + assert "id" in data + assert "attributes" in data + assert "relationships" in data + + def test_api_keys_create_response_structure( + self, authenticated_client, create_test_user + ): + """Test that create response follows JSON:API structure.""" + data = { + "data": { + "type": "api-keys", + "attributes": { + "name": "Structure Test Key", + }, + } + } + response = authenticated_client.post( + reverse("api-key-list"), + data=json.dumps(data), + content_type="application/vnd.api+json", + ) + assert response.status_code == status.HTTP_201_CREATED + response_data = response.json() + + # Verify top-level structure + assert "data" in response_data + data = response_data["data"] + + # Verify resource object structure + assert "type" in data + assert data["type"] == "api-keys" + assert "id" in data + assert "attributes" in data + assert "relationships" in data + + # Verify api_key is included in creation response only + assert "api_key" in data["attributes"] + assert data["attributes"]["api_key"] is not None + + def test_api_keys_error_response_structure(self, authenticated_client): + """Test that error responses follow JSON:API structure.""" + response = authenticated_client.get( + reverse( + "api-key-detail", + kwargs={"pk": "f498b103-c760-4785-9a3e-e23fafbb7b02"}, + ) + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + response_data = response.json() + + # Verify error structure + assert "errors" in response_data + assert isinstance(response_data["errors"], list) + assert len(response_data["errors"]) > 0 + + # Verify error object structure + error = response_data["errors"][0] + assert "detail" in error or "title" in error diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py index 640504564c..533dc1c06d 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -39,6 +39,7 @@ from api.models import ( StateChoices, StatusChoices, Task, + TenantAPIKey, User, UserRoleRelationship, ) @@ -2735,3 +2736,103 @@ class LighthouseConfigUpdateSerializer(BaseWriteSerializer): instance.api_key_decoded = api_key instance.save() return instance + + +# API Keys + + +class TenantApiKeySerializer(RLSSerializer): + """ + Serializer for the TenantApiKey model. + """ + + # Map database field names to API field names for consistency + expires_at = serializers.DateTimeField(source="expiry_date", read_only=True) + inserted_at = serializers.DateTimeField(source="created", read_only=True) + + class Meta: + model = TenantAPIKey + fields = [ + "id", + "name", + "prefix", + "expires_at", + "revoked", + "inserted_at", + "last_used_at", + "entity", + ] + + +class TenantApiKeyCreateSerializer(RLSSerializer, BaseWriteSerializer): + """Serializer for creating new API keys.""" + + # Map database field names to API field names for consistency + expires_at = serializers.DateTimeField(source="expiry_date", required=False) + inserted_at = serializers.DateTimeField(source="created", read_only=True) + api_key = serializers.SerializerMethodField() + + class Meta: + model = TenantAPIKey + fields = [ + "id", + "name", + "prefix", + "expires_at", + "revoked", + "entity", + "inserted_at", + "last_used_at", + "api_key", + ] + extra_kwargs = { + "id": {"read_only": True}, + "prefix": {"read_only": True}, + "revoked": {"read_only": True}, + "entity": {"read_only": True}, + "inserted_at": {"read_only": True}, + "last_used_at": {"read_only": True}, + "api_key": {"read_only": True}, + } + + def get_api_key(self, obj): + """Return the raw API key if it was stored during creation.""" + return getattr(obj, "_raw_api_key", None) + + def create(self, validated_data): + instance, raw_api_key = TenantAPIKey.objects.create_api_key( + **validated_data, + tenant_id=self.context.get("tenant_id"), + entity=self.context.get("request").user, + ) + # Store the raw API key temporarily on the instance for the serializer + instance._raw_api_key = raw_api_key + return instance + + +class TenantApiKeyUpdateSerializer(RLSSerializer, BaseWriteSerializer): + """Serializer for updating API keys - only allows changing the name.""" + + # Map database field names to API field names for consistency + expires_at = serializers.DateTimeField(source="expiry_date", read_only=True) + inserted_at = serializers.DateTimeField(source="created", read_only=True) + + class Meta: + model = TenantAPIKey + fields = [ + "id", + "name", + "prefix", + "expires_at", + "entity", + "inserted_at", + "last_used_at", + ] + extra_kwargs = { + "id": {"read_only": True}, + "prefix": {"read_only": True}, + "entity": {"read_only": True}, + "expires_at": {"read_only": True}, + "inserted_at": {"read_only": True}, + "last_used_at": {"read_only": True}, + } diff --git a/api/src/backend/api/v1/urls.py b/api/src/backend/api/v1/urls.py index a64791505d..1e8694ef23 100644 --- a/api/src/backend/api/v1/urls.py +++ b/api/src/backend/api/v1/urls.py @@ -39,6 +39,7 @@ from api.v1.views import ( TenantViewSet, UserRoleRelationshipView, UserViewSet, + TenantApiKeyViewSet, ) router = routers.DefaultRouter(trailing_slash=False) @@ -65,6 +66,7 @@ router.register( LighthouseConfigViewSet, basename="lighthouseconfiguration", ) +router.register(r"api-keys", TenantApiKeyViewSet, basename="api-key") tenants_router = routers.NestedSimpleRouter(router, r"tenants", lookup="tenant") tenants_router.register( diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index f9d16e044e..8097ee2321 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -95,6 +95,7 @@ from api.filters import ( ScanSummarySeverityFilter, ServiceOverviewFilter, TaskFilter, + TenantApiKeyFilter, TenantFilter, UserFilter, ) @@ -124,6 +125,7 @@ from api.models import ( SeverityChoices, StateChoices, Task, + TenantAPIKey, User, UserRoleRelationship, ) @@ -189,6 +191,9 @@ from api.v1.serializers import ( ScanUpdateSerializer, ScheduleDailyCreateSerializer, TaskSerializer, + TenantApiKeyCreateSerializer, + TenantApiKeySerializer, + TenantApiKeyUpdateSerializer, TenantSerializer, TokenRefreshSerializer, TokenSerializer, @@ -387,6 +392,11 @@ class SchemaView(SpectacularAPIView): "description": "Endpoints for Single Sign-On authentication management via SAML for seamless user " "authentication.", }, + { + "name": "API Keys", + "description": "Endpoints for API keys management. These can be used as an alternative to JWT " + "authorization.", + }, ] return super().get(request, *args, **kwargs) @@ -4190,3 +4200,84 @@ class ProcessorViewSet(BaseRLSViewSet): elif self.action == "partial_update": return ProcessorUpdateSerializer return super().get_serializer_class() + + +@extend_schema_view( + list=extend_schema( + tags=["API Keys"], + summary="List API keys", + description="Retrieve a list of API keys for the tenant, with filtering support.", + ), + retrieve=extend_schema( + tags=["API Keys"], + summary="Retrieve API key details", + description="Fetch detailed information about a specific API key by its ID.", + ), + create=extend_schema( + tags=["API Keys"], + summary="Create a new API key", + description="Create a new API key for the tenant.", + ), + partial_update=extend_schema( + tags=["API Keys"], + summary="Partially update an API key", + description="Modify certain fields of an existing API key without affecting other settings.", + ), + revoke=extend_schema( + tags=["API Keys"], + summary="Revoke an API key", + description="Revoke an API key by its ID. This action is irreversible and will prevent the key from being " + "used.", + request=None, + responses={ + 200: OpenApiResponse( + response=TenantApiKeySerializer, + description="API key was successfully revoked", + ) + }, + ), +) +class TenantApiKeyViewSet(BaseRLSViewSet): + queryset = TenantAPIKey.objects.all() + serializer_class = TenantApiKeySerializer + filterset_class = TenantApiKeyFilter + http_method_names = ["get", "post", "patch", "delete"] + ordering = ["revoked", "-created"] + ordering_fields = ["name", "prefix", "revoked", "inserted_at", "expires_at"] + # RBAC required permissions + required_permissions = [Permissions.MANAGE_ACCOUNT] + + def get_queryset(self): + queryset = TenantAPIKey.objects.filter( + tenant_id=self.request.tenant_id + ).annotate(inserted_at=F("created"), expires_at=F("expiry_date")) + return queryset + + def get_serializer_class(self): + if self.action == "create": + return TenantApiKeyCreateSerializer + elif self.action == "partial_update": + return TenantApiKeyUpdateSerializer + return super().get_serializer_class() + + @extend_schema(exclude=True) + def destroy(self, request, *args, **kwargs): + raise MethodNotAllowed(method="DESTROY") + + @action(detail=True, methods=["delete"]) + def revoke(self, request, *args, **kwargs): + instance = self.get_object() + + # Check if already revoked + if instance.revoked: + raise ValidationError( + { + "detail": "API key is already revoked", + } + ) + + TenantAPIKey.objects.revoke_api_key(instance.pk) + instance.refresh_from_db() + + serializer = self.get_serializer(instance) + return Response(data=serializer.data, status=status.HTTP_200_OK) diff --git a/api/src/backend/config/custom_logging.py b/api/src/backend/config/custom_logging.py index 83bc94f023..fb04930679 100644 --- a/api/src/backend/config/custom_logging.py +++ b/api/src/backend/config/custom_logging.py @@ -48,6 +48,10 @@ class NDJSONFormatter(logging.Formatter): log_record["user_id"] = record.user_id if hasattr(record, "tenant_id"): log_record["tenant_id"] = record.tenant_id + if hasattr(record, "api_key_prefix"): + log_record["api_key_prefix"] = ( + record.api_key_prefix if record.api_key_prefix != "N/A" else None + ) if hasattr(record, "method"): log_record["method"] = record.method if hasattr(record, "path"): @@ -90,6 +94,9 @@ class HumanReadableFormatter(logging.Formatter): # Add REST API extra fields if hasattr(record, "user_id"): log_components.append(f"({record.user_id})") + if hasattr(record, "api_key_prefix"): + if record.api_key_prefix != "N/A": + log_components.append(f"(API-Key {record.api_key_prefix})") if hasattr(record, "tenant_id"): log_components.append(f"[{record.tenant_id}]") if hasattr(record, "method"): diff --git a/api/src/backend/config/django/base.py b/api/src/backend/config/django/base.py index 601c68c49c..80b96952d7 100644 --- a/api/src/backend/config/django/base.py +++ b/api/src/backend/config/django/base.py @@ -43,6 +43,7 @@ INSTALLED_APPS = [ "allauth.socialaccount.providers.saml", "dj_rest_auth.registration", "rest_framework.authtoken", + "drf_simple_apikey", ] MIDDLEWARE = [ @@ -84,7 +85,7 @@ TEMPLATES = [ REST_FRAMEWORK = { "DEFAULT_SCHEMA_CLASS": "drf_spectacular_jsonapi.schemas.openapi.JsonApiAutoSchema", "DEFAULT_AUTHENTICATION_CLASSES": ( - "rest_framework_simplejwt.authentication.JWTAuthentication", + "api.authentication.CombinedJWTOrAPIKeyAuthentication", ), "PAGE_SIZE": 10, "EXCEPTION_HANDLER": "api.exceptions.custom_exception_handler", @@ -220,7 +221,8 @@ SIMPLE_JWT = { "JTI_CLAIM": "jti", "USER_ID_FIELD": "id", "USER_ID_CLAIM": "sub", - # Issuer and Audience claims, for the moment we will keep these values as default values, they may change in the future. + # Issuer and Audience claims, for the moment we will keep these values as default values, they may change in the + # future. "AUDIENCE": env.str("DJANGO_JWT_AUDIENCE", "https://api.prowler.com"), "ISSUER": env.str("DJANGO_JWT_ISSUER", "https://api.prowler.com"), # Additional security settings @@ -229,6 +231,13 @@ SIMPLE_JWT = { SECRETS_ENCRYPTION_KEY = env.str("DJANGO_SECRETS_ENCRYPTION_KEY", "") +# DRF Simple API Key settings +DRF_API_KEY = { + "FERNET_SECRET": SECRETS_ENCRYPTION_KEY, + "API_KEY_LIFETIME": 365, + "AUTHENTICATION_KEYWORD_HEADER": "Api-Key", +} + # Internationalization # https://docs.djangoproject.com/en/5.0/topics/i18n/ diff --git a/api/src/backend/config/django/testing.py b/api/src/backend/config/django/testing.py index d7f1f941ff..5289f067fa 100644 --- a/api/src/backend/config/django/testing.py +++ b/api/src/backend/config/django/testing.py @@ -20,6 +20,13 @@ DATABASE_ROUTERS = [] TESTING = True SECRETS_ENCRYPTION_KEY = "ZMiYVo7m4Fbe2eXXPyrwxdJss2WSalXSv3xHBcJkPl0=" +# DRF Simple API Key settings +DRF_API_KEY = { + "FERNET_SECRET": SECRETS_ENCRYPTION_KEY, + "API_KEY_LIFETIME": 365, + "AUTHENTICATION_KEYWORD_HEADER": "Api-Key", +} + # JWT SIMPLE_JWT["ALGORITHM"] = "HS256" # noqa: F405 diff --git a/api/src/backend/conftest.py b/api/src/backend/conftest.py index 025c894107..5d1acf1e88 100644 --- a/api/src/backend/conftest.py +++ b/api/src/backend/conftest.py @@ -38,6 +38,7 @@ from api.models import ( StateChoices, StatusChoices, Task, + TenantAPIKey, User, UserRoleRelationship, ) @@ -1368,6 +1369,56 @@ def saml_sociallogin(users_fixture): return sociallogin +@pytest.fixture +def api_keys_fixture(tenants_fixture, create_test_user): + """Create test API keys for testing.""" + tenant = tenants_fixture[0] + user = create_test_user + + # Create and assign role to user for API key authentication + role = Role.objects.create( + tenant_id=tenant.id, + name="Test API Key Role", + unlimited_visibility=True, + manage_account=True, + ) + UserRoleRelationship.objects.create( + user=user, + role=role, + tenant_id=tenant.id, + ) + + # Create API keys with different states + api_key1, raw_key1 = TenantAPIKey.objects.create_api_key( + name="Test API Key 1", + tenant_id=tenant.id, + entity=user, + ) + + api_key2, raw_key2 = TenantAPIKey.objects.create_api_key( + name="Test API Key 2", + tenant_id=tenant.id, + entity=user, + expiry_date=datetime.now(timezone.utc) + timedelta(days=60), + ) + + # Revoked API key + api_key3, raw_key3 = TenantAPIKey.objects.create_api_key( + name="Revoked API Key", + tenant_id=tenant.id, + entity=user, + ) + api_key3.revoked = True + api_key3.save() + + # Store raw keys on instances for testing + api_key1._raw_key = raw_key1 + api_key2._raw_key = raw_key2 + api_key3._raw_key = raw_key3 + + return [api_key1, api_key2, api_key3] + + def get_authorization_header(access_token: str) -> dict: return {"Authorization": f"Bearer {access_token}"} From 09b5afe9c36bbe45285cc54557e10821302530a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20De=20la=20Torre=20Vico?= Date: Fri, 3 Oct 2025 13:48:55 +0200 Subject: [PATCH 05/32] chore(aws): enhance metadata for `awslambda` service (#8825) Co-authored-by: Daniel Barranquero <74871504+danibarranqueroo@users.noreply.github.com> Co-authored-by: Daniel Barranquero --- prowler/CHANGELOG.md | 2 +- ...wslambda_function_inside_vpc.metadata.json | 39 ++++++++++++------- ...s_cloudtrail_logging_enabled.metadata.json | 33 +++++++++------- ..._function_no_secrets_in_code.metadata.json | 27 ++++++++----- ...tion_no_secrets_in_variables.metadata.json | 32 +++++++++------ ...tion_not_publicly_accessible.metadata.json | 33 ++++++++++------ ...bda_function_url_cors_policy.metadata.json | 37 +++++++++++------- ...wslambda_function_url_public.metadata.json | 34 ++++++++++------ ...ion_using_supported_runtimes.metadata.json | 37 +++++++++++------- ...lambda_function_vpc_multi_az.metadata.json | 36 ++++++++++------- 10 files changed, 199 insertions(+), 111 deletions(-) diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 7b83df9abf..07884e6cae 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -26,7 +26,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - Update `moto` dependency from 5.0.28 to 5.1.11 [(#7100)](https://github.com/prowler-cloud/prowler/pull/7100) - Update AWS API Gateway service metadata to new format [(#8788)](https://github.com/prowler-cloud/prowler/pull/8788) - Update AWS Athena service metadata to new format [(#8790)](https://github.com/prowler-cloud/prowler/pull/8790) - +- Update AWS Lambda service metadata to new format [(#8825)](https://github.com/prowler-cloud/prowler/pull/8825) ### Fixed - Fix SNS topics showing empty AWS_ResourceID in Quick Inventory output [(#8762)](https://github.com/prowler-cloud/prowler/issues/8762) diff --git a/prowler/providers/aws/services/awslambda/awslambda_function_inside_vpc/awslambda_function_inside_vpc.metadata.json b/prowler/providers/aws/services/awslambda/awslambda_function_inside_vpc/awslambda_function_inside_vpc.metadata.json index b25df32ee0..b63f42f4d8 100644 --- a/prowler/providers/aws/services/awslambda/awslambda_function_inside_vpc/awslambda_function_inside_vpc.metadata.json +++ b/prowler/providers/aws/services/awslambda/awslambda_function_inside_vpc/awslambda_function_inside_vpc.metadata.json @@ -1,29 +1,42 @@ { "Provider": "aws", "CheckID": "awslambda_function_inside_vpc", - "CheckTitle": "Ensure AWS Lambda Functions Are Deployed Inside a VPC", - "CheckType": [], + "CheckTitle": "Lambda function is deployed inside a VPC", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices" + ], "ServiceName": "awslambda", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:lambda:region:account-id:function/function-name", + "ResourceIdTemplate": "", "Severity": "low", "ResourceType": "AwsLambdaFunction", - "Description": "This check verifies whether an AWS Lambda function is deployed within a Virtual Private Cloud (VPC). Deploying Lambda functions inside a VPC improves security by allowing control over the network environment, reducing the exposure to public internet threats.", - "Risk": "Lambda functions not deployed in a VPC may expose your application to increased security risks, including unauthorized access and data breaches. Without the network isolation provided by a VPC, your Lambda functions are more vulnerable to attacks.", - "RelatedUrl": "https://docs.aws.amazon.com/lambda/latest/dg/configuration-vpc.html", + "Description": "**AWS Lambda function** uses **VPC networking** with specified subnets and security groups, rather than the default Lambda-managed network.\n\nPresence of a VPC association (`vpc_id`) indicates private connectivity to VPC resources.", + "Risk": "Without VPC attachment, functions lack network isolation and granular egress control, weakening **confidentiality** and **integrity**.\n\nTraffic must use public endpoints, raising risks of data exfiltration and SSRF via unrestricted outbound. If private databases are required, missing VPC access can impact **availability**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/lambda/latest/dg/configuration-vpc.html", + "https://repost.aws/pt/knowledge-center/lambda-dedicated-vpc", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/Lambda/function-in-vpc.html", + "https://docs.aws.amazon.com/securityhub/latest/userguide/lambda-controls.html#lambda-3", + "https://stackoverflow.com/questions/55074793/how-can-we-force-aws-lamda-to-run-securely-in-a-vpc", + "https://www.techtarget.com/searchCloudComputing/answer/How-do-I-configure-AWS-Lambda-functions-in-a-VPC/" + ], "Remediation": { "Code": { - "CLI": "aws lambda update-function-configuration --region --function-name --vpc-config SubnetIds=,,SecurityGroupIds=", - "NativeIaC": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/Lambda/function-in-vpc.html", - "Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/lambda-controls.html#lambda-3", - "Terraform": "https://docs.prowler.com/checks/aws/general-policies/ensure-that-aws-lambda-function-is-configured-inside-a-vpc-1/" + "CLI": "aws lambda update-function-configuration --function-name --vpc-config SubnetIds=,SecurityGroupIds=", + "NativeIaC": "```yaml\nAWSTemplateFormatVersion: '2010-09-09'\nResources:\n LambdaFunction:\n Type: AWS::Lambda::Function\n Properties:\n FunctionName: \n Role: \n Handler: index.handler\n Runtime: python3.12\n Code:\n S3Bucket: \n S3Key: \n # Critical: Attach the function to a VPC by specifying at least one subnet and one security group\n # This sets VpcConfig, which gives the function a VPC ID and makes the check PASS\n VpcConfig:\n SubnetIds:\n - \n SecurityGroupIds:\n - \n```", + "Other": "1. In the AWS Lambda console, open your function\n2. Go to Configuration > VPC and click Edit\n3. Select the target VPC\n4. Choose at least one Subnet and one Security group\n5. Click Save", + "Terraform": "```hcl\nresource \"aws_lambda_function\" \"example\" {\n function_name = \"\"\n role = \"\"\n handler = \"index.handler\"\n runtime = \"python3.12\"\n filename = \"\"\n\n # Critical: VPC config attaches the function to a VPC, providing a VPC ID so the check passes\n vpc_config {\n subnet_ids = [\"\"] # at least one subnet\n security_group_ids = [\"\"]\n }\n}\n```" }, "Recommendation": { - "Text": "Configure your AWS Lambda functions to operate within a Virtual Private Cloud (VPC) to enhance security and control network access.", - "Url": "" + "Text": "Attach functions to a VPC with private subnets and restrictive security groups to enforce **least privilege** and egress control.\n- Prefer **VPC endpoints** for AWS services\n- Use NAT only when necessary\n- Spread subnets across AZs for resilience\n- Govern with IAM conditions requiring `VpcIds`, `SubnetIds`, and `SecurityGroupIds`.", + "Url": "https://hub.prowler.com/check/awslambda_function_inside_vpc" } }, - "Categories": [], + "Categories": [ + "trust-boundaries" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/aws/services/awslambda/awslambda_function_invoke_api_operations_cloudtrail_logging_enabled/awslambda_function_invoke_api_operations_cloudtrail_logging_enabled.metadata.json b/prowler/providers/aws/services/awslambda/awslambda_function_invoke_api_operations_cloudtrail_logging_enabled/awslambda_function_invoke_api_operations_cloudtrail_logging_enabled.metadata.json index 4354b042c3..d08ec8262a 100644 --- a/prowler/providers/aws/services/awslambda/awslambda_function_invoke_api_operations_cloudtrail_logging_enabled/awslambda_function_invoke_api_operations_cloudtrail_logging_enabled.metadata.json +++ b/prowler/providers/aws/services/awslambda/awslambda_function_invoke_api_operations_cloudtrail_logging_enabled/awslambda_function_invoke_api_operations_cloudtrail_logging_enabled.metadata.json @@ -1,30 +1,37 @@ { "Provider": "aws", "CheckID": "awslambda_function_invoke_api_operations_cloudtrail_logging_enabled", - "CheckTitle": "Check if Lambda functions invoke API operations are being recorded by CloudTrail.", - "CheckType": [], + "CheckTitle": "Lambda function Invoke API calls are recorded by CloudTrail", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "TTPs/Defense Evasion" + ], "ServiceName": "awslambda", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:lambda:region:account-id:function/function-name", + "ResourceIdTemplate": "", "Severity": "low", "ResourceType": "AwsLambdaFunction", - "Description": "Check if Lambda functions invoke API operations are being recorded by CloudTrail.", - "Risk": "If logs are not enabled, monitoring of service use and threat analysis is not possible.", - "RelatedUrl": "https://docs.aws.amazon.com/lambda/latest/dg/logging-using-cloudtrail.html", + "Description": "**AWS Lambda** function invocations are recorded as **CloudTrail data events** when trails include `AWS::Lambda::Function` resources.\n\nThe finding reflects whether a function's `Invoke` activity is being logged by an eligible trail.", + "Risk": "Without Lambda `Invoke` data events, per-invocation accountability is lost. Adversaries or misused automation can run code without an audit trail, obscuring actor, time, and source. This hinders forensics and enables covert exfiltration or unauthorized changes, impacting **confidentiality** and **integrity**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/lambda/latest/dg/logging-using-cloudtrail.html", + "https://support.icompaas.com/support/solutions/articles/62000127055-ensure-lambda-functions-invoke-api-operations-are-being-recorded-by-cloudtrail" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "aws cloudtrail put-event-selectors --trail-name --advanced-event-selectors '[{\"FieldSelectors\":[{\"Field\":\"eventCategory\",\"Equals\":[\"Data\"]},{\"Field\":\"resources.type\",\"Equals\":[\"AWS::Lambda::Function\"]}]}]'", + "NativeIaC": "```yaml\nResources:\n :\n Type: AWS::CloudTrail::Trail\n Properties:\n S3BucketName: \n IsLogging: true\n EventSelectors:\n - DataResources:\n - Type: AWS::Lambda::Function # Critical: enables Lambda data event logging\n Values:\n - arn:aws:lambda:::function # Critical: logs Invoke events for all functions in the specified account/region\n```", + "Other": "1. In the AWS Console, go to CloudTrail > Trails\n2. Select your trail and click Edit or Event logging\n3. Under Data events, choose Add data event selector (or Edit)\n4. Select Lambda function and choose to log data events for all functions (or specify functions)\n5. Save changes", + "Terraform": "```hcl\nresource \"aws_cloudtrail\" \"\" {\n name = \"\"\n s3_bucket_name = \"\"\n\n event_selector {\n data_resource {\n type = \"AWS::Lambda::Function\" # Critical: enable Lambda data events\n values = [\"arn:aws:lambda:::function\"] # Critical: capture Invoke for all functions in this account/region\n }\n }\n}\n```" }, "Recommendation": { - "Text": "Make sure you are logging information about Lambda operations. Create a lifecycle and use cases for each trail.", - "Url": "https://docs.aws.amazon.com/lambda/latest/dg/logging-using-cloudtrail.html" + "Text": "Enable **CloudTrail data event logging** for `AWS::Lambda::Function` to capture `Invoke` calls across required Regions and accounts. Apply **least privilege** selectors to scope events, centralize logs with strong retention, and integrate alerts for anomalous invokes as part of **defense in depth**.", + "Url": "https://hub.prowler.com/check/awslambda_function_invoke_api_operations_cloudtrail_logging_enabled" } }, "Categories": [ - "forensics-ready", "logging" ], "DependsOn": [], diff --git a/prowler/providers/aws/services/awslambda/awslambda_function_no_secrets_in_code/awslambda_function_no_secrets_in_code.metadata.json b/prowler/providers/aws/services/awslambda/awslambda_function_no_secrets_in_code/awslambda_function_no_secrets_in_code.metadata.json index 43cb61491d..438512c25d 100644 --- a/prowler/providers/aws/services/awslambda/awslambda_function_no_secrets_in_code/awslambda_function_no_secrets_in_code.metadata.json +++ b/prowler/providers/aws/services/awslambda/awslambda_function_no_secrets_in_code/awslambda_function_no_secrets_in_code.metadata.json @@ -1,26 +1,35 @@ { "Provider": "aws", "CheckID": "awslambda_function_no_secrets_in_code", - "CheckTitle": "Find secrets in Lambda functions code.", - "CheckType": [], + "CheckTitle": "Lambda function code contains no hardcoded secrets", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "Sensitive Data Identifications/Passwords", + "Effects/Data Exposure" + ], "ServiceName": "awslambda", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:lambda:region:account-id:function/function-name", + "ResourceIdTemplate": "", "Severity": "critical", "ResourceType": "AwsLambdaFunction", - "Description": "Find secrets in Lambda functions code.", - "Risk": "The use of a hard-coded password increases the possibility of password guessing. If hard-coded passwords are used, it is possible that malicious users gain access through the account in question.", - "RelatedUrl": "https://docs.aws.amazon.com/secretsmanager/latest/userguide/lambda-functions.html", + "Description": "**Lambda function code** is analyzed for **embedded secrets** across files in the deployment package, detecting patterns like API keys, passwords, tokens, and connection strings. Findings reference file names and line numbers where potential secrets appear.", + "Risk": "**Hardcoded secrets** undermine confidentiality and integrity: if code, layers, or artifacts are exposed, attackers can reuse credentials to access databases, APIs, or cloud resources, enabling data exfiltration and unauthorized changes.\n\nRotation is harder, increasing dwell time and blast radius of compromises.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/secretsmanager/latest/userguide/best-practices.html", + "https://aws.amazon.com/blogs/security/how-to-securely-provide-database-credentials-to-lambda-functions-by-using-aws-secrets-manager/", + "https://www.cloudcurls.com/2025/08/how-to-manage-secrets-securely-with-aws-secrets-manager.html" + ], "Remediation": { "Code": { "CLI": "", "NativeIaC": "", - "Other": "", + "Other": "1. In AWS Secrets Manager, click Store a new secret and create a secret for the value you hardcoded. Note the secret name/ARN.\n2. In IAM > Roles, open your Lambda execution role and add an inline policy allowing secretsmanager:GetSecretValue on that secret only.\n3. Edit your Lambda function code to remove the hardcoded value and retrieve it at runtime using the AWS SDK (GetSecretValue) with the secret name/ARN.\n4. Deploy the updated function code.", "Terraform": "" }, "Recommendation": { - "Text": "Use Secrets Manager to securely provide database credentials to Lambda functions and secure the databases as well as use the credentials to connect and query them without hardcoding the secrets in code or passing them through environmental variables.", - "Url": "https://docs.aws.amazon.com/secretsmanager/latest/userguide/lambda-functions.html" + "Text": "Use **AWS Secrets Manager** (or Parameter Store) to store secrets and retrieve at runtime; never put them in code or Lambda env vars.\n- Apply **least privilege** IAM\n- Enable **rotation**\n- Prevent secret logging; encrypt\n- Add CI/CD secret scanning", + "Url": "https://hub.prowler.com/check/awslambda_function_no_secrets_in_code" } }, "Categories": [ diff --git a/prowler/providers/aws/services/awslambda/awslambda_function_no_secrets_in_variables/awslambda_function_no_secrets_in_variables.metadata.json b/prowler/providers/aws/services/awslambda/awslambda_function_no_secrets_in_variables/awslambda_function_no_secrets_in_variables.metadata.json index ae2ee8da98..8213ca1d24 100644 --- a/prowler/providers/aws/services/awslambda/awslambda_function_no_secrets_in_variables/awslambda_function_no_secrets_in_variables.metadata.json +++ b/prowler/providers/aws/services/awslambda/awslambda_function_no_secrets_in_variables/awslambda_function_no_secrets_in_variables.metadata.json @@ -1,26 +1,34 @@ { "Provider": "aws", "CheckID": "awslambda_function_no_secrets_in_variables", - "CheckTitle": "Find secrets in Lambda functions variables.", - "CheckType": [], + "CheckTitle": "Lambda function environment variables do not contain secrets", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "Sensitive Data Identifications/Passwords", + "Effects/Data Exposure" + ], "ServiceName": "awslambda", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:lambda:region:account-id:function/function-name", + "ResourceIdTemplate": "", "Severity": "critical", "ResourceType": "AwsLambdaFunction", - "Description": "Find secrets in Lambda functions variables.", - "Risk": "The use of a hard-coded password increases the possibility of password guessing. If hard-coded passwords are used, it is possible that malicious users gain access through the account in question.", - "RelatedUrl": "https://docs.aws.amazon.com/secretsmanager/latest/userguide/lambda-functions.html", + "Description": "AWS Lambda function environment variables are analyzed for content that resembles **secrets** (API keys, tokens, passwords). Pattern-based detection highlights potential hardcoded credentials present in the function's environment.", + "Risk": "Secrets in Lambda environment variables weaken **confidentiality**: users with config read access, runtime introspection, or logs may obtain them. Exposure can grant access to downstream systems, enable **lateral movement**, and allow tampering, impacting **integrity** and **availability**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/secretsmanager/latest/userguide/best-practices.html", + "https://support.icompaas.com/support/solutions/articles/62000129505-ensure-there-are-no-secrets-in-lambda-functions-variables" + ], "Remediation": { "Code": { - "CLI": "aws lambda get-function-configuration --region --function-name --query Environment.Variables", - "NativeIaC": "https://docs.prowler.com/checks/aws/secrets-policies/bc_aws_secrets_3#cloudformation", - "Other": "https://docs.prowler.com/checks/aws/secrets-policies/bc_aws_secrets_3", - "Terraform": "https://docs.prowler.com/checks/aws/secrets-policies/bc_aws_secrets_3#terraform" + "CLI": "aws lambda update-function-configuration --region --function-name --environment \"Variables={}\"", + "NativeIaC": "```yaml\nResources:\n :\n Type: AWS::Lambda::Function\n Properties:\n Environment:\n Variables: {} # CRITICAL: clears environment variables to ensure no secrets are stored\n```", + "Other": "1. Open the AWS Lambda console and select the function\n2. Go to Configuration > Environment variables\n3. Click Edit\n4. Delete variables that contain secrets (or remove all variables)\n5. Click Save", + "Terraform": "```hcl\nresource \"aws_lambda_function\" \"\" {\n environment {\n variables = {} # CRITICAL: remove all env vars so no secrets are present\n }\n}\n```" }, "Recommendation": { - "Text": "Use Secrets Manager to securely provide database credentials to Lambda functions and secure the databases as well as use the credentials to connect and query them without hardcoding the secrets in code or passing them through environmental variables.", - "Url": "https://docs.aws.amazon.com/secretsmanager/latest/userguide/lambda-functions.html" + "Text": "Do not store secrets in environment variables or code. Use **AWS Secrets Manager** or **Parameter Store** with encryption, fetch at runtime using **least privilege** IAM, and prefer short-lived creds via **IAM roles**.\n\nRotate keys, limit configuration read access, and apply **defense in depth** with logging and alerts for secret access.", + "Url": "https://hub.prowler.com/check/awslambda_function_no_secrets_in_variables" } }, "Categories": [ diff --git a/prowler/providers/aws/services/awslambda/awslambda_function_not_publicly_accessible/awslambda_function_not_publicly_accessible.metadata.json b/prowler/providers/aws/services/awslambda/awslambda_function_not_publicly_accessible/awslambda_function_not_publicly_accessible.metadata.json index eea317ad90..c980bcc258 100644 --- a/prowler/providers/aws/services/awslambda/awslambda_function_not_publicly_accessible/awslambda_function_not_publicly_accessible.metadata.json +++ b/prowler/providers/aws/services/awslambda/awslambda_function_not_publicly_accessible/awslambda_function_not_publicly_accessible.metadata.json @@ -1,26 +1,35 @@ { "Provider": "aws", "CheckID": "awslambda_function_not_publicly_accessible", - "CheckTitle": "Check if Lambda functions have resource-based policy set as Public.", - "CheckType": [], + "CheckTitle": "Lambda function resource-based policy does not allow public access", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices" + ], "ServiceName": "awslambda", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:lambda:region:account-id:function/function-name", + "ResourceIdTemplate": "", "Severity": "critical", "ResourceType": "AwsLambdaFunction", - "Description": "Check if Lambda functions have resource-based policy set as Public.", - "Risk": "Publicly accessible services could expose sensitive data to bad actors.", - "RelatedUrl": "https://docs.aws.amazon.com/lambda/latest/dg/access-control-resource-based.html", + "Description": "**AWS Lambda** function resource-based policies are assessed for **public access**. The finding identifies policies with wildcard or empty `Principal` that allow actions like `lambda:InvokeFunction` to any principal.", + "Risk": "**Public invocation** lets outsiders run code under the function's IAM role.\n\nImpacts:\n- **Confidentiality**: data exfiltration via backend access\n- **Integrity**: unauthorized state changes from side effects\n- **Availability/cost**: invocation floods causing throttling and spend spikes", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/config/latest/developerguide/lambda-function-public-access-prohibited.html", + "https://docs.aws.amazon.com/lambda/latest/dg/access-control-resource-based.html", + "https://docs.aws.amazon.com/securityhub/latest/userguide/lambda-controls.html", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/Lambda/function-exposed.html" + ], "Remediation": { "Code": { - "CLI": "aws lambda remove-permission --region --function-name --statement-id FullAccess", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/Lambda/function-exposed.html", - "Terraform": "" + "CLI": "aws lambda remove-permission --function-name --statement-id ", + "NativeIaC": "```yaml\n# CloudFormation: restrict Lambda permission to a non-public principal\nResources:\n Permission:\n Type: AWS::Lambda::Permission\n Properties:\n Action: lambda:InvokeFunction\n FunctionName: \n Principal: 123456789012 # Critical: not \"*\"; limits invoke permission to a specific account to prevent public access\n```", + "Other": "1. Open the AWS Lambda console and select the function\n2. Go to Configuration > Permissions\n3. Under Resource-based policy, view the policy statements\n4. Find any statement with Principal set to \"*\" (or { \"AWS\": \"*\" })\n5. Delete that statement and save\n6. If access is needed, re-add a permission for a specific principal only (for example, an AWS account ID or a service principal)", + "Terraform": "```hcl\n# Restrict Lambda permission to a non-public principal\nresource \"aws_lambda_permission\" \"\" {\n statement_id = \"AllowSpecificPrincipal\"\n action = \"lambda:InvokeFunction\"\n function_name = \"\"\n principal = \"123456789012\" # Critical: not \"*\"; prevents public access\n}\n```" }, "Recommendation": { - "Text": "Grant usage permission on a per-resource basis and applying least privilege principle.", - "Url": "https://docs.aws.amazon.com/lambda/latest/dg/access-control-resource-based.html" + "Text": "Remove public principals from function policies. Grant access only to specific accounts, roles, or services using fixed ARNs and **least privilege**. Add conditions like `AWS:SourceAccount` and `AWS:SourceArn` to constrain service triggers. Enforce **separation of duties** and monitor access for **defense in depth**.", + "Url": "https://hub.prowler.com/check/awslambda_function_not_publicly_accessible" } }, "Categories": [ diff --git a/prowler/providers/aws/services/awslambda/awslambda_function_url_cors_policy/awslambda_function_url_cors_policy.metadata.json b/prowler/providers/aws/services/awslambda/awslambda_function_url_cors_policy/awslambda_function_url_cors_policy.metadata.json index 77d0d0fa19..12f6a57bbe 100644 --- a/prowler/providers/aws/services/awslambda/awslambda_function_url_cors_policy/awslambda_function_url_cors_policy.metadata.json +++ b/prowler/providers/aws/services/awslambda/awslambda_function_url_cors_policy/awslambda_function_url_cors_policy.metadata.json @@ -1,29 +1,40 @@ { "Provider": "aws", "CheckID": "awslambda_function_url_cors_policy", - "CheckTitle": "Check Lambda Function URL CORS configuration.", - "CheckType": [], + "CheckTitle": "Lambda function URL CORS does not allow wildcard origins (*)", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "Effects/Data Exposure" + ], "ServiceName": "awslambda", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:lambda:region:account-id:function/function-name", + "ResourceIdTemplate": "", "Severity": "medium", "ResourceType": "AwsLambdaFunction", - "Description": "Check Lambda Function URL CORS configuration.", - "Risk": "Publicly accessible services could expose sensitive data to bad actors.", - "RelatedUrl": "https://docs.aws.amazon.com/secretsmanager/latest/userguide/lambda-functions.html", + "Description": "**Lambda function URL** CORS policy is reviewed for `AllowOrigins`. The presence of `*` indicates a wide origin allowance in the CORS configuration.", + "Risk": "**Wildcard origins** allow any website to call the endpoint from a browser and read responses, weakening origin isolation.\n\nThis can lead to data exposure (C) and unauthorized actions (I) if state-changing methods are reachable, enabling scripted abuse and cross-origin attacks.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://support.icompaas.com/support/solutions/articles/62000229584-ensure-lambda-function-url-cors-configurations-were-checked", + "https://docs.aws.amazon.com/lambda/latest/api/API_Cors.html", + "https://tutorialsdojo.com/how-to-configure-aws-lambda-function-url-with-cross-origin-resource-sharing/", + "https://dev.to/rimutaka/aws-lambda-function-url-with-cors-explained-by-example-14df" + ], "Remediation": { "Code": { - "CLI": "aws lambda update-function-url-config --region AWS_REGION --function-name FUNCTION-NAME --auth-type AWS_IAM --cors 'AllowOrigins=https://www.example.com,AllowMethods=*,ExposeHeaders=keep-alive,MaxAge=3600,AllowCredentials=false'", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "aws lambda update-function-url-config --function-name --cors AllowOrigins=https://www.example.com", + "NativeIaC": "```yaml\n# CloudFormation: restrict Lambda Function URL CORS to a specific origin\nResources:\n FunctionUrl:\n Type: AWS::Lambda::Url\n Properties:\n TargetFunctionArn: \n AuthType: AWS_IAM\n Cors:\n AllowOrigins:\n - https://www.example.com # Critical: removes '*' wildcard by allowing only this origin\n```", + "Other": "1. In the AWS Console, go to Lambda > Functions and select \n2. Open Configuration > Function URL > Edit\n3. In CORS, remove '*' from Allowed origins and enter https://www.example.com\n4. Save changes", + "Terraform": "```hcl\n# Terraform: restrict Lambda Function URL CORS to a specific origin\nresource \"aws_lambda_function_url\" \"example\" {\n function_name = \"\"\n authorization_type = \"AWS_IAM\"\n cors {\n allow_origins = [\"https://www.example.com\"] # Critical: removes '*' wildcard by allowing only this origin\n }\n}\n```" }, "Recommendation": { - "Text": "Grant usage permission on a per-resource basis and applying least privilege principle.", - "Url": "https://docs.aws.amazon.com/secretsmanager/latest/userguide/lambda-functions.html" + "Text": "Apply least privilege to CORS:\n- Restrict `AllowOrigins` to trusted domains; avoid `*`\n- Limit `AllowMethods`/`AllowHeaders`; disable `AllowCredentials` unless required\n- Prefer authenticated access (e.g., `AWS_IAM`) and enforce resource policies for defense in depth", + "Url": "https://hub.prowler.com/check/awslambda_function_url_cors_policy" } }, - "Categories": [], + "Categories": [ + "internet-exposed" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/aws/services/awslambda/awslambda_function_url_public/awslambda_function_url_public.metadata.json b/prowler/providers/aws/services/awslambda/awslambda_function_url_public/awslambda_function_url_public.metadata.json index 74fa75f1f7..6ca66b7b21 100644 --- a/prowler/providers/aws/services/awslambda/awslambda_function_url_public/awslambda_function_url_public.metadata.json +++ b/prowler/providers/aws/services/awslambda/awslambda_function_url_public/awslambda_function_url_public.metadata.json @@ -1,26 +1,36 @@ { "Provider": "aws", "CheckID": "awslambda_function_url_public", - "CheckTitle": "Check Public Lambda Function URL.", - "CheckType": [], + "CheckTitle": "Lambda function URL is not publicly accessible", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "Effects/Data Exposure" + ], "ServiceName": "awslambda", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:lambda:region:account-id:function/function-name", + "ResourceIdTemplate": "", "Severity": "high", "ResourceType": "AwsLambdaFunction", - "Description": "Check Public Lambda Function URL.", - "Risk": "Publicly accessible services could expose sensitive data to bad actors.", - "RelatedUrl": "https://docs.aws.amazon.com/secretsmanager/latest/userguide/lambda-functions.html", + "Description": "**AWS Lambda function URLs** are assessed to determine whether `AuthType` enforces **AWS IAM authentication** or permits **public invocation**.\n\nApplies to functions with a function URL and highlights when requests must be authenticated and authorized via IAM principals.", + "Risk": "An unauthenticated function URL lets anyone invoke code:\n- Confidentiality: data exposure\n- Integrity: unintended changes via over-privileged logic\n- Availability: DoS/denial-of-wallet through high request rates\n\nAttackers can script calls, exfiltrate data, and pivot using the function's permissions.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/Lambda/iam-auth-function-url.html", + "https://www.roastdev.com/post/aws-lambda-url-invocations-with-iam-authentication-and-throttling-limits", + "https://docs.aws.amazon.com/secretsmanager/latest/userguide/lambda-functions.html", + "https://dev.to/aws-builders/hands-on-aws-lambda-function-url-with-aws-iam-authentication-type-180g", + "https://www.rahulpnath.com/blog/how-to-secure-and-authenticate-lambda-function-urls/" + ], "Remediation": { "Code": { - "CLI": "aws lambda update-function-url-config --region AWS_REGION --function-name FUNCTION-NAME --auth-type AWS_IAM", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "aws lambda update-function-url-config --function-name --auth-type AWS_IAM", + "NativeIaC": "```yaml\n# CloudFormation: set Lambda Function URL to require IAM auth\nResources:\n FunctionUrl:\n Type: AWS::Lambda::Url\n Properties:\n TargetFunctionArn: arn:aws:lambda:::function/\n AuthType: AWS_IAM # CRITICAL: requires IAM authentication, preventing public access\n```", + "Other": "1. In AWS Console, go to Lambda > Functions and open \n2. Select Configuration > Function URL > Edit\n3. Set Auth type to AWS_IAM\n4. Click Save", + "Terraform": "```hcl\n# Set Lambda Function URL to require IAM authentication\nresource \"aws_lambda_function_url\" \"example\" {\n function_name = \"\"\n authorization_type = \"AWS_IAM\" # CRITICAL: blocks public access by requiring IAM auth\n}\n```" }, "Recommendation": { - "Text": "Grant usage permission on a per-resource basis and applying least privilege principle.", - "Url": "https://docs.aws.amazon.com/secretsmanager/latest/userguide/lambda-functions.html" + "Text": "Enforce `AWS_IAM` on function URLs and apply **least privilege**:\n- Grant `lambda:InvokeFunctionUrl` only to required principals\n- Avoid `*` principals or broad conditions\n- Limit CORS to trusted origins and methods\n- Set reserved concurrency to contain abuse\n\nConsider **defense in depth** (WAF/CDN or private access) for Internet use.", + "Url": "https://hub.prowler.com/check/awslambda_function_url_public" } }, "Categories": [ diff --git a/prowler/providers/aws/services/awslambda/awslambda_function_using_supported_runtimes/awslambda_function_using_supported_runtimes.metadata.json b/prowler/providers/aws/services/awslambda/awslambda_function_using_supported_runtimes/awslambda_function_using_supported_runtimes.metadata.json index a3048e5b9e..f5e9856ebf 100644 --- a/prowler/providers/aws/services/awslambda/awslambda_function_using_supported_runtimes/awslambda_function_using_supported_runtimes.metadata.json +++ b/prowler/providers/aws/services/awslambda/awslambda_function_using_supported_runtimes/awslambda_function_using_supported_runtimes.metadata.json @@ -1,29 +1,40 @@ { "Provider": "aws", "CheckID": "awslambda_function_using_supported_runtimes", - "CheckTitle": "Find obsolete Lambda runtimes.", - "CheckType": [], + "CheckTitle": "Lambda function uses a supported runtime", + "CheckType": [ + "Software and Configuration Checks/Patch Management", + "Software and Configuration Checks/AWS Security Best Practices" + ], "ServiceName": "awslambda", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:lambda:region:account-id:function/function-name", + "ResourceIdTemplate": "", "Severity": "medium", "ResourceType": "AwsLambdaFunction", - "Description": "Find obsolete Lambda runtimes.", - "Risk": "If you have functions running on a runtime that will be deprecated in the next 60 days, Lambda notifies you by email that you should prepare by migrating your function to a supported runtime. In some cases, such as security issues that require a backwards-incompatible update, or software that does not support a long-term support (LTS) schedule, advance notice might not be possible. After a runtime is deprecated, Lambda might retire it completely at any time by disabling invocation. Deprecated runtimes are not eligible for security updates or technical support.", - "RelatedUrl": "https://docs.aws.amazon.com/lambda/latest/dg/runtime-support-policy.html", + "Description": "**Lambda functions** using **obsolete runtimes**-such as `python3.8`, `nodejs14.x`, `go1.x`, `ruby2.7`-are identified against a curated list of deprecated runtime identifiers.", + "Risk": "Unmaintained runtimes lack security patches, exposing code and libraries to known CVEs (**confidentiality, integrity**).\n\nDeprecation can block create/update and break builds, causing failed deployments or runtime errors (**availability**). Tooling may stop supporting builds, slowing fixes and recovery.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://aws.amazon.com/blogs/compute/managing-aws-lambda-runtime-upgrades/", + "https://docs.aws.amazon.com/lambda/latest/dg/runtime-support-policy.html", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/Lambda/supported-runtime-environment.html", + "https://docs.aws.amazon.com/lambda/latest/dg/lambda-runtimes.html" + ], "Remediation": { "Code": { - "CLI": "aws lambda update-function-configuration --region AWS-REGION --function-name FUNCTION-NAME --runtime 'RUNTIME'", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "aws lambda update-function-configuration --function-name --runtime ", + "NativeIaC": "```yaml\n# CloudFormation: set Lambda to a supported runtime\nResources:\n :\n Type: AWS::Lambda::Function\n Properties:\n Role: \n Handler: \n Runtime: # FIX: change to a supported runtime (e.g., python3.12) to pass the check\n Code:\n S3Bucket: \n S3Key: \n```", + "Other": "1. Open the AWS Lambda console and select the function\n2. Go to Configuration > Runtime settings > Edit\n3. In Runtime, choose a supported runtime (e.g., python3.12) and click Save", + "Terraform": "```hcl\n# Set Lambda to a supported runtime\nresource \"aws_lambda_function\" \"\" {\n function_name = \"\"\n role = \"\"\n handler = \"\"\n runtime = \"\" # FIX: use a supported runtime (e.g., python3.12) to pass the check\n filename = \"\"\n}\n```" }, "Recommendation": { - "Text": "Test new runtimes as they are made available. Implement them in production as soon as possible.", - "Url": "https://docs.aws.amazon.com/lambda/latest/dg/runtime-support-policy.html" + "Text": "Upgrade to **supported LTS runtimes** (AL2/AL2023) and include runtime upgrades in a secure SDLC.\n\nTest in staging, deploy via versions/aliases, and keep dependencies current. Monitor deprecation notices. Apply guardrails to block deprecated `runtime` values and allow only approved runtimes, aligning with **defense in depth**.", + "Url": "https://hub.prowler.com/check/awslambda_function_using_supported_runtimes" } }, - "Categories": [], + "Categories": [ + "container-security" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/aws/services/awslambda/awslambda_function_vpc_multi_az/awslambda_function_vpc_multi_az.metadata.json b/prowler/providers/aws/services/awslambda/awslambda_function_vpc_multi_az/awslambda_function_vpc_multi_az.metadata.json index 6c6cfff74e..97151509ed 100644 --- a/prowler/providers/aws/services/awslambda/awslambda_function_vpc_multi_az/awslambda_function_vpc_multi_az.metadata.json +++ b/prowler/providers/aws/services/awslambda/awslambda_function_vpc_multi_az/awslambda_function_vpc_multi_az.metadata.json @@ -1,29 +1,39 @@ { "Provider": "aws", "CheckID": "awslambda_function_vpc_multi_az", - "CheckTitle": "Check if AWS Lambda Function VPC is deployed Across Multiple Availability Zones", - "CheckType": [], + "CheckTitle": "Lambda function is configured with VPC subnets in at least two Availability Zones", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability" + ], "ServiceName": "awslambda", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:lambda:region:account-id:function/function-name", + "ResourceIdTemplate": "", "Severity": "medium", "ResourceType": "AwsLambdaFunction", - "Description": "This control checks whether an AWS Lambda function connected to a VPC operates in at least the specified number of Availability Zones (AZs). A failure occurs if the function does not operate in the required number of AZs, which by default is two.", - "Risk": "A Lambda function not deployed across multiple AZs increases the risk of a single point of failure, which can result in a complete disruption of the function's operations if an AZ becomes unavailable.", - "RelatedUrl": "https://docs.aws.amazon.com/lambda/latest/operatorguide/networking-vpc.html", + "Description": "**AWS Lambda** functions attached to a VPC use subnets that span at least the required number of **Availability Zones** (`2` by default).\n\nThe evaluation counts the unique AZs of the function's configured subnets.", + "Risk": "Single-AZ placement limits **availability**. An AZ outage or subnet/IP exhaustion can block ENI creation and VPC access, causing failed invocations, timeouts, and event backlogs.\n\nThis degrades uptime and can delay processing of critical events.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/lambda/latest/operatorguide/networking-vpc.html", + "https://stackzonecom.tawk.help/article/aws-config-rule-lambda-vpc-multi-az-check", + "https://stackoverflow.com/questions/62052490/why-aws-lambda-suggests-to-set-up-two-subnets-if-vpc-is-configured", + "https://docs.aws.amazon.com/securityhub/latest/userguide/lambda-controls.html#lambda-5" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/lambda-controls.html#lambda-5", - "Terraform": "" + "CLI": "aws lambda update-function-configuration --function-name --vpc-config SubnetIds=,,SecurityGroupIds=", + "NativeIaC": "```yaml\nResources:\n :\n Type: AWS::Lambda::Function\n Properties:\n Role: \n Handler: index.handler\n Runtime: python3.12\n Code:\n ZipFile: |\n def handler(event, context):\n return \"\"\n VpcConfig:\n SecurityGroupIds:\n - \n SubnetIds:\n - # Critical: select subnets in different AZs\n - # Critical: ensures function operates in >=2 AZs\n```", + "Other": "1. Open the Lambda console and select the function\n2. Go to Configuration > VPC > Edit\n3. Select the target VPC and choose at least two subnets in different Availability Zones\n4. Select a security group\n5. Click Save", + "Terraform": "```hcl\nresource \"aws_lambda_function\" \"\" {\n function_name = \"\"\n role = \"\"\n handler = \"index.handler\"\n runtime = \"python3.12\"\n filename = \"function.zip\"\n\n vpc_config {\n subnet_ids = [\"\", \"\"] # Critical: subnets in different AZs\n security_group_ids = [\"\"]\n }\n}\n```" }, "Recommendation": { - "Text": "Ensure that your AWS Lambda functions connected to a VPC are distributed across multiple Availability Zones (AZs) to enhance availability and resilience.", - "Url": "https://docs.aws.amazon.com/lambda/latest/dg/configuration-vpc.html" + "Text": "Distribute VPC-connected functions across subnets in `2` distinct AZs to ensure **fault tolerance**.\n- Choose subnets from different AZs\n- Avoid AZ-pinned configs or fixed IPs\n- Provide per-AZ egress/endpoints and routing\n- Regularly test AZ failover\nAligns with **resilience** and **defense in depth**.", + "Url": "https://hub.prowler.com/check/awslambda_function_vpc_multi_az" } }, - "Categories": [], + "Categories": [ + "resilience" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" From 502525eff13671450522ae195fa82ed440f427d5 Mon Sep 17 00:00:00 2001 From: Daniel Barranquero <74871504+danibarranqueroo@users.noreply.github.com> Date: Mon, 6 Oct 2025 10:27:16 +0200 Subject: [PATCH 06/32] fix(compliance): generate file extension correctly (#8791) --- prowler/CHANGELOG.md | 9 ++++ .../outputs/compliance/compliance_output.py | 5 +- .../lib/outputs/compliance/compliance_test.py | 51 +++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 07884e6cae..5f03b32970 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -41,6 +41,15 @@ All notable changes to the **Prowler SDK** are documented in this file. --- +--- + +## [v5.12.4] (Prowler UNRELEASED) + +### Fixed +- Fix file extension parsing for compliance reports [(#8791)](https://github.com/prowler-cloud/prowler/pull/8791) + +--- + ## [v5.12.1] (Prowler v5.12.1) ### Fixed diff --git a/prowler/lib/outputs/compliance/compliance_output.py b/prowler/lib/outputs/compliance/compliance_output.py index d3b855521e..f4f84561f4 100644 --- a/prowler/lib/outputs/compliance/compliance_output.py +++ b/prowler/lib/outputs/compliance/compliance_output.py @@ -42,7 +42,10 @@ class ComplianceOutput(Output): self._from_cli = from_cli if not file_extension and file_path: - self._file_extension = "".join(Path(file_path).suffixes) + # Compliance reports are always CSV, so just use the last suffix + # e.g., "cis_5.0_aws.csv" should have extension ".csv", not ".0_aws.csv" + path_obj = Path(file_path) + self._file_extension = path_obj.suffix if path_obj.suffix else "" if file_extension: self._file_extension = file_extension self.file_path = f"{file_path}{self.file_extension}" diff --git a/tests/lib/outputs/compliance/compliance_test.py b/tests/lib/outputs/compliance/compliance_test.py index 839e5518ec..bb6a7e4089 100644 --- a/tests/lib/outputs/compliance/compliance_test.py +++ b/tests/lib/outputs/compliance/compliance_test.py @@ -391,3 +391,54 @@ class TestCompliance: assert get_check_compliance(finding, "github", bulk_checks_metadata) == { "CIS-1.0": ["1.1.11"], } + + +class TestComplianceOutput: + """Test ComplianceOutput file extension parsing fix.""" + + def test_compliance_output_file_extension_with_dots(self): + """Test that ComplianceOutput correctly parses file extensions when framework names contain dots.""" + from prowler.lib.outputs.compliance.generic.generic import GenericCompliance + + compliance = Compliance( + Framework="CIS", + Version="5.0", + Provider="AWS", + Name="CIS Amazon Web Services Foundations Benchmark v5.0", + Description="Test compliance framework", + Requirements=[], + ) + + # Test with problematic file path that contains dots in framework name + # This simulates the real scenario from Prowler App S3 integration + problematic_file_path = "output/compliance/prowler-output-123456789012-20250101120000_cis_5.0_aws.csv" + + # Create GenericCompliance object with file_path (no explicit file_extension) + compliance_output = GenericCompliance( + findings=[], compliance=compliance, file_path=problematic_file_path + ) + + assert compliance_output.file_extension == ".csv" + assert compliance_output.file_extension != ".0_aws.csv" + + def test_compliance_output_file_extension_explicit(self): + """Test that ComplianceOutput uses explicit file_extension when provided.""" + from prowler.lib.outputs.compliance.generic.generic import GenericCompliance + + compliance = Compliance( + Framework="CIS", + Version="5.0", + Provider="AWS", + Name="CIS Amazon Web Services Foundations Benchmark v5.0", + Description="Test compliance framework", + Requirements=[], + ) + + compliance_output = GenericCompliance( + findings=[], + compliance=compliance, + file_path="output/compliance/test", + file_extension=".csv", + ) + + assert compliance_output.file_extension == ".csv" From be4b1bd99be1471b5c632b8a1657b5cb2f4f4e7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20De=20la=20Torre=20Vico?= Date: Mon, 6 Oct 2025 10:47:51 +0200 Subject: [PATCH 07/32] chore: add first version of AGENTS.md (#8799) --- .gitignore | 3 - AGENTS.md | 110 ++++++++++ docs/AGETNS.md | 533 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 643 insertions(+), 3 deletions(-) create mode 100644 AGENTS.md create mode 100644 docs/AGETNS.md diff --git a/.gitignore b/.gitignore index f4279fdee4..4b39b18b83 100644 --- a/.gitignore +++ b/.gitignore @@ -80,9 +80,6 @@ _data/ # Claude CLAUDE.md -# LLM's (Until we have a standard one) -AGENTS.md - # MCP Server mcp_server/prowler_mcp_server/prowler_app/server.py mcp_server/prowler_mcp_server/prowler_app/utils/schema.yaml diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..c6a6027c18 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,110 @@ +# Repository Guidelines + +## How to Use This Guide + +- Start here for cross-project norms, Prowler is a monorepo with several components. Every component should have an `AGENTS.md` file that contains the guidelines for the agents in that component. The file is located beside the code you are touching (e.g. `api/AGENTS.md`, `ui/AGENTS.md`, `prowler/AGENTS.md`). +- Follow the stricter rule when guidance conflicts; component docs override this file for their scope. +- Keep instructions synchronized. When you add new workflows or scripts, update both, the relevant component `AGENTS.md` and this file if they apply broadly. + +## Project Overview + +Prowler is an open-source cloud security assessment tool that supports multiple cloud providers (AWS, Azure, GCP, Kubernetes, GitHub, M365, etc.). The project consists in a monorepo with the following main components: + +- **Prowler SDK**: Python SDK, includes the Prowler CLI, providers, services, checks, compliances, config, etc. (`prowler/`) +- **Prowler API**: Django-based REST API backend (`api/`) +- **Prowler UI**: Next.js frontend application (`ui/`) +- **Prowler MCP Server**: Model Context Protocol server that gives access to the entire Prowler ecosystem for LLMs (`mcp_server/`) +- **Prowler Dashboard**: Prowler CLI feature that allows to visualize the results of the scans in a simple dashboard (`dashboard/`) + +### Project Structure (Key Folders & Files) + +- `prowler/`: Main source code for Prowler SDK (CLI, providers, services, checks, compliances, config, etc.) +- `api/`: Django-based REST API backend components +- `ui/`: Next.js frontend application +- `mcp_server/`: Model Context Protocol server that gives access to the entire Prowler ecosystem for LLMs +- `dashboard/`: Prowler CLI feature that allows to visualize the results of the scans in a simple dashboard +- `docs/`: Documentation +- `examples/`: Example output formats for providers and scripts +- `permissions/`: Permission-related files and policies +- `contrib/`: Community-contributed scripts or modules +- `tests/`: Prowler SDK test suite +- `docker-compose.yml`: Docker compose file to run the Prowler App (API + UI) production environment +- `docker-compose-dev.yml`: Docker compose file to run the Prowler App (API + UI) development environment +- `pyproject.toml`: Poetry Prowler SDK project file +- `.pre-commit-config.yaml`: Pre-commit hooks configuration +- `Makefile`: Makefile to run the project +- `LICENSE`: License file +- `README.md`: README file +- `CONTRIBUTING.md`: Contributing guide + +## Python Development + +Most of the code is written in Python, so the main files in the root are focused on Python code. + +### Poetry Dev Environment + +For developing in Python we recommend using `poetry` to manage the dependencies. The minimal version is `2.1.1`. So it is recommended to run all commands using `poetry run ...`. + +To install the core dependencies to develop it is needed to run `poetry install --with dev`. + +### Pre-commit hooks + +The project has pre-commit hooks to lint and format the code. They are installed by running `poetry run pre-commit install`. + +When commiting a change, the hooks will be run automatically. Some of them are: + +- Code formatting (black, isort) +- Linting (flake8, pylint) +- Security checks (bandit, safety, trufflehog) +- YAML/JSON validation +- Poetry lock file validation + + +### Linting and Formatting + +We use the following tools to lint and format the code: + +- `flake8`: for linting the code +- `black`: for formatting the code +- `pylint`: for linting the code + +You can run all using the `make` command: +```bash +poetry run make lint +poetry run make format +``` + +Or they will be run automatically when you commit your changes using pre-commit hooks. + +## Commit & Pull Request Guidelines + +For the commit messages and pull requests name follow the conventional-commit style. + +Befire creating a pull request, complete the checklist in `.github/pull_request_template.md`. Summaries should explain deployment impact, highlight review steps, and note changelog or permission updates. Run all relevant tests and linters before requesting review and link screenshots for UI or dashboard changes. + +### Conventional Commit Style + +The Conventional Commits specification is a lightweight convention on top of commit messages. It provides an easy set of rules for creating an explicit commit history; which makes it easier to write automated tools on top of. + +The commit message should be structured as follows: + +``` +[optional scope]: + +[optional body] + +[optional footer(s)] +``` + +Any line of the commit message cannot be longer 100 characters! This allows the message to be easier to read on GitHub as well as in various git tools + +#### Commit Types + +- **feat**: code change introuce new functionality to the application +- **fix**: code change that solve a bug in the codebase +- **docs**: documentation only changes +- **chore**: changes related to the build process or auxiliary tools and libraries, that do not affect the application's functionality +- **perf**: code change that improves performance +- **refactor**: code change that neither fixes a bug nor adds a feature +- **style**: changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc) +- **test**: adding missing tests or correcting existing tests diff --git a/docs/AGETNS.md b/docs/AGETNS.md new file mode 100644 index 0000000000..47e24d419d --- /dev/null +++ b/docs/AGETNS.md @@ -0,0 +1,533 @@ +Always that you are writting documentation try to follow this text/communication style guide: + +# Prowler's Brand Voice + +Prowler is the open cloud security platform trusted by thousands of organizations automating security monitoring and compliance with hundreds of built-in security checks, remediation solutions, and compliance frameworks. With over 10 million downloads, thousands of contributors, and a vibrant global community, Prowler is driving the open-source cloud security movement by providing transparent, customizable, and user-friendly solutions that help teams secure AWS, Azure, GCP, Kubernetes, and Microsoft 365 environments. Leveraging open-source innovation and cost savings, the Prowler platform makes cloud security 10 times more cost-effective and accessible than alternatives. + +These values must be demonstrated in all our conversations and communications. + +--- + +## Unbiased Communication + +Prowler aims to reach every person in the globe. Our communications must be as inclusive and diverse as possible every time. We are guided by the following principles: + +### Avoid Gendered Pronouns + +Reference to gendered pronouns (she/her/hers, he/his/his, they/them/theirs) must be avoided whenever possible. + +* Use second person for communications (you/your/yours). +* Use a third-person reference instead of a gendered pronoun (the customer, the user). +* In case a gendered pronoun must be forcibly used, use they/them/theirs. +* Avoid double references like she/he, s/he, etc. + +### Use Alternatives for Gendered Nouns + +Avoid nouns that include gendered components. Examples: + +* Businessman 🡪 Entrepreneur, businessperson, executive +* Salesman 🡪 Sales executive, sales representative +* Mankind 🡪 Humanity, people +* Penmanship 🡪 Calligraphy, handwriting +* Middleman 🡪 Intermediary, negotiator + +### Diversity, Equity, and Inclusion + +All communications must prioritize diversity and inclusivity. When incorporating examples, ensure representation across sex, gender, age, identity, race, culture, background, ability, and socioeconomic status. Strive for balanced and respectful depictions. + +### Cultural and Geographical Awareness + +Before referencing a region, country, culture, national status, political status, or socioeconomic realities, conduct thorough research. Maintain a respectful, informed approach and avoid unnecessary conflicts. + +### Avoiding Generalizations + +Avoid broad assumptions about gender, sex, race, sexual orientation, nationality, or culture. Generalizations can introduce bias and misrepresentation. Example to avoid: "Cybersecurity is of the utmost importance in the country, where corruption runs amok." + +### Respectful Language + +Derogatory terms must not be used. If uncertain about terminology, consult individuals from the relevant region or community to ensure accuracy and appropriateness. + +### Clear and Accessible Language + +* **Jargon:** Use technical terminology only when the audience is expected to understand it. If uncertain, opt for clear and universally accessible language. +* **Slang:** Minimize slang usage. Even when confident about the audience, prefer formal and neutral language to enhance clarity. + +### Militaristic Language + +Current tendencies in a topic as sensitive as cybersecurity avoid violent and militaristic references save for explicit reference to combat. These are some alternatives: + +* Combat, fight, eliminate 🡪 Address, protect, safeguard, ward +* Kill chain 🡪 Cyberattack chain +* Attacker 🡪 Cyberattacker, bad actor, threat actor +* Defense-in-depth approach 🡪 Multilayered approach +* First line of defense, frontline 🡪 Security, protection, defense +* External attack surface 🡪 Vulnerabilities, point of access, external exposure + +### Note on Safety and Security + +“Safety” and “security” are terms often misunderstood. “Safety” is the microscopic, personal and individual term, while “security” is the macroscopic, broader, national term. Examples: + +a. Seat belts are great for personal safety. +b. National security is of the utmost concern nowadays. + +--- + +## Naming Conventions + +### Prowler Features + +Prowler Features are considered proper nouns. They are to be referenced without articles in all pieces of writing. + +This is a list of Prowler Features: + +* **Prowler App** +* **Prowler CLI** +* **Prowler SDK** +* **Built-in Compliance Checks** +* **Multi-cloud Security Scanning** +* **Autonomous Cloud Security Analyst (AI)** +* **Threat & Misconfiguration Detection** +* **Role-Based Access Control (RBAC)** +* **Identity & Access Risk Detection** +* **Tag-Based Scanning & Filtering** +* **Audit Logs & Security Reports** +* **Agentless & Works Anywhere** +* **Automated Scans & Continuous Monitoring** +* **Chat-based Security Querying (AI)** +* **AI-Generated Detections & Remediations** +* **Prowler Studio** +* **Custom Security Policies** +* **Prowler Cloud** +* **Prowler Registry** +* **Open Source & Full APIs** + +--- + +## Verbal Constructions in Technical Writing + +Choosing verbal constructions (using verbs) over nominal (using nouns) constructions can significantly impact the clarity, conciseness and especially readability of the content. + +Nominal constructions often introduce unnecessary complexity or vagueness. For example: + +* Nominal: "The creation of the report was successful." +* Verbal: "The report was successfully created." + +Verbal constructions also tend to use fewer words, resulting in a more polished and concise style: + +* Nominal: "The implementation of the solution reduced system downtime." +* Verbal: "The solution reduced system downtime." + +Verbal constructions are to be chosen over nominal constructions whenever possible. + +--- + +### Addendum: Verbal Structures Actually State your Purpose + +* **Example 1:** Recommendation for multiple subscriptions +* **Example 2:** Recommendation for Managing Multiple Subscriptions + +Example 1 is vague and even potentially ambiguous. Verbs state your purpose and they must be used whenever possible. + +--- + +## Avoiding The Second Person Except for Imperative Instructions + +Explicit use of second-person pronouns (you) and possessives (your) should be minimized whenever possible. Those constructions are best reserved for cases when instructions are directly given in an imperative form: + +**Example of Improvement Through Avoiding Second Person Pronouns** + +**Original:** +Prowler App can be installed in different ways, depending on your environment: + +**Improved Version:** +Prowler App offers flexible installation methods tailored to various environments: + +--- + +## Title-Case Capitalization + +We use title case. + +**Example:** This Is an Example on Title Case + +Title case tends to be better for SEO because it improves readability and makes headlines more visually distinct, which can lead to higher click-through rates (CTR). + +--- + +### Other Considerations on Capitalization + +Follow these additional guidelines for capitalization: + +### Inner Capitalization + +Avoid internal capitalization of words in body text unless it is part of a proper name or brand denomination. Example: instead of E-mail and e-Book use email/e-mail and e-book. + +### Acronym Capitalization + +Do not capitalize the individual words of the spelled-out form of acronyms. Example: instead of CTI (Cyber Threat Intelligence) use CTI (cyber threat intelligence), but AWS (Amazon Web Services) is to be kept as is. + +### Avoid Capitalization for Emphasis + +Do not capitalize words in order to emphasize them. + +### Capitalization of Languages and Standards + +Check for the proper capitalization of language and standard names. + +Language examples: HTML, JSON, YAML, XML, etc., must be capitalized. + +Standard examples: standards follow title-case capitalization: Industrial Automation and Control Systems (IACS). + +### Capitalization of Laws and Regulations + +Laws and Regulations follow title-case capitalization. If referring to a non-domestic law or regulation, add the nationality and the original name. + +**Example:** Code for the Cybersecurity Law published in the Spanish Official State Bulletin (BOE, Boletín Oficial del Estado). + +Most UE Regulations have an official translation for all UE languages; please check it on EUR-Lex portal and choose the proper language: https://eur-lex.europa.eu/. + +The different languages can be chosen on the portal under Languages, formats and link to OJ. + +--- + +## Hyphenation + +Hyphenation is to be used for noun modifiers in prenominal position, i.e., placed before nouns. + +**Example:** Prowler is a world-leading company in open-source software. + +It is not to be used for predicate adjectives in postnominal position. + +**Example:** Prowler has many features built in. + +### Note on Hyphenation and SEO + +Google treats hyphens as word separators, as if they were blank spaces, i.e., the term `high quality checks` is treated as if it was the same term as `high-quality checks`. Hyphenation does not affect SEO on body text, thus the grammatically correct approach is recommended as sign of good writing. However, underscores (`_`) are treated as different words. This has implications particularly for URLs. + +Hyphens are preferred for URLs as they improve readability and indexing. + +**Example:** +* Better approach: `example.com/this-is-an-URL` +* Less ideal approach: `example.com/this_is_an_URL` + +--- + +## Bullet Points + +Bullet points offer several advantages: + +* **Improved readability:** Bullet points make information scannable and enable vertical reading, allowing users to easily spot relevant details and breaking content into more digestible pieces. +* **Highlighting of relevant information:** They emphasize the most salient points, improving focus and enabling quick summarization at a glance. +* **Improved retention:** Bullet points enhance memory retention and contribute to a clearer, more polished final product. +* **Structured presentation:** They improve user experience through the logical organization of content. +* **SEO relevance (Search Engine Optimization):** Bullet points make content easier to consume and offer the following SEO benefits: + * Reduced bounce rates + * Increased time spent on page + * Strategic keyword optimization + * Improved chances of being featured in search engine snippets + * Enhanced crawlability for search engines. + +--- + +### When to Use Bullet Points + +The use of bullet points is highly recommended when: + +* Information can be logically divided into multiple categories, each sharing characteristics, features, or other relevant classifications. +* Items are significant enough as standalone concepts to deserve their own bullet point. + +**Example of Improvement Through Bullet Points** + +**Original:** +It contains hundreds of controls covering CIS, NIST 800, NIST CSF, CISA, RBI, FedRAMS, PCI-DSS, GDPR, HIPAA, FFIEC, SOC2, GXP, AWS Well-Architected Framework Security Pillar, AWS Foundational Technical Review (FTR), ENS (Spanish National Security Scheme), and your custom security frameworks. + +**Improved with Bullet Points:** + +**Prowler CLI Features:** +Prowler CLI includes hundreds of built-in controls to ensure compliance with standards and frameworks, including: + +* **Industry standards:** CIS, NIST 800, NIST CSF, and CISA +* **Regulatory compliance and governance:** RBI, FedRAMP, and PCI-DSS +* **Frameworks for sensitive data and privacy:** GDPR, HIPAA, and FFIEC +* **Frameworks for organizational governance and quality control:** SOC2 and GXP +* **AWS-specific guidance:** AWS Foundational Technical Review (FTR) and AWS Well-Architected Framework (Security Pillar) +* **Regional compliance:** ENS (Spanish National Security Scheme) +* **Custom security frameworks:** Tailored to meet your organization’s specific needs + +--- + +### Punctuation of Bullet Points + +There are several options for punctuating bullet points. Regardless of the style chosen, it is imperative to maintain consistency throughout the text. + +* **No punctuation (minimalistic):** This strategy is suitable when no verbs are involved and is best used to highlight products or features in isolation. For example: + + Prowler App is composed of three key components: + * Prowler UI + * Prowler API + * Prowler SDK + + This example highlights each element individually and fosters retention with a noise-free approach. + +* **Periods for full sentences:** This approach works best when each bullet point forms a full sentence or includes verbs. For example: + + Prowler App is composed of three key components: + * Prowler UI, a web-based interface, built with Next.js, providing a user-friendly experience for executing Prowler scans and visualizing results. + * Prowler API, a backend service, developed with Django REST Framework, responsible for running Prowler scans and storing the generated results. + * Prowler SDK, a Python SDK designed to extend the functionality of the Prowler CLI for advanced capabilities. + + This example demonstrates a polished list using proper punctuation. + +* **Semi-colons with final period:** This approach was traditionally used for those cases when bullet points were part of a continuous sentence or logical succession. However, it is being deprecated, consistent with the declining use of semi-colons in modern writing. It is to be avoided whenever possible. For example: + + Prowler UI: + * is a web-based interface; + * is built with Next.js; + * provides a user-friendly experience for executing Prowler scans and visualizing results. + +--- + +### Advantages of Adding Headers to Bullet Points + +Adding headers to bullet points in technical writing is a powerful technique that enhances both the clarity and usability of the content. It has also advantages for SEO: + +* Increased crawlability of search engines +* Enhanced keyword integration +* Improved user engagement +* Enhanced snippetting by search engines +* Reduced bounce rates + +It is recommended to add headers to bullet points whenever possible. + +--- + +## Quotation Marks + +### Quotation Marks Usage in Technical Documentation + +Proper use of quotation marks enhances clarity and consistency in technical writing. Below are key guidelines for using double and single quotation marks, following American English conventions. + +### Double Quotation Marks + +* Use for titles of books, movies, songs, and articles. +* Enclose direct quotes: + * **Example:** The developer said, “We will try to fix this issue.” +* Capitalize the first word if quoting a full sentence. +* When quoting a phrase within a sentence, do not capitalize: + * **Example:** The news portal called our product “one of the most efficient online help authoring tools.” +* Use scare quotes when words acquire a different or ironic meaning: + * **Example:** The update is “scheduled” to release next week. +* To refer to a term without applying its meaning, use double quotes (or italics): + * **Example:** Avoid terms like “don’t worry” in pop-ups to prevent user anxiety. + +### Single Quotation Marks + +* Used inside double quotes: + * **Example:** He said, “I am not sure what ‘single sourcing’ means.” +* In British English, the order is often reversed (single quotation marks on the outside). + +--- + +### Double Quoting in Software Documentation + +Double quoting is to be used through software documentation where their use does not interfere with formatting restrictions. + +1. **Menu Items & UI Options** + * Use double quotation marks when referring to selectable items in software interfaces. + * **Example:** Click “File” and select “Save As” to export your document. +2. **Buttons & Commands** + * Use double quotes for labeled interface elements that users interact with. + * **Example:** Select “Submit” to finalize the form. +3. **Exact Input & User Actions** + * If users need to enter exact text, enclose it in double quotes. + * **Example:** Type “admin” in the username field. +4. **Avoid Quoting Software Names** + * Do not use quotation marks for software product names unless required for clarity. + * **Correct:** Open Microsoft Excel. + * **Incorrect:** Open “Microsoft Excel.” + +--- + +## Interaction Verbs + +The following are the correct verbs that must be used when referring to user interactions with the software. + +### Mouse & Trackpad Actions (Desktop/Laptop) + +* **Click:** Press and release the left mouse button or trackpad without moving the pointer. + * **Example:** Click the “OK” button to confirm. 🡪 Transitive +* **Click on:** Often interchangeable with "Click," but less commonly used in technical writing for UI interactions. + * **Example:** Click on the "Settings" icon to open preferences. (Less recommended—“Click” is preferred.) +* **Double-click:** Press and release twice in quick succession, usually to open files or applications. + * **Example:** Double-click the document to open it. 🡪 Transitive +* **Right-click:** Press and release the right mouse button to open a context menu. + * **Example:** Right-click the folder and select “Properties.” 🡪 Transitive + +### Touchscreen Actions (Mobile & Touch) + +* **Tap:** Touch the screen lightly with a finger or stylus, equivalent to "Click" on a mouse. + * **Example:** Tap the “Sign in” button. +* **Double-tap:** Quickly touch the screen twice, often used for zooming or selecting text. + * **Example:** Double-tap an image to zoom in. +* **Press and hold:** Touch and hold the screen for a moment to access additional options. + * **Example:** Press and hold an app icon to see more actions. (Similar to “Right-click” in desktop environments.) + +### Additional Actions + +* **Drag:** Click or tap an item and move it while holding down the button or finger. + * **Example:** Drag the file into the folder. +* **Swipe:** Move a finger across the touchscreen horizontally or vertically. + * **Example:** Swipe left to dismiss the notification. +* **Pinch to zoom:** Use two fingers to zoom in or out. + * **Example:** Pinch the screen to zoom in on the image. +* **Scroll:** Move the mouse wheel, swipe, or use the arrow keys to navigate up/down. + * **Example:** Scroll down to see more results. + +The widely-accepted terminology for gestures is Windows’: https://support.microsoft.com/en-us/windows/touch-gestures-for-windows-a9d28305-4818-a5df-4e2b-e5590f850741 + +--- + +## Sentence Structure for Technical Writing and SEO + +When writing technical documentation, clarity, conciseness, and searchability (SEO) are key factors. Let’s compare the following two sentence structures, extracted from Prowler’s documentation: + +**Option 1:** +"Open a terminal and execute the following command to create a new custom role." + +**Option 2:** +"To create a new custom role, open a terminal and execute the following command." + +### SEO Optimization + +* Search engines prioritize clear intent at the beginning of a sentence. +* Option 2 starts with the action users are likely to search for (e.g., "Create a custom role"), which improves SEO rankings and makes the content more likely to match search queries. +* Option 1 places the primary search term toward the end, making it less effective for keyword optimization. + +### Technical Writing Best Practices + +* Technical writing emphasizes clear objectives first, followed by actions. +* Option 2 follows this best practice by stating the goal first ("To create a new custom role") and then providing instructions. +* Option 1 is still acceptable for step-by-step guides, but Option 2 is more effective for tutorials, manuals, and documentation. + +### Key Takeaways + +* Draft trying to mimic the most likely way users are to find the information (“Ctrl + F approach”). +* Place keywords and key terms at the beginning of sentences so that they rank better SEO-wise. +* Rule of thumb: “In order to what” precedes the “what”. “What” must mirror the user’s most likely way of drafting or searching. + +--- + +## Section Titles and Headers in Technical Writing + +Effective headers and section titles enhance document readability and structure, making content more accessible to the reader. This chapter outlines best practices for crafting clear, consistent, and meaningful headings. + +1. **Purpose of Headers** + Headers serve several key functions: + * **Improve Navigation:** Allow users to quickly locate relevant information. + * **Enhance Readability:** Break down complex topics into manageable sections. + * **Establish Hierarchy:** Define the logical flow of content. + * **SEO:** Headers impact SEO both directly and indirectly: + * Search engines use headings to determine the hierarchy and relevance of content. + * **H1:** The primary heading (should be unique and descriptive). + * **H2-H6:** Subheadings that break down content logically. + * Best practices for SEO-friendly headers: + * Include keywords naturally in headings. + * Avoid keyword stuffing—keep it clear and readable. + * Use structured hierarchy (H1 → H2 → H3, etc.). +2. **Header Levels and Formatting** + Use a structured approach for organizing section titles. Common conventions include: + * **Title:** The primary heading of the document (e.g., H1). + * **Main Sections:** First-level headers (H2), introducing key content areas. + * **Subsections:** Second-level headers (H3) to detail specific topics within sections. + * **Subtopics:** Third-level headers (H4+) used sparingly for finer details. + + **Example:** + + ```markdown + # Document Title (H1) + ## Main Section (H2) + ### Subsection (H3) + #### Subtopic (H4) + ``` + +3. **Writing Effective Headers** + When crafting headers and section titles, follow these guidelines: + * **Be Descriptive:** Clearly indicate what the section covers. + * **Poor:** Introduction (too vague) + * **Good:** Introduction to AWS CloudShell Installation (informative) + * **Keep It Concise:** Use precise language without unnecessary words. + * **Maintain Consistency:** Apply uniform formatting and style conventions throughout. + * **Avoid Special Characters:** Limit punctuation for clarity—avoid excessive symbols, dashes, or underscores. +4. **Capitalization Rules** + Use Title Case for headers to ensure a professional look: + * **Good:** How to Clone and Install Prowler from GitHub + * **Poor:** How to clone and install Prowler from GitHub + + For technical documentation, sentence case may be used for readability in subheadings. Please note this differs from headers and it is only a recommendation, but consistency is to be kept throughout the documentation: + + * **Example:** + * How to Clone and Install Prowler from GitHub (header: Title case) + * How to install poetry dependencies (subheading: Sentence case) +5. **Using Keywords in Headers** + Headers should include relevant keywords to improve document searchability: + * **Good:** Scanning AWS Accounts in Parallel + * **Poor:** Ways to scan on AWS (vague and imprecise) +6. **Consistency Across Documents** + Ensure uniformity in section titles across related documentation: + * **Standardized Header Naming:** Use consistent wording for common sections (e.g., "Installation," "Setup," "Configuration"). + * **Numbering Sections (If Necessary):** For structured guides, include numbering where appropriate (e.g., "Step 1: Install Prowler"). + +--- + +## Avoid Assumptions Regarding Audience’s Expertise + +### Understand Your Audience’s Expertise + +Despite knowing your target audience, assumptions on target audience’s expertise or knowledge are to be avoided. + +Adjust the level of detail based on expected reader proficiency, but make sure to be as explanatory as humanly possible. + +### Define Key Terms and Acronyms on First Use + +Even if your audience is technical, some domain-specific terms may vary. +* Introduce jargon only after defining it clearly. +* If using acronyms (e.g., IAM, MFA), spell them out on first mention: + * AWS Identity and Access Management (IAM) + * Multifactor Authentication (MFA) + +### Don’t Assume Unwritten Knowledge + +Even experienced readers may not know every prerequisite. If a process relies on prior steps, briefly reference them: + +* Before configuring security groups, ensure VPC networking is set up. + +### Use Consistent Formatting + +### Provide as Many Examples as Deemed Right… and Then Some + +### Anticipate Common Knowledge Gaps + +### Avoid Excessive Notes + +Notes are often omitted by readers and they clutter text, so use them sparingly and only for additional information that is not essential or prompts any error or mistake. + +--- + +## Using Warnings and Danger Calls for High-Severity Information + +In technical documentation, warnings and danger calls highlight critical risks, guiding users in preventing security breaches or system failures. Proper usage ensures clarity and actionable guidance. + +1. **Define Severity Levels** + Before applying Note, Warning, or Danger, clearly define their significance: + * **Note:** Provides general information or best practices (low severity). + * **Warning:** Indicates potential issues if instructions are not followed (moderate severity). + * **Danger:** Highlights actions that could result in severe consequences, such as system corruption or data loss (high severity). +2. **Explain Consequences** + Each warning or danger call should explicitly describe the impact of ignoring the caution: + * **Good:** Disabling encryption may expose sensitive data to unauthorized access. + * **Poor:** Avoid disabling encryption. +3. **Provide Remediation and Troubleshooting** + Whenever possible, direct users to troubleshooting guides or mitigation steps to resolve the issue. + + **Example:** + **Danger:** Running this command will **permanently delete all data**. Refer to @Data Recovery Guide for restoration steps. From 8c2668ebe4297e709560bb257a794536cc340349 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20De=20la=20Torre=20Vico?= Date: Mon, 6 Oct 2025 10:53:17 +0200 Subject: [PATCH 08/32] chore: rename docs AGENTS (#8846) --- docs/{AGETNS.md => AGENTS.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/{AGETNS.md => AGENTS.md} (100%) diff --git a/docs/AGETNS.md b/docs/AGENTS.md similarity index 100% rename from docs/AGETNS.md rename to docs/AGENTS.md From 612d867838a4535b5106f4896ed4181d53d2d4c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Fern=C3=A1ndez=20Poyatos?= Date: Mon, 6 Oct 2025 12:42:16 +0200 Subject: [PATCH 09/32] fix(tests): Race condition on redundant API unit test (#8849) --- api/src/backend/api/tests/test_views.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index ccfb1ff01d..416da2e1c9 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -7418,8 +7418,6 @@ class TestTenantApiKeyViewSet: assert response.status_code == status.HTTP_200_OK data = response.json()["data"] assert len(data) == len(api_keys_fixture) - # Verify keys are ordered by -created (newest first) - assert data[0]["attributes"]["name"] == TenantAPIKey.objects.first().name def test_api_keys_list_empty(self, authenticated_client, tenants_fixture): """Test listing API keys when none exist returns empty list.""" From 5f0017046f0eda1eec765bac3d91274c6edfa9f9 Mon Sep 17 00:00:00 2001 From: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com> Date: Mon, 6 Oct 2025 14:04:20 +0200 Subject: [PATCH 10/32] chore(findings): change `References` display in UI (#8793) --- ui/CHANGELOG.md | 1 + .../findings/table/finding-detail.tsx | 21 ++++++++++--------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index fbbbb8b643..e99fc6ffaf 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -24,6 +24,7 @@ All notable changes to the **Prowler UI** are documented in this file. - Updated LangChain to latest versions with API improvements [(#8748)](https://github.com/prowler-cloud/prowler/pull/8748) - Migrated all page components to async `params`/`searchParams` API [(#8748)](https://github.com/prowler-cloud/prowler/pull/8748) - Migrated from `useFormState` to `useActionState` for React 19 compatibility [(#8748)](https://github.com/prowler-cloud/prowler/pull/8748) +- References display in findings detail page now shows as a proper bulleted list [(#8793)](https://github.com/prowler-cloud/prowler/pull/8793) --- diff --git a/ui/components/findings/table/finding-detail.tsx b/ui/components/findings/table/finding-detail.tsx index 083812bf6a..154edcbe99 100644 --- a/ui/components/findings/table/finding-detail.tsx +++ b/ui/components/findings/table/finding-detail.tsx @@ -209,20 +209,21 @@ export const FindingDetail = ({ {attributes.check_metadata.additionalurls && attributes.check_metadata.additionalurls.length > 0 && ( -
+
    {attributes.check_metadata.additionalurls.map( (link, idx) => ( - - {link} - +
  • + + {link} + +
  • ), )} -
+
)}
From 7e5e48c588330c552da6f4f55956616aa7fe2b20 Mon Sep 17 00:00:00 2001 From: Daniel Barranquero <74871504+danibarranqueroo@users.noreply.github.com> Date: Mon, 6 Oct 2025 15:22:15 +0200 Subject: [PATCH 11/32] fix(changelog): duplicated v5.12.4 in SDK changelog (#8852) --- prowler/CHANGELOG.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 5f03b32970..e0468bd9d8 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -38,14 +38,6 @@ All notable changes to the **Prowler SDK** are documented in this file. ### Fixed - Fix KeyError in `elb_ssl_listeners_use_acm_certificate` check and handle None cluster version in `eks_cluster_uses_a_supported_version` check [(#8791)](https://github.com/prowler-cloud/prowler/pull/8791) - ---- - ---- - -## [v5.12.4] (Prowler UNRELEASED) - -### Fixed - Fix file extension parsing for compliance reports [(#8791)](https://github.com/prowler-cloud/prowler/pull/8791) --- From 37f77bb7781ab2a76e2e4ae89981b4d0bc20e225 Mon Sep 17 00:00:00 2001 From: Prowler Bot Date: Mon, 6 Oct 2025 15:23:03 +0200 Subject: [PATCH 12/32] chore(regions_update): Changes in regions for AWS services (#8847) Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com> --- .../providers/aws/aws_regions_by_service.json | 21 ++++--------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/prowler/providers/aws/aws_regions_by_service.json b/prowler/providers/aws/aws_regions_by_service.json index 09347d738f..7eafb1f862 100644 --- a/prowler/providers/aws/aws_regions_by_service.json +++ b/prowler/providers/aws/aws_regions_by_service.json @@ -1247,6 +1247,7 @@ "ap-southeast-3", "ap-southeast-4", "ap-southeast-5", + "ap-southeast-6", "ap-southeast-7", "ca-central-1", "ca-west-1", @@ -5461,6 +5462,7 @@ "ap-southeast-3", "ap-southeast-4", "ap-southeast-5", + "ap-southeast-7", "ca-central-1", "ca-west-1", "eu-central-1", @@ -5474,6 +5476,7 @@ "il-central-1", "me-central-1", "me-south-1", + "mx-central-1", "sa-east-1", "us-east-1", "us-east-2", @@ -9145,6 +9148,7 @@ "ap-southeast-3", "ap-southeast-4", "ap-southeast-5", + "ap-southeast-6", "ap-southeast-7", "ca-central-1", "ca-west-1", @@ -9426,23 +9430,6 @@ ] } }, - "robomaker": { - "regions": { - "aws": [ - "ap-northeast-1", - "ap-southeast-1", - "eu-central-1", - "eu-west-1", - "us-east-1", - "us-east-2", - "us-west-2" - ], - "aws-cn": [], - "aws-us-gov": [ - "us-gov-west-1" - ] - } - }, "rolesanywhere": { "regions": { "aws": [ From 736badb284b7bf4d9df9bceccec6a74e833f3c0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20De=20la=20Torre=20Vico?= Date: Mon, 6 Oct 2025 15:29:06 +0200 Subject: [PATCH 13/32] chore(aws): enhance metadata for `appstream` service (#8789) Co-authored-by: HugoPBrito --- prowler/CHANGELOG.md | 1 + ...ult_internet_access_disabled.metadata.json | 38 +++++++++++-------- ...eet_maximum_session_duration.metadata.json | 27 +++++++------ ...t_session_disconnect_timeout.metadata.json | 31 ++++++++------- ...sion_idle_disconnect_timeout.metadata.json | 35 +++++++++-------- 5 files changed, 76 insertions(+), 56 deletions(-) diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index e0468bd9d8..b73be163f9 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -24,6 +24,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - Update AWS ACM service metadata to new format [(#8716)](https://github.com/prowler-cloud/prowler/pull/8716) - HTML output now properly renders markdown syntax in Risk and Recommendation fields [(#8727)](https://github.com/prowler-cloud/prowler/pull/8727) - Update `moto` dependency from 5.0.28 to 5.1.11 [(#7100)](https://github.com/prowler-cloud/prowler/pull/7100) +- Update AWS AppStream service metadata to new format [(#8789)](https://github.com/prowler-cloud/prowler/pull/8789) - Update AWS API Gateway service metadata to new format [(#8788)](https://github.com/prowler-cloud/prowler/pull/8788) - Update AWS Athena service metadata to new format [(#8790)](https://github.com/prowler-cloud/prowler/pull/8790) - Update AWS Lambda service metadata to new format [(#8825)](https://github.com/prowler-cloud/prowler/pull/8825) diff --git a/prowler/providers/aws/services/appstream/appstream_fleet_default_internet_access_disabled/appstream_fleet_default_internet_access_disabled.metadata.json b/prowler/providers/aws/services/appstream/appstream_fleet_default_internet_access_disabled/appstream_fleet_default_internet_access_disabled.metadata.json index 8e4c94a601..265116c988 100644 --- a/prowler/providers/aws/services/appstream/appstream_fleet_default_internet_access_disabled/appstream_fleet_default_internet_access_disabled.metadata.json +++ b/prowler/providers/aws/services/appstream/appstream_fleet_default_internet_access_disabled/appstream_fleet_default_internet_access_disabled.metadata.json @@ -1,33 +1,41 @@ { "Provider": "aws", "CheckID": "appstream_fleet_default_internet_access_disabled", - "CheckTitle": "Ensure default Internet Access from your Amazon AppStream fleet streaming instances should remain unchecked.", + "CheckTitle": "AppStream fleet has default internet access disabled", "CheckType": [ - "Software and Configuration Checks", - "Industry and Regulatory Standards", - "CIS AWS Foundations Benchmark" + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark" ], "ServiceName": "appstream", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:appstream:region:account-id:fleet/resource-id", + "ResourceIdTemplate": "", "Severity": "medium", "ResourceType": "Other", - "Description": "Ensure default Internet Access from your Amazon AppStream fleet streaming instances should remain unchecked.", - "Risk": "Default Internet Access from your fleet streaming instances should be controlled using a NAT gateway in the VPC.", - "RelatedUrl": "https://docs.aws.amazon.com/appstream2/latest/developerguide/set-up-stacks-fleets.html", + "Description": "**Amazon AppStream fleets** are assessed for the `EnableDefaultInternetAccess` setting, identifying fleets where streaming instances have default Internet connectivity.", + "Risk": "**Direct Internet access** gives streaming instances public exposure. Threats include:\n- Remote exploitation and malware, undermining **confidentiality** and **integrity**\n- Uncontrolled egress enabling **data exfiltration**\n\nIt also enforces ~100-instance limits, reducing **availability** for high-concurrency deployments.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/appstream2/latest/developerguide/set-up-stacks-fleets.html", + "https://support.icompaas.com/support/solutions/articles/62000233540-ensure-default-internet-access-from-your-amazon-appstream-fleet-streaming-instances-remains-unchecked", + "https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-appstream-fleet.html", + "https://docs.aws.amazon.com/appstream2/latest/developerguide/internet-access.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "aws appstream update-fleet --name --no-enable-default-internet-access", + "NativeIaC": "```yaml\n# CloudFormation: disable default internet access on an AppStream fleet\nResources:\n :\n Type: AWS::AppStream::Fleet\n Properties:\n Name: \n InstanceType: \n EnableDefaultInternetAccess: false # Critical: disables default internet access to pass the check\n```", + "Other": "1. In the AWS console, go to Amazon AppStream 2.0 > Fleets\n2. Select the target fleet\n3. If the fleet is RUNNING, click Actions > Stop and wait until the state is Stopped\n4. Click Edit (or Modify)\n5. Uncheck \"Default internet access\" (Disable \"Enable default internet access\")\n6. Save/Update the fleet and start it if needed", + "Terraform": "```hcl\n# Terraform: disable default internet access on an AppStream fleet\nresource \"aws_appstream_fleet\" \"\" {\n name = \"\"\n instance_type = \"stream.standard.small\"\n image_name = \"\"\n compute_capacity { desired_instances = 1 }\n\n enable_default_internet_access = false # Critical: disables default internet access to pass the check\n}\n```" }, "Recommendation": { - "Text": "Uncheck the default internet access for the AppStream Fleet.", - "Url": "https://docs.aws.amazon.com/appstream2/latest/developerguide/set-up-stacks-fleets.html" + "Text": "Disable default Internet access (`EnableDefaultInternetAccess=false`) and place fleets in **private subnets**. Provide egress via **NAT gateways** or proxies, enforce **egress filtering**, and apply **least privilege** and **zero trust** to restrict outbound traffic. Use private connectivity to AWS services where possible.", + "Url": "https://hub.prowler.com/check/appstream_fleet_default_internet_access_disabled" } }, - "Categories": [], + "Categories": [ + "internet-exposed" + ], "DependsOn": [], "RelatedTo": [], "Notes": "Infrastructure Security" diff --git a/prowler/providers/aws/services/appstream/appstream_fleet_maximum_session_duration/appstream_fleet_maximum_session_duration.metadata.json b/prowler/providers/aws/services/appstream/appstream_fleet_maximum_session_duration/appstream_fleet_maximum_session_duration.metadata.json index bb623a8dd8..5eb4260b50 100644 --- a/prowler/providers/aws/services/appstream/appstream_fleet_maximum_session_duration/appstream_fleet_maximum_session_duration.metadata.json +++ b/prowler/providers/aws/services/appstream/appstream_fleet_maximum_session_duration/appstream_fleet_maximum_session_duration.metadata.json @@ -1,28 +1,31 @@ { "Provider": "aws", "CheckID": "appstream_fleet_maximum_session_duration", - "CheckTitle": "Ensure user maximum session duration is no longer than 10 hours.", + "CheckTitle": "AppStream fleet maximum user session duration is less than 10 hours", "CheckType": [ - "Infrastructure Security" + "Software and Configuration Checks/AWS Security Best Practices" ], "ServiceName": "appstream", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:appstream:region:account-id:fleet/resource-id", + "ResourceIdTemplate": "", "Severity": "medium", "ResourceType": "Other", - "Description": "Ensure user maximum session duration is no longer than 10 hours.", - "Risk": "Having a session duration lasting longer than 10 hours should not be necessary and if running for any malicious reasons provides a greater time for usage than should be allowed.", - "RelatedUrl": "https://docs.aws.amazon.com/appstream2/latest/developerguide/set-up-stacks-fleets.html", + "Description": "**AppStream fleets** enforce a **maximum user session duration**. This finding evaluates each fleet's configured limit against a threshold-default `10 hours` (`36000` seconds)-and identifies fleets whose session duration exceeds that limit.", + "Risk": "Overlong sessions widen the window for **session hijacking**, **lateral movement**, and **data exfiltration** if endpoints or tokens are compromised. Reduced reauthentication weakens **confidentiality** and **integrity**, and extended access can increase **costs** and resource contention.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/appstream2/latest/developerguide/set-up-stacks-fleets.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "aws appstream update-fleet --name --max-user-duration-in-seconds 3600", + "NativeIaC": "```yaml\n# CloudFormation: Set AppStream Fleet session duration below 10 hours\nResources:\n AppStreamFleet:\n Type: AWS::AppStream::Fleet\n Properties:\n Name: \"\"\n MaxUserDurationInSeconds: 3600 # CRITICAL: ensures max session duration is < 10h to pass the check\n```", + "Other": "1. Open the AWS Console and go to Amazon AppStream 2.0\n2. Click Fleets and select \n3. Click Edit\n4. Set Maximum session duration to a value under 10 hours (e.g., 3600 seconds)\n5. Save changes", + "Terraform": "```hcl\n# Terraform: Set AppStream Fleet session duration below 10 hours\nresource \"aws_appstream_fleet\" \"\" {\n name = \"\"\n max_user_duration_in_seconds = 3600 # CRITICAL: ensures max session duration is < 10h to pass the check\n}\n```" }, "Recommendation": { - "Text": "Change the Maximum session duration is set to 600 minutes or less for the AppStream Fleet.", - "Url": "https://docs.aws.amazon.com/appstream2/latest/developerguide/set-up-stacks-fleets.html" + "Text": "Configure the **maximum session duration** to `<= 10 hours` (e.g., `600` minutes) or less based on data sensitivity. Prefer shorter limits, enforce **reauthentication** on renewal, apply **least privilege**, and enable **idle timeouts**. Monitor session activity as part of **defense in depth**.", + "Url": "https://hub.prowler.com/check/appstream_fleet_maximum_session_duration" } }, "Categories": [], diff --git a/prowler/providers/aws/services/appstream/appstream_fleet_session_disconnect_timeout/appstream_fleet_session_disconnect_timeout.metadata.json b/prowler/providers/aws/services/appstream/appstream_fleet_session_disconnect_timeout/appstream_fleet_session_disconnect_timeout.metadata.json index b73618e6a7..5b918be5c3 100644 --- a/prowler/providers/aws/services/appstream/appstream_fleet_session_disconnect_timeout/appstream_fleet_session_disconnect_timeout.metadata.json +++ b/prowler/providers/aws/services/appstream/appstream_fleet_session_disconnect_timeout/appstream_fleet_session_disconnect_timeout.metadata.json @@ -1,30 +1,33 @@ { "Provider": "aws", "CheckID": "appstream_fleet_session_disconnect_timeout", - "CheckTitle": "Ensure session disconnect timeout is set to 5 minutes or less.", + "CheckTitle": "AppStream fleet session disconnect timeout is 5 minutes or less", "CheckType": [ - "Software and Configuration Checks", - "Industry and Regulatory Standards", - "CIS AWS Foundations Benchmark" + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark" ], "ServiceName": "appstream", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:appstream:region:account-id:fleet/resource-id", + "ResourceIdTemplate": "", "Severity": "medium", "ResourceType": "Other", - "Description": "Ensure session disconnect timeout is set to 5 minutes or less", - "Risk": "Disconnect timeout in minutes, is the amount of of time that a streaming session remains active after users disconnect.", - "RelatedUrl": "https://docs.aws.amazon.com/appstream2/latest/developerguide/set-up-stacks-fleets.html", + "Description": "**AppStream fleets** are evaluated for `DisconnectTimeoutInSeconds` being at or below `300` seconds (5 minutes), which defines how long a streaming session remains active after a user disconnects.", + "Risk": "Long disconnect times keep sessions active, enabling **session hijacking** or unintended reconnection on lost/stolen devices. This raises data exposure (confidentiality), permits unauthorized actions (integrity), and ties up capacity and costs (availability/operations).", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/appstream2/latest/developerguide/set-up-stacks-fleets.html", + "https://awscli.amazonaws.com/v2/documentation/api/2.9.6/reference/appstream/update-fleet.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "aws appstream update-fleet --name --disconnect-timeout-in-seconds 300", + "NativeIaC": "```yaml\n# CloudFormation: Set AppStream Fleet disconnect timeout to 5 minutes or less\nResources:\n ExampleFleet:\n Type: AWS::AppStream::Fleet\n Properties:\n Name: \n InstanceType: stream.standard.medium\n ImageName: \n ComputeCapacity:\n DesiredInstances: 1\n DisconnectTimeoutInSeconds: 300 # CRITICAL: ensures session disconnect timeout is <= 300s\n```", + "Other": "1. In the AWS console, go to Amazon AppStream 2.0 > Fleets\n2. Select the fleet and choose Edit\n3. Set Disconnect timeout to 5 minutes (300 seconds) or less\n4. Save changes", + "Terraform": "```hcl\n# Terraform: Set AppStream Fleet disconnect timeout to 5 minutes or less\nresource \"aws_appstream_fleet\" \"\" {\n name = \"\"\n instance_type = \"stream.standard.medium\"\n image_name = \"\"\n\n compute_capacity {\n desired_instances = 1\n }\n\n disconnect_timeout_in_seconds = 300 # CRITICAL: ensures timeout is <= 300s\n}\n```" }, "Recommendation": { - "Text": "Change the Disconnect timeout to 5 minutes or less for the AppStream Fleet.", - "Url": "https://docs.aws.amazon.com/appstream2/latest/developerguide/set-up-stacks-fleets.html" + "Text": "Set `DisconnectTimeoutInSeconds` to `300` or less across fleets. Pair with a short `IdleDisconnectTimeoutInSeconds`, require re-authentication on reconnect, and enforce **least privilege**. Monitor session events and use **defense in depth** (network restrictions, device posture) to reduce takeover risk.", + "Url": "https://hub.prowler.com/check/appstream_fleet_session_disconnect_timeout" } }, "Categories": [], diff --git a/prowler/providers/aws/services/appstream/appstream_fleet_session_idle_disconnect_timeout/appstream_fleet_session_idle_disconnect_timeout.metadata.json b/prowler/providers/aws/services/appstream/appstream_fleet_session_idle_disconnect_timeout/appstream_fleet_session_idle_disconnect_timeout.metadata.json index d8a5b4935a..d03a02055a 100644 --- a/prowler/providers/aws/services/appstream/appstream_fleet_session_idle_disconnect_timeout/appstream_fleet_session_idle_disconnect_timeout.metadata.json +++ b/prowler/providers/aws/services/appstream/appstream_fleet_session_idle_disconnect_timeout/appstream_fleet_session_idle_disconnect_timeout.metadata.json @@ -1,33 +1,38 @@ { "Provider": "aws", "CheckID": "appstream_fleet_session_idle_disconnect_timeout", - "CheckTitle": "Ensure session idle disconnect timeout is set to 10 minutes or less.", + "CheckTitle": "AppStream fleet session idle disconnect timeout is 10 minutes or less", "CheckType": [ - "Software and Configuration Checks", - "Industry and Regulatory Standards", - "CIS AWS Foundations Benchmark" + "Software and Configuration Checks/AWS Security Best Practices", + "Effects/Data Exposure" ], "ServiceName": "appstream", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:appstream:region:account-id:fleet/resource-id", + "ResourceIdTemplate": "", "Severity": "medium", "ResourceType": "Other", - "Description": "Ensure session idle disconnect timeout is set to 10 minutes or less.", - "Risk": "Idle disconnect timeout in minutes is the amount of time that users can be inactive before they are disconnected from their streaming session and the Disconnect timeout in minutes time begins.", - "RelatedUrl": "https://docs.aws.amazon.com/appstream2/latest/developerguide/set-up-stacks-fleets.html", + "Description": "**Amazon AppStream fleets** are evaluated for the **idle disconnect timeout** setting, confirming it is configured to `10 minutes` (`<=600s`) or less before inactive users are dropped and the session's `disconnect_timeout` window begins.", + "Risk": "**Long idle sessions** keep desktops/apps accessible without user presence, enabling **session hijacking**, **shoulder surfing**, and **data exposure**. They also **consume capacity** and extend **billing**, reducing **availability** for other users.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/appstream2/latest/developerguide/set-up-stacks-fleets.html", + "https://awscli.amazonaws.com/v2/documentation/api/2.9.6/reference/appstream/update-fleet.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "aws appstream update-fleet --name --idle-disconnect-timeout-in-seconds 600", + "NativeIaC": "```yaml\nResources:\n :\n Type: AWS::AppStream::Fleet\n Properties:\n Name: \n InstanceType: stream.standard.small\n ComputeCapacity:\n DesiredInstances: 1\n IdleDisconnectTimeoutInSeconds: 600 # Critical: set to 10 minutes or less to pass the check\n```", + "Other": "1. Open the AWS Console and go to AppStream 2.0 > Fleets\n2. Select your fleet and click Edit\n3. Find \"Idle disconnect timeout\"\n4. Set it to 10 minutes (600 seconds) or less\n5. Click Save", + "Terraform": "```hcl\nresource \"aws_appstream_fleet\" \"\" {\n name = \"\"\n instance_type = \"stream.standard.small\"\n image_name = \"\"\n\n compute_capacity {\n desired_instances = 1\n }\n\n idle_disconnect_timeout_in_seconds = 600 # Critical: enforce <= 10 minutes to pass the check\n}\n```" }, "Recommendation": { - "Text": "Change the session idle timeout to 10 minutes or less for the AppStream Fleet.", - "Url": "https://docs.aws.amazon.com/appstream2/latest/developerguide/set-up-stacks-fleets.html" + "Text": "Configure an **idle disconnect timeout 10 minutes**. Pair with a short `disconnect_timeout`, require **re-authentication** on reconnect, and enforce **least privilege**. Monitor session metrics and adjust per role to balance **security**, **cost**, and **user experience**.", + "Url": "https://hub.prowler.com/check/appstream_fleet_session_idle_disconnect_timeout" } }, - "Categories": [], + "Categories": [ + "identity-access" + ], "DependsOn": [], "RelatedTo": [], "Notes": "Infrastructure Security" From 76a55cdb5403fd7e0235ba3672e44b7c0d43571d Mon Sep 17 00:00:00 2001 From: Chandrapal Badshah Date: Mon, 6 Oct 2025 20:55:20 +0530 Subject: [PATCH 14/32] fix: remove maxTokens for gpt-5 (#8843) Co-authored-by: Chandrapal Badshah <12944530+Chan9390@users.noreply.github.com> --- ui/CHANGELOG.md | 6 ++++++ ui/lib/lighthouse/utils.ts | 1 + 2 files changed, 7 insertions(+) diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index e99fc6ffaf..79a4930edf 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -28,6 +28,12 @@ All notable changes to the **Prowler UI** are documented in this file. --- +## [1.12.4] (Prowler v5.12.4) + +### 🐞 Fixed + +- Remove maxTokens model param for GPT-5 models [(#8843)](https://github.com/prowler-cloud/prowler/pull/8843) + ## [1.12.3] (Prowler v5.12.3) ### 🐞 Fixed diff --git a/ui/lib/lighthouse/utils.ts b/ui/lib/lighthouse/utils.ts index 8ab416a7b9..7b6516e398 100644 --- a/ui/lib/lighthouse/utils.ts +++ b/ui/lib/lighthouse/utils.ts @@ -46,6 +46,7 @@ export const getModelParams = (config: any): ModelParams => { if (modelId.startsWith("gpt-5")) { params.temperature = undefined; params.reasoningEffort = "minimal" as const; + params.maxTokens = undefined; } return params; From 8814a0710aebc9e27e2b64024bba3d3d3d26dff1 Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Mon, 6 Oct 2025 17:52:38 +0200 Subject: [PATCH 15/32] fix(scans): detail drawer fails after dependencies migration (#8856) --- ui/components/scans/auto-refresh.tsx | 9 ++- .../scans/table/scans/column-get-scans.tsx | 33 +++++++++-- .../table/scans/data-table-row-details.tsx | 27 +-------- .../table/scans/skeleton-scan-detail.tsx | 58 +++++++++++++++++++ ui/components/ui/sheet/trigger-sheet.tsx | 4 +- 5 files changed, 100 insertions(+), 31 deletions(-) create mode 100644 ui/components/scans/table/scans/skeleton-scan-detail.tsx diff --git a/ui/components/scans/auto-refresh.tsx b/ui/components/scans/auto-refresh.tsx index 218d5ccc32..fa2c93424f 100644 --- a/ui/components/scans/auto-refresh.tsx +++ b/ui/components/scans/auto-refresh.tsx @@ -1,6 +1,6 @@ "use client"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { useEffect } from "react"; interface AutoRefreshProps { @@ -9,16 +9,21 @@ interface AutoRefreshProps { export function AutoRefresh({ hasExecutingScan }: AutoRefreshProps) { const router = useRouter(); + const searchParams = useSearchParams(); useEffect(() => { if (!hasExecutingScan) return; + // Don't auto-refresh if scan details drawer is open + const scanId = searchParams.get("scanId"); + if (scanId) return; + const interval = setInterval(() => { router.refresh(); }, 5000); return () => clearInterval(interval); - }, [hasExecutingScan, router]); + }, [hasExecutingScan, router, searchParams]); return null; } diff --git a/ui/components/scans/table/scans/column-get-scans.tsx b/ui/components/scans/table/scans/column-get-scans.tsx index 918d2a80c1..f3f2e3ac62 100644 --- a/ui/components/scans/table/scans/column-get-scans.tsx +++ b/ui/components/scans/table/scans/column-get-scans.tsx @@ -2,7 +2,7 @@ import { Tooltip } from "@heroui/tooltip"; import { ColumnDef } from "@tanstack/react-table"; -import { useSearchParams } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { InfoIcon } from "@/components/icons"; import { TableLink } from "@/components/ui/custom"; @@ -21,19 +21,44 @@ const getScanData = (row: { original: ScanProps }) => { }; const ScanDetailsCell = ({ row }: { row: any }) => { + const router = useRouter(); const searchParams = useSearchParams(); const scanId = searchParams.get("scanId"); const isOpen = scanId === row.original.id; + const scanState = row.original.attributes?.state; + const isExecuting = scanState === "executing" || scanState === "available"; + + const handleOpenChange = (open: boolean) => { + if (isExecuting) return; + + const params = new URLSearchParams(searchParams.toString()); + + if (open) { + params.set("scanId", row.original.id); + } else { + params.delete("scanId"); + } + + router.push(`?${params.toString()}`, { scroll: false }); + }; return (
} + triggerComponent={ + + } title="Scan Details" description="View the scan details" - defaultOpen={isOpen} + open={isOpen} + onOpenChange={handleOpenChange} > - + {isOpen && }
); diff --git a/ui/components/scans/table/scans/data-table-row-details.tsx b/ui/components/scans/table/scans/data-table-row-details.tsx index 307f7676f5..1b15a665ab 100644 --- a/ui/components/scans/table/scans/data-table-row-details.tsx +++ b/ui/components/scans/table/scans/data-table-row-details.tsx @@ -1,36 +1,20 @@ "use client"; -import { useRouter, useSearchParams } from "next/navigation"; import { useEffect, useState } from "react"; import { getProvider } from "@/actions/providers"; import { getScan } from "@/actions/scans"; import { getTask } from "@/actions/task"; import { ScanDetail } from "@/components/scans/table"; -import { Alert } from "@/components/ui/alert/Alert"; import { checkTaskStatus } from "@/lib"; import { ScanProps } from "@/types"; +import { SkeletonScanDetail } from "./skeleton-scan-detail"; + export const DataTableRowDetails = ({ entityId }: { entityId: string }) => { - const router = useRouter(); - const searchParams = useSearchParams(); const [scanDetails, setScanDetails] = useState(null); const [isLoading, setIsLoading] = useState(true); - useEffect(() => { - // Add scanId to URL - const params = new URLSearchParams(searchParams.toString()); - params.set("scanId", entityId); - router.push(`?${params.toString()}`, { scroll: false }); - - // Cleanup function: remove scanId from URL when component unmounts - return () => { - const newParams = new URLSearchParams(searchParams.toString()); - newParams.delete("scanId"); - router.push(`?${newParams.toString()}`, { scroll: false }); - }; - }, [entityId, router, searchParams]); - useEffect(() => { const fetchScanDetails = async () => { try { @@ -76,12 +60,7 @@ export const DataTableRowDetails = ({ entityId }: { entityId: string }) => { }, [entityId]); if (isLoading) { - return ( - - Scan details are loading and will be available once the scan is - completed. - - ); + return ; } if (!scanDetails) { diff --git a/ui/components/scans/table/scans/skeleton-scan-detail.tsx b/ui/components/scans/table/scans/skeleton-scan-detail.tsx new file mode 100644 index 0000000000..5e69becb48 --- /dev/null +++ b/ui/components/scans/table/scans/skeleton-scan-detail.tsx @@ -0,0 +1,58 @@ +export const SkeletonScanDetail = () => { + return ( +
+ {/* Header Skeleton */} +
+
+
+
+
+
+
+
+
+
+ + {/* Scan Details Section Skeleton */} +
+
+ + {/* First grid row */} +
+ {Array.from({ length: 3 }).map((_, index) => ( +
+
+
+
+ ))} +
+ + {/* Second grid row */} +
+ {Array.from({ length: 3 }).map((_, index) => ( +
+
+
+
+ ))} +
+ + {/* Scan ID field */} +
+
+
+
+ + {/* Third grid row */} +
+ {Array.from({ length: 3 }).map((_, index) => ( +
+
+
+
+ ))} +
+
+
+ ); +}; diff --git a/ui/components/ui/sheet/trigger-sheet.tsx b/ui/components/ui/sheet/trigger-sheet.tsx index dcb8e793f4..fb51a65c47 100644 --- a/ui/components/ui/sheet/trigger-sheet.tsx +++ b/ui/components/ui/sheet/trigger-sheet.tsx @@ -12,6 +12,7 @@ interface TriggerSheetProps { title: string; description: string; children: React.ReactNode; + open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; } @@ -21,11 +22,12 @@ export function TriggerSheet({ title, description, children, + open, defaultOpen = false, onOpenChange, }: TriggerSheetProps) { return ( - + {triggerComponent} From 338a11eaafc0ceb06c28a548f57043f5882fcbb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20De=20la=20Torre=20Vico?= Date: Mon, 6 Oct 2025 19:27:47 +0200 Subject: [PATCH 16/32] chore(aws): enhance metadata for `account` service (#8715) Co-authored-by: MrCloudSec Co-authored-by: Daniel Barranquero --- prowler/CHANGELOG.md | 1 + ...tain_current_contact_details.metadata.json | 34 ++++++++++++------ ...urity_billing_and_operations.metadata.json | 36 ++++++++++++------- ...ct_information_is_registered.metadata.json | 30 ++++++++++------ ...egistered_in_the_aws_account.metadata.json | 24 +++++++------ 5 files changed, 81 insertions(+), 44 deletions(-) diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index b73be163f9..c8420d43a4 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. - Update AWS Neptune service metadata to new format [(#8494)](https://github.com/prowler-cloud/prowler/pull/8494) - Update AWS Config service metadata to new format [(#8641)](https://github.com/prowler-cloud/prowler/pull/8641) +- Update AWS Account service metadata to new format [(#8715)](https://github.com/prowler-cloud/prowler/pull/8715) - Update AWS AccessAnalyzer service metadata to new format [(#8688)](https://github.com/prowler-cloud/prowler/pull/8688) - Update AWS Api Gateway V2 service metadata to new format [(#8719)](https://github.com/prowler-cloud/prowler/pull/8719) - Update AWS AppSync service metadata to new format [(#8721)](https://github.com/prowler-cloud/prowler/pull/8721) diff --git a/prowler/providers/aws/services/account/account_maintain_current_contact_details/account_maintain_current_contact_details.metadata.json b/prowler/providers/aws/services/account/account_maintain_current_contact_details/account_maintain_current_contact_details.metadata.json index 5efea50007..3fb364d20a 100644 --- a/prowler/providers/aws/services/account/account_maintain_current_contact_details/account_maintain_current_contact_details.metadata.json +++ b/prowler/providers/aws/services/account/account_maintain_current_contact_details/account_maintain_current_contact_details.metadata.json @@ -1,28 +1,40 @@ { "Provider": "aws", "CheckID": "account_maintain_current_contact_details", - "CheckTitle": "Maintain current contact details.", + "CheckTitle": "AWS account contact information is current", "CheckType": [ - "IAM" + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices" ], "ServiceName": "account", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:service:region:account-id", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AwsAccount", - "Description": "Maintain current contact details.", - "Risk": "Ensure contact email and telephone details for AWS accounts are current and map to more than one individual in your organization. An AWS account supports a number of contact details, and AWS will use these to contact the account owner if activity judged to be in breach of Acceptable Use Policy. If an AWS account is observed to be behaving in a prohibited or suspicious manner, AWS will attempt to contact the account owner by email and phone using the contact details listed. If this is unsuccessful and the account behavior needs urgent mitigation, proactive measures may be taken, including throttling of traffic between the account exhibiting suspicious behavior and the AWS API endpoints and the Internet. This will result in impaired service to and from the account in question.", + "ResourceType": "Other", + "Description": "**AWS account contact information** is current for the **primary contact** and the **alternate contacts** for `security`, `billing`, and `operations`, with accurate email addresses and phone numbers.", + "Risk": "Outdated or single-person contacts delay **security notifications**, slow **incident response**, and complicate **account recovery**.\n\nAWS may throttle services during abuse mitigation, reducing **availability**. Missed alerts enable ongoing misuse, risking **data exfiltration** and unauthorized changes (**integrity**).", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.prowler.com/checks/aws/iam-policies/iam_18-maintain-contact-details#aws-console", + "https://repost.aws/knowledge-center/update-phone-number", + "https://support.stax.io/docs/accounts/update-aws-account-contact-details", + "https://maartenbruntink.nl/blog/2022/09/26/aws-account-hygiene-101-mass-updating-alternate-account-contacts/", + "https://docs.aws.amazon.com/security-ir/latest/userguide/update-account-contact-info.html", + "https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-update-contact-primary.html", + "https://repost.aws/knowledge-center/add-update-billing-contact", + "https://aws.amazon.com/blogs/security/update-the-alternate-security-contact-across-your-aws-accounts-for-timely-security-notifications/", + "https://docs.aws.amazon.com/organizations/latest/userguide/orgs_manage_accounts_update_contacts.html", + "https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-update-contact-alternate.html" + ], "Remediation": { "Code": { - "CLI": "No command available.", + "CLI": "aws account put-alternate-contact --alternate-contact-type=SECURITY --email-address= --name=\"\" --phone-number=\"\" --title=\"Security\"", "NativeIaC": "", - "Other": "https://docs.prowler.com/checks/aws/iam-policies/iam_18-maintain-contact-details#aws-console", - "Terraform": "" + "Other": "1. Sign in to the AWS Management Console\n2. Open Billing and Cost Management, then click your account name > Account\n3. Under Contact information, click Edit, update details, then Update\n4. Under Alternate contacts, click Edit\n5. Enter Security contact name, email, and phone (use a team alias), then Update\n6. Repeat for Billing and Operations if needed", + "Terraform": "```hcl\n# Set the Security alternate contact for the AWS account\nresource \"aws_account_alternate_contact\" \"\" {\n alternate_contact_type = \"SECURITY\" # Critical: ensures AWS can reach your security team\n email_address = \"\" # Critical: contact destination\n name = \"\"\n phone_number = \"\"\n title = \"Security\"\n}\n```" }, "Recommendation": { - "Text": "Using the Billing and Cost Management console complete contact details.", - "Url": "https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-update-contact.html" + "Text": "Adopt:\n- **Primary** and **alternate contacts** for `security`, `billing`, `operations`\n- Shared, monitored aliases and SMS-capable phone numbers (non-personal)\n- Centralized management across accounts with periodic reviews\n- **Least privilege** for who can modify contact data\n- Regular reachability tests and documented ownership", + "Url": "https://hub.prowler.com/check/account_maintain_current_contact_details" } }, "Categories": [], diff --git a/prowler/providers/aws/services/account/account_maintain_different_contact_details_to_security_billing_and_operations/account_maintain_different_contact_details_to_security_billing_and_operations.metadata.json b/prowler/providers/aws/services/account/account_maintain_different_contact_details_to_security_billing_and_operations/account_maintain_different_contact_details_to_security_billing_and_operations.metadata.json index ae7b180a9f..9f9703823f 100644 --- a/prowler/providers/aws/services/account/account_maintain_different_contact_details_to_security_billing_and_operations/account_maintain_different_contact_details_to_security_billing_and_operations.metadata.json +++ b/prowler/providers/aws/services/account/account_maintain_different_contact_details_to_security_billing_and_operations/account_maintain_different_contact_details_to_security_billing_and_operations.metadata.json @@ -1,31 +1,43 @@ { "Provider": "aws", "CheckID": "account_maintain_different_contact_details_to_security_billing_and_operations", - "CheckTitle": "Maintain different contact details to security, billing and operations.", + "CheckTitle": "AWS account has distinct Security, Billing, and Operations contact details, different from each other and from the root contact", "CheckType": [ - "IAM" + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark" ], "ServiceName": "account", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:service:region:account-id", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AwsAccount", - "Description": "Maintain different contact details to security, billing and operations.", - "Risk": "Ensure contact email and telephone details for AWS accounts are current and map to more than one individual in your organization. An AWS account supports a number of contact details, and AWS will use these to contact the account owner if activity judged to be in breach of Acceptable Use Policy. If an AWS account is observed to be behaving in a prohibited or suspicious manner, AWS will attempt to contact the account owner by email and phone using the contact details listed. If this is unsuccessful and the account behavior needs urgent mitigation, proactive measures may be taken, including throttling of traffic between the account exhibiting suspicious behavior and the AWS API endpoints and the Internet. This will result in impaired service to and from the account in question.", - "RelatedUrl": "https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-update-contact.html", + "ResourceType": "Other", + "Description": "**AWS account alternate contacts** are defined for **Security**, **Billing**, and **Operations** with `name`, `email`, and `phone`. The finding evaluates that all three exist, are distinct from one another, and differ from the **primary (root) contact**.", + "Risk": "Missing or shared contacts can delay response to abuse alerts, credential compromise, or billing anomalies, reducing **availability** (possible AWS traffic throttling) and raising **confidentiality** and **integrity** risk through extended exposure. If AWS cannot reach you, urgent mitigation may disrupt service.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/account_alternate_contact", + "https://docs.prowler.com/checks/aws/iam-policies/iam_18-maintain-contact-details#aws-console", + "https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-update-contact-alternate.html", + "https://builder.aws.com/content/2qRw97fe8JFwfk2AbpJ3sYNpNvM/aws-bulk-update-alternate-contacts-across-organization", + "https://github.com/aws-samples/aws-account-alternate-contact-with-terraform", + "https://trendmicro.com/cloudoneconformity/knowledge-base/aws/IAM/account-security-alternate-contacts.html", + "https://repost.aws/articles/ARDFbpt-bvQ8iuErnqVVcCXQ/managing-aws-organization-alternate-contacts-via-csv" + ], "Remediation": { "Code": { "CLI": "", "NativeIaC": "", - "Other": "https://docs.prowler.com/checks/aws/iam-policies/iam_18-maintain-contact-details#aws-console", - "Terraform": "" + "Other": "1. Sign in to the AWS Management Console with a user that can edit account contacts (root, or IAM with account:PutAlternateContact)\n2. In the upper right, click your account name > Account\n3. Scroll to \"Alternate contacts\" and click Edit\n4. Add all three contacts with unique details:\n - Billing contact (distinct name, email, phone)\n - Operations contact (distinct name, email, phone)\n - Security contact (distinct name, email, phone)\n5. Ensure each contact’s email/phone differs from each other and from the primary (root) contact, then click Update", + "Terraform": "```hcl\n# Set distinct alternate contacts for SECURITY, BILLING, and OPERATIONS\n# Critical: each resource sets a required contact type to satisfy the check\n\nresource \"aws_account_alternate_contact\" \"security\" {\n alternate_contact_type = \"SECURITY\" # Sets SECURITY contact\n email_address = \"security@example.com\"\n name = \"Security Team\"\n phone_number = \"+1-555-0100\"\n title = \"Security\"\n}\n\nresource \"aws_account_alternate_contact\" \"billing\" {\n alternate_contact_type = \"BILLING\" # Sets BILLING contact\n email_address = \"billing@example.com\"\n name = \"Billing Team\"\n phone_number = \"+1-555-0101\"\n title = \"Billing\"\n}\n\nresource \"aws_account_alternate_contact\" \"operations\" {\n alternate_contact_type = \"OPERATIONS\" # Sets OPERATIONS contact\n email_address = \"operations@example.com\"\n name = \"Operations Team\"\n phone_number = \"+1-555-0102\"\n title = \"Operations\"\n}\n```" }, "Recommendation": { - "Text": "Using the Billing and Cost Management console complete contact details.", - "Url": "https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-update-contact.html" + "Text": "Maintain distinct, monitored **Security**, **Billing**, and **Operations** alternate contacts that differ from the root contact.\n- Use team aliases and 24x7 phones\n- Review and test contact paths regularly\n- Centralize at org level for consistency\n\nApplies **operational resilience** and **separation of duties**.", + "Url": "https://hub.prowler.com/check/account_maintain_different_contact_details_to_security_billing_and_operations" } }, - "Categories": [], + "Categories": [ + "resilience" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/aws/services/account/account_security_contact_information_is_registered/account_security_contact_information_is_registered.metadata.json b/prowler/providers/aws/services/account/account_security_contact_information_is_registered/account_security_contact_information_is_registered.metadata.json index 5ec402edbe..6363edfb46 100644 --- a/prowler/providers/aws/services/account/account_security_contact_information_is_registered/account_security_contact_information_is_registered.metadata.json +++ b/prowler/providers/aws/services/account/account_security_contact_information_is_registered/account_security_contact_information_is_registered.metadata.json @@ -1,28 +1,36 @@ { "Provider": "aws", "CheckID": "account_security_contact_information_is_registered", - "CheckTitle": "Ensure security contact information is registered.", + "CheckTitle": "AWS account has security alternate contact registered", "CheckType": [ - "IAM" + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices" ], "ServiceName": "account", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:service:region:account-id", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AwsAccount", - "Description": "Ensure security contact information is registered.", - "Risk": "AWS provides customers with the option of specifying the contact information for accounts security team. It is recommended that this information be provided. Specifying security-specific contact information will help ensure that security advisories sent by AWS reach the team in your organization that is best equipped to respond to them.", + "ResourceType": "Other", + "Description": "Account settings contain a **Security alternate contact** in Alternate Contacts (name, `EmailAddress`, `PhoneNumber`) for targeted AWS security notifications.", + "Risk": "Missing or outdated **security contact** can delay or prevent AWS advisories from reaching responders, increasing risk to:\n- Confidentiality: data exfiltration from undetected compromise\n- Integrity: unauthorized changes persist longer\n- Availability: resource abuse (e.g., cryptomining) and outages", "RelatedUrl": "", + "AdditionalURLs": [ + "https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/account_alternate_contact", + "https://docs.prowler.com/checks/aws/iam-policies/iam_19/", + "https://support.icompaas.com/support/solutions/articles/62000234161-1-2-ensure-security-contact-information-is-registered-manual-", + "https://www.plerion.com/cloud-knowledge-base/ensure-security-contact-information-is-registered", + "https://repost.aws/articles/ARDFbpt-bvQ8iuErnqVVcCXQ/managing-aws-organization-alternate-contacts-via-csv" + ], "Remediation": { "Code": { - "CLI": "No command available.", + "CLI": "aws account put-alternate-contact --alternate-contact-type SECURITY --email-address --name --phone-number ", "NativeIaC": "", - "Other": "https://docs.prowler.com/checks/aws/iam-policies/iam_19#aws-console", - "Terraform": "" + "Other": "1. Sign in to the AWS Management Console as the root user or an admin with account:PutAlternateContact\n2. Click your account name (top-right) and select My Account (or Account)\n3. Scroll to Alternate Contacts and click Edit in the Security section\n4. Enter Security Email, Name, and Phone Number\n5. Click Update (or Save changes)", + "Terraform": "```hcl\n# Set the SECURITY alternate contact for the current AWS account\nresource \"aws_account_alternate_contact\" \"\" {\n alternate_contact_type = \"SECURITY\" # Critical: sets Security contact type\n email_address = \"security@example.com\" # Contact email\n name = \"Security Team\" # Contact name\n phone_number = \"+1-555-0100\" # Contact phone\n}\n```" }, "Recommendation": { - "Text": "Go to the My Account section and complete alternate contacts.", - "Url": "https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-update-contact.html" + "Text": "Define and maintain a **Security alternate contact**:\n- Use a monitored alias (e.g., `security@domain`) and team phone\n- Apply to every account (prefer Org-wide automation)\n- Review after org/personnel changes and test delivery\n- Document ownership and escalation paths\nAlign with **incident response** and **least privilege** principles.", + "Url": "https://hub.prowler.com/check/account_security_contact_information_is_registered" } }, "Categories": [], diff --git a/prowler/providers/aws/services/account/account_security_questions_are_registered_in_the_aws_account/account_security_questions_are_registered_in_the_aws_account.metadata.json b/prowler/providers/aws/services/account/account_security_questions_are_registered_in_the_aws_account/account_security_questions_are_registered_in_the_aws_account.metadata.json index 41ee551bbe..976eef238f 100644 --- a/prowler/providers/aws/services/account/account_security_questions_are_registered_in_the_aws_account/account_security_questions_are_registered_in_the_aws_account.metadata.json +++ b/prowler/providers/aws/services/account/account_security_questions_are_registered_in_the_aws_account/account_security_questions_are_registered_in_the_aws_account.metadata.json @@ -1,28 +1,32 @@ { "Provider": "aws", "CheckID": "account_security_questions_are_registered_in_the_aws_account", - "CheckTitle": "Ensure security questions are registered in the AWS account.", + "CheckTitle": "[DEPRECATED] AWS root user has security challenge questions configured", "CheckType": [ - "IAM" + "Software and Configuration Checks/AWS Security Best Practices" ], "ServiceName": "account", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:service:region:account-id", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AwsAccount", - "Description": "Ensure security questions are registered in the AWS account.", - "Risk": "The AWS support portal allows account owners to establish security questions that can be used to authenticate individuals calling AWS customer service for support. It is recommended that security questions be established. When creating a new AWS account a default super user is automatically created. This account is referred to as the root account. It is recommended that the use of this account be limited and highly controlled. During events in which the root password is no longer accessible or the MFA token associated with root is lost/destroyed it is possible through authentication using secret questions and associated answers to recover root login access.", + "ResourceType": "Other", + "Description": "[DEPRECATED] **AWS account root** configuration may include legacy **security challenge questions** for support identity verification. This evaluates whether those questions are set on the account. *New configuration is discontinued by AWS and remaining support for this feature is time-limited.*", + "Risk": "Absence of these questions can limit support-assisted recovery if root credentials or MFA are lost, reducing **availability** and slowing **incident response**. Reliance on KBA also weakens **confidentiality** due to **social engineering**. Treat this as a recovery gap and adopt stronger, phishing-resistant factors.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.prowler.com/checks/aws/iam-policies/iam_15", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/IAM/security-challenge-questions.html" + ], "Remediation": { "Code": { - "CLI": "No command available.", + "CLI": "", "NativeIaC": "", - "Other": "https://docs.prowler.com/checks/aws/iam-policies/iam_15", + "Other": "1. Sign in to the AWS Management Console\n2. Navigate to your AWS account settings page at https://console.aws.amazon.com/billing/home?#/account/\n3. Scroll down to Configure Security Challenge Questions section and click the Edit link\n4. Select three different questions made available by Amazon and provide appropriate answers\n5. Store the answers in a secure but accessible location\n6. Click the Update button to save the changes", "Terraform": "" }, "Recommendation": { - "Text": "Login as root account and from My Account configure Security questions.", - "Url": "https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-security-challenge.html" + "Text": "Favor stronger recovery instead of KBA:\n- Enforce **MFA for root** and minimize root use\n- Keep **alternate contacts** and root email current and protected\n- Establish a tightly controlled **break-glass role**, applying least privilege and separation of duties\n- Document and test recovery procedures; monitor root activity", + "Url": "https://hub.prowler.com/check/account_security_questions_are_registered_in_the_aws_account" } }, "Categories": [], From 06eb69e4557b1137b393befbf20ac83fbec2b130 Mon Sep 17 00:00:00 2001 From: Josema Camacho Date: Tue, 7 Oct 2025 09:38:11 +0200 Subject: [PATCH 17/32] chore(security): update Django to 5.1.13 (#8842) --- api/CHANGELOG.md | 3 +++ api/poetry.lock | 15 ++++++++++----- api/pyproject.toml | 2 +- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 9958941369..114fbc61c8 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -13,6 +13,9 @@ All notable changes to the **Prowler API** are documented in this file. - Now the MANAGE_ACCOUNT permission is required to modify or read user permissions instead of MANAGE_USERS [(#8281)](https://github.com/prowler-cloud/prowler/pull/8281) - Now at least one user with MANAGE_ACCOUNT permission is required in the tenant [(#8729)](https://github.com/prowler-cloud/prowler/pull/8729) +### Security +- Django updated to the latest 5.1 security release, 5.1.13, due to problems with potential [SQL injection](https://github.com/prowler-cloud/prowler/security/dependabot/104) and [directory traversals](https://github.com/prowler-cloud/prowler/security/dependabot/103) [(#8842)](https://github.com/prowler-cloud/prowler/pull/8842) + --- ## [1.13.2] (Prowler 5.12.3) diff --git a/api/poetry.lock b/api/poetry.lock index 255a33d89f..c9bd9c6be3 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. [[package]] name = "about-time" @@ -1563,14 +1563,14 @@ with-social = ["django-allauth[socialaccount] (>=64.0.0)"] [[package]] name = "django" -version = "5.1.12" +version = "5.1.13" description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." optional = false python-versions = ">=3.10" groups = ["main", "dev"] files = [ - {file = "django-5.1.12-py3-none-any.whl", hash = "sha256:9eb695636cea3601b65690f1596993c042206729afb320ca0960b55f8ed4477b"}, - {file = "django-5.1.12.tar.gz", hash = "sha256:8a8991b1ec052ef6a44fefd1ef336ab8daa221287bcb91a4a17d5e1abec5bbcc"}, + {file = "django-5.1.13-py3-none-any.whl", hash = "sha256:06f257f79dc4c17f3f9e23b106a4c5ed1335abecbe731e83c598c941d14fbeed"}, + {file = "django-5.1.13.tar.gz", hash = "sha256:543ff21679f15e80edfc01fe7ea35f8291b6d4ea589433882913626a7c1cf929"}, ] [package.dependencies] @@ -5317,6 +5317,7 @@ files = [ {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f66efbc1caa63c088dead1c4170d148eabc9b80d95fb75b6c92ac0aad2437d76"}, {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:22353049ba4181685023b25b5b51a574bce33e7f51c759371a7422dcae5402a6"}, {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:932205970b9f9991b34f55136be327501903f7c66830e9760a8ffb15b07f05cd"}, + {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a52d48f4e7bf9005e8f0a89209bf9a73f7190ddf0489eee5eb51377385f59f2a"}, {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-win32.whl", hash = "sha256:3eac5a91891ceb88138c113f9db04f3cebdae277f5d44eaa3651a4f573e6a5da"}, {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-win_amd64.whl", hash = "sha256:ab007f2f5a87bd08ab1499bdf96f3d5c6ad4dcfa364884cb4549aa0154b13a28"}, {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:4a6679521a58256a90b0d89e03992c15144c5f3858f40d7c18886023d7943db6"}, @@ -5325,6 +5326,7 @@ files = [ {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:811ea1594b8a0fb466172c384267a4e5e367298af6b228931f273b111f17ef52"}, {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:cf12567a7b565cbf65d438dec6cfbe2917d3c1bdddfce84a9930b7d35ea59642"}, {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7dd5adc8b930b12c8fc5b99e2d535a09889941aa0d0bd06f4749e9a9397c71d2"}, + {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1492a6051dab8d912fc2adeef0e8c72216b24d57bd896ea607cb90bb0c4981d3"}, {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-win32.whl", hash = "sha256:bd0a08f0bab19093c54e18a14a10b4322e1eacc5217056f3c063bd2f59853ce4"}, {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-win_amd64.whl", hash = "sha256:a274fb2cb086c7a3dea4322ec27f4cb5cc4b6298adb583ab0e211a4682f241eb"}, {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:20b0f8dc160ba83b6dcc0e256846e1a02d044e13f7ea74a3d1d56ede4e48c632"}, @@ -5333,6 +5335,7 @@ files = [ {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:749c16fcc4a2b09f28843cda5a193e0283e47454b63ec4b81eaa2242f50e4ccd"}, {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bf165fef1f223beae7333275156ab2022cffe255dcc51c27f066b4370da81e31"}, {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:32621c177bbf782ca5a18ba4d7af0f1082a3f6e517ac2a18b3974d4edf349680"}, + {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b82a7c94a498853aa0b272fd5bc67f29008da798d4f93a2f9f289feb8426a58d"}, {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-win32.whl", hash = "sha256:e8c4ebfcfd57177b572e2040777b8abc537cdef58a2120e830124946aa9b42c5"}, {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-win_amd64.whl", hash = "sha256:0467c5965282c62203273b838ae77c0d29d7638c8a4e3a1c8bdd3602c10904e4"}, {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4c8c5d82f50bb53986a5e02d1b3092b03622c02c2eb78e29bec33fd9593bae1a"}, @@ -5341,6 +5344,7 @@ files = [ {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96777d473c05ee3e5e3c3e999f5d23c6f4ec5b0c38c098b3a5229085f74236c6"}, {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:3bc2a80e6420ca8b7d3590791e2dfc709c88ab9152c00eeb511c9875ce5778bf"}, {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e188d2699864c11c36cdfdada94d781fd5d6b0071cd9c427bceb08ad3d7c70e1"}, + {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f6f3eac23941b32afccc23081e1f50612bdbe4e982012ef4f5797986828cd01"}, {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-win32.whl", hash = "sha256:6442cb36270b3afb1b4951f060eccca1ce49f3d087ca1ca4563a6eb479cb3de6"}, {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-win_amd64.whl", hash = "sha256:e5b8daf27af0b90da7bb903a876477a9e6d7270be6146906b276605997c7e9a3"}, {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:fc4b630cd3fa2cf7fce38afa91d7cfe844a9f75d7f0f36393fa98815e911d987"}, @@ -5349,6 +5353,7 @@ files = [ {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e2f1c3765db32be59d18ab3953f43ab62a761327aafc1594a2a1fbe038b8b8a7"}, {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d85252669dc32f98ebcd5d36768f5d4faeaeaa2d655ac0473be490ecdae3c285"}, {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e143ada795c341b56de9418c58d028989093ee611aa27ffb9b7f609c00d813ed"}, + {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2c59aa6170b990d8d2719323e628aaf36f3bfbc1c26279c0eeeb24d05d2d11c7"}, {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-win32.whl", hash = "sha256:beffaed67936fbbeffd10966a4eb53c402fafd3d6833770516bf7314bc6ffa12"}, {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-win_amd64.whl", hash = "sha256:040ae85536960525ea62868b642bdb0c2cc6021c9f9d507810c0c604e66f5a7b"}, {file = "ruamel.yaml.clib-0.2.12.tar.gz", hash = "sha256:6c8fbb13ec503f99a91901ab46e0b07ae7941cd527393187039aec586fdfd36f"}, @@ -6254,4 +6259,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.11,<3.13" -content-hash = "ea50c84bc5c6e46b101614b78f85d3408337cc61836fa04ae4b4597512c98055" +content-hash = "03442fd4673006c5a74374f90f53621fd1c9d117279fe6cc0355ef833eb7f9bb" diff --git a/api/pyproject.toml b/api/pyproject.toml index 1d8ba7c179..d7ef074e36 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -7,7 +7,7 @@ authors = [{name = "Prowler Engineering", email = "engineering@prowler.com"}] dependencies = [ "celery[pytest] (>=5.4.0,<6.0.0)", "dj-rest-auth[with_social,jwt] (==7.0.1)", - "django (==5.1.12)", + "django (==5.1.13)", "django-allauth[saml] (>=65.8.0,<66.0.0)", "django-celery-beat (>=2.7.0,<3.0.0)", "django-celery-results (>=2.5.1,<3.0.0)", From 5b3f0fbd7f67033e9f03dda090476575e91a7a6d Mon Sep 17 00:00:00 2001 From: Josema Camacho Date: Tue, 7 Oct 2025 09:38:20 +0200 Subject: [PATCH 18/32] fix(doc): document about using the same .env as the code version (#8804) --- api/README.md | 4 +++- docs/installation/prowler-app.md | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/api/README.md b/api/README.md index df3a3240c0..60a2669718 100644 --- a/api/README.md +++ b/api/README.md @@ -18,10 +18,12 @@ Valkey exposes a Redis 7.2 compliant API. Any service that exposes the Redis API # Modify environment variables -Under the root path of the project, you can find a file called `.env.example`. This file shows all the environment variables that the project uses. You *must* create a new file called `.env` and set the values for the variables. +Under the root path of the project, you can find a file called `.env`. This file shows all the environment variables that the project uses. You should review it and set the values for the variables you want to change. If you don’t set `DJANGO_TOKEN_SIGNING_KEY` or `DJANGO_TOKEN_VERIFYING_KEY`, the API will generate them at `~/.config/prowler-api/` with `0600` and `0644` permissions; back up these files to persist identity across redeploys. +**Important note**: Every Prowler version (or repository branches and tags) could have different variables set in its `.env` file. Please use the `.env` file that corresponds with each version. + ## Local deployment Keep in mind if you export the `.env` file to use it with local deployment that you will have to do it within the context of the Poetry interpreter, not before. Otherwise, variables will not be loaded properly. diff --git a/docs/installation/prowler-app.md b/docs/installation/prowler-app.md index 03eb373b02..4a5b7eb639 100644 --- a/docs/installation/prowler-app.md +++ b/docs/installation/prowler-app.md @@ -4,6 +4,9 @@ Prowler App supports multiple installation methods based on your environment. Refer to the [Prowler App Tutorial](../tutorials/prowler-app.md) for detailed usage instructions. +???+ warning + Prowler configuration is based in `.env` files. Every version of Prowler can have differences on that file, so, please, use the file that corresponds with that version or repository branch or tag. + === "Docker Compose" _Requirements_: From 42b7f0f1a9041ef7941c74c7e527f98b56519bf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Fern=C3=A1ndez=20Poyatos?= Date: Tue, 7 Oct 2025 12:39:30 +0200 Subject: [PATCH 19/32] fix(migrations): API key RLS migration (#8863) --- api/src/backend/api/migrations/0048_api_key.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/api/src/backend/api/migrations/0048_api_key.py b/api/src/backend/api/migrations/0048_api_key.py index ff491b0385..9bc68af8e6 100644 --- a/api/src/backend/api/migrations/0048_api_key.py +++ b/api/src/backend/api/migrations/0048_api_key.py @@ -116,8 +116,9 @@ class Migration(migrations.Migration): ), migrations.AddConstraint( model_name="tenantapikey", - constraint=api.rls.BaseSecurityConstraint( - name="statements_on_tenantapikey", + constraint=api.rls.RowLevelSecurityConstraint( + "tenant_id", + name="rls_on_tenantapikey", statements=["SELECT", "INSERT", "UPDATE", "DELETE"], ), ), From 71e444d4aea9fe31ce5e0cb60cd3ac5d7877d768 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20De=20la=20Torre=20Vico?= Date: Tue, 7 Oct 2025 15:30:14 +0200 Subject: [PATCH 20/32] chore: improve API docs for `Provider` endpoints (#8723) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Víctor Fernández Poyatos --- api/src/backend/api/filters.py | 39 ++++++++++++++++++--- api/src/backend/api/specs/v1.yaml | 49 +++++++++++++++++++++++++-- api/src/backend/api/v1/serializers.py | 16 +++++++++ 3 files changed, 96 insertions(+), 8 deletions(-) diff --git a/api/src/backend/api/filters.py b/api/src/backend/api/filters.py index b1307c0563..ed706006e9 100644 --- a/api/src/backend/api/filters.py +++ b/api/src/backend/api/filters.py @@ -220,10 +220,31 @@ class MembershipFilter(FilterSet): class ProviderFilter(FilterSet): - inserted_at = DateFilter(field_name="inserted_at", lookup_expr="date") - updated_at = DateFilter(field_name="updated_at", lookup_expr="date") - connected = BooleanFilter() + inserted_at = DateFilter( + field_name="inserted_at", + lookup_expr="date", + help_text="""Filter by date when the provider was added + (format: YYYY-MM-DD)""", + ) + updated_at = DateFilter( + field_name="updated_at", + lookup_expr="date", + help_text="""Filter by date when the provider was updated + (format: YYYY-MM-DD)""", + ) + connected = BooleanFilter( + help_text="""Filter by connection status. Set to True to return only + connected providers, or False to return only providers with failed + connections. If not specified, both connected and failed providers are + included. Providers with no connection attempt (status is null) are + excluded from this filter.""" + ) provider = ChoiceFilter(choices=Provider.ProviderChoices.choices) + provider__in = ChoiceInFilter( + field_name="provider", + choices=Provider.ProviderChoices.choices, + lookup_expr="in", + ) class Meta: model = Provider @@ -649,8 +670,16 @@ class LatestFindingFilter(CommonFindingFilters): class ProviderSecretFilter(FilterSet): - inserted_at = DateFilter(field_name="inserted_at", lookup_expr="date") - updated_at = DateFilter(field_name="updated_at", lookup_expr="date") + inserted_at = DateFilter( + field_name="inserted_at", + lookup_expr="date", + help_text="Filter by date when the secret was added (format: YYYY-MM-DD)", + ) + updated_at = DateFilter( + field_name="updated_at", + lookup_expr="date", + help_text="Filter by date when the secret was updated (format: YYYY-MM-DD)", + ) provider = UUIDFilter(field_name="provider__id", lookup_expr="exact") class Meta: diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index 9908f6979e..3d68533d48 100644 --- a/api/src/backend/api/specs/v1.yaml +++ b/api/src/backend/api/specs/v1.yaml @@ -4642,6 +4642,12 @@ paths: name: filter[connected] schema: type: boolean + description: |- + Filter by connection status. Set to True to return only + connected providers, or False to return only providers with failed + connections. If not specified, both connected and failed providers are + included. Providers with no connection attempt (status is null) are + excluded from this filter. - in: query name: filter[id] schema: @@ -4662,6 +4668,9 @@ paths: schema: type: string format: date + description: |- + Filter by date when the provider was added + (format: YYYY-MM-DD) - in: query name: filter[inserted_at__gte] schema: @@ -4697,7 +4706,22 @@ paths: type: array items: type: string - description: Multiple values may be separated by commas. + enum: + - aws + - azure + - gcp + - github + - kubernetes + - m365 + description: |- + Multiple values may be separated by commas. + + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + * `m365` - M365 + * `github` - GitHub explode: false style: form - name: filter[search] @@ -4728,6 +4752,9 @@ paths: schema: type: string format: date + description: |- + Filter by date when the provider was updated + (format: YYYY-MM-DD) - in: query name: filter[updated_at__gte] schema: @@ -5018,6 +5045,7 @@ paths: schema: type: string format: date + description: 'Filter by date when the secret was added (format: YYYY-MM-DD)' - in: query name: filter[name] schema: @@ -5042,6 +5070,7 @@ paths: schema: type: string format: date + description: 'Filter by date when the secret was updated (format: YYYY-MM-DD)' - name: page[number] required: false in: query @@ -12099,6 +12128,8 @@ components: alias: type: string nullable: true + description: Human readable name to identify the provider, e.g. + 'Production AWS Account', 'Dev Environment' maxLength: 100 minLength: 3 required: @@ -13140,6 +13171,8 @@ components: alias: type: string nullable: true + description: Human readable name to identify the provider, e.g. 'Production + AWS Account', 'Dev Environment' maxLength: 100 minLength: 3 provider: @@ -13151,17 +13184,21 @@ components: - m365 - github type: string + x-spec-enum-id: 4c1e219dad1cc0e7 description: |- + Type of provider to create. + * `aws` - AWS * `azure` - Azure * `gcp` - GCP * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub - x-spec-enum-id: 4c1e219dad1cc0e7 uid: type: string title: Unique identifier for the provider, set by the provider + description: Unique identifier for the provider, set by the provider, + e.g. AWS account ID, Azure subscription ID, GCP project ID, etc. maxLength: 250 minLength: 3 required: @@ -13188,6 +13225,8 @@ components: alias: type: string nullable: true + description: Human readable name to identify the provider, e.g. + 'Production AWS Account', 'Dev Environment' maxLength: 100 minLength: 3 provider: @@ -13199,18 +13238,22 @@ components: - m365 - github type: string + x-spec-enum-id: 4c1e219dad1cc0e7 description: |- + Type of provider to create. + * `aws` - AWS * `azure` - Azure * `gcp` - GCP * `kubernetes` - Kubernetes * `m365` - M365 * `github` - GitHub - x-spec-enum-id: 4c1e219dad1cc0e7 uid: type: string minLength: 3 title: Unique identifier for the provider, set by the provider + description: Unique identifier for the provider, set by the provider, + e.g. AWS account ID, Azure subscription ID, GCP project ID, etc. maxLength: 250 required: - uid diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py index 533dc1c06d..c8e3e02f08 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -909,6 +909,17 @@ class ProviderCreateSerializer(RLSSerializer, BaseWriteSerializer): "uid", # "scanner_args" ] + extra_kwargs = { + "alias": { + "help_text": "Human readable name to identify the provider, e.g. 'Production AWS Account', 'Dev Environment'", + }, + "provider": { + "help_text": "Type of provider to create.", + }, + "uid": { + "help_text": "Unique identifier for the provider, set by the provider, e.g. AWS account ID, Azure subscription ID, GCP project ID, etc.", + }, + } class ProviderUpdateSerializer(BaseWriteSerializer): @@ -923,6 +934,11 @@ class ProviderUpdateSerializer(BaseWriteSerializer): "alias", # "scanner_args" ] + extra_kwargs = { + "alias": { + "help_text": "Human readable name to identify the provider, e.g. 'Production AWS Account', 'Dev Environment'", + } + } # Scans From 155a1813cc289d73199a997a0bd2b24357160d46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20De=20la=20Torre=20Vico?= Date: Tue, 7 Oct 2025 16:39:23 +0200 Subject: [PATCH 21/32] chore(aws): enhance metadata for `cloudformation` service (#8828) Co-authored-by: Daniel Barranquero --- prowler/CHANGELOG.md | 1 + ...cdktoolkit_bootstrap_version.metadata.json | 37 ++++++++++++------- ...n_stack_outputs_find_secrets.metadata.json | 34 +++++++++++------ ...rmination_protection_enabled.metadata.json | 33 +++++++++++------ 4 files changed, 68 insertions(+), 37 deletions(-) diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index c8420d43a4..26d30938f2 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -28,6 +28,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - Update AWS AppStream service metadata to new format [(#8789)](https://github.com/prowler-cloud/prowler/pull/8789) - Update AWS API Gateway service metadata to new format [(#8788)](https://github.com/prowler-cloud/prowler/pull/8788) - Update AWS Athena service metadata to new format [(#8790)](https://github.com/prowler-cloud/prowler/pull/8790) +- Update AWS CloudFormation service metadata to new format [(#8828)](https://github.com/prowler-cloud/prowler/pull/8828) - Update AWS Lambda service metadata to new format [(#8825)](https://github.com/prowler-cloud/prowler/pull/8825) ### Fixed diff --git a/prowler/providers/aws/services/cloudformation/cloudformation_stack_cdktoolkit_bootstrap_version/cloudformation_stack_cdktoolkit_bootstrap_version.metadata.json b/prowler/providers/aws/services/cloudformation/cloudformation_stack_cdktoolkit_bootstrap_version/cloudformation_stack_cdktoolkit_bootstrap_version.metadata.json index 494daa3337..0feb6b73a2 100644 --- a/prowler/providers/aws/services/cloudformation/cloudformation_stack_cdktoolkit_bootstrap_version/cloudformation_stack_cdktoolkit_bootstrap_version.metadata.json +++ b/prowler/providers/aws/services/cloudformation/cloudformation_stack_cdktoolkit_bootstrap_version/cloudformation_stack_cdktoolkit_bootstrap_version.metadata.json @@ -1,29 +1,40 @@ { "Provider": "aws", "CheckID": "cloudformation_stack_cdktoolkit_bootstrap_version", - "CheckTitle": "Ensure that CDKToolkit stacks have a Bootstrap version of 21 or higher to mitigate security risks.", - "CheckType": [], + "CheckTitle": "CDKToolkit CloudFormation stack has Bootstrap version 21 or higher", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Patch Management" + ], "ServiceName": "cloudformation", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:cloudformation:region:account-id:stack/resource-id", + "ResourceIdTemplate": "", "Severity": "high", "ResourceType": "AwsCloudFormationStack", - "Description": "Ensure that CDKToolkit stacks have a Bootstrap version of 21 or higher to mitigate security risks.", - "Risk": "Using outdated CDKToolkit Bootstrap versions can expose accounts to risks such as bucket takeover or privilege escalation.", - "RelatedUrl": "https://docs.aws.amazon.com/cdk/latest/guide/bootstrapping.html", + "Description": "**CloudFormation CDKToolkit** stack's `BootstrapVersion` is compared to a recommended minimum (default `21`). A lower value indicates the environment uses legacy bootstrap resources and IAM roles from older templates.", + "Risk": "**Outdated bootstrap stacks** can lack recent hardening. Asset buckets or ECR repos may be easier to misuse, and deployment roles may have broader trust.\n\nAdversaries could tamper artifacts or assume privileged roles, compromising integrity/confidentiality and enabling privilege escalation.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://towardsthecloud.com/blog/aws-cdk-bootstrap", + "https://support.icompaas.com/support/solutions/articles/62000233694-ensure-that-cdktoolkit-stacks-have-a-bootstrap-version-of-21-or-higher-to-mitigate-security-risks", + "https://docs.aws.amazon.com/cdk/v2/guide/ref-cli-cmd-bootstrap.html", + "https://docs.aws.amazon.com/cdk/v2/guide/bootstrapping-customizing.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "cdk bootstrap aws:///", + "NativeIaC": "```yaml\n# Minimal CloudFormation to expose BootstrapVersion >= 21 for CDKToolkit\n# Deploy this template as a stack named \"CDKToolkit\"\nResources:\n CdkBootstrapVersion:\n Type: AWS::SSM::Parameter\n Properties:\n Type: String\n Name: /cdk-bootstrap/hnb659fds/version # critical: stores the bootstrap version used by CDK\n Value: \"21\" # critical: set to 21 (or higher) to satisfy the check\nOutputs:\n BootstrapVersion:\n Value: !GetAtt CdkBootstrapVersion.Value # critical: exposes the version in stack outputs so the check passes\n```", + "Other": "1. Sign in to the AWS Console and open CloudShell\n2. Run: cdk bootstrap aws:///\n3. In the console, go to CloudFormation > Stacks > CDKToolkit > Outputs\n4. Confirm Output \"BootstrapVersion\" is 21 or higher", + "Terraform": "```hcl\n# Create/Update the CDKToolkit stack with BootstrapVersion >= 21\nresource \"aws_cloudformation_stack\" \"cdktoolkit\" {\n name = \"CDKToolkit\"\n # critical: template sets the BootstrapVersion output to 21 (or higher) so the check passes\n template_body = <= 21\nOutputs:\n BootstrapVersion:\n Value: !GetAtt CdkBootstrapVersion.Value # critical: exposes version via stack output\nYAML\n}\n```" }, "Recommendation": { - "Text": "Update the CDKToolkit stack Bootstrap version to 21 or later by running the cdk bootstrap command with the latest CDK version.", - "Url": "https://docs.aws.amazon.com/cdk/latest/guide/bootstrapping.html" + "Text": "Standardize on the modern bootstrap at or above the recommended version (e.g., `>= 21`) in every account and Region.\n\nApply **least privilege** to bootstrap roles, limit trusted accounts, enable termination protection, and periodically review for version drift to strengthen **defense in depth**.", + "Url": "https://hub.prowler.com/check/cloudformation_stack_cdktoolkit_bootstrap_version" } }, - "Categories": [], + "Categories": [ + "vulnerabilities" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/aws/services/cloudformation/cloudformation_stack_outputs_find_secrets/cloudformation_stack_outputs_find_secrets.metadata.json b/prowler/providers/aws/services/cloudformation/cloudformation_stack_outputs_find_secrets/cloudformation_stack_outputs_find_secrets.metadata.json index 1ec5211876..ee1d39c7ca 100644 --- a/prowler/providers/aws/services/cloudformation/cloudformation_stack_outputs_find_secrets/cloudformation_stack_outputs_find_secrets.metadata.json +++ b/prowler/providers/aws/services/cloudformation/cloudformation_stack_outputs_find_secrets/cloudformation_stack_outputs_find_secrets.metadata.json @@ -1,26 +1,36 @@ { "Provider": "aws", "CheckID": "cloudformation_stack_outputs_find_secrets", - "CheckTitle": "Find secrets in CloudFormation outputs", - "CheckType": [], + "CheckTitle": "CloudFormation stack outputs do not contain secrets", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "Sensitive Data Identifications/Passwords", + "Sensitive Data Identifications/Security", + "Effects/Data Exposure" + ], "ServiceName": "cloudformation", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:cloudformation:region:account-id:stack/resource-id", + "ResourceIdTemplate": "", "Severity": "critical", "ResourceType": "AwsCloudFormationStack", - "Description": "Find secrets in CloudFormation outputs", - "Risk": "Secrets hardcoded into CloudFormation outputs can be used by malware and bad actors to gain lateral access to other services.", - "RelatedUrl": "https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/outputs-section-structure.html", + "Description": "**CloudFormation stack Outputs** are analyzed for hardcoded secrets-passwords, API keys, tokens-using pattern-based detection across output values. A finding indicates potential secret strings present within `Outputs` of the template or stack.", + "Risk": "**Secrets in Outputs** are readable to anyone with stack metadata access, enabling credential theft, unauthorized API calls, and lateral movement. Exposure via consoles, exports, or CI logs undermines confidentiality and can lead to privilege escalation and data exfiltration.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/secretsmanager/latest/userguide/best-practices.html", + "https://support.icompaas.com/support/solutions/articles/62000127093-ensure-no-secrets-are-found-in-cloudformation-outputs", + "https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/outputs-section-structure.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "https://docs.prowler.com/checks/aws/secrets-policies/bc_aws_secrets_2", - "Terraform": "" + "CLI": "aws cloudformation update-stack --stack-name --template-body file://.yaml", + "NativeIaC": "```yaml\nAWSTemplateFormatVersion: '2010-09-09'\nOutputs:\n # Critical: remove outputs that expose secrets (passwords/tokens/keys)\n # Keeping only non-sensitive values in Outputs remediates the finding\n SafeInfo:\n Value: \"non-sensitive\"\n```", + "Other": "1. In the AWS Console, go to CloudFormation > Stacks and select the stack\n2. Click Update > Replace current template\n3. Upload or paste the template with any secret-bearing Outputs removed (do not output passwords/tokens/keys)\n4. Click Next through the wizard and choose Submit to apply the change set\n5. Verify the stack Outputs tab no longer shows sensitive values", + "Terraform": "```hcl\n# Critical: the embedded CloudFormation template removes secret outputs\nresource \"aws_cloudformation_stack\" \"\" {\n name = \"\"\n template_body = <<-YAML\n AWSTemplateFormatVersion: '2010-09-09'\n # Critical: delete Outputs that expose secrets; keep only non-sensitive values\n Outputs:\n SafeInfo:\n Value: \"non-sensitive\" # Avoids exposing secrets in stack outputs\n YAML\n}\n```" }, "Recommendation": { - "Text": "Implement automated detective control to scan accounts for passwords and secrets. Use secrets manager service to store and retrieve passwords and secrets.", - "Url": "https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-secretsmanager-secret-generatesecretstring.html" + "Text": "Remove secrets from `Outputs`. Store credentials in **Secrets Manager** or **Parameter Store** and reference them via dynamic references; set `NoEcho` for sensitive parameters. Apply **least privilege** to view stack metadata, avoid exporting sensitive values, and add automated IaC secret scanning for **defense in depth**.", + "Url": "https://hub.prowler.com/check/cloudformation_stack_outputs_find_secrets" } }, "Categories": [ diff --git a/prowler/providers/aws/services/cloudformation/cloudformation_stacks_termination_protection_enabled/cloudformation_stacks_termination_protection_enabled.metadata.json b/prowler/providers/aws/services/cloudformation/cloudformation_stacks_termination_protection_enabled/cloudformation_stacks_termination_protection_enabled.metadata.json index 03011fc315..c9d43bcb7e 100644 --- a/prowler/providers/aws/services/cloudformation/cloudformation_stacks_termination_protection_enabled/cloudformation_stacks_termination_protection_enabled.metadata.json +++ b/prowler/providers/aws/services/cloudformation/cloudformation_stacks_termination_protection_enabled/cloudformation_stacks_termination_protection_enabled.metadata.json @@ -1,29 +1,38 @@ { "Provider": "aws", "CheckID": "cloudformation_stacks_termination_protection_enabled", - "CheckTitle": "Enable termination protection for Cloudformation Stacks", - "CheckType": [], + "CheckTitle": "CloudFormation stack has termination protection enabled", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "Effects/Data Destruction" + ], "ServiceName": "cloudformation", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:cloudformation:region:account-id:stack/resource-id", + "ResourceIdTemplate": "", "Severity": "medium", "ResourceType": "AwsCloudFormationStack", - "Description": "Enable termination protection for Cloudformation Stacks", - "Risk": "Without termination protection enabled, a critical cloudformation stack can be accidently deleted.", - "RelatedUrl": "https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-protect-stacks.html", + "Description": "**AWS CloudFormation root stacks** are evaluated for **termination protection**. The detection identifies whether `termination protection` is enabled to block stack deletions on non-nested stacks.", + "Risk": "Without **termination protection**, human error or automation can delete entire stacks, causing immediate **availability** loss and potential **data destruction** of managed resources.\n\nAttackers with delete rights can more easily trigger outages and hinder recovery.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-protect-stacks.html", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/CloudFormation/stack-termination-protection.html" + ], "Remediation": { "Code": { - "CLI": "aws cloudformation update-termination-protection --region --stack-name --enable-termination-protection", + "CLI": "aws cloudformation update-termination-protection --stack-name --enable-termination-protection", "NativeIaC": "", - "Other": "", - "Terraform": "" + "Other": "1. Open the AWS CloudFormation console\n2. Select the target stack\n3. Choose Stack actions > Edit termination protection\n4. Select Enable and Save", + "Terraform": "```hcl\nresource \"aws_cloudformation_stack\" \"\" {\n name = \"\"\n template_url = \"https://s3.amazonaws.com//