mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-11-09 04:38:49 +00:00
feat(azure): add Deploy-to-Azure Bicep template and certificate auth
This commit is contained in:
1 parent
d41b2eaa0f
commit
ab738e8a4e
34 files changed
+1953
-93
No files matched your search
@@ -0,0 +1 @@
|
||||
Add a Deploy to Azure quick-start to the add-provider wizard. Azure now shows a credential-type selector (Certificate authentication recommended, Service Principal with Client Secret fallback), and the certificate form exposes a one-click button that opens the Azure Portal deployment blade pre-loaded with the Prowler Bicep template (App Registration + Service Principal + Reader + custom ProwlerRole + certificate credential)
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { SelectViaAlibabaCloud } from "@/components/providers/workflow/forms/select-credentials-type/alibabacloud";
|
||||
import { SelectViaAWS } from "@/components/providers/workflow/forms/select-credentials-type/aws";
|
||||
import { SelectViaAzure } from "@/components/providers/workflow/forms/select-credentials-type/azure";
|
||||
import { SelectViaCloudflare } from "@/components/providers/workflow/forms/select-credentials-type/cloudflare";
|
||||
import { SelectViaGCP } from "@/components/providers/workflow/forms/select-credentials-type/gcp";
|
||||
import { SelectViaGitHub } from "@/components/providers/workflow/forms/select-credentials-type/github";
|
||||
@@ -21,6 +22,9 @@ export const CredentialsUpdateInfo = ({
|
||||
if (providerType === "aws") {
|
||||
return <SelectViaAWS initialVia={initialVia} />;
|
||||
}
|
||||
if (providerType === "azure") {
|
||||
return <SelectViaAzure initialVia={initialVia} />;
|
||||
}
|
||||
if (providerType === "gcp") {
|
||||
return <SelectViaGCP initialVia={initialVia} />;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ vi.mock("../../workflow/forms/select-credentials-type/aws", () => ({
|
||||
SelectViaAWS: () => <div>select-via-aws</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../../workflow/forms/select-credentials-type/azure", () => ({
|
||||
SelectViaAzure: () => <div>select-via-azure</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../../workflow/forms/select-credentials-type/alibabacloud", () => ({
|
||||
SelectViaAlibabaCloud: () => <div>select-via-alibabacloud</div>,
|
||||
}));
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "../../workflow/forms";
|
||||
import { SelectViaAlibabaCloud } from "../../workflow/forms/select-credentials-type/alibabacloud";
|
||||
import { SelectViaAWS } from "../../workflow/forms/select-credentials-type/aws";
|
||||
import { SelectViaAzure } from "../../workflow/forms/select-credentials-type/azure";
|
||||
import { SelectViaCloudflare } from "../../workflow/forms/select-credentials-type/cloudflare";
|
||||
import {
|
||||
AddViaServiceAccountForm,
|
||||
@@ -131,6 +132,14 @@ export function CredentialsStep({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (providerType === "azure") {
|
||||
return (
|
||||
<SelectViaAzure
|
||||
initialVia={via || undefined}
|
||||
onViaChange={handleViaChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (providerType === "gcp") {
|
||||
return (
|
||||
<SelectViaGCP
|
||||
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
ApiResponse,
|
||||
AWSCredentials,
|
||||
AWSCredentialsRole,
|
||||
AzureCredentials,
|
||||
AzureCertificateCredentials,
|
||||
AzureClientSecretCredentials,
|
||||
CloudflareApiKeyCredentials,
|
||||
CloudflareTokenCredentials,
|
||||
GCPDefaultCredentials,
|
||||
@@ -44,6 +45,10 @@ import {
|
||||
} from "./select-credentials-type/alibabacloud/credentials-type";
|
||||
import { AWSStaticCredentialsForm } from "./select-credentials-type/aws/credentials-type";
|
||||
import { AWSRoleCredentialsForm } from "./select-credentials-type/aws/credentials-type/aws-role-credentials-form";
|
||||
import {
|
||||
AzureCertificateCredentialsForm,
|
||||
AzureServicePrincipalCredentialsForm,
|
||||
} from "./select-credentials-type/azure";
|
||||
import {
|
||||
CloudflareApiKeyCredentialsForm,
|
||||
CloudflareApiTokenCredentialsForm,
|
||||
@@ -54,7 +59,6 @@ import {
|
||||
M365CertificateCredentialsForm,
|
||||
M365ClientSecretCredentialsForm,
|
||||
} from "./select-credentials-type/m365";
|
||||
import { AzureCredentialsForm } from "./via-credentials/azure-credentials-form";
|
||||
import { GitHubCredentialsForm } from "./via-credentials/github-credentials-form";
|
||||
import { GoogleWorkspaceCredentialsForm } from "./via-credentials/googleworkspace-credentials-form";
|
||||
import { IacCredentialsForm } from "./via-credentials/iac-credentials-form";
|
||||
@@ -175,9 +179,18 @@ export const BaseCredentialsForm = ({
|
||||
control={form.control as unknown as Control<AWSCredentials>}
|
||||
/>
|
||||
)}
|
||||
{providerType === "azure" && (
|
||||
<AzureCredentialsForm
|
||||
control={form.control as unknown as Control<AzureCredentials>}
|
||||
{providerType === "azure" && effectiveVia === "app_client_secret" && (
|
||||
<AzureServicePrincipalCredentialsForm
|
||||
control={
|
||||
form.control as unknown as Control<AzureClientSecretCredentials>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{providerType === "azure" && effectiveVia === "app_certificate" && (
|
||||
<AzureCertificateCredentialsForm
|
||||
control={
|
||||
form.control as unknown as Control<AzureCertificateCredentials>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{providerType === "m365" && effectiveVia === "app_client_secret" && (
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
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 (
|
||||
<FormProvider {...form}>
|
||||
<AzureCertificateCredentialsForm control={form.control} />
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe("AzureCertificateCredentialsForm", () => {
|
||||
it("renders the Deploy to Azure link with the Portal custom-deployment URL and safe target attributes", () => {
|
||||
// Given
|
||||
render(<Harness />);
|
||||
|
||||
// When
|
||||
const link = screen.getByRole("link", { name: /deploy to azure/i });
|
||||
|
||||
// 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",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the certificate generation docs link pointing at the Azure authentication doc", () => {
|
||||
// Given
|
||||
render(<Harness />);
|
||||
|
||||
// When
|
||||
const docsLink = screen.getByRole("link", {
|
||||
name: /certificate generation guide/i,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(docsLink).toHaveAttribute(
|
||||
"href",
|
||||
"https://docs.prowler.com/user-guide/providers/azure/authentication#certificate-authentication",
|
||||
);
|
||||
expect(docsLink).toHaveAttribute("target", "_blank");
|
||||
expect(docsLink).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
});
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Control } from "react-hook-form";
|
||||
|
||||
import {
|
||||
WizardInputField,
|
||||
WizardTextareaField,
|
||||
} from "@/components/providers/workflow/forms/fields";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { getAzureDeploymentQuickLink } from "@/lib/external-urls";
|
||||
import { AzureCertificateCredentials } from "@/types";
|
||||
|
||||
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 selection happens inside the Portal deployment blade).
|
||||
// If Prowler ever needs to pin the deployment to a specific subscription
|
||||
// or region, thread that value through `getAzureDeploymentQuickLink` here.
|
||||
const deployToAzureUrl = getAzureDeploymentQuickLink();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col">
|
||||
<div className="text-md text-text-neutral-primary leading-9 font-bold">
|
||||
Certificate Authentication (Recommended)
|
||||
</div>
|
||||
<div className="text-text-neutral-tertiary text-sm">
|
||||
Deploy the Prowler Bicep template to provision the App Registration,
|
||||
Service Principal, and read-only roles in one click, then paste the
|
||||
resulting credentials below.
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<Button variant="link" size="link-sm" asChild>
|
||||
<a href={deployToAzureUrl} target="_blank" rel="noopener noreferrer">
|
||||
Deploy to Azure
|
||||
</a>
|
||||
</Button>
|
||||
<p className="text-text-neutral-tertiary text-xs">
|
||||
After deployment, copy <strong>Tenant ID</strong> and{" "}
|
||||
<strong>Application (Client) ID</strong> from the deployment outputs —
|
||||
the certificate private key you generated locally goes in the
|
||||
“Certificate Private Key” field below.
|
||||
</p>
|
||||
</div>
|
||||
<WizardInputField
|
||||
control={control}
|
||||
name="tenant_id"
|
||||
type="text"
|
||||
label="Tenant ID"
|
||||
labelPlacement="inside"
|
||||
placeholder="Enter the Tenant ID"
|
||||
variant="bordered"
|
||||
isRequired
|
||||
/>
|
||||
<WizardInputField
|
||||
control={control}
|
||||
name="client_id"
|
||||
type="text"
|
||||
label="Client ID"
|
||||
labelPlacement="inside"
|
||||
placeholder="Enter the Client ID"
|
||||
variant="bordered"
|
||||
isRequired
|
||||
/>
|
||||
<WizardTextareaField
|
||||
control={control}
|
||||
name="certificate_content"
|
||||
label="Certificate Private Key (Base64)"
|
||||
labelPlacement="inside"
|
||||
placeholder="Paste the base64-encoded private key that pairs with the certificate deployed to Entra ID"
|
||||
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 uploaded to Entra ID by the Bicep template (not the public
|
||||
certificate, and not the thumbprint). Generation instructions (openssl /
|
||||
PowerShell) are 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+19
-15
@@ -1,23 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { Control } from "react-hook-form";
|
||||
|
||||
import { WizardInputField } from "@/components/providers/workflow/forms/fields";
|
||||
import { AzureCredentials } from "@/types";
|
||||
import { AzureClientSecretCredentials } from "@/types";
|
||||
|
||||
export const AzureCredentialsForm = ({
|
||||
export const AzureServicePrincipalCredentialsForm = ({
|
||||
control,
|
||||
}: {
|
||||
control: Control<AzureCredentials>;
|
||||
control: Control<AzureClientSecretCredentials>;
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col">
|
||||
<div className="text-md text-text-neutral-primary leading-9 font-bold">
|
||||
Connect via Credentials
|
||||
Service Principal with Client Secret
|
||||
</div>
|
||||
<div className="text-text-neutral-tertiary text-sm">
|
||||
Please provide the information for your Azure credentials.
|
||||
Please provide the Azure Service Principal credentials issued from
|
||||
your App Registration's <em>Certificates & secrets</em>{" "}
|
||||
blade.
|
||||
</div>
|
||||
</div>
|
||||
<WizardInputField
|
||||
control={control}
|
||||
name="tenant_id"
|
||||
type="text"
|
||||
label="Tenant ID"
|
||||
labelPlacement="inside"
|
||||
placeholder="Enter the Tenant ID"
|
||||
variant="bordered"
|
||||
isRequired
|
||||
/>
|
||||
<WizardInputField
|
||||
control={control}
|
||||
name="client_id"
|
||||
@@ -38,16 +52,6 @@ export const AzureCredentialsForm = ({
|
||||
variant="bordered"
|
||||
isRequired
|
||||
/>
|
||||
<WizardInputField
|
||||
control={control}
|
||||
name="tenant_id"
|
||||
type="text"
|
||||
label="Tenant ID"
|
||||
labelPlacement="inside"
|
||||
placeholder="Enter the Tenant ID"
|
||||
variant="bordered"
|
||||
isRequired
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export { AzureCertificateCredentialsForm } from "./azure-certificate-credentials-form";
|
||||
export { AzureServicePrincipalCredentialsForm } from "./azure-service-principal-credentials-form";
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
AzureCertificateCredentialsForm,
|
||||
AzureServicePrincipalCredentialsForm,
|
||||
} from "./credentials-type";
|
||||
export { SelectViaAzure } from "./select-via-azure";
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { Control, Controller } from "react-hook-form";
|
||||
|
||||
import { WizardRadioCard } from "@/components/providers/workflow/forms/fields";
|
||||
import { FormMessage } from "@/components/shadcn/form";
|
||||
import { RadioGroup } from "@/components/shadcn/radio-group/radio-group";
|
||||
|
||||
type RadioGroupAzureViaCredentialsFormProps = {
|
||||
control: Control<any>;
|
||||
isInvalid: boolean;
|
||||
errorMessage?: string;
|
||||
onChange?: (value: string) => void;
|
||||
};
|
||||
|
||||
// The via values (`app_client_secret` / `app_certificate`) mirror M365 on
|
||||
// purpose: both providers authenticate against an Entra ID App Registration
|
||||
// and reuse the same wizard state machine + per-method docs anchors.
|
||||
export const RadioGroupAzureViaCredentialsTypeForm = ({
|
||||
control,
|
||||
isInvalid,
|
||||
errorMessage,
|
||||
onChange,
|
||||
}: RadioGroupAzureViaCredentialsFormProps) => {
|
||||
return (
|
||||
<Controller
|
||||
name="azureCredentialsType"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<>
|
||||
<RadioGroup
|
||||
name={field.name}
|
||||
value={field.value || ""}
|
||||
onValueChange={(value: string) => {
|
||||
field.onChange(value);
|
||||
onChange?.(value);
|
||||
}}
|
||||
>
|
||||
<span className="text-text-neutral-tertiary text-sm">
|
||||
Select Authentication Method
|
||||
</span>
|
||||
<WizardRadioCard value="app_certificate" isInvalid={isInvalid}>
|
||||
Certificate Authentication (Recommended)
|
||||
</WizardRadioCard>
|
||||
<WizardRadioCard value="app_client_secret" isInvalid={isInvalid}>
|
||||
Service Principal with Client Secret
|
||||
</WizardRadioCard>
|
||||
</RadioGroup>
|
||||
{errorMessage && (
|
||||
<FormMessage className="text-text-error-primary">
|
||||
{errorMessage}
|
||||
</FormMessage>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { Form } from "@/components/shadcn/form";
|
||||
|
||||
import { RadioGroupAzureViaCredentialsTypeForm } from "./radio-group-azure-via-credentials-type-form";
|
||||
|
||||
interface SelectViaAzureProps {
|
||||
initialVia?: string;
|
||||
onViaChange?: (via: string) => void;
|
||||
}
|
||||
|
||||
export const SelectViaAzure = ({
|
||||
initialVia,
|
||||
onViaChange,
|
||||
}: SelectViaAzureProps) => {
|
||||
const router = useRouter();
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
azureCredentialsType: initialVia || "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleSelectionChange = (value: string) => {
|
||||
if (onViaChange) {
|
||||
onViaChange(value);
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("via", value);
|
||||
router.push(url.toString());
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<RadioGroupAzureViaCredentialsTypeForm
|
||||
control={form.control}
|
||||
isInvalid={!!form.formState.errors.azureCredentialsType}
|
||||
errorMessage={form.formState.errors.azureCredentialsType?.message}
|
||||
onChange={handleSelectionChange}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from "./azure-credentials-form";
|
||||
export * from "./github-credentials-form";
|
||||
export * from "./iac-credentials-form";
|
||||
export * from "./image-credentials-form";
|
||||
|
||||
@@ -55,8 +55,10 @@ export const useCredentialsForm = ({
|
||||
if (providerType === "gcp" && effectiveVia === "service-account") {
|
||||
return addCredentialsServiceAccountFormSchema(providerType);
|
||||
}
|
||||
// For GitHub, M365, and Cloudflare, we need to pass the via parameter to determine which fields are required
|
||||
// For Azure, GitHub, M365, and Cloudflare, we need to pass the via
|
||||
// parameter to determine which fields are required
|
||||
if (
|
||||
providerType === "azure" ||
|
||||
providerType === "github" ||
|
||||
providerType === "m365" ||
|
||||
providerType === "cloudflare"
|
||||
@@ -111,6 +113,19 @@ export const useCredentialsForm = ({
|
||||
[ProviderCredentialFields.AWS_SESSION_TOKEN]: "",
|
||||
};
|
||||
case "azure":
|
||||
// Azure now mirrors the M365 flow: the user picks the auth method
|
||||
// in the selector step, which sets `via`, and we only seed the
|
||||
// fields that method actually shows. Missing fields would still be
|
||||
// registered by the schema as optional, but seeding them here keeps
|
||||
// the react-hook-form store in sync with the visible form.
|
||||
if (effectiveVia === "app_certificate") {
|
||||
return {
|
||||
...baseDefaults,
|
||||
[ProviderCredentialFields.CLIENT_ID]: "",
|
||||
[ProviderCredentialFields.CERTIFICATE_CONTENT]: "",
|
||||
[ProviderCredentialFields.TENANT_ID]: "",
|
||||
};
|
||||
}
|
||||
return {
|
||||
...baseDefaults,
|
||||
[ProviderCredentialFields.CLIENT_ID]: "",
|
||||
|
||||
@@ -10,8 +10,10 @@ import {
|
||||
buildGitHubPersonalAccessTokenOrgUrl,
|
||||
getAWSCredentialsTemplateLinks,
|
||||
getAWSOrgDeploymentQuickLink,
|
||||
getAzureDeploymentQuickLink,
|
||||
PRECONFIGURED_CREDENTIAL_URLS,
|
||||
getProviderHelpText,
|
||||
PROWLER_AZURE_BICEP_TEMPLATE_URL,
|
||||
PROWLER_CF_TEMPLATE_URL,
|
||||
} from "./external-urls";
|
||||
|
||||
@@ -285,6 +287,59 @@ 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 `+`).
|
||||
const url = getAzureDeploymentQuickLink();
|
||||
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getProviderHelpText", () => {
|
||||
const AWS_SHORTLINK = "https://goto.prowler.com/provider-aws";
|
||||
const AWS_CREDENTIALS_STEP_DOCS =
|
||||
|
||||
@@ -40,6 +40,19 @@ 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 (App
|
||||
// Registration + Service Principal + Reader + custom ProwlerRole + certificate
|
||||
// credential). The template is 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";
|
||||
|
||||
// Prowler Cloud billing/subscription management page.
|
||||
export const BILLING_URL = "https://cloud.prowler.com/billing";
|
||||
|
||||
@@ -215,6 +228,14 @@ const PROVIDER_CREDENTIALS_METHOD_DOCS_URL: Record<
|
||||
credentials:
|
||||
"https://docs.prowler.com/user-guide/providers/aws/getting-started-aws#credentials-static-access-keys",
|
||||
},
|
||||
azure: {
|
||||
// The Deploy-to-Azure certificate flow is the recommended path;
|
||||
// client-secret authentication remains the manual fallback.
|
||||
app_certificate:
|
||||
"https://docs.prowler.com/user-guide/providers/azure/getting-started-azure#certificate-authentication-recommended",
|
||||
app_client_secret:
|
||||
"https://docs.prowler.com/user-guide/providers/azure/getting-started-azure#service-principal-with-client-secret",
|
||||
},
|
||||
m365: {
|
||||
app_certificate:
|
||||
"https://docs.prowler.com/user-guide/providers/microsoft365/getting-started-m365#application-certificate-authentication-recommended",
|
||||
@@ -393,3 +414,20 @@ 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/<double-encoded-template-url>` — the URL
|
||||
// fragment is re-parsed by the Portal SPA which URL-decodes the segment once
|
||||
// more, 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)}`;
|
||||
@@ -87,6 +87,7 @@ export const getProviderFormType = (
|
||||
// Providers that need credential type selection
|
||||
const needsSelector = [
|
||||
"aws",
|
||||
"azure",
|
||||
"gcp",
|
||||
"github",
|
||||
"m365",
|
||||
@@ -119,6 +120,17 @@ export const getProviderFormType = (
|
||||
return "credentials";
|
||||
}
|
||||
|
||||
// Azure credential types — shares the M365 `app_client_secret` /
|
||||
// `app_certificate` via values because both providers authenticate against
|
||||
// an Entra ID App Registration; keeping the via strings identical lets the
|
||||
// per-method docs URLs and the wizard state machine reuse the same logic.
|
||||
if (
|
||||
providerType === "azure" &&
|
||||
["app_client_secret", "app_certificate"].includes(via || "")
|
||||
) {
|
||||
return "credentials";
|
||||
}
|
||||
|
||||
// M365 credential types
|
||||
if (
|
||||
providerType === "m365" &&
|
||||
|
||||
+16
-1
@@ -219,13 +219,28 @@ export type AWSCredentialsRole = {
|
||||
[ProviderCredentialFields.CREDENTIALS_TYPE]?: AWSCredentialsType;
|
||||
};
|
||||
|
||||
export type AzureCredentials = {
|
||||
export type AzureClientSecretCredentials = {
|
||||
[ProviderCredentialFields.CLIENT_ID]: string;
|
||||
[ProviderCredentialFields.CLIENT_SECRET]: string;
|
||||
[ProviderCredentialFields.TENANT_ID]: string;
|
||||
[ProviderCredentialFields.PROVIDER_ID]: string;
|
||||
};
|
||||
|
||||
export type AzureCertificateCredentials = {
|
||||
[ProviderCredentialFields.CLIENT_ID]: string;
|
||||
[ProviderCredentialFields.CERTIFICATE_CONTENT]: string;
|
||||
[ProviderCredentialFields.TENANT_ID]: string;
|
||||
[ProviderCredentialFields.PROVIDER_ID]: string;
|
||||
};
|
||||
|
||||
// `AzureCredentials` used to be the client-secret-only shape. It now spans
|
||||
// both service-principal auth methods so callers that render either form can
|
||||
// keep the same react-hook-form Control<>. The two forms share `client_id`
|
||||
// and `tenant_id`, so consumers touching only those fields need no changes.
|
||||
export type AzureCredentials =
|
||||
| AzureClientSecretCredentials
|
||||
| AzureCertificateCredentials;
|
||||
|
||||
export type M365ClientSecretCredentials = {
|
||||
[ProviderCredentialFields.CLIENT_ID]: string;
|
||||
[ProviderCredentialFields.CLIENT_SECRET]: string;
|
||||
|
||||
+39
-2
@@ -211,12 +211,19 @@ export const addCredentialsFormSchema = (
|
||||
}
|
||||
: providerType === "azure"
|
||||
? {
|
||||
// Client secret vs. certificate is a per-form choice driven by
|
||||
// `via`; the field-level presence check runs inside the
|
||||
// credential-type form (see azure-*-credentials-form.tsx). The
|
||||
// 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_SECRET]: z
|
||||
[ProviderCredentialFields.CLIENT_SECRET]: z.string().optional(),
|
||||
[ProviderCredentialFields.CERTIFICATE_CONTENT]: z
|
||||
.string()
|
||||
.min(1, "Client Secret is required"),
|
||||
.optional(),
|
||||
[ProviderCredentialFields.TENANT_ID]: z
|
||||
.string()
|
||||
.min(1, "Tenant ID is required"),
|
||||
@@ -442,6 +449,36 @@ export const addCredentialsFormSchema = (
|
||||
: {}),
|
||||
})
|
||||
.superRefine((data: Record<string, string | undefined>, ctx) => {
|
||||
if (providerType === "azure") {
|
||||
// Azure schema keeps both `client_secret` and `certificate_content`
|
||||
// optional at field level (the credential-type selector picks which
|
||||
// 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).
|
||||
if (via === "app_client_secret") {
|
||||
const clientSecret = data[ProviderCredentialFields.CLIENT_SECRET];
|
||||
if (!clientSecret || clientSecret.trim() === "") {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Client Secret is required",
|
||||
path: [ProviderCredentialFields.CLIENT_SECRET],
|
||||
});
|
||||
}
|
||||
} 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",
|
||||
path: [ProviderCredentialFields.CERTIFICATE_CONTENT],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (providerType === "m365") {
|
||||
// Validate based on the via parameter
|
||||
if (via === "app_client_secret") {
|
||||
|
||||
Reference in new issue
Block a user