@@ -33,6 +50,28 @@ export const CloudflareApiTokenCredentialsForm = ({
variant="bordered"
isRequired
/>
+
Tokens never leave your browser unencrypted and are stored as secrets in
the backend. You can revoke the token from the Cloudflare dashboard
diff --git a/ui/components/providers/workflow/forms/select-credentials-type/github/credentials-type/github-personal-access-token-form.test.tsx b/ui/components/providers/workflow/forms/select-credentials-type/github/credentials-type/github-personal-access-token-form.test.tsx
new file mode 100644
index 0000000000..2ea4cd70f7
--- /dev/null
+++ b/ui/components/providers/workflow/forms/select-credentials-type/github/credentials-type/github-personal-access-token-form.test.tsx
@@ -0,0 +1,87 @@
+import { render, screen } from "@testing-library/react";
+import { FormProvider, useForm } from "react-hook-form";
+import { describe, expect, it } from "vitest";
+
+import { GitHubPersonalAccessTokenForm } from "./github-personal-access-token-form";
+
+// Wraps the form in a react-hook-form context so the WizardInputField mounts
+// without exploding. We are testing the surrounding links, not the input.
+const Harness = ({ providerUid }: { providerUid?: string }) => {
+ const form = useForm();
+ return (
+
+
+
+ );
+};
+
+const USER_URL =
+ "https://github.com/settings/personal-access-tokens/new?name=Prowler+Security+Scanner&description=Fine-grained+PAT+for+Prowler+security+scanning&expires_in=90&administration=read&contents=read&vulnerability_alerts=read&emails=read";
+
+const expectSafeExternalLink = (href: string) => (name: RegExp) => {
+ const link = screen.getByRole("link", { name });
+ expect(link).toHaveAttribute("href", href);
+ expect(link).toHaveAttribute("target", "_blank");
+ expect(link).toHaveAttribute("rel", "noopener noreferrer");
+};
+
+describe("GitHubPersonalAccessTokenForm", () => {
+ it("renders the personal-repositories link with the correct href and safe target attributes", () => {
+ // Given
+ render(
);
+
+ // Then
+ expectSafeExternalLink(USER_URL)(
+ /create a pre-configured token for personal repositories/i,
+ );
+ });
+
+ it("does not render the organization link when providerUid is missing so the user is not offered an identical-looking duplicate", () => {
+ // Given
+ render(
);
+
+ // Then
+ expect(
+ screen.queryByRole("link", {
+ name: /create a pre-configured token for organization/i,
+ }),
+ ).not.toBeInTheDocument();
+ });
+
+ it("renders the organization link with the identifier pinned as target_name when providerUid is provided", () => {
+ // Given
+ render(
);
+
+ // When
+ const orgLink = screen.getByRole("link", {
+ name: /create a pre-configured token for organization prowler-cloud/i,
+ });
+
+ // Then
+ expect(orgLink).toHaveAttribute("target", "_blank");
+ expect(orgLink).toHaveAttribute("rel", "noopener noreferrer");
+ const orgUrl = new URL(orgLink.getAttribute("href") ?? "");
+ expect(orgUrl.origin + orgUrl.pathname).toBe(
+ "https://github.com/settings/personal-access-tokens/new",
+ );
+ expect(orgUrl.searchParams.get("target_name")).toBe("prowler-cloud");
+ expect(orgUrl.searchParams.get("organization_administration")).toBe("read");
+ expect(orgUrl.searchParams.get("members")).toBe("read");
+ expect(orgUrl.searchParams.get("emails")).toBeNull();
+ });
+
+ it("ignores whitespace around providerUid so a stray user-typed space does not hide the organization link", () => {
+ // Given
+ render(
);
+
+ // Then
+ expect(
+ screen.queryByRole("link", {
+ name: /create a pre-configured token for organization/i,
+ }),
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/ui/components/providers/workflow/forms/select-credentials-type/github/credentials-type/github-personal-access-token-form.tsx b/ui/components/providers/workflow/forms/select-credentials-type/github/credentials-type/github-personal-access-token-form.tsx
index 1b749e238f..e8b52db9de 100644
--- a/ui/components/providers/workflow/forms/select-credentials-type/github/credentials-type/github-personal-access-token-form.tsx
+++ b/ui/components/providers/workflow/forms/select-credentials-type/github/credentials-type/github-personal-access-token-form.tsx
@@ -3,13 +3,29 @@
import { Control } from "react-hook-form";
import { WizardInputField } from "@/components/providers/workflow/forms/fields";
+import { Button } from "@/components/shadcn";
+import {
+ buildGitHubPersonalAccessTokenOrgUrl,
+ PRECONFIGURED_CREDENTIAL_URLS,
+} from "@/lib/external-urls";
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
+interface GitHubPersonalAccessTokenFormProps {
+ control: Control
;
+ // GitHub identifier entered in the previous wizard step. When it names an
+ // organization, it flows into the org-scoped token URL as `target_name` so
+ // GitHub pre-selects the right Resource Owner and surfaces the org-only
+ // permissions (`organization_administration`, `members`). When absent, only
+ // the personal-repositories link is shown.
+ providerUid?: string;
+}
+
export const GitHubPersonalAccessTokenForm = ({
control,
-}: {
- control: Control;
-}) => {
+ providerUid,
+}: GitHubPersonalAccessTokenFormProps) => {
+ const trimmedProviderUid = providerUid?.trim();
+
return (
<>
@@ -30,6 +46,30 @@ export const GitHubPersonalAccessTokenForm = ({
variant="bordered"
isRequired
/>
+
>
);
};
diff --git a/ui/components/providers/workflow/forms/via-credentials/github-credentials-form.tsx b/ui/components/providers/workflow/forms/via-credentials/github-credentials-form.tsx
index 94d3443cb7..4eadf37856 100644
--- a/ui/components/providers/workflow/forms/via-credentials/github-credentials-form.tsx
+++ b/ui/components/providers/workflow/forms/via-credentials/github-credentials-form.tsx
@@ -11,15 +11,22 @@ import {
interface GitHubCredentialsFormProps {
control: Control
;
credentialsType?: string;
+ providerUid?: string;
}
export const GitHubCredentialsForm = ({
control,
credentialsType,
+ providerUid,
}: GitHubCredentialsFormProps) => {
switch (credentialsType) {
case "personal_access_token":
- return ;
+ return (
+
+ );
case "oauth_app":
return ;
case "github_app":
diff --git a/ui/lib/external-urls.test.ts b/ui/lib/external-urls.test.ts
index 86a9976ccd..86535e7794 100644
--- a/ui/lib/external-urls.test.ts
+++ b/ui/lib/external-urls.test.ts
@@ -6,8 +6,11 @@ import { describe, expect, it } from "vitest";
import { PROVIDER_WIZARD_STEP } from "@/types/provider-wizard";
import {
+ buildCloudflareAccountOwnedApiTokenUrl,
+ buildGitHubPersonalAccessTokenOrgUrl,
getAWSCredentialsTemplateLinks,
getAWSOrgDeploymentQuickLink,
+ PRECONFIGURED_CREDENTIAL_URLS,
getProviderHelpText,
PROWLER_CF_TEMPLATE_URL,
} from "./external-urls";
@@ -110,6 +113,178 @@ describe("getAWSOrgDeploymentQuickLink", () => {
});
});
+describe("PRECONFIGURED_CREDENTIAL_URLS", () => {
+ it("keeps the Cloudflare User API Token URL under the profile route with the four required read scopes", () => {
+ // Snapshot check: fixes the exact URL so a stray edit to the permission
+ // scopes, token name, account/zone selectors or console origin trips a
+ // failing test instead of silently shipping a broken pre-configured
+ // token flow to users. Matches the "User API Token" link in
+ // docs/user-guide/providers/cloudflare/authentication.mdx.
+ expect(PRECONFIGURED_CREDENTIAL_URLS.CLOUDFLARE_API_TOKEN_USER).toBe(
+ "https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22account_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22read%22%7D%5D&accountId=%2A&zoneId=all&name=Prowler%20Security%20Scanner",
+ );
+ });
+
+ it("carries the four Prowler read scopes as decoded permissionGroupKeys on the Cloudflare User API Token URL", () => {
+ // Semantic contract: the URL must request read on account_settings, zone,
+ // zone_settings and dns and reuse the shared Prowler token name.
+ const parsed = new URL(
+ PRECONFIGURED_CREDENTIAL_URLS.CLOUDFLARE_API_TOKEN_USER,
+ );
+ const permissionGroupKeys = JSON.parse(
+ parsed.searchParams.get("permissionGroupKeys") ?? "[]",
+ );
+
+ expect(permissionGroupKeys).toEqual([
+ { key: "account_settings", type: "read" },
+ { key: "zone", type: "read" },
+ { key: "zone_settings", type: "read" },
+ { key: "dns", type: "read" },
+ ]);
+ expect(parsed.searchParams.get("name")).toBe("Prowler Security Scanner");
+ });
+
+ it("keeps the GitHub user-scope PAT URL pre-filled with the four required read permissions", () => {
+ // Snapshot check: fixes the exact URL so any accidental scope broadening
+ // (or a rename of `expires_in` / permission slugs on GitHub's side) trips
+ // a failing test. Matches the "user repositories" URL published in
+ // docs/user-guide/providers/github/authentication.mdx.
+ expect(
+ PRECONFIGURED_CREDENTIAL_URLS.GITHUB_PERSONAL_ACCESS_TOKEN_USER,
+ ).toBe(
+ "https://github.com/settings/personal-access-tokens/new?name=Prowler+Security+Scanner&description=Fine-grained+PAT+for+Prowler+security+scanning&expires_in=90&administration=read&contents=read&vulnerability_alerts=read&emails=read",
+ );
+ });
+
+ it("carries only read-level permissions on the GitHub user-scope PAT URL and no organization-only scopes", () => {
+ // Semantic contract: every permission query-param must be `read`, and the
+ // two organization-only permissions must NOT leak into the user URL
+ // (otherwise GitHub would reject the whole permission set with a
+ // Resource-Owner mismatch when the caller is a personal account).
+ const parsed = new URL(
+ PRECONFIGURED_CREDENTIAL_URLS.GITHUB_PERSONAL_ACCESS_TOKEN_USER,
+ );
+
+ expect(parsed.searchParams.get("name")).toBe("Prowler Security Scanner");
+ expect(parsed.searchParams.get("expires_in")).toBe("90");
+ expect(parsed.searchParams.get("organization_administration")).toBeNull();
+ expect(parsed.searchParams.get("members")).toBeNull();
+
+ const NON_PERMISSION_PARAMS = new Set([
+ "name",
+ "description",
+ "expires_in",
+ ]);
+ for (const [key, value] of Array.from(parsed.searchParams.entries())) {
+ if (NON_PERMISSION_PARAMS.has(key)) continue;
+ expect(
+ value,
+ `permission "${key}" should be granted at "read" level`,
+ ).toBe("read");
+ }
+ });
+});
+
+describe("buildCloudflareAccountOwnedApiTokenUrl", () => {
+ it("pins the token to the account by routing through the dashboard `to=` param with the account id substituted", () => {
+ // Cloudflare's SPA only reads the pre-fill query params
+ // (`permissionGroupKeys`, `name`) when the user arrives via the dashboard
+ // router with a `to=` value — navigating straight to
+ // `//api-tokens/create?params` renders the form but drops the
+ // params on the floor. Substituting the account id in place of the docs'
+ // `:account` placeholder keeps the pre-fill working and avoids ambiguity
+ // for users signed into multiple accounts.
+ const url = new URL(
+ buildCloudflareAccountOwnedApiTokenUrl(
+ "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
+ ),
+ );
+
+ expect(url.origin).toBe("https://dash.cloudflare.com");
+ expect(url.pathname).toBe("/");
+ expect(url.searchParams.get("to")).toBe(
+ "/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4/api-tokens",
+ );
+ expect(url.searchParams.get("name")).toBe("Prowler Security Scanner");
+ const permissionGroupKeys = JSON.parse(
+ url.searchParams.get("permissionGroupKeys") ?? "[]",
+ );
+ expect(permissionGroupKeys).toEqual([
+ { key: "account_settings", type: "read" },
+ { key: "zone", type: "read" },
+ { key: "zone_settings", type: "read" },
+ { key: "dns", type: "read" },
+ ]);
+ });
+
+ it("keeps the `to=` path unencoded so Cloudflare's router matches it", () => {
+ // Cloudflare's router matches on the raw string in `to`, so the slashes
+ // inside `//api-tokens` must NOT be percent-encoded.
+ // URLSearchParams would encode them; asserting the raw substring guards
+ // against a future refactor that swaps the manual query-string build for
+ // URLSearchParams.
+ const url = buildCloudflareAccountOwnedApiTokenUrl(
+ "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
+ );
+
+ expect(url).toContain("?to=/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4/api-tokens&");
+ });
+
+ it("URL-encodes account ids that contain characters requiring escaping", () => {
+ // Real Cloudflare account ids are hex strings today, but the wizard
+ // accepts whatever the user typed. Encoding the account id inside the
+ // `to=` path prevents a stray `/` or `#` from silently breaking the URL:
+ // an unencoded `#` would truncate the query string, and an unencoded `/`
+ // would let Cloudflare mis-parse the account id boundary.
+ const url = buildCloudflareAccountOwnedApiTokenUrl("acct/with#gaps");
+
+ expect(url).toContain("?to=/acct%2Fwith%23gaps/api-tokens&");
+ });
+});
+
+describe("buildGitHubPersonalAccessTokenOrgUrl", () => {
+ it("pins the token Resource Owner via target_name and requests the org-only permissions", () => {
+ // Without a `target_name`, GitHub silently ignores the two organization
+ // permissions (`organization_administration`, `members`) because the
+ // Resource Owner defaults to the caller's personal account. Pinning the
+ // owner is what makes the pre-checked org permissions actually surface.
+ const url = new URL(buildGitHubPersonalAccessTokenOrgUrl("prowler-cloud"));
+
+ expect(url.origin + url.pathname).toBe(
+ "https://github.com/settings/personal-access-tokens/new",
+ );
+ expect(url.searchParams.get("target_name")).toBe("prowler-cloud");
+ expect(url.searchParams.get("organization_administration")).toBe("read");
+ expect(url.searchParams.get("members")).toBe("read");
+ expect(url.searchParams.get("administration")).toBe("read");
+ expect(url.searchParams.get("contents")).toBe("read");
+ expect(url.searchParams.get("vulnerability_alerts")).toBe("read");
+ expect(url.searchParams.get("name")).toBe("Prowler Security Scanner");
+ expect(url.searchParams.get("expires_in")).toBe("90");
+ });
+
+ it("omits the account-only `emails` permission from the org URL", () => {
+ // `emails` is an account-level permission that only makes sense when the
+ // Resource Owner is a personal user account. Including it on an org URL
+ // would cause GitHub to reject the whole permission set.
+ const url = new URL(buildGitHubPersonalAccessTokenOrgUrl("prowler-cloud"));
+
+ expect(url.searchParams.get("emails")).toBeNull();
+ });
+
+ it("URL-encodes an organization slug that contains characters requiring escaping", () => {
+ // GitHub org slugs cannot contain `&` or spaces today, but callers pass
+ // whatever the wizard collected verbatim, so the helper must not blindly
+ // concatenate. URLSearchParams enforces percent-encoding.
+ const url = new URL(
+ buildGitHubPersonalAccessTokenOrgUrl("prowler & friends"),
+ );
+
+ expect(url.searchParams.get("target_name")).toBe("prowler & friends");
+ expect(url.search).toContain("target_name=prowler+%26+friends");
+ });
+});
+
describe("getProviderHelpText", () => {
const AWS_SHORTLINK = "https://goto.prowler.com/provider-aws";
const AWS_CREDENTIALS_STEP_DOCS =
diff --git a/ui/lib/external-urls.ts b/ui/lib/external-urls.ts
index 0c5a9585cf..f7ffe5b0cf 100644
--- a/ui/lib/external-urls.ts
+++ b/ui/lib/external-urls.ts
@@ -46,6 +46,81 @@ export const BILLING_URL = "https://cloud.prowler.com/billing";
const CF_QUICKCREATE_BASE_URL =
"https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacks/quickcreate";
+// Deep links that open each provider's cloud console with the credential
+// creation form pre-filled with the exact permissions, scopes and name
+// Prowler needs. The full 16-provider audit (which providers support this and
+// which do not) lives in the PROWLER-2187 PR description; keep both in sync
+// when adding or removing entries here.
+// AWS has its own CloudFormation quick-create link built in
+// `getAWSCredentialsTemplateLinks` below.
+export const PRECONFIGURED_CREDENTIAL_URLS = {
+ // Opens the Cloudflare "Create Custom Token" form under the user profile
+ // pre-filled with the four read-only scopes Prowler needs
+ // (`Account Settings`, `Zone`, `Zone Settings`, `DNS`) and the token name.
+ // Kept in sync with the "User API Token" URL published in
+ // docs/user-guide/providers/cloudflare/authentication.mdx.
+ CLOUDFLARE_API_TOKEN_USER:
+ "https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22account_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22read%22%7D%5D&accountId=%2A&zoneId=all&name=Prowler%20Security%20Scanner",
+ // Opens the GitHub fine-grained PAT creation form pre-filled with the four
+ // read-only permissions Prowler needs to scan a user's own repositories.
+ // Kept in sync with the "user repositories" URL published in
+ // docs/user-guide/providers/github/authentication.mdx.
+ GITHUB_PERSONAL_ACCESS_TOKEN_USER:
+ "https://github.com/settings/personal-access-tokens/new?name=Prowler+Security+Scanner&description=Fine-grained+PAT+for+Prowler+security+scanning&expires_in=90&administration=read&contents=read&vulnerability_alerts=read&emails=read",
+} as const;
+
+// Builds the account-owned Cloudflare API Token URL for the given account.
+// Uses the dashboard router pattern (`?to=//api-tokens&...`)
+// published in docs/user-guide/providers/cloudflare/authentication.mdx, with
+// the account id substituted in place of the docs' `:account` placeholder to
+// avoid ambiguity when the user is signed into multiple accounts. Navigating
+// directly to `//api-tokens/create` does NOT pre-fill the form —
+// Cloudflare only reads the pre-fill params when they arrive via the router.
+// Same four read-only scopes as the user token URL.
+export const buildCloudflareAccountOwnedApiTokenUrl = (
+ accountId: string,
+): string => {
+ // Cloudflare's router expects the `to=` value with unencoded slashes; using
+ // URLSearchParams would percent-encode them and break the redirect, so we
+ // assemble the query string manually and only encode the pieces that need
+ // it.
+ const encodedAccountId = encodeURIComponent(accountId);
+ const permissionGroupKeys = encodeURIComponent(
+ JSON.stringify([
+ { key: "account_settings", type: "read" },
+ { key: "zone", type: "read" },
+ { key: "zone_settings", type: "read" },
+ { key: "dns", type: "read" },
+ ]),
+ );
+ const name = encodeURIComponent("Prowler Security Scanner");
+ return `https://dash.cloudflare.com/?to=/${encodedAccountId}/api-tokens&permissionGroupKeys=${permissionGroupKeys}&name=${name}`;
+};
+
+// Builds the organization-scoped GitHub fine-grained PAT URL. GitHub validates
+// permissions against the token's Resource Owner and only surfaces
+// `organization_administration` and `members` when it is an organization, so
+// we pin the owner via `target_name` and skip account-only permissions
+// (`emails`) that the docs' org template does not request. Kept in sync with
+// the "organization scanning" URL published in
+// docs/user-guide/providers/github/authentication.mdx.
+export const buildGitHubPersonalAccessTokenOrgUrl = (
+ targetName: string,
+): string => {
+ const params = new URLSearchParams({
+ name: "Prowler Security Scanner",
+ description: "Fine-grained PAT for Prowler organization security scanning",
+ expires_in: "90",
+ target_name: targetName,
+ administration: "read",
+ contents: "read",
+ vulnerability_alerts: "read",
+ organization_administration: "read",
+ members: "read",
+ });
+ return `https://github.com/settings/personal-access-tokens/new?${params.toString()}`;
+};
+
export interface AWSOrgDeploymentQuickLinkParams {
externalId: string;
organizationalUnitId: string;