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

This commit is contained in:
Lydia Vilchez
2026-08-14 13:14:42 +02:00
parent 903025aadc
commit 4004c53bc7
5 changed files with 108 additions and 169 deletions
@@ -51,7 +51,7 @@ describe("AzureCertificateCredentialsForm", () => {
// When
const docsLink = screen.getByRole("link", {
name: /certificate generation guide/i,
name: /full guide/i,
});
// Then
@@ -23,22 +23,16 @@ const AZURE_PORTAL_NEW_APP_REGISTRATION_URL =
const AZURE_PORTAL_ENTERPRISE_APPLICATIONS_URL =
"https://portal.azure.com/#view/Microsoft_AAD_IAM/StartboardApplicationsMenuBlade/~/AppAppsPreview";
const DOCS_CERT_GENERATION_URL =
"https://docs.prowler.com/user-guide/providers/azure/authentication#certificate-authentication";
export const AzureCertificateCredentialsForm = ({
control,
}: {
control: Control<AzureCertificateCredentials>;
}) => {
// The Deploy-to-Azure link is a plain constant today: unlike the AWS
// organisation link, it does not need any wizard-collected value in the
// URL (subscription and the service principal Object ID are filled inside
// the Portal deployment blade). Prefilling the SP Object ID via the URL
// would need the Portal's uiFormDefinitionUri machinery, which is more
// moving parts than the copy-paste we already ask the user to do.
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>(
@@ -52,112 +46,67 @@ export const AzureCertificateCredentialsForm = ({
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 App Registration's Certificates blade 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.";
: "Failed to generate the certificate in-browser. Fall back to openssl / PowerShell.";
setGeneratorError(message);
} finally {
setIsGeneratingCert(false);
}
setIsGeneratingCert(false);
};
return (
<>
<div className="flex flex-col">
<div className="flex flex-col gap-1">
<div className="text-md text-text-neutral-primary leading-9 font-bold">
Certificate Authentication (Recommended)
</div>
<div className="text-text-neutral-tertiary text-sm">
Prowler authenticates against Azure with an X.509 certificate bound
to an Entra ID App Registration. The setup is a two-step flow: create
the App Registration and upload the certificate through the Azure
Portal, then click <strong>Deploy to Azure</strong> below to grant
that App Registration the read-only permissions Prowler needs on the
subscription.
<div className="text-text-neutral-tertiary text-xs">
Requires <strong>Application Administrator</strong> (Entra ID) and{" "}
<strong>Owner</strong> (subscription).{" "}
<Link
href={DOCS_CERT_GENERATION_URL}
target="_blank"
rel="noopener noreferrer"
className="text-button-tertiary"
>
Full guide
</Link>
.
</div>
</div>
<div className="border-content-warning-tertiary bg-content-warning-tertiary/10 flex flex-col gap-2 rounded-md border p-3">
<div className="text-text-neutral-primary text-sm font-semibold">
Before you start
</div>
<p className="text-text-neutral-tertiary text-xs">
Your Azure account needs permissions in <em>two</em> separate systems.
The wizard cannot work around a missing role request them from your
Azure administrator if needed:
</p>
<ul className="text-text-neutral-tertiary ml-4 list-disc space-y-1 text-xs">
<li>
<strong>Microsoft Entra ID</strong>:{" "}
<strong>Application Administrator</strong>,{" "}
<strong>Cloud Application Administrator</strong>, or{" "}
<strong>Global Administrator</strong> needed to create the App
Registration and upload the certificate in step 1. Not required if
your tenant already allows all users to register applications
(Entra ID Users User settings).
</li>
<li>
<strong>Azure RBAC</strong>: <strong>Owner</strong> on the target
subscription needed by <em>Deploy to Azure</em> in step 3 to
assign the <code>Reader</code> and custom <code>ProwlerRole</code>{" "}
roles.
</li>
</ul>
</div>
<ol className="border-content-neutral-tertiary flex flex-col gap-4 rounded-md border p-4">
<li className="flex flex-col gap-2">
<div className="text-text-neutral-primary text-sm font-semibold">
1. Create the App Registration in Azure
</div>
<p className="text-text-neutral-tertiary text-xs">
Open the Portal in a new tab, sign in to the tenant you want
Prowler to scan, and register a new application. Any name works
(e.g. <code>Prowler</code>); keep the default single-tenant
audience and no redirect URI.
</p>
<ol className="border-content-neutral-tertiary flex flex-col gap-3 rounded-md border p-4 text-sm">
<li className="flex items-center justify-between gap-3">
<span>
<strong>1.</strong> Create the App Registration in Azure.
</span>
<Button variant="link" size="link-sm" asChild>
<a
href={AZURE_PORTAL_NEW_APP_REGISTRATION_URL}
target="_blank"
rel="noopener noreferrer"
>
Open Azure Portal New App Registration
New App Reg
</a>
</Button>
</li>
<li className="flex flex-col gap-2">
<div className="text-text-neutral-primary text-sm font-semibold">
2. Attach a certificate to that App Registration
</div>
<p className="text-text-neutral-tertiary text-xs">
In your new App Registration, open{" "}
<em>Certificates &amp; secrets Certificates Upload
certificate</em> and upload the public certificate file
(<code>.cer</code>). The button below generates the keypair in your
browser the private key is auto-filled into the form below and
the public certificate downloads as a text file you decode and
upload.
</p>
<div className="border-content-neutral-tertiary flex flex-col items-start gap-2 rounded-md border border-dashed p-3">
<p className="text-text-neutral-tertiary text-xs">
<strong>The certificate never leaves your browser.</strong>{" "}
Prowler generates the keypair client-side and only receives the
private half; the public half goes to Entra ID via the manual
upload.
</p>
<div className="flex items-center justify-between gap-3">
<span>
<strong>2.</strong> Generate a certificate and upload{" "}
<code>prowler-cert.cer</code> to your App Reg (
<em>Certificates &amp; secrets</em>).
</span>
<Button
type="button"
variant="default"
@@ -165,81 +114,50 @@ export const AzureCertificateCredentialsForm = ({
onClick={handleGenerateCertificate}
disabled={isGeneratingCert}
>
{isGeneratingCert ? "Generating…" : "Generate certificate for me"}
{isGeneratingCert ? "Generating…" : "Generate cert"}
</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> decode it back to a{" "}
<code>.cer</code> with{" "}
<code>base64 -D -i prowler-cert-base64.txt -o prowler.cer</code>{" "}
and upload <code>prowler.cer</code> in the Portal.
</p>
)}
<p className="text-text-neutral-tertiary text-xs">
Prefer the command line? See the{" "}
<Link
href="https://docs.prowler.com/user-guide/providers/azure/authentication#certificate-authentication"
target="_blank"
rel="noopener noreferrer"
className="text-button-tertiary p-0 text-sm"
>
openssl / PowerShell instructions
</Link>
.
</div>
{generatorError && (
<p className="text-text-error-primary text-xs">{generatorError}</p>
)}
{generatedThumbprint && (
<p className="text-text-success-primary text-xs">
Downloaded <code>prowler-cert.cer</code> · thumbprint{" "}
<code>{generatedThumbprint}</code>
</p>
</div>
)}
</li>
<li className="flex flex-col gap-2">
<div className="text-text-neutral-primary text-sm font-semibold">
3. Grant the App Registration read access to your subscription
</div>
<p className="text-text-neutral-tertiary text-xs">
You need the App Registration&apos;s <strong>Service Principal
Object ID</strong> not the same as the App Registration&apos;s
Object ID. Find it in{" "}
<li className="flex items-center justify-between gap-3">
<span>
<strong>3.</strong> Grant read access (needs the SP Object ID from{" "}
<Link
href={AZURE_PORTAL_ENTERPRISE_APPLICATIONS_URL}
target="_blank"
rel="noopener noreferrer"
className="text-button-tertiary p-0 text-sm"
className="text-button-tertiary"
>
Entra ID Enterprise applications
</Link>{" "}
search for the app you just created <em>Overview Object
ID</em>. Then click Deploy to Azure below and paste that Object ID
when the Portal asks.
</p>
Enterprise applications
</Link>
).
</span>
<Button variant="link" size="link-sm" asChild>
<a
href={deployToAzureUrl}
target="_blank"
rel="noopener noreferrer"
>
Deploy to Azure
Deploy to Azure
</a>
</Button>
</li>
<li className="flex flex-col gap-2">
<div className="text-text-neutral-primary text-sm font-semibold">
4. Come back here and fill the fields below
</div>
<p className="text-text-neutral-tertiary text-xs">
Copy the <strong>Tenant ID</strong> from Entra ID Overview,
the <strong>Application (client) ID</strong> from your App
Registration&apos;s Overview page, and paste both into the fields
below. The private key field is already pre-filled from step 2 (or
paste your own base64-encoded PEM bundle / PKCS#12 if you
generated the keypair manually).
</p>
<li>
<strong>4.</strong> Paste the IDs below (Tenant ID and Application
(client) ID from your App Reg&apos;s Overview).
</li>
</ol>
<WizardInputField
control={control}
name="tenant_id"
@@ -265,27 +183,11 @@ export const AzureCertificateCredentialsForm = ({
name="certificate_content"
label="Certificate Private Key (Base64)"
labelPlacement="inside"
placeholder="Paste the base64-encoded private key that pairs with the certificate uploaded to Entra ID"
placeholder="Auto-filled by 'Generate cert', or paste your own"
variant="bordered"
isRequired
minRows={4}
/>
<p className="text-text-neutral-tertiary text-sm">
This is the <strong>base64-encoded private key</strong> that matches
the certificate you uploaded to Entra ID in step 2 (not the public
certificate, and not the thumbprint). Use the{" "}
<em>Generate certificate for me</em> button in step 2, or follow the
manual openssl / PowerShell instructions in the{" "}
<Link
href="https://docs.prowler.com/user-guide/providers/azure/authentication#certificate-authentication"
target="_blank"
rel="noopener noreferrer"
className="text-button-tertiary p-0 text-sm"
>
certificate generation guide
</Link>
.
</p>
</>
);
};
+15 -6
View File
@@ -126,14 +126,19 @@ describe("downloadPublicCertificateFile", () => {
return originalCreateElement(tag);
}) as typeof document.createElement;
// When
downloadPublicCertificateFile("MIIBase64Contents", "prowler-cert.txt");
// When — pass a valid base64 payload (the helper now decodes it back to
// raw DER bytes so the download is a `.cer` file the Portal accepts).
// `MII=` is short but valid base64 that decodes to bytes [0x30, 0x82],
// matching the ASN.1 SEQUENCE tag prefix that real X.509 DER starts
// with — good enough to prove the decode path without pulling in a
// real cert.
downloadPublicCertificateFile("MII=", "prowler-cert.cer");
// Then
expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
expect(clickSpy).toHaveBeenCalledTimes(1);
expect(realAnchor.href).toContain(objectUrl);
expect(realAnchor.download).toBe("prowler-cert.txt");
expect(realAnchor.download).toBe("prowler-cert.cer");
// Revoked to avoid leaking the blob URL for the tab's lifetime.
expect(revokeSpy).toHaveBeenCalledWith(objectUrl);
});
@@ -149,10 +154,14 @@ describe("downloadPublicCertificateFile", () => {
return originalCreateElement(tag);
}) as typeof document.createElement;
// When
downloadPublicCertificateFile("payload");
// When — pass a real base64 payload; the helper now decodes it back to
// raw DER bytes so the download is a valid `.cer` file the Portal accepts
// without any manual decoding step. `AA==` decodes to a single 0x00 byte,
// which is enough to exercise the base64→bytes path without pulling a
// real certificate into the test.
downloadPublicCertificateFile("AA==");
// Then
expect(realAnchor.download).toBe("prowler-cert-base64.txt");
expect(realAnchor.download).toBe("prowler-cert.cer");
});
});
+26 -6
View File
@@ -165,19 +165,25 @@ export async function generateProwlerCertificate(
}
/**
* 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.
* Trigger a browser download of the public certificate as a `.cer` file (raw
* DER bytes) so the user can upload it directly on the App Registration's
* *Certificates* blade in the Azure Portal — no terminal step or manual
* base64 decoding required.
*
* The Portal upload accepts `.cer`, `.pem` and `.crt`; we emit `.cer` because
* it matches the raw DER bytes we already have and is the extension the
* Portal upload dialog shows first.
*
* 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",
filename = "prowler-cert.cer",
): void {
const blob = new Blob([publicCertificateBase64Der], {
type: "text/plain;charset=utf-8",
const derBytes = base64ToBytes(publicCertificateBase64Der);
const blob = new Blob([derBytes as BlobPart], {
type: "application/x-x509-ca-cert",
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
@@ -208,6 +214,20 @@ function toBase64(bytes: Uint8Array): string {
return btoa(binary);
}
/**
* Inverse of `toBase64` — decode a base64 string back to raw bytes. Only used
* by `downloadPublicCertificateFile` to reconstitute the DER blob for the
* `.cer` download; the generator itself works in raw bytes end-to-end.
*/
function base64ToBytes(base64: string): Uint8Array {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
function randomHex(chars: number): string {
const bytes = new Uint8Array(Math.ceil(chars / 2));
globalThis.crypto.getRandomValues(bytes);
@@ -78,6 +78,14 @@ export const buildAzureSecret = (formData: FormData) => {
formData,
ProviderCredentialFields.TENANT_ID,
),
// Certificate auth (PROWLER-2378). The backend `AzureProviderSecret`
// serializer accepts either `client_secret` or `certificate_content`
// and rejects both-empty, so we always forward the field — the empty
// one gets stripped by `filterEmptyValues` below.
[ProviderCredentialFields.CERTIFICATE_CONTENT]: getFormValue(
formData,
ProviderCredentialFields.CERTIFICATE_CONTENT,
),
};
return filterEmptyValues(secret);
};