feat(github): add external resource link (#9153)

Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
Andoni Alonso
2025-11-05 15:57:41 +01:00
committed by GitHub
parent 191d51675c
commit b25ed9fd27
8 changed files with 295 additions and 55 deletions

View File

@@ -17,6 +17,10 @@ SAMPLE_TRIVY_OUTPUT = {
"Severity": "LOW",
"PrimaryURL": "https://avd.aquasec.com/misconfig/aws/s3/avd-aws-0001",
"RuleID": "AVD-AWS-0001",
"CauseMetadata": {
"StartLine": 10,
"EndLine": 15,
},
},
{
"ID": "AVD-AWS-0002",
@@ -27,6 +31,10 @@ SAMPLE_TRIVY_OUTPUT = {
"Severity": "LOW",
"PrimaryURL": "https://avd.aquasec.com/misconfig/aws/s3/avd-aws-0002",
"RuleID": "AVD-AWS-0002",
"CauseMetadata": {
"StartLine": 20,
"EndLine": 25,
},
},
],
"Vulnerabilities": [],
@@ -46,6 +54,10 @@ SAMPLE_TRIVY_OUTPUT = {
"Severity": "LOW",
"PrimaryURL": "https://avd.aquasec.com/misconfig/aws/s3/avd-aws-0003",
"RuleID": "AVD-AWS-0003",
"CauseMetadata": {
"StartLine": 30,
"EndLine": 35,
},
}
],
"Vulnerabilities": [],
@@ -67,6 +79,10 @@ SAMPLE_FAILED_CHECK = {
"Severity": "low",
"PrimaryURL": "https://avd.aquasec.com/misconfig/aws/s3/avd-aws-0001",
"RuleID": "AVD-AWS-0001",
"CauseMetadata": {
"StartLine": 10,
"EndLine": 15,
},
}
SAMPLE_PASSED_CHECK = {
@@ -78,6 +94,10 @@ SAMPLE_PASSED_CHECK = {
"Severity": "low",
"PrimaryURL": "https://avd.aquasec.com/misconfig/aws/s3/avd-aws-0003",
"RuleID": "AVD-AWS-0003",
"CauseMetadata": {
"StartLine": 30,
"EndLine": 35,
},
}
# Additional sample checks

View File

@@ -9,6 +9,7 @@ All notable changes to the **Prowler UI** are documented in this file.
- RSS feeds support [(#9109)](https://github.com/prowler-cloud/prowler/pull/9109)
- Customer Support menu item [(#9143)](https://github.com/prowler-cloud/prowler/pull/9143)
- IaC (Infrastructure as Code) provider support for scanning remote repositories [(#8751)](https://github.com/prowler-cloud/prowler/pull/8751)
- External resource link to IaC findings for direct navigation to source code in Git repositories [(#9151)](https://github.com/prowler-cloud/prowler/pull/9151)
### 🔄 Changed

View File

@@ -1,18 +1,17 @@
"use client";
import { Snippet } from "@heroui/snippet";
import { Tooltip } from "@heroui/tooltip";
import { ExternalLink, Link } from "lucide-react";
import ReactMarkdown from "react-markdown";
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
import { CustomSection } from "@/components/ui/custom";
import { CustomLink } from "@/components/ui/custom/custom-link";
import {
CopyLinkButton,
EntityInfoShort,
InfoField,
} from "@/components/ui/entities";
import { EntityInfoShort, InfoField } from "@/components/ui/entities";
import { DateWithTime } from "@/components/ui/entities/date-with-time";
import { SeverityBadge } from "@/components/ui/table/severity-badge";
import { buildGitFileUrl, extractLineRangeFromUid } from "@/lib/iac-utils";
import { FindingProps, ProviderType } from "@/types";
import { Muted } from "../muted";
@@ -60,14 +59,32 @@ export const FindingDetail = ({
params.set("id", findingDetails.id);
const url = `${window.location.origin}${currentUrl.pathname}?${params.toString()}`;
// Build Git URL for IaC findings
const gitUrl =
providerDetails.provider === "iac"
? buildGitFileUrl(
providerDetails.uid,
resource.name,
extractLineRangeFromUid(attributes.uid) || "",
)
: null;
return (
<div className="flex flex-col gap-6 rounded-lg">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="dark:text-prowler-theme-pale/90 line-clamp-2 text-lg leading-tight font-medium text-gray-800">
<h2 className="dark:text-prowler-theme-pale/90 line-clamp-2 flex items-center gap-2 text-lg leading-tight font-medium text-gray-800">
{renderValue(attributes.check_metadata.checktitle)}
<CopyLinkButton url={url} />
<Tooltip content="Copy finding link to clipboard" size="sm">
<button
onClick={() => navigator.clipboard.writeText(url)}
className="text-bg-data-info inline-flex cursor-pointer transition-opacity hover:opacity-80"
aria-label="Copy finding link to clipboard"
>
<Link size={16} />
</button>
</Tooltip>
</h2>
</div>
<div className="flex items-center gap-x-4">
@@ -238,7 +255,30 @@ export const FindingDetail = ({
</CustomSection>
{/* Resource Details */}
<CustomSection title="Resource Details">
<CustomSection
title={
providerDetails.provider === "iac" ? (
<span className="flex items-center gap-2">
Resource Details
{gitUrl && (
<Tooltip content="Go to Resource in the Repository" size="sm">
<a
href={gitUrl}
target="_blank"
rel="noopener noreferrer"
className="text-bg-data-info inline-flex cursor-pointer"
aria-label="Open resource in repository"
>
<ExternalLink size={16} className="inline" />
</a>
</Tooltip>
)}
</span>
) : (
"Resource Details"
)
}
>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<InfoField label="Resource Name">
{renderValue(resource.name)}

View File

@@ -2,7 +2,8 @@
import { Snippet } from "@heroui/snippet";
import { Spinner } from "@heroui/spinner";
import { InfoIcon } from "lucide-react";
import { Tooltip } from "@heroui/tooltip";
import { ExternalLink, InfoIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { getFindingById } from "@/actions/findings";
@@ -17,6 +18,7 @@ import {
} from "@/components/ui/entities";
import { SeverityBadge, StatusFindingBadge } from "@/components/ui/table";
import { createDict } from "@/lib";
import { buildGitFileUrl } from "@/lib/iac-utils";
import { FindingProps, ProviderType, ResourceProps } from "@/types";
const renderValue = (value: string | null | undefined) => {
@@ -162,6 +164,12 @@ export const ResourceDetail = ({
const providerData = resource.relationships.provider.data.attributes;
const allFindings = findingsData;
// Build Git URL for IaC resources
const gitUrl =
providerData.provider === "iac"
? buildGitFileUrl(providerData.uid, attributes.name, "")
: null;
if (selectedFindingId) {
const findingTitle =
findingDetails?.attributes?.check_metadata?.checktitle ||
@@ -186,7 +194,30 @@ export const ResourceDetail = ({
return (
<div className="flex flex-col gap-6 rounded-lg">
{/* Resource Details section */}
<CustomSection title="Resource Details">
<CustomSection
title={
providerData.provider === "iac" ? (
<span className="flex items-center gap-2">
Resource Details
{gitUrl && (
<Tooltip content="Go to Resource in the Repository" size="sm">
<a
href={gitUrl}
target="_blank"
rel="noopener noreferrer"
className="text-bg-data-info inline-flex cursor-pointer"
aria-label="Open resource in repository"
>
<ExternalLink size={16} className="inline" />
</a>
</Tooltip>
)}
</span>
) : (
"Resource Details"
)
}
>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<InfoField label="Resource UID" variant="simple">
<Snippet className="bg-gray-50 py-1 dark:bg-slate-800" hideSymbol>

View File

@@ -1,7 +1,9 @@
import { type ReactNode } from "react";
interface CustomSectionProps {
title: string;
children: React.ReactNode;
action?: React.ReactNode;
title: string | ReactNode;
children: ReactNode;
action?: ReactNode;
}
export const CustomSection = ({

View File

@@ -1,41 +0,0 @@
"use client";
import { Tooltip } from "@heroui/tooltip";
import { CheckCheck, ExternalLink } from "lucide-react";
import { useState } from "react";
type CopyLinkButtonProps = {
url: string;
};
export const CopyLinkButton = ({ url }: CopyLinkButtonProps) => {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(url);
setCopied(true);
setTimeout(() => setCopied(false), 500);
} catch (err) {
console.error("Failed to copy URL to clipboard:", err);
}
};
return (
<Tooltip content="Copy URL to clipboard" size="sm">
<button
type="button"
onClick={handleCopy}
className="ml-2 inline-flex cursor-pointer flex-row items-center gap-2 p-0"
aria-label="Copy URL to clipboard"
>
{copied ? (
<CheckCheck size={16} className="inline" />
) : (
<ExternalLink size={16} className="inline" />
)}
</button>
</Tooltip>
);
};

View File

@@ -1,4 +1,3 @@
export * from "./copy-link-button";
export * from "./date-with-time";
export * from "./entity-info-short";
export * from "./get-provider-logo";

188
ui/lib/iac-utils.ts Normal file
View File

@@ -0,0 +1,188 @@
/**
* Extracts line range from a Finding UID
* Finding UID format: {CheckID}-{resource_name}-{line_range}
* Example: "AVD-AWS-0001-main.tf-10:15" -> "10:15"
*
* @param findingUid - The finding UID
* @returns Line range string or null if not found
*/
export function extractLineRangeFromUid(findingUid: string): string | null {
if (!findingUid) {
return null;
}
// Split by dash and get the last part (line range)
const parts = findingUid.split("-");
const lastPart = parts[parts.length - 1];
// Check if the last part is a line range in format "number:number"
// This ensures we don't confuse numeric filenames with line ranges
if (/^\d+:\d+$/.test(lastPart)) {
return lastPart;
}
return null;
}
/**
* Builds a Git repository URL with file path and line numbers
* Supports GitHub, GitLab, Bitbucket, and generic Git URLs
*
* @param repoUrl - Repository URL (can be HTTPS or git@ format)
* @param filePath - Path to the file in the repository
* @param lineRange - Line range in format "10-15" or "10:15" or "10"
* @returns Complete URL to the file with line numbers, or null if URL cannot be built
*/
export function buildGitFileUrl(
repoUrl: string,
filePath: string,
lineRange: string,
): string | null {
if (!repoUrl || !filePath) {
return null;
}
try {
// Normalize the repository URL
let normalizedUrl = repoUrl.trim();
// Convert git@ format to HTTPS (best effort)
if (normalizedUrl.startsWith("git@")) {
// git@github.com:user/repo.git -> https://github.com/user/repo
normalizedUrl = normalizedUrl
.replace(/^git@/, "https://")
.replace(/\.git$/, "")
.replace(/:([^:]+)$/, "/$1"); // Replace last : with /
}
// Remove .git suffix if present
normalizedUrl = normalizedUrl.replace(/\.git$/, "");
// Parse URL to determine provider
const url = new URL(normalizedUrl);
const hostname = url.hostname.toLowerCase();
// Clean up file path (remove leading slashes)
const cleanFilePath = filePath.replace(/^\/+/, "");
// Parse line range
const { startLine, endLine } = parseLineRange(lineRange);
// Build URL based on Git provider
if (hostname.includes("github")) {
return buildGitHubUrl(normalizedUrl, cleanFilePath, startLine, endLine);
} else if (hostname.includes("gitlab")) {
return buildGitLabUrl(normalizedUrl, cleanFilePath, startLine, endLine);
} else if (hostname.includes("bitbucket")) {
return buildBitbucketUrl(
normalizedUrl,
cleanFilePath,
startLine,
endLine,
);
} else {
// Generic Git provider - try GitHub format as fallback
return buildGitHubUrl(normalizedUrl, cleanFilePath, startLine, endLine);
}
} catch (error) {
console.error("Error building Git file URL:", error);
return null;
}
}
/**
* Parses line range string into start and end line numbers
*/
function parseLineRange(lineRange: string): {
startLine: number | null;
endLine: number | null;
} {
if (!lineRange || lineRange === "file") {
return { startLine: null, endLine: null };
}
// Handle formats: "10-15", "10:15", "10"
// Safe regex: anchored pattern for line numbers only (no ReDoS risk)
// eslint-disable-next-line security/detect-unsafe-regex
const match = lineRange.match(/^(\d+)[-:]?(\d+)?$/);
if (match) {
const startLine = parseInt(match[1], 10);
const endLine = match[2] ? parseInt(match[2], 10) : startLine;
return { startLine, endLine };
}
return { startLine: null, endLine: null };
}
/**
* Builds GitHub-style URL
* Format: https://github.com/user/repo/blob/main/path/file.tf#L10-L15
*/
function buildGitHubUrl(
baseUrl: string,
filePath: string,
startLine: number | null,
endLine: number | null,
): string {
// Assume main/master branch for simplicity
const branch = "main";
let url = `${baseUrl}/blob/${branch}/${filePath}`;
if (startLine !== null) {
if (endLine !== null && endLine !== startLine) {
url += `#L${startLine}-L${endLine}`;
} else {
url += `#L${startLine}`;
}
}
return url;
}
/**
* Builds GitLab-style URL
* Format: https://gitlab.com/user/repo/-/blob/main/path/file.tf#L10-15
*/
function buildGitLabUrl(
baseUrl: string,
filePath: string,
startLine: number | null,
endLine: number | null,
): string {
const branch = "main";
let url = `${baseUrl}/-/blob/${branch}/${filePath}`;
if (startLine !== null) {
if (endLine !== null && endLine !== startLine) {
url += `#L${startLine}-${endLine}`;
} else {
url += `#L${startLine}`;
}
}
return url;
}
/**
* Builds Bitbucket-style URL
* Format: https://bitbucket.org/user/repo/src/main/path/file.tf#lines-10:15
*/
function buildBitbucketUrl(
baseUrl: string,
filePath: string,
startLine: number | null,
endLine: number | null,
): string {
const branch = "main";
let url = `${baseUrl}/src/${branch}/${filePath}`;
if (startLine !== null) {
if (endLine !== null && endLine !== startLine) {
url += `#lines-${startLine}:${endLine}`;
} else {
url += `#lines-${startLine}`;
}
}
return url;
}