From d84099e87a57fe5ecb8c6b3a4f7399e3485b00b2 Mon Sep 17 00:00:00 2001 From: Andoni Alonso <14891798+andoniaf@users.noreply.github.com> Date: Wed, 13 May 2026 11:16:28 +0200 Subject: [PATCH] feat(aws): add external resource link to AWS Console (#9172) Co-authored-by: Hugo P.Brito Co-authored-by: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com> --- ui/CHANGELOG.md | 4 + .../resource-detail-drawer-content.tsx | 50 +++++++--- .../table/resource-detail-content.tsx | 40 ++------ .../external-resource-link.test.tsx | 71 ++++++++++++++ .../external-resource-link.tsx | 94 +++++++++++++++++++ .../shared/external-resource-link/index.ts | 4 + ui/lib/aws-utils.test.ts | 26 +++++ ui/lib/aws-utils.ts | 9 ++ 8 files changed, 256 insertions(+), 42 deletions(-) create mode 100644 ui/components/shared/external-resource-link/external-resource-link.test.tsx create mode 100644 ui/components/shared/external-resource-link/external-resource-link.tsx create mode 100644 ui/components/shared/external-resource-link/index.ts create mode 100644 ui/lib/aws-utils.test.ts create mode 100644 ui/lib/aws-utils.ts diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 8ea4ee3b73..c622279475 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -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) diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx index b5c011b00c..5cc3b7b298 100644 --- a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx +++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx @@ -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 ? ( - + hasIdAction ? ( + + {resourceDetailHref && ( + + )} + {externalResourceTarget && ( + + )} + ) : undefined } /> diff --git a/ui/components/resources/table/resource-detail-content.tsx b/ui/components/resources/table/resource-detail-content.tsx index e9596e77c9..81979db0c5 100644 --- a/ui/components/resources/table/resource-detail-content.tsx +++ b/ui/components/resources/table/resource-detail-content.tsx @@ -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 = ({ Copy resource link to clipboard - {providerData.provider === "iac" && gitUrl && ( - - - - - View in Repository - - - - Go to Resource in the Repository - - - )} + diff --git a/ui/components/shared/external-resource-link/external-resource-link.test.tsx b/ui/components/shared/external-resource-link/external-resource-link.test.tsx new file mode 100644 index 0000000000..c4f5629710 --- /dev/null +++ b/ui/components/shared/external-resource-link/external-resource-link.test.tsx @@ -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(); + + 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( + , + ); + + 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( + , + ); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders nothing for IaC resources missing repo url or filename", () => { + const { container } = render( + , + ); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders nothing for providers without external link support", () => { + const { container } = render( + , + ); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/ui/components/shared/external-resource-link/external-resource-link.tsx b/ui/components/shared/external-resource-link/external-resource-link.tsx new file mode 100644 index 0000000000..e08544ea19 --- /dev/null +++ b/ui/components/shared/external-resource-link/external-resource-link.tsx @@ -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 ( + + + + + {target.tooltip} + + ); +}; diff --git a/ui/components/shared/external-resource-link/index.ts b/ui/components/shared/external-resource-link/index.ts new file mode 100644 index 0000000000..3c1b341060 --- /dev/null +++ b/ui/components/shared/external-resource-link/index.ts @@ -0,0 +1,4 @@ +export { + ExternalResourceLink, + resolveExternalTarget, +} from "./external-resource-link"; diff --git a/ui/lib/aws-utils.test.ts b/ui/lib/aws-utils.test.ts new file mode 100644 index 0000000000..32957a18ad --- /dev/null +++ b/ui/lib/aws-utils.test.ts @@ -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(); + }); +}); diff --git a/ui/lib/aws-utils.ts b/ui/lib/aws-utils.ts new file mode 100644 index 0000000000..96041481ce --- /dev/null +++ b/ui/lib/aws-utils.ts @@ -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)}`; +};