From ba258a5346eddd1afd753deee6c2d884c13cf5f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Mon, 14 Sep 2026 11:01:15 +0200 Subject: [PATCH 01/23] fix(container): patch high Debian CVEs in SDK and API (#12804) --- Dockerfile | 15 ++++++++++----- api/Dockerfile | 15 ++++++++++----- api/changelog.d/api-image-debian-cves.security.md | 1 + .../changelog.d/sdk-image-debian-cves.security.md | 1 + 4 files changed, 22 insertions(+), 10 deletions(-) create mode 100644 api/changelog.d/api-image-debian-cves.security.md create mode 100644 prowler/changelog.d/sdk-image-debian-cves.security.md diff --git a/Dockerfile b/Dockerfile index fdc85831de..559b39c9eb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,20 +22,25 @@ ARG POWERSHELL_SHA256_ARM64=2503b71da3e83635592b092df59a0aca4c3606b4d9b068217bb0 ARG ZIZMOR_SHA256_AMD64=a8000f3c683319a523d3b20df0e75457ba591f049cfcbfa98966631b56733c03 ARG ZIZMOR_SHA256_ARM64=d66e37ef8a375fb07939c630ebf9709a6e0f20242bdc3faf672a7ed97e0b768d -# High CVEs fixed in Debian trixie-security but not yet in the pinned base image: +# High CVEs fixed in Debian trixie but not yet in the pinned base image: # openssl/libssl3t64/openssl-provider-legacy 3.5.7-1~deb13u2 CVE-2026-14456, # -14457, -18798, -54874, -63072, -63073, -63074, -63075, -63076, -75803 -# (image ships 3.5.6-1~deb13u2) +# libsqlite3-0 3.46.1-7+deb13u2 CVE-2026-11822, -11824 +# gzip 1.13-1+deb13u1 CVE-2026-41992 +# perl-base 5.40.1-6+deb13u1 CVE-2026-42497, -48962, -57432 +# libssh2-1t64 1.11.1-1+deb13u2 CVE-2026-58050 +# libpcre2-8-0 10.46-1~deb13u2 CVE-2026-86145, -89161 # Taken as a targeted --only-upgrade rather than by moving the digest: the newest -# published python:3.12-slim-trixie carries the same vulnerable version. The three -# packages are all built from openssl and are flagged separately, so all are named. -# Drop them once the base image ships 3.5.7-1~deb13u2 or later. +# published python:3.12-slim-trixie carries the same vulnerable versions. The three +# openssl packages are flagged separately, so all are named. +# Drop each one once the base image ships its fixed version. # hadolint ignore=DL3008 RUN apt-get update && apt-get install -y --no-install-recommends \ wget libicu76 libunwind8 libssl3 libcurl4 ca-certificates apt-transport-https gnupg \ build-essential pkg-config libzstd-dev zlib1g-dev \ && apt-get install -y --no-install-recommends --only-upgrade \ util-linux libssl3t64 openssl openssl-provider-legacy \ + libsqlite3-0 gzip perl-base libssh2-1t64 libpcre2-8-0 \ && rm -rf /var/lib/apt/lists/* # Install PowerShell diff --git a/api/Dockerfile b/api/Dockerfile index 6866494bb4..ce5bccbb2c 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -21,14 +21,18 @@ ARG POWERSHELL_SHA256_ARM64=2503b71da3e83635592b092df59a0aca4c3606b4d9b068217bb0 ARG ZIZMOR_SHA256_AMD64=a8000f3c683319a523d3b20df0e75457ba591f049cfcbfa98966631b56733c03 ARG ZIZMOR_SHA256_ARM64=d66e37ef8a375fb07939c630ebf9709a6e0f20242bdc3faf672a7ed97e0b768d -# High CVEs fixed in Debian trixie-security but not yet in the pinned base image: +# High CVEs fixed in Debian trixie but not yet in the pinned base image: # openssl/libssl3t64/openssl-provider-legacy 3.5.7-1~deb13u2 CVE-2026-14456, # -14457, -18798, -54874, -63072, -63073, -63074, -63075, -63076, -75803 -# (image ships 3.5.6-1~deb13u2) +# libsqlite3-0 3.46.1-7+deb13u2 CVE-2026-11822, -11824 +# gzip 1.13-1+deb13u1 CVE-2026-41992 +# perl-base 5.40.1-6+deb13u1 CVE-2026-42497, -48962, -57432 +# libssh2-1t64 1.11.1-1+deb13u2 CVE-2026-58050 +# libpcre2-8-0 10.46-1~deb13u2 CVE-2026-86145, -89161 # Taken as a targeted --only-upgrade rather than by moving the digest: the newest -# published python:3.12-slim-trixie carries the same vulnerable version. The three -# packages are all built from openssl and are flagged separately, so all are named. -# Drop them once the base image ships 3.5.7-1~deb13u2 or later. +# published python:3.12-slim-trixie carries the same vulnerable versions. The three +# openssl packages are flagged separately, so all are named. +# Drop each one once the base image ships its fixed version. # hadolint ignore=DL3008 RUN apt-get update && apt-get install -y --no-install-recommends \ wget \ @@ -46,6 +50,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ python3-dev \ && apt-get install -y --no-install-recommends --only-upgrade \ util-linux libssl3t64 openssl openssl-provider-legacy \ + libsqlite3-0 gzip perl-base libssh2-1t64 libpcre2-8-0 \ && rm -rf /var/lib/apt/lists/* # Install PowerShell diff --git a/api/changelog.d/api-image-debian-cves.security.md b/api/changelog.d/api-image-debian-cves.security.md new file mode 100644 index 0000000000..2d8b0403ad --- /dev/null +++ b/api/changelog.d/api-image-debian-cves.security.md @@ -0,0 +1 @@ +`libsqlite3-0`, `gzip`, `perl-base` and `libpcre2-8-0` upgraded in the API container image, patching high Debian CVEs diff --git a/prowler/changelog.d/sdk-image-debian-cves.security.md b/prowler/changelog.d/sdk-image-debian-cves.security.md new file mode 100644 index 0000000000..50bd4186f3 --- /dev/null +++ b/prowler/changelog.d/sdk-image-debian-cves.security.md @@ -0,0 +1 @@ +`libsqlite3-0`, `gzip`, `perl-base`, `libssh2-1t64` and `libpcre2-8-0` upgraded in the SDK container image, patching nine high Debian CVEs From 8b265a83140d78f342a887359478d1dc9702246e Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:18:31 +0200 Subject: [PATCH 02/23] fix(ui): defer billing onboarding and add AWS button styling (#12803) --- .../aws-marketplace-button.added.md | 1 + .../defer-onboarding-on-billing.fixed.md | 1 + .../__tests__/onboarding-gate.test.tsx | 52 ++++++++++++++++++ ui/components/onboarding/onboarding-gate.tsx | 7 ++- ui/components/shadcn/button/button.tsx | 2 + ui/styles/globals.css | 3 + .../aws-marketplace-button-desktop.png | Bin 0 -> 12676 bytes .../aws-marketplace-button-mobile.png | Bin 0 -> 10992 bytes .../aws-marketplace-button-tablet.png | Bin 0 -> 11292 bytes 9 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 ui/changelog.d/aws-marketplace-button.added.md create mode 100644 ui/changelog.d/defer-onboarding-on-billing.fixed.md create mode 100644 ui/tests/onboarding/evidence/aws-marketplace-button-desktop.png create mode 100644 ui/tests/onboarding/evidence/aws-marketplace-button-mobile.png create mode 100644 ui/tests/onboarding/evidence/aws-marketplace-button-tablet.png diff --git a/ui/changelog.d/aws-marketplace-button.added.md b/ui/changelog.d/aws-marketplace-button.added.md new file mode 100644 index 0000000000..235112967f --- /dev/null +++ b/ui/changelog.d/aws-marketplace-button.added.md @@ -0,0 +1 @@ +AWS Marketplace button variant with outlined styling for light and dark themes diff --git a/ui/changelog.d/defer-onboarding-on-billing.fixed.md b/ui/changelog.d/defer-onboarding-on-billing.fixed.md new file mode 100644 index 0000000000..5afcc36bcc --- /dev/null +++ b/ui/changelog.d/defer-onboarding-on-billing.fixed.md @@ -0,0 +1 @@ +Automatic onboarding stays hidden on billing pages and remains available after leaving billing diff --git a/ui/components/onboarding/__tests__/onboarding-gate.test.tsx b/ui/components/onboarding/__tests__/onboarding-gate.test.tsx index 4198ba5846..9a5f7cf88e 100644 --- a/ui/components/onboarding/__tests__/onboarding-gate.test.tsx +++ b/ui/components/onboarding/__tests__/onboarding-gate.test.tsx @@ -9,9 +9,11 @@ import { OnboardingGate } from "../onboarding-gate"; const pushMock = vi.fn(); const armMock = vi.fn(); +const pathnameMock = vi.fn(); vi.mock("next/navigation", () => ({ useRouter: () => ({ push: pushMock, replace: vi.fn() }), + usePathname: () => pathnameMock(), })); vi.mock("@/store/onboarding-checkpoint", () => ({ @@ -30,12 +32,62 @@ describe("OnboardingGate", () => { window.localStorage.clear(); pushMock.mockClear(); armMock.mockClear(); + pathnameMock.mockReturnValue("/"); }); afterEach(() => { vi.restoreAllMocks(); }); + it.each(["/billing", "/billing/", "/billing/checkout"])( + "defers onboarding on %s without resolving it", + (pathname) => { + // Given + pathnameMock.mockReturnValue(pathname); + + // When + render(); + + // Then + expect( + screen.queryByRole("button", { name: /get started/i }), + ).not.toBeInTheDocument(); + expect(localStorageAdapter.get(addProviderTourId)).toBeNull(); + expect(armMock).not.toHaveBeenCalled(); + expect(pushMock).not.toHaveBeenCalled(); + }, + ); + + it("offers onboarding after leaving billing without remounting the gate", async () => { + // Given + pathnameMock.mockReturnValue("/billing"); + const { rerender } = render(); + + // When + pathnameMock.mockReturnValue("/"); + rerender(); + + // Then + expect( + await screen.findByRole("button", { name: /get started/i }), + ).toBeInTheDocument(); + expect(localStorageAdapter.get(addProviderTourId)).toBeNull(); + expect(armMock).not.toHaveBeenCalled(); + }); + + it("does not suppress onboarding on a route that only shares the billing prefix", async () => { + // Given + pathnameMock.mockReturnValue("/billing-settings"); + + // When + render(); + + // Then + expect( + await screen.findByRole("button", { name: /get started/i }), + ).toBeInTheDocument(); + }); + describe("when the user has no providers and no completion record", () => { it("shows the Welcome modal", async () => { render(); diff --git a/ui/components/onboarding/onboarding-gate.tsx b/ui/components/onboarding/onboarding-gate.tsx index 0161bffef4..f32d411938 100644 --- a/ui/components/onboarding/onboarding-gate.tsx +++ b/ui/components/onboarding/onboarding-gate.tsx @@ -1,6 +1,6 @@ "use client"; -import { useRouter } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; import { useState } from "react"; import { getOrderedFlows, shouldStartOnboarding } from "@/lib/onboarding"; @@ -20,6 +20,10 @@ interface OnboardingGateProps { // via useSyncExternalStore — server renders nothing, no hydration mismatch. export function OnboardingGate({ hasProviders }: OnboardingGateProps) { const router = useRouter(); + const pathname = usePathname(); + // Billing must stay usable before onboarding; leaving it keeps the gate eligible. + const isBillingRoute = + pathname === "/billing" || pathname?.startsWith("/billing/"); // Gate forces only the first flow (`add-provider`); remaining flows come via checkpoint/replay. const flow = getOrderedFlows()[0] ?? null; @@ -32,6 +36,7 @@ export function OnboardingGate({ hasProviders }: OnboardingGateProps) { const activeFlow = flow && + !isBillingRoute && !resolvedThisSession && shouldStartOnboarding({ hasProviders, completionRecord }) ? flow diff --git a/ui/components/shadcn/button/button.tsx b/ui/components/shadcn/button/button.tsx index f34347507b..bc37c08bc9 100644 --- a/ui/components/shadcn/button/button.tsx +++ b/ui/components/shadcn/button/button.tsx @@ -19,6 +19,8 @@ const buttonVariants = cva( "border border-transparent bg-bg-fail text-white hover:bg-bg-fail/90 active:bg-bg-fail/80 focus-visible:ring-bg-fail/50", outline: "border border-border-neutral-secondary bg-bg-neutral-secondary hover:bg-bg-neutral-tertiary active:bg-border-neutral-tertiary text-text-neutral-primary focus-visible:ring-border-neutral-tertiary/50", + "aws-marketplace": + "border-2 border-button-aws-marketplace bg-transparent text-button-aws-marketplace font-semibold hover:bg-button-aws-marketplace/5 active:bg-button-aws-marketplace/10 focus-visible:ring-button-aws-marketplace", ghost: "border border-transparent text-text-neutral-primary hover:bg-bg-neutral-tertiary active:bg-border-neutral-secondary focus-visible:ring-border-neutral-secondary/50", link: "text-button-tertiary underline-offset-4 hover:text-button-tertiary-hover disabled:bg-transparent", diff --git a/ui/styles/globals.css b/ui/styles/globals.css index d879b452d2..c8f60e2992 100644 --- a/ui/styles/globals.css +++ b/ui/styles/globals.css @@ -92,6 +92,7 @@ --bg-button-tertiary-hover: var(--color-blue-500); --bg-button-tertiary-active: var(--color-indigo-600); --bg-button-disabled: var(--color-neutral-300); + --button-aws-marketplace: #232f3e; /* Radar Map */ --bg-radar-map: #b51c8033; @@ -218,6 +219,7 @@ --bg-button-tertiary-hover: var(--color-blue-400); --bg-button-tertiary-active: var(--color-blue-600); --bg-button-disabled: var(--color-neutral-700); + --button-aws-marketplace: var(--color-white); /* Neutral Map */ --bg-neutral-map: var(--color-gray-800); @@ -330,6 +332,7 @@ --color-button-tertiary-hover: var(--bg-button-tertiary-hover); --color-button-tertiary-active: var(--bg-button-tertiary-active); --color-button-disabled: var(--bg-button-disabled); + --color-button-aws-marketplace: var(--button-aws-marketplace); /* Input Colors */ --color-bg-input-primary: var(--bg-input-primary); diff --git a/ui/tests/onboarding/evidence/aws-marketplace-button-desktop.png b/ui/tests/onboarding/evidence/aws-marketplace-button-desktop.png new file mode 100644 index 0000000000000000000000000000000000000000..ddfac1e755a83a4ceb9be7926e1ff18a4b3ccae0 GIT binary patch literal 12676 zcmdUWXH=72*Jcm}QOcu;NEZPSX^&K?K~ZT^q<1V7>C!t`9y&+|r6eFVbO;?nM5Ie6 zv_JqUfzYG`NJz-syzlp|HQ!n@^J`|!%%7axN$%vFefHV=+Sj!ct*4{LaE1K}2n1r# zP=BHi0#Sblo)a%$03MeR&6yz3Um%Sqj|}~@x8`YswXr-sd(78@Cd3|%nAW^`;^I{B zR%>9vDc0Q8$SBsBw&H%peOIH#7j@Dx(?Q`;fAK!sin?~K=1%W*0>zW(^Rm5EF#;w` zZnFuPpYnnP?KEQ;=&FL@9FtesDbhBU%BV6GG87!f06Fu zTVVKp@q!dEyu3hl{>i^TfEa+`i^_W%V0eD}|IOQYThkp$ewS!yc1*T$s4^+nF+Jqw zP>uqatSBfXq%j*FdjmI#H(RF0ivDR2T+MXK5Vx5uvl1p({__tS`5am{Yd4U-Gtyq~ zw9pw*xAF#CGSakq8@#{YoIx`(z>Gb*W&v6mmMc3ld7kKS&cuZ#~ZX`1tIqw zwg}q^Pp9&~;WbRA_Fc_or(A6PzGL3MzwP+&;Y0d$$`6~$cinyH)s;g(<=-VG>kAaq zg4qgl8JOja5nM7hy}ap`hX(u5kN`BGCCA>`Po&mi7}61Xdf12r$pkzEfy~lWU%&pA zF30X4xIOQuQZGQuBe$LCBR&IGgss8WWSjA%7W1o@&8>s(icH?oDIy5f>-jmm?OPJ#m3phUFm( zd9v@lyLXQ^Vc-gDxZ~zs_rbQX6EYR(w(~$aFK4nh{!m0GM}8UZTI>-aXd#V4M>6qD zp%uu-w;WPvAxsG)g2$#3DPwifk#p8@Z7ZX_!l=XY^ttY`&Cz^8Dapz5dE4x{rACXO zg5FJ6)LJt)g2pPYt-K@7d8(2r*vG%Kd%HsUu#UK$68yoAg6(_&)ry<^wR}nqQXxtR zs|fCp6u153aAf!B-aw8wI7RCiq48E$J-6S=;tW_O2wKU$dY#Y-GvCeSvD~%mfH=lW=jW! zP7|)yPSZfFYdOtk3;GuN-qa)Sv_1nT~6hFJ~C#MM?1B3Wm~(>ZkSnoU8t}(Fbg9*sY815{{9wBglNwW zC{$sL0(wG8jtwH(gMV=b8>UxL3izO{&-+atFMOC|_11%EL0*KIps;rqXnov?3p@gR z&?%crK@cdR?zvo4^|k>8-PUDNuN!%>#qgbpYndwDDslZ~zDPzF@H*eygx$2Rv@}cGjif(`pPQO224fLJ2l z@j#aGga8sC@w;Z}E*Wa(}?t=3!V%@D+1*+H0DDp+yI=Fvhn@ zX_j!$(0FzuR6)T?o;!jvaJZm9w1KKzg&sJp{Q9a2|0x}9116o6p%nLxhaqwmZz5&P zXAh4Jwj(waTw>Iy{dT*MH%0YLwKXFQ;$NxKYLi^*l(R6D*|HEML{aCPZNG`^@9mCO zU)rKsaOhmnSX6B6F2*@`>xrXK+g>2Wz=NQSee)cx<{>K0ako%%_ z_f(BH%*?w52jsp*Y+4fjB4{zm{`AUWUrJtJ;Ldn!5G+|>B}`k|(dS;C$E`Z~r5t6D z`M0Bikihk0;*L7EUX8-yncl`ThE3SBT~Xlkrytg>&-TUjl(f}kHBLp1~gz8L6 zK(}hz{*0BuiX8R^8+n2>>6l7`)`+c`^<7GK^HtWM zs);cH)B@{iTaj+R!N8OeG&Q7wO#5-%<}nSpDy#_t0!@R_wCi%nd2p~E%Eo3r0Lfnj z?J|>c@ejLIcY`1`P}S*FOE##0`hUyjjxMOY+Zd{_%dLHna;){hv_4B3PdZF_VZ3>p zlCQPeMEU;esixrzWua zXg_-a8R!!_&AZJ5yNlCG#Nj(aELN>*&1qpbd6cV_c<;IZ8I&v#ri8~D6ze!Unl=Pi zI2l%$?|J^b1{;B1Wf zpDkhPhH>}(u4S*09TKrpvcC@d)=C;|qwKX) zoPP~w1>gc+pZzGjUTxTF&mc?w0DuVf4Ri6>2B##&P!*(3e+%B?Ix};jTj^c2lK)Li zQm4&AUFZwGVIr(@O!$*psLURR=o?NhczwYB&N?nWt3l(!xCzW7pGFAFbFlPhy^J>* z&41gtEIqh#{mU2A<)^0K41_=Ep02^Il^|gbKNEp~Vm6ODf}LK%GdQzOmRVRp53K`b zOEj*B?9R(xRWjBqJ?%a)61QqGhtrFR6Z}08aLv`2G~w2@z7#YrdL_dfK$l~Q$J+zZ zJe%ZDCdji9bqNF8lUL9|EfW!Ph02E&FKU~F&y`lKEiK1H{AOv57nUp#th}TuU77D& zflE=QsE4j*u$*l~_UY*8NMu2<)?CvT#+{?1HDm+bqxDHMpP{c=qi}^VuX@>(Xcw5;3~x{A(#(H)@NA#yiBE)0Q%PZme?ZPuM#W9!QuMhf~0@Lceu#8jr?j^n%V#C3H;!dQqp4Tj#aED<`gSHn-Y@uQi zQ~F=pqJ6DK19!pXY|b9t9C>(Xr!g6mik~RbX4h=Hsu1=HRfz`Z5gN zoOqP{A)QJzQ`UM5p~>z%<>oXyt?Hv`UtwOaUwVM;PaE$l{1u^X5q26f>T5#*b$%^< z`ucZHDpOJnES-u)7-O?kP{6;OXtwN6m&5Lre61GD7i|nZ+;0KDmL`{0@WYb@v|KR1 zH(TFiv`sJfs2;W+%94Ke83Yz%5lJ$?SKF0Vb)iz}Eq=6H<4ma&32LdAtPwX0lRe}M z>db};UkZpk|Zk-(7XcI~xryGh_RV2^`?3p~Iuc>S>0^vX;0oYf3P1 zvZ}pg7)QGIu7l5{EY9?Y4#)}c2u8MCMuF6OAw7d7^^oy z8+Q-fV-Hcn7ccRFfjFj5fa8)Db_{%raGyq*5BVzZh|lv7MRrlF#~9)K>H{{VfTu~{ z!sAoA=u@yi#-1F#^o6^q+WU3S=+(-7y&WMT0E@0O9kY_^u8 zm2x%%3&j}?@U{3mtw#H@Ys=zi*=K?6f0Vo!J)Gj5Q#S!|6lP3T{Gh zzx0i##^H)M0v3`^3gIJmXSs)!$a8U3yz#>*2Gn_LwCEd=-Q*{D|LEV2O1Lge)y8tjD&P8ad;Hc?ub`Vxc+&5# z9kpp;lPXvTcV=Of7{Q1VZL>Qi(y4xhHT3zZ#fnf|%IIp-D@T z_zn)COItGzijUNa9oklUZpt?H6HD?^7mV`HJ@Y)~SBEvwkA(fQ$nKCU)T_8q7nGAJ zwCg%b3YB+37d4}1+6!)#XY|j}ci!h8P@d1L@VLBI@#PjGr{i0dJ>hFB0D6c7e5xU> zigN_tT>(6|T%D>w=>o&9YCm=`O&H<1{Dmp^0B7qxW_RcRHnzDc&CUlyZb^sg~Vz#1w1eqBI% ze;a()_77hQ58F_9ct*SImDjr%>W#MaGLxO+ZMZ{co%eimN@^&w<^&Nq*sTE#;}3Fk z+F$9@BTq4fd}$iO^^Vdf|K#BgeROo6RXkfDi0^uotji&RkReCRg{gL^jS;`a2w=+a z4V2F)92By55d&*qA|4B#JF?}uBSQxn@1*h{FApEnx^Q0Er&D0IB`J6up7{tIs?rG@ z6)K)oJqqzd%-4qdY)qL3R~W%Fl|5e`+*|;v4Ka)MqqRYceVdbRZFCEsvt-z87?Ga- zO8={*PQQU6O#i{EOLTNJG0I`U=hZ%J$P76&j%^M>6Q$5PIve4|OYVrfX73JZ-ODu0 z+Rwb2&y4>G58o_KNeObEaC}i6 z#$njR`4$g!q!#a0OVKNMvYK9@(9_-R$o98?uY%K1+0-{>Zdr}ps#kL525|Z-`tk8u z!M3_8@YK!)%Dcz?PuUh)%BGUX8}1tx&x*T0UI-GjNaYcJh5+jfiagzWR5cGyudza> z&w^!~k4SR_tNX@ym#KDihqn*$kFXb# z+hm1|a4TNXen8^}yQvE~?0X-C%}Kisvlfs}zBspxxuh-eqf~LSCIoF5>$ShdB5bS| z-o4%4N@U~GcU3lM@Z-HCB0e9eYyL zs@aN3C?O?+*k+3@4k**v*G{b0-z^r=_B9`?n%p$5z?c>WzA2j;%eQbKH|YYQ1E-o4 z#38+z``J^Hn+hh$Sj{HkJyFcu?lj&)m`dp>M1l9WJA_Rt4Rne43FxZY7bjGy`k+ym z1!QAdhsW$tx6XC^u{-h<)Aas*2sigmM`Qw}`R!MX_%7V(MsJv3Q}~uM3jdawJ#g*i zhzvmxCYH~aEFN{@ate?~96gp+&4EVJW5hGtl>>?TbMW%b#rOln@m}i@ZbkqVU>&%0 zCO6wo@kp2dHgNyW=G3}z!4p@-s*g`z$u`msMT}w#-N@EEutIH#9XF&;QMYC3s}W*> zhkA6HY$QNR!LcH@2s@@_@ON9Y2op)&m>KBjAe(xDZ0$@^j=dc7Tn00E4EyzHo_=|8 z8DUi`!P{prN^ic`M6Z@z};QQmnhc6LYKhr&S)I z`+Ew+hdy6tzCdzvv_ci|lBhG!F;lYb4S?&i`$~M5Q)(m}s%T%3PK9&2ZCq98UvuXT zxP|g3Gy)!`zIeKUmGcxnxK;J$n_=ZvY1m=yngp6{;C5sk(aLJz)Wy08`b^bWh)Vd3vHO|Ql8uf7T@l0NR;0ue4P z4>p49$!j2obN;ZSzZdAnMX`L7RzkRtCHH{1mazUcbQ5A)>)t zfJ`Rrprb<=G9qtCum$d)nL#r!!jz1A)*>&l^00i4V^yhvX)_xr3o-gvf4T>z-LBc& zeDDb1EMzM!Y!z=k5FopzD%}gvJ={&RZ#F-H%yYyEcxn8j#X2lw?XPQHwcC&e5SeC( zXRus=6d7z%o)OheHAdRA9Uy#C8r!Zc%d1OK{C`~zsOg4#75D@YQf;CQ5hL({i2AFm zg6w6XgxbDhf_G0tpzbjDu*RwdG!^w3puf1PS2133JS}yFYLf=qm1eDkQoHZZ%om{a zq1J5YxQP1&qmWvUatpsewu0Z8rKx|-w{W1|CLxX2W1^##d~k>HS&gN}u(yhms%z1) zv0&dV+vhGW8)Jn9GTFGxDeLPTY6&^LS%L`{6iS? z>ku#)_T^+@5oK8&FsVEzkua#X00VGUtoc{{Lhg)3XT=}77=eIBJArYz<(Tx@zo%Aas>NfrVl3Cy`!9wl;S$o_0KlGXD6Mamz12@AQssyOKETw_>NTP8c z{-wpdyi5yR{(q&@{8urlx6-aCzeh)8&$`}j^YbTKCN;~p0xFeBqi_DBfQr-b7E|I? ze3T~K>;!r*($D+g!Ha>5z@C%5NvRB3!7bz}1N7ve^HiyItt}k`sJ8OYpQw;Wv{SCr z@?jtRHjM`{VeY1@n!0a+y9Es*-aUuzS>VkF=jQI`fZ<{N1vR<>N*aMQiK~4PIx>E> zqbb@@7m%IgJq}d{P~z%z`vvmMPxrGgjkc4A?zjn99@Y0SqeTpL$SGnpKM3om)}BWN zs{M;2;X}G-YynnU82iGL<_{0nx;Vmx9wF~7Zd|91%m{J>y)XZf&aw_Hv|l&n02q@8RT0#Xmf%TDX)FheGfu2FNNp~NfOmCSjoG-n@o{G3$ z!z^k6_B;C($^iP4t8eG*hN=GIg$Atk5)OX{m=LucmLmmHxl$XD8ec#I>b>rAGq=%Y zezd{I3s}xa7j8Q~7%<<9Svld$iyfWy&PG!pkWla13z=kg&~2^inOkp*=Z5GWyaKMI zUHGYP5A>!AUpv<+%~<~TD}lCCTNW=zcz4sB$s}rBD8o4Dwq$dv$I;=t-HE)Mpb95I zdvtuK{`~obONUUS*W%xAau-0~ zd%2nJ7&?^aljs+D^|Nm4}@V z{U_?nm+5a$ZDE&e_X7{(W0NupU(6ErEakj3mmfb_0YdE|77@HU+h~QeNir7ovb3vA}`Z!(0M!}wR!!h{54#EN5nAAPN;~;9?uvW-D9P;<;sOM)A zK^Ug-uyGMqqT@nCp_Db((ODi}kKkvnRa6l{5E=#_2}*0PD~1N@bK9}3Q-Qwo#q)CZ z-l(vU;+7~mUZfJQcOQRzzqYE4(0(Jb{<(2$5&G)9yh)k9#%#VaF}OSX5*=NmbV{gA zlc98I)@}tm9ygJSRtjP&>2qywHS7exgwA1FSok)~*nOOgPD48MHHIr$EF`aRDAI8-UM}%v& zQWE|6#I4dJNLH~emG;~Zy;Gm>DEw6}{%$a$Vk}=1$;!7oRFy9GYTs|{Xr@M3MDexX zOT8zyD+!XJ=<2RgHNxV5asehg?8jXbRY)jI+;*cBuOD*+2vW<#?J=F1YBw0aj_}XxS1hy}!?bFS&XxiIq=$<0{);hrgPd8eIoc)Rb6! zj7u#;_?zwTxBBA#AcwdL^45fQ^^FDWqla3L_)T(JmC@FZ8$>McoVJtugpf&UR?V)M zg1ou@e%avKgIMZF*8}u?y;tPUFt~yWh@by};-A-q9>@rhb;usZi|wBz2pPP!TE$16 zt*;Jzo9BPG8n~ew9g`&07(*iVSNl^v#MlDOJ@H-hA5M+1z#^Dyuh{EJRjnm#5X5-Djm^m74yUZQ^>)kLlR{M6@ ztYTIIIWx6#QjVs&cRw+5AK@4H;)y=a4R5pCNbeD+)Sr@Va@FQ%J<&UT_U=q> z)$XJM$9S}&xQEU8QM^pW{`K1cTsQu7ar?n;`%I&W$yZK^*Kree6>A!rnqz2JsLS0( zXIbLUpFjI6?Ab29mZliEn)g;ryl>o{s|YMWyfMkO*Qhk7k#b736cjTls++@9(^LL7 zZ|uVst=h5|KOQc*hL&jdT{d=SK65B>P?d=&5*C90mT9!mQ=*iQo^mYYCn@0pdD zm^*A!uD)a+uM`|i&ZxlLe!AHfaNs{t=34)2O`H8pYBS+dtcfO<`OVOc{j{9cV@w+h zpI`mk-+6W_9Ulp+lMjOSxm}uq6nKPPQqHQ4i~zAVe=EQZg))WrvKVpK!(H+ zkZyXfUmoeT)PJGuNh*-4HPg9dbvsPC*$6++%v&7q<-0R&eBm&1xqU+9Q=9=rzC}k{ zHoBB5D5b|;s@w*PL*1ji4*{ZNK--fV%}SPX`=PZ&(q{iQnoa3y4-lH2`mSei)ykFh z0D;?M;lttz)npoE1nYvEQE|J<8u&c9E|5-}eR3e3^p~0$&qmVJ!_Z$*TQhJd6Ec@O z8Ej(>X!y1)7vI{(Fm;E>5zsbnT|d>yM)*2cl%bZ`Lb`(Y%1M*PLc=V}!?3+8%c|xw z_zAT2aX0vu2EXfk+uoSbX8nr604gdb#+a;IuVc5?7T`6oeLRkh_y-bFW8tt}CDITV zn*M|EQ}@|M&a0(s`iwSp-uR$ST3LKKa!@>AT|MMD6KAb4c3)+)YagT3+ROOO1Da9Hz!;m3;uTog2X7RhlapNG6%FW-o&+^Z_beeORU;mJ@C|Q97`!vQ`N--vs0NFA2 zM(8i6#E4*)AO?Sf`|Y1-+oTRr?X%#U%*-4i%SC%zoRrT61qE6rm3QYglJC?#7++3$ zgLX|~kWB`?kBsHz^uMehF-w<%v}kZbl$!e0XMZ9<2XYiLNRAYFy1}lP8&J<;%ODURfK+ni>r&7*_QUy?85Z*r;}cvy z)&*tpfk1Iv4^)8DJ&72Q9P>Yzud#g#qDaha^>(YV*YYM0CeeVtf7;s9{HK55lbYKx zS~UJl+-G+k)nB3clmR4>@&wD`ECZYMVjd<+7HLnoL;`qGCH)nW$~=x6Y=?mkNP$lL zG`0h_f;y$L?x;`$?@dveU!>vMw{D&s2LCxUJ6onoY_{xw7!=s#hZ_--eC7r;q}?8< zJ_f2GGinYE0ERUso_EjvU#)Tyt>Eq-9!0j9RN@bq6hmYH2>NS9zk6o96JZePyHj5Z z>`_6-d3^%E^e+r8Gt`vdqx&es&%7M{nBw5^=SIukmy{ z{TcSNYi7V?nOeA86i-bOPX#o?gD-nOh=+igaoV=i}gU?2S;DQ3ITbskR5S zjqn6gLi3|Qnm>BL_R2h(MGRg8ft~@L1OV{WhOZ7v3?T07^>q6G_KUsSkA1r3`-ef? zck1bWU(5u(PyJtQMD>F3wckiSUfv}lo}+YoaWRoE(g30uP;2LOry91qCP(`-;`O;ScX~_qn#uHJ57 zvX8zS+v2%8n<<@TN7J`n`;RM{1ZekKl%>udYHFsRU0oWd>!T^9KzvcT#C~*77ou3o zYqWIX(*7p7SSKe5U2B&!KkuwwLl`R*#$mGMyqcO)VHS;nd5;rBZQ9R%cWGUZ!mDYN z?#$MAZlj|Qu;MAVLP-8^!>`!kgO0ams?ukQwBdYeI_lvHfB$(IenpIykuxdOvF_~> z+jUR-fpjjwM?qBVM?%WV*3zCUy|18I1I6#~L z!4@QPd8Aq`uzL^+hI;&bx;=|ZPEP*#DM3pUpsN_JT=^WU^>(faGg+a4GG%nYCSMOL zeCVpU{&$eC8Y(fd!ffc3L$H{1z^3tYpchjLpu5Y=_8VgsaWyj{ae15oE#q)4_m$u4 zH_IgQu|~dW&7IGmXX5zM6L`P^Pk(>rvTzt!s}7?q<*Vsxy17k{SQTP*a@t~U2-P{E z?$V8Pr*hu(eFVR_3N+OfzQ|QkULMylI_*tj)^|KQEEo1!PfQ%wFG+987SwgqKwZ0* z!5!A-4?yWLNf1a6SQx9mAw<&Q#kX*r-lcaw2C>Ey&;WBBvu&-y`b3+-v_3oeRO z-uch){Ii6Ks7WKw~V2bg4L5+PS4~zmPXJ?MH(-o7wq+AvMaa zquRrI&k2I(U+k}@%~|wsk1e#1zn7Y8@-Sd9ZovJ@lGQEEumtdNGAL&fLF^Q;&`Or^ z)f^=rZU3BW_HsPg4QQBh_6!nv2e-c}GpBw5#4Q%_ZZRoULa7)9Q+LzBV!XL`YyqkV zG}rkTBj88>hNr}?Fx(; z0dZfMIqAKt-rD$YqJX*%AV7eu!51%Hq$8+GeUi~ zVPfN;#RY+SYz@%o>k@OGW+SX<-(I`r-5a!O4yJq`cOWFts;qemEpKfsVPLAQziu@@ zdVii`Fw{~YTZVrzLyM2^1BUoN(J%fI4zdE+zdwI2|5wuL(mFs0?p&1z$Q%8{!F(5f zX^=`f@ZtGy0r@^04FF4a!}j1FhX!z$z(fAKcbyvE9ZpB5S3niQ?2$Aee?3^Ow5+?S zWpi>FaCJCSenFJ-pw11o1&>|;n%=2r{_bB(I;01zF?fH(-18dI7GMf}w@raoDh38n zuH%OwCV#1Y`CBUn&11~};>%VYw^opmJVn}DwRb&~h*Dp%@DHw=fY4rvHqV z0bS>MwC_c9WxQ*fQ{o{mL|f{6S&y8C0fWr!-~N&MnOmXeXAla|Fd4ZOIX(80X+UN` z+sD%~ld4(3h(kHEUPBC^-X9XO|7!6!dtOAp1Z*;>_Wu8)e+M{Y0#ZC>d)-YN2L+VmcC-=6W4gE^Q7|e%a8vJY%F(p literal 0 HcmV?d00001 diff --git a/ui/tests/onboarding/evidence/aws-marketplace-button-mobile.png b/ui/tests/onboarding/evidence/aws-marketplace-button-mobile.png new file mode 100644 index 0000000000000000000000000000000000000000..b0009e3b42bac2e7e4e92868619615683cdc59e2 GIT binary patch literal 10992 zcmd6Nbx>7dyD!bA*$9Xr-307kOrkwQc@(OrKAK&r8}jOhWq-( z+t!zRZ@LPElWE6QpiAt8r@Un&R+ID$OQ zn2Cf$hy<6F*73^R%fj*^mb>a5bPBu1M;Jp02^C=)G*S$Yu~@J}xN?Iq2iI%Z-jr9! z%65~O92p6VMVV+yJ0&oVF}OUSeJnpBi!TR*A>wJyj{>)icFaapy3l;96TJh~v&AL_ z<|ik`Z(DEu&2-hTAutFp2DLPVwpR*Hhaf{^j3RtNn9Kl+l-5Xu&|*mcPdUgK)<-7c z=cS{Q%u!`jzmw&;ph=y=qWNN>#alum4V{;se)Xu2dRox$`geovWEtb&>}Px5%k{KL zh4q1y>xyl*l88vfAC?`W>c zHD8Kcz=q#_N2m2wZ_Fb&9PTsx<)F>DKpwCDU1H+#PI(n}M&te6wPX9O2Qimvi;&ya zXt~FXzf-eXwiqk6daKWwY2dB9;oad^0{sdDTHKevKE1}Hcq(iDJzMmw;9{^LfcWtd^Ok#rEEh?PRnVS-U6Sb6iORgV8^tf1`OoO2(gS-@qHDXRF+)W-}X zL@%Y7mxkGLM=zYCydj|D3lZcPFkDj0j($1AIsb}x0iPu2Y(H%zdixhHo zO10v||GaDKy1TvBz5G1upuLVse9&ZJBDvZ5S_ms&F$Qu{~~$0mh^^%y;)n`u1`CS!|y>v zI&-!c%uJT0c;HZ5rz!CM4xfT9Rdf9^WCW5Ub2a0`c+iK#A_N+ z5EQCdqno|D?>fR994aZ693RH(tkhefhEh@u?z?)#boBI=c>E;+w^xlBoeEMV=z^;D zVb&!!lV$llVyB}rWqW=c2ttBoDBNc`?4d*Ba-d<^+?(w#a7M9s*)T?$J zTaM)U!|K>P5n^LQ@A)Q+x7fmQnmvA(+0e{cn;$N9vIK(7Q`<=(aY4h+PdvP`mP`5R zv)`r5Cf@-r%371_<_rHq^cq_H<-MPEWLBfC`aO&Hcb<=F-V)os@b|+}?CwByH^6&I zEQIa-XY`54T;(%SO)`h3by8W~Ei5A`rv3K&+p27Sr1qQRXtBfd#Xz$v1vQPSzV7bl z&Y;5k)63PDgI4J?7jAZR@wmkNiiM=SjnuM4!fd}UqafX1eh*yRm$*IEUQ8UW}xnQgWmg_tEiYw4ePv-{M`zAaWLCJV0JUEF3D;8oQG1x zE#A_kfNYIiGN6Ur|7#Ct`|+nwpMrvr45J)9=bPPmN!iQ-bWcy8hVK;iE?=c8AsCLd z)x{Yp`ikg(>!~|dnYEjqjU!LO^NL=6&ylP;zIxt;z)~y{Eo{gu20f*rBKW7w3%x2+ z&WZ}siEd#TG<3VgHt`W=6)4;4+=D&zM?cL1Z*^aVXa1S@m+kEJW z;!ZFs&3u2H*3Mz{Ns$ZY$+JXAMz+=R%EQAWiAi-W7MuA&x$q*VQ`y+;)|PdG+*u@pEfLUiz?AYZ`D6nNUM7AYXy%-IEsm zYJEjr@)k|O)Bq#orRS)s8%A0usI)N8m+fhY@v>8hPYu|it-j|2auE!IF@`Bc=w*8X z-8d+P5-HmZGf`!l+j*c@8SAbz?@g4BR~Xju9OGSgb85n~GwgnSf|K$LMfg^YY8~+S zl4XCrI&^juhc%qJgFd>t=d%S1Dj)UccN$1$Rf?;IwFlf5QgzBWmn!6fWmZn*fI6T$ zJCD+vm*)K-P6IWn(4exN(*MM#Nd5SopkubOIMI}5M|8q^oWxas(fOMLD0vxyEpZ1P zY1a=QT`bj`o0IQho52-(zv>5zsIb^^mR;`99B{SbPRr31^2~-zqi+)tEb=K8{SL&&R?_u>~zp+0FQ3vH*JLe>heaSCU21x+^ca!s7Bq0L;zpK;G!Z|SWc zub|X>-QS*nolRR0!+FRi4wHm-c9wR=>Q8mB2I0v>Cn$XtH>(dy8vWwwC_WJ15#4Pl zN067X6F&w^<9a45fryA0L}}^qIos)sK0P_nmM_8(hwhXiBm0G-V&Ha|V&IV%{t03E zzPLyoP&05YJwJ+kPq_Swj8X%bumd|E5;7-^&mE?9MH81SIXT2O96~MuwwCkkNK; z94hoO2!j3ui})?@9<9~*~1T3v{L{~jxjpbxU$ZNv8K z3Sc>SUpD~wq0%)KZj!eV<1E}*Xf0RM6Ag}Lnq+O)`=TipNRl^)sA`lUwKqFG2jZRx zgpfgIeBj+l4%vL8jqgR>m88 zMzS85w>f|X3q7|2o_@Kl*+PHtnd(ldEqLfiQbfspPt zuM~QHW#e@I)I@sNS2lEiNFgku-s87XedKeElryNQdeDBUD5*nEc?os6$Uu^sXT?s2_{z{R zBhV8g&=`J5S*tGOo43v!Qr}-hXrayB%%7$_FF*7_6rr5Yd*)QgB9*9%PfHUO2YsnI z(W|!D%uH5}$CG9; z?-Fsmtp9+o>X7eP6r>~U)jcAW2+sV0!B(g>BkHO1U1ulmvEmZez)$lL*KRJO61fPO zLzlgq@c1FuN5w)&X`7CL*BKnU>MkY70qYE_p9`qQwCY|tZVFw>&O=_`<%DQ0hI1)i2 zbdlH0wToOKtG@GvZ#(b#DV50XTbuidQf+){;kl&8nLgZoYH*kPPHKT9V*+a8X1L?* z=iy~-Nc@%j*(e6;B!21#0)nRPcMxS+p2iK0QmP79Bw@-)iT(EgcV#pR%t}YDhriDz z(v(*DNcf3B&%DyU#?FNyj41oZ3D;;M8x7UwAn*{ z8QArvv<9n<^)^G!fYUd!)7MWMu<~wNn#IEJ{+vje$W2U{LjFj~l+o$sjGN~;%E<{H zZVpOud4+@A?b2yyLRbTE+53dBkTjD5Kjw4E(yt>s&v%(#O7vIFPFg_&|Np(y?s`5?fL3n1RGBb{^ zzCN2nd*g3X{Fl=&n~j`vT+=eiz=QhM_%iVFpsLMmYADp7V^1jA(yW z_g>S_4)bp&CzhB-$S{t_m?|G{o8$`Et!%{6U0hyDCp*(@Z*Q}igzS_qL+j~TplwVh zv5l~`3OURPujuL#F`~fHK9n75?=|R|Y8V0`b?roRh0sEa^HB;mA(>@>w~MmQI!RG4 zr@|eGA3r9X$0AClC}Jn>?pcnIbvhX4o{E6#u!h6S|uM5gTLEolqrl}JiLp2_2IF}Ezycl)!gM$4qS+MA$Q zj%^yd1Bu|eIfAd5mo9~5rXD4rHFJ3ez7& zB8R3cxhl!Q51~+g_o9_bt_X=Gg@1bi;@`AxvGXi6ez(RJYiou>6*s#WH3|gl+@J1%R`hRCbhJ+d3EwP3o|t{XmTQ5^D*@r zW6{ykS%XQZc=~a;TXM~Wg%%DEodm3N-$FM@YuXp6FEsH%N_y9~&eG69j7`M@m4GvB z3q?`)9W?d!=i-d$8Wjvt#4&XY&==%4lhXgTN|rkXlL^PALS*oN2&*s9cN4x-{%fe9 z3jX_q|B{yySj(_|@V@CT;{i-%RXwS2X=w>4mhNs@XVr^~i!r$?on!OE&NiIzurnab`sI zMi&J(UP)rn24Z{MjoL4^`JedSUM{ryX7Ilbfq|a}hxx}6;@-!Fe(=4kvt6Lumuka+ zta{jmpnbZjuNZ>$fJr4=?C12f=W1{4F=vbjA*CNVCFSpce_qrF4rH{pWr>Q1$7df= zYj94nT9(n_27&l#e5Hbu>!~2q+eduzt^4AV5?5RFRzOaCG0iiirKSCRnnbuFY?oAM zRBr=US1@p_kbfi{pOcg0$Ii>q4jz*whKx7gtUbV{{Pgq`-11;rx8z|5Y8lmT^5@Y- z8ERr;ga^-DYI=Iv)MFqLjNS>mGEG=4bp+QEiH0G}UD2FvhJ{@>8W+`J{W&4_!N!wi zx^YzEWu)hOKTjt0&HQhU`ha49 zo0}hNZ%zCtQr3)&i#zyPS6oqH)-LfxfrW<4_o(NggSK== zTx|~)Tc-(}Nl{5-Cd|npSq>bbf4nN%y&*>Tg9S<~ng}I^LQTML|FCL*FsVn6M4_uM z&Iuq-&+lSK@9n{!(NkCc%4riA1}vO1AAVb9uu@cAYVcpRmddf@0t*4x_GzxQIllW% zK*tbQUjh#+yoSe6K0YNi^$$QhZlHxfE>+NrfHLB+Ee znU44Am;$hH3gXg*)qlzJ6b)0>SGQfemB%U@w5|@I#a?Q=&i}B)<3_1p_D*!b>4nIU!?pn z#-`MrCz~Z!wAr0OVR;rICiX4d+B{9QGSLlE_OduGgnqW9ni@2-guIW@f925>2pOH3Ys!5a??s zA=ddPD-2z?M)N?$jSCJ9568ZF)3xEkOGgJIB`jcMWZbq@l7_3PRf2+^V;7(>M)717 z67mAB?Z2+S$>)H*<{2;9t9aAqe@#eADeThiO^Id;Hbvw+8ZOAVjz8E`qMrLEpzk#{ z3cc4HAn3Lh_c;~vIkkqtLR{VwZz26!Y!AE#TEDiguBz$pibp9hL=nO&@$O8CXuD}x z;0VIdTm~o%HzZLO!V6LQA9pY^u`3A;1LM!Zq9h$%zWeSJ@MMV)@%ivquU^^M*l20} z0LAS2`xm2FFJ2cPzvF_Kh)A>5_iP+ej>zcfS+MQo<-=(Z#Hu&9w=ytTettd%0oBdf zw3&K3|LgPp`7Ts11}6&M7iey7*MEKo1O&uM-dzB?TdIH{e*L_Q$KoAd4EYB<)QI;u z1kc;k_ycY(d=FYZXR6IJ1RY5M3@HNV{pE`n8^9m493VDlXGDCAsg8_^nX9vk1K+E? z#LsD1YXP?1=15lE^kwz9GVBhrZ(?oDa!U_uLB3F8Wo0GnspYM*C{8<_GIr!NX*>k} z9SMSv8>x+`r4Y0V%&$&eOm-)Uxyb;yU^~i$u$HKbiV8q5zP!Q!eJ=K~unRyJcD)Kh z1fgTTX{!$&Iln=xPklA&EEBRU2u*r=uwapC$X_EaaWbmy|9I?9eQdD%Ne7`d#lWTB z;I`}fYyz5WnnPo!wreLAEz`?_%aa=8{}z(}BUJJFGnp8eSzTQX!UoVk#c61yK=Gvj zOhF(J@7}!w>xmW0d({VjHIgZ8Gr=MasUdhb`HuG`;^U0<-G-8uiHV7Y1p_u{6~@)i zJ3$Mp!I6frid7E-VgO(^jzSPW`snCLQ&Y1`;!QgP{GNrA(;mRkO^p(4kB)(X;ptOg z4}Pe7HB2t<^Qk)O1sA-Z`s1@NdIpYB`Bcys+!~CRA9{cr_cKMGZKItn%I|aaiogz( z_A_c-h;tNs_$zWIC8FNxL+VR2804Q-^Xh$2Pqs| zc)`ts$i&AV5EtOKCww$$^SjigCJDe6(7_wg493qYH!naPz# z1*SE4mEbsT*xwFVrmOt>;r!NEfkGHI$*b?#Sy@?}iqO6}s9@X3Eamgw^})1hTb{(@ z<3Vl(1qB>}q|8jeLCywHCIAKgf?iJG-6gmsCbi6&M(0)Pz#GlRbG(rZ!Gi_g1Cn2% z`lIQMZ3MPRRDBK!RLX(ebkGYWEs$zQ>!n7QHj zL~xTARVhd4b8DV}Gmx3~rVH2x++A8xQ|ADpvlYHWOZ4D@wzhWr?H}Xh=_)g7a`K(s zU3P5v{oSpld4D`@ZMDyt&5h%)Po*ZwU1)2laffla3M}2dy(91WFeAJ-hTc1Jj%16^ zf@%W@As?TQ%UCLO2y!C#-@obs&HHFnpC|yf_(e`ag7eMye{&j#6)~5vB)0aHg&y(-2;6dn%^bV*r9->;6*hz+kSk9;2J2@x!Zd0=8$qnA?_y84e3XFBFy+ zBc_=p;;vBw*n!x?Cf#p|j77vsxx^X|yzB`F4C`JAdr~Akyerr|{+9R9)Jxi+9f?+8 zE8BPfa@Xws4N8bmKPkCFda38ZLP;=R!6F~pe)X_pLfA^YhN-}l^{+IlwKvW{;dqOY#G zkrQ_R76ploX2W%TTDX{RG?f)xkpR1%ab-Bl*t+afF{# zxT()JW2=_Dg$A|Xn>~TIkoR^IG?!r>Yo{oP_`|69={}^z63qf~e(TXhOMM5x^la*&gb`B3_kB6l$zBnPwf^sfvV0`I3+qy>+?}NR!Bh z5>YDyObqd@*D{~s+v2`fyAr_Ihp$b3jH-N6H1h7hhG7dpdb`0(0-G6)Ys zxR<#2g;b9_M`mUW&X$O1*Wyq^LkzKY)53oLp!ek+Q%(d>@on*G)>HdmA?P^32OCIJ zC33Ovc$y#yYXRI}h@-c!PeD!&>zZYwu7QN_)esqgUmC;=CX8@)c)*weZs1+?Pfqac zR)e2*jlAQ6{P-s?FK>3_bc-Sw#n|}za~7~GtpEOi2HjBb|C8GSc4>DZf&{bcET}@Q zh8CD%^0;mc{hb4VMf!JCfE173u7`a=Bj1#>$!WO@j1qkM#4inL(n|+V{?WKWIurw| z&FGjs{%#Dg5zs(ndFi^jXjI0=#sHGmoueo9m?Xwa8h!EL#0G9t;XfEh(a$=og3(fK z<}b%eJvZVG{Z=^arUl?%qCD(mX^k(eKbYq;nET<~CrA>3Je?+1nLPdVJF><+)|zv$0sKv?w|hRFzZl-u!jHV$(0Y1tMYcmz?(>6*Z)%MwmtNI+}pe9A-8GrprY1P zg(1b6p<#p^ZK+P_n<~ejEiJ3SHJI3~${V(sv`OYL+?lD_0}cSzimFCp%iD0g47WY6 za#Po%m7?V2Gf@vanMdhAzIaL!1GuHdhI@I%?M&K&$&b|JWIUf+floin_1m?Ilo**R zFD^IzL%bd&GjO99e8wc8Iy^cm)yUIYAJBDZ6UJQ_Pvu<9k_?pK0eO2Qxf9^Ny^~&{5vVFr-IF0d_X9rTutSl{s_L?;dn10WCvTFAN2cpiY z^pJU1^ARE8)~fbOni>AgT{%%4I7nB0g{g?-gN~gaKM;{Jsm^NlpJ`|5axtir^5px4JeK!C6VsNbFySdK&hB6K1l69nX8(5~c|_%;7eN zT35avmqHl9;p{%ATcKOH?2pCMpH-TmTO^;l3DxS<2I(X?9T88i%R%Ck%lNEDAnd}7 z`bjc()}0R|wUpkVB{Mi;dQ-23Ye+#nmb<+B!>&p!2Wc94*!4K1@kJTdNDI9{6&mEr z!b=svu`n4lOzgx$$@W;ggzDrHFhEsn=bCG64a@jr>C^uOa>^ z##t)x-e-coDlBoWsN?9-6N@!GK@$%Ri{Ua|Gtmm|Vl{tjG_;kD;MRD82^yzRJ7l}J z9e88!-8`1WOF!m1gIqe88u1~FBzoJP?kbdlR$2G6o#ZF53et0J{;wtP8Y#AalXCu= z0DJE!mu3Nbe0BkcNNTho`XP5`0$j!9XtzqCV&|TqQ?D{+a6}>`7mt<#g};%nT;D7k zt=jtpqgzkF=0dt|4Pm$a2#!y6gT*dmOX&B}to^t$D%TwezD6sTVLF!kd9uN4kPzL;~+exktn`5{09lo zI#I^ULUr+ey5NSej>9)pP=XhG2aLQ<^!j~Nt0?YG-*g2VYUi!cj$V*8 z{$G3p9?irbqbK}>IBBThG?yS)GYcS%fzKe*eYhZMCb_O;f3x`P~4?qk~PF0{ONg|;u z0^8I*|AL+Bv76x(2A-F_F+c?2fM!mERx@AGB9|y-U;woHR}h>~5faisXs5Azr{llN z-}OH@N`D9k_J^Mxf1p|l!++3j|GC5d{|pQw>=TpUzuyM_)c;o5PvJ_cs(4sfP$2G6 z!wF6vJ~hu^09Me?>+$!9csk^NX>sajIJmeb^)@NMkflL*JuoyhTwY%O;_;ivLjyJ= z3kGC>@It%>2m^qdGKN}Gf}7j%&)&~2roEfjzktam!wMn|LB|CS2yIW+XZzU?g@ql^ zpRK+P)mWm;$hpP1j&%(k2 z0#kHGyBh<FrX zgpv?Y>0P7;NapaoGqYyiuk)^1>-};UD~sIRyU#v*|Np;z!u532=xDFdLJ&l!{!qmL zf+)bB5P^mqT+_%KIcQ5 zjx_bFp8r%dW$#Chf9{`}YVug@f4=Dxv^XV~Li<#7% zzF$t33lenv7t-vW1swjJ-=7YsbRI9(pKV^PSRENfv2v#_bKMafv+o^t1J7 zL(cb4ju0GI@66>lcQLp-_4ZR*TH0eFU4=vI{0Grf_;267z2yp;;O&Z*o<7Lje4!T2 zqZTvuyp0ag^eud30b64iD-(n?ts|Ul2D{>P6;F0{mPfR|L|u_A)y|M|EVpbSRG>EQ zbbXAq$CSeNH)pbc?mQ-7xk3u=U4=r+*-&4!o8i3G{4zo#Hb_YLSF_<64 zYl^z9lZcz7J-ZQ#k>TMf3p1fA+uqb%Tz{rq8Px^plhlVc_B%^M6x8$*9kU-i`nNV8 zAPxR3?+UXVuh}WqNJ{Zmc&(0=86neeJ^3b7^dY`;=dpph*35O$=B4YHvfi!PZ3^!5 z-Hy~n&6=Z$I9D_tU8Zw;+=#Bdj+%k<>aNl*ZIgGUWs7;h@9*KwCuq`<;u~JM`K6)! zmBa47zLU8G-BE83opgz=tFhQRr=v4jn}M4F3ug0+iloEwqiF`dHF^$NUIqEo9kS>1 zn9BX<3W|{M=R0SW=-8#!z&A!!FvD=9n-6KP0o~;7@89f%vlPj0?IfJ5qVtLtv1o=x z_x5VZsfI6(8tP)Cy#j;bMM)ZZ8~Q882C*SBoYHvD1gDNx76|%-G~y>)$_m&hfi06_ zMX^;+-)0a8?=v*j%YHJCYfmyueD63`&Q@0&!y$(aYYjzN>FVppISWorgk@E^;F~w} zX&hPy z-?T5r%I{5#=qk3j&a{GmD&`v0HC4b=NpaLX{z@)=QahqEl2;2~XLO$U^ki+KYNOH= z0}|Ep`PVZp5mtH0&-*0Di%%9ZT(M_}^J2lv(=8~LvgrJIdxw#a+L8FbRil`LDUSgM zV%uAW)tCNfsp+piYJJg!Nb?9f>_M!YSxIu#>8tel1riVDCE{2gu-Rf<`OQUM#N@_Q zz8Ztay?*(9^IcsB?b_GtxCYWHg7g=Kn4gVRtQR{jDa!g=REa=O>kM>t4dG=x7QkNY zK7s#GJyFkSC|GuhQ1JEF-6dbQokaOmDg@|rL5E;z9kMsxuv*$rHM#`_wT>BWuNs!_ zj>}oKMkuuV@B6G{3q@QTO6yZ;KbWRxDrcLC%$yVrdJkFz@=mcrfwu=emf6^Md}|vd zA=*2Fen388uhx0}$+>gq-ZF4)^wW&wj&eR8D>q4qkGB$VG?sIn{``SPx|DF^@Kp{d zW3~*Ovgz2V?WO$KX}1mz?_aXdCK7)#gk~uQx0($Iy6{Cy+wTwN-VfUUj`x|tgFGl! z9@KC(Hg|iInRovk{lgl&0jap5hq6XLVtVzaWj8->*UEc+G5i>A!6@HzvOhzbw9B5{ z`sTQl%7M|$8?TVo+zb_Za-o(spgVKp=gNo%aXk2BTvM#YdQ*7*v>0W|dPQV8C1Dl#IB9-B2=;W*xb@_?LLVZ#6kmz$1T3A=`P2(ACn1U*C2&;{#e>Mqz2q}2s zXvLG;m@-xl-@3UXGR)Yt%@RmU?e9_dC}6*hK@k==tU`RXRHs%bfs9h161VG@;A>o~ z?y(FyoNo*^MR_CZORp#%cIk2kA8$>t?R+Nq3Zj;J)Tj9D&lMCFE{h|&7X`lV--z3# z3~^66rhV`ITl7RQ^QK9)N`QQTw4-2c-YX~EYzzBrd2vDGEYa~T*>dsP$Hph}y7#tT zXB6i@-kfgfh-PoF8{k~u*!7w@Q%)&jTH7gAYFt^-9J)nif5Up|7MCP?MLLKuFs;VncJg(zu$bw|^{!!U z7Y0{ZZ_#s)bvaWj|L{Z#daKxwc|J zUj+-?5--fcqJRFAB5E;JRKa_;6*2Q%p6uuMhe41*@O!6NHt}{9+G{S|13`P@l;Esa-ZI~QLXD}~>HpG%i;_)Y(7L*<^=mF6e2{?uaVV|V!; zj4@(8OZCL2FGG5~YA|}lAbHr)ex&J~T-w9wJK_4us%H1MOyPnJ3bJ|G!NkJ_Uuzqi z!$51pk6J??d;0rD9h$KbQT`&{&5i>8RkYIn)Obh12S@n_9M1Qd*Gpj~ze%x!Qn&f^ z+N+tx7!P@ikxKdNmD+aKW%6~i6&$rvZz-=Xh)(CDEwAPtRNTobOAu0DEvfsuYdU3U zf41;N(4`mXOXj+eppQDelCW>%#Xec8iPDfe5ptZhE_f{6n=Dzc(twOj;ry8Kf*S7*nO5HuQf?cQ#AYQqybD5c~Y+ zpS9y|O5up5#xjcI8#!Dl^Ks>U4+N-L*LPX$F_%&OcgbN5?_2&$xC! z%)aKu@m9wkrisYm)4sbqj@abPTXUMR;jm9qk#?OfcTBd-r+=N^#}SJNSeHbGNf870 z@kJY|a<{jhm|>&#cycT5lbj6@Q8JvaExXC96-hwY>1OEQ&G@vX5fvJx9@=6?>~jt1 z6Xa@_WL!e2RkkLVric+;5`;Ol7f8N??WLm8+#D)}m({Yt4@ZKd4RvHxW!i}7ks2rb zl-Ctzr6RQ@#T@?3YqMhcGtNq4FCRHG`{{@H0Ge49t3e1E;K5c zb4LrPMz15{(I(2z)Ath{^EHK|HF4b7xQdxu)mpLEy4|`(A(zPH$!?$YOq9PRp9n{m z7p1|W#o2a6>S-0)ha1*am2-}orXI#Whsfc`a5)lCe3%qFHKCFZhUVsLo4-%W?#iCs zBR?>v9W>f~8zqRk`R#mZd!s_~ z6b<~)5c5iJl_v8eY$~l*L=-JG)}HuG*hb`Ty9Gw?oYtIUg6wagxyJEu>1d}TJC5Wt z$gG|Y+Ev0Al%hjIGr?xc_}9KvGX(3l38;V2h~kj-)QbT!+Xr?7zG)X)35!!Tdo&QA zon5JwPYOX3cPsG-JR6l8-$4+c4X7iO5C7^uInOTDTUS?8!{eA={5ap@69+AF@Ns_l zURK4IiVDgjr61h;+5}eg!5g!GV7DKTl|Aieqo8rw6k+WqYp{!me0x6;54{OBSqPMZ6e_zP$T()ZVOQpVylh{~+&s z1fhZ9G6DuLU+mH{q8-~){e%_GO7kVSbyh?Bhk?|ba>+F z2pa1T#2at?Sy5Wm(KFz%^zlmU>*?8k+rOj_6xZiHtP?)*``cH=`+Xcec;O^Zml%S% zIbkRtM&-LwQgke~U!}M~C`a5Z@bzrd=zZtybbG_&!r618Y zkvN--_Su{+R#a7uE)KO(PgG!+bld@UtFgI6H=8u98M5LT0Cu$nK|dG91w#i^?C9E_ zzBDLVcW=kDgIcK8+&UQ03HD2ax%wSmq{KqXNKyVvCJKjh z3B}&#JpWsa+)I`kZLKf}x+=Y%OOv{dx^@`Lj;Mr{E* zLn7{5n5n7!;7{7$VbX|p2}!xPKjqOEGgQ{NGpxCOZbvOVNrkLau?An+E>xx3x7LwZ z%ylmuZtylSV?)PPBJNV=j9OY;+^_gFMgCrmTU#n6`uSmm+_6eTrFHqhUTpC3Q?GFX z*DcapD=WM=&bb0P{IUKIv$5}W9-jZj0?dCQtb{jA4+CGgSS|nWc(H_d!v1tjE*NdY!ZXCd>V@8LzPL-l^p$7)M8PjG6jF23C5AEcUsAd6Z$kg-?8pdd2IH=vI(l z*r;xnja>p_-mK2i<~QBu;O$<^B%P`S;EFv)EWAV_xKd$hH61lI{jxXqsIP-!zJQfI zn5?SuPX_*cQlPv!De=;38*L`2S9h6!F@)th-G9Am z2@8ufOAwD+HE7;D!)kuZb^0F1oaY}83t`8JOYeTai#(%o)<4*1Z6bHk-9&Go+H={U zZ$YPVgyraXd!SA$(EZsj70r?R$E*es{MzY)i0g@zGrg=jZoV+_()n(Q*_^inVP1@CtJP?ZhGj|c9>a%$Oum3gX`^BA^hf< z1EJ&E_4s5IZ87njeNt_`sdT{atU{3-k{b5r2JMG-)O+RsJI>ssj0U+&YFh(a_4jiz z?}RH3&TujcT|D%AsNc>dWqo~INT_7I-QWS+t;ZoKOP%Y-Pn7tl)$L-k+YJm1T95FX z&FSyGJ}auYEoq3U>?tF~M zkZ&)~y%F|m)ERbtXyO4(;YNk!+J5W4d>}OCQL?uj8hON<+NsCemw%}ov8JyuvJicx zYO%ruKD;Opx4+sr#t3!myOY1~K1;kdM(PvG?w;LfERJ}y&bT#@7}IitkhhE_w6n>T zHyRe{to(^+d5zA}lq>>{8_#?^`}e5enOOsIS+l}_lm2CeTN|CUN+xHeqneRXvRu_< zU|URJ`K+$nI?e^vXVIss-{#G`OjBTB1Y-z|QOEuy?gosSUEGb&*Et@lzjDX=-ONa? z*bW}$(_l<>2Qker%4aW`7j|#8LXr5OrqAHngK@yia^SdnIV~%z;`v8bb}zQoN5Y&-9Qve4|2Ly{ux`1$&}5dQ zuhd*OO=|`Bs-wdnM(XKm3l+r*%`MM92@uvaNtB9_%IszrK0df%IP6&jIBZM6z7ccH z&dwfPog+pgqYQ9B!Ut9smSoT0Sw;`IGz%HeuT>AWDNY&zE@j9udOcHW2924#RUriYLpp4kHf%th=|qRErwX(M2h&Lv`$<{euuq z!JKhO7z0<(dfm9Kot>EF=LtyZ5r8uy0s?k`6EY|qm zIcZ`F;2~k>*{T87TOKK5hrXnMfY)XoY1;xa~Ks>=ZQz^YM>f-Gh%2&Gq*T4>!S3T_j zP<0gg5?+`&fNYp{j4!?E9S^nE|D?*9nwkQRd0qcZGS=o%}4a zMAr4en5!~Y?EGwCgOc+#j+0$KfdtjoLPH{=ZzxkhA%oW-vj2TOOK^Sp%d7|>0-XQ%JLBrtGkvBf8(g!60y5=%6xM4;Kq0OHDH!Yza^Q}*IPih8&e+;Y^M^npj*N_8 zQUUb@aB*}AQ)c{%ip~gW3Wvc8P)M@|kPeYowio(v&0b|!BT~^uZzuB^1?=-#vq;`Y)+WlRiT7admYd?|{@x3li-6 zrMXMw`Dn3z`kklk=NS*v$mN1T%)J^uebQS86CNM1+XG)mDD|aFj%05%&eF3Z$_27t z7-YHu>}#ase&VSB9xsi_e8P&wmHJzcd4`yc&7WL{i~bNp zhG20GuExuPJUp|2(FIvrPJ`qC+x;<(k|1Nm8QDEnCZ=Z-pOk$RNyi@WSEKe5pK>pS z(Q$dLjOwG5Us0XvV)EBmFx(i>E~w6{g@LhGilu*-h@KZy-*e*OVp$;2AEQxvmP60F z?>p*dNFh}tSsZJhO&Y-2j*0q& zoiYkA#=?fr9QOM#X$ZqhYXkN-K;39g!)U_I&CA1Hz1Xxi?VbCq!Hy5dsW7uLHL)1%haPT z_4U($>u=)_i^Q)CK|k+AhxU7oTiC_WjT~wOAJ1}mIn_jRG|rC5P9PKk%|v8KIcr(( ztO6+SdYvc26NfaD#;q5od^}$emY0f@D(M*zRk@~KCLMI_YsOfuyMi`=lrDTiJV?`1 z(DRyGDXTQH|MaqrpIP(G)$MtjztaJmlj7~cq@$TK`vOq`cB2;q^7|iLKs~)8e|#FB zdS^3GOaSdI3wJxJu|is)j2cRL(|n8eCM02x^p_6%T!UtHYXi-?WeKaqlcU{;Q*y1X zuC6}hzIJwRCCaeH4)DKqY#qC-r~T>}Qmkdi0HuWUpoVDZ$uD##3(FQ|`Im;DXO!yr z_V(LL7r6b_Vy$AUvs&->7SBPTgptMiMLHae%kKV1>kXK@fD=NCzEY@}zfDP*k+OEyUg3-P7sqLe%BkRzrEJ`mdgH`u-#v=M=Zq zTD!BqHu>~^cz6LHs|L&`JackE0B-ends~})qh;Tck*yp_=v%%B>L9+!qlkYR;7k9N z0fnR9eg`>pZxyfgbz_xUI^0EXtV5B=s+>FooohNh7XX5z>DQSY=M4;Btu+KG{{#IK z-{jN*FEi?k3Dm!2u(be`C+r9e(u}@72n4gd8uf2RVOn#eK5&HA9ASu$E zD&UlB83nXbe=645Y$ppVS)jb5k7}lgDo95l>HDh0r#aZ|Ybkf@m|0i|no=E9E|HN| zr;bOFxWy3AB8TfMs%V}V8iPXIJCbtr=|OWfE`Ri^`Tfl|%x0qL;&w3{liPG9`$(If zWa8u#fiNq?Vt*!c>1f>IZ;saJ#!C`*dR}5Xi-Wr>{zr->%q&HR!ktw}^g@n*XDG#3N*HHG5gHpEPe>NExUx@82XPD4Qfq2!!w1GeBQ8OS+xB*Lci-u{e};x8@h25G^k$&h zg5wUP)6x^r9>r#2V#4o7qokBWDFIDl2g2ysAH-Z$2Q&2RLR8V0Z|8xe2dbW3>RGD# zCGrbkz0NBwFE0Z-1%KN4{#pSSB#?Dy@L+2evjn<(bvwnyQ~CDWB>(WXDuGN- zw03()wDT&CgRIy$^m524%Etj0N&(G(-w0j%55XpSOGKlAjg5^8>ef|Oe(93XXqSKP zJPp0*YZ~CBz{>rntH2WaOxUer{rm^`X1W->Tyrp~73i0%GVb4}D`6!Dv+aEnEseej zGCH82mYLLy^=IPtH}JE%q$@qo26OZ=rI>mby!)S@Km6nS~hCmgX zKDrxx_}6FDD75L|x3c5CqqWN(9|*fBpikR3TN(;JJ%6cq;&jvF=NDygc%)|_n>eJl zdLH^|76ZM`x$D2P6h^mt!??m6<(=8=wVLUn(=2>nGx@U@kWgT(g^dK%@jVkIH{}l? zI<~h-HaL0zf^0wAaZ!tAAxP*_$x#f>LlGTK0KJ+HUD}B623&G%<~S^Xy@xZUOEa*_1~fiS*K%DA-imRzD>_q zdaK7GJw7YQ|E+lcQr`07hY^%^hIi>@j(fg(+eHPQYlFEpW~PxW$iyHClDa_Vz2`q| zFlFO^vdh#HY!|b$OLT6l-W16V*w5mS%PL&9KfFjvN(J`JGgbeAi;6G z++>7HBJkkP6WrbBKSRQ|M|2QV@Tsb=_CRj&-zo0wx7GC@zjSa-04jb+qeSz;v`$BM z@x!!ZvH0qrU-xMRHgv%@fcx1Jc2a<@iBI<4C@HFU#*}I&3B#QUX$mLI%mJ{g2GHN& zr3SD>{P6)-B<$^3R~0SXwjPKqwlR$NeUQlyItc@FB_r4Lc7xyv*xfMI^yA}YAnP5h{|3v}*!K70tLF{9f=jkFEi8mFy;&ze z9R5VIis^~L^B-s@zF+&1>5S>Xe)@I4uiQG{rTlQ)ZUb94f>^-Tl>wWg8|;X!ZPHL! zQ3=@YV@q$fA9{fj6ft6$tg-VM?S`P%mUttK?r)m2a{fF#JZ%tPv01}3D4==#VJ zi~Z|vVrj(;cHnqLQkh0mP9A5g(|B-DXqy3_y2Z1OVTNyoIXPdxvq|hp-i|s?!_@rc z50WV(sF(FsKz)(~I!4}&>u(Z6Y*hHPD+Dp0vj9Aa+mq&BYV?0zLD7te0NS!OFyQ|x znowfO{*NRJ!v3Z!l=ET2;ywO9Pd#l;Py!IXb-aS<4{c1X#@U`Kx-1$ok&dYcCv0OVx= zjS-k;vWK99h(DXSb=rO+2GwT{|8?)4Q2fr$&f3}<83k2AK><(&nyRX*etv#3GBP$p z+o}*bIo}c)4J1tE9j}!0{F(}w!pKr`|JPjA&nW@7=Q91di46KaxS=nnPQiUEo+Tt% z#4I^E*%pFs*9MI03_~q98d_d{6A+(Ve7<))Ks~TK>cb_5MJ6@2fNFdP8BpHX z*oZ&}fpRVZh2}=6#{=|Q@bRNCRbQ4o4uWFX6kZ&Fd2L55mn%g1U|3jb7+9D(2L}p|gmXUWRDo*U#5!j>g38P*7?Q zH-EW_30lFF?E`*w^UX1AhqG?+t0hmxxgoox~h&!#XpZj{|8uD B*2(|? literal 0 HcmV?d00001 From 1b228d590b5faa92d9e0bbbab7d6c2c38d3cfeda Mon Sep 17 00:00:00 2001 From: Ethan Troy <63926014+ethanolivertroy@users.noreply.github.com> Date: Mon, 14 Sep 2026 05:53:19 -0400 Subject: [PATCH 03/23] feat(compliance): replace FedRAMP 20x pilot KSI with Consolidated Rules 2026.06.24.01 (#11701) Co-authored-by: Ethan Troy Co-authored-by: Cursor Co-authored-by: pedrooot --- .../compliance/fedramp_20x_ksi_low_aws.py | 46 - .../compliance/fedramp_20x_ksi_low_azure.py | 46 - .../compliance/fedramp_20x_ksi_low_gcp.py | 46 - .../changelog.d/fedramp-20x-ksi-2026.added.md | 1 + .../fedramp-20x-ksi-low-pilot.removed.md | 1 + .../aws/fedramp_20x_ksi_low_aws.json | 383 --- .../azure/fedramp_20x_ksi_low_azure.json | 305 --- prowler/compliance/fedramp_20x_ksi_2026.json | 2284 +++++++++++++++++ .../gcp/fedramp_20x_ksi_low_gcp.json | 294 --- 9 files changed, 2286 insertions(+), 1120 deletions(-) delete mode 100644 dashboard/compliance/fedramp_20x_ksi_low_aws.py delete mode 100644 dashboard/compliance/fedramp_20x_ksi_low_azure.py delete mode 100644 dashboard/compliance/fedramp_20x_ksi_low_gcp.py create mode 100644 prowler/changelog.d/fedramp-20x-ksi-2026.added.md create mode 100644 prowler/changelog.d/fedramp-20x-ksi-low-pilot.removed.md delete mode 100644 prowler/compliance/aws/fedramp_20x_ksi_low_aws.json delete mode 100644 prowler/compliance/azure/fedramp_20x_ksi_low_azure.json create mode 100644 prowler/compliance/fedramp_20x_ksi_2026.json delete mode 100644 prowler/compliance/gcp/fedramp_20x_ksi_low_gcp.json diff --git a/dashboard/compliance/fedramp_20x_ksi_low_aws.py b/dashboard/compliance/fedramp_20x_ksi_low_aws.py deleted file mode 100644 index 5ca220301f..0000000000 --- a/dashboard/compliance/fedramp_20x_ksi_low_aws.py +++ /dev/null @@ -1,46 +0,0 @@ -import warnings - -from dashboard.common_methods import get_section_containers_cis - -warnings.filterwarnings("ignore") - - -def get_table(data): - aux = data[ - [ - "REQUIREMENTS_ID", - "REQUIREMENTS_DESCRIPTION", - "REQUIREMENTS_ATTRIBUTES_SECTION", - "CHECKID", - "STATUS", - "REGION", - "ACCOUNTID", - "RESOURCEID", - ] - ].copy() - - # Shorten the long FedRAMP KSI descriptions for better display - ksi_short_names = { - "A secure cloud service offering will protect user data, control access, and apply zero trust principles": "Identity and Access Management", - "A secure cloud service offering will use cloud native architecture and design principles to enforce and enhance the Confidentiality, Integrity and Availability of the system": "Cloud Native Architecture", - "A secure cloud service provider will ensure that all system changes are properly documented and configuration baselines are updated accordingly": "Change Management", - "A secure cloud service provider will continuously educate their employees on cybersecurity measures, testing them regularly": "Cybersecurity Education", - "A secure cloud service offering will document, report, and analyze security incidents to ensure regulatory compliance and continuous security improvement": "Incident Reporting", - "A secure cloud service offering will monitor, log, and audit all important events, activity, and changes": "Monitoring, Logging, and Auditing", - "A secure cloud service offering will have intentional, organized, universal guidance for how every information resource, including personnel, is secured": "Policy and Inventory", - "A secure cloud service offering will define, maintain, and test incident response plan(s) and recovery capabilities to ensure minimal service disruption and data loss": "Recovery Planning", - "A secure cloud service offering will follow FedRAMP encryption policies, continuously verify information resource integrity, and restrict access to third-party information resources": "Service Configuration", - "A secure cloud service offering will understand, monitor, and manage supply chain risks from third-party information resources": "Third-Party Information Resources", - } - - # Replace long descriptions with short names - use contains for partial matching - if not aux.empty: - for long_desc, short_name in ksi_short_names.items(): - mask = aux["REQUIREMENTS_DESCRIPTION"].str.contains( - long_desc, na=False, regex=False - ) - aux.loc[mask, "REQUIREMENTS_DESCRIPTION"] = short_name - - return get_section_containers_cis( - aux, "REQUIREMENTS_ID", "REQUIREMENTS_ATTRIBUTES_SECTION" - ) diff --git a/dashboard/compliance/fedramp_20x_ksi_low_azure.py b/dashboard/compliance/fedramp_20x_ksi_low_azure.py deleted file mode 100644 index 5ca220301f..0000000000 --- a/dashboard/compliance/fedramp_20x_ksi_low_azure.py +++ /dev/null @@ -1,46 +0,0 @@ -import warnings - -from dashboard.common_methods import get_section_containers_cis - -warnings.filterwarnings("ignore") - - -def get_table(data): - aux = data[ - [ - "REQUIREMENTS_ID", - "REQUIREMENTS_DESCRIPTION", - "REQUIREMENTS_ATTRIBUTES_SECTION", - "CHECKID", - "STATUS", - "REGION", - "ACCOUNTID", - "RESOURCEID", - ] - ].copy() - - # Shorten the long FedRAMP KSI descriptions for better display - ksi_short_names = { - "A secure cloud service offering will protect user data, control access, and apply zero trust principles": "Identity and Access Management", - "A secure cloud service offering will use cloud native architecture and design principles to enforce and enhance the Confidentiality, Integrity and Availability of the system": "Cloud Native Architecture", - "A secure cloud service provider will ensure that all system changes are properly documented and configuration baselines are updated accordingly": "Change Management", - "A secure cloud service provider will continuously educate their employees on cybersecurity measures, testing them regularly": "Cybersecurity Education", - "A secure cloud service offering will document, report, and analyze security incidents to ensure regulatory compliance and continuous security improvement": "Incident Reporting", - "A secure cloud service offering will monitor, log, and audit all important events, activity, and changes": "Monitoring, Logging, and Auditing", - "A secure cloud service offering will have intentional, organized, universal guidance for how every information resource, including personnel, is secured": "Policy and Inventory", - "A secure cloud service offering will define, maintain, and test incident response plan(s) and recovery capabilities to ensure minimal service disruption and data loss": "Recovery Planning", - "A secure cloud service offering will follow FedRAMP encryption policies, continuously verify information resource integrity, and restrict access to third-party information resources": "Service Configuration", - "A secure cloud service offering will understand, monitor, and manage supply chain risks from third-party information resources": "Third-Party Information Resources", - } - - # Replace long descriptions with short names - use contains for partial matching - if not aux.empty: - for long_desc, short_name in ksi_short_names.items(): - mask = aux["REQUIREMENTS_DESCRIPTION"].str.contains( - long_desc, na=False, regex=False - ) - aux.loc[mask, "REQUIREMENTS_DESCRIPTION"] = short_name - - return get_section_containers_cis( - aux, "REQUIREMENTS_ID", "REQUIREMENTS_ATTRIBUTES_SECTION" - ) diff --git a/dashboard/compliance/fedramp_20x_ksi_low_gcp.py b/dashboard/compliance/fedramp_20x_ksi_low_gcp.py deleted file mode 100644 index 5ca220301f..0000000000 --- a/dashboard/compliance/fedramp_20x_ksi_low_gcp.py +++ /dev/null @@ -1,46 +0,0 @@ -import warnings - -from dashboard.common_methods import get_section_containers_cis - -warnings.filterwarnings("ignore") - - -def get_table(data): - aux = data[ - [ - "REQUIREMENTS_ID", - "REQUIREMENTS_DESCRIPTION", - "REQUIREMENTS_ATTRIBUTES_SECTION", - "CHECKID", - "STATUS", - "REGION", - "ACCOUNTID", - "RESOURCEID", - ] - ].copy() - - # Shorten the long FedRAMP KSI descriptions for better display - ksi_short_names = { - "A secure cloud service offering will protect user data, control access, and apply zero trust principles": "Identity and Access Management", - "A secure cloud service offering will use cloud native architecture and design principles to enforce and enhance the Confidentiality, Integrity and Availability of the system": "Cloud Native Architecture", - "A secure cloud service provider will ensure that all system changes are properly documented and configuration baselines are updated accordingly": "Change Management", - "A secure cloud service provider will continuously educate their employees on cybersecurity measures, testing them regularly": "Cybersecurity Education", - "A secure cloud service offering will document, report, and analyze security incidents to ensure regulatory compliance and continuous security improvement": "Incident Reporting", - "A secure cloud service offering will monitor, log, and audit all important events, activity, and changes": "Monitoring, Logging, and Auditing", - "A secure cloud service offering will have intentional, organized, universal guidance for how every information resource, including personnel, is secured": "Policy and Inventory", - "A secure cloud service offering will define, maintain, and test incident response plan(s) and recovery capabilities to ensure minimal service disruption and data loss": "Recovery Planning", - "A secure cloud service offering will follow FedRAMP encryption policies, continuously verify information resource integrity, and restrict access to third-party information resources": "Service Configuration", - "A secure cloud service offering will understand, monitor, and manage supply chain risks from third-party information resources": "Third-Party Information Resources", - } - - # Replace long descriptions with short names - use contains for partial matching - if not aux.empty: - for long_desc, short_name in ksi_short_names.items(): - mask = aux["REQUIREMENTS_DESCRIPTION"].str.contains( - long_desc, na=False, regex=False - ) - aux.loc[mask, "REQUIREMENTS_DESCRIPTION"] = short_name - - return get_section_containers_cis( - aux, "REQUIREMENTS_ID", "REQUIREMENTS_ATTRIBUTES_SECTION" - ) diff --git a/prowler/changelog.d/fedramp-20x-ksi-2026.added.md b/prowler/changelog.d/fedramp-20x-ksi-2026.added.md new file mode 100644 index 0000000000..5fae5bb4a2 --- /dev/null +++ b/prowler/changelog.d/fedramp-20x-ksi-2026.added.md @@ -0,0 +1 @@ +`FedRAMP-20x-KSI` universal compliance framework (`fedramp_20x_ksi_2026`) with the 46 Key Security Indicators from the FedRAMP Consolidated Rules 2026 mapped for AWS, Azure, GCP, Kubernetes and M365 diff --git a/prowler/changelog.d/fedramp-20x-ksi-low-pilot.removed.md b/prowler/changelog.d/fedramp-20x-ksi-low-pilot.removed.md new file mode 100644 index 0000000000..fdca7d54e5 --- /dev/null +++ b/prowler/changelog.d/fedramp-20x-ksi-low-pilot.removed.md @@ -0,0 +1 @@ +`fedramp_20x_ksi_low_aws`, `fedramp_20x_ksi_low_azure` and `fedramp_20x_ksi_low_gcp` FedRAMP 20x Phase One pilot frameworks, superseded by `fedramp_20x_ksi_2026` diff --git a/prowler/compliance/aws/fedramp_20x_ksi_low_aws.json b/prowler/compliance/aws/fedramp_20x_ksi_low_aws.json deleted file mode 100644 index 178f07d3a5..0000000000 --- a/prowler/compliance/aws/fedramp_20x_ksi_low_aws.json +++ /dev/null @@ -1,383 +0,0 @@ -{ - "Framework": "FedRAMP-20x-KSI-Low", - "Name": "FedRAMP 20x Key Security Indicators (KSIs) - Low Impact Level v25.05C", - "Version": "25.05C", - "Provider": "AWS", - "Description": "FedRAMP 20x Key Security Indicators (KSIs) Low Impact Level represent core security indicators for cloud service providers, focusing on automation, continuous monitoring, and cloud-native security principles per FedRAMP 20x Phase One pilot requirements for Low impact systems.", - "Requirements": [ - { - "Id": "ksi-cmt", - "Name": "KSI-CMT: Change Management", - "Description": "A secure cloud service provider will ensure that all system changes are properly documented and configuration baselines are updated accordingly", - "Attributes": [ - { - "ItemId": "ksi-cmt", - "Section": "Change Management", - "Service": "aws" - } - ], - "Checks": [ - "cloudtrail_multi_region_enabled", - "cloudtrail_log_file_validation_enabled", - "cloudtrail_s3_dataevents_read_enabled", - "cloudtrail_s3_dataevents_write_enabled", - "cloudwatch_changes_to_network_acls_alarm_configured", - "cloudwatch_changes_to_network_gateways_alarm_configured", - "cloudwatch_changes_to_network_route_tables_alarm_configured", - "cloudwatch_changes_to_vpcs_alarm_configured", - "cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled", - "cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled", - "cloudwatch_log_metric_filter_aws_organizations_changes", - "cloudwatch_log_metric_filter_for_s3_bucket_policy_changes", - "cloudwatch_log_metric_filter_policy_changes", - "cloudwatch_log_metric_filter_security_group_changes", - "config_recorder_all_regions_enabled", - "ec2_instance_managed_by_ssm", - "ec2_instance_older_than_specific_days", - "ssm_managed_compliant_patching" - ], - "ConfigRequirements": [ - { - "Check": "config_recorder_all_regions_enabled", - "ConfigKey": "mute_non_default_regions", - "Operator": "eq", - "Value": false - } - ] - }, - { - "Id": "ksi-cna", - "Name": "KSI-CNA: Cloud Native Architecture", - "Description": "A secure cloud service offering will use cloud native architecture and design principles to enforce and enhance the Confidentiality, Integrity and Availability of the system", - "Attributes": [ - { - "ItemId": "ksi-cna", - "Section": "Cloud Native Architecture", - "Service": "aws" - } - ], - "Checks": [ - "autoscaling_group_multiple_az", - "autoscaling_group_multiple_instance_types", - "autoscaling_group_capacity_rebalance_enabled", - "dynamodb_tables_pitr_enabled", - "dynamodb_table_deletion_protection_enabled", - "ec2_instance_imdsv2_enabled", - "ec2_networkacl_allow_ingress_any_port", - "ec2_securitygroup_default_restrict_traffic", - "ec2_securitygroup_allow_ingress_from_internet_to_any_port", - "eks_cluster_network_policy_enabled", - "eks_cluster_not_publicly_accessible", - "eks_cluster_private_nodes_enabled", - "eks_cluster_uses_a_supported_version", - "elb_cross_zone_load_balancing_enabled", - "elbv2_is_in_multiple_az", - "elbv2_waf_acl_attached", - "rds_instance_multi_az", - "rds_cluster_multi_az", - "vpc_subnet_no_public_ip_by_default", - "vpc_peering_routing_tables_with_least_privilege", - "ec2_confidential_workload_host_imdsv2_not_enforced" - ] - }, - { - "Id": "ksi-iam", - "Name": "KSI-IAM: Identity and Access Management", - "Description": "A secure cloud service offering will protect user data, control access, and apply zero trust principles", - "Attributes": [ - { - "ItemId": "ksi-iam", - "Section": "Identity and Access Management", - "Service": "aws" - } - ], - "Checks": [ - "iam_inline_policy_no_wildcard_marketplace_subscribe", - "iam_policy_no_wildcard_marketplace_subscribe", - "iam_administrator_access_with_mfa", - "iam_aws_attached_policy_no_administrative_privileges", - "iam_customer_attached_policy_no_administrative_privileges", - "iam_inline_policy_no_administrative_privileges", - "iam_no_custom_policy_permissive_role_assumption", - "iam_no_root_access_key", - "iam_password_policy_expires_passwords_within_90_days_or_less", - "iam_password_policy_lowercase", - "iam_password_policy_minimum_length_14", - "iam_password_policy_number", - "iam_password_policy_reuse_24", - "iam_password_policy_symbol", - "iam_password_policy_uppercase", - "iam_policy_attached_only_to_group_or_roles", - "iam_policy_no_full_access_to_cloudtrail", - "iam_policy_no_full_access_to_kms", - "iam_root_hardware_mfa_enabled", - "iam_root_mfa_enabled", - "iam_rotate_access_key_90_days", - "iam_role_access_not_stale_to_bedrock", - "iam_user_access_not_stale_to_bedrock", - "iam_user_access_not_stale_to_sagemaker", - "iam_user_accesskey_unused", - "iam_user_console_access_unused", - "iam_user_hardware_mfa_enabled", - "iam_user_mfa_enabled_console_access", - "iam_user_two_active_access_key", - "organizations_scp_check_deny_regions", - "organizations_opt_out_ai_services_policy" - ] - }, - { - "Id": "ksi-inr", - "Name": "KSI-INR: Incident Response", - "Description": "A secure cloud service offering will respond to incidents according to FedRAMP requirements and cloud service provider policies", - "Attributes": [ - { - "ItemId": "ksi-inr", - "Section": "Incident Response", - "Service": "aws" - } - ], - "Checks": [ - "guardduty_centrally_managed", - "guardduty_ec2_malware_protection_enabled", - "guardduty_eks_audit_log_enabled", - "guardduty_eks_runtime_monitoring_enabled", - "guardduty_is_enabled", - "guardduty_lambda_protection_enabled", - "guardduty_no_high_severity_findings", - "guardduty_rds_protection_enabled", - "guardduty_s3_protection_enabled", - "inspector2_is_enabled", - "inspector2_active_findings_exist", - "securityhub_enabled", - "sns_topics_kms_encryption_at_rest_enabled" - ], - "ConfigRequirements": [ - { - "Check": "guardduty_is_enabled", - "ConfigKey": "mute_non_default_regions", - "Operator": "eq", - "Value": false - }, - { - "Check": "securityhub_enabled", - "ConfigKey": "mute_non_default_regions", - "Operator": "eq", - "Value": false - } - ] - }, - { - "Id": "ksi-mla", - "Name": "KSI-MLA: Monitoring, Logging, and Auditing", - "Description": "A secure cloud service offering will monitor, log, and audit all important events, activity, and changes", - "Attributes": [ - { - "ItemId": "ksi-mla", - "Section": "Monitoring, Logging, and Auditing", - "Service": "aws" - } - ], - "Checks": [ - "apigateway_restapi_logging_enabled", - "cloudtrail_cloudwatch_logging_enabled", - "cloudtrail_kms_encryption_enabled", - "cloudtrail_log_file_validation_enabled", - "cloudtrail_multi_region_enabled", - "cloudtrail_s3_dataevents_read_enabled", - "cloudtrail_s3_dataevents_write_enabled", - "cloudwatch_log_group_kms_encryption_enabled", - "cloudwatch_log_group_retention_policy_specific_days_enabled", - "ecs_cluster_container_insights_enabled", - "eks_control_plane_logging_all_types_enabled", - "elb_logging_enabled", - "elbv2_logging_enabled", - "inspector2_is_enabled", - "opensearch_service_domains_cloudwatch_logging_enabled", - "rds_instance_enhanced_monitoring_enabled", - "rds_instance_integration_cloudwatch_logs", - "redshift_cluster_audit_logging", - "s3_bucket_server_access_logging_enabled", - "vpc_flow_logs_enabled", - "wafv2_webacl_logging_enabled", - "kms_key_enclave_debug_attestation_detected", - "kms_key_enclave_attestation_unknown_image" - ] - }, - { - "Id": "ksi-piy", - "Name": "KSI-PIY: Policy and Inventory", - "Description": "A secure cloud service offering will have intentional, organized, universal guidance for how every information resource, including personnel, is secured", - "Attributes": [ - { - "ItemId": "ksi-piy", - "Section": "Policy and Inventory", - "Service": "aws" - } - ], - "Checks": [ - "config_recorder_all_regions_enabled", - "config_recorder_using_aws_service_role", - "ec2_instance_managed_by_ssm", - "organizations_account_part_of_organizations", - "organizations_delegated_administrators", - "organizations_scp_check_deny_regions", - "organizations_tags_policies_enabled_and_attached", - "resourceexplorer2_indexes_found", - "trustedadvisor_premium_support_plan_subscribed" - ], - "ConfigRequirements": [ - { - "Check": "config_recorder_all_regions_enabled", - "ConfigKey": "mute_non_default_regions", - "Operator": "eq", - "Value": false - } - ] - }, - { - "Id": "ksi-rpl", - "Name": "KSI-RPL: Recovery Planning", - "Description": "A secure cloud service offering will define, maintain, and test incident response plan(s) and recovery capabilities to ensure minimal service disruption and data loss", - "Attributes": [ - { - "ItemId": "ksi-rpl", - "Section": "Recovery Planning", - "Service": "aws" - } - ], - "Checks": [ - "backup_plans_exist", - "backup_reportplans_exist", - "backup_vaults_exist", - "backup_vaults_encrypted", - "backup_recovery_point_encrypted", - "dlm_ebs_snapshot_lifecycle_policy_exists", - "dynamodb_tables_pitr_enabled", - "dynamodb_table_deletion_protection_enabled", - "efs_have_backup_enabled", - "fsx_file_system_copy_tags_to_backups_enabled", - "rds_instance_backup_enabled", - "rds_instance_deletion_protection", - "rds_cluster_deletion_protection", - "rds_snapshots_encrypted", - "redshift_cluster_automated_snapshot" - ] - }, - { - "Id": "ksi-svc", - "Name": "KSI-SVC: Service Configuration", - "Description": "A secure cloud service offering will follow FedRAMP encryption policies, continuously verify information resource integrity, and restrict access to third-party information resources", - "Attributes": [ - { - "ItemId": "ksi-svc", - "Section": "Service Configuration", - "Service": "aws" - } - ], - "Checks": [ - "acm_certificates_expiration_check", - "apigateway_restapi_cache_encrypted", - "cloudtrail_kms_encryption_enabled", - "dynamodb_accelerator_cluster_encryption_enabled", - "dynamodb_tables_kms_cmk_encryption_enabled", - "ec2_ebs_volume_encryption", - "ec2_ebs_default_encryption", - "efs_encryption_at_rest_enabled", - "eks_cluster_kms_cmk_encryption_in_secrets_enabled", - "elasticache_redis_cluster_rest_encryption_enabled", - "elasticache_redis_cluster_in_transit_encryption_enabled", - "elbv2_ssl_listeners", - "kinesis_stream_encrypted_at_rest", - "kms_cmk_rotation_enabled", - "kms_cmk_not_deleted_unintentionally", - "kms_key_not_publicly_accessible", - "rds_instance_storage_encrypted", - "rds_cluster_storage_encrypted", - "redshift_cluster_encrypted_at_rest", - "redshift_cluster_in_transit_encryption_enabled", - "s3_bucket_default_encryption", - "s3_bucket_secure_transport_policy", - "sagemaker_notebook_instance_encryption_enabled", - "sns_topics_kms_encryption_at_rest_enabled", - "sqs_queues_server_side_encryption_enabled", - "kms_key_enclave_attestation_not_enforced" - ] - }, - { - "Id": "ksi-tpr", - "Name": "KSI-TPR: Third-Party Information Resources", - "Description": "A secure cloud service offering will understand, monitor, and manage supply chain risks from third-party information resources", - "Attributes": [ - { - "ItemId": "ksi-tpr", - "Section": "Third-Party Information Resources", - "Service": "aws" - } - ], - "Checks": [ - "ecr_registry_scan_images_on_push_enabled", - "ecr_repositories_lifecycle_policy_enabled", - "ecr_repositories_not_publicly_accessible", - "ecr_repositories_scan_images_on_push_enabled", - "ecr_repositories_scan_vulnerabilities_in_latest_image", - "ecr_repositories_tag_immutability", - "inspector2_active_findings_exist", - "inspector2_is_enabled", - "awslambda_function_using_supported_runtimes", - "ssm_managed_compliant_patching", - "trustedadvisor_premium_support_plan_subscribed", - "guardduty_no_high_severity_findings" - ] - }, - { - "Id": "ksi-iam-07", - "Name": "KSI-IAM-07: Account Lifecycle Management", - "Description": "Securely manage the lifecycle and privileges of all accounts, roles, and groups", - "Attributes": [ - { - "ItemId": "ksi-iam-07", - "Section": "Identity and Access Management", - "Service": "aws" - } - ], - "Checks": [ - "iam_no_root_access_key", - "iam_policy_attached_only_to_group_or_roles", - "iam_rotate_access_key_90_days", - "iam_role_access_not_stale_to_bedrock", - "iam_user_access_not_stale_to_bedrock", - "iam_user_access_not_stale_to_sagemaker", - "iam_user_accesskey_unused", - "iam_user_console_access_unused", - "organizations_delegated_administrators" - ] - }, - { - "Id": "ksi-mla-07", - "Name": "KSI-MLA-07: Monitoring and Logging Inventory", - "Description": "Maintain a list of information resources and event types that will be monitored, logged, and audited", - "Attributes": [ - { - "ItemId": "ksi-mla-07", - "Section": "Monitoring, Logging, and Auditing", - "Service": "aws" - } - ], - "Checks": [ - "cloudtrail_multi_region_enabled", - "cloudwatch_log_group_retention_policy_specific_days_enabled", - "config_recorder_all_regions_enabled", - "inspector2_is_enabled", - "resourceexplorer2_indexes_found" - ], - "ConfigRequirements": [ - { - "Check": "config_recorder_all_regions_enabled", - "ConfigKey": "mute_non_default_regions", - "Operator": "eq", - "Value": false - } - ] - } - ] -} diff --git a/prowler/compliance/azure/fedramp_20x_ksi_low_azure.json b/prowler/compliance/azure/fedramp_20x_ksi_low_azure.json deleted file mode 100644 index 3e5ea9d956..0000000000 --- a/prowler/compliance/azure/fedramp_20x_ksi_low_azure.json +++ /dev/null @@ -1,305 +0,0 @@ -{ - "Framework": "FedRAMP-20x-KSI-Low", - "Name": "FedRAMP 20x Key Security Indicators (KSIs) - Low Impact Level v25.05C", - "Version": "25.05C", - "Provider": "Azure", - "Description": "FedRAMP 20x Key Security Indicators (KSIs) Low Impact Level represent core security indicators for cloud service providers, focusing on automation, continuous monitoring, and cloud-native security principles per FedRAMP 20x Phase One pilot requirements for Low impact systems.", - "Requirements": [ - { - "Id": "ksi-cmt", - "Name": "KSI-CMT: Change Management", - "Description": "A secure cloud service provider will ensure that all system changes are properly documented and configuration baselines are updated accordingly", - "Attributes": [ - { - "ItemId": "ksi-cmt", - "Section": "Change Management", - "Service": "azure" - } - ], - "Checks": [ - "monitor_alert_create_policy_assignment", - "monitor_alert_create_update_sqlserver_fr", - "monitor_alert_delete_sqlserver_fr", - "monitor_alert_create_update_nsg", - "monitor_alert_create_update_public_ip_address_rule", - "monitor_alert_create_update_security_solution", - "monitor_alert_delete_nsg", - "monitor_alert_delete_policy_assignment", - "monitor_alert_delete_public_ip_address_rule", - "monitor_alert_delete_security_solution", - "monitor_diagnostic_setting_with_appropriate_categories", - "defender_assessments_vm_endpoint_protection_installed" - ] - }, - { - "Id": "ksi-cna", - "Name": "KSI-CNA: Cloud Native Architecture", - "Description": "A secure cloud service offering will use cloud native architecture and design principles to enforce and enhance the Confidentiality, Integrity and Availability of the system", - "Attributes": [ - { - "ItemId": "ksi-cna", - "Section": "Cloud Native Architecture", - "Service": "azure" - } - ], - "Checks": [ - "aks_clusters_created_with_private_nodes", - "aks_clusters_public_access_disabled", - "aks_network_policy_enabled", - "app_function_vnet_integration_enabled", - "app_function_not_publicly_accessible", - "containerregistry_not_publicly_accessible", - "containerregistry_uses_private_link", - "cosmosdb_account_use_private_endpoints", - "cosmosdb_account_firewall_use_selected_networks", - "databricks_workspace_vnet_injection_enabled", - "keyvault_access_only_through_private_endpoints", - "keyvault_private_endpoints", - "network_bastion_host_exists", - "network_flow_log_captured_sent", - "network_ssh_internet_access_restricted", - "network_rdp_internet_access_restricted", - "network_watcher_enabled", - "storage_default_network_access_rule_is_denied" - ] - }, - { - "Id": "ksi-iam", - "Name": "KSI-IAM: Identity and Access Management", - "Description": "A secure cloud service offering will protect user data, control access, and apply zero trust principles", - "Attributes": [ - { - "ItemId": "ksi-iam", - "Section": "Identity and Access Management", - "Service": "azure" - } - ], - "Checks": [ - "entra_conditional_access_policy_require_mfa_for_management_api", - "entra_global_admin_in_less_than_five_users", - "entra_non_privileged_user_has_mfa", - "entra_policy_default_users_cannot_create_security_groups", - "entra_policy_ensure_default_user_cannot_create_apps", - "entra_policy_ensure_default_user_cannot_create_tenants", - "entra_policy_guest_invite_only_for_admin_roles", - "entra_policy_guest_users_access_restrictions", - "entra_policy_restricts_user_consent_for_apps", - "entra_policy_user_consent_for_verified_apps", - "entra_privileged_user_has_mfa", - "entra_security_defaults_enabled", - "entra_trusted_named_locations_exists", - "entra_user_with_vm_access_has_mfa", - "entra_users_cannot_create_microsoft_365_groups", - "iam_custom_role_has_permissions_to_administer_resource_locks", - "iam_role_user_access_admin_restricted", - "iam_subscription_roles_owner_custom_not_created", - "keyvault_rbac_enabled", - "app_function_identity_is_configured", - "app_function_identity_without_admin_privileges", - "app_ensure_auth_is_set_up", - "app_register_with_identity" - ] - }, - { - "Id": "ksi-inr", - "Name": "KSI-INR: Incident Response", - "Description": "A secure cloud service offering will respond to incidents according to FedRAMP requirements and cloud service provider policies", - "Attributes": [ - { - "ItemId": "ksi-inr", - "Section": "Incident Response", - "Service": "azure" - } - ], - "Checks": [ - "defender_attack_path_notifications_properly_configured", - "defender_ensure_notify_alerts_severity_is_high", - "defender_ensure_notify_emails_to_owners", - "defender_additional_email_configured_with_a_security_contact", - "defender_container_images_resolved_vulnerabilities", - "defender_container_images_scan_enabled", - "defender_ensure_defender_for_app_services_is_on", - "defender_ensure_defender_for_arm_is_on", - "defender_ensure_defender_for_azure_sql_databases_is_on", - "defender_ensure_defender_for_containers_is_on", - "defender_ensure_defender_for_cosmosdb_is_on", - "defender_ensure_defender_for_databases_is_on", - "defender_ensure_defender_for_dns_is_on", - "defender_ensure_defender_for_keyvault_is_on", - "defender_ensure_defender_for_os_relational_databases_is_on", - "defender_ensure_defender_for_server_is_on", - "defender_ensure_defender_for_sql_servers_is_on", - "defender_ensure_defender_for_storage_is_on", - "defender_ensure_iot_hub_defender_is_on", - "defender_ensure_wdatp_is_enabled" - ] - }, - { - "Id": "ksi-mla", - "Name": "KSI-MLA: Monitoring, Logging, and Auditing", - "Description": "A secure cloud service offering will monitor, log, and audit all important events, activity, and changes", - "Attributes": [ - { - "ItemId": "ksi-mla", - "Section": "Monitoring, Logging, and Auditing", - "Service": "azure" - } - ], - "Checks": [ - "app_function_application_insights_enabled", - "app_http_logs_enabled", - "appinsights_ensure_is_configured", - "defender_auto_provisioning_log_analytics_agent_vms_on", - "defender_auto_provisioning_vulnerabilty_assessments_machines_on", - "keyvault_logging_enabled", - "monitor_diagnostic_setting_with_appropriate_categories", - "monitor_diagnostic_settings_exists", - "network_flow_log_captured_sent", - "network_flow_log_more_than_90_days", - "network_watcher_enabled", - "postgresql_flexible_server_log_checkpoints_on", - "postgresql_flexible_server_log_connections_on", - "postgresql_flexible_server_log_disconnections_on", - "sqlserver_auditing_enabled", - "sqlserver_auditing_retention_90_days" - ] - }, - { - "Id": "ksi-piy", - "Name": "KSI-PIY: Policy and Inventory", - "Description": "A secure cloud service offering will have intentional, organized, universal guidance for how every information resource, including personnel, is secured", - "Attributes": [ - { - "ItemId": "ksi-piy", - "Section": "Policy and Inventory", - "Service": "azure" - } - ], - "Checks": [ - "defender_ensure_defender_for_containers_is_on", - "defender_ensure_defender_for_app_services_is_on", - "defender_ensure_defender_for_azure_sql_databases_is_on", - "defender_ensure_defender_for_keyvault_is_on", - "defender_ensure_defender_for_server_is_on", - "defender_ensure_defender_for_sql_servers_is_on", - "defender_ensure_defender_for_storage_is_on" - ] - }, - { - "Id": "ksi-rpl", - "Name": "KSI-RPL: Recovery Planning", - "Description": "A secure cloud service offering will define, maintain, and test incident response plan(s) and recovery capabilities to ensure minimal service disruption and data loss", - "Attributes": [ - { - "ItemId": "ksi-rpl", - "Section": "Recovery Planning", - "Service": "azure" - } - ], - "Checks": [ - "mysql_flexible_server_geo_redundant_backup_enabled", - "postgresql_flexible_server_geo_redundant_backup_enabled", - "storage_geo_redundant_enabled", - "storage_infrastructure_encryption_is_enabled", - "storage_ensure_soft_delete_is_enabled", - "vm_backup_enabled", - "vm_sufficient_daily_backup_retention_period" - ] - }, - { - "Id": "ksi-svc", - "Name": "KSI-SVC: Service Configuration", - "Description": "A secure cloud service offering will follow FedRAMP encryption policies, continuously verify information resource integrity, and restrict access to third-party information resources", - "Attributes": [ - { - "ItemId": "ksi-svc", - "Section": "Service Configuration", - "Service": "azure" - } - ], - "Checks": [ - "app_client_certificates_on", - "app_ensure_http_is_redirected_to_https", - "app_minimum_tls_version_12", - "containerregistry_admin_user_disabled", - "cosmosdb_account_use_aad_and_rbac", - "databricks_workspace_cmk_encryption_enabled", - "keyvault_key_expiration_set_in_non_rbac", - "keyvault_key_rotation_enabled", - "keyvault_non_rbac_secret_expiration_set", - "mysql_flexible_server_ssl_connection_enabled", - "mysql_flexible_server_minimum_tls_version_12", - "postgresql_flexible_server_enforce_ssl_enabled", - "defender_ensure_defender_for_sql_servers_is_on", - "sqlserver_tde_encrypted_with_cmk", - "sqlserver_tde_encryption_enabled", - "sqlserver_recommended_minimal_tls_version", - "storage_secure_transfer_required_is_enabled", - "storage_ensure_encryption_with_customer_managed_keys", - "storage_infrastructure_encryption_is_enabled", - "storage_ensure_minimum_tls_version_12", - "vm_ensure_attached_disks_encrypted_with_cmk" - ] - }, - { - "Id": "ksi-tpr", - "Name": "KSI-TPR: Third-Party Information Resources", - "Description": "A secure cloud service offering will understand, monitor, and manage supply chain risks from third-party information resources", - "Attributes": [ - { - "ItemId": "ksi-tpr", - "Section": "Third-Party Information Resources", - "Service": "azure" - } - ], - "Checks": [ - "app_ensure_java_version_is_latest", - "app_ensure_php_version_is_latest", - "app_ensure_python_version_is_latest", - "app_function_latest_runtime_version", - "defender_container_images_resolved_vulnerabilities", - "defender_container_images_scan_enabled", - "defender_ensure_system_updates_are_applied", - "defender_assessments_vm_endpoint_protection_installed" - ] - }, - { - "Id": "ksi-iam-07", - "Name": "KSI-IAM-07: Account Lifecycle Management", - "Description": "Securely manage the lifecycle and privileges of all accounts, roles, and groups", - "Attributes": [ - { - "ItemId": "ksi-iam-07", - "Section": "Identity and Access Management", - "Service": "azure" - } - ], - "Checks": [ - "entra_non_privileged_user_has_mfa", - "entra_privileged_user_has_mfa", - "entra_user_with_recent_sign_in", - "entra_user_with_vm_access_has_mfa", - "iam_custom_role_has_permissions_to_administer_resource_locks", - "iam_role_user_access_admin_restricted", - "app_function_identity_is_configured" - ] - }, - { - "Id": "ksi-mla-07", - "Name": "KSI-MLA-07: Monitoring and Logging Inventory", - "Description": "Maintain a list of information resources and event types that will be monitored, logged, and audited", - "Attributes": [ - { - "ItemId": "ksi-mla-07", - "Section": "Monitoring, Logging, and Auditing", - "Service": "azure" - } - ], - "Checks": [ - "monitor_diagnostic_setting_with_appropriate_categories", - "monitor_diagnostic_settings_exists", - "network_watcher_enabled" - ] - } - ] -} diff --git a/prowler/compliance/fedramp_20x_ksi_2026.json b/prowler/compliance/fedramp_20x_ksi_2026.json new file mode 100644 index 0000000000..b893675faf --- /dev/null +++ b/prowler/compliance/fedramp_20x_ksi_2026.json @@ -0,0 +1,2284 @@ +{ + "framework": "FedRAMP-20x-KSI", + "name": "FedRAMP 20x Key Security Indicators (KSI) 2026", + "version": "2026.07.14.01", + "description": "FedRAMP 20x Key Security Indicators (KSIs) from the FedRAMP Consolidated Rules for 2026 (release 2026.07.14.01, https://github.com/FedRAMP/rules). KSIs are outcome-oriented indicators that cloud service providers must demonstrate through automation and continuous monitoring; they do not replace the FedRAMP Rules (FRR) program obligations. Class A authorizations mandate a subset of seven KSIs via FRC-CLA-MFR; Classes B and C apply the full catalog within the Minimum Assessment Scope, with five indicators optional on Class B and required on Class C.", + "icon": "fedramp", + "attributes_metadata": [ + { + "key": "Theme", + "label": "Theme", + "type": "str", + "required": true, + "enum": [ + "KSI-CED: Cybersecurity Education", + "KSI-CMT: Change Management", + "KSI-CNA: Cloud Native Architecture", + "KSI-IAM: Identity and Access Management", + "KSI-INR: Incident Response", + "KSI-MLA: Monitoring, Logging, and Auditing", + "KSI-PIY: Policy and Inventory", + "KSI-RPL: Recovery Planning", + "KSI-SCR: Supply Chain Risk", + "KSI-SVC: Service Configuration" + ] + }, + { + "key": "NISTControls", + "label": "NIST SP 800-53 Controls", + "type": "str" + }, + { + "key": "ClassApplicability", + "label": "Class Applicability", + "type": "str", + "required": true, + "enum": [ + "Required for Classes B and C", + "Optional for Class B, required for Class C" + ] + } + ], + "outputs": { + "table_config": { + "group_by": "Theme" + }, + "pdf_config": { + "language": "en", + "primary_color": "#1B3A5C", + "secondary_color": "#2E6DA4", + "bg_color": "#F0F4FA", + "group_by_field": "Theme", + "sections": [ + "KSI-CED: Cybersecurity Education", + "KSI-CMT: Change Management", + "KSI-CNA: Cloud Native Architecture", + "KSI-IAM: Identity and Access Management", + "KSI-INR: Incident Response", + "KSI-MLA: Monitoring, Logging, and Auditing", + "KSI-PIY: Policy and Inventory", + "KSI-RPL: Recovery Planning", + "KSI-SCR: Supply Chain Risk", + "KSI-SVC: Service Configuration" + ], + "section_short_names": { + "KSI-CED: Cybersecurity Education": "KSI-CED", + "KSI-CMT: Change Management": "KSI-CMT", + "KSI-CNA: Cloud Native Architecture": "KSI-CNA", + "KSI-IAM: Identity and Access Management": "KSI-IAM", + "KSI-INR: Incident Response": "KSI-INR", + "KSI-MLA: Monitoring, Logging, and Auditing": "KSI-MLA", + "KSI-PIY: Policy and Inventory": "KSI-PIY", + "KSI-RPL: Recovery Planning": "KSI-RPL", + "KSI-SCR: Supply Chain Risk": "KSI-SCR", + "KSI-SVC: Service Configuration": "KSI-SVC" + }, + "charts": [ + { + "id": "theme_compliance", + "type": "horizontal_bar", + "group_by": "Theme", + "title": "Compliance Score by KSI Theme", + "y_label": "Theme", + "x_label": "Compliance %", + "value_source": "compliance_percent", + "color_mode": "by_value" + } + ], + "filter": { + "only_failed": true, + "include_manual": false + } + } + }, + "requirements": [ + { + "id": "KSI-CED-RAT", + "name": "Reviewing All Training", + "description": "The effectiveness of relevant cybersecurity education and training is persistently reviewed, including at least general training for all employees, role-specific training for employees in high risk roles, training for development and engineering staff on secure software delivery, and training for staff involved with incident response or disaster recovery.", + "attributes": { + "Theme": "KSI-CED: Cybersecurity Education", + "NISTControls": "CP-3, IR-2, PS-6, AT-2, AT-2.2, AT-2.3, AT-3.5, AT-4, IR-2.3, AT-3, SR-11.1", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "KSI-CMT-LMC", + "name": "Logging Changes", + "description": "Modifications to the cloud service offering are logged and monitored.", + "attributes": { + "Theme": "KSI-CMT: Change Management", + "NISTControls": "AU-2, CM-3, CM-3.2, CM-4.2, CM-6, CM-8.3, MA-2", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "cloudtrail_cloudwatch_logging_enabled", + "cloudtrail_multi_region_enabled", + "cloudtrail_multi_region_enabled_logging_management_events", + "cloudtrail_s3_dataevents_write_enabled", + "cloudwatch_changes_to_network_acls_alarm_configured", + "cloudwatch_changes_to_network_gateways_alarm_configured", + "cloudwatch_changes_to_network_route_tables_alarm_configured", + "cloudwatch_changes_to_vpcs_alarm_configured", + "cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled", + "cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled", + "cloudwatch_log_metric_filter_aws_organizations_changes", + "cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk", + "cloudwatch_log_metric_filter_for_s3_bucket_policy_changes", + "cloudwatch_log_metric_filter_policy_changes", + "cloudwatch_log_metric_filter_security_group_changes", + "config_recorder_all_regions_enabled" + ], + "azure": [ + "monitor_alert_create_policy_assignment", + "monitor_alert_create_update_nsg", + "monitor_alert_create_update_public_ip_address_rule", + "monitor_alert_create_update_security_solution", + "monitor_alert_create_update_sqlserver_fr", + "monitor_alert_delete_nsg", + "monitor_alert_delete_policy_assignment", + "monitor_alert_delete_public_ip_address_rule", + "monitor_alert_delete_security_solution", + "monitor_alert_delete_sqlserver_fr", + "monitor_diagnostic_setting_with_appropriate_categories", + "monitor_diagnostic_settings_exists" + ], + "gcp": [ + "iam_audit_logs_enabled", + "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", + "logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", + "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled", + "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled" + ], + "kubernetes": [ + "apiserver_audit_log_path_set" + ], + "m365": [ + "exchange_organization_mailbox_auditing_enabled", + "exchange_user_mailbox_auditing_enabled", + "purview_audit_log_search_enabled" + ] + }, + "config_requirements": [ + { + "Check": "exchange_user_mailbox_auditing_enabled", + "ConfigKey": "audit_log_age", + "Operator": "gte", + "Value": 90, + "Provider": "m365" + }, + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + } + ] + }, + { + "id": "KSI-CMT-RMV", + "name": "Redeploying vs Modifying", + "description": "Changes to machine-based information resources are executed through the redeployment of version controlled resources rather than direct modification wherever reasonable.", + "attributes": { + "Theme": "KSI-CMT: Change Management", + "NISTControls": "CM-2, CM-3, CM-5, CM-6, CM-7, CM-8.1, SI-3", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "autoscaling_group_using_ec2_launch_template", + "ecs_task_definitions_containers_readonly_access" + ], + "azure": [], + "gcp": [], + "kubernetes": [ + "apiserver_always_pull_images_plugin", + "core_image_tag_fixed", + "core_readonly_root_filesystem_enabled" + ], + "m365": [] + } + }, + { + "id": "KSI-CMT-RVP", + "name": "Reviewing Change Procedures", + "description": "The effectiveness of documented change management procedures is persistently reviewed.", + "attributes": { + "Theme": "KSI-CMT: Change Management", + "NISTControls": "CM-3, CM-3.2, CM-3.4, CM-5, CM-7.1, CM-9", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "KSI-CMT-VTD", + "name": "Validating Throughout Deployment", + "description": "Persistent testing and validation of changes throughout deployment is automated.", + "attributes": { + "Theme": "KSI-CMT: Change Management", + "NISTControls": "CM-3, CM-3.2, CM-4.2, SI-2", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "KSI-CNA-DFP", + "name": "Defining Functionality and Privileges", + "description": "The functionality and privileges for infrastructure and services are strictly defined.", + "attributes": { + "Theme": "KSI-CNA: Cloud Native Architecture", + "NISTControls": "CM-2, SI-3", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "ec2_instance_imdsv2_enabled", + "ecs_task_definitions_host_namespace_not_shared", + "ecs_task_definitions_host_networking_mode_users", + "ecs_task_definitions_no_privileged_containers", + "organizations_scp_check_deny_regions", + "sagemaker_notebook_instance_root_access_disabled" + ], + "azure": [], + "gcp": [ + "cloudstorage_bucket_uniform_bucket_level_access" + ], + "kubernetes": [ + "apiserver_auth_mode_include_node", + "apiserver_auth_mode_include_rbac", + "apiserver_auth_mode_not_always_allow", + "apiserver_namespace_lifecycle_plugin", + "apiserver_node_restriction_plugin", + "apiserver_security_context_deny_plugin", + "apiserver_service_account_plugin" + ], + "m365": [ + "entra_admin_portals_access_restriction", + "entra_all_apps_conditional_access_coverage", + "entra_conditional_access_policy_app_enforced_restrictions", + "entra_conditional_access_policy_approved_client_app_required_for_mobile", + "entra_conditional_access_policy_device_code_flow_blocked", + "entra_managed_device_required_for_authentication" + ] + } + }, + { + "id": "KSI-CNA-EIS", + "name": "Enforcing Intended State", + "description": "Automated services are used to persistently assess the security of all machine-based information resources and automatically enforce their intended operational state.", + "attributes": { + "Theme": "KSI-CNA: Cloud Native Architecture", + "NISTControls": "CA-2.1, CA-7.1", + "ClassApplicability": "Optional for Class B, required for Class C" + }, + "checks": { + "aws": [ + "config_delegated_admin_and_org_aggregator_all_regions", + "config_recorder_all_regions_enabled", + "securityhub_enabled", + "ssm_managed_compliant_patching" + ], + "azure": [ + "defender_ensure_defender_cspm_is_on", + "policy_ensure_asc_enforcement_enabled" + ], + "gcp": [], + "kubernetes": [], + "m365": [] + }, + "config_requirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + }, + { + "Check": "config_delegated_admin_and_org_aggregator_all_regions", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + } + ] + }, + { + "id": "KSI-CNA-IBP", + "name": "Implementing Best Practices", + "description": "The use and configuration of third-party machine-based information resources is persistently compared against the original provider's best practices and guidance.", + "attributes": { + "Theme": "KSI-CNA: Cloud Native Architecture", + "NISTControls": "AC-17.3, CM-2, PL-10", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "awslambda_function_using_supported_runtimes", + "ec2_instance_with_outdated_ami", + "ecs_service_fargate_latest_platform_version", + "eks_cluster_uses_a_supported_version", + "kafka_cluster_uses_latest_version", + "opensearch_service_domains_updated_to_the_latest_service_software_version", + "rds_instance_deprecated_engine_version", + "wellarchitected_workload_no_high_or_medium_risks" + ], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [ + "defender_strict_preset_security_policy_enabled" + ] + }, + "config_requirements": [ + { + "Check": "awslambda_function_using_supported_runtimes", + "ConfigKey": "obsolete_lambda_runtimes", + "Operator": "superset", + "Value": [ + "java8", + "go1.x", + "provided", + "python3.6", + "python2.7", + "python3.7", + "python3.8", + "nodejs4.3", + "nodejs4.3-edge", + "nodejs6.10", + "nodejs", + "nodejs8.10", + "nodejs10.x", + "nodejs12.x", + "nodejs14.x", + "nodejs16.x", + "dotnet5.0", + "dotnet6", + "dotnet7", + "dotnetcore1.0", + "dotnetcore2.0", + "dotnetcore2.1", + "dotnetcore3.1", + "ruby2.5", + "ruby2.7" + ], + "Provider": "aws" + } + ] + }, + { + "id": "KSI-CNA-MAT", + "name": "Minimizing Attack Surface", + "description": "Machine-based information resources are persistently reviewed to ensure they have a minimal attack surface and that lateral movement is minimized if compromised.", + "attributes": { + "Theme": "KSI-CNA: Cloud Native Architecture", + "NISTControls": "AC-17.3, AC-18.1, AC-18.3, AC-20.1, CA-9, SC-7.3, SC-7.4, SC-7.5, SC-7.8, SC-8, SC-10, SI-10, SI-11, SI-16", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "apigateway_restapi_public", + "appstream_fleet_default_internet_access_disabled", + "autoscaling_group_launch_configuration_no_public_ip", + "awslambda_function_not_publicly_accessible", + "awslambda_function_url_public", + "codebuild_project_not_publicly_accessible", + "dms_instance_no_public_access", + "ec2_instance_internet_facing_with_instance_profile", + "ec2_instance_public_ip", + "ec2_instance_uses_single_eni", + "ec2_launch_template_no_public_ip", + "ecs_service_no_assign_public_ip", + "ecs_task_set_no_assign_public_ip", + "efs_mount_target_not_publicly_accessible", + "eks_cluster_not_publicly_accessible", + "eks_cluster_private_nodes_enabled", + "elasticache_cluster_uses_public_subnet", + "elb_internet_facing", + "elbv2_internet_facing", + "emr_cluster_account_public_block_enabled", + "emr_cluster_master_nodes_no_public_ip", + "emr_cluster_publicly_accesible", + "kafka_cluster_is_public", + "lightsail_database_public", + "lightsail_instance_public", + "mq_broker_not_publicly_accessible", + "neptune_cluster_uses_public_subnet", + "opensearch_service_domains_not_publicly_accessible", + "rds_instance_no_public_access", + "redshift_cluster_public_access", + "sagemaker_models_network_isolation_enabled", + "sagemaker_notebook_instance_without_direct_internet_access_configured", + "sagemaker_training_jobs_network_isolation_enabled", + "vpc_peering_routing_tables_with_least_privilege", + "vpc_subnet_no_public_ip_by_default" + ], + "azure": [ + "aisearch_service_not_publicly_accessible", + "aks_clusters_created_with_private_nodes", + "aks_clusters_public_access_disabled", + "app_function_ftps_deployment_disabled", + "app_function_not_publicly_accessible", + "containerregistry_not_publicly_accessible", + "cosmosdb_account_public_network_access_disabled", + "databricks_workspace_no_public_ip_enabled", + "databricks_workspace_public_network_access_disabled", + "postgresql_flexible_server_allow_access_services_disabled", + "sqlserver_unrestricted_inbound_access", + "storage_account_public_network_access_disabled", + "storage_default_network_access_rule_is_denied" + ], + "gcp": [ + "cloudfunction_function_not_publicly_accessible", + "cloudsql_instance_private_ip_assignment", + "cloudsql_instance_public_access", + "cloudsql_instance_public_ip", + "cloudsql_instance_sqlserver_cross_db_ownership_chaining_flag", + "cloudsql_instance_sqlserver_external_scripts_enabled_flag", + "cloudsql_instance_sqlserver_remote_access_flag", + "compute_instance_block_project_wide_ssh_keys_disabled", + "compute_instance_ip_forwarding_is_enabled", + "compute_instance_public_ip", + "compute_instance_single_network_interface" + ], + "kubernetes": [ + "apiserver_anonymous_requests", + "apiserver_disable_profiling", + "apiserver_no_always_admit_plugin", + "controllermanager_disable_profiling", + "core_minimize_admission_windows_hostprocess_containers", + "core_minimize_allowPrivilegeEscalation_containers", + "core_minimize_containers_added_capabilities", + "core_minimize_containers_capabilities_assigned", + "core_minimize_hostpath_volume_mounts", + "core_minimize_net_raw_capability_admission", + "core_minimize_privileged_containers", + "core_minimize_root_containers_admission", + "kubelet_disable_read_only_port", + "scheduler_profiling" + ], + "m365": [ + "entra_device_registration_laps_enabled", + "exchange_roles_assignment_policy_addins_disabled", + "sharepoint_onedrive_sync_restricted_unmanaged_devices", + "teams_email_sending_to_channel_disabled", + "teams_external_file_sharing_restricted", + "teams_external_users_cannot_start_conversations", + "teams_meeting_anonymous_user_join_disabled", + "teams_meeting_anonymous_user_start_disabled", + "teams_meeting_chat_anonymous_users_disabled", + "teams_meeting_dial_in_lobby_bypass_disabled", + "teams_meeting_external_chat_disabled", + "teams_meeting_external_control_disabled", + "teams_meeting_external_lobby_bypass_disabled", + "teams_meeting_presenters_restricted", + "teams_meeting_recording_disabled", + "teams_unmanaged_communication_disabled" + ] + } + }, + { + "id": "KSI-CNA-OFA", + "name": "Optimizing for Availability", + "description": "Machine-based information resources are persistently reviewed to ensure they are appropriately optimized for high availability and rapid recovery.", + "attributes": { + "Theme": "KSI-CNA: Cloud Native Architecture", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "autoscaling_group_capacity_rebalance_enabled", + "autoscaling_group_elb_health_check_enabled", + "autoscaling_group_multiple_az", + "autoscaling_group_multiple_instance_types", + "awslambda_function_vpc_multi_az", + "cloudfront_distributions_multiple_origin_failover_configured", + "directconnect_connection_redundancy", + "directconnect_virtual_interface_redundancy", + "dms_instance_multi_az_enabled", + "documentdb_cluster_multi_az_enabled", + "dynamodb_accelerator_cluster_multi_az", + "dynamodb_table_autoscaling_enabled", + "dynamodb_table_deletion_protection_enabled", + "dynamodb_tables_pitr_enabled", + "efs_multi_az_enabled", + "elasticache_redis_cluster_automatic_failover_enabled", + "elasticache_redis_cluster_multi_az_enabled", + "elb_cross_zone_load_balancing_enabled", + "elb_is_in_multiple_az", + "elbv2_cross_zone_load_balancing_enabled", + "elbv2_is_in_multiple_az", + "eventbridge_global_endpoint_event_replication_enabled", + "fsx_windows_file_system_multi_az_enabled", + "mq_broker_active_deployment_mode", + "mq_broker_cluster_deployment_mode", + "neptune_cluster_multi_az", + "networkfirewall_multi_az", + "opensearch_service_domains_fault_tolerant_data_nodes", + "opensearch_service_domains_fault_tolerant_master_nodes", + "rds_cluster_multi_az", + "rds_instance_multi_az", + "redshift_cluster_multi_az_enabled", + "sagemaker_endpoint_config_prod_variant_instances", + "storagegateway_gateway_fault_tolerant", + "vpc_endpoint_multi_az_enabled", + "vpc_subnet_different_az", + "vpc_vpn_connection_tunnels_up" + ], + "azure": [ + "cosmosdb_account_automatic_failover_enabled", + "mysql_flexible_server_high_availability_enabled", + "postgresql_flexible_server_high_availability_enabled", + "vm_backup_enabled", + "vm_scaleset_associated_with_load_balancer" + ], + "gcp": [ + "cloudsql_instance_high_availability_enabled", + "compute_instance_automatic_restart_enabled", + "compute_instance_group_autohealing_enabled", + "compute_instance_group_load_balancer_attached", + "compute_instance_group_multiple_zones", + "compute_instance_on_host_maintenance_migrate", + "compute_instance_preemptible_vm_disabled" + ], + "kubernetes": [ + "core_liveness_probe_configured", + "core_readiness_probe_configured" + ], + "m365": [] + }, + "config_requirements": [ + { + "Check": "awslambda_function_vpc_multi_az", + "ConfigKey": "lambda_min_azs", + "Operator": "gte", + "Value": 2, + "Provider": "aws" + }, + { + "Check": "elb_is_in_multiple_az", + "ConfigKey": "elb_min_azs", + "Operator": "gte", + "Value": 2, + "Provider": "aws" + }, + { + "Check": "compute_instance_group_multiple_zones", + "ConfigKey": "mig_min_zones", + "Operator": "gte", + "Value": 2, + "Provider": "gcp" + } + ] + }, + { + "id": "KSI-CNA-RNT", + "name": "Restricting Network Traffic", + "description": "Machine-based information resources are persistently reviewed to ensure they are appropriately configured to limit inbound and outbound network traffic.", + "attributes": { + "Theme": "KSI-CNA: Cloud Native Architecture", + "NISTControls": "AC-17.3, CA-9, CM-7.1, SC-7.5, SI-8", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "ec2_networkacl_allow_ingress_any_port", + "ec2_networkacl_allow_ingress_tcp_port_22", + "ec2_networkacl_allow_ingress_tcp_port_3389", + "ec2_securitygroup_allow_ingress_from_internet_to_all_ports", + "ec2_securitygroup_allow_ingress_from_internet_to_any_port", + "ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip", + "ec2_securitygroup_allow_ingress_from_internet_to_high_risk_tcp_ports", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_3389", + "ec2_securitygroup_allow_wide_open_public_ipv4", + "ec2_securitygroup_default_restrict_traffic" + ], + "azure": [ + "network_http_internet_access_restricted", + "network_rdp_internet_access_restricted", + "network_ssh_internet_access_restricted", + "network_udp_internet_access_restricted" + ], + "gcp": [ + "compute_firewall_rdp_access_from_the_internet_allowed", + "compute_firewall_ssh_access_from_the_internet_allowed", + "compute_network_default_in_use" + ], + "kubernetes": [ + "apiserver_deny_service_external_ips", + "controllermanager_bind_address", + "core_minimize_admission_hostport_containers", + "core_minimize_hostNetwork_containers", + "scheduler_bind_address" + ], + "m365": [] + }, + "config_requirements": [ + { + "Check": "ec2_securitygroup_allow_ingress_from_internet_to_any_port", + "ConfigKey": "ec2_allowed_interface_types", + "Operator": "subset", + "Value": [ + "api_gateway_managed", + "vpc_endpoint" + ], + "Provider": "aws" + }, + { + "Check": "ec2_securitygroup_allow_ingress_from_internet_to_any_port", + "ConfigKey": "ec2_allowed_instance_owners", + "Operator": "subset", + "Value": [ + "amazon-elb" + ], + "Provider": "aws" + }, + { + "Check": "ec2_securitygroup_allow_ingress_from_internet_to_high_risk_tcp_ports", + "ConfigKey": "ec2_high_risk_ports", + "Operator": "superset", + "Value": [ + 25, + 110, + 135, + 143, + 445, + 3000, + 4333, + 5000, + 5500, + 8080, + 8088 + ], + "Provider": "aws" + } + ] + }, + { + "id": "KSI-CNA-RVP", + "name": "Reviewing Protections", + "description": "The effectiveness of protection against denial of service attacks and other unwanted activity for machine-based information resources is persistently reviewed.", + "attributes": { + "Theme": "KSI-CNA: Cloud Native Architecture", + "NISTControls": "SC-5, SI-8, SI-8.2", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "apigateway_restapi_waf_acl_attached", + "cloudfront_distributions_using_waf", + "cognito_user_pool_waf_acl_attached", + "elb_desync_mitigation_mode", + "elbv2_desync_mitigation_mode", + "elbv2_waf_acl_attached", + "fms_policy_compliant", + "shield_advanced_protection_in_associated_elastic_ips", + "shield_advanced_protection_in_classic_load_balancers", + "shield_advanced_protection_in_cloudfront_distributions", + "shield_advanced_protection_in_global_accelerators", + "shield_advanced_protection_in_internet_facing_load_balancers", + "shield_advanced_protection_in_route53_hosted_zones", + "waf_global_rule_with_conditions", + "waf_global_rulegroup_not_empty", + "waf_global_webacl_with_rules", + "waf_regional_rule_with_conditions", + "waf_regional_rulegroup_not_empty", + "waf_regional_webacl_with_rules", + "wafv2_webacl_with_rules" + ], + "azure": [ + "network_vnet_ddos_protection_enabled", + "postgresql_flexible_server_connection_throttling_on" + ], + "gcp": [], + "kubernetes": [ + "apiserver_event_rate_limit", + "apiserver_request_timeout_set", + "core_cpu_limits_set", + "core_memory_limits_set", + "kubelet_streaming_connection_timeout" + ], + "m365": [] + } + }, + { + "id": "KSI-CNA-ULN", + "name": "Using Logical Networking", + "description": "Logical networking and related capabilities are used and persistently reviewed to enforce traffic flow controls.", + "attributes": { + "Theme": "KSI-CNA: Cloud Native Architecture", + "NISTControls": "AC-12, AC-17.3, CA-9, SC-4, SC-7, SC-7.7, SC-8, SC-10", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "eks_cluster_network_policy_enabled", + "eks_cluster_vpc_cni_network_policy_enforced", + "networkfirewall_in_all_vpc", + "networkfirewall_policy_default_action_fragmented_packets", + "networkfirewall_policy_default_action_full_packets", + "networkfirewall_policy_rule_group_associated", + "vpc_endpoint_connections_trust_boundaries", + "vpc_endpoint_services_allowed_principals_trust_boundaries", + "vpc_subnet_separate_private_public" + ], + "azure": [ + "aks_network_policy_enabled", + "network_bastion_host_exists", + "network_subnet_nsg_associated" + ], + "gcp": [ + "cloudfunction_function_inside_vpc", + "cloudstorage_uses_vpc_service_controls" + ], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "KSI-IAM-AAM", + "name": "Automating Account Management", + "description": "The lifecycle and privileges of all accounts, roles, and groups are securely managed using automation.", + "attributes": { + "Theme": "KSI-IAM: Identity and Access Management", + "NISTControls": "AC-2.2, AC-2.3, AC-2.13, AC-6.7, IA-4.4, IA-12, IA-12.2, IA-12.3, IA-12.5", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "iam_no_root_access_key", + "iam_root_credentials_management_enabled", + "iam_user_accesskey_unused", + "iam_user_console_access_unused", + "iam_user_no_setup_initial_access_key", + "organizations_delegated_administrators" + ], + "azure": [ + "entra_user_with_recent_sign_in" + ], + "gcp": [ + "iam_service_account_unused" + ], + "kubernetes": [], + "m365": [ + "entra_dynamic_group_for_guests_created" + ] + }, + "config_requirements": [ + { + "Check": "iam_user_accesskey_unused", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 45, + "Provider": "aws" + }, + { + "Check": "iam_user_console_access_unused", + "ConfigKey": "max_console_access_days", + "Operator": "lte", + "Value": 45, + "Provider": "aws" + }, + { + "Check": "iam_service_account_unused", + "ConfigKey": "max_unused_account_days", + "Operator": "lte", + "Value": 180, + "Provider": "gcp" + } + ] + }, + { + "id": "KSI-IAM-APM", + "name": "Adopting Passwordless Methods", + "description": "Secure passwordless methods are used for user authentication and authorization when feasible, otherwise strong passwords with phishing-resistant MFA is used.", + "attributes": { + "Theme": "KSI-IAM: Identity and Access Management", + "NISTControls": "AC-3, IA-5.1, IA-5.2, IA-5.6, IA-6, AC-2, IA-2, IA-2.1, IA-2.2, IA-2.8, IA-5, IA-8, SC-23", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "cognito_user_pool_password_policy_lowercase", + "cognito_user_pool_password_policy_minimum_length_14", + "cognito_user_pool_password_policy_number", + "cognito_user_pool_password_policy_symbol", + "cognito_user_pool_password_policy_uppercase", + "iam_password_policy_lowercase", + "iam_password_policy_minimum_length_14", + "iam_password_policy_number", + "iam_password_policy_reuse_24", + "iam_password_policy_symbol", + "iam_password_policy_uppercase", + "iam_root_hardware_mfa_enabled", + "iam_user_hardware_mfa_enabled" + ], + "azure": [ + "vm_linux_enforce_ssh_authentication" + ], + "gcp": [], + "kubernetes": [], + "m365": [ + "entra_admin_users_phishing_resistant_mfa_enabled", + "entra_break_glass_account_fido2_security_key_registered", + "entra_password_protection_custom_banned_list_enforced", + "entra_password_protection_on_premises_enforced" + ] + } + }, + { + "id": "KSI-IAM-ELP", + "name": "Ensuring Least Privilege", + "description": "Identity and access management measures are used and persistently reviewed to ensure each user or device can only access the resources they need.", + "attributes": { + "Theme": "KSI-IAM: Identity and Access Management", + "NISTControls": "AC-2.5, AC-2.6, AC-3, AC-4, AC-6, AC-12, AC-14, AC-17, AC-17.1, AC-17.2, AC-17.3, AC-20, AC-20.1, CM-2.7, CM-9, IA-2, IA-3, IA-4, IA-4.4, IA-5.2, IA-5.6, IA-11, PS-2, PS-3, PS-4, PS-5, PS-6, SC-4, SC-20, SC-21, SC-22, SC-23, SC-39, SI-3", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "accessanalyzer_enabled", + "accessanalyzer_enabled_without_findings", + "bedrock_agent_role_least_privilege", + "bedrock_agent_role_not_shared_across_agents", + "efs_access_point_enforce_root_directory", + "efs_access_point_enforce_user_identity", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_sagemaker", + "opensearch_service_domains_access_control_enabled" + ], + "azure": [ + "aks_cluster_rbac_enabled", + "entra_policy_default_users_cannot_create_security_groups", + "entra_policy_ensure_default_user_cannot_create_apps", + "entra_policy_ensure_default_user_cannot_create_tenants", + "entra_policy_guest_invite_only_for_admin_roles", + "entra_policy_guest_users_access_restrictions", + "entra_policy_restricts_user_consent_for_apps", + "entra_policy_user_consent_for_verified_apps", + "entra_users_cannot_create_microsoft_365_groups", + "keyvault_rbac_enabled" + ], + "gcp": [ + "compute_instance_default_service_account_in_use", + "compute_instance_default_service_account_in_use_with_full_api_access" + ], + "kubernetes": [ + "kubelet_authorization_mode", + "rbac_cluster_admin_usage", + "rbac_minimize_csr_approval_access", + "rbac_minimize_node_proxy_subresource_access", + "rbac_minimize_pod_creation_access", + "rbac_minimize_pv_creation_access", + "rbac_minimize_secret_access", + "rbac_minimize_service_account_token_creation", + "rbac_minimize_webhook_config_access", + "rbac_minimize_wildcard_use_roles" + ], + "m365": [ + "admincenter_users_admins_reduced_license_footprint", + "admincenter_users_between_two_and_four_global_admins", + "entra_access_review_guest_users_configured", + "entra_access_review_privileged_roles_configured", + "entra_admin_users_cloud_only", + "entra_conditional_access_policy_groups_management_restricted", + "entra_device_registration_global_admins_not_local_admins", + "entra_device_registration_registering_user_not_local_admin", + "entra_policy_default_user_cannot_create_m365_groups", + "entra_policy_default_user_cannot_create_security_groups", + "entra_policy_guest_invite_only_for_admin_roles", + "entra_policy_guest_users_access_restrictions" + ] + }, + "config_requirements": [ + { + "Check": "iam_user_access_not_stale_to_bedrock", + "ConfigKey": "max_unused_bedrock_access_days", + "Operator": "lte", + "Value": 60, + "Provider": "aws" + }, + { + "Check": "iam_role_access_not_stale_to_bedrock", + "ConfigKey": "max_unused_bedrock_access_days", + "Operator": "lte", + "Value": 60, + "Provider": "aws" + }, + { + "Check": "iam_user_access_not_stale_to_sagemaker", + "ConfigKey": "max_unused_sagemaker_access_days", + "Operator": "lte", + "Value": 90, + "Provider": "aws" + }, + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + } + ] + }, + { + "id": "KSI-IAM-JIT", + "name": "Authorizing Just-in-Time", + "description": "A least-privileged, role and attribute-based, and just-in-time security authorization model is used and persistently reviewed for all user and non-user accounts and services.", + "attributes": { + "Theme": "KSI-IAM: Identity and Access Management", + "NISTControls": "AC-2, AC-2.1, AC-2.2, AC-2.3, AC-2.4, AC-2.6, AC-3, AC-4, AC-5, AC-6, AC-6.1, AC-6.2, AC-6.5, AC-6.7, AC-6.9, AC-6.10, AC-7, AC-20.1, AC-17, AU-9.4, CM-5, CM-7, CM-7.2, CM-7.5, CM-9, IA-4, IA-4.4, IA-7, PS-2, PS-3, PS-4, PS-5, PS-6, PS-9, RA-5.5, SC-2, SC-23, SC-39", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "bedrock_api_key_no_administrative_privileges", + "bedrock_full_access_policy_attached", + "iam_aws_attached_policy_no_administrative_privileges", + "iam_customer_attached_policy_no_administrative_privileges", + "iam_customer_unattached_policy_no_administrative_privileges", + "iam_group_administrator_access_policy", + "iam_inline_policy_allows_privilege_escalation", + "iam_inline_policy_no_administrative_privileges", + "iam_inline_policy_no_wildcard_marketplace_subscribe", + "iam_no_custom_policy_permissive_role_assumption", + "iam_policy_allows_privilege_escalation", + "iam_policy_attached_only_to_group_or_roles", + "iam_policy_cloudshell_admin_not_attached", + "iam_policy_no_agentcore_workload_access_token_wildcard", + "iam_policy_no_full_access_to_cloudtrail", + "iam_policy_no_full_access_to_kms", + "iam_policy_no_wildcard_marketplace_subscribe", + "iam_policy_passrole_to_bedrock_agentcore_restricted", + "iam_role_administratoraccess_policy", + "iam_role_cross_service_confused_deputy_prevention", + "iam_role_service_trust_restricts_source_to_account", + "iam_user_administrator_access_policy", + "iam_user_with_temporary_credentials", + "rolesanywhere_profile_restricts_session_permissions" + ], + "azure": [ + "app_function_identity_without_admin_privileges", + "entra_global_admin_in_less_than_five_users", + "iam_custom_role_has_permissions_to_administer_resource_locks", + "iam_role_user_access_admin_restricted", + "iam_subscription_roles_owner_custom_not_created", + "vm_jit_access_enabled" + ], + "gcp": [ + "iam_no_service_roles_at_project_level", + "iam_role_kms_enforce_separation_of_duties", + "iam_role_sa_enforce_separation_of_duties", + "iam_sa_no_administrative_privileges" + ], + "kubernetes": [], + "m365": [ + "entra_admin_users_sign_in_frequency_enabled", + "entra_intune_enrollment_sign_in_frequency_every_time", + "entra_pim_global_administrator_approval_required", + "entra_pim_privileged_role_administrator_approval_required", + "entra_service_principal_privileged_role_no_owners" + ] + } + }, + { + "id": "KSI-IAM-SNU", + "name": "Securing Non-User Authentication", + "description": "Appropriately secure authentication methods are used and persistently reviewed for non-user accounts and services.", + "attributes": { + "Theme": "KSI-IAM: Identity and Access Management", + "NISTControls": "AC-2, AC-2.2, AC-4, AC-6.5, IA-3, IA-5.2, RA-5.5", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "apigateway_restapi_authorizers_enabled", + "apigatewayv2_api_authorizers_enabled", + "appsync_graphql_api_no_api_key_authentication", + "bedrock_api_key_no_long_term_credentials", + "dms_endpoint_neptune_iam_authorization_enabled", + "ec2_instance_profile_attached", + "elasticache_redis_replication_group_auth_enabled", + "iam_rotate_access_key_90_days", + "iam_user_two_active_access_key", + "kafka_cluster_unrestricted_access_disabled", + "neptune_cluster_iam_authentication_enabled", + "rds_cluster_iam_authentication_enabled", + "rds_instance_iam_authentication_enabled" + ], + "azure": [ + "aks_cluster_local_accounts_disabled", + "app_function_identity_is_configured", + "app_register_with_identity", + "cosmosdb_account_use_aad_and_rbac", + "storage_account_key_access_disabled", + "storage_default_to_entra_authorization_enabled" + ], + "gcp": [ + "apikeys_api_restrictions_configured", + "apikeys_key_exists", + "apikeys_key_rotated_in_90_days", + "iam_sa_no_user_managed_keys", + "iam_sa_user_managed_key_rotate_90_days", + "iam_sa_user_managed_key_unused", + "iam_workload_identity_pool_provider_attribute_condition" + ], + "kubernetes": [ + "apiserver_kubelet_cert_auth", + "apiserver_kubelet_tls_auth", + "apiserver_no_token_auth_file", + "apiserver_service_account_key_file_set", + "apiserver_service_account_lookup_true", + "controllermanager_service_account_credentials", + "controllermanager_service_account_private_key_file", + "kubelet_client_ca_file_set" + ], + "m365": [ + "entra_app_registration_client_secret_unused", + "entra_default_app_management_policy_enabled", + "entra_service_principal_no_secrets_for_permanent_tier0_roles", + "exchange_shared_mailbox_sign_in_disabled" + ] + }, + "config_requirements": [ + { + "Check": "iam_sa_user_managed_key_unused", + "ConfigKey": "max_unused_account_days", + "Operator": "lte", + "Value": 180, + "Provider": "gcp" + } + ] + }, + { + "id": "KSI-IAM-SUS", + "name": "Responding to Suspicious Activity", + "description": "Accounts with privileged access are disabled or otherwise secured in response to suspicious activity.", + "attributes": { + "Theme": "KSI-IAM: Identity and Access Management", + "NISTControls": "AC-2, AC-2.1, AC-2.3, AC-2.13, AC-7, PS-4, PS-8", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "cognito_user_pool_advanced_security_enabled", + "cognito_user_pool_blocks_compromised_credentials_sign_in_attempts", + "cognito_user_pool_blocks_potential_malicious_sign_in_attempts" + ], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [ + "entra_conditional_access_policy_block_elevated_insider_risk", + "entra_conditional_access_policy_block_high_medium_sign_in_risk", + "entra_conditional_access_policy_block_o365_elevated_insider_risk", + "entra_identity_protection_sign_in_risk_enabled", + "entra_identity_protection_user_risk_enabled", + "entra_password_protection_lockout_duration_configured", + "entra_password_protection_lockout_threshold_limited" + ] + }, + "config_requirements": [] + }, + { + "id": "KSI-INR-AAR", + "name": "Generating After Action Reports", + "description": "Incident after action reports are generated and lessons learned are persistently incorporated.", + "attributes": { + "Theme": "KSI-INR: Incident Response", + "NISTControls": "IR-3, IR-4, IR-4.1, IR-8", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "KSI-INR-RIR", + "name": "Reviewing Incident Response Procedures", + "description": "The effectiveness of documented incident response procedures is persistently reviewed.", + "attributes": { + "Theme": "KSI-INR: Incident Response", + "NISTControls": "IR-4, IR-4.1, IR-6, IR-6.1, IR-6.3, IR-7, IR-7.1, IR-8, IR-8.1, SI-4.5", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + }, + "config_requirements": [] + }, + { + "id": "KSI-INR-RPI", + "name": "Reviewing Past Incidents", + "description": "Past incidents are persistently reviewed for patterns or vulnerabilities that were not previously apparent or identified.", + "attributes": { + "Theme": "KSI-INR: Incident Response", + "NISTControls": "IR-3, IR-4, IR-4.1, IR-5, IR-8", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "KSI-MLA-ALA", + "name": "Authorizing Log Access", + "description": "A least-privileged, role and attribute-based, and just-in-time access authorization model is used and persistently reviewed for access to log data based on organizationally defined data sensitivity.", + "attributes": { + "Theme": "KSI-MLA: Monitoring, Logging, and Auditing", + "NISTControls": "SI-11", + "ClassApplicability": "Optional for Class B, required for Class C" + }, + "checks": { + "aws": [ + "cloudtrail_logs_s3_bucket_is_not_publicly_accessible", + "cloudwatch_cross_account_sharing_disabled", + "cloudwatch_log_group_not_publicly_accessible", + "iam_inline_policy_no_full_access_to_cloudtrail" + ], + "azure": [ + "monitor_storage_account_with_activity_logs_is_private" + ], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "KSI-MLA-EVC", + "name": "Evaluating Configurations", + "description": "The configuration of machine-based information resources, especially infrastructure as code, is persistently evaluated and tested.", + "attributes": { + "Theme": "KSI-MLA: Monitoring, Logging, and Auditing", + "NISTControls": "CA-7, CM-2, CM-6, SI-7.7", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "config_recorder_all_regions_enabled" + ], + "azure": [ + "sqlserver_va_periodic_recurring_scans_enabled", + "sqlserver_vulnerability_assessment_enabled" + ], + "gcp": [], + "kubernetes": [], + "m365": [] + }, + "config_requirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + } + ] + }, + { + "id": "KSI-MLA-LET", + "name": "Logging Event Types", + "description": "A list of information resources and event types that will be logged, monitored, and audited is maintained and persistently reviewed to ensure these activities occur.", + "attributes": { + "Theme": "KSI-MLA: Monitoring, Logging, and Auditing", + "NISTControls": "AC-2.4, AC-6.9, AC-17.1, AC-20.1, AU-2, AU-7.1, AU-12, SI-4.4, SI-4.5, SI-7.7", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "apigateway_restapi_logging_enabled", + "apigatewayv2_api_access_logging_enabled", + "appsync_field_level_logging_enabled", + "athena_workgroup_logging_enabled", + "awslambda_function_invoke_api_operations_cloudtrail_logging_enabled", + "bedrock_model_invocation_logging_enabled", + "cloudfront_distributions_logging_enabled", + "cloudtrail_bedrock_logging_enabled", + "cloudtrail_logs_s3_bucket_access_logging_enabled", + "cloudtrail_multi_region_enabled_logging_management_events", + "cloudtrail_s3_dataevents_read_enabled", + "cloudtrail_s3_dataevents_write_enabled", + "codebuild_project_logging_enabled", + "config_recorder_all_regions_enabled", + "datasync_task_logging_enabled", + "directoryservice_directory_log_forwarding_enabled", + "dms_replication_task_source_logging_enabled", + "dms_replication_task_target_logging_enabled", + "documentdb_cluster_cloudwatch_log_export", + "ec2_client_vpn_endpoint_connection_logging_enabled", + "ecs_cluster_container_insights_enabled", + "ecs_task_definitions_logging_enabled", + "eks_control_plane_logging_all_types_enabled", + "elasticbeanstalk_environment_cloudwatch_logging_enabled", + "elb_logging_enabled", + "elbv2_logging_enabled", + "glue_etl_jobs_logging_enabled", + "mq_broker_logging_enabled", + "neptune_cluster_integration_cloudwatch_logs", + "networkfirewall_logging_enabled", + "opensearch_service_domains_audit_logging_enabled", + "opensearch_service_domains_cloudwatch_logging_enabled", + "rds_cluster_integration_cloudwatch_logs", + "rds_instance_enhanced_monitoring_enabled", + "rds_instance_integration_cloudwatch_logs", + "redshift_cluster_audit_logging", + "route53_public_hosted_zones_cloudwatch_logging_enabled", + "s3_bucket_server_access_logging_enabled", + "stepfunctions_statemachine_logging_enabled", + "vpc_flow_logs_enabled", + "waf_global_webacl_logging_enabled", + "waf_regional_webacl_logging_enabled", + "wafv2_webacl_logging_enabled" + ], + "azure": [ + "aks_cluster_azure_monitor_enabled", + "app_function_application_insights_enabled", + "app_http_logs_enabled", + "appinsights_ensure_is_configured", + "defender_auto_provisioning_log_analytics_agent_vms_on", + "monitor_diagnostic_setting_with_appropriate_categories", + "monitor_diagnostic_settings_exists", + "mysql_flexible_server_audit_log_connection_activated", + "mysql_flexible_server_audit_log_enabled", + "network_flow_log_captured_sent", + "network_flow_log_more_than_90_days", + "network_watcher_enabled", + "postgresql_flexible_server_log_checkpoints_on", + "postgresql_flexible_server_log_connections_on", + "postgresql_flexible_server_log_disconnections_on", + "sqlserver_auditing_enabled", + "sqlserver_auditing_retention_90_days" + ], + "gcp": [ + "cloudsql_instance_postgres_enable_pgaudit_flag", + "cloudsql_instance_postgres_log_connections_flag", + "cloudsql_instance_postgres_log_disconnections_flag", + "cloudsql_instance_postgres_log_error_verbosity_flag", + "cloudsql_instance_postgres_log_min_duration_statement_flag", + "cloudsql_instance_postgres_log_min_error_statement_flag", + "cloudsql_instance_postgres_log_min_messages_flag", + "cloudsql_instance_postgres_log_statement_flag", + "cloudsql_instance_sqlserver_trace_flag", + "cloudstorage_audit_logs_enabled", + "cloudstorage_bucket_logging_enabled", + "compute_loadbalancer_logging_enabled", + "compute_network_dns_logging_enabled", + "compute_subnet_flow_logs_enabled", + "iam_audit_logs_enabled", + "logging_sink_created" + ], + "kubernetes": [ + "apiserver_audit_log_path_set" + ], + "m365": [ + "exchange_mailbox_audit_bypass_disabled", + "exchange_organization_mailbox_auditing_enabled", + "exchange_user_mailbox_auditing_enabled" + ] + }, + "config_requirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + }, + { + "Check": "eks_control_plane_logging_all_types_enabled", + "ConfigKey": "eks_required_log_types", + "Operator": "superset", + "Value": [ + "api", + "audit", + "authenticator", + "controllerManager", + "scheduler" + ], + "Provider": "aws" + }, + { + "Check": "exchange_user_mailbox_auditing_enabled", + "ConfigKey": "audit_log_age", + "Operator": "gte", + "Value": 90, + "Provider": "m365" + } + ] + }, + { + "id": "KSI-MLA-OSM", + "name": "Operating SIEM Capability", + "description": "A Security Information and Event Management (SIEM) or similar system(s) is used and persistently reviewed for centralized, tamper-resistant logging of events, activities, and changes.", + "attributes": { + "Theme": "KSI-MLA: Monitoring, Logging, and Auditing", + "NISTControls": "AC-17.1, AC-20.1, AU-2, AU-3, AU-3.1, AU-4, AU-5, AU-6.1, AU-6.3, AU-7, AU-7.1, AU-8, AU-9, AU-11, IR-4.1, SI-4.2, SI-4.4, SI-7.7", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "cloudtrail_bucket_requires_mfa_delete", + "cloudtrail_cloudwatch_logging_enabled", + "cloudtrail_kms_encryption_enabled", + "cloudtrail_log_file_validation_enabled", + "cloudtrail_multi_region_enabled", + "cloudwatch_log_group_kms_encryption_enabled", + "cloudwatch_log_group_retention_policy_specific_days_enabled" + ], + "azure": [ + "monitor_diagnostic_settings_exists" + ], + "gcp": [ + "cloudstorage_bucket_log_retention_policy_lock", + "iam_audit_logs_enabled", + "logging_sink_created" + ], + "kubernetes": [ + "apiserver_audit_log_maxage_set", + "apiserver_audit_log_maxbackup_set", + "apiserver_audit_log_maxsize_set", + "apiserver_audit_log_path_set" + ], + "m365": [ + "purview_audit_log_search_enabled" + ] + }, + "config_requirements": [ + { + "Check": "apiserver_audit_log_maxage_set", + "ConfigKey": "audit_log_maxage", + "Operator": "gte", + "Value": 30, + "Provider": "kubernetes" + }, + { + "Check": "apiserver_audit_log_maxbackup_set", + "ConfigKey": "audit_log_maxbackup", + "Operator": "gte", + "Value": 10, + "Provider": "kubernetes" + }, + { + "Check": "apiserver_audit_log_maxsize_set", + "ConfigKey": "audit_log_maxsize", + "Operator": "gte", + "Value": 100, + "Provider": "kubernetes" + }, + { + "Check": "cloudwatch_log_group_retention_policy_specific_days_enabled", + "ConfigKey": "log_group_retention_days", + "Operator": "gte", + "Value": 365, + "Provider": "aws" + } + ] + }, + { + "id": "KSI-MLA-RVL", + "name": "Reviewing Logs", + "description": "Logs are persistently reviewed and audited.", + "attributes": { + "Theme": "KSI-MLA: Monitoring, Logging, and Auditing", + "NISTControls": "AC-2.4, AC-6.9, AU-2, AU-6, AU-6.1, SI-4, SI-4.4", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "KSI-PIY-GIV", + "name": "Generating Inventories", + "description": "Authoritative sources are used to automatically generate real-time inventories of all information resources when needed.", + "attributes": { + "Theme": "KSI-PIY: Policy and Inventory", + "NISTControls": "CM-2.2, CM-7.5, CM-8, CM-8.1, CM-12, CM-12.1, CP-2.8", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "config_delegated_admin_and_org_aggregator_all_regions", + "config_recorder_all_regions_enabled", + "resourceexplorer2_indexes_found" + ], + "azure": [], + "gcp": [ + "iam_cloud_asset_inventory_enabled" + ], + "kubernetes": [], + "m365": [] + }, + "config_requirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + }, + { + "Check": "config_delegated_admin_and_org_aggregator_all_regions", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + } + ] + }, + { + "id": "KSI-PIY-RES", + "name": "Reviewing Executive Support", + "description": "Executive support for achieving the provider's security goals is persistently reviewed and demonstrated.", + "attributes": { + "Theme": "KSI-PIY: Policy and Inventory", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "KSI-PIY-RIS", + "name": "Reviewing Investments in Security", + "description": "The effectiveness of the provider's investments in achieving security goals is persistently reviewed.", + "attributes": { + "Theme": "KSI-PIY: Policy and Inventory", + "NISTControls": "AC-5, CA-2, CP-2.1, CP-4.1, IR-3.2, PM-3, SA-2, SA-3, SR-2.1", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "KSI-PIY-RSD", + "name": "Reviewing Security in the SDLC", + "description": "The effectiveness of building security and privacy considerations into the Software Development Lifecycle and aligning with CISA Secure By Design principles is persistently reviewed.", + "attributes": { + "Theme": "KSI-PIY: Policy and Inventory", + "NISTControls": "AC-5, AU-3.3, CM-3.4, PL-8, PM-7, SA-3, SA-8, SC-4, SC-18, SI-10, SI-11, SI-16", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "KSI-PIY-RVD", + "name": "Reviewing Vulnerability Disclosures", + "description": "The effectiveness of the provider's vulnerability disclosure program is persistently reviewed.", + "attributes": { + "Theme": "KSI-PIY: Policy and Inventory", + "NISTControls": "RA-5.11", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "KSI-RPL-ABO", + "name": "Aligning Backups with Objectives", + "description": "The alignment of machine-based information resource backups with defined recovery objectives is persistently reviewed.", + "attributes": { + "Theme": "KSI-RPL: Recovery Planning", + "NISTControls": "CM-2.3, CP-6, CP-9, CP-10, CP-10.2, SI-12", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "dlm_ebs_snapshot_lifecycle_policy_exists", + "documentdb_cluster_backup_enabled", + "dynamodb_table_protected_by_backup_plan", + "dynamodb_tables_pitr_enabled", + "ec2_ebs_volume_protected_by_backup_plan", + "ec2_ebs_volume_snapshots_exists", + "efs_have_backup_enabled", + "elasticache_redis_cluster_backup_enabled", + "lightsail_instance_automated_snapshots", + "neptune_cluster_backup_enabled", + "rds_cluster_backtrack_enabled", + "rds_cluster_protected_by_backup_plan", + "rds_instance_backup_enabled", + "rds_instance_protected_by_backup_plan", + "redshift_cluster_automated_snapshot", + "s3_bucket_cross_region_replication", + "s3_bucket_object_versioning" + ], + "azure": [ + "cosmosdb_account_backup_policy_continuous", + "keyvault_recoverable", + "mysql_flexible_server_geo_redundant_backup_enabled", + "postgresql_flexible_server_geo_redundant_backup_enabled", + "recovery_vault_backup_policy_retention_adequate", + "recovery_vault_has_protected_items", + "storage_blob_versioning_is_enabled", + "storage_ensure_file_shares_soft_delete_is_enabled", + "storage_ensure_soft_delete_is_enabled", + "storage_geo_redundant_enabled", + "vm_backup_enabled", + "vm_sufficient_daily_backup_retention_period" + ], + "gcp": [ + "cloudsql_instance_automated_backups", + "cloudstorage_bucket_soft_delete_enabled", + "cloudstorage_bucket_sufficient_retention_period", + "cloudstorage_bucket_versioning_enabled" + ], + "kubernetes": [], + "m365": [] + }, + "config_requirements": [ + { + "Check": "vm_sufficient_daily_backup_retention_period", + "ConfigKey": "vm_backup_min_daily_retention_days", + "Operator": "gte", + "Value": 7, + "Provider": "azure" + }, + { + "Check": "documentdb_cluster_backup_enabled", + "ConfigKey": "minimum_backup_retention_period", + "Operator": "gte", + "Value": 7, + "Provider": "aws" + }, + { + "Check": "neptune_cluster_backup_enabled", + "ConfigKey": "minimum_backup_retention_period", + "Operator": "gte", + "Value": 7, + "Provider": "aws" + }, + { + "Check": "elasticache_redis_cluster_backup_enabled", + "ConfigKey": "minimum_snapshot_retention_period", + "Operator": "gte", + "Value": 7, + "Provider": "aws" + } + ] + }, + { + "id": "KSI-RPL-ARP", + "name": "Aligning Recovery Plan", + "description": "The alignment of recovery plans with defined recovery objectives is persistently reviewed.", + "attributes": { + "Theme": "KSI-RPL: Recovery Planning", + "NISTControls": "CP-2, CP-2.1, CP-2.3, CP-4.1, CP-6, CP-6.1, CP-6.3, CP-7, CP-7.1, CP-7.2, CP-7.3, CP-8, CP-8.1, CP-8.2, CP-10, CP-10.2", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "backup_plans_exist", + "backup_reportplans_exist", + "backup_vaults_exist" + ], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + }, + "config_requirements": [] + }, + { + "id": "KSI-RPL-RRO", + "name": "Reviewing Recovery Objectives", + "description": "The desired Recovery Time Objectives (RTO) and Recovery Point Objectives (RPO) are defined and persistently reviewed for alignment with the provider's business needs and capabilities.", + "attributes": { + "Theme": "KSI-RPL: Recovery Planning", + "NISTControls": "CP-2.3, CP-10", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "KSI-RPL-TRC", + "name": "Testing Recovery Capabilities", + "description": "The capability to recover from incidents and contingencies aligned with defined recovery objectives is persistently tested.", + "attributes": { + "Theme": "KSI-RPL: Recovery Planning", + "NISTControls": "CP-2.1, CP-2.3, CP-4, CP-4.1, CP-6, CP-6.1, CP-9.1, CP-10, IR-3, IR-3.2", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "drs_job_exist" + ], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + }, + "config_requirements": [ + { + "Check": "drs_job_exist", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + } + ] + }, + { + "id": "KSI-SCR-MIT", + "name": "Mitigating Supply Chain Risk", + "description": "Persistently identify, review, and mitigate potential supply chain risks.", + "attributes": { + "Theme": "KSI-SCR: Supply Chain Risk", + "NISTControls": "AC-20, RA-3.1, SA-9, SA-10, SA-11, SA-15.3, SA-22, SI-7.1, SR-5, SR-6, CA-7.4, SC-18", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "awslambda_function_using_cross_account_layers", + "codeartifact_packages_external_public_publishing_disabled", + "codebuild_project_user_controlled_buildspec", + "codebuild_project_uses_allowed_github_organizations", + "ecr_registry_enhanced_scanning_enabled", + "ecr_registry_scan_images_on_push_enabled", + "ecr_repositories_not_publicly_accessible", + "ecr_repositories_scan_vulnerabilities_in_latest_image", + "ecr_repositories_tag_immutability", + "s3_bucket_shadow_resource_vulnerability" + ], + "azure": [ + "defender_container_images_resolved_vulnerabilities", + "defender_container_images_scan_enabled" + ], + "gcp": [ + "artifacts_container_analysis_enabled", + "gcr_container_scanning_enabled" + ], + "kubernetes": [ + "apiserver_always_pull_images_plugin" + ], + "m365": [] + }, + "config_requirements": [ + { + "Check": "ecr_repositories_scan_vulnerabilities_in_latest_image", + "ConfigKey": "ecr_repository_vulnerability_minimum_severity", + "Operator": "in", + "Value": [ + "MEDIUM" + ], + "Provider": "aws" + } + ] + }, + { + "id": "KSI-SCR-MON", + "name": "Monitoring Supply Chain Risk", + "description": "Third party software information resources are automatically monitored for upstream vulnerabilities using mechanisms that may include contractual notification requirements or active monitoring services.", + "attributes": { + "Theme": "KSI-SCR: Supply Chain Risk", + "NISTControls": "AC-20, CA-3, IR-6.3, PS-7, RA-5, SA-9, SI-5, SR-5, SR-6, SR-8", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "awslambda_function_using_supported_runtimes", + "ecr_registry_enhanced_scanning_enabled", + "inspector2_active_findings_exist", + "inspector2_is_enabled", + "ssm_managed_compliant_patching" + ], + "azure": [ + "app_ensure_java_version_is_latest", + "app_ensure_php_version_is_latest", + "app_ensure_python_version_is_latest", + "app_function_latest_runtime_version", + "defender_ensure_system_updates_are_applied" + ], + "gcp": [], + "kubernetes": [], + "m365": [] + }, + "config_requirements": [ + { + "Check": "awslambda_function_using_supported_runtimes", + "ConfigKey": "obsolete_lambda_runtimes", + "Operator": "superset", + "Value": [ + "java8", + "go1.x", + "provided", + "python3.6", + "python2.7", + "python3.7", + "python3.8", + "nodejs4.3", + "nodejs4.3-edge", + "nodejs6.10", + "nodejs", + "nodejs8.10", + "nodejs10.x", + "nodejs12.x", + "nodejs14.x", + "nodejs16.x", + "dotnet5.0", + "dotnet6", + "dotnet7", + "dotnetcore1.0", + "dotnetcore2.0", + "dotnetcore2.1", + "dotnetcore3.1", + "ruby2.5", + "ruby2.7" + ], + "Provider": "aws" + } + ] + }, + { + "id": "KSI-SVC-ACM", + "name": "Automating Configuration Management", + "description": "The configuration of machine-based information resources is managed using automation and persistently reviewed for drift.", + "attributes": { + "Theme": "KSI-SVC: Service Configuration", + "NISTControls": "AC-2.4, CM-2, CM-2.2, CM-2.3, CM-6, CM-7.1, PL-9, PL-10, SA-5, SI-5, SR-10", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "config_recorder_all_regions_enabled", + "ec2_instance_managed_by_ssm", + "ssm_managed_compliant_patching" + ], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + }, + "config_requirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + } + ] + }, + { + "id": "KSI-SVC-ASM", + "name": "Automating Secret Management", + "description": "Management, protection, and regular rotation of digital keys, certificates, and other secrets is automated and persistently reviewed.", + "attributes": { + "Theme": "KSI-SVC: Service Configuration", + "NISTControls": "AC-17.2, IA-5.2, IA-5.6, SC-12, SC-17", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "acm_certificates_expiration_check", + "amplify_app_no_secrets_in_environment", + "apigateway_restapi_no_secrets_in_stage_variables", + "awslambda_function_no_secrets_in_code", + "awslambda_function_no_secrets_in_variables", + "awslambda_layer_no_secrets_in_content", + "batch_job_definition_no_secrets", + "cloudformation_stack_outputs_find_secrets", + "cloudwatch_log_group_no_secrets_in_logs", + "codebuild_project_no_secrets_in_variables", + "codebuild_project_source_repo_url_no_sensitive_credentials", + "codecommit_repository_no_secrets", + "datapipeline_pipeline_no_secrets_in_definition", + "directoryservice_ldap_certificate_expiration", + "ec2_instance_secrets_user_data", + "ec2_launch_template_no_secrets", + "ecr_repository_image_no_secrets", + "ecs_task_definitions_no_environment_secrets", + "elasticbeanstalk_environment_no_secrets_in_configuration", + "glue_catalog_connection_no_secrets", + "glue_etl_jobs_no_secrets_in_arguments", + "iam_no_expired_server_certificates_stored", + "kms_cmk_rotation_enabled", + "kms_key_not_publicly_accessible", + "rds_instance_certificate_expiration", + "sagemaker_notebook_instance_no_secrets", + "secretsmanager_automatic_rotation_enabled", + "secretsmanager_has_restrictive_resource_policy", + "secretsmanager_not_publicly_accessible", + "secretsmanager_secret_rotated_periodically", + "secretsmanager_secret_unused", + "ssm_document_secrets", + "stepfunctions_statemachine_no_secrets_in_definition" + ], + "azure": [ + "entra_app_registration_credential_not_expired", + "keyvault_key_expiration_set_in_non_rbac", + "keyvault_key_rotation_enabled", + "keyvault_non_rbac_secret_expiration_set", + "keyvault_rbac_key_expiration_set", + "keyvault_rbac_secret_expiration_set", + "storage_key_rotation_90_days" + ], + "gcp": [ + "kms_key_not_publicly_accessible", + "kms_key_rotation_enabled", + "kms_key_rotation_max_90_days", + "secretmanager_secret_not_publicly_accessible", + "secretmanager_secret_rotation_enabled" + ], + "kubernetes": [ + "apiserver_encryption_provider_config_set", + "apiserver_service_account_key_file_set", + "controllermanager_rotate_kubelet_server_cert", + "controllermanager_service_account_private_key_file", + "kubelet_rotate_certificates" + ], + "m365": [ + "entra_policy_default_user_cannot_read_bitlocker_keys" + ] + }, + "config_requirements": [ + { + "Check": "acm_certificates_expiration_check", + "ConfigKey": "days_to_expire_threshold", + "Operator": "gte", + "Value": 7, + "Provider": "aws" + }, + { + "Check": "secretsmanager_secret_unused", + "ConfigKey": "max_days_secret_unused", + "Operator": "lte", + "Value": 90, + "Provider": "aws" + }, + { + "Check": "secretsmanager_secret_rotated_periodically", + "ConfigKey": "max_days_secret_unrotated", + "Operator": "lte", + "Value": 90, + "Provider": "aws" + } + ] + }, + { + "id": "KSI-SVC-EIS", + "name": "Evaluating and Improving Security", + "description": "Information resources are persistently evaluated for opportunities to improve security and those improvements are persistently made.", + "attributes": { + "Theme": "KSI-SVC: Service Configuration", + "NISTControls": "CM-7.1, CM-12.1, MA-2, PL-8, SC-7, SC-39, SI-2.2, SI-4, SR-10", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "trustedadvisor_errors_and_warnings" + ], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + }, + "config_requirements": [] + }, + { + "id": "KSI-SVC-PRR", + "name": "Preventing Residual Risk", + "description": "Plans, procedures, and the state of information resources are persistently reviewed after making changes to limit and remove unwanted residual elements that would likely negatively affect the confidentiality, integrity, or availability of federal customer data.", + "attributes": { + "Theme": "KSI-SVC: Service Configuration", + "NISTControls": "SC-4", + "ClassApplicability": "Optional for Class B, required for Class C" + }, + "checks": { + "aws": [ + "cloudfront_distributions_s3_origin_non_existent_bucket", + "ec2_elastic_ip_unassigned", + "lightsail_static_ip_unused", + "route53_dangling_ip_subdomain_takeover" + ], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "KSI-SVC-RUD", + "name": "Removing Unwanted Data", + "description": "Unwanted federal customer data is removed promptly when requested by an agency in alignment with customer agreements, including from backups if appropriate; this typically applies when a customer spills information or when a customer seeks to remove information from a service due to a change in usage.", + "attributes": { + "Theme": "KSI-SVC: Service Configuration", + "NISTControls": "SI-12.3, SI-18.4", + "ClassApplicability": "Optional for Class B, required for Class C" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "KSI-SVC-SIN", + "name": "Securing Information", + "description": "Information is encrypted or otherwise secured from unwanted access or modification.", + "attributes": { + "Theme": "KSI-SVC: Service Configuration", + "NISTControls": "AC-1, AC-17.2, CP-9.8, SC-8, SC-8.1, SC-13, SC-20, SC-21, SC-22, SC-23, SC-28, SC-28.1", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "apigateway_restapi_cache_encrypted", + "athena_workgroup_encryption", + "awslambda_function_env_vars_not_encrypted_with_cmk", + "backup_recovery_point_encrypted", + "backup_vaults_encrypted", + "bedrock_custom_model_encrypted_with_cmk", + "bedrock_knowledge_base_encrypted_with_cmk", + "bedrock_prompt_encrypted_with_cmk", + "cloudfront_distributions_field_level_encryption_enabled", + "cloudfront_distributions_https_enabled", + "cloudfront_distributions_origin_traffic_encrypted", + "cloudfront_distributions_s3_origin_access_control", + "cloudfront_distributions_using_deprecated_ssl_protocols", + "codebuild_report_group_export_encrypted", + "dms_endpoint_redis_in_transit_encryption_enabled", + "dms_endpoint_ssl_enabled", + "documentdb_cluster_public_snapshot", + "documentdb_cluster_storage_encrypted", + "dynamodb_accelerator_cluster_encryption_enabled", + "dynamodb_accelerator_cluster_in_transit_encryption_enabled", + "dynamodb_table_cross_account_access", + "dynamodb_tables_kms_cmk_encryption_enabled", + "ec2_ami_account_block_public_access", + "ec2_ami_public", + "ec2_ebs_default_encryption", + "ec2_ebs_public_snapshot", + "ec2_ebs_snapshot_account_block_public_access", + "ec2_ebs_snapshots_encrypted", + "ec2_ebs_volume_encryption", + "efs_encryption_at_rest_enabled", + "efs_not_publicly_accessible", + "eks_cluster_kms_cmk_encryption_in_secrets_enabled", + "elasticache_redis_cluster_in_transit_encryption_enabled", + "elasticache_redis_cluster_rest_encryption_enabled", + "elb_insecure_ssl_ciphers", + "elb_ssl_listeners", + "elbv2_insecure_ssl_ciphers", + "elbv2_nlb_tls_termination_enabled", + "elbv2_ssl_listeners", + "eventbridge_bus_cross_account_access", + "eventbridge_bus_exposed", + "firehose_stream_encrypted_at_rest", + "glacier_vaults_policy_public_access", + "glue_data_catalogs_metadata_encryption_enabled", + "glue_data_catalogs_not_publicly_accessible", + "glue_database_connections_ssl_enabled", + "glue_development_endpoints_job_bookmark_encryption_enabled", + "glue_development_endpoints_s3_encryption_enabled", + "glue_etl_jobs_amazon_s3_encryption_enabled", + "glue_etl_jobs_job_bookmark_encryption_enabled", + "glue_ml_transform_encrypted_at_rest", + "kafka_cluster_encryption_at_rest_uses_cmk", + "kafka_cluster_in_transit_encryption_enabled", + "kafka_connector_in_transit_encryption_enabled", + "kinesis_stream_encrypted_at_rest", + "memorydb_cluster_in_transit_encryption_enabled", + "neptune_cluster_public_snapshot", + "neptune_cluster_snapshot_encrypted", + "neptune_cluster_storage_encrypted", + "opensearch_service_domains_encryption_at_rest_enabled", + "opensearch_service_domains_https_communications_enforced", + "opensearch_service_domains_node_to_node_encryption_enabled", + "rds_cluster_storage_encrypted", + "rds_instance_storage_encrypted", + "rds_instance_transport_encrypted", + "rds_snapshots_encrypted", + "rds_snapshots_public_access", + "redshift_cluster_encrypted_at_rest", + "redshift_cluster_in_transit_encryption_enabled", + "s3_access_point_public_access_block", + "s3_account_level_public_access_blocks", + "s3_bucket_acl_prohibited", + "s3_bucket_cross_account_access", + "s3_bucket_kms_encryption", + "s3_bucket_level_public_access_block", + "s3_bucket_no_mfa_delete", + "s3_bucket_object_lock", + "s3_bucket_object_public", + "s3_bucket_policy_public_write_access", + "s3_bucket_public_access", + "s3_bucket_public_list_acl", + "s3_bucket_public_write_acl", + "s3_bucket_secure_transport_policy", + "s3_multi_region_access_point_public_access_block", + "sagemaker_endpoint_config_kms_encryption_enabled", + "sagemaker_notebook_instance_encryption_enabled", + "sagemaker_training_jobs_intercontainer_encryption_enabled", + "sagemaker_training_jobs_volume_and_output_encryption_enabled", + "sns_subscription_not_using_http_endpoints", + "sns_topics_not_publicly_accessible", + "sqs_queues_not_publicly_accessible", + "sqs_queues_server_side_encryption_enabled", + "ssm_documents_set_as_public", + "stepfunctions_statemachine_encrypted_with_cmk", + "storagegateway_fileshare_encryption_enabled", + "transfer_server_in_transit_encryption_enabled", + "workspaces_volume_encryption_enabled" + ], + "azure": [ + "app_client_certificates_on", + "app_ensure_auth_is_set_up", + "app_ensure_http_is_redirected_to_https", + "app_ftp_deployment_disabled", + "app_function_ensure_http_is_redirected_to_https", + "app_minimum_tls_version_12", + "cosmosdb_account_minimum_tls_version", + "databricks_workspace_cmk_encryption_enabled", + "monitor_storage_account_with_activity_logs_cmk_encrypted", + "mysql_flexible_server_minimum_tls_version_12", + "mysql_flexible_server_ssl_connection_enabled", + "postgresql_flexible_server_enforce_ssl_enabled", + "sqlserver_recommended_minimal_tls_version", + "sqlserver_tde_encrypted_with_cmk", + "sqlserver_tde_encryption_enabled", + "storage_blob_public_access_level_is_disabled", + "storage_ensure_encryption_with_customer_managed_keys", + "storage_ensure_minimum_tls_version_12", + "storage_infrastructure_encryption_is_enabled", + "storage_secure_transfer_required_is_enabled", + "storage_smb_channel_encryption_with_secure_algorithm", + "vm_ensure_attached_disks_encrypted_with_cmk", + "vm_ensure_unattached_disks_encrypted_with_cmk" + ], + "gcp": [ + "bigquery_dataset_cmk_encryption", + "bigquery_dataset_public_access", + "bigquery_table_cmk_encryption", + "cloudsql_instance_cmek_encryption_enabled", + "cloudsql_instance_ssl_connections", + "cloudstorage_bucket_public_access", + "compute_image_not_publicly_shared", + "compute_instance_confidential_computing_enabled", + "compute_instance_encryption_with_csek_enabled", + "dataproc_encrypted_with_cmks_disabled" + ], + "kubernetes": [ + "apiserver_encryption_provider_config_set", + "apiserver_etcd_tls_config", + "apiserver_tls_config", + "etcd_peer_tls_config", + "etcd_tls_encryption", + "kubelet_tls_cert_and_key", + "rbac_minimize_secret_access" + ], + "m365": [ + "exchange_organization_modern_authentication_enabled", + "exchange_transport_config_smtp_auth_disabled", + "sharepoint_modern_authentication_required" + ] + }, + "config_requirements": [ + { + "Check": "sqlserver_recommended_minimal_tls_version", + "ConfigKey": "recommended_minimal_tls_versions", + "Operator": "subset", + "Value": [ + "1.2", + "1.3" + ], + "Provider": "azure" + }, + { + "Check": "storage_smb_channel_encryption_with_secure_algorithm", + "ConfigKey": "recommended_smb_channel_encryption_algorithms", + "Operator": "subset", + "Value": [ + "AES-256-GCM" + ], + "Provider": "azure" + } + ] + }, + { + "id": "KSI-SVC-VCM", + "name": "Validating Communications", + "description": "The authenticity and integrity of communications between machine-based information resources is persistently validated using automation.", + "attributes": { + "Theme": "KSI-SVC: Service Configuration", + "NISTControls": "SC-23, SI-7.1", + "ClassApplicability": "Optional for Class B, required for Class C" + }, + "checks": { + "aws": [ + "apigateway_restapi_client_certificate_enabled", + "kafka_cluster_mutual_tls_authentication_enabled", + "ses_identity_dkim_enabled" + ], + "azure": [ + "app_client_certificates_on" + ], + "gcp": [ + "dns_dnssec_disabled" + ], + "kubernetes": [ + "apiserver_client_ca_file_set", + "apiserver_etcd_tls_config", + "apiserver_kubelet_cert_auth", + "apiserver_kubelet_tls_auth", + "etcd_client_cert_auth", + "etcd_no_auto_tls", + "etcd_no_peer_auto_tls", + "etcd_peer_client_cert_auth", + "etcd_peer_tls_config", + "kubelet_client_ca_file_set" + ], + "m365": [ + "defender_domain_dkim_enabled", + "defender_domain_dmarc_records_published", + "exchange_organization_reject_direct_send_enabled" + ] + } + }, + { + "id": "KSI-SVC-VRI", + "name": "Validating Resource Integrity", + "description": "Use cryptographic methods to validate the integrity of machine-based information resources.", + "attributes": { + "Theme": "KSI-SVC: Service Configuration", + "NISTControls": "CM-2.2, CM-8.3, SC-13, SC-23, SI-7, SI-7.1, SR-10", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "cloudtrail_log_file_validation_enabled", + "kms_key_enclave_attestation_bypassable_path", + "kms_key_enclave_attestation_no_deployment_binding", + "kms_key_enclave_attestation_not_enforced", + "kms_key_enclave_attestation_pcr_mismatch", + "kms_key_enclave_attestation_unknown_image", + "kms_key_enclave_debug_attestation_detected" + ], + "azure": [ + "vm_trusted_launch_enabled" + ], + "gcp": [ + "compute_instance_shielded_vm_enabled", + "dns_dnssec_disabled", + "dns_rsasha1_in_use_to_key_sign_in_dnssec", + "dns_rsasha1_in_use_to_zone_sign_in_dnssec" + ], + "kubernetes": [ + "apiserver_etcd_cafile_set", + "controllermanager_root_ca_file_set", + "etcd_client_cert_auth", + "etcd_peer_client_cert_auth" + ], + "m365": [] + }, + "config_requirements": [ + { + "Check": "kms_key_enclave_debug_attestation_detected", + "ConfigKey": "enclave_debug_lookback_window_hours", + "Operator": "gte", + "Value": 2160, + "Provider": "aws" + }, + { + "Check": "kms_key_enclave_debug_attestation_detected", + "ConfigKey": "enclave_debug_max_events", + "Operator": "gte", + "Value": 5000, + "Provider": "aws" + }, + { + "Check": "kms_key_enclave_attestation_unknown_image", + "ConfigKey": "enclave_unknown_image_lookback_window_hours", + "Operator": "gte", + "Value": 2160, + "Provider": "aws" + }, + { + "Check": "kms_key_enclave_attestation_unknown_image", + "ConfigKey": "enclave_unknown_image_max_events", + "Operator": "gte", + "Value": 5000, + "Provider": "aws" + } + ] + } + ] +} diff --git a/prowler/compliance/gcp/fedramp_20x_ksi_low_gcp.json b/prowler/compliance/gcp/fedramp_20x_ksi_low_gcp.json deleted file mode 100644 index 5638b0c51e..0000000000 --- a/prowler/compliance/gcp/fedramp_20x_ksi_low_gcp.json +++ /dev/null @@ -1,294 +0,0 @@ -{ - "Framework": "FedRAMP-20x-KSI-Low", - "Name": "FedRAMP 20x Key Security Indicators (KSIs) - Low Impact Level v25.05C", - "Version": "25.05C", - "Provider": "GCP", - "Description": "FedRAMP 20x Key Security Indicators (KSIs) Low Impact Level represent core security indicators for cloud service providers, focusing on automation, continuous monitoring, and cloud-native security principles per FedRAMP 20x Phase One pilot requirements for Low impact systems.", - "Requirements": [ - { - "Id": "ksi-cmt", - "Name": "KSI-CMT: Change Management", - "Description": "A secure cloud service provider will ensure that all system changes are properly documented and configuration baselines are updated accordingly", - "Attributes": [ - { - "ItemId": "ksi-cmt", - "Section": "Change Management", - "Service": "gcp" - } - ], - "Checks": [ - "iam_audit_logs_enabled", - "iam_cloud_asset_inventory_enabled", - "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", - "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", - "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled", - "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled", - "compute_instance_serial_ports_in_use", - "compute_project_os_login_enabled" - ] - }, - { - "Id": "ksi-cna", - "Name": "KSI-CNA: Cloud Native Architecture", - "Description": "A secure cloud service offering will use cloud native architecture and design principles to enforce and enhance the Confidentiality, Integrity and Availability of the system", - "Attributes": [ - { - "ItemId": "ksi-cna", - "Section": "Cloud Native Architecture", - "Service": "gcp" - } - ], - "Checks": [ - "cloudsql_instance_private_ip_assignment", - "cloudsql_instance_public_access", - "cloudsql_instance_public_ip", - "cloudstorage_bucket_uniform_bucket_level_access", - "compute_firewall_rdp_access_from_the_internet_allowed", - "compute_firewall_ssh_access_from_the_internet_allowed", - "compute_instance_block_project_wide_ssh_keys_disabled", - "compute_instance_confidential_computing_enabled", - "compute_instance_ip_forwarding_is_enabled", - "compute_instance_public_ip", - "compute_instance_shielded_vm_enabled", - "compute_loadbalancer_logging_enabled", - "compute_network_default_in_use", - "compute_network_dns_logging_enabled", - "compute_network_not_legacy", - "compute_subnet_flow_logs_enabled", - "gke_cluster_no_default_service_account" - ] - }, - { - "Id": "ksi-iam", - "Name": "KSI-IAM: Identity and Access Management", - "Description": "A secure cloud service offering will protect user data, control access, and apply zero trust principles", - "Attributes": [ - { - "ItemId": "ksi-iam", - "Section": "Identity and Access Management", - "Service": "gcp" - } - ], - "Checks": [ - "apikeys_api_restrictions_configured", - "apikeys_key_exists", - "apikeys_key_rotated_in_90_days", - "compute_instance_default_service_account_in_use", - "compute_instance_default_service_account_in_use_with_full_api_access", - "iam_no_service_roles_at_project_level", - "iam_role_kms_enforce_separation_of_duties", - "iam_role_sa_enforce_separation_of_duties", - "iam_sa_no_administrative_privileges", - "iam_sa_no_user_managed_keys", - "iam_sa_user_managed_key_rotate_90_days", - "iam_sa_user_managed_key_unused", - "iam_service_account_unused" - ] - }, - { - "Id": "ksi-inr", - "Name": "KSI-INR: Incident Response", - "Description": "A secure cloud service offering will respond to incidents according to FedRAMP requirements and cloud service provider policies", - "Attributes": [ - { - "ItemId": "ksi-inr", - "Section": "Incident Response", - "Service": "gcp" - } - ], - "Checks": [ - "iam_organization_essential_contacts_configured", - "iam_account_access_approval_enabled", - "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", - "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", - "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled", - "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled" - ] - }, - { - "Id": "ksi-mla", - "Name": "KSI-MLA: Monitoring, Logging, and Auditing", - "Description": "A secure cloud service offering will monitor, log, and audit all important events, activity, and changes", - "Attributes": [ - { - "ItemId": "ksi-mla", - "Section": "Monitoring, Logging, and Auditing", - "Service": "gcp" - } - ], - "Checks": [ - "cloudsql_instance_postgres_enable_pgaudit_flag", - "cloudsql_instance_postgres_log_connections_flag", - "cloudsql_instance_postgres_log_disconnections_flag", - "cloudsql_instance_postgres_log_error_verbosity_flag", - "cloudsql_instance_postgres_log_min_duration_statement_flag", - "cloudsql_instance_postgres_log_min_error_statement_flag", - "cloudsql_instance_postgres_log_min_messages_flag", - "cloudsql_instance_postgres_log_statement_flag", - "cloudsql_instance_sqlserver_trace_flag", - "cloudstorage_bucket_log_retention_policy_lock", - "compute_loadbalancer_logging_enabled", - "compute_network_dns_logging_enabled", - "compute_subnet_flow_logs_enabled", - "iam_audit_logs_enabled", - "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", - "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", - "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled", - "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled", - "logging_sink_created" - ] - }, - { - "Id": "ksi-piy", - "Name": "KSI-PIY: Policy and Inventory", - "Description": "A secure cloud service offering will have intentional, organized, universal guidance for how every information resource, including personnel, is secured", - "Attributes": [ - { - "ItemId": "ksi-piy", - "Section": "Policy and Inventory", - "Service": "gcp" - } - ], - "Checks": [ - "iam_cloud_asset_inventory_enabled", - "iam_organization_essential_contacts_configured", - "iam_audit_logs_enabled", - "compute_project_os_login_enabled", - "compute_instance_serial_ports_in_use", - "compute_instance_block_project_wide_ssh_keys_disabled", - "logging_sink_created" - ] - }, - { - "Id": "ksi-rpl", - "Name": "KSI-RPL: Recovery Planning", - "Description": "A secure cloud service offering will define, maintain, and test incident response plan(s) and recovery capabilities to ensure minimal service disruption and data loss", - "Attributes": [ - { - "ItemId": "ksi-rpl", - "Section": "Recovery Planning", - "Service": "gcp" - } - ], - "Checks": [ - "cloudsql_instance_automated_backups", - "cloudstorage_bucket_log_retention_policy_lock", - "cloudstorage_bucket_versioning_enabled", - "cloudstorage_bucket_lifecycle_management_enabled" - ] - }, - { - "Id": "ksi-svc", - "Name": "KSI-SVC: Service Configuration", - "Description": "A secure cloud service offering will follow FedRAMP encryption policies, continuously verify information resource integrity, and restrict access to third-party information resources", - "Attributes": [ - { - "ItemId": "ksi-svc", - "Section": "Service Configuration", - "Service": "gcp" - } - ], - "Checks": [ - "bigquery_dataset_cmk_encryption", - "bigquery_table_cmk_encryption", - "cloudsql_instance_mysql_local_infile_flag", - "cloudsql_instance_mysql_skip_show_database_flag", - "cloudsql_instance_postgres_enable_pgaudit_flag", - "cloudsql_instance_postgres_log_connections_flag", - "cloudsql_instance_postgres_log_disconnections_flag", - "cloudsql_instance_postgres_log_error_verbosity_flag", - "cloudsql_instance_postgres_log_min_duration_statement_flag", - "cloudsql_instance_postgres_log_min_error_statement_flag", - "cloudsql_instance_postgres_log_min_messages_flag", - "cloudsql_instance_postgres_log_statement_flag", - "cloudsql_instance_sqlserver_contained_database_authentication_flag", - "cloudsql_instance_sqlserver_cross_db_ownership_chaining_flag", - "cloudsql_instance_sqlserver_external_scripts_enabled_flag", - "cloudsql_instance_sqlserver_remote_access_flag", - "cloudsql_instance_sqlserver_trace_flag", - "cloudsql_instance_sqlserver_user_connections_flag", - "cloudsql_instance_sqlserver_user_options_flag", - "cloudsql_instance_ssl_connections", - "compute_instance_encryption_with_csek_enabled", - "compute_instance_shielded_vm_enabled", - "dataproc_encrypted_with_cmks_disabled", - "dns_dnssec_disabled", - "dns_rsasha1_in_use_to_key_sign_in_dnssec", - "dns_rsasha1_in_use_to_zone_sign_in_dnssec", - "kms_key_not_publicly_accessible", - "kms_key_rotation_enabled" - ] - }, - { - "Id": "ksi-tpr", - "Name": "KSI-TPR: Third-Party Information Resources", - "Description": "A secure cloud service offering will understand, monitor, and manage supply chain risks from third-party information resources", - "Attributes": [ - { - "ItemId": "ksi-tpr", - "Section": "Third-Party Information Resources", - "Service": "gcp" - } - ], - "Checks": [ - "artifacts_container_analysis_enabled", - "gcr_container_scanning_enabled", - "compute_public_address_shodan", - "cloudsql_instance_automated_backups", - "iam_sa_user_managed_key_rotate_90_days", - "iam_service_account_unused", - "gemini_api_disabled" - ] - }, - { - "Id": "ksi-iam-07", - "Name": "KSI-IAM-07: Account Lifecycle Management", - "Description": "Securely manage the lifecycle and privileges of all accounts, roles, and groups", - "Attributes": [ - { - "ItemId": "ksi-iam-07", - "Section": "Identity and Access Management", - "Service": "gcp" - } - ], - "Checks": [ - "apikeys_key_rotated_in_90_days", - "iam_sa_user_managed_key_rotate_90_days", - "iam_sa_user_managed_key_unused", - "iam_service_account_unused", - "compute_instance_default_service_account_in_use" - ] - }, - { - "Id": "ksi-mla-07", - "Name": "KSI-MLA-07: Monitoring and Logging Inventory", - "Description": "Maintain a list of information resources and event types that will be monitored, logged, and audited", - "Attributes": [ - { - "ItemId": "ksi-mla-07", - "Section": "Monitoring, Logging, and Auditing", - "Service": "gcp" - } - ], - "Checks": [ - "iam_audit_logs_enabled", - "iam_cloud_asset_inventory_enabled", - "logging_sink_created", - "compute_subnet_flow_logs_enabled", - "compute_network_dns_logging_enabled" - ] - } - ] -} From 3860cd3dceab27381c0e56595e367e39ddc2ef7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Mon, 14 Sep 2026 16:35:05 +0200 Subject: [PATCH 04/23] feat(compliance): FedRAMP 20x Class C FRR + AWS checks (#12808) --- README.md | 10 +- contrib/k8s/helm/prowler-api/values.yaml | 8 + docs/developer-guide/configurable-checks.mdx | 2 + .../cli/tutorials/configuration_file.mdx | 10 + permissions/prowler-additions-policy.json | 1 + .../cloudformation/prowler-scan-role.yml | 2 + .../aws-inspector2-fips-checks.added.md | 1 + .../fedramp-20x-frr-class-c-2026.added.md | 1 + .../fedramp_20x_frr_class_c_2026.json | 2970 +++++++++++++++++ prowler/config/config.yaml | 8 + prowler/config/schema/aws.py | 14 + .../__init__.py | 0 ...v2_listener_fips_tls_enabled.metadata.json | 43 + .../elbv2_listener_fips_tls_enabled.py | 35 + .../__init__.py | 0 ...findings_kev_within_due_date.metadata.json | 42 + ...or2_active_findings_kev_within_due_date.py | 66 + .../__init__.py | 0 ...wn_exploited_vulnerabilities.metadata.json | 43 + ...ings_no_known_exploited_vulnerabilities.py | 57 + .../__init__.py | 0 ...tive_findings_within_max_age.metadata.json | 43 + ...spector2_active_findings_within_max_age.py | 51 + .../__init__.py | 0 ...r2_coverage_recently_scanned.metadata.json | 43 + .../inspector2_coverage_recently_scanned.py | 57 + .../__init__.py | 0 ..._coverage_scan_status_active.metadata.json | 42 + .../inspector2_coverage_scan_status_active.py | 46 + .../services/inspector2/inspector2_service.py | 201 ++ .../aws/services/inspector2/lib/__init__.py | 0 .../inspector2/lib/vulnerabilities.py | 8 + .../__init__.py | 0 ...fips_security_policy_enabled.metadata.json | 42 + ...fer_server_fips_security_policy_enabled.py | 38 + tests/config/config_test.py | 2 + tests/config/fixtures/config.yaml | 8 + .../elbv2_listener_fips_tls_enabled_test.py | 156 + ...ctive_findings_kev_within_due_date_test.py | 136 + ...no_known_exploited_vulnerabilities_test.py | 149 + ...or2_active_findings_within_max_age_test.py | 140 + ...spector2_coverage_recently_scanned_test.py | 170 + ...ector2_coverage_scan_status_active_test.py | 132 + .../inspector2/inspector2_service_test.py | 220 +- ...erver_fips_security_policy_enabled_test.py | 111 + 45 files changed, 5100 insertions(+), 8 deletions(-) create mode 100644 prowler/changelog.d/aws-inspector2-fips-checks.added.md create mode 100644 prowler/changelog.d/fedramp-20x-frr-class-c-2026.added.md create mode 100644 prowler/compliance/fedramp_20x_frr_class_c_2026.json create mode 100644 prowler/providers/aws/services/elbv2/elbv2_listener_fips_tls_enabled/__init__.py create mode 100644 prowler/providers/aws/services/elbv2/elbv2_listener_fips_tls_enabled/elbv2_listener_fips_tls_enabled.metadata.json create mode 100644 prowler/providers/aws/services/elbv2/elbv2_listener_fips_tls_enabled/elbv2_listener_fips_tls_enabled.py create mode 100644 prowler/providers/aws/services/inspector2/inspector2_active_findings_kev_within_due_date/__init__.py create mode 100644 prowler/providers/aws/services/inspector2/inspector2_active_findings_kev_within_due_date/inspector2_active_findings_kev_within_due_date.metadata.json create mode 100644 prowler/providers/aws/services/inspector2/inspector2_active_findings_kev_within_due_date/inspector2_active_findings_kev_within_due_date.py create mode 100644 prowler/providers/aws/services/inspector2/inspector2_active_findings_no_known_exploited_vulnerabilities/__init__.py create mode 100644 prowler/providers/aws/services/inspector2/inspector2_active_findings_no_known_exploited_vulnerabilities/inspector2_active_findings_no_known_exploited_vulnerabilities.metadata.json create mode 100644 prowler/providers/aws/services/inspector2/inspector2_active_findings_no_known_exploited_vulnerabilities/inspector2_active_findings_no_known_exploited_vulnerabilities.py create mode 100644 prowler/providers/aws/services/inspector2/inspector2_active_findings_within_max_age/__init__.py create mode 100644 prowler/providers/aws/services/inspector2/inspector2_active_findings_within_max_age/inspector2_active_findings_within_max_age.metadata.json create mode 100644 prowler/providers/aws/services/inspector2/inspector2_active_findings_within_max_age/inspector2_active_findings_within_max_age.py create mode 100644 prowler/providers/aws/services/inspector2/inspector2_coverage_recently_scanned/__init__.py create mode 100644 prowler/providers/aws/services/inspector2/inspector2_coverage_recently_scanned/inspector2_coverage_recently_scanned.metadata.json create mode 100644 prowler/providers/aws/services/inspector2/inspector2_coverage_recently_scanned/inspector2_coverage_recently_scanned.py create mode 100644 prowler/providers/aws/services/inspector2/inspector2_coverage_scan_status_active/__init__.py create mode 100644 prowler/providers/aws/services/inspector2/inspector2_coverage_scan_status_active/inspector2_coverage_scan_status_active.metadata.json create mode 100644 prowler/providers/aws/services/inspector2/inspector2_coverage_scan_status_active/inspector2_coverage_scan_status_active.py create mode 100644 prowler/providers/aws/services/inspector2/lib/__init__.py create mode 100644 prowler/providers/aws/services/inspector2/lib/vulnerabilities.py create mode 100644 prowler/providers/aws/services/transfer/transfer_server_fips_security_policy_enabled/__init__.py create mode 100644 prowler/providers/aws/services/transfer/transfer_server_fips_security_policy_enabled/transfer_server_fips_security_policy_enabled.metadata.json create mode 100644 prowler/providers/aws/services/transfer/transfer_server_fips_security_policy_enabled/transfer_server_fips_security_policy_enabled.py create mode 100644 tests/providers/aws/services/elbv2/elbv2_listener_fips_tls_enabled/elbv2_listener_fips_tls_enabled_test.py create mode 100644 tests/providers/aws/services/inspector2/inspector2_active_findings_kev_within_due_date/inspector2_active_findings_kev_within_due_date_test.py create mode 100644 tests/providers/aws/services/inspector2/inspector2_active_findings_no_known_exploited_vulnerabilities/inspector2_active_findings_no_known_exploited_vulnerabilities_test.py create mode 100644 tests/providers/aws/services/inspector2/inspector2_active_findings_within_max_age/inspector2_active_findings_within_max_age_test.py create mode 100644 tests/providers/aws/services/inspector2/inspector2_coverage_recently_scanned/inspector2_coverage_recently_scanned_test.py create mode 100644 tests/providers/aws/services/inspector2/inspector2_coverage_scan_status_active/inspector2_coverage_scan_status_active_test.py create mode 100644 tests/providers/aws/services/transfer/transfer_server_fips_security_policy_enabled/transfer_server_fips_security_policy_enabled_test.py diff --git a/README.md b/README.md index ad69260cb0..2851101f05 100644 --- a/README.md +++ b/README.md @@ -126,12 +126,12 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically | Provider | Checks | Services | [Compliance Frameworks](https://docs.prowler.com/user-guide/compliance/tutorials/compliance) | [Categories](https://docs.prowler.com/user-guide/cli/tutorials/misc#categories) | Support | Interface | |---|---|---|---|---|---|---| -| AWS | 639 | 86 | 47 | 19 | Official | UI, API, CLI | -| Azure | 191 | 22 | 21 | 16 | Official | UI, API, CLI | -| GCP | 109 | 20 | 19 | 12 | Official | UI, API, CLI | -| Kubernetes | 92 | 7 | 8 | 11 | Official | UI, API, CLI | +| AWS | 662 | 86 | 50 | 19 | Official | UI, API, CLI | +| Azure | 191 | 22 | 25 | 16 | Official | UI, API, CLI | +| GCP | 110 | 20 | 22 | 12 | Official | UI, API, CLI | +| Kubernetes | 92 | 7 | 11 | 11 | Official | UI, API, CLI | | GitHub | 24 | 3 | 2 | 5 | Official | UI, API, CLI | -| M365 | 143 | 10 | 6 | 10 | Official | UI, API, CLI | +| M365 | 144 | 10 | 9 | 10 | Official | UI, API, CLI | | OCI | 52 | 14 | 5 | 10 | Official | UI, API, CLI | | Alibaba Cloud | 63 | 9 | 6 | 9 | Official | UI, API, CLI | | Cloudflare | 29 | 3 | 2 | 5 | Official | UI, API, CLI | diff --git a/contrib/k8s/helm/prowler-api/values.yaml b/contrib/k8s/helm/prowler-api/values.yaml index a6074c7852..5332f9d2eb 100644 --- a/contrib/k8s/helm/prowler-api/values.yaml +++ b/contrib/k8s/helm/prowler-api/values.yaml @@ -212,6 +212,14 @@ mainConfig: # MEDIUM ecr_repository_vulnerability_minimum_severity: "MEDIUM" + # AWS Inspector2 + # aws.inspector2_coverage_recently_scanned + # Maximum days since Inspector2 last scanned an actively covered resource + inspector2_max_days_since_last_scan: 3 + # aws.inspector2_active_findings_within_max_age + # Maximum days an Inspector2 finding can stay active since it was first observed + inspector2_active_finding_max_age_days: 192 + # AWS Trusted Advisor # aws.trustedadvisor_premium_support_plan_subscribed verify_premium_support_plans: True diff --git a/docs/developer-guide/configurable-checks.mdx b/docs/developer-guide/configurable-checks.mdx index 04a31a8cde..c44f82c5aa 100644 --- a/docs/developer-guide/configurable-checks.mdx +++ b/docs/developer-guide/configurable-checks.mdx @@ -154,6 +154,8 @@ Only fields with a numeric range, a fixed value set, or a length cap are listed. | `max_days_secret_unused` | `7..365` days | | | `max_days_secret_unrotated` | `1..180` days | NIST IA-5: rotate quarterly; CIS ≤90 | | `min_kinesis_stream_retention_hours` | `24..8760` h | 1 day .. 1 year | +| `inspector2_max_days_since_last_scan` | `1..90` days | | +| `inspector2_active_finding_max_age_days` | `1..365` days | Default `192` matches the FedRAMP 20x rule that marks vulnerabilities still open after 192 days as accepted | | `shodan_api_key` | ≤512 chars | | ### Azure diff --git a/docs/user-guide/cli/tutorials/configuration_file.mdx b/docs/user-guide/cli/tutorials/configuration_file.mdx index ac21f5bc6c..a1c14e6626 100644 --- a/docs/user-guide/cli/tutorials/configuration_file.mdx +++ b/docs/user-guide/cli/tutorials/configuration_file.mdx @@ -91,6 +91,8 @@ The following list includes all the AWS checks with configurable variables that | `iam_user_access_not_stale_to_sagemaker` | `max_unused_sagemaker_access_days` | Integer | `90` | | `iam_user_accesskey_unused` | `max_unused_access_keys_days` | Integer | `45` | | `iam_user_console_access_unused` | `max_console_access_days` | Integer | `45` | +| `inspector2_active_findings_within_max_age` | `inspector2_active_finding_max_age_days` | Integer | `192` | +| `inspector2_coverage_recently_scanned` | `inspector2_max_days_since_last_scan` | Integer | `3` | | `kinesis_stream_data_retention_period` | `min_kinesis_stream_retention_hours` | Integer | `168` | | `neptune_cluster_backup_enabled` | `minimum_backup_retention_period` | Integer | `7` | | `opensearch_service_domains_not_publicly_accessible` | `trusted_ips` | List of Strings | `[]` | @@ -490,6 +492,14 @@ aws: # MEDIUM ecr_repository_vulnerability_minimum_severity: "MEDIUM" + # AWS Inspector2 + # aws.inspector2_coverage_recently_scanned + # Maximum days since Inspector2 last scanned an actively covered resource + inspector2_max_days_since_last_scan: 3 + # aws.inspector2_active_findings_within_max_age + # Maximum days an Inspector2 finding can stay active since it was first observed + inspector2_active_finding_max_age_days: 192 + # AWS Trusted Advisor # aws.trustedadvisor_premium_support_plan_subscribed verify_premium_support_plans: True diff --git a/permissions/prowler-additions-policy.json b/permissions/prowler-additions-policy.json index 25ea46b09d..be17979d2e 100644 --- a/permissions/prowler-additions-policy.json +++ b/permissions/prowler-additions-policy.json @@ -38,6 +38,7 @@ "glue:GetSecurityConfiguration*", "glue:SearchTables", "glue:GetMLTransforms", + "inspector2:BatchGetFindingDetails", "lambda:GetFunction*", "lambda:GetLayerVersion", "logs:FilterLogEvents", diff --git a/permissions/templates/cloudformation/prowler-scan-role.yml b/permissions/templates/cloudformation/prowler-scan-role.yml index d04c8f25d6..d31dea9846 100644 --- a/permissions/templates/cloudformation/prowler-scan-role.yml +++ b/permissions/templates/cloudformation/prowler-scan-role.yml @@ -210,6 +210,7 @@ Resources: - "glue:GetSecurityConfiguration*" - "glue:SearchTables" - "glue:GetMLTransforms" + - "inspector2:BatchGetFindingDetails" - "lambda:GetFunction*" - "logs:FilterLogEvents" - "lightsail:GetRelationalDatabases" @@ -479,6 +480,7 @@ Resources: - "glue:GetSecurityConfiguration*" - "glue:SearchTables" - "glue:GetMLTransforms" + - "inspector2:BatchGetFindingDetails" - "lambda:GetFunction*" - "logs:FilterLogEvents" - "lightsail:GetRelationalDatabases" diff --git a/prowler/changelog.d/aws-inspector2-fips-checks.added.md b/prowler/changelog.d/aws-inspector2-fips-checks.added.md new file mode 100644 index 0000000000..40c36f8062 --- /dev/null +++ b/prowler/changelog.d/aws-inspector2-fips-checks.added.md @@ -0,0 +1 @@ +`inspector2_coverage_scan_status_active`, `inspector2_coverage_recently_scanned`, `inspector2_active_findings_no_known_exploited_vulnerabilities`, `inspector2_active_findings_kev_within_due_date`, `inspector2_active_findings_within_max_age`, `elbv2_listener_fips_tls_enabled` and `transfer_server_fips_security_policy_enabled` checks for AWS provider, covering FedRAMP 20x Class C vulnerability detection, CISA KEV remediation and FIPS cryptography rules; the KEV checks require `inspector2:BatchGetFindingDetails`, now in the Prowler additions policy diff --git a/prowler/changelog.d/fedramp-20x-frr-class-c-2026.added.md b/prowler/changelog.d/fedramp-20x-frr-class-c-2026.added.md new file mode 100644 index 0000000000..147ad052f3 --- /dev/null +++ b/prowler/changelog.d/fedramp-20x-frr-class-c-2026.added.md @@ -0,0 +1 @@ +`FedRAMP-20x-FRR-Class-C` universal compliance framework (`fedramp_20x_frr_class_c_2026`) with the 158 provider rules of the FedRAMP 20x Class C ruleset from the FedRAMP Consolidated Rules 2026 for AWS, Azure, GCP, Kubernetes and M365 diff --git a/prowler/compliance/fedramp_20x_frr_class_c_2026.json b/prowler/compliance/fedramp_20x_frr_class_c_2026.json new file mode 100644 index 0000000000..f4db7f37bd --- /dev/null +++ b/prowler/compliance/fedramp_20x_frr_class_c_2026.json @@ -0,0 +1,2970 @@ +{ + "framework": "FedRAMP-20x-FRR-Class-C", + "name": "FedRAMP 20x Class C Rules (FRR) 2026", + "version": "2026.09.13.02", + "description": "FedRAMP Rules (FRR) that cloud service providers must follow for a FedRAMP 20x Class C Certification, from the FedRAMP Consolidated Rules for 2026 (release 2026.09.13.02, https://github.com/FedRAMP/rules). Covers the 158 provider rules of the 15 rulesets on the 20x Class C reference page; rules that only bind FedRAMP, agencies, assessors or advisors are excluded, and class-varying rules use their Class C statement. Most rules are program and process obligations that need manual evidence. The Key Security Indicators of the same ruleset are in the fedramp_20x_ksi_2026 framework.", + "icon": "fedramp", + "attributes_metadata": [ + { + "key": "Ruleset", + "label": "Ruleset", + "type": "str", + "required": true, + "enum": [ + "AFC: Addressing FedRAMP Communication", + "CCM: Collaborative Continuous Monitoring", + "CDS: Certification Data Sharing", + "CMU: Cryptographic Module Use", + "CPO: Certification Package Overview", + "FRC: FedRAMP Certification", + "IEC: Incident Evaluation and Communication", + "IVV: Independent Verification and Validation", + "MAS: Minimum Assessment Scope", + "MKT: Marketplace Listing", + "SCG: Secure Configuration Guide", + "SCN: Significant Change Notification", + "SDR: Security Decision Record", + "VDR: Vulnerability Detection and Response", + "VER: Vulnerability Evaluation and Reporting" + ] + }, + { + "key": "Subset", + "label": "Subset", + "type": "str", + "required": true + }, + { + "key": "Force", + "label": "Force", + "type": "str", + "required": true, + "enum": [ + "MUST", + "MUST NOT", + "SHOULD", + "SHOULD NOT", + "MAY" + ] + } + ], + "outputs": { + "table_config": { + "group_by": "Ruleset" + }, + "pdf_config": { + "language": "en", + "primary_color": "#1B3A5C", + "secondary_color": "#2E6DA4", + "bg_color": "#F0F4FA", + "group_by_field": "Ruleset", + "sections": [ + "AFC: Addressing FedRAMP Communication", + "CCM: Collaborative Continuous Monitoring", + "CDS: Certification Data Sharing", + "CMU: Cryptographic Module Use", + "CPO: Certification Package Overview", + "FRC: FedRAMP Certification", + "IEC: Incident Evaluation and Communication", + "IVV: Independent Verification and Validation", + "MAS: Minimum Assessment Scope", + "MKT: Marketplace Listing", + "SCG: Secure Configuration Guide", + "SCN: Significant Change Notification", + "SDR: Security Decision Record", + "VDR: Vulnerability Detection and Response", + "VER: Vulnerability Evaluation and Reporting" + ], + "section_short_names": { + "AFC: Addressing FedRAMP Communication": "AFC", + "CCM: Collaborative Continuous Monitoring": "CCM", + "CDS: Certification Data Sharing": "CDS", + "CMU: Cryptographic Module Use": "CMU", + "CPO: Certification Package Overview": "CPO", + "FRC: FedRAMP Certification": "FRC", + "IEC: Incident Evaluation and Communication": "IEC", + "IVV: Independent Verification and Validation": "IVV", + "MAS: Minimum Assessment Scope": "MAS", + "MKT: Marketplace Listing": "MKT", + "SCG: Secure Configuration Guide": "SCG", + "SCN: Significant Change Notification": "SCN", + "SDR: Security Decision Record": "SDR", + "VDR: Vulnerability Detection and Response": "VDR", + "VER: Vulnerability Evaluation and Reporting": "VER" + }, + "charts": [ + { + "id": "ruleset_compliance", + "type": "horizontal_bar", + "group_by": "Ruleset", + "title": "Compliance Score by FRR Ruleset", + "y_label": "Ruleset", + "x_label": "Compliance %", + "value_source": "compliance_percent", + "color_mode": "by_value" + } + ], + "filter": { + "only_failed": true, + "include_manual": false + } + } + }, + "requirements": [ + { + "id": "AFC-CSO-INB", + "name": "Maintain a FedRAMP Security Inbox", + "description": "Providers MUST establish and maintain an email address to receive messages from FedRAMP; this inbox is a FedRAMP Security Inbox (FSI).", + "attributes": { + "Ruleset": "AFC: Addressing FedRAMP Communication", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "AFC-CSO-NOC", + "name": "Notification of Changes", + "description": "Providers MUST immediately notify FedRAMP of any changes to the email address for their FedRAMP Security Inbox.", + "attributes": { + "Ruleset": "AFC: Addressing FedRAMP Communication", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "AFC-CSO-TFG", + "name": "Trust @fedramp.gov and @gsa.gov", + "description": "Providers MUST treat any email originating from an @fedramp.gov or @gsa.gov email address as if it was sent from FedRAMP by default; if such a message is confirmed to originate from someone other than FedRAMP then the FedRAMP Security Inbox rules no longer apply.", + "attributes": { + "Ruleset": "AFC: Addressing FedRAMP Communication", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "AFC-CSO-RCV", + "name": "Receive Email Without Disruption", + "description": "Providers MUST receive and react to email messages from FedRAMP without disruption and without requiring additional actions from FedRAMP.", + "attributes": { + "Ruleset": "AFC: Addressing FedRAMP Communication", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "AFC-CSO-CRA", + "name": "Complete Required Actions", + "description": "Providers MUST complete the required actions in Emergency or Emergency Test designated messages sent by FedRAMP within the timeframe included in the message.", + "attributes": { + "Ruleset": "AFC: Addressing FedRAMP Communication", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "AFC-CSO-EMR", + "name": "Emergency Message Routing", + "description": "Providers MUST route Emergency designated messages sent by FedRAMP to a senior security official for their awareness.", + "attributes": { + "Ruleset": "AFC: Addressing FedRAMP Communication", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "AFC-CSO-IMA", + "name": "Important Message Actions", + "description": "Providers SHOULD complete the required actions in Important designated messages sent by FedRAMP within the timeframe specified in the message.", + "attributes": { + "Ruleset": "AFC: Addressing FedRAMP Communication", + "Subset": "CSO: General Provider Responsibilities", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "AFC-CSO-ACK", + "name": "Acknowledge Receipt", + "description": "Providers SHOULD promptly and automatically acknowledge the receipt of messages received from FedRAMP in their FedRAMP Security Inbox.", + "attributes": { + "Ruleset": "AFC: Addressing FedRAMP Communication", + "Subset": "CSO: General Provider Responsibilities", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CCM-OCR-AVL", + "name": "Report Availability", + "description": "Providers MUST supply an Ongoing Certification Report to all necessary parties every 3 months, covering the entire period since the previous summary, in a consistent format that is human readable; this report MUST include high-level summaries of at least the following information (if applicable): Changes to FedRAMP Certification Data; Planned changes to FedRAMP Certification Data during at least the next 3 months; Accepted vulnerabilities; Transformative changes; Updated recommendations or best practices for security, configuration, usage, or similar aspects of the cloud service offering; A list of all agencies that are directly using the product; FedRAMP Reportable Incidents or an attestation that no such incidents occurred; Lessons learned and changes planned or made as a result of FedRAMP Reportable Incidents (if such occurred)", + "attributes": { + "Ruleset": "CCM: Collaborative Continuous Monitoring", + "Subset": "OCR: Ongoing Certification Reports", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CCM-OCR-NRD", + "name": "Next Report Date", + "description": "Providers MUST supply the target date for their next Ongoing Certification Report with other public FedRAMP Certification Data.", + "attributes": { + "Ruleset": "CCM: Collaborative Continuous Monitoring", + "Subset": "OCR: Ongoing Certification Reports", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CCM-OCR-FBM", + "name": "Feedback Mechanism", + "description": "Providers MUST supply an asynchronous mechanism for all necessary parties to provide feedback or ask questions about each Ongoing Certification Report.", + "attributes": { + "Ruleset": "CCM: Collaborative Continuous Monitoring", + "Subset": "OCR: Ongoing Certification Reports", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CCM-OCR-AFS", + "name": "Anonymized Feedback Summary", + "description": "Providers MUST supply an anonymized and desensitized summary of the feedback, questions, and answers about each Ongoing Certification Report as an addendum to the Ongoing Certification Report OR in the next Ongoing Certification Report.", + "attributes": { + "Ruleset": "CCM: Collaborative Continuous Monitoring", + "Subset": "OCR: Ongoing Certification Reports", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CCM-OCR-LSI", + "name": "Limit Sensitive Information", + "description": "Providers MUST NOT irresponsibly disclose sensitive information in an Ongoing Certification Report that would likely have an adverse effect on the cloud service offering.", + "attributes": { + "Ruleset": "CCM: Collaborative Continuous Monitoring", + "Subset": "OCR: Ongoing Certification Reports", + "Force": "MUST NOT" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CCM-OCR-SOR", + "name": "Spread Out Reports", + "description": "Providers SHOULD establish a regular 3 month cycle for Ongoing Certification Reports that is spread out from the beginning, middle, or end of each quarter.", + "attributes": { + "Ruleset": "CCM: Collaborative Continuous Monitoring", + "Subset": "OCR: Ongoing Certification Reports", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CCM-OCR-RPS", + "name": "Responsible Public Certification Report Sharing", + "description": "Providers MAY responsibly supply some or all of the information an Ongoing Certification Report to the public or other parties if the provider determines doing so will NOT likely have an adverse effect on the cloud service offering.", + "attributes": { + "Ruleset": "CCM: Collaborative Continuous Monitoring", + "Subset": "OCR: Ongoing Certification Reports", + "Force": "MAY" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CCM-QTR-MTG", + "name": "Quarterly Review Meeting", + "description": "Providers with Class C Certifications MUST host a synchronous Quarterly Review every 3 months, open to all necessary parties, to review aspects of the most recent Ongoing Certification Reports that the provider determines are of the most relevance to agencies.", + "attributes": { + "Ruleset": "CCM: Collaborative Continuous Monitoring", + "Subset": "QTR: Quarterly Reviews", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CCM-QTR-REG", + "name": "Meeting Registration Info", + "description": "Providers MUST supply either a registration link or a downloadable calendar file with meeting information for Quarterly Reviews to all necessary parties.", + "attributes": { + "Ruleset": "CCM: Collaborative Continuous Monitoring", + "Subset": "QTR: Quarterly Reviews", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CCM-QTR-NRD", + "name": "Next Review Date", + "description": "Providers MUST publicly supply the target date for their next Quarterly Review with other public FedRAMP Certification Data.", + "attributes": { + "Ruleset": "CCM: Collaborative Continuous Monitoring", + "Subset": "QTR: Quarterly Reviews", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CCM-QTR-NID", + "name": "No Irresponsible Disclosure", + "description": "Providers MUST NOT irresponsibly disclose sensitive information in a Quarterly Review that would likely have an adverse effect on the cloud service offering.", + "attributes": { + "Ruleset": "CCM: Collaborative Continuous Monitoring", + "Subset": "QTR: Quarterly Reviews", + "Force": "MUST NOT" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CCM-QTR-SAR", + "name": "Schedule Around Reports", + "description": "Providers SHOULD regularly schedule Quarterly Reviews to occur at least 3 business days after releasing an Ongoing Certification Report AND within 10 business days of such release.", + "attributes": { + "Ruleset": "CCM: Collaborative Continuous Monitoring", + "Subset": "QTR: Quarterly Reviews", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CCM-QTR-ACT", + "name": "Additional Content", + "description": "Providers SHOULD supply additional information in Quarterly Reviews that the provider determines is of interest, use, or otherwise relevant to agencies.", + "attributes": { + "Ruleset": "CCM: Collaborative Continuous Monitoring", + "Subset": "QTR: Quarterly Reviews", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CCM-QTR-RTR", + "name": "Record/Transcribe Reviews", + "description": "Providers SHOULD record or transcribe Quarterly Reviews and supply them to all necessary parties.", + "attributes": { + "Ruleset": "CCM: Collaborative Continuous Monitoring", + "Subset": "QTR: Quarterly Reviews", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CCM-QTR-RTP", + "name": "Restrict Third Parties", + "description": "Providers SHOULD NOT invite third parties to attend Quarterly Reviews intended for agencies unless they have specific relevance.", + "attributes": { + "Ruleset": "CCM: Collaborative Continuous Monitoring", + "Subset": "QTR: Quarterly Reviews", + "Force": "SHOULD NOT" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CCM-QTR-SRR", + "name": "Share Recordings Responsibly", + "description": "Providers MAY responsibly supply recordings or transcriptions of Quarterly Reviews to the public or other parties ONLY if the provider removes all agency information (comments, questions, names, etc.) AND determines doing so will NOT likely have an adverse effect on the cloud service offering.", + "attributes": { + "Ruleset": "CCM: Collaborative Continuous Monitoring", + "Subset": "QTR: Quarterly Reviews", + "Force": "MAY" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CCM-QTR-SCR", + "name": "Share Content Responsibly", + "description": "Providers MAY responsibly supply content prepared for a Quarterly Review to the public or other parties if the provider determines doing so will NOT likely have an adverse effect on the cloud service offering.", + "attributes": { + "Ruleset": "CCM: Collaborative Continuous Monitoring", + "Subset": "QTR: Quarterly Reviews", + "Force": "MAY" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CDS-CSO-PUB", + "name": "Public Information", + "description": "Providers MUST publicly share up-to-date information about the cloud service offering in both human-readable and JSON formats, including at least the following information that is available and applicable: FedRAMP ID; Service Model; Deployment Model; Business Category; UEI Number; Sales Contact Information; Security Contact Information; Product Website Link; Link to Product Logo; Overall Service Description; Detailed list of specific services and their security categories (see CDS-CSO-SVC (Public Service List) (Service List)); Link to Secure Configuration Guidance; Overview of documentation supplied by the provider for the cloud service offering; Link to Trust Center landing page that includes instructions on accessing information in the trust center; Next Ongoing Certification Report date (see CCM-OCR-NRD (Next Report Date)); Current FedRAMP Recognized independent assessment service", + "attributes": { + "Ruleset": "CDS: Certification Data Sharing", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CDS-CSO-SVC", + "name": "Public Service List", + "description": "Providers MUST publicly share a detailed list of specific services and their security categories that are included in the cloud service offering using clear feature or service names that align with standard public marketing materials; this list MUST be complete enough for a potential customer to determine which services are and are not included in the FedRAMP Minimum Assessment Scope without requesting access to underlying FedRAMP Certification Data.", + "attributes": { + "Ruleset": "CDS: Certification Data Sharing", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CDS-CSO-FID", + "name": "Always Include FedRAMP ID", + "description": "Providers MUST always include the FedRAMP ID of the related cloud service offering in all FedRAMP Certification Data once assigned, including all reports, notifications, and other communication that results from FedRAMP rules.", + "attributes": { + "Ruleset": "CDS: Certification Data Sharing", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CDS-CSO-FRC", + "name": "FedRAMP Certification Reports", + "description": "Providers MUST include FedRAMP Certification Reports with their FedRAMP Certification Data without inappropriate modifications, and make such reports available within 2 weeks of receiving the materials from FedRAMP.", + "attributes": { + "Ruleset": "CDS: Certification Data Sharing", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CDS-CSO-AVR", + "name": "Availability Reporting", + "description": "Providers with Class C Certifications MUST maintain a web service, available to all necessary parties, that indicates current and historical availability of core services within the cloud service offering over at least the past 30 days, including availability incidents, in both human-readable and machine-readable formats; this service MUST be available even if the primary cloud service offering is unavailable.", + "attributes": { + "Ruleset": "CDS: Certification Data Sharing", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CDS-CSO-UTC", + "name": "Use Trust Centers", + "description": "Providers MUST use a FedRAMP-compatible trust center to store and share FedRAMP Certification Data with all necessary parties.", + "attributes": { + "Ruleset": "CDS: Certification Data Sharing", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CDS-CSO-CBF", + "name": "Consistency Between Formats", + "description": "Providers MUST use automation to ensure information remains consistent between human-readable and machine-readable formats when FedRAMP Certification Data is provided in both formats.", + "attributes": { + "Ruleset": "CDS: Certification Data Sharing", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CDS-CSO-RIS", + "name": "Responsible Information Sharing", + "description": "Providers MUST provide sufficient information in FedRAMP Certification Data to support agency authorization decisions but SHOULD NOT include sensitive information that would likely enable a threat actor to gain unauthorized access, cause harm, disrupt operations, or otherwise have a negative adverse impact on the cloud service offering.", + "attributes": { + "Ruleset": "CDS: Certification Data Sharing", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CDS-CSO-IRP", + "name": "Include Relevant Policies", + "description": "Providers MUST supply all relevant policies and procedures in the FedRAMP Certification Data, including a human-readable and machine-readable reference that explains at least the following about each included policy and procedure: Name of policy or procedure; Name of file, document, web page, etc.; Brief summary of policy or procedure; Word count of document; Current version; Date of last update; Related FedRAMP Practices (if applicable)", + "attributes": { + "Ruleset": "CDS: Certification Data Sharing", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CDS-CSO-HAD", + "name": "Historical FedRAMP Certification Data", + "description": "Providers MUST supply snapshots of FedRAMP Certification Data aligned to Ongoing Certification Reports to all necessary parties; these snapshots MUST be available for the duration of FedRAMP Certification.", + "attributes": { + "Ruleset": "CDS: Certification Data Sharing", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CDS-CSO-PSM", + "name": "Per-Service Certification Materials", + "description": "Providers with Class C Certifications MAY supply per-service FedRAMP Certification materials.", + "attributes": { + "Ruleset": "CDS: Certification Data Sharing", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MAY" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CDS-CSO-RPS", + "name": "Responsible Public Package Sharing", + "description": "Providers MAY responsibly share some or all of the information in a FedRAMP Certification Package publicly or with other parties if the provider determines doing so will NOT likely have an adverse effect on the cloud service offering.", + "attributes": { + "Ruleset": "CDS: Certification Data Sharing", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MAY" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CDS-TRC-USH", + "name": "Uninterrupted Sharing", + "description": "Trust centers MUST share FedRAMP Certification Data with all necessary parties without interruption.", + "attributes": { + "Ruleset": "CDS: Certification Data Sharing", + "Subset": "TRC: FedRAMP-Compatible Trust Centers", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CDS-TRC-PAC", + "name": "Programmatic Access", + "description": "Trust centers MUST provide documented programmatic access to all FedRAMP Certification Data, including programmatic access to human-readable materials.", + "attributes": { + "Ruleset": "CDS: Certification Data Sharing", + "Subset": "TRC: FedRAMP-Compatible Trust Centers", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CDS-TRC-AAI", + "name": "Agency Access Inventory", + "description": "Trust centers MUST maintain an inventory and history of federal agency users or systems with access to FedRAMP Certification Data and MUST make this information available to FedRAMP upon request.", + "attributes": { + "Ruleset": "CDS: Certification Data Sharing", + "Subset": "TRC: FedRAMP-Compatible Trust Centers", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CDS-TRC-ACL", + "name": "Access Logging", + "description": "Trust centers MUST log access to FedRAMP Certification Data and store summaries of access for at least six months; such information, as it pertains to specific parties, SHOULD be made available upon request by those parties.", + "attributes": { + "Ruleset": "CDS: Certification Data Sharing", + "Subset": "TRC: FedRAMP-Compatible Trust Centers", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CDS-TRC-HMR", + "name": "Human and Machine-Readable Certification Data", + "description": "Trust centers SHOULD make FedRAMP Certification Data available to view and download in both human-readable and machine-readable formats.", + "attributes": { + "Ruleset": "CDS: Certification Data Sharing", + "Subset": "TRC: FedRAMP-Compatible Trust Centers", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CDS-TRC-SSM", + "name": "Self-Service Access Management", + "description": "Trust centers SHOULD include features that encourage all necessary parties to provision and manage access to FedRAMP Certification Data for their users and services directly.", + "attributes": { + "Ruleset": "CDS: Certification Data Sharing", + "Subset": "TRC: FedRAMP-Compatible Trust Centers", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CDS-UTC-AAD", + "name": "Agency Access Denial", + "description": "Providers MUST notify FedRAMP within 5 business days of denying an agency access request for FedRAMP Certification Data.", + "attributes": { + "Ruleset": "CDS: Certification Data Sharing", + "Subset": "UTC: Using a Trust Center", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CDS-UTC-AGA", + "name": "Agency Access", + "description": "Providers SHOULD supply access to the FedRAMP Certification Package with agencies upon request.", + "attributes": { + "Ruleset": "CDS: Certification Data Sharing", + "Subset": "UTC: Using a Trust Center", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CMU-CSO-CMD", + "name": "Cryptographic Module Documentation", + "description": "Providers MUST document the cryptographic modules used in each service (or groups of services that use the same modules) where cryptographic services are used to protect federal customer data, including whether these modules are validated under the NIST Cryptographic Module Validation Program or are update streams of such modules.", + "attributes": { + "Ruleset": "CMU: Cryptographic Module Use", + "Subset": "CSO: Cloud Service Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CMU-CSO-UVM", + "name": "Using Validated Cryptographic Modules", + "description": "Providers with Class C Certifications SHOULD use cryptographic modules or update streams of cryptographic modules with active validations under the NIST Cryptographic Module Validation Program when using cryptographic services to protect federal customer data.", + "attributes": { + "Ruleset": "CMU: Cryptographic Module Use", + "Subset": "CSO: Cloud Service Provider Responsibilities", + "Force": "SHOULD" + }, + "checks": { + "aws": [ + "elbv2_listener_fips_tls_enabled", + "transfer_server_fips_security_policy_enabled" + ], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CMU-CSO-CAT", + "name": "Configuration of Agency Tenants", + "description": "Providers SHOULD configure agency tenants by default to use cryptographic services that use cryptographic modules or update streams of cryptographic modules with active validations under the NIST Cryptographic Module Validation Program when such modules are available.", + "attributes": { + "Ruleset": "CMU: Cryptographic Module Use", + "Subset": "CSO: Cloud Service Provider Responsibilities", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CPO-CSO-OVR", + "name": "Overview of the Cloud Service Offering", + "description": "Providers MUST supply a Certification Package Overview within their FedRAMP Certification Package, in both human-readable and JSON formats, that includes at least all of the information required by the following rules: Certification Package Overview: CPO-CSO-MTD (Certification Package Overview Metadata); Certification Data Sharing: CDS-CSO-PUB (Public Information); Certification Data Sharing: CDS-CSO-SVC (Public Service List); Certification Data Sharing: CDS-CSO-IRP (Include Relevant Policies); Minimum Assessment Scope: MAS-CSO-IIR (Identify Information Resources); Minimum Assessment Scope: MAS-CSO-FLO (Information Flows and Security Categories); Minimum Assessment Scope: MAS-CSO-TPR (Third-Party Information Resources); Using Cryptographic Modules: CMU-CSO-CMD (Cryptographic Module Documentation); Independent Verification and Validation: IVV-CSO-ICP (Inclusion in Certification Package)", + "attributes": { + "Ruleset": "CPO: Certification Package Overview", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CPO-CSO-MTD", + "name": "Certification Package Overview Metadata", + "description": "Providers MUST also include the following basic metadata in their Certification Package Overview: Name, title, and contact information of official that is responsible and accountable for the FedRAMP Certification Package; Version; Date and time of last update; Source of update", + "attributes": { + "Ruleset": "CPO: Certification Package Overview", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CPO-CSO-OSA", + "name": "Overall Summary of Assessment in Certification Package", + "description": "Providers seeking Class C Certification MUST also include the overall summary of their FedRAMP independent assessment, supplied by the assessor per IVV-IAS-OSA (Overall Summary of Assessment), in their Certification Package Overview.", + "attributes": { + "Ruleset": "CPO: Certification Package Overview", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CPO-CSX-CPM", + "name": "Certification Package Maintenance for 20x", + "description": "Providers with 20x Class C Certifications MUST persistently maintain their FedRAMP Certification Package to ensure it is up to date and complete at least once every 2 weeks.", + "attributes": { + "Ruleset": "CPO: Certification Package Overview", + "Subset": "CSX: 20x-Specific Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "FRC-CSO-FCP", + "name": "FedRAMP Certification Profile", + "description": "Providers MUST identify a target FedRAMP Certification Profile and apply all relevant FedRAMP Practices to the cloud service offering.", + "attributes": { + "Ruleset": "FRC: FedRAMP Certification", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "FRC-CSO-PKG", + "name": "FedRAMP Certification Package", + "description": "Providers seeking a Certification MUST supply a complete FedRAMP Certification Package to FedRAMP for initial certification; the FedRAMP Certification Package MUST include at least the following information: Information about the Cloud Service Offering following CPO-CSO-OVR (Overview of the Cloud Service Offering); Implementation, Validation, and Assessment information for each relevant FedRAMP requirement/control/ksi as defined in SDR-CSO-FRR (FedRAMP Rules); A real or example Ongoing Certification Report following CCM-OCR-AVL (Report Availability)", + "attributes": { + "Ruleset": "FRC: FedRAMP Certification", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "FRC-CSO-JSN", + "name": "FedRAMP JSON Schemas", + "description": "Providers MUST supply machine-readable information in JSON documents that are valid against the corresponding JSON schema when a rule contains a FedRAMP JSON schema, UNLESS otherwise specified in the rule.", + "attributes": { + "Ruleset": "FRC: FedRAMP Certification", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "FRC-CSO-MRA", + "name": "Maintain Responsibility and Accountability", + "description": "Providers MUST maintain responsibility and accountability for the accuracy and completeness of all information in the FedRAMP Certification Package, especially when they engage a third party (such as an independent assessor, advisory service, or external tools) to supply information on their behalf.", + "attributes": { + "Ruleset": "FRC: FedRAMP Certification", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "FRC-CSO-POP", + "name": "Pick One Program Certification Type", + "description": "Providers MUST NOT seek both FedRAMP Rev5 Program Certification and FedRAMP 20x Program Certification for the same cloud service offering; pick one type.", + "attributes": { + "Ruleset": "FRC: FedRAMP Certification", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST NOT" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "FRC-APP-MLF", + "name": "Marketplace Listing First", + "description": "Providers MUST be listed in the FedRAMP Marketplace before applying for FedRAMP Certification, including: FedRAMP Marketplace: MKT-CSO-MLR (Marketplace Listing Requirements); FedRAMP Marketplace: MKT-CSO-PML (Provider Marketplace Listing Requests); FedRAMP Marketplace: MKT-IIP-AGU (Agency Use Cases); FedRAMP Marketplace: MKT-IIP-DCP (Demonstrating Continuous Progress)", + "attributes": { + "Ruleset": "FRC: FedRAMP Certification", + "Subset": "APP: Applying for FedRAMP Certification", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "FRC-APP-AFC", + "name": "Applying for FedRAMP Certification", + "description": "Providers MUST complete the FedRAMP Certification Application Form in full to request an initial assessment by FedRAMP.", + "attributes": { + "Ruleset": "FRC: FedRAMP Certification", + "Subset": "APP: Applying for FedRAMP Certification", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "FRC-APP-FCP", + "name": "Fresh FedRAMP Certification Package", + "description": "Providers MUST supply a fresh initial FedRAMP Certification Package that shows the current status of the cloud service offering as verified and validated by the provider within the previous 7 days.", + "attributes": { + "Ruleset": "FRC: FedRAMP Certification", + "Subset": "APP: Applying for FedRAMP Certification", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "FRC-APP-FIA", + "name": "Fresh Independent Assessment", + "description": "Providers seeking Class C Certification MUST supply a fresh initial FedRAMP independent assessment that was completed by a FedRAMP Recognized independent assessment service within the previous 3 months.", + "attributes": { + "Ruleset": "FRC: FedRAMP Certification", + "Subset": "APP: Applying for FedRAMP Certification", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "FRC-APP-NTP", + "name": "No Third-Party Applicants", + "description": "Providers MUST NOT use a third party to apply for a FedRAMP Certification on their behalf; this includes independent assessment services.", + "attributes": { + "Ruleset": "FRC: FedRAMP Certification", + "Subset": "APP: Applying for FedRAMP Certification", + "Force": "MUST NOT" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "FRC-APP-USA", + "name": "Updating Stale Assessments", + "description": "Providers MAY freshen a stale initial independent verification and validation assessment by having a FedRAMP Recognized independent assessment service review any changes between the original assessment and the current status of the cloud service offering in place of a full re-assessment, UNLESS the stale assessment is more than 9 months old.", + "attributes": { + "Ruleset": "FRC: FedRAMP Certification", + "Subset": "APP: Applying for FedRAMP Certification", + "Force": "MAY" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "FRC-CSX-VVK", + "name": "Automated Verification and Validation of Key Security Indicators", + "description": "Providers seeking 20x Class C Certification MUST implement automated methods to persistently verify and validate the accuracy and completeness of Key Security Indicators with at least 2 automated methods for each Key Security Indicator.", + "attributes": { + "Ruleset": "FRC: FedRAMP Certification", + "Subset": "CSX: 20x-Specific Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "FRC-CSX-MOT", + "name": "Metrics Over Time for Key Security Indicators", + "description": "Providers seeking 20x Class C Certification MUST supply historical metrics including status from persistent validation over at least the past 6 months for all Key Security Indicators.", + "attributes": { + "Ruleset": "FRC: FedRAMP Certification", + "Subset": "CSX: 20x-Specific Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "FRC-CSX-VVR", + "name": "Automated Verification and Validation of FedRAMP Rules", + "description": "Providers seeking 20x Class C Certification SHOULD implement automated methods to persistently verify and validate the accuracy and completeness of the Security Decision Record for FedRAMP rules when applicable.", + "attributes": { + "Ruleset": "FRC: FedRAMP Certification", + "Subset": "CSX: 20x-Specific Provider Responsibilities", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "FRC-CSX-MAS", + "name": "Application within MAS", + "description": "Providers SHOULD apply ALL Key Security Indicators to ALL aspects of their cloud service offering that are within the FedRAMP Minimum Assessment Scope.", + "attributes": { + "Ruleset": "FRC: FedRAMP Certification", + "Subset": "CSX: 20x-Specific Provider Responsibilities", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "IEC-CSO-EFR", + "name": "Evaluate FedRAMP Reportability", + "description": "Providers MUST promptly evaluate incidents to determine if they affect confidentiality or integrity of federal customer data or are likely to affect confidentiality or integrity of federal customer data; such incidents are FedRAMP Reportable Incidents and must be reported following the FedRAMP Incident Evaluation and Communication rules.", + "attributes": { + "Ruleset": "IEC: Incident Evaluation and Communication", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "IEC-CSO-DPR", + "name": "Default PAIN Rating", + "description": "Providers MUST treat FedRAMP Reportable Incidents as if they have a Potential Agency Impact N-rating (PAIN) of 5 UNLESS they promptly estimate the PAIN rating following the rule in IEC-CSO-EFI (Estimate Federal Impact).", + "attributes": { + "Ruleset": "IEC: Incident Evaluation and Communication", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "IEC-CSO-IIR", + "name": "Initial Incident Report", + "description": "Providers with Class C Certifications MUST responsibly notify all affected parties after identifying FedRAMP Reportable Incidents by providing an Initial Incident Report with as much of the following information that is available at the time of reporting and/or the current relevant status for each item: Contact information for the federal incident response coordinator.; Provider's internally assigned tracking identifier; Description of the incident; Timeline of the incident, including start time, time and source of detection, time of completed FedRAMP Reportable Incident evaluation, and other major incident milestones determined by the provider; Historically and currently estimated Potential Agency Impact N-rating (PAIN) of the incident, including an explanation of the evaluation following the requirements in IEC-CSO-EFI (Estimate Federal Impact) (if applicable); Functional impact to federal agency customers (include impact to confidentiality and/or integrity and the impacted federal customer data types); Estimated recovery plan, milestones, and timelines; List of likely affected customer agencies; N1 Initial Incident Report: 1 bizdays; N2 Initial Incident Report: 24 hours; N3 Initial Incident Report: 1 hours; N4 Initial Incident Report: 1 hours; N5 Initial Incident Report: 1 hours", + "attributes": { + "Ruleset": "IEC: Incident Evaluation and Communication", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "IEC-CSO-OIR", + "name": "Ongoing Incident Reports", + "description": "Providers with Class C Certifications MUST responsibly notify all affected parties of ongoing activity as new information becomes available during incident response for FedRAMP Reportable Incidents, including updates (or lack of updates) to all previously reported information and as much of the following additional information that is available and/or the current relevant status for each item: Observed incident activity; Indicators of compromise; Related Common Vulnerabilities and Exposures (CVE) identifier, if applicable; Root cause; Response and recovery activities; N1 Ongoing Incident Report: 1 bizdays; N2 Ongoing Incident Report: 24 hours; N3 Ongoing Incident Report: 6 hours; N4 Ongoing Incident Report: 6 hours; N5 Ongoing Incident Report: 6 hours", + "attributes": { + "Ruleset": "IEC: Incident Evaluation and Communication", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "IEC-CSO-FIR", + "name": "Final Incident Report", + "description": "Providers with Class C Certifications MUST responsibly notify all affected parties by providing a Final Incident Report once the incident has been resolved and recovery is complete, including final updates to all previously reported information. N1 Final Incident Report: 1 bizdays; N2 Final Incident Report: 1 bizdays; N3 Final Incident Report: 6 hours; N4 Final Incident Report: 6 hours; N5 Final Incident Report: 6 hours", + "attributes": { + "Ruleset": "IEC: Incident Evaluation and Communication", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "IEC-CSO-EFI", + "name": "Estimate Federal Impact", + "description": "Providers SHOULD promptly estimate the likely adverse impact of an incident on agency customers to assign a Potential Agency Impact N-rating; this step is called Incident Rating. N1 for a likely minimal customer effect on 1 or more agencies.; N2 for a likely narrow customer effect on 1 or more agencies.; N3 for a likely disruptive customer effect on 1 agency.; N4 for a likely debilitating customer effect on 1 agency or a likely disruptive customer effect on more than 1 agency.; N5 for a likely debilitating customer effect on more than 1 agency.", + "attributes": { + "Ruleset": "IEC: Incident Evaluation and Communication", + "Subset": "CSO: General Provider Responsibilities", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "IEC-CSO-AIR", + "name": "Automated Incident Reporting", + "description": "Providers SHOULD use automation to minimize human intervention in the process of reporting FedRAMP Reportable Incidents to all affected parties.", + "attributes": { + "Ruleset": "IEC: Incident Evaluation and Communication", + "Subset": "CSO: General Provider Responsibilities", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "IVV-CSO-FIA", + "name": "FedRAMP Independent Assessments", + "description": "Providers with Class C Certifications MUST persistently complete an independent verification and validation assessment of all applicable FedRAMP rules with a FedRAMP Recognized independent assessment service OR FedRAMP at least once per year; this is a FedRAMP independent assessment.", + "attributes": { + "Ruleset": "IVV: Independent Verification and Validation", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "IVV-CSO-SEI", + "name": "Supply Evidence of Implementation", + "description": "Providers MUST supply evidence to all necessary assessors of the implementation of the measures that have been documented to meet FedRAMP Practices; this evidence is the result of verification.", + "attributes": { + "Ruleset": "IVV: Independent Verification and Validation", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "IVV-CSO-SEE", + "name": "Supply Evidence of Effectiveness", + "description": "Providers MUST supply evidence to all necessary assessors of the effectiveness of the measures that have been implemented to meet FedRAMP Practices; this evidence is the result of validation.", + "attributes": { + "Ruleset": "IVV: Independent Verification and Validation", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "IVV-CSO-ICP", + "name": "Inclusion in Certification Package", + "description": "Providers MUST supply the results of FedRAMP independent assessments in their FedRAMP Certification Package without inappropriate modification.", + "attributes": { + "Ruleset": "IVV: Independent Verification and Validation", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "IVV-CSO-DUS", + "name": "Document Use of Representative Samples", + "description": "Providers MUST document and explain the use of representative samples during verification and validation when using representative samples as allowed by IVV-CSO-USR (Use Representative Samples).", + "attributes": { + "Ruleset": "IVV: Independent Verification and Validation", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "IVV-CSO-STE", + "name": "Supply Technical Explanations", + "description": "Providers SHOULD supply all necessary assessors with technical explanations, demonstrations, and other relevant supporting information about the technical capabilities they employ to address FedRAMP rules; this SHOULD be supplied as necessary to ensure the assessor can effectively complete verification and validation.", + "attributes": { + "Ruleset": "IVV: Independent Verification and Validation", + "Subset": "CSO: General Provider Responsibilities", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "IVV-CSO-USR", + "name": "Use Representative Samples", + "description": "Providers MAY use representative samples as appropriate during verification and validation.", + "attributes": { + "Ruleset": "IVV: Independent Verification and Validation", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MAY" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "IVV-CSO-RAA", + "name": "Receiving Assessor Advice", + "description": "Providers MAY ask for and accept advice from their assessor during assessment regarding techniques and procedures that will improve their security posture or the effectiveness, clarity, and accuracy of their verification, validation and reporting procedures, UNLESS doing so is likely to compromise the objectivity and integrity of the assessment.", + "attributes": { + "Ruleset": "IVV: Independent Verification and Validation", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MAY" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "IVV-CSX-AIA", + "name": "Annual Independent Assessments for 20x", + "description": "Providers with 20x Class C Certifications MUST include all Key Security Indicators in a FedRAMP independent assessment at least once per year.", + "attributes": { + "Ruleset": "IVV: Independent Verification and Validation", + "Subset": "CSX: 20x-Specific Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "MAS-CSO-IIR", + "name": "Identify Information Resources", + "description": "Providers MUST identify a set of information resources to assess for FedRAMP Certification that includes all information resources that are likely to handle federal customer data or likely to impact the confidentiality, integrity, or availability of federal customer data handled by the cloud service offering; this set of information resources is the cloud service offering.", + "attributes": { + "Ruleset": "MAS: Minimum Assessment Scope", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [ + "config_recorder_all_regions_enabled", + "resourceexplorer2_indexes_found" + ], + "azure": [], + "gcp": [ + "iam_cloud_asset_inventory_enabled" + ], + "kubernetes": [], + "m365": [] + }, + "config_requirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + } + ] + }, + { + "id": "MAS-CSO-FLO", + "name": "Information Flows and Security Categories", + "description": "Providers MUST clearly identify, document, and explain information flows and security categories for ALL information resources or sets of information resources in the cloud service offering.", + "attributes": { + "Ruleset": "MAS: Minimum Assessment Scope", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "MAS-CSO-TPR", + "name": "Third-Party Information Resources", + "description": "Providers MUST address the potential impact to federal customer data from third-party information resources used by the cloud service offering, ONLY IF MAS-CSO-IIR (Identify Information Resources) APPLIES, by documenting the following information about each applicable third-party information resource: General usage and configuration; Explanation or justification for use; Mitigation measures in place to reduce the potential impact to federal customer data; Compensating controls in place to reduce the potential impact to federal customer data", + "attributes": { + "Ruleset": "MAS: Minimum Assessment Scope", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "MAS-CSO-MDI", + "name": "Metadata Inclusion", + "description": "Providers MUST include metadata (including metadata about federal customer data) in the Minimum Assessment Scope ONLY IF MAS-CSO-IIR (Identify Information Resources) APPLIES.", + "attributes": { + "Ruleset": "MAS: Minimum Assessment Scope", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "MAS-CSO-SUP", + "name": "Supplemental Information", + "description": "Providers MAY include additional materials about other information resources that are not part of the cloud service offering in a FedRAMP Certification Package supplement; these resources will not be FedRAMP Certified and MUST be clearly marked and separated from the cloud service offering.", + "attributes": { + "Ruleset": "MAS: Minimum Assessment Scope", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MAY" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "MKT-CSO-MLR", + "name": "Marketplace Listing Requirements", + "description": "Providers MUST address at least these FedRAMP rules to apply for a new FedRAMP Marketplace listing OR to request updates to an existing listing: Certification Data Sharing: CDS-CSO-PUB (Public Information)", + "attributes": { + "Ruleset": "MKT: Marketplace Listing", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "MKT-CSO-PML", + "name": "Provider Marketplace Listing Requests", + "description": "Providers MUST notify FedRAMP using the FedRAMP Marketplace Providing Listing Request Form to request a listing in the FedRAMP Marketplace.", + "attributes": { + "Ruleset": "MKT: Marketplace Listing", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "MKT-IIP-AGU", + "name": "Agency Use Cases", + "description": "Providers MUST demonstrate that a cloud service offering is intended for one of the following use cases: Direct Use: The product will be used directly by agency customers for integration into a federal information system that falls within the scope of 44 USC § 3506 and will receive an agency Authorization to Operate.; Indirect Use: The product will be included as a third-party information resource in other cloud service offerings that are directly used by agency customers.", + "attributes": { + "Ruleset": "MKT: Marketplace Listing", + "Subset": "IIP: Provider Responsibilities for Initial Implementation Phase Listings", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "MKT-IIP-DCP", + "name": "Demonstrating Continuous Progress", + "description": "Providers MUST demonstrate continuous progress towards a FedRAMP Certification, documented in their Trust Center or website and updated at least quarterly; progress is measured by the provider against documented goals and milestones.", + "attributes": { + "Ruleset": "MKT: Marketplace Listing", + "Subset": "IIP: Provider Responsibilities for Initial Implementation Phase Listings", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "MKT-IIP-DLA", + "name": "Deadline for Assessment", + "description": "Providers MUST demonstrate that an assessment for a FedRAMP Certification Class B, C, or D has been scheduled within 2 years of initial listing in the Initial Implementation Phase.", + "attributes": { + "Ruleset": "MKT: Marketplace Listing", + "Subset": "IIP: Provider Responsibilities for Initial Implementation Phase Listings", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SCG-CSO-RSC", + "name": "Recommended Secure Configuration", + "description": "Providers MUST create, maintain, and make available recommendations for securely configuring their cloud services (the Secure Configuration Guide) that includes at least the following information: Required: Instructions on how to securely access, configure, operate, and decommission top-level administrative accounts that control enterprise access to the entire cloud service offering.; Required: Explanations of security-related settings that can be operated only by top-level administrative accounts and their security implications.; Recommended: Explanations of security-related settings that can be operated only by privileged accounts and their security implications.", + "attributes": { + "Ruleset": "SCG: Secure Configuration Guide", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SCG-CSO-AUP", + "name": "Use Instructions", + "description": "Providers MUST include instructions in the FedRAMP Certification Package that explain how to obtain and use the Secure Configuration Guide.", + "attributes": { + "Ruleset": "SCG: Secure Configuration Guide", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SCG-CSO-PUB", + "name": "Public Secure Configuration Guidance", + "description": "Providers SHOULD make the Secure Configuration Guide available publicly.", + "attributes": { + "Ruleset": "SCG: Secure Configuration Guide", + "Subset": "CSO: General Provider Responsibilities", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SCG-CSO-SDF", + "name": "Secure Defaults", + "description": "Providers SHOULD set all settings to their recommended secure defaults for top-level administrative accounts and privileged accounts when initially provisioned.", + "attributes": { + "Ruleset": "SCG: Secure Configuration Guide", + "Subset": "CSO: General Provider Responsibilities", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SCG-ENH-CMP", + "name": "Comparison Capability", + "description": "Providers SHOULD offer the capability to compare all current settings for top-level administrative accounts and privileged accounts to the recommended secure defaults.", + "attributes": { + "Ruleset": "SCG: Secure Configuration Guide", + "Subset": "ENH: Enhanced Capabilities", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SCG-ENH-EXP", + "name": "Export Capability", + "description": "Providers SHOULD offer the capability to export all security settings in a machine-readable format.", + "attributes": { + "Ruleset": "SCG: Secure Configuration Guide", + "Subset": "ENH: Enhanced Capabilities", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SCG-ENH-API", + "name": "API Capability", + "description": "Providers SHOULD offer the capability to view and adjust security settings via an API or similar capability.", + "attributes": { + "Ruleset": "SCG: Secure Configuration Guide", + "Subset": "ENH: Enhanced Capabilities", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SCG-ENH-MRG", + "name": "Machine-Readable Guidance", + "description": "Providers SHOULD also provide the Secure Configuration Guide in a machine-readable format that can be used by customers or third-party tools to compare against current settings.", + "attributes": { + "Ruleset": "SCG: Secure Configuration Guide", + "Subset": "ENH: Enhanced Capabilities", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SCG-ENH-VRH", + "name": "Versioning and Release History", + "description": "Providers SHOULD provide versioning and a release history for recommended secure default settings for top-level administrative accounts and privileged accounts as they are adjusted over time.", + "attributes": { + "Ruleset": "SCG: Secure Configuration Guide", + "Subset": "ENH: Enhanced Capabilities", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SCN-CSO-EVA", + "name": "Evaluate Changes", + "description": "Providers MUST evaluate all potential significant changes to determine the type of significant change and follow the appropriate Significant Change Notification rules. Is it a significant change? --> Continue evaluation and follow the Significant Change Notification rules.; If it is, is it an FedRAMP Certification class change? --> This requires a new assessment and cannot be done under the Significant Change Notification rules.; If it is not, is it a routine recurring change? --> Follow the Routine Recurring Change rules (SCN-RTR Routine Recurring Changes).; If it is not, is it a transformative change? --> Follow the Transformative Change rules (SCN-TRF Transformative Changes).; If it is not, then it is an adaptive change --> Follow the Adaptive Change rules (SCN-ADP Adaptive Changes).", + "attributes": { + "Ruleset": "SCN: Significant Change Notification", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SCN-CSO-MAR", + "name": "Maintain Audit Records", + "description": "Providers MUST maintain auditable records of the significant change evaluation activities required by SCN-CSO-EVA (Evaluate Changes) and make them available to FedRAMP as requested.", + "attributes": { + "Ruleset": "SCN: Significant Change Notification", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SCN-CSO-INF", + "name": "Required Information", + "description": "Providers MUST include at least the following information in Significant Change Notifications: Service Offering FedRAMP ID; Assessor Name (if applicable); Related Vulnerability (if applicable); Significant Change type and explanation of categorization; Short description of change; Reason for change; Summary of customer impact, including changes to services and customer configuration responsibilities; Plan and timeline for the change, including for the verification, assessment, and/or validation of impacted Key Security Indicators or Rev5 Controls; Copy of the business or security impact analysis; Name and title of approver", + "attributes": { + "Ruleset": "SCN: Significant Change Notification", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SCN-CSO-HIS", + "name": "Historical Notifications", + "description": "Providers MUST keep 12 months of historical Significant Change Notifications available with their FedRAMP Certification Data.", + "attributes": { + "Ruleset": "SCN: Significant Change Notification", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SCN-CSO-HRM", + "name": "Human and Machine-Readable Notifications", + "description": "Providers MUST make ALL Significant Change Notifications and related audit records available in human-readable and JSON formats.", + "attributes": { + "Ruleset": "SCN: Significant Change Notification", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SCN-CSO-ARI", + "name": "Additional Relevant Information", + "description": "Providers MAY include additional relevant information in Significant Change Notifications.", + "attributes": { + "Ruleset": "SCN: Significant Change Notification", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MAY" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SCN-CSO-NOM", + "name": "Notification Mechanisms", + "description": "Providers MAY notify necessary parties in a variety of ways as long as the mechanism for notification is clearly documented in the FedRAMP Certification Package and easily accessible.", + "attributes": { + "Ruleset": "SCN: Significant Change Notification", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MAY" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SCN-CSO-EMG", + "name": "Emergency Changes", + "description": "Providers MAY execute significant changes (including transformative changes) during an emergency or incident without following the Significant Change Notification rules in advance. In such emergencies, providers MUST follow all relevant procedures, notify all necessary parties, retroactively provide all Significant Change Notification materials, and complete appropriate assessment after the incident.", + "attributes": { + "Ruleset": "SCN: Significant Change Notification", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MAY" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SCN-ADP-NTF", + "name": "Notification Requirements", + "description": "Providers MUST notify all necessary parties within 10 business days after finishing adaptive changes, also including the following information: Summary of any new risks identified and/or vulnerabilities resulting from the change (if applicable)", + "attributes": { + "Ruleset": "SCN: Significant Change Notification", + "Subset": "ADP: Adaptive Changes", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SCN-RTR-NNR", + "name": "No Notification Requirements", + "description": "Providers SHOULD NOT make formal Significant Change Notifications for routine recurring changes; this type of change is exempted from notification requirements.", + "attributes": { + "Ruleset": "SCN: Significant Change Notification", + "Subset": "RTR: Routine Recurring Changes", + "Force": "SHOULD NOT" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SCN-TRF-NIP", + "name": "Notification of Initial Plans", + "description": "Providers MUST notify all necessary parties of initial plans for transformative changes at least 30 business days before starting transformative changes, including a summary of any likely security impacts or changes in risk.", + "attributes": { + "Ruleset": "SCN: Significant Change Notification", + "Subset": "TRF: Transformative Changes", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SCN-TRF-NFP", + "name": "Notification of Final Plans", + "description": "Providers MUST notify all necessary parties of final plans for transformative changes at least 10 business days before starting transformative changes, including updates to all previously sent information.", + "attributes": { + "Ruleset": "SCN: Significant Change Notification", + "Subset": "TRF: Transformative Changes", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SCN-TRF-NAF", + "name": "Notification After Finishing", + "description": "Providers MUST notify all necessary parties within 5 business days after finishing transformative changes, including updates to all previously sent information.", + "attributes": { + "Ruleset": "SCN: Significant Change Notification", + "Subset": "TRF: Transformative Changes", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SCN-TRF-NAV", + "name": "Notification After Verification", + "description": "Providers MUST notify all necessary parties within 5 business days after completing the verification, assessment, and/or validation of transformative changes, also including the following information: Updates to all previously sent information; Summary of any new risks identified and/or vulnerabilities resulting from the change (if applicable); Copy of the security assessment report (if applicable)", + "attributes": { + "Ruleset": "SCN: Significant Change Notification", + "Subset": "TRF: Transformative Changes", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SCN-TRF-UPD", + "name": "Update Documentation", + "description": "Providers MUST publish updated service documentation and other materials to reflect transformative changes within 30 business days after finishing transformative changes.", + "attributes": { + "Ruleset": "SCN: Significant Change Notification", + "Subset": "TRF: Transformative Changes", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SCN-TRF-TPR", + "name": "Third-Party Review", + "description": "Providers SHOULD engage a third-party assessor to review the scope and impact of the planned change before starting transformative changes if human validation is necessary; such reviews SHOULD be limited to security decisions that require human validation.", + "attributes": { + "Ruleset": "SCN: Significant Change Notification", + "Subset": "TRF: Transformative Changes", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SDR-CSO-FRR", + "name": "FedRAMP Rules", + "description": "Providers MUST supply a Security Decision Record, in both human-readable and JSON formats, that includes at least all of the following information for each applicable FedRAMP rule: Explanation of how the rule is followed, or an explanation of the reason and resulting risk to customers for not following the rule.; Verification that the implementation is appropriate for the rule, or that the reason for not implementing is accepted by a senior official.; Validation that the implementation is in place and working as intended, or that the reason for not implementing is accepted by a senior official.; Independent verification.; Independent validation.; Any responses or clarifications to the comments in the independent verification or validation.; Rule-specific artifacts (if applicable).", + "attributes": { + "Ruleset": "SDR: Security Decision Record", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SDR-CSO-MTD", + "name": "Security Decision Record Metadata", + "description": "Providers MUST also include the following basic metadata in their Security Decision Record: Version; Date and time of last update; Source of update", + "attributes": { + "Ruleset": "SDR: Security Decision Record", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SDR-CSX-KSI", + "name": "Key Security Indicators", + "description": "Providers MUST also include short and simple high-level summaries of at least the following for each applicable Key Security Indicator: Explanation of measures (and their objectives) that demonstrate the Key Security Indicator, or an explanation of the reason and resulting risk to customers for not having measures available for that Key Security Indicator.; Explanation of the cycle for any measures that are implemented persistently (if applicable).; Verification that the measures demonstrate the Key Security Indicator, or that the reason for not having them is accepted.; Verification that the automation in place is accurate and sufficient to demonstrate appropriate measures for the Key Security Indicator, or that automation is not necessary for each measure.; Validation that the measures are accurately produced and are in place and working as intended, or that the reason for not having them is valid.", + "attributes": { + "Ruleset": "SDR: Security Decision Record", + "Subset": "CSX: 20x-Specific Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SDR-CSX-KMT", + "name": "Key Security Indicator Metrics", + "description": "Providers with 20x Class C Certifications MUST also include historical metrics in their Security Decision Record, supplying at least the following information for each applicable Key Security Indicator: Summary of each metric over the past 30 days; Summary of metric up to the past year (where available); All daily metric data up to the past year (where available)", + "attributes": { + "Ruleset": "SDR: Security Decision Record", + "Subset": "CSX: 20x-Specific Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VDR-CSO-DET", + "name": "Vulnerability Detection", + "description": "Providers MUST systematically, persistently, and promptly discover and identify vulnerabilities within their cloud service offering using appropriate techniques such as assessment, scanning, threat intelligence, vulnerability disclosure mechanisms, bug bounties, penetration testing, incident response, automated control testing, supply chain monitoring, and other relevant capabilities; this process is called vulnerability detection. Vulnerability detection includes persistently verifying and validating that information resources and processes are operating as intended and documented for FedRAMP Practices.", + "attributes": { + "Ruleset": "VDR: Vulnerability Detection and Response", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [ + "ecr_registry_enhanced_scanning_enabled", + "ecr_registry_scan_images_on_push_enabled", + "inspector2_is_enabled", + "securityhub_enabled" + ], + "azure": [ + "defender_auto_provisioning_vulnerabilty_assessments_machines_on", + "defender_container_images_scan_enabled", + "defender_ensure_defender_cspm_is_on", + "sqlserver_va_periodic_recurring_scans_enabled" + ], + "gcp": [ + "gcr_container_scanning_enabled" + ], + "kubernetes": [], + "m365": [] + }, + "config_requirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + } + ] + }, + { + "id": "VDR-CSO-RES", + "name": "Vulnerability Response", + "description": "Providers MUST systematically, persistently, and promptly track, evaluate, monitor, mitigate, remediate, assess exploitation of, report, and otherwise manage all detected vulnerabilities within their cloud service offering; this process is called vulnerability response.", + "attributes": { + "Ruleset": "VDR: Vulnerability Detection and Response", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [ + "ecr_repositories_scan_vulnerabilities_in_latest_image", + "inspector2_active_findings_exist" + ], + "azure": [ + "defender_container_images_resolved_vulnerabilities" + ], + "gcp": [], + "kubernetes": [], + "m365": [] + }, + "config_requirements": [ + { + "Check": "ecr_repositories_scan_vulnerabilities_in_latest_image", + "ConfigKey": "ecr_repository_vulnerability_minimum_severity", + "Operator": "eq", + "Value": "MEDIUM", + "Provider": "aws" + } + ] + }, + { + "id": "VDR-CSO-FAV", + "name": "Failures Are Vulnerabilities", + "description": "Providers MUST treat problems or failures with their vulnerability detection and response processes as vulnerabilities.", + "attributes": { + "Ruleset": "VDR: Vulnerability Detection and Response", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [ + "inspector2_coverage_scan_status_active" + ], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VDR-CSO-DFR", + "name": "Design For Resilience", + "description": "Providers SHOULD make design and architecture decisions for their cloud service offering that mitigate the risk of vulnerabilities by default AND decrease the risk and complexity of vulnerability detection and response.", + "attributes": { + "Ruleset": "VDR: Vulnerability Detection and Response", + "Subset": "CSO: General Provider Responsibilities", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VDR-CSO-ADT", + "name": "Automate Detection", + "description": "Providers SHOULD use automated services to improve and streamline vulnerability detection and response.", + "attributes": { + "Ruleset": "VDR: Vulnerability Detection and Response", + "Subset": "CSO: General Provider Responsibilities", + "Force": "SHOULD" + }, + "checks": { + "aws": [ + "ecr_registry_enhanced_scanning_enabled", + "ecr_registry_scan_images_on_push_enabled", + "inspector2_is_enabled" + ], + "azure": [ + "defender_auto_provisioning_vulnerabilty_assessments_machines_on", + "defender_container_images_scan_enabled", + "sqlserver_va_periodic_recurring_scans_enabled" + ], + "gcp": [ + "gcr_container_scanning_enabled" + ], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VDR-CSO-DAC", + "name": "Detect After Changes", + "description": "Providers SHOULD automatically perform vulnerability detection on representative samples of new or significantly changed information resources.", + "attributes": { + "Ruleset": "VDR: Vulnerability Detection and Response", + "Subset": "CSO: General Provider Responsibilities", + "Force": "SHOULD" + }, + "checks": { + "aws": [ + "ecr_registry_scan_images_on_push_enabled", + "inspector2_is_enabled" + ], + "azure": [ + "defender_container_images_scan_enabled" + ], + "gcp": [ + "gcr_container_scanning_enabled" + ], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VDR-CSO-MSP", + "name": "Maintain Security", + "description": "Providers SHOULD NOT weaken the security of information resources to facilitate vulnerability scanning, detection, or assessment activities.", + "attributes": { + "Ruleset": "VDR: Vulnerability Detection and Response", + "Subset": "CSO: General Provider Responsibilities", + "Force": "SHOULD NOT" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VDR-CSO-AKE", + "name": "Avoid KEVs", + "description": "Providers SHOULD NOT deploy or otherwise activate new machine-based information resources with Known Exploited Vulnerabilities.", + "attributes": { + "Ruleset": "VDR: Vulnerability Detection and Response", + "Subset": "CSO: General Provider Responsibilities", + "Force": "SHOULD NOT" + }, + "checks": { + "aws": [ + "inspector2_active_findings_no_known_exploited_vulnerabilities" + ], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VDR-CSO-SIR", + "name": "Sampling", + "description": "Providers MAY sample effectively identical information resources, especially machine-based information resources, when performing vulnerability detection UNLESS doing so would decrease the efficiency or effectiveness of vulnerability detection.", + "attributes": { + "Ruleset": "VDR: Vulnerability Detection and Response", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MAY" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VDR-TFR-NMV", + "name": "Non-Machine Verification and Validation", + "description": "Providers MUST verify and validate the status of non-machine-based information resources at least once every 3 months.", + "attributes": { + "Ruleset": "VDR: Vulnerability Detection and Response", + "Subset": "TFR: Timeframes", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VDR-TFR-PDD", + "name": "Persistent Drift Detection", + "description": "Providers with Class C Certifications SHOULD persistently perform vulnerability detection on all information resources that are likely to drift, at least once every 14 days.", + "attributes": { + "Ruleset": "VDR: Vulnerability Detection and Response", + "Subset": "TFR: Timeframes", + "Force": "SHOULD" + }, + "checks": { + "aws": [ + "inspector2_coverage_recently_scanned", + "inspector2_is_enabled", + "securityhub_enabled" + ], + "azure": [ + "defender_ensure_defender_cspm_is_on" + ], + "gcp": [], + "kubernetes": [], + "m365": [] + }, + "config_requirements": [ + { + "Check": "inspector2_coverage_recently_scanned", + "ConfigKey": "inspector2_max_days_since_last_scan", + "Operator": "lte", + "Value": 14, + "Provider": "aws" + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + } + ] + }, + { + "id": "VDR-TFR-PCD", + "name": "Persistently Complete Detection", + "description": "Providers with Class C Certifications SHOULD persistently perform vulnerability detection on all information resources that are NOT likely to drift, at least once every month.", + "attributes": { + "Ruleset": "VDR: Vulnerability Detection and Response", + "Subset": "TFR: Timeframes", + "Force": "SHOULD" + }, + "checks": { + "aws": [ + "inspector2_coverage_recently_scanned" + ], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + }, + "config_requirements": [ + { + "Check": "inspector2_coverage_recently_scanned", + "ConfigKey": "inspector2_max_days_since_last_scan", + "Operator": "lte", + "Value": 30, + "Provider": "aws" + } + ] + }, + { + "id": "VDR-TFR-PVR", + "name": "Mitigation and Remediation Expectations", + "description": "Providers with Class C Certifications SHOULD partially mitigate vulnerabilities, fully mitigate vulnerabilities, or remediate vulnerabilities to a lower Potential Agency Impact N-rating within the timeframes from evaluation shown below, factoring for the current Potential Agency Impact N-rating as defined in VER-EVA-EPA (Estimate Potential Agency Impact), internet reachability, and likely exploitability: N2 Internet-Reachable Vulnerability + Likely Exploitable Vulnerability: 48 days; N2 Not Internet-Reachable Vulnerability + Likely Exploitable Vulnerability: 128 days; N2 Not Likely Exploitable Vulnerability: 192 days; N3 Internet-Reachable Vulnerability + Likely Exploitable Vulnerability: 16 days; N3 Not Internet-Reachable Vulnerability + Likely Exploitable Vulnerability: 32 days; N3 Not Likely Exploitable Vulnerability: 128 days; N4 Internet-Reachable Vulnerability + Likely Exploitable Vulnerability: 4 days; N4 Not Internet-Reachable Vulnerability + Likely Exploitable Vulnerability: 8 days; N4 Not Likely Exploitable Vulnerability: 64 days; N5 Internet-Reachable Vulnerability + Likely Exploitable Vulnerability: 2 days; N5 Not Internet-Reachable Vulnerability + Likely Exploitable Vulnerability: 4 days; N5 Not Likely Exploitable Vulnerability: 16 days", + "attributes": { + "Ruleset": "VDR: Vulnerability Detection and Response", + "Subset": "TFR: Timeframes", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VDR-TFR-RMN", + "name": "Remaining Vulnerabilities", + "description": "Providers SHOULD mitigate or remediate remaining vulnerabilities during routine operations as determined necessary by the provider.", + "attributes": { + "Ruleset": "VDR: Vulnerability Detection and Response", + "Subset": "TFR: Timeframes", + "Force": "SHOULD" + }, + "checks": { + "aws": [ + "ssm_managed_compliant_patching" + ], + "azure": [ + "defender_ensure_system_updates_are_applied" + ], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VDR-TFR-KEV", + "name": "Remediate KEVs", + "description": "Providers SHOULD remediate Known Exploited Vulnerabilities according to the due dates in the CISA Known Exploited Vulnerabilities Catalog (even if the vulnerability has been fully mitigated) as required by CISA Binding Operational Directive (BOD) 26-04 or any successor guidance from CISA.", + "attributes": { + "Ruleset": "VDR: Vulnerability Detection and Response", + "Subset": "TFR: Timeframes", + "Force": "SHOULD" + }, + "checks": { + "aws": [ + "inspector2_active_findings_kev_within_due_date" + ], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VDR-TFR-PSD", + "name": "Persistent Sample Detection", + "description": "Providers with Class C Certifications SHOULD persistently perform vulnerability detection on representative samples of similar machine-based information resources, at least once every 3 days.", + "attributes": { + "Ruleset": "VDR: Vulnerability Detection and Response", + "Subset": "TFR: Timeframes", + "Force": "SHOULD" + }, + "checks": { + "aws": [ + "inspector2_coverage_recently_scanned" + ], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + }, + "config_requirements": [ + { + "Check": "inspector2_coverage_recently_scanned", + "ConfigKey": "inspector2_max_days_since_last_scan", + "Operator": "lte", + "Value": 3, + "Provider": "aws" + } + ] + }, + { + "id": "VDR-TFR-MVX", + "name": "Persistent Machine Verification and Validation for 20x", + "description": "Providers of FedRAMP 20x Class C offerings MUST verify and validate the status of machine-based information resources at least once every 3 days.", + "attributes": { + "Ruleset": "VDR: Vulnerability Detection and Response", + "Subset": "TFR: Timeframes", + "Force": "MUST" + }, + "checks": { + "aws": [ + "config_recorder_all_regions_enabled", + "inspector2_coverage_recently_scanned", + "securityhub_enabled" + ], + "azure": [ + "defender_ensure_defender_cspm_is_on" + ], + "gcp": [ + "iam_cloud_asset_inventory_enabled" + ], + "kubernetes": [], + "m365": [] + }, + "config_requirements": [ + { + "Check": "inspector2_coverage_recently_scanned", + "ConfigKey": "inspector2_max_days_since_last_scan", + "Operator": "lte", + "Value": 3, + "Provider": "aws" + }, + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + } + ] + }, + { + "id": "VER-EVA-ELX", + "name": "Evaluate Exploitability", + "description": "Providers MUST evaluate detected vulnerabilities, considering the context of the cloud service offering, to determine if they are likely exploitable vulnerabilities.", + "attributes": { + "Ruleset": "VER: Vulnerability Evaluation and Reporting", + "Subset": "EVA: Evaluation", + "Force": "MUST" + }, + "checks": { + "aws": [ + "inspector2_is_enabled" + ], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VER-EVA-EIR", + "name": "Evaluate Internet-Reachability", + "description": "Providers MUST evaluate detected vulnerabilities, considering the context of the cloud service offering, to determine if they are internet-reachable vulnerabilities.", + "attributes": { + "Ruleset": "VER: Vulnerability Evaluation and Reporting", + "Subset": "EVA: Evaluation", + "Force": "MUST" + }, + "checks": { + "aws": [ + "inspector2_is_enabled" + ], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VER-EVA-EPA", + "name": "Estimate Potential Agency Impact", + "description": "Providers MUST evaluate detected vulnerabilities, considering the context of the cloud service offering, to estimate the potential agency impact of exploitation on government customers AND assign one of the following Potential Agency Impact N-ratings (PAIN): N1: Exploitation could be expected to have minimal customer effects on one or more agencies that use the cloud service offering.; N2: Exploitation could be expected to have narrow customer effects on one or more agencies that use the cloud service offering.; N3: Exploitation could be expected to have a disruptive customer effect on one agency that uses the cloud service offering.; N4: Exploitation could be expected to have a debilitating customer effect on one agency that uses the cloud service offering OR a disruptive customer effect on more than one federal agency that uses the cloud service offering.; N5: Exploitation could be expected to have a debilitating customer effect on more than one agency that uses the cloud service offering.", + "attributes": { + "Ruleset": "VER: Vulnerability Evaluation and Reporting", + "Subset": "EVA: Evaluation", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VER-EVA-AIA", + "name": "Assume It's Automatable", + "description": "Providers MUST assume the exploitation of vulnerabilities can be automated UNLESS they have evidence proving otherwise.", + "attributes": { + "Ruleset": "VER: Vulnerability Evaluation and Reporting", + "Subset": "EVA: Evaluation", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VER-EVA-GRV", + "name": "Group Vulnerabilities", + "description": "Providers SHOULD evaluate detected vulnerabilities, considering the context of the cloud service offering, to identify logical groupings of affected information resources that may improve the efficiency and effectiveness of vulnerability response by consolidating further activity; FedRAMP Vulnerability Detection and Response rules are then applied to these consolidated groupings of vulnerabilities instead of each individual detected instance.", + "attributes": { + "Ruleset": "VER: Vulnerability Evaluation and Reporting", + "Subset": "EVA: Evaluation", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VER-EVA-EFP", + "name": "Evaluate False Positives", + "description": "Providers SHOULD evaluate detected vulnerabilities, considering the context of the cloud service offering, to determine if they are false positive vulnerabilities.", + "attributes": { + "Ruleset": "VER: Vulnerability Evaluation and Reporting", + "Subset": "EVA: Evaluation", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VER-EVA-EFA", + "name": "Evaluation Factors", + "description": "Providers SHOULD consider at least the following factors when considering the context of the cloud service offering to evaluate detected vulnerabilities: Criticality: How important are the systems or information that might be impacted by the vulnerability?; Reachability: How might a threat actor reach the vulnerability and how likely is that?; Exploitability: How easy is it for a threat actor to exploit the vulnerability and how likely is that?; Detectability: How easy is it for a threat actor to become aware of the vulnerability and how likely is that?; Prevalence: How much of the cloud service offering is affected by the vulnerability?; Privilege: How much privileged authority or access is granted or can be gained from exploiting the vulnerability?; Proximate Vulnerabilities: How does this vulnerability interact with previously detected vulnerabilities, especially partially or fully mitigated vulnerabilities?; Known Threats: How might already known threats leverage the vulnerability and how likely is that?", + "attributes": { + "Ruleset": "VER: Vulnerability Evaluation and Reporting", + "Subset": "EVA: Evaluation", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VER-RPT-PER", + "name": "Persistent Reporting", + "description": "Providers MUST report vulnerability detection and response activity (including persistent verification and validation) to all necessary parties persistently, summarizing ALL activity since the previous report; these reports are FedRAMP Certification Data and are subject to FedRAMP Certification Data Sharing rules.", + "attributes": { + "Ruleset": "VER: Vulnerability Evaluation and Reporting", + "Subset": "RPT: Reporting", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VER-RPT-VDT", + "name": "Vulnerability Details", + "description": "Providers MUST include the following information (if applicable) on detected vulnerabilities when reporting on vulnerability detection and response activity, UNLESS it is an accepted vulnerability: Provider's internally assigned tracking identifier; Time and source of the detection; Time of completed evaluation; Is it an internet-reachable vulnerability or not?; Is it a likely exploitable vulnerability or not?; Historically and currently estimated Potential Agency Impact N-rating of exploitation; Time and Potential Agency Impact N-rating of each completed and evaluated reduction in Potential Agency Impact N-rating; Estimated time and target Potential Agency Impact N-rating of next reduction in Potential Agency Impact N-rating; Is it currently or is it likely to become an overdue vulnerability or not? If so, explain.; Any supplementary information the provider responsibly determines will help federal agencies assess or mitigate the risk to their federal customer data within the cloud service offering resulting from the vulnerability; Final disposition of the vulnerability", + "attributes": { + "Ruleset": "VER: Vulnerability Evaluation and Reporting", + "Subset": "RPT: Reporting", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VER-RPT-AVI", + "name": "Accepted Vulnerability Info", + "description": "Providers MUST include the following information on accepted vulnerabilities when reporting on vulnerability detection and response activity: Provider's internally assigned tracking identifier; Time and source of the detection; Time of completed evaluation; Is it an internet-reachable vulnerability or not?; Is it a likely exploitable vulnerability or not?; Currently estimated Potential Agency Impact N-rating; Explanation of why this is an accepted vulnerability; Any supplementary information the provider determines will responsibly help federal agencies assess or mitigate the risk to their federal customer data within the cloud service offering resulting from the accepted vulnerability", + "attributes": { + "Ruleset": "VER: Vulnerability Evaluation and Reporting", + "Subset": "RPT: Reporting", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VER-RPT-NID", + "name": "Responsible Disclosure", + "description": "Providers MUST NOT irresponsibly disclose specific sensitive information about vulnerabilities that would likely lead to exploitation, but MUST disclose sufficient information for informed risk-based decision-making to all necessary parties.", + "attributes": { + "Ruleset": "VER: Vulnerability Evaluation and Reporting", + "Subset": "RPT: Reporting", + "Force": "MUST NOT" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VER-RPT-HLO", + "name": "High-Level Overviews", + "description": "Providers SHOULD include high-level overviews of ALL vulnerability detection and response activities conducted during this period for the cloud service offering; this includes vulnerability disclosure programs, bug bounty programs, penetration testing, assessments, etc.", + "attributes": { + "Ruleset": "VER: Vulnerability Evaluation and Reporting", + "Subset": "RPT: Reporting", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VER-RPT-RPD", + "name": "Responsible Public Disclosure", + "description": "Providers MAY responsibly disclose vulnerabilities publicly or with other parties if the provider determines doing so will NOT likely lead to exploitation.", + "attributes": { + "Ruleset": "VER: Vulnerability Evaluation and Reporting", + "Subset": "RPT: Reporting", + "Force": "MAY" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VER-TFR-MHR", + "name": "Monthly Activity Report", + "description": "Providers MUST report vulnerability detection and response activity to all necessary parties in a consistent format that is human readable at least monthly.", + "attributes": { + "Ruleset": "VER: Vulnerability Evaluation and Reporting", + "Subset": "TFR: Timeframes", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VER-TFR-MAV", + "name": "Mark Accepted Vulnerabilities", + "description": "Providers MUST categorize any vulnerability that is not or will not be fully mitigated or remediated within 192 days of evaluation as an accepted vulnerability.", + "attributes": { + "Ruleset": "VER: Vulnerability Evaluation and Reporting", + "Subset": "TFR: Timeframes", + "Force": "MUST" + }, + "checks": { + "aws": [ + "inspector2_active_findings_within_max_age" + ], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + }, + "config_requirements": [ + { + "Check": "inspector2_active_findings_within_max_age", + "ConfigKey": "inspector2_active_finding_max_age_days", + "Operator": "lte", + "Value": 192, + "Provider": "aws" + } + ] + }, + { + "id": "VER-TFR-MRH", + "name": "Historical Activity", + "description": "Providers with Class C Certifications SHOULD make all recent historical vulnerability detection and response activity available in JSON format for automated retrieval by all necessary parties (e.g. using an API service or similar); this information SHOULD be updated persistently, at least once every 14 days.", + "attributes": { + "Ruleset": "VER: Vulnerability Evaluation and Reporting", + "Subset": "TFR: Timeframes", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VER-TFR-EVU", + "name": "Evaluate Vulnerabilities Quickly", + "description": "Providers with Class C Certifications SHOULD evaluate ALL vulnerabilities as required by VER-EVA (Evaluation) within 5 days of detection.", + "attributes": { + "Ruleset": "VER: Vulnerability Evaluation and Reporting", + "Subset": "TFR: Timeframes", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VER-TFR-IRI", + "name": "Internet-Reachable Incidents", + "description": "Providers with Class C Certifications SHOULD treat internet-reachable likely exploitable vulnerabilities where Potential Agency Impact N-rating > 3 as a FedRAMP Reportable Incident until they are partially mitigated vulnerabilities at N3 or below.", + "attributes": { + "Ruleset": "VER: Vulnerability Evaluation and Reporting", + "Subset": "TFR: Timeframes", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VER-TFR-NRI", + "name": "Non-Internet-Reachable Incidents", + "description": "Providers with Class C Certifications MAY treat likely exploitable vulnerabilities that are NOT internet-reachable where Potential Agency Impact N-rating = 5 as a FedRAMP Reportable Incident until they are partially mitigated vulnerabilities at N4 or below.", + "attributes": { + "Ruleset": "VER: Vulnerability Evaluation and Reporting", + "Subset": "TFR: Timeframes", + "Force": "MAY" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + } + ] +} diff --git a/prowler/config/config.yaml b/prowler/config/config.yaml index 46127c9699..0c7551d634 100644 --- a/prowler/config/config.yaml +++ b/prowler/config/config.yaml @@ -190,6 +190,14 @@ aws: # MEDIUM ecr_repository_vulnerability_minimum_severity: "MEDIUM" + # AWS Inspector2 + # aws.inspector2_coverage_recently_scanned + # Maximum days since Inspector2 last scanned an actively covered resource + inspector2_max_days_since_last_scan: 3 + # aws.inspector2_active_findings_within_max_age + # Maximum days an Inspector2 finding can stay active since it was first observed + inspector2_active_finding_max_age_days: 192 + # AWS Trusted Advisor # aws.trustedadvisor_premium_support_plan_subscribed verify_premium_support_plans: True diff --git a/prowler/config/schema/aws.py b/prowler/config/schema/aws.py index 75fa5f73c0..c46d848650 100644 --- a/prowler/config/schema/aws.py +++ b/prowler/config/schema/aws.py @@ -333,6 +333,20 @@ class AWSProviderConfig(ProviderConfigBase): description="Highest severity tolerated for ECR images.", ) + # --- Inspector2 ------------------------------------------------------- + inspector2_max_days_since_last_scan: Optional[int] = Field( + default=None, + ge=1, + le=90, + description="Days since Inspector2 last scanned a covered resource. Range: 1..90.", + ) + inspector2_active_finding_max_age_days: Optional[int] = Field( + default=None, + ge=1, + le=365, + description="Days an Inspector2 finding can stay active since first observed. Range: 1..365.", + ) + # --- Trusted Advisor -------------------------------------------------- verify_premium_support_plans: Optional[bool] = None diff --git a/prowler/providers/aws/services/elbv2/elbv2_listener_fips_tls_enabled/__init__.py b/prowler/providers/aws/services/elbv2/elbv2_listener_fips_tls_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/elbv2/elbv2_listener_fips_tls_enabled/elbv2_listener_fips_tls_enabled.metadata.json b/prowler/providers/aws/services/elbv2/elbv2_listener_fips_tls_enabled/elbv2_listener_fips_tls_enabled.metadata.json new file mode 100644 index 0000000000..9e321f7c54 --- /dev/null +++ b/prowler/providers/aws/services/elbv2/elbv2_listener_fips_tls_enabled/elbv2_listener_fips_tls_enabled.metadata.json @@ -0,0 +1,43 @@ +{ + "Provider": "aws", + "CheckID": "elbv2_listener_fips_tls_enabled", + "CheckTitle": "ELBv2 HTTPS/TLS listeners use a FIPS TLS security policy", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "elbv2", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "AwsElbv2LoadBalancer", + "ResourceGroup": "network", + "Description": "**ELBv2 HTTPS and TLS listeners** are assessed for use of a **FIPS** TLS security policy (`ELBSecurityPolicy-*-FIPS-*`). FIPS policies terminate TLS with the AWS-LC FIPS validated cryptographic module.", + "Risk": "Listeners without a FIPS policy terminate TLS with cryptographic modules that are not FIPS 140 validated, which does not meet requirements to protect federal or regulated data with **NIST CMVP validated cryptography**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/elasticloadbalancing/latest/application/describe-ssl-policies.html", + "https://docs.aws.amazon.com/elasticloadbalancing/latest/network/describe-ssl-policies.html", + "https://aws.amazon.com/compliance/fips/" + ], + "Remediation": { + "Code": { + "CLI": "aws elbv2 modify-listener --listener-arn --ssl-policy ELBSecurityPolicy-TLS13-1-2-FIPS-2023-04", + "NativeIaC": "```yaml\nResources:\n :\n Type: AWS::ElasticLoadBalancingV2::Listener\n Properties:\n LoadBalancerArn: \n Protocol: HTTPS\n Port: 443\n DefaultActions:\n - Type: forward\n TargetGroupArn: \n Certificates:\n - CertificateArn: \n SslPolicy: ELBSecurityPolicy-TLS13-1-2-FIPS-2023-04 # FIX: uses a FIPS TLS policy\n```", + "Other": "1. In the AWS Console, go to EC2 > Load Balancers\n2. Select the load balancer and open the Listeners tab\n3. Select each HTTPS/TLS listener and choose Edit\n4. Set Security policy to a FIPS policy such as ELBSecurityPolicy-TLS13-1-2-FIPS-2023-04\n5. Save changes", + "Terraform": "```hcl\nresource \"aws_lb_listener\" \"\" {\n load_balancer_arn = \"\"\n port = 443\n protocol = \"HTTPS\"\n ssl_policy = \"ELBSecurityPolicy-TLS13-1-2-FIPS-2023-04\" # FIX: FIPS TLS policy\n certificate_arn = \"\"\n\n default_action {\n type = \"forward\"\n target_group_arn = \"\"\n }\n}\n```" + }, + "Recommendation": { + "Text": "Use a **FIPS** TLS security policy, such as `ELBSecurityPolicy-TLS13-1-2-FIPS-2023-04`, on every HTTPS and TLS listener that carries federal or regulated data.", + "Url": "https://hub.prowler.com/check/elbv2_listener_fips_tls_enabled" + } + }, + "Categories": [ + "encryption" + ], + "DependsOn": [], + "RelatedTo": [ + "elbv2_insecure_ssl_ciphers", + "elbv2_listener_pqc_tls_enabled" + ], + "Notes": "" +} diff --git a/prowler/providers/aws/services/elbv2/elbv2_listener_fips_tls_enabled/elbv2_listener_fips_tls_enabled.py b/prowler/providers/aws/services/elbv2/elbv2_listener_fips_tls_enabled/elbv2_listener_fips_tls_enabled.py new file mode 100644 index 0000000000..3a9d07f4ba --- /dev/null +++ b/prowler/providers/aws/services/elbv2/elbv2_listener_fips_tls_enabled/elbv2_listener_fips_tls_enabled.py @@ -0,0 +1,35 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.elbv2.elbv2_client import elbv2_client + + +class elbv2_listener_fips_tls_enabled(Check): + """Ensure every ELBv2 HTTPS or TLS listener uses a FIPS TLS security policy.""" + + def execute(self) -> list[Check_Report_AWS]: + """Report whether each load balancer terminates HTTPS/TLS with a FIPS policy.""" + findings = [] + for lb in elbv2_client.loadbalancersv2.values(): + if lb.listener_discovery_failed: + continue + report = Check_Report_AWS(metadata=self.metadata(), resource=lb) + tls_listeners = { + listener_arn: listener + for listener_arn, listener in lb.listeners.items() + if listener.protocol in ("HTTPS", "TLS") + } + non_fips_listeners = [ + f"{listener.protocol}:{listener.port} ({listener_arn}) uses {listener.ssl_policy or ''}" + for listener_arn, listener in tls_listeners.items() + if "FIPS" not in (listener.ssl_policy or "").split("-") + ] + if not tls_listeners: + report.status = "PASS" + report.status_extended = f"ELBv2 {lb.name} has no HTTPS/TLS listeners." + elif non_fips_listeners: + report.status = "FAIL" + report.status_extended = f"ELBv2 {lb.name} has HTTPS/TLS listeners without a FIPS TLS security policy: {', '.join(non_fips_listeners)}." + else: + report.status = "PASS" + report.status_extended = f"ELBv2 {lb.name} has all HTTPS/TLS listeners using a FIPS TLS security policy." + findings.append(report) + return findings diff --git a/prowler/providers/aws/services/inspector2/inspector2_active_findings_kev_within_due_date/__init__.py b/prowler/providers/aws/services/inspector2/inspector2_active_findings_kev_within_due_date/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/inspector2/inspector2_active_findings_kev_within_due_date/inspector2_active_findings_kev_within_due_date.metadata.json b/prowler/providers/aws/services/inspector2/inspector2_active_findings_kev_within_due_date/inspector2_active_findings_kev_within_due_date.metadata.json new file mode 100644 index 0000000000..3795a96fae --- /dev/null +++ b/prowler/providers/aws/services/inspector2/inspector2_active_findings_kev_within_due_date/inspector2_active_findings_kev_within_due_date.metadata.json @@ -0,0 +1,42 @@ +{ + "Provider": "aws", + "CheckID": "inspector2_active_findings_kev_within_due_date", + "CheckTitle": "Inspector2 has no active findings for CISA Known Exploited Vulnerabilities past their remediation due date", + "CheckType": [ + "Software and Configuration Checks/Vulnerabilities/CVE", + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "inspector2", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "critical", + "ResourceType": "Other", + "ResourceGroup": "security", + "Description": "**Amazon Inspector** active findings for **CISA Known Exploited Vulnerabilities** are compared with the remediation due date (`dateDue`) that CISA assigns to each entry of the KEV catalog. Findings that are still active after that date are reported.\n\nThe result is reported per Region where Inspector is enabled.", + "Risk": "CISA due dates reflect **active exploitation**. Missing them keeps exploited vulnerabilities open beyond the window CISA sets for federal agencies and shows that vulnerability response is not keeping pace with real threats.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.cisa.gov/known-exploited-vulnerabilities-catalog", + "https://docs.aws.amazon.com/inspector/v2/APIReference/API_BatchGetFindingDetails.html" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. In the AWS Console, open Amazon Inspector > Findings\n2. Filter by Vulnerability ID for each overdue CVE reported by this check\n3. Patch or upgrade the affected packages, rebuild container images or update Lambda runtimes\n4. If a fix is not available, apply the mitigations listed in the CISA catalog entry\n5. Confirm the findings move to Closed", + "Terraform": "" + }, + "Recommendation": { + "Text": "Track every KEV finding against its **CISA due date** and remediate before it passes. When no fix exists yet, apply the vendor or CISA mitigations and document the residual risk.", + "Url": "https://hub.prowler.com/check/inspector2_active_findings_kev_within_due_date" + } + }, + "Categories": [ + "vulnerabilities" + ], + "DependsOn": [], + "RelatedTo": [ + "inspector2_active_findings_no_known_exploited_vulnerabilities" + ], + "Notes": "" +} diff --git a/prowler/providers/aws/services/inspector2/inspector2_active_findings_kev_within_due_date/inspector2_active_findings_kev_within_due_date.py b/prowler/providers/aws/services/inspector2/inspector2_active_findings_kev_within_due_date/inspector2_active_findings_kev_within_due_date.py new file mode 100644 index 0000000000..e0ec8ddd30 --- /dev/null +++ b/prowler/providers/aws/services/inspector2/inspector2_active_findings_kev_within_due_date/inspector2_active_findings_kev_within_due_date.py @@ -0,0 +1,66 @@ +from datetime import datetime, timezone + +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.inspector2.inspector2_client import ( + inspector2_client, +) +from prowler.providers.aws.services.inspector2.lib.vulnerabilities import ( + summarize_vulnerabilities, +) + + +class inspector2_active_findings_kev_within_due_date(Check): + """Ensure active Inspector2 findings for CISA KEVs are not past their CISA due date.""" + + def execute(self) -> list[Check_Report_AWS]: + """Report, per Region, whether any CISA KEV finding is past its remediation due date.""" + findings = [] + now = datetime.now(timezone.utc) + known_exploited = inspector2_client.known_exploited_vulnerabilities + lookup_failed = inspector2_client.vulnerability_lookup_failed + for inspector in inspector2_client.inspectors: + if inspector.status != "ENABLED": + continue + report = Check_Report_AWS(metadata=self.metadata(), resource=inspector) + if inspector.findings is None: + report.status = "MANUAL" + report.status_extended = ( + f"Inspector2 findings could not be retrieved in region {inspector.region}; " + "verify the inspector2:ListFindings permission." + ) + findings.append(report) + continue + vulnerability_ids = { + finding.vulnerability_id + for finding in inspector.findings + if finding.vulnerability_id + } + overdue = sorted( + f"{vulnerability_id} (due {known_exploited[vulnerability_id].date_due.date().isoformat()})" + for vulnerability_id in known_exploited.keys() & vulnerability_ids + if known_exploited[vulnerability_id].date_due + and known_exploited[vulnerability_id].date_due < now + ) + unverified_ids = sorted(vulnerability_ids & lookup_failed) + if overdue: + report.status = "FAIL" + report.status_extended = ( + f"Inspector2 has active findings in region {inspector.region} for CISA " + "Known Exploited Vulnerabilities past their remediation due date: " + f"{summarize_vulnerabilities(overdue)}." + ) + elif unverified_ids: + report.status = "MANUAL" + report.status_extended = ( + "Inspector2 could not verify the CISA Known Exploited Vulnerabilities status of " + f"{summarize_vulnerabilities(unverified_ids)} in region {inspector.region}; " + "verify the inspector2:BatchGetFindingDetails permission." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Inspector2 has no active findings in region {inspector.region} for CISA " + "Known Exploited Vulnerabilities past their remediation due date." + ) + findings.append(report) + return findings diff --git a/prowler/providers/aws/services/inspector2/inspector2_active_findings_no_known_exploited_vulnerabilities/__init__.py b/prowler/providers/aws/services/inspector2/inspector2_active_findings_no_known_exploited_vulnerabilities/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/inspector2/inspector2_active_findings_no_known_exploited_vulnerabilities/inspector2_active_findings_no_known_exploited_vulnerabilities.metadata.json b/prowler/providers/aws/services/inspector2/inspector2_active_findings_no_known_exploited_vulnerabilities/inspector2_active_findings_no_known_exploited_vulnerabilities.metadata.json new file mode 100644 index 0000000000..5072ebd6a6 --- /dev/null +++ b/prowler/providers/aws/services/inspector2/inspector2_active_findings_no_known_exploited_vulnerabilities/inspector2_active_findings_no_known_exploited_vulnerabilities.metadata.json @@ -0,0 +1,43 @@ +{ + "Provider": "aws", + "CheckID": "inspector2_active_findings_no_known_exploited_vulnerabilities", + "CheckTitle": "Inspector2 has no active findings for CISA Known Exploited Vulnerabilities", + "CheckType": [ + "Software and Configuration Checks/Vulnerabilities/CVE", + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "inspector2", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "Other", + "ResourceGroup": "security", + "Description": "**Amazon Inspector** active findings are cross-referenced with the **CISA Known Exploited Vulnerabilities (KEV)** catalog, using the CISA data that Inspector returns in the finding details (`BatchGetFindingDetails`) of each CVE.\n\nThe result is reported per Region where Inspector is enabled.", + "Risk": "KEV entries are vulnerabilities **confirmed as exploited in the wild**. Workloads carrying them are prime targets for initial access and ransomware, enabling remote code execution, data exfiltration and lateral movement.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.cisa.gov/known-exploited-vulnerabilities-catalog", + "https://docs.aws.amazon.com/inspector/v2/APIReference/API_BatchGetFindingDetails.html" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. In the AWS Console, open Amazon Inspector > Findings\n2. Filter by Vulnerability ID for each CVE reported by this check\n3. Patch or upgrade the affected packages, rebuild container images or update Lambda runtimes\n4. Confirm the findings move to Closed", + "Terraform": "" + }, + "Recommendation": { + "Text": "Remediate KEV findings before any other vulnerability: patch or upgrade the affected packages, rebuild and redeploy container images, and stop deploying new resources that carry **known exploited vulnerabilities**.", + "Url": "https://hub.prowler.com/check/inspector2_active_findings_no_known_exploited_vulnerabilities" + } + }, + "Categories": [ + "vulnerabilities" + ], + "DependsOn": [], + "RelatedTo": [ + "inspector2_active_findings_exist", + "inspector2_active_findings_kev_within_due_date" + ], + "Notes": "" +} diff --git a/prowler/providers/aws/services/inspector2/inspector2_active_findings_no_known_exploited_vulnerabilities/inspector2_active_findings_no_known_exploited_vulnerabilities.py b/prowler/providers/aws/services/inspector2/inspector2_active_findings_no_known_exploited_vulnerabilities/inspector2_active_findings_no_known_exploited_vulnerabilities.py new file mode 100644 index 0000000000..e483205cb1 --- /dev/null +++ b/prowler/providers/aws/services/inspector2/inspector2_active_findings_no_known_exploited_vulnerabilities/inspector2_active_findings_no_known_exploited_vulnerabilities.py @@ -0,0 +1,57 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.inspector2.inspector2_client import ( + inspector2_client, +) +from prowler.providers.aws.services.inspector2.lib.vulnerabilities import ( + summarize_vulnerabilities, +) + + +class inspector2_active_findings_no_known_exploited_vulnerabilities(Check): + """Ensure no active Inspector2 finding is a CISA Known Exploited Vulnerability.""" + + def execute(self) -> list[Check_Report_AWS]: + """Report, per Region, whether any active finding is a CISA Known Exploited Vulnerability.""" + findings = [] + known_exploited = inspector2_client.known_exploited_vulnerabilities + lookup_failed = inspector2_client.vulnerability_lookup_failed + for inspector in inspector2_client.inspectors: + if inspector.status != "ENABLED": + continue + report = Check_Report_AWS(metadata=self.metadata(), resource=inspector) + if inspector.findings is None: + report.status = "MANUAL" + report.status_extended = ( + f"Inspector2 findings could not be retrieved in region {inspector.region}; " + "verify the inspector2:ListFindings permission." + ) + findings.append(report) + continue + vulnerability_ids = { + finding.vulnerability_id + for finding in inspector.findings + if finding.vulnerability_id + } + kev_ids = sorted(known_exploited.keys() & vulnerability_ids) + unverified_ids = sorted(vulnerability_ids & lookup_failed) + if kev_ids: + report.status = "FAIL" + report.status_extended = ( + f"Inspector2 has active findings in region {inspector.region} for CISA " + f"Known Exploited Vulnerabilities: {summarize_vulnerabilities(kev_ids)}." + ) + elif unverified_ids: + report.status = "MANUAL" + report.status_extended = ( + "Inspector2 could not verify the CISA Known Exploited Vulnerabilities status of " + f"{summarize_vulnerabilities(unverified_ids)} in region {inspector.region}; " + "verify the inspector2:BatchGetFindingDetails permission." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Inspector2 has no active findings in region {inspector.region} for CISA " + "Known Exploited Vulnerabilities." + ) + findings.append(report) + return findings diff --git a/prowler/providers/aws/services/inspector2/inspector2_active_findings_within_max_age/__init__.py b/prowler/providers/aws/services/inspector2/inspector2_active_findings_within_max_age/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/inspector2/inspector2_active_findings_within_max_age/inspector2_active_findings_within_max_age.metadata.json b/prowler/providers/aws/services/inspector2/inspector2_active_findings_within_max_age/inspector2_active_findings_within_max_age.metadata.json new file mode 100644 index 0000000000..7846da4f05 --- /dev/null +++ b/prowler/providers/aws/services/inspector2/inspector2_active_findings_within_max_age/inspector2_active_findings_within_max_age.metadata.json @@ -0,0 +1,43 @@ +{ + "Provider": "aws", + "CheckID": "inspector2_active_findings_within_max_age", + "CheckTitle": "Inspector2 has no active findings older than the configured maximum age", + "CheckType": [ + "Software and Configuration Checks/Vulnerabilities/CVE", + "Software and Configuration Checks/Patch Management", + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "inspector2", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Other", + "ResourceGroup": "security", + "Description": "**Amazon Inspector** active findings are evaluated against the configurable `inspector2_active_finding_max_age_days` threshold (192 days by default), using the time since each finding was first observed (`firstObservedAt`). Suppressed and closed findings are not active and are not evaluated.\n\nThe result is reported per Region where Inspector is enabled.", + "Risk": "Findings left open for months show that **vulnerability response** is not keeping up. Long-lived vulnerabilities give attackers time to discover and exploit them, and a backlog that is neither fixed nor formally accepted hides real risk from decision makers.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/inspector/latest/user/findings-understanding.html", + "https://docs.aws.amazon.com/inspector/latest/user/findings-managing-supression-rules.html" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. In the Amazon Inspector console, open Findings and filter by Finding status = Active\n2. Remediate the findings first observed longest ago\n3. For vulnerabilities you formally accept, choose Suppression rules in the navigation pane and create a rule so they stop counting as active", + "Terraform": "" + }, + "Recommendation": { + "Text": "Remediate findings within your vulnerability response timeframes. Vulnerabilities you decide not to fix should be formally **accepted** and suppressed with a documented justification instead of staying active indefinitely.", + "Url": "https://hub.prowler.com/check/inspector2_active_findings_within_max_age" + } + }, + "Categories": [ + "vulnerabilities" + ], + "DependsOn": [], + "RelatedTo": [ + "inspector2_active_findings_exist" + ], + "Notes": "" +} diff --git a/prowler/providers/aws/services/inspector2/inspector2_active_findings_within_max_age/inspector2_active_findings_within_max_age.py b/prowler/providers/aws/services/inspector2/inspector2_active_findings_within_max_age/inspector2_active_findings_within_max_age.py new file mode 100644 index 0000000000..220ee3c9cd --- /dev/null +++ b/prowler/providers/aws/services/inspector2/inspector2_active_findings_within_max_age/inspector2_active_findings_within_max_age.py @@ -0,0 +1,51 @@ +from datetime import datetime, timedelta, timezone + +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.inspector2.inspector2_client import ( + inspector2_client, +) + + +class inspector2_active_findings_within_max_age(Check): + """Ensure no Inspector2 finding stays active longer than the configured days.""" + + def execute(self) -> list[Check_Report_AWS]: + """Report, per Region, whether any active finding is older than the allowed days.""" + findings = [] + max_age_days = inspector2_client.audit_config.get( + "inspector2_active_finding_max_age_days", 192 + ) + max_age = timedelta(days=max_age_days) + now = datetime.now(timezone.utc) + for inspector in inspector2_client.inspectors: + if inspector.status != "ENABLED": + continue + report = Check_Report_AWS(metadata=self.metadata(), resource=inspector) + if inspector.findings is None: + report.status = "MANUAL" + report.status_extended = ( + f"Inspector2 findings could not be retrieved in region {inspector.region}; " + "verify the inspector2:ListFindings permission." + ) + findings.append(report) + continue + stale_ages = [ + now - finding.first_observed_at + for finding in inspector.findings + if finding.first_observed_at + and now - finding.first_observed_at > max_age + ] + if stale_ages: + report.status = "FAIL" + report.status_extended = ( + f"Inspector2 has {len(stale_ages)} active findings in region {inspector.region} " + f"first observed more than {max_age_days} days ago, the oldest {max(stale_ages).days} days ago." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Inspector2 has no active findings in region {inspector.region} " + f"first observed more than {max_age_days} days ago." + ) + findings.append(report) + return findings diff --git a/prowler/providers/aws/services/inspector2/inspector2_coverage_recently_scanned/__init__.py b/prowler/providers/aws/services/inspector2/inspector2_coverage_recently_scanned/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/inspector2/inspector2_coverage_recently_scanned/inspector2_coverage_recently_scanned.metadata.json b/prowler/providers/aws/services/inspector2/inspector2_coverage_recently_scanned/inspector2_coverage_recently_scanned.metadata.json new file mode 100644 index 0000000000..8fc174f17f --- /dev/null +++ b/prowler/providers/aws/services/inspector2/inspector2_coverage_recently_scanned/inspector2_coverage_recently_scanned.metadata.json @@ -0,0 +1,43 @@ +{ + "Provider": "aws", + "CheckID": "inspector2_coverage_recently_scanned", + "CheckTitle": "Inspector2 covered resource was scanned within the configured number of days", + "CheckType": [ + "Software and Configuration Checks/Vulnerabilities/CVE", + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "inspector2", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Other", + "ResourceGroup": "security", + "Description": "**Amazon Inspector** coverage is evaluated for every actively monitored resource. The time since the resource was last scanned (`lastScannedAt`) is compared with the configurable `inspector2_max_days_since_last_scan` threshold (3 days by default).\n\nResources still pending their first scan are not evaluated.", + "Risk": "Stale scans leave **newly published CVEs** and configuration **drift** undetected. A resource that has not been rescanned for weeks can keep running exploitable packages long after a fix is available.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/inspector/latest/user/assessing-coverage.html", + "https://docs.aws.amazon.com/inspector/latest/user/scanning-ecr.html" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. In the Amazon Inspector console, choose Account management and review the Last scanned at value in the Instances, Container images and Lambda functions tabs\n2. For EC2 instances, confirm the SSM Agent is healthy or, under General settings > EC2 scanning settings, set the scan mode to hybrid\n3. For ECR images, increase the Amazon ECR re-scan duration in the Amazon Inspector settings\n4. Confirm the resources are rescanned", + "Terraform": "" + }, + "Recommendation": { + "Text": "Keep continuous scanning healthy: use **hybrid** EC2 scanning so instances without a working SSM agent are still scanned, set a long ECR **rescan duration** for images in use, and investigate every resource whose last scan is older than the allowed window.", + "Url": "https://hub.prowler.com/check/inspector2_coverage_recently_scanned" + } + }, + "Categories": [ + "vulnerabilities" + ], + "DependsOn": [], + "RelatedTo": [ + "inspector2_is_enabled", + "inspector2_coverage_scan_status_active" + ], + "Notes": "" +} diff --git a/prowler/providers/aws/services/inspector2/inspector2_coverage_recently_scanned/inspector2_coverage_recently_scanned.py b/prowler/providers/aws/services/inspector2/inspector2_coverage_recently_scanned/inspector2_coverage_recently_scanned.py new file mode 100644 index 0000000000..0e5a3bf6fd --- /dev/null +++ b/prowler/providers/aws/services/inspector2/inspector2_coverage_recently_scanned/inspector2_coverage_recently_scanned.py @@ -0,0 +1,57 @@ +from datetime import datetime, timedelta, timezone + +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.inspector2.inspector2_client import ( + inspector2_client, +) + +PENDING_SCAN_REASONS = { + "PENDING_INITIAL_SCAN", + "PENDING_REVIVAL_SCAN", + "SCAN_IN_PROGRESS", +} + + +class inspector2_coverage_recently_scanned(Check): + """Ensure Inspector2 scanned every actively covered resource within the configured days.""" + + def execute(self) -> list[Check_Report_AWS]: + """Report whether each actively covered resource was scanned within the allowed days.""" + findings = [] + max_days = inspector2_client.audit_config.get( + "inspector2_max_days_since_last_scan", 3 + ) + max_elapsed = timedelta(days=max_days) + now = datetime.now(timezone.utc) + for inspector in inspector2_client.inspectors: + if inspector.status != "ENABLED": + continue + if inspector.coverage is None: + report = Check_Report_AWS(metadata=self.metadata(), resource=inspector) + report.status = "MANUAL" + report.status_extended = ( + f"Inspector2 coverage could not be retrieved in region {inspector.region}; " + "verify the inspector2:ListCoverage permission." + ) + findings.append(report) + continue + for resource in inspector.coverage: + if resource.scan_status_code != "ACTIVE": + continue + if ( + resource.last_scanned_at is None + and resource.scan_status_reason in PENDING_SCAN_REASONS + ): + continue + report = Check_Report_AWS(metadata=self.metadata(), resource=resource) + if resource.last_scanned_at is None: + report.status = "FAIL" + report.status_extended = f"{resource.resource_type} {resource.id} has no recorded Inspector2 scan." + elif now - resource.last_scanned_at > max_elapsed: + report.status = "FAIL" + report.status_extended = f"{resource.resource_type} {resource.id} was last scanned by Inspector2 more than {max_days} days ago." + else: + report.status = "PASS" + report.status_extended = f"{resource.resource_type} {resource.id} was last scanned by Inspector2 within the last {max_days} days." + findings.append(report) + return findings diff --git a/prowler/providers/aws/services/inspector2/inspector2_coverage_scan_status_active/__init__.py b/prowler/providers/aws/services/inspector2/inspector2_coverage_scan_status_active/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/inspector2/inspector2_coverage_scan_status_active/inspector2_coverage_scan_status_active.metadata.json b/prowler/providers/aws/services/inspector2/inspector2_coverage_scan_status_active/inspector2_coverage_scan_status_active.metadata.json new file mode 100644 index 0000000000..0488e5097f --- /dev/null +++ b/prowler/providers/aws/services/inspector2/inspector2_coverage_scan_status_active/inspector2_coverage_scan_status_active.metadata.json @@ -0,0 +1,42 @@ +{ + "Provider": "aws", + "CheckID": "inspector2_coverage_scan_status_active", + "CheckTitle": "Inspector2 covered resource is actively scanned", + "CheckType": [ + "Software and Configuration Checks/Vulnerabilities/CVE", + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "inspector2", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Other", + "ResourceGroup": "security", + "Description": "**Amazon Inspector** coverage is evaluated for every resource it tracks (EC2 instances, ECR images and repositories, Lambda functions). A resource whose scan status is `INACTIVE`, for example because of `UNMANAGED_EC2_INSTANCE`, `NO_INVENTORY`, `UNSUPPORTED_OS` or `ACCESS_DENIED`, is not being scanned for vulnerabilities.\n\nStopped, terminated, tag-excluded and aged-out resources are not evaluated.", + "Risk": "Resources that Inspector cannot scan silently fall out of **vulnerability detection**. New CVEs affecting those workloads are never reported, so exploitable software can stay deployed while the account still appears covered.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/inspector/latest/user/assessing-coverage.html", + "https://docs.aws.amazon.com/inspector/latest/user/scanning-ec2.html" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. In the Amazon Inspector console, choose Account management\n2. Open the Instances, Container images or Lambda functions tab and review the resources that are not actively scanned\n3. Fix the reported cause: register EC2 instances with Systems Manager or set the EC2 scan mode to hybrid, use supported operating systems and runtimes, and grant access to the required encryption keys\n4. Confirm the resource is actively scanned", + "Terraform": "" + }, + "Recommendation": { + "Text": "Resolve the reason reported in each inactive resource's scan status so Inspector can scan every in-scope workload. Register EC2 instances with **Systems Manager** or enable **hybrid scanning**, keep operating systems and runtimes supported, and treat scanning gaps as vulnerabilities to track.", + "Url": "https://hub.prowler.com/check/inspector2_coverage_scan_status_active" + } + }, + "Categories": [ + "vulnerabilities" + ], + "DependsOn": [], + "RelatedTo": [ + "inspector2_is_enabled" + ], + "Notes": "" +} diff --git a/prowler/providers/aws/services/inspector2/inspector2_coverage_scan_status_active/inspector2_coverage_scan_status_active.py b/prowler/providers/aws/services/inspector2/inspector2_coverage_scan_status_active/inspector2_coverage_scan_status_active.py new file mode 100644 index 0000000000..c1cd63cc02 --- /dev/null +++ b/prowler/providers/aws/services/inspector2/inspector2_coverage_scan_status_active/inspector2_coverage_scan_status_active.py @@ -0,0 +1,46 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.inspector2.inspector2_client import ( + inspector2_client, +) + +NOT_APPLICABLE_SCAN_REASONS = { + "EC2_INSTANCE_STOPPED", + "EXCLUDED_BY_TAG", + "NO_RESOURCES_FOUND", + "PENDING_DISABLE", + "RESOURCE_TERMINATED", + "SCAN_ELIGIBILITY_EXPIRED", +} + + +class inspector2_coverage_scan_status_active(Check): + """Ensure Inspector2 is actively scanning every covered resource.""" + + def execute(self) -> list[Check_Report_AWS]: + """Report whether Inspector2 is actively scanning each covered resource.""" + findings = [] + for inspector in inspector2_client.inspectors: + if inspector.status != "ENABLED": + continue + if inspector.coverage is None: + report = Check_Report_AWS(metadata=self.metadata(), resource=inspector) + report.status = "MANUAL" + report.status_extended = ( + f"Inspector2 coverage could not be retrieved in region {inspector.region}; " + "verify the inspector2:ListCoverage permission." + ) + findings.append(report) + continue + for resource in inspector.coverage: + if resource.scan_status_reason in NOT_APPLICABLE_SCAN_REASONS: + continue + report = Check_Report_AWS(metadata=self.metadata(), resource=resource) + if resource.scan_status_code == "ACTIVE": + report.status = "PASS" + report.status_extended = f"Inspector2 is actively scanning {resource.resource_type} {resource.id}." + else: + report.status = "FAIL" + reason = resource.scan_status_reason or "no reason reported" + report.status_extended = f"Inspector2 is not scanning {resource.resource_type} {resource.id}: {reason}." + findings.append(report) + return findings diff --git a/prowler/providers/aws/services/inspector2/inspector2_service.py b/prowler/providers/aws/services/inspector2/inspector2_service.py index cc6dac7413..6acf186a66 100644 --- a/prowler/providers/aws/services/inspector2/inspector2_service.py +++ b/prowler/providers/aws/services/inspector2/inspector2_service.py @@ -1,16 +1,33 @@ +from datetime import datetime +from typing import Optional + from pydantic.v1 import BaseModel from prowler.lib.logger import logger +from prowler.lib.scan_filters.scan_filters import is_resource_filtered from prowler.providers.aws.lib.service.service import AWSService +FINDING_DETAILS_BATCH_SIZE = 10 + class Inspector2(AWSService): def __init__(self, provider): # Call AWSService's __init__ super().__init__(__class__.__name__, provider) self.inspectors = [] + self.known_exploited_vulnerabilities = {} + self.vulnerability_lookup_failed = set() self.__threading_call__(self._batch_get_account_status) self.__threading_call__(self._list_active_findings, self.inspectors) + enabled_inspectors = [ + inspector for inspector in self.inspectors if inspector.status == "ENABLED" + ] + self.__threading_call__(self._list_findings, enabled_inspectors) + self.__threading_call__(self._list_coverage, enabled_inspectors) + self.__threading_call__( + self._batch_get_finding_details, + self._get_finding_detail_batches(enabled_inspectors), + ) def _batch_get_account_status(self, regional_client): # We use this function to check if inspector2 is enabled @@ -59,6 +76,188 @@ class Inspector2(AWSService): f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _list_findings(self, inspector): + """Store the active findings of the audited account for an enabled Region.""" + logger.info("Inspector2 - Listing active findings details...") + try: + paginator = self.regional_clients[inspector.region].get_paginator( + "list_findings" + ) + findings = [] + for page in paginator.paginate( + filterCriteria={ + "awsAccountId": [ + {"comparison": "EQUALS", "value": self.audited_account}, + ], + "findingStatus": [{"comparison": "EQUALS", "value": "ACTIVE"}], + }, + PaginationConfig={"PageSize": 100}, + ): + for finding in page.get("findings", []): + findings.append( + Finding( + arn=finding.get("findingArn", ""), + type=finding.get("type", ""), + severity=finding.get("severity", ""), + first_observed_at=finding.get("firstObservedAt"), + vulnerability_id=finding.get( + "packageVulnerabilityDetails", {} + ).get("vulnerabilityId"), + resource_ids=[ + resource["id"] + for resource in finding.get("resources", []) + if resource.get("id") + ], + ) + ) + inspector.findings = findings + except Exception as error: + logger.error( + f"{inspector.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _list_coverage(self, inspector): + """Store the resources Inspector2 covers in an enabled Region, respecting audit resources.""" + logger.info("Inspector2 - Listing coverage...") + try: + paginator = self.regional_clients[inspector.region].get_paginator( + "list_coverage" + ) + coverage = [] + for page in paginator.paginate( + filterCriteria={ + "accountId": [ + {"comparison": "EQUALS", "value": self.audited_account}, + ], + }, + PaginationConfig={"PageSize": 200}, + ): + for covered_resource in page.get("coveredResources", []): + resource_id = covered_resource.get("resourceId", "") + resource_type = covered_resource.get("resourceType", "") + scan_status = covered_resource.get("scanStatus", {}) + arn = self._get_covered_resource_arn( + resource_type, resource_id, inspector.region + ) + if self.audit_resources and not is_resource_filtered( + arn, self.audit_resources + ): + continue + coverage.append( + CoveredResource( + id=resource_id, + arn=arn, + region=inspector.region, + resource_type=resource_type, + scan_type=covered_resource.get("scanType", ""), + scan_status_code=scan_status.get("statusCode", ""), + scan_status_reason=scan_status.get("reason", ""), + last_scanned_at=covered_resource.get("lastScannedAt"), + ) + ) + inspector.coverage = coverage + except Exception as error: + logger.error( + f"{inspector.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _get_covered_resource_arn(self, resource_type, resource_id, region): + """Return the ARN of a covered resource, building it for EC2 instance IDs.""" + if resource_type == "AWS_EC2_INSTANCE" and not resource_id.startswith("arn:"): + return f"arn:{self.audited_partition}:ec2:{region}:{self.audited_account}:instance/{resource_id}" + return resource_id + + @staticmethod + def _get_finding_detail_batches(inspectors): + """Group one active finding per CVE into finding details batches per Region.""" + representatives = {} + for inspector in inspectors: + for finding in inspector.findings or []: + if finding.vulnerability_id and finding.vulnerability_id.startswith( + "CVE-" + ): + representatives.setdefault( + finding.vulnerability_id, (inspector.region, finding.arn) + ) + findings_by_region = {} + for vulnerability_id, (region, finding_arn) in representatives.items(): + findings_by_region.setdefault(region, []).append( + (finding_arn, vulnerability_id) + ) + return [ + (region, findings[index : index + FINDING_DETAILS_BATCH_SIZE]) + for region, findings in findings_by_region.items() + for index in range(0, len(findings), FINDING_DETAILS_BATCH_SIZE) + ] + + def _batch_get_finding_details(self, batch): + """Record the CISA KEV data of the CVEs in a batch, flagging failed lookups.""" + region, findings = batch + vulnerability_ids = dict(findings) + logger.info("Inspector2 - Getting finding details...") + try: + response = self.regional_clients[region].batch_get_finding_details( + findingArns=list(vulnerability_ids) + ) + for detail in response.get("findingDetails", []): + vulnerability_id = vulnerability_ids.get(detail.get("findingArn")) + cisa_data = detail.get("cisaData") + if vulnerability_id and cisa_data: + self.known_exploited_vulnerabilities[vulnerability_id] = ( + KnownExploitedVulnerability( + id=vulnerability_id, + date_added=cisa_data.get("dateAdded"), + date_due=cisa_data.get("dateDue"), + ) + ) + for detail_error in response.get("errors", []): + # Inspector has no intelligence for the CVE, so it cannot be a KEV + if detail_error.get("errorCode") == "FINDING_DETAILS_NOT_FOUND": + continue + vulnerability_id = vulnerability_ids.get(detail_error.get("findingArn")) + if vulnerability_id: + self.vulnerability_lookup_failed.add(vulnerability_id) + logger.error( + f"{region} -- {detail_error.get('errorCode')} getting finding details for {vulnerability_id}: {detail_error.get('errorMessage')}" + ) + except Exception as error: + self.vulnerability_lookup_failed.update(vulnerability_ids.values()) + logger.error( + f"{region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + +class Finding(BaseModel): + """Active Inspector2 finding.""" + + arn: str + type: str + severity: str + first_observed_at: Optional[datetime] + vulnerability_id: Optional[str] + resource_ids: list[str] = [] + + +class CoveredResource(BaseModel): + """Resource tracked by Inspector2 coverage.""" + + id: str + arn: str + region: str + resource_type: str + scan_type: str + scan_status_code: str + scan_status_reason: str + last_scanned_at: Optional[datetime] + + +class KnownExploitedVulnerability(BaseModel): + """CISA Known Exploited Vulnerability data of a CVE.""" + + id: str + date_added: Optional[datetime] + date_due: Optional[datetime] + class Inspector(BaseModel): id: str @@ -70,3 +269,5 @@ class Inspector(BaseModel): lambda_status: str lambda_code_status: str active_findings: bool = None + findings: Optional[list[Finding]] = None + coverage: Optional[list[CoveredResource]] = None diff --git a/prowler/providers/aws/services/inspector2/lib/__init__.py b/prowler/providers/aws/services/inspector2/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/inspector2/lib/vulnerabilities.py b/prowler/providers/aws/services/inspector2/lib/vulnerabilities.py new file mode 100644 index 0000000000..7544f0de4d --- /dev/null +++ b/prowler/providers/aws/services/inspector2/lib/vulnerabilities.py @@ -0,0 +1,8 @@ +MAX_LISTED_VULNERABILITIES = 10 + + +def summarize_vulnerabilities(vulnerabilities: list[str]) -> str: + """Join vulnerability identifiers, truncating long lists.""" + listed = ", ".join(vulnerabilities[:MAX_LISTED_VULNERABILITIES]) + remaining = len(vulnerabilities) - MAX_LISTED_VULNERABILITIES + return f"{listed} and {remaining} more" if remaining > 0 else listed diff --git a/prowler/providers/aws/services/transfer/transfer_server_fips_security_policy_enabled/__init__.py b/prowler/providers/aws/services/transfer/transfer_server_fips_security_policy_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/transfer/transfer_server_fips_security_policy_enabled/transfer_server_fips_security_policy_enabled.metadata.json b/prowler/providers/aws/services/transfer/transfer_server_fips_security_policy_enabled/transfer_server_fips_security_policy_enabled.metadata.json new file mode 100644 index 0000000000..c1d8b48e20 --- /dev/null +++ b/prowler/providers/aws/services/transfer/transfer_server_fips_security_policy_enabled/transfer_server_fips_security_policy_enabled.metadata.json @@ -0,0 +1,42 @@ +{ + "Provider": "aws", + "CheckID": "transfer_server_fips_security_policy_enabled", + "CheckTitle": "AWS Transfer Family server uses a FIPS security policy", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "transfer", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "AwsTransferServer", + "ResourceGroup": "network", + "Description": "**AWS Transfer Family servers** (SFTP, FTPS, AS2) are assessed for use of a **FIPS** security policy (`TransferSecurityPolicy-FIPS-*`, flagged `Fips: true` by AWS), which limits file-transfer sessions to the FIPS-enabled set of SSH and TLS algorithms.", + "Risk": "Servers without a FIPS security policy can negotiate algorithms outside the FIPS-enabled set, which does not meet requirements to protect federal or regulated files and credentials with **NIST CMVP validated cryptography**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/transfer/latest/userguide/security-policies.html", + "https://aws.amazon.com/compliance/fips/" + ], + "Remediation": { + "Code": { + "CLI": "aws transfer update-server --server-id --security-policy-name TransferSecurityPolicy-FIPS-2025-03", + "NativeIaC": "```yaml\nResources:\n :\n Type: AWS::Transfer::Server\n Properties:\n Protocols:\n - SFTP\n SecurityPolicyName: TransferSecurityPolicy-FIPS-2025-03 # FIX: FIPS security policy\n```", + "Other": "1. In the AWS Console, go to AWS Transfer Family > Servers\n2. Select the server and choose Edit on the Additional details panel\n3. Set Cryptographic algorithm options (Security policy) to a FIPS policy such as TransferSecurityPolicy-FIPS-2025-03\n4. Save the changes", + "Terraform": "```hcl\nresource \"aws_transfer_server\" \"\" {\n protocols = [\"SFTP\"]\n security_policy_name = \"TransferSecurityPolicy-FIPS-2025-03\" # FIX: FIPS security policy\n}\n```" + }, + "Recommendation": { + "Text": "Use a **FIPS** security policy, such as `TransferSecurityPolicy-FIPS-2025-03`, on every Transfer Family server that exchanges federal or regulated data.", + "Url": "https://hub.prowler.com/check/transfer_server_fips_security_policy_enabled" + } + }, + "Categories": [ + "encryption" + ], + "DependsOn": [], + "RelatedTo": [ + "transfer_server_in_transit_encryption_enabled", + "transfer_server_pqc_ssh_kex_enabled" + ], + "Notes": "" +} diff --git a/prowler/providers/aws/services/transfer/transfer_server_fips_security_policy_enabled/transfer_server_fips_security_policy_enabled.py b/prowler/providers/aws/services/transfer/transfer_server_fips_security_policy_enabled/transfer_server_fips_security_policy_enabled.py new file mode 100644 index 0000000000..99d86097ba --- /dev/null +++ b/prowler/providers/aws/services/transfer/transfer_server_fips_security_policy_enabled/transfer_server_fips_security_policy_enabled.py @@ -0,0 +1,38 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.transfer.transfer_client import transfer_client + + +class transfer_server_fips_security_policy_enabled(Check): + """Ensure every AWS Transfer Family server uses a FIPS security policy.""" + + def execute(self) -> list[Check_Report_AWS]: + """Report whether each Transfer Family server uses a FIPS security policy.""" + findings = [] + unretrieved_servers = [] + for server in transfer_client.servers.values(): + policy = server.security_policy_name + if not policy: + unretrieved_servers.append(server.id) + continue + report = Check_Report_AWS(metadata=self.metadata(), resource=server) + if "FIPS" in policy.split("-"): + report.status = "PASS" + report.status_extended = ( + f"Transfer Server {server.id} uses FIPS security policy {policy}." + ) + else: + report.status = "FAIL" + report.status_extended = f"Transfer Server {server.id} uses security policy {policy}, which is not a FIPS security policy." + findings.append(report) + if unretrieved_servers: + report = Check_Report_AWS(metadata=self.metadata(), resource={}) + report.resource_id = transfer_client.audited_account + report.resource_arn = transfer_client.audited_account_arn + report.region = transfer_client.region + report.status = "MANUAL" + report.status_extended = ( + "Transfer Server security policies could not be retrieved for " + f"{', '.join(unretrieved_servers)}; verify the transfer:DescribeServer permission." + ) + findings.append(report) + return findings diff --git a/tests/config/config_test.py b/tests/config/config_test.py index fbff8ade29..4d469ef009 100644 --- a/tests/config/config_test.py +++ b/tests/config/config_test.py @@ -138,6 +138,8 @@ config_aws = { "organizations_enabled_regions": [], "organizations_trusted_delegated_administrators": [], "ecr_repository_vulnerability_minimum_severity": "MEDIUM", + "inspector2_max_days_since_last_scan": 3, + "inspector2_active_finding_max_age_days": 192, "verify_premium_support_plans": True, "threat_detection_privilege_escalation_threshold": 0.2, "threat_detection_privilege_escalation_minutes": 1440, diff --git a/tests/config/fixtures/config.yaml b/tests/config/fixtures/config.yaml index a64e497544..8b02ba2d43 100644 --- a/tests/config/fixtures/config.yaml +++ b/tests/config/fixtures/config.yaml @@ -139,6 +139,14 @@ aws: # MEDIUM ecr_repository_vulnerability_minimum_severity: "MEDIUM" + # AWS Inspector2 + # aws.inspector2_coverage_recently_scanned + # Maximum days since Inspector2 last scanned an actively covered resource + inspector2_max_days_since_last_scan: 3 + # aws.inspector2_active_findings_within_max_age + # Maximum days an Inspector2 finding can stay active since it was first observed + inspector2_active_finding_max_age_days: 192 + # AWS Trusted Advisor # aws.trustedadvisor_premium_support_plan_subscribed verify_premium_support_plans: True diff --git a/tests/providers/aws/services/elbv2/elbv2_listener_fips_tls_enabled/elbv2_listener_fips_tls_enabled_test.py b/tests/providers/aws/services/elbv2/elbv2_listener_fips_tls_enabled/elbv2_listener_fips_tls_enabled_test.py new file mode 100644 index 0000000000..ee1afde340 --- /dev/null +++ b/tests/providers/aws/services/elbv2/elbv2_listener_fips_tls_enabled/elbv2_listener_fips_tls_enabled_test.py @@ -0,0 +1,156 @@ +from unittest import mock + +from boto3 import client, resource +from moto import mock_aws + +from tests.providers.aws.utils import ( + AWS_REGION_EU_WEST_1, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +CHECK_MODULE = "prowler.providers.aws.services.elbv2.elbv2_listener_fips_tls_enabled.elbv2_listener_fips_tls_enabled" +FIPS_POLICY = "ELBSecurityPolicy-TLS13-1-2-FIPS-2023-04" +NON_FIPS_POLICY = "ELBSecurityPolicy-TLS13-1-2-2021-06" + + +def create_application_load_balancer(): + conn = client("elbv2", region_name=AWS_REGION_EU_WEST_1) + ec2 = resource("ec2", region_name=AWS_REGION_EU_WEST_1) + security_group = ec2.create_security_group( + GroupName="a-security-group", Description="First One" + ) + vpc = ec2.create_vpc(CidrBlock="172.28.7.0/24", InstanceTenancy="default") + subnet1 = ec2.create_subnet( + VpcId=vpc.id, + CidrBlock="172.28.7.192/26", + AvailabilityZone=f"{AWS_REGION_EU_WEST_1}a", + ) + subnet2 = ec2.create_subnet( + VpcId=vpc.id, + CidrBlock="172.28.7.0/26", + AvailabilityZone=f"{AWS_REGION_EU_WEST_1}b", + ) + lb = conn.create_load_balancer( + Name="my-lb", + Subnets=[subnet1.id, subnet2.id], + SecurityGroups=[security_group.id], + Scheme="internal", + Type="application", + )["LoadBalancers"][0] + target_group_arn = conn.create_target_group( + Name="a-target", Protocol="HTTP", Port=8080, VpcId=vpc.id + )["TargetGroups"][0]["TargetGroupArn"] + return conn, lb, target_group_arn + + +def create_listener(conn, lb, target_group_arn, protocol, port, ssl_policy=None): + listener_args = { + "LoadBalancerArn": lb["LoadBalancerArn"], + "Protocol": protocol, + "Port": port, + "DefaultActions": [{"Type": "forward", "TargetGroupArn": target_group_arn}], + } + if ssl_policy: + listener_args["SslPolicy"] = ssl_policy + return conn.create_listener(**listener_args)["Listeners"][0] + + +def execute_check(service=None): + from prowler.providers.aws.services.elbv2.elbv2_service import ELBv2 + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1], + create_default_organization=False, + ) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.elbv2_client", new=service or ELBv2(aws_provider)), + ): + from prowler.providers.aws.services.elbv2.elbv2_listener_fips_tls_enabled.elbv2_listener_fips_tls_enabled import ( + elbv2_listener_fips_tls_enabled, + ) + + return elbv2_listener_fips_tls_enabled().execute() + + +class Test_elbv2_listener_fips_tls_enabled: + @mock_aws + def test_no_load_balancers(self): + assert execute_check() == [] + + @mock_aws + def test_http_listener_only(self): + conn, lb, target_group_arn = create_application_load_balancer() + create_listener(conn, lb, target_group_arn, "HTTP", 80) + + result = execute_check() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == "ELBv2 my-lb has no HTTPS/TLS listeners." + + @mock_aws + def test_fips_policy(self): + conn, lb, target_group_arn = create_application_load_balancer() + create_listener(conn, lb, target_group_arn, "HTTPS", 443, FIPS_POLICY) + + result = execute_check() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "ELBv2 my-lb has all HTTPS/TLS listeners using a FIPS TLS security policy." + ) + assert result[0].resource_id == "my-lb" + assert result[0].resource_arn == lb["LoadBalancerArn"] + assert result[0].region == AWS_REGION_EU_WEST_1 + + @mock_aws + def test_non_fips_policy(self): + conn, lb, target_group_arn = create_application_load_balancer() + listener = create_listener( + conn, lb, target_group_arn, "HTTPS", 443, NON_FIPS_POLICY + ) + + result = execute_check() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"ELBv2 my-lb has HTTPS/TLS listeners without a FIPS TLS security policy: HTTPS:443 ({listener['ListenerArn']}) uses {NON_FIPS_POLICY}." + ) + + @mock_aws + def test_mixed_listeners(self): + conn, lb, target_group_arn = create_application_load_balancer() + create_listener(conn, lb, target_group_arn, "HTTPS", 443, FIPS_POLICY) + create_listener(conn, lb, target_group_arn, "HTTPS", 8443, NON_FIPS_POLICY) + + result = execute_check() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert NON_FIPS_POLICY in result[0].status_extended + assert FIPS_POLICY not in result[0].status_extended + + @mock_aws + def test_listener_discovery_failed(self): + from prowler.providers.aws.services.elbv2.elbv2_service import ELBv2 + + conn, lb, target_group_arn = create_application_load_balancer() + create_listener(conn, lb, target_group_arn, "HTTPS", 443, NON_FIPS_POLICY) + service = ELBv2( + set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1], + create_default_organization=False, + ) + ) + service.loadbalancersv2[lb["LoadBalancerArn"]].listener_discovery_failed = True + + assert execute_check(service) == [] diff --git a/tests/providers/aws/services/inspector2/inspector2_active_findings_kev_within_due_date/inspector2_active_findings_kev_within_due_date_test.py b/tests/providers/aws/services/inspector2/inspector2_active_findings_kev_within_due_date/inspector2_active_findings_kev_within_due_date_test.py new file mode 100644 index 0000000000..bf57b346c2 --- /dev/null +++ b/tests/providers/aws/services/inspector2/inspector2_active_findings_kev_within_due_date/inspector2_active_findings_kev_within_due_date_test.py @@ -0,0 +1,136 @@ +from datetime import datetime, timedelta, timezone +from unittest import mock + +from prowler.providers.aws.services.inspector2.inspector2_service import ( + Finding, + Inspector, + KnownExploitedVulnerability, +) +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_EU_WEST_1, + set_mocked_aws_provider, +) + +INSPECTOR_ARN = ( + f"arn:aws:inspector2:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:inspector2" +) +FINDING_ARN = f"arn:aws:inspector2:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:finding/0e436649379db5f327e3cf5bb4421d76" +KEV_ID = "CVE-2024-3400" +CHECK_MODULE = "prowler.providers.aws.services.inspector2.inspector2_active_findings_kev_within_due_date.inspector2_active_findings_kev_within_due_date" + + +def build_inspector(findings=None, status="ENABLED"): + return Inspector( + id="Inspector2", + arn=INSPECTOR_ARN, + region=AWS_REGION_EU_WEST_1, + status=status, + ec2_status="ENABLED", + ecr_status="ENABLED", + lambda_status="ENABLED", + lambda_code_status="ENABLED", + findings=findings, + ) + + +def build_finding(vulnerability_id=KEV_ID): + return Finding( + arn=FINDING_ARN, + type="PACKAGE_VULNERABILITY", + severity="CRITICAL", + first_observed_at=datetime.now(timezone.utc), + vulnerability_id=vulnerability_id, + resource_ids=["i-0123456789abcdef0"], + ) + + +def build_kev(date_due): + return KnownExploitedVulnerability( + id=KEV_ID, + date_added=datetime(2024, 4, 12, tzinfo=timezone.utc), + date_due=date_due, + ) + + +def execute_check(inspectors, known_exploited=None, lookup_failed=None): + inspector2_client = mock.MagicMock() + inspector2_client.inspectors = inspectors + inspector2_client.known_exploited_vulnerabilities = known_exploited or {} + inspector2_client.vulnerability_lookup_failed = lookup_failed or set() + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider([AWS_REGION_EU_WEST_1]), + ), + mock.patch(f"{CHECK_MODULE}.inspector2_client", new=inspector2_client), + ): + from prowler.providers.aws.services.inspector2.inspector2_active_findings_kev_within_due_date.inspector2_active_findings_kev_within_due_date import ( + inspector2_active_findings_kev_within_due_date, + ) + + return inspector2_active_findings_kev_within_due_date().execute() + + +class Test_inspector2_active_findings_kev_within_due_date: + def test_no_resources(self): + assert execute_check([]) == [] + + def test_inspector_disabled(self): + assert execute_check([build_inspector(findings=[], status="DISABLED")]) == [] + + def test_kev_past_due_date(self): + result = execute_check( + [build_inspector(findings=[build_finding()])], + known_exploited={ + KEV_ID: build_kev(datetime(2024, 4, 19, tzinfo=timezone.utc)) + }, + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Inspector2 has active findings in region {AWS_REGION_EU_WEST_1} for CISA Known Exploited Vulnerabilities past their remediation due date: {KEV_ID} (due 2024-04-19)." + ) + assert result[0].resource_id == "Inspector2" + assert result[0].resource_arn == INSPECTOR_ARN + assert result[0].region == AWS_REGION_EU_WEST_1 + + def test_kev_within_due_date(self): + result = execute_check( + [build_inspector(findings=[build_finding()])], + known_exploited={ + KEV_ID: build_kev(datetime.now(timezone.utc) + timedelta(days=7)) + }, + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Inspector2 has no active findings in region {AWS_REGION_EU_WEST_1} for CISA Known Exploited Vulnerabilities past their remediation due date." + ) + + def test_no_kev_findings(self): + result = execute_check( + [build_inspector(findings=[build_finding("CVE-2022-40897")])] + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_kev_status_not_verified(self): + result = execute_check( + [build_inspector(findings=[build_finding()])], + lookup_failed={KEV_ID}, + ) + + assert len(result) == 1 + assert result[0].status == "MANUAL" + + def test_findings_not_retrieved(self): + result = execute_check([build_inspector(findings=None)]) + + assert len(result) == 1 + assert result[0].status == "MANUAL" diff --git a/tests/providers/aws/services/inspector2/inspector2_active_findings_no_known_exploited_vulnerabilities/inspector2_active_findings_no_known_exploited_vulnerabilities_test.py b/tests/providers/aws/services/inspector2/inspector2_active_findings_no_known_exploited_vulnerabilities/inspector2_active_findings_no_known_exploited_vulnerabilities_test.py new file mode 100644 index 0000000000..288e7224ea --- /dev/null +++ b/tests/providers/aws/services/inspector2/inspector2_active_findings_no_known_exploited_vulnerabilities/inspector2_active_findings_no_known_exploited_vulnerabilities_test.py @@ -0,0 +1,149 @@ +from datetime import datetime, timezone +from unittest import mock + +from prowler.providers.aws.services.inspector2.inspector2_service import ( + Finding, + Inspector, + KnownExploitedVulnerability, +) +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_EU_WEST_1, + set_mocked_aws_provider, +) + +INSPECTOR_ARN = ( + f"arn:aws:inspector2:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:inspector2" +) +FINDING_ARN = f"arn:aws:inspector2:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:finding/0e436649379db5f327e3cf5bb4421d76" +KEV_ID = "CVE-2024-3400" +CHECK_MODULE = "prowler.providers.aws.services.inspector2.inspector2_active_findings_no_known_exploited_vulnerabilities.inspector2_active_findings_no_known_exploited_vulnerabilities" + + +def build_inspector(findings=None, status="ENABLED"): + return Inspector( + id="Inspector2", + arn=INSPECTOR_ARN, + region=AWS_REGION_EU_WEST_1, + status=status, + ec2_status="ENABLED", + ecr_status="ENABLED", + lambda_status="ENABLED", + lambda_code_status="ENABLED", + findings=findings, + ) + + +def build_finding(vulnerability_id): + return Finding( + arn=FINDING_ARN, + type="PACKAGE_VULNERABILITY", + severity="CRITICAL", + first_observed_at=datetime.now(timezone.utc), + vulnerability_id=vulnerability_id, + resource_ids=["i-0123456789abcdef0"], + ) + + +def build_kev(vulnerability_id): + return KnownExploitedVulnerability( + id=vulnerability_id, + date_added=datetime(2024, 4, 12, tzinfo=timezone.utc), + date_due=datetime(2024, 4, 19, tzinfo=timezone.utc), + ) + + +def execute_check(inspectors, known_exploited=None, lookup_failed=None): + inspector2_client = mock.MagicMock() + inspector2_client.inspectors = inspectors + inspector2_client.known_exploited_vulnerabilities = known_exploited or {} + inspector2_client.vulnerability_lookup_failed = lookup_failed or set() + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider([AWS_REGION_EU_WEST_1]), + ), + mock.patch(f"{CHECK_MODULE}.inspector2_client", new=inspector2_client), + ): + from prowler.providers.aws.services.inspector2.inspector2_active_findings_no_known_exploited_vulnerabilities.inspector2_active_findings_no_known_exploited_vulnerabilities import ( + inspector2_active_findings_no_known_exploited_vulnerabilities, + ) + + return inspector2_active_findings_no_known_exploited_vulnerabilities().execute() + + +class Test_inspector2_active_findings_no_known_exploited_vulnerabilities: + def test_no_resources(self): + assert execute_check([]) == [] + + def test_inspector_disabled(self): + assert execute_check([build_inspector(findings=[], status="DISABLED")]) == [] + + def test_no_kev_findings(self): + result = execute_check( + [build_inspector(findings=[build_finding("CVE-2022-40897")])] + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Inspector2 has no active findings in region {AWS_REGION_EU_WEST_1} for CISA Known Exploited Vulnerabilities." + ) + assert result[0].resource_id == "Inspector2" + assert result[0].resource_arn == INSPECTOR_ARN + assert result[0].region == AWS_REGION_EU_WEST_1 + + def test_kev_finding(self): + result = execute_check( + [ + build_inspector( + findings=[build_finding(KEV_ID), build_finding("CVE-2022-40897")] + ) + ], + known_exploited={KEV_ID: build_kev(KEV_ID)}, + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Inspector2 has active findings in region {AWS_REGION_EU_WEST_1} for CISA Known Exploited Vulnerabilities: {KEV_ID}." + ) + + def test_many_kev_findings_are_truncated(self): + vulnerability_ids = [f"CVE-2024-{number:04d}" for number in range(1, 13)] + result = execute_check( + [ + build_inspector( + findings=[build_finding(vid) for vid in vulnerability_ids] + ) + ], + known_exploited={vid: build_kev(vid) for vid in vulnerability_ids}, + ) + + assert result[0].status == "FAIL" + assert result[0].status_extended.endswith("CVE-2024-0010 and 2 more.") + + def test_kev_status_not_verified(self): + result = execute_check( + [build_inspector(findings=[build_finding(KEV_ID)])], + lookup_failed={KEV_ID}, + ) + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + result[0].status_extended + == f"Inspector2 could not verify the CISA Known Exploited Vulnerabilities status of {KEV_ID} in region {AWS_REGION_EU_WEST_1}; verify the inspector2:BatchGetFindingDetails permission." + ) + + def test_findings_not_retrieved(self): + result = execute_check([build_inspector(findings=None)]) + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + result[0].status_extended + == f"Inspector2 findings could not be retrieved in region {AWS_REGION_EU_WEST_1}; verify the inspector2:ListFindings permission." + ) diff --git a/tests/providers/aws/services/inspector2/inspector2_active_findings_within_max_age/inspector2_active_findings_within_max_age_test.py b/tests/providers/aws/services/inspector2/inspector2_active_findings_within_max_age/inspector2_active_findings_within_max_age_test.py new file mode 100644 index 0000000000..8b847c9014 --- /dev/null +++ b/tests/providers/aws/services/inspector2/inspector2_active_findings_within_max_age/inspector2_active_findings_within_max_age_test.py @@ -0,0 +1,140 @@ +from datetime import datetime, timedelta, timezone +from unittest import mock + +from prowler.providers.aws.services.inspector2.inspector2_service import ( + Finding, + Inspector, +) +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_EU_WEST_1, + set_mocked_aws_provider, +) + +INSPECTOR_ARN = ( + f"arn:aws:inspector2:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:inspector2" +) +FINDING_ARN = f"arn:aws:inspector2:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:finding/0e436649379db5f327e3cf5bb4421d76" +CHECK_MODULE = "prowler.providers.aws.services.inspector2.inspector2_active_findings_within_max_age.inspector2_active_findings_within_max_age" + + +def build_inspector(findings=None, status="ENABLED"): + return Inspector( + id="Inspector2", + arn=INSPECTOR_ARN, + region=AWS_REGION_EU_WEST_1, + status=status, + ec2_status="ENABLED", + ecr_status="ENABLED", + lambda_status="ENABLED", + lambda_code_status="ENABLED", + findings=findings, + ) + + +def build_finding(age_days): + first_observed_at = ( + datetime.now(timezone.utc) - timedelta(days=age_days, hours=1) + if age_days is not None + else None + ) + return Finding( + arn=FINDING_ARN, + type="PACKAGE_VULNERABILITY", + severity="HIGH", + first_observed_at=first_observed_at, + vulnerability_id="CVE-2022-40897", + resource_ids=["i-0123456789abcdef0"], + ) + + +def execute_check(inspectors, audit_config=None): + inspector2_client = mock.MagicMock() + inspector2_client.inspectors = inspectors + inspector2_client.audit_config = audit_config or {} + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider([AWS_REGION_EU_WEST_1]), + ), + mock.patch(f"{CHECK_MODULE}.inspector2_client", new=inspector2_client), + ): + from prowler.providers.aws.services.inspector2.inspector2_active_findings_within_max_age.inspector2_active_findings_within_max_age import ( + inspector2_active_findings_within_max_age, + ) + + return inspector2_active_findings_within_max_age().execute() + + +class Test_inspector2_active_findings_within_max_age: + def test_no_resources(self): + assert execute_check([]) == [] + + def test_inspector_disabled(self): + assert execute_check([build_inspector(findings=[], status="DISABLED")]) == [] + + def test_no_active_findings(self): + result = execute_check([build_inspector(findings=[])]) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Inspector2 has no active findings in region {AWS_REGION_EU_WEST_1} first observed more than 192 days ago." + ) + assert result[0].resource_id == "Inspector2" + assert result[0].resource_arn == INSPECTOR_ARN + assert result[0].region == AWS_REGION_EU_WEST_1 + + def test_recent_findings(self): + result = execute_check([build_inspector(findings=[build_finding(30)])]) + + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_stale_findings(self): + result = execute_check( + [ + build_inspector( + findings=[ + build_finding(30), + build_finding(200), + build_finding(400), + ] + ) + ] + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Inspector2 has 2 active findings in region {AWS_REGION_EU_WEST_1} first observed more than 192 days ago, the oldest 400 days ago." + ) + + def test_finding_just_over_max_age(self): + result = execute_check([build_inspector(findings=[build_finding(192)])]) + + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_custom_max_age(self): + result = execute_check( + [build_inspector(findings=[build_finding(30)])], + audit_config={"inspector2_active_finding_max_age_days": 14}, + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_finding_without_first_observed_date_is_ignored(self): + result = execute_check([build_inspector(findings=[build_finding(None)])]) + + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_findings_not_retrieved(self): + result = execute_check([build_inspector(findings=None)]) + + assert len(result) == 1 + assert result[0].status == "MANUAL" diff --git a/tests/providers/aws/services/inspector2/inspector2_coverage_recently_scanned/inspector2_coverage_recently_scanned_test.py b/tests/providers/aws/services/inspector2/inspector2_coverage_recently_scanned/inspector2_coverage_recently_scanned_test.py new file mode 100644 index 0000000000..64d757feb2 --- /dev/null +++ b/tests/providers/aws/services/inspector2/inspector2_coverage_recently_scanned/inspector2_coverage_recently_scanned_test.py @@ -0,0 +1,170 @@ +from datetime import datetime, timedelta, timezone +from unittest import mock + +from prowler.providers.aws.services.inspector2.inspector2_service import ( + CoveredResource, + Inspector, +) +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_EU_WEST_1, + set_mocked_aws_provider, +) + +INSPECTOR_ARN = ( + f"arn:aws:inspector2:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:inspector2" +) +INSTANCE_ID = "i-0123456789abcdef0" +INSTANCE_ARN = ( + f"arn:aws:ec2:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:instance/{INSTANCE_ID}" +) +CHECK_MODULE = "prowler.providers.aws.services.inspector2.inspector2_coverage_recently_scanned.inspector2_coverage_recently_scanned" + + +def build_inspector(coverage=None, status="ENABLED"): + return Inspector( + id="Inspector2", + arn=INSPECTOR_ARN, + region=AWS_REGION_EU_WEST_1, + status=status, + ec2_status="ENABLED", + ecr_status="ENABLED", + lambda_status="ENABLED", + lambda_code_status="ENABLED", + coverage=coverage, + ) + + +def build_instance( + days_since_scan=None, scan_status_code="ACTIVE", scan_status_reason="SUCCESSFUL" +): + last_scanned_at = ( + datetime.now(timezone.utc) - timedelta(days=days_since_scan, hours=1) + if days_since_scan is not None + else None + ) + return CoveredResource( + id=INSTANCE_ID, + arn=INSTANCE_ARN, + region=AWS_REGION_EU_WEST_1, + resource_type="AWS_EC2_INSTANCE", + scan_type="PACKAGE", + scan_status_code=scan_status_code, + scan_status_reason=scan_status_reason, + last_scanned_at=last_scanned_at, + ) + + +def execute_check(inspectors, audit_config=None): + inspector2_client = mock.MagicMock() + inspector2_client.inspectors = inspectors + inspector2_client.audit_config = audit_config or {} + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider([AWS_REGION_EU_WEST_1]), + ), + mock.patch(f"{CHECK_MODULE}.inspector2_client", new=inspector2_client), + ): + from prowler.providers.aws.services.inspector2.inspector2_coverage_recently_scanned.inspector2_coverage_recently_scanned import ( + inspector2_coverage_recently_scanned, + ) + + return inspector2_coverage_recently_scanned().execute() + + +class Test_inspector2_coverage_recently_scanned: + def test_no_resources(self): + assert execute_check([]) == [] + + def test_inspector_disabled(self): + assert execute_check([build_inspector(status="DISABLED", coverage=[])]) == [] + + def test_recently_scanned_resource(self): + result = execute_check([build_inspector(coverage=[build_instance(1)])]) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"AWS_EC2_INSTANCE {INSTANCE_ID} was last scanned by Inspector2 within the last 3 days." + ) + assert result[0].resource_id == INSTANCE_ID + assert result[0].resource_arn == INSTANCE_ARN + assert result[0].region == AWS_REGION_EU_WEST_1 + + def test_stale_resource(self): + result = execute_check([build_inspector(coverage=[build_instance(10)])]) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"AWS_EC2_INSTANCE {INSTANCE_ID} was last scanned by Inspector2 more than 3 days ago." + ) + + def test_resource_scanned_just_over_max_days(self): + result = execute_check([build_inspector(coverage=[build_instance(3)])]) + + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_custom_max_days(self): + result = execute_check( + [build_inspector(coverage=[build_instance(10)])], + audit_config={"inspector2_max_days_since_last_scan": 14}, + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_resource_without_recorded_scan(self): + result = execute_check([build_inspector(coverage=[build_instance(None)])]) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"AWS_EC2_INSTANCE {INSTANCE_ID} has no recorded Inspector2 scan." + ) + + def test_pending_initial_scan_is_skipped(self): + assert ( + execute_check( + [ + build_inspector( + coverage=[ + build_instance( + None, scan_status_reason="PENDING_INITIAL_SCAN" + ) + ] + ) + ] + ) + == [] + ) + + def test_inactive_resource_is_skipped(self): + assert ( + execute_check( + [ + build_inspector( + coverage=[ + build_instance( + 10, + scan_status_code="INACTIVE", + scan_status_reason="NO_INVENTORY", + ) + ] + ) + ] + ) + == [] + ) + + def test_coverage_not_retrieved(self): + result = execute_check([build_inspector(coverage=None)]) + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert result[0].resource_arn == INSPECTOR_ARN diff --git a/tests/providers/aws/services/inspector2/inspector2_coverage_scan_status_active/inspector2_coverage_scan_status_active_test.py b/tests/providers/aws/services/inspector2/inspector2_coverage_scan_status_active/inspector2_coverage_scan_status_active_test.py new file mode 100644 index 0000000000..1ce2e21c4a --- /dev/null +++ b/tests/providers/aws/services/inspector2/inspector2_coverage_scan_status_active/inspector2_coverage_scan_status_active_test.py @@ -0,0 +1,132 @@ +from datetime import datetime, timezone +from unittest import mock + +from prowler.providers.aws.services.inspector2.inspector2_service import ( + CoveredResource, + Inspector, +) +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_EU_WEST_1, + set_mocked_aws_provider, +) + +INSPECTOR_ARN = ( + f"arn:aws:inspector2:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:inspector2" +) +INSTANCE_ID = "i-0123456789abcdef0" +INSTANCE_ARN = ( + f"arn:aws:ec2:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:instance/{INSTANCE_ID}" +) +CHECK_MODULE = "prowler.providers.aws.services.inspector2.inspector2_coverage_scan_status_active.inspector2_coverage_scan_status_active" + + +def build_inspector(status="ENABLED", coverage=None): + return Inspector( + id="Inspector2", + arn=INSPECTOR_ARN, + region=AWS_REGION_EU_WEST_1, + status=status, + ec2_status="ENABLED", + ecr_status="ENABLED", + lambda_status="ENABLED", + lambda_code_status="ENABLED", + coverage=coverage, + ) + + +def build_instance(scan_status_code, scan_status_reason): + return CoveredResource( + id=INSTANCE_ID, + arn=INSTANCE_ARN, + region=AWS_REGION_EU_WEST_1, + resource_type="AWS_EC2_INSTANCE", + scan_type="PACKAGE", + scan_status_code=scan_status_code, + scan_status_reason=scan_status_reason, + last_scanned_at=datetime.now(timezone.utc), + ) + + +def execute_check(inspectors): + inspector2_client = mock.MagicMock() + inspector2_client.inspectors = inspectors + inspector2_client.audit_config = {} + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider([AWS_REGION_EU_WEST_1]), + ), + mock.patch(f"{CHECK_MODULE}.inspector2_client", new=inspector2_client), + ): + from prowler.providers.aws.services.inspector2.inspector2_coverage_scan_status_active.inspector2_coverage_scan_status_active import ( + inspector2_coverage_scan_status_active, + ) + + return inspector2_coverage_scan_status_active().execute() + + +class Test_inspector2_coverage_scan_status_active: + def test_no_resources(self): + assert execute_check([]) == [] + + def test_inspector_disabled(self): + assert execute_check([build_inspector(status="DISABLED", coverage=[])]) == [] + + def test_no_covered_resources(self): + assert execute_check([build_inspector(coverage=[])]) == [] + + def test_active_resource(self): + result = execute_check( + [build_inspector(coverage=[build_instance("ACTIVE", "SUCCESSFUL")])] + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Inspector2 is actively scanning AWS_EC2_INSTANCE {INSTANCE_ID}." + ) + assert result[0].resource_id == INSTANCE_ID + assert result[0].resource_arn == INSTANCE_ARN + assert result[0].region == AWS_REGION_EU_WEST_1 + + def test_inactive_resource(self): + result = execute_check( + [ + build_inspector( + coverage=[build_instance("INACTIVE", "UNMANAGED_EC2_INSTANCE")] + ) + ] + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Inspector2 is not scanning AWS_EC2_INSTANCE {INSTANCE_ID}: UNMANAGED_EC2_INSTANCE." + ) + assert result[0].resource_id == INSTANCE_ID + + def test_not_applicable_resource_is_skipped(self): + assert ( + execute_check( + [ + build_inspector( + coverage=[build_instance("INACTIVE", "EC2_INSTANCE_STOPPED")] + ) + ] + ) + == [] + ) + + def test_coverage_not_retrieved(self): + result = execute_check([build_inspector(coverage=None)]) + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + result[0].status_extended + == f"Inspector2 coverage could not be retrieved in region {AWS_REGION_EU_WEST_1}; verify the inspector2:ListCoverage permission." + ) + assert result[0].resource_arn == INSPECTOR_ARN diff --git a/tests/providers/aws/services/inspector2/inspector2_service_test.py b/tests/providers/aws/services/inspector2/inspector2_service_test.py index c84797eacd..7695e889eb 100644 --- a/tests/providers/aws/services/inspector2/inspector2_service_test.py +++ b/tests/providers/aws/services/inspector2/inspector2_service_test.py @@ -1,18 +1,30 @@ -from datetime import datetime +from datetime import datetime, timezone from unittest.mock import patch import botocore +from botocore.exceptions import ClientError -from prowler.providers.aws.services.inspector2.inspector2_service import Inspector2 +from prowler.providers.aws.services.inspector2.inspector2_service import ( + Finding, + Inspector, + Inspector2, +) from tests.providers.aws.utils import ( AWS_ACCOUNT_NUMBER, AWS_REGION_EU_WEST_1, + AWS_REGION_US_EAST_1, set_mocked_aws_provider, ) FINDING_ARN = ( "arn:aws:inspector2:us-east-1:123456789012:finding/0e436649379db5f327e3cf5bb4421d76" ) +VULNERABILITY_ID = "CVE-2022-40897" +INSTANCE_ID = "i-0123456789abcdef0" +FIRST_OBSERVED_AT = datetime(2024, 1, 1, tzinfo=timezone.utc) +LAST_SCANNED_AT = datetime(2024, 6, 1, tzinfo=timezone.utc) +KEV_DATE_ADDED = datetime(2024, 4, 12, tzinfo=timezone.utc) +KEV_DATE_DUE = datetime(2024, 5, 3, tzinfo=timezone.utc) # Mocking Calls make_api_call = botocore.client.BaseClient._make_api_call @@ -64,16 +76,83 @@ def mock_make_api_call(self, operation_name, kwargs): "description": "Finding Description", "severity": "MEDIUM", "status": "ACTIVE", - "title": "CVE-2022-40897 - setuptools", + "title": f"{VULNERABILITY_ID} - setuptools", "type": "PACKAGE_VULNERABILITY", + "firstObservedAt": FIRST_OBSERVED_AT, "updatedAt": datetime(2024, 1, 1), + "packageVulnerabilityDetails": { + "vulnerabilityId": VULNERABILITY_ID + }, + "resources": [{"id": INSTANCE_ID, "type": "AWS_EC2_INSTANCE"}], } ] } + if operation_name == "ListCoverage": + return { + "coveredResources": [ + { + "resourceId": INSTANCE_ID, + "resourceType": "AWS_EC2_INSTANCE", + "accountId": AWS_ACCOUNT_NUMBER, + "scanType": "PACKAGE", + "scanStatus": {"statusCode": "ACTIVE", "reason": "SUCCESSFUL"}, + "lastScannedAt": LAST_SCANNED_AT, + } + ] + } + if operation_name == "BatchGetFindingDetails": + return { + "findingDetails": [ + { + "findingArn": FINDING_ARN, + "cisaData": { + "dateAdded": KEV_DATE_ADDED, + "dateDue": KEV_DATE_DUE, + }, + } + ], + "errors": [], + } return make_api_call(self, operation_name, kwargs) +def mock_make_api_call_finding_details_denied(self, operation_name, kwargs): + if operation_name == "BatchGetFindingDetails": + raise ClientError( + {"Error": {"Code": "AccessDeniedException", "Message": "denied"}}, + operation_name, + ) + return mock_make_api_call(self, operation_name, kwargs) + + +def mock_finding_details_error(error_code): + def _mock(self, operation_name, kwargs): + if operation_name == "BatchGetFindingDetails": + return { + "findingDetails": [], + "errors": [ + { + "findingArn": FINDING_ARN, + "errorCode": error_code, + "errorMessage": "error", + } + ], + } + return mock_make_api_call(self, operation_name, kwargs) + + return _mock + + +def mock_make_api_call_list_denied(self, operation_name, kwargs): + if operation_name in ("ListFindings", "ListCoverage"): + raise ClientError( + {"Error": {"Code": "AccessDeniedException", "Message": "denied"}}, + operation_name, + ) + return mock_make_api_call(self, operation_name, kwargs) + + def mock_generate_regional_clients(provider, service): regional_client = provider._session.current_session.client( service, region_name=AWS_REGION_EU_WEST_1 @@ -82,6 +161,29 @@ def mock_generate_regional_clients(provider, service): return {AWS_REGION_EU_WEST_1: regional_client} +def build_inspector(region, vulnerability_ids): + return Inspector( + id="Inspector2", + arn=f"arn:aws:inspector2:{region}:{AWS_ACCOUNT_NUMBER}:inspector2", + region=region, + status="ENABLED", + ec2_status="ENABLED", + ecr_status="ENABLED", + lambda_status="ENABLED", + lambda_code_status="ENABLED", + findings=[ + Finding( + arn=f"arn:aws:inspector2:{region}:{AWS_ACCOUNT_NUMBER}:finding/{index}", + type="PACKAGE_VULNERABILITY", + severity="HIGH", + first_observed_at=FIRST_OBSERVED_AT, + vulnerability_id=vulnerability_id, + ) + for index, vulnerability_id in enumerate(vulnerability_ids) + ], + ) + + # Patch every AWS call using Boto3 and generate_regional_clients to have 1 client @patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call) @patch( @@ -118,3 +220,115 @@ class Test_Inspector2_Service: aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) inspector2 = Inspector2(aws_provider) assert inspector2.inspectors[0].active_findings + + def test_list_findings(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + inspector2 = Inspector2(aws_provider) + findings = inspector2.inspectors[0].findings + assert len(findings) == 1 + assert findings[0].arn == FINDING_ARN + assert findings[0].type == "PACKAGE_VULNERABILITY" + assert findings[0].severity == "MEDIUM" + assert findings[0].first_observed_at == FIRST_OBSERVED_AT + assert findings[0].vulnerability_id == VULNERABILITY_ID + assert findings[0].resource_ids == [INSTANCE_ID] + + def test_list_coverage(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + inspector2 = Inspector2(aws_provider) + coverage = inspector2.inspectors[0].coverage + assert len(coverage) == 1 + assert coverage[0].id == INSTANCE_ID + assert ( + coverage[0].arn + == f"arn:aws:ec2:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:instance/{INSTANCE_ID}" + ) + assert coverage[0].region == AWS_REGION_EU_WEST_1 + assert coverage[0].resource_type == "AWS_EC2_INSTANCE" + assert coverage[0].scan_type == "PACKAGE" + assert coverage[0].scan_status_code == "ACTIVE" + assert coverage[0].scan_status_reason == "SUCCESSFUL" + assert coverage[0].last_scanned_at == LAST_SCANNED_AT + + def test_list_coverage_keeps_audited_resources(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + aws_provider._audit_resources = [ + f"arn:aws:ec2:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:instance/{INSTANCE_ID}" + ] + inspector2 = Inspector2(aws_provider) + assert len(inspector2.inspectors[0].coverage) == 1 + + def test_list_coverage_skips_non_audited_resources(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + aws_provider._audit_resources = [ + f"arn:aws:ec2:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:instance/i-0fedcba9876543210" + ] + inspector2 = Inspector2(aws_provider) + assert inspector2.inspectors[0].coverage == [] + + def test_batch_get_finding_details(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + inspector2 = Inspector2(aws_provider) + known_exploited = inspector2.known_exploited_vulnerabilities[VULNERABILITY_ID] + assert known_exploited.id == VULNERABILITY_ID + assert known_exploited.date_added == KEV_DATE_ADDED + assert known_exploited.date_due == KEV_DATE_DUE + assert inspector2.vulnerability_lookup_failed == set() + + def test_batch_get_finding_details_denied(self): + with patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_finding_details_denied, + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + inspector2 = Inspector2(aws_provider) + assert inspector2.known_exploited_vulnerabilities == {} + assert inspector2.vulnerability_lookup_failed == {VULNERABILITY_ID} + + def test_finding_details_not_found_is_not_a_lookup_failure(self): + with patch( + "botocore.client.BaseClient._make_api_call", + new=mock_finding_details_error("FINDING_DETAILS_NOT_FOUND"), + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + inspector2 = Inspector2(aws_provider) + assert inspector2.known_exploited_vulnerabilities == {} + assert inspector2.vulnerability_lookup_failed == set() + + def test_finding_details_error_is_a_lookup_failure(self): + with patch( + "botocore.client.BaseClient._make_api_call", + new=mock_finding_details_error("INTERNAL_ERROR"), + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + inspector2 = Inspector2(aws_provider) + assert inspector2.known_exploited_vulnerabilities == {} + assert inspector2.vulnerability_lookup_failed == {VULNERABILITY_ID} + + def test_list_findings_and_coverage_denied(self): + with patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_list_denied, + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + inspector2 = Inspector2(aws_provider) + assert inspector2.inspectors[0].findings is None + assert inspector2.inspectors[0].coverage is None + assert inspector2.known_exploited_vulnerabilities == {} + + def test_finding_detail_batches_use_one_finding_per_cve(self): + vulnerability_ids = [f"CVE-2024-{number:04d}" for number in range(25)] + batches = Inspector2._get_finding_detail_batches( + [ + build_inspector( + AWS_REGION_EU_WEST_1, + vulnerability_ids + vulnerability_ids[:5] + ["GHSA-xxxx-yyyy-zzzz"], + ), + build_inspector(AWS_REGION_US_EAST_1, vulnerability_ids[:3]), + ] + ) + assert [region for region, _ in batches] == [AWS_REGION_EU_WEST_1] * 3 + assert [len(findings) for _, findings in batches] == [10, 10, 5] + assert sorted(cve for _, findings in batches for _, cve in findings) == sorted( + vulnerability_ids + ) diff --git a/tests/providers/aws/services/transfer/transfer_server_fips_security_policy_enabled/transfer_server_fips_security_policy_enabled_test.py b/tests/providers/aws/services/transfer/transfer_server_fips_security_policy_enabled/transfer_server_fips_security_policy_enabled_test.py new file mode 100644 index 0000000000..c60add1918 --- /dev/null +++ b/tests/providers/aws/services/transfer/transfer_server_fips_security_policy_enabled/transfer_server_fips_security_policy_enabled_test.py @@ -0,0 +1,111 @@ +from unittest import mock +from unittest.mock import patch + +import botocore +from moto import mock_aws + +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +SERVER_ID = "s-01234567890abcdef" +SERVER_ARN = ( + f"arn:aws:transfer:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:server/{SERVER_ID}" +) +CHECK_MODULE = "prowler.providers.aws.services.transfer.transfer_server_fips_security_policy_enabled.transfer_server_fips_security_policy_enabled" + +make_api_call = botocore.client.BaseClient._make_api_call + + +def mock_server_with_policy(security_policy_name): + def _mock(self, operation_name, kwarg): + if operation_name == "ListServers": + return {"Servers": [{"Arn": SERVER_ARN, "ServerId": SERVER_ID}]} + if operation_name == "DescribeServer": + return { + "Server": { + "Arn": SERVER_ARN, + "ServerId": SERVER_ID, + "Protocols": ["SFTP"], + "SecurityPolicyName": security_policy_name, + } + } + return make_api_call(self, operation_name, kwarg) + + return _mock + + +def execute_check(): + from prowler.providers.aws.services.transfer.transfer_service import Transfer + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.transfer_client", new=Transfer(aws_provider)), + ): + from prowler.providers.aws.services.transfer.transfer_server_fips_security_policy_enabled.transfer_server_fips_security_policy_enabled import ( + transfer_server_fips_security_policy_enabled, + ) + + return transfer_server_fips_security_policy_enabled().execute() + + +class Test_transfer_server_fips_security_policy_enabled: + @mock_aws + def test_no_servers(self): + assert execute_check() == [] + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_server_with_policy("TransferSecurityPolicy-FIPS-2025-03"), + ) + @mock_aws + def test_fips_policy(self): + result = execute_check() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Transfer Server {SERVER_ID} uses FIPS security policy TransferSecurityPolicy-FIPS-2025-03." + ) + assert result[0].resource_id == SERVER_ID + assert result[0].resource_arn == SERVER_ARN + assert result[0].region == AWS_REGION_US_EAST_1 + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_server_with_policy("TransferSecurityPolicy-2024-01"), + ) + @mock_aws + def test_non_fips_policy(self): + result = execute_check() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Transfer Server {SERVER_ID} uses security policy TransferSecurityPolicy-2024-01, which is not a FIPS security policy." + ) + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_server_with_policy(""), + ) + @mock_aws + def test_policy_not_retrieved(self): + result = execute_check() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + result[0].status_extended + == f"Transfer Server security policies could not be retrieved for {SERVER_ID}; verify the transfer:DescribeServer permission." + ) + assert result[0].resource_id == AWS_ACCOUNT_NUMBER + assert result[0].region == AWS_REGION_US_EAST_1 From 682353e054fe98c0a4c57ae6baa2947e40654c7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Tue, 15 Sep 2026 09:44:32 +0200 Subject: [PATCH 05/23] fix(container): bump PowerShell to 7.5.11 in SDK and API (#12811) --- .trivyignore.yaml | 30 ------------------- Dockerfile | 6 ++-- api/Dockerfile | 6 ++-- ...pi-image-powershell-dotnet-cve.security.md | 1 + ...dk-image-powershell-dotnet-cve.security.md | 1 + 5 files changed, 8 insertions(+), 36 deletions(-) create mode 100644 api/changelog.d/api-image-powershell-dotnet-cve.security.md create mode 100644 prowler/changelog.d/sdk-image-powershell-dotnet-cve.security.md diff --git a/.trivyignore.yaml b/.trivyignore.yaml index 715c7b625e..efac339352 100644 --- a/.trivyignore.yaml +++ b/.trivyignore.yaml @@ -130,36 +130,6 @@ vulnerabilities: - "pkg:npm/ip-address" expired_at: 2027-01-31 - # CVE-2026-62901 is a DoS in System.Net.WebSockets (unchecked input for loop condition, - # CWE-606), fixed in .NET 9.0.19 / 10.0.11 (published 2026-08-11). The vulnerable runtime - # ships inside the PowerShell tarball the Dockerfile pins: 7.5.9 is the latest 7.5.x and - # bundles .NET 9.0.18; 7.6.4 bundles .NET 10.0.x < 10.0.11, so no published PowerShell - # release contains the fix yet. Prowler only invokes pwsh locally to run M365 module - # cmdlets; the image does not accept inbound WebSocket connections, so the DoS path is - # not reachable from the network. Remove this temporary suppression as soon as a - # PowerShell release shipping .NET 9.0.19+ is available. - - id: CVE-2026-62901 - purls: - - "pkg:nuget/Microsoft.NETCore.App.Runtime.linux-x64" - - "pkg:nuget/Microsoft.NETCore.App.Runtime.linux-arm64" - expired_at: 2026-09-15 - - # Modules compiled into the Trivy binary the images ship. The binary is pinned by version - # and verified by checksum in the Dockerfile; only a rebuild by its vendor moves these. - # CVE-2026-71556 affects go-git worktree operations that can follow symlinks outside a - # cloned repository. Trivy 0.73.0, the latest published release and the version the - # images ship, still pins that vulnerable version: - # https://github.com/aquasecurity/trivy/blob/v0.73.0/go.mod#L46 - # Trivy main already contains the 5.19.2 fix, but no published release includes it yet: - # https://github.com/aquasecurity/trivy/commit/a2edba9a03987ba0d2ebc8212c1a9a1e6979497b - # Prowler invokes Trivy only with `fs` on an existing local path or with `image`; it does - # not ask Trivy to clone or mutate a Git worktree, so the affected path is not reachable. - # Remove this temporary suppression as soon as a fixed Trivy release is available. - - id: CVE-2026-71556 - purls: - - "pkg:golang/github.com/go-git/go-git/v5" - expired_at: 2026-09-15 - # CVE-2026-84304 is a DoS in grpc-go <= 1.83.0: a peer fragments a gRPC stream into # millions of tiny HTTP/2 DATA frames until the receiver runs out of heap. Fixed in # 1.83.1 (published 2026-09-01). Trivy 0.74.0, the latest published release and the diff --git a/Dockerfile b/Dockerfile index 559b39c9eb..dc62fd64a6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ FROM python:3.12.13-slim-trixie@sha256:57cd7c3a7a273101a6485ba99423ee56815788280 LABEL maintainer="https://github.com/prowler-cloud/prowler" LABEL org.opencontainers.image.source="https://github.com/prowler-cloud/prowler" -ARG POWERSHELL_VERSION=7.5.9 +ARG POWERSHELL_VERSION=7.5.11 ENV POWERSHELL_VERSION=${POWERSHELL_VERSION} # Opt out of PowerShell telemetry (Application Insights -> dc.services.visualstudio.com) ENV POWERSHELL_TELEMETRY_OPTOUT=1 @@ -17,8 +17,8 @@ ENV ZIZMOR_VERSION=${ZIZMOR_VERSION} # Pinned here, not fetched with the artefact: a compromised release ships its own checksum. ARG TRIVY_SHA256_AMD64=2ae6fe3ee734b7fdf11335663e18c75ea12dccc76062f09f164a3b0f8be4371a ARG TRIVY_SHA256_ARM64=b94ce1976bbf3c15b514b605ee88be7c6d94a29be2302847ff01cb794d47aad5 -ARG POWERSHELL_SHA256_AMD64=492ff26bb958336bf61e597ce19e07648b4003bd2a08659e02f0e3e0446ebfe0 -ARG POWERSHELL_SHA256_ARM64=2503b71da3e83635592b092df59a0aca4c3606b4d9b068217bb00be989cb0d56 +ARG POWERSHELL_SHA256_AMD64=82a8b13d92b0f3ae48e56cf2f3f7961679371736ca90145ca71617c2913ba9d8 +ARG POWERSHELL_SHA256_ARM64=830ebda118c731ece3fa7e6b7e8573a21346387cbbca5b2f5e3b9bfe24f96672 ARG ZIZMOR_SHA256_AMD64=a8000f3c683319a523d3b20df0e75457ba591f049cfcbfa98966631b56733c03 ARG ZIZMOR_SHA256_ARM64=d66e37ef8a375fb07939c630ebf9709a6e0f20242bdc3faf672a7ed97e0b768d diff --git a/api/Dockerfile b/api/Dockerfile index ce5bccbb2c..2262bd00d6 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -2,7 +2,7 @@ FROM python:3.12.13-slim-trixie@sha256:57cd7c3a7a273101a6485ba99423ee56815788280 LABEL maintainer="https://github.com/prowler-cloud/api" -ARG POWERSHELL_VERSION=7.5.9 +ARG POWERSHELL_VERSION=7.5.11 ENV POWERSHELL_VERSION=${POWERSHELL_VERSION} # Opt out of PowerShell telemetry (Application Insights -> dc.services.visualstudio.com) ENV POWERSHELL_TELEMETRY_OPTOUT=1 @@ -16,8 +16,8 @@ ENV ZIZMOR_VERSION=${ZIZMOR_VERSION} # Pinned here, not fetched with the artefact: a compromised release ships its own checksum. ARG TRIVY_SHA256_AMD64=2ae6fe3ee734b7fdf11335663e18c75ea12dccc76062f09f164a3b0f8be4371a ARG TRIVY_SHA256_ARM64=b94ce1976bbf3c15b514b605ee88be7c6d94a29be2302847ff01cb794d47aad5 -ARG POWERSHELL_SHA256_AMD64=492ff26bb958336bf61e597ce19e07648b4003bd2a08659e02f0e3e0446ebfe0 -ARG POWERSHELL_SHA256_ARM64=2503b71da3e83635592b092df59a0aca4c3606b4d9b068217bb00be989cb0d56 +ARG POWERSHELL_SHA256_AMD64=82a8b13d92b0f3ae48e56cf2f3f7961679371736ca90145ca71617c2913ba9d8 +ARG POWERSHELL_SHA256_ARM64=830ebda118c731ece3fa7e6b7e8573a21346387cbbca5b2f5e3b9bfe24f96672 ARG ZIZMOR_SHA256_AMD64=a8000f3c683319a523d3b20df0e75457ba591f049cfcbfa98966631b56733c03 ARG ZIZMOR_SHA256_ARM64=d66e37ef8a375fb07939c630ebf9709a6e0f20242bdc3faf672a7ed97e0b768d diff --git a/api/changelog.d/api-image-powershell-dotnet-cve.security.md b/api/changelog.d/api-image-powershell-dotnet-cve.security.md new file mode 100644 index 0000000000..28b91a32a0 --- /dev/null +++ b/api/changelog.d/api-image-powershell-dotnet-cve.security.md @@ -0,0 +1 @@ +PowerShell from 7.5.9 to 7.5.11 in the API container image, bundling .NET runtime 9.0.20 and patching CVE-2026-62901 diff --git a/prowler/changelog.d/sdk-image-powershell-dotnet-cve.security.md b/prowler/changelog.d/sdk-image-powershell-dotnet-cve.security.md new file mode 100644 index 0000000000..370aa37289 --- /dev/null +++ b/prowler/changelog.d/sdk-image-powershell-dotnet-cve.security.md @@ -0,0 +1 @@ +PowerShell from 7.5.9 to 7.5.11 in the SDK container image, bundling .NET runtime 9.0.20 and patching CVE-2026-62901 From 61ef44a03bb8842bf82e88e25e55931c24a8729d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Tue, 15 Sep 2026 09:56:50 +0200 Subject: [PATCH 06/23] fix(m365): skip defender preset policies without rules (#12809) --- ...5-defender-preset-policy-keyerror.fixed.md | 1 + ...defender_antiphishing_policy_configured.py | 6 ++ ...ispam_policy_inbound_no_allowed_domains.py | 6 ++ ...olicy_common_attachments_filter_enabled.py | 6 ++ ...omprehensive_attachments_filter_applied.py | 6 ++ ...ications_internal_users_malware_enabled.py | 6 ++ ...der_antiphishing_policy_configured_test.py | 83 +++++++++++++++++++ ..._policy_inbound_no_allowed_domains_test.py | 62 ++++++++++++++ ..._common_attachments_filter_enabled_test.py | 74 +++++++++++++++++ ...hensive_attachments_filter_applied_test.py | 74 +++++++++++++++++ ...ons_internal_users_malware_enabled_test.py | 76 +++++++++++++++++ 11 files changed, 400 insertions(+) create mode 100644 prowler/changelog.d/m365-defender-preset-policy-keyerror.fixed.md diff --git a/prowler/changelog.d/m365-defender-preset-policy-keyerror.fixed.md b/prowler/changelog.d/m365-defender-preset-policy-keyerror.fixed.md new file mode 100644 index 0000000000..2a50037e84 --- /dev/null +++ b/prowler/changelog.d/m365-defender-preset-policy-keyerror.fixed.md @@ -0,0 +1 @@ +`KeyError` in M365 Defender malware, anti-phishing and inbound anti-spam checks when the tenant has Standard or Strict preset security policies diff --git a/prowler/providers/m365/services/defender/defender_antiphishing_policy_configured/defender_antiphishing_policy_configured.py b/prowler/providers/m365/services/defender/defender_antiphishing_policy_configured/defender_antiphishing_policy_configured.py index 61727b9d3c..5c467f941a 100644 --- a/prowler/providers/m365/services/defender/defender_antiphishing_policy_configured/defender_antiphishing_policy_configured.py +++ b/prowler/providers/m365/services/defender/defender_antiphishing_policy_configured/defender_antiphishing_policy_configured.py @@ -55,6 +55,12 @@ class defender_antiphishing_policy_configured(Check): policy_name, policy, ) in defender_client.antiphishing_policies.items(): + # Preset security policies are scoped by protection policy rules, not filter rules + if ( + not policy.default + and policy.name not in defender_client.antiphishing_rules + ): + continue report = CheckReportM365( metadata=self.metadata(), resource=policy, diff --git a/prowler/providers/m365/services/defender/defender_antispam_policy_inbound_no_allowed_domains/defender_antispam_policy_inbound_no_allowed_domains.py b/prowler/providers/m365/services/defender/defender_antispam_policy_inbound_no_allowed_domains/defender_antispam_policy_inbound_no_allowed_domains.py index 67b6643e9e..56616258e0 100644 --- a/prowler/providers/m365/services/defender/defender_antispam_policy_inbound_no_allowed_domains/defender_antispam_policy_inbound_no_allowed_domains.py +++ b/prowler/providers/m365/services/defender/defender_antispam_policy_inbound_no_allowed_domains/defender_antispam_policy_inbound_no_allowed_domains.py @@ -51,6 +51,12 @@ class defender_antispam_policy_inbound_no_allowed_domains(Check): default_policy_well_configured = False for policy in defender_client.inbound_spam_policies: + # Preset security policies are scoped by protection policy rules, not filter rules + if ( + not policy.default + and policy.identity not in defender_client.inbound_spam_rules + ): + continue report = CheckReportM365( metadata=self.metadata(), resource=policy, diff --git a/prowler/providers/m365/services/defender/defender_malware_policy_common_attachments_filter_enabled/defender_malware_policy_common_attachments_filter_enabled.py b/prowler/providers/m365/services/defender/defender_malware_policy_common_attachments_filter_enabled/defender_malware_policy_common_attachments_filter_enabled.py index a0b817cda3..d9a7080295 100644 --- a/prowler/providers/m365/services/defender/defender_malware_policy_common_attachments_filter_enabled/defender_malware_policy_common_attachments_filter_enabled.py +++ b/prowler/providers/m365/services/defender/defender_malware_policy_common_attachments_filter_enabled/defender_malware_policy_common_attachments_filter_enabled.py @@ -51,6 +51,12 @@ class defender_malware_policy_common_attachments_filter_enabled(Check): default_policy_well_configured = False for policy in defender_client.malware_policies: + # Preset security policies are scoped by protection policy rules, not filter rules + if ( + not policy.is_default + and policy.identity not in defender_client.malware_rules + ): + continue report = CheckReportM365( metadata=self.metadata(), resource=policy, diff --git a/prowler/providers/m365/services/defender/defender_malware_policy_comprehensive_attachments_filter_applied/defender_malware_policy_comprehensive_attachments_filter_applied.py b/prowler/providers/m365/services/defender/defender_malware_policy_comprehensive_attachments_filter_applied/defender_malware_policy_comprehensive_attachments_filter_applied.py index 87aa4ab50e..808ece11b9 100644 --- a/prowler/providers/m365/services/defender/defender_malware_policy_comprehensive_attachments_filter_applied/defender_malware_policy_comprehensive_attachments_filter_applied.py +++ b/prowler/providers/m365/services/defender/defender_malware_policy_comprehensive_attachments_filter_applied/defender_malware_policy_comprehensive_attachments_filter_applied.py @@ -114,6 +114,12 @@ class defender_malware_policy_comprehensive_attachments_filter_applied(Check): default_policy_well_configured = False for policy in defender_client.malware_policies: + # Preset security policies are scoped by protection policy rules, not filter rules + if ( + not policy.is_default + and policy.identity not in defender_client.malware_rules + ): + continue report = CheckReportM365( metadata=self.metadata(), resource=policy, diff --git a/prowler/providers/m365/services/defender/defender_malware_policy_notifications_internal_users_malware_enabled/defender_malware_policy_notifications_internal_users_malware_enabled.py b/prowler/providers/m365/services/defender/defender_malware_policy_notifications_internal_users_malware_enabled/defender_malware_policy_notifications_internal_users_malware_enabled.py index 735cc6b9e9..ab5efc8efe 100644 --- a/prowler/providers/m365/services/defender/defender_malware_policy_notifications_internal_users_malware_enabled/defender_malware_policy_notifications_internal_users_malware_enabled.py +++ b/prowler/providers/m365/services/defender/defender_malware_policy_notifications_internal_users_malware_enabled/defender_malware_policy_notifications_internal_users_malware_enabled.py @@ -51,6 +51,12 @@ class defender_malware_policy_notifications_internal_users_malware_enabled(Check default_policy_well_configured = False for policy in defender_client.malware_policies: + # Preset security policies are scoped by protection policy rules, not filter rules + if ( + not policy.is_default + and policy.identity not in defender_client.malware_rules + ): + continue report = CheckReportM365( metadata=self.metadata(), resource=policy, diff --git a/tests/providers/m365/services/defender/defender_antiphishing_policy_configured/defender_antiphishing_policy_configured_test.py b/tests/providers/m365/services/defender/defender_antiphishing_policy_configured/defender_antiphishing_policy_configured_test.py index 8610e96769..984fa648df 100644 --- a/tests/providers/m365/services/defender/defender_antiphishing_policy_configured/defender_antiphishing_policy_configured_test.py +++ b/tests/providers/m365/services/defender/defender_antiphishing_policy_configured/defender_antiphishing_policy_configured_test.py @@ -521,3 +521,86 @@ class Test_defender_antiphishing_policy_configured: check = defender_antiphishing_policy_configured() result = check.execute() assert len(result) == 0 + + def test_preset_policy_without_rule_is_skipped(self): + defender_client = mock.MagicMock() + defender_client.audited_tenant = "audited_tenant" + defender_client.audited_domain = DOMAIN + defender_client.audit_config = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.defender.defender_antiphishing_policy_configured.defender_antiphishing_policy_configured.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_antiphishing_policy_configured.defender_antiphishing_policy_configured import ( + defender_antiphishing_policy_configured, + ) + from prowler.providers.m365.services.defender.defender_service import ( + AntiphishingPolicy, + AntiphishingRule, + ) + + defender_client.antiphishing_policies = { + "Default": AntiphishingPolicy( + name="Default", + spoof_intelligence=True, + spoof_intelligence_action="Quarantine", + dmarc_reject_action="Quarantine", + dmarc_quarantine_action="Quarantine", + safety_tips=True, + unauthenticated_sender_action=True, + show_tag=True, + honor_dmarc_policy=True, + default=True, + ), + "Standard Preset Security Policy1663355404982": AntiphishingPolicy( + name="Standard Preset Security Policy1663355404982", + spoof_intelligence=True, + spoof_intelligence_action="Quarantine", + dmarc_reject_action="Quarantine", + dmarc_quarantine_action="Quarantine", + safety_tips=True, + unauthenticated_sender_action=True, + show_tag=True, + honor_dmarc_policy=True, + default=False, + ), + "Custom1": AntiphishingPolicy( + name="Custom1", + spoof_intelligence=True, + spoof_intelligence_action="Quarantine", + dmarc_reject_action="Quarantine", + dmarc_quarantine_action="Quarantine", + safety_tips=True, + unauthenticated_sender_action=True, + show_tag=True, + honor_dmarc_policy=True, + default=False, + ), + } + defender_client.antiphishing_rules = { + "Custom1": AntiphishingRule( + state="Enabled", + priority=1, + users=["user1@example.com"], + groups=None, + domains=None, + ) + } + + check = defender_antiphishing_policy_configured() + result = check.execute() + + assert len(result) == 2 + assert "Standard Preset Security Policy1663355404982" not in [ + finding.resource_id for finding in result + ] diff --git a/tests/providers/m365/services/defender/defender_antispam_policy_inbound_no_allowed_domains/defender_antispam_policy_inbound_no_allowed_domains_test.py b/tests/providers/m365/services/defender/defender_antispam_policy_inbound_no_allowed_domains/defender_antispam_policy_inbound_no_allowed_domains_test.py index 625fc8ded3..4e0a5482a1 100644 --- a/tests/providers/m365/services/defender/defender_antispam_policy_inbound_no_allowed_domains/defender_antispam_policy_inbound_no_allowed_domains_test.py +++ b/tests/providers/m365/services/defender/defender_antispam_policy_inbound_no_allowed_domains/defender_antispam_policy_inbound_no_allowed_domains_test.py @@ -410,3 +410,65 @@ class Test_defender_antispam_policy_inbound_no_allowed_domains: check = defender_antispam_policy_inbound_no_allowed_domains() result = check.execute() assert len(result) == 0 + + def test_preset_policy_without_rule_is_skipped(self): + defender_client = mock.MagicMock() + defender_client.audited_tenant = "audited_tenant" + defender_client.audited_domain = DOMAIN + defender_client.audit_config = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.defender.defender_antispam_policy_inbound_no_allowed_domains.defender_antispam_policy_inbound_no_allowed_domains.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_antispam_policy_inbound_no_allowed_domains.defender_antispam_policy_inbound_no_allowed_domains import ( + defender_antispam_policy_inbound_no_allowed_domains, + ) + from prowler.providers.m365.services.defender.defender_service import ( + DefenderInboundSpamPolicy, + InboundSpamRule, + ) + + defender_client.inbound_spam_policies = [ + DefenderInboundSpamPolicy( + identity="Default", + allowed_sender_domains=[], + default=True, + ), + DefenderInboundSpamPolicy( + identity="Standard Preset Security Policy1663355404982", + allowed_sender_domains=[], + default=False, + ), + DefenderInboundSpamPolicy( + identity="Custom1", + allowed_sender_domains=[], + default=False, + ), + ] + defender_client.inbound_spam_rules = { + "Custom1": InboundSpamRule( + state="Enabled", + priority=1, + users=["user1@example.com"], + groups=None, + domains=None, + ) + } + + check = defender_antispam_policy_inbound_no_allowed_domains() + result = check.execute() + + assert len(result) == 2 + assert "Standard Preset Security Policy1663355404982" not in [ + finding.resource_id for finding in result + ] diff --git a/tests/providers/m365/services/defender/defender_malware_policy_common_attachments_filter_enabled/defender_malware_policy_common_attachments_filter_enabled_test.py b/tests/providers/m365/services/defender/defender_malware_policy_common_attachments_filter_enabled/defender_malware_policy_common_attachments_filter_enabled_test.py index adb62b213d..f2b9fdb402 100644 --- a/tests/providers/m365/services/defender/defender_malware_policy_common_attachments_filter_enabled/defender_malware_policy_common_attachments_filter_enabled_test.py +++ b/tests/providers/m365/services/defender/defender_malware_policy_common_attachments_filter_enabled/defender_malware_policy_common_attachments_filter_enabled_test.py @@ -442,3 +442,77 @@ class Test_defender_malware_policy_common_attachments_filter_enabled: check = defender_malware_policy_common_attachments_filter_enabled() result = check.execute() assert len(result) == 0 + + def test_preset_policy_without_rule_is_skipped(self): + defender_client = mock.MagicMock() + defender_client.audited_tenant = "audited_tenant" + defender_client.audited_domain = DOMAIN + defender_client.audit_config = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.defender.defender_malware_policy_common_attachments_filter_enabled.defender_malware_policy_common_attachments_filter_enabled.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_malware_policy_common_attachments_filter_enabled.defender_malware_policy_common_attachments_filter_enabled import ( + defender_malware_policy_common_attachments_filter_enabled, + ) + from prowler.providers.m365.services.defender.defender_service import ( + MalwarePolicy, + MalwareRule, + ) + + defender_client.audit_config = { + "recommended_blocked_file_types": ["exe", "bat"] + } + defender_client.malware_policies = [ + MalwarePolicy( + identity="Default", + enable_file_filter=True, + enable_internal_sender_admin_notifications=True, + internal_sender_admin_address="admin@example.com", + file_types=["exe", "bat"], + is_default=True, + ), + MalwarePolicy( + identity="Standard Preset Security Policy1663355404982", + enable_file_filter=True, + enable_internal_sender_admin_notifications=True, + internal_sender_admin_address="admin@example.com", + file_types=["exe", "bat"], + is_default=False, + ), + MalwarePolicy( + identity="Custom1", + enable_file_filter=True, + enable_internal_sender_admin_notifications=True, + internal_sender_admin_address="admin@example.com", + file_types=["exe", "bat"], + is_default=False, + ), + ] + defender_client.malware_rules = { + "Custom1": MalwareRule( + state="Enabled", + priority=1, + users=["user1@example.com"], + groups=None, + domains=None, + ) + } + + check = defender_malware_policy_common_attachments_filter_enabled() + result = check.execute() + + assert len(result) == 2 + assert "Standard Preset Security Policy1663355404982" not in [ + finding.resource_id for finding in result + ] diff --git a/tests/providers/m365/services/defender/defender_malware_policy_comprehensive_attachments_filter_applied/defender_malware_policy_comprehensive_attachments_filter_applied_test.py b/tests/providers/m365/services/defender/defender_malware_policy_comprehensive_attachments_filter_applied/defender_malware_policy_comprehensive_attachments_filter_applied_test.py index 682f86c768..7a9cfbbd90 100644 --- a/tests/providers/m365/services/defender/defender_malware_policy_comprehensive_attachments_filter_applied/defender_malware_policy_comprehensive_attachments_filter_applied_test.py +++ b/tests/providers/m365/services/defender/defender_malware_policy_comprehensive_attachments_filter_applied/defender_malware_policy_comprehensive_attachments_filter_applied_test.py @@ -452,3 +452,77 @@ class Test_defender_malware_policy_comprehensive_attachments_filter_applied: check = defender_malware_policy_comprehensive_attachments_filter_applied() result = check.execute() assert len(result) == 0 + + def test_preset_policy_without_rule_is_skipped(self): + defender_client = mock.MagicMock() + defender_client.audited_tenant = "audited_tenant" + defender_client.audited_domain = DOMAIN + defender_client.audit_config = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.defender.defender_malware_policy_comprehensive_attachments_filter_applied.defender_malware_policy_comprehensive_attachments_filter_applied.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_malware_policy_comprehensive_attachments_filter_applied.defender_malware_policy_comprehensive_attachments_filter_applied import ( + defender_malware_policy_comprehensive_attachments_filter_applied, + ) + from prowler.providers.m365.services.defender.defender_service import ( + MalwarePolicy, + MalwareRule, + ) + + defender_client.audit_config = { + "recommended_blocked_file_types": ["exe", "bat"] + } + defender_client.malware_policies = [ + MalwarePolicy( + identity="Default", + enable_file_filter=True, + enable_internal_sender_admin_notifications=True, + internal_sender_admin_address="admin@example.com", + file_types=["exe", "bat"], + is_default=True, + ), + MalwarePolicy( + identity="Standard Preset Security Policy1663355404982", + enable_file_filter=True, + enable_internal_sender_admin_notifications=True, + internal_sender_admin_address="admin@example.com", + file_types=["exe", "bat"], + is_default=False, + ), + MalwarePolicy( + identity="Custom1", + enable_file_filter=True, + enable_internal_sender_admin_notifications=True, + internal_sender_admin_address="admin@example.com", + file_types=["exe", "bat"], + is_default=False, + ), + ] + defender_client.malware_rules = { + "Custom1": MalwareRule( + state="Enabled", + priority=1, + users=["user1@example.com"], + groups=None, + domains=None, + ) + } + + check = defender_malware_policy_comprehensive_attachments_filter_applied() + result = check.execute() + + assert len(result) == 2 + assert "Standard Preset Security Policy1663355404982" not in [ + finding.resource_id for finding in result + ] diff --git a/tests/providers/m365/services/defender/defender_malware_policy_notifications_internal_users_malware_enabled/defender_malware_policy_notifications_internal_users_malware_enabled_test.py b/tests/providers/m365/services/defender/defender_malware_policy_notifications_internal_users_malware_enabled/defender_malware_policy_notifications_internal_users_malware_enabled_test.py index 16fe656c2b..7764da21e4 100644 --- a/tests/providers/m365/services/defender/defender_malware_policy_notifications_internal_users_malware_enabled/defender_malware_policy_notifications_internal_users_malware_enabled_test.py +++ b/tests/providers/m365/services/defender/defender_malware_policy_notifications_internal_users_malware_enabled/defender_malware_policy_notifications_internal_users_malware_enabled_test.py @@ -456,3 +456,79 @@ class Test_defender_malware_policy_notifications_internal_users_malware_enabled: ) result = check.execute() assert len(result) == 0 + + def test_preset_policy_without_rule_is_skipped(self): + defender_client = mock.MagicMock() + defender_client.audited_tenant = "audited_tenant" + defender_client.audited_domain = DOMAIN + defender_client.audit_config = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.defender.defender_malware_policy_notifications_internal_users_malware_enabled.defender_malware_policy_notifications_internal_users_malware_enabled.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_malware_policy_notifications_internal_users_malware_enabled.defender_malware_policy_notifications_internal_users_malware_enabled import ( + defender_malware_policy_notifications_internal_users_malware_enabled, + ) + from prowler.providers.m365.services.defender.defender_service import ( + MalwarePolicy, + MalwareRule, + ) + + defender_client.audit_config = { + "recommended_blocked_file_types": ["exe", "bat"] + } + defender_client.malware_policies = [ + MalwarePolicy( + identity="Default", + enable_file_filter=True, + enable_internal_sender_admin_notifications=True, + internal_sender_admin_address="admin@example.com", + file_types=["exe", "bat"], + is_default=True, + ), + MalwarePolicy( + identity="Standard Preset Security Policy1663355404982", + enable_file_filter=True, + enable_internal_sender_admin_notifications=True, + internal_sender_admin_address="admin@example.com", + file_types=["exe", "bat"], + is_default=False, + ), + MalwarePolicy( + identity="Custom1", + enable_file_filter=True, + enable_internal_sender_admin_notifications=True, + internal_sender_admin_address="admin@example.com", + file_types=["exe", "bat"], + is_default=False, + ), + ] + defender_client.malware_rules = { + "Custom1": MalwareRule( + state="Enabled", + priority=1, + users=["user1@example.com"], + groups=None, + domains=None, + ) + } + + check = ( + defender_malware_policy_notifications_internal_users_malware_enabled() + ) + result = check.execute() + + assert len(result) == 2 + assert "Standard Preset Security Policy1663355404982" not in [ + finding.resource_id for finding in result + ] From c0fdd5bdf37d6cd82f23b434af62a035ee3e64b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Tue, 15 Sep 2026 11:07:31 +0200 Subject: [PATCH 07/23] fix(ui): add FedRAMP 20x cross-provider mappers (#12810) Co-authored-by: alejandrobailo --- ...ramp-20x-cross-provider-breakdown.fixed.md | 1 + .../fedramp-20x-details.tsx | 89 +++++++++ ui/lib/compliance/c5.tsx | 10 +- ui/lib/compliance/cis-controls.tsx | 10 +- ui/lib/compliance/{cmmc.tsx => cmmc.ts} | 70 +------ ui/lib/compliance/commons.tsx | 11 +- ui/lib/compliance/compliance-mapper.test.ts | 11 ++ ui/lib/compliance/compliance-mapper.ts | 30 +++ .../compliance-report-types.test.ts | 2 + ui/lib/compliance/compliance-report-types.ts | 5 +- ui/lib/compliance/csa.tsx | 10 +- ui/lib/compliance/cyber-essentials.tsx | 10 +- ui/lib/compliance/dora.tsx | 10 +- ui/lib/compliance/fedramp-20x.test.ts | 183 ++++++++++++++++++ ui/lib/compliance/fedramp-20x.ts | 110 +++++++++++ ui/lib/compliance/grouped-accordion.tsx | 55 ++++++ ui/lib/compliance/okta-idaas-stig.tsx | 8 +- ui/types/compliance.ts | 16 ++ 18 files changed, 526 insertions(+), 115 deletions(-) create mode 100644 ui/changelog.d/fedramp-20x-cross-provider-breakdown.fixed.md create mode 100644 ui/components/compliance/compliance-custom-details/fedramp-20x-details.tsx rename ui/lib/compliance/{cmmc.tsx => cmmc.ts} (56%) create mode 100644 ui/lib/compliance/fedramp-20x.test.ts create mode 100644 ui/lib/compliance/fedramp-20x.ts create mode 100644 ui/lib/compliance/grouped-accordion.tsx diff --git a/ui/changelog.d/fedramp-20x-cross-provider-breakdown.fixed.md b/ui/changelog.d/fedramp-20x-cross-provider-breakdown.fixed.md new file mode 100644 index 0000000000..dc7c71ce1b --- /dev/null +++ b/ui/changelog.d/fedramp-20x-cross-provider-breakdown.fixed.md @@ -0,0 +1 @@ +Per-provider breakdown and OCSF download for FedRAMP 20x KSI and Class C FRR in the cross-provider compliance view diff --git a/ui/components/compliance/compliance-custom-details/fedramp-20x-details.tsx b/ui/components/compliance/compliance-custom-details/fedramp-20x-details.tsx new file mode 100644 index 0000000000..31f684a33b --- /dev/null +++ b/ui/components/compliance/compliance-custom-details/fedramp-20x-details.tsx @@ -0,0 +1,89 @@ +import { Requirement } from "@/types/compliance"; + +import { + ComplianceBadge, + ComplianceBadgeContainer, + ComplianceDetailContainer, + ComplianceDetailSection, + ComplianceDetailText, +} from "./shared-components"; + +interface FedRAMP20xDetailsProps { + requirement: Requirement; +} + +const DescriptionSection = ({ requirement }: FedRAMP20xDetailsProps) => + requirement.description ? ( + + {requirement.description} + + ) : null; + +export const FedRAMP20xKSICustomDetails = ({ + requirement, +}: FedRAMP20xDetailsProps) => { + return ( + + + + + {requirement.theme && ( + + )} + {requirement.class_applicability && ( + + )} + + + {requirement.nist_controls && ( + + + {requirement.nist_controls as string} + + + )} + + ); +}; + +export const FedRAMP20xFRRCustomDetails = ({ + requirement, +}: FedRAMP20xDetailsProps) => { + return ( + + + + + {requirement.ruleset && ( + + )} + {requirement.subset && ( + + )} + {requirement.force && ( + + )} + + + ); +}; diff --git a/ui/lib/compliance/c5.tsx b/ui/lib/compliance/c5.tsx index 4839df3d66..c39a2dd538 100644 --- a/ui/lib/compliance/c5.tsx +++ b/ui/lib/compliance/c5.tsx @@ -3,13 +3,12 @@ import { ComplianceAccordionRequirementTitle } from "@/components/compliance/com import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title"; import { AccordionItemProps } from "@/components/shadcn/accordion/Accordion"; import { FindingStatus } from "@/components/shadcn/table/status-finding-badge"; -import { +import type { AttributesData, C5AttributesMetadata, Control, Framework, Requirement, - REQUIREMENT_STATUS, RequirementsData, RequirementStatus, } from "@/types/compliance"; @@ -20,14 +19,9 @@ import { findOrCreateCategory, findOrCreateControl, findOrCreateFramework, + getStatusCounters, } from "./commons"; -const getStatusCounters = (status: RequirementStatus) => ({ - pass: status === REQUIREMENT_STATUS.PASS ? 1 : 0, - fail: status === REQUIREMENT_STATUS.FAIL ? 1 : 0, - manual: status === REQUIREMENT_STATUS.MANUAL ? 1 : 0, -}); - export const mapComplianceData = ( attributesData: AttributesData, requirementsData: RequirementsData, diff --git a/ui/lib/compliance/cis-controls.tsx b/ui/lib/compliance/cis-controls.tsx index 7a7f283fc6..a1fab412e1 100644 --- a/ui/lib/compliance/cis-controls.tsx +++ b/ui/lib/compliance/cis-controls.tsx @@ -3,12 +3,11 @@ import { ComplianceAccordionRequirementTitle } from "@/components/compliance/com import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title"; import { AccordionItemProps } from "@/components/shadcn/accordion/Accordion"; import { FindingStatus } from "@/components/shadcn/table/status-finding-badge"; -import { +import type { AttributesData, CISControlsAttributesMetadata, Framework, Requirement, - REQUIREMENT_STATUS, RequirementsData, RequirementStatus, } from "@/types/compliance"; @@ -19,14 +18,9 @@ import { findOrCreateCategory, findOrCreateControl, findOrCreateFramework, + getStatusCounters, } from "./commons"; -const getStatusCounters = (status: RequirementStatus) => ({ - pass: status === REQUIREMENT_STATUS.PASS ? 1 : 0, - fail: status === REQUIREMENT_STATUS.FAIL ? 1 : 0, - manual: status === REQUIREMENT_STATUS.MANUAL ? 1 : 0, -}); - // Sort the 18 CIS Controls by their leading number ("1. ...", "2. ...", ..., // "18. ...") so the accordion always reads in canonical control order // regardless of how the API returns the sections. diff --git a/ui/lib/compliance/cmmc.tsx b/ui/lib/compliance/cmmc.ts similarity index 56% rename from ui/lib/compliance/cmmc.tsx rename to ui/lib/compliance/cmmc.ts index 557c28b95c..748d0ed39c 100644 --- a/ui/lib/compliance/cmmc.tsx +++ b/ui/lib/compliance/cmmc.ts @@ -1,14 +1,8 @@ -import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content"; -import { ComplianceAccordionRequirementTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title"; -import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title"; -import { AccordionItemProps } from "@/components/shadcn/accordion/Accordion"; -import { FindingStatus } from "@/components/shadcn/table/status-finding-badge"; -import { +import type { AttributesData, CMMCAttributesMetadata, Framework, Requirement, - REQUIREMENT_STATUS, RequirementsData, RequirementStatus, } from "@/types/compliance"; @@ -19,8 +13,11 @@ import { findOrCreateCategory, findOrCreateControl, findOrCreateFramework, + getStatusCounters, } from "./commons"; +export { toGroupedAccordionItems as toAccordionItems } from "./grouped-accordion"; + // Canonical NIST SP 800-171 family order for the 14 CMMC domains, so the // accordion always reads in the same order regardless of the API response. export const CMMC_DOMAIN_ORDER: readonly string[] = [ @@ -40,12 +37,6 @@ export const CMMC_DOMAIN_ORDER: readonly string[] = [ "System and Information Integrity", ]; -const getStatusCounters = (status: RequirementStatus) => ({ - pass: status === REQUIREMENT_STATUS.PASS ? 1 : 0, - fail: status === REQUIREMENT_STATUS.FAIL ? 1 : 0, - manual: status === REQUIREMENT_STATUS.MANUAL ? 1 : 0, -}); - export const mapComplianceData = ( attributesData: AttributesData, requirementsData: RequirementsData, @@ -109,56 +100,3 @@ export const mapComplianceData = ( return frameworks; }; - -export const toAccordionItems = ( - data: Framework[], - scanId: string | undefined, -): AccordionItemProps[] => { - const safeId = scanId || ""; - - return data.flatMap((framework) => - framework.categories.map((category) => ({ - key: `${framework.name}-${category.name}`, - title: ( - - ), - content: "", - // Domain → requirements (flat, no intermediate "control" level). - // Keys are derived from the requirement name (which starts with the - // unique CMMC id, e.g. "AC.L1-b.1.i") instead of the array index, so - // expanded state stays attached to the right requirement even if the - // list is reordered or filtered. - items: category.controls.flatMap((control) => - control.requirements.map((requirement) => ({ - key: `${framework.name}-${category.name}-${requirement.name}`, - title: ( - - ), - content: ( - - ), - items: [], - })), - ), - })), - ); -}; diff --git a/ui/lib/compliance/commons.tsx b/ui/lib/compliance/commons.tsx index 24a7b8407c..15b315be4d 100644 --- a/ui/lib/compliance/commons.tsx +++ b/ui/lib/compliance/commons.tsx @@ -7,6 +7,7 @@ import { REQUIREMENT_STATUS, RequirementItemData, RequirementsData, + RequirementsTotals, RequirementStatus, TOP_FAILED_DATA_TYPE, TopFailedDataType, @@ -66,7 +67,7 @@ const incrementFailedCount = ( }; export const updateCounters = ( - target: { pass: number; fail: number; manual: number }, + target: RequirementsTotals, status: RequirementStatus, ) => { if (status === REQUIREMENT_STATUS.MANUAL) { @@ -78,6 +79,14 @@ export const updateCounters = ( } }; +export const getStatusCounters = ( + status: RequirementStatus, +): RequirementsTotals => { + const counters: RequirementsTotals = { pass: 0, fail: 0, manual: 0 }; + updateCounters(counters, status); + return counters; +}; + export const getTopFailedSections = ( mappedData: Framework[], ): TopFailedResult => { diff --git a/ui/lib/compliance/compliance-mapper.test.ts b/ui/lib/compliance/compliance-mapper.test.ts index c7ce69040e..be7ba8074c 100644 --- a/ui/lib/compliance/compliance-mapper.test.ts +++ b/ui/lib/compliance/compliance-mapper.test.ts @@ -54,6 +54,13 @@ vi.mock( "@/components/compliance/compliance-custom-details/ens-details", () => ({ ENSCustomDetails: stubFactory("ENSStub") }), ); +vi.mock( + "@/components/compliance/compliance-custom-details/fedramp-20x-details", + () => ({ + FedRAMP20xFRRCustomDetails: stubFactory("FedRAMP20xFRRStub"), + FedRAMP20xKSICustomDetails: stubFactory("FedRAMP20xKSIStub"), + }), +); vi.mock( "@/components/compliance/compliance-custom-details/generic-details", () => ({ GenericCustomDetails: stubFactory("GenericStub") }), @@ -159,6 +166,8 @@ describe("getComplianceMapper", () => { { framework: "CMMC", expected: "CMMCStub" }, { framework: "Okta-IDaaS-STIG", expected: "OktaIDaaSStigStub" }, { framework: "Cyber-Essentials", expected: "CyberEssentialsStub" }, + { framework: "FedRAMP-20x-KSI", expected: "FedRAMP20xKSIStub" }, + { framework: "FedRAMP-20x-FRR-Class-C", expected: "FedRAMP20xFRRStub" }, ]; for (const { framework, expected } of wiring) { @@ -206,6 +215,8 @@ describe("getComplianceMapper", () => { "CMMC", "Okta-IDaaS-STIG", "Cyber-Essentials", + "FedRAMP-20x-KSI", + "FedRAMP-20x-FRR-Class-C", ]) { const mapper = getComplianceMapper(framework); expect(Object.keys(mapper).sort(), framework).toEqual(expectedKeys); diff --git a/ui/lib/compliance/compliance-mapper.ts b/ui/lib/compliance/compliance-mapper.ts index 80b3558aeb..d80a980485 100644 --- a/ui/lib/compliance/compliance-mapper.ts +++ b/ui/lib/compliance/compliance-mapper.ts @@ -11,6 +11,10 @@ import { CSACustomDetails } from "@/components/compliance/compliance-custom-deta import { CyberEssentialsCustomDetails } from "@/components/compliance/compliance-custom-details/cyber-essentials-details"; import { DORACustomDetails } from "@/components/compliance/compliance-custom-details/dora-details"; import { ENSCustomDetails } from "@/components/compliance/compliance-custom-details/ens-details"; +import { + FedRAMP20xFRRCustomDetails, + FedRAMP20xKSICustomDetails, +} from "@/components/compliance/compliance-custom-details/fedramp-20x-details"; import { GenericCustomDetails } from "@/components/compliance/compliance-custom-details/generic-details"; import { ISOCustomDetails } from "@/components/compliance/compliance-custom-details/iso-details"; import { KISACustomDetails } from "@/components/compliance/compliance-custom-details/kisa-details"; @@ -72,6 +76,11 @@ import { mapComplianceData as mapENSComplianceData, toAccordionItems as toENSAccordionItems, } from "./ens"; +import { + mapFRRComplianceData as mapFedRAMP20xFRRComplianceData, + mapKSIComplianceData as mapFedRAMP20xKSIComplianceData, + toAccordionItems as toFedRAMP20xAccordionItems, +} from "./fedramp-20x"; import { mapComplianceData as mapGenericComplianceData, toAccordionItems as toGenericAccordionItems, @@ -296,6 +305,27 @@ const getComplianceMappers = (): Record => ({ getDetailsComponent: (requirement: Requirement) => createElement(CMMCCustomDetails, { requirement }), }, + // Universal frameworks must compose requirement names as `${id} - ${name}` + // (see `composeRequirementName`); the generic mapper does not, so they need + // a dedicated entry for the cross-provider breakdown to render. + "FedRAMP-20x-KSI": { + mapComplianceData: mapFedRAMP20xKSIComplianceData, + toAccordionItems: toFedRAMP20xAccordionItems, + getTopFailedSections, + calculateCategoryHeatmapData: (data: Framework[]) => + calculateCategoryHeatmapData(data), + getDetailsComponent: (requirement: Requirement) => + createElement(FedRAMP20xKSICustomDetails, { requirement }), + }, + "FedRAMP-20x-FRR-Class-C": { + mapComplianceData: mapFedRAMP20xFRRComplianceData, + toAccordionItems: toFedRAMP20xAccordionItems, + getTopFailedSections, + calculateCategoryHeatmapData: (data: Framework[]) => + calculateCategoryHeatmapData(data), + getDetailsComponent: (requirement: Requirement) => + createElement(FedRAMP20xFRRCustomDetails, { requirement }), + }, }); /** diff --git a/ui/lib/compliance/compliance-report-types.test.ts b/ui/lib/compliance/compliance-report-types.test.ts index 8a431d9748..59b26beed3 100644 --- a/ui/lib/compliance/compliance-report-types.test.ts +++ b/ui/lib/compliance/compliance-report-types.test.ts @@ -41,6 +41,8 @@ describe("isOcsfSupported", () => { expect(isOcsfSupported("csa_ccm_4.0")).toBe(true); expect(isOcsfSupported("cis_controls_8.1")).toBe(true); expect(isOcsfSupported("cmmc_2.0")).toBe(true); + expect(isOcsfSupported("fedramp_20x_ksi_2026")).toBe(true); + expect(isOcsfSupported("fedramp_20x_frr_class_c_2026")).toBe(true); }); it("returns false for legacy/per-provider frameworks without OCSF output", () => { diff --git a/ui/lib/compliance/compliance-report-types.ts b/ui/lib/compliance/compliance-report-types.ts index 68ef3072de..2553df013d 100644 --- a/ui/lib/compliance/compliance-report-types.ts +++ b/ui/lib/compliance/compliance-report-types.ts @@ -167,7 +167,8 @@ export const pickLatestCisPerProvider = ( * Only universal compliance frameworks that declare an ``outputs`` block in * their schema (see ``prowler/compliance/.json``) produce a dedicated * OCSF artifact during scan output generation. Today that is DORA, - * CSA CCM 4.0, CIS Controls 8.1 and CMMC 2.0. Any other framework only + * CSA CCM 4.0, CIS Controls 8.1, CMMC 2.0 and FedRAMP 20x (KSI and Class C + * FRR). Any other framework only * offers CSV (and, for the curated list above, PDF). * * Keep this Set in lock-step with the backend: ``get_prowler_provider_compliance`` @@ -182,6 +183,8 @@ const OCSF_SUPPORTED_COMPLIANCE_IDS: ReadonlySet = new Set([ "csa_ccm_4.0", "cis_controls_8.1", "cmmc_2.0", + "fedramp_20x_ksi_2026", + "fedramp_20x_frr_class_c_2026", ]); export const isOcsfSupported = (complianceId: string | undefined): boolean => diff --git a/ui/lib/compliance/csa.tsx b/ui/lib/compliance/csa.tsx index 3bb601de0c..c38136b19f 100644 --- a/ui/lib/compliance/csa.tsx +++ b/ui/lib/compliance/csa.tsx @@ -4,12 +4,11 @@ import { ComplianceAccordionTitle } from "@/components/compliance/compliance-acc import { ComplianceBadgeVariant } from "@/components/compliance/compliance-custom-details/shared-components"; import { AccordionItemProps } from "@/components/shadcn/accordion/Accordion"; import { FindingStatus } from "@/components/shadcn/table/status-finding-badge"; -import { +import type { AttributesData, CSAAttributesMetadata, Framework, Requirement, - REQUIREMENT_STATUS, RequirementsData, RequirementStatus, } from "@/types/compliance"; @@ -20,6 +19,7 @@ import { findOrCreateCategory, findOrCreateControl, findOrCreateFramework, + getStatusCounters, } from "./commons"; export interface CSAMappingSection { @@ -36,12 +36,6 @@ export const CSA_MAPPING_SECTIONS: CSAMappingSection[] = [ }, ]; -const getStatusCounters = (status: RequirementStatus) => ({ - pass: status === REQUIREMENT_STATUS.PASS ? 1 : 0, - fail: status === REQUIREMENT_STATUS.FAIL ? 1 : 0, - manual: status === REQUIREMENT_STATUS.MANUAL ? 1 : 0, -}); - export const mapComplianceData = ( attributesData: AttributesData, requirementsData: RequirementsData, diff --git a/ui/lib/compliance/cyber-essentials.tsx b/ui/lib/compliance/cyber-essentials.tsx index aee4d8c5db..ebdd73209d 100644 --- a/ui/lib/compliance/cyber-essentials.tsx +++ b/ui/lib/compliance/cyber-essentials.tsx @@ -3,12 +3,11 @@ import { ComplianceAccordionRequirementTitle } from "@/components/compliance/com import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title"; import { AccordionItemProps } from "@/components/shadcn/accordion/Accordion"; import { FindingStatus } from "@/components/shadcn/table/status-finding-badge"; -import { +import type { AttributesData, CyberEssentialsAttributesMetadata, Framework, Requirement, - REQUIREMENT_STATUS, RequirementsData, RequirementStatus, } from "@/types/compliance"; @@ -19,6 +18,7 @@ import { findOrCreateCategory, findOrCreateControl, findOrCreateFramework, + getStatusCounters, } from "./commons"; // Display order for the five Cyber Essentials control themes in the accordion @@ -33,12 +33,6 @@ export const CYBER_ESSENTIALS_THEME_ORDER: readonly string[] = [ "Malware Protection", ]; -const getStatusCounters = (status: RequirementStatus) => ({ - pass: status === REQUIREMENT_STATUS.PASS ? 1 : 0, - fail: status === REQUIREMENT_STATUS.FAIL ? 1 : 0, - manual: status === REQUIREMENT_STATUS.MANUAL ? 1 : 0, -}); - export const mapComplianceData = ( attributesData: AttributesData, requirementsData: RequirementsData, diff --git a/ui/lib/compliance/dora.tsx b/ui/lib/compliance/dora.tsx index 8ac364f29f..7fc6bba26c 100644 --- a/ui/lib/compliance/dora.tsx +++ b/ui/lib/compliance/dora.tsx @@ -3,12 +3,11 @@ import { ComplianceAccordionRequirementTitle } from "@/components/compliance/com import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title"; import { AccordionItemProps } from "@/components/shadcn/accordion/Accordion"; import { FindingStatus } from "@/components/shadcn/table/status-finding-badge"; -import { +import type { AttributesData, DORAAttributesMetadata, Framework, Requirement, - REQUIREMENT_STATUS, RequirementsData, RequirementStatus, } from "@/types/compliance"; @@ -19,6 +18,7 @@ import { findOrCreateCategory, findOrCreateControl, findOrCreateFramework, + getStatusCounters, } from "./commons"; // Display order for DORA pillars in the accordion and any grouped chart. The @@ -33,12 +33,6 @@ export const DORA_PILLAR_ORDER: readonly string[] = [ "Information Sharing", ]; -const getStatusCounters = (status: RequirementStatus) => ({ - pass: status === REQUIREMENT_STATUS.PASS ? 1 : 0, - fail: status === REQUIREMENT_STATUS.FAIL ? 1 : 0, - manual: status === REQUIREMENT_STATUS.MANUAL ? 1 : 0, -}); - export const mapComplianceData = ( attributesData: AttributesData, requirementsData: RequirementsData, diff --git a/ui/lib/compliance/fedramp-20x.test.ts b/ui/lib/compliance/fedramp-20x.test.ts new file mode 100644 index 0000000000..21d40c69bc --- /dev/null +++ b/ui/lib/compliance/fedramp-20x.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock( + "@/components/compliance/compliance-accordion/client-accordion-content", + () => ({ ClientAccordionContent: () => null }), +); +vi.mock( + "@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title", + () => ({ ComplianceAccordionRequirementTitle: () => null }), +); +vi.mock( + "@/components/compliance/compliance-accordion/compliance-accordion-title", + () => ({ ComplianceAccordionTitle: () => null }), +); + +import { + buildRequirementExtrasMap, + crossProviderToMapperInput, +} from "@/app/(prowler)/compliance/_lib/cross-provider-adapter"; +import type { CrossProviderOverviewAttributes } from "@/app/(prowler)/compliance/_types"; +import { Framework, Requirement } from "@/types/compliance"; + +import { + mapFRRComplianceData, + mapKSIComplianceData, + toAccordionItems, +} from "./fedramp-20x"; + +type OverviewRequirement = + CrossProviderOverviewAttributes["requirements"][number]; + +const buildOverview = ( + framework: string, + requirements: Array>, +): CrossProviderOverviewAttributes => + ({ + framework, + version: "2026", + description: "", + providers: ["aws"], + compatible_providers: ["aws"], + scan_ids_by_provider: {}, + requirements: requirements.map((requirement) => ({ + ...requirement, + description: "Requirement text.", + status: "PASS", + providers: { aws: "PASS" }, + check_ids_by_provider: { aws: ["check_one"] }, + })), + }) as unknown as CrossProviderOverviewAttributes; + +const mapOverview = ( + overview: CrossProviderOverviewAttributes, + mapper: typeof mapKSIComplianceData, +): Framework[] => { + const { attributesData, requirementsData } = + crossProviderToMapperInput(overview); + return mapper(attributesData, requirementsData); +}; + +const allRequirements = (frameworks: Framework[]): Requirement[] => + frameworks.flatMap((framework) => + framework.categories.flatMap((category) => + category.controls.flatMap((control) => control.requirements), + ), + ); + +const KSI_OVERVIEW = buildOverview("FedRAMP-20x-KSI", [ + { + id: "KSI-SVC-VRI", + name: "Validating Resource Integrity", + attributes: { + Theme: "KSI-SVC: Service Configuration", + NISTControls: "SC-13", + ClassApplicability: "Required for Classes B and C", + }, + }, + { + id: "KSI-CED-RAT", + name: "Reviewing All Training", + attributes: { + Theme: "KSI-CED: Cybersecurity Education", + NISTControls: null, + ClassApplicability: "Required for Classes B and C", + }, + }, +]); + +const FRR_OVERVIEW = buildOverview("FedRAMP-20x-FRR-Class-C", [ + { + id: "CMU-CSO-UVM", + name: "Using Validated Cryptographic Modules", + attributes: { + Ruleset: "CMU: Cryptographic Module Use", + Subset: "CSO: Cloud Service Provider Responsibilities", + Force: "MUST", + }, + }, + { + id: "AFC-CSO-INB", + name: "Maintain a FedRAMP Security Inbox", + attributes: { + Ruleset: "AFC: Addressing FedRAMP Communication", + Subset: "CSO: General Provider Responsibilities", + Force: "MUST", + }, + }, +]); + +describe.each([ + { label: "KSI", overview: KSI_OVERVIEW, mapper: mapKSIComplianceData }, + { + label: "FRR Class C", + overview: FRR_OVERVIEW, + mapper: mapFRRComplianceData, + }, +])("FedRAMP 20x $label cross-provider join", ({ overview, mapper }) => { + it("names every requirement with a key of the per-provider breakdown", () => { + const extras = buildRequirementExtrasMap(overview); + const names = allRequirements(mapOverview(overview, mapper)).map( + (requirement) => requirement.name, + ); + + expect(names).toHaveLength(overview.requirements.length); + for (const name of names) { + expect(extras.has(name), name).toBe(true); + } + }); +}); + +describe("mapKSIComplianceData", () => { + it("groups by Theme in catalog order and exposes KSI attributes", () => { + const [framework] = mapOverview(KSI_OVERVIEW, mapKSIComplianceData); + + expect(framework.categories.map((category) => category.name)).toEqual([ + "KSI-CED: Cybersecurity Education", + "KSI-SVC: Service Configuration", + ]); + + const [svc] = framework.categories[1].controls[0].requirements; + expect(svc.name).toBe("KSI-SVC-VRI - Validating Resource Integrity"); + expect(svc.theme).toBe("KSI-SVC: Service Configuration"); + expect(svc.nist_controls).toBe("SC-13"); + expect(svc.class_applicability).toBe("Required for Classes B and C"); + + const [ced] = framework.categories[0].controls[0].requirements; + expect(ced.nist_controls).toBeUndefined(); + }); +}); + +describe("mapFRRComplianceData", () => { + it("groups by Ruleset in catalog order and exposes FRR attributes", () => { + const [framework] = mapOverview(FRR_OVERVIEW, mapFRRComplianceData); + + expect(framework.categories.map((category) => category.name)).toEqual([ + "AFC: Addressing FedRAMP Communication", + "CMU: Cryptographic Module Use", + ]); + + const [cmu] = framework.categories[1].controls[0].requirements; + expect(cmu.ruleset).toBe("CMU: Cryptographic Module Use"); + expect(cmu.subset).toBe("CSO: Cloud Service Provider Responsibilities"); + expect(cmu.force).toBe("MUST"); + expect(framework.pass).toBe(2); + }); +}); + +describe("toAccordionItems (FedRAMP 20x)", () => { + it("keys requirement leaves by name instead of position", () => { + const items = toAccordionItems( + mapOverview(FRR_OVERVIEW, mapFRRComplianceData), + "scan-1", + ); + + expect(items.map((item) => item.key)).toEqual([ + "FedRAMP-20x-FRR-Class-C-AFC: Addressing FedRAMP Communication", + "FedRAMP-20x-FRR-Class-C-CMU: Cryptographic Module Use", + ]); + expect(items[1].items?.[0]?.key).toBe( + "FedRAMP-20x-FRR-Class-C-CMU: Cryptographic Module Use-CMU-CSO-UVM - Using Validated Cryptographic Modules", + ); + }); +}); diff --git a/ui/lib/compliance/fedramp-20x.ts b/ui/lib/compliance/fedramp-20x.ts new file mode 100644 index 0000000000..fd7d9fb1a3 --- /dev/null +++ b/ui/lib/compliance/fedramp-20x.ts @@ -0,0 +1,110 @@ +import type { + AttributesData, + FedRAMP20xFRRAttributesMetadata, + FedRAMP20xKSIAttributesMetadata, + Framework, + Requirement, + RequirementsData, + RequirementStatus, +} from "@/types/compliance"; + +import { + calculateFrameworkCounters, + createRequirementsMap, + findOrCreateCategory, + findOrCreateControl, + findOrCreateFramework, + getStatusCounters, +} from "./commons"; + +export { toGroupedAccordionItems as toAccordionItems } from "./grouped-accordion"; + +type RequirementFields = Record; + +const mapByGroup = ( + attributesData: AttributesData, + requirementsData: RequirementsData, + getGroup: (attrs: TMetadata) => string, + getFields: (attrs: TMetadata) => RequirementFields, +): Framework[] => { + const attributes = attributesData?.data || []; + const requirementsMap = createRequirementsMap(requirementsData); + const frameworks: Framework[] = []; + + for (const attributeItem of attributes) { + const id = attributeItem.id; + const metadataArray = attributeItem.attributes?.attributes + ?.metadata as unknown as TMetadata[]; + const attrs = metadataArray?.[0]; + if (!attrs) continue; + + const requirementData = requirementsMap.get(id); + if (!requirementData) continue; + + const categoryName = getGroup(attrs); + const requirementName = attributeItem.attributes.name || ""; + const status = (requirementData.attributes.status || + "") as RequirementStatus; + + const framework = findOrCreateFramework( + frameworks, + attributeItem.attributes.framework, + ); + const category = findOrCreateCategory(framework.categories, categoryName); + const control = findOrCreateControl(category.controls, categoryName); + + // The name must match `composeRequirementName` in the cross-provider + // adapter, or the per-provider breakdown cannot be joined. + const requirement: Requirement = { + ...getFields(attrs), + name: requirementName ? `${id} - ${requirementName}` : id, + description: attributeItem.attributes.description, + status, + check_ids: attributeItem.attributes.attributes.check_ids || [], + invalid_config: requirementData.attributes.invalid_config || false, + ...getStatusCounters(status), + }; + + control.requirements.push(requirement); + } + + // Theme and Ruleset names start with their catalog code, so alphabetical + // order is the catalog order. + for (const framework of frameworks) { + framework.categories.sort((a, b) => a.name.localeCompare(b.name)); + } + + calculateFrameworkCounters(frameworks); + + return frameworks; +}; + +export const mapKSIComplianceData = ( + attributesData: AttributesData, + requirementsData: RequirementsData, +): Framework[] => + mapByGroup( + attributesData, + requirementsData, + (attrs) => attrs.Theme, + (attrs) => ({ + theme: attrs.Theme, + nist_controls: attrs.NISTControls || undefined, + class_applicability: attrs.ClassApplicability, + }), + ); + +export const mapFRRComplianceData = ( + attributesData: AttributesData, + requirementsData: RequirementsData, +): Framework[] => + mapByGroup( + attributesData, + requirementsData, + (attrs) => attrs.Ruleset, + (attrs) => ({ + ruleset: attrs.Ruleset, + subset: attrs.Subset, + force: attrs.Force, + }), + ); diff --git a/ui/lib/compliance/grouped-accordion.tsx b/ui/lib/compliance/grouped-accordion.tsx new file mode 100644 index 0000000000..cea6651079 --- /dev/null +++ b/ui/lib/compliance/grouped-accordion.tsx @@ -0,0 +1,55 @@ +import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content"; +import { ComplianceAccordionRequirementTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title"; +import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title"; +import type { AccordionItemProps } from "@/components/shadcn/accordion/Accordion"; +import type { FindingStatus } from "@/components/shadcn/table/status-finding-badge"; +import type { Framework } from "@/types/compliance"; + +/** Category → requirement accordion, with stable keys across reordering. */ +export const toGroupedAccordionItems = ( + data: Framework[], + scanId: string | undefined, +): AccordionItemProps[] => { + const safeId = scanId || ""; + + return data.flatMap((framework) => + framework.categories.map((category) => ({ + key: `${framework.name}-${category.name}`, + title: ( + + ), + content: "", + items: category.controls.flatMap((control) => + control.requirements.map((requirement) => ({ + key: `${framework.name}-${category.name}-${requirement.name}`, + title: ( + + ), + content: ( + + ), + items: [], + })), + ), + })), + ); +}; diff --git a/ui/lib/compliance/okta-idaas-stig.tsx b/ui/lib/compliance/okta-idaas-stig.tsx index 8603c33711..3d8b797b8a 100644 --- a/ui/lib/compliance/okta-idaas-stig.tsx +++ b/ui/lib/compliance/okta-idaas-stig.tsx @@ -10,7 +10,6 @@ import { isOktaIDaaSStigAttributesMetadata, OktaIDaaSStigRequirement, Requirement, - REQUIREMENT_STATUS, RequirementsData, RequirementStatus, } from "@/types/compliance"; @@ -21,14 +20,9 @@ import { findOrCreateCategory, findOrCreateControl, findOrCreateFramework, + getStatusCounters, } from "./commons"; -const getStatusCounters = (status: RequirementStatus) => ({ - pass: status === REQUIREMENT_STATUS.PASS ? 1 : 0, - fail: status === REQUIREMENT_STATUS.FAIL ? 1 : 0, - manual: status === REQUIREMENT_STATUS.MANUAL ? 1 : 0, -}); - export const mapComplianceData = ( attributesData: AttributesData, requirementsData: RequirementsData, diff --git a/ui/types/compliance.ts b/ui/types/compliance.ts index be7462f549..e7f27a8e09 100644 --- a/ui/types/compliance.ts +++ b/ui/types/compliance.ts @@ -474,6 +474,20 @@ export interface CMMCAttributesMetadata { SourceRequirement: string; } +// FedRAMP 20x KSI (`prowler/compliance/fedramp_20x_ksi_2026.json`), grouped by Theme. +export interface FedRAMP20xKSIAttributesMetadata { + Theme: string; + NISTControls?: string | null; + ClassApplicability: string; +} + +// FedRAMP 20x Class C FRR (`prowler/compliance/fedramp_20x_frr_class_c_2026.json`), grouped by Ruleset. +export interface FedRAMP20xFRRAttributesMetadata { + Ruleset: string; + Subset: string; + Force: string; +} + export interface AttributesItemData { type: "compliance-requirements-attributes"; id: string; @@ -501,6 +515,8 @@ export interface AttributesItemData { | CISControlsAttributesMetadata[] | CyberEssentialsAttributesMetadata[] | CMMCAttributesMetadata[] + | FedRAMP20xKSIAttributesMetadata[] + | FedRAMP20xFRRAttributesMetadata[] | GenericAttributesMetadata[]; check_ids: string[]; // MITRE structure From 757cd44ecbc306137723163881e9b3703ac9dbf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9sar=20Arroba?= <19954079+cesararroba@users.noreply.github.com> Date: Wed, 16 Sep 2026 09:24:41 +0200 Subject: [PATCH 08/23] fix(aws): try the rest of the partition when the bootstrap region is unreachable (#12799) Co-authored-by: pedrooot --- .../providers/aws/regions-and-partitions.mdx | 2 + ...rap-falls-back-to-the-next-region.fixed.md | 1 + prowler/providers/aws/aws_provider.py | 150 ++++++- tests/providers/aws/aws_provider_test.py | 408 ++++++++++++++++++ 4 files changed, 548 insertions(+), 13 deletions(-) create mode 100644 prowler/changelog.d/aws-partition-bootstrap-falls-back-to-the-next-region.fixed.md diff --git a/docs/user-guide/providers/aws/regions-and-partitions.mdx b/docs/user-guide/providers/aws/regions-and-partitions.mdx index bf88aafc2f..369f581826 100644 --- a/docs/user-guide/providers/aws/regions-and-partitions.mdx +++ b/docs/user-guide/providers/aws/regions-and-partitions.mdx @@ -39,6 +39,8 @@ It matters most where nothing else says. Resolving an identity means calling STS A region configured for the session still wins when it belongs to the declared partition, so a deployment in `us-gov-west-1` is not sent to `us-gov-east-1`. A region belonging to a different partition is ignored, since a partition that has been declared explicitly is the more deliberate statement of the two. +When no configured region says which one to prefer, the first region of the partition is tried, and up to two more follow if it cannot be reached. A network that routes to only one region of its partition therefore works without having to declare which one that is. Only a connection failure moves on to the next region: a credential error is reported from the first, since it would be the same everywhere. A region excluded from the scan is tried last, so it is avoided whenever another region of the partition answers. + Set it wherever the scan runs. For deployments that scan from containers, that means the environment of the containers doing the scanning, not only the one accepting the request. diff --git a/prowler/changelog.d/aws-partition-bootstrap-falls-back-to-the-next-region.fixed.md b/prowler/changelog.d/aws-partition-bootstrap-falls-back-to-the-next-region.fixed.md new file mode 100644 index 0000000000..4bd9c855d1 --- /dev/null +++ b/prowler/changelog.d/aws-partition-bootstrap-falls-back-to-the-next-region.fixed.md @@ -0,0 +1 @@ +Bootstrap STS calls now try up to two more regions of the partition declared in `PROWLER_AWS_PARTITION` when the first one cannot be reached, so a deployment that routes to only one region of its partition no longer fails on an endpoint it has no path to. This covers validating credentials, assuming a role and getting an MFA session token diff --git a/prowler/providers/aws/aws_provider.py b/prowler/providers/aws/aws_provider.py index e204318593..784daf0caa 100644 --- a/prowler/providers/aws/aws_provider.py +++ b/prowler/providers/aws/aws_provider.py @@ -3,12 +3,19 @@ import pathlib from datetime import datetime from functools import lru_cache from re import fullmatch -from typing import Optional +from typing import Any, Callable, Optional from boto3.session import Session from botocore.config import Config from botocore.credentials import RefreshableCredentials -from botocore.exceptions import ClientError, NoCredentialsError, ProfileNotFound +from botocore.exceptions import ( + ClientError, + ConnectTimeoutError, + EndpointConnectionError, + NoCredentialsError, + ProfileNotFound, + ReadTimeoutError, +) from botocore.session import Session as BotocoreSession from colorama import Fore, Style from pytz import utc @@ -263,7 +270,10 @@ class AwsProvider(Provider): caller_identity = self.validate_credentials( session=self.session.current_session, aws_region=sts_region, + excluded_regions=excluded_regions, ) + # Later STS calls go where validation got an answer, not where it timed out + sts_region = caller_identity.region logger.info("Credentials validated") ######## @@ -690,8 +700,6 @@ class AwsProvider(Provider): or session.region_name or AWS_STS_GLOBAL_ENDPOINT_REGION ) - sts_client = AwsProvider.create_sts_session(session, sts_region) - # TODO: pass values from the input mfa_info = AwsProvider.input_role_mfa_token_and_code() # TODO: validate MFA ARN here @@ -699,8 +707,12 @@ class AwsProvider(Provider): "SerialNumber": mfa_info.arn, "TokenCode": mfa_info.totp, } - session_credentials = sts_client.get_session_token( - **get_session_token_arguments + _, session_credentials = AwsProvider.sts_call_with_partition_failover( + session, + sts_region, + lambda sts_client: sts_client.get_session_token( + **get_session_token_arguments + ), ) mfa_session = Session( aws_access_key_id=session_credentials["Credentials"]["AccessKeyId"], @@ -1244,10 +1256,11 @@ class AwsProvider(Provider): mfa_info = AwsProvider.input_role_mfa_token_and_code() assume_role_arguments["SerialNumber"] = mfa_info.arn assume_role_arguments["TokenCode"] = mfa_info.totp - sts_client = AwsProvider.create_sts_session( - session, assumed_role_info.sts_region + _, assumed_credentials = AwsProvider.sts_call_with_partition_failover( + session, + assumed_role_info.sts_region, + lambda sts_client: sts_client.assume_role(**assume_role_arguments), ) - assumed_credentials = sts_client.assume_role(**assume_role_arguments) # Convert the UTC datetime object to your local timezone credentials_expiration_local_time = ( assumed_credentials["Credentials"]["Expiration"] @@ -1326,30 +1339,98 @@ class AwsProvider(Provider): ) raise error + @staticmethod + def sts_call_with_partition_failover( + session: Session, + aws_region: str, + operation: Callable[[Any], Any], + excluded_regions: set[str] | None = None, + ) -> tuple[str, Any]: + """ + Run a bootstrap STS call, moving on when a region cannot be reached. + + Bootstrap calls happen before anything is known about the credentials, so + the region they go to is a guess whenever none was configured. On a network + that routes to only one region of its partition that guess is fatal, and the + remaining regions of the partition declared in PROWLER_AWS_PARTITION are the + ones worth trying. + + Args: + session (Session): The AWS session object. + aws_region (str): The region to try first. + operation (Callable[[Any], Any]): Receives an STS client and performs + the call. + excluded_regions (set[str] | None): Regions excluded from the scan, + tried after the rest of the partition. + + Returns: + tuple[str, Any]: The region that answered and whatever the operation + returned. + + Raises: + Exception: Whatever the operation raises, or the last connection error + when no region could be reached. + """ + *fallback_regions, last_region = get_partition_bootstrap_candidates( + aws_region, session.region_name, excluded_regions + ) + + for candidate_region in fallback_regions: + try: + sts_client = AwsProvider.create_sts_session(session, candidate_region) + return candidate_region, operation(sts_client) + # The credentials are not at fault, so the next region is worth trying + except ( + EndpointConnectionError, + ConnectTimeoutError, + ReadTimeoutError, + ) as unreachable: + logger.warning( + f"{unreachable.__class__.__name__}[{unreachable.__traceback__.tb_lineno}]: {unreachable}" + ) + + # Nothing is left to try after the last region, so its error is the answer + sts_client = AwsProvider.create_sts_session(session, last_region) + return last_region, operation(sts_client) + @staticmethod def validate_credentials( session: Session, aws_region: str, + excluded_regions: set[str] | None = None, ) -> AWSCallerIdentity: """ Validates the AWS credentials using the provided session and AWS region. + + When the region cannot be reached, the remaining regions of the partition + declared in PROWLER_AWS_PARTITION are tried before giving up. A credential + error is returned from the first region instead, since it would be the same + everywhere. + Args: session (Session): The AWS session object. aws_region (str): The AWS region to validate the credentials. + excluded_regions (set[str] | None): Regions excluded from the scan, + tried after the rest of the partition. Returns: - AWSCallerIdentity: An object containing the caller identity information. + AWSCallerIdentity: An object containing the caller identity information, + including the region that answered. Raises: Exception: If an error occurs during the validation process. """ try: - sts_client = AwsProvider.create_sts_session(session, aws_region) - caller_identity = sts_client.get_caller_identity() + sts_region, caller_identity = AwsProvider.sts_call_with_partition_failover( + session, + aws_region, + lambda sts_client: sts_client.get_caller_identity(), + excluded_regions, + ) # Include the region where the caller_identity has validated the credentials return AWSCallerIdentity( user_id=caller_identity.get("UserId"), account=caller_identity.get("Account"), arn=ARN(caller_identity.get("Arn")), - region=aws_region, + region=sts_region, ) except ClientError as client_error: logger.error( @@ -1846,6 +1927,49 @@ def get_env_partition_bootstrap_region( return regions[0] if regions else None +# An unreachable endpoint costs a connection timeout, so a partition with many +# regions is not walked in full +MAX_STS_BOOTSTRAP_ATTEMPTS = 3 + + +def get_partition_bootstrap_candidates( + aws_region: str, + session_region: Optional[str] = None, + excluded_regions: set[str] | None = None, +) -> list: + """ + Get the STS bootstrap regions to try, in order, starting with the chosen one. + + A deployment reached only through its own region's endpoints has no route to + the rest of its partition, and which region that is cannot be known from the + environment alone: a container may carry a region belonging to no partition + it scans. Offering the remaining regions of the declared partition lets the + bootstrap succeed without anything having to declare the right one. + + Args: + aws_region (str): The region already chosen for the bootstrap call. + session_region (Optional[str]): The region of the AWS session. + excluded_regions (set[str] | None): Regions excluded from the scan. They + go after the rest of the partition, so the bootstrap avoids them + whenever another region answers and still has them as a last resort. + + Returns: + list: The regions to try, preferred first, capped at + MAX_STS_BOOTSTRAP_ATTEMPTS. + """ + excluded_regions = set(excluded_regions or ()) + partition_regions = get_env_partition_regions(session_region) or [] + # sorted() is stable, so the partition order survives on each side of the split + ordered_regions = sorted( + partition_regions, key=lambda region: region in excluded_regions + ) + candidates = [aws_region] + for region in ordered_regions: + if region not in candidates: + candidates.append(region) + return candidates[:MAX_STS_BOOTSTRAP_ATTEMPTS] + + # TODO: This can be moved to another class since it doesn't need self def get_aws_region_for_sts( session_region: str, diff --git a/tests/providers/aws/aws_provider_test.py b/tests/providers/aws/aws_provider_test.py index f899706453..2d759f362a 100644 --- a/tests/providers/aws/aws_provider_test.py +++ b/tests/providers/aws/aws_provider_test.py @@ -17,10 +17,12 @@ from pytest import raises from tzlocal import get_localzone from prowler.providers.aws.aws_provider import ( + MAX_STS_BOOTSTRAP_ATTEMPTS, AwsProvider, get_aws_region_for_sts, get_env_partition_bootstrap_region, get_env_partition_regions, + get_partition_bootstrap_candidates, ) from prowler.providers.aws.config import ( AWS_STS_GLOBAL_ENDPOINT_REGION, @@ -32,6 +34,7 @@ from prowler.providers.aws.config import ( get_default_session_config, ) from prowler.providers.aws.exceptions.exceptions import ( + AWSAccessKeyIDInvalidError, AWSArgumentTypeValidationError, AWSIAMRoleARNInvalidResourceTypeError, AWSInvalidBoto3TimeoutError, @@ -1583,6 +1586,411 @@ aws: assert get_caller_identity.arn.resource == "test-user" assert get_caller_identity.arn.resource_type == "user" + def test_get_partition_bootstrap_candidates_adds_the_rest_of_the_partition( + self, monkeypatch + ): + monkeypatch.setenv("PROWLER_AWS_PARTITION", AWS_GOV_CLOUD_PARTITION) + + assert get_partition_bootstrap_candidates( + AWS_REGION_GOV_CLOUD_US_EAST_1, AWS_REGION_US_EAST_1 + ) == [AWS_REGION_GOV_CLOUD_US_EAST_1, AWS_REGION_GOV_CLOUD_US_WEST_1] + + def test_get_partition_bootstrap_candidates_without_partition_offers_one_region( + self, monkeypatch + ): + monkeypatch.delenv("PROWLER_AWS_PARTITION", raising=False) + + assert get_partition_bootstrap_candidates(AWS_REGION_EU_WEST_1) == [ + AWS_REGION_EU_WEST_1 + ] + + def test_get_partition_bootstrap_candidates_is_capped(self, monkeypatch): + monkeypatch.setenv("PROWLER_AWS_PARTITION", AWS_COMMERCIAL_PARTITION) + + candidates = get_partition_bootstrap_candidates( + AWS_REGION_EU_WEST_1, AWS_REGION_EU_WEST_1 + ) + + assert len(candidates) == MAX_STS_BOOTSTRAP_ATTEMPTS + assert candidates[0] == AWS_REGION_EU_WEST_1 + + def test_get_partition_bootstrap_candidates_tries_excluded_regions_last( + self, monkeypatch + ): + monkeypatch.setenv("PROWLER_AWS_PARTITION", AWS_COMMERCIAL_PARTITION) + partition_regions = get_env_partition_regions(AWS_REGION_EU_WEST_1) + excluded_regions = set(partition_regions[1:3]) + + candidates = get_partition_bootstrap_candidates( + AWS_REGION_EU_WEST_1, AWS_REGION_EU_WEST_1, excluded_regions + ) + + assert candidates == [AWS_REGION_EU_WEST_1, *partition_regions[3:5]] + + def test_get_partition_bootstrap_candidates_keeps_excluded_regions_as_a_last_resort( + self, monkeypatch + ): + monkeypatch.setenv("PROWLER_AWS_PARTITION", AWS_GOV_CLOUD_PARTITION) + + assert get_partition_bootstrap_candidates( + AWS_REGION_GOV_CLOUD_US_EAST_1, + AWS_REGION_US_EAST_1, + {AWS_REGION_GOV_CLOUD_US_EAST_1, AWS_REGION_GOV_CLOUD_US_WEST_1}, + ) == [AWS_REGION_GOV_CLOUD_US_EAST_1, AWS_REGION_GOV_CLOUD_US_WEST_1] + + def test_validate_credentials_falls_back_to_the_next_partition_region( + self, monkeypatch + ): + monkeypatch.setenv("PROWLER_AWS_PARTITION", AWS_GOV_CLOUD_PARTITION) + # A container may carry a region that belongs to no partition it scans + current_session = session.Session(region_name=AWS_REGION_US_EAST_1) + attempted_regions = [] + + def create_sts_session(session, aws_region): + attempted_regions.append(aws_region) + if aws_region == AWS_REGION_GOV_CLOUD_US_EAST_1: + raise botocore.exceptions.EndpointConnectionError( + endpoint_url=f"https://sts.{aws_region}.amazonaws.com" + ) + sts_client = mock.MagicMock() + sts_client.get_caller_identity.return_value = { + "UserId": "test-user-id", + "Account": AWS_ACCOUNT_NUMBER, + "Arn": AWS_GOV_CLOUD_ACCOUNT_ARN, + } + return sts_client + + with patch( + "prowler.providers.aws.aws_provider.AwsProvider.create_sts_session", + new=create_sts_session, + ): + caller_identity = AwsProvider.validate_credentials( + session=current_session, aws_region=AWS_REGION_GOV_CLOUD_US_EAST_1 + ) + + assert attempted_regions == [ + AWS_REGION_GOV_CLOUD_US_EAST_1, + AWS_REGION_GOV_CLOUD_US_WEST_1, + ] + assert caller_identity.region == AWS_REGION_GOV_CLOUD_US_WEST_1 + + def test_validate_credentials_falls_back_when_a_region_does_not_answer( + self, monkeypatch + ): + monkeypatch.setenv("PROWLER_AWS_PARTITION", AWS_GOV_CLOUD_PARTITION) + current_session = session.Session(region_name=AWS_REGION_US_EAST_1) + attempted_regions = [] + + # The connection is accepted but nothing comes back before the read timeout + def create_sts_session(session, aws_region): + attempted_regions.append(aws_region) + if aws_region == AWS_REGION_GOV_CLOUD_US_EAST_1: + raise botocore.exceptions.ReadTimeoutError( + endpoint_url=f"https://sts.{aws_region}.amazonaws.com" + ) + sts_client = mock.MagicMock() + sts_client.get_caller_identity.return_value = { + "UserId": "test-user-id", + "Account": AWS_ACCOUNT_NUMBER, + "Arn": AWS_GOV_CLOUD_ACCOUNT_ARN, + } + return sts_client + + with patch( + "prowler.providers.aws.aws_provider.AwsProvider.create_sts_session", + new=create_sts_session, + ): + caller_identity = AwsProvider.validate_credentials( + session=current_session, aws_region=AWS_REGION_GOV_CLOUD_US_EAST_1 + ) + + assert attempted_regions == [ + AWS_REGION_GOV_CLOUD_US_EAST_1, + AWS_REGION_GOV_CLOUD_US_WEST_1, + ] + assert caller_identity.region == AWS_REGION_GOV_CLOUD_US_WEST_1 + + def test_validate_credentials_raises_when_no_partition_region_answers( + self, monkeypatch + ): + monkeypatch.setenv("PROWLER_AWS_PARTITION", AWS_GOV_CLOUD_PARTITION) + current_session = session.Session(region_name=AWS_REGION_US_EAST_1) + attempted_regions = [] + + def create_sts_session(session, aws_region): + attempted_regions.append(aws_region) + raise botocore.exceptions.EndpointConnectionError( + endpoint_url=f"https://sts.{aws_region}.amazonaws.com" + ) + + with patch( + "prowler.providers.aws.aws_provider.AwsProvider.create_sts_session", + new=create_sts_session, + ): + with raises(botocore.exceptions.EndpointConnectionError): + AwsProvider.validate_credentials( + session=current_session, aws_region=AWS_REGION_GOV_CLOUD_US_EAST_1 + ) + + assert attempted_regions == [ + AWS_REGION_GOV_CLOUD_US_EAST_1, + AWS_REGION_GOV_CLOUD_US_WEST_1, + ] + + def test_validate_credentials_avoids_an_excluded_region_when_failing_over( + self, monkeypatch + ): + monkeypatch.setenv("PROWLER_AWS_PARTITION", AWS_COMMERCIAL_PARTITION) + current_session = session.Session(region_name=AWS_REGION_EU_WEST_1) + partition_regions = get_env_partition_regions(AWS_REGION_EU_WEST_1) + excluded_region, answering_region = partition_regions[1:3] + attempted_regions = [] + + def create_sts_session(session, aws_region): + attempted_regions.append(aws_region) + if aws_region == AWS_REGION_EU_WEST_1: + raise botocore.exceptions.EndpointConnectionError( + endpoint_url=f"https://sts.{aws_region}.amazonaws.com" + ) + sts_client = mock.MagicMock() + sts_client.get_caller_identity.return_value = { + "UserId": "test-user-id", + "Account": AWS_ACCOUNT_NUMBER, + "Arn": AWS_ACCOUNT_ARN, + } + return sts_client + + with patch( + "prowler.providers.aws.aws_provider.AwsProvider.create_sts_session", + new=create_sts_session, + ): + caller_identity = AwsProvider.validate_credentials( + session=current_session, + aws_region=AWS_REGION_EU_WEST_1, + excluded_regions={excluded_region}, + ) + + assert attempted_regions == [AWS_REGION_EU_WEST_1, answering_region] + assert caller_identity.region == answering_region + + def test_validate_credentials_does_not_retry_a_credential_error(self, monkeypatch): + monkeypatch.setenv("PROWLER_AWS_PARTITION", AWS_GOV_CLOUD_PARTITION) + current_session = session.Session(region_name=AWS_REGION_US_EAST_1) + attempted_regions = [] + + def create_sts_session(session, aws_region): + attempted_regions.append(aws_region) + sts_client = mock.MagicMock() + sts_client.get_caller_identity.side_effect = ( + botocore.exceptions.ClientError( + {"Error": {"Code": "InvalidClientTokenId", "Message": "invalid"}}, + "GetCallerIdentity", + ) + ) + return sts_client + + with patch( + "prowler.providers.aws.aws_provider.AwsProvider.create_sts_session", + new=create_sts_session, + ): + with raises(AWSAccessKeyIDInvalidError): + AwsProvider.validate_credentials( + session=current_session, aws_region=AWS_REGION_GOV_CLOUD_US_EAST_1 + ) + + assert attempted_regions == [AWS_REGION_GOV_CLOUD_US_EAST_1] + + def test_assume_role_falls_back_to_the_next_partition_region(self, monkeypatch): + monkeypatch.setenv("PROWLER_AWS_PARTITION", AWS_GOV_CLOUD_PARTITION) + current_session = session.Session(region_name=AWS_REGION_US_EAST_1) + attempted_regions = [] + + def create_sts_session(session, aws_region): + attempted_regions.append(aws_region) + if aws_region == AWS_REGION_GOV_CLOUD_US_EAST_1: + raise botocore.exceptions.EndpointConnectionError( + endpoint_url=f"https://sts.{aws_region}.amazonaws.com" + ) + sts_client = mock.MagicMock() + sts_client.assume_role.return_value = { + "Credentials": { + "AccessKeyId": "AKIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "secret", + "SessionToken": "token", + "Expiration": datetime.now() + timedelta(seconds=3600), + } + } + return sts_client + + assumed_role_info = AWSAssumeRoleInfo( + role_arn=ARN( + arn=f"arn:{AWS_GOV_CLOUD_PARTITION}:iam::{AWS_ACCOUNT_NUMBER}:role/test-role" + ), + session_duration=3600, + external_id=None, + mfa_enabled=False, + role_session_name=ROLE_SESSION_NAME, + sts_region=AWS_REGION_GOV_CLOUD_US_EAST_1, + ) + + with patch( + "prowler.providers.aws.aws_provider.AwsProvider.create_sts_session", + new=create_sts_session, + ): + credentials = AwsProvider.assume_role(current_session, assumed_role_info) + + assert attempted_regions == [ + AWS_REGION_GOV_CLOUD_US_EAST_1, + AWS_REGION_GOV_CLOUD_US_WEST_1, + ] + assert isinstance(credentials, AWSCredentials) + assert credentials.aws_access_key_id == "AKIAIOSFODNN7EXAMPLE" + + def test_setup_session_mfa_falls_back_to_the_next_partition_region( + self, monkeypatch + ): + monkeypatch.setenv("PROWLER_AWS_PARTITION", AWS_GOV_CLOUD_PARTITION) + monkeypatch.setenv("AWS_DEFAULT_REGION", AWS_REGION_US_EAST_1) + attempted_regions = [] + + def create_sts_session(session, aws_region): + attempted_regions.append(aws_region) + if aws_region == AWS_REGION_GOV_CLOUD_US_EAST_1: + raise botocore.exceptions.EndpointConnectionError( + endpoint_url=f"https://sts.{aws_region}.amazonaws.com" + ) + sts_client = mock.MagicMock() + sts_client.get_session_token.return_value = { + "Credentials": { + "AccessKeyId": "AKIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "secret", + "SessionToken": "token", + } + } + return sts_client + + with ( + patch( + "prowler.providers.aws.aws_provider.AwsProvider.input_role_mfa_token_and_code", + return_value=AWSMFAInfo( + arn=f"arn:{AWS_GOV_CLOUD_PARTITION}:iam::{AWS_ACCOUNT_NUMBER}:mfa/test", + totp="123456", + ), + ), + patch( + "prowler.providers.aws.aws_provider.AwsProvider.create_sts_session", + new=create_sts_session, + ), + ): + mfa_session = AwsProvider.setup_session( + mfa=True, + aws_access_key_id="test-access-key", + aws_secret_access_key="test-secret-key", + ) + + assert attempted_regions == [ + AWS_REGION_GOV_CLOUD_US_EAST_1, + AWS_REGION_GOV_CLOUD_US_WEST_1, + ] + assert mfa_session.get_credentials().access_key == "AKIAIOSFODNN7EXAMPLE" + assert mfa_session.get_credentials().token == "token" + + @mock_aws + def test_aws_provider_hands_excluded_regions_to_credential_validation( + self, monkeypatch + ): + monkeypatch.setenv("PROWLER_AWS_PARTITION", AWS_GOV_CLOUD_PARTITION) + monkeypatch.setenv("AWS_DEFAULT_REGION", AWS_REGION_US_EAST_1) + handed = [] + + class Validated(Exception): + pass + + # Stops at the validation: what it was handed is all this checks + def validate_credentials(session, aws_region, excluded_regions=None): + handed.append((aws_region, set(excluded_regions or ()))) + raise Validated + + with patch( + "prowler.providers.aws.aws_provider.AwsProvider.validate_credentials", + side_effect=validate_credentials, + ): + with raises(Validated): + AwsProvider(excluded_regions={AWS_REGION_GOV_CLOUD_US_EAST_1}) + + assert handed == [ + (AWS_REGION_GOV_CLOUD_US_WEST_1, {AWS_REGION_GOV_CLOUD_US_EAST_1}) + ] + + @mock_aws + def test_aws_provider_assumes_the_role_where_validation_got_an_answer( + self, monkeypatch + ): + monkeypatch.setenv("PROWLER_AWS_PARTITION", AWS_GOV_CLOUD_PARTITION) + # Out of the partition, so the first candidate is botocore's, not this one + monkeypatch.setenv("AWS_DEFAULT_REGION", AWS_REGION_US_EAST_1) + role_arn = ( + f"arn:{AWS_GOV_CLOUD_PARTITION}:iam::{AWS_ACCOUNT_NUMBER}:role/test-role" + ) + answered = AWSCallerIdentity( + user_id="test-user-id", + account=AWS_ACCOUNT_NUMBER, + arn=ARN(AWS_GOV_CLOUD_ACCOUNT_ARN), + region=AWS_REGION_GOV_CLOUD_US_WEST_1, + ) + + with patch( + "prowler.providers.aws.aws_provider.AwsProvider.validate_credentials", + return_value=answered, + ): + aws_provider = AwsProvider(role_arn=role_arn, session_duration=900) + + assert ( + aws_provider._assumed_role_configuration.info.sts_region + == AWS_REGION_GOV_CLOUD_US_WEST_1 + ) + + @mock_aws + def test_aws_provider_assumes_the_organizations_role_where_validation_got_an_answer( + self, monkeypatch + ): + monkeypatch.setenv("PROWLER_AWS_PARTITION", AWS_GOV_CLOUD_PARTITION) + monkeypatch.setenv("AWS_DEFAULT_REGION", AWS_REGION_US_EAST_1) + organizations_role_arn = f"arn:{AWS_GOV_CLOUD_PARTITION}:iam::{AWS_ACCOUNT_NUMBER}:role/organizations-role" + answered = AWSCallerIdentity( + user_id="test-user-id", + account=AWS_ACCOUNT_NUMBER, + arn=ARN(AWS_GOV_CLOUD_ACCOUNT_ARN), + region=AWS_REGION_GOV_CLOUD_US_WEST_1, + ) + sts_regions = [] + + class RoleAssumed(Exception): + pass + + # Stops at the assumption: the region it was handed is all this checks + def assume_role(session, assumed_role_info): + sts_regions.append(assumed_role_info.sts_region) + raise RoleAssumed + + with ( + patch( + "prowler.providers.aws.aws_provider.AwsProvider.validate_credentials", + return_value=answered, + ), + patch( + "prowler.providers.aws.aws_provider.AwsProvider.assume_role", + side_effect=assume_role, + ), + ): + with raises(RoleAssumed): + AwsProvider( + organizations_role_arn=organizations_role_arn, + session_duration=900, + ) + + assert sts_regions == [AWS_REGION_GOV_CLOUD_US_WEST_1] + @mock_aws def test_test_connection_with_env_credentials(self, monkeypatch): # Create a mock IAM user From 75c22df63b2107f04c6d80de325147feec375149 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9sar=20Arroba?= <19954079+cesararroba@users.noreply.github.com> Date: Wed, 16 Sep 2026 09:25:08 +0200 Subject: [PATCH 09/23] fix(azure): use the selected cloud endpoints in Defender and Key Vault (#12813) --- ...ign-cloud-defender-keyvault-hosts.fixed.md | 1 + .../providers/azure/lib/service/service.py | 1 + prowler/providers/azure/models.py | 4 +- .../services/defender/defender_service.py | 13 ++- .../services/keyvault/keyvault_service.py | 27 +++++- .../azure/lib/service/azure_service_test.py | 16 ++++ .../defender/defender_service_test.py | 51 +++++++++++ .../keyvault/keyvault_service_test.py | 84 +++++++++++++++++++ 8 files changed, 190 insertions(+), 7 deletions(-) create mode 100644 prowler/changelog.d/azure-sovereign-cloud-defender-keyvault-hosts.fixed.md diff --git a/prowler/changelog.d/azure-sovereign-cloud-defender-keyvault-hosts.fixed.md b/prowler/changelog.d/azure-sovereign-cloud-defender-keyvault-hosts.fixed.md new file mode 100644 index 0000000000..99ef3354e0 --- /dev/null +++ b/prowler/changelog.d/azure-sovereign-cloud-defender-keyvault-hosts.fixed.md @@ -0,0 +1 @@ +Azure Defender security contacts and Key Vault key rotation policies now use the endpoints of the selected cloud (`--azure-region`) instead of the hardcoded `management.azure.com` and `vault.azure.net` hosts, so both work on `AzureUSGovernment` and `AzureChinaCloud` diff --git a/prowler/providers/azure/lib/service/service.py b/prowler/providers/azure/lib/service/service.py index 9d63639e94..9647723173 100644 --- a/prowler/providers/azure/lib/service/service.py +++ b/prowler/providers/azure/lib/service/service.py @@ -27,6 +27,7 @@ class AzureService: ) self.subscriptions = provider.identity.subscriptions + self.region_config = provider.region_config self.resource_groups = provider.resource_groups self.locations = provider.locations self.audit_config = provider.audit_config diff --git a/prowler/providers/azure/models.py b/prowler/providers/azure/models.py index 62d03db365..5df80afab6 100644 --- a/prowler/providers/azure/models.py +++ b/prowler/providers/azure/models.py @@ -18,8 +18,8 @@ class AzureIdentityInfo(BaseModel): class AzureRegionConfig(BaseModel): name: str = "" authority: Optional[str] = None - base_url: str = "" - credential_scopes: list = [] + base_url: str = "https://management.azure.com" + credential_scopes: list = ["https://management.azure.com/.default"] graph_host: str = "https://graph.microsoft.com" graph_scope: str = "https://graph.microsoft.com/.default" logs_endpoint: str = "https://api.loganalytics.io" diff --git a/prowler/providers/azure/services/defender/defender_service.py b/prowler/providers/azure/services/defender/defender_service.py index d68d88dc22..17777843e4 100644 --- a/prowler/providers/azure/services/defender/defender_service.py +++ b/prowler/providers/azure/services/defender/defender_service.py @@ -12,7 +12,16 @@ from prowler.providers.azure.lib.service.service import AzureService class Defender(AzureService): + """Microsoft Defender for Cloud service: pricings, settings, assessments, + security contacts, IoT solutions and JIT policies per subscription.""" + def __init__(self, provider: AzureProvider): + """Collect the Defender configuration of every audited subscription. + + Args: + provider: Azure provider supplying the session, subscriptions and + the region config whose endpoints are used for every call. + """ super().__init__(SecurityCenter, provider) self.pricings = self._get_pricings() @@ -21,7 +30,7 @@ class Defender(AzureService): self.settings = self._get_settings() self.security_contact_configurations = self._get_security_contacts( token=provider.session.get_token( - "https://management.azure.com/.default" + *self.region_config.credential_scopes ).token ) self.iot_security_solutions = self._get_iot_security_solutions() @@ -168,7 +177,7 @@ class Defender(AzureService): security_contacts = {} for subscription_id, display_name in self.subscriptions.items(): try: - url = f"https://management.azure.com/subscriptions/{subscription_id}/providers/Microsoft.Security/securityContacts?api-version=2023-12-01-preview" + url = f"{self.region_config.base_url}/subscriptions/{subscription_id}/providers/Microsoft.Security/securityContacts?api-version=2023-12-01-preview" headers = { "Authorization": f"Bearer {token}", "Content-Type": "application/json", diff --git a/prowler/providers/azure/services/keyvault/keyvault_service.py b/prowler/providers/azure/services/keyvault/keyvault_service.py index e5b2e76427..5e79412f4b 100644 --- a/prowler/providers/azure/services/keyvault/keyvault_service.py +++ b/prowler/providers/azure/services/keyvault/keyvault_service.py @@ -83,6 +83,7 @@ class KeyVault(AzureService): subscription, resource_group, keyvault_name, + getattr(keyvault_properties, "vault_uri", ""), provider, ) secrets_future = executor.submit( @@ -150,7 +151,22 @@ class KeyVault(AzureService): ) return None - def _get_keys(self, subscription, resource_group, keyvault_name, provider): + def _get_keys( + self, subscription, resource_group, keyvault_name, vault_uri, provider + ): + """Get the keys of a Key Vault, enriched with their rotation policies. + + Args: + subscription: Subscription ID the vault belongs to. + resource_group: Resource group name of the vault. + keyvault_name: Vault name, used for the management API and logs. + vault_uri: Data-plane URI of the vault as returned by ARM, valid in + any Azure cloud. When empty, rotation policies are skipped. + provider: Azure provider whose session authenticates the KeyClient. + + Returns: + A list of Key objects; rotation_policy is set when it could be read. + """ logger.info(f"KeyVault - Getting keys for {keyvault_name}...") keys = [] keys_dict = {} @@ -179,10 +195,15 @@ class KeyVault(AzureService): f"Subscription ID: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + if not vault_uri: + logger.warning( + f"KeyVault {keyvault_name} in {subscription} -- has no vault URI, skipping key rotation policies" + ) + return keys + try: key_client = KeyClient( - vault_url=f"https://{keyvault_name}.vault.azure.net/", - # TODO: review the following line + vault_url=vault_uri, credential=provider.session, ) properties = list(key_client.list_properties_of_keys()) diff --git a/tests/providers/azure/lib/service/azure_service_test.py b/tests/providers/azure/lib/service/azure_service_test.py index 9360be85ea..49042dc476 100644 --- a/tests/providers/azure/lib/service/azure_service_test.py +++ b/tests/providers/azure/lib/service/azure_service_test.py @@ -106,3 +106,19 @@ class TestAzureServiceSovereignClouds: service.__set_clients__(identity, session, logs_service, region_config) logs_service.assert_called_once_with(credential=session, endpoint=logs_endpoint) + + +class TestAzureServiceRegionConfig: + def test_init_keeps_provider_region_config(self): + region_config = AzureRegionConfig( + name="AzureUSGovernment", + base_url="https://management.usgovcloudapi.net", + credential_scopes=["https://management.usgovcloudapi.net/.default"], + ) + provider = MagicMock() + provider.region_config = region_config + + with patch.object(AzureService, "__set_clients__", return_value={}): + service = AzureService(MagicMock(), provider) + + assert service.region_config is region_config diff --git a/tests/providers/azure/services/defender/defender_service_test.py b/tests/providers/azure/services/defender/defender_service_test.py index b50ddd26c9..d9cdb149ab 100644 --- a/tests/providers/azure/services/defender/defender_service_test.py +++ b/tests/providers/azure/services/defender/defender_service_test.py @@ -1,6 +1,7 @@ from datetime import timedelta from unittest.mock import MagicMock, patch +from prowler.providers.azure.models import AzureRegionConfig from prowler.providers.azure.services.defender.defender_service import ( Assesment, AutoProvisioningSetting, @@ -618,3 +619,53 @@ class Test_Defender_get_jit_policies: mock_client.jit_network_access_policies.list_by_resource_group.assert_called_once_with( resource_group_name="RG" ) + + +US_GOV_REGION_CONFIG = AzureRegionConfig( + name="AzureUSGovernment", + base_url="https://management.usgovcloudapi.net", + credential_scopes=["https://management.usgovcloudapi.net/.default"], +) + + +class Test_Defender_get_security_contacts_sovereign_cloud: + def _defender(self, provider): + with ( + patch(DEFENDER_INIT_PATCHES[0], return_value={}), + patch(DEFENDER_INIT_PATCHES[1], return_value={}), + patch(DEFENDER_INIT_PATCHES[2], return_value={}), + patch(DEFENDER_INIT_PATCHES[3], return_value={}), + patch(DEFENDER_INIT_PATCHES[4], return_value={}), + patch(DEFENDER_INIT_PATCHES[5], return_value={}), + patch(DEFENDER_INIT_PATCHES[6], return_value={}), + ): + return Defender(provider) + + def test_init_requests_token_for_cloud_scope(self): + provider = set_mocked_azure_provider(azure_region_config=US_GOV_REGION_CONFIG) + + self._defender(provider) + + provider.session.get_token.assert_called_once_with( + "https://management.usgovcloudapi.net/.default" + ) + + def test_get_security_contacts_uses_cloud_management_host(self): + provider = set_mocked_azure_provider(azure_region_config=US_GOV_REGION_CONFIG) + defender = self._defender(provider) + + response = MagicMock() + response.json.return_value = {"value": []} + with patch( + "prowler.providers.azure.services.defender.defender_service.requests.get", + return_value=response, + ) as mock_get: + result = defender._get_security_contacts(token="token") + + assert result == {AZURE_SUBSCRIPTION_ID: {}} + mock_get.assert_called_once() + url = mock_get.call_args.args[0] + assert url.startswith( + f"https://management.usgovcloudapi.net/subscriptions/{AZURE_SUBSCRIPTION_ID}/" + ) + assert "management.azure.com" not in url diff --git a/tests/providers/azure/services/keyvault/keyvault_service_test.py b/tests/providers/azure/services/keyvault/keyvault_service_test.py index e43b7a9fff..d52d292af3 100644 --- a/tests/providers/azure/services/keyvault/keyvault_service_test.py +++ b/tests/providers/azure/services/keyvault/keyvault_service_test.py @@ -470,3 +470,87 @@ class Test_KeyVault_get_key_vaults: mock_client.vaults.list_by_resource_group.assert_called_once_with( resource_group_name="MyRG" ) + + +class Test_KeyVault_get_keys: + def test_get_keys_builds_key_client_from_vault_uri(self): + mock_client = MagicMock() + mock_client.keys.list.return_value = [] + + mock_provider = MagicMock() + mock_provider.identity = MagicMock() + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.azure.services.monitor.monitor_service.Monitor", + new=MagicMock(), + ), + patch( + "prowler.providers.azure.services.keyvault.keyvault_service.KeyVault._get_key_vaults", + return_value={}, + ), + ): + from prowler.providers.azure.services.keyvault.keyvault_service import ( + KeyVault, + ) + + keyvault = KeyVault(set_mocked_azure_provider()) + + keyvault.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + provider = set_mocked_azure_provider() + vault_uri = "https://my-vault.vault.usgovcloudapi.net/" + + with patch( + "prowler.providers.azure.services.keyvault.keyvault_service.KeyClient" + ) as mock_key_client_cls: + mock_key_client_cls.return_value.list_properties_of_keys.return_value = [] + keys = keyvault._get_keys( + AZURE_SUBSCRIPTION_ID, RESOURCE_GROUP, "my-vault", vault_uri, provider + ) + + assert keys == [] + mock_key_client_cls.assert_called_once_with( + vault_url=vault_uri, credential=provider.session + ) + + def test_get_keys_without_vault_uri_skips_rotation_policies(self): + mock_client = MagicMock() + mock_client.keys.list.return_value = [] + + mock_provider = MagicMock() + mock_provider.identity = MagicMock() + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.azure.services.monitor.monitor_service.Monitor", + new=MagicMock(), + ), + patch( + "prowler.providers.azure.services.keyvault.keyvault_service.KeyVault._get_key_vaults", + return_value={}, + ), + ): + from prowler.providers.azure.services.keyvault.keyvault_service import ( + KeyVault, + ) + + keyvault = KeyVault(set_mocked_azure_provider()) + + keyvault.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + provider = set_mocked_azure_provider() + + with patch( + "prowler.providers.azure.services.keyvault.keyvault_service.KeyClient" + ) as mock_key_client_cls: + keys = keyvault._get_keys( + AZURE_SUBSCRIPTION_ID, RESOURCE_GROUP, "my-vault", "", provider + ) + + assert keys == [] + mock_key_client_cls.assert_not_called() From 974f4251dd50175578e005249da3be33d9f63a58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9sar=20Arroba?= <19954079+cesararroba@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:29:50 +0200 Subject: [PATCH 10/23] chore(trivy): suppress fast-uri CVE-2026-75931 from Teams SPDX manifest (#12823) --- .trivyignore.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.trivyignore.yaml b/.trivyignore.yaml index efac339352..f572065625 100644 --- a/.trivyignore.yaml +++ b/.trivyignore.yaml @@ -125,6 +125,10 @@ vulnerabilities: purls: - "pkg:npm/fast-uri" expired_at: 2027-01-31 + - id: CVE-2026-75931 + purls: + - "pkg:npm/fast-uri" + expired_at: 2027-01-31 - id: CVE-2026-69192 purls: - "pkg:npm/ip-address" From 2198ba2d84fb0dd3e85b8e705fbd6901cd3443c0 Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Wed, 16 Sep 2026 12:21:25 +0200 Subject: [PATCH 11/23] feat(ui): complete Registry provider onboarding for Private Cloud (#12494) Co-authored-by: alejandrobailo --- .github/test-impact.yml | 11 + .github/workflows/ui-e2e-tests-v2.yml | 85 +- .../developer-guide/environment-variables.mdx | 10 + ui/Dockerfile | 2 + ui/actions/auth/auth.test.ts | 28 +- ui/actions/auth/auth.ts | 69 +- .../dynamic-provider-credentials.test.ts | 180 ++ .../providers/dynamic-provider-credentials.ts | 115 + ui/actions/providers/index.ts | 1 + .../provider-schemas.adapter.test.ts | 83 + .../providers/provider-schemas.adapter.ts | 38 + ui/actions/providers/provider-schemas.test.ts | 138 ++ ui/actions/providers/provider-schemas.ts | 61 + .../providers/registry-provider.test.ts | 113 + ui/actions/providers/registry-provider.ts | 57 + ui/actions/registry/registry.adapter.test.ts | 696 ++++++ ui/actions/registry/registry.adapter.ts | 441 ++++ ui/actions/registry/registry.test.ts | 1138 +++++++++ ui/actions/registry/registry.ts | 578 +++++ ui/actions/roles/roles.test.ts | 31 + ui/actions/roles/roles.ts | 4 + ui/app/(prowler)/layout.tsx | 17 +- ui/app/(prowler)/registry/page.tsx | 26 + ui/auth.config.test.ts | 21 + ui/auth.config.ts | 1 + .../registry-private-cloud.added.md | 1 + .../app-sidebar/app-sidebar-content.test.tsx | 28 + .../app-sidebar/app-sidebar-content.tsx | 4 + .../layout/app-sidebar/navigation-config.ts | 15 + .../providers/radio-group-provider.test.tsx | 134 + .../providers/radio-group-provider.tsx | 207 +- .../providers/table/column-providers.tsx | 2 +- .../table/data-table-row-actions.test.tsx | 20 +- .../table/data-table-row-actions.tsx | 4 +- .../wizard/provider-wizard-modal.test.tsx | 230 ++ .../wizard/provider-wizard-modal.tsx | 11 +- .../providers/wizard/steps/connect-step.tsx | 1 + .../wizard/steps/credentials-step.test.tsx | 3 + .../wizard/steps/credentials-step.tsx | 20 +- .../steps/dynamic-credentials-step.test.tsx | 365 +++ .../wizard/steps/dynamic-credentials-step.tsx | 336 +++ .../providers/wizard/steps/footer-controls.ts | 1 + .../forms/connect-account-form.test.tsx | 84 + .../workflow/forms/connect-account-form.tsx | 131 +- .../provider-credential-fields.test.tsx | 136 ++ .../workflow/provider-credential-fields.tsx | 152 ++ .../registry/registry-access-dialog.tsx | 162 ++ ...egistry-artifact-card.integration.test.tsx | 236 ++ .../registry/registry-artifact-card.tsx | 410 ++++ .../registry/registry-artifact-grid.tsx | 51 + .../registry-artifact-task-handler.test.tsx | 187 ++ .../registry-artifact-task-handler.ts | 37 + .../registry/registry-credential-banner.tsx | 64 + .../registry-credential-task-handler.test.ts | 112 + .../registry-credential-task-handler.ts | 16 + .../registry-explorer.integration.test.tsx | 2170 +++++++++++++++++ .../registry/registry-explorer.model.test.ts | 273 +++ .../registry/registry-explorer.model.ts | 153 ++ ui/components/registry/registry-explorer.tsx | 741 ++++++ .../registry/registry-remove-dialog.tsx | 94 + ui/components/registry/registry-toolbar.tsx | 156 ++ .../registry/use-registry-refresh.ts | 97 + .../workflow/forms/add-role-form.test.tsx | 22 + .../roles/workflow/forms/add-role-form.tsx | 2 + .../workflow/forms/edit-role-form.test.tsx | 22 + .../roles/workflow/forms/edit-role-form.tsx | 4 + ui/components/shadcn/toast/Toast.tsx | 2 +- .../shadcn/toast/Toaster.integration.test.tsx | 66 + ui/components/shadcn/toast/Toaster.test.tsx | 3 +- ui/components/shared/task-polling-watcher.tsx | 12 + ui/hooks/use-auth.ts | 1 + ui/lib/auth/current-user.test.ts | 146 ++ ui/lib/auth/current-user.ts | 102 + ui/lib/csp.ts | 4 +- ui/lib/helper.ts | 5 + ui/lib/permissions.test.ts | 29 + ui/lib/permissions.ts | 5 + .../fixtures/openai-credential-schema.json | 24 + .../fixtures/template-credential-schema.json | 49 + .../provider-credential-schema.test.ts | 266 ++ .../provider-credential-schema.ts | 258 ++ .../provider-credential-values.test.ts | 136 ++ .../provider-credential-values.ts | 85 + ui/lib/provider-helpers.test.ts | 36 + ui/lib/provider-helpers.ts | 11 +- ui/lib/registry/access.server.test.ts | 177 ++ ui/lib/registry/access.server.ts | 61 + ui/lib/registry/access.test.ts | 18 + ui/lib/registry/access.ts | 19 + ui/lib/registry/artifact-execution.test.ts | 186 ++ ui/lib/registry/artifact-execution.ts | 122 + ui/lib/registry/artifact-notifications.tsx | 45 + ui/lib/registry/artifacts.ts | 8 + ui/lib/registry/credential-execution.test.ts | 311 +++ ui/lib/registry/credential-execution.ts | 63 + ui/lib/registry/credential-result.ts | 142 ++ ui/lib/registry/credential-task.test.ts | 20 + ui/lib/registry/credential-task.ts | 47 + ui/lib/registry/presentation.test.ts | 43 + ui/lib/registry/presentation.ts | 33 + ui/lib/registry/provider-options.test.ts | 120 + ui/lib/registry/provider-options.ts | 56 + ui/lib/role-permissions.ts | 1 + ui/next.config.js | 2 + ui/next.config.test.ts | 11 + ui/package.json | 1 + ui/playwright.base.ts | 24 + ui/playwright.config.ts | 29 +- ui/playwright.registry.config.ts | 93 + ui/proxy.registry.test.ts | 38 + ui/proxy.test.ts | 3 + ui/proxy.ts | 17 +- ui/store/task-watcher/store.test.ts | 174 ++ ui/store/task-watcher/store.ts | 117 +- ui/store/ui/store-initializer.test.tsx | 36 + ui/store/ui/store-initializer.tsx | 12 +- ui/store/ui/store.ts | 11 + ui/tests/providers/providers-page.ts | 9 + ui/tests/providers/providers.md | 13 +- ui/tests/providers/providers.spec.ts | 3 +- ui/tests/registry/add-provider-tour-report.md | 16 + ui/tests/registry/controlled-registry-api.mts | 852 +++++++ .../registry/controlled-registry-fixture.ts | 89 + .../registry-catalog-desktop-dark.png | Bin 0 -> 133227 bytes .../registry-catalog-desktop-light.png | Bin 0 -> 136335 bytes .../evidence/registry-catalog-mobile-dark.png | Bin 0 -> 163686 bytes .../registry-catalog-tablet-light.png | Bin 0 -> 82812 bytes .../registry-provider-credentials.png | Bin 0 -> 84649 bytes .../registry-provider-scan-completed.png | Bin 0 -> 103799 bytes .../evidence/registry-provider-selector.png | Bin 0 -> 103904 bytes ui/tests/registry/registry-page.ts | 310 +++ ui/tests/registry/registry.md | 114 + ui/tests/registry/registry.spec.ts | 492 ++++ ui/tests/registry/validation.md | 69 + ui/tests/setups/manage-registry.auth.setup.ts | 30 + ui/types/dynamic-provider-form.test.ts | 28 + ui/types/env.d.ts | 2 + ui/types/formSchemas.test.ts | 25 + ui/types/formSchemas.ts | 24 +- ui/types/provider-schema.ts | 33 + ui/types/registry.ts | 290 +++ ui/types/users.ts | 2 + ui/vitest.config.ts | 6 +- ui/vitest.integration.setup.ts | 4 + 144 files changed, 16218 insertions(+), 311 deletions(-) create mode 100644 ui/actions/providers/dynamic-provider-credentials.test.ts create mode 100644 ui/actions/providers/dynamic-provider-credentials.ts create mode 100644 ui/actions/providers/provider-schemas.adapter.test.ts create mode 100644 ui/actions/providers/provider-schemas.adapter.ts create mode 100644 ui/actions/providers/provider-schemas.test.ts create mode 100644 ui/actions/providers/provider-schemas.ts create mode 100644 ui/actions/providers/registry-provider.test.ts create mode 100644 ui/actions/providers/registry-provider.ts create mode 100644 ui/actions/registry/registry.adapter.test.ts create mode 100644 ui/actions/registry/registry.adapter.ts create mode 100644 ui/actions/registry/registry.test.ts create mode 100644 ui/actions/registry/registry.ts create mode 100644 ui/app/(prowler)/registry/page.tsx create mode 100644 ui/changelog.d/registry-private-cloud.added.md create mode 100644 ui/components/providers/radio-group-provider.test.tsx create mode 100644 ui/components/providers/wizard/provider-wizard-modal.test.tsx create mode 100644 ui/components/providers/wizard/steps/dynamic-credentials-step.test.tsx create mode 100644 ui/components/providers/wizard/steps/dynamic-credentials-step.tsx create mode 100644 ui/components/providers/workflow/forms/connect-account-form.test.tsx create mode 100644 ui/components/providers/workflow/provider-credential-fields.test.tsx create mode 100644 ui/components/providers/workflow/provider-credential-fields.tsx create mode 100644 ui/components/registry/registry-access-dialog.tsx create mode 100644 ui/components/registry/registry-artifact-card.integration.test.tsx create mode 100644 ui/components/registry/registry-artifact-card.tsx create mode 100644 ui/components/registry/registry-artifact-grid.tsx create mode 100644 ui/components/registry/registry-artifact-task-handler.test.tsx create mode 100644 ui/components/registry/registry-artifact-task-handler.ts create mode 100644 ui/components/registry/registry-credential-banner.tsx create mode 100644 ui/components/registry/registry-credential-task-handler.test.ts create mode 100644 ui/components/registry/registry-credential-task-handler.ts create mode 100644 ui/components/registry/registry-explorer.integration.test.tsx create mode 100644 ui/components/registry/registry-explorer.model.test.ts create mode 100644 ui/components/registry/registry-explorer.model.ts create mode 100644 ui/components/registry/registry-explorer.tsx create mode 100644 ui/components/registry/registry-remove-dialog.tsx create mode 100644 ui/components/registry/registry-toolbar.tsx create mode 100644 ui/components/registry/use-registry-refresh.ts create mode 100644 ui/components/shadcn/toast/Toaster.integration.test.tsx create mode 100644 ui/lib/auth/current-user.test.ts create mode 100644 ui/lib/auth/current-user.ts create mode 100644 ui/lib/provider-credentials/fixtures/openai-credential-schema.json create mode 100644 ui/lib/provider-credentials/fixtures/template-credential-schema.json create mode 100644 ui/lib/provider-credentials/provider-credential-schema.test.ts create mode 100644 ui/lib/provider-credentials/provider-credential-schema.ts create mode 100644 ui/lib/provider-credentials/provider-credential-values.test.ts create mode 100644 ui/lib/provider-credentials/provider-credential-values.ts create mode 100644 ui/lib/provider-helpers.test.ts create mode 100644 ui/lib/registry/access.server.test.ts create mode 100644 ui/lib/registry/access.server.ts create mode 100644 ui/lib/registry/access.test.ts create mode 100644 ui/lib/registry/access.ts create mode 100644 ui/lib/registry/artifact-execution.test.ts create mode 100644 ui/lib/registry/artifact-execution.ts create mode 100644 ui/lib/registry/artifact-notifications.tsx create mode 100644 ui/lib/registry/artifacts.ts create mode 100644 ui/lib/registry/credential-execution.test.ts create mode 100644 ui/lib/registry/credential-execution.ts create mode 100644 ui/lib/registry/credential-result.ts create mode 100644 ui/lib/registry/credential-task.test.ts create mode 100644 ui/lib/registry/credential-task.ts create mode 100644 ui/lib/registry/presentation.test.ts create mode 100644 ui/lib/registry/presentation.ts create mode 100644 ui/lib/registry/provider-options.test.ts create mode 100644 ui/lib/registry/provider-options.ts create mode 100644 ui/playwright.base.ts create mode 100644 ui/playwright.registry.config.ts create mode 100644 ui/proxy.registry.test.ts create mode 100644 ui/store/ui/store-initializer.test.tsx create mode 100644 ui/tests/registry/add-provider-tour-report.md create mode 100644 ui/tests/registry/controlled-registry-api.mts create mode 100644 ui/tests/registry/controlled-registry-fixture.ts create mode 100644 ui/tests/registry/evidence/registry-catalog-desktop-dark.png create mode 100644 ui/tests/registry/evidence/registry-catalog-desktop-light.png create mode 100644 ui/tests/registry/evidence/registry-catalog-mobile-dark.png create mode 100644 ui/tests/registry/evidence/registry-catalog-tablet-light.png create mode 100644 ui/tests/registry/evidence/registry-provider-credentials.png create mode 100644 ui/tests/registry/evidence/registry-provider-scan-completed.png create mode 100644 ui/tests/registry/evidence/registry-provider-selector.png create mode 100644 ui/tests/registry/registry-page.ts create mode 100644 ui/tests/registry/registry.md create mode 100644 ui/tests/registry/registry.spec.ts create mode 100644 ui/tests/registry/validation.md create mode 100644 ui/tests/setups/manage-registry.auth.setup.ts create mode 100644 ui/types/dynamic-provider-form.test.ts create mode 100644 ui/types/provider-schema.ts create mode 100644 ui/types/registry.ts diff --git a/.github/test-impact.yml b/.github/test-impact.yml index 874e9eba50..67c106d58b 100644 --- a/.github/test-impact.yml +++ b/.github/test-impact.yml @@ -451,6 +451,17 @@ modules: e2e: - ui/tests/home/** + - name: ui-registry + match: + - ui/actions/registry/** + - ui/app/**/registry/** + - ui/components/registry/** + - ui/lib/registry/** + - ui/tests/registry/** + tests: [] + e2e: + - ui/tests/registry/** + - name: ui-shadcn match: - ui/components/shadcn/** diff --git a/.github/workflows/ui-e2e-tests-v2.yml b/.github/workflows/ui-e2e-tests-v2.yml index 2a384597c3..2bf6db8a5a 100644 --- a/.github/workflows/ui-e2e-tests-v2.yml +++ b/.github/workflows/ui-e2e-tests-v2.yml @@ -10,12 +10,12 @@ on: - master - "v5.*" paths: - - '.github/workflows/ui-e2e-tests-v2.yml' - - '.github/test-impact.yml' - - 'ui/**' - - 'api/**' # API changes can affect UI E2E - - '!ui/CHANGELOG.md' - - '!api/CHANGELOG.md' + - ".github/workflows/ui-e2e-tests-v2.yml" + - ".github/test-impact.yml" + - "ui/**" + - "api/**" # API changes can affect UI E2E + - "!ui/CHANGELOG.md" + - "!api/CHANGELOG.md" concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -40,11 +40,11 @@ jobs: (needs.impact-analysis.outputs.has-ui-e2e == 'true' || needs.impact-analysis.outputs.run-all == 'true') runs-on: ubuntu-latest env: - AUTH_SECRET: 'fallback-ci-secret-for-testing' + AUTH_SECRET: "fallback-ci-secret-for-testing" AUTH_TRUST_HOST: true - NEXTAUTH_URL: 'http://localhost:3000' - AUTH_URL: 'http://localhost:3000' - UI_API_BASE_URL: 'http://localhost:8080/api/v1' + NEXTAUTH_URL: "http://localhost:3000" + AUTH_URL: "http://localhost:3000" + UI_API_BASE_URL: "http://localhost:8080/api/v1" E2E_ADMIN_USER: ${{ secrets.E2E_ADMIN_USER }} E2E_ADMIN_PASSWORD: ${{ secrets.E2E_ADMIN_PASSWORD }} E2E_AWS_PROVIDER_ACCOUNT_ID: ${{ secrets.E2E_AWS_PROVIDER_ACCOUNT_ID }} @@ -60,7 +60,7 @@ jobs: E2E_M365_SECRET_ID: ${{ secrets.E2E_M365_SECRET_ID }} E2E_M365_TENANT_ID: ${{ secrets.E2E_M365_TENANT_ID }} E2E_M365_CERTIFICATE_CONTENT: ${{ secrets.E2E_M365_CERTIFICATE_CONTENT }} - E2E_KUBERNETES_CONTEXT: 'kind-kind' + E2E_KUBERNETES_CONTEXT: "kind-kind" E2E_KUBERNETES_KUBECONFIG_PATH: /home/runner/.kube/config E2E_GCP_BASE64_SERVICE_ACCOUNT_KEY: ${{ secrets.E2E_GCP_BASE64_SERVICE_ACCOUNT_KEY }} E2E_GCP_PROJECT_ID: ${{ secrets.E2E_GCP_PROJECT_ID }} @@ -292,7 +292,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: - node-version-file: 'ui/.nvmrc' + node-version-file: "ui/.nvmrc" - name: Setup pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 @@ -337,60 +337,59 @@ jobs: if: steps.playwright-cache.outputs.cache-hit != 'true' run: pnpm run test:e2e:install - - name: Run E2E tests + - name: Run standard E2E tests + id: standard-e2e working-directory: ./ui run: | if [[ "${RUN_ALL_TESTS}" == "true" ]]; then - echo "Running ALL E2E tests..." + echo "Running all standard E2E tests..." pnpm run test:e2e else - echo "Running targeted E2E tests: ${E2E_TEST_PATHS}" - # Convert glob patterns to playwright test paths - # e.g., "ui/tests/providers/**" -> "tests/providers" + echo "Running targeted standard E2E tests: ${E2E_TEST_PATHS}" TEST_PATHS="${E2E_TEST_PATHS}" - # Remove ui/ prefix and convert ** to empty (playwright handles recursion) TEST_PATHS=$(echo "$TEST_PATHS" | sed 's|ui/||g' | sed 's|\*\*||g' | tr ' ' '\n' | sort -u) - # Drop auth setup helpers (not runnable test suites) - TEST_PATHS=$(echo "$TEST_PATHS" | grep -v '^tests/setups/') - # Safety net: if bare "tests/" appears (from broad patterns like ui/tests/**), - # expand to specific subdirs to avoid Playwright discovering setup files + TEST_PATHS=$(echo "$TEST_PATHS" | grep -vE '^tests/(setups|registry)/' || true) + if echo "$TEST_PATHS" | grep -qx 'tests/'; then - echo "Expanding bare 'tests/' to specific subdirs (excluding setups)..." SPECIFIC_DIRS="" for dir in tests/*/; do - [[ "$dir" == "tests/setups/" ]] && continue + [[ "$dir" == "tests/setups/" || "$dir" == "tests/registry/" ]] && continue SPECIFIC_DIRS="${SPECIFIC_DIRS}${dir}"$'\n' done - # Replace "tests/" with specific dirs, keep other paths - TEST_PATHS=$(echo "$TEST_PATHS" | grep -vx 'tests/') + TEST_PATHS=$(echo "$TEST_PATHS" | grep -vx 'tests/' || true) TEST_PATHS="${TEST_PATHS}"$'\n'"${SPECIFIC_DIRS}" TEST_PATHS=$(echo "$TEST_PATHS" | grep -v '^$' | sort -u) fi - if [[ -z "$TEST_PATHS" ]]; then - echo "No runnable E2E test paths after filtering setups" - exit 0 - fi - # Filter out directories that don't contain any test files + VALID_PATHS="" - while IFS= read -r p; do - [[ -z "$p" ]] && continue - if find "$p" -name '*.spec.ts' -o -name '*.test.ts' 2>/dev/null | head -1 | grep -q .; then - VALID_PATHS="${VALID_PATHS}${p}"$'\n' + while IFS= read -r path; do + [[ -z "$path" ]] && continue + if find "$path" -name '*.spec.ts' -o -name '*.test.ts' 2>/dev/null | head -1 | grep -q .; then + VALID_PATHS="${VALID_PATHS}${path}"$'\n' else - echo "Skipping empty test directory: $p" + echo "Skipping empty test directory: $path" fi done <<< "$TEST_PATHS" VALID_PATHS=$(echo "$VALID_PATHS" | grep -v '^$' || true) - if [[ -z "$VALID_PATHS" ]]; then - echo "No test files found in any resolved paths — skipping E2E" - exit 0 + + if [[ -n "$VALID_PATHS" ]]; then + TEST_PATHS=$(echo "$VALID_PATHS" | tr '\n' ' ') + echo "Resolved standard test paths: $TEST_PATHS" + read -ra test_paths <<< "$TEST_PATHS" + pnpm exec playwright test "${test_paths[@]}" + else + echo "No standard E2E test paths selected." fi - TEST_PATHS=$(echo "$VALID_PATHS" | tr '\n' ' ') - echo "Resolved test paths: $TEST_PATHS" - read -ra test_paths <<< "$TEST_PATHS" - pnpm exec playwright test "${test_paths[@]}" fi + - name: Run Registry fixture E2E tests + if: | + !cancelled() && + (steps.standard-e2e.outcome == 'success' || steps.standard-e2e.outcome == 'failure') && + (env.RUN_ALL_TESTS == 'true' || contains(format(' {0} ', env.E2E_TEST_PATHS), ' ui/tests/registry/')) + working-directory: ./ui + run: pnpm run test:e2e:registry + - name: Upload test reports uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 if: failure() diff --git a/docs/developer-guide/environment-variables.mdx b/docs/developer-guide/environment-variables.mdx index e2bec0f94b..a6ba5c9d54 100644 --- a/docs/developer-guide/environment-variables.mdx +++ b/docs/developer-guide/environment-variables.mdx @@ -40,6 +40,16 @@ The former build-time variables map to the new runtime variables as follows: `UI_CLOUD_ENABLED` is a plain runtime boolean flag that enables Prowler Cloud behavior when set to the exact string `"true"` and defaults to off; unlike the other renamed variables it has no legacy fallback, so `NEXT_PUBLIC_IS_CLOUD_ENV` is no longer read. +## Registry UI Rollout and Rollback + +`UI_REGISTRY_ENABLED` is an optional runtime flag for Prowler Cloud and Private Cloud. Registry is eligible only when both `UI_CLOUD_ENABLED` and `UI_REGISTRY_ENABLED` are the exact string `"true"` and the current user has the backend-authorized `manage_registry` permission. Unset, `"false"`, or malformed values fail closed. The flag defaults to off and is not a replacement for backend authorization. Registry access is independent of billing; Private Cloud can use it with `CLOUD_BILLING_ENABLED=false`. + +Roll out Registry only after the Registry backend dependency is deployed, intended roles have `manage_registry`, and acceptance with real credentials has exercised installation, provider account creation, credentials, connection, and scan launch. Deploy the UI with `UI_REGISTRY_ENABLED` unset or `"false"`; set it to `"true"` only in the prepared process environment, then restart or otherwise apply the environment update required by the platform. A Registry key must belong to the configured Registry environment; a production key does not authenticate against a development Registry. + +The catalog displays all artifacts, including built-ins and packages containing only checks or compliance frameworks. Only external provider artifacts support Add. After confirmed installation, open Providers and select the option labeled Registry to configure an account. Creating accounts and running scans also require the corresponding provider and scan permissions. Removing an artifact keeps existing provider accounts, but future connections or scans can fail until the artifact is installed again. + +To roll back, set `UI_REGISTRY_ENABLED=false` or remove it and apply the environment update. Proxy, page, and action checks deny on their next request. Navigation refreshes from server-authorized access when the page is requested again. Rollback does not delete Registry credentials, tenant artifact records, or provider accounts. + The build-time-only Sentry variables used for source-map upload — `SENTRY_ORG`, `SENTRY_PROJECT`, `SENTRY_AUTH_TOKEN`, and `SENTRY_RELEASE` — keep their names, as they are not part of Prowler Local Server's runtime configuration. ## Enabling Third-Party Integrations diff --git a/ui/Dockerfile b/ui/Dockerfile index a7ff72e65f..278c8b8602 100644 --- a/ui/Dockerfile +++ b/ui/Dockerfile @@ -100,6 +100,8 @@ ENV HOSTNAME="0.0.0.0" # - required: UI_API_BASE_URL, AUTH_URL, AUTH_SECRET (missing ⇒ fail fast at boot) # - optional: UI_API_DOCS_URL # - optional: UI_CLOUD_ENABLED ("true" only in Prowler Cloud deployments) +# - optional: UI_REGISTRY_ENABLED ("true" only after the Registry dependency, +# Cloud role grant, and controlled acceptance are ready; unset/false hides Registry) # - gated integrations (load only when *_ENABLED="true"; the value is then # required or boot fails). Their legacy names (NEXT_PUBLIC_SENTRY_*, # NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID, POSTHOG_KEY/HOST) still work: diff --git a/ui/actions/auth/auth.test.ts b/ui/actions/auth/auth.test.ts index 1f20392bb8..adf04ebcf1 100644 --- a/ui/actions/auth/auth.test.ts +++ b/ui/actions/auth/auth.test.ts @@ -23,7 +23,7 @@ vi.mock("@/lib/sentry-breadcrumbs", () => ({ import { createNewUser, getUserByMe } from "./auth"; -const userMeResponse = (roleAttributes: Record) => ({ +const userMeResponse = (roleAttributes: Record) => ({ data: { type: "users", id: "019b1234-5678-7abc-9def-0123456789ab", @@ -43,7 +43,7 @@ const userMeResponse = (roleAttributes: Record) => ({ ], }); -const mockUserMe = (roleAttributes: Record) => { +const mockUserMe = (roleAttributes: Record) => { fetchMock.mockResolvedValue( new Response(JSON.stringify(userMeResponse(roleAttributes)), { status: 200, @@ -178,6 +178,30 @@ describe("auth actions", () => { expect(result.permissions.manage_users).toBe(true); }); + it("should carry an exact manage_registry permission into the session", async () => { + // Given + mockUserMe({ manage_registry: true }); + + // When + const result = await getUserByMe("access-token"); + + // Then + expect(result.permissions.manage_registry).toBe(true); + }); + + it.each([undefined, "true", "TRUE", 1])( + "should deny a malformed manage_registry value of %j", + async (manageRegistry) => { + // Given + mockUserMe({ manage_registry: manageRegistry }); + + // When + const result = await getUserByMe("access-token"); + + // Then + expect(result.permissions.manage_registry).toBe(false); + }, + ); it("should forward an abort signal when loading the current user", async () => { // Given mockUserMe({ manage_users: true }); diff --git a/ui/actions/auth/auth.ts b/ui/actions/auth/auth.ts index 6d275bbf92..0cd5df4d94 100644 --- a/ui/actions/auth/auth.ts +++ b/ui/actions/auth/auth.ts @@ -4,7 +4,7 @@ import { AuthError } from "next-auth"; import { signIn, signOut } from "@/auth.config"; import { apiBaseUrl } from "@/lib"; -import { UserMeError } from "@/lib/auth-errors"; +import { fetchCurrentUser } from "@/lib/auth/current-user"; import { addAuthEvent } from "@/lib/sentry-breadcrumbs"; import type { UtmParams } from "@/lib/utm"; import type { SignInFormData, SignUpFormData } from "@/types"; @@ -145,66 +145,15 @@ export const getUserByMe = async ( accessToken: string, signal?: AbortSignal, ) => { - const url = new URL(`${apiBaseUrl}/users/me?include=roles`); + const currentUser = await fetchCurrentUser(accessToken, { signal }); - try { - const response = await fetch(url.toString(), { - method: "GET", - headers: { - Accept: "application/vnd.api+json", - Authorization: `Bearer ${accessToken}`, - }, - signal, - }); - - if (!response.ok) { - const errorMessage = - response.status === 401 - ? "Invalid or expired token" - : response.status === 403 - ? "Access denied" - : response.status === 404 - ? "User not found" - : "Unable to load user"; - throw new UserMeError(errorMessage, response.status); - } - - const parsedResponse = await response.json(); - - const userRole = parsedResponse.included?.find( - (item: any) => item.type === "roles", - ); - - const permissions = { - manage_users: userRole.attributes.manage_users || false, - manage_account: userRole.attributes.manage_account || false, - manage_providers: userRole.attributes.manage_providers || false, - manage_scans: userRole.attributes.manage_scans || false, - manage_ingestions: userRole.attributes.manage_ingestions || false, - manage_integrations: userRole.attributes.manage_integrations || false, - manage_billing: userRole.attributes.manage_billing || false, - manage_alerts: userRole.attributes.manage_alerts || false, - manage_lighthouse_ai_configuration: - userRole.attributes.manage_lighthouse_ai_configuration || false, - unlimited_visibility: userRole.attributes.unlimited_visibility || false, - }; - - return { - name: parsedResponse.data.attributes.name, - email: parsedResponse.data.attributes.email, - company: parsedResponse.data.attributes.company_name, - dateJoined: parsedResponse.data.attributes.date_joined, - permissions, - }; - } catch (error: unknown) { - if (error instanceof UserMeError) throw error; - - throw new UserMeError( - error instanceof Error - ? error.message - : "Network error or server unreachable", - ); - } + return { + name: currentUser.name, + email: currentUser.email, + company: currentUser.company, + dateJoined: currentUser.dateJoined, + permissions: currentUser.permissions, + }; }; export async function logOut() { diff --git a/ui/actions/providers/dynamic-provider-credentials.test.ts b/ui/actions/providers/dynamic-provider-credentials.test.ts new file mode 100644 index 0000000000..f8ab619bc8 --- /dev/null +++ b/ui/actions/providers/dynamic-provider-credentials.test.ts @@ -0,0 +1,180 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import openaiSchema from "@/lib/provider-credentials/fixtures/openai-credential-schema.json"; +import templateSchema from "@/lib/provider-credentials/fixtures/template-credential-schema.json"; +const { fetchMock, getProviderSchemas, getAuthHeaders, revalidatePath } = + vi.hoisted(() => ({ + fetchMock: vi.fn(), + getProviderSchemas: vi.fn(), + getAuthHeaders: vi.fn(), + revalidatePath: vi.fn(), + })); +vi.mock("@/lib", () => ({ + apiBaseUrl: "https://api.test/api/v1", + getAuthHeaders, +})); +vi.mock("next/cache", () => ({ revalidatePath })); +vi.mock("./provider-schemas", () => ({ getProviderSchemas })); + +import { saveDynamicProviderCredentials } from "./dynamic-provider-credentials"; + +const input = { + providerId: "account", + secretType: "api_key", + secret: { token: "private-value" }, +}; +const response = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status }); +const account = (secretId: string | null = null) => ({ + data: { + id: "account", + attributes: { provider: "acme" }, + relationships: { secret: { data: secretId ? { id: secretId } : null } }, + }, +}); + +describe("dynamic provider credential actions", () => { + beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + fetchMock.mockReset(); + getAuthHeaders.mockResolvedValue({ Authorization: "Bearer test" }); + getProviderSchemas.mockResolvedValue({ + status: "success", + providerType: "acme", + secretTypes: { + api_key: { + type: "object", + description: openaiSchema.description, + properties: { + token: { type: "string", format: "password", writeOnly: true }, + }, + required: ["token"], + }, + }, + }); + }); + it("validates the account's current schema and sends JSON credentials without the builtin mapping", async () => { + fetchMock + .mockResolvedValueOnce(response(account())) + .mockResolvedValueOnce(response({ data: { id: "saved" } }, 201)); + expect(await saveDynamicProviderCredentials(input)).toEqual({ + status: "saved", + secretId: "saved", + }); + expect(getProviderSchemas).toHaveBeenCalledWith("acme"); + const [url, request] = fetchMock.mock.calls[1]; + expect(url).toBe("https://api.test/api/v1/providers/secrets"); + expect(JSON.parse(request.body).data).toEqual({ + type: "provider-secrets", + attributes: { + secret_type: "api_key", + secret: { token: "private-value" }, + }, + relationships: { + provider: { data: { id: "account", type: "providers" } }, + }, + }); + }); + it("updates the authoritative existing secret, including after a retry", async () => { + fetchMock + .mockResolvedValueOnce(response(account("existing"))) + .mockResolvedValueOnce(response({ data: { id: "existing" } })); + expect((await saveDynamicProviderCredentials(input)).status).toBe("saved"); + expect( + fetchMock.mock.calls[1][0].endsWith("/providers/secrets/existing"), + ).toBe(true); + expect(fetchMock.mock.calls[1][1].method).toBe("PATCH"); + }); + it("validates and sends Template credentials with their JSON types", async () => { + // Given + const templateAccount = account(); + templateAccount.data.attributes.provider = "template"; + getProviderSchemas.mockResolvedValue({ + status: "success", + providerType: "template", + secretTypes: { static: templateSchema }, + }); + fetchMock + .mockResolvedValueOnce(response(templateAccount)) + .mockResolvedValueOnce(response({ data: { id: "saved" } }, 201)); + const secret = { + api_url: "https://api.example.test", + api_key: "fixture-key-not-a-secret", + verify_tls: false, + timeout_seconds: 60, + }; + + // When / Then + expect( + await saveDynamicProviderCredentials({ + ...input, + secretType: "static", + secret, + }), + ).toEqual({ + status: "saved", + secretId: "saved", + }); + expect(JSON.parse(fetchMock.mock.calls[1][1].body).data.attributes).toEqual( + { + secret_type: "static", + secret, + }, + ); + + // Server-side validation also rejects requests that bypass the form. + fetchMock.mockReset().mockResolvedValueOnce(response(templateAccount)); + expect( + await saveDynamicProviderCredentials({ + ...input, + secretType: "static", + secret: { ...secret, timeout_seconds: 301 }, + }), + ).toMatchObject({ + status: "invalid", + errors: { timeout_seconds: expect.any(String) }, + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + it.each([ + { ...input, secretType: "invented" }, + { ...input, secret: { token: "" } }, + { ...input, secret: { token: "x", unknown: "hidden" } }, + ])("does not write invalid credentials", async (values) => { + fetchMock.mockResolvedValueOnce(response(account())); + expect((await saveDynamicProviderCredentials(values)).status).not.toBe( + "saved", + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + it("fails closed for an absent schema, revoked permission, and malformed accounts", async () => { + getProviderSchemas.mockResolvedValue({ + status: "success", + providerType: "acme", + secretTypes: {}, + }); + fetchMock.mockResolvedValueOnce(response(account())); + expect((await saveDynamicProviderCredentials(input)).status).toBe( + "schema_unavailable", + ); + fetchMock.mockResolvedValueOnce(response({}, 403)); + expect((await saveDynamicProviderCredentials(input)).status).toBe( + "access_denied", + ); + fetchMock.mockResolvedValueOnce(response({})); + expect((await saveDynamicProviderCredentials(input)).status).toBe("error"); + expect(fetchMock.mock.calls.every(([, options]) => !options.method)).toBe( + true, + ); + }); + it("does not echo a rejected secret in errors", async () => { + fetchMock + .mockResolvedValueOnce(response(account())) + .mockResolvedValueOnce( + response({ errors: [{ detail: "private-value invalid" }] }, 400), + ); + expect( + JSON.stringify(await saveDynamicProviderCredentials(input)), + ).not.toContain("private-value"); + }); +}); diff --git a/ui/actions/providers/dynamic-provider-credentials.ts b/ui/actions/providers/dynamic-provider-credentials.ts new file mode 100644 index 0000000000..79975fa2e7 --- /dev/null +++ b/ui/actions/providers/dynamic-provider-credentials.ts @@ -0,0 +1,115 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; + +import { apiBaseUrl, getAuthHeaders } from "@/lib"; +import { parseRegistryCredentialSchema } from "@/lib/provider-credentials/provider-credential-schema"; +import { validateCredentialValues } from "@/lib/provider-credentials/provider-credential-values"; +import { isKnownProviderType } from "@/types/providers"; + +import { getProviderSchemas } from "./provider-schemas"; + +const resourceId = z.string().regex(/^[a-zA-Z0-9_-]{1,100}$/); +const inputSchema = z.object({ + providerId: resourceId, + secretType: z.string().min(1), + secret: z.unknown(), +}); +const accountSchema = z.object({ + data: z.object({ + id: resourceId, + attributes: z.object({ provider: z.string() }), + relationships: z.object({ + secret: z.object({ data: z.object({ id: resourceId }).nullable() }), + }), + }), +}); + +export type DynamicCredentialsResult = + | { status: "saved"; secretId: string } + | { status: "invalid"; errors: Record } + | { status: "access_denied" | "schema_unavailable" | "error" }; + +export async function saveDynamicProviderCredentials( + input: unknown, +): Promise { + const parsed = inputSchema.safeParse(input); + if (!parsed.success) + return { + status: "invalid", + errors: { _form: "Check the provider and credential fields." }, + }; + const { providerId, secretType, secret } = parsed.data; + try { + const headers = await getAuthHeaders({ contentType: true }); + const accountResponse = await fetch( + `${apiBaseUrl}/providers/${encodeURIComponent(providerId)}`, + { headers, cache: "no-store" }, + ); + if (accountResponse.status === 401 || accountResponse.status === 403) + return { status: "access_denied" }; + if (!accountResponse.ok) return { status: "error" }; + const account = accountSchema.safeParse(await accountResponse.json()); + if ( + !account.success || + account.data.data.id !== providerId || + isKnownProviderType(account.data.data.attributes.provider) + ) + return { status: "error" }; + const schemas = await getProviderSchemas( + account.data.data.attributes.provider, + ); + if (schemas.status === "access_denied") return { status: "access_denied" }; + if ( + schemas.status !== "success" || + !Object.hasOwn(schemas.secretTypes, secretType) + ) + return { status: "schema_unavailable" }; + const schema = parseRegistryCredentialSchema( + schemas.secretTypes[secretType], + ); + if (!schema) return { status: "schema_unavailable" }; + const validated = validateCredentialValues(schema, secret); + if (!validated.valid) + return { status: "invalid", errors: validated.errors }; + + // Read the relationship again on every save so retries update a secret that + // was already created, including after a lost response. + const secretId = account.data.data.relationships.secret.data?.id; + const response = await fetch( + `${apiBaseUrl}/providers/secrets${secretId ? `/${encodeURIComponent(secretId)}` : ""}`, + { + method: secretId ? "PATCH" : "POST", + headers, + cache: "no-store", + body: JSON.stringify({ + data: { + type: "provider-secrets", + ...(secretId + ? { id: secretId } + : { + relationships: { + provider: { data: { id: providerId, type: "providers" } }, + }, + }), + attributes: { secret_type: secretType, secret: validated.secret }, + }, + }), + }, + ); + if (response.status === 401 || response.status === 403) + return { status: "access_denied" }; + // API validation details may echo credential values. Keep them out of both + // client errors and application logs. + if (!response.ok) return { status: "error" }; + const saved = z + .object({ data: z.object({ id: resourceId }) }) + .safeParse(await response.json()); + if (!saved.success) return { status: "error" }; + revalidatePath("/providers"); + return { status: "saved", secretId: saved.data.data.id }; + } catch { + return { status: "error" }; + } +} diff --git a/ui/actions/providers/index.ts b/ui/actions/providers/index.ts index 5532383f5f..d3580b7346 100644 --- a/ui/actions/providers/index.ts +++ b/ui/actions/providers/index.ts @@ -1 +1,2 @@ +export * from "./provider-schemas"; export * from "./providers"; diff --git a/ui/actions/providers/provider-schemas.adapter.test.ts b/ui/actions/providers/provider-schemas.adapter.test.ts new file mode 100644 index 0000000000..32c5e95fce --- /dev/null +++ b/ui/actions/providers/provider-schemas.adapter.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; + +import { + adaptProviderSchemas, + normalizeProviderType, +} from "./provider-schemas.adapter"; + +describe("provider schemas adapter", () => { + it("adapts a matching provider schema resource without interpreting schema keywords", () => { + // Given + const payload = { + data: { + type: "provider-schemas", + id: "acme", + attributes: { + secret_types: { + credentials: { + type: "object", + properties: { access_key: { type: "string" } }, + }, + }, + }, + }, + }; + + // When + const result = adaptProviderSchemas(payload, "acme"); + + // Then + expect(result).toEqual({ + status: "success", + providerType: "acme", + secretTypes: payload.data.attributes.secret_types, + }); + }); + + it.each([ + ["null", null], + ["string scalar", "secret"], + ["number scalar", 1], + ["boolean scalar", true], + ])("rejects %s secret_types values", (_description, secretType) => { + // Given + const payload = { + data: { + type: "provider-schemas", + id: "acme", + attributes: { secret_types: { credentials: secretType } }, + }, + }; + + // When + const result = adaptProviderSchemas(payload, "acme"); + + // Then + expect(result).toBeNull(); + }); + + it("rejects malformed or contradictory documents without reading schema keywords", () => { + // Given + const document = { + data: { + type: "provider-schemas", + id: "aws", + attributes: { secret_types: {} }, + }, + }; + + // When + const results = [ + { ...document, errors: [] }, + { data: { ...document.data, id: "aws " } }, + { data: { ...document.data, type: "providers" } }, + { data: { ...document.data, attributes: { secret_types: { key: [] } } } }, + ].map((payload) => adaptProviderSchemas(payload, "aws")); + + // Then + expect(results).toEqual([null, null, null, null]); + expect(normalizeProviderType(" AWS ")).toBe("aws"); + expect(normalizeProviderType(" ")).toBeNull(); + expect(normalizeProviderType("a".repeat(51))).toBeNull(); + }); +}); diff --git a/ui/actions/providers/provider-schemas.adapter.ts b/ui/actions/providers/provider-schemas.adapter.ts new file mode 100644 index 0000000000..9187afcf8f --- /dev/null +++ b/ui/actions/providers/provider-schemas.adapter.ts @@ -0,0 +1,38 @@ +import { z } from "zod"; + +import { + PROVIDER_SCHEMA_STATUS, + type ProviderSchemasSuccessResult, +} from "@/types/provider-schema"; + +const providerTypeSchema = z.string().trim().toLowerCase().min(1).max(50); +const providerSchemasDocumentSchema = z.strictObject({ + data: z.strictObject({ + type: z.literal("provider-schemas"), + id: z.string().min(1).max(50), + attributes: z.strictObject({ + secret_types: z.record(z.string(), z.record(z.string(), z.unknown())), + }), + }), +}); + +export function normalizeProviderType(value: unknown): string | null { + const parsed = providerTypeSchema.safeParse(value); + return parsed.success ? parsed.data : null; +} + +export function adaptProviderSchemas( + payload: unknown, + normalizedProviderType: string, +): ProviderSchemasSuccessResult | null { + const parsed = providerSchemasDocumentSchema.safeParse(payload); + if (!parsed.success || parsed.data.data.id !== normalizedProviderType) { + return null; + } + + return { + status: PROVIDER_SCHEMA_STATUS.SUCCESS, + providerType: parsed.data.data.id, + secretTypes: parsed.data.data.attributes.secret_types, + }; +} diff --git a/ui/actions/providers/provider-schemas.test.ts b/ui/actions/providers/provider-schemas.test.ts new file mode 100644 index 0000000000..0f6ead52f6 --- /dev/null +++ b/ui/actions/providers/provider-schemas.test.ts @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { authMock, fetchMock } = vi.hoisted(() => ({ + authMock: vi.fn(), + fetchMock: vi.fn(), +})); + +vi.mock("@/auth.config", () => ({ auth: authMock })); +vi.mock("@/lib", () => ({ apiBaseUrl: "https://api.test/api/v1" })); + +import { getProviderSchemas } from "./provider-schemas"; + +const schemaResponse = (providerType = "acme") => + new Response( + JSON.stringify({ + data: { + type: "provider-schemas", + id: providerType, + attributes: { secret_types: {} }, + }, + }), + { status: 200 }, + ); + +describe("getProviderSchemas", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + authMock.mockResolvedValue({ accessToken: "access-token" }); + fetchMock.mockResolvedValue(schemaResponse()); + }); + + it("requests the normalized provider schema with authenticated JSON:API headers", async () => { + // When + const result = await getProviderSchemas(" ACME "); + + // Then + expect(result).toEqual({ + status: "success", + providerType: "acme", + secretTypes: {}, + }); + expect(fetchMock).toHaveBeenCalledWith( + "https://api.test/api/v1/provider-schemas/acme", + { + cache: "no-store", + headers: { + Accept: "application/vnd.api+json", + Authorization: "Bearer access-token", + }, + }, + ); + }); + + it("does not fetch invalid input and encodes a normalized path segment", async () => { + // Given + fetchMock.mockResolvedValueOnce(schemaResponse("acme/team")); + + // When + const invalid = await Promise.all([ + getProviderSchemas(" "), + getProviderSchemas("a".repeat(51)), + ]); + const encoded = await getProviderSchemas(" ACME/TEAM "); + + // Then + expect(invalid).toEqual([{ status: "error" }, { status: "error" }]); + expect(encoded).toMatchObject({ + status: "success", + providerType: "acme/team", + }); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock).toHaveBeenCalledWith( + "https://api.test/api/v1/provider-schemas/acme%2Fteam", + expect.any(Object), + ); + }); + + it("denies an unauthenticated request without fetching", async () => { + // Given + authMock.mockResolvedValue({}); + + // When + const result = await getProviderSchemas("acme"); + + // Then + expect(result).toEqual({ status: "access_denied" }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it.each([ + [401, { status: "access_denied" }], + [403, { status: "access_denied" }], + [404, { status: "not_found" }], + [409, { status: "unavailable" }], + [500, { status: "error" }], + ])("maps HTTP %i to a safe result", async (status, expected) => { + // Given + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ errors: [{ detail: "private detail" }] }), { + status, + }), + ); + + // When + const result = await getProviderSchemas("acme"); + + // Then + expect(result).toEqual(expected); + expect(JSON.stringify(result)).not.toContain("private detail"); + }); + + it("returns a generic safe error when fetch rejects", async () => { + // Given + const rejection = new Error("connection detail must not leak"); + fetchMock.mockRejectedValueOnce(rejection); + + // When + const result = await getProviderSchemas("acme"); + + // Then + expect(result).toEqual({ status: "error" }); + expect(JSON.stringify(result)).not.toContain(rejection.message); + }); + + it("distinguishes a malformed success document from a transport failure", async () => { + // Given + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ errors: [] })), + ); + + // When + const result = await getProviderSchemas("acme"); + + // Then + expect(result).toEqual({ status: "malformed" }); + }); +}); diff --git a/ui/actions/providers/provider-schemas.ts b/ui/actions/providers/provider-schemas.ts new file mode 100644 index 0000000000..afdf0868d2 --- /dev/null +++ b/ui/actions/providers/provider-schemas.ts @@ -0,0 +1,61 @@ +"use server"; + +import { auth } from "@/auth.config"; +import { apiBaseUrl } from "@/lib"; +import { + PROVIDER_SCHEMA_STATUS, + type ProviderSchemasResult, +} from "@/types/provider-schema"; + +import { + adaptProviderSchemas, + normalizeProviderType, +} from "./provider-schemas.adapter"; + +export async function getProviderSchemas( + providerType: unknown, +): Promise { + const normalizedProviderType = normalizeProviderType(providerType); + if (!normalizedProviderType) return { status: PROVIDER_SCHEMA_STATUS.ERROR }; + + let accessToken: string | undefined; + try { + accessToken = (await auth())?.accessToken?.trim(); + } catch { + return { status: PROVIDER_SCHEMA_STATUS.ERROR }; + } + if (!accessToken) return { status: PROVIDER_SCHEMA_STATUS.ACCESS_DENIED }; + + let response: Response; + try { + response = await fetch( + `${apiBaseUrl}/provider-schemas/${encodeURIComponent(normalizedProviderType)}`, + { + cache: "no-store", + headers: { + Accept: "application/vnd.api+json", + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + } catch { + return { status: PROVIDER_SCHEMA_STATUS.ERROR }; + } + + if (response.status === 401 || response.status === 403) { + return { status: PROVIDER_SCHEMA_STATUS.ACCESS_DENIED }; + } + if (response.status === 404) { + return { status: PROVIDER_SCHEMA_STATUS.NOT_FOUND }; + } + if (response.status === 409) { + return { status: PROVIDER_SCHEMA_STATUS.UNAVAILABLE }; + } + if (!response.ok) return { status: PROVIDER_SCHEMA_STATUS.ERROR }; + + const schema = adaptProviderSchemas( + await response.json().catch(() => undefined), + normalizedProviderType, + ); + return schema ?? { status: PROVIDER_SCHEMA_STATUS.MALFORMED }; +} diff --git a/ui/actions/providers/registry-provider.test.ts b/ui/actions/providers/registry-provider.test.ts new file mode 100644 index 0000000000..188cf43357 --- /dev/null +++ b/ui/actions/providers/registry-provider.test.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + getInstalledRegistryProviderOptions, + addProvider, + getProviders, + updateProvider, +} = vi.hoisted(() => ({ + getInstalledRegistryProviderOptions: vi.fn(), + addProvider: vi.fn(), + getProviders: vi.fn(), + updateProvider: vi.fn(), +})); +vi.mock("@/actions/registry/registry", () => ({ + getInstalledRegistryProviderOptions, +})); +vi.mock("./providers", () => ({ addProvider, getProviders, updateProvider })); + +import { addRegistryProvider } from "./registry-provider"; + +const formData = (alias = "Test") => { + const form = new FormData(); + form.set("providerType", "acme"); + form.set("providerUid", "account"); + form.set("providerAlias", alias); + return form; +}; +describe("Registry provider account creation", () => { + beforeEach(() => { + vi.clearAllMocks(); + getInstalledRegistryProviderOptions.mockResolvedValue({ + status: "ready", + options: [{ type: "acme", label: "Acme" }], + }); + getProviders.mockResolvedValue({ data: [] }); + }); + it("refuses removed artifacts and revoked permission before creating an account", async () => { + getInstalledRegistryProviderOptions + .mockResolvedValueOnce({ status: "access_denied" }) + .mockResolvedValueOnce({ status: "ready", options: [] }); + expect((await addRegistryProvider(formData()))?.errors).toBeDefined(); + expect((await addRegistryProvider(formData()))?.errors).toBeDefined(); + expect(addProvider).not.toHaveBeenCalled(); + }); + it("reuses a previously created account after a failed credential attempt or lost response", async () => { + const existing = { + id: "existing", + attributes: { provider: "acme", uid: "account", alias: "Test" }, + }; + getProviders.mockResolvedValue({ data: [existing] }); + expect(await addRegistryProvider(formData())).toEqual({ data: existing }); + expect(addProvider).not.toHaveBeenCalled(); + expect(updateProvider).not.toHaveBeenCalled(); + }); + it.each(["Test", "", " Edited "])( + "saves alias %j before resuming credentials for an existing account", + async (alias) => { + // Given + const existing = { + id: "existing", + attributes: { provider: "acme", uid: "account", alias: "Original" }, + }; + const updated = { + ...existing, + attributes: { ...existing.attributes, alias: alias.trim() }, + }; + getProviders.mockResolvedValue({ data: [existing] }); + updateProvider.mockResolvedValue({ data: updated }); + + // When + const result = await addRegistryProvider(formData(alias)); + + // Then + expect(result).toEqual({ data: updated }); + expect(Object.fromEntries(updateProvider.mock.calls[0][0])).toEqual({ + providerId: "existing", + providerAlias: alias.trim(), + }); + expect(addProvider).not.toHaveBeenCalled(); + }, + ); + it("keeps alias update failures visible instead of resuming with stale details", async () => { + // Given + const failure = { + errors: [ + { + detail: "Alias is invalid", + source: { pointer: "/data/attributes/alias" }, + }, + ], + }; + getProviders.mockResolvedValue({ + data: [ + { + id: "existing", + attributes: { provider: "acme", uid: "account", alias: "Original" }, + }, + ], + }); + updateProvider.mockResolvedValue(failure); + + // When / Then + await expect(addRegistryProvider(formData())).resolves.toEqual(failure); + expect(addProvider).not.toHaveBeenCalled(); + }); + it("creates a validated installed provider account", async () => { + addProvider.mockResolvedValue({ data: { id: "new" } }); + expect(await addRegistryProvider(formData())).toEqual({ + data: { id: "new" }, + }); + expect(addProvider).toHaveBeenCalledOnce(); + }); +}); diff --git a/ui/actions/providers/registry-provider.ts b/ui/actions/providers/registry-provider.ts new file mode 100644 index 0000000000..68fa43ce40 --- /dev/null +++ b/ui/actions/providers/registry-provider.ts @@ -0,0 +1,57 @@ +"use server"; + +import { getInstalledRegistryProviderOptions } from "@/actions/registry/registry"; +import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields"; +import { createAddProviderFormSchema } from "@/types/formSchemas"; +import { isKnownProviderType } from "@/types/providers"; + +import { addProvider, getProviders, updateProvider } from "./providers"; + +export async function addRegistryProvider(formData: FormData) { + const unavailable = { + errors: [ + { + detail: + "This Registry provider is no longer available. Check your permissions and installed artifacts, then try again.", + source: { pointer: "/data/attributes/provider" }, + }, + ], + }; + try { + const discovery = await getInstalledRegistryProviderOptions(); + if (discovery.status !== "ready") return unavailable; + const values = createAddProviderFormSchema( + discovery.options.map((option) => option.type), + ).safeParse(Object.fromEntries(formData)); + if (!values.success || isKnownProviderType(values.data.providerType)) + return unavailable; + const { providerType, providerUid } = values.data; + const existing = await getProviders({ + filters: { "filter[provider]": providerType, "filter[uid]": providerUid }, + pageSize: 100, + }); + // A previous request may have created the account before its response was + // lost. Reuse that identity when returning to the credential step. + if (!existing?.data) return unavailable; + const account = existing.data.find( + (provider) => + provider.attributes.provider === providerType && + provider.attributes.uid === providerUid, + ); + if (account) { + const alias = values.data.providerAlias.trim(); + if ((account.attributes.alias ?? "") === alias) return { data: account }; + const update = new FormData(); + update.set(ProviderCredentialFields.PROVIDER_ID, account.id); + update.set(ProviderCredentialFields.PROVIDER_ALIAS, alias); + return await updateProvider(update); + } + const validated = new FormData(); + Object.entries(values.data).forEach(([key, value]) => { + if (value !== undefined) validated.set(key, value); + }); + return await addProvider(validated); + } catch { + return unavailable; + } +} diff --git a/ui/actions/registry/registry.adapter.test.ts b/ui/actions/registry/registry.adapter.test.ts new file mode 100644 index 0000000000..dc35be5060 --- /dev/null +++ b/ui/actions/registry/registry.adapter.test.ts @@ -0,0 +1,696 @@ +import { describe, expect, it } from "vitest"; + +import { + REGISTRY_ENDPOINT, + REGISTRY_FAILURE, + REGISTRY_SUBMISSION, +} from "@/types/registry"; + +import { + adaptRegistryCredentialStatus, + adaptRegistryTenantArtifacts, + classifyRegistryFailure, + collectCompleteRegistryCatalog, + parseRegistryArtifactSubmission, +} from "./registry.adapter"; + +const credentialPayload = { + data: { + attributes: { + configured: true, + is_valid: true, + scopes: ["catalog:read"], + last_validated_at: "2026-03-20T12:00:00Z", + validation_status: "valid", + validation_pending: false, + key: "registry-secret-value", + masked_key: "reg_***", + pending_key: "queued-secret", + arbitrary_backend_detail: "do not expose", + }, + }, +}; + +const activeCredential = adaptRegistryCredentialStatus(credentialPayload); +const jsonError = (status: number, code: string) => + new Response( + JSON.stringify({ errors: [{ code, detail: "private detail" }] }), + { + status, + }, + ); + +describe("Registry adapter", () => { + it("reads the resolved installed version separately from the requested spec", () => { + // Given / When + const artifacts = adaptRegistryTenantArtifacts({ + data: [ + { + type: "registry-artifacts", + id: "template", + attributes: { + version_spec: "latest", + resolved_version: " 1.0.0 ", + }, + }, + ], + }); + // Then + expect(artifacts).toEqual([ + expect.objectContaining({ + normalizedName: "template", + versionSpec: "latest", + resolvedVersion: "1.0.0", + }), + ]); + }); + + it.each([undefined, null, "", " "])( + "accepts an unknown resolved version %j", + (resolvedVersion) => { + // Given / When + const artifacts = adaptRegistryTenantArtifacts({ + data: [ + { + type: "registry-artifacts", + id: "template", + attributes: { + version_spec: "latest", + resolved_version: resolvedVersion, + }, + }, + ], + }); + // Then + expect(artifacts).toMatchObject([{ resolvedVersion: undefined }]); + }, + ); + + it("maps only documented non-secret credential status fields", () => { + // Given + const malformedPayload = { data: { attributes: { configured: true } } }; + + // When + const status = adaptRegistryCredentialStatus(credentialPayload); + + // Then + expect(status).toEqual({ + configured: true, + isValid: true, + scopes: ["catalog:read"], + lastValidatedAt: "2026-03-20T12:00:00Z", + validationStatus: "valid", + validationPending: false, + }); + expect(adaptRegistryCredentialStatus(malformedPayload)).toBeNull(); + }); + + it("normalizes an absent credential status with nullable validation fields", () => { + // Given + const absentCredentialPayload = { + data: { + attributes: { + configured: false, + is_valid: false, + scopes: [], + last_validated_at: null, + validation_status: null, + validation_pending: false, + }, + }, + }; + + // When + const status = adaptRegistryCredentialStatus(absentCredentialPayload); + + // Then + expect(status).toEqual({ + configured: false, + isValid: false, + scopes: [], + lastValidatedAt: undefined, + validationStatus: undefined, + validationPending: false, + }); + }); + + it("accepts only a matching artifact 202 task and fixed Content-Location path", async () => { + // Given + const response = new Response( + JSON.stringify({ data: { type: "tasks", id: "task-123" } }), + { + status: 202, + headers: { "Content-Location": "/api/v1/tasks/task-123" }, + }, + ); + + // When + const result = await parseRegistryArtifactSubmission(response); + + // Then + expect(result).toEqual({ + status: REGISTRY_SUBMISSION.PENDING, + taskId: "task-123", + }); + }); + + it("rejects a non-202 response or a mismatched task location", async () => { + // Given + const task = JSON.stringify({ data: { type: "tasks", id: "task-123" } }); + const wrongStatus = new Response(task, { status: 201 }); + const wrongLocation = new Response(task, { + status: 202, + headers: { "Content-Location": "/api/v1/tasks/other" }, + }); + + // When + const results = await Promise.all([ + parseRegistryArtifactSubmission(wrongStatus), + parseRegistryArtifactSubmission(wrongLocation), + ]); + + // Then + expect(results).toEqual([ + { status: REGISTRY_SUBMISSION.ERROR }, + { status: REGISTRY_SUBMISSION.ERROR }, + ]); + }); + + it("classifies every Registry 401 or 403 as access denied first", async () => { + // Given + const responses = [ + [401, REGISTRY_ENDPOINT.CREDENTIAL], + [403, REGISTRY_ENDPOINT.MUTATION], + [403, REGISTRY_ENDPOINT.PROVIDERS], + ] as const; + + // When + const results = await Promise.all( + responses.map(([status, endpoint]) => + classifyRegistryFailure( + jsonError(status, "registry_key_rejected"), + endpoint, + activeCredential, + ), + ), + ); + + // Then + expect(results).toEqual([ + { status: REGISTRY_FAILURE.ACCESS_DENIED }, + { status: REGISTRY_FAILURE.ACCESS_DENIED }, + { status: REGISTRY_FAILURE.ACCESS_DENIED }, + ]); + }); + + it("maps only a 409 with an authoritative no-active credential to onboarding", async () => { + // Given + const noCredential = adaptRegistryCredentialStatus({ + data: { + attributes: { + configured: false, + is_valid: false, + scopes: [], + validation_pending: false, + }, + }, + }); + + // When + const results = await Promise.all( + [noCredential, null].map((credential) => + classifyRegistryFailure( + new Response(null, { status: 409 }), + REGISTRY_ENDPOINT.AVAILABLE_ARTIFACTS, + credential, + ), + ), + ); + + // Then + expect(results).toEqual([ + { status: REGISTRY_FAILURE.ONBOARDING }, + { status: REGISTRY_FAILURE.ERROR }, + ]); + }); + + it("maps only exact documented 502 and 503 status-code pairs", async () => { + // Given + const rejected = jsonError(502, "registry_key_rejected"); + const unavailable = jsonError(503, "registry_unavailable"); + + // When + const results = await Promise.all([ + classifyRegistryFailure( + rejected, + REGISTRY_ENDPOINT.PROVIDERS, + activeCredential, + ), + classifyRegistryFailure( + unavailable, + REGISTRY_ENDPOINT.AVAILABLE_ARTIFACTS, + activeCredential, + ), + ]); + + // Then + expect(results).toEqual([ + { status: REGISTRY_FAILURE.RECONNECT }, + { status: REGISTRY_FAILURE.UNAVAILABLE }, + ]); + }); + + it("keeps wrong, malformed, and unrelated failures generic", async () => { + // Given + const malformed = new Response("key=private", { status: 503 }); + + // When + const results = await Promise.all([ + classifyRegistryFailure( + jsonError(502, "other_error"), + REGISTRY_ENDPOINT.PROVIDERS, + activeCredential, + ), + classifyRegistryFailure( + jsonError(502, "registry_unavailable"), + REGISTRY_ENDPOINT.PROVIDERS, + activeCredential, + ), + classifyRegistryFailure( + malformed, + REGISTRY_ENDPOINT.PROVIDERS, + activeCredential, + ), + ]); + + // Then + expect(results).toEqual([ + { status: REGISTRY_FAILURE.ERROR }, + { status: REGISTRY_FAILURE.ERROR }, + { status: REGISTRY_FAILURE.ERROR }, + ]); + }); + + it("degrades a non-terminal empty first catalog page", async () => { + // Given + const document = (page: number) => ({ + data: [], + meta: { pagination: { page, pages: 2, count: 0 } }, + }); + + // When + const result = await collectCompleteRegistryCatalog(async (page) => + document(page), + ); + + // Then + expect(result).toEqual({ + status: "incomplete", + reason: "invalid_page", + collectedCount: 0, + }); + }); + + it("accepts a terminal empty first catalog page", async () => { + // Given + const document = { + data: [], + meta: { pagination: { page: 1, pages: 1, count: 0 } }, + }; + + // When + const result = await collectCompleteRegistryCatalog(async () => document); + + // Then + expect(result).toEqual({ status: "complete", artifacts: [] }); + }); + + it("maps the flat owner attributes tolerantly", async () => { + // Given + const document = { + data: [ + { + type: "registry-artifacts", + id: "core", + attributes: { + owner_name: "Prowler", + owner_slug: "prowler", + owner_type: "organization", + owner_logo_url: "https://cdn.example/prowler.png", + }, + }, + { + type: "registry-artifacts", + id: "plain-owner", + attributes: { + owner_name: "Ada", + owner_slug: "ada", + owner_type: "user", + owner_logo_url: null, + }, + }, + { + type: "registry-artifacts", + id: "ownerless", + attributes: { owner_name: " ", owner_logo_url: " " }, + }, + ], + meta: { pagination: { page: 1, pages: 1, count: 3 } }, + }; + + // When + const result = await collectCompleteRegistryCatalog(async () => document); + + // Then + expect(result).toMatchObject({ + status: "complete", + artifacts: [ + { + normalizedName: "core", + owners: [ + { + type: "organization", + name: "Prowler", + logoUrl: "https://cdn.example/prowler.png", + }, + ], + }, + { + normalizedName: "ownerless", + owners: [], + }, + { + normalizedName: "plain-owner", + owners: [{ type: "user", name: "Ada", logoUrl: undefined }], + }, + ], + }); + }); + + it("defaults omitted built-in status and maps explicit built-ins", async () => { + // Given + const document = { + data: [ + { type: "registry-artifacts", id: "installable", attributes: {} }, + { + type: "registry-artifacts", + id: "built-in", + attributes: { is_builtin: true }, + }, + ], + meta: { pagination: { page: 1, pages: 1, count: 2 } }, + }; + + // When + const result = await collectCompleteRegistryCatalog(async () => document); + + // Then + expect(result).toMatchObject({ + status: "complete", + artifacts: [ + { normalizedName: "built-in", isBuiltin: true }, + { normalizedName: "installable", isBuiltin: false }, + ], + }); + }); + + it("rejects malformed built-in values and preserves built-in duplicates", async () => { + // Given + const document = (data: unknown[]) => ({ + data, + meta: { pagination: { page: 1, pages: 1, count: data.length } }, + }); + const resource = (id: string, isBuiltin: unknown) => ({ + type: "registry-artifacts", + id, + attributes: { is_builtin: isBuiltin }, + }); + + // When + const explicitFalse = await collectCompleteRegistryCatalog(async () => + document([resource("installable", false)]), + ); + const malformed = await Promise.all( + [null, "true", 1].map((isBuiltin) => + collectCompleteRegistryCatalog(async () => + document([resource("malformed", isBuiltin)]), + ), + ), + ); + const duplicate = await collectCompleteRegistryCatalog(async (page) => ({ + data: [resource("built-in", page === 2)], + meta: { pagination: { page, pages: 2, count: 2 } }, + })); + + // Then + expect(explicitFalse).toMatchObject({ + status: "complete", + artifacts: [{ normalizedName: "installable", isBuiltin: false }], + }); + expect(malformed).toEqual([ + { status: "incomplete", reason: "invalid_resource", collectedCount: 1 }, + { status: "incomplete", reason: "invalid_resource", collectedCount: 1 }, + { status: "incomplete", reason: "invalid_resource", collectedCount: 1 }, + ]); + expect(duplicate).toMatchObject({ + status: "complete", + artifacts: [{ normalizedName: "built-in", isBuiltin: true }], + }); + }); + + it("preserves artifact counts, including zero, without inventing missing counts", async () => { + // Given + const resources = [ + { id: "aws", attributes: { check_count: 645, compliance_count: 45 } }, + { id: "openai", attributes: { check_count: 2, compliance_count: 0 } }, + { id: "missing", attributes: {} }, + { + id: "unknown", + attributes: { check_count: null, compliance_count: null }, + }, + { id: "aws", attributes: { check_count: 645 } }, + ].map((resource) => ({ + type: "registry-available-artifacts", + ...resource, + })); + + // When + const result = await collectCompleteRegistryCatalog(async () => ({ + data: resources, + meta: { pagination: { page: 1, pages: 1, count: resources.length } }, + })); + + // Then + expect(result).toMatchObject({ + status: "complete", + artifacts: [ + { normalizedName: "aws", checkCount: 645, complianceCount: 45 }, + { + normalizedName: "missing", + checkCount: undefined, + complianceCount: undefined, + }, + { normalizedName: "openai", checkCount: 2, complianceCount: 0 }, + { + normalizedName: "unknown", + checkCount: undefined, + complianceCount: undefined, + }, + ], + }); + }); + + it("preserves the declared provider when merging complementary catalog entries", async () => { + // Given + const fetchPage = async (page: number) => ({ + data: [ + { + type: "registry-artifacts", + id: "external-package", + attributes: + page === 1 + ? { providers: ["aaa"], has_checks: true } + : { providers: ["zzz"], has_provider: true }, + }, + ], + meta: { pagination: { page, pages: 2, count: 2 } }, + }); + + // When + const result = await collectCompleteRegistryCatalog(fetchPage); + + // Then + expect(result).toMatchObject({ + status: "complete", + artifacts: [ + { hasProvider: true, providerSlug: "zzz", providers: ["aaa", "zzz"] }, + ], + }); + }); + + it("rejects duplicate catalog entries with conflicting declared providers", async () => { + // Given + const fetchPage = async (page: number) => ({ + data: [ + { + type: "registry-artifacts", + id: "external-package", + attributes: { + has_provider: true, + providers: [page === 1 ? "aaa" : "zzz"], + }, + }, + ], + meta: { pagination: { page, pages: 2, count: 2 } }, + }); + + // When / Then + await expect( + collectCompleteRegistryCatalog(fetchPage), + ).resolves.toMatchObject({ + status: "incomplete", + reason: "conflicting_duplicate", + }); + }); + + it("traverses, merges, and degrades unsafe catalog data", async () => { + // Given + + const resource = ( + id: string, + attributes: Record = {}, + ) => ({ type: "registry-artifacts", id, attributes }); + + const document = ( + page: number, + pages: number, + count: number, + data: unknown[], + ) => ({ data, meta: { pagination: { page, pages, count } } }); + const requests: Array<[number, string | null, string | null]> = []; + + // When + + const complete = await collectCompleteRegistryCatalog( + async (page, query) => { + requests.push([ + page, + query.get("page[number]"), + query.get("page[size]"), + ]); + return page === 1 + ? document(1, 2, 3, [ + resource("core", { + name: "Core", + providers: ["AWS"], + is_verified: true, + version_count: 1, + total_downloads: 2, + owner_name: "Prowler", + owner_type: "organization", + }), + resource("zeta"), + ]) + : document(2, 2, 3, [ + resource("core", { + description: "Registry core", + latest_version: "2.0.0", + providers: ["gcp"], + is_official: true, + has_checks: true, + version_count: 3, + total_downloads: 8, + }), + ]); + }, + ); + + const limits = await Promise.all( + [999, 1000, 1001].map(async (pages) => { + let requests = 0; + const result = await collectCompleteRegistryCatalog(async (page) => { + requests += 1; + return document(page, pages, pages, [resource(`item-${page}`)]); + }); + return [pages, requests, result] as const; + }), + ); + + const failures = await Promise.all([ + collectCompleteRegistryCatalog(async () => ({ data: {}, meta: {} })), + collectCompleteRegistryCatalog(async () => + document(1, 1, 2, [resource("one")]), + ), + collectCompleteRegistryCatalog(async (page) => + document(page === 1 ? 1 : 1, 2, 2, [resource(`item-${page}`)]), + ), + collectCompleteRegistryCatalog(async (page) => + document(page, page === 1 ? 2 : 3, 2, [resource(`item-${page}`)]), + ), + collectCompleteRegistryCatalog(async () => + document(1, 1, 1, [resource("")]), + ), + collectCompleteRegistryCatalog(async (page) => + document(page, 2, 2, [ + resource("duplicate", { name: page === 1 ? "One" : "Two" }), + ]), + ), + collectCompleteRegistryCatalog(async (page) => { + if (page === 2) throw new Error("offline"); + return document(1, 2, 2, [resource("first")]); + }), + ]); + + // Then + expect(requests).toEqual([ + [1, "1", "100"], + [2, "2", "100"], + ]); + + expect(complete).toMatchObject({ + status: "complete", + artifacts: [ + { + normalizedName: "core", + name: "Core", + description: "Registry core", + latestVersion: "2.0.0", + providers: ["aws", "gcp"], + isVerified: true, + isOfficial: true, + hasChecks: true, + versionCount: 3, + totalDownloads: 8, + owners: [{ type: "organization", name: "Prowler" }], + }, + { normalizedName: "zeta" }, + ], + }); + expect(limits.map(([pages, requests]) => [pages, requests])).toEqual([ + [999, 999], + [1000, 1000], + [1001, 1], + ]); + expect(limits[2]?.[2]).toEqual({ + status: "incomplete", + reason: "guard_exhausted", + collectedCount: 1, + }); + expect( + failures.map((result) => + result.status === "incomplete" ? result.reason : undefined, + ), + ).toEqual([ + "invalid_page", + "count_mismatch", + "invalid_page", + "invalid_page", + "invalid_resource", + "conflicting_duplicate", + "page_failed", + ]); + failures.forEach((result) => + expect(result).not.toHaveProperty("artifacts"), + ); + }); +}); diff --git a/ui/actions/registry/registry.adapter.ts b/ui/actions/registry/registry.adapter.ts new file mode 100644 index 0000000000..46bd08d8e0 --- /dev/null +++ b/ui/actions/registry/registry.adapter.ts @@ -0,0 +1,441 @@ +import { z } from "zod"; + +import { isActiveRegistryCredential } from "@/lib/registry/credential-task"; +import { + REGISTRY_CATALOG, + REGISTRY_CATALOG_INCOMPLETE_REASON, + REGISTRY_ENDPOINT, + REGISTRY_FAILURE, + REGISTRY_MUTATION, + REGISTRY_SUBMISSION, + type RegistryCatalogArtifact, + type RegistryCatalogResult, + type RegistryCredentialStatus, + type RegistryTaskSubmissionResult, + type RegistryEndpoint, + type RegistryFailureResult, + type RegistryMutationResult, + type RegistryTenantArtifact, +} from "@/types/registry"; + +const REGISTRY_TASK_PATH_PREFIX = "/api/v1/tasks/"; +const REGISTRY_ERROR_CODE = { + KEY_REJECTED: "registry_key_rejected", + UNAVAILABLE: "registry_unavailable", +} as const; +const REGISTRY_MUTATION_REFUSAL_COPY = { + no_installable_version: "No available version can be added.", + registry_artifact_not_found: "This artifact is no longer available.", + version_not_found: "This version is not available.", + version_not_processed: "This version is not ready to add yet.", + version_not_verified: "This version is not verified and cannot be added.", + version_yanked: "This version is no longer available.", +} as const; +const registryDiscoveryEndpoints = new Set([ + REGISTRY_ENDPOINT.PROVIDERS, + REGISTRY_ENDPOINT.AVAILABLE_ARTIFACTS, +]); + +const credentialStatusSchema = z.object({ + data: z.object({ + attributes: z.object({ + configured: z.boolean(), + is_valid: z.boolean(), + scopes: z.array(z.string()), + last_validated_at: z.string().nullish(), + validation_status: z.string().nullish(), + validation_pending: z.boolean(), + }), + }), +}); + +const taskSubmissionSchema = z.object({ + data: z.object({ + type: z.literal("tasks"), + id: z.string().min(1), + }), +}); + +const registryCollectionSchema = z.object({ data: z.array(z.unknown()) }); +const tenantArtifactsSchema = z.object({ + data: z.array( + z.object({ + type: z.string().trim().min(1), + id: z.string().trim().min(1), + attributes: z.object({ + version_spec: z.string().trim().min(1), + resolved_version: z.string().trim().nullish(), + inserted_at: z.string().optional(), + updated_at: z.string().optional(), + }), + }), + ), +}); + +const errorDocumentSchema = z.object({ + errors: z.array(z.object({ code: z.string().min(1) })).min(1), +}); + +export function adaptRegistryCredentialStatus( + payload: unknown, +): RegistryCredentialStatus | null { + const parsed = credentialStatusSchema.safeParse(payload); + if (!parsed.success) return null; + + const { attributes } = parsed.data.data; + return { + configured: attributes.configured, + isValid: attributes.is_valid, + scopes: attributes.scopes, + lastValidatedAt: attributes.last_validated_at ?? undefined, + validationStatus: attributes.validation_status ?? undefined, + validationPending: attributes.validation_pending, + }; +} + +export function adaptRegistryTenantArtifacts( + payload: unknown, +): RegistryTenantArtifact[] | null { + const parsed = tenantArtifactsSchema.safeParse(payload); + if (!parsed.success) return null; + + return parsed.data.data.map(({ attributes, id }) => ({ + normalizedName: id, + versionSpec: attributes.version_spec, + resolvedVersion: attributes.resolved_version || undefined, + insertedAt: attributes.inserted_at, + updatedAt: attributes.updated_at, + })); +} + +export function isRegistryCollection(payload: unknown) { + return registryCollectionSchema.safeParse(payload).success; +} + +export class RegistryCatalogPageError extends Error { + constructor(readonly failure: RegistryFailureResult) { + super("Registry catalog page request failed"); + } +} + +export const parseRegistryCredentialSubmission = ( + response: Response, +): Promise => + parseRegistryTaskSubmission(response); + +export const parseRegistryArtifactSubmission = ( + response: Response, +): Promise => + parseRegistryTaskSubmission(response); + +async function parseRegistryTaskSubmission( + response: Response, +): Promise { + if (response.status !== 202) return { status: REGISTRY_SUBMISSION.ERROR }; + + const parsed = taskSubmissionSchema.safeParse( + await response.json().catch(() => undefined), + ); + const taskId = parsed.success ? parsed.data.data.id : undefined; + const location = response.headers.get("Content-Location"); + if ( + !taskId || + location !== `${REGISTRY_TASK_PATH_PREFIX}${encodeURIComponent(taskId)}` + ) { + return { status: REGISTRY_SUBMISSION.ERROR }; + } + + return { status: REGISTRY_SUBMISSION.PENDING, taskId }; +} + +export async function classifyRegistryMutationRefusal( + response: Response, +): Promise | null> { + const code = await getRegistryErrorCode(response); + const message = code + ? REGISTRY_MUTATION_REFUSAL_COPY[ + code as keyof typeof REGISTRY_MUTATION_REFUSAL_COPY + ] + : undefined; + return message ? { status: REGISTRY_MUTATION.REFUSED, message } : null; +} + +export async function classifyRegistryFailure( + response: Response, + endpoint: RegistryEndpoint, + credentialStatus: RegistryCredentialStatus | null, +): Promise { + if (response.status === 401 || response.status === 403) { + return { status: REGISTRY_FAILURE.ACCESS_DENIED }; + } + + if (!isRegistryDiscoveryEndpoint(endpoint)) { + return { status: REGISTRY_FAILURE.ERROR }; + } + + if ( + response.status === 409 && + credentialStatus !== null && + !isActiveRegistryCredential(credentialStatus) + ) { + return { status: REGISTRY_FAILURE.ONBOARDING }; + } + + const code = await getRegistryErrorCode(response); + if (response.status === 502 && code === REGISTRY_ERROR_CODE.KEY_REJECTED) { + return { status: REGISTRY_FAILURE.RECONNECT }; + } + if (response.status === 503 && code === REGISTRY_ERROR_CODE.UNAVAILABLE) { + return { status: REGISTRY_FAILURE.UNAVAILABLE }; + } + + return { status: REGISTRY_FAILURE.ERROR }; +} + +function isRegistryDiscoveryEndpoint(endpoint: RegistryEndpoint) { + return registryDiscoveryEndpoints.has(endpoint); +} + +async function getRegistryErrorCode(response: Response) { + const parsed = errorDocumentSchema.safeParse( + await response + .clone() + .json() + .catch(() => undefined), + ); + return parsed.success ? parsed.data.errors[0]?.code : undefined; +} + +const REGISTRY_CATALOG_PAGE_SIZE = 100; +const REGISTRY_CATALOG_MAX_PAGES = 1000; +const safeInteger = z.number().int().nonnegative().safe(); +const catalogPageSchema = z.object({ + data: z.array(z.unknown()), + meta: z.object({ + pagination: z.object({ + page: safeInteger, + pages: safeInteger, + count: safeInteger, + }), + }), +}); +const catalogAttributesSchema = z.object({ + name: z.string().optional(), + description: z.string().optional(), + latest_version: z.string().optional(), + providers: z.array(z.string().trim().min(1)).optional(), + owner_name: z.string().optional(), + owner_type: z.string().optional(), + owner_logo_url: z.string().nullable().optional(), + is_verified: z.boolean().optional(), + is_official: z.boolean().optional(), + is_builtin: z.boolean().optional(), + is_meta: z.boolean().optional(), + has_provider: z.boolean().optional(), + has_checks: z.boolean().optional(), + has_compliance: z.boolean().optional(), + check_count: safeInteger.nullish(), + compliance_count: safeInteger.nullish(), + version_count: safeInteger.optional(), + total_downloads: safeInteger.optional(), +}); +const catalogResourceSchema = z.object({ + type: z.string().trim().min(1), + id: z.string().trim().min(1), + attributes: catalogAttributesSchema, +}); +type RegistryCatalogPageFetcher = ( + page: number, + searchParams: URLSearchParams, +) => Promise; + +export async function collectCompleteRegistryCatalog( + fetchPage: RegistryCatalogPageFetcher, +): Promise { + const resources: unknown[] = []; + let expectedPages: number | undefined; + let expectedCount: number | undefined; + for (let page = 1; ; page += 1) { + let payload: unknown; + try { + payload = await fetchPage( + page, + new URLSearchParams({ + "page[number]": String(page), + "page[size]": String(REGISTRY_CATALOG_PAGE_SIZE), + }), + ); + } catch (error) { + if (error instanceof RegistryCatalogPageError) throw error; + return incomplete("PAGE_FAILED", resources.length); + } + const parsed = catalogPageSchema.safeParse(payload); + if (!parsed.success) return incomplete("INVALID_PAGE", resources.length); + const { count, page: responsePage, pages } = parsed.data.meta.pagination; + if ( + responsePage !== page || + (expectedPages !== undefined && + (pages !== expectedPages || count !== expectedCount)) + ) + return incomplete("INVALID_PAGE", resources.length); + expectedPages ??= pages; + expectedCount ??= count; + if (page === 1 && pages > 1 && count === 0 && parsed.data.data.length === 0) + return incomplete("INVALID_PAGE", resources.length); + if (pages === 0) + return page === 1 && count === 0 && parsed.data.data.length === 0 + ? { status: REGISTRY_CATALOG.COMPLETE, artifacts: [] } + : incomplete("INVALID_PAGE", resources.length); + resources.push(...parsed.data.data); + if (pages > REGISTRY_CATALOG_MAX_PAGES) + return incomplete("GUARD_EXHAUSTED", resources.length); + if (page === pages) break; + if (page > pages) return incomplete("INVALID_PAGE", resources.length); + } + const merged = mergeCatalogResources(resources); + return merged.status === REGISTRY_CATALOG.INCOMPLETE || + resources.length === expectedCount + ? merged + : incomplete("COUNT_MISMATCH", resources.length); +} + +function mergeCatalogResources(resources: unknown[]): RegistryCatalogResult { + const artifacts = new Map(); + for (const resource of resources) { + const artifact = adaptCatalogArtifact(resource); + if (!artifact) return incomplete("INVALID_RESOURCE", resources.length); + const prior = artifacts.get(artifact.normalizedName); + const next = prior ? mergeArtifacts(prior, artifact) : artifact; + if (!next) return incomplete("CONFLICTING_DUPLICATE", resources.length); + artifacts.set(next.normalizedName, next); + } + return { + status: REGISTRY_CATALOG.COMPLETE, + artifacts: Array.from(artifacts.values()).sort((left, right) => + compare(left.normalizedName, right.normalizedName), + ), + }; +} + +function adaptCatalogArtifact( + resource: unknown, +): RegistryCatalogArtifact | null { + const parsed = catalogResourceSchema.safeParse(resource); + if (!parsed.success) return null; + const { attributes: a, id } = parsed.data; + return { + normalizedName: id, + name: text(a.name), + description: text(a.description), + latestVersion: text(a.latest_version), + providers: unique( + a.providers?.map((provider) => provider.toLowerCase()) ?? [], + ), + ...(a.has_provider === true && a.providers?.[0] + ? { providerSlug: a.providers[0].toLowerCase() } + : {}), + owners: flatOwner(a), + isVerified: a.is_verified ?? false, + isOfficial: a.is_official ?? false, + isBuiltin: a.is_builtin ?? false, + isMeta: a.is_meta ?? false, + hasProvider: a.has_provider ?? false, + hasChecks: a.has_checks ?? false, + hasCompliance: a.has_compliance ?? false, + checkCount: a.check_count ?? undefined, + complianceCount: a.compliance_count ?? undefined, + versionCount: a.version_count ?? 0, + totalDownloads: a.total_downloads ?? 0, + }; +} + +function mergeArtifacts( + left: RegistryCatalogArtifact, + right: RegistryCatalogArtifact, +): RegistryCatalogArtifact | null { + const [name, description, latestVersion, providerSlug] = [ + mergeText(left.name, right.name), + mergeText(left.description, right.description), + mergeText(left.latestVersion, right.latestVersion), + mergeText(left.providerSlug, right.providerSlug), + ]; + if ( + [name, description, latestVersion, providerSlug].some( + (value) => value === null, + ) + ) + return null; + return { + ...left, + name: name ?? undefined, + description: description ?? undefined, + latestVersion: latestVersion ?? undefined, + providerSlug: providerSlug ?? undefined, + providers: unique([...left.providers, ...right.providers]), + owners: uniqueOwners([...left.owners, ...right.owners]), + isVerified: left.isVerified || right.isVerified, + isOfficial: left.isOfficial || right.isOfficial, + isBuiltin: left.isBuiltin || right.isBuiltin, + isMeta: left.isMeta || right.isMeta, + hasProvider: left.hasProvider || right.hasProvider, + hasChecks: left.hasChecks || right.hasChecks, + hasCompliance: left.hasCompliance || right.hasCompliance, + checkCount: mergeCount(left.checkCount, right.checkCount), + complianceCount: mergeCount(left.complianceCount, right.complianceCount), + versionCount: Math.max(left.versionCount, right.versionCount), + totalDownloads: Math.max(left.totalDownloads, right.totalDownloads), + }; +} + +function incomplete( + reason: keyof typeof REGISTRY_CATALOG_INCOMPLETE_REASON, + collectedCount: number, +): RegistryCatalogResult { + return { + status: REGISTRY_CATALOG.INCOMPLETE, + reason: REGISTRY_CATALOG_INCOMPLETE_REASON[reason], + collectedCount, + }; +} +function text(value: string | undefined) { + return value?.trim() || undefined; +} +function mergeText(left: string | undefined, right: string | undefined) { + return left && right && left !== right ? null : (left ?? right); +} +function mergeCount(left: number | undefined, right: number | undefined) { + if (left === undefined) return right; + if (right === undefined) return left; + return Math.max(left, right); +} +function unique(values: string[]) { + return Array.from(new Set(values)).sort(compare); +} +function flatOwner( + a: z.infer, +): RegistryCatalogArtifact["owners"] { + const name = text(a.owner_name); + if (!name) return []; + return [ + { + name, + type: text(a.owner_type) ?? "", + logoUrl: text(a.owner_logo_url ?? undefined), + }, + ]; +} +function uniqueOwners(owners: RegistryCatalogArtifact["owners"]) { + return Array.from( + new Map( + owners.map((owner) => [`${owner.type}\u0000${owner.name}`, owner]), + ).values(), + ).sort((left, right) => + compare( + `${left.type}\u0000${left.name}`, + `${right.type}\u0000${right.name}`, + ), + ); +} +function compare(left: string, right: string) { + return left < right ? -1 : left > right ? 1 : 0; +} diff --git a/ui/actions/registry/registry.test.ts b/ui/actions/registry/registry.test.ts new file mode 100644 index 0000000000..f81ee78d23 --- /dev/null +++ b/ui/actions/registry/registry.test.ts @@ -0,0 +1,1138 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + authMock, + evaluateAccessMock, + evaluateProviderAccessMock, + fetchMock, + pollTaskUntilSettledMock, +} = vi.hoisted(() => ({ + authMock: vi.fn(), + evaluateAccessMock: vi.fn(), + evaluateProviderAccessMock: vi.fn(), + fetchMock: vi.fn(), + pollTaskUntilSettledMock: vi.fn(), +})); + +vi.mock("@/auth.config", () => ({ auth: authMock })); +vi.mock("@/lib", () => ({ apiBaseUrl: "https://api.test/api/v1" })); +vi.mock("@/actions/task/poll", () => ({ + pollTaskUntilSettled: pollTaskUntilSettledMock, +})); +vi.mock("@/lib/registry/access.server", () => ({ + evaluateRegistryAccess: evaluateAccessMock, + evaluateRegistryProviderAccess: evaluateProviderAccessMock, +})); + +import { + addRegistryArtifact, + confirmRegistryArtifactAddition, + disconnectRegistryCredential, + getRegistryBootstrap, + getInstalledRegistryProviderOptions, + refreshRegistryCollections, + removeRegistryArtifact, + refreshRegistryCredential, + submitRegistryCredential, +} from "./registry"; + +const activeCredential = { + configured: true, + isValid: true, + scopes: ["catalog:read"], + validationPending: false, +}; +const noCredential = { + configured: false, + isValid: false, + scopes: [], + validationPending: false, +}; +const pendingCredential = { + configured: true, + isValid: false, + scopes: [], + validationPending: true, +}; + +const jsonResponse = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/vnd.api+json" }, + }); +const credentialResponse = (credential = activeCredential) => + jsonResponse({ + data: { + attributes: { + configured: credential.configured, + is_valid: credential.isValid, + scopes: credential.scopes, + validation_pending: credential.validationPending, + }, + }, + }); +const tenantArtifactsResponse = () => + jsonResponse({ + data: [ + { + type: "registry-artifacts", + id: "prowler-aws", + attributes: { + version_spec: "latest", + inserted_at: "2026-03-20T12:00:00Z", + }, + }, + ], + }); +const catalogResponse = () => + jsonResponse({ + data: [ + { + type: "registry-artifacts", + id: "prowler-aws", + attributes: { name: "Prowler AWS", providers: ["aws"] }, + }, + ], + meta: { pagination: { page: 1, pages: 1, count: 1 } }, + }); + +beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + authMock.mockResolvedValue({ accessToken: "access-token" }); + evaluateAccessMock.mockResolvedValue({ status: "eligible" }); + evaluateProviderAccessMock.mockResolvedValue({ status: "eligible" }); + fetchMock.mockReset(); + pollTaskUntilSettledMock.mockReset(); +}); + +function mockRequestDeadlines() { + // Native AbortSignal.timeout uses real timers; drive it with the test clock. + vi.spyOn(AbortSignal, "timeout").mockImplementation((milliseconds) => { + const controller = new AbortController(); + setTimeout(() => controller.abort(), milliseconds); + return controller.signal; + }); +} + +describe("installed Registry provider discovery", () => { + function mockDiscovery({ + emptyMetadata = false, + failedEndpoint, + failureStatus = 500, + }: { + emptyMetadata?: boolean; + failedEndpoint?: string; + failureStatus?: number; + } = {}) { + fetchMock.mockImplementation((url: string) => { + const endpoint = new URL(url).pathname.split("/").pop(); + if (endpoint === failedEndpoint) return jsonResponse({}, failureStatus); + if (endpoint === "available-artifacts") + return jsonResponse({ + data: [ + { + type: "registry-artifacts", + id: "acme-package", + attributes: { + name: "Acme package", + providers: ["acme"], + has_provider: true, + }, + }, + ], + meta: { pagination: { page: 1, pages: 1, count: 1 } }, + }); + if (endpoint === "artifacts") + return jsonResponse({ + data: [ + { + type: "registry-artifacts", + id: "acme-package", + attributes: { version_spec: "latest" }, + }, + ], + }); + if (endpoint === "providers") + return jsonResponse({ + data: emptyMetadata + ? [] + : [ + { + id: "acme", + attributes: { + name: "Acme Cloud", + logo_url: "https://media.registry.test/acme.svg", + }, + }, + ], + }); + throw new Error(`Unexpected endpoint: ${endpoint}`); + }); + } + + it("joins catalog declarations, installed membership and provider metadata", async () => { + mockDiscovery(); + expect(await getInstalledRegistryProviderOptions()).toEqual({ + status: "ready", + options: [ + { + type: "acme", + label: "Acme Cloud", + logoUrl: "https://media.registry.test/acme.svg", + }, + ], + }); + }); + + it("allows installed-provider discovery without Registry management access", async () => { + // Given + mockDiscovery({ emptyMetadata: true }); + evaluateAccessMock.mockResolvedValue({ status: "ineligible" }); + // When / Then + expect(await getInstalledRegistryProviderOptions()).toEqual({ + status: "ready", + options: [{ type: "acme", label: "Acme package" }], + }); + expect(evaluateProviderAccessMock).toHaveBeenCalledWith("access-token"); + expect(evaluateAccessMock).not.toHaveBeenCalled(); + }); + + it.each(["ineligible", "unknown"])( + "denies installed-provider discovery when provider access is %s", + async (status) => { + // Given + evaluateProviderAccessMock.mockResolvedValue({ status }); + // When / Then + expect(await getInstalledRegistryProviderOptions()).toEqual({ + status: "access_denied", + }); + expect(fetchMock).not.toHaveBeenCalled(); + }, + ); + + it("uses the declared provider and artifact name when metadata is empty", async () => { + mockDiscovery({ emptyMetadata: true }); + expect(await getInstalledRegistryProviderOptions()).toEqual({ + status: "ready", + options: [{ type: "acme", label: "Acme package" }], + }); + }); + + it.each(["available-artifacts", "artifacts", "providers"])( + "returns an error when the %s read fails", + async (failedEndpoint) => { + mockDiscovery({ failedEndpoint }); + expect(await getInstalledRegistryProviderOptions()).toEqual({ + status: "error", + }); + }, + ); + + it.each(["available-artifacts", "artifacts", "providers"])( + "preserves access denial from the %s read", + async (failedEndpoint) => { + mockDiscovery({ failedEndpoint, failureStatus: 403 }); + expect(await getInstalledRegistryProviderOptions()).toEqual({ + status: "access_denied", + }); + }, + ); +}); + +describe("Registry guarded reads", () => { + it("recovers when a Registry read stalls", async () => { + // Given: the upstream responds only when its request is aborted. + vi.useFakeTimers(); + try { + mockRequestDeadlines(); + fetchMock.mockImplementation( + (_url, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => + reject(init.signal?.reason), + ); + }), + ); + const settled = vi.fn(); + + // When + void refreshRegistryCredential().then(settled); + await vi.advanceTimersByTimeAsync(30_000); + + // Then + expect(settled).toHaveBeenCalledWith({ status: "error" }); + } finally { + vi.useRealTimers(); + } + }); + + it.each([ + [ + "credential submission", + () => submitRegistryCredential("registry-test-key"), + ], + ["credential disconnection", disconnectRegistryCredential], + [ + "artifact addition", + () => addRegistryArtifact({ normalizedName: "external-package" }), + ], + ["artifact removal", () => removeRegistryArtifact("external-package")], + ])("recovers when %s stalls", async (_name, action) => { + // Given: prerequisite reads succeed, but the mutation never responds. + vi.useFakeTimers(); + try { + mockRequestDeadlines(); + fetchMock.mockImplementation((url: string, init?: RequestInit) => { + if (init?.method === "POST" || init?.method === "DELETE") { + return new Promise((_resolve, reject) => { + init.signal?.addEventListener("abort", () => + reject(init.signal?.reason), + ); + }); + } + if (url.includes("available-artifacts")) { + return Promise.resolve( + jsonResponse({ + data: [ + { + type: "registry-artifacts", + id: "external-package", + attributes: { has_provider: true, is_builtin: false }, + }, + ], + meta: { pagination: { page: 1, pages: 1, count: 1 } }, + }), + ); + } + return Promise.resolve(credentialResponse(noCredential)); + }); + const settled = vi.fn(); + + // When + void action().then(settled); + await vi.advanceTimersByTimeAsync(30_000); + + // Then + expect(settled).toHaveBeenCalledWith({ status: "error" }); + } finally { + vi.useRealTimers(); + } + }); + + it("denies Registry management actions before any Registry endpoint call", async () => { + // Given + evaluateAccessMock.mockResolvedValue({ status: "ineligible" }); + const actions = [ + getRegistryBootstrap, + refreshRegistryCredential, + refreshRegistryCollections, + () => submitRegistryCredential("registry-test-key"), + disconnectRegistryCredential, + ]; + + // When + const results = await Promise.all(actions.map((action) => action())); + + // Then + expect(results).toEqual(actions.map(() => ({ status: "access_denied" }))); + expect(evaluateAccessMock).toHaveBeenCalledTimes(actions.length); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it.each(["unknown", "ineligible"])( + "does not fetch Registry collections when access is %s", + async (status) => { + // Given + evaluateAccessMock.mockResolvedValue({ status }); + + // When + const results = await Promise.all([ + getRegistryBootstrap(), + refreshRegistryCredential(), + refreshRegistryCollections(), + ]); + + // Then + expect(results).toEqual([ + { status: "access_denied" }, + { status: "access_denied" }, + { status: status === "unknown" ? "error" : "access_denied" }, + ]); + expect(fetchMock).not.toHaveBeenCalled(); + }, + ); + + it("bootstraps in credential, tenant-artifact, then complete-catalog order", async () => { + // Given + fetchMock + .mockResolvedValueOnce(credentialResponse()) + .mockResolvedValueOnce(tenantArtifactsResponse()) + .mockResolvedValueOnce(catalogResponse()); + + // When + const result = await getRegistryBootstrap(); + + // Then + expect(result).toEqual({ + status: "ready", + state: { + status: "ready", + credential: activeCredential, + catalog: { + status: "complete", + artifacts: [ + expect.objectContaining({ normalizedName: "prowler-aws" }), + ], + }, + tenantArtifacts: [ + { + normalizedName: "prowler-aws", + versionSpec: "latest", + insertedAt: "2026-03-20T12:00:00Z", + }, + ], + }, + }); + expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ + "https://api.test/api/v1/registry/credential", + "https://api.test/api/v1/registry/artifacts", + "https://api.test/api/v1/registry/available-artifacts?page%5Bnumber%5D=1&page%5Bsize%5D=100", + ]); + fetchMock.mock.calls.forEach(([, options]) => { + expect(options).toMatchObject({ + cache: "no-store", + headers: { + Accept: "application/vnd.api+json", + Authorization: "Bearer access-token", + }, + }); + }); + }); + + it.each([ + [noCredential, "onboarding"], + [pendingCredential, "validation_pending"], + ] as const)( + "blocks catalog bootstrap as %s credential is authoritative", + async (credential, expectedStatus) => { + // Given + fetchMock + .mockResolvedValueOnce(credentialResponse(credential)) + .mockResolvedValueOnce(tenantArtifactsResponse()); + + // When + const result = await getRegistryBootstrap(); + + // Then + expect(result).toEqual({ + status: "ready", + state: { + status: expectedStatus, + credential, + tenantArtifacts: [ + { + normalizedName: "prowler-aws", + versionSpec: "latest", + insertedAt: "2026-03-20T12:00:00Z", + }, + ], + }, + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }, + ); + + it("returns only a non-secret status read after a fresh guard", async () => { + // Given + fetchMock.mockResolvedValueOnce(credentialResponse()); + + // When + const result = await refreshRegistryCredential(); + + // Then + expect(result).toEqual({ status: "status", credential: activeCredential }); + expect(fetchMock).toHaveBeenCalledWith( + "https://api.test/api/v1/registry/credential", + expect.objectContaining({ cache: "no-store" }), + ); + }); + + it("returns fresh complete collections", async () => { + // Given + fetchMock + .mockResolvedValueOnce(catalogResponse()) + .mockResolvedValueOnce(tenantArtifactsResponse()); + + // When + const result = await refreshRegistryCollections(); + + // Then + expect(result).toEqual({ + status: "complete", + catalog: { + status: "complete", + artifacts: [expect.objectContaining({ normalizedName: "prowler-aws" })], + }, + tenantArtifacts: [ + { + normalizedName: "prowler-aws", + versionSpec: "latest", + insertedAt: "2026-03-20T12:00:00Z", + }, + ], + }); + expect(evaluateAccessMock).toHaveBeenCalledWith("access-token"); + }); + + it("maps a discovery 409 to onboarding after an authoritative no-credential read", async () => { + // Given + fetchMock + .mockResolvedValueOnce(jsonResponse({ errors: [] }, 409)) + .mockResolvedValueOnce(credentialResponse(noCredential)); + + // When + const result = await refreshRegistryCollections(); + + // Then + expect(result).toEqual({ status: "onboarding" }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("maps documented read recovery without exposing retained or partial catalog data", async () => { + // Given + fetchMock.mockResolvedValueOnce( + jsonResponse({ errors: [{ code: "registry_key_rejected" }] }, 502), + ); + + // When + const reconnect = await refreshRegistryCollections(); + + // Then + expect(reconnect).toEqual({ status: "reconnect" }); + + // Given + fetchMock.mockResolvedValueOnce( + jsonResponse({ errors: [{ code: "registry_unavailable" }] }, 503), + ); + + // When + const unavailable = await refreshRegistryCollections(); + + // Then + expect(unavailable).toEqual({ status: "unavailable" }); + expect(unavailable).not.toHaveProperty("catalog"); + + // Given + fetchMock.mockResolvedValueOnce( + jsonResponse({ errors: [{ code: "other_failure" }] }, 502), + ); + + // When + const generic = await refreshRegistryCollections(); + + // Then + expect(generic).toEqual({ status: "error" }); + }); + + it("keeps a transient access check failure retryable when refreshing collections", async () => { + // Given: the API cannot answer the permission check during a transient outage. + evaluateAccessMock.mockResolvedValueOnce({ status: "unknown" }); + + // When / Then: preserve the current page instead of treating the outage as revocation. + expect(await refreshRegistryCollections()).toEqual({ status: "error" }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("maps Registry 401 and 403 to access denial before any recovery classification", async () => { + // Given + fetchMock.mockResolvedValueOnce( + jsonResponse({ errors: [{ code: "registry_key_rejected" }] }, 401), + ); + + // When + const credential = await refreshRegistryCredential(); + + // Then + expect(credential).toEqual({ status: "access_denied" }); + + // Given + fetchMock.mockResolvedValueOnce( + jsonResponse({ errors: [{ code: "registry_unavailable" }] }, 403), + ); + + // When + const collections = await refreshRegistryCollections(); + + // Then + expect(collections).toEqual({ status: "access_denied" }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("rechecks access between separate actions after permission revocation", async () => { + // Given + evaluateAccessMock + .mockResolvedValueOnce({ status: "eligible" }) + .mockResolvedValueOnce({ status: "ineligible" }); + fetchMock.mockResolvedValueOnce(credentialResponse()); + + // When + const first = await refreshRegistryCredential(); + const second = await refreshRegistryCollections(); + + // Then + expect(first).toEqual({ status: "status", credential: activeCredential }); + expect(second).toEqual({ status: "access_denied" }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(evaluateAccessMock).toHaveBeenCalledTimes(2); + }); + + it("returns the accepted validation task immediately without server-side polling", async () => { + // Given + const key = " registry-test-key "; + fetchMock + .mockResolvedValueOnce(credentialResponse(noCredential)) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ data: { type: "tasks", id: "task-123" } }), + { + status: 202, + headers: { "Content-Location": "/api/v1/tasks/task-123" }, + }, + ), + ); + + // When + const result = await submitRegistryCredential(key); + + // Then + expect(result).toEqual({ + status: "submitted", + taskId: "task-123", + priorConfigured: false, + }); + expect(pollTaskUntilSettledMock).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + "https://api.test/api/v1/registry/credential", + expect.objectContaining({ + body: JSON.stringify({ + data: { + type: "registry-credentials", + attributes: { api_key: key.trim() }, + }, + }), + cache: "no-store", + method: "POST", + }), + ); + expect(JSON.stringify(result)).not.toContain(key); + }); + + it("marks an accepted replacement as superseding a configured credential", async () => { + // Given + fetchMock + .mockResolvedValueOnce(credentialResponse(activeCredential)) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ data: { type: "tasks", id: "task-456" } }), + { + status: 202, + headers: { "Content-Location": "/api/v1/tasks/task-456" }, + }, + ), + ); + + // When + const result = await submitRegistryCredential("registry-replacement-key"); + + // Then + expect(result).toEqual({ + status: "submitted", + taskId: "task-456", + priorConfigured: true, + }); + expect(pollTaskUntilSettledMock).not.toHaveBeenCalled(); + }); + + it("re-reads credential and preserves authoritative My artifacts after disconnect", async () => { + // Given + fetchMock + .mockResolvedValueOnce(new Response(null, { status: 204 })) + .mockResolvedValueOnce(credentialResponse(noCredential)) + .mockResolvedValueOnce(tenantArtifactsResponse()); + + // When + const result = await disconnectRegistryCredential(); + + // Then + expect(result).toEqual({ + status: "disconnected", + credential: noCredential, + tenantArtifacts: [ + { + normalizedName: "prowler-aws", + versionSpec: "latest", + insertedAt: "2026-03-20T12:00:00Z", + }, + ], + }); + expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ + "https://api.test/api/v1/registry/credential", + "https://api.test/api/v1/registry/credential", + "https://api.test/api/v1/registry/artifacts", + ]); + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + "https://api.test/api/v1/registry/credential", + expect.objectContaining({ cache: "no-store", method: "DELETE" }), + ); + }); + + it("rejects a task-binding mismatch without returning the key or a task", async () => { + // Given + const key = "registry-test-key"; + fetchMock + .mockResolvedValueOnce(credentialResponse(noCredential)) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ data: { type: "tasks", id: "task-123" } }), + { + status: 202, + headers: { "Content-Location": "/api/v1/tasks/other-task" }, + }, + ), + ); + + // When + const result = await submitRegistryCredential(key); + + // Then + expect(result).toEqual({ status: "error" }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(pollTaskUntilSettledMock).not.toHaveBeenCalled(); + expect(JSON.stringify(result)).not.toContain(key); + }); + + it("rejects malformed accepted task data without a task identity", async () => { + // Given + fetchMock + .mockResolvedValueOnce(credentialResponse(noCredential)) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ data: { type: "not-a-task", id: "task-123" } }), + { + status: 202, + headers: { "Content-Location": "/api/v1/tasks/task-123" }, + }, + ), + ); + + // When + const result = await submitRegistryCredential("registry-test-key"); + + // Then + expect(result).toEqual({ status: "error" }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(result).not.toHaveProperty("taskId"); + }); + + it("preserves an active credential after a rejected replacement", async () => { + // Given + const key = "registry-replacement-key"; + fetchMock + .mockResolvedValueOnce(credentialResponse(activeCredential)) + .mockResolvedValueOnce(jsonResponse({ errors: [] }, 500)); + + // When + const result = await submitRegistryCredential(key); + + // Then + expect(result).toEqual({ + status: "replacement_failed", + credential: activeCredential, + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(JSON.stringify(result)).not.toContain(key); + }); + + it("handles action authorization failures safely", async () => { + // Given + fetchMock + .mockResolvedValueOnce(credentialResponse(noCredential)) + .mockResolvedValueOnce(jsonResponse({ errors: [] }, 401)) + .mockResolvedValueOnce(jsonResponse({ errors: [] }, 403)); + + // When + const rejected = await submitRegistryCredential("registry-test-key"); + const disconnected = await disconnectRegistryCredential(); + + // Then + expect(rejected).toEqual({ status: "access_denied" }); + expect(disconnected).toEqual({ status: "access_denied" }); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); +}); + +const installCatalogMock = vi.fn(); +describe("Registry artifact mutations", () => { + beforeEach(() => { + installCatalogMock.mockImplementation(() => + jsonResponse({ + data: [ + { + type: "registry-available-artifacts", + id: "later-guard", + attributes: { + has_provider: true, + is_builtin: false, + providers: ["acme"], + }, + }, + ], + meta: { pagination: { page: 1, pages: 1, count: 1 } }, + }), + ); + vi.stubGlobal("fetch", (url: string, options?: RequestInit) => + url.includes("/available-artifacts") + ? installCatalogMock(url, options) + : fetchMock(url, options), + ); + }); + + it.each([ + { has_provider: true, is_builtin: true }, + { has_provider: false, is_builtin: false }, + ])( + "refuses ineligible catalog entries before POST: %j", + async (attributes) => { + // Given + installCatalogMock.mockImplementation(() => + jsonResponse({ + data: [ + { + type: "registry-available-artifacts", + id: "later-guard", + attributes, + }, + ], + meta: { pagination: { page: 1, pages: 1, count: 1 } }, + }), + ); + // When + const result = await addRegistryArtifact({ + normalizedName: "later-guard", + }); + // Then + expect(result).toMatchObject({ status: "refused" }); + expect(fetchMock).not.toHaveBeenCalled(); + }, + ); + + it("returns an accepted Add task without reading My artifacts", async () => { + // Given + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ data: { type: "tasks", id: "artifact-task" } }), + { + status: 202, + headers: { "Content-Location": "/api/v1/tasks/artifact-task" }, + }, + ), + ); + + // When + const result = await addRegistryArtifact({ + normalizedName: "later-guard", + versionSpec: " 2.0.0 ", + }); + + // Then + expect(result).toEqual({ status: "submitted", taskId: "artifact-task" }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + "https://api.test/api/v1/registry/artifacts", + expect.objectContaining({ + body: JSON.stringify({ + data: { + type: "registry-artifacts", + attributes: { + normalized_name: "later-guard", + version_spec: "2.0.0", + }, + }, + }), + cache: "no-store", + method: "POST", + }), + ); + }); + + it("defaults Add to latest", async () => { + // Given + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ data: { type: "tasks", id: "artifact-task" } }), + { + status: 202, + headers: { "Content-Location": "/api/v1/tasks/artifact-task" }, + }, + ), + ); + + // When + const result = await addRegistryArtifact({ normalizedName: "later-guard" }); + + // Then + expect(result).toEqual({ status: "submitted", taskId: "artifact-task" }); + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + "https://api.test/api/v1/registry/artifacts", + expect.objectContaining({ + body: JSON.stringify({ + data: { + type: "registry-artifacts", + attributes: { + normalized_name: "later-guard", + version_spec: "latest", + }, + }, + }), + }), + ); + }); + + it("rejects invalid accepted task bindings without reading My artifacts", async () => { + // Given + const document = JSON.stringify({ + data: { type: "tasks", id: "artifact-task" }, + }); + fetchMock + .mockResolvedValueOnce( + new Response(JSON.stringify({ data: { type: "tasks" } }), { + status: 202, + headers: { "Content-Location": "/api/v1/tasks/artifact-task" }, + }), + ) + .mockResolvedValueOnce(new Response(document, { status: 202 })) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ data: { type: "other", id: "artifact-task" } }), + { + status: 202, + headers: { "Content-Location": "/api/v1/tasks/artifact-task" }, + }, + ), + ) + .mockResolvedValueOnce( + new Response(document, { + status: 202, + headers: { "Content-Location": "/api/v1/tasks/other" }, + }), + ); + + // When + const outcomes = await Promise.all( + ["missing-id", "missing-location", "wrong-type", "wrong-location"].map( + () => addRegistryArtifact({ normalizedName: "later-guard" }), + ), + ); + + // Then + expect(outcomes).toEqual(Array(4).fill({ status: "error" })); + expect(fetchMock).toHaveBeenCalledTimes(4); + }); + + it("keeps a missing Registry credential synchronous", async () => { + // Given + fetchMock.mockResolvedValueOnce(jsonResponse({ errors: [] }, 409)); + + // When + const outcome = await addRegistryArtifact({ + normalizedName: "later-guard", + }); + + // Then + expect(outcome).toEqual({ status: "onboarding" }); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it.each([ + ["registry_artifact_not_found", "This artifact is no longer available."], + ["version_yanked", "This version is no longer available."], + [ + "version_not_verified", + "This version is not verified and cannot be added.", + ], + ["version_not_processed", "This version is not ready to add yet."], + ["version_not_found", "This version is not available."], + ["no_installable_version", "No available version can be added."], + ])("keeps membership unchanged for %s", async (code, message) => { + // Given + fetchMock.mockResolvedValueOnce( + jsonResponse( + { errors: [{ code }] }, + code === "registry_artifact_not_found" ? 404 : 400, + ), + ); + + // When + const result = await addRegistryArtifact({ + normalizedName: "later-guard", + versionSpec: "2.0.0", + }); + + // Then + expect(result).toEqual({ status: "refused", message }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("reports an in-use artifact when Remove returns 409 without refreshing membership", async () => { + // Given + fetchMock.mockResolvedValueOnce(new Response(null, { status: 409 })); + + // When + const result = await removeRegistryArtifact("aws-guard"); + + // Then + expect(result).toEqual({ status: "in_use" }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it.each([ + [401, "access_denied"], + [403, "access_denied"], + [400, "error"], + [500, "error"], + ])("preserves the Remove failure for HTTP %s", async (status, expected) => { + // Given + fetchMock.mockResolvedValueOnce(new Response(null, { status })); + + // When + const result = await removeRegistryArtifact("aws-guard"); + + // Then + expect(result).toEqual({ status: expected }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("reports a Remove network failure without refreshing membership", async () => { + // Given + fetchMock.mockRejectedValueOnce(new TypeError("Failed to fetch")); + + // When + const result = await removeRegistryArtifact("aws-guard"); + + // Then + expect(result).toEqual({ status: "error" }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("encodes the deletion identity and confirms Remove after an absent refresh", async () => { + // Given + fetchMock + .mockResolvedValueOnce(new Response(null, { status: 204 })) + .mockResolvedValueOnce(jsonResponse({ data: [] })); + + // When + const result = await removeRegistryArtifact("guard/with space"); + + // Then + expect(result).toEqual({ status: "confirmed", tenantArtifacts: [] }); + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + "https://api.test/api/v1/registry/artifacts/guard%2Fwith%20space", + expect.objectContaining({ cache: "no-store", method: "DELETE" }), + ); + }); + + it.each(["1.0.0", null, undefined])( + "does not confirm an update when the installed version is %j", + async (resolvedVersion) => { + // Given + fetchMock.mockResolvedValueOnce( + jsonResponse({ + data: [ + { + type: "registry-artifacts", + id: "template", + attributes: { + version_spec: "latest", + resolved_version: resolvedVersion, + }, + }, + ], + }), + ); + // When + const result = await confirmRegistryArtifactAddition("template", "1.1.0"); + // Then + expect(result).toEqual({ status: "refresh_failed" }); + }, + ); + + it("confirms an update only after reading its resolved target version", async () => { + // Given + fetchMock.mockResolvedValueOnce( + jsonResponse({ + data: [ + { + type: "registry-artifacts", + id: "template", + attributes: { version_spec: "latest", resolved_version: "1.1.0" }, + }, + ], + }), + ); + // When / Then + expect( + await confirmRegistryArtifactAddition("template", "1.1.0"), + ).toMatchObject({ + status: "confirmed", + tenantArtifacts: [ + { normalizedName: "template", resolvedVersion: "1.1.0" }, + ], + }); + }); + + it.each([ + [ + "Add", + () => addRegistryArtifact({ normalizedName: "later-guard" }), + { data: [] }, + { status: "error" }, + 1, + ], + [ + "Remove", + () => removeRegistryArtifact("later-guard"), + { + data: [ + { + type: "registry-artifacts", + id: "later-guard", + attributes: { version_spec: "latest" }, + }, + ], + }, + { status: "refresh_failed" }, + 2, + ], + ])( + "keeps membership unchanged when %s refresh contradicts acceptance", + async (_name, mutate, refreshedArtifacts, expected, calls) => { + // Given + fetchMock + .mockResolvedValueOnce(new Response(null, { status: 204 })) + .mockResolvedValueOnce(jsonResponse(refreshedArtifacts)); + + // When + const result = await mutate(); + + // Then + expect(result).toEqual(expected); + expect(fetchMock).toHaveBeenCalledTimes(calls); + }, + ); +}); diff --git a/ui/actions/registry/registry.ts b/ui/actions/registry/registry.ts new file mode 100644 index 0000000000..a67c83e57f --- /dev/null +++ b/ui/actions/registry/registry.ts @@ -0,0 +1,578 @@ +"use server"; + +import { z } from "zod"; + +import { auth } from "@/auth.config"; +import { apiBaseUrl } from "@/lib"; +import { REGISTRY_ACCESS } from "@/lib/registry/access"; +import { + evaluateRegistryAccess, + evaluateRegistryProviderAccess, +} from "@/lib/registry/access.server"; +import { isRegistryArtifactInstallable } from "@/lib/registry/artifacts"; +import { isActiveRegistryCredential } from "@/lib/registry/credential-task"; +import { + buildRegistryProviderOptions, + type RegistryProviderOption, +} from "@/lib/registry/provider-options"; +import { + REGISTRY_ARTIFACT_ACTION, + REGISTRY_ARTIFACT_REMOVAL, + REGISTRY_BOOTSTRAP_STATE, + REGISTRY_CATALOG, + REGISTRY_CREDENTIAL_ACTION, + REGISTRY_CREDENTIAL_READ, + REGISTRY_ENDPOINT, + REGISTRY_FAILURE, + REGISTRY_SUBMISSION, + type RegistryAddArtifactInput, + type RegistryArtifactRemovalResult, + type RegistryBootstrapResult, + type RegistryBootstrapState, + type RegistryCollectionsResult, + type RegistryCredentialActionResult, + type RegistryCredentialReadResult, + type RegistryCredentialStatus, + type RegistryCredentialSubmitResult, + type RegistryFailureResult, + type RegistryMutationResult, +} from "@/types/registry"; + +import { + adaptRegistryCredentialStatus, + adaptRegistryTenantArtifacts, + classifyRegistryFailure, + classifyRegistryMutationRefusal, + collectCompleteRegistryCatalog, + isRegistryCollection, + parseRegistryArtifactSubmission, + parseRegistryCredentialSubmission, + RegistryCatalogPageError, +} from "./registry.adapter"; + +const REGISTRY_REQUEST_TIMEOUT_MS = 15_000; + +async function getRegistryAccess(): Promise { + const accessToken = (await auth())?.accessToken; + const access = await evaluateRegistryAccess(accessToken); + return access.status === REGISTRY_ACCESS.ELIGIBLE && accessToken?.trim() + ? accessToken + : null; +} + +async function readRegistryResponse( + accessToken: string, + resource: string, + endpoint: (typeof REGISTRY_ENDPOINT)[keyof typeof REGISTRY_ENDPOINT], + credential: RegistryCredentialStatus | null = null, + searchParams?: URLSearchParams, +): Promise { + const url = new URL(`${apiBaseUrl}/registry/${resource}`); + if (searchParams) url.search = searchParams.toString(); + + let response: Response; + try { + response = await fetch(url.toString(), { + cache: "no-store", + signal: AbortSignal.timeout(REGISTRY_REQUEST_TIMEOUT_MS), + headers: { + Accept: "application/vnd.api+json", + Authorization: `Bearer ${accessToken}`, + }, + }); + } catch { + return { status: REGISTRY_FAILURE.ERROR }; + } + if (response.ok) return response; + + return endpoint === REGISTRY_ENDPOINT.PROVIDERS || + endpoint === REGISTRY_ENDPOINT.AVAILABLE_ARTIFACTS + ? classifyDiscoveryFailure(response, endpoint, accessToken, credential) + : classifyRegistryFailure(response, endpoint, credential); +} + +async function readRegistryCredential(accessToken: string) { + const result = await readRegistryResponse( + accessToken, + "credential", + REGISTRY_ENDPOINT.CREDENTIAL, + ); + if (!(result instanceof Response)) return result; + + const credential = adaptRegistryCredentialStatus( + await result.json().catch(() => undefined), + ); + return credential + ? { status: REGISTRY_CREDENTIAL_READ.STATUS, credential } + : { status: REGISTRY_FAILURE.ERROR }; +} + +async function readRegistryTenantArtifacts(accessToken: string) { + const result = await readRegistryResponse( + accessToken, + "artifacts", + REGISTRY_ENDPOINT.MUTATION, + ); + if (!(result instanceof Response)) return result; + + const tenantArtifacts = adaptRegistryTenantArtifacts( + await result.json().catch(() => undefined), + ); + return tenantArtifacts + ? { status: "ready" as const, tenantArtifacts } + : { status: REGISTRY_FAILURE.ERROR }; +} + +async function classifyDiscoveryFailure( + response: Response, + endpoint: + | typeof REGISTRY_ENDPOINT.PROVIDERS + | typeof REGISTRY_ENDPOINT.AVAILABLE_ARTIFACTS, + accessToken: string, + credential: RegistryCredentialStatus | null, +) { + if (response.status === 409 && credential === null) { + const currentCredential = await readRegistryCredential(accessToken); + if (currentCredential.status === REGISTRY_FAILURE.ACCESS_DENIED) { + return currentCredential; + } + credential = + currentCredential.status === REGISTRY_CREDENTIAL_READ.STATUS + ? currentCredential.credential + : null; + } + return classifyRegistryFailure(response, endpoint, credential); +} + +async function readRegistryProviders( + accessToken: string, + credential: RegistryCredentialStatus | null, +) { + const result = await readRegistryResponse( + accessToken, + "providers", + REGISTRY_ENDPOINT.PROVIDERS, + credential, + ); + if (!(result instanceof Response)) return result; + const payload = await result.json().catch(() => undefined); + const metadata = z + .object({ + data: z.array( + z.object({ + id: z.string(), + attributes: z + .object({ + name: z.string().optional(), + logo_url: z.string().nullable().optional(), + }) + .optional(), + }), + ), + }) + .safeParse(payload); + return isRegistryCollection(payload) + ? { + status: "ready" as const, + providers: metadata.success + ? metadata.data.data.map((provider) => ({ + type: provider.id, + label: provider.attributes?.name || provider.id, + ...(provider.attributes?.logo_url + ? { logoUrl: provider.attributes.logo_url } + : {}), + })) + : [], + } + : { status: REGISTRY_FAILURE.ERROR }; +} + +export async function getInstalledRegistryProviderOptions(): Promise< + | { status: "ready"; options: RegistryProviderOption[] } + | { status: "access_denied" | "error" } +> { + const access = (await auth())?.accessToken; + const permission = await evaluateRegistryProviderAccess(access); + if (!access || permission.status !== REGISTRY_ACCESS.ELIGIBLE) + return { status: "access_denied" }; + const [catalog, installed, providers] = await Promise.all([ + readCompleteRegistryCatalog(access, null), + readRegistryTenantArtifacts(access), + readRegistryProviders(access, null), + ]); + if ( + [catalog.status, installed.status, providers.status].some( + (status) => status === REGISTRY_FAILURE.ACCESS_DENIED, + ) + ) + return { status: "access_denied" }; + if ( + catalog.status !== REGISTRY_CATALOG.COMPLETE || + installed.status !== "ready" || + providers.status !== "ready" + ) + return { status: "error" }; + return { + status: "ready", + options: buildRegistryProviderOptions( + catalog.artifacts, + installed.tenantArtifacts, + providers.providers, + ), + }; +} + +async function readCompleteRegistryCatalog( + accessToken: string, + credential: RegistryCredentialStatus | null, +) { + try { + return await collectCompleteRegistryCatalog(async (_page, searchParams) => { + const result = await readRegistryResponse( + accessToken, + "available-artifacts", + REGISTRY_ENDPOINT.AVAILABLE_ARTIFACTS, + credential, + searchParams, + ); + if (!(result instanceof Response)) + throw new RegistryCatalogPageError(result); + return result.json(); + }); + } catch (error) { + return error instanceof RegistryCatalogPageError + ? error.failure + : { status: REGISTRY_FAILURE.ERROR }; + } +} + +async function confirmRegistryMutation( + accessToken: string, + normalizedName: string, + shouldBePresent: boolean, + expectedVersion?: string, +): Promise { + const tenantArtifacts = await readRegistryTenantArtifacts(accessToken); + if (tenantArtifacts.status === REGISTRY_FAILURE.ACCESS_DENIED) + return tenantArtifacts; + if ( + tenantArtifacts.status !== "ready" || + tenantArtifacts.tenantArtifacts.some( + (artifact) => artifact.normalizedName === normalizedName, + ) !== shouldBePresent || + (expectedVersion !== undefined && + tenantArtifacts.tenantArtifacts.find( + (artifact) => artifact.normalizedName === normalizedName, + )?.resolvedVersion !== expectedVersion.trim()) + ) { + return { status: "refresh_failed" }; + } + return { + status: "confirmed", + tenantArtifacts: tenantArtifacts.tenantArtifacts, + }; +} + +function bootstrapReady( + state: RegistryBootstrapState, +): RegistryBootstrapResult { + return { status: REGISTRY_BOOTSTRAP_STATE.READY, state }; +} + +function bootstrapFailure( + failure: RegistryFailureResult, +): RegistryBootstrapResult { + if (failure.status === REGISTRY_FAILURE.ACCESS_DENIED) { + return { status: REGISTRY_FAILURE.ACCESS_DENIED }; + } + return bootstrapReady({ + status: + failure.status === REGISTRY_FAILURE.ONBOARDING + ? REGISTRY_BOOTSTRAP_STATE.ERROR + : failure.status, + }); +} + +export async function getRegistryBootstrap(): Promise { + const access = await getRegistryAccess(); + if (!access) return { status: REGISTRY_FAILURE.ACCESS_DENIED }; + + const credentialRead = await readRegistryCredential(access); + if (credentialRead.status !== REGISTRY_CREDENTIAL_READ.STATUS) { + return bootstrapFailure(credentialRead); + } + const tenantArtifactsRead = await readRegistryTenantArtifacts(access); + if (tenantArtifactsRead.status !== "ready") { + return bootstrapFailure(tenantArtifactsRead); + } + + const { credential } = credentialRead; + const { tenantArtifacts } = tenantArtifactsRead; + if (!isActiveRegistryCredential(credential)) { + return bootstrapReady({ + status: credential.validationPending + ? REGISTRY_BOOTSTRAP_STATE.VALIDATION_PENDING + : REGISTRY_BOOTSTRAP_STATE.ONBOARDING, + credential, + tenantArtifacts, + }); + } + + const catalog = await readCompleteRegistryCatalog(access, credential); + if (catalog.status === REGISTRY_FAILURE.ACCESS_DENIED) { + return { status: REGISTRY_FAILURE.ACCESS_DENIED }; + } + if (catalog.status === REGISTRY_CATALOG.INCOMPLETE) { + return bootstrapReady({ + status: REGISTRY_BOOTSTRAP_STATE.INCOMPLETE, + catalog, + }); + } + if (catalog.status !== REGISTRY_CATALOG.COMPLETE) { + return bootstrapFailure(catalog); + } + + return bootstrapReady({ + status: REGISTRY_BOOTSTRAP_STATE.READY, + credential, + catalog, + tenantArtifacts, + }); +} + +export async function refreshRegistryCredential(): Promise { + const access = await getRegistryAccess(); + if (!access) return { status: REGISTRY_FAILURE.ACCESS_DENIED }; + return readRegistryCredential(access); +} + +export async function refreshRegistryCollections(): Promise { + const access = (await auth())?.accessToken; + const permission = await evaluateRegistryAccess(access); + if (permission.status === REGISTRY_ACCESS.UNKNOWN) + return { status: REGISTRY_FAILURE.ERROR }; + if (permission.status !== REGISTRY_ACCESS.ELIGIBLE || !access?.trim()) + return { status: REGISTRY_FAILURE.ACCESS_DENIED }; + + const catalog = await readCompleteRegistryCatalog(access, null); + if (catalog.status !== REGISTRY_CATALOG.COMPLETE) return catalog; + const tenantArtifactsRead = await readRegistryTenantArtifacts(access); + return tenantArtifactsRead.status === "ready" + ? { + status: REGISTRY_CATALOG.COMPLETE, + catalog, + tenantArtifacts: tenantArtifactsRead.tenantArtifacts, + } + : tenantArtifactsRead; +} + +export async function addRegistryArtifact({ + normalizedName, + versionSpec, +}: RegistryAddArtifactInput): Promise { + const access = await getRegistryAccess(); + if (!access) return { status: REGISTRY_FAILURE.ACCESS_DENIED } as const; + if ( + typeof normalizedName !== "string" || + !normalizedName.trim() || + (versionSpec !== undefined && typeof versionSpec !== "string") + ) + return { status: REGISTRY_FAILURE.ERROR }; + const catalog = await readCompleteRegistryCatalog(access, null); + if (catalog.status !== REGISTRY_CATALOG.COMPLETE) { + return catalog.status === REGISTRY_CATALOG.INCOMPLETE + ? { status: REGISTRY_FAILURE.ERROR } + : catalog; + } + const artifact = catalog.artifacts.find( + (entry) => entry.normalizedName === normalizedName, + ); + if (!artifact || !isRegistryArtifactInstallable(artifact)) + return { + status: "refused", + message: "Only external provider artifacts can be added.", + }; + const selectedVersion = versionSpec?.trim() || "latest"; + + let response: Response; + try { + response = await fetch(`${apiBaseUrl}/registry/artifacts`, { + method: "POST", + cache: "no-store", + signal: AbortSignal.timeout(REGISTRY_REQUEST_TIMEOUT_MS), + headers: { + Accept: "application/vnd.api+json", + "Content-Type": "application/vnd.api+json", + Authorization: `Bearer ${access}`, + }, + body: JSON.stringify({ + data: { + type: "registry-artifacts", + attributes: { + normalized_name: normalizedName, + version_spec: selectedVersion, + }, + }, + }), + }); + } catch { + return { status: REGISTRY_FAILURE.ERROR } as const; + } + if (response.status === 401 || response.status === 403) { + return { status: REGISTRY_FAILURE.ACCESS_DENIED } as const; + } + if (response.status === 409) { + return { status: REGISTRY_FAILURE.ONBOARDING }; + } + if (!response.ok) { + return ( + (await classifyRegistryMutationRefusal(response)) ?? { + status: REGISTRY_FAILURE.ERROR, + } + ); + } + + const submission = await parseRegistryArtifactSubmission(response); + return submission.status === REGISTRY_SUBMISSION.PENDING + ? { status: REGISTRY_ARTIFACT_ACTION.SUBMITTED, taskId: submission.taskId } + : { status: REGISTRY_FAILURE.ERROR }; +} + +export async function confirmRegistryArtifactAddition( + normalizedName: string, + expectedVersion?: string, +): Promise { + const access = await getRegistryAccess(); + if (!access) return { status: REGISTRY_FAILURE.ACCESS_DENIED }; + if ( + expectedVersion !== undefined && + (typeof expectedVersion !== "string" || !expectedVersion.trim()) + ) { + return { status: REGISTRY_FAILURE.ERROR }; + } + return confirmRegistryMutation(access, normalizedName, true, expectedVersion); +} + +export async function removeRegistryArtifact( + normalizedName: string, +): Promise { + const access = await getRegistryAccess(); + if (!access) return { status: REGISTRY_FAILURE.ACCESS_DENIED }; + + let response: Response; + try { + response = await fetch( + `${apiBaseUrl}/registry/artifacts/${encodeURIComponent(normalizedName)}`, + { + method: "DELETE", + cache: "no-store", + signal: AbortSignal.timeout(REGISTRY_REQUEST_TIMEOUT_MS), + headers: { + Accept: "application/vnd.api+json", + Authorization: `Bearer ${access}`, + }, + }, + ); + } catch { + return { status: REGISTRY_FAILURE.ERROR }; + } + if (response.status === 401 || response.status === 403) { + return { status: REGISTRY_FAILURE.ACCESS_DENIED }; + } + if (response.status === 409) { + return { status: REGISTRY_ARTIFACT_REMOVAL.IN_USE }; + } + if (!response.ok) return { status: REGISTRY_FAILURE.ERROR }; + + return confirmRegistryMutation(access, normalizedName, false); +} + +export async function submitRegistryCredential( + key: string, +): Promise { + const access = await getRegistryAccess(); + if (!access) return { status: REGISTRY_FAILURE.ACCESS_DENIED }; + + const priorCredential = await readRegistryCredential(access); + if (priorCredential.status !== REGISTRY_CREDENTIAL_READ.STATUS) { + return priorCredential; + } + + let response: Response; + try { + response = await fetch(`${apiBaseUrl}/registry/credential`, { + method: "POST", + cache: "no-store", + signal: AbortSignal.timeout(REGISTRY_REQUEST_TIMEOUT_MS), + headers: { + Accept: "application/vnd.api+json", + "Content-Type": "application/vnd.api+json", + Authorization: `Bearer ${access}`, + }, + body: JSON.stringify({ + data: { + type: "registry-credentials", + attributes: { api_key: key.trim() }, + }, + }), + }); + } catch { + return { status: REGISTRY_FAILURE.ERROR }; + } + if (response.status === 401 || response.status === 403) { + return { status: REGISTRY_FAILURE.ACCESS_DENIED }; + } + + // The task settles client-side through the task watcher; this action only + // hands back the verified task identity so the caller can watch it. + const submission = await parseRegistryCredentialSubmission(response); + if (submission.status !== REGISTRY_SUBMISSION.PENDING) { + return priorCredential.credential.configured + ? { + status: REGISTRY_CREDENTIAL_ACTION.REPLACEMENT_FAILED, + credential: priorCredential.credential, + } + : { status: REGISTRY_FAILURE.ERROR }; + } + + return { + status: REGISTRY_CREDENTIAL_ACTION.SUBMITTED, + taskId: submission.taskId, + priorConfigured: priorCredential.credential.configured, + }; +} + +export async function disconnectRegistryCredential(): Promise { + const access = await getRegistryAccess(); + if (!access) return { status: REGISTRY_FAILURE.ACCESS_DENIED }; + + let response: Response; + try { + response = await fetch(`${apiBaseUrl}/registry/credential`, { + method: "DELETE", + cache: "no-store", + signal: AbortSignal.timeout(REGISTRY_REQUEST_TIMEOUT_MS), + headers: { + Accept: "application/vnd.api+json", + Authorization: `Bearer ${access}`, + }, + }); + } catch { + return { status: REGISTRY_FAILURE.ERROR }; + } + if (response.status === 401 || response.status === 403) { + return { status: REGISTRY_FAILURE.ACCESS_DENIED }; + } + + const credential = await readRegistryCredential(access); + const tenantArtifacts = await readRegistryTenantArtifacts(access); + if (credential.status !== REGISTRY_CREDENTIAL_READ.STATUS) return credential; + if (tenantArtifacts.status !== "ready") return tenantArtifacts; + if (!response.ok) return { status: REGISTRY_FAILURE.ERROR }; + + return { + status: REGISTRY_CREDENTIAL_ACTION.DISCONNECTED, + credential: credential.credential, + tenantArtifacts: tenantArtifacts.tenantArtifacts, + }; +} diff --git a/ui/actions/roles/roles.test.ts b/ui/actions/roles/roles.test.ts index 3b253cce58..6c79579b99 100644 --- a/ui/actions/roles/roles.test.ts +++ b/ui/actions/roles/roles.test.ts @@ -50,6 +50,7 @@ const makeRoleFormData = () => { formData.set("manage_scans", "false"); formData.set("manage_alerts", "true"); formData.set("manage_lighthouse_ai_configuration", "true"); + formData.set("manage_registry", "true"); formData.set("unlimited_visibility", "false"); return formData; }; @@ -73,6 +74,36 @@ describe("role actions", () => { vi.unstubAllEnvs(); }); + it("includes manage_registry when creating and updating a role in Prowler Cloud", async () => { + // Given + vi.stubEnv("UI_CLOUD_ENABLED", "true"); + + // When + await addRole(makeRoleFormData()); + const createAttributes = lastRequestBody().data.attributes; + await updateRole(makeRoleFormData(), "role-1"); + const updateAttributes = lastRequestBody().data.attributes; + + // Then + expect(createAttributes.manage_registry).toBe(true); + expect(updateAttributes.manage_registry).toBe(true); + }); + + it("omits manage_registry when creating and updating a role outside Prowler Cloud", async () => { + // Given + vi.stubEnv("UI_CLOUD_ENABLED", "false"); + + // When + await addRole(makeRoleFormData()); + const createAttributes = lastRequestBody().data.attributes; + await updateRole(makeRoleFormData(), "role-1"); + const updateAttributes = lastRequestBody().data.attributes; + + // Then + expect(createAttributes).not.toHaveProperty("manage_registry"); + expect(updateAttributes).not.toHaveProperty("manage_registry"); + }); + it("includes manage_alerts when creating a role in Prowler Cloud", async () => { // Given vi.stubEnv("UI_CLOUD_ENABLED", "true"); diff --git a/ui/actions/roles/roles.ts b/ui/actions/roles/roles.ts index 972d6d12e8..00302165e9 100644 --- a/ui/actions/roles/roles.ts +++ b/ui/actions/roles/roles.ts @@ -116,6 +116,8 @@ export const addRole = async (formData: FormData) => { formData.get("manage_alerts") === "true"; payload.data.attributes.manage_lighthouse_ai_configuration = formData.get("manage_lighthouse_ai_configuration") === "true"; + payload.data.attributes.manage_registry = + formData.get("manage_registry") === "true"; } // Add provider groups relationships only if there are items @@ -175,6 +177,8 @@ export const updateRole = async (formData: FormData, roleId: string) => { formData.get("manage_alerts") === "true"; payload.data.attributes.manage_lighthouse_ai_configuration = formData.get("manage_lighthouse_ai_configuration") === "true"; + payload.data.attributes.manage_registry = + formData.get("manage_registry") === "true"; } // Add provider groups relationships only if there are items diff --git a/ui/app/(prowler)/layout.tsx b/ui/app/(prowler)/layout.tsx index 4d0d06e092..a753c99c1a 100644 --- a/ui/app/(prowler)/layout.tsx +++ b/ui/app/(prowler)/layout.tsx @@ -6,6 +6,7 @@ import { ReactNode, Suspense } from "react"; import { getProviders } from "@/actions/providers"; import { getScansByState } from "@/actions/scans/scans"; +import { auth } from "@/auth.config"; import MainLayout from "@/components/layout/main-layout/main-layout"; import { OnboardingCheckpointWatcher, @@ -20,6 +21,8 @@ import { GlobalSidePanel } from "@/components/side-panel"; import { FeedbackSurvey } from "@/components/survey/feedback-survey"; import { fontMono, fontSans } from "@/config/fonts"; import { siteConfig } from "@/config/site"; +import { REGISTRY_ACCESS } from "@/lib/registry/access"; +import { evaluateRegistryAccess } from "@/lib/registry/access.server"; import { isCloud } from "@/lib/shared/env"; import { cn } from "@/lib/utils"; import { StoreInitializer } from "@/store/ui/store-initializer"; @@ -56,6 +59,13 @@ export default async function RootLayout({ // Skip Cloud-only onboarding fetches and orchestrators in OSS. const cloudEnabled = isCloud(); + // One-time server-side Registry gate per request: only an ELIGIBLE answer + // shows the sidebar entry; UNKNOWN and INELIGIBLE both hide it. Started + // here so it resolves in parallel with the Cloud onboarding fetches. + const registryAccessPromise = auth().then((session) => + evaluateRegistryAccess(session?.accessToken), + ); + // Fail-open: unknown scan state is treated as "has data" so the banner never blocks // progression on a fetch error. let hasCompletedScan = true; @@ -78,6 +88,9 @@ export default async function RootLayout({ : undefined; } + const registryEligible = + (await registryAccessPromise).status === REGISTRY_ACCESS.ELIGIBLE; + return ( @@ -98,7 +111,9 @@ export default async function RootLayout({ {/* Store uses boolean; gate receives tri-state to fail open on fetch errors. */} - + {cloudEnabled && ( <> diff --git a/ui/app/(prowler)/registry/page.tsx b/ui/app/(prowler)/registry/page.tsx new file mode 100644 index 0000000000..d5e3ab63de --- /dev/null +++ b/ui/app/(prowler)/registry/page.tsx @@ -0,0 +1,26 @@ +import { redirect } from "next/navigation"; + +import { getRegistryBootstrap } from "@/actions/registry/registry"; +import { RegistryExplorer } from "@/components/registry/registry-explorer"; +import { ContentLayout } from "@/components/shadcn/content-layout/content-layout"; +import { getRegistryPresentation } from "@/lib/registry/presentation"; +import { readEnv } from "@/lib/runtime-env"; +import { REGISTRY_FAILURE } from "@/types/registry"; + +export const dynamic = "force-dynamic"; + +export default async function RegistryPage() { + const bootstrap = await getRegistryBootstrap(); + if (bootstrap.status === REGISTRY_FAILURE.ACCESS_DENIED) redirect("/profile"); + + return ( + + + + ); +} diff --git a/ui/auth.config.test.ts b/ui/auth.config.test.ts index 5815f31496..0bb714cc93 100644 --- a/ui/auth.config.test.ts +++ b/ui/auth.config.test.ts @@ -39,6 +39,7 @@ const RESTRICTED_PERMISSIONS: RolePermissionAttributes = { manage_integrations: false, manage_billing: false, manage_alerts: false, + manage_registry: false, manage_lighthouse_ai_configuration: false, unlimited_visibility: false, }; @@ -46,6 +47,7 @@ const RESTRICTED_PERMISSIONS: RolePermissionAttributes = { const ELEVATED_PERMISSIONS: RolePermissionAttributes = { ...RESTRICTED_PERMISSIONS, manage_users: true, + manage_registry: true, manage_scans: true, }; @@ -174,6 +176,25 @@ describe("authConfig JWT callback", () => { }); }); + it("should default manage_registry to false when a sign-in user omits it", async () => { + // Given + const jwtCallback = authConfig.callbacks?.jwt; + if (!jwtCallback) throw new Error("JWT callback is not configured"); + + // When + const result = await jwtCallback({ + token: {}, + account: {} as Parameters[0]["account"], + user: { + accessToken: "access-token", + refreshToken: "refresh-token", + } as Parameters[0]["user"], + }); + + // Then + expect(result.user?.permissions.manage_registry).toBe(false); + }); + it("should report a tenant switch failure while preserving the current session", async () => { // Given vi.spyOn(console, "warn").mockImplementation(() => undefined); diff --git a/ui/auth.config.ts b/ui/auth.config.ts index c741534d24..f3ba8bbe18 100644 --- a/ui/auth.config.ts +++ b/ui/auth.config.ts @@ -61,6 +61,7 @@ const DEFAULT_PERMISSIONS: RolePermissionAttributes = { manage_billing: false, manage_alerts: false, manage_lighthouse_ai_configuration: false, + manage_registry: false, unlimited_visibility: false, }; diff --git a/ui/changelog.d/registry-private-cloud.added.md b/ui/changelog.d/registry-private-cloud.added.md new file mode 100644 index 0000000000..0f5b9fb6d3 --- /dev/null +++ b/ui/changelog.d/registry-private-cloud.added.md @@ -0,0 +1 @@ +Registry marketplace and external provider onboarding for Private Cloud, with permission-based access independent of billing, confirmed artifact installation, schema-driven credentials, connection checks, and scan launch diff --git a/ui/components/layout/app-sidebar/app-sidebar-content.test.tsx b/ui/components/layout/app-sidebar/app-sidebar-content.test.tsx index 911068b1cc..8e33a31b86 100644 --- a/ui/components/layout/app-sidebar/app-sidebar-content.test.tsx +++ b/ui/components/layout/app-sidebar/app-sidebar-content.test.tsx @@ -2,6 +2,7 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useUIStore } from "@/store/ui/store"; import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; import { AppSidebarContent } from "./app-sidebar-content"; @@ -57,6 +58,7 @@ describe("AppSidebarContent", () => { openCloudUpgradeMock.mockClear(); openLaunchScanModalMock.mockClear(); useAppSidebarMode.setState({ mode: APP_SIDEBAR_MODE.BROWSE }); + useUIStore.setState({ registryEligible: false }); }); afterEach(() => { @@ -89,6 +91,32 @@ describe("AppSidebarContent", () => { expect(screen.getAllByText("Cloud").length).toBeGreaterThan(0); }); + it("shows Registry navigation when the server marked this request eligible", () => { + // Given + vi.stubEnv("UI_CLOUD_ENABLED", "true"); + useUIStore.setState({ registryEligible: true }); + + // When + render(); + + // Then + expect(screen.getByRole("link", { name: /Registry/ })).toHaveAttribute( + "href", + "/registry", + ); + }); + + it("hides Registry navigation without a server eligibility decision", () => { + // Given / When + vi.stubEnv("UI_CLOUD_ENABLED", "true"); + render(); + + // Then + expect( + screen.queryByRole("link", { name: /Registry/ }), + ).not.toBeInTheDocument(); + }); + it("keeps the existing Lighthouse chat sidebar in Cloud Chat mode", () => { // Given vi.stubEnv("UI_CLOUD_ENABLED", "true"); diff --git a/ui/components/layout/app-sidebar/app-sidebar-content.tsx b/ui/components/layout/app-sidebar/app-sidebar-content.tsx index 8d5e869793..f157e2f7ae 100644 --- a/ui/components/layout/app-sidebar/app-sidebar-content.tsx +++ b/ui/components/layout/app-sidebar/app-sidebar-content.tsx @@ -8,6 +8,7 @@ import { ProwlerBrand } from "@/components/icons"; import { useAuth } from "@/hooks"; import { useRuntimeConfig } from "@/hooks/use-runtime-config"; import { isCloud } from "@/lib/shared/env"; +import { useUIStore } from "@/store/ui/store"; import { useAppSidebarMode } from "./app-sidebar-mode-store"; import { AppSidebarModeToggle } from "./app-sidebar-mode-toggle"; @@ -24,6 +25,8 @@ interface AppSidebarContentProps { export function AppSidebarContent({ onSelect }: AppSidebarContentProps) { const pathname = usePathname(); const { permissions } = useAuth(); + // One-time server decision per request, seeded by the root layout. + const registryEligible = useUIStore((state) => state.registryEligible); const { apiDocsUrl, cloudBillingEnabled } = useRuntimeConfig(); const mode = useAppSidebarMode((state) => state.mode); const isCloudEnvironment = isCloud(); @@ -31,6 +34,7 @@ export function AppSidebarContent({ onSelect }: AppSidebarContentProps) { pathname, apiDocsUrl, cloudBillingEnabled, + registryEligible, permissions, }); const showChat = isCloudEnvironment && mode === APP_SIDEBAR_MODE.CHAT; diff --git a/ui/components/layout/app-sidebar/navigation-config.ts b/ui/components/layout/app-sidebar/navigation-config.ts index 05ff31abae..aa0904d3b9 100644 --- a/ui/components/layout/app-sidebar/navigation-config.ts +++ b/ui/components/layout/app-sidebar/navigation-config.ts @@ -5,6 +5,7 @@ import { GitBranch, LayoutGrid, MessageCircleQuestion, + Package, Settings, ShieldCheck, SquareChartGantt, @@ -32,6 +33,7 @@ interface NavigationConfigOptions { pathname: string; apiDocsUrl?: string | null; cloudBillingEnabled?: boolean; + registryEligible?: boolean; permissions?: RolePermissionAttributes; } @@ -108,6 +110,7 @@ export function getNavigationConfig({ pathname, apiDocsUrl = null, cloudBillingEnabled = false, + registryEligible = false, permissions, }: NavigationConfigOptions): NavigationSection[] { const isCloudEnvironment = isCloud(); @@ -180,6 +183,18 @@ export function getNavigationConfig({ icon: Warehouse, active: isRouteActive(pathname, "/resources"), }, + ...(registryEligible + ? [ + { + kind: NAVIGATION_ITEM_KIND.LINK, + href: "/registry", + label: "Registry", + icon: Package, + active: isRouteActive(pathname, "/registry"), + highlight: true, + } as const, + ] + : []), ], }, { diff --git a/ui/components/providers/radio-group-provider.test.tsx b/ui/components/providers/radio-group-provider.test.tsx new file mode 100644 index 0000000000..34987d5a41 --- /dev/null +++ b/ui/components/providers/radio-group-provider.test.tsx @@ -0,0 +1,134 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useForm } from "react-hook-form"; +import { describe, expect, it } from "vitest"; + +import type { RegistryProviderOption } from "@/lib/registry/provider-options"; +import type { AddProviderFormValues } from "@/types/formSchemas"; + +import { RadioGroupProvider } from "./radio-group-provider"; + +function Selector({ + registryOptions = [{ type: "acme", label: "Acme Cloud" }], +}: { + registryOptions?: RegistryProviderOption[]; +}) { + const form = useForm(); + return ( + + ); +} + +describe("provider selector", () => { + it("preserves selected provider across tabs and supports searching by type", async () => { + // Given + const user = userEvent.setup(); + render( + , + ); + expect(screen.getByRole("tab", { name: "All providers" })).toHaveAttribute( + "aria-selected", + "true", + ); + await user.click( + screen.getByRole("option", { name: /Amazon Web Services/ }), + ); + + // When + await user.click(screen.getByRole("tab", { name: "Registry" })); + await user.type( + screen.getByRole("textbox", { name: "Search providers" }), + " ACME_SLUG ", + ); + + // Then + expect( + screen.getByRole("option", { name: /Acme Cloud Registry/ }), + ).toBeVisible(); + + // When + await user.click(screen.getByRole("button", { name: "Clear search" })); + await user.click(screen.getByRole("tab", { name: "All providers" })); + + // Then + expect( + screen.getByRole("option", { name: /Amazon Web Services/ }), + ).toHaveAttribute("aria-selected", "true"); + }); + + it("keeps both tabs available when no Registry providers are installed", async () => { + // Given + const user = userEvent.setup(); + const { rerender } = render(); + + // When + await user.click(screen.getByRole("tab", { name: "Registry" })); + + // Then + expect(screen.getByText("No Registry providers available.")).toBeVisible(); + expect(screen.getByRole("tab", { name: "All providers" })).toBeEnabled(); + + // When / Then: discovery can refresh the installed options. + rerender(); + expect( + screen.getByRole("option", { name: /Acme Cloud Registry/ }), + ).toBeVisible(); + rerender(); + expect(screen.queryByRole("option")).not.toBeInTheDocument(); + expect(screen.getByText("No Registry providers available.")).toBeVisible(); + }); + + it("filters Registry providers and preserves search across tabs", async () => { + // Given + const user = userEvent.setup(); + render(); + + // When + await user.click(screen.getByRole("tab", { name: "Registry" })); + + // Then + expect( + screen.getByRole("option", { name: /Acme Cloud Registry/ }), + ).toBeVisible(); + expect( + screen.queryByRole("option", { name: /Amazon Web Services/ }), + ).not.toBeInTheDocument(); + + // When + await user.type( + screen.getByRole("textbox", { name: "Search providers" }), + "amazon", + ); + expect( + screen.getByText('No providers found matching "amazon"'), + ).toBeVisible(); + await user.click(screen.getByRole("tab", { name: "All providers" })); + + // Then + expect( + screen.getByRole("textbox", { name: "Search providers" }), + ).toHaveValue("amazon"); + expect( + screen.getByRole("option", { name: /Amazon Web Services/ }), + ).toBeVisible(); + expect( + screen.queryByRole("option", { name: /Acme Cloud Registry/ }), + ).not.toBeInTheDocument(); + }); + + it("adds Registry-labelled providers alongside the incorporated options", () => { + render(); + expect( + screen.getByRole("option", { name: /Acme Cloud Registry/ }), + ).toBeVisible(); + expect( + screen.getByRole("option", { name: /Amazon Web Services/ }), + ).toBeVisible(); + }); +}); diff --git a/ui/components/providers/radio-group-provider.tsx b/ui/components/providers/radio-group-provider.tsx index de8d941f3a..0da4bf2d83 100644 --- a/ui/components/providers/radio-group-provider.tsx +++ b/ui/components/providers/radio-group-provider.tsx @@ -2,117 +2,38 @@ import { FC, useState } from "react"; import { Control, Controller } from "react-hook-form"; -import { z } from "zod"; - -import { SearchInput } from "@/components/shadcn"; -import { FormMessage } from "@/components/shadcn/form"; -import { cn } from "@/lib/utils"; -import { addProviderFormSchema } from "@/types"; import { - AlibabaCloudProviderBadge, - AWSProviderBadge, - AzureProviderBadge, - CloudflareProviderBadge, - GCPProviderBadge, - GitHubProviderBadge, - GoogleWorkspaceProviderBadge, - IacProviderBadge, - ImageProviderBadge, - KS8ProviderBadge, - M365ProviderBadge, - MongoDBAtlasProviderBadge, - OktaProviderBadge, - OpenStackProviderBadge, - OracleCloudProviderBadge, - VercelProviderBadge, -} from "../icons/providers-badge"; + ProviderTypeIcon, + PROVIDER_TYPE_DATA, +} from "@/components/icons/providers-badge/provider-type-icon"; +import { Badge, SearchInput } from "@/components/shadcn"; +import { + Avatar, + AvatarFallback, + AvatarImage, +} from "@/components/shadcn/avatar"; +import { FormMessage } from "@/components/shadcn/form"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/components/shadcn/tabs/tabs"; +import type { RegistryProviderOption } from "@/lib/registry/provider-options"; +import { cn } from "@/lib/utils"; +import type { AddProviderFormValues } from "@/types/formSchemas"; -const PROVIDERS = [ - { - value: "aws", - label: "Amazon Web Services", - badge: AWSProviderBadge, - }, - { - value: "gcp", - label: "Google Cloud Platform", - badge: GCPProviderBadge, - }, - { - value: "azure", - label: "Microsoft Azure", - badge: AzureProviderBadge, - }, - { - value: "m365", - label: "Microsoft 365", - badge: M365ProviderBadge, - }, - { - value: "mongodbatlas", - label: "MongoDB Atlas", - badge: MongoDBAtlasProviderBadge, - }, - { - value: "kubernetes", - label: "Kubernetes", - badge: KS8ProviderBadge, - }, - { - value: "github", - label: "GitHub", - badge: GitHubProviderBadge, - }, - { - value: "googleworkspace", - label: "Google Workspace", - badge: GoogleWorkspaceProviderBadge, - }, - { - value: "iac", - label: "Infrastructure as Code", - badge: IacProviderBadge, - }, - { - value: "image", - label: "Container Registry", - badge: ImageProviderBadge, - }, - { - value: "oraclecloud", - label: "Oracle Cloud Infrastructure", - badge: OracleCloudProviderBadge, - }, - { - value: "alibabacloud", - label: "Alibaba Cloud", - badge: AlibabaCloudProviderBadge, - }, - { - value: "cloudflare", - label: "Cloudflare", - badge: CloudflareProviderBadge, - }, - { - value: "openstack", - label: "OpenStack", - badge: OpenStackProviderBadge, - }, - { - value: "vercel", - label: "Vercel", - badge: VercelProviderBadge, - }, - { - value: "okta", - label: "Okta", - badge: OktaProviderBadge, - }, -] as const; +const PROVIDERS = Object.entries(PROVIDER_TYPE_DATA).map( + ([value, { label }]) => ({ value, label }), +); + +const PROVIDER_TAB = { ALL: "all", REGISTRY: "registry" } as const; +type ProviderTab = (typeof PROVIDER_TAB)[keyof typeof PROVIDER_TAB]; interface RadioGroupProviderProps { - control: Control>; + control: Control; + registryOptions?: RegistryProviderOption[]; isInvalid: boolean; errorMessage?: string; } @@ -121,25 +42,53 @@ export const RadioGroupProvider: FC = ({ control, isInvalid, errorMessage, + registryOptions = [], }) => { const [searchTerm, setSearchTerm] = useState(""); + const [activeTab, setActiveTab] = useState(PROVIDER_TAB.ALL); + const options = [ + ...PROVIDERS.map((provider) => ({ + value: provider.value as string, + label: provider.label as string, + registry: false, + logoUrl: undefined as string | undefined, + })), + ...registryOptions.map((provider) => ({ + value: provider.type, + label: provider.label, + registry: true, + logoUrl: provider.logoUrl, + })), + ]; + const tabProviders = + activeTab === PROVIDER_TAB.REGISTRY + ? options.filter((provider) => provider.registry) + : options; const lowerSearch = searchTerm.trim().toLowerCase(); const filteredProviders = lowerSearch - ? PROVIDERS.filter( + ? tabProviders.filter( (provider) => provider.label.toLowerCase().includes(lowerSearch) || provider.value.toLowerCase().includes(lowerSearch), ) - : PROVIDERS; + : tabProviders; return ( ( -
-
+ setActiveTab(value as ProviderTab)} + > + + All providers + Registry + +
= ({ />
-
+
= ({ > {filteredProviders.length > 0 ? ( filteredProviders.map((provider) => { - const BadgeComponent = provider.badge; const isSelected = field.value === provider.value; return ( @@ -165,6 +113,7 @@ export const RadioGroupProvider: FC = ({ key={provider.value} type="button" role="option" + aria-label={`${provider.label}${provider.registry ? " Registry" : ""}`} aria-selected={isSelected} onClick={() => field.onChange(provider.value)} className={cn( @@ -183,28 +132,54 @@ export const RadioGroupProvider: FC = ({
- + {provider.registry ? ( + + + + + + + ) : ( + + )} {provider.label} + {provider.registry && ( + Registry + )}
); }) ) : (

- No providers found matching "{searchTerm}" + {lowerSearch ? ( + <>No providers found matching "{searchTerm}" + ) : ( + "No Registry providers available." + )}

)}
-
+ {errorMessage && ( {errorMessage} )} -
+ )} /> ); diff --git a/ui/components/providers/table/column-providers.tsx b/ui/components/providers/table/column-providers.tsx index 78c59ac764..548dda2e82 100644 --- a/ui/components/providers/table/column-providers.tsx +++ b/ui/components/providers/table/column-providers.tsx @@ -251,7 +251,7 @@ export function getColumnProviders( entityId={provider.attributes.uid} nameAction={ provider.attributes.is_dynamic ? ( - Custom + Registry ) : undefined } /> diff --git a/ui/components/providers/table/data-table-row-actions.test.tsx b/ui/components/providers/table/data-table-row-actions.test.tsx index 5fa6f3f409..c65863abd2 100644 --- a/ui/components/providers/table/data-table-row-actions.test.tsx +++ b/ui/components/providers/table/data-table-row-actions.test.tsx @@ -1,3 +1,17 @@ +vi.mock("@/actions/providers/registry-provider", () => ({ + addRegistryProvider: vi.fn(), +})); +vi.mock("@/actions/registry/registry", () => ({ + getInstalledRegistryProviderOptions: vi + .fn() + .mockResolvedValue({ status: "access_denied" }), +})); +vi.mock("@/actions/providers/provider-schemas", () => ({ + getProviderSchemas: vi.fn(), +})); +vi.mock("@/actions/providers/dynamic-provider-credentials", () => ({ + saveDynamicProviderCredentials: vi.fn(), +})); import { Row } from "@tanstack/react-table"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; @@ -327,7 +341,7 @@ describe("DataTableRowActions", () => { expect(screen.queryByText("Update Credentials")).not.toBeInTheDocument(); }); - it("allows rename/delete and operational actions for a dynamic provider but hides credential management", async () => { + it("allows credential editing and operational actions for a dynamic provider", async () => { // Given a dynamic provider outside the configurable set, with the advanced // schedule capability enabled (so Edit Scan Schedule can show). const user = userEvent.setup(); @@ -354,9 +368,9 @@ describe("DataTableRowActions", () => { expect(screen.getByText("Test Connection")).toBeInTheDocument(); expect(screen.getByText("View Scan Jobs")).toBeInTheDocument(); expect(screen.getByText("Edit Scan Schedule")).toBeInTheDocument(); - // ...but credential management is hidden (no bespoke wizard for dynamic types) + // Existing dynamic accounts use the same wizard with schema-based credentials. expect(screen.queryByText("Add Credentials")).not.toBeInTheDocument(); - expect(screen.queryByText("Update Credentials")).not.toBeInTheDocument(); + expect(screen.getByText("Update Credentials")).toBeInTheDocument(); }); it("navigates to the provider-filtered scan jobs from View Scan Jobs", async () => { diff --git a/ui/components/providers/table/data-table-row-actions.tsx b/ui/components/providers/table/data-table-row-actions.tsx index 1866a44f73..bcbbd8e583 100644 --- a/ui/components/providers/table/data-table-row-actions.tsx +++ b/ui/components/providers/table/data-table-row-actions.tsx @@ -52,7 +52,6 @@ import { OrgFlowType, } from "@/types/organizations"; import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard"; -import { isConfigurableProvider } from "@/types/providers"; import { isProvidersOrganizationRow, PROVIDERS_GROUP_KIND, @@ -355,8 +354,7 @@ export function DataTableRowActions({ const provider = isOrganizationRow ? null : rowData; const providerId = provider?.id ?? ""; const providerType = provider?.attributes.provider ?? ""; - // Only predefined providers can manage credentials from the UI - const canManageCredentials = isConfigurableProvider(providerType); + const canManageCredentials = Boolean(providerType); const providerUid = provider?.attributes.uid ?? ""; const providerAlias = provider?.attributes.alias ?? null; const providerSecretId = provider?.relationships.secret.data?.id ?? null; diff --git a/ui/components/providers/wizard/provider-wizard-modal.test.tsx b/ui/components/providers/wizard/provider-wizard-modal.test.tsx new file mode 100644 index 0000000000..40414c3688 --- /dev/null +++ b/ui/components/providers/wizard/provider-wizard-modal.test.tsx @@ -0,0 +1,230 @@ +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { Toaster } from "@/components/shadcn/toast/Toaster"; +import { resetToasts } from "@/components/shadcn/toast/use-toast"; +import { useProviderWizardStore } from "@/store/provider-wizard/store"; + +import { ProviderWizardModal } from "./provider-wizard-modal"; + +const { addRegistryProvider, getInstalledRegistryProviderOptions } = vi.hoisted( + () => ({ + addRegistryProvider: vi.fn(), + getInstalledRegistryProviderOptions: vi.fn(), + }), +); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }), +})); +vi.mock("@/actions/providers/providers", () => ({ addProvider: vi.fn() })); +vi.mock("@/actions/providers/registry-provider", () => ({ + addRegistryProvider, +})); +vi.mock("@/actions/registry/registry", () => ({ + getInstalledRegistryProviderOptions, +})); +vi.mock( + "@/components/providers/workflow/forms", + async () => import("../workflow/forms/connect-account-form"), +); +vi.mock("@/hooks/use-scroll-hint", () => ({ + useScrollHint: () => ({ showScrollHint: false }), +})); +vi.mock("@/lib/tours/use-driver-tour", () => ({ + advanceActiveTour: vi.fn(), + endActiveTour: vi.fn(), +})); +vi.mock("./steps/credentials-step", () => ({ + CredentialsStep: () =>

Credential details

, +})); +vi.mock("./steps/test-connection-step", () => ({ + TestConnectionStep: () => null, +})); +vi.mock("./steps/launch-step", () => ({ LaunchStep: () => null })); +vi.mock("../organizations/azure-org-setup-form", () => ({ + AzureOrgSetupForm: () => null, +})); +vi.mock("../organizations/gcp-org-setup-form", () => ({ + GcpOrgSetupForm: () => null, +})); +vi.mock("../organizations/org-setup-form", () => ({ + OrgSetupForm: () => null, +})); +vi.mock("../organizations/org-account-selection", () => ({ + OrgAccountSelection: () => null, +})); +vi.mock("../organizations/org-launch-scan", () => ({ + OrgLaunchScan: () => null, +})); + +const createdAccount = { + data: { + id: "account", + attributes: { provider: "acme", uid: "acme-account", alias: null }, + }, +}; + +async function enterAccountDetails() { + const user = userEvent.setup(); + render( + <> + + + , + ); + await user.click( + await screen.findByRole("option", { name: "Acme Cloud Registry" }), + ); + await user.type( + screen.getByRole("textbox", { name: "Provider UID" }), + "acme-account", + ); + await waitFor(() => + expect(screen.getByRole("button", { name: "Next" })).toBeEnabled(), + ); + return user; +} + +describe("provider wizard account creation", () => { + beforeEach(() => { + useProviderWizardStore.getState().reset(); + resetToasts(); + getInstalledRegistryProviderOptions.mockResolvedValue({ + status: "ready", + options: [{ type: "acme", label: "Acme Cloud" }], + }); + }); + + it("shows progress, blocks repeat clicks, and advances after creation", async () => { + // Given + let resolveCreation!: (value: typeof createdAccount) => void; + addRegistryProvider.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveCreation = resolve; + }), + ); + const user = await enterAccountDetails(); + + // When + await user.click(screen.getByRole("button", { name: "Next" })); + + // Then + const pending = await screen.findByRole("button", { + name: "Creating provider...", + }); + expect(pending).toBeDisabled(); + expect(pending).toHaveAttribute("aria-busy", "true"); + expect(screen.getByRole("button", { name: "Back" })).toBeDisabled(); + await user.dblClick(pending); + expect(addRegistryProvider).toHaveBeenCalledOnce(); + + // When / Then + await act(async () => resolveCreation(createdAccount)); + expect(await screen.findByText("Credential details")).toBeVisible(); + }); + + it("restores Next after a failed creation and retries the same account", async () => { + // Given + const failure = { errors: [{ detail: "Creation failed. Try again." }] }; + let resolveCreation!: (value: typeof failure) => void; + addRegistryProvider + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveCreation = resolve; + }), + ) + .mockResolvedValueOnce(createdAccount); + const user = await enterAccountDetails(); + + // When + await user.click(screen.getByRole("button", { name: "Next" })); + await screen.findByRole("button", { name: "Creating provider..." }); + await act(async () => resolveCreation(failure)); + + // Then + expect(await screen.findByText(failure.errors[0].detail)).toBeVisible(); + const next = screen.getByRole("button", { name: "Next" }); + await waitFor(() => expect(next).toBeEnabled()); + expect(next).not.toHaveAttribute("aria-busy", "true"); + expect(screen.getByRole("button", { name: "Back" })).toBeEnabled(); + expect(screen.getByRole("textbox", { name: "Provider UID" })).toHaveValue( + "acme-account", + ); + + // When / Then + await user.click(next); + expect(await screen.findByText("Credential details")).toBeVisible(); + expect(addRegistryProvider).toHaveBeenCalledTimes(2); + expect( + Object.fromEntries(addRegistryProvider.mock.calls[1][0]), + ).toMatchObject({ providerType: "acme", providerUid: "acme-account" }); + }); + + it("shows provider conflicts in the account step and allows retrying", async () => { + // Given + const detail = + "The artifact 'acme' is not installed on this deployment yet. Install it again and retry."; + addRegistryProvider + .mockResolvedValueOnce({ + errors: [ + { + status: "409", + detail, + source: { pointer: "/data/attributes/provider" }, + }, + ], + }) + .mockResolvedValueOnce(createdAccount); + const user = await enterAccountDetails(); + + // When + await user.click(screen.getByRole("button", { name: "Next" })); + + // Then + expect(await screen.findByRole("alert")).toHaveTextContent(detail); + expect(screen.getByRole("textbox", { name: "Provider UID" })).toHaveValue( + "acme-account", + ); + const next = await screen.findByRole("button", { name: "Next" }); + await waitFor(() => expect(next).toBeEnabled()); + expect(screen.getByRole("button", { name: "Back" })).toBeEnabled(); + + // When / Then: the provider becomes available and the same account retries. + await user.click(next); + expect(await screen.findByText("Credential details")).toBeVisible(); + expect(screen.queryByText(detail)).not.toBeInTheDocument(); + expect(addRegistryProvider).toHaveBeenCalledTimes(2); + }); + + it("keeps native providers available during a Registry discovery error and retries", async () => { + // Given + getInstalledRegistryProviderOptions.mockRejectedValueOnce( + new Error("Unavailable"), + ); + const user = userEvent.setup(); + render(); + await screen.findByText("Registry providers could not be loaded"); + expect( + screen.getByRole("option", { name: /Amazon Web Services/ }), + ).toBeVisible(); + + // When + await user.click(screen.getByRole("tab", { name: "Registry" })); + expect(screen.getByText("No Registry providers available.")).toBeVisible(); + await user.click( + screen.getByRole("button", { name: "Retry Registry providers" }), + ); + + // Then + expect( + await screen.findByRole("option", { name: "Acme Cloud Registry" }), + ).toBeVisible(); + expect( + screen.queryByText("Registry providers could not be loaded"), + ).not.toBeInTheDocument(); + }); +}); diff --git a/ui/components/providers/wizard/provider-wizard-modal.tsx b/ui/components/providers/wizard/provider-wizard-modal.tsx index d39a491b17..fab1a4440f 100644 --- a/ui/components/providers/wizard/provider-wizard-modal.tsx +++ b/ui/components/providers/wizard/provider-wizard-modal.tsx @@ -1,6 +1,6 @@ "use client"; -import { ExternalLink, Info } from "lucide-react"; +import { ExternalLink, Info, Loader2 } from "lucide-react"; import { AzureOrgSetupForm } from "@/components/providers/organizations/azure-org-setup-form"; import { GcpOrgSetupForm } from "@/components/providers/organizations/gcp-org-setup-form"; @@ -405,7 +405,11 @@ export function ProviderWizardModal({ : "button" } form={resolvedFooterConfig.actionFormId} - disabled={resolvedFooterConfig.actionDisabled} + disabled={ + resolvedFooterConfig.actionDisabled || + resolvedFooterConfig.actionLoading + } + aria-busy={resolvedFooterConfig.actionLoading || undefined} onClick={ resolvedFooterConfig.actionType === WIZARD_FOOTER_ACTION_TYPE.BUTTON @@ -413,6 +417,9 @@ export function ProviderWizardModal({ : undefined } > + {resolvedFooterConfig.actionLoading && ( + + )} {resolvedFooterConfig.actionLabel} )} diff --git a/ui/components/providers/wizard/steps/connect-step.tsx b/ui/components/providers/wizard/steps/connect-step.tsx index a26013aba3..649f0d1562 100644 --- a/ui/components/providers/wizard/steps/connect-step.tsx +++ b/ui/components/providers/wizard/steps/connect-step.tsx @@ -63,6 +63,7 @@ export function ConnectStep({ onBack: () => backHandlerRef.current?.(), showAction: uiState.showAction, actionLabel: uiState.actionLabel, + actionLoading: uiState.isLoading, actionDisabled: uiState.actionDisabled || uiState.isLoading, actionType: WIZARD_FOOTER_ACTION_TYPE.SUBMIT, actionFormId: formId, diff --git a/ui/components/providers/wizard/steps/credentials-step.test.tsx b/ui/components/providers/wizard/steps/credentials-step.test.tsx index 7f4610b212..8fa035ea33 100644 --- a/ui/components/providers/wizard/steps/credentials-step.test.tsx +++ b/ui/components/providers/wizard/steps/credentials-step.test.tsx @@ -1,3 +1,6 @@ +vi.mock("./dynamic-credentials-step", () => ({ + DynamicCredentialsStep: () =>
dynamic-credentials-form
, +})); import { render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; diff --git a/ui/components/providers/wizard/steps/credentials-step.tsx b/ui/components/providers/wizard/steps/credentials-step.tsx index 371f356d30..75836a25e3 100644 --- a/ui/components/providers/wizard/steps/credentials-step.tsx +++ b/ui/components/providers/wizard/steps/credentials-step.tsx @@ -4,7 +4,7 @@ import { useEffect, useState } from "react"; import { getProviderFormType } from "@/lib/provider-helpers"; import { useProviderWizardStore } from "@/store/provider-wizard/store"; -import { ProviderType } from "@/types/providers"; +import { isKnownProviderType, ProviderType } from "@/types/providers"; import { AddViaCredentialsForm, @@ -23,6 +23,7 @@ import { SelectViaGitHub } from "../../workflow/forms/select-credentials-type/gi import { SelectViaM365 } from "../../workflow/forms/select-credentials-type/m365"; import { UpdateViaServiceAccountForm } from "../../workflow/forms/update-via-service-account-key-form"; +import { DynamicCredentialsStep } from "./dynamic-credentials-step"; import { WIZARD_FOOTER_ACTION_TYPE, WizardFooterConfig, @@ -34,7 +35,22 @@ interface CredentialsStepProps { onFooterChange: (config: WizardFooterConfig) => void; } -export function CredentialsStep({ +export function CredentialsStep(props: CredentialsStepProps) { + const providerId = useProviderWizardStore((state) => state.providerId); + const providerType = useProviderWizardStore((state) => state.providerType); + if (providerId && providerType && !isKnownProviderType(providerType)) { + return ( + + ); + } + return ; +} + +function BuiltinCredentialsStep({ onNext, onBack, onFooterChange, diff --git a/ui/components/providers/wizard/steps/dynamic-credentials-step.test.tsx b/ui/components/providers/wizard/steps/dynamic-credentials-step.test.tsx new file mode 100644 index 0000000000..ce28a075b1 --- /dev/null +++ b/ui/components/providers/wizard/steps/dynamic-credentials-step.test.tsx @@ -0,0 +1,365 @@ +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +import openaiSchema from "@/lib/provider-credentials/fixtures/openai-credential-schema.json"; +import templateSchema from "@/lib/provider-credentials/fixtures/template-credential-schema.json"; +import { useProviderWizardStore } from "@/store/provider-wizard/store"; +import type { ProviderSchemasResult } from "@/types/provider-schema"; + +const { getProviderSchemas, saveDynamicProviderCredentials, toast } = + vi.hoisted(() => ({ + getProviderSchemas: vi.fn(), + saveDynamicProviderCredentials: vi.fn(), + toast: vi.fn(), + })); +vi.mock("@/actions/providers/provider-schemas", () => ({ getProviderSchemas })); +vi.mock("@/actions/providers/dynamic-provider-credentials", () => ({ + saveDynamicProviderCredentials, +})); +vi.mock("@/components/shadcn/toast", () => ({ useToast: () => ({ toast }) })); + +import { DynamicCredentialsStep } from "./dynamic-credentials-step"; + +beforeAll(() => { + for (const method of [ + "hasPointerCapture", + "setPointerCapture", + "releasePointerCapture", + "scrollIntoView", + ]) { + Object.defineProperty(HTMLElement.prototype, method, { + configurable: true, + value: vi.fn(() => false), + }); + } +}); + +const props = { + providerId: "account", + providerType: "acme", + onNext: vi.fn(), + onBack: vi.fn(), + onFooterChange: vi.fn(), +}; +const schema = { + type: "object", + description: openaiSchema.description, + properties: { + token: { + type: "string", + title: "API token", + format: "password", + writeOnly: true, + }, + }, + required: ["token"], +}; + +describe("dynamic credentials in the provider wizard", () => { + beforeEach(() => { + vi.clearAllMocks(); + sessionStorage.clear(); + localStorage.clear(); + useProviderWizardStore.getState().reset(); + getProviderSchemas.mockResolvedValue({ + status: "success", + providerType: "acme", + secretTypes: { api_key: schema }, + }); + saveDynamicProviderCredentials.mockResolvedValue({ + status: "saved", + secretId: "secret", + }); + }); + it("saves through the dynamic action and never persists entered secrets", async () => { + render(); + const field = await screen.findByLabelText(/API token/); + fireEvent.change(field, { target: { value: "only-in-memory" } }); + expect(JSON.stringify(sessionStorage)).not.toContain("only-in-memory"); + expect(JSON.stringify(localStorage)).not.toContain("only-in-memory"); + fireEvent.submit(field.closest("form")!); + await waitFor(() => expect(props.onNext).toHaveBeenCalledOnce()); + expect(saveDynamicProviderCredentials).toHaveBeenCalledWith({ + providerId: "account", + secretType: "api_key", + secret: { token: "only-in-memory" }, + }); + expect(useProviderWizardStore.getState().secretId).toBe("secret"); + expect(field).toHaveValue(""); + }); + it("masks the OpenAI API key and submits the original credential values", async () => { + // Given + const user = userEvent.setup(); + getProviderSchemas.mockResolvedValue({ + status: "success", + providerType: "openai", + secretTypes: { api_key: openaiSchema }, + }); + render(); + const apiKey = await screen.findByLabelText(/Platform Api Key/); + const organization = screen.getByLabelText(/Organization Id/); + const baseUrl = screen.getByLabelText(/Base Url/); + + // When + await user.type(organization, "org-fixture"); + await user.type(apiKey, "fixture-key-not-a-secret"); + + // Then + expect(apiKey).toHaveAttribute("type", "password"); + expect(apiKey).toHaveAttribute("autocomplete", "new-password"); + expect(organization).toHaveAttribute("type", "text"); + expect(baseUrl).toHaveAttribute("type", "text"); + expect( + screen.queryByRole("button", { name: /show|reveal/i }), + ).not.toBeInTheDocument(); + + // When / Then: this form submits from the wizard's external footer. + act(() => apiKey.closest("form")!.requestSubmit()); + await waitFor(() => expect(props.onNext).toHaveBeenCalledOnce()); + expect(saveDynamicProviderCredentials).toHaveBeenCalledWith({ + providerId: "account", + secretType: "api_key", + secret: { + organization_id: "org-fixture", + platform_api_key: "fixture-key-not-a-secret", + base_url: "https://api.openai.com/v1", + }, + }); + }); + it("renders and submits the installed Template credential form with typed values", async () => { + // Given + const user = userEvent.setup(); + getProviderSchemas.mockResolvedValue({ + status: "success", + providerType: "template", + secretTypes: { static: templateSchema }, + }); + render(); + const apiUrl = await screen.findByLabelText(/API URL/); + const apiKey = screen.getByLabelText(/API Key/); + const verifyTls = screen.getByRole("checkbox", { name: "Verify TLS" }); + const timeout = screen.getByRole("spinbutton", { name: "Timeout" }); + + // Then + expect(apiUrl).toHaveAttribute("placeholder", "https://api.acme.com"); + expect(apiKey).toHaveAttribute("type", "password"); + expect(screen.getByLabelText("CA Bundle").tagName).toBe("TEXTAREA"); + expect(verifyTls).toBeChecked(); + expect(timeout).toHaveValue(30); + expect(timeout).toHaveAttribute("min", "1"); + expect(timeout).toHaveAttribute("max", "300"); + expect(timeout).toHaveAttribute("step", "1"); + expect( + screen.getByRole("combobox", { name: "Authentication Scheme" }), + ).toHaveTextContent("bearer"); + expect(apiUrl).toHaveValue(""); + + // When: false must remain a boolean and numeric input must become a number. + await user.type(apiUrl, "https://api.example.test"); + await user.type(apiKey, "fixture-key-not-a-secret"); + await user.click(verifyTls); + await user.clear(timeout); + await user.type(timeout, "60"); + act(() => apiKey.closest("form")!.requestSubmit()); + + // Then + await waitFor(() => expect(props.onNext).toHaveBeenCalledOnce()); + expect(saveDynamicProviderCredentials).toHaveBeenCalledWith({ + providerId: "account", + secretType: "static", + secret: { + api_url: "https://api.example.test", + api_key: "fixture-key-not-a-secret", + verify_tls: false, + timeout_seconds: 60, + auth_scheme: "bearer", + }, + }); + }); + it.each<{ result: ProviderSchemasResult; title: string }>([ + { + result: { status: "success", providerType: "acme", secretTypes: {} }, + title: "Credential form unavailable", + }, + { + result: { + status: "success", + providerType: "acme", + secretTypes: { + api_key: { + type: "object", + properties: { nested: { type: "object" } }, + }, + }, + }, + title: "Credential form not supported", + }, + { + result: { status: "access_denied" }, + title: "Access required", + }, + { + result: { status: "unavailable" }, + title: "Provider installation unavailable", + }, + ])( + "explains $title without allowing credential submission", + async ({ result, title }) => { + getProviderSchemas.mockResolvedValue(result); + render(); + expect( + await screen.findByRole("button", { name: "Try again" }), + ).toBeVisible(); + expect(screen.getByRole("alert")).toHaveTextContent(title); + expect(screen.queryByLabelText(/API token/)).not.toBeInTheDocument(); + expect(saveDynamicProviderCredentials).not.toHaveBeenCalled(); + }, + ); + it("explains a loading failure and recovers when retried", async () => { + // Given + getProviderSchemas.mockRejectedValueOnce(new Error("Network unavailable")); + const user = userEvent.setup(); + render(); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Could not load credential form", + ); + expect(screen.getByRole("alert")).toHaveTextContent( + "Check your connection and try again.", + ); + expect(screen.getByRole("link", { name: "Open Registry" })).toHaveAttribute( + "href", + "/registry", + ); + + // When + await user.click(screen.getByRole("button", { name: "Try again" })); + + // Then + expect(await screen.findByLabelText(/API token/)).toBeVisible(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + it("clears credentials when changing providers", async () => { + const view = render(); + fireEvent.change(await screen.findByLabelText(/API token/), { + target: { value: "previous-secret" }, + }); + view.rerender( + , + ); + await waitFor(() => + expect(screen.getByLabelText(/API token/)).toHaveValue(""), + ); + }); + it("clears credentials when switching authentication methods", async () => { + getProviderSchemas.mockResolvedValue({ + status: "success", + providerType: "acme", + secretTypes: { api_key: schema, personal_token: schema }, + }); + render(); + fireEvent.change(await screen.findByLabelText(/API token/), { + target: { value: "previous-method-secret" }, + }); + const user = userEvent.setup(); + await user.click( + screen.getByRole("combobox", { name: "Authentication method" }), + ); + await user.click(screen.getByRole("option", { name: "personal token" })); + expect(screen.getByLabelText(/API token/)).toHaveValue(""); + expect(JSON.stringify(sessionStorage)).not.toContain( + "previous-method-secret", + ); + expect(JSON.stringify(localStorage)).not.toContain( + "previous-method-secret", + ); + }); + it("rejects double submission and retries a failed save for the same account", async () => { + let rejectSave!: (error: Error) => void; + saveDynamicProviderCredentials.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectSave = reject; + }), + ); + render(); + const field = await screen.findByLabelText(/API token/); + fireEvent.change(field, { target: { value: "retry-secret" } }); + fireEvent.submit(field.closest("form")!); + fireEvent.submit(field.closest("form")!); + expect(saveDynamicProviderCredentials).toHaveBeenCalledOnce(); + rejectSave(new Error("Network unavailable")); + await screen.findByText( + "Could not save the credentials. Check your connection and retry.", + ); + expect(props.onNext).not.toHaveBeenCalled(); + fireEvent.submit(field.closest("form")!); + await waitFor(() => expect(props.onNext).toHaveBeenCalledOnce()); + expect(saveDynamicProviderCredentials).toHaveBeenCalledTimes(2); + expect(saveDynamicProviderCredentials).toHaveBeenLastCalledWith({ + providerId: "account", + secretType: "api_key", + secret: { token: "retry-secret" }, + }); + }); + it("keeps other field and form errors visible while editing one credential", async () => { + // Given + const user = userEvent.setup(); + getProviderSchemas.mockResolvedValue({ + status: "success", + providerType: "acme", + secretTypes: { + api_key: { + ...schema, + properties: { + ...schema.properties, + project: { type: "string", title: "Project" }, + }, + required: ["token", "project"], + }, + }, + }); + saveDynamicProviderCredentials.mockResolvedValueOnce({ + status: "invalid", + errors: { + token: "Token was rejected", + project: "Project is unavailable", + _form: "Review the credential fields", + }, + }); + render(); + const token = await screen.findByLabelText(/API token/); + await user.type(token, "fixture-token"); + await user.type(screen.getByLabelText(/Project/), "fixture-project"); + act(() => token.closest("form")!.requestSubmit()); + expect(await screen.findByText("Token was rejected")).toBeVisible(); + + // When + await user.type(token, "-edited"); + + // Then + expect(screen.queryByText("Token was rejected")).not.toBeInTheDocument(); + expect(screen.getByText("Project is unavailable")).toBeVisible(); + expect(screen.getByText("Review the credential fields")).toBeVisible(); + + // When / Then: submitting again replaces the earlier validation errors. + act(() => token.closest("form")!.requestSubmit()); + await waitFor(() => expect(props.onNext).toHaveBeenCalledOnce()); + expect( + screen.queryByText("Project is unavailable"), + ).not.toBeInTheDocument(); + expect( + screen.queryByText("Review the credential fields"), + ).not.toBeInTheDocument(); + }); +}); diff --git a/ui/components/providers/wizard/steps/dynamic-credentials-step.tsx b/ui/components/providers/wizard/steps/dynamic-credentials-step.tsx new file mode 100644 index 0000000000..35fc5f0daa --- /dev/null +++ b/ui/components/providers/wizard/steps/dynamic-credentials-step.tsx @@ -0,0 +1,336 @@ +"use client"; + +import { RotateCcw } from "lucide-react"; +import Link from "next/link"; +import { useEffect, useRef, useState } from "react"; + +import { saveDynamicProviderCredentials } from "@/actions/providers/dynamic-provider-credentials"; +import { getProviderSchemas } from "@/actions/providers/provider-schemas"; +import { RegistryCredentialFields } from "@/components/providers/workflow/provider-credential-fields"; +import { Button } from "@/components/shadcn/button/button"; +import { Field, FieldLabel } from "@/components/shadcn/field/field"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/shadcn/select/select"; +import { Skeleton } from "@/components/shadcn/skeleton/skeleton"; +import { useToast } from "@/components/shadcn/toast"; +import { StatusAlert } from "@/components/shared/status-alert"; +import { + parseRegistryCredentialSchema, + type RegistryCredentialSchema, +} from "@/lib/provider-credentials/provider-credential-schema"; +import { + getCredentialDefaults, + validateCredentialValues, +} from "@/lib/provider-credentials/provider-credential-values"; +import { useProviderWizardStore } from "@/store/provider-wizard/store"; +import type { ProviderSchemasResult } from "@/types/provider-schema"; + +import { + WIZARD_FOOTER_ACTION_TYPE, + type WizardFooterConfig, +} from "./footer-controls"; + +interface DynamicCredentialsStepProps { + providerId: string; + providerType: string; + onNext: () => void; + onBack: () => void; + onFooterChange: (config: WizardFooterConfig) => void; +} + +function credentialFormError(status: ProviderSchemasResult["status"]) { + switch (status) { + case "access_denied": + return { + title: "Access required", + description: + "Your session may have expired or you may not have permission. Sign in again or contact your administrator.", + }; + case "unavailable": + return { + title: "Provider installation unavailable", + description: + "Install this provider's artifact again in Registry, then try again.", + }; + case "not_found": + return { + title: "Credential form unavailable", + description: + "This provider does not provide a credential form. Contact its publisher or your administrator.", + }; + case "success": + case "malformed": + return { + title: "Credential form not supported", + description: + "We could not display this provider's credential form. Contact its publisher or your administrator.", + }; + default: + return { + title: "Could not load credential form", + description: "Check your connection and try again.", + }; + } +} + +function DynamicCredentialForm({ + providerId, + secretType, + schema, + onNext, + onBack, + onFooterChange, + onLoadingChange, +}: Omit & { + secretType: string; + schema: RegistryCredentialSchema; + onLoadingChange: (value: boolean) => void; +}) { + const { toast } = useToast(); + const setSecretId = useProviderWizardStore((state) => state.setSecretId); + // Credentials belong only to this form. A new account or authentication + // method mounts a fresh instance; no values enter the persisted wizard store. + const [values, setValues] = useState(() => getCredentialDefaults(schema)); + const [errors, setErrors] = useState>({}); + const [saving, setSaving] = useState(false); + const inFlight = useRef(false); + const mounted = useRef(true); + const formId = "provider-wizard-dynamic-credentials-form"; + const valid = validateCredentialValues(schema, values).valid; + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + + useEffect(() => { + onFooterChange({ + showBack: true, + backLabel: "Back", + backDisabled: saving, + onBack, + showAction: true, + actionLabel: "Authenticate", + actionDisabled: saving || !valid, + actionType: WIZARD_FOOTER_ACTION_TYPE.SUBMIT, + actionFormId: formId, + }); + }, [onBack, onFooterChange, saving, valid]); + + return ( +
{ + event.preventDefault(); + if (inFlight.current) return; + const validation = validateCredentialValues(schema, values); + setErrors(validation.errors); + if (!validation.valid) return; + inFlight.current = true; + setSaving(true); + onLoadingChange(true); + try { + const result = await saveDynamicProviderCredentials({ + providerId, + secretType, + secret: validation.secret, + }); + if (!mounted.current) return; + if (result.status === "saved") { + setValues({}); + setSecretId(result.secretId); + toast({ + title: "Credentials saved", + description: "Test the provider connection to continue.", + }); + onNext(); + } else if (result.status === "invalid") { + setErrors(result.errors); + } else { + const description = + result.status === "schema_unavailable" + ? "The credential schema is unavailable. Check the installed artifact in Registry and reload the form." + : result.status === "access_denied" + ? "You no longer have permission to update these credentials. Contact an administrator." + : "Check your credentials and try again. Your provider account is already created."; + setErrors({ _form: description }); + toast({ + variant: "destructive", + title: "Credentials could not be saved", + description, + }); + } + } catch { + if (mounted.current) { + const description = + "Could not save the credentials. Check your connection and retry."; + setErrors({ _form: description }); + toast({ + variant: "destructive", + title: "Credentials could not be saved", + description, + }); + } + } finally { + inFlight.current = false; + if (mounted.current) { + setSaving(false); + onLoadingChange(false); + } + } + }} + > +
+ {errors._form && ( + + {errors._form} + + )} + { + setValues((current) => ({ ...current, [name]: value })); + setErrors((current) => { + const next = { ...current }; + delete next[name]; + return next; + }); + }} + /> +
+
+ ); +} + +function DynamicCredentialsContent(props: DynamicCredentialsStepProps) { + const { providerType, onBack, onFooterChange } = props; + const [schemas, setSchemas] = useState(null); + const [selectedMethod, setSelectedMethod] = useState(""); + const [attempt, setAttempt] = useState(0); + const [saving, setSaving] = useState(false); + useEffect(() => { + let active = true; + setSchemas(null); + setSelectedMethod(""); + getProviderSchemas(providerType) + .then((result) => { + if (active) setSchemas(result); + }) + .catch(() => { + if (active) setSchemas({ status: "error" }); + }); + return () => { + active = false; + }; + }, [providerType, attempt]); + + const methods = + schemas?.status === "success" ? Object.keys(schemas.secretTypes) : []; + const secretType = selectedMethod || methods[0]; + const schema = + schemas?.status === "success" && secretType + ? parseRegistryCredentialSchema(schemas.secretTypes[secretType]) + : null; + useEffect(() => { + if (!schema) + onFooterChange({ + showBack: true, + backLabel: "Back", + onBack, + showAction: false, + actionLabel: "Authenticate", + actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON, + }); + }, [schema, onBack, onFooterChange]); + + if (!schemas) + return ( +
+ + +
+ ); + + const error = credentialFormError( + schemas.status === "success" && methods.length === 0 + ? "not_found" + : schemas.status, + ); + + return ( +
+ {methods.length > 1 && ( + + + Authentication method + + + + )} + {schema ? ( + + ) : ( +
+ + {error.description} + +
+ + +
+
+ )} +
+ ); +} + +export function DynamicCredentialsStep(props: DynamicCredentialsStepProps) { + return ( + + ); +} diff --git a/ui/components/providers/wizard/steps/footer-controls.ts b/ui/components/providers/wizard/steps/footer-controls.ts index ff41ae8061..7bdd0d5d58 100644 --- a/ui/components/providers/wizard/steps/footer-controls.ts +++ b/ui/components/providers/wizard/steps/footer-controls.ts @@ -22,6 +22,7 @@ export interface WizardFooterConfig { onSecondaryAction?: () => void; showAction: boolean; actionLabel: string; + actionLoading?: boolean; actionDisabled?: boolean; actionType: WizardFooterActionType; actionFormId?: string; diff --git a/ui/components/providers/workflow/forms/connect-account-form.test.tsx b/ui/components/providers/workflow/forms/connect-account-form.test.tsx new file mode 100644 index 0000000000..7e74064f8e --- /dev/null +++ b/ui/components/providers/workflow/forms/connect-account-form.test.tsx @@ -0,0 +1,84 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { addProvider, updateProvider, getInstalledRegistryProviderOptions } = + vi.hoisted(() => ({ + addProvider: vi.fn(), + updateProvider: vi.fn(), + getInstalledRegistryProviderOptions: vi.fn(), + })); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn() }), +})); +vi.mock("@/actions/providers/providers", () => ({ + addProvider, + updateProvider, +})); +vi.mock("@/actions/providers/registry-provider", () => ({ + addRegistryProvider: vi.fn(), +})); +vi.mock("@/actions/registry/registry", () => ({ + getInstalledRegistryProviderOptions, +})); + +import { ConnectAccountForm } from "./connect-account-form"; + +describe("provider account aliases", () => { + beforeEach(() => { + vi.clearAllMocks(); + getInstalledRegistryProviderOptions.mockResolvedValue({ + status: "access_denied", + }); + }); + + it("saves an alias changed after an account was already created", async () => { + // Given + const onSuccess = vi.fn(); + const user = userEvent.setup(); + const account = { + id: "existing", + attributes: { provider: "github", uid: "octocat", alias: "Original" }, + }; + addProvider.mockResolvedValue({ data: account }); + updateProvider.mockResolvedValue({ + data: { + ...account, + attributes: { ...account.attributes, alias: "Edited" }, + }, + }); + render(); + await user.click(screen.getByRole("option", { name: "GitHub" })); + await user.type( + screen.getByRole("textbox", { name: "Username/Organization" }), + "octocat", + ); + const alias = screen.getByRole("textbox", { + name: "Provider alias (optional)", + }); + await user.type(alias, "Original"); + await user.click(screen.getByRole("button", { name: "Next" })); + await waitFor(() => expect(onSuccess).toHaveBeenCalledOnce()); + + // When + await user.clear(alias); + await user.type(alias, "Edited"); + await user.click(screen.getByRole("button", { name: "Next" })); + + // Then + await waitFor(() => + expect(onSuccess).toHaveBeenLastCalledWith({ + id: "existing", + providerType: "github", + uid: "octocat", + alias: "Edited", + }), + ); + expect(addProvider).toHaveBeenCalledOnce(); + expect(Object.fromEntries(updateProvider.mock.calls[0][0])).toMatchObject({ + providerId: "existing", + providerAlias: "Edited", + }); + }); +}); diff --git a/ui/components/providers/workflow/forms/connect-account-form.tsx b/ui/components/providers/workflow/forms/connect-account-form.tsx index a46de4871d..2ccd6531ff 100644 --- a/ui/components/providers/workflow/forms/connect-account-form.tsx +++ b/ui/components/providers/workflow/forms/connect-account-form.tsx @@ -3,20 +3,25 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { ChevronLeftIcon, ChevronRightIcon, Loader2 } from "lucide-react"; import { useRouter } from "next/navigation"; -import { Dispatch, SetStateAction, useEffect, useState } from "react"; +import { Dispatch, SetStateAction, useEffect, useRef, useState } from "react"; import { useForm, UseFormReturn } from "react-hook-form"; -import { z } from "zod"; -import { addProvider } from "@/actions/providers/providers"; +import { addProvider, updateProvider } from "@/actions/providers/providers"; +import { addRegistryProvider } from "@/actions/providers/registry-provider"; +import { getInstalledRegistryProviderOptions } from "@/actions/registry/registry"; import { AwsMethodSelector } from "@/components/providers/organizations/aws-method-selector"; import { AzureMethodSelector } from "@/components/providers/organizations/azure-method-selector"; import { GcpMethodSelector } from "@/components/providers/organizations/gcp-method-selector"; import { WizardInputField } from "@/components/providers/workflow/forms/fields"; import { ProviderTitleDocs } from "@/components/providers/workflow/provider-title-docs"; import { Button, useToast } from "@/components/shadcn"; +import { Alert, AlertDescription, AlertTitle } from "@/components/shadcn/alert"; import { Form } from "@/components/shadcn/form"; +import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields"; +import type { RegistryProviderOption } from "@/lib/registry/provider-options"; import { - addProviderFormSchema, + createAddProviderFormSchema, + AddProviderFormValues, ApiError, KnownProviderType, ProviderType, @@ -26,10 +31,11 @@ import { OrgFlowType, toOrgFlowType, } from "@/types/organizations"; +import { isKnownProviderType } from "@/types/providers"; import { RadioGroupProvider } from "../../radio-group-provider"; -export type FormValues = z.infer; +export type FormValues = AddProviderFormValues; export interface ConnectAccountSuccessData { id: string; @@ -209,7 +215,41 @@ export const ConnectAccountForm = ({ const [method, setMethod] = useState<"single" | null>(null); const router = useRouter(); - const formSchema = addProviderFormSchema; + const [registryOptions, setRegistryOptions] = useState< + RegistryProviderOption[] + >([]); + const [registryError, setRegistryError] = useState(false); + const [providerError, setProviderError] = useState(null); + const [discoveryAttempt, setDiscoveryAttempt] = useState(0); + const submitting = useRef(false); + const createdAccount = useRef(null); + + useEffect(() => { + let active = true; + const load = async () => { + try { + const result = await getInstalledRegistryProviderOptions(); + if (!active) return; + setRegistryOptions(result.status === "ready" ? result.options : []); + setRegistryError(result.status === "error"); + } catch { + if (active) { + setRegistryOptions([]); + setRegistryError(true); + } + } + }; + void load(); + window.addEventListener("registry-artifacts-changed", load); + return () => { + active = false; + window.removeEventListener("registry-artifacts-changed", load); + }; + }, [discoveryAttempt]); + + const formSchema = createAddProviderFormSchema( + registryOptions.map((option) => option.type), + ); const form = useForm({ resolver: zodResolver(formSchema), @@ -229,6 +269,22 @@ export const ConnectAccountForm = ({ const isLoading = form.formState.isSubmitting; const onSubmitClient = async (values: FormValues) => { + if (submitting.current) return; + const existingAccount = + createdAccount.current?.providerType === values.providerType && + createdAccount.current.uid === values.providerUid + ? createdAccount.current + : null; + if ( + existingAccount && + (existingAccount.alias ?? "") === (values.providerAlias?.trim() ?? "") && + onSuccess + ) { + onSuccess(existingAccount); + return; + } + submitting.current = true; + setProviderError(null); const formValues = { ...values }; const formData = new FormData(); @@ -237,7 +293,20 @@ export const ConnectAccountForm = ({ ); try { - const data = await addProvider(formData); + let data; + if (existingAccount) { + const update = new FormData(); + update.set(ProviderCredentialFields.PROVIDER_ID, existingAccount.id); + update.set( + ProviderCredentialFields.PROVIDER_ALIAS, + values.providerAlias?.trim() ?? "", + ); + data = await updateProvider(update); + } else { + data = await (isKnownProviderType(values.providerType) + ? addProvider(formData) + : addRegistryProvider(formData)); + } if (data?.errors && data.errors.length > 0) { data.errors.forEach((error: ApiError) => { @@ -246,10 +315,9 @@ export const ConnectAccountForm = ({ switch (pointer) { case "/data/attributes/provider": - form.setError("providerType", { - type: "server", - message: errorMessage, - }); + // Provider selection is hidden here; keep failures visible and + // retryable when availability changes without editing the form. + setProviderError(errorMessage); break; case "/data/attributes/uid": case "/data/attributes/__all__": @@ -280,12 +348,13 @@ export const ConnectAccountForm = ({ } = data.data; if (onSuccess) { - onSuccess({ + createdAccount.current = { id, providerType: createdProviderType, uid: uid || values.providerUid, alias: alias ?? values.providerAlias ?? null, - }); + }; + onSuccess(createdAccount.current); return; } @@ -301,10 +370,13 @@ export const ConnectAccountForm = ({ ? error.message : "Something went wrong. Please try again.", }); + } finally { + submitting.current = false; } }; const handleBackStep = () => { + setProviderError(null); applyBackStep({ prevStep, method, @@ -327,6 +399,7 @@ export const ConnectAccountForm = ({ useEffect(() => { onBackHandlerChange?.(() => { + setProviderError(null); applyBackStep({ prevStep, method, @@ -352,7 +425,7 @@ export const ConnectAccountForm = ({ onUiStateChange?.({ showBack: prevStep === 2, showAction: prevStep === 2 && showUidForm, - actionLabel: "Next", + actionLabel: isLoading ? "Creating provider..." : "Next", actionDisabled: !canSubmit || isLoading, isLoading, }); @@ -375,7 +448,26 @@ export const ConnectAccountForm = ({ {/* Step 1: Provider selection */} {prevStep === 1 && (
+ {registryError && ( + + Registry providers could not be loaded + + Built-in providers are available. Check the Registry + connection and try again. + + + + )} + {providerError && ( + + Unable to create provider + {providerError} + + )} {isLoading ? ( - + ) : ( )} - {isLoading ? "Loading" : "Next"} + {isLoading ? "Creating provider..." : "Next"} )}
diff --git a/ui/components/providers/workflow/provider-credential-fields.test.tsx b/ui/components/providers/workflow/provider-credential-fields.test.tsx new file mode 100644 index 0000000000..6925925f23 --- /dev/null +++ b/ui/components/providers/workflow/provider-credential-fields.test.tsx @@ -0,0 +1,136 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeAll, describe, expect, it, vi } from "vitest"; + +import type { RegistryCredentialSchema } from "@/lib/provider-credentials/provider-credential-schema"; + +import { RegistryCredentialFields } from "./provider-credential-fields"; + +const schema: RegistryCredentialSchema = { + fields: [ + { + name: "api_key", + label: "API Key", + description: "Issued from the console.", + kind: "password", + required: true, + }, + { + name: "scheme", + label: "Scheme", + kind: "select", + options: ["bearer", "basic"], + required: false, + }, + { name: "notes", label: "Notes", kind: "textarea", required: false }, + ], +}; + +beforeAll(() => { + for (const name of [ + "hasPointerCapture", + "releasePointerCapture", + "scrollIntoView", + ]) { + Object.defineProperty(HTMLElement.prototype, name, { + configurable: true, + value: () => false, + }); + } +}); + +describe("RegistryCredentialFields", () => { + it("renders accessible controlled credential fields and emits changes", async () => { + // Given + const user = userEvent.setup(); + const onChange = vi.fn(); + + render( + , + ); + + // When + await user.type(screen.getByLabelText(/API Key/), "x"); + await user.click(screen.getByRole("combobox", { name: "Scheme" })); + await user.keyboard("{ArrowDown}{Enter}"); + + // Then + const apiKey = screen.getByLabelText(/API Key/); + const description = screen.getByText("Issued from the console."); + const error = screen.getByRole("alert"); + expect(apiKey).toHaveAttribute("type", "password"); + expect(apiKey).toHaveAttribute("autocomplete", "new-password"); + + expect(apiKey).toHaveAttribute( + "aria-describedby", + `${description.id} ${error.id}`, + ); + expect(apiKey.id).toMatch(/-0-control$/); + expect(apiKey).toHaveAttribute("aria-invalid", "true"); + expect(apiKey).toBeRequired(); + expect(description.id).toMatch(/-0-description$/); + expect(error).toHaveTextContent("A key is required."); + expect(error.id).toMatch(/-0-error$/); + expect(onChange).toHaveBeenCalledWith("api_key", "x"); + expect(onChange).toHaveBeenCalledWith("scheme", "basic"); + }); + + it("uses unique index-based IDs for hostile field names and instances", () => { + // Given + + const hostileSchema: RegistryCredentialSchema = { + fields: [ + { + name: "x-description", + label: "First", + kind: "text", + required: false, + }, + { + name: "registry-credential-x", + label: "Second", + description: "Second description.", + kind: "text", + required: false, + }, + ], + }; + + const { container } = render( + <> + + + + , + ); + + // When / Then + expect(screen.getByLabelText("First").id).toMatch(/-0-control$/); + + expect(screen.getByText("Second description.").id).toMatch( + /-1-description$/, + ); + const ids = Array.from(container.querySelectorAll("[id]"), ({ id }) => id); + expect(new Set(ids).size).toBe(ids.length); + }); +}); diff --git a/ui/components/providers/workflow/provider-credential-fields.tsx b/ui/components/providers/workflow/provider-credential-fields.tsx new file mode 100644 index 0000000000..157f4a0d7e --- /dev/null +++ b/ui/components/providers/workflow/provider-credential-fields.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { type ChangeEvent, useId } from "react"; + +import { Checkbox } from "@/components/shadcn/checkbox/checkbox"; +import { Field, FieldError, FieldLabel } from "@/components/shadcn/field/field"; +import { Input } from "@/components/shadcn/input/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/shadcn/select/select"; +import { Textarea } from "@/components/shadcn/textarea/textarea"; +import type { + RegistryCredentialSchema, + RegistryCredentialValue, +} from "@/lib/provider-credentials/provider-credential-schema"; + +interface RegistryCredentialFieldsProps { + readonly errors: Readonly>; + readonly onChange: (name: string, value: RegistryCredentialValue) => void; + readonly schema: RegistryCredentialSchema; + readonly values: Readonly< + Record + >; +} + +export function RegistryCredentialFields({ + errors, + onChange, + schema, + values, +}: RegistryCredentialFieldsProps) { + const instanceId = useId(); + + return ( +
+ {schema.fields.map((field, index) => { + const error = errors[field.name]; + const fieldId = `registry-credential-${instanceId}-${index}`; + const id = `${fieldId}-control`; + const descriptionId = field.description + ? `${fieldId}-description` + : undefined; + const errorId = error ? `${fieldId}-error` : undefined; + const describedBy = + [descriptionId, errorId].filter(Boolean).join(" ") || undefined; + const invalid = error ? true : undefined; + const value = values[field.name]; + const textControlProps = { + "aria-describedby": describedBy, + "aria-invalid": invalid, + id, + + onChange: ( + event: ChangeEvent, + ) => onChange(field.name, event.target.value), + required: field.required, + placeholder: field.placeholder, + spellCheck: false, + value: + typeof value === "string" || typeof value === "number" ? value : "", + }; + + return ( + + {field.kind === "checkbox" ? ( +
+ + onChange(field.name, checked === true) + } + /> + + {field.label} + {field.required && } + +
+ ) : ( + + {field.label} + {field.required && } + + )} + {field.kind === "checkbox" ? null : field.kind === "select" ? ( + + ) : field.kind === "textarea" ? ( +