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/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