201 lines
7.1 KiB
PowerShell
201 lines
7.1 KiB
PowerShell
#Requires -Version 5.1
|
|
# Append one Evidence item under work/<case>/evidence/ (ops Evidence contract).
|
|
#
|
|
# Usage (simple):
|
|
# powershell -File skills/scripts/append-evidence.ps1 -CaseRoot work\my-case `
|
|
# -Id E-001 -Title "Open clock API" -ReproCommand 'curl -sI https://example/' `
|
|
# -Severity info -Status observed
|
|
#
|
|
# Special characters / spaces / quotes in excerpts (recommended for nested -File):
|
|
# Set-Content excerpt.txt -Value '"XML parsing error" / Entities are not allowed'
|
|
# powershell -File skills/scripts/append-evidence.ps1 ... -RawExcerptFile excerpt.txt
|
|
#
|
|
# NOTE: Nested `powershell -File` splits unquoted multi-word values. Prefer -RawExcerptFile
|
|
# or call the script in-process: & .\append-evidence.ps1 -RawExcerpt 'full text here'
|
|
[CmdletBinding()]
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string] $CaseRoot,
|
|
|
|
[Parameter(Mandatory = $true)]
|
|
[string] $Id,
|
|
|
|
[Parameter(Mandatory = $true)]
|
|
[string] $Title,
|
|
|
|
[Parameter(Mandatory = $true)]
|
|
[string] $ReproCommand,
|
|
|
|
[string] $Severity = 'info',
|
|
[string] $Status = 'observed',
|
|
[string] $SourceType = 'command',
|
|
[string] $RawExcerpt = '',
|
|
[string] $RawExcerptFile = '',
|
|
[string] $Location = '',
|
|
[string] $Notes = '',
|
|
[string] $NotesFile = '',
|
|
[string] $ReproCommandFile = '',
|
|
[string] $ArtifactPath = ''
|
|
)
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
# Advanced functions reject unknown positionals at bind time; keep explicit check for edge hosts.
|
|
if ($args -and $args.Count -gt 0) {
|
|
$extra = ($args | ForEach-Object { [string]$_ }) -join ' | '
|
|
throw ("Unexpected arguments (likely -RawExcerpt/-Title quoting broke multi-word value): {0}. Use -RawExcerptFile for special characters, or pass a single quoted string." -f $extra)
|
|
}
|
|
|
|
$allowedSeverity = @('critical', 'high', 'medium', 'low', 'info', 'n/a', 'n/a_re')
|
|
$allowedStatus = @('observed', 'candidate', 'validated', 'false_positive', 'accepted_risk')
|
|
|
|
function ConvertTo-YamlBlock([string] $text) {
|
|
if ([string]::IsNullOrEmpty($text)) { return ' n/a' }
|
|
$clean = ($text -replace '[\x00-\x08\x0B\x0C\x0E-\x1F]', '')
|
|
$lines = $clean -split "`r?`n", -1
|
|
if ($lines.Count -eq 0) { return ' n/a' }
|
|
return (($lines | ForEach-Object { ' ' + $_ }) -join [Environment]::NewLine)
|
|
}
|
|
|
|
function ConvertTo-SingleLine([string] $text) {
|
|
if ([string]::IsNullOrEmpty($text)) { return 'n/a' }
|
|
$t = ($text -replace '[\x00-\x1F]', ' ')
|
|
$t = $t -replace '\s+', ' '
|
|
return $t.Trim()
|
|
}
|
|
|
|
function Read-OptionalFile([string] $path) {
|
|
if ([string]::IsNullOrWhiteSpace($path)) { return $null }
|
|
if (-not (Test-Path -LiteralPath $path)) {
|
|
throw "File not found: $path"
|
|
}
|
|
return [System.IO.File]::ReadAllText((Resolve-Path -LiteralPath $path), [System.Text.Encoding]::UTF8)
|
|
}
|
|
|
|
if (-not (Test-Path -LiteralPath $CaseRoot)) {
|
|
throw "CaseRoot does not exist: $CaseRoot"
|
|
}
|
|
|
|
# File overrides beat inline strings (safe path for special characters under powershell -File)
|
|
$fromReproFile = Read-OptionalFile $ReproCommandFile
|
|
if ($null -ne $fromReproFile) { $ReproCommand = $fromReproFile }
|
|
|
|
$fromExcerptFile = Read-OptionalFile $RawExcerptFile
|
|
if ($null -ne $fromExcerptFile) { $RawExcerpt = $fromExcerptFile }
|
|
|
|
$fromNotesFile = Read-OptionalFile $NotesFile
|
|
if ($null -ne $fromNotesFile) { $Notes = $fromNotesFile }
|
|
|
|
$contentHash = 'n/a'
|
|
$artifactRef = 'n/a'
|
|
if (-not [string]::IsNullOrWhiteSpace($ArtifactPath)) {
|
|
$artifactCandidate = $ArtifactPath
|
|
if (-not [System.IO.Path]::IsPathRooted($artifactCandidate)) {
|
|
$caseArtifact = Join-Path $CaseRoot $artifactCandidate
|
|
if (Test-Path -LiteralPath $caseArtifact -PathType Leaf) { $artifactCandidate = $caseArtifact }
|
|
}
|
|
if (-not (Test-Path -LiteralPath $artifactCandidate -PathType Leaf)) {
|
|
throw "ArtifactPath must point to a file inside CaseRoot: $ArtifactPath"
|
|
}
|
|
$caseRootFull = (Resolve-Path -LiteralPath $CaseRoot).Path
|
|
$artifactFull = (Resolve-Path -LiteralPath $artifactCandidate).Path
|
|
$casePrefix = $caseRootFull.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar
|
|
if (-not $artifactFull.StartsWith($casePrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
|
|
throw "ArtifactPath must point inside CaseRoot: $ArtifactPath"
|
|
}
|
|
$artifactRef = $artifactFull.Substring($casePrefix.Length) -replace '\\', '/'
|
|
$contentHash = 'sha256:' + (Get-FileHash -LiteralPath $artifactFull -Algorithm SHA256).Hash.ToLowerInvariant()
|
|
}
|
|
|
|
$evDir = Join-Path $CaseRoot 'evidence'
|
|
New-Item -ItemType Directory -Force -Path $evDir | Out-Null
|
|
|
|
$idSafe = ($Id -replace '[^\w\-]+', '-').Trim('-')
|
|
if ($idSafe -notmatch '^E-') { $idSafe = 'E-' + $idSafe }
|
|
$fileName = $idSafe + '.md'
|
|
$path = Join-Path $evDir $fileName
|
|
|
|
$observed = Get-Date -Format 'o'
|
|
$titleLine = ConvertTo-SingleLine $Title
|
|
$sev = (ConvertTo-SingleLine $Severity).ToLowerInvariant()
|
|
if ($allowedSeverity -notcontains $sev) {
|
|
throw ("Invalid -Severity '{0}' (allowed: {1})" -f $Severity, ($allowedSeverity -join ', '))
|
|
}
|
|
$st = (ConvertTo-SingleLine $Status).ToLowerInvariant()
|
|
if ($allowedStatus -notcontains $st) {
|
|
throw ("Invalid -Status '{0}' (allowed: {1})" -f $Status, ($allowedStatus -join ', '))
|
|
}
|
|
$src = ConvertTo-SingleLine $SourceType
|
|
$loc = ConvertTo-SingleLine $(if ($Location) { $Location } else { 'n/a' })
|
|
if ([string]::IsNullOrWhiteSpace($ReproCommand)) {
|
|
throw '-ReproCommand (or -ReproCommandFile) is required and must be non-empty'
|
|
}
|
|
$reproBlock = ConvertTo-YamlBlock $ReproCommand
|
|
$excerptBlock = ConvertTo-YamlBlock $(if ($RawExcerpt) { $RawExcerpt } else { 'n/a' })
|
|
$notesBlock = ConvertTo-YamlBlock $(if ($Notes) { $Notes } else { 'n/a' })
|
|
|
|
$body = @"
|
|
### $idSafe
|
|
- title: $titleLine
|
|
- observed_at: $observed
|
|
- source_type: $src
|
|
- source_ref: append-evidence.ps1
|
|
- content_hash: $contentHash
|
|
- artifact_path: $artifactRef
|
|
- severity: $sev
|
|
- status: $st
|
|
- location: $loc
|
|
- repro_command: |
|
|
$reproBlock
|
|
- raw_excerpt: |
|
|
$excerptBlock
|
|
- linked_workitem: n/a
|
|
- supersedes: none
|
|
- notes: |
|
|
$notesBlock
|
|
"@
|
|
|
|
$utf8 = New-Object System.Text.UTF8Encoding $false
|
|
$stream = $null
|
|
$writer = $null
|
|
try {
|
|
try {
|
|
$stream = [System.IO.File]::Open(
|
|
$path,
|
|
[System.IO.FileMode]::CreateNew,
|
|
[System.IO.FileAccess]::Write,
|
|
[System.IO.FileShare]::None
|
|
)
|
|
} catch [System.IO.IOException] {
|
|
if (Test-Path -LiteralPath $path) {
|
|
throw ("Evidence record already exists and is immutable: {0}. Use a new -Id." -f $fileName)
|
|
}
|
|
throw
|
|
}
|
|
$writer = [System.IO.StreamWriter]::new($stream, $utf8)
|
|
$writer.Write($body)
|
|
} finally {
|
|
if ($null -ne $writer) {
|
|
$writer.Dispose()
|
|
} elseif ($null -ne $stream) {
|
|
$stream.Dispose()
|
|
}
|
|
}
|
|
|
|
$index = Join-Path $evDir 'INDEX.md'
|
|
$line = "- $idSafe | $sev | $st | $titleLine | $fileName"
|
|
if (-not (Test-Path -LiteralPath $index)) {
|
|
$hdr = @"
|
|
# Evidence index
|
|
|
|
| id | severity | status | title | file |
|
|
|----|----------|--------|-------|------|
|
|
|
|
"@
|
|
[System.IO.File]::WriteAllText($index, $hdr, $utf8)
|
|
}
|
|
Add-Content -Path $index -Value $line -Encoding UTF8
|
|
|
|
Write-Host ("EVIDENCE -> {0}" -f $path) -ForegroundColor Green
|
|
Write-Host ("ID: {0} | {1} | {2}" -f $idSafe, $sev, $st)
|
|
exit 0
|