mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-24 21:11:53 +00:00
feat(aws): add external resource link to AWS Console (#9172)
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com> Co-authored-by: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com>
This commit is contained in:
@@ -4,6 +4,10 @@ All notable changes to the **Prowler UI** are documented in this file.
|
||||
|
||||
## [1.27.0] (Prowler UNRELEASED)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- AWS findings and resource details now expose a "View in AWS Console" link that opens the resource directly in the AWS Console via the universal `/go/view` ARN resolver. The per-provider external link is rendered by a new shared `ExternalResourceLink` component, which also covers the existing IaC repository link [(#9172)](https://github.com/prowler-cloud/prowler/pull/9172)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- Trimmed unused npm dependencies [(#11115)](https://github.com/prowler-cloud/prowler/pull/11115)
|
||||
|
||||
+39
-11
@@ -44,6 +44,10 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { EventsTimeline } from "@/components/shared/events-timeline/events-timeline";
|
||||
import {
|
||||
ExternalResourceLink,
|
||||
resolveExternalTarget,
|
||||
} from "@/components/shared/external-resource-link";
|
||||
import {
|
||||
QUERY_EDITOR_LANGUAGE,
|
||||
QueryCodeEditor,
|
||||
@@ -429,6 +433,16 @@ export function ResourceDetailDrawerContent({
|
||||
const resourceDetailHref = f?.resourceId
|
||||
? buildResourceDetailHref(f.resourceId)
|
||||
: null;
|
||||
const externalResourceTarget = resolveExternalTarget({
|
||||
providerType,
|
||||
resourceUid,
|
||||
providerUid,
|
||||
resourceName,
|
||||
findingUid: f?.uid,
|
||||
region: resourceRegion,
|
||||
});
|
||||
const hasIdAction =
|
||||
Boolean(resourceDetailHref) || Boolean(externalResourceTarget);
|
||||
const findingRecommendationUrl = f?.remediation.recommendation.url;
|
||||
const checkRecommendationUrl = checkMeta.remediation.recommendation.url;
|
||||
const recommendationUrl = isNonEmptyString(findingRecommendationUrl)
|
||||
@@ -699,17 +713,31 @@ export function ResourceDetailDrawerContent({
|
||||
entityId={resourceUid}
|
||||
idLabel="UID"
|
||||
idAction={
|
||||
resourceDetailHref ? (
|
||||
<Button variant="link" size="link-sm" asChild>
|
||||
<Link
|
||||
href={resourceDetailHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
View Resource
|
||||
<ExternalLink className="size-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
hasIdAction ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{resourceDetailHref && (
|
||||
<Button variant="link" size="link-sm" asChild>
|
||||
<Link
|
||||
href={resourceDetailHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
View Resource
|
||||
<ExternalLink className="size-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{externalResourceTarget && (
|
||||
<ExternalResourceLink
|
||||
providerType={providerType}
|
||||
resourceUid={resourceUid}
|
||||
providerUid={providerUid}
|
||||
resourceName={resourceName}
|
||||
findingUid={f?.uid}
|
||||
region={resourceRegion}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Row, RowSelectionState } from "@tanstack/react-table";
|
||||
import { Container, CornerDownRight, ExternalLink, Link } from "lucide-react";
|
||||
import { Container, CornerDownRight, Link } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { FloatingMuteButton } from "@/components/findings/floating-mute-button";
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from "@/components/shadcn/info-field/info-field";
|
||||
import { LoadingState } from "@/components/shadcn/spinner/loading-state";
|
||||
import { EventsTimeline } from "@/components/shared/events-timeline/events-timeline";
|
||||
import { ExternalResourceLink } from "@/components/shared/external-resource-link";
|
||||
import {
|
||||
QUERY_EDITOR_LANGUAGE,
|
||||
QueryCodeEditor,
|
||||
@@ -31,7 +32,6 @@ import { DateWithTime } from "@/components/ui/entities/date-with-time";
|
||||
import { EntityInfo } from "@/components/ui/entities/entity-info";
|
||||
import { DataTable } from "@/components/ui/table";
|
||||
import { getGroupLabel } from "@/lib/categories";
|
||||
import { buildGitFileUrl } from "@/lib/iac-utils";
|
||||
import { getRegionFlag } from "@/lib/region-flags";
|
||||
import { ProviderType, ResourceProps } from "@/types";
|
||||
|
||||
@@ -190,16 +190,6 @@ export const ResourceDetailContent = ({
|
||||
handleMuteComplete,
|
||||
);
|
||||
|
||||
const gitUrl =
|
||||
providerData.provider === "iac"
|
||||
? buildGitFileUrl(
|
||||
providerData.uid,
|
||||
attributes.name,
|
||||
"",
|
||||
attributes.region,
|
||||
)
|
||||
: null;
|
||||
|
||||
const findingTitle =
|
||||
findingDetails?.attributes?.check_metadata?.checktitle || "Finding Detail";
|
||||
const resourceName =
|
||||
@@ -268,25 +258,13 @@ export const ResourceDetailContent = ({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Copy resource link to clipboard</TooltipContent>
|
||||
</Tooltip>
|
||||
{providerData.provider === "iac" && gitUrl && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href={gitUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-bg-data-info inline-flex items-center gap-1 text-sm"
|
||||
aria-label="Open resource in repository"
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
View in Repository
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Go to Resource in the Repository
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<ExternalResourceLink
|
||||
providerType={providerData.provider}
|
||||
resourceUid={attributes.uid}
|
||||
providerUid={providerData.uid}
|
||||
resourceName={attributes.name}
|
||||
region={attributes.region}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ExternalResourceLink } from "./external-resource-link";
|
||||
|
||||
describe("ExternalResourceLink", () => {
|
||||
it("renders an AWS Console link for AWS resources with a valid ARN", () => {
|
||||
const arn = "arn:aws:s3:::example-bucket";
|
||||
render(<ExternalResourceLink providerType="aws" resourceUid={arn} />);
|
||||
|
||||
const link = screen.getByRole("link", {
|
||||
name: /open resource in aws console/i,
|
||||
});
|
||||
expect(link).toHaveAttribute(
|
||||
"href",
|
||||
`https://console.aws.amazon.com/go/view?arn=${encodeURIComponent(arn)}`,
|
||||
);
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
expect(link).toHaveAttribute("rel", "noopener noreferrer");
|
||||
expect(link).toHaveTextContent("View in AWS Console");
|
||||
});
|
||||
|
||||
it("renders a repository link for IaC resources", () => {
|
||||
render(
|
||||
<ExternalResourceLink
|
||||
providerType="iac"
|
||||
providerUid="https://github.com/example/repo"
|
||||
resourceName="main.tf"
|
||||
findingUid="check-id-main.tf-10:15"
|
||||
region="develop"
|
||||
/>,
|
||||
);
|
||||
|
||||
const link = screen.getByRole("link", {
|
||||
name: /open resource in the repository/i,
|
||||
});
|
||||
expect(link).toHaveAttribute(
|
||||
"href",
|
||||
"https://github.com/example/repo/blob/develop/main.tf#L10-L15",
|
||||
);
|
||||
expect(link).toHaveTextContent("View in Repository");
|
||||
});
|
||||
|
||||
it("renders nothing for AWS resources without a valid ARN", () => {
|
||||
const { container } = render(
|
||||
<ExternalResourceLink providerType="aws" resourceUid="not-an-arn" />,
|
||||
);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("renders nothing for IaC resources missing repo url or filename", () => {
|
||||
const { container } = render(
|
||||
<ExternalResourceLink
|
||||
providerType="iac"
|
||||
providerUid=""
|
||||
resourceName="main.tf"
|
||||
/>,
|
||||
);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("renders nothing for providers without external link support", () => {
|
||||
const { container } = render(
|
||||
<ExternalResourceLink
|
||||
providerType="azure"
|
||||
resourceUid="/subscriptions/abc/resourceGroups/rg"
|
||||
/>,
|
||||
);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { ExternalLink } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/shadcn";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { buildAwsConsoleUrl } from "@/lib/aws-utils";
|
||||
import { buildGitFileUrl, extractLineRangeFromUid } from "@/lib/iac-utils";
|
||||
|
||||
interface ExternalResourceLinkProps {
|
||||
providerType: string | null | undefined;
|
||||
resourceUid?: string | null;
|
||||
providerUid?: string | null;
|
||||
resourceName?: string | null;
|
||||
findingUid?: string | null;
|
||||
region?: string | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface ExternalResourceTarget {
|
||||
url: string;
|
||||
label: string;
|
||||
tooltip: string;
|
||||
}
|
||||
|
||||
export const resolveExternalTarget = ({
|
||||
providerType,
|
||||
resourceUid,
|
||||
providerUid,
|
||||
resourceName,
|
||||
findingUid,
|
||||
region,
|
||||
}: ExternalResourceLinkProps): ExternalResourceTarget | null => {
|
||||
if (providerType === "aws" && resourceUid) {
|
||||
const url = buildAwsConsoleUrl(resourceUid);
|
||||
if (!url) return null;
|
||||
return {
|
||||
url,
|
||||
label: "View in AWS Console",
|
||||
tooltip: "Open resource in AWS Console",
|
||||
};
|
||||
}
|
||||
|
||||
if (providerType === "iac" && providerUid && resourceName) {
|
||||
const lineRange = findingUid
|
||||
? (extractLineRangeFromUid(findingUid) ?? "")
|
||||
: "";
|
||||
const url = buildGitFileUrl(
|
||||
providerUid,
|
||||
resourceName,
|
||||
lineRange,
|
||||
region ?? undefined,
|
||||
);
|
||||
if (!url) return null;
|
||||
return {
|
||||
url,
|
||||
label: "View in Repository",
|
||||
tooltip: "Open resource in the repository",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const ExternalResourceLink = (props: ExternalResourceLinkProps) => {
|
||||
const target = resolveExternalTarget(props);
|
||||
if (!target) return null;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="link"
|
||||
size="link-sm"
|
||||
asChild
|
||||
className={props.className}
|
||||
>
|
||||
<a
|
||||
href={target.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={target.tooltip}
|
||||
>
|
||||
{target.label}
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{target.tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
ExternalResourceLink,
|
||||
resolveExternalTarget,
|
||||
} from "./external-resource-link";
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildAwsConsoleUrl } from "./aws-utils";
|
||||
|
||||
describe("buildAwsConsoleUrl", () => {
|
||||
it("returns a `/go/view` URL with the ARN URL-encoded", () => {
|
||||
const arn = "arn:aws:s3:::my-bucket";
|
||||
expect(buildAwsConsoleUrl(arn)).toBe(
|
||||
`https://console.aws.amazon.com/go/view?arn=${encodeURIComponent(arn)}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves regional and account scoping in the encoded ARN", () => {
|
||||
const arn =
|
||||
"arn:aws:iam::123456789012:role/MyRole-with+special/chars and spaces";
|
||||
const url = buildAwsConsoleUrl(arn);
|
||||
expect(url).not.toBeNull();
|
||||
expect(url).toContain(encodeURIComponent(arn));
|
||||
});
|
||||
|
||||
it("returns null for missing or non-ARN inputs", () => {
|
||||
expect(buildAwsConsoleUrl("")).toBeNull();
|
||||
expect(buildAwsConsoleUrl("not-an-arn")).toBeNull();
|
||||
expect(buildAwsConsoleUrl("https://example.com")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
// Uses the AWS Console's universal `/go/view` redirect so we don't have to
|
||||
// special-case each service — the console resolves the ARN to the right page.
|
||||
export const buildAwsConsoleUrl = (resourceArn: string): string | null => {
|
||||
if (!resourceArn || !resourceArn.startsWith("arn:")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `https://console.aws.amazon.com/go/view?arn=${encodeURIComponent(resourceArn)}`;
|
||||
};
|
||||
Reference in New Issue
Block a user