PSSecurity.build.ps1

Set-StrictMode -Version Latest

$script:Generated = Join-Path -Path $PSScriptRoot -ChildPath 'Generated'
$script:SourceFolders = @(
    (Join-Path -Path $PSScriptRoot -ChildPath 'Public')
    (Join-Path -Path $PSScriptRoot -ChildPath 'Private')
)

#### <h1 style="color: #DCA657;">🎆 PSSecurity.Build</h1>
####
#### > Defines the validation, documentation, integrity, and audit workflows for PSSecurity.
####
#### Run the default build from the repository root:
####
#### ```powershell
#### Invoke-Build
#### ```
####
#### Run one task by name when an agent needs a specific artifact or check:
####
#### ```powershell
#### Invoke-Build -Task <task-name>
#### ```
####
#### ---
####
#### <h2 style="color: #DCA657;">Build context</h2>
####
#### The build expects the repository root to be the current directory because several task
#### paths are relative. Generated reports are written beneath `Generated`. Pester coverage is
#### measured against the PowerShell files in `Public` and `Private`.
####
#### <h2 style="color: #DCA657;">Lifecycle commands</h2>
####
#### The default task runs all lifecycle commands in policy order.
####
#### | Task | Result |
#### | --- | --- |
#### | `Install` | Install runtime and build-time module dependencies. |
#### | `Build` | Regenerate the repository hash indexes. |
#### | `Test` | Run the test suite and write test-result and coverage reports. |
#### | `Lint` | Analyze the repository and write the findings report. |
#### | `Doc` | Regenerate Markdown documentation from source comments. |
####
#### Integrity comparison and security audits are on-demand tasks. They do not run as part of
#### the default pipeline.
####
#### ---

