Просмотр исходного кода

feat(windows-ops): Adopt term.ps1 design system (PowerShell)

Wire the PowerShell skills into the shared terminal toolkit (skills/_lib/term.ps1),
the PowerShell mirror of term.sh. Stream separation holds: framing on stderr, the
data product (Write-Data / TSV / --json) plain on stdout.

- windows-ops/_lib/common.ps1 (the lever for all 8 scripts): Write-Section and
  Write-Log now render via term.ps1 — cyan section headers (no long === rules) and
  colored [TAG] log lines. The original Write-Log emitted plain stderr ("can't
  colorise stderr in PS"); term.ps1 supplies NO_COLOR/FORCE_COLOR/TERM_ASCII-aware
  color, and the [PASS]/[FAIL] tag text stays literal and greppable.
- supply-chain-defense/phone-home-monitor.ps1: dot-sources term.ps1, colorizes the
  header; em-dashes in stderr strings swapped to ASCII so framing is pure under
  TERM_ASCII=1.
- term.ps1: the double-source guard read $Script:__TermPs1Loaded before it was set,
  which throws under Set-StrictMode -Version Latest (phone-home-monitor uses strict
  mode). Guarded with Test-Path Variable: so dot-sourcing is strict-mode safe.
- NEW skills/windows-ops/tests/run.sh: term.ps1 adoption (static, runs everywhere)
  + pwsh-gated runtime ASCII purity / colorization / literal-tag checks that skip
  cleanly where pwsh is absent. 6/6 on a PowerShell host.

mac-ops already adopted the panel UI (commit eaad08c); no change needed there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
0xDarkMatter 3 дней назад
Родитель
Сommit
fd12d2e2c3

+ 4 - 2
skills/_lib/term.ps1

@@ -23,8 +23,10 @@
     Spec: ../docs/TERMINAL-DESIGN.md.
 #>
 
-# Guard against double-sourcing
-if ($Script:__TermPs1Loaded) { return }
+# Guard against double-sourcing. Test-Path keeps this safe under
+# Set-StrictMode -Version Latest, where reading an unset variable throws (this
+# lib is dot-sourced into strict-mode consumer scripts, e.g. phone-home-monitor).
+if ((Test-Path Variable:Script:__TermPs1Loaded) -and $Script:__TermPs1Loaded) { return }
 $Script:__TermPs1Loaded = $true
 
 # ─── Globals (populated by Initialize-Term) ──────────────────────────────────

+ 17 - 10
skills/supply-chain-defense/scripts/phone-home-monitor.ps1

@@ -1,5 +1,5 @@
 #!/usr/bin/env pwsh
-# Outbound-connection ("phone-home") monitor for Windows  exfiltration tripwire.
+# Outbound-connection ("phone-home") monitor for Windows - exfiltration tripwire.
 #
 # Maps every outbound TCP connection to its owning process, parent chain, and
 # Authenticode signing status, then flags the patterns the 2026 npm-worm family
@@ -57,6 +57,13 @@ $SCHEMA = 'claude-mods.supply-chain-defense.phone-home-monitor/v1'
 $TASK_NAME = 'SupplyChain-PhoneHomeMonitor'
 $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
 
+# Shared terminal design system (skills/_lib/term.ps1) - colorized, ASCII-aware
+# stderr framing; the TSV/--json data product stays plain on stdout. term.ps1
+# honors NO_COLOR / FORCE_COLOR / TERM_ASCII. Degrade to plain if the lib is gone.
+$__scTermLib = Join-Path $ScriptDir '..\..\_lib\term.ps1'
+if (Test-Path $__scTermLib) { . $__scTermLib; Initialize-Term }
+else { function Get-TermColor { param($Token, $Text) return $Text } }
+
 function Write-Info([string]$msg) { if (-not $Quiet) { [Console]::Error.WriteLine($msg) } }
 
 function Show-Help {
@@ -90,7 +97,7 @@ if (Test-Path $iocPath) {
             if ($e.PSObject.Properties['ips'])     { $iocIps     += @($e.ips     | ForEach-Object { @{ value = $_; id = $e.id } }) }
         }
     } catch {
-        [Console]::Error.WriteLine("ERROR: IOC catalog unparseable: $iocPath  $($_.Exception.Message)")
+        [Console]::Error.WriteLine("ERROR: IOC catalog unparseable: $iocPath - $($_.Exception.Message)")
         if ($Json) { Write-Output (@{ error = @{ code = 'VALIDATION'; message = "IOC catalog unparseable: $iocPath" } } | ConvertTo-Json -Compress) }
         exit 4
     }
@@ -136,7 +143,7 @@ function Get-DomainAgeDays([string]$hostname) {
         $r = Invoke-RestMethod -Uri "https://rdap.org/domain/$domain" -TimeoutSec 5
         $reg = $r.events | Where-Object { $_.eventAction -eq 'registration' } | Select-Object -First 1
         if ($reg) { $age = [int]((Get-Date).ToUniversalTime() - [datetime]$reg.eventDate).TotalDays }
-    } catch { Write-Info "  [rdap unavailable] $domain  domain age unknown (advisory only)" }
+    } catch { Write-Info "  [rdap unavailable] $domain - domain age unknown (advisory only)" }
     $rdapCache[$domain] = $age
     return $age
 }
