modules/Invoke-Gitleaks.ps1
|
#Requires -Version 7.0 <# .SYNOPSIS Wrapper for gitleaks CLI (secrets scanner). .DESCRIPTION Runs the gitleaks CLI against a git repository to detect leaked secrets such as API keys, tokens, and passwords in git history. If gitleaks is not installed, writes a warning and returns an empty result. Never throws -- designed for graceful degradation in the orchestrator. Security: The --redact flag ensures the report file never contains plaintext secret values. Secret/Match fields are also stripped during post-processing as a defense-in-depth layer. The report is written to the system temp directory (not inside the scanned repo). .PARAMETER RepoPath Path to the repository to scan. Defaults to the current directory. .PARAMETER NoGit Switch for scanning non-git directories (uses --no-git flag). .PARAMETER GitleaksConfigPath Optional local path to a gitleaks TOML config file. When provided, the wrapper passes --config <path> to gitleaks. #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] param ( [string] $RepoPath = '.', [switch] $NoGit, [string] $RemoteUrl, [string] $GitleaksConfigPath ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' # Dot-source shared modules for Remove-Credentials, Invoke-WithRetry, Invoke-RemoteRepoClone $sharedDir = Join-Path (Split-Path $PSScriptRoot -Parent) 'modules' 'shared' if (-not $sharedDir -or -not (Test-Path $sharedDir)) { $sharedDir = Join-Path $PSScriptRoot 'shared' } $sanitizePath = Join-Path $sharedDir 'Sanitize.ps1' if (Test-Path $sanitizePath) { . $sanitizePath } $missingToolPath = Join-Path $sharedDir 'MissingTool.ps1' if (Test-Path $missingToolPath) { . $missingToolPath } $retryPath = Join-Path $sharedDir 'Retry.ps1' if (Test-Path $retryPath) { . $retryPath } $remoteClonePath = Join-Path $sharedDir 'RemoteClone.ps1' if (Test-Path $remoteClonePath) { . $remoteClonePath } $errorsPath = Join-Path $sharedDir 'Errors.ps1' if (Test-Path $errorsPath) { . $errorsPath } # Bootstrap Invoke-WithTimeout for CLI timeout protection $cliTimeoutPath = Join-Path $sharedDir 'CliTimeout.ps1' if (Test-Path $cliTimeoutPath) { . $cliTimeoutPath } $envelopePath = Join-Path $sharedDir 'New-WrapperEnvelope.ps1' if (Test-Path $envelopePath) { . $envelopePath } if (-not (Get-Command New-WrapperEnvelope -ErrorAction SilentlyContinue)) { function New-WrapperEnvelope { param([string]$Source,[string]$Status='Failed',[string]$Message='',[object[]]$FindingErrors=@()) return [PSCustomObject]@{ Source=$Source; SchemaVersion='1.0'; Status=$Status; Message=$Message; Findings=@(); Errors=@($FindingErrors) } } } if (-not (Get-Command Remove-Credentials -ErrorAction SilentlyContinue)) { function Remove-Credentials { param ([string]$Text) return $Text } } if (-not (Get-Command New-FindingError -ErrorAction SilentlyContinue)) { function New-FindingError { param([string]$Source,[string]$Category,[string]$Reason,[string]$Remediation,[string]$Details) return [pscustomobject]@{ Source=$Source; Category=$Category; Reason=$Reason; Remediation=$Remediation; Details=$Details } } } if (-not (Get-Command Format-FindingErrorMessage -ErrorAction SilentlyContinue)) { function Format-FindingErrorMessage { param([Parameter(Mandatory)]$FindingError) $line = "[{0}] {1}: {2}" -f $FindingError.Source, $FindingError.Category, $FindingError.Reason if ($FindingError.Remediation) { $line += " Action: $($FindingError.Remediation)" } return $line } } function Test-GitleaksInstalled { $null -ne (Get-Command gitleaks -ErrorAction SilentlyContinue) } function Get-GitleaksToolVersion { try { $rawVersion = gitleaks version 2>&1 if ($LASTEXITCODE -ne 0) { return '' } $versionText = if ($rawVersion -is [array]) { ($rawVersion -join ' ') } else { [string]$rawVersion } $match = [regex]::Match($versionText, '(\d+\.\d+\.\d+(?:[-+][A-Za-z0-9\.-]+)?)') if ($match.Success) { return $match.Groups[1].Value } return $versionText.Trim() } catch { return '' } } function Get-GitRemoteUrl { param ( [Parameter(Mandatory)] [string] $RepositoryPath ) try { $raw = git -C $RepositoryPath config --get remote.origin.url 2>$null if ($LASTEXITCODE -ne 0) { return '' } $value = if ($raw -is [array]) { ($raw | Select-Object -First 1) } else { [string]$raw } return [string]$value } catch { return '' } } function Resolve-RepositoryMetadata { param ( [string] $RemoteCandidate ) $candidate = [string]$RemoteCandidate if ([string]::IsNullOrWhiteSpace($candidate)) { return [PSCustomObject]@{ Host = 'github.com' Owner = 'local' Name = 'local' EntityId = 'github.com/local/local' RepositoryId = 'github.com/local/local' RepositoryUrl = 'https://github.com/local/local' } } $normalized = $candidate.Trim() -replace '\.git$', '' $repoHost = '' $owner = '' $name = '' if ($normalized -match '^https?://([^/]+)/([^/]+)/([^/?#]+)$') { $repoHost = $matches[1] $owner = $matches[2] $name = $matches[3] } elseif ($normalized -match '^git@([^:]+):([^/]+)/([^/?#]+)$') { $repoHost = $matches[1] $owner = $matches[2] $name = $matches[3] } elseif ($normalized -match '^([^/]+)/([^/]+)/([^/]+)$') { $repoHost = $matches[1] $owner = $matches[2] $name = $matches[3] } if ([string]::IsNullOrWhiteSpace($repoHost) -or [string]::IsNullOrWhiteSpace($owner) -or [string]::IsNullOrWhiteSpace($name)) { return [PSCustomObject]@{ Host = 'github.com' Owner = 'local' Name = 'local' EntityId = 'github.com/local/local' RepositoryId = 'github.com/local/local' RepositoryUrl = 'https://github.com/local/local' } } $repoHost = $repoHost.ToLowerInvariant() $owner = $owner.ToLowerInvariant() $name = $name.ToLowerInvariant() [PSCustomObject]@{ Host = $repoHost Owner = $owner Name = $name EntityId = "$repoHost/$owner/$name" RepositoryId = "$repoHost/$owner/$name" RepositoryUrl = "https://$repoHost/$owner/$name" } } function Get-GitleaksSeverity { param ( [string] $RuleId, [string] $Description, [string[]] $Tags ) $material = @($RuleId, $Description, (@($Tags) -join ' ')) -join ' ' if ($material -match '(?i)aws|azure|gcp|google|cloud|access[-_\s]?key|secret[-_\s]?access[-_\s]?key|service[-_\s]?account|connection[-_\s]?string|storage[-_\s]?key') { return 'Critical' } return 'Medium' } function Get-GitleaksFrameworks { param ( [string] $RuleId, [string] $Description, [string[]] $Tags ) $frameworks = [System.Collections.Generic.List[hashtable]]::new() $material = @($RuleId, $Description, (@($Tags) -join ' ')) -join ' ' if ($material -match '(?i)access|auth|credential|token|key|password|secret') { $frameworks.Add(@{ kind = 'NIST 800-53'; controlId = 'IA' }) | Out-Null $frameworks.Add(@{ kind = 'ISO 27001'; controlId = 'A.9' }) | Out-Null } if ($material -match '(?i)access|permission|privilege|rbac') { $frameworks.Add(@{ kind = 'NIST 800-53'; controlId = 'AC' }) | Out-Null } if ($material -match '(?i)audit|log|trace') { $frameworks.Add(@{ kind = 'NIST 800-53'; controlId = 'AU' }) | Out-Null } return @($frameworks) } function Get-BaselineTags { param ( [string] $RuleId, [string[]] $Tags ) $output = [System.Collections.Generic.List[string]]::new() $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) if (-not [string]::IsNullOrWhiteSpace($RuleId)) { $ruleTag = "gitleaks:rule:$($RuleId.ToLowerInvariant())" if ($seen.Add($ruleTag)) { $output.Add($ruleTag) | Out-Null } } $hasSecretTag = $false foreach ($tag in @($Tags)) { if ([string]::IsNullOrWhiteSpace([string]$tag)) { continue } $normalizedTag = [string]$tag if ($normalizedTag -match '(?i)secret') { $hasSecretTag = $true } $tagValue = "gitleaks:tag:$($normalizedTag.Trim().ToLowerInvariant())" if ($seen.Add($tagValue)) { $output.Add($tagValue) | Out-Null } } if (-not $hasSecretTag) { $secretTag = 'gitleaks:tag:secret' if ($seen.Add($secretTag)) { $output.Add($secretTag) | Out-Null } } return @($output) } function Get-EvidenceUris { param ( [Parameter(Mandatory)] [pscustomobject] $RepositoryMeta, [string] $FilePath, [int] $StartLine, [string] $Commit ) $uris = [System.Collections.Generic.List[string]]::new() if ([string]::IsNullOrWhiteSpace($RepositoryMeta.RepositoryUrl)) { return @() } if (-not [string]::IsNullOrWhiteSpace($Commit)) { $uris.Add("$($RepositoryMeta.RepositoryUrl)/commit/$Commit") | Out-Null } if (-not [string]::IsNullOrWhiteSpace($FilePath) -and -not [string]::IsNullOrWhiteSpace($Commit)) { $normalizedFile = $FilePath.Trim() -replace '\\', '/' $lineFragment = if ($StartLine -gt 0) { "#L$StartLine" } else { '' } $uris.Add("$($RepositoryMeta.RepositoryUrl)/blob/$Commit/$normalizedFile$lineFragment") | Out-Null } return @($uris) } function Get-EntityRefs { param ( [Parameter(Mandatory)] [pscustomobject] $RepositoryMeta, [string] $Commit, [string] $FilePath ) $refs = [System.Collections.Generic.List[string]]::new() $refs.Add($RepositoryMeta.EntityId) | Out-Null if (-not [string]::IsNullOrWhiteSpace($Commit)) { $refs.Add("commit:$($RepositoryMeta.Owner)/$($RepositoryMeta.Name)/$Commit") | Out-Null } $normalizedFile = [string]$FilePath if (-not [string]::IsNullOrWhiteSpace($normalizedFile)) { $normalizedFile = $normalizedFile -replace '\\', '/' if ($normalizedFile -match '(?i)^\.github/workflows/[^/]+\.ya?ml$') { $refs.Add("workflow:$($RepositoryMeta.Owner)/$($RepositoryMeta.Name)/$normalizedFile") | Out-Null } } return @($refs) } function Get-GitleaksRemediationSnippets { param ( [Parameter(Mandatory)] [pscustomobject] $RepositoryMeta ) return @( @{ language = 'text' code = "1) Revoke or rotate the exposed credential immediately. 2) Replace the secret with a secure reference (GitHub Actions secret, Azure Key Vault, or managed identity). 3) Remove the secret from Git history and force rotate any dependent systems." }, @{ language = 'bash' code = "gh api -X PATCH repos/$($RepositoryMeta.Owner)/$($RepositoryMeta.Name) --raw-field security_and_analysis[secret_scanning][status]=enabled --raw-field security_and_analysis[secret_scanning_push_protection][status]=enabled" } ) } function Test-GitleaksConfigDisablesDefaults { param ( [Parameter(Mandatory)] [string] $ConfigPath ) $content = Get-Content -Path $ConfigPath -Raw -ErrorAction Stop $extendMatch = [regex]::Match($content, '(?ms)^\s*\[extend\]\s*(?<body>.*?)(?=^\s*\[[^\[]|\z)') if (-not $extendMatch.Success) { return $false } $extendBody = [string]$extendMatch.Groups['body'].Value $usesNoDefaults = $extendBody -match '(?im)^\s*useDefault\s*=\s*false\s*$' if (-not $usesNoDefaults) { return $false } $hasCustomRules = $content -match '(?m)^\s*\[\[rules\]\]\s*$' return (-not $hasCustomRules) } function Resolve-GitleaksConfig { param ( [Parameter(Mandatory)] [string] $ConfigPath ) if ([string]::IsNullOrWhiteSpace($ConfigPath)) { return $null } if ($ConfigPath -match '^[a-zA-Z][a-zA-Z0-9+.-]*://') { throw (Format-FindingErrorMessage (New-FindingError -Source 'wrapper:gitleaks' -Category 'InvalidParameter' -Reason "Gitleaks config path must be a local file path. URLs are not allowed: '$ConfigPath'" -Remediation 'Provide a local .toml file path via -GitleaksConfigPath.')) } if ([System.IO.Path]::GetExtension($ConfigPath).ToLowerInvariant() -ne '.toml') { throw (Format-FindingErrorMessage (New-FindingError -Source 'wrapper:gitleaks' -Category 'InvalidParameter' -Reason "Gitleaks config path must point to a .toml file: '$ConfigPath'" -Remediation 'Use a gitleaks TOML config file for -GitleaksConfigPath.')) } if (-not (Test-Path -Path $ConfigPath -PathType Leaf)) { throw (Format-FindingErrorMessage (New-FindingError -Source 'wrapper:gitleaks' -Category 'NotFound' -Reason "Gitleaks config file not found: '$ConfigPath'" -Remediation 'Verify the -GitleaksConfigPath value resolves to an existing file.')) } $resolvedConfigPath = Resolve-Path -Path $ConfigPath -ErrorAction Stop | Select-Object -ExpandProperty Path [PSCustomObject]@{ Path = $resolvedConfigPath DisablesDefaultsWithoutCustomRules = (Test-GitleaksConfigDisablesDefaults -ConfigPath $resolvedConfigPath) } } if (-not (Test-GitleaksInstalled)) { Write-MissingToolNotice -Tool 'gitleaks' -Message "gitleaks is not installed. Skipping gitleaks scan. Install from https://github.com/gitleaks/gitleaks/releases" return [PSCustomObject]@{ Source = 'gitleaks' SchemaVersion = '1.0' Status = 'Skipped' Message = 'gitleaks CLI not installed. Install from https://github.com/gitleaks/gitleaks/releases' Findings = @() Errors = @() } } $resolvedConfig = $null if (-not [string]::IsNullOrWhiteSpace($GitleaksConfigPath)) { $resolvedConfig = Resolve-GitleaksConfig -ConfigPath $GitleaksConfigPath } $cloneInfo = $null $cleanupClone = $null $toolVersion = Get-GitleaksToolVersion try { if ($RemoteUrl) { if (-not (Get-Command Invoke-RemoteRepoClone -ErrorAction SilentlyContinue)) { Write-Warning "RemoteClone helper not loaded; cannot scan remote URL." return [PSCustomObject]@{ Source = 'gitleaks' SchemaVersion = '1.0'; Status = 'Failed' Message = 'RemoteClone helper unavailable'; Findings = @() Errors = @() } } $cloneInfo = Invoke-RemoteRepoClone -RepoUrl $RemoteUrl if (-not $cloneInfo) { return [PSCustomObject]@{ Source = 'gitleaks' SchemaVersion = '1.0'; Status = 'Failed' Message = "Remote clone failed or host not on allow-list: $RemoteUrl" Findings = @() Errors = @() } } $cleanupClone = $cloneInfo.Cleanup $RepoPath = $cloneInfo.Path } $resolvedPath = Resolve-Path $RepoPath -ErrorAction Stop | Select-Object -ExpandProperty Path Write-Verbose "Running gitleaks for path $resolvedPath" $remoteCandidate = if ($RemoteUrl) { $RemoteUrl } elseif ($cloneInfo -and $cloneInfo.Url) { [string]$cloneInfo.Url } else { Get-GitRemoteUrl -RepositoryPath $resolvedPath } $repositoryMeta = Resolve-RepositoryMetadata -RemoteCandidate $remoteCandidate # Write report to system temp dir — never inside the scanned repo $reportFile = Join-Path ([System.IO.Path]::GetTempPath()) "gitleaks-report-$([guid]::NewGuid().ToString('N')).json" try { # --redact: gitleaks replaces secret values with REDACTED in the report so plaintext secrets are never written to disk $gitleaksArgs = @('detect', '--source', $resolvedPath, '--report-format', 'json', '--report-path', $reportFile, '--no-banner', '--redact', '--exit-code', '0') if ($NoGit) { $gitleaksArgs += '--no-git' } if ($resolvedConfig) { $gitleaksArgs += @('--config', $resolvedConfig.Path) } $useRetry = Get-Command Invoke-WithRetry -ErrorAction SilentlyContinue $invokeGitleaksScan = { $script:gitleaksExec = Invoke-WithTimeout -Command 'gitleaks' -Arguments $gitleaksArgs -TimeoutSec 300 if ([int]$script:gitleaksExec.ExitCode -eq -1) { throw (Format-FindingErrorMessage (New-FindingError -Source 'wrapper:gitleaks' -Category 'TimeoutExceeded' -Reason 'gitleaks timed out after 300 seconds.' -Remediation 'Check repository size or increase timeout.' -Details '')) } if ($script:gitleaksExec.Output) { Write-Verbose "gitleaks output: $($script:gitleaksExec.Output)" } } if ($useRetry) { Invoke-WithRetry -ScriptBlock $invokeGitleaksScan } else { & $invokeGitleaksScan } $exitCode = [int]$script:gitleaksExec.ExitCode # Validate: non-zero exit code with no report = hard failure if ($exitCode -ne 0 -and -not (Test-Path $reportFile)) { Write-Warning (Remove-Credentials "gitleaks exited with code $exitCode and produced no report") return [PSCustomObject]@{ Source = 'gitleaks' SchemaVersion = '1.0' Status = 'Failed' Message = Remove-Credentials "gitleaks exited with code $exitCode and produced no report" Findings = @() Errors = @() } } $json = @() if (Test-Path $reportFile) { $jsonText = Get-Content $reportFile -Raw -ErrorAction SilentlyContinue if ($jsonText) { try { $json = $jsonText | ConvertFrom-Json -ErrorAction Stop } catch { Write-Warning (Remove-Credentials "gitleaks report JSON parse failed: $_") return [PSCustomObject]@{ Source = 'gitleaks' SchemaVersion = '1.0' Status = 'Failed' Message = Remove-Credentials "Report JSON parse failed: $_" Findings = @() Errors = @() } } } } elseif ($exitCode -eq 0) { # exit 0 but no report file — gitleaks found nothing; treat as success $json = @() } } finally { Remove-Item $reportFile -Force -ErrorAction SilentlyContinue } $findings = [System.Collections.Generic.List[PSCustomObject]]::new() if ($resolvedConfig) { $sanitizedConfigPath = Remove-Credentials ([string]$resolvedConfig.Path) if ($resolvedConfig.DisablesDefaultsWithoutCustomRules) { $findings.Add([PSCustomObject]@{ Id = [guid]::NewGuid().ToString() RuleId = 'gitleaks.config.disable-defaults' Category = 'Configuration' Title = 'Gitleaks pattern override disables all built-in rules' Severity = 'High' Compliant = $false Detail = Remove-Credentials "Custom gitleaks config '$sanitizedConfigPath' sets [extend] useDefault = false without custom [[rules]]. This creates a high risk of missed secrets." Remediation = 'Set useDefault = true or add at least one vetted custom [[rules]] entry before scanning.' ResourceId = $sanitizedConfigPath LearnMoreUrl = 'https://github.com/gitleaks/gitleaks' Pillar = 'Security' Impact = 'High' Effort = 'Low' DeepLinkUrl = 'https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml' RemediationSnippets = @(Get-GitleaksRemediationSnippets -RepositoryMeta $repositoryMeta) BaselineTags = @('gitleaks:config:custom','gitleaks:tag:secret') Frameworks = @(@{ kind = 'NIST 800-53'; controlId = 'IA' }, @{ kind = 'ISO 27001'; controlId = 'A.9' }) EntityRefs = @($repositoryMeta.EntityId) ToolVersion = $toolVersion }) } $findings.Add([PSCustomObject]@{ Id = [guid]::NewGuid().ToString() RuleId = 'gitleaks.config.custom-applied' Category = 'Configuration' Title = 'Custom gitleaks config applied' Severity = 'Info' Compliant = $true Detail = Remove-Credentials "Applied custom gitleaks config: '$sanitizedConfigPath'." Remediation = 'Review custom allowlist and rule overrides regularly to keep secret detection coverage strong.' ResourceId = $sanitizedConfigPath LearnMoreUrl = 'https://github.com/gitleaks/gitleaks' Pillar = 'Security' Impact = 'Low' Effort = 'Low' DeepLinkUrl = 'https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml' RemediationSnippets = @(Get-GitleaksRemediationSnippets -RepositoryMeta $repositoryMeta) BaselineTags = @('gitleaks:config:custom','gitleaks:tag:secret') Frameworks = @(@{ kind = 'NIST 800-53'; controlId = 'IA' }, @{ kind = 'ISO 27001'; controlId = 'A.9' }) EntityRefs = @($repositoryMeta.EntityId) ToolVersion = $toolVersion }) } $items = if ($json -is [System.Collections.IEnumerable] -and $json -isnot [string]) { @($json) } elseif ($null -ne $json) { @($json) } else { @() } foreach ($item in $items) { $ruleId = '' if ($item.PSObject.Properties['RuleID'] -and $item.RuleID) { $ruleId = [string]$item.RuleID } $description = '' if ($item.PSObject.Properties['Description'] -and $item.Description) { $description = [string]$item.Description } $filePath = '' if ($item.PSObject.Properties['File'] -and $item.File) { $filePath = [string]$item.File } $startLine = 0 if ($item.PSObject.Properties['StartLine'] -and $item.StartLine) { $startLine = [int]$item.StartLine } $commit = '' if ($item.PSObject.Properties['Commit'] -and $item.Commit) { $commit = [string]$item.Commit } $fingerprint = '' if ($item.PSObject.Properties['Fingerprint'] -and $item.Fingerprint) { $fingerprint = [string]$item.Fingerprint } # Strip Secret/Match fields — defense-in-depth; --redact already replaces values in the report # Severity: Secret-type findings → High, everything else → Medium $tags = @() if ($item.PSObject.Properties['Tags'] -and $item.Tags) { $tags = @($item.Tags | ForEach-Object { [string]$_ }) } $severity = Get-GitleaksSeverity -RuleId $ruleId -Description $description -Tags $tags $frameworks = @(Get-GitleaksFrameworks -RuleId $ruleId -Description $description -Tags $tags) $baselineTags = @(Get-BaselineTags -RuleId $ruleId -Tags $tags) $evidenceUris = @(Get-EvidenceUris -RepositoryMeta $repositoryMeta -FilePath $filePath -StartLine $startLine -Commit $commit) $entityRefs = @(Get-EntityRefs -RepositoryMeta $repositoryMeta -Commit $commit -FilePath $filePath) $ruleAnchor = if ($ruleId) { "#rule-$([uri]::EscapeDataString($ruleId.ToLowerInvariant()))" } else { '' } $deepLinkUrl = "https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml$ruleAnchor" $title = if ($description -and $filePath) { "$description found in $filePath" } elseif ($description) { $description } elseif ($ruleId) { "Secret detected: $ruleId" } else { 'Secret detected' } $commitRef = if ($commit) { $commit.Substring(0, [Math]::Min(7, $commit.Length)) } else { '' } $detail = "Rule '$ruleId' matched in file $filePath at line $startLine." if ($commitRef) { $detail += " Commit: $commitRef." } $detail = Remove-Credentials $detail $findings.Add([PSCustomObject]@{ Id = if ($fingerprint) { $fingerprint } else { [guid]::NewGuid().ToString() } RuleId = $ruleId Category = 'Secret Detection' Title = $title Severity = $severity Compliant = $false Detail = $detail Remediation = 'Rotate the exposed credential and remove it from git history using git-filter-repo or BFG Repo-Cleaner.' ResourceId = $filePath LearnMoreUrl = 'https://github.com/gitleaks/gitleaks' Pillar = 'Security' Impact = if ($severity -eq 'Critical') { 'High' } else { 'Medium' } Effort = 'Low' DeepLinkUrl = $deepLinkUrl RemediationSnippets = @(Get-GitleaksRemediationSnippets -RepositoryMeta $repositoryMeta) EvidenceUris = $evidenceUris BaselineTags = $baselineTags Frameworks = $frameworks EntityRefs = $entityRefs ToolVersion = $toolVersion }) } if ($null -eq $findings) { $findings = @() } return [PSCustomObject]@{ Source = 'gitleaks' SchemaVersion = '1.0' Status = 'Success' Message = '' RepositoryId = $repositoryMeta.RepositoryId RepositoryEntityId = $repositoryMeta.EntityId RepositoryUrl = $repositoryMeta.RepositoryUrl ToolVersion = $toolVersion Findings = @($findings) Errors = @() } } catch { Write-Warning (Remove-Credentials "gitleaks scan failed: $_") return [PSCustomObject]@{ Source = 'gitleaks' SchemaVersion = '1.0' Status = 'Failed' Message = Remove-Credentials "$_" Findings = @() Errors = @() } } finally { if ($cleanupClone) { try { & $cleanupClone } catch { Write-Verbose "gitleaks clone cleanup failed: $(Remove-Credentials -Text ([string]$_.Exception.Message))" } } } |