From e2cae35d38c9732bac6c73e2e293441210b160bf Mon Sep 17 00:00:00 2001 From: lydiavilchez <114735608+lydiavilchez@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:36:10 +0200 Subject: [PATCH] feat(ui): pre-fill Cloudflare and GitHub token creation URLs (#12349) --- ...rd-preconfigured-credential-links.added.md | 1 + .../workflow/forms/base-credentials-form.tsx | 2 + ...dflare-api-token-credentials-form.test.tsx | 86 +++++++++ .../cloudflare-api-token-credentials-form.tsx | 45 ++++- ...github-personal-access-token-form.test.tsx | 87 +++++++++ .../github-personal-access-token-form.tsx | 46 ++++- .../github-credentials-form.tsx | 9 +- ui/lib/external-urls.test.ts | 175 ++++++++++++++++++ ui/lib/external-urls.ts | 75 ++++++++ 9 files changed, 519 insertions(+), 7 deletions(-) create mode 100644 ui/changelog.d/provider-wizard-preconfigured-credential-links.added.md create mode 100644 ui/components/providers/workflow/forms/select-credentials-type/cloudflare/credentials-type/cloudflare-api-token-credentials-form.test.tsx create mode 100644 ui/components/providers/workflow/forms/select-credentials-type/github/credentials-type/github-personal-access-token-form.test.tsx diff --git a/ui/changelog.d/provider-wizard-preconfigured-credential-links.added.md b/ui/changelog.d/provider-wizard-preconfigured-credential-links.added.md new file mode 100644 index 0000000000..90ea9a436e --- /dev/null +++ b/ui/changelog.d/provider-wizard-preconfigured-credential-links.added.md @@ -0,0 +1 @@ +Surface pre-configured credential creation links in the add-provider wizard. Cloudflare exposes the User API Token template and an Account-Owned template pinned to the Cloudflare Account ID entered in the wizard, GitHub exposes the personal-repositories template and an organization-scanning template pinned to the identifier entered in the wizard diff --git a/ui/components/providers/workflow/forms/base-credentials-form.tsx b/ui/components/providers/workflow/forms/base-credentials-form.tsx index b90c9095c4..6ada8eccc0 100644 --- a/ui/components/providers/workflow/forms/base-credentials-form.tsx +++ b/ui/components/providers/workflow/forms/base-credentials-form.tsx @@ -213,6 +213,7 @@ export const BaseCredentialsForm = ({ )} {providerType === "iac" && ( @@ -256,6 +257,7 @@ export const BaseCredentialsForm = ({ control={ form.control as unknown as Control } + providerUid={providerUid} /> )} {providerType === "cloudflare" && effectiveVia === "api_key" && ( diff --git a/ui/components/providers/workflow/forms/select-credentials-type/cloudflare/credentials-type/cloudflare-api-token-credentials-form.test.tsx b/ui/components/providers/workflow/forms/select-credentials-type/cloudflare/credentials-type/cloudflare-api-token-credentials-form.test.tsx new file mode 100644 index 0000000000..d8278030f4 --- /dev/null +++ b/ui/components/providers/workflow/forms/select-credentials-type/cloudflare/credentials-type/cloudflare-api-token-credentials-form.test.tsx @@ -0,0 +1,86 @@ +import { render, screen } from "@testing-library/react"; +import { FormProvider, useForm } from "react-hook-form"; +import { describe, expect, it } from "vitest"; + +import { CloudflareTokenCredentials } from "@/types"; + +import { CloudflareApiTokenCredentialsForm } from "./cloudflare-api-token-credentials-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://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"; + +describe("CloudflareApiTokenCredentialsForm", () => { + it("always renders the User API Token link with the correct href and safe target attributes", () => { + // Given + render(); + + // When + const link = screen.getByRole("link", { + name: /create a pre-configured user api token/i, + }); + + // Then + expect(link).toHaveAttribute("href", USER_URL); + expect(link).toHaveAttribute("target", "_blank"); + expect(link).toHaveAttribute("rel", "noopener noreferrer"); + }); + + it("does not render the Account-Owned link when providerUid is missing so the user is not offered an ambiguous duplicate", () => { + // Given + render(); + + // Then + expect( + screen.queryByRole("link", { + name: /create a pre-configured account-owned api token/i, + }), + ).not.toBeInTheDocument(); + }); + + it("renders the Account-Owned link routed through Cloudflare's dashboard `to=` param with the account id substituted when providerUid is provided", () => { + // Given + render(); + + // When + const link = screen.getByRole("link", { + name: /create a pre-configured account-owned api token/i, + }); + + // Then + expect(link).toHaveAttribute("target", "_blank"); + expect(link).toHaveAttribute("rel", "noopener noreferrer"); + const parsed = new URL(link.getAttribute("href") ?? ""); + expect(parsed.origin).toBe("https://dash.cloudflare.com"); + expect(parsed.pathname).toBe("/"); + expect(parsed.searchParams.get("to")).toBe( + "/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4/api-tokens", + ); + expect(parsed.searchParams.get("name")).toBe("Prowler Security Scanner"); + }); + + it("ignores whitespace around providerUid so a stray user-typed space does not hide the Account-Owned link", () => { + // Given + render(); + + // Then + expect( + screen.queryByRole("link", { + name: /create a pre-configured account-owned api token/i, + }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/ui/components/providers/workflow/forms/select-credentials-type/cloudflare/credentials-type/cloudflare-api-token-credentials-form.tsx b/ui/components/providers/workflow/forms/select-credentials-type/cloudflare/credentials-type/cloudflare-api-token-credentials-form.tsx index 08f316b627..583ce3f5a3 100644 --- a/ui/components/providers/workflow/forms/select-credentials-type/cloudflare/credentials-type/cloudflare-api-token-credentials-form.tsx +++ b/ui/components/providers/workflow/forms/select-credentials-type/cloudflare/credentials-type/cloudflare-api-token-credentials-form.tsx @@ -3,14 +3,31 @@ import { Control } from "react-hook-form"; import { WizardInputField } from "@/components/providers/workflow/forms/fields"; +import { Button } from "@/components/shadcn"; +import { + buildCloudflareAccountOwnedApiTokenUrl, + PRECONFIGURED_CREDENTIAL_URLS, +} from "@/lib/external-urls"; import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields"; import { CloudflareTokenCredentials } from "@/types"; +interface CloudflareApiTokenCredentialsFormProps { + control: Control; + // Cloudflare Account ID captured in the previous wizard step. When present, + // it flows into the account-owned token URL as the account path segment so + // Cloudflare lands the user directly on the correct account's Create Custom + // Token page. When absent, only the user-scoped link is shown to avoid + // relying on Cloudflare's `:account` router placeholder, which can silently + // fall through when the user is signed into more than one account. + providerUid?: string; +} + export const CloudflareApiTokenCredentialsForm = ({ control, -}: { - control: Control; -}) => { + providerUid, +}: CloudflareApiTokenCredentialsFormProps) => { + const trimmedProviderUid = providerUid?.trim(); + return ( <>
@@ -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;