feat(azure): add Deploy-to-Azure Bicep template and certificate auth

This commit is contained in:
Lydia Vilchez committed 2026-08-12 09:48:34 +02:00
1 parent ab738e8a4e
commit d2df95546b
9 files changed
+712 -18

No files matched your search

@@ -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
@@ -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'))]"
}
}
}
}
@@ -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<string | null>(null);
const [generatedThumbprint, setGeneratedThumbprint] = useState<string | null>(
null,
);
const { setValue } = useFormContext<AzureCertificateCredentials>();
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 (
<>
<div className="flex flex-col">
@@ -48,6 +93,40 @@ export const AzureCertificateCredentialsForm = ({
&ldquo;Certificate Private Key&rdquo; field below.
</p>
</div>
<div className="border-content-neutral-tertiary flex flex-col items-start gap-2 rounded-md border border-dashed p-3">
<div className="text-text-neutral-primary text-sm font-semibold">
Don&apos;t have a certificate yet?
</div>
<p className="text-text-neutral-tertiary text-xs">
Generate a self-signed X.509 certificate right in your browser. The
<strong> private key never leaves your device</strong> — it goes
straight into the field below. The public certificate downloads as a
text file for you to paste into the Bicep{" "}
<code>Certificate Base64</code> parameter in the Portal.
</p>
<Button
type="button"
variant="default"
size="sm"
onClick={handleGenerateCertificate}
disabled={isGeneratingCert}
>
{isGeneratingCert
? "Generating…"
: "Generate certificate for me"}
</Button>
{generatorError && (
<p className="text-text-error-primary text-xs">{generatorError}</p>
)}
{generatedThumbprint && (
<p className="text-text-success-primary text-xs">
Done. Certificate SHA-1 thumbprint: <code>{generatedThumbprint}</code>
. Downloaded <code>prowler-cert-base64.txt</code> — paste its
contents into the Bicep <code>Certificate Base64</code> parameter,
then keep filling the fields below.
</p>
)}
</div>
<WizardInputField
control={control}
name="tenant_id"
@@ -81,8 +160,9 @@ export const AzureCertificateCredentialsForm = ({
<p className="text-text-neutral-tertiary text-sm">
This is the <strong>base64-encoded private key</strong> 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{" "}
<em>Generate certificate for me</em> button above, or follow the manual
openssl / PowerShell instructions in the{" "}
<Link
href="https://docs.prowler.com/user-guide/providers/azure/authentication#certificate-authentication"
target="_blank"
+158
View File
@@ -0,0 +1,158 @@
import { webcrypto } from "node:crypto";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
downloadPublicCertificateFile,
generateProwlerCertificate,
} from "./azure-cert-generator";
// jsdom exposes `globalThis.crypto` but historically without a working
// `subtle` implementation. Node 20+ ships one on `node:crypto.webcrypto`
// that satisfies both @peculiar/x509 and our helper. Bind it once so the
// module-level `cryptoProvider.set(...)` inside the SUT resolves it.
beforeAll(() => {
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");
});
});
+240
View File
@@ -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<GeneratedProwlerCertificate> {
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 };
+2
View File
@@ -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",
+177
View File
@@ -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
+34 -1
View File
@@ -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<void> {
// 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<void> {
await this.verifyWizardModalOpen();
await expect(this.m365StaticCredentialsRadio).toBeVisible();
+4
View File
@@ -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();