mirror of
https://github.com/prowler-cloud/prowler.git
synced 2025-12-19 05:17:47 +00:00
feat(github): add external resource link (#9153)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = ({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
188
ui/lib/iac-utils.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user