napt 0.3.1__py3-none-any.whl
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.
- napt/__init__.py +91 -0
- napt/build/__init__.py +47 -0
- napt/build/manager.py +1087 -0
- napt/build/packager.py +315 -0
- napt/build/template.py +301 -0
- napt/cli.py +602 -0
- napt/config/__init__.py +42 -0
- napt/config/loader.py +465 -0
- napt/core.py +385 -0
- napt/detection.py +630 -0
- napt/discovery/__init__.py +86 -0
- napt/discovery/api_github.py +445 -0
- napt/discovery/api_json.py +452 -0
- napt/discovery/base.py +244 -0
- napt/discovery/url_download.py +304 -0
- napt/discovery/web_scrape.py +467 -0
- napt/exceptions.py +149 -0
- napt/io/__init__.py +42 -0
- napt/io/download.py +357 -0
- napt/io/upload.py +37 -0
- napt/logging.py +230 -0
- napt/policy/__init__.py +50 -0
- napt/policy/updates.py +126 -0
- napt/psadt/__init__.py +43 -0
- napt/psadt/release.py +309 -0
- napt/requirements.py +566 -0
- napt/results.py +143 -0
- napt/state/__init__.py +58 -0
- napt/state/tracker.py +371 -0
- napt/validation.py +467 -0
- napt/versioning/__init__.py +115 -0
- napt/versioning/keys.py +309 -0
- napt/versioning/msi.py +725 -0
- napt-0.3.1.dist-info/METADATA +114 -0
- napt-0.3.1.dist-info/RECORD +38 -0
- napt-0.3.1.dist-info/WHEEL +4 -0
- napt-0.3.1.dist-info/entry_points.txt +3 -0
- napt-0.3.1.dist-info/licenses/LICENSE +202 -0
napt/detection.py
ADDED
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
# Copyright 2025 Roger Cibrian
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Detection script generation for Intune Win32 apps.
|
|
16
|
+
|
|
17
|
+
This module generates PowerShell detection scripts for Intune Win32 app
|
|
18
|
+
deployments. Scripts check Windows uninstall registry keys for installed
|
|
19
|
+
software and version information using CMTrace-formatted logging.
|
|
20
|
+
|
|
21
|
+
Detection Logic:
|
|
22
|
+
- Checks HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall (always)
|
|
23
|
+
- Checks HKCU:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall (always)
|
|
24
|
+
- Checks HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall
|
|
25
|
+
(only on 64-bit OS with 64-bit PowerShell process)
|
|
26
|
+
- Checks HKCU:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall
|
|
27
|
+
(only on 64-bit OS with 64-bit PowerShell process)
|
|
28
|
+
- Matches by DisplayName (using AppName from recipe or MSI ProductName)
|
|
29
|
+
- Compares version (exact or minimum version match based on config)
|
|
30
|
+
- Provides verbose logging with detailed detection results, registry paths,
|
|
31
|
+
installed vs expected versions, and match type information
|
|
32
|
+
|
|
33
|
+
Installer Type Filtering:
|
|
34
|
+
Scripts filter registry entries based on installer type to prevent false
|
|
35
|
+
matches when both MSI and EXE versions of software exist:
|
|
36
|
+
|
|
37
|
+
- MSI installers (strict): Only matches registry entries with
|
|
38
|
+
WindowsInstaller=1. Prevents false matches with EXE versions.
|
|
39
|
+
- Non-MSI installers (permissive): Matches ANY registry entry. Handles
|
|
40
|
+
EXE installers that run embedded MSIs internally.
|
|
41
|
+
|
|
42
|
+
Logging:
|
|
43
|
+
- Primary (System): C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs\\NAPTDetections.log
|
|
44
|
+
- Primary (User): C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs\\NAPTDetectionsUser.log
|
|
45
|
+
- Fallback (System): C:\\ProgramData\\NAPT\\NAPTDetections.log
|
|
46
|
+
- Fallback (User): %LOCALAPPDATA%\\NAPT\\NAPTDetectionsUser.log
|
|
47
|
+
- Log rotation: 2-file rotation (.log and .log.old), configurable max size
|
|
48
|
+
(default: 3MB)
|
|
49
|
+
- Format: CMTrace format for compatibility with Intune diagnostics
|
|
50
|
+
- Features: Write permission testing with automatic fallback to alternate
|
|
51
|
+
locations, verbose component-based logging with dynamic component names
|
|
52
|
+
based on app name and version, detailed detection workflow logging
|
|
53
|
+
|
|
54
|
+
Example:
|
|
55
|
+
Generate detection script:
|
|
56
|
+
```python
|
|
57
|
+
from pathlib import Path
|
|
58
|
+
from napt.detection import DetectionConfig, generate_detection_script
|
|
59
|
+
|
|
60
|
+
config = DetectionConfig(
|
|
61
|
+
app_name="Google Chrome",
|
|
62
|
+
version="131.0.6778.86",
|
|
63
|
+
log_format="cmtrace",
|
|
64
|
+
log_level="INFO",
|
|
65
|
+
)
|
|
66
|
+
script_path = generate_detection_script(
|
|
67
|
+
config=config,
|
|
68
|
+
output_path=Path("builds/chrome/131.0.6778.86/Google-Chrome-131.0.6778.86-Detection.ps1"),
|
|
69
|
+
)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Note:
|
|
73
|
+
Detection scripts are saved as siblings to the packagefiles directory
|
|
74
|
+
to prevent them from being included in the .intunewin package. They
|
|
75
|
+
should be uploaded separately to Intune alongside the package.
|
|
76
|
+
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
from __future__ import annotations
|
|
80
|
+
|
|
81
|
+
from dataclasses import dataclass
|
|
82
|
+
from pathlib import Path
|
|
83
|
+
import re
|
|
84
|
+
import string
|
|
85
|
+
from typing import Literal
|
|
86
|
+
|
|
87
|
+
LogFormat = Literal["cmtrace"]
|
|
88
|
+
LogLevel = Literal["INFO", "WARNING", "ERROR", "DEBUG"]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# Type alias for architecture values
|
|
92
|
+
ArchitectureMode = Literal["x86", "x64", "arm64", "any"]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class DetectionConfig:
|
|
97
|
+
"""Configuration for detection script generation.
|
|
98
|
+
|
|
99
|
+
Attributes:
|
|
100
|
+
app_name: Application name to search for in registry DisplayName.
|
|
101
|
+
version: Expected version string to match.
|
|
102
|
+
log_format: Log format (currently only "cmtrace" supported).
|
|
103
|
+
log_level: Minimum log level (INFO, WARNING, ERROR, DEBUG).
|
|
104
|
+
log_rotation_mb: Maximum log file size in MB before rotation.
|
|
105
|
+
exact_match: If True, version must match exactly. If False, minimum
|
|
106
|
+
version comparison (remote >= expected).
|
|
107
|
+
app_id: Application ID (used for fallback if app_name sanitization
|
|
108
|
+
results in empty string).
|
|
109
|
+
is_msi_installer: If True, only match MSI-based registry entries.
|
|
110
|
+
If False, only match non-MSI entries. This prevents false matches
|
|
111
|
+
when both MSI and EXE versions of software exist with the same
|
|
112
|
+
DisplayName.
|
|
113
|
+
expected_architecture: Architecture filter for registry view selection.
|
|
114
|
+
- "x86": Check only 32-bit registry view
|
|
115
|
+
- "x64": Check only 64-bit registry view
|
|
116
|
+
- "arm64": Check only 64-bit registry view (ARM64 uses 64-bit registry)
|
|
117
|
+
- "any": Check both 32-bit and 64-bit views (permissive)
|
|
118
|
+
use_wildcard: If True, use PowerShell -like operator for DisplayName
|
|
119
|
+
matching (supports * and ? wildcards). If False, use exact -eq match.
|
|
120
|
+
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
app_name: str
|
|
124
|
+
version: str
|
|
125
|
+
log_format: LogFormat = "cmtrace"
|
|
126
|
+
log_level: LogLevel = "INFO"
|
|
127
|
+
log_rotation_mb: int = 3
|
|
128
|
+
exact_match: bool = False
|
|
129
|
+
app_id: str = ""
|
|
130
|
+
is_msi_installer: bool = False
|
|
131
|
+
expected_architecture: ArchitectureMode = "any"
|
|
132
|
+
use_wildcard: bool = False
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def sanitize_filename(name: str, app_id: str = "") -> str:
|
|
136
|
+
"""Sanitize string for use in Windows filename.
|
|
137
|
+
|
|
138
|
+
Rules:
|
|
139
|
+
- Replace spaces with hyphens
|
|
140
|
+
- Remove invalid Windows filename characters (< > : " | ? * \\ /)
|
|
141
|
+
- Normalize multiple consecutive hyphens to single hyphen
|
|
142
|
+
- Remove leading/trailing hyphens and dots
|
|
143
|
+
- If result is empty, fallback to app_id (or "app" if app_id is empty)
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
name: String to sanitize (e.g., "Google Chrome").
|
|
147
|
+
app_id: Fallback identifier if name becomes empty after sanitization.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
Sanitized filename-safe string (e.g., "Google-Chrome").
|
|
151
|
+
|
|
152
|
+
Example:
|
|
153
|
+
Basic sanitization:
|
|
154
|
+
```python
|
|
155
|
+
sanitize_filename("Google Chrome") # Returns: "Google-Chrome"
|
|
156
|
+
sanitize_filename("My App v2.0") # Returns: "My-App-v2.0"
|
|
157
|
+
sanitize_filename("Test<>App") # Returns: "TestApp"
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Fallback behavior:
|
|
161
|
+
```python
|
|
162
|
+
sanitize_filename(" ", "my-app") # Returns: "my-app"
|
|
163
|
+
sanitize_filename("", "test") # Returns: "test"
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
"""
|
|
167
|
+
# Replace spaces with hyphens
|
|
168
|
+
sanitized = name.replace(" ", "-")
|
|
169
|
+
|
|
170
|
+
# Remove invalid Windows filename characters: < > : " | ? * \ /
|
|
171
|
+
invalid_chars = '<>:"|?*\\/'
|
|
172
|
+
for char in invalid_chars:
|
|
173
|
+
sanitized = sanitized.replace(char, "")
|
|
174
|
+
|
|
175
|
+
# Normalize multiple consecutive hyphens to single hyphen
|
|
176
|
+
sanitized = re.sub(r"-+", "-", sanitized)
|
|
177
|
+
|
|
178
|
+
# Remove leading/trailing hyphens and dots
|
|
179
|
+
sanitized = sanitized.strip(".-")
|
|
180
|
+
|
|
181
|
+
# Fallback to app_id if empty
|
|
182
|
+
if not sanitized:
|
|
183
|
+
sanitized = app_id if app_id else "app"
|
|
184
|
+
|
|
185
|
+
return sanitized
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# PowerShell detection script template
|
|
189
|
+
# TODO: Move this template to a separate .ps1 file for syntax highlighting and easier editing
|
|
190
|
+
_DETECTION_SCRIPT_TEMPLATE = """# Detection script for ${app_name} ${version}
|
|
191
|
+
# Generated by NAPT (Not a Package Tool)
|
|
192
|
+
# This script checks Windows uninstall registry keys for software installation.
|
|
193
|
+
# Uses explicit registry views for deterministic architecture-aware detection.
|
|
194
|
+
|
|
195
|
+
param(
|
|
196
|
+
[string]$$AppName = "${app_name}",
|
|
197
|
+
[string]$$ExpectedVersion = "${version}",
|
|
198
|
+
[bool]$$ExactMatch = ${exact_match},
|
|
199
|
+
[bool]$$IsMSIInstaller = ${is_msi_installer},
|
|
200
|
+
[string]$$ExpectedArchitecture = "${expected_architecture}"
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# CMTrace log format function
|
|
204
|
+
function Write-CMTraceLog {
|
|
205
|
+
param(
|
|
206
|
+
[string]$$Message,
|
|
207
|
+
[string]$$Component = $$script:ComponentName,
|
|
208
|
+
[string]$$Type = "INFO" # "INFO", "WARNING", "ERROR", "DEBUG"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
$$LogFile = $$script:LogFilePath
|
|
212
|
+
|
|
213
|
+
if (-not $$LogFile) {
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
# Convert string log level to CMTrace numeric type
|
|
218
|
+
# 1=Info, 2=Warning, 3=Error, 4=Debug
|
|
219
|
+
$$TypeNumber = switch ($$Type.ToUpper()) {
|
|
220
|
+
"INFO" { 1 }
|
|
221
|
+
"WARNING" { 2 }
|
|
222
|
+
"ERROR" { 3 }
|
|
223
|
+
"DEBUG" { 4 }
|
|
224
|
+
default { 1 } # Default to INFO if unknown
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
# Format time: HH:mm:ss.fff-offset (offset in minutes, e.g., -480 for -08:00)
|
|
228
|
+
$$Now = [DateTimeOffset](Get-Date)
|
|
229
|
+
$$TimeFormatted = $$Now.ToString("HH:mm:ss.fff")
|
|
230
|
+
$$OffsetMinutes = [int]$$Now.Offset.TotalMinutes
|
|
231
|
+
$$TimeWithOffset = "$$TimeFormatted$$OffsetMinutes"
|
|
232
|
+
|
|
233
|
+
# Format date: M-d-yyyy (single digit month/day when appropriate)
|
|
234
|
+
$$DateFormatted = $$Now.ToString("M-d-yyyy")
|
|
235
|
+
|
|
236
|
+
# Get context (user identity name) and script file path
|
|
237
|
+
$$ContextName = if ($$script:CurrentIdentity) { $$script:CurrentIdentity.Name } else { "UNKNOWN" }
|
|
238
|
+
$$ScriptFile = if ($$MyInvocation.ScriptName) { $$MyInvocation.ScriptName } else { "detection.ps1" }
|
|
239
|
+
|
|
240
|
+
$$Line = "<![LOG[$$Message]LOG]!><time=""$$TimeWithOffset"" date=""$$DateFormatted"" component=""$$Component"" context=""$$ContextName"" type=""$$TypeNumber"" thread=""$$PID"" file=""$$ScriptFile"">"
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
Add-Content -Path $$LogFile -Value $$Line -Encoding UTF8 -ErrorAction SilentlyContinue
|
|
244
|
+
} catch {
|
|
245
|
+
# Silently fail if we can't write to log
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
# Determine log file location
|
|
250
|
+
function Initialize-LogFile {
|
|
251
|
+
$$script:CurrentIdentity = [System.Security.Principal.WindowsIdentity]::GetCurrent()
|
|
252
|
+
$$IsSystemContext = $$script:CurrentIdentity.Name -eq "NT AUTHORITY\\SYSTEM"
|
|
253
|
+
|
|
254
|
+
if ($$IsSystemContext) {
|
|
255
|
+
# System context - try Intune log folder first
|
|
256
|
+
$$PrimaryLogDir = "C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs"
|
|
257
|
+
$$PrimaryLogFile = Join-Path $$PrimaryLogDir "NAPTDetections.log"
|
|
258
|
+
$$FallbackLogDir = "C:\\ProgramData\\NAPT"
|
|
259
|
+
$$FallbackLogFile = Join-Path $$FallbackLogDir "NAPTDetections.log"
|
|
260
|
+
} else {
|
|
261
|
+
# User context
|
|
262
|
+
$$PrimaryLogDir = "C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs"
|
|
263
|
+
$$PrimaryLogFile = Join-Path $$PrimaryLogDir "NAPTDetectionsUser.log"
|
|
264
|
+
$$FallbackLogDir = $$env:LOCALAPPDATA
|
|
265
|
+
$$FallbackLogFile = Join-Path $$FallbackLogDir "NAPT\\NAPTDetectionsUser.log"
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
# Try primary location first
|
|
269
|
+
try {
|
|
270
|
+
# Ensure parent directory exists (fails with -ErrorAction Stop if no perms)
|
|
271
|
+
$$PrimaryLogParent = Split-Path -Path $$PrimaryLogFile -Parent
|
|
272
|
+
if (-not (Test-Path -Path $$PrimaryLogParent)) {
|
|
273
|
+
New-Item -Path $$PrimaryLogParent -ItemType Directory -Force -ErrorAction Stop | Out-Null
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
# Handle log rotation if needed
|
|
277
|
+
if (Test-Path -Path $$PrimaryLogFile) {
|
|
278
|
+
$$LogSize = (Get-Item $$PrimaryLogFile).Length
|
|
279
|
+
$$MaxSize = ${log_rotation_mb} * 1024 * 1024
|
|
280
|
+
if ($$LogSize -ge $$MaxSize) {
|
|
281
|
+
$$OldLogFile = "$$PrimaryLogFile.old"
|
|
282
|
+
if (Test-Path $$OldLogFile) { Remove-Item $$OldLogFile -Force -ErrorAction Stop }
|
|
283
|
+
Move-Item -Path $$PrimaryLogFile -Destination $$OldLogFile -Force -ErrorAction Stop
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
# Verify write access (appends empty string - fails if no write permission)
|
|
288
|
+
[System.IO.File]::AppendAllText($$PrimaryLogFile, "")
|
|
289
|
+
$$script:LogFilePath = $$PrimaryLogFile
|
|
290
|
+
return
|
|
291
|
+
} catch {
|
|
292
|
+
# Fall through to fallback (directory creation, rotation, or write failed)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
# Fallback location
|
|
296
|
+
try {
|
|
297
|
+
$$FallbackLogParent = Split-Path -Path $$FallbackLogFile -Parent
|
|
298
|
+
if (-not (Test-Path -Path $$FallbackLogParent)) {
|
|
299
|
+
New-Item -Path $$FallbackLogParent -ItemType Directory -Force -ErrorAction Stop | Out-Null
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (Test-Path -Path $$FallbackLogFile) {
|
|
303
|
+
$$LogSize = (Get-Item $$FallbackLogFile).Length
|
|
304
|
+
$$MaxSize = ${log_rotation_mb} * 1024 * 1024
|
|
305
|
+
if ($$LogSize -ge $$MaxSize) {
|
|
306
|
+
$$OldLogFile = "$$FallbackLogFile.old"
|
|
307
|
+
if (Test-Path $$OldLogFile) { Remove-Item $$OldLogFile -Force -ErrorAction Stop }
|
|
308
|
+
Move-Item -Path $$FallbackLogFile -Destination $$OldLogFile -Force -ErrorAction Stop
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
[System.IO.File]::AppendAllText($$FallbackLogFile, "")
|
|
313
|
+
$$script:LogFilePath = $$FallbackLogFile
|
|
314
|
+
} catch {
|
|
315
|
+
# All log locations failed - log warning to stderr and continue
|
|
316
|
+
Write-Warning "NAPT Detection: Failed to initialize logging (primary and fallback locations unavailable). Detection will continue but no log file will be created."
|
|
317
|
+
$$script:LogFilePath = $$null
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
# Check if registry entry is MSI-based installation
|
|
322
|
+
function Test-IsMSIInstallation {
|
|
323
|
+
param(
|
|
324
|
+
[Microsoft.Win32.RegistryKey]$$RegKey
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
# Check WindowsInstaller DWORD value - set automatically by Windows Installer
|
|
328
|
+
# for all MSI installations. This is the authoritative indicator.
|
|
329
|
+
try {
|
|
330
|
+
$$WindowsInstaller = $$RegKey.GetValue("WindowsInstaller")
|
|
331
|
+
return ($$WindowsInstaller -eq 1)
|
|
332
|
+
} catch {
|
|
333
|
+
return $$false
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
# Get registry keys using explicit RegistryView for deterministic behavior
|
|
338
|
+
# This works regardless of PowerShell process bitness
|
|
339
|
+
function Get-UninstallKeys {
|
|
340
|
+
param(
|
|
341
|
+
[Microsoft.Win32.RegistryHive]$$Hive,
|
|
342
|
+
[Microsoft.Win32.RegistryView]$$View,
|
|
343
|
+
[string]$$ViewName
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
$$Results = @()
|
|
347
|
+
$$UninstallPath = "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall"
|
|
348
|
+
|
|
349
|
+
try {
|
|
350
|
+
$$BaseKey = [Microsoft.Win32.RegistryKey]::OpenBaseKey($$Hive, $$View)
|
|
351
|
+
$$UninstallKey = $$BaseKey.OpenSubKey($$UninstallPath)
|
|
352
|
+
|
|
353
|
+
if ($$UninstallKey) {
|
|
354
|
+
foreach ($$SubKeyName in $$UninstallKey.GetSubKeyNames()) {
|
|
355
|
+
try {
|
|
356
|
+
$$SubKey = $$UninstallKey.OpenSubKey($$SubKeyName)
|
|
357
|
+
if ($$SubKey) {
|
|
358
|
+
$$Results += @{
|
|
359
|
+
Key = $$SubKey
|
|
360
|
+
Hive = $$Hive
|
|
361
|
+
View = $$ViewName
|
|
362
|
+
Path = "$$($$(if ($$Hive -eq [Microsoft.Win32.RegistryHive]::LocalMachine) { 'HKLM' } else { 'HKCU' })):\\$$UninstallPath\\$$SubKeyName"
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
} catch {
|
|
366
|
+
# Skip keys we can't open
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
# Note: Don't close BaseKey/UninstallKey here as SubKeys are still in use
|
|
372
|
+
} catch {
|
|
373
|
+
Write-CMTraceLog -Message "[Detection] Error opening registry: $$Hive $$ViewName - $$($$_.Exception.Message)" -Type "WARNING"
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return $$Results
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
# Version comparison function
|
|
380
|
+
function Compare-Version {
|
|
381
|
+
param(
|
|
382
|
+
[string]$$InstalledVersion,
|
|
383
|
+
[string]$$ExpectedVersion,
|
|
384
|
+
[bool]$$ExactMatch
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
if ($$ExactMatch) {
|
|
388
|
+
return $$InstalledVersion -eq $$ExpectedVersion
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
# Minimum version comparison (installed >= expected)
|
|
392
|
+
$$InstalledParts = $$InstalledVersion -split '[.\\-]' | ForEach-Object { [int]$$_ }
|
|
393
|
+
$$ExpectedParts = $$ExpectedVersion -split '[.\\-]' | ForEach-Object { [int]$$_ }
|
|
394
|
+
|
|
395
|
+
$$MaxLength = [Math]::Max($$InstalledParts.Count, $$ExpectedParts.Count)
|
|
396
|
+
|
|
397
|
+
for ($$i = 0; $$i -lt $$MaxLength; $$i++) {
|
|
398
|
+
$$InstalledPart = if ($$i -lt $$InstalledParts.Count) { $$InstalledParts[$$i] } else { 0 }
|
|
399
|
+
$$ExpectedPart = if ($$i -lt $$ExpectedParts.Count) { $$ExpectedParts[$$i] } else { 0 }
|
|
400
|
+
|
|
401
|
+
if ($$InstalledPart -gt $$ExpectedPart) {
|
|
402
|
+
return $$true
|
|
403
|
+
}
|
|
404
|
+
if ($$InstalledPart -lt $$ExpectedPart) {
|
|
405
|
+
return $$false
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return $$true # Versions are equal
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
# Main detection logic
|
|
413
|
+
Initialize-LogFile
|
|
414
|
+
|
|
415
|
+
# Build component identifier from app name and version (sanitized for valid identifier)
|
|
416
|
+
$$SanitizedAppName = $$AppName -replace '[^a-zA-Z0-9]', '-' -replace '-+', '-' -replace '^-|-$', ''
|
|
417
|
+
$$script:ComponentName = "$$SanitizedAppName-$$ExpectedVersion-Detection"
|
|
418
|
+
|
|
419
|
+
Write-CMTraceLog -Message "[Initialization] Detection script running as user: $$($$script:CurrentIdentity.Name)" -Type "INFO"
|
|
420
|
+
Write-CMTraceLog -Message "[Initialization] Starting detection check for: $$AppName (Expected: $$ExpectedVersion, Mode: $$(if ($$ExactMatch) { 'Exact Match' } else { 'Minimum Version' }), Installer Type: $$(if ($$IsMSIInstaller) { 'MSI' } else { 'Non-MSI' }), Architecture: $$ExpectedArchitecture)" -Type "INFO"
|
|
421
|
+
|
|
422
|
+
# Detect OS architecture
|
|
423
|
+
$$Is64BitOS = [Environment]::Is64BitOperatingSystem
|
|
424
|
+
|
|
425
|
+
Write-CMTraceLog -Message "[Initialization] OS Architecture: $$(if ($$Is64BitOS) { '64-bit' } else { '32-bit' })" -Type "INFO"
|
|
426
|
+
Write-CMTraceLog -Message "[Initialization] PowerShell Process: $$(if ([Environment]::Is64BitProcess) { '64-bit' } else { '32-bit' })" -Type "INFO"
|
|
427
|
+
|
|
428
|
+
# Build list of registry keys to check based on expected architecture
|
|
429
|
+
# Uses OpenBaseKey with explicit RegistryView for deterministic behavior
|
|
430
|
+
$$AllKeys = @()
|
|
431
|
+
|
|
432
|
+
# Determine which registry views to check based on architecture
|
|
433
|
+
$$CheckViews = @()
|
|
434
|
+
|
|
435
|
+
switch ($$ExpectedArchitecture.ToLower()) {
|
|
436
|
+
"x64" {
|
|
437
|
+
if (-not $$Is64BitOS) {
|
|
438
|
+
Write-CMTraceLog -Message "[Detection] Expected x64 architecture but running on 32-bit OS - app cannot be installed" -Type "WARNING"
|
|
439
|
+
} else {
|
|
440
|
+
$$CheckViews += @{ View = [Microsoft.Win32.RegistryView]::Registry64; Name = "64-bit" }
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
"arm64" {
|
|
444
|
+
# ARM64 uses 64-bit registry view
|
|
445
|
+
if (-not $$Is64BitOS) {
|
|
446
|
+
Write-CMTraceLog -Message "[Detection] Expected arm64 architecture but running on 32-bit OS - app cannot be installed" -Type "WARNING"
|
|
447
|
+
} else {
|
|
448
|
+
$$CheckViews += @{ View = [Microsoft.Win32.RegistryView]::Registry64; Name = "64-bit (ARM64)" }
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
"x86" {
|
|
452
|
+
$$CheckViews += @{ View = [Microsoft.Win32.RegistryView]::Registry32; Name = "32-bit" }
|
|
453
|
+
}
|
|
454
|
+
"any" {
|
|
455
|
+
# Check both views (permissive mode)
|
|
456
|
+
if ($$Is64BitOS) {
|
|
457
|
+
$$CheckViews += @{ View = [Microsoft.Win32.RegistryView]::Registry64; Name = "64-bit" }
|
|
458
|
+
}
|
|
459
|
+
$$CheckViews += @{ View = [Microsoft.Win32.RegistryView]::Registry32; Name = "32-bit" }
|
|
460
|
+
}
|
|
461
|
+
default {
|
|
462
|
+
Write-CMTraceLog -Message "[Detection] Unknown architecture '$$ExpectedArchitecture', defaulting to 'any'" -Type "WARNING"
|
|
463
|
+
if ($$Is64BitOS) {
|
|
464
|
+
$$CheckViews += @{ View = [Microsoft.Win32.RegistryView]::Registry64; Name = "64-bit" }
|
|
465
|
+
}
|
|
466
|
+
$$CheckViews += @{ View = [Microsoft.Win32.RegistryView]::Registry32; Name = "32-bit" }
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
# Build literal registry paths for logging (matches actual paths opened by OpenBaseKey per view)
|
|
471
|
+
$$RegPathDescriptions = @()
|
|
472
|
+
foreach ($$ViewInfo in $$CheckViews) {
|
|
473
|
+
if ($$ViewInfo.Name -like '64-bit*') {
|
|
474
|
+
$$RegPathDescriptions += "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall"
|
|
475
|
+
$$RegPathDescriptions += "HKCU:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall"
|
|
476
|
+
} else {
|
|
477
|
+
if ($$Is64BitOS) {
|
|
478
|
+
$$RegPathDescriptions += "HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall"
|
|
479
|
+
$$RegPathDescriptions += "HKCU:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall"
|
|
480
|
+
} else {
|
|
481
|
+
$$RegPathDescriptions += "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall"
|
|
482
|
+
$$RegPathDescriptions += "HKCU:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall"
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
Write-CMTraceLog -Message "[Initialization] Registry paths to check: $$($$RegPathDescriptions -join ', ')" -Type "INFO"
|
|
487
|
+
|
|
488
|
+
# Collect all registry keys from selected views
|
|
489
|
+
foreach ($$ViewInfo in $$CheckViews) {
|
|
490
|
+
# HKLM (machine-level installations)
|
|
491
|
+
$$AllKeys += Get-UninstallKeys -Hive ([Microsoft.Win32.RegistryHive]::LocalMachine) -View $$ViewInfo.View -ViewName $$ViewInfo.Name
|
|
492
|
+
# HKCU (user-level installations)
|
|
493
|
+
$$AllKeys += Get-UninstallKeys -Hive ([Microsoft.Win32.RegistryHive]::CurrentUser) -View $$ViewInfo.View -ViewName $$ViewInfo.Name
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
Write-CMTraceLog -Message "[Initialization] Found $$($$AllKeys.Count) registry keys to check" -Type "INFO"
|
|
497
|
+
|
|
498
|
+
$$Found = $$false
|
|
499
|
+
$$InstalledVersion = $$null
|
|
500
|
+
$$DisplayName = $$null
|
|
501
|
+
|
|
502
|
+
# Check all registry keys to find matching DisplayName and version
|
|
503
|
+
foreach ($$KeyInfo in $$AllKeys) {
|
|
504
|
+
try {
|
|
505
|
+
$$Key = $$KeyInfo.Key
|
|
506
|
+
$$DisplayNameValue = $$Key.GetValue("DisplayName")
|
|
507
|
+
$$VersionValue = $$Key.GetValue("DisplayVersion")
|
|
508
|
+
|
|
509
|
+
if ($$DisplayNameValue ${display_name_operator} $$AppName) {
|
|
510
|
+
$$RegKeyPath = $$KeyInfo.Path
|
|
511
|
+
|
|
512
|
+
# Check if installer type matches (MSI vs non-MSI)
|
|
513
|
+
# MSI installers: strict matching - only accept MSI registry entries
|
|
514
|
+
# Non-MSI installers: permissive - accept any entry (EXEs may use embedded MSIs)
|
|
515
|
+
$$IsMSIEntry = Test-IsMSIInstallation -RegKey $$Key
|
|
516
|
+
if ($$IsMSIInstaller -and -not $$IsMSIEntry) {
|
|
517
|
+
# Building from MSI, but registry entry is not MSI - skip
|
|
518
|
+
Write-CMTraceLog -Message "[Detection] Type mismatch: $$DisplayNameValue (Found: Non-MSI, Expected: MSI, Path: $$RegKeyPath)" -Type "INFO"
|
|
519
|
+
continue
|
|
520
|
+
}
|
|
521
|
+
# Note: Non-MSI installers accept ANY registry entry (MSI or non-MSI)
|
|
522
|
+
# because EXE installers often wrap embedded MSIs that set WindowsInstaller=1
|
|
523
|
+
|
|
524
|
+
$$DisplayName = $$DisplayNameValue
|
|
525
|
+
$$InstalledVersion = $$VersionValue
|
|
526
|
+
|
|
527
|
+
Write-CMTraceLog -Message "[Detection] Match found: $$DisplayName (Found: $$(if ($$InstalledVersion) { $$InstalledVersion } else { 'None' }), Type: $$(if ($$IsMSIEntry) { 'MSI' } else { 'Non-MSI' }), Arch: $$($KeyInfo.View), Path: $$RegKeyPath)" -Type "INFO"
|
|
528
|
+
|
|
529
|
+
if ($$InstalledVersion) {
|
|
530
|
+
if (Compare-Version -InstalledVersion $$InstalledVersion -ExpectedVersion $$ExpectedVersion -ExactMatch $$ExactMatch) {
|
|
531
|
+
Write-CMTraceLog -Message "[Detection] Version check passed: $$InstalledVersion $$(if ($$ExactMatch) { '=' } else { '>=' }) $$ExpectedVersion" -Type "INFO"
|
|
532
|
+
$$Found = $$true
|
|
533
|
+
break
|
|
534
|
+
} else {
|
|
535
|
+
Write-CMTraceLog -Message "[Detection] Version check not met: $$InstalledVersion $$(if ($$ExactMatch) { '!=' } else { '<' }) $$ExpectedVersion" -Type "INFO"
|
|
536
|
+
}
|
|
537
|
+
} else {
|
|
538
|
+
Write-CMTraceLog -Message "[Detection] No version found: $$DisplayName (Path: $$RegKeyPath)" -Type "WARNING"
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
} catch {
|
|
542
|
+
Write-CMTraceLog -Message "[Detection] Error checking registry path $$($KeyInfo.Path) : $$($$_.Exception.Message)" -Type "ERROR"
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if ($$Found) {
|
|
547
|
+
Write-CMTraceLog -Message "[Result] Detected: $$AppName (Found: $$InstalledVersion, Expected: $$(if ($$ExactMatch) { '=' } else { '>=' }) $$ExpectedVersion, Type: $$(if ($$IsMSIInstaller) { 'MSI' } else { 'Non-MSI' }), Arch: $$ExpectedArchitecture, Mode: $$(if ($$ExactMatch) { 'Exact' } else { 'Minimum' }))" -Type "INFO"
|
|
548
|
+
Write-Output "Installed"
|
|
549
|
+
exit 0
|
|
550
|
+
} else {
|
|
551
|
+
$$FoundVersion = if ($$InstalledVersion) { $$InstalledVersion } else { "None" }
|
|
552
|
+
Write-CMTraceLog -Message "[Result] Not Detected: $$AppName (Found: $$FoundVersion, Expected: $$(if ($$ExactMatch) { '=' } else { '>=' }) $$ExpectedVersion, Type: $$(if ($$IsMSIInstaller) { 'MSI' } else { 'Non-MSI' }), Arch: $$ExpectedArchitecture, Mode: $$(if ($$ExactMatch) { 'Exact' } else { 'Minimum' }))" -Type "WARNING"
|
|
553
|
+
exit 1
|
|
554
|
+
}
|
|
555
|
+
"""
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def generate_detection_script(config: DetectionConfig, output_path: Path) -> Path:
|
|
559
|
+
"""Generate PowerShell detection script for Intune Win32 app.
|
|
560
|
+
|
|
561
|
+
Creates a PowerShell script that checks Windows uninstall registry keys
|
|
562
|
+
for software installation and version. The script uses CMTrace-formatted
|
|
563
|
+
logging with verbose output, includes log rotation logic, and performs
|
|
564
|
+
write permission testing with automatic fallback to alternate log locations
|
|
565
|
+
if primary locations are unavailable.
|
|
566
|
+
|
|
567
|
+
Args:
|
|
568
|
+
config: Detection configuration (app name, version, logging settings).
|
|
569
|
+
output_path: Path where the detection script will be saved.
|
|
570
|
+
|
|
571
|
+
Returns:
|
|
572
|
+
Path to the generated detection script.
|
|
573
|
+
|
|
574
|
+
Raises:
|
|
575
|
+
OSError: If the script file cannot be written.
|
|
576
|
+
|
|
577
|
+
Example:
|
|
578
|
+
Generate script with default settings:
|
|
579
|
+
```python
|
|
580
|
+
from pathlib import Path
|
|
581
|
+
from napt.detection import DetectionConfig, generate_detection_script
|
|
582
|
+
|
|
583
|
+
config = DetectionConfig(
|
|
584
|
+
app_name="Google Chrome",
|
|
585
|
+
version="131.0.6778.86",
|
|
586
|
+
)
|
|
587
|
+
script_path = generate_detection_script(
|
|
588
|
+
config,
|
|
589
|
+
Path("detection.ps1")
|
|
590
|
+
)
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
Note:
|
|
594
|
+
The script is saved with UTF-8 BOM encoding for proper PowerShell
|
|
595
|
+
execution on Windows systems.
|
|
596
|
+
|
|
597
|
+
"""
|
|
598
|
+
from napt.logging import get_global_logger
|
|
599
|
+
|
|
600
|
+
logger = get_global_logger()
|
|
601
|
+
|
|
602
|
+
logger.verbose("DETECTION", f"Generating detection script: {output_path.name}")
|
|
603
|
+
|
|
604
|
+
# Generate script content from template
|
|
605
|
+
# Use safe_substitute() so PowerShell variables ($$Variable) are preserved
|
|
606
|
+
# as $Variable without raising KeyError for missing placeholders
|
|
607
|
+
script_content = string.Template(_DETECTION_SCRIPT_TEMPLATE).safe_substitute(
|
|
608
|
+
app_name=config.app_name,
|
|
609
|
+
version=config.version,
|
|
610
|
+
exact_match="$True" if config.exact_match else "$False",
|
|
611
|
+
log_rotation_mb=config.log_rotation_mb,
|
|
612
|
+
is_msi_installer="$True" if config.is_msi_installer else "$False",
|
|
613
|
+
expected_architecture=config.expected_architecture,
|
|
614
|
+
display_name_operator="-like" if config.use_wildcard else "-eq",
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
# Ensure output directory exists
|
|
618
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
619
|
+
|
|
620
|
+
# Write script with UTF-8 BOM encoding (required for PowerShell)
|
|
621
|
+
try:
|
|
622
|
+
script_bytes = script_content.encode("utf-8-sig")
|
|
623
|
+
output_path.write_bytes(script_bytes)
|
|
624
|
+
logger.verbose("DETECTION", f"Detection script written to: {output_path}")
|
|
625
|
+
except OSError as err:
|
|
626
|
+
raise OSError(
|
|
627
|
+
f"Failed to write detection script to {output_path}: {err}"
|
|
628
|
+
) from err
|
|
629
|
+
|
|
630
|
+
return output_path
|