#### <h2 style="color: #DCA657;">Install</h2>
####
#### Installs every runtime and build-time module declared in `Config/Setting.psd1`.
####
Add-BuildTask Install {
    $settingsPath = Join-Path -Path $PSScriptRoot -ChildPath 'Config' -AdditionalChildPath 'Setting.psd1'
    $settings = Import-PowerShellDataFile -LiteralPath $settingsPath
    $dependencies = @($settings.PSSecurityRuntimeModules) + @($settings.PSSecurityBuildtimeModules)

    foreach ($dependency in $dependencies) {
        if (-not (Get-InstalledPSResource -Name $dependency.ModuleName -Version $dependency.ModuleVersion `
                -ErrorAction SilentlyContinue)) {
            Install-PSResource -Name $dependency.ModuleName -Version $dependency.ModuleVersion `
                -Scope CurrentUser -TrustRepository
        }
    }
}

####
#### <h2 style="color: #DCA657;">Build</h2>
####
#### Imports PSSecurity and rebuilds `HashIndex.md` and `HashIndex.json` from the current
#### repository contents. This task creates a new baseline; it does not compare against the
#### previous index.
####
Add-BuildTask Build {
    $moduleFile = Join-Path -Path "$PSScriptRoot" -ChildPath 'PSSecurity.psd1'
    Import-Module $moduleFile
    Write-DirectoryHash $PSScriptRoot
}

####
#### <h2 style="color: #DCA657;">Test</h2>
####
#### Runs Pester from the repository root with plain-text console rendering. The task enables
#### pass-through results, test-result output, and code coverage for `Public` and `Private`.
####
#### | Artifact | Contents |
#### | --- | --- |
#### | `Generated/PesterTests.xml` | Test execution results. |
#### | `Generated/PesterCodeCoverage.xml` | Coverage results for the module source folders. |
####

Add-BuildTask Test {
    $originalRendering = $PSStyle.OutputRendering
    $PSStyle.OutputRendering = 'PlainText'
    Import-Module Pester -ErrorAction SilentlyContinue
    # get default from static property
    $configuration = [PesterConfiguration]::Default
    # PassThru is what makes Invoke-Pester hand back the result object we assert on
    $configuration.Run.PassThru = $true
    $configuration.TestResult.Enabled = $true
    # adding properties & discover via intellisense
    $configuration.TestResult.OutputPath = Join-Path -Path .\Generated -ChildPath 'PesterTests.xml'
    $configuration.CodeCoverage.Enabled = $true
    $configuration.CodeCoverage.OutputPath = Join-Path -Path .\Generated -ChildPath 'PesterCodeCoverage.xml'
    $configuration.CodeCoverage.Path = $script:SourceFolders
    [void](New-Item -Path .\Generated -ItemType Directory -Force)
    Invoke-Pester -Configuration $configuration
    $PSStyle.OutputRendering = $originalRendering
}

####
#### <h2 style="color: #DCA657;">Lint</h2>
####
#### Runs PSScriptAnalyzer recursively with `PSScriptAnalyzerSettings.psd1`. Suppressed findings
#### remain visible in the report. The task writes the findings to
#### `Generated/ScriptAnalyzer.txt` and also returns them to the build output.
####
Add-BuildTask Lint {
    Import-Module -Name PSScriptAnalyzer -ErrorAction SilentlyContinue
    New-Item -Path 'Generated' -ItemType Directory -ErrorAction SilentlyContinue
    $filePath = '.\PSScriptAnalyzerSettings.psd1'
    $scriptAnalysis = Invoke-ScriptAnalyzer -Path .\ -Recurse -IncludeSuppressed -Settings $filePath -ErrorAction SilentlyContinue
    $generatedPath = (Join-Path -Path "$PSScriptRoot" -ChildPath 'Generated' -AdditionalChildPath 'ScriptAnalyzer.txt')
    $scriptAnalysis | Out-File -FilePath $generatedPath
    $scriptAnalysis
}

# #### <h2 style="color: #DCA657;">Doc</h2>
# ####
# #### Imports Sharpdown and recursively renders the repository's PowerShell documentation into
# #### `Doc`. Generated Markdown is derived from the Sharpdown comments in the source files and
# #### should not be edited directly.
# ####
# Add-BuildTask Doc {
# Import-Module Sharpdown
# ConvertTo-SharpDown -Language PowerShell -Path .\ -OutPath .\Doc\ -Recurse
# }

#### <h2 style="color: #DCA657;">confirm_hash_index_integrity</h2>
####
#### Checks the current repository against the existing `HashIndex.json` baseline:
####
#### 1. Move the existing JSON index to the system temporary directory.
#### 2. Import PSSecurity and generate a new index from the repository.
#### 3. Compare the old and new JSON files line by line.
#### 4. Remove the temporary baseline when the comparison completes.
####
#### This task is on demand and is not part of the default pipeline.
####
#### <b style="color: #C22514;">Throws</b>
####
#### - When `HashIndex.json` does not exist before the check.
#### - When the regenerated index differs from the existing baseline.
####
Add-BuildTask confirm_hash_index_integrity {
    if (-not (Test-Path -Path .\HashIndex.json)) {
        throw 'Existing Hash Index is required to compare against.'
    }
    New-Item -Path 'Generated' -ItemType Directory -ErrorAction SilentlyContinue
    Move-Item -Path .\HashIndex.json -Destination "$($env:TEMP)" -Force
    $moduleFile = Join-Path -Path "$PSScriptRoot" -ChildPath 'PSSecurity.psd1'
    Import-Module $moduleFile
    Write-DirectoryHash $PSScriptRoot
    $difference = Compare-Object (Get-Content "$($env:TEMP)\HashIndex.json") (Get-Content .\HashIndex.json)
    if ($difference) {
        $difference
        Remove-Item "$($env:TEMP)\HashIndex.json" -Force
        throw 'Integrity violation. Hash indexes do not match.'
    }
    Remove-Item "$($env:TEMP)\HashIndex.json" -Force
}

#### <h2 style="color: #DCA657;">get_application_authenticode_audit</h2>
####
#### Imports PSSecurity, audits application signatures, and writes the results as CSV to
#### `Generated/AppSigAudit.csv`. This task is on demand and is not part of the default pipeline.
####
Add-BuildTask get_application_authenticode_audit {
    $moduleFile = Join-Path -Path "$PSScriptRoot" -ChildPath 'PSSecurity.psd1'
    Import-Module $moduleFile

    Get-ApplicationSignatureAudit | ConvertTo-Csv | Out-File -FilePath .\Generated\AppSigAudit.csv
}

#### <h2 style="color: #DCA657;">get_acl_item_owner_anomaly_audit</h2>
####
#### Recursively reads ACL information beneath the current user's home directory, measures owner
#### anomalies, groups those anomalies by owner, and writes the grouped JSON to
#### `Generated/AclItemOwnerAnomaly.json`. Hidden items are included. This task is on demand and
#### is not part of the default pipeline.
####
Add-BuildTask get_acl_item_owner_anomaly_audit {
    $moduleFile = Join-Path -Path "$PSScriptRoot" -ChildPath 'PSSecurity.psd1'
    Import-Module $moduleFile
    Get-ChildItem -Path (Resolve-Path '~') -Recurse -Force |
        ForEach-Object { $_.FullName | Get-AclItem } |
        Measure-OwnerAnomaly | Select-Object -ExpandProperty Anomalies |
        Group-Object -Property Owner | ConvertTo-Json -Depth 10 | Set-Content .\Generated\AclItemOwnerAnomaly.json
}

####
#### ---
####
#### <h2 style="color: #DCA657;">Default task</h2>
####
#### The `.` task is the entry point used when `Invoke-Build` is called without a task name. Its
#### dependency list defines the default pipeline shown at the top of this document.
####

Add-BuildTask . Install, Build, Test, Lint