modules/Invoke-IaCBicep.ps1
|
#Requires -Version 7.0 <# .SYNOPSIS Wrapper for Bicep IaC validation. .DESCRIPTION Runs bicep build against .bicep files in a repository to detect syntax errors, unresolved references, and structural issues. Returns findings as PSObjects in the standard v1 wrapper envelope. Never throws -- designed for graceful degradation in the orchestrator. Security: All output passes through Remove-Credentials. Clones go through RemoteClone.ps1 (HTTPS-only, host allow-list). .PARAMETER Repository Path to the repository root containing .bicep files. Defaults to '.'. Aliases: Repo, RepoPath, Path '.'. .PARAMETER RemoteUrl Remote repository URL to clone and scan. #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] param ( [Alias('Repo', 'RepoPath', 'Path')] [string] $Repository = '.', [string] $RemoteUrl ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' # Dot-source shared modules $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 } # Load the adapter $adapterPath = Join-Path $PSScriptRoot 'iac' 'IaCAdapters.ps1' if (Test-Path $adapterPath) { . $adapterPath } $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 } } function Get-BicepToolVersion { try { $raw = bicep --version 2>&1 if ($LASTEXITCODE -ne 0) { return '' } $text = if ($raw -is [array]) { ($raw -join ' ') } else { [string]$raw } $match = [regex]::Match($text, '(\d+\.\d+\.\d+(?:[-+][A-Za-z0-9\.-]+)?)') if ($match.Success) { return $match.Groups[1].Value } return $text.Trim() } catch { return '' } } function Get-PsRuleAzureVersion { try { $module = Get-Module PSRule.Rules.Azure -ListAvailable | Sort-Object Version -Descending | Select-Object -First 1 if ($module -and $module.Version) { return [string]$module.Version } return '' } catch { return '' } } function ConvertTo-RepositoryWebUrl { param ([string] $Url) if ([string]::IsNullOrWhiteSpace($Url)) { return '' } $normalized = $Url.Trim() if ($normalized -match '^git@([^:]+):(.+)$') { return "https://$($Matches[1])/$($Matches[2] -replace '\.git$','')" } if ($normalized -match '^https?://') { return ($normalized -replace '\.git$','').TrimEnd('/') } return '' } if (-not (Get-Command bicep -ErrorAction SilentlyContinue)) { Write-MissingToolNotice -Tool 'iac-bicep' -Message "bicep CLI is not installed. Skipping Bicep IaC validation. Install from https://learn.microsoft.com/azure/azure-resource-manager/bicep/install" return [PSCustomObject]@{ Source = 'bicep-iac' SchemaVersion = '1.0' Status = 'Skipped' Message = 'bicep CLI not installed. Install from https://learn.microsoft.com/azure/azure-resource-manager/bicep/install' Findings = @() Errors = @() } } $cloneInfo = $null $cleanupClone = $null try { $bicepVersion = Get-BicepToolVersion $psRuleVersion = Get-PsRuleAzureVersion $toolVersionParts = [System.Collections.Generic.List[string]]::new() if (-not [string]::IsNullOrWhiteSpace($bicepVersion)) { $toolVersionParts.Add("bicep:$bicepVersion") | Out-Null } if (-not [string]::IsNullOrWhiteSpace($psRuleVersion)) { $toolVersionParts.Add("psrule.rules.azure:$psRuleVersion") | Out-Null } $toolVersion = ($toolVersionParts -join ';') $repositoryUrl = '' if ($RemoteUrl) { if (-not (Get-Command Invoke-RemoteRepoClone -ErrorAction SilentlyContinue)) { Write-Warning "RemoteClone helper not loaded; cannot scan remote URL." return [PSCustomObject]@{ Source = 'bicep-iac' SchemaVersion = '1.0'; Status = 'Failed' Message = 'RemoteClone helper unavailable'; Findings = @() Errors = @() } } $cloneInfo = Invoke-RemoteRepoClone -RepoUrl $RemoteUrl if (-not $cloneInfo) { return [PSCustomObject]@{ Source = 'bicep-iac' SchemaVersion = '1.0'; Status = 'Failed' Message = "Remote clone failed or host not on allow-list: $RemoteUrl" Findings = @() Errors = @() } } $cleanupClone = $cloneInfo.Cleanup $Repository = $cloneInfo.Path if ($cloneInfo.PSObject.Properties['Url']) { $repositoryUrl = ConvertTo-RepositoryWebUrl -Url ([string]$cloneInfo.Url) } } if (-not (Test-Path $Repository)) { return [PSCustomObject]@{ Source = 'bicep-iac' SchemaVersion = '1.0'; Status = 'Failed' Message = "Repository path not found: $Repository"; Findings = @() Errors = @() } } Write-Verbose "Running Bicep IaC validation on '$Repository'" if (-not (Get-Command Invoke-IaCAdapter -ErrorAction SilentlyContinue)) { Write-Warning "IaCAdapters module not loaded. Bicep IaC validation cannot proceed." return [PSCustomObject]@{ Source = 'bicep-iac' SchemaVersion = '1.0'; Status = 'Failed' Message = 'IaCAdapters module not loaded. Ensure modules/iac/IaCAdapters.ps1 is present.' Findings = @() Errors = @() } } if ([string]::IsNullOrWhiteSpace($repositoryUrl)) { try { $origin = git -C $Repository config --get remote.origin.url 2>$null if ($origin) { $repositoryUrl = ConvertTo-RepositoryWebUrl -Url ([string]$origin) } } catch {} # best-effort: not a git repo or git CLI absent; repositoryUrl remains empty } $result = Invoke-IaCAdapter -Flavour 'bicep' -RepoPath $Repository if ($result -and $result.PSObject) { if (-not $result.PSObject.Properties['ToolVersion']) { $result | Add-Member -NotePropertyName ToolVersion -NotePropertyValue $toolVersion } elseif ([string]::IsNullOrWhiteSpace([string]$result.ToolVersion)) { $result.ToolVersion = $toolVersion } if (-not [string]::IsNullOrWhiteSpace($repositoryUrl)) { if (-not $result.PSObject.Properties['RepositoryUrl']) { $result | Add-Member -NotePropertyName RepositoryUrl -NotePropertyValue $repositoryUrl } else { $result.RepositoryUrl = $repositoryUrl } if (-not $result.PSObject.Properties['RepositoryRef']) { $result | Add-Member -NotePropertyName RepositoryRef -NotePropertyValue 'main' } } foreach ($finding in @($result.Findings)) { if (-not $finding) { continue } if (-not $finding.PSObject.Properties['ToolVersion']) { $finding | Add-Member -NotePropertyName ToolVersion -NotePropertyValue $toolVersion } elseif ([string]::IsNullOrWhiteSpace([string]$finding.ToolVersion)) { $finding.ToolVersion = $toolVersion } } } return $result } catch { Write-Warning "Bicep IaC validation failed: $(Remove-Credentials -Text ([string]$_))" return [PSCustomObject]@{ Source = 'bicep-iac' SchemaVersion = '1.0' Status = 'Failed' Message = Remove-Credentials -Text ([string]$_) Findings = @() Errors = @() } } finally { if ($cleanupClone) { try { & $cleanupClone } catch { Write-Verbose "Bicep clone cleanup failed: $(Remove-Credentials -Text ([string]$_.Exception.Message))" } } } |