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:
Andoni Alonso
2026-05-13 11:16:28 +02:00
committed by GitHub
parent cf55f7eb43
commit d84099e87a
8 changed files with 256 additions and 42 deletions
+4
View File
@@ -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)
@@ -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";
+26
View File
@@ -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();
});
});
+9
View File
@@ -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)}`;
};