<# .SYNOPSIS sosig.site Windows CVE 점검 에이전트 v3 (2026-10-17) .DESCRIPTION 이 PC 의 Windows 빌드와 Microsoft 앱(Edge, .NET, ASP.NET Core, .NET Framework, Visual Studio, VS Code, PowerShell 7, Office 클릭투런)의 설치 버전을 읽고, sosig.site 에서 제품마다 CVE 별 수정 버전 목록(Microsoft MSRC 보안 업데이트 공개 자료, Office 는 보안 업데이트 페이지)을 서명된 요청으로 받아, 판정은 이 PowerShell 에서 한다(DESIGN.md §3.2 '줄' 규칙). v3: 설치 기록이 있는 다른 회사 프로그램(제거 프로그램 목록)을 서버의 매핑표와 맞추고(/win/apps/match), 맞은 앱마다 NVD CPE 버전 범위(/win/apps/bundle)를 받아 같은 방식으로 여기서 판정한다(DESIGN.md §8.4). 서버에 v3 API 가 없으면 v3 만 건너뛰고 v2 는 그대로 한다. -NoApps 로 끌 수 있다. 3.1(2026-10-17, DESIGN.md §4 S14·S15-2): 대조 요청에 basis nvd·msrc 를 넣어 Store 앱 등 Microsoft PC 앱(msapp)도 MSRC 수정 목록으로 판정하고(이 Windows 판의 MSRC 제품 이름을 고름), 서버가 붙인 판 묶음 지원 기간(cycle)과 공식 내려받기 주소·winget ID(links)를 결과 문서에 옮긴다. 지원 끝·곧 끝남·보안 수정만 판정은 여기서 오늘 날짜로 한다. 3.2(2026-10-17, DESIGN.md §4 S23): -OutFile 이 있으면 취약으로 판정한 CVE 마다 왜 위험한지(CWE·CVSS 벡터·NVD 설명 원문·MSRC 악용 상태)를 서버에 묻고(/win/cve/info, 서명 확인) 결과 문서의 vulnerable 항목에 info 로 붙인다. 판정·보고 행에는 쓰지 않고, 받지 못해도 점검은 그대로다. 3.4(2026-10-23): 서버 주소를 https://sosig.site 로 옮겼다(sosig.shop 은 2026-11-28 만료 뒤 연장하지 않음). 기본값·User-Agent 만 바뀌었고 판정·보고는 3.3 과 같다. 설정 파일의 server 줄도 sosig.site 로 바꾼다. Windows 취약점은 누적 업데이트로 고쳐지므로 설치된 KB 목록이 아니라 빌드 번호가 근거다 (누적 업데이트는 앞 KB 를 대체해 Get-HotFix 에는 마지막 것만 남는다). - 읽기만 한다. 설정·파일을 바꾸지 않고 관리자 권한도 필요 없다. - 서버가 준 목록은 응답 서명(X-Signature)을 확인한 뒤에만 쓴다. -FixtureDir 일 때만 서명 없는 시험 자료를 읽고, 그때는 보고하지 않는다. - 한 CVE 가 여러 구성요소(OS·앱)에 걸리면 한 줄로 합쳐 보낸다(가장 나쁜 판정). - Windows PowerShell 5.1 과 PowerShell 7 에서 돈다. 설정 파일: %USERPROFILE%\.config\sosig-agent.conf (리눅스 에이전트와 같은 형식) host_id = h-xxxxxxxxxxxx secret = 발급받은_값 server = https://sosig.site .EXAMPLE powershell -NoProfile -ExecutionPolicy Bypass -File sosig_win_agent.ps1 -DryRun 판정까지 하고 보고하지 않는다. .EXAMPLE powershell -NoProfile -ExecutionPolicy Bypass -File sosig_win_agent.ps1 -FixtureDir fixtures 서버 대신 fixtures\ 의 시험 자료로 판정한다(서명 확인 없음, 보고하지 않음). .EXAMPLE ... -FixtureDir fixtures -SimulateVersion 'vs=17.14.42','dotnet:sdk=10.0.113' 설치 버전을 바꿔 판정을 시험한다. 없는 앱은 'office_c2r=16.0.20326.20232@Office C2R · Current Channel' 처럼 제품 이름을 붙여 만든다. 다른 회사 프로그램은 'app:chrome=154.0.7000.1' 처럼 매핑표의 app_key 로 준다. #> param( [string]$Config = (Join-Path $HOME '.config\sosig-agent.conf'), [switch]$DryRun, [switch]$Quiet, [string]$FixtureDir, [string[]]$SimulateVersion, [string]$OutFile, [switch]$Detail, [switch]$NoApps ) $ErrorActionPreference = 'Stop' Set-StrictMode -Version 2 $AgentVersion = '3.4' $UserAgent = "sosig-win-agent/$AgentVersion (+https://sosig.site/vuln)" $ReportChunk = 400 # 서버 MAX_RESULTS(500) 아래로 나눠 보낸다 $TextLimit = 500 # 서버가 detected·reason 을 500자에서 자른다 $AppFamilies = @('edge', 'dotnet', 'aspnet', 'netfx', 'vs', 'vscode', 'pwsh', 'office_c2r') $VerdictRank = @{ '양호' = 1; '점검불가' = 2; '취약' = 3 } $VsYear = @{ 15 = '2017'; 16 = '2019'; 17 = '2022'; 18 = '2026' } # 판이 여러 개인 가족에서 -SimulateVersion 이 종류를 안 적었을 때 고칠 구성요소 $DefaultKind = @{ dotnet = 'runtime'; netfx = 'v4' } # 제품 목록에 설치한 판의 제품이 없을 때 바로 위 판을 찾는 이름 꼴(그룹 1 = major.minor) $NextPattern = @{ dotnet = '^\.NET (\d+\.\d+) installed on Windows$' aspnet = '^ASP\.NET Core (\d+\.\d+)$' pwsh = '^PowerShell (\d+\.\d+)$' } # Office 클릭투런 채널: CDNBaseUrl 끝 GUID → 서버 제품 이름의 채널 (DESIGN.md §3.1 표) $OfficeChannels = @{ '492350f6-3a01-4f97-b9c0-c7c6ddf67d60' = 'Current Channel' '55336b82-a18d-4dd6-b5f6-9e5095c314a6' = 'Monthly Enterprise Channel' '7ffbc6bf-bc32-4f92-8982-f9dd17fd3114' = 'Semi-Annual Enterprise Channel' 'b8f9b850-328d-4355-9145-c59439a0c4cf' = 'Semi-Annual Enterprise Channel (Preview)' '7983bac0-e531-40cf-be00-fd24fe66619c' = 'Office LTSC 2024 Volume Licensed' '5030841d-c919-4594-8d2d-84ae4f96e58e' = 'Office LTSC 2021 Volume Licensed' 'f2e724c1-748f-4b47-8fb8-8e0d210e9208' = 'Office 2019 Volume Licensed' } # 보안 업데이트 페이지에 없는 미리보기 채널 $OfficePreviewChannels = @{ '64256afe-f5d9-4f86-8936-8840a6a4f5be' = 'Current Channel (Preview)' '5440fd1f-7ecb-4221-8110-145efaa6372f' = 'Beta Channel' } # v3: 설치 기록(제거 프로그램 목록) 세 곳. 32비트 PowerShell 에서는 앞 두 곳이 같은 키를 가리켜 겹치는 것은 하나로 센다. $UninstallPlaces = @( @{ Path = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall'; Scope = 'machine64' }, @{ Path = 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall'; Scope = 'machine32' }, @{ Path = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall'; Scope = 'user' } ) $MaxPrograms = 1000 # 서버 /win/apps/match 한도 # 3.1: 대조 요청의 basis — msrc 를 넣은 PC 에만 서버가 Store 앱 등 MSRC 로 판정할 Microsoft 앱(msapp)을 준다(PROTOCOL.md §5.0-S14·S15) $MatchBasis = @('nvd', 'msrc') # 판 묶음 지원 기간: 이 날 수 안에 지원이 끝나면 '곧 끝남'(묶음이 몇 주마다 끝나는 rapid 제품은 띄우지 않음, DESIGN.md §4 S14) $EolSoonDays = 183 # 지원 기간을 판정할 오늘(시험이 날짜를 고정할 수 있게 변수로 둔다) $CycleToday = (Get-Date).Date # 3.2: 취약 CVE 풀이 자료(/win/cve/info) 한 번에 묻는 CVE 수, 설명 원문 길이, 악용 평가 글 길이 (DESIGN.md §4 S23) $CveInfoChunk = 500 $CveInfoDescMax = 600 $CveInfoAssessMax = 80 $CveInfoCweMax = 40 # Microsoft 가 게시한 것 중 v2(MSRC)가 맡는 프로그램 이름 — 시험 자료 모드의 매칭 흉내에서 'MSRC(v2)로 점검'으로 보인다 $MsrcAppNames = '^(Microsoft Edge$|Microsoft Visual Studio Code|Visual Studio (Build Tools|Community|Professional|Enterprise) 20\d\d|Microsoft \.NET (SDK|Runtime|Host)|Microsoft ASP\.NET Core|Microsoft Windows Desktop Runtime|PowerShell 7|Microsoft 365|Microsoft Office)' $script:Http = $null $script:ExitCode = 0 function Say([string]$Message) { if (-not $Quiet) { Write-Host $Message } } function Read-AgentConfig([string]$Path) { $cfg = @{ server = 'https://sosig.site'; host_id = $env:SOSIG_HOST_ID; secret = $env:SOSIG_SECRET } if ($env:SOSIG_SERVER) { $cfg.server = $env:SOSIG_SERVER } if (Test-Path -LiteralPath $Path) { foreach ($line in Get-Content -LiteralPath $Path -Encoding UTF8) { if ($line -match '^\s*(host_id|secret|server)\s*[=:]\s*(.+?)\s*$') { $cfg[$Matches[1]] = $Matches[2] } } } if (-not $cfg.host_id -or -not $cfg.secret) { throw "설정 없음: $Path 에 host_id 와 secret 을 두세요" } $cfg } function Get-OsFacts { $cv = Get-ItemProperty -LiteralPath 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' $prop = { param($name) if ($cv.PSObject.Properties[$name]) { [string]$cv.$name } else { '' } } $display = & $prop 'DisplayVersion' if (-not $display) { $display = & $prop 'ReleaseId' } # 20H2 이전 판은 ReleaseId(1809 등)만 있다 # 32비트 PowerShell 로 실행돼도 OS 의 아키텍처를 본다 $arch = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE } [ordered]@{ product_name = & $prop 'ProductName' # Windows 11 에서도 'Windows 10 Pro' 로 남아 있어 판 구분에 쓰지 않는다 edition = & $prop 'EditionID' installation_type = & $prop 'InstallationType' # Client · Server · Server Core display_version = $display build = [int](& $prop 'CurrentBuildNumber') ubr = [int](& $prop 'UBR') arch = $arch hostname = $env:COMPUTERNAME } } # MSRC 보안 업데이트의 제품 이름(FullProductName)으로 바꾼다. 모르는 판이면 $null. function Get-MsrcProduct($Os) { if ($Os.installation_type -like 'Server*') { $name = switch ($Os.build) { 9200 { 'Windows Server 2012' } 9600 { 'Windows Server 2012 R2' } 14393 { 'Windows Server 2016' } 17763 { 'Windows Server 2019' } 20348 { 'Windows Server 2022' } 25398 { 'Windows Server 2022, 23H2 Edition' } 26100 { 'Windows Server 2025' } default { $null } } if ($name -and $Os.installation_type -eq 'Server Core') { $name += ' (Server Core installation)' } return $name } $archText = switch ($Os.arch) { 'AMD64' { 'x64-based' } 'ARM64' { 'ARM64-based' } 'x86' { '32-bit' } default { $null } } if (-not $archText -or -not $Os.display_version) { return $null } $family = if ($Os.build -ge 22000) { 'Windows 11' } else { 'Windows 10' } # 22000 = Windows 11 첫 판 "$family Version $($Os.display_version) for $archText Systems" } # ── 작은 도우미 ───────────────────────────────────────────── # 레지스트리 값 하나. 키나 값이 없으면 $null (StrictMode 2 에서는 없는 속성을 읽으면 오류라 GetValue 를 쓴다) function Get-RegValue([string]$Path, [string]$Name) { $key = Get-Item -LiteralPath $Path -ErrorAction SilentlyContinue if (-not $key) { return $null } $key.GetValue($Name) } # JSON 객체의 있을 수도 없을 수도 있는 속성 function Get-Prop($Object, [string]$Name) { if ($null -eq $Object) { return $null } $p = $Object.PSObject.Properties[$Name] if ($p) { $p.Value } else { $null } } # 버전 글자 → 네 자리 [version]. [version] 은 자리 수가 다르면 10.0.13 < 10.0.13.0 이 되므로 빈 자리를 0 으로 채운다. # 미리보기 표시(10.0.0-rc.1, 11.0 RC2)나 주소처럼 숫자 버전이 아니면 $null. function ConvertTo-Ver([string]$Text) { if (-not $Text) { return $null } $v = $null if (-not [version]::TryParse($Text.Trim(), [ref]$v)) { return $null } New-Object version $v.Major, $v.Minor, ([Math]::Max($v.Build, 0)), ([Math]::Max($v.Revision, 0)) } # MSRC fixed_build 한 칸 → 조각들 (DESIGN.md §3.2-1, 형식 예는 fixtures\formats.json) # 나누기: 쉼표·세미콜론·&·and(대소문자 무관). 조각마다 앞 v 를 떼고, 빗금이 있으면 앞 값(112.0.5615.49/50 → …49) # 읽은 값: ^\d+(\.\d+){1,3}$ 에 맞는 것 → V. 나머지(10.0.0-rc.1, 11.0 RC1, 다섯 자리 오타 4.8.1.09277.02)는 V 가 $null 인 # '읽지 못한 조각'이고, 앞쪽 숫자(Prefix)로 어느 구성요소 몫인지 가린다. 주소는 수정 버전이 아니라 버린다. function Split-FixedBuild([string]$Text) { if (-not $Text) { return } foreach ($raw in [regex]::Split($Text, '\s*(?:,|;|&|\band\b)\s*', 'IgnoreCase')) { $raw = $raw.Trim() if (-not $raw -or $raw -match '^https?://') { continue } $part = ($raw -replace '^[vV]+', '').Split('/')[0] $v = $null if ($part -match '^\d+(\.\d+){1,3}$') { $v = ConvertTo-Ver $part } if ($v) { [pscustomobject]@{ Text = $part; V = $v; Prefix = $v }; continue } $m = [regex]::Match($raw, '^[vV]*(\d+(?:\.\d+){1,2})') $prefix = $null if ($m.Success) { $prefix = ConvertTo-Ver $m.Groups[1].Value } [pscustomobject]@{ Text = $raw; V = $null; Prefix = $prefix } } } function Get-FileVersionText([string]$Path) { if (-not (Test-Path -LiteralPath $Path)) { return $null } $vi = (Get-Item -LiteralPath $Path).VersionInfo # FileVersion 글자에는 'built by: …' 가 붙어 있어 숫자 자리를 쓴다 '{0}.{1}.{2}.{3}' -f $vi.FileMajorPart, $vi.FileMinorPart, $vi.FileBuildPart, $vi.FilePrivatePart } function Limit-Text([string]$Text) { if ($Text.Length -le $TextLimit) { return $Text } $Text.Substring(0, $TextLimit - 1) + '…' } # ── 구성요소 (DESIGN.md §3.1) ────────────────────────────── # 구성요소 하나 = 서버에 물을 MSRC 제품 하나 + 비교할 설치 버전 하나. 제품이 비어 있으면 Note 의 이유로 점검하지 않는다. function New-Component { param([string]$Family, [string]$Product = '', [string]$Name, [string]$VersionText = '', [string]$Kind = '', [string]$Source = '', [string]$Note = '') [pscustomobject]@{ Family = $Family Product = $Product Name = $Name Version = (ConvertTo-Ver $VersionText) # 비교할 수 없는 판(미리보기 등)이면 $null VersionText = $VersionText Kind = $Kind Source = $Source Note = $Note Cycle = $null # S16: 제품 이름의 판 묶음 지원 기간(Add-ComponentCycles), 판정에는 쓰지 않음 CycleSource = '' # 'cycle' 또는 'cycle_enterprise'(OS) } } function Get-OsComponent($Os, [string]$Product) { $text = '10.0.{0}.{1}' -f $Os.build, $Os.ubr $note = '' if (-not $Product) { $note = '이 Windows 판은 MSRC 제품 이름으로 바꿀 수 없음' } $c = New-Component -Family os -Product $Product -Name 'OS 빌드' -VersionText $text -Note $note ` -Source 'HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion' # S16: 지원 기간(가정·Pro 용 cycle / Enterprise·Education 용 cycle_enterprise)을 고를 EditionID Add-Member -InputObject $c -NotePropertyName Edition -NotePropertyValue (Get-OsEdition $Os) $c } function Get-EdgeComponent { $guid = '{56EB18F8-B008-4CBD-B6D2-8C97FE7E9062}' # Edge 안정판 foreach ($k in @("HKLM:\SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\$guid", "HKLM:\SOFTWARE\Microsoft\EdgeUpdate\Clients\$guid")) { $pv = [string](Get-RegValue $k 'pv') if ($pv) { return (New-Component -Family edge -Product 'Microsoft Edge (Chromium-based)' -Name 'Edge' -VersionText $pv -Source "$k pv") } } if (${env:ProgramFiles(x86)}) { $exe = Join-Path ${env:ProgramFiles(x86)} 'Microsoft\Edge\Application\msedge.exe' if (Test-Path -LiteralPath $exe) { New-Component -Family edge -Product 'Microsoft Edge (Chromium-based)' -Name 'Edge' -Source $exe ` -VersionText ([string](Get-Item -LiteralPath $exe).VersionInfo.ProductVersion) } } } function Get-DotNetRoots { $pf = if ($env:ProgramW6432) { $env:ProgramW6432 } else { $env:ProgramFiles } if ($pf) { [pscustomobject]@{ Path = (Join-Path $pf 'dotnet'); Suffix = '' } } if (${env:ProgramFiles(x86)}) { [pscustomobject]@{ Path = (Join-Path ${env:ProgramFiles(x86)} 'dotnet'); Suffix = ' (x86)' } } } function Get-VersionDirs([string]$Path) { if (-not (Test-Path -LiteralPath $Path)) { return } foreach ($d in Get-ChildItem -LiteralPath $Path -Directory) { [pscustomobject]@{ Text = $d.Name; V = (ConvertTo-Ver $d.Name); Path = $d.FullName } } } # 정식판이 미리보기판보다 낫고, 같은 종류면 높은 판 function Test-Better($A, $B) { if ($A.V -and -not $B.V) { return $true } if ($B.V -and -not $A.V) { return $false } if ($A.V) { return ($A.V -gt $B.V) } [string]::CompareOrdinal($A.Text, $B.Text) -gt 0 } # 버전 폴더들 → 묶음(major.minor, -Band 면 SDK 밴드까지)마다 가장 나은 하나 function Select-HighestPerGroup($Dirs, [switch]$Band) { $groups = @{} foreach ($d in $Dirs) { $m = [regex]::Match($d.Text, '^(\d+)\.(\d+)\.(\d+)') if (-not $m.Success) { continue } $key = '{0}.{1}' -f $m.Groups[1].Value, $m.Groups[2].Value if ($Band) { $key += '.' + [Math]::Floor([int]$m.Groups[3].Value / 100) } if ($null -eq $groups[$key] -or (Test-Better $d $groups[$key])) { $groups[$key] = $d } } foreach ($k in ($groups.Keys | Sort-Object { [version]$_ })) { [pscustomobject]@{ Key = $k; Dir = $groups[$k] } } } # .NET 은 설치 폴더로 읽는다. 레지스트리(dotnet\Setup\InstalledVersions)에는 지운 판이 남아 거짓 취약이 나온다. function Get-DotNetComponents($Roots) { if ($null -eq $Roots) { $Roots = @(Get-DotNetRoots) } foreach ($root in $Roots) { if (-not (Test-Path -LiteralPath $root.Path)) { continue } $shared = Join-Path $root.Path 'shared' # 런타임: NETCore.App 과 WindowsDesktop.App 은 같은 제품(.NET M.0)이다. 같은 major 는 높은 판이 쓰이고, 둘이 다르면 낮은 쪽으로 본다 $runtime = @{} foreach ($fw in 'Microsoft.NETCore.App', 'Microsoft.WindowsDesktop.App') { foreach ($g in @(Select-HighestPerGroup @(Get-VersionDirs (Join-Path $shared $fw)))) { if ($null -eq $runtime[$g.Key] -or (Test-Better $runtime[$g.Key] $g.Dir)) { $runtime[$g.Key] = $g.Dir } } } foreach ($k in ($runtime.Keys | Sort-Object { [version]$_ })) { New-Component -Family dotnet -Kind runtime -Product ".NET $k installed on Windows" -Name ".NET 런타임$($root.Suffix)" ` -VersionText $runtime[$k].Text -Source $runtime[$k].Path } foreach ($g in @(Select-HighestPerGroup @(Get-VersionDirs (Join-Path $shared 'Microsoft.AspNetCore.App')))) { New-Component -Family aspnet -Kind runtime -Product "ASP.NET Core $($g.Key)" -Name "ASP.NET Core 런타임$($root.Suffix)" ` -VersionText $g.Dir.Text -Source $g.Dir.Path } # SDK 는 밴드(10.0.1xx·10.0.4xx)마다 따로 고쳐진다 foreach ($g in @(Select-HighestPerGroup @(Get-VersionDirs (Join-Path $root.Path 'sdk')) -Band)) { $mm = ($g.Key -split '\.')[0..1] -join '.' New-Component -Family dotnet -Kind sdk -Product ".NET $mm installed on Windows" -Name ".NET SDK$($root.Suffix)" ` -VersionText $g.Dir.Text -Source $g.Dir.Path } } } # .NET Framework 4.x 판: NDP v4 Full 의 Release 값 function Get-NetFx4Release([int]$Release) { if ($Release -ge 533320) { return '4.8.1' } if ($Release -ge 528040) { return '4.8' } if ($Release -ge 461808) { return '4.7.2' } if ($Release -ge 461308) { return '4.7.1' } if ($Release -ge 460798) { return '4.7' } if ($Release -ge 394802) { return '4.6.2' } $null } function Get-NetFxFacts { $root = Join-Path $env:WINDIR 'Microsoft.NET\Framework64' if (-not (Test-Path -LiteralPath $root)) { $root = Join-Path $env:WINDIR 'Microsoft.NET\Framework' } $f4 = Join-Path $root 'v4.0.30319\mscorlib.dll' $f2 = Join-Path $root 'v2.0.50727\mscorlib.dll' # .NET Framework 3.5 를 켜면 생긴다(3.5 는 2.0 CLR 위에서 돈다) $release = Get-RegValue 'HKLM:\SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full' 'Release' $install35 = Get-RegValue 'HKLM:\SOFTWARE\Microsoft\NET Framework Setup\NDP\v3.5' 'Install' $fx4 = $null if ($release) { $fx4 = Get-NetFx4Release ([int]$release) } [pscustomobject]@{ Fx4 = $fx4; File4 = (Get-FileVersionText $f4); Path4 = $f4 Has35 = ([int]$install35 -eq 1 -and (Test-Path -LiteralPath $f2)); File2 = (Get-FileVersionText $f2); Path2 = $f2 } } # .NET Framework 제품 이름의 'on ' 꼬리 중 이 PC 에 맞는 것들 function Get-NetFxOsTails($Os) { if ($Os.installation_type -like 'Server*') { $name = Get-MsrcProduct $Os if (-not $name) { return } $base = $name -replace ' \(Server Core installation\)$', '' $base if ($name -ne $base) { $name } return } if (-not $Os.display_version) { return } $family = if ($Os.build -ge 22000) { 'Windows 11' } else { 'Windows 10' } "$family version $($Os.display_version)" $archText = switch ($Os.arch) { 'AMD64' { 'x64-based' } 'ARM64' { 'ARM64-based' } 'x86' { '32-bit' } default { $null } } if ($archText) { "$family Version $($Os.display_version) for $archText Systems" } } # 제품 목록에서 이 PC 의 OS 에 맞는 .NET Framework 제품을 고르고, 이름에 든 판(3.5 AND 4.8.1 …)으로 구성요소를 정한다 function Select-NetFxProducts($Names, $Tails, [string]$Fx4, [bool]$Has35) { foreach ($n in $Names) { $m = [regex]::Match($n, '^Microsoft \.NET Framework (.+?) on (.+)$') if (-not $m.Success -or -not (@($Tails) -contains $m.Groups[2].Value)) { continue } $tokens = @($m.Groups[1].Value -split '\s+AND\s+' | ForEach-Object { $_ -split '/' } | ForEach-Object { $_.Trim() }) [pscustomobject]@{ Product = $n; Fx4 = [bool]($Fx4 -and $tokens -contains $Fx4); Fx35 = [bool]($Has35 -and $tokens -contains '3.5') } } } function Get-NetFxComponents($Os, $ProductNames, $Facts) { if ($null -eq $Facts) { $Facts = Get-NetFxFacts } if (-not $Facts.Fx4 -and -not $Facts.Has35) { return } $label = '.NET Framework ' + ((@($Facts.Fx4, $(if ($Facts.Has35) { '3.5' })) | Where-Object { $_ }) -join '·') if ($null -eq $ProductNames) { return (New-Component -Family netfx -Name $label -Note '앱 제품 목록이 없어 점검하지 않음(제품 이름이 Windows 판마다 다름)') } $tails = @(Get-NetFxOsTails $Os) $hit = $false foreach ($p in @(Select-NetFxProducts $ProductNames $tails $Facts.Fx4 $Facts.Has35)) { if ($p.Fx4) { $hit = $true New-Component -Family netfx -Kind v4 -Product $p.Product -Name ".NET Framework $($Facts.Fx4)" -VersionText $Facts.File4 -Source $Facts.Path4 } if ($p.Fx35) { $hit = $true New-Component -Family netfx -Kind v2 -Product $p.Product -Name '.NET Framework 3.5' -VersionText $Facts.File2 -Source $Facts.Path2 } } if (-not $hit) { $osName = if ($tails.Count) { $tails[0] } else { '알 수 없는 판' } New-Component -Family netfx -Name $label -Note "MSRC 자료에서 이 Windows 판($osName)의 .NET Framework 제품을 찾지 못함" } } # 제품 목록에 설치한 판의 제품이 없으면(지원이 끝난 판) 같은 가족의 바로 위 판 제품을 찾는다. # 그 제품과 비교하면 대체형 규칙으로 '설치한 줄이 모든 줄보다 옛것 → 취약'이 된다. function Find-NextProduct($Names, [string]$Pattern, [version]$Mine) { $best = $null; $bestV = $null foreach ($n in $Names) { $m = [regex]::Match($n, $Pattern) if (-not $m.Success) { continue } $v = ConvertTo-Ver $m.Groups[1].Value if ($v -and $v -gt $Mine -and ($null -eq $bestV -or $v -lt $bestV)) { $best = $n; $bestV = $v } } $best } # Visual Studio 제품 이름: 'Microsoft Visual Studio 2022 version 17.14', 2017·2019 는 '… 16.11 (includes 16.0 - 16.10)' function Resolve-VsProduct($ProductNames, [string]$Year, [int]$Major, [int]$Minor) { $prefix = "Microsoft Visual Studio $Year version $Major.$Minor" if ($null -eq $ProductNames) { if ([int]$Year -ge 2022) { return [pscustomobject]@{ Product = $prefix; Note = '' } } return [pscustomobject]@{ Product = ''; Note = '앱 제품 목록이 없어 점검하지 않음' } } foreach ($n in $ProductNames) { if ($n -eq $prefix -or $n.StartsWith("$prefix ", [StringComparison]::OrdinalIgnoreCase)) { return [pscustomobject]@{ Product = $n; Note = '' } } } $mine = ConvertTo-Ver "$Major.$Minor" foreach ($n in $ProductNames) { $m = [regex]::Match($n, "^Microsoft Visual Studio $Year version \d+\.\d+ \(includes (\d+\.\d+) - (\d+\.\d+)\)") if ($m.Success -and $mine -ge (ConvertTo-Ver $m.Groups[1].Value) -and $mine -le (ConvertTo-Ver $m.Groups[2].Value)) { return [pscustomobject]@{ Product = $n; Note = '' } } } $next = Find-NextProduct $ProductNames "^Microsoft Visual Studio $Year version (\d+\.\d+)" $mine if ($next) { return [pscustomobject]@{ Product = $next; Note = "$prefix 자료가 없어(지원이 끝난 판) 바로 위 판과 비교" } } [pscustomobject]@{ Product = ''; Note = "MSRC 자료에 없음: $prefix" } } function Invoke-VsWhere { if (-not ${env:ProgramFiles(x86)}) { return $null } $exe = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' if (-not (Test-Path -LiteralPath $exe)) { return $null } $psi = New-Object System.Diagnostics.ProcessStartInfo $psi.FileName = $exe $psi.Arguments = '-all -prerelease -products * -format json -utf8' # -products * 가 없으면 Build Tools 가 안 나온다 $psi.UseShellExecute = $false $psi.RedirectStandardOutput = $true $psi.CreateNoWindow = $true $psi.StandardOutputEncoding = [Text.Encoding]::UTF8 $p = [Diagnostics.Process]::Start($psi) $out = $p.StandardOutput.ReadToEnd() $p.WaitForExit() if ($p.ExitCode -ne 0) { throw "vswhere 종료 코드 $($p.ExitCode)" } $out } # 버전은 catalog.productDisplayVersion('17.14.41 (September 2026)')으로 읽는다. installationVersion(17.14.37710.0)은 내부 번호다. function ConvertFrom-VsWhere([string]$Json, $ProductNames) { if (-not $Json -or -not $Json.Trim()) { return } $list = ConvertFrom-Json -InputObject $Json # 5.1 은 배열을 한 덩어리로 내놓으므로 변수에 받아 돈다 foreach ($inst in $list) { $cat = Get-Prop $inst 'catalog' $disp = [string](Get-Prop $cat 'productDisplayVersion') $name = [string](Get-Prop $inst 'displayName') if (-not $name) { $name = 'Visual Studio' } $path = [string](Get-Prop $inst 'installationPath') $m = [regex]::Match($disp, '^(\d+)\.(\d+)\.(\d+)') if (-not $m.Success) { New-Component -Family vs -Name $name -Source $path -Note "버전을 읽지 못함 (productDisplayVersion '$disp')" continue } $major = [int]$m.Groups[1].Value $year = [string](Get-Prop $cat 'productLineVersion') if (-not $year) { $year = [string]$VsYear[$major] } $text = $m.Value if ([bool](Get-Prop $inst 'isPrerelease')) { $text = $disp; $name += ' (미리보기)' } # 미리보기판은 비교하지 않는다(점검불가) $r = Resolve-VsProduct $ProductNames $year $major ([int]$m.Groups[2].Value) New-Component -Family vs -Product $r.Product -Name $name -VersionText $text -Source $path -Note $r.Note } } function Get-VSCodeComponents { $keys = @('HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall', 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall', 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall') foreach ($k in $keys) { if (-not (Test-Path -LiteralPath $k)) { continue } foreach ($sub in Get-ChildItem -LiteralPath $k -ErrorAction SilentlyContinue) { $dn = [string]$sub.GetValue('DisplayName') if ($dn -ne 'Microsoft Visual Studio Code' -and $dn -ne 'Microsoft Visual Studio Code (User)') { continue } # Insiders 는 뺀다 $name = if ($dn -like '*(User)') { 'VS Code (사용자 설치)' } else { 'VS Code' } New-Component -Family vscode -Product 'Visual Studio Code' -Name $name -VersionText ([string]$sub.GetValue('DisplayVersion')) -Source $sub.Name } } } function New-PwshComponent([string]$SemVer, [string]$Name, [string]$Source) { $m = [regex]::Match($SemVer, '^(\d+)\.(\d+)') if (-not $m.Success) { return } New-Component -Family pwsh -Product ('PowerShell {0}.{1}' -f $m.Groups[1].Value, $m.Groups[2].Value) -Name $Name -VersionText $SemVer -Source $Source } # Store 판 패키지 이름 'Microsoft.PowerShell_7.6.7.0_x64__8wekyb3d8bbwe' → 7.6.7, 미리보기 '…Preview_7.7.0.5…' → 7.7.0-preview.5 function ConvertFrom-PwshPackage([string]$PackageName) { $m = [regex]::Match($PackageName, '^Microsoft\.PowerShell(Preview)?_(\d+)\.(\d+)\.(\d+)\.(\d+)_[^_]*__8wekyb3d8bbwe$') if (-not $m.Success) { return } $g = $m.Groups if ($g[1].Success) { New-PwshComponent ('{0}.{1}.{2}-preview.{3}' -f $g[2].Value, $g[3].Value, $g[4].Value, $g[5].Value) 'PowerShell 7 미리보기 (Microsoft Store)' $PackageName } else { New-PwshComponent ('{0}.{1}.{2}' -f $g[2].Value, $g[3].Value, $g[4].Value) 'PowerShell 7 (Microsoft Store)' $PackageName } } function Get-PwshComponents { # MSI 설치판 foreach ($base in @('HKLM:\SOFTWARE\Microsoft\PowerShellCore\InstalledVersions', 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\PowerShellCore\InstalledVersions')) { if (-not (Test-Path -LiteralPath $base)) { continue } foreach ($sub in Get-ChildItem -LiteralPath $base -ErrorAction SilentlyContinue) { $sv = [string]$sub.GetValue('SemanticVersion') if ($sv) { New-PwshComponent $sv 'PowerShell 7' $sub.Name } } } # Microsoft Store 판(MSIX)은 위 레지스트리에 없다. 이 사용자에게 등록된 패키지 이름에서 읽는다. $repo = 'HKCU:\Software\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\Repository\Packages' if (-not (Test-Path -LiteralPath $repo)) { return } $best = @{} foreach ($sub in Get-ChildItem -LiteralPath $repo -ErrorAction SilentlyContinue) { $c = ConvertFrom-PwshPackage $sub.PSChildName if (-not $c) { continue } $pkg = ConvertTo-Ver ([regex]::Match($sub.PSChildName, '_(\d+\.\d+\.\d+\.\d+)_').Groups[1].Value) # 업데이트 중에는 옛 판이 같이 등록돼 있을 수 있다 → 같은 패키지는 높은 판 하나 if ($null -eq $best[$c.Name] -or $pkg -gt $best[$c.Name].Pkg) { $best[$c.Name] = @{ Pkg = $pkg; Comp = $c } } } foreach ($k in ($best.Keys | Sort-Object)) { $best[$k].Comp } } function ConvertFrom-C2RConfig([string]$Version, [string]$CdnBaseUrl, [string]$ReleaseIds) { if (-not $Version) { return } $guid = '' $m = [regex]::Match($CdnBaseUrl, '([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\s*/?\s*$') if ($m.Success) { $guid = $m.Groups[1].Value.ToLowerInvariant() } $src = "ClickToRun\Configuration VersionToReport · CDN $guid" if ($OfficePreviewChannels.ContainsKey($guid)) { return (New-Component -Family office_c2r -Name "Office ($($OfficePreviewChannels[$guid]))" -VersionText $Version -Source $src ` -Note '미리보기 채널은 보안 업데이트 페이지에 없어 점검하지 않음') } if (-not $OfficeChannels.ContainsKey($guid)) { return (New-Component -Family office_c2r -Name 'Office' -VersionText $Version -Source $src -Note "채널을 알 수 없음 (CDN $guid)") } $channel = $OfficeChannels[$guid] $products = New-Object System.Collections.Generic.List[string] if ($channel -eq 'Current Channel') { # Current Channel 빌드를 받는 영구 라이선스 소매판(ProPlus2024Retail 등)은 제품이 따로 있다 foreach ($id in ($ReleaseIds -split ',')) { $id = $id.Trim() if (-not $id) { continue } $y = [regex]::Match($id, '(20\d\d)Retail$') $p = if ($y.Success) { "Office $($y.Groups[1].Value) Retail" } else { 'Current Channel' } if (-not $products.Contains($p)) { $products.Add($p) } } if ($products.Count -eq 0) { $products.Add('Current Channel') } } else { $products.Add($channel) } foreach ($p in $products) { New-Component -Family office_c2r -Product "Office C2R · $p" -Name "Office ($p)" -VersionText $Version -Source $src } } function Get-OfficeC2RComponents { $k = 'HKLM:\SOFTWARE\Microsoft\Office\ClickToRun\Configuration' $ver = [string](Get-RegValue $k 'VersionToReport') if (-not $ver) { return } ConvertFrom-C2RConfig $ver ([string](Get-RegValue $k 'CDNBaseUrl')) ([string](Get-RegValue $k 'ProductReleaseIds')) } # 이 PC 의 구성요소 전부. 앱 하나를 못 읽어도 나머지는 점검한다. function Get-AllComponents($Os, [string]$OsProduct, $ProductNames) { Get-OsComponent $Os $OsProduct $readers = @( @{ Name = 'Edge'; Run = { Get-EdgeComponent } }, @{ Name = '.NET'; Run = { Get-DotNetComponents } }, @{ Name = '.NET Framework'; Run = { Get-NetFxComponents $Os $ProductNames } }, @{ Name = 'Visual Studio'; Run = { ConvertFrom-VsWhere (Invoke-VsWhere) $ProductNames } }, @{ Name = 'VS Code'; Run = { Get-VSCodeComponents } }, @{ Name = 'PowerShell 7'; Run = { Get-PwshComponents } }, @{ Name = 'Office'; Run = { Get-OfficeC2RComponents } } ) foreach ($r in $readers) { try { & $r.Run } catch { New-Component -Family error -Name $r.Name -Note "읽기 실패: $($_.Exception.Message)" } } } # 버전에서 바로 만들 수 있는 제품 이름 (-SimulateVersion 용). .NET Framework·Office·OS 는 @제품 이름으로 준다. function Get-DerivedProduct($Comp, $ProductNames) { $m = [regex]::Match([string]$Comp.VersionText, '^(\d+)\.(\d+)') if (-not $m.Success) { return $null } $mm = '{0}.{1}' -f $m.Groups[1].Value, $m.Groups[2].Value switch ($Comp.Family) { 'edge' { return 'Microsoft Edge (Chromium-based)' } 'vscode' { return 'Visual Studio Code' } 'pwsh' { return "PowerShell $mm" } 'dotnet' { return ".NET $mm installed on Windows" } 'aspnet' { return "ASP.NET Core $mm" } 'vs' { $major = [int]$m.Groups[1].Value return (Resolve-VsProduct $ProductNames ([string]$VsYear[$major]) $major ([int]$m.Groups[2].Value)).Product } } $null } # -SimulateVersion '<가족>[:<종류>]=<버전>[@<제품 이름>]' : 설치 버전을 바꾸거나, 없는 구성요소를 만든다 function Set-SimulatedVersions($Components, $Specs, $ProductNames) { $list = New-Object System.Collections.Generic.List[object] foreach ($c in $Components) { $list.Add($c) } foreach ($spec in $Specs) { if ($spec -match '^\s*app:') { continue } # 다른 회사 프로그램은 Set-SimulatedApps 가 맡는다 $m = [regex]::Match($spec, '^\s*([a-z0-9_]+)(?::([a-z0-9]+))?\s*=\s*([^@]+?)\s*(?:@\s*(.+?)\s*)?$') if (-not $m.Success) { throw "-SimulateVersion 형식이 틀림: '$spec' ('<가족>[:<종류>]=<버전>[@<제품 이름>]')" } $fam = $m.Groups[1].Value $kind = $m.Groups[2].Value if (-not $kind -and $DefaultKind.ContainsKey($fam)) { $kind = $DefaultKind[$fam] } $hits = @($list | Where-Object { $_.Family -eq $fam -and (-not $kind -or $_.Kind -eq $kind) }) if ($hits.Count -eq 0) { $c = New-Component -Family $fam -Kind $kind -Name "$fam 시험" $list.Add($c) $hits = @($c) } foreach ($c in $hits) { $c.Source = "-SimulateVersion (설치: $($c.VersionText))" $c.VersionText = $m.Groups[3].Value $c.Version = ConvertTo-Ver $c.VersionText $c.Note = '' if ($m.Groups[4].Success) { $c.Product = $m.Groups[4].Value } else { $p = Get-DerivedProduct $c $ProductNames if ($p) { $c.Product = $p } if (-not $c.Product) { $c.Note = "제품 이름을 '@<제품 이름>'으로 주세요" } } } } $list } # 제품 목록과 맞춰 본다: 없으면 바로 위 판 제품(지원이 끝난 판) 또는 점검하지 않음 function Resolve-Products($Components, $ProductNames) { foreach ($c in $Components) { if ($c.Product -and $c.Family -ne 'os' -and $null -ne $ProductNames -and -not (@($ProductNames) -contains $c.Product)) { $next = $null if ($NextPattern.ContainsKey($c.Family) -and $c.Version) { $next = Find-NextProduct $ProductNames $NextPattern[$c.Family] (ConvertTo-Ver "$($c.Version.Major).$($c.Version.Minor)") } if ($next) { $c.Note = "$($c.Product) 자료가 없어(지원이 끝난 판) 바로 위 판과 비교"; $c.Product = $next } else { $c.Note = "MSRC 자료에 없음: $($c.Product)"; $c.Product = '' } } $c } } # ── 판정 (DESIGN.md §3.2 '줄' 규칙) ───────────────────────── # '줄' = 같은 계열로 볼 버전 자리 function Get-LineKey($Comp, [version]$V) { switch ($Comp.Family) { 'os' { return (New-Object version $V.Build, 0) } # 빌드 계열 26100·26300 'office_c2r' { return (New-Object version $V.Build, 0) } # 16.0.20430.x → 20430 (Version 2609) 'edge' { return (New-Object version $V.Major, 0) } 'msapp' { # 서버 매핑표의 line: single = 모든 판이 한 줄, build = 세 번째 자리(10.0.26100.x 의 26100)마다 병렬(Windows SDK) if ($Comp.Line -eq 'build') { return (New-Object version $V.Build, 0) } return (New-Object version 0, 0) } } if ($Comp.Family -eq 'dotnet' -and $Comp.Kind -eq 'sdk') { return (New-Object version $V.Major, $V.Minor, ([int][Math]::Floor($V.Build / 100))) # 밴드 10.0.4xx → 10.0.4 } if ($Comp.Family -eq 'netfx') { # 빌드 천 단위까지: 4.8.4802 은 4.8 계열, 4.8.9334·4.8.10008 은 4.8.1 계열. 한 행에 두 계열이 같이 온다 return (New-Object version $V.Major, $V.Minor, ([int][Math]::Floor($V.Build / 1000))) } New-Object version $V.Major, $V.Minor } function Format-LineKey($Comp, [version]$L) { switch ($Comp.Family) { 'os' { return [string]$L.Major } 'office_c2r' { return "빌드 $($L.Major)" } 'edge' { return [string]$L.Major } 'msapp' { return "빌드 $($L.Major)" } } if ($Comp.Family -eq 'dotnet' -and $Comp.Kind -eq 'sdk') { return "$($L.Major).$($L.Minor).$($L.Build)xx" } if ($Comp.Family -eq 'netfx') { return "$($L.Major).$($L.Minor).$($L.Build)xxx" } "$($L.Major).$($L.Minor)" } # 줄끼리 서로 대체하지 않는 가족: 같은 줄이 없으면 점검불가 function Test-ParallelLines($Comp) { if ($Comp.Family -eq 'msapp') { return ($Comp.Line -eq 'build') } ($Comp.Family -eq 'os') -or ($Comp.Family -eq 'netfx') -or ($Comp.Family -eq 'dotnet' -and $Comp.Kind -eq 'sdk') } # 수정 값 중 이 구성요소에 해당하는 것: .NET 은 패치 자리 100 미만이 런타임, 이상이 SDK. # .NET Framework 는 설치 파일과 major 가 같은 값만(4.x 파일 → 4.x 값, 3.5 의 2.0 파일 → 2.0 값) function Test-Pick($Comp, [version]$V) { switch ($Comp.Family) { 'dotnet' { if ($Comp.Kind -eq 'sdk') { return ($V.Build -ge 100) } return ($V.Build -lt 100) } 'aspnet' { return ($V.Build -lt 100) } 'netfx' { return ($null -ne $Comp.Version -and $V.Major -eq $Comp.Version.Major) } } $true } function Format-RowInfo($Row) { $kb = if ($Row[2]) { "KB$($Row[2])" } else { $null } (@($kb, $Row[3], $Row[4], $Row[5]) | Where-Object { $_ }) -join ' · ' } # 구성요소 하나를 CVE 마다 판정한다. 해당하는 수정 값이 없는 CVE 는 결과를 내지 않는다(해당 없음). # $Rows: 서버 fixes 행 [cve, fixed_build, kb, month, severity, impact] function Get-ComponentVerdicts($Comp, $Rows) { $byCve = @{} $unreadable = @{} # CVE → 이 구성요소 몫인데 읽지 못한 조각(원문)과 그 행 foreach ($r in $Rows) { $cve = [string]$r[0] foreach ($f in @(Split-FixedBuild ([string]$r[1]))) { if ($f.V) { if (-not (Test-Pick $Comp $f.V)) { continue } if (-not $byCve.ContainsKey($cve)) { $byCve[$cve] = New-Object System.Collections.Generic.List[object] } $byCve[$cve].Add([pscustomobject]@{ V = $f.V; Text = $f.Text; Line = (Get-LineKey $Comp $f.V); Row = $r }) } elseif ($null -eq $f.Prefix -or (Test-Pick $Comp $f.Prefix)) { if (-not $unreadable.ContainsKey($cve)) { $unreadable[$cve] = New-Object System.Collections.Generic.List[object] } $unreadable[$cve].Add([pscustomobject]@{ Text = $f.Text; Row = $r }) } } } # 읽을 수 있는 값이 하나도 없고 읽지 못한 조각만 있으면 점검불가 — 조용히 빠지지 않게 한다 foreach ($cve in ($unreadable.Keys | Sort-Object)) { if ($byCve.ContainsKey($cve)) { continue } $bad = $unreadable[$cve].ToArray() # 5.1 은 @($hashtable[$key]) 에서 List 를 배열로 바꾸다 내부 오류(ArgumentException)가 난다 $reason = '수정 버전 형식을 읽을 수 없음: ' + ((@($bad | ForEach-Object { $_.Text } | Select-Object -Unique)) -join ', ') $info = Format-RowInfo $bad[0].Row if ($info) { $reason += " ($info)" } New-Verdict $cve '점검불가' $reason $bad[0].Text $null $bad[0].Row $Comp } $parallel = Test-ParallelLines $Comp $word = if ($Comp.Family -eq 'os' -or $Comp.Family -eq 'office_c2r') { '수정 빌드' } else { '수정 버전' } $mine = $null if ($Comp.Version) { $mine = Get-LineKey $Comp $Comp.Version } foreach ($cve in ($byCve.Keys | Sort-Object)) { $vals = @($byCve[$cve] | Sort-Object { $_.V }) $top = $vals[-1] $basis = $top if (-not $Comp.Version) { $verdict = '점검불가' $reason = "설치 버전을 비교할 수 없음: $($Comp.VersionText)(미리보기판 등) — $word $($top.Text)" } else { $same = @($vals | Where-Object { $_.Line -eq $mine }) if ($same.Count -gt 0) { $basis = $same[-1] if ($Comp.Version -ge $basis.V) { $verdict = '양호' $reason = "$word $($basis.Text) 이상" } else { $verdict = '취약' $hint = if ($Comp.Family -ne 'os') { "$($basis.Text) 이상으로 업데이트" } elseif ($basis.Row[2]) { "KB$($basis.Row[2]) 또는 그 뒤 누적 업데이트" } else { '그 뒤 누적 업데이트' } $reason = "$word $($basis.Text) 미만 — $hint 필요" } } else { $lines = @($vals | ForEach-Object { $_.Line } | Sort-Object -Unique) $others = (@($vals | ForEach-Object { $_.Text } | Select-Object -Unique)) -join ', ' $myLine = Format-LineKey $Comp $mine if ($parallel) { $verdict = '점검불가' $reason = "설치한 줄($myLine)이 아닌 다른 줄($others)만 수정 목록에 있음" } elseif ($mine -gt $lines[-1]) { $verdict = '양호' $reason = "설치한 줄($myLine)이 수정 목록의 줄($others)보다 새것 — 수정이 들어 있음" } elseif ($mine -lt $lines[0]) { $verdict = '취약' $reason = "설치한 줄($myLine)이 수정 목록의 줄($others)보다 옛것 — 지원이 끝난 판, $($top.Text) 이상으로 올려야 함" } else { $verdict = '점검불가' $reason = "설치한 줄($myLine)이 수정 목록의 줄($others) 사이에 있어 비교하지 않음" } } } $info = Format-RowInfo $basis.Row if ($info) { $reason += " ($info)" } New-Verdict $cve $verdict $reason $basis.Text $basis.V $basis.Row $Comp } } # 판정 한 건. 근거 행의 KB·달·심각도·영향은 -OutFile(PC 보안 점검이 읽음)에 쓴다 function New-Verdict([string]$Cve, [string]$Verdict, [string]$Reason, [string]$Fixed, $FixedV, $Row, $Comp) { [pscustomobject]@{ Cve = $Cve; Verdict = $Verdict; Reason = $Reason; Fixed = $Fixed; FixedV = $FixedV Kb = $Row[2]; Month = $Row[3]; Severity = $Row[4]; Impact = $Row[5]; Comp = $Comp } } function Format-Detected($Comp) { if ((Get-Prop $Comp 'Basis') -eq 'nvd') { return "$($Comp.Name) $($Comp.VersionText) · NVD $((@($Comp.Cpes) -join ', ') -replace '\\', '')" } "$($Comp.Name) $($Comp.VersionText) · $($Comp.Product)" } # CVE 하나에 한 줄 (DESIGN.md §3.3). 서버 화면이 (호스트, CVE)마다 마지막 행만 보여 주므로 PC 에서 합친다. # 판정은 취약 > 점검불가 > 양호 로 가장 나쁜 것, 구성요소는 나쁜 것부터 ' / ' 로 잇는다. # basis 는 그 판정을 낸 쪽(MSRC 'msrc' · 다른 회사 프로그램의 NVD 'nvd', PROTOCOL.md §5)이다. function Merge-Verdicts($Items) { $groups = @{} $seq = 0 foreach ($it in $Items) { if (-not $groups.ContainsKey($it.Cve)) { $groups[$it.Cve] = New-Object System.Collections.Generic.List[object] } $groups[$it.Cve].Add([pscustomobject]@{ Item = $it; Seq = $seq }) $seq++ } foreach ($cve in ($groups.Keys | Sort-Object)) { $g = @($groups[$cve] | Sort-Object @{ Expression = { $VerdictRank[$_.Item.Verdict] }; Descending = $true }, Seq | ForEach-Object { $_.Item }) $worst = $g[0] $detected = (@($g | ForEach-Object { Format-Detected $_.Comp })) -join ' / ' if ($detected.Length -gt $TextLimit) { $detected = Limit-Text ('{0} 외 구성요소 {1}개' -f (Format-Detected $worst.Comp), ($g.Count - 1)) } if ($g.Count -eq 1) { $reason = $worst.Reason } else { $reason = (@($g | ForEach-Object { "$($_.Comp.Name): $($_.Reason)" })) -join ' / ' } $basis = 'msrc' if ((Get-Prop $worst.Comp 'Basis') -eq 'nvd') { $basis = 'nvd' } [pscustomobject]@{ cve_id = $cve; verdict = $worst.Verdict; detected = $detected; reason = (Limit-Text $reason); basis = $basis; Items = $g } } } # ── 서버·시험 자료 ───────────────────────────────────────── function Get-HttpClient { if (-not $script:Http) { Add-Type -AssemblyName System.Net.Http [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 $script:Http = New-Object System.Net.Http.HttpClient $script:Http.Timeout = [TimeSpan]::FromSeconds(120) [void]$script:Http.DefaultRequestHeaders.TryAddWithoutValidation('User-Agent', $UserAgent) } $script:Http } function Get-Signature([string]$Secret, [byte[]]$Bytes) { $hmac = [System.Security.Cryptography.HMACSHA256]::new([Text.Encoding]::UTF8.GetBytes($Secret)) try { 'sha256=' + (($hmac.ComputeHash($Bytes) | ForEach-Object { $_.ToString('x2') }) -join '') } finally { $hmac.Dispose() } } # 서명해서 보내고, 받은 바이트 그대로 서버 서명을 확인한다(파싱한 값을 다시 직렬화하면 바이트가 달라진다). # -AllowNotFound: 404 면 $null (서버에 아직 없는 API) function Invoke-Signed($Cfg, [string]$Path, $Payload, [switch]$AllowNotFound) { $http = Get-HttpClient # System.Net.Http 를 먼저 불러야 아래 형식을 만들 수 있다(5.1) $Payload['host_id'] = $Cfg.host_id $Payload['ts'] = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() $body = [Text.Encoding]::UTF8.GetBytes(($Payload | ConvertTo-Json -Compress -Depth 6)) $request = New-Object System.Net.Http.HttpRequestMessage ([System.Net.Http.HttpMethod]::Post), ($Cfg.server.TrimEnd('/') + $Path) $request.Content = New-Object System.Net.Http.ByteArrayContent (, $body) $request.Content.Headers.ContentType = New-Object System.Net.Http.Headers.MediaTypeHeaderValue 'application/json' [void]$request.Headers.TryAddWithoutValidation('X-Signature', (Get-Signature $Cfg.secret $body)) $response = $http.SendAsync($request).GetAwaiter().GetResult() if ($AllowNotFound -and [int]$response.StatusCode -eq 404) { return $null } $raw = $response.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult() $text = [Text.Encoding]::UTF8.GetString($raw) if (-not $response.IsSuccessStatusCode) { throw "서버 응답 $([int]$response.StatusCode): $text" } $values = $null $signature = if ($response.Headers.TryGetValues('X-Signature', [ref]$values)) { @($values)[0] } else { '' } if ($signature -cne (Get-Signature $Cfg.secret $raw)) { throw '서버 응답 서명이 맞지 않습니다 — 받은 목록을 쓰지 않습니다' } $text | ConvertFrom-Json } function Read-JsonFile([string]$Path) { [IO.File]::ReadAllText($Path, [Text.Encoding]::UTF8) | ConvertFrom-Json } # 시험 자료 폴더: products.json 과 bundle_*_p<쪽>.json. 파일과 제품은 파일 안의 product 값으로 잇는다. function Read-FixtureIndex([string]$Dir) { $pages = @{} foreach ($f in Get-ChildItem -LiteralPath $Dir -Filter 'bundle_*.json' | Sort-Object Name) { $d = Read-JsonFile $f.FullName if (-not $pages.ContainsKey($d.product)) { $pages[$d.product] = @{} } $pages[$d.product][[int]$d.page] = $d } $products = $null $pf = Join-Path $Dir 'products.json' if (Test-Path -LiteralPath $pf) { $products = Read-JsonFile $pf } # v3: 매핑표(서버 win_app_map 흉내)와 앱별 NVD 범위 apps_bundle__p<쪽>.json $appPages = @{} foreach ($f in Get-ChildItem -LiteralPath $Dir -Filter 'apps_bundle_*.json' | Sort-Object Name) { $d = Read-JsonFile $f.FullName $key = [string](Get-Prop $d 'app_key') if (-not $key) { continue } if (-not $appPages.ContainsKey($key)) { $appPages[$key] = @{} } $appPages[$key][[int]$d.page] = $d } $map = $null $mf = Join-Path $Dir 'win_app_map.json' if (Test-Path -LiteralPath $mf) { $map = Read-JsonFile $mf } # 3.1: 운영 서버 대조 응답에서 뽑은 cycle·links 와 msrc 항목(Store 앱 등) $extra = $null $ef = Join-Path $Dir 'win_app_extra.json' if (Test-Path -LiteralPath $ef) { $extra = Read-JsonFile $ef } # 3.2: /win/cve/info 응답 흉내(S23) $cveInfo = $null $cf = Join-Path $Dir 'cve_info.json' if (Test-Path -LiteralPath $cf) { $cveInfo = Read-JsonFile $cf } [pscustomobject]@{ Pages = $pages; Products = $products; AppPages = $appPages; AppMap = $map; AppExtra = $extra; CveInfo = $cveInfo } } # 자료 출처: 서버($Cfg, 서명 확인) 또는 시험 자료 폴더($FixtureDir, 서명 없음) function New-DataSource($Cfg, [string]$FixtureDir) { $fixture = $null if ($FixtureDir) { $fixture = Read-FixtureIndex $FixtureDir } [pscustomobject]@{ Cfg = $Cfg; Fixture = $fixture; Bundles = @{}; AppBundles = @{} } } # /win/products 요청 본문. S16(3.1): os 를 넣어야 OS 제품의 cycle(판 묶음 지원 기간)이 온다. function New-ProductsPayload { [ordered]@{ families = @($AppFamilies + 'os') } } # 앱 제품 이름 목록. 받지 못하면 $null (서버에 /win/products 가 아직 없을 때) # Names 는 앱 가족 이름만 둔다(OS 이름은 앱 제품 고르기에 쓰지 않으므로 넣지 않아 v2 판정이 그대로다). # Cycles(S16): 제품 이름 → 서버 cycle·cycle_enterprise(Windows 10·11 의 Enterprise·Education 용). 없으면 빈 표. function Get-ProductList($Src) { if ($Src.Fixture) { $data = $Src.Fixture.Products } else { $data = Invoke-Signed $Src.Cfg '/api/agent/win/products' (New-ProductsPayload) -AllowNotFound } if ($null -eq $data) { return $null } $cycles = @{} foreach ($p in @($data.products)) { if ($null -eq $p) { continue } if ($p.PSObject.Properties['cycle'] -or $p.PSObject.Properties['cycle_enterprise'] -or $p.PSObject.Properties['cycle_ltsc'] -or $p.PSObject.Properties['cycle_iot_ltsc']) { # S19: Windows 10·11 의 LTSC(EnterpriseS)·IoT LTSC(IoTEnterpriseS) 판 기간은 그 판이 있는 버전에만 온다 $cycles[[string]$p.product] = [pscustomobject]@{ Cycle = (Get-Prop $p 'cycle'); Enterprise = (Get-Prop $p 'cycle_enterprise') Ltsc = (Get-Prop $p 'cycle_ltsc'); IotLtsc = (Get-Prop $p 'cycle_iot_ltsc') } } } [pscustomobject]@{ Names = [string[]]@($data.products | Where-Object { $null -ne $_ -and [string](Get-Prop $_ 'family') -ne 'os' } | ForEach-Object { $_.product }) Coverage = [string]$data.coverage; Cycles = $cycles } } # Windows 10·11 의 Enterprise·Education·IoT 판(EditionID: Enterprise·EnterpriseN·EnterpriseS·EducationN·IoTEnterprise·IoTEnterpriseS …)과 # Enterprise 다중 세션(ServerRdsh)은 지원 기간이 더 긴 cycle_enterprise 를 쓴다(S16). 가정·Pro(Core·Professional …)는 cycle. function Test-EnterpriseEdition([string]$EditionId) { $EditionId -match '^(Enterprise|Education|IoT|ServerRdsh)' } # OS 지원 기간을 고를 칸(S19): IoTEnterpriseS…(IoT LTSC) → cycle_iot_ltsc, EnterpriseS…(LTSC, EnterpriseSN 포함) → cycle_ltsc, # 그 밖의 Enterprise·Education·IoT·다중 세션 → cycle_enterprise, 나머지(가정·Pro·Server …) → cycle. LTSC 를 먼저 본다(둘 다 Enterprise·IoT 로 시작). function Get-OsCycleKey([string]$EditionId) { if ($EditionId -match '^IoTEnterpriseS') { return 'cycle_iot_ltsc' } if ($EditionId -match '^EnterpriseS') { return 'cycle_ltsc' } if (Test-EnterpriseEdition $EditionId) { return 'cycle_enterprise' } 'cycle' } # OS 정보의 EditionID (Get-OsFacts 는 [ordered] 라 없는 키를 점으로 읽으면 StrictMode 2 에서 오류 — 인덱서로 읽는다) function Get-OsEdition($Os) { if ($null -eq $Os) { return '' } if ($Os -is [System.Collections.IDictionary]) { return [string]$Os['edition'] } [string](Get-Prop $Os 'edition') } # v2 구성요소에 제품 이름의 지원 기간을 붙인다(S16). 판정에는 쓰지 않고 결과 문서로만 옮긴다. # - 바로 위 판과 비교한 구성요소(지원이 끝난 판)는 그 판의 기간이 아니므로 붙이지 않는다. # - .NET Framework 는 제품 이름의 가장 높은 판 기간이 온다(3.5 AND 4.8.1 → 4.8.1): 4.x 구성요소에는 4.x 기간, 3.5 구성요소에는 3.5 기간만 붙인다. # - OS 는 EditionID 로 cycle·cycle_enterprise 중 고르고(CycleSource 에 남김), cycle_enterprise 가 없는 판(-w/-e 로 안 나뉜 판)은 cycle. function Add-ComponentCycles($Components, $Cycles, $Os) { foreach ($c in $Components) { if ($null -eq $c) { continue } if ($c.Family -eq 'app' -or $c.Family -eq 'msapp') { $c; continue } $c.Cycle = $null $c.CycleSource = '' if ($null -eq $Cycles -or -not $c.Product -or -not $Cycles.ContainsKey($c.Product)) { $c; continue } if ([string]$c.Note -like '*지원이 끝난 판*') { $c; continue } $entry = $Cycles[$c.Product] $cy = $entry.Cycle $src = 'cycle' if ($c.Family -eq 'os') { # S19: EditionID 로 고른다. cycle_enterprise 가 없는 판(-w/-e 로 안 나뉜 10 22H2 등)은 cycle 하나가 모든 판 기간이라 cycle. # LTSC·IoT LTSC 칸이 없으면 GA 채널(cycle·cycle_enterprise) 날짜를 빌리지 않는다 — LTSC 는 같은 빌드라도 지원이 몇 년 길어 # 빌리면 '곧 끝남'·'지원 끝'이 거짓으로 일찍 뜬다. 판 묶음을 못 찾은 것으로 보고 unknown({product, cycle: null}). $key = Get-OsCycleKey (Get-OsEdition $Os) $pick = $null switch ($key) { 'cycle_iot_ltsc' { $pick = Get-Prop $entry 'IotLtsc' } 'cycle_ltsc' { $pick = Get-Prop $entry 'Ltsc' } 'cycle_enterprise' { $pick = Get-Prop $entry 'Enterprise' } } if ($null -ne $pick) { $cy = $pick; $src = $key } elseif ($key -eq 'cycle_ltsc' -or $key -eq 'cycle_iot_ltsc') { $prod = [string](Get-Prop $entry.Cycle 'product') if (-not $prod) { $prod = 'windows' } $cy = [pscustomobject]@{ product = $prod; cycle = $null } $src = "${key}:missing" } } if ($c.Family -eq 'netfx' -and $null -ne $cy) { $cn = [string](Get-Prop $cy 'cycle') if (($c.Kind -eq 'v2' -and $cn -notlike '3.5*') -or ($c.Kind -eq 'v4' -and $cn -notlike '4*')) { $c; continue } } # VS 2017·2019 옛 판(16.4 등)은 '… 16.11 (includes 16.0 - 16.10)' 제품으로 판정하지만 기간은 16.11 것이다: # 설치한 major.minor 가 묶음(cycle.cycle)과 같을 때만 붙인다(리뷰 2026-10-17). 미리보기처럼 버전을 못 읽으면 붙이지 않는다. if ($c.Family -eq 'vs' -and $null -ne $cy) { $cn = [string](Get-Prop $cy 'cycle') if ($null -eq $c.Version -or ('{0}.{1}' -f $c.Version.Major, $c.Version.Minor) -ne $cn) { $c; continue } } $c.Cycle = $cy if ($null -ne $cy) { $c.CycleSource = $src } $c } } # 제품 하나의 수정 목록 (쪽을 다 받아 합친다). 같은 제품은 한 번만 받는다. function Get-Bundle($Src, [string]$Product) { if ($Src.Bundles.ContainsKey($Product)) { return $Src.Bundles[$Product] } $rows = New-Object System.Collections.Generic.List[object] $coverage = '' $found = $true if ($Src.Fixture) { $pages = $Src.Fixture.Pages[$Product] if (-not $pages) { $found = $false } else { $first = @($pages.Values)[0] for ($i = 0; $i -lt [int]$first.pages; $i++) { if (-not $pages.ContainsKey($i)) { throw "시험 자료 쪽이 빠짐: $Product p$i" } foreach ($f in @($pages[$i].fixes)) { if ($null -ne $f) { $rows.Add($f) } } } $coverage = [string]$first.coverage } } else { $page = 0 do { $data = Invoke-Signed $Src.Cfg '/api/agent/win/bundle' ([ordered]@{ product = $Product; page = $page }) foreach ($f in @($data.fixes)) { if ($null -ne $f) { $rows.Add($f) } } $coverage = [string]$data.coverage $page++ } while ($page -lt [int]$data.pages) } $b = [pscustomobject]@{ Rows = $rows; Coverage = $coverage; Found = $found } $Src.Bundles[$Product] = $b $b } # ── 다른 회사 프로그램 (v3, DESIGN.md §8) ───────────────────── # 설치 기록이 있는 프로그램(제거 프로그램 목록 세 곳)을 모아 서버의 매핑표(/win/apps/match)와 맞추고, 맞은 앱마다 # NVD CPE 버전 범위(/win/apps/bundle)를 받아 여기서 판정한다. 포터블 프로그램은 설치 기록이 없어 빠진다(사용자 결정). # Store 앱은 목록만 보낸다(판정은 v3-b). # 제거 프로그램 키 하나 → 프로그램 하나. 이름이 없거나, 다른 프로그램의 부품(SystemComponent·ParentKeyName)이거나, # 업데이트(ReleaseType)면 뺀다. $Values: 값 이름 → 값 function Select-Program($Values, [string]$Scope, [string]$Key) { $name = ([string]$Values['DisplayName']).Trim() if (-not $name) { return } if ([string]$Values['SystemComponent'] -eq '1') { return } if ([string]$Values['ParentKeyName']) { return } if ([string]$Values['ReleaseType'] -match '^(Update|Hotfix|Security Update|Update Rollup)$') { return } [pscustomobject]@{ name = $name; version = ([string]$Values['DisplayVersion']).Trim(); publisher = ([string]$Values['Publisher']).Trim() scope = $Scope; key = $Key } } function Get-UninstallPrograms { $seen = @{} foreach ($p in $UninstallPlaces) { if (-not (Test-Path -LiteralPath $p.Path)) { continue } foreach ($sub in Get-ChildItem -LiteralPath $p.Path -ErrorAction SilentlyContinue) { $v = @{} foreach ($n in 'DisplayName', 'DisplayVersion', 'Publisher', 'SystemComponent', 'ParentKeyName', 'ReleaseType') { $v[$n] = $sub.GetValue($n) } $prog = Select-Program $v $p.Scope $sub.PSChildName if (-not $prog) { continue } $id = '{0}|{1}|{2}' -f $prog.name, $prog.version, $prog.publisher if ($seen.ContainsKey($id)) { continue } $seen[$id] = $true $prog } } } # 인증서 주체 'CN=Microsoft Corporation, O=…' 또는 'CN="Fasoo Co., Ltd.", …' → 이름 function Get-CertName([string]$Dn) { $m = [regex]::Match([string]$Dn, '^\s*CN=(?:"((?:[^"]|"")*)"|([^,]*))') if (-not $m.Success) { return ([string]$Dn).Trim() } if ($m.Groups[1].Success) { return $m.Groups[1].Value.Replace('""', '"') } $m.Groups[2].Value.Trim() } # 비시스템 Store 앱 (프레임워크·리소스 패키지·System 서명은 뺀다). 같은 이름은 높은 판 하나. function Get-AppxPrograms { $pk = $null try { $pk = @(Get-AppxPackage -ErrorAction Stop) } catch { return [pscustomobject]@{ Items = @(); Note = "Store 앱 목록을 읽지 못함: $($_.Exception.Message)" } } $best = @{} foreach ($a in $pk) { if ((Get-Prop $a 'IsFramework') -or (Get-Prop $a 'IsResourcePackage') -or [string](Get-Prop $a 'SignatureKind') -eq 'System') { continue } $name = [string](Get-Prop $a 'Name') if (-not $name) { continue } $ver = ConvertTo-Ver ([string](Get-Prop $a 'Version')) if ($best.ContainsKey($name) -and $null -ne $ver -and $ver -le $best[$name].V) { continue } $best[$name] = @{ V = $ver; Item = [pscustomobject]@{ name = $name; version = [string](Get-Prop $a 'Version'); publisher = (Get-CertName ([string](Get-Prop $a 'Publisher'))); signature = [string](Get-Prop $a 'SignatureKind') } } } [pscustomobject]@{ Items = @($best.Keys | Sort-Object | ForEach-Object { $best[$_].Item }); Note = '' } } # 시험 자료 모드에서 서버의 /win/apps/match 를 흉내 낸다(fixtures\win_app_map.json, DESIGN.md §8.3). # 응답 모양은 운영 서버(2026-10-17)와 같다: matched 의 version 은 버전 규칙으로 고친 값, version_raw 는 DisplayVersion. # 규칙으로 버전을 읽지 못하면 unmatched('버전 못 읽음')로 보낸다. 정규식은 대소문자를 가린다. # 3.1: $Extra(fixtures\win_app_extra.json, 운영 서버 응답에서 뽑은 것)가 있으면 서버처럼 NVD 항목에 cycle·links 를 붙이고, # $Basis 에 msrc 가 있으면 설치 이름이 같은 msrc 항목(Store 앱 등)을 matched 에 넣는다(그 제거 프로그램은 unmatched 에서 빠진다). function Invoke-FixtureMatch($Map, $Programs, $Appx = @(), $Extra = $null, $Basis = @()) { $matched = New-Object System.Collections.Generic.List[object] $unmatched = New-Object System.Collections.Generic.List[object] $noVersion = New-Object System.Collections.Generic.List[string] $msrcByName = @{} if ($Extra -and (@($Basis) -contains 'msrc')) { foreach ($it in @(Get-Prop $Extra 'msrc_items')) { if ($null -ne $it) { $msrcByName[[string]$it.name] = $it } } } $cycleLinks = Get-Prop $Extra 'cycle_links' $msrcHit = @{} foreach ($a in @($Appx)) { if ($null -ne $a -and $msrcByName.ContainsKey([string]$a.name)) { $msrcHit[[string]$a.name] = $true } } foreach ($p in @($Programs)) { if ($null -eq $p) { continue } if ($msrcByName.ContainsKey([string]$p.name)) { $msrcHit[[string]$p.name] = $true; continue } if (-not [string]$p.version) { $noVersion.Add([string]$p.name); continue } $hit = $false foreach ($a in @($Map.apps)) { if (-not [regex]::IsMatch([string]$p.name, [string]$a.match_name)) { continue } $mp = [string](Get-Prop $a 'match_publisher') if ($mp -and -not [regex]::IsMatch([string]$p.publisher, $mp)) { continue } $hit = $true $norm = Get-RuleVersion $p $a.version_rule if (-not $norm) { $unmatched.Add([pscustomobject]@{ name = [string]$p.name; version = [string]$p.version; why = '버전 못 읽음' }) continue } $cl = Get-Prop $cycleLinks ([string]$a.app_key) $matched.Add([pscustomobject]@{ app_key = [string]$a.app_key; label = [string]$a.label; name = [string]$p.name; version_raw = [string]$p.version; version = $norm cpes = @($a.cpes); group = [string](Get-Prop $a 'group'); cycle = (Get-Prop $cl 'cycle'); links = (Get-Prop $cl 'links') }) } if ($hit) { continue } $why = '점검 자료 없음' if ([string]$p.publisher -match '^Microsoft') { if ([string]$p.name -match $MsrcAppNames) { $why = 'MSRC(v2)로 점검' } else { $why = 'Microsoft 앱 — 점검 자료 없음' } } $unmatched.Add([pscustomobject]@{ name = [string]$p.name; version = [string]$p.version; why = $why }) } foreach ($name in ($msrcHit.Keys | Sort-Object)) { $matched.Add($msrcByName[$name]) } [pscustomobject]@{ coverage = '시험 자료 매핑표 fixtures\win_app_map.json'; matched = $matched.ToArray(); unmatched = $unmatched.ToArray(); no_version = $noVersion.ToArray() } } # /win/apps/match 요청 본문. -DryRun 이면 서버가 설치 목록을 저장하지 않게 dry_run 을 붙인다(S12). # basis(3.1): msrc 를 넣어야 서버가 Store 앱 등 MSRC 로 판정할 Microsoft 앱을 준다(S15-2). function New-MatchPayload($Programs, $Appx) { $payload = [ordered]@{ programs = @(@($Programs) | Select-Object -First $MaxPrograms | ForEach-Object { [ordered]@{ name = $_.name; version = $_.version; publisher = $_.publisher; scope = $_.scope } }) appx = @(@($Appx) | Select-Object -First $MaxPrograms | ForEach-Object { [ordered]@{ name = $_.name; version = $_.version; publisher = $_.publisher } }) basis = @($MatchBasis) } if ($DryRun) { $payload['dry_run'] = $true } $payload } # 서버(또는 시험 자료)와 맞춰 본다. 서버에 v3 API 가 없으면 $null. function Get-AppMatch($Src, $Programs, $Appx) { if ($Src.Fixture) { if (-not $Src.Fixture.AppMap) { return $null } return (Invoke-FixtureMatch $Src.Fixture.AppMap $Programs $Appx $Src.Fixture.AppExtra $MatchBasis) } Invoke-Signed $Src.Cfg '/api/agent/win/apps/match' (New-MatchPayload $Programs $Appx) -AllowNotFound } # NVD·설치 버전 글자 → 숫자 부분([version] 네 자리)과 접미사(f1, a, b4, rc.1 …, 소문자). # 날짜형(2019-09-12), 다섯 자리 이상(1.2.3.4.5), 숫자로 시작하지 않는 글자는 비교할 수 없어 $null. function Split-AppVersion([string]$Text) { if (-not $Text) { return $null } $t = ($Text -replace '\\(.)', '$1').Trim() # CPE 이스케이프(2017.1.2p4\+)를 푼다 $m = [regex]::Match($t, '^(\d+)((?:\.\d+){0,3})(.*)$') if (-not $m.Success) { return $null } $suffix = $m.Groups[3].Value if ($suffix -and $suffix -notmatch '^[-_.]?[A-Za-z][A-Za-z0-9._+-]*$' -and $suffix -ne '+') { return $null } $numText = $m.Groups[1].Value + $m.Groups[2].Value if (-not $m.Groups[2].Value) { $numText += '.0' } $num = ConvertTo-Ver $numText if (-not $num) { return $null } [pscustomobject]@{ Num = $num; Suffix = ($suffix -replace '^[-_.]', '').ToLowerInvariant(); Text = $t } } # 앱 구성요소: Product = app_key(서버에 물을 이름), Kind = app_key, Basis = nvd. # 매핑표의 version_rule 로 읽지 못한 버전은 AppVer 가 $null 이고, 그 앱의 CVE 는 모두 점검불가가 된다. function New-AppComponent { param([string]$Key, [string]$Name, [string]$Raw, [string]$Normalized, [string[]]$Cpes = @(), [string]$Source = '', [string]$Rule = '', [string]$Group = '', $Cycle = $null, $Links = $null) $text = $Normalized if (-not $text) { $text = $Raw } $ver = $null if ($Normalized) { $ver = Split-AppVersion $Normalized } [pscustomobject]@{ Family = 'app'; Product = $Key; Name = $Name; Version = $null; VersionText = $text; Kind = $Key; Source = $Source; Note = '' Basis = 'nvd'; Cpes = @($Cpes); AppVer = $ver; Raw = $Raw; Rule = $Rule; Group = $Group; Cycle = $Cycle; Links = $Links } } # ── Store 앱 등 Microsoft PC 앱 (3.1, msapp, DESIGN.md §4 S15-2) ── # 서버의 msrc 항목은 MSRC 제품 이름 목록(products)을 준다. 이름에 Windows 판 꼬리가 붙은 것(… on Windows 11 Version 24H2 for # x64-based Systems, … for Windows 11)은 이 PC 의 판에 맞는 것만 고른다. 꼬리는 ' on|for Windows 10|11|Server…' 꼴만 본다: # 'Outlook for Windows'·'Remote Desktop client for Windows Desktop'의 ' for Windows' 는 앱 이름이다(GB10 주의). # 제품 이름 → @{ App; Os } (Windows 판 꼬리가 없으면 $null) function Get-OsPart([string]$Product) { $m = [regex]::Match($Product, '^(.+?) (?:on|for) (Windows (?:10|11|Server)\b.*)$') if (-not $m.Success) { return $null } [pscustomobject]@{ App = $m.Groups[1].Value; Os = $m.Groups[2].Value } } # 이 PC 가 받아들이는 꼬리: .NET Framework 와 같은 꼴(Windows 11 version 26H2, Windows 11 Version 26H2 for x64-based Systems)과 # 판 없이 가족만 적은 꼴(Windows 11 — 'Windows Terminal for Windows 11'). 'Windows 10 for x64-based Systems' 는 MSRC 에서 1507 판 이름이라 넣지 않는다. function Get-MsAppOsTails($Os) { if ($null -eq $Os) { return } Get-NetFxOsTails $Os if ($Os.installation_type -notlike 'Server*') { if ($Os.build -ge 22000) { 'Windows 11' } else { 'Windows 10' } } } # 판이 맞는 이름이 없을 때 비교할 이름: 같은 가족(Windows 11·10·Server)과 같은 아키텍처의 다른 판들, 없으면 꼬리 있는 이름 전부. function Select-NearOsProducts($Tailed, $Os) { if ($null -eq $Os) { return @($Tailed) } if ($Os.installation_type -like 'Server*') { $family = 'Windows Server' } elseif ($Os.build -ge 22000) { $family = 'Windows 11' } else { $family = 'Windows 10' } $arch = switch ($Os.arch) { 'AMD64' { 'x64-based' } 'ARM64' { 'ARM64-based' } 'x86' { '32-bit' } default { '' } } $near = @($Tailed | Where-Object { $o = (Get-OsPart $_).Os $o.StartsWith($family, [StringComparison]::OrdinalIgnoreCase) -and ($family -eq 'Windows Server' -or ($arch -and $o -like "*for $arch Systems")) }) if ($near.Count) { return $near } @($Tailed) } # msrc 항목의 제품 이름들 중 이 PC 에 맞는 것. OsMatch=$false 면 이 판 이름이 없어 다른 판 이름들과 비교한다(판이 같으면 판정, 다르면 점검불가). function Select-MsAppProducts($Products, $Os) { $all = @(@($Products) | Where-Object { $_ } | ForEach-Object { [string]$_ }) $plain = @($all | Where-Object { $null -eq (Get-OsPart $_) }) $tailed = @($all | Where-Object { $null -ne (Get-OsPart $_) }) if ($tailed.Count -eq 0) { return [pscustomobject]@{ Products = $plain; OsMatch = $true; Compared = 0; Note = '' } } $tails = @(Get-MsAppOsTails $Os) $mine = @($tailed | Where-Object { $o = (Get-OsPart $_).Os; @($tails | Where-Object { $_ -ieq $o }).Count -gt 0 }) if ($mine.Count) { return [pscustomobject]@{ Products = @($plain + $mine); OsMatch = $true; Compared = 0; Note = '' } } $near = @(Select-NearOsProducts $tailed $Os) $osName = '알 수 없는 판' if ($null -ne $Os -and $Os.display_version) { $osName = [string]$Os.display_version } elseif ($null -ne $Os -and (Get-MsrcProduct $Os)) { $osName = Get-MsrcProduct $Os } [pscustomobject]@{ Products = @($plain + $near); OsMatch = $false; Compared = $near.Count Note = "이 Windows 판($osName) 이름이 MSRC에 없어 다른 Windows 판 이름 $($near.Count)개와 비교" } } # 서버 msrc 항목 → msapp 구성요소. 판정은 v2 와 같은 '줄' 규칙(Get-ComponentVerdicts), 제품 이름이 여럿이면 Get-MsAppVerdicts 가 합친다. function New-MsAppComponent($Item, $Os) { $label = [string](Get-Prop $Item 'label') if (-not $label) { $label = [string]$Item.name } $raw = [string](Get-Prop $Item 'version_raw') if (-not $raw) { $raw = [string]$Item.version } $sel = Select-MsAppProducts (Get-Prop $Item 'products') $Os $line = [string](Get-Prop $Item 'line') if (-not $line) { $line = 'single' } $first = '' if (@($sel.Products).Count) { $first = @($sel.Products)[0] } $note = $sel.Note if (-not $first) { $note = 'MSRC 제품 이름이 없어 점검하지 않음' } [pscustomobject]@{ Family = 'msapp'; Product = $first; Name = $label; Version = (ConvertTo-Ver ([string]$Item.version)); VersionText = [string]$Item.version Kind = [string]$Item.app_key; Source = ("설치 이름 '{0}' {1}" -f $Item.name, $raw); Note = $note Basis = 'msrc'; Products = @($sel.Products); OsMatch = [bool]$sel.OsMatch; Compared = [int]$sel.Compared; Line = $line Package = [string]$Item.name; Raw = $raw; Cycle = (Get-Prop $Item 'cycle'); Links = (Get-Prop $Item 'links') } } # 제품 이름 하나로 판정할 msapp 구성요소 사본 (근거 detected 에 그 제품 이름이 나오게) function Copy-MsAppComponent($Comp, [string]$Product) { $c = $Comp.PSObject.Copy() $c.Product = $Product $c } # msapp 구성요소를 제품 이름마다 판정하고 CVE 마다 합친다. 제품 이름이 하나면 그 판정 그대로. # 여럿이면(같은 판의 두 이름, 또는 이 판 이름이 없어 다른 판들과 비교) 그 CVE 가 있는 이름들의 판정이 모두 같을 때만 쓰고, 다르면 점검불가. # 제품 이름 하나라도 수정 목록을 받지 못하면(404·5xx·서명·시간 초과, 시험 자료 쪽 빠짐) 그 앱만 판정하지 않고 이유(Why)를 남긴다. # 일부 이름만으로 '판정이 모두 같음'을 보면 빠진 이름의 다른 판정을 놓칠 수 있어 앱 전체를 뺀다. v2·다른 앱은 계속한다(리뷰 2026-10-17). function Get-MsAppVerdicts($Src, $Comp) { $r = [pscustomobject]@{ Verdicts = @(); Found = $false; Empty = $true; Missing = 0; Why = '' } $byCve = @{} $order = New-Object System.Collections.Generic.List[string] foreach ($p in @($Comp.Products)) { try { $b = Get-Bundle $Src $p } catch { $r.Found = $false $r.Why = "MSRC 자료를 받지 못함: $($_.Exception.Message)" return $r } if (-not $b.Found) { $r.Missing++; continue } $r.Found = $true if ($b.Rows.Count -eq 0) { continue } $r.Empty = $false $pc = Copy-MsAppComponent $Comp $p foreach ($v in @(Get-ComponentVerdicts $pc $b.Rows)) { if (-not $byCve.ContainsKey($v.Cve)) { $byCve[$v.Cve] = New-Object System.Collections.Generic.List[object]; $order.Add($v.Cve) } $byCve[$v.Cve].Add($v) } } $out = New-Object System.Collections.Generic.List[object] foreach ($cve in ($order | Sort-Object)) { $vs = $byCve[$cve].ToArray() $kinds = @($vs | ForEach-Object { $_.Verdict } | Select-Object -Unique) if ($kinds.Count -eq 1) { # 같은 판정이면 고칠 판이 가장 높은 이름을 근거로 한다(같으면 제품 이름 순서에서 앞의 것 — 5.1 과 7 의 정렬 차이를 피함) $best = $vs[0] foreach ($v in $vs) { if ($v.FixedV -and (-not $best.FixedV -or $v.FixedV -gt $best.FixedV)) { $best = $v } } $out.Add($best) continue } $parts = @($vs | ForEach-Object { $o = Get-OsPart $_.Comp.Product $where = if ($o) { $o.Os } else { $_.Comp.Product } $fx = if ($_.Fixed) { " (수정 버전 $($_.Fixed))" } else { '' } "${where}: $($_.Verdict)$fx" }) $first = $vs[0] $row = @($cve, $null, $first.Kb, $first.Month, $first.Severity, $first.Impact) $reason = "Windows 판 이름마다 판정이 다름 — $($parts -join ' / ')" $info = Format-RowInfo $row if ($info) { $reason += " ($info)" } $out.Add((New-Verdict $cve '점검불가' $reason $null $null $row $Comp)) } $r.Verdicts = $out.ToArray() $r } # 버전 규칙 {from: display_version|name, regex: 그룹 1 이 비교할 버전} function Get-RuleVersion($Program, $Rule) { $from = [string](Get-Prop $Rule 'from') $rx = [string](Get-Prop $Rule 'regex') if (-not $rx) { return $null } $text = [string]$Program.version if ($from -eq 'name') { $text = [string]$Program.name } $m = [regex]::Match($text, $rx) if ($m.Success -and $m.Groups.Count -gt 1 -and $m.Groups[1].Success) { return $m.Groups[1].Value } $null } # 대조 결과 → 앱 구성요소. 서버는 version 을 매핑표의 버전 규칙으로 이미 고쳐 보낸다(version_raw = DisplayVersion). # version_rule 칸이 오면(PROTOCOL.md §5 초안 꼴) 여기서 규칙을 적용한다. function ConvertTo-AppComponents($Match, $Os = $null) { foreach ($m in @($Match.matched)) { if ($null -eq $m) { continue } # 3.1: msrc 항목(basis 'msrc', family 'msapp')은 MSRC 수정 목록으로 판정한다. basis 가 없으면 NVD 항목(3.0 서버와 같음). if ((Get-Prop $m 'basis') -eq 'msrc') { New-MsAppComponent $m $Os; continue } $label = [string](Get-Prop $m 'label') if (-not $label) { $label = [string]$m.name } $raw = [string](Get-Prop $m 'version_raw') if (-not $raw) { $raw = [string]$m.version } $rule = Get-Prop $m 'version_rule' $ruleText = '' if ($rule) { $ruleText = '{0} ~ {1}' -f (Get-Prop $rule 'from'), (Get-Prop $rule 'regex') $norm = Get-RuleVersion ([pscustomobject]@{ name = [string]$m.name; version = $raw }) $rule } else { $norm = [string]$m.version $ruleText = '서버가 고친 버전' } New-AppComponent -Key ([string]$m.app_key) -Name $label -Raw $raw -Normalized $norm ` -Cpes @($m.cpes) -Source ("제거 프로그램 '{0}' {1}" -f $m.name, $raw) -Rule $ruleText -Group ([string](Get-Prop $m 'group')) ` -Cycle (Get-Prop $m 'cycle') -Links (Get-Prop $m 'links') } } # -SimulateVersion 'app:=<버전>' : 맞은 앱의 버전을 바꾸거나, 깔려 있지 않은 앱을 만든다(버전은 이미 정규화된 글자로 준다) function Set-SimulatedApps($Components, $Specs) { $list = New-Object System.Collections.Generic.List[object] foreach ($c in @($Components)) { if ($null -ne $c) { $list.Add($c) } } foreach ($spec in @($Specs)) { if ($spec -notmatch '^\s*app:') { continue } $m = [regex]::Match($spec, '^\s*app:([a-z0-9_]+)\s*=\s*(\S+)\s*$') if (-not $m.Success) { throw "-SimulateVersion 형식이 틀림: '$spec' ('app:=<버전>')" } $key = $m.Groups[1].Value $ver = $m.Groups[2].Value $hits = @($list | Where-Object { $_.Kind -eq $key }) if ($hits.Count -eq 0) { $list.Add((New-AppComponent -Key $key -Name "$key 시험" -Raw $ver -Normalized $ver -Source '-SimulateVersion')) continue } foreach ($c in $hits) { $c.Source = "-SimulateVersion (설치: $($c.Raw))" $c.Raw = $ver $c.VersionText = $ver if ($c.Family -eq 'msapp') { $c.Version = ConvertTo-Ver $ver } # Store 앱 등은 v2 처럼 [version] 으로 비교 else { $c.AppVer = Split-AppVersion $ver } } } $list.ToArray() } # 앱 하나의 NVD 범위 (쪽을 다 받아 합친다). 받지 못해도 예외를 내지 않고 Found=$false 와 이유를 남긴다. function Get-AppBundle($Src, [string]$Key) { if ($Src.AppBundles.ContainsKey($Key)) { return $Src.AppBundles[$Key] } $rows = New-Object System.Collections.Generic.List[object] $b = [pscustomobject]@{ Rows = $rows; Coverage = ''; Unanalyzed = 0; Found = $true; Why = '' } try { if ($Src.Fixture) { $pages = $Src.Fixture.AppPages[$Key] if (-not $pages) { $b.Found = $false; $b.Why = '시험 자료 없음' } else { $first = @($pages.Values)[0] for ($i = 0; $i -lt [int]$first.pages; $i++) { if (-not $pages.ContainsKey($i)) { throw "시험 자료 쪽이 빠짐: $Key p$i" } foreach ($r in @($pages[$i].ranges)) { if ($null -ne $r) { $rows.Add($r) } } } $b.Coverage = [string](Get-Prop $first 'coverage') $b.Unanalyzed = [int](Get-Prop $first 'unanalyzed') } } else { $page = 0 do { $data = Invoke-Signed $Src.Cfg '/api/agent/win/apps/bundle' ([ordered]@{ app_key = $Key; page = $page }) -AllowNotFound if ($null -eq $data) { $b.Found = $false; $b.Why = '서버에 /win/apps/bundle 이 없음'; break } foreach ($r in @($data.ranges)) { if ($null -ne $r) { $rows.Add($r) } } $b.Coverage = [string](Get-Prop $data 'coverage') $b.Unanalyzed = [int](Get-Prop $data 'unanalyzed') $page++ } while ($page -lt [int]$data.pages) } } catch { $rows.Clear() $b.Found = $false $b.Why = "NVD 자료를 받지 못함: $($_.Exception.Message)" } $Src.AppBundles[$Key] = $b $b } # 행의 칸 하나 (서버가 칸을 덜 보내도 오류 없이 $null) function Get-Cell($Row, [int]$Index) { $a = @($Row) if ($Index -lt $a.Count) { return $a[$Index] } $null } function Format-AppRange($Row) { $p = @() if (Get-Cell $Row 1) { $p += "$(Get-Cell $Row 1) 이상" } if (Get-Cell $Row 2) { $p += "$(Get-Cell $Row 2) 초과" } if (Get-Cell $Row 3) { $p += "$(Get-Cell $Row 3) 이하" } if (Get-Cell $Row 4) { $p += "$(Get-Cell $Row 4) 미만" } if (Get-Cell $Row 5) { $p += "$(Get-Cell $Row 5) 판" } $p -join ' · ' } function New-RangeState([string]$State, [string]$Why) { [pscustomobject]@{ State = $State; Why = $Why } } # 행의 9번째 칸 cpe(운영 서버가 보냄)와 10번째 칸 published(NVD 공개일, 서버에 요청함 — DESIGN.md §4 S12). 없으면 '' / $null. function Get-RowCpe($Row) { $c = [string](Get-Cell $Row 8) if ($c -match ':') { return $c } '' } function Get-RowPublished($Row) { foreach ($i in 9, 8) { $c = [string](Get-Cell $Row $i) if ($c -match '^\d{4}-\d{2}-\d{2}') { return $c.Substring(0, 10) } } $null } # 범위 행 하나 [cve, start_incl, start_excl, end_incl, end_excl, ver_exact, kev, severity, cpe, published] 와 설치 버전 → in | out | unknown # 경계와 숫자 부분이 같은데 접미사(f1, b4 …)가 다르면 판단이 갈리므로 unknown. 한 경계라도 확실히 벗어나면 out. # ver_exact: '*'·'-' 는 unknown, 같은 판이면 in, 숫자가 같은 다른 빌드(beta 등)면 unknown, 숫자가 다르면 out. function Test-AppRange($V, $Row) { $exact = ([string](Get-Cell $Row 5)) -replace '\\(.)', '$1' if ($exact) { if ($exact -eq '*') { return (New-RangeState unknown 'NVD 가 모든 버전(*)으로만 적어 비교하지 않음') } if ($exact -eq '-') { return (New-RangeState unknown "NVD 버전 칸이 '-'(해당 없음)라 비교하지 않음") } $parts = $exact.Split([char]':', 2) $b = Split-AppVersion $parts[0] if (-not $b) { return (New-RangeState unknown "NVD 버전 형식을 읽을 수 없음: $exact") } if ($b.Num -ne $V.Num) { return (New-RangeState out '') } $sfx = $b.Suffix if ($parts.Count -gt 1) { $sfx += $parts[1].ToLowerInvariant() } if ($sfx -eq $V.Suffix) { return (New-RangeState in "NVD 가 취약하다고 적은 판 $exact 과 같음") } return (New-RangeState unknown "숫자가 같은 다른 빌드($exact)라 비교하지 않음") } $unknown = '' foreach ($bd in @(@(1, 'ge'), @(2, 'gt'), @(3, 'le'), @(4, 'lt'))) { $t = [string](Get-Cell $Row $bd[0]) if (-not $t) { continue } $b = Split-AppVersion $t if (-not $b) { if (-not $unknown) { $unknown = "범위 경계 형식을 읽을 수 없음: $t" } continue } if ($b.Num -eq $V.Num -and $b.Suffix -ne $V.Suffix) { if (-not $unknown) { $unknown = "접미사 때문에 경계 $t 에서 판단이 갈려 비교하지 않음" } continue } $cmp = $V.Num.CompareTo($b.Num) $ok = $false switch ($bd[1]) { 'ge' { $ok = ($cmp -ge 0) } 'gt' { $ok = ($cmp -gt 0) } 'le' { $ok = ($cmp -le 0) } 'lt' { $ok = ($cmp -lt 0) } } if (-not $ok) { return (New-RangeState out '') } } if ($unknown) { return (New-RangeState unknown $unknown) } New-RangeState in "NVD 취약 범위 안: $(Format-AppRange $Row)" } # 앱 판정 한 건. Month 칸에 NVD 공개일을 넣어 -OutFile 을 읽는 쪽이 '가장 최신 CVE'를 고를 수 있게 한다. function New-AppVerdict([string]$Cve, [string]$Verdict, [string]$Reason, [string]$Fixed, $FixedV, $Row, $Comp) { $sev = Get-Cell $Row 7 $pub = Get-RowPublished $Row $kev = [bool](Get-Cell $Row 6) $info = @() if ($sev) { $info += [string]$sev } if ($pub) { $info += "공개 $pub" } $cpe = Get-RowCpe $Row if ($cpe -and @($Comp.Cpes).Count -gt 1) { $info += "NVD $cpe" } # 앱 하나에 CPE 가 여럿이면 어느 쪽 범위인지 if ($info.Count) { $Reason += " ($($info -join ' · '))" } if ($Verdict -eq '취약' -and $kev) { $Reason = '[실제 악용 KEV] ' + $Reason } [pscustomobject]@{ Cve = $Cve; Verdict = $Verdict; Reason = $Reason; Fixed = $Fixed; FixedV = $FixedV Kb = $null; Month = $pub; Severity = $sev; Impact = $null; Comp = $Comp; Kev = $kev; Published = $pub } } # 앱 하나를 CVE 마다 판정한다 (DESIGN.md §8.4): 범위 행이 하나라도 안이면 취약, 아니고 비교 못 한 행이 있으면 점검불가, 모두 밖이면 양호. function Get-AppVerdicts($Comp, $Rows) { $byCve = @{} foreach ($r in $Rows) { # @($Rows) 는 쓰지 않는다: 5.1 은 List 를 @() 로 감싸면 내부 오류(ArgumentException)가 난다(DESIGN.md §6) if ($null -eq $r) { continue } $cve = [string](Get-Cell $r 0) if (-not $byCve.ContainsKey($cve)) { $byCve[$cve] = New-Object System.Collections.Generic.List[object] } $byCve[$cve].Add($r) } foreach ($cve in ($byCve.Keys | Sort-Object)) { $rows = $byCve[$cve].ToArray() if (-not $Comp.AppVer) { $why = "설치 버전을 NVD 형식으로 읽을 수 없음: $($Comp.Raw)" if ($Comp.Rule) { $why += " (읽는 규칙 $($Comp.Rule))" } New-AppVerdict $cve '점검불가' $why $null $null $rows[0] $Comp continue } $hit = $null $hitFix = $null $unk = $null foreach ($r in $rows) { $s = Test-AppRange $Comp.AppVer $r if ($s.State -eq 'in') { # 범위가 여럿 걸리면 고친 판이 가장 높은 것을 근거로 한다 $endText = [string](Get-Cell $r 4) if (-not $endText) { $endText = [string](Get-Cell $r 3) } $endV = $null if ($endText) { $sv = Split-AppVersion $endText; if ($sv) { $endV = $sv.Num } } if ($null -eq $hit -or ($null -ne $endV -and ($null -eq $hitFix -or $endV -gt $hitFix))) { $hit = @{ Row = $r; Why = $s.Why }; $hitFix = $endV } } elseif ($s.State -eq 'unknown' -and $null -eq $unk) { $unk = @{ Row = $r; Why = $s.Why } } } if ($hit) { $r = $hit.Row $fixed = $null if (Get-Cell $r 4) { $fixed = [string](Get-Cell $r 4); $hint = "$fixed 이상으로 업데이트 필요" } elseif (Get-Cell $r 3) { $fixed = "$(Get-Cell $r 3) 초과"; $hint = "$(Get-Cell $r 3) 보다 새 판으로 업데이트 필요" } else { $hint = '최신 판으로 업데이트 필요(NVD 에 고친 판이 적혀 있지 않음)' } New-AppVerdict $cve '취약' "$($hit.Why) — $hint" $fixed $hitFix $r $Comp } elseif ($unk) { New-AppVerdict $cve '점검불가' $unk.Why $null $null $unk.Row $Comp } else { New-AppVerdict $cve '양호' "NVD 취약 범위 $($rows.Count)개 모두 밖" $null $null $rows[0] $Comp } } } # v3 전체: 설치 목록 → 대조 → 앱 구성요소. 실패해도 v2 는 계속하게 예외를 내지 않고 Note 에 이유를 남긴다. function Get-AppCheck($Src, $Os = $null) { $r = [pscustomobject]@{ Programs = @(); Appx = @(); AppxNote = ''; Match = $null; Comps = @(); Note = ''; Coverage = '' } try { $r.Programs = @(Get-UninstallPrograms) } catch { $r.Note = "설치 목록을 읽지 못함: $($_.Exception.Message)"; return $r } $ax = Get-AppxPrograms $r.Appx = @($ax.Items) $r.AppxNote = $ax.Note $m = $null try { $m = Get-AppMatch $Src $r.Programs $r.Appx } catch { $r.Note = "다른 회사 프로그램 대조(/win/apps/match) 실패: $($_.Exception.Message)"; return $r } if ($null -eq $m) { if ($Src.Fixture) { $r.Note = '시험 자료에 매핑표(win_app_map.json)가 없음' } else { $r.Note = '서버에 v3 API(/win/apps/match)가 아직 없어 다른 회사 프로그램은 점검하지 않음' } return $r } $r.Match = $m $r.Coverage = [string](Get-Prop $m 'coverage') $r.Comps = @(ConvertTo-AppComponents $m $Os) $r } function Format-Counts($Counts) { (@('취약', '점검불가', '양호') | Where-Object { $Counts[$_] } | ForEach-Object { "$_ $($Counts[$_])" }) -join ' · ' } # 구성요소마다 판정하고 CVE 마다 합친다 function Invoke-Check($Src, $Components) { $items = New-Object System.Collections.Generic.List[object] $summaries = New-Object System.Collections.Generic.List[object] foreach ($c in $Components) { $s = [pscustomobject]@{ Comp = $c; Counts = @{}; Status = $c.Note; Needed = $null; Verdicts = @(); Unanalyzed = 0 } if ($c.Product -and $c.Family -eq 'msapp') { # 3.1: Store 앱 등 — 제품 이름이 여럿일 수 있어 따로 판정해 합친다. 이 판 이름이 없어 다른 판과 비교한 사실(Note)은 상태에 남긴다. $mv = Get-MsAppVerdicts $Src $c if ($mv.Why) { $s.Status = (@($mv.Why, $c.Note) | Where-Object { $_ }) -join ' · ' } elseif (-not $mv.Found) { $s.Status = (@('시험 자료 없음', $c.Note) | Where-Object { $_ }) -join ' · ' } elseif ($mv.Empty) { $s.Status = (@('MSRC 자료에 행 없음', $c.Note) | Where-Object { $_ }) -join ' · ' } else { $verdicts = @($mv.Verdicts) $s.Verdicts = $verdicts foreach ($v in $verdicts) { $s.Counts[$v.Verdict] = 1 + [int]$s.Counts[$v.Verdict]; $items.Add($v) } if ($verdicts.Count -eq 0) { $s.Status = (@('해당하는 수정 값 없음', $c.Note) | Where-Object { $_ }) -join ' · ' } $need = @($verdicts | Where-Object { $_.Verdict -eq '취약' } | Sort-Object { $_.FixedV }) if ($need.Count) { $s.Needed = $need[-1].Fixed } } } elseif ($c.Product) { $isApp = $c.Family -eq 'app' if ($isApp) { $b = Get-AppBundle $Src $c.Product } else { $b = Get-Bundle $Src $c.Product } if (-not $b.Found) { $s.Status = '시험 자료 없음' if ($isApp -and $b.Why) { $s.Status = $b.Why } } elseif ($b.Rows.Count -eq 0) { if ($isApp) { $s.Status = 'NVD 자료에 윈도우에 해당하는 범위 없음' } else { $s.Status = 'MSRC 자료에 행 없음' } } else { if ($isApp) { $verdicts = @(Get-AppVerdicts $c $b.Rows); $s.Unanalyzed = [int]$b.Unanalyzed } else { $verdicts = @(Get-ComponentVerdicts $c $b.Rows) } $s.Verdicts = $verdicts foreach ($v in $verdicts) { $s.Counts[$v.Verdict] = 1 + [int]$s.Counts[$v.Verdict]; $items.Add($v) } if ($verdicts.Count -eq 0) { $s.Status = '해당하는 수정 값 없음' } $need = @($verdicts | Where-Object { $_.Verdict -eq '취약' } | Sort-Object { $_.FixedV }) if ($need.Count) { $s.Needed = $need[-1].Fixed } } } $summaries.Add($s) } [pscustomobject]@{ Summaries = $summaries; Results = @(Merge-Verdicts $items) } } # ── 취약 CVE 풀이 자료 (3.2, DESIGN.md §4 S23) ── # 취약으로 판정한 CVE 마다 서버가 CWE·CVSS 벡터·NVD 설명 원문·MSRC 악용 상태를 준다(/win/cve/info, 서명 확인). # 판정·보고 행에는 쓰지 않고 -OutFile 결과 문서의 vulnerable 항목에만 info 로 붙인다(PC 보안 점검이 정해진 규칙으로 쉬운 말로 푼다). # 서버 값은 꼴을 확인해 아는 칸만 남긴다. 받지 못해도(서버에 아직 없음·오류·서명) 점검은 그대로 하고 cve_info.error 에 이유를 남긴다. # 취약으로 합친 CVE 번호(모든 가족: v2 MSRC·다른 회사 프로그램 NVD·msapp), 겹치지 않게 function Get-VulnerableCves($Check) { @($Check.Results | Where-Object { $_.verdict -eq '취약' } | ForEach-Object { [string]$_.cve_id } | Sort-Object -Unique) } function Test-JsonObject($Value) { $Value -is [System.Management.Automation.PSCustomObject] } function Test-JsonNumber($Value) { ($Value -is [int]) -or ($Value -is [long]) -or ($Value -is [double]) -or ($Value -is [decimal]) } # 서버 글 → 제어 문자를 지우고 줄인 글. 글이 아니거나 비면 $null. function ConvertTo-CleanText($Value, [int]$Max) { if (-not ($Value -is [string])) { return $null } $t = [regex]::Replace([regex]::Replace($Value, '[\p{Cc}\p{Cf}]+', ' '), '\s{2,}', ' ').Trim() if ($t.Length -gt $Max) { $t = $t.Substring(0, $Max) } if ($t) { $t } else { $null } } # CVE 한 칸 → 아는 칸만 꼴을 확인해 남긴 값. 꼴이 틀린 칸은 $null, 남은 것이 하나도 없으면 $null. # source 'nvd'|'msrc' · cwe 글 배열(CWE-번호·NVD-CWE-Other·NVD-CWE-noinfo, 40개까지) · cvss { version 5자까지, vector 200자·41칸까지, score 0~10 } # · desc 600자까지 · exploit { exploited·disclosed 참·거짓, assessment 80자까지 } # · epss { score 0~1, percentile 0~1, date YYYY-MM-DD } (3.3, §4 S24 — 30일 안에 실제 공격에 쓰일 확률, score 가 없으면 epss 전체 null) function ConvertTo-CveInfo($Raw) { if (-not (Test-JsonObject $Raw)) { return $null } $source = Get-Prop $Raw 'source' if (-not ($source -is [string] -and $source -cin @('nvd', 'msrc'))) { $source = $null } $cwe = $null # Get-Prop 은 함수라 돌려줄 때 배열을 푼다(빈 배열 → $null, 한 개 → 글 하나). 배열 칸은 속성 값을 바로 읽는다. $rc = $null $rcp = $Raw.PSObject.Properties['cwe'] if ($rcp) { $rc = $rcp.Value } # 서버는 개수가 아니라 칸 길이(200자)로 묶는다 — 40개까지 받는다. 서버는 모르면 빈 목록 대신 null 을 준다. if ($rc -is [array]) { $cwe = @($rc | Where-Object { $_ -is [string] -and $_ -cmatch '^(CWE-\d{1,5}|NVD-CWE-(Other|noinfo))\z' } | Select-Object -First $CveInfoCweMax) } $cvss = $null $rv = Get-Prop $Raw 'cvss' if (Test-JsonObject $rv) { $ver = Get-Prop $rv 'version' if (-not ($ver -is [string] -and $ver.Length -ge 1 -and $ver.Length -le 5)) { $ver = $null } # NVD 의 CVSS 4.0 벡터는 기본·위협·환경·보충 칸 32개를 모두 적고(…/E:X/CR:X/…/U:X, 최대 178자), MSRC 벡터는 시간 칸(E:F/RL:O/RC:C)이 붙는다. # 칸 이름 1~3글자, 값 1~6글자(4.0 보충 U:Clear·Green·Amber·Red), 41칸까지, 200자까지(서버 한도, S23 서버 완료 09:4x). $vec = Get-Prop $rv 'vector' if (-not ($vec -is [string] -and $vec.Length -le 200 -and $vec -cmatch '^(CVSS:\d\.\d/)?[A-Za-z]{1,3}:[A-Za-z]{1,6}(/[A-Za-z]{1,3}:[A-Za-z]{1,6}){2,40}\z')) { $vec = $null } $sc = Get-Prop $rv 'score' if ((Test-JsonNumber $sc) -and [double]$sc -ge 0 -and [double]$sc -le 10) { $sc = [double]$sc } else { $sc = $null } if ($null -ne $ver -or $null -ne $vec -or $null -ne $sc) { $cvss = [ordered]@{ version = $ver; vector = $vec; score = $sc } } } $exploit = $null $rx = Get-Prop $Raw 'exploit' if (Test-JsonObject $rx) { $ex = Get-Prop $rx 'exploited' if (-not ($ex -is [bool])) { $ex = $null } $di = Get-Prop $rx 'disclosed' if (-not ($di -is [bool])) { $di = $null } $as = Get-Prop $rx 'assessment' if ($as -is [string] -and $as.Length -le $CveInfoAssessMax) { $as = ConvertTo-CleanText $as $CveInfoAssessMax } else { $as = $null } if ($null -ne $ex -or $null -ne $di -or $null -ne $as) { $exploit = [ordered]@{ exploited = $ex; disclosed = $di; assessment = $as } } } $epss = $null $rp = Get-Prop $Raw 'epss' if (Test-JsonObject $rp) { $es = Get-Prop $rp 'score' if ((Test-JsonNumber $es) -and [double]$es -ge 0 -and [double]$es -le 1) { $es = [double]$es } else { $es = $null } $ep = Get-Prop $rp 'percentile' if ((Test-JsonNumber $ep) -and [double]$ep -ge 0 -and [double]$ep -le 1) { $ep = [double]$ep } else { $ep = $null } # PowerShell 7 의 ConvertFrom-Json 은 날짜 꼴 글을 날짜 값으로 바꿀 수 있어 둘 다 받는다 $ed = Get-Prop $rp 'date' if ($ed -is [datetime]) { $ed = $ed.ToString('yyyy-MM-dd', [Globalization.CultureInfo]::InvariantCulture) } if (-not ($ed -is [string] -and $ed -cmatch '^\d{4}-\d{2}-\d{2}\z')) { $ed = $null } if ($null -ne $es) { $epss = [ordered]@{ score = $es; percentile = $ep; date = $ed } } } $desc = ConvertTo-CleanText (Get-Prop $Raw 'desc') $CveInfoDescMax # @($null).Count 는 1 이므로 배열 길이는 $null 을 먼저 본다 $noCwe = ($null -eq $cwe) -or ($cwe.Count -eq 0) if ($null -eq $source -and $noCwe -and $null -eq $cvss -and $null -eq $desc -and $null -eq $exploit -and $null -eq $epss) { return $null } [ordered]@{ source = $source; cwe = $cwe; cvss = $cvss; desc = $desc; exploit = $exploit; epss = $epss } } # 응답 하나를 표에 더한다. {"CVE-…": {...}} 또는 {"ok": true, "info": {"CVE-…": {...}}}. 묻지 않은 CVE 는 버린다. function Add-CveInfo([hashtable]$Map, $Response, [hashtable]$Asked) { if (-not (Test-JsonObject $Response)) { return } $body = $Response $wrapped = Get-Prop $Response 'info' if (Test-JsonObject $wrapped) { $body = $wrapped } foreach ($p in $body.PSObject.Properties) { if (-not $Asked.ContainsKey($p.Name)) { continue } $info = ConvertTo-CveInfo $p.Value if ($null -ne $info) { $Map[$p.Name] = $info } } } # 오류 글 → 첫 줄 200자까지(서버가 HTML 오류 쪽을 돌려줄 때 결과 문서가 커지지 않게) function Limit-CveInfoError([string]$Text) { $t = ([string]$Text -split "`r?`n")[0].Trim() if ($t.Length -gt 200) { $t = $t.Substring(0, 199) + '…' } $t } # 취약 CVE 들의 풀이 자료. 서버에는 $CveInfoChunk 개씩 묻고, 시험 자료 모드는 fixtures\cve_info.json 을 읽는다. # 결과: Map(CVE → 남긴 값), Asked(물은 수), Got(받은 수), Error(받지 못한 이유, 없으면 $null). 앞 쪽은 받고 뒤 쪽에서 실패하면 받은 것은 남긴다. function Get-CveInfoMap($Src, $Cves) { $map = @{} $asked = @{} foreach ($c in @($Cves)) { if ($c) { $asked[[string]$c] = $true } } $err = $null if ($asked.Count -gt 0) { if ($Src.Fixture) { $fx = Get-Prop $Src.Fixture 'CveInfo' if ($null -eq $fx) { $err = '시험 자료 없음(cve_info.json)' } else { Add-CveInfo $map $fx $asked } } else { $list = @($asked.Keys | Sort-Object) for ($i = 0; $i -lt $list.Count; $i += $CveInfoChunk) { $chunk = @($list[$i..([Math]::Min($i + $CveInfoChunk, $list.Count) - 1)]) $resp = $null try { $resp = Invoke-Signed $Src.Cfg '/api/agent/win/cve/info' ([ordered]@{ cves = $chunk }) -AllowNotFound } catch { $err = Limit-CveInfoError $_.Exception.Message; break } if ($null -eq $resp) { $err = '서버에 /win/cve/info 가 아직 없음(404)'; break } Add-CveInfo $map $resp $asked } } } [pscustomobject]@{ Map = $map; Asked = $asked.Count; Got = $map.Count; Error = $err } } # 결과 문서의 cve_info 칸. 묻지 않았으면(-OutFile 없음) $null. function New-CveInfoDoc($CveInfo) { if ($null -eq $CveInfo) { return $null } [ordered]@{ asked = [int]$CveInfo.Asked; got = [int]$CveInfo.Got; error = $CveInfo.Error } } # ── 판 묶음 지원 기간 (3.1, DESIGN.md §4 S14) ── # 서버가 endoflife.date 자료로 맞은 앱마다 cycle(판 묶음, 일반 지원 끝 support, 지원 끝 eol, rapid, move_to, no_installer …)을 붙인다. # 판정은 여기서 오늘 날짜로 한다. # 날짜 칸 'YYYY-MM-DD' → [datetime] (없거나 날짜가 아니면 $null). # 있을 수 없는 날짜(2026-02-30)도 예외 없이 $null — 화면·결과 문서용 값 하나 때문에 점검·보고 전체가 멈추지 않게(리뷰 2026-10-17). function ConvertTo-CycleDate($Value) { if ($null -eq $Value) { return $null } if ($Value -is [datetime]) { return $Value.Date } $t = [string]$Value if ($t -notmatch '^\d{4}-\d{2}-\d{2}') { return $null } $d = [datetime]::MinValue if (-not [datetime]::TryParseExact($t.Substring(0, 10), 'yyyy-MM-dd', [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::None, [ref]$d)) { return $null } $d } # 서버 cycle 그대로에 PC 판정 state·days_left 를 더한다. 서버가 cycle: null(제품 자료 없음)이면 $null. # eol: is_eol 이거나 오늘 > eol · eol_soon: rapid 가 아니고 eol 까지 $EolSoonDays 일 안 · security_only: 오늘 > support(일반 지원 끝) # · supported · unknown: 판 묶음을 못 찾음(cycle.cycle 이 null). days_left = eol 까지 남은 날(지났으면 음수), eol 날짜가 없으면 null. function Get-CycleView($Cycle, $Today = $CycleToday) { if ($null -eq $Cycle) { return $null } $o = [ordered]@{} foreach ($p in $Cycle.PSObject.Properties) { $o[$p.Name] = $p.Value } $state = 'supported' $days = $null if (-not (Get-Prop $Cycle 'cycle')) { $state = 'unknown' } else { $today = ([datetime]$Today).Date $eolValue = Get-Prop $Cycle 'eol' $supportValue = Get-Prop $Cycle 'support' $eol = ConvertTo-CycleDate $eolValue $support = ConvertTo-CycleDate $supportValue # 날짜 칸에 값이 있는데 날짜로 읽지 못하면(2026-02-30 등) 지원 중이라고 하지 않고 unknown $badEol = ($null -ne $eolValue -and -not ($eolValue -is [bool]) -and [string]$eolValue -ne '' -and $null -eq $eol) $badSupport = ($null -ne $supportValue -and -not ($supportValue -is [bool]) -and [string]$supportValue -ne '' -and $null -eq $support) if ($eol) { $days = [int][Math]::Floor(($eol - $today).TotalDays) } if ((Get-Prop $Cycle 'is_eol') -eq $true -or ($eolValue -is [bool] -and $eolValue)) { $state = 'eol' } elseif ($badEol -or $badSupport) { $state = 'unknown' } elseif ($eol -and $today -gt $eol) { $state = 'eol' } elseif ($eol -and -not [bool](Get-Prop $Cycle 'rapid') -and $days -le $EolSoonDays) { $state = 'eol_soon' } elseif ($support -and $today -gt $support) { $state = 'security_only' } } $o['state'] = $state $o['days_left'] = $days $o } # 화면 한 줄 (지원 끝·곧 끝남·보안 수정만일 때만, 아니면 '') function Format-CycleLine($View) { if ($null -eq $View) { return '' } $move = $View['move_to'] $to = '' if ($move -and (Get-Prop $move 'cycle')) { $to = " → $(Get-Prop $move 'cycle')로 옮김" } switch ([string]$View['state']) { 'eol' { $t = '지원 끝' if ($View['eol'] -and -not ($View['eol'] -is [bool])) { $t += " $($View['eol'])" } return ($t + $to) } 'eol_soon' { # 보안 수정만 받는 판이 곧 끝나면(Python 3.10 등) 설치 파일이 없다는 것도 알린다(리뷰 2026-10-17, 서버 S18 문구는 §4 S20) $t = "지원 곧 끝남 $($View['days_left'])일 뒤($($View['eol']))" if ($View['no_installer'] -eq $true) { $t += ' · Windows 설치 파일 없음' } return ($t + $to) } 'security_only' { $t = '보안 수정만' if ($View['support']) { $t += "(일반 지원 $($View['support']) 끝)" } if ($View['no_installer'] -eq $true) { $t += ' · Windows 설치 파일 없음' } return ($t + $to) } } '' } # -OutFile 결과 문서 (형식 1). PC 보안 점검(pc_audit)이 점검 때 이 에이전트를 -DryRun -OutFile 로 불러 읽는다. # 비밀값·host_id 는 넣지 않는다. 크기를 줄이려고 합친 행은 양호가 아닌 것만 넣는다(양호는 개수만). # v3 은 형식 1 에 더하기만 한다: 앱 구성요소(family 'app', basis 'nvd', cpes), 그 CVE 의 kev·published(month 에도 공개일), not_ok 의 basis, # 그리고 programs(설치 목록 요약). v2 만 읽는 쪽은 모르는 칸을 무시하면 된다. # 3.1 도 더하기만 한다: app·msapp 구성요소의 cycle(서버 값 + state·days_left)·links, msapp 구성요소(family 'msapp', basis 'msrc', # installed_version, package, os_match, compared_products), programs.matched_list 의 basis. # 3.2 도 더하기만 한다: vulnerable 항목의 info(풀이 자료가 있을 때만), 맨 위 cve_info { asked, got, error }. function New-ResultDoc([string]$Mode, [string]$OsProduct, [string]$Coverage, $Check, $Apps = $null, $CveInfo = $null) { $en = @{ '취약' = 'vulnerable'; '점검불가' = 'unknown'; '양호' = 'ok' } $totals = [ordered]@{ cves = @($Check.Results).Count; vulnerable = 0; unknown = 0; ok = 0 } foreach ($r in $Check.Results) { $totals[$en[$r.verdict]]++ } [ordered]@{ format = 1; agent_version = $AgentVersion; checked_at = (Get-Date).ToString('yyyy-MM-ddTHH:mm:sszzz'); mode = $Mode os_product = $OsProduct; coverage = $Coverage; error = $null totals = $totals components = @($Check.Summaries | ForEach-Object { $s = $_ $isApp = $s.Comp.Family -eq 'app' $counts = [ordered]@{ vulnerable = [int]$s.Counts['취약']; unknown = [int]$s.Counts['점검불가']; ok = [int]$s.Counts['양호'] } $o = [ordered]@{ family = $s.Comp.Family; kind = $s.Comp.Kind; name = $s.Comp.Name; version = $s.Comp.VersionText product = $s.Comp.Product; source = $s.Comp.Source; status = $s.Status; counts = $counts; needed = $s.Needed vulnerable = @($s.Verdicts | Where-Object { $_.Verdict -eq '취약' } | ForEach-Object { $e = [ordered]@{ cve = $_.Cve; fixed = $_.Fixed; severity = $_.Severity; impact = $_.Impact; month = $_.Month; kb = $_.Kb } if ($isApp) { $e['kev'] = [bool]$_.Kev; $e['published'] = $_.Published } if ($null -ne $CveInfo -and $CveInfo.Map.ContainsKey([string]$_.Cve)) { $e['info'] = $CveInfo.Map[[string]$_.Cve] } $e }) unknown = @($s.Verdicts | Where-Object { $_.Verdict -eq '점검불가' } | ForEach-Object { $e = [ordered]@{ cve = $_.Cve; reason = $_.Reason } if ($isApp) { $e['month'] = $_.Published } $e }) } if ($isApp) { $o['basis'] = 'nvd'; $o['cpes'] = @($s.Comp.Cpes); $o['installed_version'] = $s.Comp.Raw; $o['group'] = $s.Comp.Group $o['unanalyzed'] = [int]$s.Unanalyzed } if ($s.Comp.Family -eq 'msapp') { $o['basis'] = 'msrc'; $o['installed_version'] = $s.Comp.Raw; $o['package'] = $s.Comp.Package; $o['os_match'] = [bool]$s.Comp.OsMatch if (-not $s.Comp.OsMatch) { $o['compared_products'] = [int]$s.Comp.Compared } } if ($isApp -or $s.Comp.Family -eq 'msapp') { $o['cycle'] = Get-CycleView $s.Comp.Cycle $o['links'] = $s.Comp.Links } else { # S16: v2 구성요소도 제품 이름의 지원 기간(없으면 null). links 는 없다. OS 는 어느 기간을 골랐는지(cycle·cycle_enterprise)와 EditionID. $o['cycle'] = Get-CycleView (Get-Prop $s.Comp 'Cycle') $cs = [string](Get-Prop $s.Comp 'CycleSource') if ($cs) { $o['cycle_source'] = $cs } if ($s.Comp.Family -eq 'os') { $o['edition'] = Get-Prop $s.Comp 'Edition' } } $o }) not_ok = @($Check.Results | Where-Object { $_.verdict -ne '양호' } | ForEach-Object { [ordered]@{ cve_id = $_.cve_id; verdict = $_.verdict; detected = $_.detected; reason = $_.reason; basis = $_.basis } }) programs = (New-ProgramsDoc $Apps) cve_info = (New-CveInfoDoc $CveInfo) } } # 설치 목록 요약 (v3). -NoApps 면 $null. function New-ProgramsDoc($Apps) { if ($null -eq $Apps) { return $null } $m = $Apps.Match $doc = [ordered]@{ checked = ($null -ne $m); note = $Apps.Note; coverage = $Apps.Coverage installed = @($Apps.Programs).Count; appx = @($Apps.Appx).Count; appx_note = $Apps.AppxNote matched = 0; unmatched = 0; no_version = 0 matched_list = @(); unmatched_list = @(); no_version_list = @() } if ($null -ne $m) { $doc.matched_list = @(@($m.matched) | Where-Object { $null -ne $_ } | ForEach-Object { $bs = [string](Get-Prop $_ 'basis') if (-not $bs) { $bs = 'nvd' } [ordered]@{ app_key = $_.app_key; label = (Get-Prop $_ 'label'); name = $_.name; version = $_.version; group = (Get-Prop $_ 'group'); basis = $bs } }) $doc.unmatched_list = @(@($m.unmatched) | Where-Object { $null -ne $_ } | ForEach-Object { [ordered]@{ name = $_.name; version = $_.version; why = $_.why } }) $doc.no_version_list = @(@($m.no_version) | Where-Object { $_ }) $doc.matched = @($doc.matched_list).Count $doc.unmatched = @($doc.unmatched_list).Count $doc.no_version = @($doc.no_version_list).Count } $doc } function Write-ResultDoc($Doc) { [IO.File]::WriteAllText($OutFile, ($Doc | ConvertTo-Json -Depth 8), (New-Object Text.UTF8Encoding $false)) } function Invoke-SosigAgent { $fixture = [bool]$FixtureDir $cfg = $null $dir = $null if ($fixture) { $dir = $FixtureDir if (-not (Test-Path -LiteralPath $dir) -and $PSScriptRoot) { $dir = Join-Path $PSScriptRoot $FixtureDir } $dir = (Resolve-Path -LiteralPath $dir).Path } else { $cfg = Read-AgentConfig $Config } $src = New-DataSource $cfg $dir $os = Get-OsFacts $osProduct = Get-MsrcProduct $os Say ("이 PC: {0} {1} · 빌드 10.0.{2}.{3} · {4}" -f $os.product_name, $os.display_version, $os.build, $os.ubr, $os.arch) if ($fixture) { Say "시험 자료: $dir (서명 확인 없음, 보고하지 않음)" } $plist = Get-ProductList $src $names = $null $coverage = '' if ($plist) { $names = $plist.Names; $coverage = $plist.Coverage } else { Say '앱 제품 목록(/win/products)을 받지 못해, 이름을 바로 만들 수 있는 앱만 점검합니다.' } $components = @(Get-AllComponents $os $osProduct $names) if ($SimulateVersion) { $components = @(Set-SimulatedVersions $components $SimulateVersion $names) } $components = @(Resolve-Products $components $names) # S16: v2 구성요소(OS 포함)에 제품 이름의 판 묶음 지원 기간을 붙인다(결과 문서용) $cycles = $null if ($plist) { $cycles = $plist.Cycles } $components = @(Add-ComponentCycles $components $cycles $os) # v3: 다른 회사 프로그램. 대조가 안 되면(서버에 API 없음 등) 이유만 남기고 v2 는 그대로 한다. $apps = $null if (-not $NoApps) { $apps = Get-AppCheck $src $os if ($apps.Match -and $SimulateVersion) { $apps.Comps = @(Set-SimulatedApps $apps.Comps $SimulateVersion) } $components = @($components) + @($apps.Comps) } $check = Invoke-Check $src $components if ($apps) { $ab = @($src.AppBundles.Values | Where-Object { $_.Coverage }) if ($ab.Count) { $apps.Coverage = $ab[0].Coverage } } Say "구성요소 $($components.Count)개" foreach ($s in $check.Summaries) { $c = $s.Comp $head = "$($c.Name) $($c.VersionText)".Trim() if ($c.Family -eq 'app') { $head += " · NVD $((@($c.Cpes) -join ', ') -replace '\\', '')" } elseif ($c.Product) { $head += " · $($c.Product)" if ($c.Family -eq 'msapp' -and @($c.Products).Count -gt 1) { $head += " 외 $(@($c.Products).Count - 1)개" } } $what = Format-Counts $s.Counts if ($s.Needed -and ([string]$s.Needed).EndsWith(' 초과')) { $what += " — 모두 고치려면 $(([string]$s.Needed).Replace(' 초과', '')) 보다 새 판" } elseif ($s.Needed) { $what += " — 모두 고치려면 $($s.Needed) 이상" } if ($s.Status) { $what = (@($what, $s.Status) | Where-Object { $_ }) -join ' · ' } Say " - ${head}: $what" $cl = Format-CycleLine (Get-CycleView (Get-Prop $c 'Cycle')) if ($cl) { Say " $cl" } } if ($apps) { if ($apps.Match) { $pd = New-ProgramsDoc $apps $nvdComps = @($apps.Comps | Where-Object { $_.Family -eq 'app' }) $nd = @($nvdComps | Where-Object { -not $_.AppVer }).Count $ms = @($apps.Comps | Where-Object { $_.Family -eq 'msapp' }) $msNear = @($ms | Where-Object { -not $_.OsMatch }).Count Say ("설치 프로그램 {0}개 · Store 앱 {1}개 — NVD 대조 {2}개(버전을 못 읽은 것 {3}) · MSRC 대조 {4}개(이 Windows 판 이름이 없어 다른 판과 비교 {5}) · 점검 자료 없음 {6}개 · 버전 없음 {7}개 (자료 {8})" -f $pd.installed, $pd.appx, $nvdComps.Count, $nd, $ms.Count, $msNear, $pd.unmatched, $pd.no_version, $apps.Coverage) } else { Say "다른 회사 프로그램: $($apps.Note)" } if ($apps.AppxNote) { Say " $($apps.AppxNote)" } } if (-not $coverage) { $b = @($src.Bundles.Values | Where-Object { $_.Coverage }) if ($b.Count) { $coverage = $b[0].Coverage } } $results = $check.Results $count = @{} foreach ($r in $results) { $count[$r.verdict] = 1 + [int]$count[$r.verdict] } Say ("판정: CVE {0}개 — 취약 {1} · 양호 {2} · 점검불가 {3} (자료 {4})" -f $results.Count, [int]$count['취약'], [int]$count['양호'], [int]$count['점검불가'], $coverage) if ($Detail) { foreach ($r in ($results | Where-Object { $_.verdict -ne '양호' })) { Say " $($r.cve_id) [$($r.verdict)] $($r.reason)" } } if ($OutFile) { # 3.2(S23): 취약 CVE 풀이 자료. 결과 문서에만 쓰므로 -OutFile 일 때만 묻는다(-DryRun 도 읽기만). 어떤 실패도 점검을 멈추지 않는다. try { $cveInfo = Get-CveInfoMap $src (Get-VulnerableCves $check) } catch { $cveInfo = [pscustomobject]@{ Map = @{}; Asked = @(Get-VulnerableCves $check).Count; Got = 0; Error = (Limit-CveInfoError $_.Exception.Message) } } if ($cveInfo.Error) { Say "CVE 풀이 자료를 받지 못함: $($cveInfo.Error)" } elseif ($cveInfo.Asked) { Say "CVE 풀이 자료: 취약 CVE $($cveInfo.Asked)개 중 $($cveInfo.Got)개" } $mode = if ($fixture) { 'fixture' } elseif ($DryRun) { 'dry_run' } else { 'report' } $doc = New-ResultDoc $mode $osProduct $coverage $check $apps $cveInfo Write-ResultDoc $doc Say "결과 파일: $OutFile" } if ($results.Count -eq 0) { Say '판정할 CVE 가 없습니다.'; $script:ExitCode = 2; return } if ($fixture -or $DryRun) { Say '보고하지 않았습니다(-DryRun 또는 -FixtureDir).'; return } $sent = 0 for ($i = 0; $i -lt $results.Count; $i += $ReportChunk) { # basis 는 NVD(v3) 판정일 때만 붙인다 — v2 만 쓰는 PC 의 보고는 전과 같다(서버 기본값 msrc) $chunk = @($results[$i..([Math]::Min($i + $ReportChunk, $results.Count) - 1)] | ForEach-Object { $row = [ordered]@{ cve_id = $_.cve_id; verdict = $_.verdict; detected = $_.detected; reason = $_.reason } if ($_.basis -eq 'nvd') { $row['basis'] = 'nvd' } $row }) $answer = Invoke-Signed $cfg '/api/agent/win/report' ([ordered]@{ hostname = $os.hostname; product = [string]$osProduct; os_build = "10.0.$($os.build).$($os.ubr)"; results = $chunk }) $sent += [int]$answer.stored } Say "보고: $sent 건 저장됨" } # 점 찍어 불러오면(. .\sosig_win_agent.ps1) 함수만 정의한다 — tests\run_tests.ps1 이 이렇게 쓴다 if ($MyInvocation.InvocationName -ne '.') { try { Invoke-SosigAgent exit $script:ExitCode } catch { $ErrorActionPreference = 'Continue' $message = $_.Exception.Message # 결과 파일을 기다리는 쪽(PC 보안 점검)이 이유를 알 수 있게 실패도 남긴다 if ($OutFile) { try { Write-ResultDoc ([ordered]@{ format = 1; agent_version = $AgentVersion; checked_at = (Get-Date).ToString('yyyy-MM-ddTHH:mm:sszzz'); error = $message }) } catch { } } Write-Error "sosig Windows 점검 실패: $message" exit 1 } }