agenshield 2026.8.2-beta.5083739 → 2026.8.2
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/bin/agenshield +26 -6
- package/bin/install.ps1 +1037 -0
- package/bin/install.sh +19 -6
- package/package.json +1 -1
package/bin/agenshield
CHANGED
|
@@ -225,14 +225,34 @@ macos_residue_uninstall() {
|
|
|
225
225
|
kill -9 "$_deact" 2>/dev/null || true
|
|
226
226
|
fi
|
|
227
227
|
|
|
228
|
-
# 3. Remove the AgenShield CA from the System keychain (
|
|
229
|
-
# SHA-1) — mirrors the CLI's removeInstalledCaTrust.
|
|
230
|
-
|
|
228
|
+
# 3. Remove the AgenShield CA from the System keychain (select by SUBJECT,
|
|
229
|
+
# delete by SHA-1) — mirrors the CLI's removeInstalledCaTrust.
|
|
230
|
+
#
|
|
231
|
+
# `find-certificate -c "AgenShield"` is a SUBSTRING match on the cert's
|
|
232
|
+
# NAME, so the previous version of this loop deleted every trusted root
|
|
233
|
+
# that merely mentioned the product: the local-CDN development CA, and any
|
|
234
|
+
# customer/MDM root called something like "AgenShield Corp Root". Deleting
|
|
235
|
+
# a root we did not issue is damage, not cleanup. Enumerate PEMs instead
|
|
236
|
+
# and keep only the subjects AgenShield's own CA builder emits — the same
|
|
237
|
+
# set as `isAgenShieldIssuedCaCn` in @agenshield/utils.
|
|
238
|
+
_pem=""
|
|
239
|
+
$SUDO /usr/bin/security find-certificate -a -p /Library/Keychains/System.keychain 2>/dev/null |
|
|
231
240
|
while IFS= read -r _line; do
|
|
241
|
+
_pem="$_pem$_line
|
|
242
|
+
"
|
|
232
243
|
case "$_line" in
|
|
233
|
-
"
|
|
234
|
-
|
|
235
|
-
|
|
244
|
+
"-----END CERTIFICATE-----")
|
|
245
|
+
_subj="$(printf '%s' "$_pem" | openssl x509 -noout -subject 2>/dev/null || true)"
|
|
246
|
+
_cn="${_subj##*CN=}"
|
|
247
|
+
_cn="${_cn%%,*}"
|
|
248
|
+
_cn="${_cn%"${_cn##*[![:space:]]}"}"
|
|
249
|
+
case "$_cn" in
|
|
250
|
+
"AgenShield CA"|"AgenShield Dev CA"|"AgenShield MITM CA"*)
|
|
251
|
+
_sha1="$(printf '%s' "$_pem" | openssl x509 -noout -fingerprint -sha1 2>/dev/null | cut -d= -f2 | tr -d ':')"
|
|
252
|
+
[ -n "$_sha1" ] && $SUDO /usr/bin/security delete-certificate -Z "$_sha1" /Library/Keychains/System.keychain >/dev/null 2>&1 || true
|
|
253
|
+
;;
|
|
254
|
+
esac
|
|
255
|
+
_pem=""
|
|
236
256
|
;;
|
|
237
257
|
esac
|
|
238
258
|
done
|
package/bin/install.ps1
ADDED
|
@@ -0,0 +1,1037 @@
|
|
|
1
|
+
<#
|
|
2
|
+
.SYNOPSIS
|
|
3
|
+
AgenShield Installer (Windows) - the canonical, versioned installer shipped
|
|
4
|
+
in the npm package and served (per-campaign) by agencloud.
|
|
5
|
+
|
|
6
|
+
.DESCRIPTION
|
|
7
|
+
Resolves the latest (or a pinned) AgenShield release from the release
|
|
8
|
+
manifest, downloads and verifies the signed MSI, stages machine-scope
|
|
9
|
+
enrollment, installs it silently, and waits for the service to come up.
|
|
10
|
+
This is the Windows sibling of tools/sea/install.sh - same environment
|
|
11
|
+
contract, same baked CDN supply-chain guard, same "never block the base
|
|
12
|
+
install on enrollment plumbing" posture.
|
|
13
|
+
|
|
14
|
+
Mirror set: the version-resolution + download logic here is deliberately
|
|
15
|
+
kept in sync with tools/sea/install.sh (macOS/Linux) and
|
|
16
|
+
libs/cli/src/utils/github-releases.ts's win32 support. Windows has no
|
|
17
|
+
GitHub-Releases fallback - every asset is served from the first-party CDN
|
|
18
|
+
(see tools/build/cdn/cdn-update-manifests.sh for the versions.json shape
|
|
19
|
+
this script parses). Also mirrored, in the agenshield-service repo:
|
|
20
|
+
apps/service/src/modules/campaigns/templates/install-script-windows.template.ts
|
|
21
|
+
(the per-campaign served copy, with CloudUrl/Token/Org/Version baked into
|
|
22
|
+
the source text - there is no shared build step across the two repos, so
|
|
23
|
+
a change to manifest resolution, ACL hardening, the checksum gate, or the
|
|
24
|
+
PSEdition-branched atomic file create below must be re-applied there BY
|
|
25
|
+
HAND, and vice versa). This asymmetry is exactly how the two sides
|
|
26
|
+
independently carried the SAME broken 7-argument FileStream constructor
|
|
27
|
+
under .NET Core / PowerShell 7 for a time - update the OTHER side's Mirror
|
|
28
|
+
set note when you edit either one.
|
|
29
|
+
|
|
30
|
+
AGENSHIELD_TOKEN / AGENSHIELD_CLOUD_URL / AGENSHIELD_ORG are deliberately
|
|
31
|
+
ENVIRONMENT-ONLY - there is no -Token/-CloudUrl/-Org parameter. Windows
|
|
32
|
+
PowerShell 5.1's Start-Transcript writes a "Host Application:" header
|
|
33
|
+
containing the FULL launch command line of the process into the transcript
|
|
34
|
+
file, which lives under %ProgramData%\AgenShield\logs (inherits
|
|
35
|
+
BUILTIN\Users: Read, and must, since the non-elevated parent tails it). A
|
|
36
|
+
documented "-Token <value>" invocation would therefore persist the bearer
|
|
37
|
+
campaign token in plaintext to a world-readable log on every run. Env vars
|
|
38
|
+
are not part of the command line, so they do not appear in that header; the
|
|
39
|
+
internal -HandoffFile mechanism carries them across the self-elevation
|
|
40
|
+
boundary instead of argv, for the same reason (Win32_Process makes a
|
|
41
|
+
process's own command line readable by any local user).
|
|
42
|
+
|
|
43
|
+
.PARAMETER Version
|
|
44
|
+
Install a specific version instead of resolving the latest for the channel.
|
|
45
|
+
Env: AGENSHIELD_VERSION.
|
|
46
|
+
|
|
47
|
+
.PARAMETER AssetsBase
|
|
48
|
+
Override the release-asset base URL (dev/test only - a production install
|
|
49
|
+
uses the baked first-party CDN base, never a host-controlled override; the
|
|
50
|
+
override is honored only when the baked value is absent, i.e. an unbaked
|
|
51
|
+
checkout). Env: AGENSHIELD_ASSETS_BASE.
|
|
52
|
+
|
|
53
|
+
.PARAMETER Channel
|
|
54
|
+
Explicit release channel: stable | rc | beta | alpha. Takes precedence over
|
|
55
|
+
ClientAlpha/ClientBeta, matching install.sh's AGENSHIELD_CHANNEL. Env:
|
|
56
|
+
AGENSHIELD_CHANNEL.
|
|
57
|
+
|
|
58
|
+
.PARAMETER ClientAlpha
|
|
59
|
+
.PARAMETER ClientBeta
|
|
60
|
+
Select the alpha/beta release channel when Channel is not given (stable is
|
|
61
|
+
the default). Env: AGENSHIELD_CLIENT_ALPHA / AGENSHIELD_CLIENT_BETA = "true".
|
|
62
|
+
|
|
63
|
+
.EXAMPLE
|
|
64
|
+
.\install.ps1
|
|
65
|
+
irm '<cloudUrl>/resources/campaigns/v1/<token>/install.ps1' | iex
|
|
66
|
+
(the per-campaign copy served by agencloud, pre-filled with the
|
|
67
|
+
enrollment token via environment variables; Task 6's served variant
|
|
68
|
+
re-fetches itself from a SELF_URL to re-invoke elevated, since a piped
|
|
69
|
+
script has no file on disk)
|
|
70
|
+
#>
|
|
71
|
+
[CmdletBinding()]
|
|
72
|
+
param(
|
|
73
|
+
[string]$Version = $env:AGENSHIELD_VERSION,
|
|
74
|
+
[string]$AssetsBase = $env:AGENSHIELD_ASSETS_BASE,
|
|
75
|
+
[string]$Channel = $env:AGENSHIELD_CHANNEL,
|
|
76
|
+
[switch]$ClientAlpha = ($env:AGENSHIELD_CLIENT_ALPHA -eq 'true'),
|
|
77
|
+
[switch]$ClientBeta = ($env:AGENSHIELD_CLIENT_BETA -eq 'true'),
|
|
78
|
+
# Internal - set only by this script's own elevated self-relaunch, never by
|
|
79
|
+
# a caller. RunId lets the parent and the elevated child agree on the
|
|
80
|
+
# transcript filename; HandoffFile is the short-lived file carrying
|
|
81
|
+
# Token/CloudUrl/Org across the elevation boundary (see the DESCRIPTION).
|
|
82
|
+
# ElevatedChild marks "this invocation IS ITSELF the product of a
|
|
83
|
+
# -Verb RunAs relaunch" - it is what stops the elevation gate from
|
|
84
|
+
# relaunching a SECOND time (and raising another UAC prompt, forever) if
|
|
85
|
+
# the relaunched process somehow still isn't elevated.
|
|
86
|
+
[string]$RunId,
|
|
87
|
+
[string]$HandoffFile,
|
|
88
|
+
[switch]$ElevatedChild
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
$ErrorActionPreference = 'Stop'
|
|
92
|
+
# Invoke-WebRequest's progress-bar rendering is notoriously slow under
|
|
93
|
+
# Windows PowerShell 5.1 and can turn a multi-MB download into a multi-minute
|
|
94
|
+
# one; SilentlyContinue disables it without affecting the actual transfer.
|
|
95
|
+
$ProgressPreference = 'SilentlyContinue'
|
|
96
|
+
# Windows PowerShell 5.1 does not always default to TLS 1.2, and every
|
|
97
|
+
# endpoint this script talks to (the CDN, the daemon) requires it - an
|
|
98
|
+
# unpatched default here fails with an opaque "Could not create SSL/TLS
|
|
99
|
+
# secure channel" instead of a useful error.
|
|
100
|
+
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor [System.Net.SecurityProtocolType]::Tls12
|
|
101
|
+
|
|
102
|
+
# Environment-only secrets (see the DESCRIPTION for why these are not
|
|
103
|
+
# parameters). Reassigned below from the handoff file when this run is the
|
|
104
|
+
# elevated child of a self-relaunch.
|
|
105
|
+
$Token = $env:AGENSHIELD_TOKEN
|
|
106
|
+
$CloudUrl = $env:AGENSHIELD_CLOUD_URL
|
|
107
|
+
$Org = $env:AGENSHIELD_ORG
|
|
108
|
+
|
|
109
|
+
if (-not $RunId) { $RunId = Get-Date -Format 'yyyyMMdd-HHmmss' }
|
|
110
|
+
# Internal parameter that reaches a log path and two msiexec log arguments - reject anything outside a safe charset rather than try to sanitize a path.
|
|
111
|
+
if ($RunId -notmatch '^[A-Za-z0-9_-]+$') {
|
|
112
|
+
throw "Invalid -RunId value: '$RunId'"
|
|
113
|
+
}
|
|
114
|
+
$script:LogDir = Join-Path $env:ProgramData 'AgenShield\logs'
|
|
115
|
+
$script:TranscriptPath = Join-Path $script:LogDir "install-$RunId.log"
|
|
116
|
+
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
# Output helpers (mirror install.sh's info/ok/warn/error glyph language)
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
function Write-Step { param([string]$Message) Write-Host ''; Write-Host "==> $Message" -ForegroundColor Cyan }
|
|
122
|
+
function Write-Info { param([string]$Message) Write-Host " $Message" -ForegroundColor DarkGray }
|
|
123
|
+
function Write-Ok { param([string]$Message) Write-Host " [ok] $Message" -ForegroundColor Green }
|
|
124
|
+
function Write-WarnLine { param([string]$Message) Write-Warning $Message }
|
|
125
|
+
function Write-ErrorLine { param([string]$Message) Write-Host " [error] $Message" -ForegroundColor Red }
|
|
126
|
+
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
# Elevation
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
function Test-IsAdministrator {
|
|
132
|
+
$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent()
|
|
133
|
+
$principal = New-Object System.Security.Principal.WindowsPrincipal($identity)
|
|
134
|
+
return $principal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
# Escapes for the Win32 argv parser before quoting - a bare wrap lets an embedded quote inject a parameter into the elevated child.
|
|
138
|
+
function ConvertTo-QuotedArg {
|
|
139
|
+
param([string]$Value)
|
|
140
|
+
if ($null -eq $Value) { $Value = '' }
|
|
141
|
+
$escaped = ''
|
|
142
|
+
$backslashRun = 0
|
|
143
|
+
for ($i = 0; $i -lt $Value.Length; $i++) {
|
|
144
|
+
$char = $Value[$i]
|
|
145
|
+
if ($char -eq '\') {
|
|
146
|
+
$backslashRun++
|
|
147
|
+
} elseif ($char -eq '"') {
|
|
148
|
+
$escaped += ('\' * (($backslashRun * 2) + 1)) + '"'
|
|
149
|
+
$backslashRun = 0
|
|
150
|
+
} else {
|
|
151
|
+
$escaped += ('\' * $backslashRun) + $char
|
|
152
|
+
$backslashRun = 0
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
$escaped += '\' * ($backslashRun * 2)
|
|
156
|
+
return '"' + $escaped + '"'
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
# System32 resolved via GetSystemDirectory(), NOT $env:SystemRoot: an env var
|
|
160
|
+
# is caller-controlled (a non-admin can set SystemRoot=C:\evil before running
|
|
161
|
+
# this script), so building an "absolute path" out of it is not actually
|
|
162
|
+
# absolute - it is the same PATH/CWD-planting class Task 3's review fixed for
|
|
163
|
+
# reg.exe/wmic/powershell.exe, one level up. [System.Environment]::SystemDirectory
|
|
164
|
+
# is a native GetSystemDirectory() call, not an environment read.
|
|
165
|
+
function Get-System32Path {
|
|
166
|
+
param([Parameter(Mandatory)][string]$RelativePath)
|
|
167
|
+
return Join-Path ([System.Environment]::SystemDirectory) $RelativePath
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
# Release-manifest resolution (pure - testable against fixture JSON; see
|
|
172
|
+
# tools/build/windows/check-install-ps1.mjs, which hand-ports this exact
|
|
173
|
+
# function and runs it against fixtures with mutation checks in CI)
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
# Plain PSCustomObject property access breaks on hyphenated keys like
|
|
177
|
+
# "win32-x64" ($obj.win32-x64 parses as a subtraction), so every manifest
|
|
178
|
+
# field read goes through this indexer instead of dot/quoted-dot syntax.
|
|
179
|
+
function Get-ManifestProperty {
|
|
180
|
+
param($InputObject, [string]$Name)
|
|
181
|
+
if ($null -eq $InputObject) { return $null }
|
|
182
|
+
$prop = $InputObject.PSObject.Properties[$Name]
|
|
183
|
+
if ($null -eq $prop) { return $null }
|
|
184
|
+
return $prop.Value
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function Get-Win32InstallerDescriptor {
|
|
188
|
+
param($VersionEntry)
|
|
189
|
+
$platforms = Get-ManifestProperty $VersionEntry 'platforms'
|
|
190
|
+
$win32 = Get-ManifestProperty $platforms 'win32-x64'
|
|
191
|
+
return Get-ManifestProperty $win32 'installer'
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
# Resolution order - NEVER the top-level latest/channels (those are the
|
|
195
|
+
# darwin-arm64 view; a Windows-only release does not advance them):
|
|
196
|
+
# 1. a pinned version, if given - hard-fail if it lacks a win32-x64 installer
|
|
197
|
+
# 2. platformChannels."win32-x64"[channel] - the fast path
|
|
198
|
+
# 3. walk versions[] (already published newest-first) for the first entry
|
|
199
|
+
# on this channel that actually carries platforms."win32-x64".installer
|
|
200
|
+
# Step 3 is not redundant with step 2: platformChannels can point at a version
|
|
201
|
+
# whose win32-x64 role was bootstrap-only (no full installer published that
|
|
202
|
+
# release) - independent macOS/Windows cadences mean the newest entry for a
|
|
203
|
+
# platform is not always the newest one that shipped an installer for it.
|
|
204
|
+
function Resolve-InstallerVersionEntry {
|
|
205
|
+
param($Manifest, [string]$PinnedVersion, [string]$Channel)
|
|
206
|
+
|
|
207
|
+
$versions = Get-ManifestProperty $Manifest 'versions'
|
|
208
|
+
if ($null -eq $versions) { $versions = @() }
|
|
209
|
+
|
|
210
|
+
if ($PinnedVersion) {
|
|
211
|
+
$entry = $versions | Where-Object { (Get-ManifestProperty $_ 'version') -eq $PinnedVersion } | Select-Object -First 1
|
|
212
|
+
if (-not $entry -or -not (Get-Win32InstallerDescriptor $entry)) {
|
|
213
|
+
throw "Pinned version '$PinnedVersion' has no win32-x64 installer in the release manifest."
|
|
214
|
+
}
|
|
215
|
+
return $entry
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
$platformChannels = Get-ManifestProperty $Manifest 'platformChannels'
|
|
219
|
+
$win32Channels = Get-ManifestProperty $platformChannels 'win32-x64'
|
|
220
|
+
$candidateVersion = Get-ManifestProperty $win32Channels $Channel
|
|
221
|
+
if ($candidateVersion) {
|
|
222
|
+
$entry = $versions | Where-Object { (Get-ManifestProperty $_ 'version') -eq $candidateVersion } | Select-Object -First 1
|
|
223
|
+
if ($entry -and (Get-Win32InstallerDescriptor $entry)) {
|
|
224
|
+
return $entry
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
foreach ($entry in $versions) {
|
|
229
|
+
if ((Get-ManifestProperty $entry 'channel') -eq $Channel -and (Get-Win32InstallerDescriptor $entry)) {
|
|
230
|
+
return $entry
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
throw "No win32-x64 installer found for channel '$Channel' in the release manifest."
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
# AGENSHIELD_CHANNEL (explicit) wins over CLIENT_ALPHA/CLIENT_BETA, matching
|
|
238
|
+
# install.sh's resolve_channel precedence; 'rc' is additionally accepted here
|
|
239
|
+
# (install.sh's own resolve_channel does not parse it, but
|
|
240
|
+
# platformChannels."win32-x64".rc IS published and would otherwise be
|
|
241
|
+
# unreachable from this script).
|
|
242
|
+
function Resolve-Channel {
|
|
243
|
+
$explicit = if ($Channel) { $Channel.Trim().ToLowerInvariant() } else { '' }
|
|
244
|
+
if ($explicit -eq 'stable' -or $explicit -eq 'rc' -or $explicit -eq 'beta' -or $explicit -eq 'alpha') {
|
|
245
|
+
return $explicit
|
|
246
|
+
}
|
|
247
|
+
if ($ClientAlpha) { return 'alpha' }
|
|
248
|
+
if ($ClientBeta) { return 'beta' }
|
|
249
|
+
return 'stable'
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
# ---------------------------------------------------------------------------
|
|
253
|
+
# Download + verify
|
|
254
|
+
# ---------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
function Invoke-DownloadWithRetry {
|
|
257
|
+
param([Parameter(Mandatory)][string]$Uri, [Parameter(Mandatory)][string]$OutFile, [int]$MaxAttempts = 3)
|
|
258
|
+
for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
|
|
259
|
+
try {
|
|
260
|
+
Invoke-WebRequest -Uri $Uri -OutFile $OutFile -UseBasicParsing -TimeoutSec 300
|
|
261
|
+
return
|
|
262
|
+
} catch {
|
|
263
|
+
if ($attempt -eq $MaxAttempts) { throw }
|
|
264
|
+
Write-WarnLine "Download attempt $attempt of $MaxAttempts failed - retrying: $($_.Exception.Message)"
|
|
265
|
+
Start-Sleep -Seconds (2 * $attempt)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
# Baked at build/publish time exactly like install.sh's CDN_BASE_URL: a
|
|
271
|
+
# deliberate constant, never a runtime env read on a production install - a
|
|
272
|
+
# host-controlled override would let anyone who can set an env var redirect
|
|
273
|
+
# the signed-MSI download to an attacker host. CdnPlaceholder is split so the
|
|
274
|
+
# substitution that bakes CdnBaseUrl below can't also rewrite THIS comparison
|
|
275
|
+
# - an unsubstituted copy (raw repo checkout) falls back to the hardcoded
|
|
276
|
+
# production default rather than silently comparing a baked value to itself.
|
|
277
|
+
# The baked value MUST outrank -AssetsBase/AGENSHIELD_ASSETS_BASE (see the
|
|
278
|
+
# effective-base resolution near Main) - an override that wins over the guard
|
|
279
|
+
# it is supposed to be an exception to defeats the guard entirely.
|
|
280
|
+
$script:CdnBaseUrl = 'https://assets.frontegg.com/agenshield'
|
|
281
|
+
$script:CdnPlaceholder = '__AGENSHIELD_' + 'CDN_BASE_URL__'
|
|
282
|
+
if ($script:CdnBaseUrl -eq $script:CdnPlaceholder) { $script:CdnBaseUrl = '' }
|
|
283
|
+
$script:CdnBaseUrl = $script:CdnBaseUrl.TrimEnd('/')
|
|
284
|
+
$script:DefaultCdnBaseUrl = 'https://assets.frontegg.com/agenshield'
|
|
285
|
+
|
|
286
|
+
# Ship FALSE - no Authenticode certificate is provisioned yet. Flipping this
|
|
287
|
+
# to $true is meant to be a one-line change once one is; the SHA-256 checksum
|
|
288
|
+
# gate above is hard regardless of this flag.
|
|
289
|
+
$script:RequireSignature = $false
|
|
290
|
+
# Filled in once a signing certificate exists; unused while RequireSignature
|
|
291
|
+
# is $false (an unsigned MSI takes the NotSigned branch below, not this one).
|
|
292
|
+
$script:ExpectedSignerThumbprint = ''
|
|
293
|
+
$script:ExpectedSignerSubject = ''
|
|
294
|
+
|
|
295
|
+
function Test-InstallerSignature {
|
|
296
|
+
param([Parameter(Mandatory)][string]$Path)
|
|
297
|
+
$sig = Get-AuthenticodeSignature -LiteralPath $Path
|
|
298
|
+
$signerOk = ($sig.Status -eq 'Valid') -and $sig.SignerCertificate -and
|
|
299
|
+
($sig.SignerCertificate.Thumbprint -eq $script:ExpectedSignerThumbprint) -and
|
|
300
|
+
($sig.SignerCertificate.Subject -eq $script:ExpectedSignerSubject)
|
|
301
|
+
|
|
302
|
+
if ($sig.Status -eq 'NotSigned') {
|
|
303
|
+
if ($script:RequireSignature) { throw 'The downloaded installer is not Authenticode-signed.' }
|
|
304
|
+
Write-WarnLine 'The downloaded installer is not Authenticode-signed (no signing certificate is provisioned yet). The SHA-256 checksum against the release manifest is still verified as a hard gate.'
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
if (-not $signerOk) {
|
|
308
|
+
if ($script:RequireSignature) { throw "The downloaded installer's Authenticode signature did not verify (status: $($sig.Status))." }
|
|
309
|
+
Write-WarnLine "The downloaded installer's Authenticode signature did not match the expected signer (status: $($sig.Status)). Continuing because RequireSignature is `$false - the SHA-256 checksum against the release manifest is still a hard gate."
|
|
310
|
+
return
|
|
311
|
+
}
|
|
312
|
+
Write-Ok "Authenticode signature verified ($($sig.SignerCertificate.Subject))"
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
# ---------------------------------------------------------------------------
|
|
316
|
+
# %ProgramData%\AgenShield\config hardening + enrollment handoff
|
|
317
|
+
#
|
|
318
|
+
# Two traps a prior task's review proved the hard way, both closed here:
|
|
319
|
+
# - icacls /grant:r (and the .NET equivalent of touching an EXISTING ACL)
|
|
320
|
+
# only ever adds/replaces grants for the principals it is given - a THIRD
|
|
321
|
+
# principal's explicit ACE (e.g. a low-privileged user who pre-created
|
|
322
|
+
# this directory, since C:\ProgramData's default DACL lets any local user
|
|
323
|
+
# create a subdirectory) survives untouched, and would keep permanent
|
|
324
|
+
# WRITE_DAC via ownership. So verification here is an ALLOWLIST (refuse
|
|
325
|
+
# any principal other than SYSTEM/Administrators, and any inherited ACE),
|
|
326
|
+
# never a denylist ("not Users").
|
|
327
|
+
# - Account-name literals are locale-dependent (icacls only ever prints
|
|
328
|
+
# resolved, localized names). Get-Acl/Set-Acl let this run entirely on
|
|
329
|
+
# SecurityIdentifier objects - SIDs are compared directly, no name
|
|
330
|
+
# resolution anywhere, in BOTH the grant and the verify half. That is
|
|
331
|
+
# strictly better than icacls can do: icacls's SID-form /grant is already
|
|
332
|
+
# locale-proof, but its listing output has no display-as-SID switch, so
|
|
333
|
+
# its VERIFY half stays English-only. Preferring Get-Acl/Set-Acl over
|
|
334
|
+
# icacls here removes that limitation entirely instead of accepting it.
|
|
335
|
+
#
|
|
336
|
+
# Ownership target is Administrators, not SYSTEM: SetSecurityInfo can only
|
|
337
|
+
# set the owner to the caller's own token SID/owner-capable group, or to any
|
|
338
|
+
# SID at all when SeRestorePrivilege is enabled - an elevated Administrator's
|
|
339
|
+
# token does not carry NT AUTHORITY\SYSTEM and does not have
|
|
340
|
+
# SeRestorePrivilege enabled by default, so SetOwner(SYSTEM) from this script
|
|
341
|
+
# throws ERROR_INVALID_OWNER. Administrators is always reachable from an
|
|
342
|
+
# elevated admin token, and Test-AllowlistedAcl already accepts either
|
|
343
|
+
# principal as owner. The MSI's own hardening action (which DOES run as
|
|
344
|
+
# LocalSystem) will re-set ownership to SYSTEM on the next install with no
|
|
345
|
+
# conflict - both writers already agree on "hardened" independent of which of
|
|
346
|
+
# the two owns it.
|
|
347
|
+
# ---------------------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
$script:SystemSid = New-Object System.Security.Principal.SecurityIdentifier('S-1-5-18')
|
|
350
|
+
$script:AdministratorsSid = New-Object System.Security.Principal.SecurityIdentifier('S-1-5-32-544')
|
|
351
|
+
$script:CurrentUserSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User
|
|
352
|
+
|
|
353
|
+
function New-LockedDownSecurity {
|
|
354
|
+
param([switch]$IsContainer)
|
|
355
|
+
$security = if ($IsContainer) { New-Object System.Security.AccessControl.DirectorySecurity } else { New-Object System.Security.AccessControl.FileSecurity }
|
|
356
|
+
$security.SetOwner($script:AdministratorsSid)
|
|
357
|
+
$security.SetAccessRuleProtection($true, $false)
|
|
358
|
+
$inheritance = if ($IsContainer) {
|
|
359
|
+
[System.Security.AccessControl.InheritanceFlags]([System.Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [System.Security.AccessControl.InheritanceFlags]::ObjectInherit)
|
|
360
|
+
} else {
|
|
361
|
+
[System.Security.AccessControl.InheritanceFlags]::None
|
|
362
|
+
}
|
|
363
|
+
foreach ($sid in @($script:SystemSid, $script:AdministratorsSid)) {
|
|
364
|
+
$rule = New-Object -TypeName System.Security.AccessControl.FileSystemAccessRule -ArgumentList @(
|
|
365
|
+
$sid,
|
|
366
|
+
[System.Security.AccessControl.FileSystemRights]::FullControl,
|
|
367
|
+
$inheritance,
|
|
368
|
+
[System.Security.AccessControl.PropagationFlags]::None,
|
|
369
|
+
[System.Security.AccessControl.AccessControlType]::Allow
|
|
370
|
+
)
|
|
371
|
+
$security.AddAccessRule($rule)
|
|
372
|
+
}
|
|
373
|
+
return $security
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
# Owner + a protected DACL are written in ONE Set-Acl call (one security
|
|
377
|
+
# descriptor, one syscall) rather than icacls's separate /setowner then
|
|
378
|
+
# /grant:r invocations - there is no window between "ownership changes" and
|
|
379
|
+
# "the DACL changes" for a pre-creating owner to race, because there is
|
|
380
|
+
# nothing sequential to race between.
|
|
381
|
+
function Set-HardenedAcl {
|
|
382
|
+
param([Parameter(Mandatory)][string]$Path, [switch]$IsContainer)
|
|
383
|
+
$security = New-LockedDownSecurity -IsContainer:$IsContainer
|
|
384
|
+
Set-Acl -LiteralPath $Path -AclObject $security
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
# True when $Path's OWN final path component is a reparse point (junction or
|
|
388
|
+
# symlink). Deliberately queries $Path as a standalone path rather than as
|
|
389
|
+
# part of a longer one: an intermediate reparse-point component in a longer
|
|
390
|
+
# path is transparently resolved through by the filesystem before Get-Item
|
|
391
|
+
# ever sees it, so this only tells the truth when $Path itself is the last
|
|
392
|
+
# segment being resolved. Callers must check every directory level they
|
|
393
|
+
# create-or-trust individually, in order, top-down (see
|
|
394
|
+
# Set-PendingEnrollmentIfRequested) - there is no ResolveLinkTarget in
|
|
395
|
+
# PowerShell 5.1 to ask "what does this chain actually point at" in one call.
|
|
396
|
+
#
|
|
397
|
+
# FAILS CLOSED: Test-Path returns $false for a junction whose target is
|
|
398
|
+
# missing (a "dangling" junction), so a caller's own not-exists check lets
|
|
399
|
+
# New-Item run, and Get-Item -Force on that same dangling junction then
|
|
400
|
+
# raises ItemNotFoundException - which, under $ErrorActionPreference='Stop',
|
|
401
|
+
# would otherwise terminate the whole script (including the base MSI
|
|
402
|
+
# install this check has nothing to do with) rather than just refuse to
|
|
403
|
+
# harden one directory. Catching it and returning $true treats "cannot even
|
|
404
|
+
# confirm this is a normal directory" the same as "it's a reparse point" -
|
|
405
|
+
# refuse to proceed, exactly what a real reparse point would already do
|
|
406
|
+
# here, so this can never be the reason the install itself fails.
|
|
407
|
+
function Test-IsReparsePoint {
|
|
408
|
+
param([Parameter(Mandatory)][string]$Path)
|
|
409
|
+
try {
|
|
410
|
+
$info = Get-Item -LiteralPath $Path -Force
|
|
411
|
+
return [bool]($info.Attributes -band [System.IO.FileAttributes]::ReparsePoint)
|
|
412
|
+
} catch {
|
|
413
|
+
return $true
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function Test-AllowlistedAcl {
|
|
418
|
+
param([Parameter(Mandatory)][string]$Path)
|
|
419
|
+
$acl = Get-Acl -LiteralPath $Path
|
|
420
|
+
if (-not $acl.AreAccessRulesProtected) { return $false }
|
|
421
|
+
# Ownership grants WRITE_DAC implicitly, regardless of the DACL's own
|
|
422
|
+
# contents - an owner can always reopen a DACL that doesn't even mention
|
|
423
|
+
# them. Set-HardenedAcl sets owner+DACL in one call, but this check does not
|
|
424
|
+
# assume that call is atomic on every filesystem/Windows build; it verifies
|
|
425
|
+
# the CURRENT owner directly rather than trusting the prior write. Either
|
|
426
|
+
# SYSTEM or Administrators is an acceptable owner (see the note above
|
|
427
|
+
# New-LockedDownSecurity on why install.ps1 sets Administrators, not SYSTEM).
|
|
428
|
+
$ownerSid = $acl.GetOwner([System.Security.Principal.SecurityIdentifier])
|
|
429
|
+
if ($ownerSid.Value -ne $script:SystemSid.Value -and $ownerSid.Value -ne $script:AdministratorsSid.Value) {
|
|
430
|
+
return $false
|
|
431
|
+
}
|
|
432
|
+
$rules = $acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])
|
|
433
|
+
$sawSystem = $false
|
|
434
|
+
$sawAdministrators = $false
|
|
435
|
+
foreach ($rule in $rules) {
|
|
436
|
+
if ($rule.IsInherited) { return $false }
|
|
437
|
+
# Deny ACEs and partial-rights grants are not "hardened" just because the SID matches (mirrors config-dir-acl-repair.ts's isAllowlistedConfigDirAcl).
|
|
438
|
+
if ($rule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow) { return $false }
|
|
439
|
+
if ($rule.FileSystemRights -ne [System.Security.AccessControl.FileSystemRights]::FullControl) { return $false }
|
|
440
|
+
$sidValue = $rule.IdentityReference.Value
|
|
441
|
+
if ($sidValue -eq $script:SystemSid.Value) { $sawSystem = $true }
|
|
442
|
+
elseif ($sidValue -eq $script:AdministratorsSid.Value) { $sawAdministrators = $true }
|
|
443
|
+
else { return $false }
|
|
444
|
+
}
|
|
445
|
+
return $sawSystem -and $sawAdministrators
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
# Creates the %TEMP%-scoped Token/CloudUrl/Org handoff file WITH its final
|
|
449
|
+
# ACL applied ATOMICALLY - not create-then-Set-Acl. A separate create, then a
|
|
450
|
+
# later Set-Acl call, leaves a window where the file exists under whatever
|
|
451
|
+
# DACL %TEMP% happens to inherit; %TEMP% is a per-user env var the SAME
|
|
452
|
+
# caller could point somewhere looser than the default per-user profile temp
|
|
453
|
+
# dir (e.g. a shared C:\Temp), so another local user could read the
|
|
454
|
+
# plaintext token in that window. FileMode.CreateNew both makes the create
|
|
455
|
+
# atomic with the ACL and fails loudly if the GUID-named path were ever
|
|
456
|
+
# somehow already occupied, rather than silently overwriting something.
|
|
457
|
+
#
|
|
458
|
+
# EDITION-BRANCHED: the FileSecurity-accepting FileStream constructor is
|
|
459
|
+
# .NET-Framework-only. A later review found this the hard way - Windows
|
|
460
|
+
# PowerShell 5.1 (PSEdition 'Desktop') runs on .NET Framework, where it is
|
|
461
|
+
# the sole 7-parameter FileStream constructor; PowerShell 7 (PSEdition
|
|
462
|
+
# 'Core') runs on .NET Core, which REMOVES it entirely -
|
|
463
|
+
# [System.IO.FileStream].GetConstructors() there yields zero 7-arg
|
|
464
|
+
# candidates, confirmed empirically under pwsh 7.6.5, where the unconditional
|
|
465
|
+
# call this function used to make fails with "Cannot find an overload for
|
|
466
|
+
# FileStream and the argument count: 7". A PS7 user with enrollment env vars
|
|
467
|
+
# set would hit that on every install. .NET Core's atomic-create-with-ACL
|
|
468
|
+
# equivalent is [System.IO.FileSystemAclExtensions]::Create (an extension
|
|
469
|
+
# method on FileInfo, not a constructor) - same atomicity guarantee, same
|
|
470
|
+
# argument shape, different type to call it through.
|
|
471
|
+
#
|
|
472
|
+
# A NARROWER threat model than Test-AllowlistedAcl's config-dir allowlist:
|
|
473
|
+
# this file only needs to resist OTHER non-admin local users, so it
|
|
474
|
+
# explicitly grants the CURRENT user full control too - the parent process
|
|
475
|
+
# (not elevated) must retain delete rights for its own cleanup. Owner is set
|
|
476
|
+
# to the current user (a no-op in practice, since NTFS already assigns the
|
|
477
|
+
# creator as owner - no SeRestorePrivilege question here, unlike
|
|
478
|
+
# New-LockedDownSecurity's SYSTEM/Administrators case).
|
|
479
|
+
function New-ProtectedHandoffFile {
|
|
480
|
+
param([Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$Content)
|
|
481
|
+
$security = New-Object System.Security.AccessControl.FileSecurity
|
|
482
|
+
$security.SetOwner($script:CurrentUserSid)
|
|
483
|
+
$security.SetAccessRuleProtection($true, $false)
|
|
484
|
+
foreach ($sid in @($script:CurrentUserSid, $script:SystemSid, $script:AdministratorsSid)) {
|
|
485
|
+
$rule = New-Object -TypeName System.Security.AccessControl.FileSystemAccessRule -ArgumentList @(
|
|
486
|
+
$sid,
|
|
487
|
+
[System.Security.AccessControl.FileSystemRights]::FullControl,
|
|
488
|
+
[System.Security.AccessControl.InheritanceFlags]::None,
|
|
489
|
+
[System.Security.AccessControl.PropagationFlags]::None,
|
|
490
|
+
[System.Security.AccessControl.AccessControlType]::Allow
|
|
491
|
+
)
|
|
492
|
+
$security.AddAccessRule($rule)
|
|
493
|
+
}
|
|
494
|
+
$bytes = (New-Object System.Text.UTF8Encoding($false)).GetBytes($Content)
|
|
495
|
+
if ($PSVersionTable.PSEdition -eq 'Core') {
|
|
496
|
+
$fileInfo = New-Object System.IO.FileInfo($Path)
|
|
497
|
+
$stream = [System.IO.FileSystemAclExtensions]::Create(
|
|
498
|
+
$fileInfo,
|
|
499
|
+
[System.IO.FileMode]::CreateNew,
|
|
500
|
+
[System.Security.AccessControl.FileSystemRights]::Write,
|
|
501
|
+
[System.IO.FileShare]::None,
|
|
502
|
+
4096,
|
|
503
|
+
[System.IO.FileOptions]::None,
|
|
504
|
+
$security
|
|
505
|
+
)
|
|
506
|
+
} else {
|
|
507
|
+
$stream = New-Object -TypeName System.IO.FileStream -ArgumentList @(
|
|
508
|
+
$Path,
|
|
509
|
+
[System.IO.FileMode]::CreateNew,
|
|
510
|
+
[System.Security.AccessControl.FileSystemRights]::Write,
|
|
511
|
+
[System.IO.FileShare]::None,
|
|
512
|
+
4096,
|
|
513
|
+
[System.IO.FileOptions]::None,
|
|
514
|
+
$security
|
|
515
|
+
)
|
|
516
|
+
}
|
|
517
|
+
try {
|
|
518
|
+
$stream.Write($bytes, 0, $bytes.Length)
|
|
519
|
+
} finally {
|
|
520
|
+
$stream.Dispose()
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function Set-PendingEnrollmentIfRequested {
|
|
525
|
+
param([string]$Token, [string]$CloudUrl, [string]$Org)
|
|
526
|
+
if (-not $Token -or -not $CloudUrl) {
|
|
527
|
+
Write-Info 'No enrollment token/cloud URL provided - skipping pending-enrollment.json (sign in from the tray, or use the registry/MDM channel).'
|
|
528
|
+
return
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
# Any local user can create a subdirectory under C:\ProgramData, including a
|
|
532
|
+
# directory junction (`mklink /J`, no privilege required) pointing anywhere
|
|
533
|
+
# - and this applies to EVERY level we create or trust, not just the config
|
|
534
|
+
# leaf: C:\ProgramData\AgenShield itself does not exist on a first install,
|
|
535
|
+
# so a non-admin can pre-plant it as a junction (e.g. at C:\Windows\System32)
|
|
536
|
+
# before this script ever runs. Checking only the leaf misses this: querying
|
|
537
|
+
# attributes on a path where the suspect component is an INTERMEDIATE
|
|
538
|
+
# segment (not the path's own final component) transparently resolves
|
|
539
|
+
# through it at the filesystem level - Get-Item on
|
|
540
|
+
# "...\AgenShield\config" would report the TARGET's attributes, not
|
|
541
|
+
# AgenShield's own, so the leaf looks like an ordinary directory even
|
|
542
|
+
# though it is a junction one level up. PowerShell 5.1 has no
|
|
543
|
+
# ResolveLinkTarget/.ResolvedTarget to ask "what does this actually point
|
|
544
|
+
# at", so the only correct check is per-component: verify EACH directory we
|
|
545
|
+
# are about to create-or-trust while it is still the FINAL component of the
|
|
546
|
+
# path being queried, before ever constructing a longer path through it.
|
|
547
|
+
#
|
|
548
|
+
# NOT ATOMIC: this is a check, not a lock. Between this Test-IsReparsePoint
|
|
549
|
+
# call returning "safe" and Set-HardenedAcl/WriteAllText actually touching
|
|
550
|
+
# the path a few lines below, a sufficiently fast attacker could still
|
|
551
|
+
# delete the real directory and drop a junction in its place - a
|
|
552
|
+
# create-check-use race PowerShell 5.1 cannot close without P/Invoking a
|
|
553
|
+
# handle-based open (e.g. CreateFile with FILE_FLAG_OPEN_REPARSE_POINT
|
|
554
|
+
# held across the whole operation). This walk closes the window the round-2
|
|
555
|
+
# review named (the PARENT directory not being checked at all) - it is a
|
|
556
|
+
# real narrowing of the attack surface, not proof the surface is zero.
|
|
557
|
+
$agenShieldRoot = Join-Path $env:ProgramData 'AgenShield'
|
|
558
|
+
if (-not (Test-Path -LiteralPath $agenShieldRoot)) {
|
|
559
|
+
New-Item -ItemType Directory -Path $agenShieldRoot -Force | Out-Null
|
|
560
|
+
}
|
|
561
|
+
if (Test-IsReparsePoint -Path $agenShieldRoot) {
|
|
562
|
+
Write-WarnLine "$agenShieldRoot is a reparse point (junction/symlink) - refusing to harden or write under it. Remove it and re-run, or use the registry/MDM channel instead."
|
|
563
|
+
return
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
# This root's OWNER is set to Administrators unconditionally in the elevated top-level block below - never gated on a token; see the WHY block there.
|
|
567
|
+
$configDir = Join-Path $agenShieldRoot 'config'
|
|
568
|
+
if (-not (Test-Path -LiteralPath $configDir)) {
|
|
569
|
+
New-Item -ItemType Directory -Path $configDir -Force | Out-Null
|
|
570
|
+
}
|
|
571
|
+
# Set-Acl and [System.IO.File]::WriteAllText both follow a junction
|
|
572
|
+
# transparently, so without this check (now that AgenShieldRoot is
|
|
573
|
+
# confirmed real) a non-admin who plants the junction AT THIS level
|
|
574
|
+
# instead could redirect this ELEVATED process into rewriting an
|
|
575
|
+
# arbitrary directory's owner/DACL to SYSTEM+Administrators-only - a
|
|
576
|
+
# denial-of-service against the box via an elevated installer, not merely
|
|
577
|
+
# against this one directory.
|
|
578
|
+
if (Test-IsReparsePoint -Path $configDir) {
|
|
579
|
+
Write-WarnLine "$configDir is a reparse point (junction/symlink) - refusing to harden or write into it. Remove it and re-run, or use the registry/MDM channel instead."
|
|
580
|
+
return
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
try {
|
|
584
|
+
Set-HardenedAcl -Path $configDir -IsContainer
|
|
585
|
+
} catch {
|
|
586
|
+
Write-WarnLine "Failed to harden $configDir : $($_.Exception.Message)"
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
if (-not (Test-AllowlistedAcl -Path $configDir)) {
|
|
590
|
+
# Not necessarily a locale problem (unlike the MSI's icacls-based reader,
|
|
591
|
+
# this check is SID-based and locale-independent) - most often means
|
|
592
|
+
# hardening above failed (e.g. a privilege issue) or the directory is
|
|
593
|
+
# owned/ACL'd by something other than SYSTEM/Administrators.
|
|
594
|
+
Write-WarnLine "$configDir is not verified SYSTEM+Administrators-only - skipping pending-enrollment.json write. Enroll via the registry/MDM channel instead."
|
|
595
|
+
return
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
$payload = [ordered]@{ token = $Token; cloudUrl = $CloudUrl }
|
|
599
|
+
if ($Org) { $payload['org'] = $Org }
|
|
600
|
+
# ConvertTo-Json handles escaping; the write below uses a BOM-less UTF8
|
|
601
|
+
# encoder explicitly - PowerShell 5.1's -Encoding UTF8 on Set-Content/
|
|
602
|
+
# Out-File emits a UTF-8 BOM, which breaks the reader's plain JSON.parse()
|
|
603
|
+
# (libs/cloud/src/pending-enrollment.ts) with no error, just silent
|
|
604
|
+
# non-enrollment.
|
|
605
|
+
$json = $payload | ConvertTo-Json -Compress
|
|
606
|
+
|
|
607
|
+
$pendingPath = Join-Path $configDir 'pending-enrollment.json'
|
|
608
|
+
if (Test-Path -LiteralPath $pendingPath) {
|
|
609
|
+
Remove-Item -LiteralPath $pendingPath -Force
|
|
610
|
+
}
|
|
611
|
+
[System.IO.File]::WriteAllText($pendingPath, $json, (New-Object System.Text.UTF8Encoding($false)))
|
|
612
|
+
|
|
613
|
+
try {
|
|
614
|
+
Set-HardenedAcl -Path $pendingPath
|
|
615
|
+
} catch {
|
|
616
|
+
Remove-Item -LiteralPath $pendingPath -Force -ErrorAction SilentlyContinue
|
|
617
|
+
Write-WarnLine "Failed to lock down pending-enrollment.json - deleted it rather than leave a bearer token under an unverified ACL: $($_.Exception.Message)"
|
|
618
|
+
return
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
if (-not (Test-AllowlistedAcl -Path $pendingPath)) {
|
|
622
|
+
Remove-Item -LiteralPath $pendingPath -Force -ErrorAction SilentlyContinue
|
|
623
|
+
Write-WarnLine 'pending-enrollment.json ACL did not verify after hardening - deleted it rather than leave a bearer token under an unverified ACL.'
|
|
624
|
+
return
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
Write-Ok 'Wrote pending-enrollment.json'
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
# ---------------------------------------------------------------------------
|
|
631
|
+
# Install (lifted from tools/build/windows/install-agenshield.ps1, which
|
|
632
|
+
# already avoids Win32_Product - it triggers a repair on every enumeration -
|
|
633
|
+
# and already handles the tray holding resources\app.asar open)
|
|
634
|
+
# ---------------------------------------------------------------------------
|
|
635
|
+
|
|
636
|
+
function Get-InstalledProductCode {
|
|
637
|
+
param([string]$DisplayName = 'AgenShield')
|
|
638
|
+
$roots = @(
|
|
639
|
+
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall',
|
|
640
|
+
'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall'
|
|
641
|
+
)
|
|
642
|
+
foreach ($root in $roots) {
|
|
643
|
+
$found = Get-ChildItem $root -ErrorAction SilentlyContinue | ForEach-Object {
|
|
644
|
+
$p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
|
|
645
|
+
if ($p.DisplayName -eq $DisplayName -and $_.PSChildName -match '^\{[0-9A-Fa-f-]+\}$') {
|
|
646
|
+
$_.PSChildName
|
|
647
|
+
}
|
|
648
|
+
} | Select-Object -First 1
|
|
649
|
+
if ($found) { return $found }
|
|
650
|
+
}
|
|
651
|
+
return $null
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function Stop-AgenShieldTray {
|
|
655
|
+
Get-Process AgenShield -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
|
|
656
|
+
Start-Sleep -Seconds 1
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function Uninstall-AgenShield {
|
|
660
|
+
$code = Get-InstalledProductCode
|
|
661
|
+
if (-not $code) { return }
|
|
662
|
+
Write-Info "Removing the existing AgenShield installation ($code)..."
|
|
663
|
+
$msiexecPath = Get-System32Path 'msiexec.exe'
|
|
664
|
+
$uninstallLog = Join-Path $script:LogDir "install-$RunId-uninstall.log"
|
|
665
|
+
$proc = Start-Process -FilePath $msiexecPath -ArgumentList "/x $code /qn /norestart /l*v `"$uninstallLog`"" -Wait -PassThru
|
|
666
|
+
if ($proc.ExitCode -ne 0 -and $proc.ExitCode -ne 3010) {
|
|
667
|
+
Write-WarnLine "Uninstalling the existing AgenShield installation exited with code $($proc.ExitCode) - continuing with install."
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
# ---------------------------------------------------------------------------
|
|
672
|
+
# Health gate
|
|
673
|
+
# ---------------------------------------------------------------------------
|
|
674
|
+
|
|
675
|
+
function Wait-DaemonHealthy {
|
|
676
|
+
param([int]$Attempts = 5, [int]$DelaySeconds = 2)
|
|
677
|
+
$lastError = $null
|
|
678
|
+
for ($i = 1; $i -le $Attempts; $i++) {
|
|
679
|
+
try {
|
|
680
|
+
$resp = Invoke-WebRequest -Uri 'http://127.0.0.1:5200/api/health' -UseBasicParsing -TimeoutSec 5
|
|
681
|
+
if ($resp.StatusCode -eq 200) { return $true }
|
|
682
|
+
} catch {
|
|
683
|
+
$lastError = $_.Exception.Message
|
|
684
|
+
}
|
|
685
|
+
if ($i -lt $Attempts) { Start-Sleep -Seconds $DelaySeconds }
|
|
686
|
+
}
|
|
687
|
+
if ($lastError) { Write-WarnLine "Last health-check error: $lastError" }
|
|
688
|
+
return $false
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
# Bounded poll of GET /api/status for data.cloudEnrolled - the same
|
|
692
|
+
# unauthenticated call libs/cli/src/commands/start.ts makes. Never blocks
|
|
693
|
+
# indefinitely: on expiry the daemon keeps retrying enrollment in the
|
|
694
|
+
# background on its own, same as install.sh's non-blocking posture.
|
|
695
|
+
function Wait-EnrollmentComplete {
|
|
696
|
+
param([int]$Attempts = 30, [int]$DelaySeconds = 2)
|
|
697
|
+
$lastError = $null
|
|
698
|
+
for ($i = 1; $i -le $Attempts; $i++) {
|
|
699
|
+
try {
|
|
700
|
+
$resp = Invoke-RestMethod -Uri 'http://127.0.0.1:5200/api/status' -TimeoutSec 5
|
|
701
|
+
if ($resp.data.cloudEnrolled -eq $true) { return $true }
|
|
702
|
+
} catch {
|
|
703
|
+
$lastError = $_.Exception.Message
|
|
704
|
+
}
|
|
705
|
+
Start-Sleep -Seconds $DelaySeconds
|
|
706
|
+
}
|
|
707
|
+
if ($lastError) { Write-WarnLine "Last enrollment-status error: $lastError" }
|
|
708
|
+
return $false
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
# ---------------------------------------------------------------------------
|
|
712
|
+
# Elevation gate - relaunch elevated if needed, then wait and tail
|
|
713
|
+
# ---------------------------------------------------------------------------
|
|
714
|
+
|
|
715
|
+
if (-not (Test-IsAdministrator)) {
|
|
716
|
+
# $ElevatedChild is set ONLY on the relaunch this same gate below performs
|
|
717
|
+
# - so a non-elevated process reaching this point with it already set
|
|
718
|
+
# means the -Verb RunAs relaunch completed but the resulting process is
|
|
719
|
+
# STILL not elevated (an unusual token/policy edge case, not the normal
|
|
720
|
+
# path). Without this guard, that child would relaunch itself AGAIN,
|
|
721
|
+
# raising another UAC prompt, forever - refuse instead of looping.
|
|
722
|
+
if ($ElevatedChild) {
|
|
723
|
+
Write-ErrorLine 'Elevation did not succeed - this process still lacks Administrator rights after a self-relaunch. Refusing to relaunch again (that would prompt UAC indefinitely).'
|
|
724
|
+
exit 1
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
Write-Step 'AgenShield - Install'
|
|
728
|
+
Write-Info 'Administrator privileges are required - requesting elevation...'
|
|
729
|
+
|
|
730
|
+
# Resolved via Get-System32Path (GetSystemDirectory(), not $env:SystemRoot):
|
|
731
|
+
# Start-Process/ShellExecute can search the CURRENT WORKING DIRECTORY before
|
|
732
|
+
# PATH, and this script is meant to be run from wherever a user downloaded
|
|
733
|
+
# it - the same binary-planting class this codebase already fixed for
|
|
734
|
+
# reg.exe/wmic/icacls on the daemon/CA side, closed here without depending
|
|
735
|
+
# on an environment variable the caller controls.
|
|
736
|
+
$powershellPath = Get-System32Path 'WindowsPowerShell\v1.0\powershell.exe'
|
|
737
|
+
|
|
738
|
+
$relaunchArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', (ConvertTo-QuotedArg $PSCommandPath), '-RunId', (ConvertTo-QuotedArg $RunId), '-ElevatedChild')
|
|
739
|
+
if ($Version) { $relaunchArgs += @('-Version', (ConvertTo-QuotedArg $Version)) }
|
|
740
|
+
if ($AssetsBase) { $relaunchArgs += @('-AssetsBase', (ConvertTo-QuotedArg $AssetsBase)) }
|
|
741
|
+
if ($Channel) { $relaunchArgs += @('-Channel', (ConvertTo-QuotedArg $Channel)) }
|
|
742
|
+
if ($ClientAlpha) { $relaunchArgs += '-ClientAlpha' }
|
|
743
|
+
if ($ClientBeta) { $relaunchArgs += '-ClientBeta' }
|
|
744
|
+
|
|
745
|
+
# Environment variables set only in THIS (non-elevated) process are not
|
|
746
|
+
# reliably inherited by a `-Verb RunAs` relaunch - UAC's elevation broker
|
|
747
|
+
# builds the elevated process's environment from the user's persisted
|
|
748
|
+
# profile, not from this process's in-memory block, so $env:AGENSHIELD_TOKEN
|
|
749
|
+
# set only here could silently vanish across the boundary. Passing the
|
|
750
|
+
# token as a plain -Token argv value instead would recreate the exact bug
|
|
751
|
+
# class a prior task fixed for the MSI custom action (a process command
|
|
752
|
+
# line is readable by any local user via Win32_Process). A short-lived
|
|
753
|
+
# handoff file under the CURRENT user's own %TEMP% carries the secrets
|
|
754
|
+
# instead; only its path ever crosses the boundary, and
|
|
755
|
+
# New-ProtectedHandoffFile creates it with its final ACL already applied -
|
|
756
|
+
# atomically, not create-then-Set-Acl - rather than trusting whatever
|
|
757
|
+
# %TEMP% happens to inherit even for the brief window before a separate
|
|
758
|
+
# Set-Acl call would land. Everything below is inside one try/finally so a
|
|
759
|
+
# Ctrl-C (or any other terminating error) during the potentially long
|
|
760
|
+
# -Wait still deletes the handoff file rather than leaving a plaintext
|
|
761
|
+
# bearer token on disk.
|
|
762
|
+
$handoffPath = $null
|
|
763
|
+
$exitCode = 1
|
|
764
|
+
try {
|
|
765
|
+
if ($Token -or $CloudUrl -or $Org) {
|
|
766
|
+
$handoffPath = Join-Path $env:TEMP ('agenshield-install-' + [guid]::NewGuid().ToString('N') + '.json')
|
|
767
|
+
$handoffPayload = [ordered]@{ token = $Token; cloudUrl = $CloudUrl; org = $Org }
|
|
768
|
+
New-ProtectedHandoffFile -Path $handoffPath -Content ($handoffPayload | ConvertTo-Json -Compress)
|
|
769
|
+
$relaunchArgs += @('-HandoffFile', (ConvertTo-QuotedArg $handoffPath))
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
try {
|
|
773
|
+
$proc = Start-Process -FilePath $powershellPath -Verb RunAs -Wait -PassThru -ArgumentList ($relaunchArgs -join ' ')
|
|
774
|
+
# -Verb RunAs -PassThru populating .ExitCode is undocumented behavior;
|
|
775
|
+
# guard BOTH $proc itself and $proc.ExitCode being $null - `exit $null`
|
|
776
|
+
# exits 0, which would report a failed elevated install as success to
|
|
777
|
+
# an MDM wrapper watching this script's own exit code.
|
|
778
|
+
$exitCode = if ($null -eq $proc -or $null -eq $proc.ExitCode) { 1 } else { $proc.ExitCode }
|
|
779
|
+
} catch {
|
|
780
|
+
Write-ErrorLine "Elevated relaunch failed: $($_.Exception.Message)"
|
|
781
|
+
$exitCode = 1
|
|
782
|
+
}
|
|
783
|
+
} finally {
|
|
784
|
+
if ($handoffPath -and (Test-Path -LiteralPath $handoffPath)) {
|
|
785
|
+
Remove-Item -LiteralPath $handoffPath -Force -ErrorAction SilentlyContinue
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
# The parent WAITED (above), THEN tails the elevated run's transcript - the
|
|
790
|
+
# analogue of macOS's `sudo -n tail -F /var/log/agenshield-install.log`,
|
|
791
|
+
# adapted for -Verb RunAs's synchronous semantics (there is no live
|
|
792
|
+
# concurrent tail across an elevation boundary the way a backgrounded sudo
|
|
793
|
+
# command allows) so output still lands back in the caller's own window.
|
|
794
|
+
if (Test-Path -LiteralPath $script:TranscriptPath) {
|
|
795
|
+
Get-Content -LiteralPath $script:TranscriptPath | ForEach-Object { Write-Host " | $_" }
|
|
796
|
+
}
|
|
797
|
+
exit $exitCode
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
if ($HandoffFile -and (Test-Path -LiteralPath $HandoffFile)) {
|
|
801
|
+
# A truncated or tampered handoff file must not abort the base install (same posture as the enrollment-staging catch below).
|
|
802
|
+
try {
|
|
803
|
+
$handoff = Get-Content -LiteralPath $HandoffFile -Raw | ConvertFrom-Json
|
|
804
|
+
if ($handoff.token) { $Token = $handoff.token }
|
|
805
|
+
if ($handoff.cloudUrl) { $CloudUrl = $handoff.cloudUrl }
|
|
806
|
+
if ($handoff.org) { $Org = $handoff.org }
|
|
807
|
+
} catch {
|
|
808
|
+
Write-WarnLine "Failed to read the enrollment handoff file - continuing without it: $($_.Exception.Message)"
|
|
809
|
+
} finally {
|
|
810
|
+
Remove-Item -LiteralPath $HandoffFile -Force -ErrorAction SilentlyContinue
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
# Validated BEFORE this (elevated) process writes ANYTHING under
|
|
815
|
+
# %ProgramData%\AgenShield - Set-PendingEnrollmentIfRequested's own
|
|
816
|
+
# reparse-point check runs much later in Main, well after $script:LogDir
|
|
817
|
+
# would already have been created here. A non-admin junction planted at
|
|
818
|
+
# %ProgramData%\AgenShield (the round-2 I6 finding) would otherwise redirect
|
|
819
|
+
# this New-Item/Start-Transcript pair into the junction's target first.
|
|
820
|
+
# Bounded blast radius for that pair even so - a directory create and a .log
|
|
821
|
+
# write, and the new directory inherits the TARGET's own DACL rather than
|
|
822
|
+
# gaining anything - but validated regardless, so no elevated write of any
|
|
823
|
+
# kind precedes it. The stakes are higher now than when this check was added:
|
|
824
|
+
# the ownership Set-Acl below would rewrite the junction TARGET's owner, so it
|
|
825
|
+
# lives inside the validated branch and never runs on a reparse point. Skips
|
|
826
|
+
# the transcript and the ownership fix (falling back to console-only output)
|
|
827
|
+
# rather than aborting the install if the root is compromised, matching
|
|
828
|
+
# Start-Transcript's own already-non-fatal failure handling below.
|
|
829
|
+
$agenShieldRoot = Join-Path $env:ProgramData 'AgenShield'
|
|
830
|
+
if (-not (Test-Path -LiteralPath $agenShieldRoot)) {
|
|
831
|
+
New-Item -ItemType Directory -Path $agenShieldRoot -Force | Out-Null
|
|
832
|
+
}
|
|
833
|
+
if (Test-IsReparsePoint -Path $agenShieldRoot) {
|
|
834
|
+
Write-WarnLine "$agenShieldRoot is a reparse point (junction/symlink) - skipping the install transcript to avoid writing under it."
|
|
835
|
+
} else {
|
|
836
|
+
New-Item -ItemType Directory -Path $script:LogDir -Force | Out-Null
|
|
837
|
+
try {
|
|
838
|
+
Start-Transcript -Path $script:TranscriptPath -Force | Out-Null
|
|
839
|
+
} catch {
|
|
840
|
+
Write-WarnLine "Could not start the install transcript: $($_.Exception.Message)"
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
# OWNERSHIP ONLY - never the leaf's restrictive protected DACL, and
|
|
844
|
+
# deliberately OUT here rather than inside Set-PendingEnrollmentIfRequested.
|
|
845
|
+
# On stock Windows, "System objects: Default owner for objects created by
|
|
846
|
+
# members of the Administrators group" defaults to Object Creator, so the
|
|
847
|
+
# New-Item above (running under an elevated ADMINISTRATOR token, not SYSTEM)
|
|
848
|
+
# leaves this root owned by that admin's own user SID - neither SYSTEM nor
|
|
849
|
+
# the Administrators GROUP. The daemon's own boot-time ACL repair
|
|
850
|
+
# (libs/shield-daemon) gates its write on the CURRENT OWNER of BOTH this
|
|
851
|
+
# root AND the config leaf being SYSTEM or Administrators (a non-admin who
|
|
852
|
+
# pre-creates the root keeps FILE_DELETE_CHILD via the default CREATOR OWNER
|
|
853
|
+
# ACE and could delete-and-replace even a hardened leaf otherwise) - so
|
|
854
|
+
# leaving this root owned by an arbitrary admin user permanently refuses that
|
|
855
|
+
# repair with harden-refused-owner on every boot, even though this script
|
|
856
|
+
# itself never hit an error. This block previously lived inside
|
|
857
|
+
# Set-PendingEnrollmentIfRequested, which returns early when no enrollment
|
|
858
|
+
# token was supplied - so the plain no-token one-liner install (the very
|
|
859
|
+
# install the public docs describe) never reached it, while the create above
|
|
860
|
+
# runs on EVERY elevated run and always wins the race to create the root.
|
|
861
|
+
# That left the backstop permanently disabled on exactly the WDAC/AppLocker
|
|
862
|
+
# estates it exists for, where the MSI's own hardening action is blocked and
|
|
863
|
+
# silently ignored. It therefore runs here: unconditionally, before Main, and
|
|
864
|
+
# so still ahead of the $configDir create/harden in
|
|
865
|
+
# Set-PendingEnrollmentIfRequested, leaving no window where an attacker-owned
|
|
866
|
+
# root contains an already-hardened child. Placed just after Start-Transcript
|
|
867
|
+
# so a failure is captured in the transcript the non-elevated parent tails
|
|
868
|
+
# rather than vanishing with the elevated child's console. Set OWNER only,
|
|
869
|
+
# preserving the current DACL exactly as read: other AgenShield components
|
|
870
|
+
# legitimately live under this root (agent homes, and the desktop's
|
|
871
|
+
# non-elevated upgrade-log reader under logs\), so applying the leaf's own
|
|
872
|
+
# restrictive DACL here would break them.
|
|
873
|
+
try {
|
|
874
|
+
$rootAcl = Get-Acl -LiteralPath $agenShieldRoot
|
|
875
|
+
$rootAcl.SetOwner($script:AdministratorsSid)
|
|
876
|
+
Set-Acl -LiteralPath $agenShieldRoot -AclObject $rootAcl
|
|
877
|
+
} catch {
|
|
878
|
+
Write-WarnLine "Failed to set ownership of $agenShieldRoot to Administrators: $($_.Exception.Message)"
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
# ---------------------------------------------------------------------------
|
|
883
|
+
# Main
|
|
884
|
+
# ---------------------------------------------------------------------------
|
|
885
|
+
|
|
886
|
+
$script:ExitCode = 0
|
|
887
|
+
try {
|
|
888
|
+
Write-Step 'AgenShield - Install'
|
|
889
|
+
|
|
890
|
+
if ($Org -and -not $CloudUrl) {
|
|
891
|
+
throw 'AGENSHIELD_ORG requires AGENSHIELD_CLOUD_URL to be specified.'
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
# ARM64 Windows and a 32-bit process report x86-ish values in
|
|
895
|
+
# PROCESSOR_ARCHITECTURE; PROCESSOR_ARCHITEW6432 carries the TRUE host
|
|
896
|
+
# architecture whenever the current process is running under WOW64, so
|
|
897
|
+
# checking it first is what makes this WOW64-proof rather than trusting
|
|
898
|
+
# whichever powershell.exe happened to be invoked.
|
|
899
|
+
$trueArch = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE }
|
|
900
|
+
if ($trueArch -ne 'AMD64') {
|
|
901
|
+
throw "AgenShield for Windows currently supports x64 only - detected '$trueArch'. ARM64/x86 Windows is not yet supported."
|
|
902
|
+
}
|
|
903
|
+
Write-Ok 'Architecture: x64'
|
|
904
|
+
|
|
905
|
+
# The baked CDN base MUST outrank -AssetsBase/AGENSHIELD_ASSETS_BASE - an
|
|
906
|
+
# env override that wins over the guard it's an exception to is not a
|
|
907
|
+
# guard. The override is honored only when the baked placeholder was never
|
|
908
|
+
# substituted (an unbaked checkout); DefaultCdnBaseUrl is the final
|
|
909
|
+
# fallback for that same unbaked case when no override is given either.
|
|
910
|
+
$effectiveAssetsBase = if ($script:CdnBaseUrl) { $script:CdnBaseUrl } elseif ($AssetsBase) { $AssetsBase.TrimEnd('/') } else { $script:DefaultCdnBaseUrl }
|
|
911
|
+
# Named distinctly from the $Channel PARAMETER on purpose - PowerShell
|
|
912
|
+
# variable names are case-insensitive, so a local $channel here would be
|
|
913
|
+
# the SAME variable as $Channel and silently overwrite the caller's raw
|
|
914
|
+
# input with the resolved value. Harmless today (nothing reads $Channel
|
|
915
|
+
# again afterward), but a trap for the next edit that assumes otherwise.
|
|
916
|
+
$channelName = Resolve-Channel
|
|
917
|
+
Write-Info "Resolving version (channel: $channelName)..."
|
|
918
|
+
$manifest = Invoke-RestMethod -Uri "$effectiveAssetsBase/versions.json" -TimeoutSec 30
|
|
919
|
+
$entry = Resolve-InstallerVersionEntry -Manifest $manifest -PinnedVersion $Version -Channel $channelName
|
|
920
|
+
$installer = Get-Win32InstallerDescriptor $entry
|
|
921
|
+
$resolvedVersion = Get-ManifestProperty $entry 'version'
|
|
922
|
+
$entryPath = Get-ManifestProperty $entry 'path'
|
|
923
|
+
if (-not $entryPath) { $entryPath = "v$resolvedVersion" }
|
|
924
|
+
$installerName = Get-ManifestProperty $installer 'name'
|
|
925
|
+
if (-not $installerName) { $installerName = "AgenShield-$resolvedVersion-win32-x64.msi" }
|
|
926
|
+
$expectedSha256 = Get-ManifestProperty $installer 'sha256'
|
|
927
|
+
|
|
928
|
+
# Defense in depth: $entryPath/$installerName came from the release manifest
|
|
929
|
+
# and are about to become a URL segment, a local file path, and part of a
|
|
930
|
+
# quoted msiexec command line. A well-formed manifest never produces
|
|
931
|
+
# anything outside this charset, so rejecting anything else costs nothing
|
|
932
|
+
# and closes off quote/backslash-based argument injection if the manifest
|
|
933
|
+
# itself were ever the compromised link in the chain. ".." is rejected
|
|
934
|
+
# explicitly - the charset alone allows it (dots are legal in a version
|
|
935
|
+
# string), and a path-traversal segment is exactly the shape a directory
|
|
936
|
+
# escape needs.
|
|
937
|
+
if ($entryPath -notmatch '^[A-Za-z0-9._-]+$' -or $entryPath.Contains('..')) {
|
|
938
|
+
throw "Refusing to use an unsafe release path from the manifest: '$entryPath'"
|
|
939
|
+
}
|
|
940
|
+
if ($installerName -notmatch '^[A-Za-z0-9._-]+\.msi$' -or $installerName.Contains('..')) {
|
|
941
|
+
throw "Refusing to use an unsafe installer filename from the manifest: '$installerName'"
|
|
942
|
+
}
|
|
943
|
+
Write-Ok "Resolved version $resolvedVersion"
|
|
944
|
+
|
|
945
|
+
$downloadUrl = "$effectiveAssetsBase/$entryPath/$installerName"
|
|
946
|
+
$tempDir = Join-Path $env:TEMP ('agenshield-install-' + [guid]::NewGuid().ToString('N'))
|
|
947
|
+
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
|
948
|
+
$msiPath = Join-Path $tempDir $installerName
|
|
949
|
+
|
|
950
|
+
try {
|
|
951
|
+
Write-Step "Downloading AgenShield $resolvedVersion"
|
|
952
|
+
Invoke-DownloadWithRetry -Uri $downloadUrl -OutFile $msiPath
|
|
953
|
+
Write-Ok "Downloaded $installerName"
|
|
954
|
+
|
|
955
|
+
# An ABSENT sha256 is a hard gate too, not just a mismatch: it is
|
|
956
|
+
# attacker-selectable (anyone who can serve versions.json simply omits
|
|
957
|
+
# the key), and cdn-update-manifests.sh refuses to publish an asset that
|
|
958
|
+
# is not in checksums.sha256 - a well-formed manifest always carries it,
|
|
959
|
+
# so leniency here protects nothing real. Windows has no Developer-ID-style
|
|
960
|
+
# backstop the way macOS's installer(8) does, and $RequireSignature is
|
|
961
|
+
# $false, so this is the only integrity check that is guaranteed to run.
|
|
962
|
+
if (-not $expectedSha256) {
|
|
963
|
+
throw "No sha256 listed for $installerName in the release manifest - refusing to install an unverified artifact."
|
|
964
|
+
}
|
|
965
|
+
$actualSha256 = (Get-FileHash -LiteralPath $msiPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
|
966
|
+
if ($actualSha256 -ne $expectedSha256.ToLowerInvariant()) {
|
|
967
|
+
throw "Checksum mismatch for $installerName (expected $expectedSha256, got $actualSha256)."
|
|
968
|
+
}
|
|
969
|
+
Write-Ok 'Checksum verified'
|
|
970
|
+
|
|
971
|
+
Test-InstallerSignature -Path $msiPath
|
|
972
|
+
|
|
973
|
+
Write-Step 'Staging enrollment'
|
|
974
|
+
# Enrollment plumbing must never be the reason the base install aborts
|
|
975
|
+
# (the header's own "never block the base install on enrollment
|
|
976
|
+
# plumbing" posture) - belt-and-suspenders alongside Test-IsReparsePoint
|
|
977
|
+
# now failing closed instead of throwing: anything else unexpected in
|
|
978
|
+
# here (permissions, a locked file, ...) degrades to a warning, not a
|
|
979
|
+
# skipped MSI install.
|
|
980
|
+
try {
|
|
981
|
+
Set-PendingEnrollmentIfRequested -Token $Token -CloudUrl $CloudUrl -Org $Org
|
|
982
|
+
} catch {
|
|
983
|
+
Write-WarnLine "Enrollment staging failed - continuing with the base install: $($_.Exception.Message)"
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
Write-Step "Installing AgenShield $resolvedVersion"
|
|
987
|
+
Stop-AgenShieldTray
|
|
988
|
+
Uninstall-AgenShield
|
|
989
|
+
$msiexecPath = Get-System32Path 'msiexec.exe'
|
|
990
|
+
$msiLogPath = Join-Path $script:LogDir "install-$RunId-msiexec.log"
|
|
991
|
+
$installProc = Start-Process -FilePath $msiexecPath -ArgumentList "/i `"$msiPath`" /qn /norestart /l*v `"$msiLogPath`"" -Wait -PassThru
|
|
992
|
+
if ($installProc.ExitCode -eq 0) {
|
|
993
|
+
Write-Ok "Installed AgenShield $resolvedVersion"
|
|
994
|
+
} elseif ($installProc.ExitCode -eq 3010) {
|
|
995
|
+
Write-Ok "Installed AgenShield $resolvedVersion (a reboot is required to finish)"
|
|
996
|
+
} else {
|
|
997
|
+
Write-ErrorLine "msiexec failed (exit $($installProc.ExitCode)) - last 25 lines of $msiLogPath`:"
|
|
998
|
+
if (Test-Path -LiteralPath $msiLogPath) {
|
|
999
|
+
Get-Content -LiteralPath $msiLogPath -Tail 25 | ForEach-Object { Write-Host " | $_" }
|
|
1000
|
+
}
|
|
1001
|
+
throw "AgenShield installation failed (msiexec exit $($installProc.ExitCode))."
|
|
1002
|
+
}
|
|
1003
|
+
} finally {
|
|
1004
|
+
Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
Write-Step 'Waiting for the AgenShield service'
|
|
1008
|
+
if (Wait-DaemonHealthy) {
|
|
1009
|
+
Write-Ok 'Daemon is running'
|
|
1010
|
+
if ($Token -and $CloudUrl) {
|
|
1011
|
+
Write-Info 'Waiting for enrollment to complete...'
|
|
1012
|
+
if (Wait-EnrollmentComplete) {
|
|
1013
|
+
Write-Ok 'Device enrolled'
|
|
1014
|
+
} else {
|
|
1015
|
+
Write-WarnLine 'Enrollment did not complete in time - the service will keep retrying in the background.'
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
} else {
|
|
1019
|
+
Write-WarnLine 'The AgenShield service did not become healthy in time - it may still be starting.'
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
# No sign-in: installation must not require or trigger an OAuth browser
|
|
1023
|
+
# flow. The device is enrolled (campaign token) or waits to be, and a user
|
|
1024
|
+
# signs in LATER from the tray to attach their account - same rule as
|
|
1025
|
+
# macOS's install.sh.
|
|
1026
|
+
Write-Host ''
|
|
1027
|
+
Write-Host 'AgenShield installed.' -ForegroundColor Green
|
|
1028
|
+
Write-Host ' Dashboard: http://localhost:5200'
|
|
1029
|
+
Write-Host ' Sign in any time from the AgenShield tray to link your account.'
|
|
1030
|
+
Write-Host ''
|
|
1031
|
+
} catch {
|
|
1032
|
+
Write-ErrorLine $_.Exception.Message
|
|
1033
|
+
$script:ExitCode = 1
|
|
1034
|
+
} finally {
|
|
1035
|
+
try { Stop-Transcript | Out-Null } catch { }
|
|
1036
|
+
}
|
|
1037
|
+
exit $script:ExitCode
|
package/bin/install.sh
CHANGED
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
# Downloads and installs the AgenShield SEA binaries for the current platform.
|
|
5
5
|
#
|
|
6
6
|
# Mirror set: the version-resolution + download logic here is deliberately
|
|
7
|
-
# kept in sync with libs/cli/src/utils/github-releases.ts (CLI)
|
|
8
|
-
# tools/build/pkg/bootstrap/scripts/postinstall (MDM bootstrap pkg)
|
|
7
|
+
# kept in sync with libs/cli/src/utils/github-releases.ts (CLI),
|
|
8
|
+
# tools/build/pkg/bootstrap/scripts/postinstall (MDM bootstrap pkg), and
|
|
9
|
+
# tools/sea/install.ps1 (the Windows sibling — same environment contract and
|
|
10
|
+
# baked CDN supply-chain guard, CDN-only with no GitHub-Releases fallback).
|
|
9
11
|
#
|
|
10
12
|
# Multi-binary layout:
|
|
11
13
|
# ~/.agenshield/bin/agenshield (CLI — on PATH)
|
|
@@ -620,11 +622,22 @@ TAILEOF
|
|
|
620
622
|
AGENSHIELD_CLI="/usr/local/bin/agenshield"
|
|
621
623
|
command -v "$AGENSHIELD_CLI" >/dev/null 2>&1 || AGENSHIELD_CLI="agenshield"
|
|
622
624
|
# Reattach the controlling terminal as stdin so the CLI's readline +
|
|
623
|
-
# isTTY work even though our own stdin is the curl pipe.
|
|
624
|
-
#
|
|
625
|
-
|
|
625
|
+
# isTTY work even though our own stdin is the curl pipe.
|
|
626
|
+
#
|
|
627
|
+
# The fallback is chosen by CAPABILITY, not by failure. It used to be
|
|
628
|
+
# `activate --post-install || activate || warn`, which re-ran the ENTIRE
|
|
629
|
+
# stepper on any non-zero exit — including Ctrl-C, so interrupting the
|
|
630
|
+
# approvals restarted them and the only way out was to interrupt twice.
|
|
631
|
+
# Worse, the retry dropped --post-install, which is what opens the app
|
|
632
|
+
# and the dashboard once approvals finish: an interrupted install ended
|
|
633
|
+
# with no window at all. Only an older pinned CLI needs the plain form.
|
|
634
|
+
if "$AGENSHIELD_CLI" activate --help 2>/dev/null | grep -q -- '--post-install'; then
|
|
635
|
+
"$AGENSHIELD_CLI" activate --post-install </dev/tty || \
|
|
636
|
+
warn "You can finish approvals any time with: agenshield activate"
|
|
637
|
+
else
|
|
626
638
|
"$AGENSHIELD_CLI" activate </dev/tty || \
|
|
627
|
-
|
|
639
|
+
warn "You can finish approvals any time with: agenshield activate"
|
|
640
|
+
fi
|
|
628
641
|
exit 0
|
|
629
642
|
else
|
|
630
643
|
# Non-interactive (MDM/scripted): use command-line installer
|