410 lines
15 KiB
PowerShell
410 lines
15 KiB
PowerShell
<#
|
||
.SYNOPSIS
|
||
Exports Randomayzer repository snapshots and diff packages for AI Security Reviewers.
|
||
|
||
.DESCRIPTION
|
||
Creates clean, deterministic ZIP archives of tracked repository code at a specific commit.
|
||
Guarantees that node_modules, local .env files, untracked artifacts, and IDE caches are never included.
|
||
Includes automated REVIEW_CONTEXT.md, diffs, and verification metrics.
|
||
|
||
.PARAMETER Diff
|
||
Generate a diff review package between Base commit and HEAD instead of a full snapshot.
|
||
|
||
.PARAMETER Base
|
||
Base commit for diff mode (defaults to HEAD^).
|
||
|
||
.PARAMETER OutputDir
|
||
Output directory for the generated ZIP file (defaults to Desktop\Randomayzer Reviews\).
|
||
|
||
.PARAMETER RequireClean
|
||
If specified, aborts the export if the git working tree has uncommitted changes.
|
||
|
||
.PARAMETER AllowSensitiveTrackedFiles
|
||
Bypasses the safety abort when potentially sensitive file patterns are detected in tracked git files.
|
||
|
||
.PARAMETER CopyPrompt
|
||
Copies the standardized security reviewer instructions to the Windows clipboard upon completion.
|
||
|
||
.EXAMPLE
|
||
.\tools\export-review.ps1
|
||
|
||
.EXAMPLE
|
||
.\tools\export-review.ps1 -Diff
|
||
|
||
.EXAMPLE
|
||
.\tools\export-review.ps1 -Diff -Base bc2b658 -CopyPrompt
|
||
|
||
.EXAMPLE
|
||
.\tools\export-review.ps1 -OutputDir "D:\Audits" -RequireClean
|
||
#>
|
||
|
||
[CmdletBinding()]
|
||
param(
|
||
[switch]$Diff,
|
||
[string]$Base,
|
||
[string]$OutputDir,
|
||
[switch]$RequireClean,
|
||
[switch]$AllowSensitiveTrackedFiles,
|
||
[switch]$CopyPrompt
|
||
)
|
||
|
||
$ErrorActionPreference = "Stop"
|
||
|
||
# 1. Resolve Git repository root and metadata
|
||
$gitRootRaw = git rev-parse --show-toplevel 2>$null
|
||
if (-not $gitRootRaw -or $LASTEXITCODE -ne 0) {
|
||
Write-Error "Not inside a git repository or git is unavailable."
|
||
exit 1
|
||
}
|
||
$gitRoot = [System.IO.Path]::GetFullPath($gitRootRaw.Trim())
|
||
|
||
$headSha = (git rev-parse HEAD 2>$null).Trim()
|
||
$shortSha = (git rev-parse --short HEAD 2>$null).Trim()
|
||
$branch = (git rev-parse --abbrev-ref HEAD 2>$null).Trim()
|
||
|
||
$remoteUrl = (git config --get remote.origin.url 2>$null)
|
||
if (-not $remoteUrl) {
|
||
$remoteUrl = "https://github.com/ochenstarik-ui/randomayzer"
|
||
} else {
|
||
$remoteUrl = $remoteUrl.Trim()
|
||
}
|
||
|
||
# 2. Output directory resolution (relative to current PowerShell location)
|
||
if (-not $OutputDir) {
|
||
$desktopPath = [Environment]::GetFolderPath([Environment+SpecialFolder]::Desktop)
|
||
$OutputDir = [System.IO.Path]::Combine($desktopPath, "Randomayzer Reviews")
|
||
} else {
|
||
if (-not [System.IO.Path]::IsPathRooted($OutputDir)) {
|
||
$OutputDir = [System.IO.Path]::Combine((Get-Location).Path, $OutputDir)
|
||
}
|
||
$OutputDir = [System.IO.Path]::GetFullPath($OutputDir)
|
||
}
|
||
|
||
if (-not (Test-Path -LiteralPath $OutputDir)) {
|
||
New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
|
||
}
|
||
|
||
# 3. Dirty worktree check
|
||
$statusOutput = git status --porcelain
|
||
$isDirty = [bool]($statusOutput -and ($statusOutput.Trim().Length -gt 0))
|
||
|
||
if ($isDirty) {
|
||
if ($RequireClean) {
|
||
Write-Error "Working tree contains uncommitted changes. -RequireClean was specified. Aborting export."
|
||
exit 1
|
||
} else {
|
||
Write-Warning "Working tree contains uncommitted changes. Snapshot represents committed HEAD only."
|
||
}
|
||
}
|
||
$dirtyText = if ($isDirty) { "YES" } else { "NO" }
|
||
|
||
# 4. Secret safety check on tracked files
|
||
$trackedFiles = git ls-tree -r --name-only HEAD
|
||
$sensitiveMatches = @()
|
||
$sensitiveRegex = '(?i)(^|/|\\)(\.env(\.(?!example$).*)?|\.env$|.*\.pem$|.*\.key$|credentials\.json$|secrets\.json$|.*\.p12$|.*\.pfx$)'
|
||
|
||
foreach ($file in $trackedFiles) {
|
||
if ($file -match $sensitiveRegex -and $file -notmatch '(?i)\.env\.example') {
|
||
$sensitiveMatches += $file
|
||
}
|
||
}
|
||
|
||
if ($sensitiveMatches.Count -gt 0) {
|
||
Write-Warning "=================================================="
|
||
Write-Warning "POTENTIALLY SENSITIVE FILES DETECTED IN TRACKED GIT:"
|
||
foreach ($sf in $sensitiveMatches) {
|
||
Write-Warning " - $sf"
|
||
}
|
||
Write-Warning "=================================================="
|
||
if (-not $AllowSensitiveTrackedFiles) {
|
||
Write-Error "Export aborted to prevent secret leakage. Pass -AllowSensitiveTrackedFiles to override."
|
||
exit 1
|
||
}
|
||
}
|
||
|
||
# Ensure .NET compression assemblies are loaded
|
||
Add-Type -AssemblyName System.IO.Compression
|
||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||
|
||
$timestamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss zzz")
|
||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||
|
||
# 5. Export generation
|
||
if (-not $Diff) {
|
||
# ── FULL SNAPSHOT MODE ──
|
||
$modeName = "FULL"
|
||
$zipFileName = "randomayzer-review-$shortSha.zip"
|
||
$zipFilePath = [System.IO.Path]::Combine($OutputDir, $zipFileName)
|
||
|
||
if (Test-Path -LiteralPath $zipFilePath) {
|
||
Remove-Item -LiteralPath $zipFilePath -Force
|
||
}
|
||
|
||
# Build clean archive directly from git HEAD
|
||
Push-Location $gitRoot
|
||
try {
|
||
git archive HEAD --format=zip --output="$zipFilePath"
|
||
if ($LASTEXITCODE -ne 0) {
|
||
Write-Error "git archive failed with exit code $LASTEXITCODE"
|
||
exit 1
|
||
}
|
||
} finally {
|
||
Pop-Location
|
||
}
|
||
|
||
# Generate metadata context
|
||
$gitLogStat = (git log -1 --stat HEAD) -join "`n"
|
||
|
||
$reviewContextLines = @(
|
||
"# Randomayzer Review Snapshot",
|
||
"",
|
||
"- **Full commit SHA:** $headSha",
|
||
"- **Short SHA:** $shortSha",
|
||
"- **Branch:** $branch",
|
||
"- **Generated at:** $timestamp",
|
||
"- **Repository:** $remoteUrl",
|
||
"- **Snapshot mode:** FULL",
|
||
"- **Working tree dirty at export time:** $dirtyText",
|
||
"",
|
||
"## Source of Truth",
|
||
"The files in this archive are the exact tracked repository snapshot for the commit shown above.",
|
||
"",
|
||
"## Reviewer Instructions",
|
||
"Do not use GitHub HEAD as source of truth.",
|
||
"Review the attached snapshot.",
|
||
"",
|
||
"## Recent Commit Log",
|
||
'```',
|
||
$gitLogStat,
|
||
'```'
|
||
)
|
||
$reviewContext = $reviewContextLines -join "`n"
|
||
|
||
# Inject REVIEW_CONTEXT.md into ZIP
|
||
$archive = [System.IO.Compression.ZipFile]::Open($zipFilePath, [System.IO.Compression.ZipArchiveMode]::Update)
|
||
try {
|
||
$existingEntry = $archive.GetEntry("REVIEW_CONTEXT.md")
|
||
if ($existingEntry) {
|
||
$existingEntry.Delete()
|
||
}
|
||
$entry = $archive.CreateEntry("REVIEW_CONTEXT.md", [System.IO.Compression.CompressionLevel]::Optimal)
|
||
$stream = $entry.Open()
|
||
try {
|
||
$bytes = $utf8NoBom.GetBytes($reviewContext)
|
||
$stream.Write($bytes, 0, $bytes.Length)
|
||
} finally {
|
||
$stream.Dispose()
|
||
}
|
||
} finally {
|
||
$archive.Dispose()
|
||
}
|
||
|
||
} else {
|
||
# ── DIFF MODE ──
|
||
$modeName = "DIFF"
|
||
$zipFileName = "randomayzer-diff-review-$shortSha.zip"
|
||
$zipFilePath = [System.IO.Path]::Combine($OutputDir, $zipFileName)
|
||
|
||
if (Test-Path -LiteralPath $zipFilePath) {
|
||
Remove-Item -LiteralPath $zipFilePath -Force
|
||
}
|
||
|
||
if (-not $Base) {
|
||
$Base = "HEAD^"
|
||
}
|
||
|
||
$baseShaRaw = git rev-parse --verify "$Base" 2>$null
|
||
if (-not $baseShaRaw -or $LASTEXITCODE -ne 0) {
|
||
Write-Error "Invalid base commit: '$Base'"
|
||
exit 1
|
||
}
|
||
$baseSha = $baseShaRaw.Trim()
|
||
$baseShortSha = (git rev-parse --short $baseSha 2>$null).Trim()
|
||
|
||
# Generate diff and list of changed files
|
||
$diffPatch = (git diff $baseSha HEAD) -join "`n"
|
||
$changedFiles = git diff --name-only $baseSha HEAD
|
||
$changedFilesList = @()
|
||
if ($changedFiles) {
|
||
$changedFilesList = @($changedFiles) | Where-Object { $_ -and $_.Trim().Length -gt 0 }
|
||
}
|
||
$changedFilesText = ($changedFilesList -join "`n")
|
||
|
||
# Determine files to include in Diff package:
|
||
# 1. Changed files present in HEAD (exclude deleted files)
|
||
# 2. All tracked test files (tests/*)
|
||
# 3. Security, config, and documentation context files
|
||
$trackedHeadFiles = git ls-tree -r --name-only HEAD
|
||
$filesToInclude = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
|
||
|
||
foreach ($cf in $changedFilesList) {
|
||
if ($trackedHeadFiles -contains $cf) {
|
||
$filesToInclude.Add($cf) | Out-Null
|
||
}
|
||
}
|
||
|
||
# Add all tracked test files for test integrity review
|
||
foreach ($tf in $trackedHeadFiles) {
|
||
if ($tf -like "tests/*" -or $tf -like "test/*") {
|
||
$filesToInclude.Add($tf) | Out-Null
|
||
}
|
||
}
|
||
|
||
# Add schema, dependencies, env.example, and documentation
|
||
$contextPatterns = @(
|
||
"package.json",
|
||
"package-lock.json",
|
||
"prisma/schema.prisma",
|
||
".env.example",
|
||
"README.md",
|
||
"AGENTS.md",
|
||
"GEMINI.md",
|
||
"docs/*"
|
||
)
|
||
|
||
foreach ($pattern in $contextPatterns) {
|
||
foreach ($hf in $trackedHeadFiles) {
|
||
if ($hf -like $pattern) {
|
||
# Security filter
|
||
if ($hf -match $sensitiveRegex -and $hf -notmatch '(?i)\.env\.example') {
|
||
continue
|
||
}
|
||
$filesToInclude.Add($hf) | Out-Null
|
||
}
|
||
}
|
||
}
|
||
|
||
# Stage files in temporary directory
|
||
$tempDir = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), ("randomayzer_export_" + [System.Guid]::NewGuid().ToString("N")))
|
||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||
|
||
try {
|
||
Push-Location $gitRoot
|
||
try {
|
||
foreach ($relPath in $filesToInclude) {
|
||
$gitRelPath = $relPath -replace '\\', '/'
|
||
$destPath = [System.IO.Path]::Combine($tempDir, ($relPath -replace '/', [System.IO.Path]::DirectorySeparatorChar))
|
||
$destParent = [System.IO.Path]::GetDirectoryName($destPath)
|
||
if (-not (Test-Path -LiteralPath $destParent)) {
|
||
New-Item -ItemType Directory -Path $destParent -Force | Out-Null
|
||
}
|
||
|
||
# Extract exact binary content from HEAD
|
||
$processInfo = New-Object System.Diagnostics.ProcessStartInfo
|
||
$processInfo.FileName = "git"
|
||
$processInfo.Arguments = "show `"HEAD:$gitRelPath`""
|
||
$processInfo.WorkingDirectory = $gitRoot
|
||
$processInfo.UseShellExecute = $false
|
||
$processInfo.RedirectStandardOutput = $true
|
||
$processInfo.RedirectStandardError = $true
|
||
$processInfo.CreateNoWindow = $true
|
||
|
||
$process = [System.Diagnostics.Process]::Start($processInfo)
|
||
$outputStream = [System.IO.File]::Create($destPath)
|
||
try {
|
||
$process.StandardOutput.BaseStream.CopyTo($outputStream)
|
||
} finally {
|
||
$outputStream.Dispose()
|
||
}
|
||
$process.WaitForExit()
|
||
}
|
||
} finally {
|
||
Pop-Location
|
||
}
|
||
|
||
# Write diff artifacts
|
||
[System.IO.File]::WriteAllText([System.IO.Path]::Combine($tempDir, "REVIEW_DIFF.patch"), $diffPatch, $utf8NoBom)
|
||
[System.IO.File]::WriteAllText([System.IO.Path]::Combine($tempDir, "REVIEW_CHANGED_FILES.txt"), $changedFilesText, $utf8NoBom)
|
||
|
||
# Generate REVIEW_CONTEXT.md
|
||
$gitLogStat = (git log --stat "$baseSha..HEAD") -join "`n"
|
||
|
||
$reviewContextLines = @(
|
||
"# Randomayzer Review Snapshot (Diff Mode)",
|
||
"",
|
||
"- **Full commit SHA:** $headSha",
|
||
"- **Short SHA:** $shortSha",
|
||
"- **Branch:** $branch",
|
||
"- **Generated at:** $timestamp",
|
||
"- **Repository:** $remoteUrl",
|
||
"- **Snapshot mode:** DIFF",
|
||
"- **Base commit:** $baseSha ($baseShortSha)",
|
||
"- **Target commit:** $headSha ($shortSha)",
|
||
"- **Working tree dirty at export time:** $dirtyText",
|
||
"",
|
||
"## Source of Truth",
|
||
"The files in this archive are the exact tracked repository snapshot for the commit shown above.",
|
||
"This archive contains the diff against base commit, changed files, relevant test suites, schema definitions, and project documentation.",
|
||
"",
|
||
"## Reviewer Instructions",
|
||
"Do not use GitHub HEAD as source of truth.",
|
||
"Review the attached snapshot.",
|
||
"- Check `REVIEW_DIFF.patch` for the complete patch.",
|
||
"- Check `REVIEW_CHANGED_FILES.txt` for the list of modified files.",
|
||
"- Inspect the included source and test files.",
|
||
"",
|
||
"## Recent Commit Log ($baseShortSha..$shortSha)",
|
||
'```',
|
||
$gitLogStat,
|
||
'```'
|
||
)
|
||
$reviewContext = $reviewContextLines -join "`n"
|
||
|
||
[System.IO.File]::WriteAllText([System.IO.Path]::Combine($tempDir, "REVIEW_CONTEXT.md"), $reviewContext, $utf8NoBom)
|
||
|
||
# Compress staged directory
|
||
[System.IO.Compression.ZipFile]::CreateFromDirectory($tempDir, $zipFilePath, [System.IO.Compression.CompressionLevel]::Optimal, $false)
|
||
} finally {
|
||
if (Test-Path -LiteralPath $tempDir) {
|
||
Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
||
}
|
||
}
|
||
}
|
||
|
||
# 6. Verify Archive & Display Summary
|
||
$zip = [System.IO.Compression.ZipFile]::OpenRead($zipFilePath)
|
||
$fileCount = $zip.Entries.Count
|
||
$zip.Dispose()
|
||
|
||
$fileInfo = Get-Item -LiteralPath $zipFilePath
|
||
$sizeBytes = $fileInfo.Length
|
||
$sizeFormatted = if ($sizeBytes -ge 1MB) {
|
||
"{0:N2} MB ({1:N0} bytes)" -f ($sizeBytes / 1MB), $sizeBytes
|
||
} elseif ($sizeBytes -ge 1KB) {
|
||
"{0:N2} KB ({1:N0} bytes)" -f ($sizeBytes / 1KB), $sizeBytes
|
||
} else {
|
||
"{0} bytes" -f $sizeBytes
|
||
}
|
||
|
||
Write-Host ""
|
||
Write-Host "==================================================" -ForegroundColor Cyan
|
||
Write-Host " Randomayzer Review Package Exported Successfully " -ForegroundColor Cyan
|
||
Write-Host "==================================================" -ForegroundColor Cyan
|
||
Write-Host ("Created: " + $zipFilePath) -ForegroundColor Green
|
||
Write-Host ("Commit: " + $headSha + " (" + $shortSha + ")")
|
||
Write-Host ("Mode: " + $modeName)
|
||
Write-Host ("Files: " + $fileCount)
|
||
Write-Host ("Size: " + $sizeFormatted)
|
||
Write-Host "==================================================" -ForegroundColor Cyan
|
||
|
||
# 7. Optional Clipboard Prompt
|
||
if ($CopyPrompt) {
|
||
$clipboardPromptLines = @(
|
||
"В приложенном архиве snapshot Randomayzer на commit $headSha.",
|
||
"Используй архив как source of truth.",
|
||
"Не используй GitHub HEAD вместо него.",
|
||
"Выполни ранее выданное security review задание."
|
||
)
|
||
$clipboardPrompt = $clipboardPromptLines -join "`n"
|
||
|
||
try {
|
||
if (Get-Command Set-Clipboard -ErrorAction SilentlyContinue) {
|
||
Set-Clipboard -Value $clipboardPrompt
|
||
Write-Host "Reviewer prompt copied to clipboard." -ForegroundColor Yellow
|
||
} else {
|
||
Write-Warning "Set-Clipboard is not available in this environment."
|
||
}
|
||
} catch {
|
||
Write-Warning "Could not copy prompt to clipboard: $_"
|
||
}
|
||
}
|