#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 関連ガイダンスを省略しています。" }