Public/Invoke-VBIPEnrichment.ps1
|
function Invoke-VBIPEnrichment { <# .SYNOPSIS Orchestrate the full enrichment pipeline against a list of private IP addresses. .DESCRIPTION Runs all enabled layers in priority order for each IP, merges results, classifies each device, persists to SQLite, and returns one enrichment object per IP. Full 11-layer pipeline: passive (AD, DHCP, PTR, ARP) then active (TCP, HTTP, SNMP, RTSP, mDNS, Switch ARP, OUI). Layers that are unavailable or whose prerequisites are not met return Skipped automatically. On PS 7 with $Context.CanUseParallel = $true, active probes (steps 5-10) run in parallel across IPs using ForEach-Object -Parallel. Passive layers always run sequentially. Classification and SQLite writes always run sequentially. Execution flow: 1. Validate context; warn if missing. 2. Validate IP list -- skip public addresses with Write-Warning. 3. Load existing rows from SQLite for supplied IPs. 4. Decide which IPs to probe (missing, stale, unresolved, or -ForceRefresh). 5a. Passive layers (sequential for all IPs): Step 1 Get-VBADComputer -> may set Hostname, OSClass Step 2 Get-VBDHCPLease -> may set Hostname, MAC Step 3 Get-VBPTRRecord -> may set Hostname if not resolved Step 4 Get-VBARPEntry -> may set MAC if not known 5b. Active layers per IP (parallel on PS7, sequential on PS5.1): Step 5 Get-VBTCPFingerprint -> OpenPorts (always) Step 6 Get-VBHTTPBanner -> gated on 80/443/8080/8443 open Step 7 Get-VBSNMPIdentity -> gated on SNMPAvailable Step 8 Get-VBRTSPBanner -> gated on port 554 open Step 9 Get-VBmDNSRecord -> gated on mDNSAvailable Step 10 Get-VBSwitchARP -> gated on SwitchTargets configured Step 11 Get-VBOUIVendor -> always runs if MAC known 6. Resolve-VBDeviceClass on merged signals. 7. Compare result to existing SQLite row; write EnrichmentHistory on change. 8. Upsert row into SQLite. 9. Emit object (stream immediately if -PassThru; collect otherwise). 10. Emit summary via Write-Verbose. .PARAMETER IPAddress One or more private IP addresses to enrich. Accepts pipeline input. .PARAMETER Context Environment context from Get-VBEnrichmentContext. Mandatory for full operation; omitting emits a warning and uses degraded defaults. .PARAMETER SkipActiveProbes Force-skip layers 5-10 even in Round 3+. Useful for scheduled passive-only runs. .PARAMETER ForceRefresh Ignore SQLite cache -- re-probe every IP even if recently enriched. .PARAMETER StaleThresholdHours Re-probe rows whose UpdatedAt is older than this many hours. Default 168 (7 days). .PARAMETER PassThru Emit each result immediately as it completes rather than collecting and emitting at end. .PARAMETER ProgressUpdateInterval Minimum seconds between Write-Progress updates. Default 1. .OUTPUTS [PSCustomObject[]] -- one enrichment object per IP (see design spec section 14). .EXAMPLE $ctx = Get-VBEnrichmentContext '192.168.1.45','192.168.1.46' | Invoke-VBIPEnrichment -Context $ctx .EXAMPLE Import-Csv ips.csv | Select-Object -ExpandProperty IPAddress | Invoke-VBIPEnrichment -Context $ctx -ForceRefresh -PassThru .NOTES Version: 1.0.0 MinPSVersion: 5.1 Author: VB ChangeLog: 1.0.0 -- 2026-05-11 -- Round 2: passive layers only (1-4) 2.0.0 -- 2026-05-11 -- Round 4: layers 8-10 wired; PS7 parallel mode for active probes #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] [Alias('IP_Address', 'IP Address', 'IP')] [string[]]$IPAddress, [Parameter()] [PSCustomObject]$Context, [Parameter()] [switch]$SkipActiveProbes, [Parameter()] [switch]$ForceRefresh, [Parameter()] [int]$StaleThresholdHours = 168, [Parameter()] [switch]$PassThru, [Parameter()] [int]$ProgressUpdateInterval = 1 ) begin { if (-not $Context) { Write-Verbose "[Orchestrator] No context provided -- auto-building context." $Context = Get-VBEnrichmentContext -Quiet } $dbPath = if ($Context) { $Context.DatabasePath } else { Join-Path $env:LOCALAPPDATA 'VB.DNSEnrichment\enrichment.db' } $dbEnabled = $Context -and $Context.DatabaseInitialized $allIPs = New-Object System.Collections.Generic.List[string] $results = New-Object System.Collections.Generic.List[PSCustomObject] $runStart = Get-Date $sw = [System.Diagnostics.Stopwatch]::StartNew() } process { foreach ($ip in $IPAddress) { $allIPs.Add($ip) } } end { # --- Step 1: Validate and filter IPs --- $validIPs = New-Object System.Collections.Generic.List[string] foreach ($ip in $allIPs) { if (Test-VBPrivateIP -IPAddress $ip) { $validIPs.Add($ip) } else { Write-Warning "[Orchestrator] Skipping public IP: $ip" } } if ($validIPs.Count -eq 0) { Write-Verbose "[Orchestrator] No valid private IPs to process." return } # --- Step 2: Load existing SQLite rows --- $cachedRows = @{} if ($dbEnabled) { try { $paramHash = @{} $placeholders = for ($i = 0; $i -lt $validIPs.Count; $i++) { $paramHash["ip$i"] = $validIPs[$i] "@ip$i" } $inClause = $placeholders -join ',' $rows = Invoke-VBSqliteCommand -DatabasePath $dbPath ` -Query "SELECT * FROM Enrichment WHERE IPAddress IN ($inClause)" ` -SqlParameters $paramHash foreach ($row in $rows) { $cachedRows[$row.IPAddress] = $row } Write-Verbose "[Orchestrator] Loaded $($cachedRows.Count) existing rows from SQLite" } catch { Write-Warning "[Orchestrator] SQLite read failed: $($_.Exception.Message)" } } # --- Step 3: Decide which IPs to probe --- $toProbe = New-Object System.Collections.Generic.List[string] $fromCache = New-Object System.Collections.Generic.List[PSCustomObject] $staleThreshold = (Get-Date).AddHours(-$StaleThresholdHours) foreach ($ip in $validIPs) { if ($ForceRefresh) { $toProbe.Add($ip) continue } $existing = $cachedRows[$ip] if ($null -eq $existing) { $toProbe.Add($ip) } elseif ($existing.IsResolved -eq 0) { $toProbe.Add($ip) } elseif ($existing.UpdatedAt -and ([datetime]$existing.UpdatedAt) -lt $staleThreshold) { $toProbe.Add($ip) } else { # Return from cache $fromCache.Add((ConvertFrom-VBSqliteEnrichmentRow -Row $existing -FromCache $true)) } } Write-Verbose "[Orchestrator] $($validIPs.Count) IPs total: $($toProbe.Count) to probe, $($fromCache.Count) from cache" # Emit cached results foreach ($cached in $fromCache) { if ($PassThru) { $cached } else { $results.Add($cached) } } # --- Step 4: Passive probes (sequential -- build one-shot caches) --- $total = $toProbe.Count $current = 0 # Collect state objects for all IPs (passive pass) $stateMap = [ordered]@{} foreach ($ip in $toProbe) { $current++ $layerTrace = New-Object System.Collections.Generic.List[PSCustomObject] # State accumulator for this IP $state = @{ IPAddress = $ip Hostname = $null HostnameSource = $null IsResolved = $false MACAddress = $null MACNormalised = $null OSClass = $null OperatingSystem = $null OU = $null OpenPorts = $null HTTPTitle = $null HTTPServer = $null SNMPDescr = $null RTSPBanner = $null MDNSServiceType = $null Location = $null LeaseExpiry = $null VendorDeviceClass = $null OUIVendor = $null PassiveTrace = $layerTrace # carry through to active phase } # ---- Step 1: AD ---- Write-VBEnrichmentProgress -Current $current -Total $total -IPAddress $ip ` -StepNumber 1 -LayerName 'AD' -ElapsedMs $sw.ElapsedMilliseconds Write-Verbose "[$ip] Step 1 AD" $adResult = Get-VBADComputer -IPAddress $ip -Context $Context $adDetail = if ($adResult.Status -eq 'Success') { "$($adResult.OSClass) | $($adResult.OU)" } else { $adResult.SkipReason + $adResult.ErrorDetail } $layerTrace.Add([PSCustomObject]@{ Step = 1 Name = 'AD' Status = $adResult.Status DurationMs = $adResult.ExecutionMs Detail = $adDetail }) if ($adResult.Status -eq 'Success') { $state.Hostname = $adResult.Hostname $state.HostnameSource = 'AD' $state.IsResolved = $true $state.OSClass = $adResult.OSClass $state.OperatingSystem = $adResult.OperatingSystem $state.OU = $adResult.OU } Write-Verbose "[$ip] Step 1 AD -> $($adResult.Status)" # ---- Step 2: DHCP (always runs -- provides MAC even if already resolved) ---- Write-VBEnrichmentProgress -Current $current -Total $total -IPAddress $ip ` -StepNumber 2 -LayerName 'DHCP' -ElapsedMs $sw.ElapsedMilliseconds Write-Verbose "[$ip] Step 2 DHCP" $dhcpResult = Get-VBDHCPLease -IPAddress $ip -Context $Context $dhcpDetail = if ($dhcpResult.Status -eq 'Success') { "$($dhcpResult.Hostname) MAC:$($dhcpResult.MACAddress)" } else { $dhcpResult.SkipReason + $dhcpResult.ErrorDetail } $layerTrace.Add([PSCustomObject]@{ Step = 2 Name = 'DHCP' Status = $dhcpResult.Status DurationMs = $dhcpResult.ExecutionMs Detail = $dhcpDetail }) if ($dhcpResult.Status -eq 'Success') { if (-not [string]::IsNullOrWhiteSpace($dhcpResult.MACAddress)) { $state.MACAddress = $dhcpResult.MACAddress $state.MACNormalised = $dhcpResult.MACNormalised } if (-not $state.IsResolved -and -not [string]::IsNullOrWhiteSpace($dhcpResult.Hostname)) { $state.Hostname = $dhcpResult.Hostname $state.HostnameSource = 'DHCP' $state.IsResolved = $true $state.LeaseExpiry = $dhcpResult.LeaseExpiry } } Write-Verbose "[$ip] Step 2 DHCP -> $($dhcpResult.Status)" # ---- Step 3: PTR (only if not already resolved) ---- Write-VBEnrichmentProgress -Current $current -Total $total -IPAddress $ip ` -StepNumber 3 -LayerName 'PTR' -ElapsedMs $sw.ElapsedMilliseconds if (-not $state.IsResolved) { Write-Verbose "[$ip] Step 3 PTR" $ptrResult = Get-VBPTRRecord -IPAddress $ip -Context $Context $ptrDetail = if ($ptrResult.Status -eq 'Success') { "$($ptrResult.Hostname) (fwd:$($ptrResult.ForwardConfirmed))" } else { $ptrResult.SkipReason + $ptrResult.ErrorDetail } $layerTrace.Add([PSCustomObject]@{ Step = 3 Name = 'PTR' Status = $ptrResult.Status DurationMs = $ptrResult.ExecutionMs Detail = $ptrDetail }) if ($ptrResult.Status -eq 'Success' -and -not [string]::IsNullOrWhiteSpace($ptrResult.Hostname)) { $state.Hostname = $ptrResult.Hostname $state.HostnameSource = if ($ptrResult.ForwardConfirmed) { 'PTR' } else { 'PTR-Unconfirmed' } $state.IsResolved = $true } Write-Verbose "[$ip] Step 3 PTR -> $($ptrResult.Status)" } else { $layerTrace.Add([PSCustomObject]@{ Step = 3; Name = 'PTR'; Status = 'Skipped'; DurationMs = 0 Detail = 'Already resolved' }) Write-Verbose "[$ip] Step 3 PTR -> Skipped (already resolved)" } # ---- Step 4: ARP (always runs -- provides MAC) ---- Write-VBEnrichmentProgress -Current $current -Total $total -IPAddress $ip ` -StepNumber 4 -LayerName 'ARP' -ElapsedMs $sw.ElapsedMilliseconds Write-Verbose "[$ip] Step 4 ARP" $arpResult = Get-VBARPEntry -IPAddress $ip -Context $Context $arpDetail = if ($arpResult.Status -eq 'Success') { "MAC:$($arpResult.MACAddress) ($($arpResult.ARPType))" } else { $arpResult.SkipReason + $arpResult.ErrorDetail } $layerTrace.Add([PSCustomObject]@{ Step = 4 Name = 'ARP' Status = $arpResult.Status DurationMs = $arpResult.ExecutionMs Detail = $arpDetail }) if ($arpResult.Status -eq 'Success' -and [string]::IsNullOrWhiteSpace($state.MACAddress)) { $state.MACAddress = $arpResult.MACAddress $state.MACNormalised = $arpResult.MACNormalised } Write-Verbose "[$ip] Step 4 ARP -> $($arpResult.Status)" $stateMap[$ip] = $state } # --- Steps 5-11: Active probes (parallel on PS7 when context allows, sequential otherwise) --- $useParallel = ($PSVersionTable.PSVersion.Major -ge 7) -and ($Context -and $Context.CanUseParallel) -and (-not $SkipActiveProbes) -and ($stateMap.Count -gt 1) if ($useParallel) { Write-Verbose "[Orchestrator] PS7 parallel active probes -- $($stateMap.Count) IPs, throttle $($Context.ParallelThrottleLimit)" # Build a serialisable list of per-IP inputs for the parallel block $parallelInputs = foreach ($ip in $stateMap.Keys) { $s = $stateMap[$ip] [PSCustomObject]@{ IPAddress = $ip IsResolved = $s.IsResolved MACAddress = $s.MACAddress MACNormalised = $s.MACNormalised SNMPAvailable = ($Context -and $Context.SNMPAvailable) mDNSAvailable = ($Context -and $Context.mDNSAvailable) RTSPProbeEnabled = ($Context -and $Context.RTSPProbeEnabled) SwitchTargetCount = if ($Context -and $Context.SwitchTargets) { [int]$Context.SwitchTargets.Count } else { 0 } } } $throttle = $Context.ParallelThrottleLimit $modulePath = (Get-Module -Name 'VB.DNSEnrichment' | Select-Object -ExpandProperty Path -First 1) if (-not $modulePath) { throw "[Orchestrator] Cannot resolve VB.DNSEnrichment module path for parallel block." } # Pre-warm OUI table in main scope so $using: can pass it into runspaces $null = Get-VBOUIVendor -MACAddress '000000000000' -Context $Context $ouiTableSnapshot = $Script:VBOUITable $parallelResults = $parallelInputs | ForEach-Object -ThrottleLimit $throttle -Parallel { $inp = $_ $ctx = $using:Context $modPath = $using:modulePath if (-not (Get-Module -Name 'VB.DNSEnrichment')) { Import-Module $modPath -ErrorAction Stop } $Script:VBOUITable = $using:ouiTableSnapshot $ip = $inp.IPAddress $activeTrace = New-Object System.Collections.Generic.List[PSCustomObject] $openPortsList = @() $fields = @{ OpenPorts = $null HTTPTitle = $null HTTPServer = $null SNMPDescr = $null RTSPBanner = $null MDNSServiceType = $null Location = $null OUIVendor = $null VendorDeviceClass = $null MACAddress = $inp.MACAddress MACNormalised = $inp.MACNormalised IsResolved = $inp.IsResolved Hostname = $null HostnameSource = $null } # Step 5 TCP $tcpResult = Get-VBTCPFingerprint -IPAddress $ip -Context $ctx $activeTrace.Add([PSCustomObject]@{ Step=5; Name='TCP'; Status=$tcpResult.Status; DurationMs=$tcpResult.ExecutionMs; Detail=if($tcpResult.Status -eq 'Success'){$tcpResult.OpenPorts}else{$tcpResult.SkipReason+$tcpResult.ErrorDetail} }) if ($tcpResult.Status -eq 'Success') { $fields.OpenPorts=$tcpResult.OpenPorts; $openPortsList=$tcpResult.OpenPortsList } # Step 6 HTTP $httpGate = @(80,443,8080,8443) if (($openPortsList | Where-Object { $httpGate -contains $_ }).Count -gt 0) { $httpResult = Get-VBHTTPBanner -IPAddress $ip -OpenPortsList $openPortsList -Context $ctx $activeTrace.Add([PSCustomObject]@{ Step=6; Name='HTTP'; Status=$httpResult.Status; DurationMs=$httpResult.ExecutionMs; Detail=if($httpResult.Status -eq 'Success'){"$($httpResult.HTTPTitle) [$($httpResult.HTTPServer)]"}else{$httpResult.SkipReason+$httpResult.ErrorDetail} }) if ($httpResult.Status -eq 'Success') { $fields.HTTPTitle=$httpResult.HTTPTitle; $fields.HTTPServer=$httpResult.HTTPServer } } else { $activeTrace.Add([PSCustomObject]@{ Step=6; Name='HTTP'; Status='Skipped'; DurationMs=0; Detail='No HTTP ports open (80/443/8080/8443)' }) } # Step 7 SNMP if ($inp.SNMPAvailable) { $snmpResult = Get-VBSNMPIdentity -IPAddress $ip -Context $ctx $activeTrace.Add([PSCustomObject]@{ Step=7; Name='SNMP'; Status=$snmpResult.Status; DurationMs=$snmpResult.ExecutionMs; Detail=if($snmpResult.Status -eq 'Success'){"$($snmpResult.SNMPDescr) loc:$($snmpResult.Location)"}else{$snmpResult.SkipReason+$snmpResult.ErrorDetail} }) if ($snmpResult.Status -eq 'Success') { $fields.SNMPDescr=$snmpResult.SNMPDescr; $fields.Location=$snmpResult.Location if (-not $fields.IsResolved -and -not [string]::IsNullOrWhiteSpace($snmpResult.Hostname)) { $fields.Hostname=$snmpResult.Hostname; $fields.HostnameSource='SNMP'; $fields.IsResolved=$true } } } else { $activeTrace.Add([PSCustomObject]@{ Step=7; Name='SNMP'; Status='Skipped'; DurationMs=0; Detail='SNMP unavailable (olePrn COM not present)' }) } # Step 8 RTSP if ($openPortsList -contains 554 -and $inp.RTSPProbeEnabled) { $rtspResult = Get-VBRTSPBanner -IPAddress $ip -Context $ctx $activeTrace.Add([PSCustomObject]@{ Step=8; Name='RTSP'; Status=$rtspResult.Status; DurationMs=$rtspResult.ExecutionMs; Detail=if($rtspResult.Status -eq 'Success'){$rtspResult.RTSPBanner.Substring(0,[math]::Min(80,$rtspResult.RTSPBanner.Length))}else{$rtspResult.SkipReason+$rtspResult.ErrorDetail} }) if ($rtspResult.Status -eq 'Success') { $fields.RTSPBanner=$rtspResult.RTSPBanner } } else { $activeTrace.Add([PSCustomObject]@{ Step=8; Name='RTSP'; Status='Skipped'; DurationMs=0; Detail=if($openPortsList -notcontains 554){'Port 554 closed'}else{'RTSPProbeDisabled'} }) } # Step 9 mDNS if ($inp.mDNSAvailable) { $mdnsResult = Get-VBmDNSRecord -IPAddress $ip -Context $ctx $activeTrace.Add([PSCustomObject]@{ Step=9; Name='mDNS'; Status=$mdnsResult.Status; DurationMs=$mdnsResult.ExecutionMs; Detail=if($mdnsResult.Status -eq 'Success'){"$($mdnsResult.MDNSServiceType) $($mdnsResult.MDNSServiceName)"}else{$mdnsResult.SkipReason+$mdnsResult.ErrorDetail} }) if ($mdnsResult.Status -eq 'Success') { $fields.MDNSServiceType=$mdnsResult.MDNSServiceType if (-not $fields.IsResolved -and -not [string]::IsNullOrWhiteSpace($mdnsResult.MDNSServiceName)) { $fields.Hostname=$mdnsResult.MDNSServiceName; $fields.HostnameSource='mDNS'; $fields.IsResolved=$true } } } else { $activeTrace.Add([PSCustomObject]@{ Step=9; Name='mDNS'; Status='Skipped'; DurationMs=0; Detail='dns-sd.exe not on PATH' }) } # Step 10 Switch if ($inp.SwitchTargetCount -gt 0 -and $inp.SNMPAvailable) { $switchResult = Get-VBSwitchARP -IPAddress $ip -Context $ctx $activeTrace.Add([PSCustomObject]@{ Step=10; Name='Switch'; Status=$switchResult.Status; DurationMs=$switchResult.ExecutionMs; Detail=if($switchResult.Status -eq 'Success'){"SW:$($switchResult.SwitchIP) Port:$($switchResult.SwitchPort) $($switchResult.PortDescription)"}else{$switchResult.SkipReason+$switchResult.ErrorDetail} }) if ($switchResult.Status -eq 'Success') { if ([string]::IsNullOrWhiteSpace($fields.MACAddress)) { $fields.MACAddress=$switchResult.MACAddress; $fields.MACNormalised=($switchResult.MACAddress -replace '[:\-\.]','').ToUpperInvariant() } if ([string]::IsNullOrWhiteSpace($fields.Location)) { $fields.Location=$switchResult.PortDescription } } } else { $activeTrace.Add([PSCustomObject]@{ Step=10; Name='Switch'; Status='Skipped'; DurationMs=0; Detail=if($inp.SwitchTargetCount -eq 0){'No SwitchTargets configured'}else{'SNMPUnavailable'} }) } # Step 11 OUI $ouiResult = Get-VBOUIVendor -MACAddress $fields.MACAddress -IPAddress $ip -Context $ctx $activeTrace.Add([PSCustomObject]@{ Step=11; Name='OUI'; Status=$ouiResult.Status; DurationMs=$ouiResult.ExecutionMs; Detail=if($ouiResult.Status -eq 'Success'){$ouiResult.Vendor}else{$ouiResult.SkipReason+$ouiResult.ErrorDetail} }) if ($ouiResult.Status -eq 'Success') { $fields.OUIVendor=$ouiResult.Vendor; $fields.VendorDeviceClass=$ouiResult.VendorDeviceClass } [PSCustomObject]@{ IPAddress=$ip; Fields=$fields; ActiveTrace=$activeTrace } } # Merge parallel results back into stateMap foreach ($pr in $parallelResults) { $s = $stateMap[$pr.IPAddress] $f = $pr.Fields $s.OpenPorts = $f.OpenPorts $s.HTTPTitle = $f.HTTPTitle $s.HTTPServer = $f.HTTPServer $s.SNMPDescr = $f.SNMPDescr $s.RTSPBanner = $f.RTSPBanner $s.MDNSServiceType = $f.MDNSServiceType $s.Location = $f.Location $s.OUIVendor = $f.OUIVendor $s.VendorDeviceClass = $f.VendorDeviceClass if (-not [string]::IsNullOrWhiteSpace($f.MACAddress) -and [string]::IsNullOrWhiteSpace($s.MACAddress)) { $s.MACAddress = $f.MACAddress $s.MACNormalised = $f.MACNormalised } if ($f.IsResolved -and -not $s.IsResolved) { $s.IsResolved = $true $s.Hostname = $f.Hostname $s.HostnameSource = $f.HostnameSource } foreach ($entry in $pr.ActiveTrace) { $s.PassiveTrace.Add($entry) } } } else { # Sequential active probes (PS5.1 or single IP or SkipActiveProbes) foreach ($ip in $stateMap.Keys) { $state = $stateMap[$ip] $layerTrace = $state.PassiveTrace $openPortsList = @() if (-not $SkipActiveProbes) { # ---- Step 5: TCP ---- $tcpResult = Get-VBTCPFingerprint -IPAddress $ip -Context $Context $tcpDetail = if ($tcpResult.Status -eq 'Success') { $tcpResult.OpenPorts } else { $tcpResult.SkipReason + $tcpResult.ErrorDetail } $layerTrace.Add([PSCustomObject]@{ Step = 5; Name = 'TCP'; Status = $tcpResult.Status; DurationMs = $tcpResult.ExecutionMs Detail = $tcpDetail }) if ($tcpResult.Status -eq 'Success') { $state.OpenPorts=$tcpResult.OpenPorts; $openPortsList=$tcpResult.OpenPortsList } Write-Verbose "[$ip] Step 5 TCP -> $($tcpResult.Status) ports:$($tcpResult.OpenPorts)" # ---- Step 6: HTTP ---- $httpGatePorts = @(80, 443, 8080, 8443) $httpOpen = @($openPortsList | Where-Object { $httpGatePorts -contains $_ }) if ($httpOpen.Count -gt 0) { $httpResult = Get-VBHTTPBanner -IPAddress $ip -OpenPortsList $openPortsList -Context $Context $layerTrace.Add([PSCustomObject]@{ Step=6; Name='HTTP'; Status=$httpResult.Status; DurationMs=$httpResult.ExecutionMs Detail=if($httpResult.Status -eq 'Success'){"$($httpResult.HTTPTitle) [$($httpResult.HTTPServer)]"}else{$httpResult.SkipReason+$httpResult.ErrorDetail} }) if ($httpResult.Status -eq 'Success') { $state.HTTPTitle=$httpResult.HTTPTitle; $state.HTTPServer=$httpResult.HTTPServer } Write-Verbose "[$ip] Step 6 HTTP -> $($httpResult.Status)" } else { $layerTrace.Add([PSCustomObject]@{ Step=6; Name='HTTP'; Status='Skipped'; DurationMs=0; Detail='No HTTP ports open (80/443/8080/8443)' }) Write-Verbose "[$ip] Step 6 HTTP -> Skipped" } # ---- Step 7: SNMP ---- if ($Context -and $Context.SNMPAvailable) { $snmpResult = Get-VBSNMPIdentity -IPAddress $ip -Context $Context $layerTrace.Add([PSCustomObject]@{ Step=7; Name='SNMP'; Status=$snmpResult.Status; DurationMs=$snmpResult.ExecutionMs Detail=if($snmpResult.Status -eq 'Success'){"$($snmpResult.SNMPDescr) loc:$($snmpResult.Location)"}else{$snmpResult.SkipReason+$snmpResult.ErrorDetail} }) if ($snmpResult.Status -eq 'Success') { $state.SNMPDescr=$snmpResult.SNMPDescr; $state.Location=$snmpResult.Location if (-not $state.IsResolved -and -not [string]::IsNullOrWhiteSpace($snmpResult.Hostname)) { $state.Hostname=$snmpResult.Hostname; $state.HostnameSource='SNMP'; $state.IsResolved=$true } } Write-Verbose "[$ip] Step 7 SNMP -> $($snmpResult.Status)" } else { $layerTrace.Add([PSCustomObject]@{ Step=7; Name='SNMP'; Status='Skipped'; DurationMs=0; Detail='SNMP unavailable (olePrn COM not present)' }) Write-Verbose "[$ip] Step 7 SNMP -> Skipped" } # ---- Step 8: RTSP ---- if ($openPortsList -contains 554 -and ($Context -and $Context.RTSPProbeEnabled)) { $rtspResult = Get-VBRTSPBanner -IPAddress $ip -Context $Context $layerTrace.Add([PSCustomObject]@{ Step=8; Name='RTSP'; Status=$rtspResult.Status; DurationMs=$rtspResult.ExecutionMs Detail=if($rtspResult.Status -eq 'Success'){$rtspResult.RTSPBanner.Substring(0,[math]::Min(80,$rtspResult.RTSPBanner.Length))}else{$rtspResult.SkipReason+$rtspResult.ErrorDetail} }) if ($rtspResult.Status -eq 'Success') { $state.RTSPBanner=$rtspResult.RTSPBanner } Write-Verbose "[$ip] Step 8 RTSP -> $($rtspResult.Status)" } else { $layerTrace.Add([PSCustomObject]@{ Step=8; Name='RTSP'; Status='Skipped'; DurationMs=0; Detail=if($openPortsList -notcontains 554){'Port 554 closed'}else{'RTSPProbeDisabled'} }) Write-Verbose "[$ip] Step 8 RTSP -> Skipped" } # ---- Step 9: mDNS ---- if ($Context -and $Context.mDNSAvailable) { $mdnsResult = Get-VBmDNSRecord -IPAddress $ip -Context $Context $layerTrace.Add([PSCustomObject]@{ Step=9; Name='mDNS'; Status=$mdnsResult.Status; DurationMs=$mdnsResult.ExecutionMs Detail=if($mdnsResult.Status -eq 'Success'){"$($mdnsResult.MDNSServiceType) $($mdnsResult.MDNSServiceName)"}else{$mdnsResult.SkipReason+$mdnsResult.ErrorDetail} }) if ($mdnsResult.Status -eq 'Success') { $state.MDNSServiceType=$mdnsResult.MDNSServiceType if (-not $state.IsResolved -and -not [string]::IsNullOrWhiteSpace($mdnsResult.MDNSServiceName)) { $state.Hostname=$mdnsResult.MDNSServiceName; $state.HostnameSource='mDNS'; $state.IsResolved=$true } } Write-Verbose "[$ip] Step 9 mDNS -> $($mdnsResult.Status)" } else { $layerTrace.Add([PSCustomObject]@{ Step=9; Name='mDNS'; Status='Skipped'; DurationMs=0; Detail='dns-sd.exe not on PATH' }) Write-Verbose "[$ip] Step 9 mDNS -> Skipped" } # ---- Step 10: Switch ---- if ($Context -and $Context.SwitchTargets.Count -gt 0 -and $Context.SNMPAvailable) { $switchResult = Get-VBSwitchARP -IPAddress $ip -Context $Context $layerTrace.Add([PSCustomObject]@{ Step=10; Name='Switch'; Status=$switchResult.Status; DurationMs=$switchResult.ExecutionMs Detail=if($switchResult.Status -eq 'Success'){"SW:$($switchResult.SwitchIP) Port:$($switchResult.SwitchPort) $($switchResult.PortDescription)"}else{$switchResult.SkipReason+$switchResult.ErrorDetail} }) if ($switchResult.Status -eq 'Success') { if ([string]::IsNullOrWhiteSpace($state.MACAddress)) { $state.MACAddress=$switchResult.MACAddress; $state.MACNormalised=ConvertTo-VBNormalisedMAC -MACAddress $switchResult.MACAddress } $state.Location=$switchResult.PortDescription } Write-Verbose "[$ip] Step 10 Switch -> $($switchResult.Status)" } else { $layerTrace.Add([PSCustomObject]@{ Step=10; Name='Switch'; Status='Skipped'; DurationMs=0; Detail=if(-not($Context -and $Context.SwitchTargets.Count -gt 0)){'No SwitchTargets configured'}else{'SNMPUnavailable'} }) Write-Verbose "[$ip] Step 10 Switch -> Skipped" } # ---- Step 11: OUI ---- $ouiResult = Get-VBOUIVendor -MACAddress $state.MACAddress -IPAddress $ip -Context $Context $layerTrace.Add([PSCustomObject]@{ Step=11; Name='OUI'; Status=$ouiResult.Status; DurationMs=$ouiResult.ExecutionMs Detail=if($ouiResult.Status -eq 'Success'){$ouiResult.Vendor}else{$ouiResult.SkipReason+$ouiResult.ErrorDetail} }) if ($ouiResult.Status -eq 'Success') { $state.OUIVendor=$ouiResult.Vendor; $state.VendorDeviceClass=$ouiResult.VendorDeviceClass } Write-Verbose "[$ip] Step 11 OUI -> $($ouiResult.Status) vendor:$($ouiResult.Vendor)" } else { foreach ($stub in @( @{ Step=5; Name='TCP' }; @{ Step=6; Name='HTTP' }; @{ Step=7; Name='SNMP' } @{ Step=8; Name='RTSP' }; @{ Step=9; Name='mDNS' }; @{ Step=10; Name='Switch' } @{ Step=11; Name='OUI' } )) { $layerTrace.Add([PSCustomObject]@{ Step=$stub.Step; Name=$stub.Name; Status='Skipped'; DurationMs=0; Detail='-SkipActiveProbes set' }) } } } } # --- Classification + SQLite writes (always sequential) --- $current = 0 foreach ($ip in $stateMap.Keys) { $current++ $state = $stateMap[$ip] $layerTrace = $state.PassiveTrace $ipSw = [System.Diagnostics.Stopwatch]::StartNew() # ---- Classification ---- Write-VBEnrichmentProgress -Current $current -Total $total -IPAddress $ip ` -StepNumber 12 -LayerName 'Classify' -ElapsedMs $sw.ElapsedMilliseconds $classResult = Resolve-VBDeviceClass ` -OSClass $state.OSClass ` -OpenPorts $state.OpenPorts ` -HTTPTitle $state.HTTPTitle ` -HTTPServer $state.HTTPServer ` -SNMPDescr $state.SNMPDescr ` -RTSPBanner $state.RTSPBanner ` -OUIVendor $state.OUIVendor ` -VendorDeviceClass $state.VendorDeviceClass ` -MDNSServiceType $state.MDNSServiceType Write-Verbose "[$ip] Classify -> $($classResult.DeviceClass) ($($classResult.Confidence)) via $($classResult.DeviceClassSource)" # ---- Tally steps ---- $attempted = @($layerTrace | Where-Object { $_.Status -ne 'Skipped' }).Count $succeeded = @($layerTrace | Where-Object { $_.Status -eq 'Success' }).Count $noResult = @($layerTrace | Where-Object { $_.Status -eq 'NoResult' }).Count $skipped = @($layerTrace | Where-Object { $_.Status -eq 'Skipped' }).Count $failed = @($layerTrace | Where-Object { $_.Status -eq 'Failed' }).Count $layerTraceJson = $layerTrace | ConvertTo-Json -Compress -Depth 3 $ipSw.Stop() $now = Get-Date $nowIso = $now.ToString('o') # ---- Compare to existing row for change detection ---- $existing = $cachedRows[$ip] $changeReason = 'NewIP' if ($existing) { if ($existing.Hostname -ne $state.Hostname -and -not [string]::IsNullOrWhiteSpace($state.Hostname)) { $changeReason = 'HostnameChanged' } elseif ($existing.MACAddressNormalised -ne $state.MACNormalised -and -not [string]::IsNullOrWhiteSpace($state.MACNormalised)) { $changeReason = 'MACChanged' } elseif ($existing.DeviceClass -ne $classResult.DeviceClass) { $changeReason = 'ClassChanged' } else { $changeReason = 'StaleRefresh' } } $firstSeen = if ($existing -and $existing.FirstSeenAt) { $existing.FirstSeenAt } else { $nowIso } # ---- Upsert into SQLite ---- if ($dbEnabled) { try { $upsertSql = @' INSERT INTO Enrichment ( IPAddress, Hostname, HostnameSource, MACAddress, MACAddressNormalised, Vendor, DeviceClass, DeviceClassSource, Confidence, OSClass, OperatingSystem, OU, OpenPorts, LeaseExpiry, StepsAttempted, StepsSucceeded, StepsNoResult, StepsSkipped, StepsFailed, LayerTraceJson, IsResolved, IsUnresolved, EnrichedAt, EnrichmentDurationMs, FirstSeenAt, UpdatedAt ) VALUES ( @ip, @hostname, @hostnameSource, @mac, @macNorm, @vendor, @deviceClass, @deviceClassSource, @confidence, @osClass, @os, @ou, @openPorts, @leaseExpiry, @attempted, @succeeded, @noResult, @skipped, @failed, @traceJson, @isResolved, @isUnresolved, @enrichedAt, @durationMs, @firstSeen, @updatedAt ) ON CONFLICT(IPAddress) DO UPDATE SET Hostname = excluded.Hostname, HostnameSource = excluded.HostnameSource, MACAddress = excluded.MACAddress, MACAddressNormalised = excluded.MACAddressNormalised, Vendor = excluded.Vendor, DeviceClass = excluded.DeviceClass, DeviceClassSource = excluded.DeviceClassSource, Confidence = excluded.Confidence, OSClass = excluded.OSClass, OperatingSystem = excluded.OperatingSystem, OU = excluded.OU, OpenPorts = excluded.OpenPorts, LeaseExpiry = excluded.LeaseExpiry, StepsAttempted = excluded.StepsAttempted, StepsSucceeded = excluded.StepsSucceeded, StepsNoResult = excluded.StepsNoResult, StepsSkipped = excluded.StepsSkipped, StepsFailed = excluded.StepsFailed, LayerTraceJson = excluded.LayerTraceJson, IsResolved = excluded.IsResolved, IsUnresolved = excluded.IsUnresolved, EnrichedAt = excluded.EnrichedAt, EnrichmentDurationMs = excluded.EnrichmentDurationMs, UpdatedAt = excluded.UpdatedAt '@ $isResolved = if ($state.IsResolved) { 1 } else { 0 } $isUnresolved = if ($classResult.DeviceClass -eq 'Unknown') { 1 } else { 0 } $leaseExpiryVal = if ($state.LeaseExpiry) { $state.LeaseExpiry.ToString('o') } else { $null } Invoke-VBSqliteCommand -DatabasePath $dbPath-Query $upsertSql ` -SqlParameters @{ ip = $ip hostname = $state.Hostname hostnameSource = $state.HostnameSource mac = $state.MACAddress macNorm = $state.MACNormalised vendor = $state.OUIVendor deviceClass = $classResult.DeviceClass deviceClassSource = $classResult.DeviceClassSource confidence = $classResult.Confidence osClass = $state.OSClass os = $state.OperatingSystem ou = $state.OU openPorts = $state.OpenPorts leaseExpiry = $leaseExpiryVal attempted = $attempted succeeded = $succeeded noResult = $noResult skipped = $skipped failed = $failed traceJson = $layerTraceJson isResolved = $isResolved isUnresolved = $isUnresolved enrichedAt = $nowIso durationMs = [int]$ipSw.ElapsedMilliseconds firstSeen = $firstSeen updatedAt = $nowIso } | Out-Null # Write history row for meaningful changes if ($changeReason -ne 'StaleRefresh' -and $changeReason -ne 'NewIP') { $historySql = @' INSERT INTO EnrichmentHistory ( IPAddress, OldHostname, NewHostname, OldMACAddress, NewMACAddress, OldDeviceClass, NewDeviceClass, ChangeReason, ChangedAt ) VALUES ( @ip, @oldHostname, @newHostname, @oldMAC, @newMAC, @oldClass, @newClass, @reason, @changedAt ) '@ Invoke-VBSqliteCommand -DatabasePath $dbPath-Query $historySql ` -SqlParameters @{ ip = $ip oldHostname = $existing.Hostname newHostname = $state.Hostname oldMAC = $existing.MACAddress newMAC = $state.MACAddress oldClass = $existing.DeviceClass newClass = $classResult.DeviceClass reason = $changeReason changedAt = $nowIso } | Out-Null } Write-Verbose "[$ip] DB upsert -> $changeReason" } catch { Write-Warning "[$ip] SQLite upsert failed: $($_.Exception.Message)" } } # ---- Build final enrichment object ---- $enriched = [PSCustomObject]@{ IPAddress = $ip Hostname = $state.Hostname HostnameSource = $state.HostnameSource MACAddress = $state.MACAddress MACAddressNormalised = $state.MACNormalised Vendor = $state.OUIVendor DeviceClass = $classResult.DeviceClass DeviceClassSource = $classResult.DeviceClassSource Confidence = $classResult.Confidence OSClass = $state.OSClass OperatingSystem = $state.OperatingSystem Model = $null Location = $state.Location OU = $state.OU OpenPorts = $state.OpenPorts HTTPTitle = $state.HTTPTitle HTTPServer = $state.HTTPServer SNMPDescr = $state.SNMPDescr RTSPBanner = $state.RTSPBanner MDNSServiceType = $state.MDNSServiceType StepsAttempted = $attempted StepsSucceeded = $succeeded StepsNoResult = $noResult StepsSkipped = $skipped StepsFailed = $failed LayerTrace = $layerTrace.ToArray() IsResolved = $state.IsResolved IsUnresolved = ($classResult.DeviceClass -eq 'Unknown') EnrichedAt = $now EnrichmentDurationMs = [int]$ipSw.ElapsedMilliseconds FirstSeenAt = [datetime]$firstSeen UpdatedAt = $now ChangeReason = $changeReason FromCache = $false } if ($PassThru) { $enriched } else { $results.Add($enriched) } } Write-VBEnrichmentProgress -Completed # --- Summary --- $fromCacheCount = $fromCache.Count $resolvedCount = @($results | Where-Object { $_.IsResolved }).Count + @($fromCache | Where-Object { $_.IsResolved }).Count $unknownCount = @($results | Where-Object { $_.IsUnresolved }).Count + @($fromCache | Where-Object { $_.IsUnresolved }).Count Write-Verbose "[Orchestrator] Processed $($validIPs.Count). From cache: $fromCacheCount. Newly resolved: $resolvedCount. Still unknown: $unknownCount. Duration: $($sw.ElapsedMilliseconds) ms" if (-not $PassThru) { foreach ($r in $results) { $r } } } } |