posimai-root/tools/pc-audit/Invoke-PcAudit.ps1

896 lines
36 KiB
PowerShell
Raw Normal View History

#Requires -Version 5.1
<#
.SYNOPSIS
Read-only local PC audit (developer / AI-tooling focused). No writes except report output.
Note: node / npm / git / python are invoked with --version to collect toolchain versions.
.PARAMETER OutDir
Directory for reports (created if missing). Default: tools/pc-audit/out under repo root.
.PARAMETER ProjectRoot
Scan root for env-like files. In monorepo layout: repo root. In portable ZIP: defaults to USERPROFILE.
.PARAMETER Format
json | md | both
.PARAMETER ShareSafe
社外社内 IT へ渡すレポート向け.claude 系のフルパスキーワードヒットを出さずdeny 構成に触れる日本語ガイダンスも省略する
#>
[CmdletBinding()]
param(
[string]$OutDir = "",
[string]$ProjectRoot = "",
[ValidateSet("json", "md", "both")]
[string]$Format = "both",
[switch]$ShareSafe
)
$ErrorActionPreference = "SilentlyContinue"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$defaultRepoFromLayout = (Resolve-Path (Join-Path $scriptDir "..\..")).Path
$monorepoMarker = Join-Path $defaultRepoFromLayout "package.json"
$expectedScriptInRepo = Join-Path $defaultRepoFromLayout "tools\pc-audit\Invoke-PcAudit.ps1"
$currentScriptPath = $MyInvocation.MyCommand.Path
if (-not $currentScriptPath) { $currentScriptPath = (Join-Path $scriptDir "Invoke-PcAudit.ps1") }
$isMonorepoLayout = $false
if ((Test-Path -LiteralPath $monorepoMarker) -and (Test-Path -LiteralPath $expectedScriptInRepo)) {
try {
$expPath = (Resolve-Path -LiteralPath $expectedScriptInRepo).Path
$curPath = (Resolve-Path -LiteralPath $currentScriptPath).Path
if ($expPath -eq $curPath) { $isMonorepoLayout = $true }
} catch {}
}
if ($isMonorepoLayout) {
$repoRoot = $defaultRepoFromLayout
} else {
$repoRoot = $scriptDir
}
if (-not $ProjectRoot) {
if ($isMonorepoLayout) {
$ProjectRoot = $repoRoot
} else {
$ProjectRoot = $env:USERPROFILE
}
}
if (-not $OutDir) { $OutDir = Join-Path $scriptDir "out" }
if (-not (Test-Path -LiteralPath $OutDir)) {
New-Item -ItemType Directory -Path $OutDir -Force | Out-Null
}
$latestPathForPrev = Join-Path $OutDir "latest.json"
$previousPathForPrev = Join-Path $OutDir "previous.json"
$script:prevAuditObj = $null
if (Test-Path -LiteralPath $latestPathForPrev) {
try {
Copy-Item -LiteralPath $latestPathForPrev -Destination $previousPathForPrev -Force
$script:prevAuditObj = Get-Content -LiteralPath $previousPathForPrev -Raw -Encoding UTF8 | ConvertFrom-Json
} catch {
$script:prevAuditObj = $null
}
}
function Test-IsAdmin {
# Check 1: current process token is elevated (Run as Administrator)
$id = [Security.Principal.WindowsIdentity]::GetCurrent()
$p = New-Object Security.Principal.WindowsPrincipal($id)
if ($p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { return $true }
# Check 2: user is a member of BUILTIN\Administrators group (SID S-1-5-32-544).
# Under UAC the token is filtered so IsInRole returns false even for admin group members.
# Comparing .Value (string form of SID) is reliable regardless of token filtering.
$adminSidValue = "S-1-5-32-544"
if ($id.Groups.Value -contains $adminSidValue) { return $true }
# Check 3: fallback via net localgroup (catches edge cases where token groups differ)
try {
$currentUser = $env:USERNAME
$members = & net localgroup Administrators 2>$null
foreach ($line in $members) {
if ($line.Trim() -eq $currentUser) { return $true }
}
} catch {}
return $false
}
function Get-UacSummary {
$base = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System"
$get = {
param($name)
try { (Get-ItemProperty -LiteralPath $base -Name $name -ErrorAction Stop).$name } catch { $null }
}
[pscustomobject]@{
EnableLUA = & $get "EnableLUA"
ConsentPromptBehaviorAdmin = & $get "ConsentPromptBehaviorAdmin"
PromptOnSecureDesktop = & $get "PromptOnSecureDesktop"
}
}
function Get-CommandVersion {
param([string]$Name)
$cmd = Get-Command $Name -ErrorAction SilentlyContinue | Select-Object -First 1
if (-not $cmd) { return $null }
$ver = $null
try {
if ($cmd.CommandType -eq "Application") {
$out = & $cmd.Source --version 2>&1
if ($out) { $ver = ($out | Out-String).Trim().Split("`n")[0].Trim() }
}
} catch {}
[pscustomobject]@{
Path = $cmd.Source
Version = $ver
}
}
function Search-JsonLikeSettings {
param([string]$Path, [string[]]$Keywords)
if (-not (Test-Path -LiteralPath $Path)) { return @() }
try {
$raw = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop
$hits = @()
foreach ($k in $Keywords) {
if ($raw -match [regex]::Escape($k)) { $hits += $k }
}
return ($hits | Select-Object -Unique)
} catch { return @() }
}
function Test-EnvLikeFileName {
param([string]$Name)
if ($Name -eq '.env' -or $Name -eq '.env.local') { return $true }
if ($Name -like '.env.*') { return $true }
if ($Name -like '*.env') { return $true }
if ($Name -like '*.env.local') { return $true }
return $false
}
function Find-EnvLikeFiles {
param([string]$Root, [int]$MaxFiles = 200, [int]$MaxDepth = 8)
$script:envScanSkippedDirs = [System.Collections.Generic.List[string]]::new()
if (-not (Test-Path -LiteralPath $Root)) { return @() }
$excludeDirs = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
@(
'node_modules', '.git', 'dist', 'build', 'out', '.next', 'coverage',
'__pycache__', '.venv', 'venv', '.turbo', '.cache', '.output'
) | ForEach-Object { [void]$excludeDirs.Add($_) }
$found = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
$rootNorm = (Resolve-Path -LiteralPath $Root).Path.TrimEnd('\')
function Walk {
param([string]$Dir, [int]$Depth)
if ($found.Count -ge $MaxFiles) { return }
if ($Depth -gt $MaxDepth) { return }
try {
foreach ($f in @(Get-ChildItem -LiteralPath $Dir -File -Force -ErrorAction Stop)) {
if ($found.Count -ge $MaxFiles) { return }
if (Test-EnvLikeFileName $f.Name) {
$rel = $f.FullName.Substring($rootNorm.Length).TrimStart('\')
[void]$found.Add($rel)
}
}
foreach ($d in @(Get-ChildItem -LiteralPath $Dir -Directory -Force -ErrorAction Stop)) {
if ($excludeDirs.Contains($d.Name)) { continue }
Walk $d.FullName ($Depth + 1)
}
} catch {
$relSkip = if ($Dir.Length -gt $rootNorm.Length) { $Dir.Substring($rootNorm.Length).TrimStart('\') } else { '.' }
[void]$script:envScanSkippedDirs.Add($relSkip)
}
}
Walk $rootNorm 0
return @($found)
}
function Get-Listeners {
$rows = @()
try {
$rows = Get-NetTCPConnection -State Listen -ErrorAction Stop |
Select-Object @{n = "Address"; e = { $_.LocalAddress } }, @{n = "Port"; e = { $_.LocalPort } }, OwningProcess |
Sort-Object Address, Port
} catch {
# Fallback: netstat -ano. Column 1 is "Address:Port" (one combined field).
$net = netstat -ano | Select-String "LISTENING"
foreach ($line in $net) {
$parts = ($line -split '\s+') | Where-Object { $_ }
if ($parts.Length -ge 5) {
$localField = $parts[1] # e.g. "0.0.0.0:135" or "[::1]:5432"
$lastColon = $localField.LastIndexOf(':')
if ($lastColon -ge 0) {
$addrOnly = $localField.Substring(0, $lastColon).Trim('[', ']')
$portVal = 0
if ([int]::TryParse($localField.Substring($lastColon + 1), [ref]$portVal)) {
$rows += [pscustomobject]@{ Address = $addrOnly; Port = $portVal; OwningProcess = $parts[-1] }
}
}
}
}
}
return $rows
}
function Get-ListenerInsights {
param([object[]]$Rows)
$Rows = @($Rows)
$notablePorts = @{
22 = "SSH"
80 = "HTTP"
443 = "HTTPS"
135 = "RPC"
139 = "NetBIOS"
445 = "SMB"
3389 = "RDP"
5985 = "WinRM(HTTP)"
5986 = "WinRM(HTTPS)"
3306 = "MySQL"
5432 = "PostgreSQL"
6379 = "Redis"
27017 = "MongoDB"
}
$allIf = 0
$localOnly = 0
$specific = 0
$enriched = New-Object System.Collections.Generic.List[object]
foreach ($r in $Rows) {
if (-not $r) { continue }
$addr = [string]$r.Address
$port = 0
try { $port = [int]$r.Port } catch { }
$scope = "specific"
if ($addr -eq "0.0.0.0" -or $addr -eq "::" -or $addr -eq "*") {
$scope = "allInterfaces"
$allIf++
}
elseif (($addr -match "^127\.") -or $addr -eq "::1" -or $addr -eq "localhost") {
$scope = "localhost"
$localOnly++
}
else {
$specific++
}
$hint = $null
if ($notablePorts.ContainsKey($port)) { $hint = $notablePorts[$port] }
$enriched.Add([pscustomobject]@{
Address = $r.Address
Port = $r.Port
OwningProcess = $r.OwningProcess
BindScope = $scope
WellKnownHint = $hint
})
}
$sample = @($enriched.ToArray() | Select-Object -First 40)
return [pscustomobject]@{
Summary = [pscustomobject]@{
TotalListenRows = @($Rows).Count
AllInterfacesCount = $allIf
LocalhostOnlyCount = $localOnly
OtherAddressCount = $specific
}
NotesJa = @(
"BindScope: allInterfaces は 0.0.0.0 や :: など「全インターフェイス」で待ち受けている行です。LAN 向けに開いている可能性がありますVPN・開発ツールではよくあります",
"WellKnownHint はポート番号から一般的な用途を示す目安です。別アプリが同じ番号を使うこともあります。",
"安全・危険の断定はしません。気になる行があればエンジニアまたは社内 IT に相談してください。"
)
RowsEnrichedSample = $sample
}
}
function Get-AuditDiff {
param(
[System.Collections.IDictionary]$Current,
$Previous
)
if (-not $Previous) {
return [pscustomobject]@{
HasPrevious = $false
NoteJa = "前回の latest.json が無かったため、差分はありません。次回実行から、ひとつ前の実行結果との差分が付きます。"
}
}
function Get-TcpKeys {
param($Net)
if (-not $Net -or -not $Net.TcpListeners) { return @() }
$keys = New-Object System.Collections.Generic.HashSet[string]
foreach ($x in @($Net.TcpListeners)) {
if (-not $x) { continue }
[void]$keys.Add("$($x.Address):$($x.Port)")
}
return @($keys | Sort-Object)
}
function Get-RunNames {
param($RunArray)
if (-not $RunArray) { return @() }
$s = New-Object System.Collections.Generic.HashSet[string]
foreach ($x in @($RunArray)) {
if ($x.Name) { [void]$s.Add([string]$x.Name) }
}
return @($s | Sort-Object)
}
function Get-EnvSet {
param($Scan)
if (-not $Scan -or -not $Scan.EnvLikeRelPaths) { return @() }
return @($Scan.EnvLikeRelPaths | Sort-Object)
}
function Compare-StringSet {
param([string[]]$Old, [string[]]$New)
$oldH = @{}; foreach ($x in $Old) { $oldH[$x] = $true }
$newH = @{}; foreach ($x in $New) { $newH[$x] = $true }
$added = @($New | Where-Object { -not $oldH.ContainsKey($_) })
$removed = @($Old | Where-Object { -not $newH.ContainsKey($_) })
return [pscustomobject]@{ Added = $added; Removed = $removed }
}
$prevGen = $null
try { $prevGen = $Previous.generatedAtUtc } catch {}
$tcpOld = Get-TcpKeys $Previous.network
$tcpNew = Get-TcpKeys $Current['network']
$tcpDiff = Compare-StringSet -Old $tcpOld -New $tcpNew
$envOld = Get-EnvSet $Previous.projectScan
$envNew = Get-EnvSet $Current['projectScan']
$envDiff = Compare-StringSet -Old $envOld -New $envNew
$hkcuOld = Get-RunNames $Previous.registryRun.HKCU_Run
$hkcuNew = Get-RunNames $Current['registryRun'].HKCU_Run
$hkcuDiff = Compare-StringSet -Old $hkcuOld -New $hkcuNew
$hklmOld = Get-RunNames $Previous.registryRun.HKLM_Run
$hklmNew = Get-RunNames $Current['registryRun'].HKLM_Run
$hklmDiff = Compare-StringSet -Old $hklmOld -New $hklmNew
$adminCh = $null
try {
$a0 = $Previous.userContext.IsAdmin
$a1 = $Current['userContext'].IsAdmin
if ($a0 -ne $a1) {
$adminCh = [pscustomobject]@{ Previous = [bool]$a0; Current = [bool]$a1 }
}
} catch {}
$luaCh = $null
try {
$u0 = $Previous.uac.EnableLUA
$u1 = $Current['uac'].EnableLUA
if ($u0 -ne $u1) {
$luaCh = [pscustomobject]@{ Previous = $u0; Current = $u1 }
}
} catch {}
return [pscustomobject]@{
HasPrevious = $true
PreviousGeneratedAtUtc = $prevGen
TcpListeners = [pscustomobject]@{
AddedCount = @($tcpDiff.Added).Count
RemovedCount = @($tcpDiff.Removed).Count
AddedSample = @($tcpDiff.Added | Select-Object -First 40)
RemovedSample = @($tcpDiff.Removed | Select-Object -First 40)
}
EnvLikeRelPaths = [pscustomobject]@{
AddedCount = @($envDiff.Added).Count
RemovedCount = @($envDiff.Removed).Count
AddedSample = @($envDiff.Added | Select-Object -First 30)
RemovedSample = @($envDiff.Removed | Select-Object -First 30)
}
HKCU_Run = [pscustomobject]@{
AddedCount = @($hkcuDiff.Added).Count
RemovedCount = @($hkcuDiff.Removed).Count
AddedSample = @($hkcuDiff.Added | Select-Object -First 25)
RemovedSample = @($hkcuDiff.Removed | Select-Object -First 25)
}
HKLM_Run = [pscustomobject]@{
AddedCount = @($hklmDiff.Added).Count
RemovedCount = @($hklmDiff.Removed).Count
AddedSample = @($hklmDiff.Added | Select-Object -First 25)
RemovedSample = @($hklmDiff.Removed | Select-Object -First 25)
}
UserIsAdminChanged = $adminCh
UacEnableLuaChanged = $luaCh
NoteJa = "差分は「ひとつ前の実行previous.json と同内容のスナップショット)」との比較です。"
}
}
function Get-RegistryRunValues {
param([string]$HivePath)
if (-not (Test-Path -LiteralPath $HivePath)) { return @() }
try {
Get-ItemProperty -LiteralPath $HivePath -ErrorAction Stop |
Get-Member -MemberType NoteProperty |
Where-Object { $_.Name -notmatch '^(PSPath|PSParentPath|PSChildName|PSDrive|PSProvider)$' } |
ForEach-Object {
$name = $_.Name
$val = (Get-ItemProperty -LiteralPath $HivePath -Name $name -ErrorAction SilentlyContinue).$name
if ($val -is [string] -and $val.Length -gt 0) {
[pscustomobject]@{ Name = $name; Value = $val }
}
}
} catch { return @() }
}
function Get-SshPublicMetadata {
$sshDir = Join-Path $env:USERPROFILE ".ssh"
if (-not (Test-Path -LiteralPath $sshDir)) {
return [pscustomobject]@{ DirectoryExists = $false; Files = @() }
}
$files = Get-ChildItem -LiteralPath $sshDir -File -Force -ErrorAction SilentlyContinue |
ForEach-Object {
[pscustomobject]@{
Name = $_.Name
Length = $_.Length
LastWriteUtc = $_.LastWriteTimeUtc.ToString("o")
}
}
return [pscustomobject]@{ DirectoryExists = $true; Files = @($files) }
}
function Get-GitGlobalSummary {
$git = Get-Command git -ErrorAction SilentlyContinue
if (-not $git) {
return [pscustomobject]@{ GitAvailable = $false }
}
$helper = ""
$userName = ""
$userEmail = ""
try { $helper = (& git config --global credential.helper 2>$null).Trim() } catch {}
try { $userName = (& git config --global user.name 2>$null).Trim() } catch {}
try { $userEmail = (& git config --global user.email 2>$null).Trim() } catch {}
return [pscustomobject]@{
GitAvailable = $true
CredentialHelper = $helper
UserName = $userName
UserEmail = $userEmail
}
}
function Get-IdePaths {
param([switch]$ShareSafe)
$claudeKeys = @{
ClaudeUserSettings = $true; ClaudeUserLocal = $true; ProjectClaude = $true; ProjectClaudeLocal = $true
}
$pairs = @(
@{ Key = "CursorUserSettings"; Path = (Join-Path $env:APPDATA "Cursor\User\settings.json") },
@{ Key = "VsCodeUserSettings"; Path = (Join-Path $env:APPDATA "Code\User\settings.json") },
@{ Key = "CursorCliConfig"; Path = (Join-Path $env:USERPROFILE ".cursor\cli-config.json") },
@{ Key = "ClaudeUserSettings"; Path = (Join-Path $env:USERPROFILE ".claude\settings.json") },
@{ Key = "ClaudeUserLocal"; Path = (Join-Path $env:USERPROFILE ".claude\settings.local.json") },
@{ Key = "ProjectClaude"; Path = (Join-Path $ProjectRoot ".claude\settings.json") },
@{ Key = "ProjectClaudeLocal"; Path = (Join-Path $ProjectRoot ".claude\settings.local.json") }
)
$kw = @("mcpServers", "permissions", "dangerouslyAllowedPaths", "autoApprove", "enableTerminalIntegration")
$out = [ordered]@{}
foreach ($pair in $pairs) {
$p = $pair.Path
$exists = Test-Path -LiteralPath $p
$isClaude = $claudeKeys.ContainsKey($pair.Key)
if ($ShareSafe -and $isClaude) {
$pathOut = if ($pair.Key -like "Project*") {
"<project-root>\\.claude\\(settings file)"
}
else {
"%USERPROFILE%\\.claude\\(settings file)"
}
$out[$pair.Key] = [pscustomobject]@{
Path = $pathOut
Exists = $exists
KeywordHits = @()
ShareSafeRedacted = $true
}
}
else {
$hits = if ($exists) { Search-JsonLikeSettings -Path $p -Keywords $kw } else { @() }
$out[$pair.Key] = [pscustomobject]@{
Path = $p
Exists = $exists
KeywordHits = @($hits)
}
}
}
return $out
}
function Get-BitLockerBrief {
try {
$v = Get-BitLockerVolume -ErrorAction Stop |
Select-Object MountPoint, VolumeStatus, ProtectionStatus
return @($v)
} catch { return @() }
}
function Get-ClaudeDenyHint {
param([string]$Path)
if (-not (Test-Path -LiteralPath $Path)) { return $null }
try {
$raw = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop
if ($raw -notmatch '"deny"') {
return "no_deny_key"
}
if ($raw -match '"deny"\s*:\s*\[\s*\]') {
return "deny_empty_array"
}
return "deny_configured"
} catch { return $null }
}
function Get-JapaneseGuidance {
param(
[System.Collections.IDictionary]$A,
[switch]$ShareSafe
)
$L = New-Object System.Collections.Generic.List[object]
$L.Add([pscustomobject]@{
Level = "info"
Title = "このレポートの位置づけ"
Body = "ここに示すのは、取得した事実に基づく補足説明です。セキュリティ製品の代替や、確定的な安全診断ではありません。会社の規程や情報システム担当への確認が必要な場合は、そちらを優先してください。"
})
if ($ShareSafe) {
$L.Add([pscustomobject]@{
Level = "info"
Title = "共有安全モードShareSafe"
Body = "社外・社内 IT などへファイルを渡す想定で、.claude 配下のフルパスと、設定ファイルから拾うキーワード一覧を出していません。AI ツールの権限まわりに踏み込む説明も省略しています。詳細は手元のフル監査ShareSafe なし)でエンジニアが確認してください。"
})
}
if ($A['userContext'].IsAdmin -eq $true) {
$L.Add([pscustomobject]@{
Level = "warn"
Title = "管理者に近い権限で動いている可能性"
Body = "管理者グループに属していると検出されました。操作ミスや自動実行ツールAI のターミナルなど)の影響が広がりやすいため、日常作業は権限の狭いユーザーで行い、必要なときだけ管理者権限を使う運用が一般的に推奨されます。"
})
}
else {
$L.Add([pscustomobject]@{
Level = "ok"
Title = "管理者ロール"
Body = "この監査を実行したユーザーに、組み込みの Administrators ロールは検出されませんでした(常に正しいとは限りません)。"
})
}
$uac = $A['uac']
if ($null -ne $uac.EnableLUA -and [int]$uac.EnableLUA -eq 0) {
$L.Add([pscustomobject]@{
Level = "warn"
Title = "UACEnableLUAが無効に近い値"
Body = "EnableLUA が 0 に見えます。ユーザーアカウント制御が弱い構成だと、不正ソフトや誤操作の影響を受けやすくなることがあります。意図した設定か、社内ポリシーと照らして確認してください。"
})
}
if ($null -ne $uac.ConsentPromptBehaviorAdmin -and [int]$uac.ConsentPromptBehaviorAdmin -eq 0) {
$L.Add([pscustomobject]@{
Level = "warn"
Title = "UAC: 管理者昇格が自動承認(プロンプトなし)に設定されています"
Body = "ConsentPromptBehaviorAdmin が 0 の場合、管理者への昇格がダイアログなしで自動的に許可されます。EnableLUA が有効でも実質的に UAC の確認が省略される状態です。意図した設定か、社内ポリシーと照らして確認してください。"
})
}
$bl = @($A['bitLocker'])
if ($bl.Count -eq 0) {
$L.Add([pscustomobject]@{
Level = "info"
Title = "ディスク暗号化BitLockerの情報が取れませんでした"
Body = "権限やエディションにより取得できない場合があります。機器紛失時のリスクを減らすには暗号化が有効か、会社の基準と合わせて別途確認してください。"
})
}
$lis = @()
try {
$lis = @($A['network'].TcpListeners)
} catch { }
$diff = $null
try { $diff = $A['diffFromPrevious'] } catch { }
if ($diff -and $diff.HasPrevious -eq $true) {
$tch = $diff.TcpListeners
if ($tch -and ($tch.AddedCount -gt 0 -or $tch.RemovedCount -gt 0)) {
$L.Add([pscustomobject]@{
Level = "info"
Title = "前回実行から TCP 待ち受けの一覧が変わりました"
Body = "追加 $($tch.AddedCount) 行 / なくなった行 $($tch.RemovedCount)Address:Port の組み合わせで比較。VPN・Docker・開発サーバの起動停止で変わることがあります。詳細は diffFromPrevious を参照してください。"
})
}
$ed = $diff.EnvLikeRelPaths
if ($ed -and ($ed.AddedCount -gt 0 -or $ed.RemovedCount -gt 0)) {
$L.Add([pscustomobject]@{
Level = "info"
Title = "前回から env 系ファイル名の一覧が変わりました"
Body = "追加 $($ed.AddedCount) / なくなった $($ed.RemovedCount)(ファイル名のみの比較)。"
})
}
}
if ($lis.Count -gt 45) {
$L.Add([pscustomobject]@{
Level = "info"
Title = "待ち受けLISTENの数が多めです"
Body = "開いているポートが多いと、把握漏れや不要な公開につながることがあります。一覧をエンジニアまたは情報システムに見てもらい、不要なサービスがないか確認する価値があります。"
})
}
try {
$sum = $A['network'].ListenerInsights.Summary
if ($sum -and $sum.AllInterfacesCount -gt 15) {
$L.Add([pscustomobject]@{
Level = "info"
Title = "「全インターフェイス」向けの待ち受け行がまとまっています"
Body = "ListenerInsights の BindScope が allInterfaces の行が $($sum.AllInterfacesCount) 行あります。LAN 側から届く可能性があるソケットが含まれることがあり、開発用途でも把握しておくと安心です。"
})
}
} catch {}
$ssh = $A['ssh']
if ($ssh.DirectoryExists -and @($ssh.Files).Count -gt 0) {
$L.Add([pscustomobject]@{
Level = "info"
Title = "SSH 用のファイルが ~/.ssh にあります"
Body = "鍵や設定ファイルが存在します(中身はこのツールでは読んでいません)。バックアップの有無・他者への共有・漏えい時の対応を、社内ルールに沿って確認してください。"
})
}
$git = $A['git']
if ($git.GitAvailable -and $git.CredentialHelper.Length -gt 0) {
$L.Add([pscustomobject]@{
Level = "info"
Title = "Git の資格情報ヘルパーが設定されています"
Body = "credential.helper が設定されていると、認証情報が OS や別ストアに保存されることがあります。会社の開発ポリシーと矛盾がないか、必要なら窓口に確認してください。"
})
}
if (-not $ShareSafe) {
$projClaudeLocal = $A['ideAndAI']['ProjectClaudeLocal']
if ($projClaudeLocal -and $projClaudeLocal.Path -and ($projClaudeLocal.Path -notlike "<*") -and $projClaudeLocal.Exists) {
$hint = Get-ClaudeDenyHint -Path $projClaudeLocal.Path
if ($hint -eq "deny_empty_array") {
$L.Add([pscustomobject]@{
Level = "warn"
Title = "Claude Code の permissions.deny が空です(プロジェクト内)"
Body = '.claude/settings.local.json に "deny": [] のように空配列が含まれる場合、意図せずプロジェクト外への操作が許されやすくなることがあります。エンジニアと相談し、不要な操作は deny で明示する運用を検討してください。'
})
}
elseif ($hint -eq "no_deny_key") {
$L.Add([pscustomobject]@{
Level = "info"
Title = "Claude Code 設定に deny キーが見当たりません"
Body = "ファイル構成によっては問題ない場合もあります。プロジェクト外へ触らせたくない方針なら、deny の有無をエンジニアに確認してください。"
})
}
}
}
$envs = @($A['projectScan'].EnvLikeRelPaths)
if ($envs.Count -gt 0) {
$L.Add([pscustomobject]@{
Level = "info"
Title = "環境変数ファイルに相当する名前のファイルが見つかりました"
Body = '.env などのファイル名だけを列挙しています中身は読んでいません。API キーなどが入ることが多いので、Git に上げていないか、共有範囲が適切かをエンジニアと確認してください。'
})
}
$skipped = @($A['projectScan'].ScanSkippedDirs)
if ($skipped.Count -gt 0) {
$L.Add([pscustomobject]@{
Level = "info"
Title = "env 系スキャンで読み取れなかったフォルダがあります"
Body = "アクセス権限などの理由でスキャンできなかったフォルダが $($skipped.Count) 件ありました(詳細: projectScan.ScanSkippedDirs。そのフォルダに .env ファイルが存在しても、このレポートには表示されません。"
})
}
$claudeIdeKeys = @{ ClaudeUserSettings = $true; ClaudeUserLocal = $true; ProjectClaude = $true; ProjectClaudeLocal = $true }
$ide = $A['ideAndAI']
foreach ($k in @($ide.Keys)) {
$e = $ide[$k]
if (-not $e.Exists) { continue }
if ($ShareSafe -and $claudeIdeKeys.ContainsKey($k)) { continue }
if ($e.KeywordHits -contains "mcpServers") {
$L.Add([pscustomobject]@{
Level = "info"
Title = "設定ファイルに MCP 関連の記述があります ($k)"
Body = "MCP外部ツール連携は便利ですが、接続先や権限によっては想定外の操作につながることがあります。どの MCP が有効か、エンジニアに説明を求めると安心です。"
})
break
}
}
# PS 5.1: @($List[object]) throws type mismatch; ToArray() is safe.
return $L.ToArray()
}
$audit = [ordered]@{
generatedAtUtc = (Get-Date).ToUniversalTime().ToString("o")
machine = [pscustomobject]@{
Name = $env:COMPUTERNAME
OS = [System.Environment]::OSVersion.VersionString
}
userContext = [pscustomobject]@{
UserName = $env:USERNAME
UserProfile = $env:USERPROFILE
IsAdmin = (Test-IsAdmin)
}
uac = Get-UacSummary
bitLocker = Get-BitLockerBrief
registryRun = [pscustomobject]@{
HKCU_Run = @(Get-RegistryRunValues "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run")
HKLM_Run = @(Get-RegistryRunValues "HKLM:\Software\Microsoft\Windows\CurrentVersion\Run")
}
network = $( $tcpRows = @(Get-Listeners); [pscustomobject]@{
TcpListeners = $tcpRows
ListenerInsights = (Get-ListenerInsights -Rows $tcpRows)
} )
ssh = Get-SshPublicMetadata
git = Get-GitGlobalSummary
toolchain = [pscustomobject]@{
Node = Get-CommandVersion "node"
Npm = Get-CommandVersion "npm"
Git = Get-CommandVersion "git"
Python = Get-CommandVersion "python"
}
ideAndAI = (Get-IdePaths -ShareSafe:$ShareSafe)
projectScan = [pscustomobject]@{
Root = $ProjectRoot
EnvLikeRelPaths = @(Find-EnvLikeFiles -Root $ProjectRoot)
ScanSkippedDirs = @($script:envScanSkippedDirs)
Note = "Secret contents are never read. Only paths under -ProjectRoot."
}
}
$audit['diffFromPrevious'] = Get-AuditDiff -Current $audit -Previous $script:prevAuditObj
$disclaimerJa = "このレポートは補助的な説明であり、セキュリティ診断やコンプライアンス判定に代わるものではありません。"
$audit['meta'] = [ordered]@{
guidanceDisclaimerJa = $disclaimerJa
viewerRelativePath = "tools/pc-audit/report-viewer.html"
shareSafe = [bool]$ShareSafe
}
$audit['guidanceJa'] = @(Get-JapaneseGuidance -A $audit -ShareSafe:$ShareSafe)
$stamp = (Get-Date).ToUniversalTime().ToString("yyyyMMdd'T'HHmmss'Z'")
$jsonPath = Join-Path $OutDir "pc-audit-$stamp.json"
$mdPath = Join-Path $OutDir "pc-audit-$stamp.md"
$latestPath = Join-Path $OutDir "latest.json"
$json = $audit | ConvertTo-Json -Depth 12
Set-Content -LiteralPath $jsonPath -Value $json -Encoding UTF8
Copy-Item -LiteralPath $jsonPath -Destination $latestPath -Force
# Keep only the 10 most recent timestamped reports; delete older ones.
$maxReports = 10
@(Get-ChildItem -LiteralPath $OutDir -Filter "pc-audit-*.json" -File -ErrorAction SilentlyContinue |
Sort-Object Name -Descending | Select-Object -Skip $maxReports) |
ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force -ErrorAction SilentlyContinue }
@(Get-ChildItem -LiteralPath $OutDir -Filter "pc-audit-*.md" -File -ErrorAction SilentlyContinue |
Sort-Object Name -Descending | Select-Object -Skip $maxReports) |
ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force -ErrorAction SilentlyContinue }
function ConvertTo-MarkdownReport {
param($Obj)
$sb = [System.Text.StringBuilder]::new()
[void]$sb.AppendLine("# PC audit (read-only)")
[void]$sb.AppendLine("")
[void]$sb.AppendLine("Generated (UTC): $($Obj.generatedAtUtc)")
if ($Obj.meta -and $Obj.meta.shareSafe -eq $true) {
[void]$sb.AppendLine("")
[void]$sb.AppendLine("ShareSafe: true.claude 詳細は匿名化)")
}
[void]$sb.AppendLine("")
[void]$sb.AppendLine("## かんたん解説(非エンジニア向け)")
[void]$sb.AppendLine("")
[void]$sb.AppendLine($Obj.meta.guidanceDisclaimerJa)
[void]$sb.AppendLine("")
foreach ($g in @($Obj.guidanceJa)) {
$label = switch ($g.Level) {
"warn" { "注意" }
"ok" { "問題なさそう" }
Default { "情報" }
}
[void]$sb.AppendLine("### [$label] $($g.Title)")
[void]$sb.AppendLine("$($g.Body)")
[void]$sb.AppendLine("")
}
[void]$sb.AppendLine("## User / privilege")
[void]$sb.AppendLine("- User: ``$($Obj.userContext.UserName)``")
[void]$sb.AppendLine("- Admin: ``$($Obj.userContext.IsAdmin)``")
[void]$sb.AppendLine("")
[void]$sb.AppendLine("## UAC (registry)")
[void]$sb.AppendLine("``````")
[void]$sb.AppendLine(($Obj.uac | ConvertTo-Json -Compress))
[void]$sb.AppendLine("``````")
[void]$sb.AppendLine("")
[void]$sb.AppendLine("## TCP listeners (sample)")
$lis = @()
if ($Obj.network.TcpListeners) {
$lis = @($Obj.network.TcpListeners | Select-Object -First 40)
}
[void]$sb.AppendLine("| Address | Port | PID |")
[void]$sb.AppendLine("|---------|------|-----|")
foreach ($r in $lis) {
[void]$sb.AppendLine("| $($r.Address) | $($r.Port) | $($r.OwningProcess) |")
}
[void]$sb.AppendLine("")
if ($Obj.network.ListenerInsights -and $Obj.network.ListenerInsights.Summary) {
$ins = $Obj.network.ListenerInsights.Summary
[void]$sb.AppendLine("### LISTEN の見方(要約)")
[void]$sb.AppendLine("- 全インターフェイス待ち受け行数: ``$($ins.AllInterfacesCount)`` / localhost のみ行数: ``$($ins.LocalhostOnlyCount)`` / 合計行: ``$($ins.TotalListenRows)``")
[void]$sb.AppendLine("")
}
if ($Obj.diffFromPrevious) {
[void]$sb.AppendLine("## 前回実行からの差分(概要)")
$df = $Obj.diffFromPrevious
if (-not $df.HasPrevious) {
[void]$sb.AppendLine($df.NoteJa)
}
else {
[void]$sb.AppendLine("- 比較元(前回の実行時刻 UTC: ``$($df.PreviousGeneratedAtUtc)``")
[void]$sb.AppendLine("- TCP Address:Port: 追加 $($df.TcpListeners.AddedCount) / 削除 $($df.TcpListeners.RemovedCount)")
[void]$sb.AppendLine("- env 系パス: 追加 $($df.EnvLikeRelPaths.AddedCount) / 削除 $($df.EnvLikeRelPaths.RemovedCount)")
[void]$sb.AppendLine("- HKCU Run 名: 追加 $($df.HKCU_Run.AddedCount) / 削除 $($df.HKCU_Run.RemovedCount)")
[void]$sb.AppendLine("- HKLM Run 名: 追加 $($df.HKLM_Run.AddedCount) / 削除 $($df.HKLM_Run.RemovedCount)")
if ($df.UserIsAdminChanged) {
[void]$sb.AppendLine("- 管理者ロールの検出結果が前回と変わりました(要確認)")
}
if ($df.UacEnableLuaChanged) {
[void]$sb.AppendLine("- UAC EnableLUA の値が前回と変わりました(要確認)")
}
}
[void]$sb.AppendLine("")
}
[void]$sb.AppendLine("## SSH directory (names only)")
[void]$sb.AppendLine("``````")
[void]$sb.AppendLine(($Obj.ssh | ConvertTo-Json -Depth 6 -Compress))
[void]$sb.AppendLine("``````")
[void]$sb.AppendLine("")
[void]$sb.AppendLine("## Git global (no remote URLs)")
[void]$sb.AppendLine("``````")
[void]$sb.AppendLine(($Obj.git | ConvertTo-Json -Compress))
[void]$sb.AppendLine("``````")
[void]$sb.AppendLine("")
[void]$sb.AppendLine("## Toolchain")
[void]$sb.AppendLine("``````")
[void]$sb.AppendLine(($Obj.toolchain | ConvertTo-Json -Depth 4 -Compress))
[void]$sb.AppendLine("``````")
[void]$sb.AppendLine("")
[void]$sb.AppendLine("## IDE / AI settings paths")
foreach ($k in @($Obj.ideAndAI.Keys)) {
$e = $Obj.ideAndAI[$k]
[void]$sb.AppendLine("- **$k**: exists=$($e.Exists); keyword hits: $($e.KeywordHits -join ', ')")
[void]$sb.AppendLine(" - ``$($e.Path)``")
}
[void]$sb.AppendLine("")
[void]$sb.AppendLine("## Project env-like files (relative)")
[void]$sb.AppendLine("- Root: ``$($Obj.projectScan.Root)``")
foreach ($p in $Obj.projectScan.EnvLikeRelPaths) {
[void]$sb.AppendLine("- ``$p``")
}
[void]$sb.AppendLine("")
[void]$sb.AppendLine("## Run keys (HKCU / HKLM)")
[void]$sb.AppendLine("``````")
[void]$sb.AppendLine(($Obj.registryRun | ConvertTo-Json -Depth 6 -Compress))
[void]$sb.AppendLine("``````")
return $sb.ToString()
}
if ($Format -eq "md" -or $Format -eq "both") {
$md = ConvertTo-MarkdownReport -Obj $audit
Set-Content -LiteralPath $mdPath -Value $md -Encoding UTF8
}
Write-Host "PC audit complete."
Write-Host " JSON: $jsonPath"
Write-Host " latest.json: $latestPath"
if ($Format -eq "md" -or $Format -eq "both") {
Write-Host " MD: $mdPath"
}
if ($isMonorepoLayout) {
$viewerPath = Join-Path $repoRoot "tools\pc-audit\report-viewer.html"
} else {
$viewerPath = Join-Path $scriptDir "report-viewer.html"
}
Write-Host ""
Write-Host "ブラウザで見る: エクスプローラーで次の HTML を開き、同じく out フォルダの JSON を選んでください。"
Write-Host " $viewerPath"
if ($ShareSafe) {
Write-Host ""
Write-Host "ShareSafe: .claude のフルパス・キーワードヒット・deny 関連ガイダンスを省略しています。"
}