mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-19 09:30:21 +00:00
feat(azure): add Deploy-to-Azure Bicep template and certificate auth
This commit is contained in:
+1
-1
@@ -51,7 +51,7 @@ describe("AzureCertificateCredentialsForm", () => {
|
||||
|
||||
// When
|
||||
const docsLink = screen.getByRole("link", {
|
||||
name: /certificate generation guide/i,
|
||||
name: /full guide/i,
|
||||
});
|
||||
|
||||
// Then
|
||||
|
||||
+58
-156
@@ -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 & 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 & 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's <strong>Service Principal
|
||||
Object ID</strong> — not the same as the App Registration'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'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'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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user