mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-29 23:04:23 +00:00
feat(ui): guide Azure certificate onboarding
- Keep the six-step provider workflow in the certificate form - Generate certificate bundles in the browser for secure upload - Link deployment actions to the documentation-hosted ARM template
This commit is contained in:
14 files changed
+521
-157
No files matched your search
@@ -0,0 +1 @@
|
||||
Azure certificate authentication in the add-provider wizard with six-step onboarding, in-browser certificate generation, and Mintlify-hosted ARM template deployment links
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { describe, expect, vi } from "vitest";
|
||||
|
||||
import { it } from "@/__tests__/fixtures";
|
||||
import { render } from "@/__tests__/render-browser";
|
||||
import { AzureCertificateCredentials } from "@/types";
|
||||
|
||||
import { AzureCertificateCredentialsForm } from "./azure-certificate-credentials-form";
|
||||
|
||||
const Harness = () => {
|
||||
const form = useForm<AzureCertificateCredentials>();
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<AzureCertificateCredentialsForm control={form.control} />
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe("AzureCertificateCredentialsForm browser flow", () => {
|
||||
it("renders deployment guidance and keeps certificate generation functional", async () => {
|
||||
// When
|
||||
const view = await render(<Harness />);
|
||||
|
||||
// Then
|
||||
expect(document.querySelectorAll("ol > li")).toHaveLength(6);
|
||||
expect(
|
||||
view.getByRole("link", { name: "Deploy to Azure" }).element(),
|
||||
).toHaveAttribute(
|
||||
"href",
|
||||
"https://portal.azure.com/#create/Microsoft.Template/uri/https%3A%2F%2Fdocs.prowler.com%2Fassets%2Ftemplates%2Fazure%2Fprowler-scan.json",
|
||||
);
|
||||
expect(
|
||||
view.getByRole("link", { name: "Open template" }).element(),
|
||||
).toHaveAttribute(
|
||||
"href",
|
||||
"https://docs.prowler.com/assets/templates/azure/prowler-scan.json",
|
||||
);
|
||||
|
||||
await view.getByRole("button", { name: "Generate certificate" }).click();
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(
|
||||
view.getByText(/Downloaded prowler-cert\.cer/).element(),
|
||||
).toBeVisible();
|
||||
},
|
||||
{ timeout: 20_000 },
|
||||
);
|
||||
const bundle = view
|
||||
.getByRole("textbox", {
|
||||
name: "Certificate and Private Key Bundle (Base64)",
|
||||
})
|
||||
.element() as HTMLTextAreaElement;
|
||||
expect(bundle.value.length).toBeGreaterThan(100);
|
||||
});
|
||||
});
|
||||
+95
-28
@@ -6,9 +6,6 @@ import { AzureCertificateCredentials } from "@/types";
|
||||
|
||||
import { AzureCertificateCredentialsForm } from "./azure-certificate-credentials-form";
|
||||
|
||||
// Wraps the form in a react-hook-form context so the WizardInputField and
|
||||
// WizardTextareaField mount without exploding. We are testing the Deploy to
|
||||
// Azure link and the field structure, not the input controls themselves.
|
||||
const Harness = () => {
|
||||
const form = useForm<AzureCertificateCredentials>();
|
||||
return (
|
||||
@@ -18,48 +15,118 @@ const Harness = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const expectExternalLinkIcon = (link: HTMLElement) => {
|
||||
const icon = link.querySelector("svg.lucide-external-link");
|
||||
|
||||
expect(icon).toBeInTheDocument();
|
||||
expect(icon).toHaveAttribute("aria-hidden", "true");
|
||||
expect(icon).toHaveClass("size-3.5", "shrink-0");
|
||||
};
|
||||
|
||||
describe("AzureCertificateCredentialsForm", () => {
|
||||
it("renders the Deploy to Azure link with the Portal custom-deployment URL and safe target attributes", () => {
|
||||
it("renders the Deploy to Azure link with the docs-hosted template", () => {
|
||||
// Given
|
||||
render(<Harness />);
|
||||
|
||||
// When
|
||||
const link = screen.getByRole("link", { name: /deploy to azure/i });
|
||||
const link = screen.getByRole("link", { name: "Deploy to Azure" });
|
||||
|
||||
// Then
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
expect(link).toHaveAttribute("rel", "noopener noreferrer");
|
||||
|
||||
const href = link.getAttribute("href") ?? "";
|
||||
expect(
|
||||
href.startsWith(
|
||||
"https://portal.azure.com/#create/Microsoft.Template/uri/",
|
||||
),
|
||||
).toBe(true);
|
||||
// The Bicep template URL must be percent-encoded exactly once into the
|
||||
// fragment; asserting on the encoded slashes guards against a future
|
||||
// refactor that swaps `encodeURIComponent` for a helper that leaves them
|
||||
// raw and breaks the Portal parser.
|
||||
expect(href).toContain(
|
||||
"https%3A%2F%2Fprowler-cloud-public.s3.eu-west-1.amazonaws.com%2Fpermissions%2Ftemplates%2Fazure%2Fbicep%2Fprowler-scan.json",
|
||||
expect(link).toHaveAttribute(
|
||||
"href",
|
||||
"https://portal.azure.com/#create/Microsoft.Template/uri/https%3A%2F%2Fdocs.prowler.com%2Fassets%2Ftemplates%2Fazure%2Fprowler-scan.json",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the certificate generation docs link pointing at the Azure authentication doc", () => {
|
||||
it("links the manual template fallback to the docs-hosted JSON", () => {
|
||||
// Given / When
|
||||
render(<Harness />);
|
||||
|
||||
// Then
|
||||
const link = screen.getByRole("link", { name: "Open template" });
|
||||
expect(link).toHaveAttribute(
|
||||
"href",
|
||||
"https://docs.prowler.com/assets/templates/azure/prowler-scan.json",
|
||||
);
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
expect(link).toHaveAttribute("rel", "noopener noreferrer");
|
||||
expectExternalLinkIcon(link);
|
||||
expect(
|
||||
screen.getByText(/build your own template in the editor/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders every external link with the shared icon and safe attributes", () => {
|
||||
// Given
|
||||
render(<Harness />);
|
||||
|
||||
// When
|
||||
const docsLink = screen.getByRole("link", {
|
||||
name: /full guide/i,
|
||||
});
|
||||
// When / Then
|
||||
const links = [
|
||||
{
|
||||
element: screen.getByRole("link", { name: "Full guide" }),
|
||||
href: "https://docs.prowler.com/user-guide/providers/azure/authentication#certificate-authentication",
|
||||
},
|
||||
{
|
||||
element: screen.getByRole("link", { name: "New App Registration" }),
|
||||
href: "https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/CreateApplicationBlade",
|
||||
},
|
||||
{
|
||||
element: screen.getByRole("link", {
|
||||
name: "Enterprise applications",
|
||||
}),
|
||||
href: "https://portal.azure.com/#view/Microsoft_AAD_IAM/StartboardApplicationsMenuBlade/~/AppAppsPreview",
|
||||
},
|
||||
{
|
||||
element: screen.getByRole("link", { name: "Deploy to Azure" }),
|
||||
},
|
||||
{
|
||||
element: screen.getByRole("link", { name: "Open template" }),
|
||||
href: "https://docs.prowler.com/assets/templates/azure/prowler-scan.json",
|
||||
},
|
||||
];
|
||||
|
||||
for (const { element, href } of links) {
|
||||
if (href) expect(element).toHaveAttribute("href", href);
|
||||
expect(element).toHaveAttribute("target", "_blank");
|
||||
expect(element).toHaveAttribute("rel", "noopener noreferrer");
|
||||
expectExternalLinkIcon(element);
|
||||
}
|
||||
});
|
||||
|
||||
it("renders the approved six-step certificate onboarding guidance", () => {
|
||||
// Given / When
|
||||
render(<Harness />);
|
||||
|
||||
// Then
|
||||
expect(docsLink).toHaveAttribute(
|
||||
"href",
|
||||
"https://docs.prowler.com/user-guide/providers/azure/authentication#certificate-authentication",
|
||||
const steps = screen.getAllByRole("listitem");
|
||||
expect(steps).toHaveLength(6);
|
||||
expect(steps[0]).toHaveTextContent(
|
||||
"1. Create an App Registration. Copy its Directory (tenant) ID and Application (client) ID.",
|
||||
);
|
||||
expect(steps[1]).toHaveTextContent(
|
||||
"2. Generate the certificate bundle and upload the public prowler-cert.cer file under Certificates & secrets. The private bundle is filled below and is submitted to Prowler only when you connect.",
|
||||
);
|
||||
expect(steps[2]).toHaveTextContent(
|
||||
"3. Add Microsoft Graph application permissions: AuditLog.Read.All, Directory.Read.All (or Domain.Read.All), and Policy.Read.All. Then grant admin consent.",
|
||||
);
|
||||
expect(steps[3]).toHaveTextContent(
|
||||
"4. Copy the Service Principal Object ID from Enterprise applications.",
|
||||
);
|
||||
expect(steps[4]).toHaveTextContent(
|
||||
"5. Deploy subscription RBAC with that Service Principal Object ID. The template creates ProwlerRole and assigns Reader and ProwlerRole; it does not create Entra resources.",
|
||||
);
|
||||
expect(steps[5]).toHaveTextContent(
|
||||
"6. Return to Prowler and connect. Paste the Tenant ID and Application Client ID below; the generated certificate bundle is already filled.",
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("textbox", {
|
||||
name: "Certificate and Private Key Bundle (Base64)",
|
||||
}),
|
||||
).toHaveAttribute(
|
||||
"placeholder",
|
||||
"Auto-filled by 'Generate certificate', or paste your own",
|
||||
);
|
||||
expect(docsLink).toHaveAttribute("target", "_blank");
|
||||
expect(docsLink).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
});
|
||||
+70
-30
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { Control, useFormContext } from "react-hook-form";
|
||||
@@ -13,7 +14,10 @@ import {
|
||||
downloadPublicCertificateFile,
|
||||
generateProwlerCertificate,
|
||||
} from "@/lib/azure-cert-generator";
|
||||
import { getAzureDeploymentQuickLink } from "@/lib/external-urls";
|
||||
import {
|
||||
getAzureDeploymentQuickLink,
|
||||
PROWLER_AZURE_ARM_TEMPLATE_URL,
|
||||
} from "@/lib/external-urls";
|
||||
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
|
||||
import { AzureCertificateCredentials } from "@/types";
|
||||
|
||||
@@ -32,7 +36,6 @@ export const AzureCertificateCredentialsForm = ({
|
||||
control: Control<AzureCertificateCredentials>;
|
||||
}) => {
|
||||
const deployToAzureUrl = getAzureDeploymentQuickLink();
|
||||
|
||||
const [isGeneratingCert, setIsGeneratingCert] = useState(false);
|
||||
const [generatorError, setGeneratorError] = useState<string | null>(null);
|
||||
const [generatedThumbprint, setGeneratedThumbprint] = useState<string | null>(
|
||||
@@ -70,15 +73,16 @@ export const AzureCertificateCredentialsForm = ({
|
||||
Certificate Authentication (Recommended)
|
||||
</div>
|
||||
<div className="text-text-neutral-tertiary text-xs">
|
||||
Requires <strong>Application Administrator</strong> (Entra ID) and{" "}
|
||||
<strong>Owner</strong> (subscription).{" "}
|
||||
Requires permission to create App Registrations and grant Microsoft
|
||||
Graph admin consent, plus <strong>Owner</strong> on the subscription.{" "}
|
||||
<Link
|
||||
href={DOCS_CERT_GENERATION_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-button-tertiary"
|
||||
className="text-button-tertiary inline-flex items-center gap-2"
|
||||
>
|
||||
Full guide
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
<span>Full guide</span>
|
||||
</Link>
|
||||
.
|
||||
</div>
|
||||
@@ -87,7 +91,8 @@ export const AzureCertificateCredentialsForm = ({
|
||||
<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.
|
||||
<strong>1.</strong> Create an App Registration. Copy its Directory
|
||||
(tenant) ID and Application (client) ID.
|
||||
</span>
|
||||
<Button variant="link" size="link-sm" asChild>
|
||||
<a
|
||||
@@ -95,7 +100,8 @@ export const AzureCertificateCredentialsForm = ({
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
New App Reg ↗
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
<span>New App Registration</span>
|
||||
</a>
|
||||
</Button>
|
||||
</li>
|
||||
@@ -103,9 +109,10 @@ export const AzureCertificateCredentialsForm = ({
|
||||
<li className="flex flex-col gap-2">
|
||||
<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>).
|
||||
<strong>2.</strong> Generate the certificate bundle and upload the
|
||||
public prowler-cert.cer file under Certificates & secrets. The
|
||||
private bundle is filled below and is submitted to Prowler only
|
||||
when you connect.
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -114,7 +121,7 @@ export const AzureCertificateCredentialsForm = ({
|
||||
onClick={handleGenerateCertificate}
|
||||
disabled={isGeneratingCert}
|
||||
>
|
||||
{isGeneratingCert ? "Generating…" : "Generate cert"}
|
||||
{isGeneratingCert ? "Generating..." : "Generate certificate"}
|
||||
</Button>
|
||||
</div>
|
||||
{generatorError && (
|
||||
@@ -122,39 +129,72 @@ export const AzureCertificateCredentialsForm = ({
|
||||
)}
|
||||
{generatedThumbprint && (
|
||||
<p className="text-text-success-primary text-xs">
|
||||
Downloaded <code>prowler-cert.cer</code> · thumbprint{" "}
|
||||
Downloaded <code>prowler-cert.cer</code>. Thumbprint{" "}
|
||||
<code>{generatedThumbprint}</code>
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<strong>3.</strong> Add Microsoft Graph application permissions:
|
||||
AuditLog.Read.All, Directory.Read.All (or Domain.Read.All), and
|
||||
Policy.Read.All. Then grant admin consent.
|
||||
</li>
|
||||
|
||||
<li className="flex items-center justify-between gap-3">
|
||||
<span>
|
||||
<strong>3.</strong> Grant read access (needs the SP Object ID from{" "}
|
||||
<strong>4.</strong> Copy the Service Principal Object ID from{" "}
|
||||
<Link
|
||||
href={AZURE_PORTAL_ENTERPRISE_APPLICATIONS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-button-tertiary"
|
||||
className="text-button-tertiary inline-flex items-center gap-2"
|
||||
>
|
||||
Enterprise applications
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
<span>Enterprise applications</span>
|
||||
</Link>
|
||||
).
|
||||
.
|
||||
</span>
|
||||
<Button variant="link" size="link-sm" asChild>
|
||||
<a
|
||||
href={deployToAzureUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Deploy to Azure ↗
|
||||
</a>
|
||||
</Button>
|
||||
</li>
|
||||
|
||||
<li className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>
|
||||
<strong>5.</strong> Deploy subscription RBAC with that Service
|
||||
Principal Object ID. The template creates ProwlerRole and assigns
|
||||
Reader and ProwlerRole; it does not create Entra resources.
|
||||
</span>
|
||||
<Button variant="link" size="link-sm" asChild>
|
||||
<a
|
||||
href={deployToAzureUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
<span>Deploy to Azure</span>
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-text-neutral-tertiary text-xs">
|
||||
To deploy the ARM JSON manually, use Azure Portal → Build your own
|
||||
template in the editor.{" "}
|
||||
<Button variant="link" size="link-sm" asChild>
|
||||
<a
|
||||
href={PROWLER_AZURE_ARM_TEMPLATE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
<span>Open template</span>
|
||||
</a>
|
||||
</Button>
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<strong>4.</strong> Paste the IDs below (Tenant ID and Application
|
||||
(client) ID from your App Reg's Overview).
|
||||
<strong>6.</strong> Return to Prowler and connect. Paste the Tenant ID
|
||||
and Application Client ID below; the generated certificate bundle is
|
||||
already filled.
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
@@ -181,9 +221,9 @@ export const AzureCertificateCredentialsForm = ({
|
||||
<WizardTextareaField
|
||||
control={control}
|
||||
name="certificate_content"
|
||||
label="Certificate Private Key (Base64)"
|
||||
label="Certificate and Private Key Bundle (Base64)"
|
||||
labelPlacement="inside"
|
||||
placeholder="Auto-filled by 'Generate cert', or paste your own"
|
||||
placeholder="Auto-filled by 'Generate certificate', or paste your own"
|
||||
variant="bordered"
|
||||
isRequired
|
||||
minRows={4}
|
||||
|
||||
+18
-2
@@ -111,6 +111,14 @@
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-06-15T07:49:41.143Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "@peculiar/x509",
|
||||
"from": "2.0.0",
|
||||
"to": "2.0.0",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-08-19T10:43:07.173Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "@radix-ui/react-alert-dialog",
|
||||
@@ -575,6 +583,14 @@
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-10-22T12:36:37.962Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "reflect-metadata",
|
||||
"from": "0.2.2",
|
||||
"to": "0.2.2",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-08-19T10:43:07.173Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "require-in-the-middle",
|
||||
@@ -595,9 +611,9 @@
|
||||
"section": "dependencies",
|
||||
"name": "sharp",
|
||||
"from": "0.33.5",
|
||||
"to": "0.33.5",
|
||||
"to": "0.35.3",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-10-22T12:36:37.962Z"
|
||||
"generatedAt": "2026-08-11T11:35:35.609Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
|
||||
@@ -27,9 +27,8 @@ import {
|
||||
|
||||
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).
|
||||
* Base64 of the DER-encoded X.509 public certificate. The download helper
|
||||
* converts this value into the `.cer` file uploaded to the App Registration.
|
||||
*/
|
||||
publicCertificateBase64Der: string;
|
||||
/**
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
getAzureDeploymentQuickLink,
|
||||
PRECONFIGURED_CREDENTIAL_URLS,
|
||||
getProviderHelpText,
|
||||
PROWLER_AZURE_BICEP_TEMPLATE_URL,
|
||||
PROWLER_AZURE_ARM_TEMPLATE_URL,
|
||||
PROWLER_CF_TEMPLATE_URL,
|
||||
} from "./external-urls";
|
||||
|
||||
@@ -288,55 +288,20 @@ describe("buildGitHubPersonalAccessTokenOrgUrl", () => {
|
||||
});
|
||||
|
||||
describe("getAzureDeploymentQuickLink", () => {
|
||||
it("pins the Bicep template to the public S3 bucket that hosts the Prowler templates", () => {
|
||||
// Snapshot check: fixes the exact template URL so a stray edit to the
|
||||
// bucket, path, or filename trips a failing test instead of silently
|
||||
// pointing the Deploy to Azure button at a 404. Kept in sync with the
|
||||
// Bicep file at `permissions/templates/azure/bicep/prowler-scan.json`
|
||||
// and its README, plus the actual object hosted in S3.
|
||||
expect(PROWLER_AZURE_BICEP_TEMPLATE_URL).toBe(
|
||||
"https://prowler-cloud-public.s3.eu-west-1.amazonaws.com/permissions/templates/azure/bicep/prowler-scan.json",
|
||||
);
|
||||
});
|
||||
|
||||
it("routes through the Azure Portal custom-deployment URL fragment with the template URL percent-encoded exactly once", () => {
|
||||
// The Portal SPA re-parses the hash fragment and URL-decodes the
|
||||
// `uri/<value>` segment once, so double-encoding would land the user on
|
||||
// a broken page. Asserting on the raw substring guards against a future
|
||||
// refactor that swaps `encodeURIComponent` for `URLSearchParams`, which
|
||||
// would encode differently (e.g. spaces as `+`).
|
||||
it("uses the public Mintlify documentation asset", () => {
|
||||
// Given / When
|
||||
const url = getAzureDeploymentQuickLink();
|
||||
|
||||
// Then
|
||||
expect(PROWLER_AZURE_ARM_TEMPLATE_URL).toBe(
|
||||
"https://docs.prowler.com/assets/templates/azure/prowler-scan.json",
|
||||
);
|
||||
expect(url).toBe(
|
||||
`https://portal.azure.com/#create/Microsoft.Template/uri/${encodeURIComponent(
|
||||
PROWLER_AZURE_BICEP_TEMPLATE_URL,
|
||||
)}`,
|
||||
);
|
||||
expect(url).toContain(
|
||||
"https%3A%2F%2Fprowler-cloud-public.s3.eu-west-1.amazonaws.com%2Fpermissions%2Ftemplates%2Fazure%2Fbicep%2Fprowler-scan.json",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts a custom template URL so operators can point the button at a private mirror without editing the constant", () => {
|
||||
// Self-hosted or air-gapped installs occasionally re-host the template
|
||||
// on their own bucket for compliance reasons. The helper accepts an
|
||||
// override so those callers don't have to reimplement the fragment
|
||||
// encoding themselves.
|
||||
const customUrl =
|
||||
"https://example.internal/templates/prowler-scan.json?v=2";
|
||||
const url = getAzureDeploymentQuickLink(customUrl);
|
||||
|
||||
expect(url).toBe(
|
||||
`https://portal.azure.com/#create/Microsoft.Template/uri/${encodeURIComponent(
|
||||
customUrl,
|
||||
)}`,
|
||||
);
|
||||
// The `?` inside the query-string of the custom URL must be encoded so
|
||||
// the Portal parser reads the entire value as the template URL instead
|
||||
// of truncating at the first `?`.
|
||||
expect(url).toContain(
|
||||
"uri/https%3A%2F%2Fexample.internal%2Ftemplates%2Fprowler-scan.json%3Fv%3D2",
|
||||
"https://portal.azure.com/#create/Microsoft.Template/uri/https%3A%2F%2Fdocs.prowler.com%2Fassets%2Ftemplates%2Fazure%2Fprowler-scan.json",
|
||||
);
|
||||
expect(url).not.toContain("localhost");
|
||||
expect(url).not.toContain("prowler-cloud-public.s3");
|
||||
expect(decodeURIComponent(url)).not.toContain("/uri//templates/azure/");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+6
-34
@@ -40,24 +40,8 @@ export const getAttackPathHubUrl = (queryId: string): string =>
|
||||
export const PROWLER_CF_TEMPLATE_URL =
|
||||
"https://prowler-cloud-public.s3.eu-west-1.amazonaws.com/permissions/templates/aws/cloudformation/prowler-scan-role.yml";
|
||||
|
||||
// ARM JSON template URL for the Prowler quick-start Azure deployment. The
|
||||
// template only grants a pre-existing App Registration the read-only
|
||||
// subscription roles Prowler needs (built-in `Reader` + custom
|
||||
// `ProwlerRole`); the App Registration itself and its certificate credential
|
||||
// are created through the Portal UI in earlier wizard steps, because
|
||||
// `Microsoft.Graph/*` Bicep resources are not deployable via the Portal
|
||||
// `Deploy to Azure` URL flow (`msgraph-bicep-types` issue #294, closed as a
|
||||
// documented Microsoft limitation).
|
||||
//
|
||||
// Authored in Bicep at `permissions/templates/azure/bicep/prowler-scan.bicep`
|
||||
// and compiled to ARM JSON at `prowler-scan.json` — the Azure Portal
|
||||
// `#create/Microsoft.Template` deep link only accepts ARM JSON, not raw
|
||||
// Bicep source. Regenerate with `make -C permissions/templates/azure/bicep
|
||||
// build` after editing the .bicep. URL-encoded and appended to the Portal
|
||||
// deep link built by `getAzureDeploymentQuickLink` below. Points at the same
|
||||
// public S3 bucket that hosts the AWS templates.
|
||||
export const PROWLER_AZURE_BICEP_TEMPLATE_URL =
|
||||
"https://prowler-cloud-public.s3.eu-west-1.amazonaws.com/permissions/templates/azure/bicep/prowler-scan.json";
|
||||
export const PROWLER_AZURE_ARM_TEMPLATE_URL =
|
||||
"https://docs.prowler.com/assets/templates/azure/prowler-scan.json";
|
||||
|
||||
// Prowler Cloud billing/subscription management page.
|
||||
export const BILLING_URL = "https://cloud.prowler.com/billing";
|
||||
@@ -421,19 +405,7 @@ export const getAWSOrgDeploymentQuickLink = ({
|
||||
return buildCloudFormationQuickCreateLink(parameters);
|
||||
};
|
||||
|
||||
// Builds the Azure Portal deep link that opens the "Custom deployment" wizard
|
||||
// pre-loaded with the Prowler Bicep template. The Portal expects
|
||||
// `#create/Microsoft.Template/uri/<url-encoded-template-url>` — the URL
|
||||
// fragment is re-parsed by the Portal SPA which URL-decodes the segment
|
||||
// once, so the template URL must be percent-encoded exactly once via
|
||||
// `encodeURIComponent`. This is the Azure analogue of the AWS CloudFormation
|
||||
// quick-create link built by `getAWSOrgDeploymentQuickLink` above.
|
||||
//
|
||||
// The resulting URL is safe to open in a new tab; the user is expected to be
|
||||
// signed into the Azure Portal already. If they are not, the Portal will
|
||||
// bounce them through the standard sign-in flow before landing on the
|
||||
// deployment form with the template still pre-loaded.
|
||||
export const getAzureDeploymentQuickLink = (
|
||||
templateUrl: string = PROWLER_AZURE_BICEP_TEMPLATE_URL,
|
||||
): string =>
|
||||
`https://portal.azure.com/#create/Microsoft.Template/uri/${encodeURIComponent(templateUrl)}`;
|
||||
export const getAzureDeploymentQuickLink = (): string =>
|
||||
`https://portal.azure.com/#create/Microsoft.Template/uri/${encodeURIComponent(
|
||||
PROWLER_AZURE_ARM_TEMPLATE_URL,
|
||||
)}`;
|
||||
@@ -139,7 +139,8 @@ type AZURECredentialType =
|
||||
export interface AZUREProviderCredential {
|
||||
type: AZURECredentialType;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
clientSecret?: string;
|
||||
certificateContent?: string;
|
||||
tenantId: string;
|
||||
}
|
||||
|
||||
@@ -343,6 +344,7 @@ export class ProvidersPage extends BasePage {
|
||||
readonly azureSubscriptionIdInput: Locator;
|
||||
readonly azureClientIdInput: Locator;
|
||||
readonly azureClientSecretInput: Locator;
|
||||
readonly azureCertificateContentInput: Locator;
|
||||
readonly azureTenantIdInput: Locator;
|
||||
|
||||
// M365 provider form elements
|
||||
@@ -465,6 +467,9 @@ export class ProvidersPage extends BasePage {
|
||||
this.azureClientSecretInput = page.getByRole("textbox", {
|
||||
name: "Client Secret",
|
||||
});
|
||||
this.azureCertificateContentInput = page.getByRole("textbox", {
|
||||
name: "Certificate and Private Key Bundle (Base64)",
|
||||
});
|
||||
this.azureTenantIdInput = page.getByRole("textbox", { name: "Tenant ID" });
|
||||
|
||||
// M365 provider form inputs
|
||||
@@ -1236,6 +1241,18 @@ export class ProvidersPage extends BasePage {
|
||||
}
|
||||
}
|
||||
|
||||
async fillAzureCertificateCredentials(
|
||||
credentials: AZUREProviderCredential,
|
||||
): Promise<void> {
|
||||
await this.azureClientIdInput.fill(credentials.clientId);
|
||||
await this.azureTenantIdInput.fill(credentials.tenantId);
|
||||
if (credentials.certificateContent) {
|
||||
await this.azureCertificateContentInput.fill(
|
||||
credentials.certificateContent,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fillM365Credentials(
|
||||
credentials: M365ProviderCredential,
|
||||
): Promise<void> {
|
||||
@@ -1613,6 +1630,35 @@ export class ProvidersPage extends BasePage {
|
||||
await expect(this.m365TenantIdInput).toBeVisible();
|
||||
}
|
||||
|
||||
async verifyAzureCertificateCredentialsPageLoaded(): Promise<void> {
|
||||
await this.verifyPageHasProwlerTitle();
|
||||
await expect(this.wizardModal.locator("ol > li")).toHaveCount(6);
|
||||
await expect(
|
||||
this.page.getByRole("link", {
|
||||
name: "Deploy to Azure",
|
||||
exact: true,
|
||||
}),
|
||||
).toHaveAttribute(
|
||||
"href",
|
||||
"https://portal.azure.com/#create/Microsoft.Template/uri/https%3A%2F%2Fdocs.prowler.com%2Fassets%2Ftemplates%2Fazure%2Fprowler-scan.json",
|
||||
);
|
||||
await expect(
|
||||
this.page.getByRole("link", {
|
||||
name: "Open template",
|
||||
exact: true,
|
||||
}),
|
||||
).toHaveAttribute(
|
||||
"href",
|
||||
"https://docs.prowler.com/assets/templates/azure/prowler-scan.json",
|
||||
);
|
||||
await expect(
|
||||
this.page.getByRole("button", { name: "Generate certificate" }),
|
||||
).toBeVisible();
|
||||
await expect(this.azureClientIdInput).toBeVisible();
|
||||
await expect(this.azureTenantIdInput).toBeVisible();
|
||||
await expect(this.azureCertificateContentInput).toBeVisible();
|
||||
}
|
||||
|
||||
async verifyM365CertificateCredentialsPageLoaded(): Promise<void> {
|
||||
// Verify the M365 certificate credentials page is loaded
|
||||
|
||||
|
||||
@@ -174,6 +174,51 @@
|
||||
|
||||
---
|
||||
|
||||
## Test Case: `PROVIDER-E2E-020` - Add Azure Provider with Certificate Credentials
|
||||
|
||||
**Priority:** `critical`
|
||||
|
||||
**Tags:**
|
||||
|
||||
- type → @e2e, @serial
|
||||
- feature → @providers
|
||||
- provider → @azure
|
||||
|
||||
**Description/Objective:** Validates the complete flow of adding an Azure provider with an existing App Registration certificate bundle.
|
||||
|
||||
**Preconditions:**
|
||||
|
||||
- Admin user authentication required
|
||||
- Environment variables configured: E2E_AZURE_SUBSCRIPTION_ID, E2E_AZURE_CLIENT_ID, E2E_AZURE_TENANT_ID, E2E_AZURE_CERTIFICATE_CONTENT
|
||||
- The public certificate matching the bundle is uploaded to the App Registration
|
||||
- Reader and ProwlerRole are assigned on the target subscription
|
||||
|
||||
### Flow Steps
|
||||
|
||||
1. Navigate to the Providers page and open the add-provider wizard
|
||||
2. Select Azure and enter the subscription details
|
||||
3. Select Certificate Authentication
|
||||
4. Verify the six-step onboarding guide, Deploy to Azure link, manual template link, and certificate generation are available
|
||||
5. Enter the Tenant ID, Client ID, and base64-encoded certificate bundle
|
||||
6. Confirm the provider connection without launching a scan
|
||||
7. Verify the provider appears in the Providers table
|
||||
|
||||
### Expected Result
|
||||
|
||||
- Azure provider connects with certificate credentials
|
||||
- Provider appears in the Providers table with the expected subscription ID
|
||||
|
||||
### Key Verification Points
|
||||
|
||||
- The certificate authentication form displays all six onboarding steps
|
||||
- Deploy to Azure loads the ARM template from the Mintlify documentation asset
|
||||
- Open template links directly to the Mintlify-hosted ARM JSON
|
||||
- Certificate generation remains available in the authentication form
|
||||
- The certificate and private key bundle is submitted through the certificate field
|
||||
- Provider connection succeeds without a client secret
|
||||
|
||||
---
|
||||
|
||||
## Test Case: `PROVIDER-E2E-004` - Add M365 Provider with Static Credentials
|
||||
|
||||
**Priority:** `critical`
|
||||
|
||||
@@ -349,12 +349,13 @@ test.describe("Add Provider", () => {
|
||||
const subscriptionId = process.env.E2E_AZURE_SUBSCRIPTION_ID ?? "";
|
||||
const clientId = process.env.E2E_AZURE_CLIENT_ID ?? "";
|
||||
const clientSecret = process.env.E2E_AZURE_SECRET_ID ?? "";
|
||||
const certificateContent = process.env.E2E_AZURE_CERTIFICATE_CONTENT ?? "";
|
||||
const tenantId = process.env.E2E_AZURE_TENANT_ID ?? "";
|
||||
|
||||
// Setup before each test
|
||||
test.beforeEach(async ({ page }) => {
|
||||
test.skip(
|
||||
!subscriptionId || !clientId || !clientSecret || !tenantId,
|
||||
!subscriptionId || !clientId || !tenantId,
|
||||
"Azure E2E env vars are not set",
|
||||
);
|
||||
providersPage = new ProvidersPage(page);
|
||||
@@ -377,6 +378,8 @@ test.describe("Add Provider", () => {
|
||||
],
|
||||
},
|
||||
async ({ page }) => {
|
||||
test.skip(!clientSecret, "E2E_AZURE_SECRET_ID is not set");
|
||||
|
||||
// Prepare test data for AZURE provider
|
||||
const azureProviderData: AZUREProviderData = {
|
||||
subscriptionId: subscriptionId,
|
||||
@@ -420,6 +423,52 @@ test.describe("Add Provider", () => {
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"should add a new Azure provider with certificate credentials",
|
||||
{
|
||||
tag: [
|
||||
"@critical",
|
||||
"@e2e",
|
||||
"@providers",
|
||||
"@azure",
|
||||
"@serial",
|
||||
"@PROVIDER-E2E-020",
|
||||
],
|
||||
},
|
||||
async ({ page }) => {
|
||||
test.skip(
|
||||
!certificateContent,
|
||||
"E2E_AZURE_CERTIFICATE_CONTENT is not set",
|
||||
);
|
||||
|
||||
const azureProviderData: AZUREProviderData = {
|
||||
subscriptionId,
|
||||
alias: "Test E2E Azure Account - Certificate",
|
||||
};
|
||||
const azureCredentials: AZUREProviderCredential = {
|
||||
type: AZURE_CREDENTIAL_OPTIONS.AZURE_CERTIFICATE_CREDENTIALS,
|
||||
clientId,
|
||||
certificateContent,
|
||||
tenantId,
|
||||
};
|
||||
|
||||
await providersPage.goto();
|
||||
await providersPage.verifyPageLoaded();
|
||||
await providersPage.clickAddProvider();
|
||||
await providersPage.verifyConnectAccountPageLoaded();
|
||||
await providersPage.selectAZUREProvider();
|
||||
await providersPage.fillAZUREProviderDetails(azureProviderData);
|
||||
await providersPage.clickNext();
|
||||
await providersPage.selectAzureCredentialsType(azureCredentials.type);
|
||||
await providersPage.verifyAzureCertificateCredentialsPageLoaded();
|
||||
await providersPage.fillAzureCertificateCredentials(azureCredentials);
|
||||
await providersPage.clickNext();
|
||||
await providersPage.completeProviderConnectionWithoutLaunchingScan(
|
||||
subscriptionId,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test.describe.serial("Add M365 Provider", () => {
|
||||
|
||||
@@ -434,6 +434,7 @@ export interface InvitationProps {
|
||||
manage_integrations?: boolean;
|
||||
manage_scans?: boolean;
|
||||
manage_alerts?: boolean;
|
||||
manage_lighthouse_ai_configuration?: boolean;
|
||||
permission_state?: PermissionState;
|
||||
};
|
||||
};
|
||||
@@ -459,6 +460,7 @@ export interface Role {
|
||||
manage_integrations: boolean;
|
||||
manage_scans: boolean;
|
||||
manage_alerts?: boolean;
|
||||
manage_lighthouse_ai_configuration?: boolean;
|
||||
unlimited_visibility: boolean;
|
||||
permission_state: PermissionState;
|
||||
inserted_at: string;
|
||||
|
||||
@@ -54,6 +54,79 @@ describe("addCredentialsRoleFormSchema", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("addCredentialsFormSchema - azure certificate", () => {
|
||||
const BASE_AZURE_VALUES = {
|
||||
[ProviderCredentialFields.PROVIDER_ID]: "provider-azure-1",
|
||||
[ProviderCredentialFields.PROVIDER_TYPE]: "azure",
|
||||
[ProviderCredentialFields.TENANT_ID]:
|
||||
"12345678-1234-1234-1234-123456789012",
|
||||
[ProviderCredentialFields.CLIENT_ID]:
|
||||
"87654321-4321-4321-4321-210987654321",
|
||||
[ProviderCredentialFields.CERTIFICATE_CONTENT]: "Y2VydGlmaWNhdGU=",
|
||||
} as const;
|
||||
|
||||
it("rejects malformed tenant and client UUIDs", () => {
|
||||
// Given
|
||||
const schema = addCredentialsFormSchema("azure", "app_certificate");
|
||||
|
||||
// When
|
||||
const result = schema.safeParse({
|
||||
...BASE_AZURE_VALUES,
|
||||
[ProviderCredentialFields.TENANT_ID]: "not-a-uuid",
|
||||
[ProviderCredentialFields.CLIENT_ID]: "also-not-a-uuid",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
expect(result.error.issues).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: [ProviderCredentialFields.TENANT_ID],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
path: [ProviderCredentialFields.CLIENT_ID],
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects malformed base64 certificate content", () => {
|
||||
// Given
|
||||
const schema = addCredentialsFormSchema("azure", "app_certificate");
|
||||
|
||||
// When
|
||||
const result = schema.safeParse({
|
||||
...BASE_AZURE_VALUES,
|
||||
[ProviderCredentialFields.CERTIFICATE_CONTENT]: "not!base64",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
expect(result.error.issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
message: "Certificate and Private Key Bundle must be valid base64",
|
||||
path: [ProviderCredentialFields.CERTIFICATE_CONTENT],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects simultaneous client-secret and certificate credentials", () => {
|
||||
// Given
|
||||
const schema = addCredentialsFormSchema("azure", "app_certificate");
|
||||
|
||||
// When
|
||||
const result = schema.safeParse({
|
||||
...BASE_AZURE_VALUES,
|
||||
[ProviderCredentialFields.CLIENT_SECRET]: "fake-client-secret",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addProviderFormSchema - okta", () => {
|
||||
const validUidFixtures = [
|
||||
"acme.okta.com",
|
||||
|
||||
+46
-12
@@ -14,6 +14,23 @@ const isRecord = (value: unknown): value is Record<string, unknown> => {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
};
|
||||
|
||||
const isValidBase64 = (value: string): boolean => {
|
||||
if (
|
||||
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(
|
||||
value,
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
atob(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const kubeconfigContainsUnsupportedCommandAuthentication = (
|
||||
value: string,
|
||||
): boolean => {
|
||||
@@ -55,6 +72,7 @@ export const roleFormSchema = z.object({
|
||||
manage_integrations: z.boolean().default(false),
|
||||
manage_scans: z.boolean().default(false),
|
||||
manage_alerts: z.boolean().default(false),
|
||||
manage_lighthouse_ai_configuration: z.boolean().default(false),
|
||||
unlimited_visibility: z.boolean().default(false),
|
||||
groups: z.array(z.string()).optional(),
|
||||
});
|
||||
@@ -217,16 +235,16 @@ export const addCredentialsFormSchema = (
|
||||
// schema keeps both optional so switching methods without a
|
||||
// page reload does not trigger a stale "required" error on the
|
||||
// field that is not shown.
|
||||
[ProviderCredentialFields.CLIENT_ID]: z
|
||||
.string()
|
||||
.min(1, "Client ID is required"),
|
||||
[ProviderCredentialFields.CLIENT_ID]: z.guid({
|
||||
error: "Client ID must be a valid GUID",
|
||||
}),
|
||||
[ProviderCredentialFields.CLIENT_SECRET]: z.string().optional(),
|
||||
[ProviderCredentialFields.CERTIFICATE_CONTENT]: z
|
||||
.string()
|
||||
.optional(),
|
||||
[ProviderCredentialFields.TENANT_ID]: z
|
||||
.string()
|
||||
.min(1, "Tenant ID is required"),
|
||||
[ProviderCredentialFields.TENANT_ID]: z.guid({
|
||||
error: "Tenant ID must be a valid GUID",
|
||||
}),
|
||||
}
|
||||
: providerType === "gcp"
|
||||
? {
|
||||
@@ -455,10 +473,21 @@ export const addCredentialsFormSchema = (
|
||||
// form is shown). The visible field for the chosen `via` is what
|
||||
// the user must fill — enforce it here so the client catches empty
|
||||
// submissions before hitting the API, mirroring M365. Error copy is
|
||||
// aligned with the visible field labels ("Certificate Private Key"
|
||||
// vs. the technical `certificate_content` field name).
|
||||
// aligned with the visible field label rather than the technical
|
||||
// `certificate_content` field name.
|
||||
const clientSecret = data[ProviderCredentialFields.CLIENT_SECRET];
|
||||
const certificateContent =
|
||||
data[ProviderCredentialFields.CERTIFICATE_CONTENT];
|
||||
if (clientSecret?.trim() && certificateContent?.trim()) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message:
|
||||
"Use either a Client Secret or Certificate and Private Key Bundle, not both",
|
||||
path: [ProviderCredentialFields.CERTIFICATE_CONTENT],
|
||||
});
|
||||
}
|
||||
|
||||
if (via === "app_client_secret") {
|
||||
const clientSecret = data[ProviderCredentialFields.CLIENT_SECRET];
|
||||
if (!clientSecret || clientSecret.trim() === "") {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
@@ -467,12 +496,17 @@ export const addCredentialsFormSchema = (
|
||||
});
|
||||
}
|
||||
} else if (via === "app_certificate") {
|
||||
const certificateContent =
|
||||
data[ProviderCredentialFields.CERTIFICATE_CONTENT];
|
||||
if (!certificateContent || certificateContent.trim() === "") {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Certificate Private Key is required",
|
||||
message: "Certificate and Private Key Bundle is required",
|
||||
path: [ProviderCredentialFields.CERTIFICATE_CONTENT],
|
||||
});
|
||||
} else if (!isValidBase64(certificateContent)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message:
|
||||
"Certificate and Private Key Bundle must be valid base64",
|
||||
path: [ProviderCredentialFields.CERTIFICATE_CONTENT],
|
||||
});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user