diff --git a/permissions/templates/azure/bicep/prowler-scan.bicep b/permissions/templates/azure/bicep/prowler-scan.bicep index 092d26b119..4d0454e680 100644 --- a/permissions/templates/azure/bicep/prowler-scan.bicep +++ b/permissions/templates/azure/bicep/prowler-scan.bicep @@ -26,23 +26,23 @@ targetScope = 'subscription' -@description('Display name of the Entra ID App Registration that Prowler will authenticate as.') +@description('Name for the App Registration Prowler will use. Keep the default unless you need a custom name.') param applicationName string = 'ProwlerApp' -@description('Base64-encoded DER of the X.509 public certificate that Entra ID will pin to the App Registration as a keyCredential. Generate with `openssl x509 -outform DER | base64` (see README). The matching private key stays with the user — Prowler never receives it during deployment.') +@description('Paste the PUBLIC certificate from the Prowler wizard (prowler-cert-base64.txt) or your own openssl output. Prowler never receives the matching private key.') @secure() param certificateBase64 string -@description('Optional friendly display name for the certificate credential.') +@description('Label for the certificate inside the App Registration. Cosmetic — keep the default if unsure.') param certificateDisplayName string = 'Prowler Certificate' -@description('Start date (ISO 8601) for the certificate credential validity window. Defaults to the deployment time.') +@description('When the certificate becomes valid. Defaults to now.') param certificateStartDateTime string = utcNow() -@description('End date (ISO 8601) for the certificate credential validity window. Defaults to one year after the deployment.') +@description('When the certificate expires. Defaults to 1 year from now. Rotate before this date.') param certificateEndDateTime string = dateTimeAdd(utcNow(), 'P1Y') -@description('Name of the custom role Prowler creates for its extra read actions.') +@description('Name of the extra role Prowler creates. Keep the default unless your org already uses this name.') param customRoleName string = 'ProwlerRole' // Deterministic GUIDs derived from `subscription().id` and the params so diff --git a/permissions/templates/azure/bicep/prowler-scan.json b/permissions/templates/azure/bicep/prowler-scan.json index 2d39fd586a..3ab4f4b7bd 100644 --- a/permissions/templates/azure/bicep/prowler-scan.json +++ b/permissions/templates/azure/bicep/prowler-scan.json @@ -6,7 +6,7 @@ "_generator": { "name": "bicep", "version": "0.46.1.21595", - "templateHash": "6180354331557312661" + "templateHash": "17365420263047938168" } }, "parameters": { @@ -14,41 +14,41 @@ "type": "string", "defaultValue": "ProwlerApp", "metadata": { - "description": "Display name of the Entra ID App Registration that Prowler will authenticate as." + "description": "Name for the App Registration Prowler will use. Keep the default unless you need a custom name." } }, "certificateBase64": { "type": "securestring", "metadata": { - "description": "Base64-encoded DER of the X.509 public certificate that Entra ID will pin to the App Registration as a keyCredential. Generate with `openssl x509 -outform DER | base64` (see README). The matching private key stays with the user — Prowler never receives it during deployment." + "description": "Paste the PUBLIC certificate from the Prowler wizard (prowler-cert-base64.txt) or your own openssl output. Prowler never receives the matching private key." } }, "certificateDisplayName": { "type": "string", "defaultValue": "Prowler Certificate", "metadata": { - "description": "Optional friendly display name for the certificate credential." + "description": "Label for the certificate inside the App Registration. Cosmetic — keep the default if unsure." } }, "certificateStartDateTime": { "type": "string", "defaultValue": "[utcNow()]", "metadata": { - "description": "Start date (ISO 8601) for the certificate credential validity window. Defaults to the deployment time." + "description": "When the certificate becomes valid. Defaults to now." } }, "certificateEndDateTime": { "type": "string", "defaultValue": "[dateTimeAdd(utcNow(), 'P1Y')]", "metadata": { - "description": "End date (ISO 8601) for the certificate credential validity window. Defaults to one year after the deployment." + "description": "When the certificate expires. Defaults to 1 year from now. Rotate before this date." } }, "customRoleName": { "type": "string", "defaultValue": "ProwlerRole", "metadata": { - "description": "Name of the custom role Prowler creates for its extra read actions." + "description": "Name of the extra role Prowler creates. Keep the default unless your org already uses this name." } } }, @@ -172,4 +172,4 @@ "value": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', variables('customRoleDefinitionName'))]" } } -} +} \ No newline at end of file diff --git a/ui/components/providers/workflow/forms/select-credentials-type/azure/credentials-type/azure-certificate-credentials-form.tsx b/ui/components/providers/workflow/forms/select-credentials-type/azure/credentials-type/azure-certificate-credentials-form.tsx index 1839eefaa5..c6b3f052a0 100644 --- a/ui/components/providers/workflow/forms/select-credentials-type/azure/credentials-type/azure-certificate-credentials-form.tsx +++ b/ui/components/providers/workflow/forms/select-credentials-type/azure/credentials-type/azure-certificate-credentials-form.tsx @@ -1,14 +1,20 @@ "use client"; import Link from "next/link"; -import { Control } from "react-hook-form"; +import { useState } from "react"; +import { Control, useFormContext } from "react-hook-form"; import { WizardInputField, WizardTextareaField, } from "@/components/providers/workflow/forms/fields"; import { Button } from "@/components/shadcn"; +import { + downloadPublicCertificateFile, + generateProwlerCertificate, +} from "@/lib/azure-cert-generator"; import { getAzureDeploymentQuickLink } from "@/lib/external-urls"; +import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields"; import { AzureCertificateCredentials } from "@/types"; export const AzureCertificateCredentialsForm = ({ @@ -23,6 +29,45 @@ export const AzureCertificateCredentialsForm = ({ // or region, thread that value through `getAzureDeploymentQuickLink` here. const deployToAzureUrl = getAzureDeploymentQuickLink(); + // Feedback state for the in-browser certificate generator. Kept local + // because the values only matter to this component — nothing else in the + // wizard needs to know that the user opted for auto-generation. + const [isGeneratingCert, setIsGeneratingCert] = useState(false); + const [generatorError, setGeneratorError] = useState(null); + const [generatedThumbprint, setGeneratedThumbprint] = useState( + null, + ); + const { setValue } = useFormContext(); + + const handleGenerateCertificate = async () => { + setGeneratorError(null); + setGeneratedThumbprint(null); + setIsGeneratingCert(true); + try { + const result = await generateProwlerCertificate(); + // Auto-fill the private key textarea with the ready-to-paste bundle so + // the user does not need to touch the field manually. `shouldValidate` + // clears the "Certificate Private Key is required" error immediately. + setValue( + ProviderCredentialFields.CERTIFICATE_CONTENT, + result.privateKeyBundleBase64Pem, + { shouldValidate: true, shouldDirty: true, shouldTouch: true }, + ); + // Hand the public certificate to the user as a file: they upload it to + // the Bicep `Certificate Base64` parameter in the Azure Portal. + downloadPublicCertificateFile(result.publicCertificateBase64Der); + setGeneratedThumbprint(result.thumbprintHex); + } catch (error) { + const message = + error instanceof Error + ? error.message + : "Failed to generate the certificate in-browser. Fall back to the openssl or PowerShell instructions in the guide."; + setGeneratorError(message); + } finally { + setIsGeneratingCert(false); + } + }; + return ( <>
@@ -48,6 +93,40 @@ export const AzureCertificateCredentialsForm = ({ “Certificate Private Key” field below.

+
+
+ Don't have a certificate yet? +
+

+ Generate a self-signed X.509 certificate right in your browser. The + private key never leaves your device — it goes + straight into the field below. The public certificate downloads as a + text file for you to paste into the Bicep{" "} + Certificate Base64 parameter in the Portal. +

+ + {generatorError && ( +

{generatorError}

+ )} + {generatedThumbprint && ( +

+ Done. Certificate SHA-1 thumbprint: {generatedThumbprint} + . Downloaded prowler-cert-base64.txt — paste its + contents into the Bicep Certificate Base64 parameter, + then keep filling the fields below. +

+ )} +
This is the base64-encoded private key that matches the certificate uploaded to Entra ID by the Bicep template (not the public - certificate, and not the thumbprint). Generation instructions (openssl / - PowerShell) are in the{" "} + certificate, and not the thumbprint). Use the{" "} + Generate certificate for me button above, or follow the manual + openssl / PowerShell instructions in the{" "} { + if (!globalThis.crypto || !globalThis.crypto.subtle) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Object.defineProperty(globalThis, "crypto", { + value: webcrypto, + configurable: true, + writable: true, + }); + } +}); + +describe("generateProwlerCertificate", () => { + it("produces a certificate + private-key bundle that round-trips through PEM parsers", async () => { + // Given / When + const result = await generateProwlerCertificate({ + // Shrink the modulus so the test finishes in <2s under CI without + // giving up any of the code paths the helper touches at 4096 bits. + modulusLength: 2048, + commonName: "prowler-test", + validityDays: 30, + }); + + // Then — DER base64 of the public certificate decodes to a byte array + // that starts with the ASN.1 SEQUENCE tag (0x30). A stray bug that + // returned PEM instead of DER, or double-encoded the base64, would trip + // this straight away. + const publicDer = Uint8Array.from( + atob(result.publicCertificateBase64Der), + (c) => c.charCodeAt(0), + ); + expect(publicDer[0]).toBe(0x30); + expect(publicDer.byteLength).toBeGreaterThan(500); + + // The private-key bundle decodes to a UTF-8 PEM string containing both + // markers, in the order azure-identity expects (cert first, key second). + const bundlePem = new TextDecoder().decode( + Uint8Array.from(atob(result.privateKeyBundleBase64Pem), (c) => + c.charCodeAt(0), + ), + ); + const certIdx = bundlePem.indexOf("-----BEGIN CERTIFICATE-----"); + const keyIdx = bundlePem.indexOf("-----BEGIN PRIVATE KEY-----"); + expect(certIdx).toBeGreaterThanOrEqual(0); + expect(keyIdx).toBeGreaterThan(certIdx); + expect(bundlePem).toContain("-----END CERTIFICATE-----"); + expect(bundlePem).toContain("-----END PRIVATE KEY-----"); + + // Validity window respects the injected days and is a real ISO 8601 + // timestamp. + const notBefore = new Date(result.notBefore); + const notAfter = new Date(result.notAfter); + expect(notBefore.getTime()).toBeLessThan(notAfter.getTime()); + const days = (notAfter.getTime() - notBefore.getTime()) / 86_400_000; + expect(days).toBeCloseTo(30, 0); + }); + + it("returns the correct SHA-1 thumbprint format expected by Entra ID", async () => { + // Given / When + const result = await generateProwlerCertificate({ modulusLength: 2048 }); + + // Then — 40 hex chars, uppercase, no separators. + expect(result.thumbprintHex).toMatch(/^[0-9A-F]{40}$/); + }); + + it("throws a friendly error when SubtleCrypto is unavailable", async () => { + // Given the browser doesn't expose subtle (insecure origin, ancient + // browser, some sandboxes). + const originalCrypto = globalThis.crypto; + Object.defineProperty(globalThis, "crypto", { + value: {}, + configurable: true, + writable: true, + }); + + // When / Then + await expect(generateProwlerCertificate()).rejects.toThrow( + /Web Crypto API is not available/i, + ); + + // Cleanup + Object.defineProperty(globalThis, "crypto", { + value: originalCrypto, + configurable: true, + writable: true, + }); + }); +}); + +describe("downloadPublicCertificateFile", () => { + const originalCreateElement = document.createElement.bind(document); + const originalCreateObjectURL = URL.createObjectURL; + const originalRevokeObjectURL = URL.revokeObjectURL; + + afterEach(() => { + document.createElement = originalCreateElement; + URL.createObjectURL = originalCreateObjectURL; + URL.revokeObjectURL = originalRevokeObjectURL; + }); + + it("triggers an anchor click with the right href and filename, then revokes the blob URL", () => { + // Given + const clickSpy = vi.fn(); + const objectUrl = "blob:mock/prowler-cert"; + URL.createObjectURL = vi.fn(() => objectUrl); + const revokeSpy = vi.fn(); + URL.revokeObjectURL = revokeSpy; + + // The anchor spy is a real HTMLAnchorElement so `document.body.appendChild` + // and `removeChild` accept it; we only intercept the `click` method. + const realAnchor = originalCreateElement("a"); + realAnchor.click = clickSpy; + document.createElement = vi.fn((tag: string) => { + if (tag === "a") return realAnchor; + return originalCreateElement(tag); + }) as typeof document.createElement; + + // When + downloadPublicCertificateFile("MIIBase64Contents", "prowler-cert.txt"); + + // Then + expect(URL.createObjectURL).toHaveBeenCalledTimes(1); + expect(clickSpy).toHaveBeenCalledTimes(1); + expect(realAnchor.href).toContain(objectUrl); + expect(realAnchor.download).toBe("prowler-cert.txt"); + // Revoked to avoid leaking the blob URL for the tab's lifetime. + expect(revokeSpy).toHaveBeenCalledWith(objectUrl); + }); + + it("defaults the filename when the caller omits it", () => { + // Given + URL.createObjectURL = vi.fn(() => "blob:mock"); + URL.revokeObjectURL = vi.fn(); + const realAnchor = originalCreateElement("a"); + realAnchor.click = vi.fn(); + document.createElement = vi.fn((tag: string) => { + if (tag === "a") return realAnchor; + return originalCreateElement(tag); + }) as typeof document.createElement; + + // When + downloadPublicCertificateFile("payload"); + + // Then + expect(realAnchor.download).toBe("prowler-cert-base64.txt"); + }); +}); diff --git a/ui/lib/azure-cert-generator.ts b/ui/lib/azure-cert-generator.ts new file mode 100644 index 0000000000..0cc194b088 --- /dev/null +++ b/ui/lib/azure-cert-generator.ts @@ -0,0 +1,240 @@ +// In-browser X.509 self-signed certificate generator for the Azure +// certificate-authentication onboarding flow. +// +// The keypair is generated with the browser's native Web Crypto API +// (`crypto.subtle.generateKey`) and never leaves the tab. `@peculiar/x509` +// wraps the public key in a self-signed X.509 certificate whose SHA-1 +// thumbprint we can hand back to the user; the private key is exported as +// base64-encoded PEM ready to paste into the Prowler wizard's Certificate +// Private Key field. +// +// See the certificate authentication guide in +// docs/user-guide/providers/azure/authentication.mdx for the equivalent +// openssl/PowerShell recipes, and PROWLER-2378 for the Deploy-to-Azure +// quick-start feature this UX affordance belongs to. + +// `@peculiar/x509` transitively depends on `tsyringe`, which pulls in a +// decorators/DI runtime that requires the `reflect-metadata` polyfill. The +// import has to happen before anything from `@peculiar/x509` is imported so +// the metadata store is registered on `Reflect` first. +import "reflect-metadata"; + +import { + cryptoProvider, + Extension, + X509CertificateGenerator, +} from "@peculiar/x509"; + +// Bind @peculiar/x509 to the browser's native SubtleCrypto. Without this the +// library falls back to a Node-only crypto provider that vitest+jsdom does +// not expose, and the helper would throw in tests. +if (typeof globalThis !== "undefined" && globalThis.crypto?.subtle) { + cryptoProvider.set(globalThis.crypto); +} + +export interface GeneratedProwlerCertificate { + /** + * Base64 of the DER-encoded X.509 public certificate. Feed this into the + * `certificateBase64` parameter of the Prowler Bicep quick-start template + * (or paste directly into the Azure Portal "Certificate Base64" field). + */ + publicCertificateBase64Der: string; + /** + * Base64 of the PEM bundle containing both the X.509 certificate and the + * PKCS#8 private key. `azure.identity.CertificateCredential` accepts this + * exact shape; the Prowler wizard pastes it into the Certificate Private + * Key (Base64) textarea. + */ + privateKeyBundleBase64Pem: string; + /** Human-readable SHA-1 thumbprint, uppercase hex, matching what Entra ID displays. */ + thumbprintHex: string; + /** ISO 8601 not-valid-before timestamp of the generated cert. */ + notBefore: string; + /** ISO 8601 not-valid-after timestamp of the generated cert. */ + notAfter: string; +} + +export interface GenerateProwlerCertificateOptions { + /** + * Common name to embed in the certificate subject. Defaults to "Prowler" + * to match the openssl/PowerShell examples in the docs. + */ + commonName?: string; + /** Certificate lifetime in days. Default 365. */ + validityDays?: number; + /** RSA modulus length. Default 4096 (matches the openssl example). */ + modulusLength?: 2048 | 3072 | 4096; + /** + * Injected clock for deterministic tests. Defaults to `Date.now()`. + */ + now?: () => Date; +} + +const DEFAULTS: Required< + Pick< + GenerateProwlerCertificateOptions, + "commonName" | "validityDays" | "modulusLength" + > +> = { + commonName: "Prowler", + validityDays: 365, + modulusLength: 4096, +}; + +/** + * Generate a fresh self-signed X.509 certificate + RSA-4096 keypair in the + * browser. Nothing crosses the network — the private key exists only in the + * returned object and inside the caller's memory. + * + * Throws when the browser does not expose SubtleCrypto (e.g. insecure origin + * or old browser). Callers should surface a friendly fallback ("use openssl + * instead") when that happens. + */ +export async function generateProwlerCertificate( + options: GenerateProwlerCertificateOptions = {}, +): Promise { + const commonName = options.commonName ?? DEFAULTS.commonName; + const validityDays = options.validityDays ?? DEFAULTS.validityDays; + const modulusLength = options.modulusLength ?? DEFAULTS.modulusLength; + const now = options.now ?? (() => new Date()); + + const subtle = globalThis.crypto?.subtle; + if (!subtle) { + throw new Error( + "Web Crypto API is not available in this browser. Use the openssl or PowerShell instructions from the certificate generation guide instead.", + ); + } + + const keyPair = (await subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], + )) as CryptoKeyPair; + + const notBefore = now(); + const notAfter = new Date( + notBefore.getTime() + validityDays * 24 * 60 * 60 * 1000, + ); + + const cert = await X509CertificateGenerator.createSelfSigned({ + // Random serial: 16 hex chars is plenty for identification purposes and + // matches how `openssl x509 -req` chooses serials by default. + serialNumber: randomHex(16), + name: `CN=${commonName}`, + notBefore, + notAfter, + signingAlgorithm: { + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256", + }, + keys: keyPair, + extensions: [] as Extension[], + }); + + const certPem = cert.toString("pem"); + const certDer = new Uint8Array(cert.rawData); + const publicCertificateBase64Der = toBase64(certDer); + + const privateKeyPkcs8 = new Uint8Array( + await subtle.exportKey("pkcs8", keyPair.privateKey), + ); + const privateKeyPem = pkcs8ToPem(privateKeyPkcs8); + + // Bundle order matches what azure-identity expects: certificate first, + // private key second. The full bundle is then base64-encoded so it can + // live inside a single form field / JSON payload. + const bundlePem = `${certPem.trim()}\n${privateKeyPem.trim()}\n`; + const privateKeyBundleBase64Pem = toBase64(new TextEncoder().encode(bundlePem)); + + const thumbprintBytes = new Uint8Array( + await subtle.digest("SHA-1", certDer), + ); + const thumbprintHex = bytesToHexUpper(thumbprintBytes); + + return { + publicCertificateBase64Der, + privateKeyBundleBase64Pem, + thumbprintHex, + notBefore: notBefore.toISOString(), + notAfter: notAfter.toISOString(), + }; +} + +/** + * Trigger a browser download of the given base64-DER public certificate as a + * plain-text file, so the user has a single file to open next to the Portal + * deployment blade and copy into the `Certificate Base64` parameter. + * + * Split from `generateProwlerCertificate` so the pure generator can be unit + * tested without stubbing `document.createElement`. + */ +export function downloadPublicCertificateFile( + publicCertificateBase64Der: string, + filename = "prowler-cert-base64.txt", +): void { + const blob = new Blob([publicCertificateBase64Der], { + type: "text/plain;charset=utf-8", + }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + // Free the blob URL immediately; the browser has already started the + // download at this point, so revoking is safe. + URL.revokeObjectURL(url); +} + +// -- helpers --------------------------------------------------------------- + +/** + * Uint8Array → base64. Kept private to this module because the codebase does + * not yet have a shared helper and this one only needs to handle small + * payloads (a cert + key are ~5 KB total). If a shared helper appears later, + * swap this out. + */ +function toBase64(bytes: Uint8Array): string { + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + const byte = bytes[i]; + binary += String.fromCharCode(byte); + } + return btoa(binary); +} + +function randomHex(chars: number): string { + const bytes = new Uint8Array(Math.ceil(chars / 2)); + globalThis.crypto.getRandomValues(bytes); + return bytesToHexUpper(bytes).slice(0, chars); +} + +function bytesToHexUpper(bytes: Uint8Array): string { + let hex = ""; + for (let i = 0; i < bytes.length; i++) { + const byte = bytes[i]; + hex += byte.toString(16).padStart(2, "0").toUpperCase(); + } + return hex; +} + +// `@peculiar/x509` exports certs to PEM directly but not PKCS#8 keys — we +// build the PEM ourselves so the private key format is deterministic and +// matches what `openssl pkey -inform DER` would emit. +function pkcs8ToPem(pkcs8: Uint8Array): string { + const base64 = toBase64(pkcs8); + // 64-char lines is the classic PEM formatting; azure-identity and every + // other PEM parser accept both wrapped and unwrapped, but wrapping keeps + // the file human-readable. + const wrapped = base64.match(/.{1,64}/g)?.join("\n") ?? base64; + return `-----BEGIN PRIVATE KEY-----\n${wrapped}\n-----END PRIVATE KEY-----`; +} + +// Named export needed by @/lib/shared/base64 fallback below. +export const __internal = { pkcs8ToPem, bytesToHexUpper, randomHex }; diff --git a/ui/package.json b/ui/package.json index 05bef52188..1119dbcf23 100644 --- a/ui/package.json +++ b/ui/package.json @@ -49,6 +49,7 @@ "@langchain/openai": "1.4.5", "@lezer/highlight": "1.2.3", "@next/third-parties": "16.2.9", + "@peculiar/x509": "2.0.0", "@radix-ui/react-alert-dialog": "1.1.14", "@radix-ui/react-avatar": "1.1.11", "@radix-ui/react-checkbox": "1.3.3", @@ -107,6 +108,7 @@ "react-hook-form": "7.62.0", "react-markdown": "10.1.0", "recharts": "2.15.4", + "reflect-metadata": "0.2.2", "require-in-the-middle": "8.0.1", "server-only": "0.0.1", "sharp": "0.35.3", diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 6941491765..f1790c4802 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -85,6 +85,9 @@ importers: '@next/third-parties': specifier: 16.2.9 version: 16.2.9(next@16.2.11(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(@types/node@24.10.8)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + '@peculiar/x509': + specifier: 2.0.0 + version: 2.0.0 '@radix-ui/react-alert-dialog': specifier: 1.1.14 version: 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -259,6 +262,9 @@ importers: recharts: specifier: 2.15.4 version: 2.15.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + reflect-metadata: + specifier: 0.2.2 + version: 0.2.2 require-in-the-middle: specifier: 8.0.1 version: 8.0.1 @@ -1787,6 +1793,43 @@ packages: '@panva/hkdf@1.2.1': resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} + '@peculiar/asn1-cms@2.8.0': + resolution: {integrity: sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==} + + '@peculiar/asn1-csr@2.8.0': + resolution: {integrity: sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==} + + '@peculiar/asn1-ecc@2.8.0': + resolution: {integrity: sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==} + + '@peculiar/asn1-pfx@2.8.0': + resolution: {integrity: sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==} + + '@peculiar/asn1-pkcs8@2.8.0': + resolution: {integrity: sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==} + + '@peculiar/asn1-pkcs9@2.8.0': + resolution: {integrity: sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==} + + '@peculiar/asn1-rsa@2.8.0': + resolution: {integrity: sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==} + + '@peculiar/asn1-schema@2.8.0': + resolution: {integrity: sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==} + + '@peculiar/asn1-x509-attr@2.8.0': + resolution: {integrity: sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==} + + '@peculiar/asn1-x509@2.8.0': + resolution: {integrity: sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==} + + '@peculiar/utils@2.0.3': + resolution: {integrity: sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==} + + '@peculiar/x509@2.0.0': + resolution: {integrity: sha512-r10lkuy6BNfRmyYdRAfgu6dq0HOmyIV2OLhXWE3gDEPBdX1b8miztJVyX/UxWhLwemNyDP3CLZHpDxDwSY0xaA==} + engines: {node: '>=20.0.0'} + '@playwright/test@1.56.1': resolution: {integrity: sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==} engines: {node: '>=18'} @@ -3835,6 +3878,10 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + asn1js@3.0.10: + resolution: {integrity: sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==} + engines: {node: '>=12.0.0'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -6157,6 +6204,13 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pvtsutils@1.3.6: + resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} + + pvutils@1.2.0: + resolution: {integrity: sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==} + engines: {node: '>=16.0.0'} + qs@6.15.2: resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} engines: {node: '>=0.6'} @@ -6272,6 +6326,9 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -6805,9 +6862,16 @@ packages: resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} engines: {node: '>=6.10'} + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsyringe@4.10.0: + resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==} + engines: {node: '>= 6.0.0'} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -8840,6 +8904,99 @@ snapshots: '@panva/hkdf@1.2.1': {} + '@peculiar/asn1-cms@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + '@peculiar/asn1-x509-attr': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-csr@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-ecc@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-pfx@2.8.0': + dependencies: + '@peculiar/asn1-cms': 2.8.0 + '@peculiar/asn1-pkcs8': 2.8.0 + '@peculiar/asn1-rsa': 2.8.0 + '@peculiar/asn1-schema': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs8@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs9@2.8.0': + dependencies: + '@peculiar/asn1-cms': 2.8.0 + '@peculiar/asn1-pfx': 2.8.0 + '@peculiar/asn1-pkcs8': 2.8.0 + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + '@peculiar/asn1-x509-attr': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-rsa@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-schema@2.8.0': + dependencies: + '@peculiar/utils': 2.0.3 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-x509-attr@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-x509@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/utils': 2.0.3 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/utils@2.0.3': + dependencies: + tslib: 2.8.1 + + '@peculiar/x509@2.0.0': + dependencies: + '@peculiar/asn1-cms': 2.8.0 + '@peculiar/asn1-csr': 2.8.0 + '@peculiar/asn1-ecc': 2.8.0 + '@peculiar/asn1-pkcs9': 2.8.0 + '@peculiar/asn1-rsa': 2.8.0 + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + pvtsutils: 1.3.6 + tslib: 2.8.1 + tsyringe: 4.10.0 + '@playwright/test@1.56.1': dependencies: playwright: 1.56.1 @@ -11035,6 +11192,12 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + asn1js@3.0.10: + dependencies: + pvtsutils: 1.3.6 + pvutils: 1.2.0 + tslib: 2.8.1 + assertion-error@2.0.1: {} ast-types-flow@0.0.8: {} @@ -13761,6 +13924,12 @@ snapshots: punycode@2.3.1: {} + pvtsutils@1.3.6: + dependencies: + tslib: 2.8.1 + + pvutils@1.2.0: {} + qs@6.15.2: dependencies: side-channel: 1.1.0 @@ -13888,6 +14057,8 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 + reflect-metadata@0.2.2: {} + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -14545,8 +14716,14 @@ snapshots: ts-dedent@2.2.0: {} + tslib@1.14.1: {} + tslib@2.8.1: {} + tsyringe@4.10.0: + dependencies: + tslib: 1.14.1 + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 diff --git a/ui/tests/providers/providers-page.ts b/ui/tests/providers/providers-page.ts index 070649a4a7..b1e99e81ec 100644 --- a/ui/tests/providers/providers-page.ts +++ b/ui/tests/providers/providers-page.ts @@ -123,9 +123,12 @@ export interface AWSProviderCredential { secretAccessKey?: string; } -// AZURE credential options +// AZURE credential options — mirror the M365 selector added for the +// Deploy-to-Azure quick-start (PROWLER-2378). "credentials" keeps the +// legacy name for the client-secret path so existing specs keep working. export const AZURE_CREDENTIAL_OPTIONS = { AZURE_CREDENTIALS: "credentials", + AZURE_CERTIFICATE_CREDENTIALS: "certificate", } as const; // AZURE credential type @@ -316,6 +319,10 @@ export class ProvidersPage extends BasePage { readonly roleCredentialsRadio: Locator; readonly staticCredentialsRadio: Locator; + // Azure credentials type selection + readonly azureServicePrincipalRadio: Locator; + readonly azureCertificateCredentialsRadio: Locator; + // M365 credentials type selection readonly m365StaticCredentialsRadio: Locator; readonly m365CertificateCredentialsRadio: Locator; @@ -595,6 +602,16 @@ export class ProvidersPage extends BasePage { name: /Connect via Credentials/i, }); + // Radios for selecting Azure credentials method (PROWLER-2378 added the + // certificate flow; the client-secret radio stayed but is now inside a + // selector step instead of being the default form). + this.azureServicePrincipalRadio = page.getByRole("radio", { + name: /Service Principal with Client Secret/i, + }); + this.azureCertificateCredentialsRadio = page.getByRole("radio", { + name: /Certificate Authentication/i, + }); + // Radios for selecting M365 credentials method this.m365StaticCredentialsRadio = page.getByRole("radio", { name: /App Client Secret Credentials/i, @@ -1076,6 +1093,22 @@ export class ProvidersPage extends BasePage { } } + async selectAzureCredentialsType(type: AZURECredentialType): Promise { + // PROWLER-2378 introduced a credential-type selector for Azure, mirroring + // AWS/GCP/M365. The credentials form is now behind a radio choice, so + // any spec that reaches Azure credentials must pick the type first. + await this.verifyWizardModalOpen(); + await expect(this.azureServicePrincipalRadio).toBeVisible(); + + if (type === AZURE_CREDENTIAL_OPTIONS.AZURE_CREDENTIALS) { + await this.azureServicePrincipalRadio.click({ force: true }); + } else if (type === AZURE_CREDENTIAL_OPTIONS.AZURE_CERTIFICATE_CREDENTIALS) { + await this.azureCertificateCredentialsRadio.click({ force: true }); + } else { + throw new Error(`Invalid Azure credential type: ${type}`); + } + } + async selectM365CredentialsType(type: M365CredentialType): Promise { await this.verifyWizardModalOpen(); await expect(this.m365StaticCredentialsRadio).toBeVisible(); diff --git a/ui/tests/providers/providers.spec.ts b/ui/tests/providers/providers.spec.ts index 8900f3fd5f..6f7ebf5789 100644 --- a/ui/tests/providers/providers.spec.ts +++ b/ui/tests/providers/providers.spec.ts @@ -406,6 +406,10 @@ test.describe("Add Provider", () => { await providersPage.fillAZUREProviderDetails(azureProviderData); await providersPage.clickNext(); + // Azure now shows a credential-type selector (PROWLER-2378) — pick + // the client-secret path before landing on the credentials form. + await providersPage.selectAzureCredentialsType(azureCredentials.type); + // Fill static credentials details await providersPage.fillAZURECredentials(azureCredentials); await providersPage.clickNext();