Skip to content

Commit b93d990

Browse files
feat(scripts): add SecurityHelpers and CIHelpers modules (#354)
## Description This PR introduces two new PowerShell modules with shared utilities for hve-core scripts: **SecurityHelpers.psm1** - Security utility functions for security scanning scripts: - `Write-SecurityLog` - Timestamped logging with severity levels and optional file output - `New-SecurityIssue` - Structured security issue object creation - `Write-SecurityReport` - Multi-format output (JSON, console, markdown) - `Test-GitHubToken` - GitHub token validation with rate limit info - `Invoke-GitHubAPIWithRetry` - GitHub API calls with exponential backoff retry **CIHelpers.psm1** - CI platform abstraction for GitHub Actions and Azure DevOps: - `Get-CIPlatform` / `Test-CIEnvironment` - Platform detection - `Set-CIOutput` - Cross-platform output variables - `Write-CIStepSummary` - Step summary output - `Write-CIAnnotation` - Warnings, errors, and notices - `Set-CITaskResult` / `Publish-CIArtifact` - Task management Both modules include comprehensive Pester tests with 90%+ coverage. ## Related Issue(s) N/A - Internal refactoring to reduce code duplication in security scripts. ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [x] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that causes existing functionality to change) - [ ] Documentation (changes to documentation only) - [ ] GitHub Actions (changes to workflow files) - [ ] Linting (changes to linting configuration) - [x] Security (changes to security configurations) - [ ] DevContainer (changes to DevContainer configuration) - [ ] Dependency (dependency updates) - [ ] AI Artifacts (changes to prompts, instructions, or agents) - [x] Script/automation (changes to scripts or tooling) ## Testing - [x] Pester unit tests added for SecurityHelpers.psm1 (62 tests passing) - [x] Pester unit tests added for CIHelpers.psm1 (47 tests passing) - [x] PSScriptAnalyzer clean (all 38 files pass) - [x] All existing tests continue to pass Test commands: ```powershell # Run SecurityHelpers tests Invoke-Pester -Path ./scripts/tests/security/SecurityHelpers.Tests.ps1 -Output Detailed # Run CIHelpers tests Invoke-Pester -Path ./scripts/tests/lib/CIHelpers.Tests.ps1 -Output Detailed # Run PSScriptAnalyzer npm run lint:ps ``` ## Checklist - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes ## Security Considerations - No secrets or credentials in code - GitHub token handling uses secure patterns (Bearer auth, rate limit awareness) - API retry logic prevents token leakage in error messages - Module follows PowerShell security best practices ## Additional Notes ### Files Changed | File | Change | Description | |------|--------|-------------| | scripts/security/Modules/SecurityHelpers.psm1 | Added | Shared security utilities module | | scripts/lib/Modules/CIHelpers.psm1 | Added | CI platform abstraction module | | scripts/tests/security/SecurityHelpers.Tests.ps1 | Added | Unit tests (62 tests) | | scripts/tests/lib/CIHelpers.Tests.ps1 | Added | Unit tests (47 tests) | | scripts/tests/Mocks/GitMocks.psm1 | Modified | Added Azure DevOps env var support | ### Commits 1. `feat(scripts): add CIHelpers.psm1 module for CI platform abstraction` 2. `fix(scripts): address PR review feedback and add copyright headers` 3. `feat(security): add SecurityHelpers module with shared utilities`
1 parent 23e7a7e commit b93d990

4 files changed

Lines changed: 1435 additions & 7 deletions

File tree

scripts/lib/Modules/CIHelpers.psm1

Lines changed: 114 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,99 @@
66
# Purpose: Shared CI platform detection and output utilities for hve-core scripts.
77
# Author: HVE Core Team
88

9+
#Requires -Version 7.0
10+
11+
function ConvertTo-GitHubActionsEscaped {
12+
<#
13+
.SYNOPSIS
14+
Escapes a string for safe use in GitHub Actions workflow commands.
15+
16+
.DESCRIPTION
17+
Percent-encodes characters that have special meaning in GitHub Actions
18+
logging commands to prevent workflow command injection attacks.
19+
20+
.PARAMETER Value
21+
The string to escape.
22+
23+
.PARAMETER ForProperty
24+
If set, also escapes colon and comma characters used in property values.
25+
#>
26+
[CmdletBinding()]
27+
[OutputType([string])]
28+
param(
29+
[Parameter(Mandatory = $true)]
30+
[AllowEmptyString()]
31+
[string]$Value,
32+
33+
[Parameter(Mandatory = $false)]
34+
[switch]$ForProperty
35+
)
36+
37+
if ([string]::IsNullOrEmpty($Value)) {
38+
return $Value
39+
}
40+
41+
# Order matters: escape % first to avoid double-encoding
42+
$escaped = $Value -replace '%', '%25'
43+
$escaped = $escaped -replace "`r", '%0D'
44+
$escaped = $escaped -replace "`n", '%0A'
45+
# Escape :: patterns to neutralize command sequences (defense in depth)
46+
# This prevents ::command:: patterns. When ForProperty is false, single colons like C:\ are preserved.
47+
$escaped = $escaped -replace '::', '%3A%3A'
48+
49+
if ($ForProperty) {
50+
$escaped = $escaped -replace ':', '%3A'
51+
$escaped = $escaped -replace ',', '%2C'
52+
}
53+
54+
return $escaped
55+
}
56+
57+
function ConvertTo-AzureDevOpsEscaped {
58+
<#
59+
.SYNOPSIS
60+
Escapes a string for safe use in Azure DevOps logging commands.
61+
62+
.DESCRIPTION
63+
Percent-encodes characters that have special meaning in Azure DevOps
64+
logging commands to prevent workflow command injection attacks.
65+
66+
.PARAMETER Value
67+
The string to escape.
68+
69+
.PARAMETER ForProperty
70+
If set, also escapes semicolon and bracket characters used in property values.
71+
#>
72+
[CmdletBinding()]
73+
[OutputType([string])]
74+
param(
75+
[Parameter(Mandatory = $true)]
76+
[AllowEmptyString()]
77+
[string]$Value,
78+
79+
[Parameter(Mandatory = $false)]
80+
[switch]$ForProperty
81+
)
82+
83+
if ([string]::IsNullOrEmpty($Value)) {
84+
return $Value
85+
}
86+
87+
# Order matters: escape % first to avoid double-encoding
88+
$escaped = $Value -replace '%', '%AZP25'
89+
$escaped = $escaped -replace "`r", '%AZP0D'
90+
$escaped = $escaped -replace "`n", '%AZP0A'
91+
# Escape brackets to prevent ##vso[ command patterns (defense in depth)
92+
$escaped = $escaped -replace '\[', '%AZP5B'
93+
$escaped = $escaped -replace '\]', '%AZP5D'
94+
95+
if ($ForProperty) {
96+
$escaped = $escaped -replace ';', '%AZP3B'
97+
}
98+
99+
return $escaped
100+
}
101+
9102
function Get-CIPlatform {
10103
<#
11104
.SYNOPSIS
@@ -84,15 +177,20 @@ function Set-CIOutput {
84177
switch ($platform) {
85178
'github' {
86179
if ($env:GITHUB_OUTPUT) {
87-
"$Name=$Value" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
180+
# GITHUB_OUTPUT uses file-based output, less vulnerable but still escape newlines
181+
$escapedName = ConvertTo-GitHubActionsEscaped -Value $Name
182+
$escapedValue = ConvertTo-GitHubActionsEscaped -Value $Value
183+
"$escapedName=$escapedValue" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
88184
}
89185
else {
90186
Write-Verbose "GITHUB_OUTPUT not set, would set: $Name=$Value"
91187
}
92188
}
93189
'azdo' {
94190
$outputFlag = if ($IsOutput) { ';isOutput=true' } else { '' }
95-
Write-Output "##vso[task.setvariable variable=$Name$outputFlag]$Value"
191+
$escapedName = ConvertTo-AzureDevOpsEscaped -Value $Name -ForProperty
192+
$escapedValue = ConvertTo-AzureDevOpsEscaped -Value $Value
193+
Write-Output "##vso[task.setvariable variable=$escapedName$outputFlag]$escapedValue"
96194
}
97195
'local' {
98196
Write-Verbose "CI Output: $Name=$Value"
@@ -204,14 +302,16 @@ function Write-CIAnnotation {
204302
$params = @()
205303
if ($File) {
206304
$normalizedFile = $File -replace '\\', '/'
207-
$params += "file=$normalizedFile"
305+
$escapedFile = ConvertTo-GitHubActionsEscaped -Value $normalizedFile -ForProperty
306+
$params += "file=$escapedFile"
208307
}
209308
if ($Line -gt 0) { $params += "line=$Line" }
210309
if ($Column -gt 0) { $params += "col=$Column" }
211310
if ($params.Count -gt 0) {
212311
$annotation += " $($params -join ',')"
213312
}
214-
Write-Output "$annotation::$Message"
313+
$escapedMessage = ConvertTo-GitHubActionsEscaped -Value $Message
314+
Write-Output "$annotation::$escapedMessage"
215315
}
216316
'azdo' {
217317
$typeMap = @{
@@ -222,11 +322,13 @@ function Write-CIAnnotation {
222322
$adoType = $typeMap[$Level]
223323
$annotation = "##vso[task.logissue type=$adoType"
224324
if ($File) {
225-
$annotation += ";sourcepath=$File"
325+
$escapedFile = ConvertTo-AzureDevOpsEscaped -Value $File -ForProperty
326+
$annotation += ";sourcepath=$escapedFile"
226327
}
227328
if ($Line -gt 0) { $annotation += ";linenumber=$Line" }
228329
if ($Column -gt 0) { $annotation += ";columnnumber=$Column" }
229-
Write-Output "$annotation]$Message"
330+
$escapedMessage = ConvertTo-AzureDevOpsEscaped -Value $Message
331+
Write-Output "$annotation]$escapedMessage"
230332
}
231333
'local' {
232334
$prefix = switch ($Level) {
@@ -322,7 +424,10 @@ function Publish-CIArtifact {
322424
}
323425
'azdo' {
324426
$container = if ($ContainerFolder) { $ContainerFolder } else { $Name }
325-
Write-Output "##vso[artifact.upload containerfolder=$container;artifactname=$Name]$Path"
427+
$escapedContainer = ConvertTo-AzureDevOpsEscaped -Value $container -ForProperty
428+
$escapedName = ConvertTo-AzureDevOpsEscaped -Value $Name -ForProperty
429+
$escapedPath = ConvertTo-AzureDevOpsEscaped -Value $Path
430+
Write-Output "##vso[artifact.upload containerfolder=$escapedContainer;artifactname=$escapedName]$escapedPath"
326431
}
327432
'local' {
328433
Write-Verbose "Artifact: $Name at $Path"
@@ -331,6 +436,8 @@ function Publish-CIArtifact {
331436
}
332437

333438
Export-ModuleMember -Function @(
439+
'ConvertTo-GitHubActionsEscaped',
440+
'ConvertTo-AzureDevOpsEscaped',
334441
'Get-CIPlatform',
335442
'Test-CIEnvironment',
336443
'Set-CIOutput',

0 commit comments

Comments
 (0)