mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-09 21:14:22 +00:00
feat(ui): pre-fill Cloudflare and GitHub token creation URLs (#12349)
This commit is contained in:
@@ -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
|
||||
@@ -213,6 +213,7 @@ export const BaseCredentialsForm = ({
|
||||
<GitHubCredentialsForm
|
||||
control={form.control}
|
||||
credentialsType={effectiveVia || undefined}
|
||||
providerUid={providerUid}
|
||||
/>
|
||||
)}
|
||||
{providerType === "iac" && (
|
||||
@@ -256,6 +257,7 @@ export const BaseCredentialsForm = ({
|
||||
control={
|
||||
form.control as unknown as Control<CloudflareTokenCredentials>
|
||||
}
|
||||
providerUid={providerUid}
|
||||
/>
|
||||
)}
|
||||
{providerType === "cloudflare" && effectiveVia === "api_key" && (
|
||||
|
||||
+86
@@ -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<CloudflareTokenCredentials>();
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<CloudflareApiTokenCredentialsForm
|
||||
control={form.control}
|
||||
providerUid={providerUid}
|
||||
/>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
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(<Harness />);
|
||||
|
||||
// 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(<Harness />);
|
||||
|
||||
// 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(<Harness providerUid="a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4" />);
|
||||
|
||||
// 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(<Harness providerUid=" " />);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.queryByRole("link", {
|
||||
name: /create a pre-configured account-owned api token/i,
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+42
-3
@@ -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<CloudflareTokenCredentials>;
|
||||
// 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<CloudflareTokenCredentials>;
|
||||
}) => {
|
||||
providerUid,
|
||||
}: CloudflareApiTokenCredentialsFormProps) => {
|
||||
const trimmedProviderUid = providerUid?.trim();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col">
|
||||
@@ -33,6 +50,28 @@ export const CloudflareApiTokenCredentialsForm = ({
|
||||
variant="bordered"
|
||||
isRequired
|
||||
/>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<Button variant="link" size="link-sm" asChild>
|
||||
<a
|
||||
href={PRECONFIGURED_CREDENTIAL_URLS.CLOUDFLARE_API_TOKEN_USER}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Create a pre-configured User API Token
|
||||
</a>
|
||||
</Button>
|
||||
{trimmedProviderUid && (
|
||||
<Button variant="link" size="link-sm" asChild>
|
||||
<a
|
||||
href={buildCloudflareAccountOwnedApiTokenUrl(trimmedProviderUid)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Create a pre-configured Account-Owned API Token
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-text-neutral-tertiary text-xs">
|
||||
Tokens never leave your browser unencrypted and are stored as secrets in
|
||||
the backend. You can revoke the token from the Cloudflare dashboard
|
||||
|
||||
+87
@@ -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 (
|
||||
<FormProvider {...form}>
|
||||
<GitHubPersonalAccessTokenForm
|
||||
control={form.control}
|
||||
providerUid={providerUid}
|
||||
/>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
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(<Harness />);
|
||||
|
||||
// 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(<Harness />);
|
||||
|
||||
// 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(<Harness providerUid="prowler-cloud" />);
|
||||
|
||||
// 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(<Harness providerUid=" " />);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.queryByRole("link", {
|
||||
name: /create a pre-configured token for organization/i,
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+43
-3
@@ -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<any>;
|
||||
// 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<any>;
|
||||
}) => {
|
||||
providerUid,
|
||||
}: GitHubPersonalAccessTokenFormProps) => {
|
||||
const trimmedProviderUid = providerUid?.trim();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col">
|
||||
@@ -30,6 +46,30 @@ export const GitHubPersonalAccessTokenForm = ({
|
||||
variant="bordered"
|
||||
isRequired
|
||||
/>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<Button variant="link" size="link-sm" asChild>
|
||||
<a
|
||||
href={
|
||||
PRECONFIGURED_CREDENTIAL_URLS.GITHUB_PERSONAL_ACCESS_TOKEN_USER
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Create a pre-configured token for personal repositories
|
||||
</a>
|
||||
</Button>
|
||||
{trimmedProviderUid && (
|
||||
<Button variant="link" size="link-sm" asChild>
|
||||
<a
|
||||
href={buildGitHubPersonalAccessTokenOrgUrl(trimmedProviderUid)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{`Create a pre-configured token for organization ${trimmedProviderUid}`}
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,15 +11,22 @@ import {
|
||||
interface GitHubCredentialsFormProps {
|
||||
control: Control<any>;
|
||||
credentialsType?: string;
|
||||
providerUid?: string;
|
||||
}
|
||||
|
||||
export const GitHubCredentialsForm = ({
|
||||
control,
|
||||
credentialsType,
|
||||
providerUid,
|
||||
}: GitHubCredentialsFormProps) => {
|
||||
switch (credentialsType) {
|
||||
case "personal_access_token":
|
||||
return <GitHubPersonalAccessTokenForm control={control} />;
|
||||
return (
|
||||
<GitHubPersonalAccessTokenForm
|
||||
control={control}
|
||||
providerUid={providerUid}
|
||||
/>
|
||||
);
|
||||
case "oauth_app":
|
||||
return <GitHubOAuthAppForm control={control} />;
|
||||
case "github_app":
|
||||
|
||||
@@ -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
|
||||
// `/<accountId>/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 `/<accountId>/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 =
|
||||
|
||||
@@ -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=/<accountId>/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 `/<accountId>/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;
|
||||
|
||||
Reference in New Issue
Block a user