diff --git a/.gitattributes b/.gitattributes index cbd8b087..c4606a3d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1 @@ -tools/pc-audit/Invoke-PcAudit.ps1 text working-tree-encoding=UTF-8-BOM +# .gitattributes diff --git a/tools/pc-audit/Invoke-PcAudit.ps1 b/tools/pc-audit/Invoke-PcAudit.ps1 new file mode 100644 index 00000000..e02570ae --- /dev/null +++ b/tools/pc-audit/Invoke-PcAudit.ps1 @@ -0,0 +1,861 @@ +#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 + Repo root to scan for env-like files. Default: detected from script location. + +.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 +$repoRoot = (Resolve-Path (Join-Path $scriptDir "..\..")).Path +if (-not $ProjectRoot) { $ProjectRoot = $repoRoot } +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 { + # IsInRole checks the *current token* (elevated or not under UAC). + # We also check group membership via SID so non-elevated admin sessions are detected. + $id = [Security.Principal.WindowsIdentity]::GetCurrent() + $p = New-Object Security.Principal.WindowsPrincipal($id) + if ($p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { return $true } + # Fallback: check if the user's identity is a member of BUILTIN\Administrators (S-1-5-32-544) + $adminSid = New-Object Security.Principal.SecurityIdentifier("S-1-5-32-544") + foreach ($g in $id.Groups) { + if ($g.Equals($adminSid)) { return $true } + } + 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*") { + "\\.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 = "UAC(EnableLUA)が無効に近い値" + 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" +} +$viewerPath = Join-Path $repoRoot "tools\pc-audit\report-viewer.html" +Write-Host "" +Write-Host "ブラウザで見る: エクスプローラーで次の HTML を開き、同じく out フォルダの JSON を選んでください。" +Write-Host " $viewerPath" +if ($ShareSafe) { + Write-Host "" + Write-Host "ShareSafe: .claude のフルパス・キーワードヒット・deny 関連ガイダンスを省略しています。" +}