feat(iac): use branch as region for IaC findings (#9295)

This commit is contained in:
Andoni Alonso
2025-11-24 17:00:06 +01:00
committed by GitHub
parent 75abd8f54d
commit 2198e461c9
12 changed files with 228 additions and 57 deletions

View File

@@ -27,6 +27,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- Add verification for provider ID in MongoDB Atlas provider [(#9211)](https://github.com/prowler-cloud/prowler/pull/9211)
- Add Prowler ThreatScore for the K8S provider [(#9235)](https://github.com/prowler-cloud/prowler/pull/9235)
- Add `postgresql_flexible_server_entra_id_authentication_enabled` check for Azure provider [(#8764)](https://github.com/prowler-cloud/prowler/pull/8764)
- Add branch name to IaC provider region [(#9296)](https://github.com/prowler-cloud/prowler/pull/9295)
### Changed
- Update AWS Direct Connect service metadata to new format [(#8855)](https://github.com/prowler-cloud/prowler/pull/8855)

View File

@@ -314,9 +314,7 @@ class Finding(BaseModel):
)
output_data["resource_uid"] = getattr(check_output, "resource_name", "")
# For IaC, resource_line_range only exists on CheckReportIAC, not on Finding objects
output_data["region"] = getattr(
check_output, "resource_line_range", "file"
)
output_data["region"] = getattr(check_output, "region", "global")
output_data["resource_line_range"] = getattr(
check_output, "resource_line_range", ""
)

View File

@@ -241,7 +241,7 @@ class HTML(Output):
<th scope="col">Status</th>
<th scope="col">Severity</th>
<th scope="col">Service Name</th>
<th scope="col">{"Line Range" if provider.type == "iac" else "Region"}</th>
<th scope="col">Region</th>
<th style="width:20%" scope="col">Check ID</th>
<th style="width:20%" scope="col">Check Title</th>
<th scope="col">Resource ID</th>

View File

@@ -325,7 +325,7 @@ class Scan:
resource_name=report.resource_name,
resource_details=report.resource_details,
resource_tags={}, # IaC doesn't have resource tags
region="global", # IaC doesn't have regions
region=report.region, # IaC region is the branch name
compliance={}, # IaC doesn't have compliance mappings yet
raw=report.resource, # The raw finding dict
)

View File

@@ -45,11 +45,12 @@ class IacProvider(Provider):
self.scan_repository_url = scan_repository_url
self.scanners = scanners
self.exclude_path = exclude_path
self.region = "global"
self.region = "branch"
self.audited_account = "local-iac"
self._session = None
self._identity = "prowler"
self._auth_method = "No auth"
self._temp_clone_dir = None # Track temporary directory for cleanup
if scan_repository_url:
oauth_app_token = oauth_app_token or environ.get("GITHUB_OAUTH_APP_TOKEN")
@@ -80,6 +81,20 @@ class IacProvider(Provider):
"No GitHub authentication method provided; proceeding without authentication."
)
# Clone repository and detect branch during initialization
# This ensures the branch is detected for both CLI and API usage
self._temp_clone_dir, branch_name = self._clone_repository(
self.scan_repository_url,
self.github_username,
self.personal_access_token,
self.oauth_app_token,
)
# Update scan_path to point to the cloned repository
self.scan_path = self._temp_clone_dir
# Update region with the detected branch name
self.region = branch_name
logger.info(f"Updated region to branch: {branch_name}")
# Audit Config
if config_content:
self._audit_config = config_content
@@ -131,6 +146,20 @@ class IacProvider(Provider):
def fixer_config(self):
return self._fixer_config
def __del__(self):
"""Cleanup temporary directory when provider is destroyed"""
self.cleanup()
def cleanup(self):
"""Remove temporary cloned repository if it exists"""
if self._temp_clone_dir:
try:
logger.info(f"Removing temporary directory {self._temp_clone_dir}...")
shutil.rmtree(self._temp_clone_dir)
self._temp_clone_dir = None
except Exception as error:
logger.warning(f"Failed to remove temporary directory: {error}")
def setup_session(self):
"""IAC provider doesn't need a session since it uses Trivy directly"""
return None
@@ -208,6 +237,8 @@ class IacProvider(Provider):
)
if finding_status == "MUTED":
report.muted = True
# Set the region from the provider
report.region = self.region
return report
except Exception as error:
logger.critical(
@@ -215,15 +246,50 @@ class IacProvider(Provider):
)
sys.exit(1)
def _detect_branch_name(self, repo_path: str) -> str:
"""
Detect the current branch name from a cloned repository.
Args:
repo_path: Path to the cloned repository
Returns:
str: The branch name, defaulting to "main" if detection fails
"""
try:
import os
# Read .git/HEAD to detect the current branch
head_file = os.path.join(repo_path, ".git", "HEAD")
if os.path.exists(head_file):
with open(head_file, "r") as f:
content = f.read().strip()
# Format: "ref: refs/heads/branch-name"
if content.startswith("ref: refs/heads/"):
branch_name = content[16:] # Remove "ref: refs/heads/"
logger.info(f"Detected branch: {branch_name}")
return branch_name
# Fallback: return "main" as default
logger.warning("Could not detect branch name, defaulting to 'main'")
return "main"
except Exception as error:
logger.error(f"Error detecting branch name: {error}")
return "main" # Safe fallback
def _clone_repository(
self,
repository_url: str,
github_username: str = None,
personal_access_token: str = None,
oauth_app_token: str = None,
) -> str:
) -> tuple[str, str]:
"""
Clone a git repository to a temporary directory, supporting GitHub authentication.
Returns:
tuple[str, str]: (temporary_directory, branch_name)
"""
try:
original_url = repository_url
@@ -276,33 +342,36 @@ class IacProvider(Provider):
porcelain.clone(repository_url, temporary_directory, depth=1)
logger.info("Repository cloned successfully!")
return temporary_directory
# Detect the branch name from the cloned repository
branch_name = self._detect_branch_name(temporary_directory)
return temporary_directory, branch_name
except Exception as error:
logger.critical(
f"{error.__class__.__name__}:{error.__traceback__.tb_lineno} -- {error}"
)
def run(self) -> List[CheckReportIAC]:
temp_dir = None
if self.scan_repository_url:
scan_dir = temp_dir = self._clone_repository(
self.scan_repository_url,
getattr(self, "github_username", None),
getattr(self, "personal_access_token", None),
getattr(self, "oauth_app_token", None),
)
else:
scan_dir = self.scan_path
"""
Execute the IaC scan.
Note: Repository cloning and branch detection now happen in __init__(),
so this method just runs the scan and returns results.
For CLI compatibility, cleanup is still performed at the end.
"""
try:
# Collect all batches from the generator
# scan_path now points to either the local directory or the cloned repo
reports = []
for batch in self.run_scan(scan_dir, self.scanners, self.exclude_path):
for batch in self.run_scan(
self.scan_path, self.scanners, self.exclude_path
):
reports.extend(batch)
finally:
if temp_dir:
logger.info(f"Removing temporary directory {temp_dir}...")
shutil.rmtree(temp_dir)
# Clean up temporary directory if this was a repository scan
# This ensures CLI usage cleans up immediately after run()
if self._temp_clone_dir:
self.cleanup()
return reports

View File

@@ -662,6 +662,7 @@ class TestFinding:
check_output.resource_name = "aws_s3_bucket.example"
check_output.resource_path = "/path/to/iac/file.tf"
check_output.resource_line_range = "1:5"
check_output.region = "main" # Branch name for remote IaC scans
check_output.resource = {
"resource": "aws_s3_bucket.example",
"value": {},
@@ -685,7 +686,7 @@ class TestFinding:
assert finding_output.auth_method == "No auth"
assert finding_output.resource_name == "aws_s3_bucket.example"
assert finding_output.resource_uid == "aws_s3_bucket.example"
assert finding_output.region == "1:5"
assert finding_output.region == "main" # Branch name, not line range
assert finding_output.status == Status.PASS
assert finding_output.status_extended == "mock_status_extended"
assert finding_output.muted is False

View File

@@ -398,7 +398,7 @@ def get_aws_html_header(args: list) -> str:
<div class="row mt-3">
<div class="col-md-4">
<a href="https://github.com/prowler-cloud/prowler/"><img class="float-left card-img-left mt-4 mr-4 ml-4"
src=https://prowler.com/wp-content/uploads/logo-html.png
src=https://raw.githubusercontent.com/prowler-cloud/prowler/dc7d2d5aeb92fdf12e8604f42ef6472cd3e8e889/docs/img/prowler-logo-black.png
alt="prowler-logo"
style="width: 15rem; height:auto;"/></a>
<div class="card">

View File

@@ -148,19 +148,31 @@ class TestIacProvider:
@mock.patch.dict(os.environ, {}, clear=True)
def test_provider_run_remote_scan(self):
scan_repository_url = "https://github.com/user/repo"
provider = IacProvider(scan_repository_url=scan_repository_url)
with tempfile.TemporaryDirectory() as temp_dir:
with (
mock.patch(
"prowler.providers.iac.iac_provider.IacProvider._clone_repository",
return_value=temp_dir,
return_value=(temp_dir, "main"),
) as mock_clone,
mock.patch(
"prowler.providers.iac.iac_provider.IacProvider.run_scan"
) as mock_run_scan,
):
# Repository cloning now happens during __init__
provider = IacProvider(scan_repository_url=scan_repository_url)
# Verify clone was called during initialization
mock_clone.assert_called_once_with(
scan_repository_url, None, None, None
)
# Verify region was updated with branch name
assert provider.region == "main"
# Run the scan
provider.run()
mock_clone.assert_called_with(scan_repository_url, None, None, None)
# Verify scan was called with the cloned directory
mock_run_scan.assert_called_with(
temp_dir, ["vuln", "misconfig", "secret"], []
)
@@ -183,17 +195,22 @@ class TestIacProvider:
@mock.patch.dict(os.environ, {}, clear=True)
def test_print_credentials_remote(self):
repo_url = "https://github.com/user/repo"
provider = IacProvider(scan_repository_url=repo_url)
with mock.patch("builtins.print") as mock_print:
provider.print_credentials()
assert any(
f"Repository: \x1b[33m{repo_url}\x1b[0m" in call.args[0]
for call in mock_print.call_args_list
)
assert any(
"Scanning remote IaC repository:" in call.args[0]
for call in mock_print.call_args_list
)
with tempfile.TemporaryDirectory() as temp_dir:
with mock.patch(
"prowler.providers.iac.iac_provider.IacProvider._clone_repository",
return_value=(temp_dir, "main"),
):
provider = IacProvider(scan_repository_url=repo_url)
with mock.patch("builtins.print") as mock_print:
provider.print_credentials()
assert any(
f"Repository: \x1b[33m{repo_url}\x1b[0m" in call.args[0]
for call in mock_print.call_args_list
)
assert any(
"Scanning remote IaC repository:" in call.args[0]
for call in mock_print.call_args_list
)
@patch("subprocess.run")
def test_iac_provider_process_check_medium_severity(self, mock_subprocess):
@@ -664,25 +681,79 @@ class TestIacProvider:
def test_clone_repository_no_auth(self, _mock_mkdtemp, mock_clone):
provider = IacProvider()
url = "https://github.com/user/repo.git"
provider._clone_repository(url)
with mock.patch.object(provider, "_detect_branch_name", return_value="main"):
temp_dir, branch_name = provider._clone_repository(url)
mock_clone.assert_called_with(url, "/tmp/fake-dir", depth=1)
assert temp_dir == "/tmp/fake-dir"
assert branch_name == "main"
@mock.patch("prowler.providers.iac.iac_provider.porcelain.clone")
@mock.patch("tempfile.mkdtemp", return_value="/tmp/fake-dir")
def test_clone_repository_with_pat(self, _mock_mkdtemp, mock_clone):
provider = IacProvider()
url = "https://github.com/user/repo.git"
provider._clone_repository(
url, github_username="user", personal_access_token="token123"
)
with mock.patch.object(provider, "_detect_branch_name", return_value="develop"):
temp_dir, branch_name = provider._clone_repository(
url, github_username="user", personal_access_token="token123"
)
expected_url = "https://user:token123@github.com/user/repo.git"
mock_clone.assert_called_with(expected_url, "/tmp/fake-dir", depth=1)
assert temp_dir == "/tmp/fake-dir"
assert branch_name == "develop"
@mock.patch("prowler.providers.iac.iac_provider.porcelain.clone")
@mock.patch("tempfile.mkdtemp", return_value="/tmp/fake-dir")
def test_clone_repository_with_oauth(self, _mock_mkdtemp, mock_clone):
provider = IacProvider()
url = "https://github.com/user/repo.git"
provider._clone_repository(url, oauth_app_token="oauth456")
with mock.patch.object(provider, "_detect_branch_name", return_value="master"):
temp_dir, branch_name = provider._clone_repository(
url, oauth_app_token="oauth456"
)
expected_url = "https://oauth2:oauth456@github.com/user/repo.git"
mock_clone.assert_called_with(expected_url, "/tmp/fake-dir", depth=1)
assert temp_dir == "/tmp/fake-dir"
assert branch_name == "master"
def test_detect_branch_name_main(self):
"""Test detecting 'main' branch from .git/HEAD"""
provider = IacProvider()
with tempfile.TemporaryDirectory() as temp_dir:
# Create a mock .git/HEAD file with main branch
git_dir = os.path.join(temp_dir, ".git")
os.makedirs(git_dir)
head_file = os.path.join(git_dir, "HEAD")
with open(head_file, "w") as f:
f.write("ref: refs/heads/main\n")
branch_name = provider._detect_branch_name(temp_dir)
assert branch_name == "main"
def test_detect_branch_name_custom_branch(self):
"""Test detecting custom branch like 'develop' from .git/HEAD"""
provider = IacProvider()
with tempfile.TemporaryDirectory() as temp_dir:
# Create a mock .git/HEAD file with develop branch
git_dir = os.path.join(temp_dir, ".git")
os.makedirs(git_dir)
head_file = os.path.join(git_dir, "HEAD")
with open(head_file, "w") as f:
f.write("ref: refs/heads/develop\n")
branch_name = provider._detect_branch_name(temp_dir)
assert branch_name == "develop"
def test_detect_branch_name_fallback(self):
"""Test fallback to 'main' when .git/HEAD doesn't exist"""
provider = IacProvider()
with tempfile.TemporaryDirectory() as temp_dir:
# Don't create .git/HEAD file
branch_name = provider._detect_branch_name(temp_dir)
assert branch_name == "main"
def test_detect_branch_name_error_handling(self):
"""Test error handling returns 'main' as fallback"""
provider = IacProvider()
# Pass a non-existent directory
branch_name = provider._detect_branch_name("/non/existent/path")
assert branch_name == "main"

View File

@@ -14,6 +14,7 @@ All notable changes to the **Prowler UI** are documented in this file.
- PDF reporting for NIS2 compliance framework [(#9170)](https://github.com/prowler-cloud/prowler/pull/9170)
- External resource link to IaC findings for direct navigation to source code in Git repositories [(#9151)](https://github.com/prowler-cloud/prowler/pull/9151)
- New Overview page and new app styles [(#9234)](https://github.com/prowler-cloud/prowler/pull/9234)
- Use branch name as region for IaC findings [(#9296)](https://github.com/prowler-cloud/prowler/pull/9296)
### 🔄 Changed

View File

@@ -72,6 +72,7 @@ export const FindingDetail = ({
providerDetails.uid,
resource.name,
extractLineRangeFromUid(attributes.uid) || "",
resource.region,
)
: null;

View File

@@ -167,7 +167,12 @@ export const ResourceDetail = ({
// Build Git URL for IaC resources
const gitUrl =
providerData.provider === "iac"
? buildGitFileUrl(providerData.uid, attributes.name, "")
? buildGitFileUrl(
providerData.uid,
attributes.name,
"",
attributes.region,
)
: null;
if (selectedFindingId) {

View File

@@ -31,12 +31,14 @@ export function extractLineRangeFromUid(findingUid: string): string | null {
* @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"
* @param branch - Git branch name (defaults to "main" if not provided)
* @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,
branch?: string,
): string | null {
if (!repoUrl || !filePath) {
return null;
@@ -70,19 +72,38 @@ export function buildGitFileUrl(
// Build URL based on Git provider
if (hostname.includes("github")) {
return buildGitHubUrl(normalizedUrl, cleanFilePath, startLine, endLine);
return buildGitHubUrl(
normalizedUrl,
cleanFilePath,
startLine,
endLine,
branch,
);
} else if (hostname.includes("gitlab")) {
return buildGitLabUrl(normalizedUrl, cleanFilePath, startLine, endLine);
return buildGitLabUrl(
normalizedUrl,
cleanFilePath,
startLine,
endLine,
branch,
);
} else if (hostname.includes("bitbucket")) {
return buildBitbucketUrl(
normalizedUrl,
cleanFilePath,
startLine,
endLine,
branch,
);
} else {
// Generic Git provider - try GitHub format as fallback
return buildGitHubUrl(normalizedUrl, cleanFilePath, startLine, endLine);
return buildGitHubUrl(
normalizedUrl,
cleanFilePath,
startLine,
endLine,
branch,
);
}
} catch (error) {
console.error("Error building Git file URL:", error);
@@ -116,17 +137,18 @@ function parseLineRange(lineRange: string): {
/**
* Builds GitHub-style URL
* Format: https://github.com/user/repo/blob/main/path/file.tf#L10-L15
* Format: https://github.com/user/repo/blob/{branch}/path/file.tf#L10-L15
*/
function buildGitHubUrl(
baseUrl: string,
filePath: string,
startLine: number | null,
endLine: number | null,
branch?: string,
): string {
// Assume main/master branch for simplicity
const branch = "main";
let url = `${baseUrl}/blob/${branch}/${filePath}`;
// Use provided branch, default to "main" if not provided
const branchName = branch || "main";
let url = `${baseUrl}/blob/${branchName}/${filePath}`;
if (startLine !== null) {
if (endLine !== null && endLine !== startLine) {
@@ -141,16 +163,17 @@ function buildGitHubUrl(
/**
* Builds GitLab-style URL
* Format: https://gitlab.com/user/repo/-/blob/main/path/file.tf#L10-15
* Format: https://gitlab.com/user/repo/-/blob/{branch}/path/file.tf#L10-15
*/
function buildGitLabUrl(
baseUrl: string,
filePath: string,
startLine: number | null,
endLine: number | null,
branch?: string,
): string {
const branch = "main";
let url = `${baseUrl}/-/blob/${branch}/${filePath}`;
const branchName = branch || "main";
let url = `${baseUrl}/-/blob/${branchName}/${filePath}`;
if (startLine !== null) {
if (endLine !== null && endLine !== startLine) {
@@ -165,16 +188,17 @@ function buildGitLabUrl(
/**
* Builds Bitbucket-style URL
* Format: https://bitbucket.org/user/repo/src/main/path/file.tf#lines-10:15
* Format: https://bitbucket.org/user/repo/src/{branch}/path/file.tf#lines-10:15
*/
function buildBitbucketUrl(
baseUrl: string,
filePath: string,
startLine: number | null,
endLine: number | null,
branch?: string,
): string {
const branch = "main";
let url = `${baseUrl}/src/${branch}/${filePath}`;
const branchName = branch || "main";
let url = `${baseUrl}/src/${branchName}/${filePath}`;
if (startLine !== null) {
if (endLine !== null && endLine !== startLine) {