@cfour-hi/dsh-plugin-devicex 0.0.0-stage → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +38 -2
- package/client.js +754 -0
- package/cordis.patch.yml +3 -0
- package/host/actions-darwin.js +1052 -0
- package/host/actions-win32.js +1299 -0
- package/host/actions.js +45 -0
- package/host/benchmark.js +413 -0
- package/host/cleanup-darwin.js +715 -0
- package/host/cleanup-win32.js +760 -0
- package/host/cleanup.js +62 -0
- package/host/collector-darwin.js +2995 -0
- package/host/collector-win32.js +2057 -0
- package/host/collector.js +71 -0
- package/host/proc.js +232 -0
- package/host/tool.js +254 -0
- package/host/transport.js +294 -0
- package/host/win/hardware.ps1 +501 -0
- package/host/win/metrics.ps1 +535 -0
- package/host/win/network.ps1 +322 -0
- package/host/win/processes.ps1 +169 -0
- package/host/win/security.ps1 +197 -0
- package/host/win/software.ps1 +119 -0
- package/host/win/system.ps1 +279 -0
- package/host/win.js +187 -0
- package/icon.svg +18 -0
- package/index.js +65 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +60 -4
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# Device X - Windows installed-software inventory (registry Uninstall keys).
|
|
2
|
+
#
|
|
3
|
+
# Emits exactly one JSON document on stdout (no other stdout text).
|
|
4
|
+
# Read-only: no admin rights, no network calls, no external modules.
|
|
5
|
+
# `winget --version` only runs when the executable is present (no install, no
|
|
6
|
+
# source agreement prompt) and never touches the network.
|
|
7
|
+
$ErrorActionPreference = 'Stop'
|
|
8
|
+
|
|
9
|
+
$errors = @()
|
|
10
|
+
|
|
11
|
+
function Invoke-NativeOem([scriptblock]$action) {
|
|
12
|
+
# Native console tools (netsh/tasklist/manage-bde/winget) write OEM/ANSI bytes
|
|
13
|
+
# on a non-English Windows. The bridge sets the console to UTF-8 for the JSON
|
|
14
|
+
# document; temporarily decode native output with the OEM code page so SSIDs,
|
|
15
|
+
# user names and device names are not mojibake, then restore in finally.
|
|
16
|
+
$originalEncoding = [Console]::OutputEncoding
|
|
17
|
+
$oemCodePage = 437
|
|
18
|
+
try {
|
|
19
|
+
$cultureCodePage = [System.Globalization.CultureInfo]::CurrentCulture.TextInfo.OEMCodePage
|
|
20
|
+
if ($cultureCodePage -gt 0) { $oemCodePage = $cultureCodePage }
|
|
21
|
+
[Console]::OutputEncoding = [System.Text.Encoding]::GetEncoding($oemCodePage)
|
|
22
|
+
return & $action
|
|
23
|
+
} finally {
|
|
24
|
+
[Console]::OutputEncoding = $originalEncoding
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
$uninstallRoots = @(
|
|
29
|
+
[pscustomobject]@{ hive = 'HKLM'; path = 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall'; arch = 'x64' },
|
|
30
|
+
[pscustomobject]@{ hive = 'HKLM'; path = 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall'; arch = 'x86' },
|
|
31
|
+
[pscustomobject]@{ hive = 'HKCU'; path = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall'; arch = 'x64' }
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
$applications = @()
|
|
35
|
+
foreach ($root in $uninstallRoots) {
|
|
36
|
+
try {
|
|
37
|
+
$uninstallKeys = @(Get-ChildItem -Path $root.path -ErrorAction Stop)
|
|
38
|
+
} catch {
|
|
39
|
+
$message = $_.Exception.Message
|
|
40
|
+
if ($message -notmatch 'Cannot find path' -and $message -notmatch 'does not exist') {
|
|
41
|
+
$errors += ('registry ' + $root.path + ': ' + $message)
|
|
42
|
+
}
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
$relativeRoot = $root.path -replace '^(HKLM|HKCU):\\', ''
|
|
46
|
+
foreach ($uninstallKey in $uninstallKeys) {
|
|
47
|
+
$keyName = [string]$uninstallKey.PSChildName
|
|
48
|
+
$keyPath = $relativeRoot + '\' + $keyName
|
|
49
|
+
$properties = $null
|
|
50
|
+
try {
|
|
51
|
+
$properties = Get-ItemProperty -Path $uninstallKey.PSPath -ErrorAction Stop
|
|
52
|
+
} catch {
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
if ($null -eq $properties) { continue }
|
|
56
|
+
$displayName = [string]$properties.DisplayName
|
|
57
|
+
if (-not $displayName.Trim()) { continue }
|
|
58
|
+
if ($null -ne $properties.SystemComponent -and [int]$properties.SystemComponent -eq 1) { continue }
|
|
59
|
+
if ($null -ne $properties.ParentKeyName -and [string]$properties.ParentKeyName) { continue }
|
|
60
|
+
$estimatedSizeKb = $null
|
|
61
|
+
if ($null -ne $properties.EstimatedSize) {
|
|
62
|
+
try { $estimatedSizeKb = [long]$properties.EstimatedSize } catch { $estimatedSizeKb = $null }
|
|
63
|
+
}
|
|
64
|
+
$applications += [ordered]@{
|
|
65
|
+
displayName = $displayName.Trim()
|
|
66
|
+
version = [string]$properties.DisplayVersion
|
|
67
|
+
publisher = [string]$properties.Publisher
|
|
68
|
+
installLocation = [string]$properties.InstallLocation
|
|
69
|
+
estimatedSizeKB = $estimatedSizeKb
|
|
70
|
+
keyName = $keyName
|
|
71
|
+
keyPath = $keyPath
|
|
72
|
+
hive = $root.hive
|
|
73
|
+
arch = $root.arch
|
|
74
|
+
uninstallString = [string]$properties.UninstallString
|
|
75
|
+
quietUninstallString = [string]$properties.QuietUninstallString
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------- winget presence --
|
|
81
|
+
$winget = [ordered]@{ available = $false; version = $null }
|
|
82
|
+
$wingetExe = $null
|
|
83
|
+
try {
|
|
84
|
+
$resolved = Get-Command -Name 'winget.exe' -CommandType Application -ErrorAction SilentlyContinue
|
|
85
|
+
if ($null -ne $resolved) { $wingetExe = [string]$resolved.Source }
|
|
86
|
+
} catch { $wingetExe = $null }
|
|
87
|
+
if (-not $wingetExe) {
|
|
88
|
+
try {
|
|
89
|
+
$resolved = Get-Command -Name 'winget' -CommandType Application -ErrorAction SilentlyContinue
|
|
90
|
+
if ($null -ne $resolved) { $wingetExe = [string]$resolved.Source }
|
|
91
|
+
} catch { $wingetExe = $null }
|
|
92
|
+
}
|
|
93
|
+
if ($wingetExe) {
|
|
94
|
+
try {
|
|
95
|
+
$versionLines = @(Invoke-NativeOem { & $wingetExe --version 2>$null })
|
|
96
|
+
$versionText = $null
|
|
97
|
+
foreach ($line in $versionLines) {
|
|
98
|
+
$text = [string]$line
|
|
99
|
+
if ($text.Trim()) {
|
|
100
|
+
if ($text -match '(\d+\.\d+[0-9A-Za-z.\-]*)') { $versionText = $matches[1]; break }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
$winget.available = $true
|
|
104
|
+
if ($versionText) { $winget.version = $versionText }
|
|
105
|
+
} catch {
|
|
106
|
+
$winget.available = $true
|
|
107
|
+
$errors += ('winget --version: ' + $_.Exception.Message)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
# -------------------------------------------------------------------- emit --
|
|
112
|
+
$result = [ordered]@{
|
|
113
|
+
generatedAt = ([DateTimeOffset]::UtcNow).ToUnixTimeMilliseconds()
|
|
114
|
+
applications = $applications
|
|
115
|
+
winget = $winget
|
|
116
|
+
errors = $errors
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
$result | ConvertTo-Json -Depth 8 -Compress
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
# Device X - Windows system inventory (OS, device, users, startup, locale).
|
|
2
|
+
#
|
|
3
|
+
# Emits exactly one JSON document on stdout (no other stdout text).
|
|
4
|
+
# Read-only: no admin rights, no network calls, no external modules.
|
|
5
|
+
$ErrorActionPreference = 'Stop'
|
|
6
|
+
|
|
7
|
+
$errors = @()
|
|
8
|
+
|
|
9
|
+
function To-EpochMs($value) {
|
|
10
|
+
if ($null -eq $value) { return $null }
|
|
11
|
+
try { return ([DateTimeOffset]$value).ToUnixTimeMilliseconds() } catch { return $null }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function To-LongOrNull($value) {
|
|
15
|
+
if ($null -eq $value) { return $null }
|
|
16
|
+
try { return [long]$value } catch { return $null }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
$hostName = $env:COMPUTERNAME
|
|
20
|
+
if (-not $hostName) {
|
|
21
|
+
try { $hostName = [System.Net.Dns]::GetHostName() } catch { $hostName = $null }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------- OS --
|
|
25
|
+
$osInfo = $null
|
|
26
|
+
$bootInfo = $null
|
|
27
|
+
try {
|
|
28
|
+
$os = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop
|
|
29
|
+
$displayVersion = $null
|
|
30
|
+
try {
|
|
31
|
+
$winVersionKey = Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -ErrorAction Stop
|
|
32
|
+
if ($winVersionKey.DisplayVersion) { $displayVersion = [string]$winVersionKey.DisplayVersion }
|
|
33
|
+
} catch { $displayVersion = $null }
|
|
34
|
+
$bootTimeMs = To-EpochMs $os.LastBootUpTime
|
|
35
|
+
$uptimeSec = $null
|
|
36
|
+
if ($null -ne $bootTimeMs -and $bootTimeMs -gt 0) {
|
|
37
|
+
$nowMs = ([DateTimeOffset]::UtcNow).ToUnixTimeMilliseconds()
|
|
38
|
+
$uptimeSec = [long][math]::Floor(($nowMs - $bootTimeMs) / 1000)
|
|
39
|
+
}
|
|
40
|
+
$osInfo = [ordered]@{
|
|
41
|
+
caption = [string]$os.Caption
|
|
42
|
+
displayVersion = $displayVersion
|
|
43
|
+
version = [string]$os.Version
|
|
44
|
+
build = [string]$os.BuildNumber
|
|
45
|
+
installDateMs = To-EpochMs $os.InstallDate
|
|
46
|
+
lastBootUpTimeMs = $bootTimeMs
|
|
47
|
+
osArchitecture = [string]$os.OSArchitecture
|
|
48
|
+
processorArchitecture = $env:PROCESSOR_ARCHITECTURE
|
|
49
|
+
}
|
|
50
|
+
$bootInfo = [ordered]@{ bootTimeMs = $bootTimeMs; uptimeSec = $uptimeSec }
|
|
51
|
+
} catch { $errors += ('Win32_OperatingSystem: ' + $_.Exception.Message) }
|
|
52
|
+
|
|
53
|
+
# ------------------------------------------------------------ OS/device facts --
|
|
54
|
+
$processorName = $null
|
|
55
|
+
try {
|
|
56
|
+
$processors = @(Get-CimInstance -ClassName Win32_Processor -ErrorAction Stop)
|
|
57
|
+
if ($processors.Count -gt 0) { $processorName = [string]$processors[0].Name }
|
|
58
|
+
} catch { $processorName = $null }
|
|
59
|
+
|
|
60
|
+
$device = $null
|
|
61
|
+
try {
|
|
62
|
+
$computerSystem = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop
|
|
63
|
+
$bios = Get-CimInstance -ClassName Win32_BIOS -ErrorAction Stop
|
|
64
|
+
$product = Get-CimInstance -ClassName Win32_ComputerSystemProduct -ErrorAction Stop
|
|
65
|
+
$modelId = $null
|
|
66
|
+
if ($computerSystem.SystemSKUNumber) { $modelId = [string]$computerSystem.SystemSKUNumber }
|
|
67
|
+
elseif ($product.Version) { $modelId = [string]$product.Version }
|
|
68
|
+
$device = [ordered]@{
|
|
69
|
+
hostname = $hostName
|
|
70
|
+
computerName = [string]$computerSystem.Name
|
|
71
|
+
localHostname = $hostName
|
|
72
|
+
manufacturer = [string]$computerSystem.Manufacturer
|
|
73
|
+
model = [string]$computerSystem.Model
|
|
74
|
+
modelId = $modelId
|
|
75
|
+
chip = $processorName
|
|
76
|
+
serial = [string]$bios.SerialNumber
|
|
77
|
+
hardwareUuid = [string]$product.UUID
|
|
78
|
+
}
|
|
79
|
+
} catch { $errors += ('Win32_ComputerSystem/BIOS: ' + $_.Exception.Message) }
|
|
80
|
+
|
|
81
|
+
# -------------------------------------------------------------------- users --
|
|
82
|
+
$profiles = @{}
|
|
83
|
+
try {
|
|
84
|
+
$profileKeys = @(Get-ChildItem -Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList' -ErrorAction Stop)
|
|
85
|
+
foreach ($profileKey in $profileKeys) {
|
|
86
|
+
$sid = [string]$profileKey.PSChildName
|
|
87
|
+
try {
|
|
88
|
+
$profileProperties = Get-ItemProperty -Path $profileKey.PSPath -Name 'ProfileImagePath' -ErrorAction Stop
|
|
89
|
+
if ($profileProperties.ProfileImagePath) { $profiles[$sid] = [string]$profileProperties.ProfileImagePath }
|
|
90
|
+
} catch { }
|
|
91
|
+
}
|
|
92
|
+
} catch { $profiles = @{} }
|
|
93
|
+
|
|
94
|
+
$localUsers = @()
|
|
95
|
+
try {
|
|
96
|
+
$accounts = @(Get-LocalUser -ErrorAction Stop)
|
|
97
|
+
foreach ($account in $accounts) {
|
|
98
|
+
$sid = $null
|
|
99
|
+
if ($account.SID) { $sid = [string]$account.SID.Value }
|
|
100
|
+
$homePath = $null
|
|
101
|
+
if ($sid -and $profiles.ContainsKey($sid)) { $homePath = $profiles[$sid] }
|
|
102
|
+
$localUsers += [ordered]@{
|
|
103
|
+
name = [string]$account.Name
|
|
104
|
+
fullName = [string]$account.FullName
|
|
105
|
+
enabled = [bool]$account.Enabled
|
|
106
|
+
sid = $sid
|
|
107
|
+
home = $homePath
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
$errors += ('Get-LocalUser: ' + $_.Exception.Message)
|
|
112
|
+
try {
|
|
113
|
+
$fallbackAccounts = @(Get-CimInstance -ClassName Win32_UserAccount -Filter 'LocalAccount = True' -ErrorAction Stop)
|
|
114
|
+
foreach ($account in $fallbackAccounts) {
|
|
115
|
+
$sid = [string]$account.SID
|
|
116
|
+
$homePath = $null
|
|
117
|
+
if ($sid -and $profiles.ContainsKey($sid)) { $homePath = $profiles[$sid] }
|
|
118
|
+
$localUsers += [ordered]@{
|
|
119
|
+
name = [string]$account.Name
|
|
120
|
+
fullName = [string]$account.FullName
|
|
121
|
+
enabled = (-not [bool]$account.Disabled)
|
|
122
|
+
sid = $sid
|
|
123
|
+
home = $homePath
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
} catch { $errors += ('Win32_UserAccount: ' + $_.Exception.Message) }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
$admins = @()
|
|
130
|
+
try {
|
|
131
|
+
$adminGroup = $null
|
|
132
|
+
try { $adminGroup = Get-LocalGroup -SID 'S-1-5-32-544' -ErrorAction Stop } catch { $adminGroup = $null }
|
|
133
|
+
if ($null -eq $adminGroup) {
|
|
134
|
+
# The group display name is localized; resolve it by the well-known
|
|
135
|
+
# Administrators SID instead of the English name.
|
|
136
|
+
$adminGroup = Get-LocalGroup -ErrorAction Stop | Where-Object { $_.SID.Value -eq 'S-1-5-32-544' } | Select-Object -First 1
|
|
137
|
+
}
|
|
138
|
+
if ($null -eq $adminGroup) { throw 'Could not resolve the local Administrators group (S-1-5-32-544).' }
|
|
139
|
+
$members = @(Get-LocalGroupMember -Group $adminGroup -ErrorAction Stop)
|
|
140
|
+
foreach ($member in $members) {
|
|
141
|
+
$memberName = [string]$member.Name
|
|
142
|
+
if ($memberName -match '\\') {
|
|
143
|
+
$parts = $memberName -split '\\'
|
|
144
|
+
$memberName = [string]$parts[$parts.Count - 1]
|
|
145
|
+
}
|
|
146
|
+
if ($memberName) { $admins += $memberName }
|
|
147
|
+
}
|
|
148
|
+
} catch { $errors += ('local administrators: ' + $_.Exception.Message) }
|
|
149
|
+
|
|
150
|
+
$currentUser = $null
|
|
151
|
+
if ($env:USERNAME) {
|
|
152
|
+
$currentFullName = $null
|
|
153
|
+
foreach ($account in $localUsers) {
|
|
154
|
+
if ([string]$account.name -ieq [string]$env:USERNAME) { $currentFullName = [string]$account.fullName; break }
|
|
155
|
+
}
|
|
156
|
+
$isAdmin = $false
|
|
157
|
+
foreach ($adminName in $admins) {
|
|
158
|
+
if ([string]$adminName -ieq [string]$env:USERNAME) { $isAdmin = $true; break }
|
|
159
|
+
}
|
|
160
|
+
$currentUser = [ordered]@{
|
|
161
|
+
name = [string]$env:USERNAME
|
|
162
|
+
fullName = $currentFullName
|
|
163
|
+
home = [string]$env:USERPROFILE
|
|
164
|
+
admin = $isAdmin
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
# ---------------------------------------------------------------- startup --
|
|
169
|
+
$startup = @()
|
|
170
|
+
$runKeys = @(
|
|
171
|
+
[pscustomobject]@{ hive = 'HKCU'; path = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' },
|
|
172
|
+
[pscustomobject]@{ hive = 'HKLM'; path = 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Run' },
|
|
173
|
+
# The 32-bit registry view needs its own pseudo-hive so the action engine
|
|
174
|
+
# (system.launchAgent) can re-enable the entry in the same key it came from;
|
|
175
|
+
# disabling startup:HKLM:<name> would otherwise target the 64-bit Run key.
|
|
176
|
+
[pscustomobject]@{ hive = 'HKLM32'; path = 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Run' }
|
|
177
|
+
)
|
|
178
|
+
foreach ($runKey in $runKeys) {
|
|
179
|
+
try {
|
|
180
|
+
$properties = Get-ItemProperty -Path $runKey.path -ErrorAction Stop
|
|
181
|
+
$scope = 'system'
|
|
182
|
+
if ($runKey.hive -eq 'HKCU') { $scope = 'user' }
|
|
183
|
+
# HKLM and HKLM32 are both machine scope; only the pseudo-path differs.
|
|
184
|
+
foreach ($property in $properties.PSObject.Properties) {
|
|
185
|
+
$valueName = [string]$property.Name
|
|
186
|
+
if ($valueName -like 'PS*') { continue }
|
|
187
|
+
$startup += [ordered]@{
|
|
188
|
+
label = $valueName
|
|
189
|
+
path = ('startup:' + $runKey.hive + ':' + $valueName)
|
|
190
|
+
scope = $scope
|
|
191
|
+
loaded = $true
|
|
192
|
+
program = [string]$property.Value
|
|
193
|
+
hive = $runKey.hive
|
|
194
|
+
location = 'Software\Microsoft\Windows\CurrentVersion\Run'
|
|
195
|
+
source = 'registry'
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
} catch {
|
|
199
|
+
$message = $_.Exception.Message
|
|
200
|
+
if ($message -notmatch 'Cannot find path' -and $message -notmatch 'does not exist') {
|
|
201
|
+
$errors += ('registry ' + $runKey.path + ': ' + $message)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
# Entries disabled by the DeviceX action engine (see system.launchAgent). The
|
|
207
|
+
# action side stores one subkey per hive under
|
|
208
|
+
# `HKCU:\Software\DeviceX\DisabledStartup\<HIVE>`, value name -> command.
|
|
209
|
+
foreach ($disabledHive in @('HKCU', 'HKLM', 'HKLM32')) {
|
|
210
|
+
$disabledKeyPath = 'HKCU:\Software\DeviceX\DisabledStartup\' + $disabledHive
|
|
211
|
+
try {
|
|
212
|
+
$disabledProperties = Get-ItemProperty -Path $disabledKeyPath -ErrorAction Stop
|
|
213
|
+
if ($null -eq $disabledProperties) { continue }
|
|
214
|
+
$disabledScope = 'system'
|
|
215
|
+
if ($disabledHive -eq 'HKCU') { $disabledScope = 'user' }
|
|
216
|
+
foreach ($property in $disabledProperties.PSObject.Properties) {
|
|
217
|
+
$valueName = [string]$property.Name
|
|
218
|
+
if ($valueName -like 'PS*' -or -not $valueName) { continue }
|
|
219
|
+
$startup += [ordered]@{
|
|
220
|
+
label = $valueName
|
|
221
|
+
path = ('startup:' + $disabledHive + ':' + $valueName)
|
|
222
|
+
scope = $disabledScope
|
|
223
|
+
loaded = $false
|
|
224
|
+
program = [string]$property.Value
|
|
225
|
+
hive = $disabledHive
|
|
226
|
+
location = 'Software\Microsoft\Windows\CurrentVersion\Run'
|
|
227
|
+
source = 'registry'
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
} catch { }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
$loginItems = @()
|
|
234
|
+
try {
|
|
235
|
+
$startupCommands = @(Get-CimInstance -ClassName Win32_StartupCommand -ErrorAction Stop)
|
|
236
|
+
foreach ($startupCommand in $startupCommands) {
|
|
237
|
+
$location = [string]$startupCommand.Location
|
|
238
|
+
if ($location -match '^(HKCU|HKLM)') { continue }
|
|
239
|
+
$loginItems += [ordered]@{
|
|
240
|
+
name = [string]$startupCommand.Name
|
|
241
|
+
command = [string]$startupCommand.Command
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
} catch { $errors += ('Win32_StartupCommand: ' + $_.Exception.Message) }
|
|
245
|
+
|
|
246
|
+
# ------------------------------------------------------ environment/locale --
|
|
247
|
+
$environment = [ordered]@{
|
|
248
|
+
PATH = [string]$env:Path
|
|
249
|
+
SHELL = $null
|
|
250
|
+
HOME = [string]$env:USERPROFILE
|
|
251
|
+
USER = [string]$env:USERNAME
|
|
252
|
+
LANG = $env:LANG
|
|
253
|
+
TERM = $env:TERM
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
$locale = [ordered]@{ timezone = $null; locale = $null; language = $null }
|
|
257
|
+
try { $locale.timezone = [string](Get-TimeZone).Id } catch { $locale.timezone = $null }
|
|
258
|
+
try { $locale.locale = [string](Get-Culture).Name } catch { $locale.locale = $null }
|
|
259
|
+
try { $locale.language = [string](Get-UICulture).Name } catch { $locale.language = $null }
|
|
260
|
+
|
|
261
|
+
# -------------------------------------------------------------------- emit --
|
|
262
|
+
$result = [ordered]@{
|
|
263
|
+
generatedAt = ([DateTimeOffset]::UtcNow).ToUnixTimeMilliseconds()
|
|
264
|
+
os = $osInfo
|
|
265
|
+
device = $device
|
|
266
|
+
boot = $bootInfo
|
|
267
|
+
users = [ordered]@{
|
|
268
|
+
current = $currentUser
|
|
269
|
+
localUsers = $localUsers
|
|
270
|
+
admins = $admins
|
|
271
|
+
}
|
|
272
|
+
startup = $startup
|
|
273
|
+
loginItems = $loginItems
|
|
274
|
+
environment = $environment
|
|
275
|
+
locale = $locale
|
|
276
|
+
errors = $errors
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
$result | ConvertTo-Json -Depth 8 -Compress
|
package/host/win.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device X — PowerShell bridge for the Windows host half.
|
|
3
|
+
*
|
|
4
|
+
* Every Windows collection and action goes through this module so one place
|
|
5
|
+
* guarantees: a resolved interpreter (Windows PowerShell 5.1 preferred, then
|
|
6
|
+
* pwsh 7), stdin-streamed scripts (`-Command -`; no shell, no quoting
|
|
7
|
+
* injection, and no 32,767-character command-line limit), UTF-8 console
|
|
8
|
+
* output, a wall-clock timeout, a bounded buffer, and JSON parsing that never
|
|
9
|
+
* throws.
|
|
10
|
+
*
|
|
11
|
+
* Parameters reach scripts through environment variables (`DEVICEX_ARG_*`)
|
|
12
|
+
* rather than string interpolation; callers use {@link psString} only for
|
|
13
|
+
* constant option values.
|
|
14
|
+
*
|
|
15
|
+
* @module @cfour-hi/dsh-plugin-devicex/host/win
|
|
16
|
+
*/
|
|
17
|
+
import { readFileSync } from 'node:fs';
|
|
18
|
+
import { run } from './proc.js';
|
|
19
|
+
|
|
20
|
+
/** Default wall-clock timeout for one PowerShell call. */
|
|
21
|
+
export const DEFAULT_TIMEOUT = 20_000;
|
|
22
|
+
/** Default stdout/stderr buffer (CIM JSON is well below this). */
|
|
23
|
+
export const DEFAULT_MAX_BUFFER = 16 * 1024 * 1024;
|
|
24
|
+
|
|
25
|
+
/** Prefix every script with UTF-8 console settings and a quiet progress bar. */
|
|
26
|
+
const OUTPUT_PREFIX =
|
|
27
|
+
"[Console]::OutputEncoding=[System.Text.Encoding]::UTF8;" +
|
|
28
|
+
"$OutputEncoding=[System.Text.Encoding]::UTF8;" +
|
|
29
|
+
"$ProgressPreference='SilentlyContinue';";
|
|
30
|
+
|
|
31
|
+
/** Interpreter candidates, in preference order.
|
|
32
|
+
*
|
|
33
|
+
* Windows PowerShell 5.1 comes first: the collectors/actions use inbox Windows
|
|
34
|
+
* modules (`Get-LocalUser`, `Get-BitLockerVolume`, `Get-MpComputerStatus`,
|
|
35
|
+
* `Get-PnpDevice`, `Get-PhysicalDisk`) that are not guaranteed under pwsh 7.
|
|
36
|
+
* Set DEVICEX_PWSH to override (useful on hosts where only pwsh 7 exists).
|
|
37
|
+
*/
|
|
38
|
+
/** @type {string[]} */
|
|
39
|
+
export const PS_CANDIDATES = /** @type {string[]} */ ([
|
|
40
|
+
process.env.DEVICEX_PWSH,
|
|
41
|
+
'powershell.exe',
|
|
42
|
+
'pwsh.exe',
|
|
43
|
+
'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
|
|
44
|
+
].filter((candidate) => typeof candidate === 'string' && candidate.length > 0));
|
|
45
|
+
|
|
46
|
+
/** One resolved interpreter per Host process lifetime. */
|
|
47
|
+
let resolution;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Resolve the first working PowerShell interpreter.
|
|
51
|
+
* @returns {Promise<{command: string, major: number|null}|null>}
|
|
52
|
+
*/
|
|
53
|
+
export function resolveInterpreter() {
|
|
54
|
+
resolution ??= (async () => {
|
|
55
|
+
for (const candidate of PS_CANDIDATES) {
|
|
56
|
+
const probe = await run(
|
|
57
|
+
candidate,
|
|
58
|
+
['-NoProfile', '-NonInteractive', '-Command', '$PSVersionTable.PSVersion.Major'],
|
|
59
|
+
{ timeout: 8_000, maxBuffer: 64 * 1024 },
|
|
60
|
+
);
|
|
61
|
+
if (probe.ok) {
|
|
62
|
+
const major = Number.parseInt(probe.stdout.trim(), 10);
|
|
63
|
+
return { command: candidate, major: Number.isFinite(major) ? major : null };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
})();
|
|
68
|
+
return resolution;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Build the interpreter argv for one streamed script.
|
|
73
|
+
*
|
|
74
|
+
* The script is written to stdin (`-Command -`) instead of being encoded into
|
|
75
|
+
* argv: Windows caps a command line at 32,767 characters and the bundled
|
|
76
|
+
* collectors are larger than that once UTF-16/base64 encoded.
|
|
77
|
+
* @returns {string[]}
|
|
78
|
+
*/
|
|
79
|
+
export function commandArguments() {
|
|
80
|
+
return ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', '-'];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Quote one value as a PowerShell single-quoted literal.
|
|
85
|
+
* @param {string} value
|
|
86
|
+
* @returns {string}
|
|
87
|
+
*/
|
|
88
|
+
export function psString(value) {
|
|
89
|
+
return `'${String(value).replaceAll("'", "''")}'`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Run a PowerShell script with no implicit shell.
|
|
94
|
+
* @param {string} script PowerShell source.
|
|
95
|
+
* @param {object} [options]
|
|
96
|
+
* @param {number} [options.timeout] milliseconds.
|
|
97
|
+
* @param {number} [options.maxBuffer] bytes.
|
|
98
|
+
* @param {Record<string,string>} [options.env] extra environment variables.
|
|
99
|
+
* @returns {Promise<{ok: boolean, stdout: string, stderr: string, code: number|null,
|
|
100
|
+
* signal: string|null, timedOut: boolean, error: string|null}>}
|
|
101
|
+
*/
|
|
102
|
+
export async function runPS(script, options = {}) {
|
|
103
|
+
const interpreter = await resolveInterpreter();
|
|
104
|
+
if (interpreter === null) {
|
|
105
|
+
return {
|
|
106
|
+
ok: false,
|
|
107
|
+
stdout: '',
|
|
108
|
+
stderr: 'no PowerShell interpreter found (pwsh.exe / powershell.exe)',
|
|
109
|
+
code: null,
|
|
110
|
+
signal: null,
|
|
111
|
+
timedOut: false,
|
|
112
|
+
error: 'PowerShell is not available on this host',
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
return run(interpreter.command, commandArguments(), {
|
|
116
|
+
timeout: options.timeout ?? DEFAULT_TIMEOUT,
|
|
117
|
+
maxBuffer: options.maxBuffer ?? DEFAULT_MAX_BUFFER,
|
|
118
|
+
env: options.env === undefined ? undefined : { ...process.env, ...options.env },
|
|
119
|
+
input: `${OUTPUT_PREFIX}${script}`,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Strip a UTF-8 BOM and surrounding whitespace. */
|
|
124
|
+
function trimOutput(text) {
|
|
125
|
+
return String(text ?? '').replace(/^\uFEFF/, '').trim();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Run a script that ends by writing one JSON document, and parse it.
|
|
130
|
+
* A failed run, empty output, or malformed JSON becomes `{ ok: false }` with
|
|
131
|
+
* a short diagnostic supplied by the script (or from stderr); it never throws.
|
|
132
|
+
* @param {string} script
|
|
133
|
+
* @param {object} [options] same as {@link runPS}.
|
|
134
|
+
* @returns {Promise<{ok: true, data: any, raw: string} | {ok: false, error: string, raw: string, stderr: string}>}
|
|
135
|
+
*/
|
|
136
|
+
export async function runPSJson(script, options = {}) {
|
|
137
|
+
const result = await runPS(script, options);
|
|
138
|
+
const raw = trimOutput(result.stdout);
|
|
139
|
+
if (raw === '') {
|
|
140
|
+
const detail = trimOutput(result.stderr) || result.error || 'PowerShell returned no output';
|
|
141
|
+
return { ok: false, error: short(detail), raw, stderr: trimOutput(result.stderr) };
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
return { ok: true, data: JSON.parse(raw), raw };
|
|
145
|
+
} catch (error) {
|
|
146
|
+
return {
|
|
147
|
+
ok: false,
|
|
148
|
+
error: `PowerShell JSON was not parseable: ${short(error instanceof Error ? error.message : String(error))}`,
|
|
149
|
+
raw,
|
|
150
|
+
stderr: trimOutput(result.stderr),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Read one bundled PowerShell script from `plugin/host/win/`.
|
|
157
|
+
* Scripts are cached after first use.
|
|
158
|
+
* @param {string} name file name inside `host/win/`.
|
|
159
|
+
* @returns {string}
|
|
160
|
+
*/
|
|
161
|
+
const scriptCache = new Map();
|
|
162
|
+
export function readScript(name) {
|
|
163
|
+
if (!scriptCache.has(name)) {
|
|
164
|
+
const url = new URL(`./win/${name}`, import.meta.url);
|
|
165
|
+
scriptCache.set(name, readFileSync(url, 'utf8'));
|
|
166
|
+
}
|
|
167
|
+
return scriptCache.get(name);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Shorten a diagnostic to a single useful line. */
|
|
171
|
+
function short(text) {
|
|
172
|
+
const line = String(text ?? '')
|
|
173
|
+
.split(/\r?\n/)
|
|
174
|
+
.map((entry) => entry.trim())
|
|
175
|
+
.filter(Boolean)
|
|
176
|
+
.slice(0, 2)
|
|
177
|
+
.join(' ');
|
|
178
|
+
return line.length > 400 ? `${line.slice(0, 400)}…` : line || 'unknown PowerShell failure';
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Test seam: stable pure helpers used by the offline verification script. */
|
|
182
|
+
export const __test = {
|
|
183
|
+
commandArguments,
|
|
184
|
+
psString,
|
|
185
|
+
trimOutput,
|
|
186
|
+
OUTPUT_PREFIX,
|
|
187
|
+
};
|
package/icon.svg
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Device X">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="dx-body" x1="0" y1="0" x2="1" y2="1">
|
|
4
|
+
<stop offset="0" stop-color="#2f6bff"/>
|
|
5
|
+
<stop offset="1" stop-color="#7a4dff"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
<linearGradient id="dx-orbit" x1="0" y1="0" x2="1" y2="1">
|
|
8
|
+
<stop offset="0" stop-color="#63e6ff"/>
|
|
9
|
+
<stop offset="1" stop-color="#9d7bff"/>
|
|
10
|
+
</linearGradient>
|
|
11
|
+
</defs>
|
|
12
|
+
<rect x="13" y="17" width="38" height="30" rx="7" fill="url(#dx-body)"/>
|
|
13
|
+
<rect x="19" y="23" width="26" height="18" rx="3" fill="#0b1024" opacity="0.82"/>
|
|
14
|
+
<path d="M19 28h26M19 33h18M19 38h22" stroke="#63e6ff" stroke-width="1.6" stroke-linecap="round" opacity="0.9"/>
|
|
15
|
+
<path d="M22 47v6M32 47v6M42 47v6M17 22v-5M32 22V14M47 22v-5" stroke="#c9d6ff" stroke-width="2.4" stroke-linecap="round"/>
|
|
16
|
+
<ellipse cx="32" cy="32" rx="29" ry="11" fill="none" stroke="url(#dx-orbit)" stroke-width="2" transform="rotate(-24 32 32)" opacity="0.95"/>
|
|
17
|
+
<circle cx="52.5" cy="21.5" r="3.1" fill="#63e6ff"/>
|
|
18
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device X host half.
|
|
3
|
+
*
|
|
4
|
+
* Owns the device data collectors, the whitelisted device action engine, the
|
|
5
|
+
* browser HTTP API consumed by `client.js`, and the two agent tools. The whole
|
|
6
|
+
* surface is one implementation: the UI routes and the tools call the same
|
|
7
|
+
* functions.
|
|
8
|
+
*
|
|
9
|
+
* @module @cfour-hi/dsh-plugin-devicex
|
|
10
|
+
*/
|
|
11
|
+
import {
|
|
12
|
+
SECTIONS,
|
|
13
|
+
PLATFORM,
|
|
14
|
+
warmup,
|
|
15
|
+
collectMetrics,
|
|
16
|
+
collectSection,
|
|
17
|
+
collectInventory,
|
|
18
|
+
} from './host/collector.js';
|
|
19
|
+
import { GROUPS, actionCatalog, actionHistory, runAction } from './host/actions.js';
|
|
20
|
+
import { scanCleanup } from './host/cleanup.js';
|
|
21
|
+
import { runBenchmark } from './host/benchmark.js';
|
|
22
|
+
import { registerRoutes } from './host/transport.js';
|
|
23
|
+
import { registerTools } from './host/tool.js';
|
|
24
|
+
|
|
25
|
+
/** Cordis function-plugin name. */
|
|
26
|
+
export const name = 'devicex';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The route carrier, its trust fence, and the tool registry. All are hard
|
|
30
|
+
* dependencies: without them the plugin has nothing to serve.
|
|
31
|
+
*/
|
|
32
|
+
export const inject = ['webServer', 'connection', 'tools'];
|
|
33
|
+
|
|
34
|
+
/** The one shared operation surface both callers use. */
|
|
35
|
+
/** @type {import('../types/contracts.js').PluginOperations} */
|
|
36
|
+
const operations = {
|
|
37
|
+
SECTIONS,
|
|
38
|
+
PLATFORM,
|
|
39
|
+
GROUPS,
|
|
40
|
+
collectMetrics,
|
|
41
|
+
collectSection,
|
|
42
|
+
collectInventory,
|
|
43
|
+
actionCatalog,
|
|
44
|
+
actionHistory,
|
|
45
|
+
runAction,
|
|
46
|
+
scanCleanup,
|
|
47
|
+
runBenchmark,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Register the browser API and the agent tools, keeping the slow collectors
|
|
52
|
+
* warm so the first panel view is fast.
|
|
53
|
+
* @param {import('../types/contracts.js').PluginContext} ctx - the plugin's Cordis context.
|
|
54
|
+
*/
|
|
55
|
+
export function apply(ctx) {
|
|
56
|
+
warmup();
|
|
57
|
+
ctx.effect(
|
|
58
|
+
() => registerRoutes(ctx, operations),
|
|
59
|
+
'devicex: browser API routes',
|
|
60
|
+
);
|
|
61
|
+
ctx.effect(
|
|
62
|
+
() => registerTools(ctx, operations),
|
|
63
|
+
'devicex: agent tools',
|
|
64
|
+
);
|
|
65
|
+
}
|
package/locale/en.json
ADDED