<# .SYNOPSIS CYNAPSE Exposure Engine discovery collector (command-line edition). .DESCRIPTION Collects read-only security evidence from this Windows host and writes a JSON package that you upload to CYNAPSE Exposure Engine. It is a plain PowerShell script so you can read every check before running it. What it does: - Reads operating system, installed software, patches, services, processes, local accounts, firewall, endpoint protection, listening ports, firmware and the last 24 hours of event log summaries. - Optionally probes an authorized network range with plain TCP connects (no exploits, no credentials, no payloads). What it does not do: - It changes nothing on the host and installs nothing. - It sends nothing anywhere. The JSON file stays on disk until you upload it yourself. - It never stores passwords or keys in the package. Run as administrator for complete evidence (Security event log, TPM). Without elevation it still runs and records what was limited. .EXAMPLE powershell -ExecutionPolicy Bypass -File .\cynapse-discovery.ps1 Collects local evidence with the OT Safe profile. .EXAMPLE powershell -ExecutionPolicy Bypass -File .\cynapse-discovery.ps1 -Mode both -Cidr 10.20.30.0/24 -AuthorizedScope Local evidence plus an authorized TCP discovery of 10.20.30.0/24. .NOTES Output: cynapse-discovery--.json in the current folder (or the path given with -Out). Upload it on the Exposure Engine page. #> [CmdletBinding()] param( [ValidateSet('local', 'network', 'both')] [string]$Mode = 'local', [ValidateSet('passive', 'ot_safe', 'standard_it', 'deep_audit')] [string]$ScanProfile = 'ot_safe', # Authorized IPv4 CIDR for network mode. Defaults to this host's /24. [string]$Cidr = '', # Comma-separated CIDRs or IPs that must never be probed. [string]$Exclude = '', # Comma-separated ports or ranges (e.g. 22,80,443,8000-8010). Defaults to the profile's ports. [string]$Ports = '', [int]$TimeoutMs = 350, [int]$MaxHosts = 1024, # Required for network mode: confirms you are authorized to scan the range. [switch]$AuthorizedScope, [string]$AssetOwner = '', [ValidateSet('Mission Critical', 'High', 'Medium', 'Low', 'Unclassified')] [string]$BusinessCriticality = 'Unclassified', [ValidateSet('IT', 'OT', 'IIoT', 'Server', 'Workstation')] [string]$Environment = 'IT', [string]$DeviceType = 'Workstation / Endpoint', [string]$ZoneConduit = '', [ValidateSet('Internet Facing', 'Vendor Reachable', 'Internal', 'Low Exposure', 'Air-Gapped', 'Requires Network Path Validation')] [string]$InternetExposure = 'Requires Network Path Validation', [string]$Out = '' ) $ErrorActionPreference = 'Continue' $ProgressPreference = 'SilentlyContinue' $CollectorVersion = '1.1.0' # --------------------------------------------------------------------------- # Profiles and labels (same as the desktop agent) # --------------------------------------------------------------------------- $DefaultPorts = @(22, 53, 80, 135, 139, 443, 445, 502, 554, 1883, 5683, 8883, 3389, 5900, 8080, 8443) $Profiles = @{ passive = @{ label = 'Passive'; active = $false; rate = 2; ports = @() } ot_safe = @{ label = 'OT Safe'; active = $true; rate = 8; ports = @(22, 80, 443, 502, 102, 44818, 20000, 47808) } standard_it = @{ label = 'Standard IT'; active = $true; rate = 40; ports = $DefaultPorts + @(5985, 5986, 9100, 161) } deep_audit = @{ label = 'Deep Audit'; active = $true; rate = 60; ports = $DefaultPorts + @(5985, 5986, 9100, 161) } } $PortLabels = @{ 22 = 'SSH'; 53 = 'DNS'; 80 = 'HTTP'; 135 = 'RPC'; 139 = 'NetBIOS'; 443 = 'HTTPS'; 445 = 'SMB' 502 = 'Modbus/TCP'; 554 = 'RTSP'; 1883 = 'MQTT'; 5683 = 'CoAP'; 8883 = 'MQTT over TLS' 3389 = 'RDP'; 5900 = 'VNC'; 8080 = 'Web Management'; 8443 = 'Secure Web Management' } $SuspiciousServiceTokens = @('ransom', 'crypt', 'miner', 'xmrig', 'mimikatz', 'psexec', 'cobalt', 'meterpreter', 'darkside', 'lockbit') # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- function Get-UtcNow { (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') } function ConvertTo-IsoDate($value) { if ($null -eq $value -or "$value" -eq '') { return $null } if ($value -is [datetime]) { return $value.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') } return "$value" } function Get-StableId([string]$value) { $sha = [System.Security.Cryptography.SHA256]::Create() $bytes = $sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes("$value")) return (($bytes | ForEach-Object { $_.ToString('x2') }) -join '').Substring(0, 16) } function Get-CompactText($value, [int]$limit = 500) { $text = ("$value" -replace '\s+', ' ').Trim() if ($text.Length -gt $limit) { $text = $text.Substring(0, $limit).TrimEnd() } return $text } function Test-IsWindows { if ($PSVersionTable.PSVersion.Major -lt 6) { return $true } return [bool]$IsWindows } function Test-IsElevated { if (-not (Test-IsWindows)) { return $false } $identity = [Security.Principal.WindowsIdentity]::GetCurrent() return ([Security.Principal.WindowsPrincipal]$identity).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } $script:AuditLog = New-Object System.Collections.ArrayList # Runs one collector, records duration and any limitation in the evidence trail. function Invoke-Collector([string]$name, [string]$task, [scriptblock]$collector, $fallback) { Write-Host (" - {0}" -f $task) $watch = [System.Diagnostics.Stopwatch]::StartNew() try { $value = & $collector [void]$script:AuditLog.Add([ordered]@{ collector = $name; status = 'completed'; duration_ms = [int]$watch.ElapsedMilliseconds }) if ($null -eq $value) { return $fallback } return $value } catch { [void]$script:AuditLog.Add([ordered]@{ collector = $name; status = 'limited'; message = (Get-CompactText $_.Exception.Message 180); duration_ms = [int]$watch.ElapsedMilliseconds }) return $fallback } } # --------------------------------------------------------------------------- # IPv4 scope helpers # --------------------------------------------------------------------------- function ConvertTo-UInt32Ip([string]$ip) { $bytes = ([System.Net.IPAddress]::Parse($ip)).GetAddressBytes() [array]::Reverse($bytes) return [BitConverter]::ToUInt32($bytes, 0) } function ConvertFrom-UInt32Ip([uint32]$value) { $bytes = [BitConverter]::GetBytes($value) [array]::Reverse($bytes) return ([System.Net.IPAddress]$bytes).ToString() } function Get-CidrRange([string]$cidr) { $parts = $cidr.Trim().Split('/') $prefix = 32 if ($parts.Count -gt 1) { $prefix = [int]$parts[1] } if ($prefix -lt 0 -or $prefix -gt 32) { throw "Invalid prefix in $cidr" } $base = ConvertTo-UInt32Ip $parts[0] $size = [uint64][math]::Pow(2, 32 - $prefix) $mask = [uint32]([uint64]4294967296 - $size) $network = [uint32]($base -band $mask) return @{ start = [uint64]$network; size = $size; prefix = $prefix } } function Test-InRanges([uint64]$value, $ranges) { foreach ($range in $ranges) { if ($value -ge $range.start -and $value -lt ($range.start + $range.size)) { return $true } } return $false } function Get-ExclusionRanges([string]$raw) { $ranges = @() foreach ($item in ("$raw" -split ',')) { $item = $item.Trim() if (-not $item) { continue } try { $ranges += Get-CidrRange $item } catch { } } return , $ranges } function Get-ScopeHosts([string]$cidr, [int]$maxHosts, [string]$exclude) { $range = Get-CidrRange $cidr if ($range.size -gt [math]::Max($maxHosts + 2, 4098)) { throw 'Scope is larger than the configured safe host limit.' } $excluded = Get-ExclusionRanges $exclude $first = $range.start; $last = $range.start + $range.size - 1 if ($range.size -gt 2) { $first += 1; $last -= 1 } $hosts = New-Object System.Collections.ArrayList for ($value = $first; $value -le $last -and $hosts.Count -lt $maxHosts; $value++) { if (-not (Test-InRanges $value $excluded)) { [void]$hosts.Add((ConvertFrom-UInt32Ip ([uint32]$value))) } } return , $hosts } function Get-PortList([string]$raw, $fallback) { if (-not $raw) { return , @($fallback) } $list = New-Object System.Collections.Generic.SortedSet[int] foreach ($item in ($raw -split ',')) { $item = $item.Trim() if (-not $item) { continue } if ($item -match '^(\d+)-(\d+)$') { for ($p = [int]$Matches[1]; $p -le [int]$Matches[2]; $p++) { if ($p -gt 0 -and $p -lt 65536) { [void]$list.Add($p) } } } elseif ($item -match '^\d+$' -and [int]$item -gt 0 -and [int]$item -lt 65536) { [void]$list.Add([int]$item) } } return , @($list) } function Get-PrimaryIPv4 { try { $udp = New-Object System.Net.Sockets.UdpClient $udp.Connect('8.8.8.8', 80) # no packet is sent; selects the outbound interface $ip = $udp.Client.LocalEndPoint.Address.ToString() $udp.Close() return $ip } catch { return '127.0.0.1' } } # --------------------------------------------------------------------------- # Local collectors # --------------------------------------------------------------------------- function Get-ComputerSystemInfo { $cs = Get-CimInstance Win32_ComputerSystem -ErrorAction Stop return @{ Manufacturer = "$($cs.Manufacturer)"; Model = "$($cs.Model)"; Domain = "$($cs.Domain)"; Workgroup = "$($cs.Workgroup)"; DomainRole = $cs.DomainRole } } function Get-UptimeInfo { $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop $uptime = (Get-Date) - $os.LastBootUpTime return [ordered]@{ LastBootUpTime = ConvertTo-IsoDate $os.LastBootUpTime UptimeDays = [math]::Floor($uptime.TotalDays); UptimeHours = $uptime.Hours; UptimeMinutes = $uptime.Minutes } } function Get-OsInventory { $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop # The update build revision (UBR) identifies the installed cumulative update: # 10.0.26200 + UBR 9711 = 10.0.26200.9711. Without it the OS cannot be # checked against Windows security updates. $nt = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -ErrorAction SilentlyContinue $ubr = $(if ($nt -and $null -ne $nt.UBR) { "$($nt.UBR)" } else { '' }) $fullBuild = $(if ($ubr) { "$($os.Version).$ubr" } else { "$($os.Version)" }) return [ordered]@{ name = "$($os.Caption)".Trim(); version = "$($os.Version)"; release = "$($os.Version)" edition = $(if ($nt) { "$($nt.EditionID)" } else { '' }) display_version = $(if ($nt -and $nt.DisplayVersion) { "$($nt.DisplayVersion)" } elseif ($nt) { "$($nt.ReleaseId)" } else { '' }) ubr = $ubr; full_build = $fullBuild build = "$($os.BuildNumber)"; architecture = "$($os.OSArchitecture)"; kernel = "$($os.Version)" install_date = ConvertTo-IsoDate $os.InstallDate } } function Get-AccountFlags($account, $groups) { $flags = @() $name = "$($account.Name)".ToLower() $groupText = ($groups -join ' ').ToLower() if ($account.Enabled -and ($name -match 'admin|administrator|test|temp|guest')) { $flags += 'Privileged or sensitive account naming' } if ($groupText -like '*administrators*') { $flags += 'Local administrator membership' } if ($account.PasswordRequired -eq $false) { $flags += 'Password not required' } if ($account.Enabled -and -not $account.LastLogon) { $flags += 'Enabled account without captured last logon' } return , $flags } function Get-LocalAccountInventory { $membership = @{} foreach ($group in (Get-LocalGroup -ErrorAction Stop)) { try { foreach ($member in (Get-LocalGroupMember -Group $group.Name -ErrorAction Stop)) { $short = ("$($member.Name)" -split '\\')[-1].ToLower() if (-not $membership.ContainsKey($short)) { $membership[$short] = @() } $membership[$short] += $group.Name } } catch { } # orphaned SIDs make Get-LocalGroupMember fail for that group only } foreach ($account in (Get-LocalUser -ErrorAction Stop)) { $groups = @() if ($membership.ContainsKey("$($account.Name)".ToLower())) { $groups = @($membership["$($account.Name)".ToLower()] | Sort-Object -Unique) } [ordered]@{ name = "$($account.Name)" enabled = [bool]$account.Enabled last_logon = ConvertTo-IsoDate $account.LastLogon password_required = [bool]$account.PasswordRequired password_last_set = ConvertTo-IsoDate $account.PasswordLastSet user_may_change_password = [bool]$account.UserMayChangePassword password_expires = ConvertTo-IsoDate $account.PasswordExpires password_never_expires = ($null -eq $account.PasswordExpires) principal_source = "$($account.PrincipalSource)" sid = "$($account.SID)" description = "$($account.Description)" groups = $groups admin_group_membership = [bool](($groups -join ' ') -match 'administrator') guest_or_default = @('guest', 'defaultaccount', 'wdagutilityaccount') -contains "$($account.Name)".ToLower() stale_account = [bool]($account.Enabled -and -not $account.LastLogon) risk_flags = (Get-AccountFlags $account $groups) } } } function Get-LoggedUserInventory { $seen = @{} $quser = $null try { $quser = & quser 2>$null } catch { } foreach ($line in @($quser | Select-Object -Skip 1)) { $clean = (("$line" -replace '>', ' ') -replace '\s+', ' ').Trim() if (-not $clean) { continue } $parts = $clean.Split(' ') $state = ($parts | Where-Object { @('active', 'disc', 'disconnected') -contains $_.ToLower() } | Select-Object -First 1) $seen[$parts[0].ToLower()] = $true [ordered]@{ raw = $clean; username = $parts[0]; session = $(if ($parts.Count -gt 1) { $parts[1] } else { '' }); state = "$state" } } foreach ($row in @(Get-CimInstance Win32_LoggedOnUser -ErrorAction SilentlyContinue)) { $user = "$($row.Antecedent.Name)" if (-not $user -or $seen.ContainsKey($user.ToLower())) { continue } $seen[$user.ToLower()] = $true [ordered]@{ username = $user; domain = "$($row.Antecedent.Domain)"; session = "$($row.Dependent.LogonId)"; state = 'LoggedOn'; logon_type = $null; start_time = $null; raw = '' } } } function Get-RecentLogins([int]$limit = 200) { $events = Get-WinEvent -FilterHashtable @{ LogName = 'Security'; Id = 4624 } -MaxEvents $limit -ErrorAction Stop foreach ($evt in $events) { [ordered]@{ TimeCreated = ConvertTo-IsoDate $evt.TimeCreated User = "$($evt.Properties[5].Value)"; Domain = "$($evt.Properties[6].Value)" LogonType = "$($evt.Properties[8].Value)"; SourceIP = "$($evt.Properties[18].Value)" } } } function Get-AdapterType([string]$name) { $lowered = $name.ToLower() if ($lowered -match 'wi-fi|wifi|wireless|wlan') { return 'Wireless' } if ($lowered -match 'ethernet|lan|gbe') { return 'Ethernet' } if ($lowered -match 'vpn|tap|tun') { return 'VPN' } return 'Network Adapter' } function Get-NetworkAdapterInventory { foreach ($adapter in (Get-NetAdapter -ErrorAction Stop)) { $ipv4 = @(Get-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue) $isUp = ("$($adapter.Status)" -eq 'Up') -and $ipv4.Count -gt 0 $type = Get-AdapterType "$($adapter.Name) $($adapter.InterfaceDescription)" [ordered]@{ name = "$($adapter.Name)"; type = $type; adapter_type = $type active = $isUp; is_up = $isUp mac = ("$($adapter.MacAddress)" -replace '-', ':').ToLower() ipv4 = @($ipv4 | ForEach-Object { "$($_.IPAddress)" }) ipv4_details = @($ipv4 | ForEach-Object { [ordered]@{ address = "$($_.IPAddress)"; prefix_length = [int]$_.PrefixLength } }) speed_mbps = $(if ($adapter.LinkSpeed) { [int](([int64]$adapter.ReceiveLinkSpeed) / 1000000) } else { 0 }) } } } function Get-NetworkConfiguration { foreach ($config in (Get-NetIPConfiguration -ErrorAction Stop)) { [ordered]@{ InterfaceAlias = "$($config.InterfaceAlias)" IPv4Address = @($config.IPv4Address | ForEach-Object { "$($_.IPAddress)" }) IPv4Gateway = @($config.IPv4DefaultGateway | ForEach-Object { "$($_.NextHop)" }) DNSServers = @($config.DNSServer | ForEach-Object { $_.ServerAddresses } | ForEach-Object { "$_" }) } } } function Get-InstalledSoftware([int]$limit = 5000) { $paths = @( 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*', 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*', 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*', 'HKCU:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' ) $rows = New-Object System.Collections.ArrayList foreach ($item in @(Get-ItemProperty $paths -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName })) { [void]$rows.Add(@{ Name = "$($item.DisplayName)"; Version = "$($item.DisplayVersion)"; Publisher = "$($item.Publisher)" InstallDate = "$($item.InstallDate)"; InstallLocation = "$($item.InstallLocation)"; UninstallString = "$($item.UninstallString)" DisplayIcon = "$($item.DisplayIcon)"; Language = "$($item.Language)"; ReleaseType = "$($item.ReleaseType)" Source = 'Registry'; RegistryKey = "$($item.PSPath)" }) } # Framework and resource packages (VCLibs, .NET Native, language packs) are # runtime components, not applications; their package versions only # produce false CVE matches. foreach ($item in @(Get-AppxPackage -ErrorAction SilentlyContinue | Where-Object { -not $_.IsFramework -and -not $_.IsResourcePackage })) { [void]$rows.Add(@{ Name = "$($item.Name)"; Version = "$($item.Version)"; Publisher = "$($item.Publisher)" InstallDate = ''; InstallLocation = "$($item.InstallLocation)"; UninstallString = ''; DisplayIcon = '' Language = ''; ReleaseType = ''; Source = 'AppxPackage'; RegistryKey = "$($item.PackageFullName)" }) } $unique = @{} foreach ($row in $rows) { $unique["$($row.Name)|$($row.Version)".ToLower()] = $row } $count = 0 foreach ($row in ($unique.Values | Sort-Object { $_.Name })) { if ($count -ge $limit) { break } $count++ [ordered]@{ name = $row.Name; version = $row.Version; publisher = $row.Publisher install_date = $row.InstallDate; install_location = $row.InstallLocation install_path = $(if ($row.InstallLocation) { $row.InstallLocation } else { 'Not Collected' }) executable_path = 'Not Collected'; display_icon = $row.DisplayIcon architecture = $(if ($row.RegistryKey -like '*WOW6432Node*') { 'x86' } else { 'x64 / Native' }) edition = $(if ($row.ReleaseType) { $row.ReleaseType } else { 'Not Collected' }) language = $(if ($row.Language) { $row.Language } else { 'Not Collected' }) build = $(if ($row.Version) { $row.Version } else { 'Not Collected' }) uninstall_string = $row.UninstallString source = $row.Source; data_source = $row.Source update_channel = 'Not Collected'; eol_eos_status = 'Requires Vendor Validation' match_confidence = $(if ($row.Version) { 'High' } else { 'Low' }) registry_key = $row.RegistryKey digital_signature = 'Requires Deep Audit'; sha256 = '' } } } # Deep Audit only: signature and hash of each product's main executable. function Add-SoftwareFileEvidence($software, [int]$limit = 250) { $checked = 0 foreach ($row in $software) { if ($checked -ge $limit) { break } $path = ("$($row.display_icon)".Trim('"') -replace ',-?\d+$', '') if (-not $path -or -not (Test-Path -LiteralPath $path -PathType Leaf)) { continue } $checked++ $row.executable_path = $path $row.file_exists = $true try { $row.digital_signature = "$((Get-AuthenticodeSignature -LiteralPath $path).Status)" } catch { $row.digital_signature = 'Unavailable' } try { $row.sha256 = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash } catch { $row.sha256 = '' } $row.file_evidence_collected_at = Get-UtcNow } } function Get-PatchInventory([int]$limit = 1000) { foreach ($fix in @(Get-HotFix -ErrorAction Stop | Sort-Object InstalledOn -Descending | Select-Object -First $limit)) { [ordered]@{ id = "$($fix.HotFixID)"; description = "$($fix.Description)"; installed_by = "$($fix.InstalledBy)"; installed_on = ConvertTo-IsoDate $fix.InstalledOn } } } # Command lines can carry passwords and tokens; they never leave the host. function Protect-CommandLine([string]$value) { if (-not $value) { return '' } $value = $value -replace '(?i)((?:^|\s)(?:-{1,2}|/)(?:password|passwd|pwd|pass|secret|token|apikey|api-key|api_key|access-key|client-secret|auth|credential|key))(\s+|=|:)("[^"]*"|\S+)', '$1$2[REDACTED]' $value = $value -replace '(?i)\b(password|passwd|pwd|secret|token|apikey|api_key|sharedaccesskey|accountkey)=([^;&\s"]+)', '$1=[REDACTED]' $value = $value -replace '(?i)(://)[^/:@\s]+:[^/@\s]+@', '$1[REDACTED]@' $value = $value -replace '(?i)(bearer\s+)[A-Za-z0-9._~+/=-]{12,}', '$1[REDACTED]' return $value } function Get-ProcessInventory([int]$limit = 3000) { foreach ($proc in @(Get-CimInstance Win32_Process -ErrorAction Stop | Select-Object -First $limit)) { [ordered]@{ pid = [int]$proc.ProcessId; name = "$($proc.Name)"; username = '' executable = "$($proc.ExecutablePath)"; command_line = (Get-CompactText (Protect-CommandLine "$($proc.CommandLine)") 600) started_at = ConvertTo-IsoDate $proc.CreationDate } } } function New-ServiceRecord($service) { $text = ("$($service.Name) $($service.DisplayName) $($service.PathName)").ToLower() $path = "$($service.PathName)" [ordered]@{ name = "$($service.Name)"; display_name = "$($service.DisplayName)"; path = $path status = "$($service.State)"; startup_type = "$($service.StartMode)"; start_name = "$($service.StartName)" process_id = [int]$service.ProcessId; service_type = "$($service.ServiceType)" description = (Get-CompactText $service.Description 300); source = 'Win32_Service' service_account = $(if ($service.StartName) { "$($service.StartName)" } else { 'Not Collected' }) executable_path = $(if ($path) { $path } else { 'Not Collected' }) remote_accessibility = 'Correlate with listening ports and firewall evidence' exposure_decision = 'Requires Review'; associated_findings = @() unknown_indicator = [bool](-not $path -or $path.ToLower() -match 'temp|appdata') suspicious_indicators = @($SuspiciousServiceTokens | Where-Object { $text.Contains($_) }) } } function Get-RunningServices { foreach ($service in @(Get-CimInstance Win32_Service -ErrorAction Stop | Where-Object { $_.State -eq 'Running' })) { New-ServiceRecord $service } } function Get-NetworkExposure([int]$limit = 5000) { $listeners = New-Object System.Collections.ArrayList $connections = New-Object System.Collections.ArrayList foreach ($item in @(Get-NetTCPConnection -ErrorAction SilentlyContinue | Select-Object -First $limit)) { $record = [ordered]@{ protocol = 'tcp'; local_address = "$($item.LocalAddress)"; local_port = [int]$item.LocalPort remote_address = "$($item.RemoteAddress)"; remote_port = [int]$item.RemotePort status = "$($item.State)"; pid = [int]$item.OwningProcess } if ("$($item.State)" -eq 'Listen') { [void]$listeners.Add($record) } elseif ("$($item.State)" -eq 'Established' -and $connections.Count -lt 500 -and "$($item.RemoteAddress)" -notmatch '^(127\.|::1$)') { [void]$connections.Add($record) } } foreach ($item in @(Get-NetUDPEndpoint -ErrorAction SilentlyContinue)) { [void]$listeners.Add([ordered]@{ protocol = 'udp'; local_address = "$($item.LocalAddress)"; local_port = [int]$item.LocalPort remote_address = ''; remote_port = 0; status = 'Listening'; pid = [int]$item.OwningProcess }) } return [ordered]@{ listening_ports = @($listeners); active_connections = @($connections) } } function Get-FirmwareInventory { $result = [ordered]@{ manufacturer = ''; model = ''; serial_number = ''; bios = @{}; tpm = @{}; disks = @(); network_interfaces = @() } $bios = Get-CimInstance Win32_BIOS -ErrorAction SilentlyContinue | Select-Object -First 1 $product = Get-CimInstance Win32_ComputerSystemProduct -ErrorAction SilentlyContinue | Select-Object -First 1 if ($bios) { $result.bios = [ordered]@{ Manufacturer = "$($bios.Manufacturer)"; SMBIOSBIOSVersion = "$($bios.SMBIOSBIOSVersion)"; SerialNumber = "$($bios.SerialNumber)"; ReleaseDate = ConvertTo-IsoDate $bios.ReleaseDate } } if ($product) { $result.manufacturer = "$($product.Vendor)"; $result.model = "$($product.Name)" $result.serial_number = $(if ($product.IdentifyingNumber) { "$($product.IdentifyingNumber)" } elseif ($bios) { "$($bios.SerialNumber)" } else { '' }) } $result.disks = @(Get-CimInstance Win32_DiskDrive -ErrorAction SilentlyContinue | ForEach-Object { [ordered]@{ Model = "$($_.Model)"; SerialNumber = "$($_.SerialNumber)".Trim(); FirmwareRevision = "$($_.FirmwareRevision)"; InterfaceType = "$($_.InterfaceType)" } }) $result.network_interfaces = @(Get-CimInstance Win32_NetworkAdapter -ErrorAction SilentlyContinue | Where-Object { $_.PhysicalAdapter } | ForEach-Object { [ordered]@{ Name = "$($_.Name)"; Manufacturer = "$($_.Manufacturer)"; ProductName = "$($_.ProductName)"; DriverVersion = "$($_.DriverVersion)"; MACAddress = "$($_.MACAddress)" } }) try { $tpm = Get-Tpm -ErrorAction Stop # requires administrator $result.tpm = [ordered]@{ TpmPresent = [bool]$tpm.TpmPresent; TpmReady = [bool]$tpm.TpmReady; TpmEnabled = [bool]$tpm.TpmEnabled; ManufacturerIdTxt = "$($tpm.ManufacturerIdTxt)"; ManufacturerVersion = "$($tpm.ManufacturerVersion)" } } catch { } return $result } function ConvertTo-GpoBool($value) { # NetSecurity reports GpoBoolean enums (True / False / NotConfigured). $text = "$value" if ($text -eq 'True') { return $true } if ($text -eq 'False') { return $false } return $null } function Get-FirewallProfiles { foreach ($fw in @(Get-NetFirewallProfile -ErrorAction Stop)) { $enabled = ConvertTo-GpoBool $fw.Enabled $inbound = "$($fw.DefaultInboundAction)" $localRules = ConvertTo-GpoBool $fw.AllowLocalFirewallRules $logBlocked = ConvertTo-GpoBool $fw.LogBlocked $flags = @() if ($enabled -eq $false) { $flags += 'Firewall profile disabled' } if ($inbound -eq 'Allow') { $flags += 'Default inbound action allows traffic' } if ($localRules -eq $true) { $flags += 'Local firewall rules are allowed' } if ($logBlocked -eq $false) { $flags += 'Blocked connection logging disabled' } [ordered]@{ name = "$($fw.Name)"; enabled = [bool]$enabled default_inbound_action = $inbound; default_outbound_action = "$($fw.DefaultOutboundAction)" allow_inbound_rules = ConvertTo-GpoBool $fw.AllowInboundRules allow_local_firewall_rules = $localRules allow_local_ipsec_rules = ConvertTo-GpoBool $fw.AllowLocalIPsecRules notify_on_listen = ConvertTo-GpoBool $fw.NotifyOnListen log_file = "$($fw.LogFileName)"; log_allowed = ConvertTo-GpoBool $fw.LogAllowed log_blocked = $logBlocked; log_max_kb = [int64]$fw.LogMaxSizeKilobytes risk_flags = $flags } } } function Get-SecurityProducts { try { $mp = Get-MpComputerStatus -ErrorAction Stop $flags = @() if ($mp.AntivirusEnabled -eq $false) { $flags += 'Antivirus disabled' } if ($mp.RealTimeProtectionEnabled -eq $false) { $flags += 'Real-time protection disabled' } if ($mp.BehaviorMonitorEnabled -eq $false) { $flags += 'Behavior monitoring disabled' } if ($mp.OnAccessProtectionEnabled -eq $false) { $flags += 'On-access protection disabled' } [ordered]@{ name = 'Microsoft Defender'; type = 'Endpoint Protection' enabled = [bool]$mp.AntivirusEnabled; real_time_protection = [bool]$mp.RealTimeProtectionEnabled behavior_monitor = [bool]$mp.BehaviorMonitorEnabled signature_last_updated = ConvertTo-IsoDate $mp.AntivirusSignatureLastUpdated signature_age = 'Calculate at analysis time'; tamper_protection = $mp.IsTamperProtected full_scan_end_time = ConvertTo-IsoDate $mp.FullScanEndTime; quick_scan_end_time = ConvertTo-IsoDate $mp.QuickScanEndTime raw = [ordered]@{ AMServiceEnabled = $mp.AMServiceEnabled; AntivirusEnabled = $mp.AntivirusEnabled; AntispywareEnabled = $mp.AntispywareEnabled RealTimeProtectionEnabled = $mp.RealTimeProtectionEnabled; BehaviorMonitorEnabled = $mp.BehaviorMonitorEnabled IoavProtectionEnabled = $mp.IoavProtectionEnabled; NISEnabled = $mp.NISEnabled OnAccessProtectionEnabled = $mp.OnAccessProtectionEnabled; IsTamperProtected = $mp.IsTamperProtected } risk_flags = $flags } } catch { } # Security Center only exists on client editions of Windows. foreach ($av in @(Get-CimInstance -Namespace root/SecurityCenter2 -ClassName AntiVirusProduct -ErrorAction SilentlyContinue)) { if ("$($av.displayName)" -and "$($av.displayName)".ToLower() -ne 'microsoft defender' -and "$($av.displayName)".ToLower() -ne 'windows defender') { [ordered]@{ name = "$($av.displayName)"; type = 'AntiVirusProduct'; enabled = $true; product_state = $av.productState; path = "$($av.pathToSignedProductExe)"; timestamp = "$($av.timestamp)"; risk_flags = @() } } } } function Get-HealthInventory { $rdp = 'unknown' try { $deny = (Get-ItemProperty 'HKLM:\System\CurrentControlSet\Control\Terminal Server' -ErrorAction Stop).fDenyTSConnections if ($deny -eq 0) { $rdp = 'enabled' } elseif ($deny -eq 1) { $rdp = 'disabled' } } catch { } return [ordered]@{ firewall = 'unknown'; defender = 'unknown'; bitlocker = 'unknown'; rdp = $rdp firewall_profiles = @(Invoke-Collector 'firewall_profiles' 'Reading firewall profiles' { Get-FirewallProfiles } @()) security_products = @(Invoke-Collector 'security_products' 'Reading endpoint protection status' { Get-SecurityProducts } @()) } } # Security-relevant events only; the full System/Application logs are mostly # noise (driver, update and application errors) and drowned the real signals. $SecurityEventIds = @{ 'System' = @(104, 7045, 6008) 'Security' = @(1102, 4625, 4719, 4720, 4722, 4724, 4726, 4728, 4732, 4738, 4740, 4756) 'Microsoft-Windows-Windows Defender/Operational' = @(1006, 1116, 1117, 5001, 5010, 5012) } function Get-EventSeverity($evt) { $id = [int]$evt.Id if (@(104, 1102, 1006, 1116, 5001, 5010, 5012) -contains $id) { return 'Critical' } if (@(4719, 4720, 4728, 4732, 4756, 7045, 1117) -contains $id) { return 'High' } if (@(4625, 4722, 4724, 4726, 4738, 4740, 6008) -contains $id) { return 'Medium' } return 'Info' } function Get-EventCategory($evt) { $id = [int]$evt.Id if (@(4625, 4740) -contains $id) { return 'Authentication' } if (@(4720, 4722, 4724, 4726, 4728, 4732, 4738, 4756) -contains $id) { return 'Account Change' } if ($id -eq 7045) { return 'Service Installation' } if (@(104, 1102, 4719) -contains $id) { return 'Audit Log Tampering' } if ("$($evt.ProviderName)".ToLower() -like '*defender*') { return 'Endpoint Protection' } if ($id -eq 6008) { return 'Unexpected Shutdown' } return 'System / Application' } function Get-EventLogSummary([int]$hours = 24, [int]$limit = 300) { $start = (Get-Date).AddHours(-$hours) $all = New-Object System.Collections.ArrayList $denied = @() # One log at a time: without elevation the Security log is denied, and that # must not hide the System and Defender events. foreach ($log in $SecurityEventIds.Keys) { try { foreach ($evt in @(Get-WinEvent -FilterHashtable @{ LogName = $log; StartTime = $start; Id = $SecurityEventIds[$log] } -MaxEvents $limit -ErrorAction Stop)) { [void]$all.Add($evt) } } catch { if ("$($_.Exception.Message)" -notmatch 'No events were found') { $denied += $log } } } $counts = [ordered]@{ Critical = 0; High = 0; Medium = 0; Info = 0 } $events = foreach ($evt in @($all | Sort-Object TimeCreated -Descending | Select-Object -First $limit)) { $severity = Get-EventSeverity $evt $counts[$severity] = [int]$counts[$severity] + 1 [ordered]@{ log = "$($evt.LogName)"; provider = "$($evt.ProviderName)"; event_id = [int]$evt.Id level = "$($evt.LevelDisplayName)"; time_created = ConvertTo-IsoDate $evt.TimeCreated message = (Get-CompactText $evt.Message 500); severity = $severity; category = (Get-EventCategory $evt) } } return [ordered]@{ window_hours = $hours; events = @($events); counts = $counts; filter = 'security_event_ids'; unreadable_logs = @($denied) } } # --------------------------------------------------------------------------- # Hardening, update health and persistence (collector 1.1) # --------------------------------------------------------------------------- function Get-RegistryValue([string]$path, [string]$name) { try { $item = Get-ItemProperty -LiteralPath $path -Name $name -ErrorAction Stop return $item.$name } catch { return $null } } # Each value is $true, $false, or $null when the setting could not be read. # Unknown is never reported as a weakness. function Get-HardeningPosture { $result = [ordered]@{} $sb = Get-RegistryValue 'HKLM:\SYSTEM\CurrentControlSet\Control\SecureBoot\State' 'UEFISecureBootEnabled' $result.secure_boot = $(if ($null -eq $sb) { $null } else { [bool]([int]$sb -eq 1) }) $result.bitlocker_os = $null try { $volume = Get-BitLockerVolume -MountPoint $env:SystemDrive -ErrorAction Stop $result.bitlocker_os = [bool]("$($volume.ProtectionStatus)" -eq 'On') } catch { try { $state = (New-Object -ComObject Shell.Application).NameSpace("$env:SystemDrive\").Self.ExtendedProperty('System.Volume.BitLockerProtection') if ($state -eq 1) { $result.bitlocker_os = $true } elseif ($state -eq 2) { $result.bitlocker_os = $false } } catch { } } $result.smb1 = $null $result.smb_signing_required = $null try { $smb = Get-SmbServerConfiguration -ErrorAction Stop $result.smb1 = [bool]$smb.EnableSMB1Protocol $result.smb_signing_required = [bool]$smb.RequireSecuritySignature } catch { $signing = Get-RegistryValue 'HKLM:\SYSTEM\CurrentControlSet\Services\LanmanServer\Parameters' 'RequireSecuritySignature' if ($null -ne $signing) { $result.smb_signing_required = [bool]([int]$signing -eq 1) } } # Only an explicit RunAsPPL = 0 counts as off: newer builds can enable LSA # protection without writing the value. $ppl = Get-RegistryValue 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' 'RunAsPPL' $result.lsa_protection = $(if ($null -eq $ppl) { $null } else { [bool](@(1, 2) -contains [int]$ppl) }) $result.credential_guard = $null try { $dg = Get-CimInstance -Namespace 'root\Microsoft\Windows\DeviceGuard' -ClassName Win32_DeviceGuard -ErrorAction Stop $result.credential_guard = [bool](@($dg.SecurityServicesRunning) -contains 1) } catch { } $deny = Get-RegistryValue 'HKLM:\System\CurrentControlSet\Control\Terminal Server' 'fDenyTSConnections' $result.rdp_enabled = $(if ($null -eq $deny) { $null } else { [bool]([int]$deny -eq 0) }) $nla = Get-RegistryValue 'HKLM:\System\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' 'UserAuthentication' $result.rdp_nla = $(if ($null -eq $nla) { $null } else { [bool]([int]$nla -eq 1) }) $result.powershell_v2 = [bool](Get-RegistryValue 'HKLM:\SOFTWARE\Microsoft\PowerShell\1\PowerShellEngine' 'PowerShellVersion') $multicast = Get-RegistryValue 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\DNSClient' 'EnableMulticast' $result.llmnr = [bool]($null -eq $multicast -or [int]$multicast -ne 0) $lua = Get-RegistryValue 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' 'EnableLUA' $result.uac = $(if ($null -eq $lua) { $null } else { [bool]([int]$lua -eq 1) }) $autorun = Get-RegistryValue 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\Explorer' 'NoDriveTypeAutoRun' $result.autorun_disabled = [bool]($null -ne $autorun -and [int]$autorun -eq 255) $wdigest = Get-RegistryValue 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\WDigest' 'UseLogonCredential' $result.wdigest = [bool]($null -ne $wdigest -and [int]$wdigest -eq 1) return $result } function Get-WindowsUpdateHealth { $result = [ordered]@{ last_install_success = ''; last_install_source = ''; reboot_pending = $false } try { $searcher = (New-Object -ComObject Microsoft.Update.Session).CreateUpdateSearcher() $total = $searcher.GetTotalHistoryCount() if ($total -gt 0) { # Operation 1 = installation, ResultCode 2 = succeeded. $latest = @($searcher.QueryHistory(0, [math]::Min($total, 100)) | Where-Object { $_.Operation -eq 1 -and $_.ResultCode -eq 2 } | Sort-Object Date -Descending | Select-Object -First 1) if ($latest.Count) { $result.last_install_success = ConvertTo-IsoDate $latest[0].Date; $result.last_install_source = 'Windows Update history' } } } catch { } if (-not $result.last_install_success) { $fix = @(Get-HotFix -ErrorAction SilentlyContinue | Where-Object { $_.InstalledOn } | Sort-Object InstalledOn -Descending | Select-Object -First 1) if ($fix.Count) { $result.last_install_success = ConvertTo-IsoDate $fix[0].InstalledOn; $result.last_install_source = 'Get-HotFix' } } $result.reboot_pending = [bool]((Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') -or (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending')) return $result } function Get-CommandTarget([string]$command) { $text = [Environment]::ExpandEnvironmentVariables("$command".Trim()) if ($text.StartsWith('"')) { return $text.Substring(1).Split('"')[0] } if ($text -match '^(.+?\.(exe|com|bat|cmd|ps1|vbs|js|dll|scr))(\s|,|$)') { return $Matches[1] } return ($text -split '\s+')[0] } function New-AutostartRecord([string]$name, [string]$location, [string]$command) { $path = Get-CommandTarget $command $signature = 'NotFound' if ($path -and (Test-Path -LiteralPath $path -PathType Leaf)) { try { $signature = "$((Get-AuthenticodeSignature -LiteralPath $path).Status)" } catch { $signature = 'Unavailable' } } [ordered]@{ name = $name; location = $location; command = (Get-CompactText (Protect-CommandLine $command) 300); path = $path; signature = $signature } } function Get-AutostartInventory([int]$limit = 300) { $rows = New-Object System.Collections.ArrayList $keys = @( 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Run', 'HKLM:\Software\Microsoft\Windows\CurrentVersion\RunOnce', 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Run', 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run', 'HKCU:\Software\Microsoft\Windows\CurrentVersion\RunOnce' ) foreach ($key in $keys) { $item = $null try { $item = Get-ItemProperty -LiteralPath $key -ErrorAction Stop } catch { } if (-not $item) { continue } foreach ($prop in $item.PSObject.Properties) { if ($prop.Name -like 'PS*' -or $rows.Count -ge $limit) { continue } [void]$rows.Add((New-AutostartRecord $prop.Name ($key -replace ':\\Software\\', ':\') "$($prop.Value)")) } } $shell = $null try { $shell = New-Object -ComObject WScript.Shell } catch { } foreach ($folder in @([Environment]::GetFolderPath('Startup'), [Environment]::GetFolderPath('CommonStartup'))) { if (-not $folder -or -not (Test-Path -LiteralPath $folder)) { continue } foreach ($file in @(Get-ChildItem -LiteralPath $folder -File -ErrorAction SilentlyContinue)) { if ($rows.Count -ge $limit -or $file.Name -eq 'desktop.ini') { continue } $target = $file.FullName if ($shell -and $file.Extension -eq '.lnk') { try { $target = $shell.CreateShortcut($file.FullName).TargetPath } catch { } } [void]$rows.Add((New-AutostartRecord $file.Name 'Startup folder' $target)) } } # Scheduled tasks outside \Microsoft\ (built-in tasks are signed Windows binaries). foreach ($task in @(Get-ScheduledTask -ErrorAction SilentlyContinue | Where-Object { $_.TaskPath -notlike '\Microsoft\*' -and $_.State -ne 'Disabled' })) { foreach ($action in @($task.Actions | Where-Object { $_.Execute })) { if ($rows.Count -ge $limit) { break } [void]$rows.Add((New-AutostartRecord "$($task.TaskPath)$($task.TaskName)" 'Scheduled task' ("$($action.Execute) $($action.Arguments)"))) } } return @($rows) } function Get-PassiveNeighbors { foreach ($neighbor in @(Get-NetNeighbor -AddressFamily IPv4 -ErrorAction Stop)) { $mac = "$($neighbor.LinkLayerAddress)" if (-not $mac -or $mac -eq '00-00-00-00-00-00' -or $mac -eq 'FF-FF-FF-FF-FF-FF') { continue } if (@('Unreachable', 'Incomplete') -contains "$($neighbor.State)") { continue } if ("$($neighbor.IPAddress)" -match '^(224|239|255)\.') { continue } [ordered]@{ ip = "$($neighbor.IPAddress)"; mac = ($mac -replace '-', ':').ToLower(); method = 'ARP cache'; confidence = 95 } } } # --------------------------------------------------------------------------- # Network discovery (authorized scope only, TCP connect, no payloads) # --------------------------------------------------------------------------- function Resolve-HostName([string]$ip) { try { return [System.Net.Dns]::GetHostEntry($ip).HostName } catch { return '' } } function Get-RiskHint($ports) { if (@($ports | Where-Object { @(3389, 5900, 502, 1883, 5683) -contains $_ }).Count) { return 'High' } if (@($ports | Where-Object { @(445, 135, 139, 554, 8080, 8443, 8883) -contains $_ }).Count) { return 'Medium' } return 'Low' } function Set-HostFingerprint($hostRow) { $ports = @($hostRow.open_services | ForEach-Object { [int]$_.port }) $rules = @( @(@(502, 102, 44818, 20000, 47808), 'PLC/RTU/IED', 'Industrial control protocol'), @(@(554), 'CCTV/NVR', 'RTSP service'), @(@(9100), 'Printer', 'Printing service'), @(@(3389, 445), 'Windows Workstation', 'Windows service combination'), @(@(22, 80, 443), 'Linux Server', 'SSH and web service combination'), @(@(161, 22, 443), 'Network Switch / Router', 'Network management services') ) $hostRow.device_type = 'Unknown'; $confidence = 35; $hostRow.detection_reasons = @() foreach ($rule in $rules) { $hits = @($ports | Where-Object { $rule[0] -contains $_ }) if ($hits.Count) { $hostRow.device_type = $rule[1]; $confidence = [math]::Min(90, 55 + 10 * $hits.Count); $hostRow.detection_reasons = @($rule[2]); break } } $hostRow.detection_confidence = [math]::Max([int]$hostRow.detection_confidence, $confidence) $hostRow.managed_status = 'Unmanaged' return $hostRow } function New-HostRow([string]$ip, $services, [string]$method, [int]$confidence, [string]$mac = '') { $ports = @($services | ForEach-Object { [int]$_.port }) return [ordered]@{ asset_id = (Get-StableId $ip); ip = $ip; mac = $mac; hostname = (Resolve-HostName $ip) status = 'discovered'; open_services = @($services); service_count = @($services).Count risk_hint = (Get-RiskHint $ports); discovery_method = $method; detection_confidence = $confidence device_type = 'Unknown'; detection_reasons = @(); managed_status = 'Unmanaged' } } # Probes (host, port) pairs in batches of $rate concurrent TCP connects. function Invoke-TcpDiscovery($hosts, $ports, [int]$timeoutMs, [int]$rate) { $open = @{} $pairs = New-Object System.Collections.ArrayList foreach ($ip in $hosts) { foreach ($port in $ports) { [void]$pairs.Add(@($ip, [int]$port)) } } for ($offset = 0; $offset -lt $pairs.Count; $offset += $rate) { $batch = @() foreach ($pair in $pairs.GetRange($offset, [math]::Min($rate, $pairs.Count - $offset))) { $client = New-Object System.Net.Sockets.TcpClient $started = [System.Diagnostics.Stopwatch]::StartNew() try { $async = $client.BeginConnect($pair[0], $pair[1], $null, $null) } catch { $async = $null } $batch += , @($pair, $client, $async, $started) } $deadline = [System.Diagnostics.Stopwatch]::StartNew() foreach ($entry in $batch) { $client = $entry[1]; $async = $entry[2] if ($async) { $remaining = [math]::Max(0, $timeoutMs - [int]$deadline.ElapsedMilliseconds) [void]$async.AsyncWaitHandle.WaitOne($remaining) if ($client.Connected) { $ip = $entry[0][0]; $port = $entry[0][1] if (-not $open.ContainsKey($ip)) { $open[$ip] = New-Object System.Collections.ArrayList } $label = 'unknown'; if ($PortLabels.ContainsKey($port)) { $label = $PortLabels[$port] } [void]$open[$ip].Add([ordered]@{ port = $port; transport = 'tcp'; service = $label; state = 'open' latency_ms = [math]::Round($entry[3].Elapsed.TotalMilliseconds, 1) banner = ''; confidence = 85; discovery_method = 'TCP connect' }) } } $client.Close() } $done = [math]::Min($offset + $rate, $pairs.Count) if ($done % ([math]::Max($rate, 1) * 25) -lt $rate) { Write-Host (" probed {0} of {1} host/port pairs" -f $done, $pairs.Count) } } return $open } function Invoke-PingSweep($hosts, [int]$timeoutMs, [int]$rate) { $alive = @{} $list = @($hosts) for ($offset = 0; $offset -lt $list.Count; $offset += $rate) { $batch = @() foreach ($ip in $list[$offset..([math]::Min($offset + $rate, $list.Count) - 1)]) { $ping = New-Object System.Net.NetworkInformation.Ping $batch += , @($ip, $ping.SendPingAsync($ip, [math]::Max(100, $timeoutMs)), $ping) } foreach ($entry in $batch) { try { [void]$entry[1].Wait([math]::Max(100, $timeoutMs) + 500); if ($entry[1].Result.Status -eq 'Success') { $alive[$entry[0]] = $true } } catch { } $entry[2].Dispose() } } return $alive } function Get-NetworkFindings($hosts) { foreach ($hostRow in $hosts) { $ports = @($hostRow.open_services | ForEach-Object { [int]$_.port }) $asset = $(if ($hostRow.hostname) { $hostRow.hostname } else { $hostRow.ip }) $short = "$($hostRow.asset_id)".Substring(0, 6) if ($ports -contains 3389) { [ordered]@{ finding_id = "VH-REMOTE-ADMIN-$short"; title = 'Remote administration service reachable in CYNAPSE fabric'; asset = $asset; ip = $hostRow.ip; severity = 'High'; confidence = 'Observed'; evidence = 'TCP/3389 reachable during authorized discovery.'; recommended_action = 'Restrict remote administration to approved jump hosts and enforce MFA.' } } if ($ports -contains 445) { [ordered]@{ finding_id = "VH-SMB-$short"; title = 'File sharing surface reachable in CYNAPSE fabric'; asset = $asset; ip = $hostRow.ip; severity = 'Medium'; confidence = 'Observed'; evidence = 'TCP/445 reachable during authorized discovery.'; recommended_action = 'Validate SMB signing, access groups, patch state, and segmentation policy.' } } if ($ports -contains 502) { [ordered]@{ finding_id = "VH-OT-SERVICE-$short"; title = 'Industrial protocol surface detected in CYNAPSE fabric'; asset = $asset; ip = $hostRow.ip; severity = 'High'; confidence = 'Observed'; evidence = 'TCP/502 reachable during authorized discovery.'; recommended_action = 'Confirm OT-safe exposure requirements and restrict access by zone/conduit policy.' } } $iot = @($ports | Where-Object { @(554, 1883, 5683, 8883) -contains $_ } | Sort-Object) if ($iot.Count) { $labels = @($iot | ForEach-Object { if ($PortLabels.ContainsKey($_)) { $PortLabels[$_] } else { "$_" } }) $sev = 'Medium'; if (@($iot | Where-Object { @(1883, 5683) -contains $_ }).Count) { $sev = 'High' } [ordered]@{ finding_id = "VH-IOT-$short"; title = 'IoT or IIoT service surface detected in CYNAPSE fabric'; asset = $asset; ip = $hostRow.ip; severity = $sev; confidence = 'Observed'; evidence = ('Connected-device protocols observed: ' + ($labels -join ', ')); recommended_action = 'Classify the device as IoT/IIoT, validate firmware and vendor access, restrict the segment, and create a GRC treatment if the device is unmanaged.' } } } } # --------------------------------------------------------------------------- # Endpoint findings and summary (same rules as the desktop agent) # --------------------------------------------------------------------------- function Get-EndpointFindings($package) { $hostName = $package.agent.hostname foreach ($account in @($package.accounts)) { foreach ($flag in @($account.risk_flags)) { $sev = 'Medium'; if ($flag.ToLower() -match 'administrator|password') { $sev = 'High' } [ordered]@{ finding_id = 'VH-ACCOUNT-' + (Get-StableId "$($account.name)$flag").Substring(0, 8); title = 'Local account posture requires review'; asset = $hostName; affected = $account.name; severity = $sev; confidence = 'Collected'; category = 'Identity'; evidence = $flag; recommended_action = 'Validate account owner, disable unused accounts, enforce password and MFA policy where applicable, and remove unnecessary local administrator membership.' } } } foreach ($profileRow in @($package.health.firewall_profiles)) { foreach ($flag in @($profileRow.risk_flags)) { $sev = 'Medium'; if ($flag.ToLower() -match 'disabled|allows traffic') { $sev = 'High' } [ordered]@{ finding_id = 'VH-FIREWALL-' + (Get-StableId "$($profileRow.name)$flag").Substring(0, 8); title = 'Endpoint firewall configuration requires hardening'; asset = $hostName; affected = $profileRow.name; severity = $sev; confidence = 'Collected'; category = 'Firewall'; evidence = $flag; recommended_action = 'Enable endpoint firewall profiles, block unsolicited inbound access by default, log blocked traffic, and align local rules with the approved workstation baseline.' } } } $products = @($package.health.security_products) if (-not $products.Count) { [ordered]@{ finding_id = 'VH-ENDPOINT-PROTECTION-MISSING'; title = 'No endpoint protection product was confirmed'; asset = $hostName; affected = 'Endpoint security'; severity = 'Critical'; confidence = 'Collector result'; category = 'Endpoint Protection'; evidence = 'No antivirus or endpoint protection product was returned by the local security inventory.'; recommended_action = 'Install or repair the approved endpoint protection agent and verify real-time protection, behavior monitoring, and update status.' } } foreach ($product in $products) { foreach ($flag in @($product.risk_flags)) { $sev = 'High'; if ($flag.ToLower() -like '*disabled*') { $sev = 'Critical' } [ordered]@{ finding_id = 'VH-ENDPOINT-PROTECTION-' + (Get-StableId "$($product.name)$flag").Substring(0, 8); title = 'Endpoint protection control gap detected'; asset = $hostName; affected = $product.name; severity = $sev; confidence = 'Collected'; category = 'Endpoint Protection'; evidence = $flag; recommended_action = 'Re-enable the protection component, validate policy assignment, update signatures, and confirm telemetry is reporting to the security console.' } } } foreach ($evt in @($package.event_logs_24h.events | Select-Object -First 80)) { if (@('Critical', 'High') -notcontains $evt.severity) { continue } [ordered]@{ finding_id = 'VH-EVENT-' + (Get-StableId "$($evt.event_id)$($evt.time_created)").Substring(0, 8); title = "$($evt.category) event requires investigation"; asset = $hostName; affected = "$($evt.log) / $($evt.event_id)"; severity = $evt.severity; confidence = 'Event log'; category = $evt.category; evidence = $(if ($evt.message) { $evt.message } else { $evt.provider }); recommended_action = 'Review the event context, correlate with user activity and service changes, and document whether the signal is expected, suspicious, or requires containment.' } } $remotePorts = @{ 3389 = 'RDP'; 22 = 'SSH'; 23 = 'Telnet'; 5900 = 'VNC'; 445 = 'SMB'; 5985 = 'WinRM' } foreach ($listener in @($package.network_exposure.listening_ports)) { $port = [int]$listener.local_port if (-not $remotePorts.ContainsKey($port)) { continue } $sev = 'Medium'; if (@(23, 3389, 5900) -contains $port) { $sev = 'High' } $where = $(if ($listener.local_address) { $listener.local_address } else { 'all interfaces' }) [ordered]@{ finding_id = 'VH-LOCAL-EXPOSURE-' + (Get-StableId "$port$($listener.pid)").Substring(0, 8); title = "$($remotePorts[$port]) listening service requires exposure validation"; asset = $hostName; affected = ("{0}/{1}" -f "$($listener.protocol)".ToUpper(), $port); severity = $sev; confidence = 'Observed locally'; category = 'Remote Access Exposure'; evidence = "Listening on $where; process ID $($listener.pid)."; recommended_action = 'Confirm business need, restrict permitted source zones, enforce strong authentication, and monitor all remote sessions.' } } $firmware = $package.firmware_inventory if (-not $firmware.serial_number -or -not $firmware.bios -or -not $firmware.bios.Count) { [ordered]@{ finding_id = 'VH-FIRMWARE-EVIDENCE-GAP'; title = 'Firmware baseline evidence is incomplete'; asset = $hostName; affected = 'BIOS / device firmware inventory'; severity = 'Medium'; confidence = 'Collector limitation'; category = 'Firmware Governance'; evidence = 'The collector could not confirm a complete serial, BIOS, disk, and NIC firmware baseline.'; recommended_action = 'Validate firmware through the approved vendor inventory method and retain the confirmed baseline as asset evidence.' } } } # Links each installed product to a running process, service and listening port. function Add-SoftwareRuntimeEvidence($package) { $processes = @($package.running_processes) $services = @($package.running_services) $listenerByPid = @{} foreach ($row in @($package.network_exposure.listening_ports)) { $key = "$($row.pid)" if (-not $listenerByPid.ContainsKey($key)) { $listenerByPid[$key] = @() } $listenerByPid[$key] += $row } $now = Get-UtcNow foreach ($software in @($package.installed_software)) { $installPath = "$($software.install_location)".ToLower().TrimEnd('\', '/') $process = $null if ($installPath) { $process = $processes | Where-Object { "$($_.executable)".ToLower().StartsWith($installPath) } | Select-Object -First 1 } $service = $null if ($installPath) { $service = $services | Where-Object { "$($_.path)".ToLower().Contains($installPath) } | Select-Object -First 1 } if (-not $service -and $process) { $service = $services | Where-Object { "$($_.process_id)" -eq "$($process.pid)" } | Select-Object -First 1 } $procId = '' if ($process) { $procId = "$($process.pid)" } elseif ($service) { $procId = "$($service.process_id)" } $bound = @(); if ($procId -and $listenerByPid.ContainsKey($procId)) { $bound = $listenerByPid[$procId] } $software.running_process = $(if ($process) { $process.name } else { 'Not Observed' }) $software.running_process_id = $(if ($process) { $process.pid } else { '' }) $software.service_name = $(if ($service) { $service.name } else { 'Not Observed' }) $software.listening_interface = $(if ($bound.Count) { (@($bound | ForEach-Object { "$($_.local_address):$($_.local_port)/$($_.protocol)" }) -join ', ') } else { 'Not Observed' }) if ($process -and @('', 'Not Collected') -contains "$($software.executable_path)") { $software.executable_path = $(if ($process.executable) { $process.executable } else { 'Not Collected' }) } $software.runtime_evidence_collected_at = $now } } function Get-CollectionSummary($package) { $accounts = @($package.accounts); $services = @($package.running_services); $findings = @($package.findings) $counts = [ordered]@{ Critical = 0; High = 0; Medium = 0; Low = 0; Info = 0 } foreach ($finding in $findings) { $sev = (Get-Culture).TextInfo.ToTitleCase("$($finding.severity)".ToLower()) if (-not $sev) { $sev = 'Info' } $counts[$sev] = [int]$counts[$sev] + 1 } $firmware = $package.firmware_inventory return [ordered]@{ enabled_accounts = @($accounts | Where-Object { $_.enabled }).Count disabled_accounts = @($accounts | Where-Object { $_.enabled -eq $false }).Count logged_user_count = @($package.logged_users).Count local_admin_accounts = @($accounts | Where-Object { ($_.risk_flags -join ' ').ToLower().Contains('administrator') }).Count software_count = @($package.installed_software).Count running_service_count = $services.Count unknown_service_count = @($services | Where-Object { $_.unknown_indicator }).Count suspicious_service_count = @($services | Where-Object { @($_.suspicious_indicators).Count }).Count firewall_profile_count = @($package.health.firewall_profiles).Count security_product_count = @($package.health.security_products).Count event_log_window_hours = 24 event_log_counts = $package.event_logs_24h.counts finding_counts = $counts patch_count = @($package.patch_inventory).Count process_count = @($package.running_processes).Count listening_port_count = @($package.network_exposure.listening_ports).Count active_connection_count = @($package.network_exposure.active_connections).Count firmware_component_count = @($firmware.disks).Count + @($firmware.network_interfaces).Count + $(if ($firmware.bios -and $firmware.bios.Count) { 1 } else { 0 }) unmanaged_asset_count = @($package.network.hosts | Where-Object { $_.managed_status -eq 'Unmanaged' }).Count } } # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- if (-not (Test-IsWindows)) { Write-Warning 'This collector is built for Windows. On other systems most evidence will be marked as limited.' } if ($Mode -ne 'local' -and -not $AuthorizedScope) { Write-Error 'Network discovery requires -AuthorizedScope to confirm you are authorized to scan the range.' exit 2 } $profileConfig = $Profiles[$ScanProfile] $portList = Get-PortList $Ports $profileConfig.ports $elevated = Test-IsElevated Write-Host '' Write-Host "CYNAPSE Exposure Engine discovery collector $CollectorVersion" Write-Host ("Profile: {0} Mode: {1} Elevated: {2}" -f $profileConfig.label, $Mode, $elevated) if (-not $elevated) { Write-Host 'Tip: run as administrator for the Security event log and TPM evidence.' } Write-Host '' [void]$script:AuditLog.Add([ordered]@{ event = 'collection_started'; at = (Get-UtcNow); profile = $ScanProfile; authorization_confirmed = [bool]$AuthorizedScope; collector = "powershell $CollectorVersion"; elevated = $elevated }) $hostName = [System.Net.Dns]::GetHostName() $primaryIp = Get-PrimaryIPv4 $octets = $primaryIp.Split('.') $suggestedScope = $(if ($primaryIp -like '127.*') { '127.0.0.1/32' } else { "$($octets[0]).$($octets[1]).$($octets[2]).0/24" }) $scope = $(if ($Cidr) { $Cidr } else { $suggestedScope }) $cs = Invoke-Collector 'computer_identity' 'Reading computer identity' { Get-ComputerSystemInfo } @{} $uptime = Invoke-Collector 'uptime' 'Reading uptime' { Get-UptimeInfo } @{} $osInfo = Invoke-Collector 'os_inventory' 'Collecting operating system evidence' { Get-OsInventory } ([ordered]@{ name = [Environment]::OSVersion.Platform.ToString(); version = [Environment]::OSVersion.Version.ToString(); release = ''; edition = ''; build = ''; architecture = ''; kernel = '' }) $package = [ordered]@{ schema = 'cynapse.vulnhub.discovery_package.v1' package_format_version = 2 collector = [ordered]@{ name = 'CYNAPSE Discovery (PowerShell)'; version = $CollectorVersion; powershell = $PSVersionTable.PSVersion.ToString(); elevated = $elevated } capabilities = @('unified_ot_it_discovery', 'firmware_inventory', 'patch_inventory', 'logged_in_users', 'network_exposure', 'evidence_trail', 'asset_business_context', 'software_version_evidence', 'identity_posture', 'endpoint_security_posture', 'os_build_revision', 'hardening_posture', 'windows_update_health', 'autostart_persistence') product = 'CYNAPSE Exposure Engine' package_id = [guid]::NewGuid().ToString() generated_at = (Get-UtcNow) scan_profile = [ordered]@{ profile = $ScanProfile; profile_label = $profileConfig.label; mode = $Mode; scope = $scope excluded_scopes = @($(if ($Exclude) { $Exclude.Split(',') | ForEach-Object { $_.Trim() } | Where-Object { $_ } })) ports = @($portList); timeout_seconds = [math]::Round($TimeoutMs / 1000.0, 3); retry_count = 0 rate_limit_per_second = $profileConfig.rate; scan_window = ''; safe_discovery = $true passive_only = -not $profileConfig.active; ot_safe = @('passive', 'ot_safe') -contains $ScanProfile exploit_modules = $false; credential_storage = 'No credentials embedded in collection package' credentialed_inventory = $false; credential_method = 'Not used by the PowerShell collector' } agent = [ordered]@{ agent_host_id = (Get-StableId ($hostName + $primaryIp)); hostname = $hostName device_manufacturer = "$($cs.Manufacturer)"; device_model = "$($cs.Model)" primary_ipv4 = $primaryIp; suggested_local_scope = $suggestedScope platform = "$($osInfo.name) $($osInfo.version)"; system = 'Windows'; release = "$($osInfo.version)" machine = "$($osInfo.architecture)"; processor = "$env:PROCESSOR_IDENTIFIER"; uptime = $uptime user_context = "$env:USERNAME"; domain = "$($cs.Domain)"; workgroup = "$($cs.Workgroup)"; domain_role = $cs.DomainRole collection_confidence = 'High' } asset_context = [ordered]@{ hostname = $hostName; ip = $primaryIp; domain = "$($cs.Domain)"; workgroup = "$($cs.Workgroup)" device_type = $DeviceType asset_owner = $(if ($AssetOwner) { $AssetOwner } else { 'Requires Business Owner Assignment' }) business_criticality = $BusinessCriticality; environment = $Environment zone_conduit = $(if ($ZoneConduit) { $ZoneConduit } else { 'Not Available' }) internet_exposure = $InternetExposure; network_segment = $scope last_seen = (Get-UtcNow); collection_confidence = 'High' } os_inventory = $osInfo } $package.accounts = @(Invoke-Collector 'accounts' 'Enumerating local accounts' { Get-LocalAccountInventory } @()) $package.logged_users = @(Invoke-Collector 'logged_users' 'Identifying active sessions' { Get-LoggedUserInventory } @()) $package.recent_logins = @(Invoke-Collector 'recent_logins' 'Reviewing recent logons' { Get-RecentLogins } @()) $package.network_adapters = @(Invoke-Collector 'network_adapters' 'Discovering network adapters' { Get-NetworkAdapterInventory } @()) $package.network_configuration = @(Invoke-Collector 'network_configuration' 'Capturing network configuration' { Get-NetworkConfiguration } @()) $package.installed_software = @(Invoke-Collector 'installed_software' 'Enumerating installed software and versions' { Get-InstalledSoftware } @()) $package.patch_inventory = @(Invoke-Collector 'patch_inventory' 'Collecting installed patch evidence' { Get-PatchInventory } @()) $package.running_processes = @(Invoke-Collector 'running_processes' 'Inspecting running processes' { Get-ProcessInventory } @()) $package.running_services = @(Invoke-Collector 'running_services' 'Enumerating running services' { Get-RunningServices } @()) $package.network_exposure = Invoke-Collector 'network_exposure' 'Analyzing listening ports and connections' { Get-NetworkExposure } ([ordered]@{ listening_ports = @(); active_connections = @() }) $package.firmware_inventory = Invoke-Collector 'hardware_firmware' 'Collecting firmware inventory' { Get-FirmwareInventory } ([ordered]@{ manufacturer = ''; model = ''; serial_number = ''; bios = @{}; tpm = @{}; disks = @(); network_interfaces = @() }) $package.health = Invoke-Collector 'health' 'Assessing endpoint security health' { Get-HealthInventory } ([ordered]@{ firewall = 'unknown'; defender = 'unknown'; bitlocker = 'unknown'; rdp = 'unknown'; firewall_profiles = @(); security_products = @() }) $package.event_logs_24h = Invoke-Collector 'event_logs_24h' 'Reviewing security events from the last 24 hours' { Get-EventLogSummary } ([ordered]@{ window_hours = 24; events = @(); counts = [ordered]@{ Critical = 0; High = 0; Medium = 0; Info = 0 } }) $package.hardening = Invoke-Collector 'hardening' 'Checking hardening settings' { Get-HardeningPosture } ([ordered]@{}) $package.windows_update = Invoke-Collector 'windows_update' 'Checking Windows Update health' { Get-WindowsUpdateHealth } ([ordered]@{}) $package.autostart = @(Invoke-Collector 'autostart' 'Reviewing startup entries and scheduled tasks' { Get-AutostartInventory } @()) $package.passive_neighbors = @(Invoke-Collector 'passive_neighbors' 'Reading neighbor (ARP) cache' { Get-PassiveNeighbors } @()) $package.network = [ordered]@{ hosts = @(); host_count = 0; open_service_count = 0; scope = $scope } $package.findings = @() $package.evidence_trail = $script:AuditLog $package.limitations = @() if (-not $elevated) { $package.limitations += 'Collector ran without administrator rights: Security event log, recent logons, TPM, BitLocker and SMB settings may be missing.' } if ($ScanProfile -eq 'deep_audit') { [void](Invoke-Collector 'software_file_evidence' 'Validating software file signatures and hashes' { Add-SoftwareFileEvidence $package.installed_software } $null) } $hostRows = @() $scopedPassive = @() if ($Mode -eq 'network' -or $Mode -eq 'both') { Write-Host " - Running authorized network discovery of $scope" try { $scopeHosts = Get-ScopeHosts $scope $MaxHosts $Exclude $inScope = @{}; foreach ($ip in $scopeHosts) { $inScope[$ip] = $true } $scopedPassive = @($package.passive_neighbors | Where-Object { $inScope.ContainsKey($_.ip) }) $merged = [ordered]@{} foreach ($row in $scopedPassive) { $hostRow = New-HostRow $row.ip @() $row.method ([int]$row.confidence) $row.mac $hostRow.status = 'passively_discovered' $merged[$row.ip] = Set-HostFingerprint $hostRow } if ($profileConfig.active) { $open = Invoke-TcpDiscovery $scopeHosts $portList $TimeoutMs $profileConfig.rate $silent = @($scopeHosts | Where-Object { -not $open.ContainsKey($_) -and -not $merged.Contains($_) }) $alive = Invoke-PingSweep $silent $TimeoutMs $profileConfig.rate foreach ($ip in $scopeHosts) { $services = @(); if ($open.ContainsKey($ip)) { $services = @($open[$ip]) } if (-not $services.Count -and -not $alive.ContainsKey($ip)) { continue } $method = $(if ($services.Count) { 'TCP connect' } else { 'ICMP reachability' }) $confidence = $(if ($services.Count) { 85 } else { 60 }) $mac = '' if ($merged.Contains($ip)) { $mac = $merged[$ip].mac; $method = "Passive cache + $method" $confidence = [math]::Max($confidence, [int]$merged[$ip].detection_confidence) } $merged[$ip] = Set-HostFingerprint (New-HostRow $ip $services $method $confidence $mac) } } $hostRows = @($merged.Values | Sort-Object { ConvertTo-UInt32Ip $_.ip }) [void]$script:AuditLog.Add([ordered]@{ collector = 'network_discovery'; status = 'completed'; hosts = $hostRows.Count }) } catch { [void]$script:AuditLog.Add([ordered]@{ collector = 'network_discovery'; status = 'limited'; message = (Get-CompactText $_.Exception.Message 180) }) $package.limitations += "Network discovery was limited: $($_.Exception.Message)" } } elseif ($profileConfig.active -and $portList.Count) { Write-Host ' - Inspecting local listening services' $open = Invoke-TcpDiscovery @('127.0.0.1') $portList $TimeoutMs $profileConfig.rate if ($open.ContainsKey('127.0.0.1')) { $hostRows = @(Set-HostFingerprint (New-HostRow '127.0.0.1' @($open['127.0.0.1']) 'TCP connect' 85)) } } $package.network.hosts = @($hostRows) $package.network.host_count = $hostRows.Count $package.network.open_service_count = [int](@($hostRows | ForEach-Object { @($_.open_services).Count }) | Measure-Object -Sum).Sum if ($null -eq $package.network.open_service_count) { $package.network.open_service_count = 0 } $package.network.credentialed_inventory_summary = [ordered]@{ enabled = $false; attempted = 0; successful = 0; limitations = @('Credentialed remote inventory is not part of the PowerShell collector.') } $package.network.discovery_summary = [ordered]@{ scope = $scope; profile = $profileConfig.label authorized = $(if ($Mode -eq 'local') { $true } else { [bool]$AuthorizedScope }) passive_neighbors = $(if ($Mode -eq 'local') { @($package.passive_neighbors).Count } else { $scopedPassive.Count }) reachable_hosts = $package.network.host_count; open_services = $package.network.open_service_count excluded_scopes = $package.scan_profile.excluded_scopes } Add-SoftwareRuntimeEvidence $package $findings = @(Get-NetworkFindings $hostRows) + @(Get-EndpointFindings $package) $unique = [ordered]@{} foreach ($finding in $findings) { # Asset + title alone would merge e.g. three different accounts into one finding. $key = ("{0}|{1}|{2}|{3}|{4}" -f "$($finding.asset)".ToLower(), "$($finding.cve)".ToUpper(), "$($finding.title)".ToLower(), "$($finding.affected)".ToLower(), "$($finding.evidence)".ToLower()) $unique[$key] = $finding } $package.findings = @($unique.Values) $package.intelligence = [ordered]@{ status = 'Correlated on upload'; findings = @(); product_status = @(); correlation_mode = 'CVE correlation runs in CYNAPSE after upload' } [void]$script:AuditLog.Add([ordered]@{ event = 'collection_completed'; at = (Get-UtcNow); finding_count = $package.findings.Count }) $package.evidence_trail = @($script:AuditLog) $package.collection_summary = Get-CollectionSummary $package if (-not $Out) { $safeHost = ($hostName -replace '[^A-Za-z0-9_-]', '_') $Out = Join-Path (Get-Location) ("cynapse-discovery-{0}-{1}.json" -f $safeHost, (Get-Date).ToString('yyyyMMdd-HHmmss')) } $json = $package | ConvertTo-Json -Depth 12 -Compress # UTF-8 without a byte order mark: the upload parser rejects a BOM. [System.IO.File]::WriteAllText($Out, $json, (New-Object System.Text.UTF8Encoding($false))) Write-Host '' Write-Host 'Collection complete.' Write-Host (" Software: {0} Services: {1} Findings: {2} Hosts: {3}" -f @($package.installed_software).Count, @($package.running_services).Count, $package.findings.Count, $package.network.host_count) Write-Host " Package: $Out" Write-Host '' Write-Host 'Next: open CYNAPSE Exposure Engine and upload this file. Nothing has been sent anywhere.'