Files
reverse-skill/skills/scripts/append-evidence.ps1
T

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