@@ -300,10 +307,10 @@ function Write-Report($findings, [string]$source, [int]$connCount) {
         }
     }
     Write-Info ''
-    Write-Info "Source: $source  $connCount outbound connection(s) examined, $(@($findings).Count) finding(s), $($counted.Count) at medium+ severity."
+    Write-Info "Source: $source - $connCount outbound connection(s) examined, $(@($findings).Count) finding(s), $($counted.Count) at medium+ severity."
     if ($counted.Count -gt 0) {
         Write-Info 'Triage: confirm the process is something you launched; check parent chain; if it is a'
-        Write-Info 'package-manager child or IOC hit, treat as an incident  isolate, rotate credentials,'
+        Write-Info 'package-manager child or IOC hit, treat as an incident - isolate, rotate credentials,'
         Write-Info 'run integrity-audit.sh + exposure-check.py. See references/phone-home-monitoring.md.'
     }
     if ($counted.Count -gt 0) { exit $EXIT_FINDING } else { exit $EXIT_OK }
@@ -330,7 +337,7 @@ if ($Status) {
     $fwLog = try { @(Get-NetFirewallProfile | Where-Object { $_.LogAllowed -eq 'True' }).Count } catch { 'unknown' }
     $task = try { [bool](Get-ScheduledTask -TaskName $TASK_NAME -ErrorAction SilentlyContinue) } catch { $false }
     $rows = [ordered]@{
-        sysmon_eid3        = if ($sysmonOk) { 'available (preferred source  use -Sysmon)' } else { 'not installed (see references/phone-home-monitoring.md to wire it)' }
+        sysmon_eid3        = if ($sysmonOk) { 'available (preferred source - use -Sysmon)' } else { 'not installed (see references/phone-home-monitoring.md to wire it)' }
         wfp_audit_5156     = $wfp
         firewall_log_allowed_profiles = $fwLog
         tcp_table_polling  = 'available (default source)'
@@ -377,7 +384,7 @@ if ($InputJson) {
 
 if ($Sysmon) {
     if (-not (Test-SysmonPresent)) {
-        Write-Info 'ERROR: Sysmon is not installed  Event ID 3 (network connections) unavailable.'
+        Write-Info 'ERROR: Sysmon is not installed - Event ID 3 (network connections) unavailable.'
         Write-Info 'Install it with a curated config (the preferred continuous source):'
         Write-Info '  winget install Microsoft.Sysinternals.Sysmon'
         Write-Info '  curl -o sysmonconfig.xml https://raw.githubusercontent.com/SwiftOnSecurity/sysmon-config/master/sysmonconfig-export.xml'
@@ -385,7 +392,7 @@ if ($Sysmon) {
         Write-Info 'See references/phone-home-monitoring.md for the full evaluation.'
         exit $EXIT_MISSING_DEP
     }
-    Write-Info "=== phone-home monitor (Sysmon EID 3, last $MaxEvents events) ==="
+    Write-Info (Get-TermColor cyan "=== phone-home monitor (Sysmon EID 3, last $MaxEvents events) ===")
     $conns = Get-SysmonConnections
     $findings = [System.Collections.Generic.List[object]]::new()
     foreach ($c in $conns) { foreach ($f in (Get-Findings $c)) { $findings.Add($f) } }
@@ -393,7 +400,7 @@ if ($Sysmon) {
 }
 
 if ($Watch) {
-    Write-Info "=== phone-home monitor (watch mode, every ${IntervalSeconds}s$(if ($DurationMinutes) { ", for ${DurationMinutes}m" })) ==="
+    Write-Info (Get-TermColor cyan "=== phone-home monitor (watch mode, every ${IntervalSeconds}s$(if ($DurationMinutes) { ", for ${DurationMinutes}m" })) ===")
     Write-Info "Findings log: $LogPath"
     $seen = @{}; $total = 0
     $deadline = if ($DurationMinutes -gt 0) { (Get-Date).AddMinutes($DurationMinutes) } else { [datetime]::MaxValue }
@@ -419,7 +426,7 @@ if ($Watch) {
 # default: one snapshot
 Write-Info '=== phone-home monitor (TCP-table snapshot) ==='
 if (-not (Test-SysmonPresent)) {
-    Write-Info 'note: Sysmon not installed  polling misses short-lived connections. Prefer -Sysmon once wired.'
+    Write-Info 'note: Sysmon not installed - polling misses short-lived connections. Prefer -Sysmon once wired.'
 }
 $conns = Get-SnapshotConnections
 $findings = [System.Collections.Generic.List[object]]::new()

+ 26 - 17
skills/windows-ops/scripts/_lib/common.ps1

@@ -11,34 +11,43 @@ $script:EXIT_PRECONDITION = 5
 $script:EXIT_TIMEOUT      = 6
 $script:EXIT_UNAVAILABLE  = 7
 
+# Shared terminal design system (skills/_lib/term.ps1) — colorized, ASCII-aware
+# framing on stderr (data stays plain on stdout via Write-Data). term.ps1 honors
+# NO_COLOR / FORCE_COLOR / TERM_ASCII. Degrade to plain text if the lib is gone.
+$script:__WinTermLib = Join-Path $PSScriptRoot '..\..\..\_lib\term.ps1'
+if (Test-Path $script:__WinTermLib) {
+    . $script:__WinTermLib
+    Initialize-Term
+    $script:__WinHaveTerm = $true
+} else {
+    $script:__WinHaveTerm = $false
+    function Get-TermColor { param($Token, $Text) return $Text }
+}
+
 function Write-Log {
-    # All logs to stderr — never pollute stdout
+    # All logs to stderr — never pollute stdout. Colorized via term.ps1 (the [TAG]
+    # text stays literal/greppable; color is amplification, never the only signal).
     param(
         [Parameter(Mandatory)][ValidateSet('INFO','WARN','ERROR','PASS','FAIL','DEBUG')]$Level,
         [Parameter(Mandatory)][string]$Message
     )
-    $color = switch ($Level) {
-        'PASS'  { 'Green' }
-        'FAIL'  { 'Red' }
-        'ERROR' { 'Red' }
-        'WARN'  { 'Yellow' }
-        'INFO'  { 'Cyan' }
-        'DEBUG' { 'DarkGray' }
-    }
-    [Console]::Error.WriteLine("[$Level] $Message")
-    # Re-emit colorised version when stderr is a TTY (for human readability)
-    if ([Console]::IsErrorRedirected -eq $false) {
-        # Can't easily colorise stderr in PS — accept plain text, color reserved for TTY-only contexts
+    $token = switch ($Level) {
+        'PASS'  { 'green' }
+        'FAIL'  { 'red' }
+        'ERROR' { 'red' }
+        'WARN'  { 'orange' }
+        'INFO'  { 'cyan' }
+        'DEBUG' { 'dim' }
     }
+    [Console]::Error.WriteLine((Get-TermColor $token "[$Level]") + " $Message")
 }
 
 function Write-Section {
+    # Cyan section header (no long === rules — design principle #4: whitespace,
+    # not rules, separates sections). ASCII-aware via term.ps1.
     param([Parameter(Mandatory)][string]$Title)
-    $line = '=' * 60
     [Console]::Error.WriteLine("")
-    [Console]::Error.WriteLine($line)
-    [Console]::Error.WriteLine("  $Title")
-    [Console]::Error.WriteLine($line)
+    [Console]::Error.WriteLine((Get-TermColor cyan "== $Title =="))
 }
 
 function Write-Data {

+ 59 - 0
skills/windows-ops/tests/run.sh

@@ -0,0 +1,59 @@
+#!/usr/bin/env bash
+# Self-test for windows-ops — terminal-design adoption + (where pwsh exists)
+# runtime ASCII purity of the shared framing.
+#
+# Static checks run everywhere. The dynamic PowerShell checks need `pwsh`; on a
+# host without it (e.g. a Linux CI runner) they skip cleanly, like the mac-ops /
+# net-ops suites gate on their platform.
+#
+# Usage:   bash tests/run.sh
+# Exit:    0 all pass (or dynamic checks skipped), 1 a failure
+
+set -uo pipefail
+
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+SKILL="$(dirname "$HERE")"
+SCRIPTS="$SKILL/scripts"
+COMMON="$SCRIPTS/_lib/common.ps1"
+TERMPS1="$SKILL/../_lib/term.ps1"
+
+PASS=0; FAIL=0
+ok(){ PASS=$((PASS+1)); printf '  PASS  %s\n' "$1"; }
+no(){ FAIL=$((FAIL+1)); printf '  FAIL  %s\n' "$1"; }
+
+echo "=== windows-ops self-test ==="
+
+# ── static: term.ps1 adoption (the lever for every script via common.ps1) ──
+echo "-- terminal design system --"
+grep -q 'term\.ps1' "$COMMON" && ok "common.ps1 sources shared term.ps1" || no "common.ps1 does not source term.ps1"
+[ -f "$TERMPS1" ] && ok "term.ps1 present" || no "term.ps1 missing at $TERMPS1"
+# Framing routes through term.ps1's Get-TermColor, not hand-rolled host coloring.
+grep -q 'Get-TermColor' "$COMMON" && ok "common.ps1 framing uses Get-TermColor" || no "common.ps1 does not use Get-TermColor"
+
+# ── dynamic: needs PowerShell; skip cleanly where absent ───────────────────
+PWSH=""
+for c in pwsh powershell; do command -v "$c" >/dev/null 2>&1 && { PWSH="$c"; break; }; done
+if [ -z "$PWSH" ]; then
+  echo "  (pwsh not found — skipping dynamic PowerShell checks)"
+  echo "=== $PASS passed, $FAIL failed ==="
+  [ "$FAIL" -eq 0 ] || exit 1
+  exit 0
+fi
+
+# Resolve a path PowerShell can open (convert MSYS -> Windows when needed).
+winpath() { if command -v cygpath >/dev/null 2>&1; then cygpath -w "$1"; else printf '%s' "$1"; fi; }
+WCOMMON="$(winpath "$COMMON")"
+
+# common.ps1 framing is ASCII-pure under TERM_ASCII=1 FORCE_COLOR=1 (principle #3).
+out="$(TERM_ASCII=1 FORCE_COLOR=1 "$PWSH" -NoProfile -Command ". '$WCOMMON'; Write-Section 'DISK'; Write-Log PASS 'ok'; Write-Log FAIL 'bad'; Write-Log WARN 'hot'" 2>&1)"
+if printf '%s' "$out" | LC_ALL=C grep -q '[^[:print:][:cntrl:]]'; then
+  no "common.ps1 framing emits non-ASCII under TERM_ASCII=1"
+else ok "common.ps1 framing pure ASCII under TERM_ASCII=1"; fi
+# Color is applied under FORCE_COLOR (ESC present).
+cout="$(FORCE_COLOR=1 "$PWSH" -NoProfile -Command ". '$WCOMMON'; Write-Log PASS 'ok'" 2>&1)"
+case "$cout" in *$'\033'*) ok "common.ps1 colorizes under FORCE_COLOR";; *) no "common.ps1 did not colorize under FORCE_COLOR";; esac
+# The [TAG] text stays literal/greppable (color is amplification, not the signal).
+case "$cout" in *'[PASS]'*) ok "common.ps1 keeps the [PASS] tag literal";; *) no "common.ps1 lost the [PASS] tag";; esac
+
+echo "=== $PASS passed, $FAIL failed ==="
+[ "$FAIL" -eq 0 ] || exit 1