diff --git a/oem-lenovo/lenovo-baseline.ps1 b/oem-lenovo/lenovo-baseline.ps1 new file mode 100644 index 0000000..86b9984 --- /dev/null +++ b/oem-lenovo/lenovo-baseline.ps1 @@ -0,0 +1,200 @@ +## PLEASE COMMENT YOUR VARIABLES DIRECTLY BELOW HERE IF YOU'RE RUNNING FROM A RMM +## NinjaRMM passes script preset variables as environment variables, so each is read via $env: in this script. +## $env:RMM - Set to "1" by NinjaRMM to indicate RMM (non-interactive) mode +## $env:Description - Ticket # or initials for audit trail +## $env:RMMScriptPath - Optional log directory base provided by the RMM +## +## Per-script variables: +## $env:RunLSUInstall - "1" to ensure Lenovo System Update is installed (default: "1") +## $env:RunLSURun - "1" to invoke LSU scan + install (default: "1") +## $env:RunLenovoBiosConfig - "1" to push BIOS config via WMI (default: "0") +## $env:RunLenovoDebloat - "1" to remove Lenovo consumer software (default: "1") +## $env:CustomFieldLenovoStatus - Ninja text custom field for last-run status (default: "lenovoBaselineStatus") +## $env:LenovoVantageCommercialAvailable - "1" if DTC has Lenovo Partner Hub enrollment and prefers +## Commercial Vantage over LSU. Currently a stub: the run script +## logs and falls back to LSU until the Commercial Vantage path is built. + +$ScriptLogName = "lenovo-baseline.log" + +# --- Default optional RMM environment variables -------------------------- + +if ([string]::IsNullOrEmpty($env:RunLSUInstall)) { $env:RunLSUInstall = "1" } +if ([string]::IsNullOrEmpty($env:RunLSURun)) { $env:RunLSURun = "1" } +if ([string]::IsNullOrEmpty($env:RunLenovoBiosConfig)) { $env:RunLenovoBiosConfig = "0" } +if ([string]::IsNullOrEmpty($env:RunLenovoDebloat)) { $env:RunLenovoDebloat = "1" } +if ([string]::IsNullOrEmpty($env:CustomFieldLenovoStatus)) { $env:CustomFieldLenovoStatus = "lenovoBaselineStatus" } + +# === LIB BOOTSTRAP === +# Libs are pinned to a commit SHA in the jsDelivr URL. jsDelivr serves +# immutable content per SHA, so the URL itself is the integrity check. +# TODO: bump SHA when oem-shared/lib or oem-lenovo/lib changes. +$libBaseUrl = "https://cdn.jsdelivr.net/gh/dtc-inc/msp-script-library@5b1b16c4b19816343145138941c3c5fa51a095e8" +$libs = @( + "$libBaseUrl/oem-shared/lib/oem-manufacturer-detect.ps1", + "$libBaseUrl/oem-lenovo/lib/lenovo-detection.ps1" +) +foreach ($url in $libs) { + $libPath = Join-Path $env:TEMP ([System.IO.Path]::GetFileName($url)) + Invoke-WebRequest -Uri $url -OutFile $libPath -UseBasicParsing -ErrorAction Stop + . $libPath +} + +# --- Input handling: RMM vs interactive ---------------------------------- + +if ($env:RMM -ne "1") { + $ValidInput = 0 + while ($ValidInput -ne 1) { + $env:Description = Read-Host "Please enter the ticket # and/or your initials for audit trail" + if ($env:Description) { + $ValidInput = 1 + } else { + Write-Host "Invalid input. Please try again." + } + } + $LogPath = "$env:WINDIR\logs\$ScriptLogName" +} else { + if (-not [string]::IsNullOrEmpty($env:RMMScriptPath)) { + $LogPath = "$env:RMMScriptPath\logs\$ScriptLogName" + } else { + $LogPath = "$env:WINDIR\logs\$ScriptLogName" + } + if ([string]::IsNullOrEmpty($env:Description)) { + Write-Host "Description is null. This was most likely run automatically from the RMM and no information was passed." + $env:Description = "Lenovo Baseline" + } +} + +$logDir = Split-Path -Path $LogPath -Parent +if (-not (Test-Path -Path $logDir)) { + New-Item -Path $logDir -ItemType Directory -Force | Out-Null +} + +# --- Script logic -------------------------------------------------------- + +$TranscriptStarted = $false +try { + Start-Transcript -Path $LogPath -ErrorAction Stop + $TranscriptStarted = $true +} catch { + Write-Host "Warning: Could not start transcript logging to $LogPath - $($_.Exception.Message)" +} + +Write-Host "Description: $env:Description" +Write-Host "Log path: $LogPath" +Write-Host "RMM: $env:RMM" + +# Ninja Custom Fields are written via Ninja-Property-Set when the agent CLI is present. +function Set-NinjaStatus { + param([string] $Value) + $cli = "$env:ProgramData\NinjaRMMAgent\ninjarmm-cli.exe" + if (Test-Path -Path $cli) { + try { + & $cli set $env:CustomFieldLenovoStatus $Value | Out-Null + } catch { + Write-Host "Warning: failed to set Ninja custom field '$($env:CustomFieldLenovoStatus)': $_" + } + } else { + Write-Host "Ninja CLI not present; status would be: $Value" + } +} + +$manufacturer = Get-OEMManufacturer +Write-Host "Manufacturer: $manufacturer" + +if ($manufacturer -ne "Lenovo") { + Write-Host "Not a Lenovo endpoint. Exiting." + Set-NinjaStatus "Skipped: not Lenovo" + if ($TranscriptStarted) { Stop-Transcript } + exit 0 +} + +$lenovoModel = Get-LenovoInstalledModel +$lenovoMachineType = Get-LenovoMachineType +Write-Host "Lenovo model: $lenovoModel (machine type: $lenovoMachineType)" + +# TODO: Commercial Vantage opt-in path. When $env:LenovoVantageCommercialAvailable = "1" AND +# DTC enrolls in Lenovo Partner Hub, swap the leaf invocation here to a Commercial Vantage +# install/run pipeline instead of LSU. Until then we fall back to LSU and log the intent. +if ($env:LenovoVantageCommercialAvailable -eq "1") { + Write-Host "LenovoVantageCommercialAvailable=1: Commercial Vantage path not yet implemented; falling back to LSU" +} + +# Fetch + invoke a leaf script from jsDelivr at the same commit SHA the libs came from. +function Invoke-LenovoLeaf { + param([Parameter(Mandatory)] [string] $LeafName) + $url = "$libBaseUrl/oem-lenovo/$LeafName" + $local = Join-Path $env:TEMP $LeafName + Invoke-WebRequest -Uri $url -OutFile $local -UseBasicParsing -ErrorAction Stop + & powershell.exe -ExecutionPolicy Bypass -NoProfile -File $local + return $LASTEXITCODE +} + +$leafResults = @{} +$overallStatus = "OK" + +if ($env:RunLSUInstall -eq "1") { + Write-Host "`n=== lenovo-system-update-install ===" + try { + $rc = Invoke-LenovoLeaf -LeafName "lenovo-system-update-install.ps1" + $leafResults["lsuInstall"] = $rc + if ($rc -ne 0) { $overallStatus = "LSUInstallFailed" } + } catch { + Write-Host "lenovo-system-update-install threw: $_" + $leafResults["lsuInstall"] = -1 + $overallStatus = "LSUInstallFailed" + } +} + +if ($env:RunLSURun -eq "1" -and $overallStatus -eq "OK") { + Write-Host "`n=== lenovo-system-update-run ===" + try { + $rc = Invoke-LenovoLeaf -LeafName "lenovo-system-update-run.ps1" + $leafResults["lsuRun"] = $rc + # TODO: Tvsu.exe exit codes are not well documented. Once observed on real + # endpoints, expand the success/reboot-required mapping. For now: 0 = success. + if ($rc -ne 0) { $overallStatus = "LSURunFailed" } + } catch { + Write-Host "lenovo-system-update-run threw: $_" + $leafResults["lsuRun"] = -1 + $overallStatus = "LSURunFailed" + } +} + +if ($env:RunLenovoBiosConfig -eq "1") { + Write-Host "`n=== lenovo-bios-config ===" + try { + $rc = Invoke-LenovoLeaf -LeafName "lenovo-bios-config.ps1" + $leafResults["biosConfig"] = $rc + if ($rc -ne 0 -and $overallStatus -eq "OK") { $overallStatus = "BiosConfigFailed" } + } catch { + Write-Host "lenovo-bios-config threw: $_" + $leafResults["biosConfig"] = -1 + if ($overallStatus -eq "OK") { $overallStatus = "BiosConfigFailed" } + } +} + +if ($env:RunLenovoDebloat -eq "1") { + Write-Host "`n=== lenovo-debloat ===" + try { + $rc = Invoke-LenovoLeaf -LeafName "lenovo-debloat.ps1" + $leafResults["debloat"] = $rc + if ($rc -ne 0 -and $overallStatus -eq "OK") { $overallStatus = "DebloatFailed" } + } catch { + Write-Host "lenovo-debloat threw: $_" + $leafResults["debloat"] = -1 + if ($overallStatus -eq "OK") { $overallStatus = "DebloatFailed" } + } +} + +Write-Host "`n=== Summary ===" +foreach ($k in $leafResults.Keys) { + Write-Host (" {0,-12} exit={1}" -f $k, $leafResults[$k]) +} +Write-Host "Overall: $overallStatus" + +$statusLine = "{0} | {1} | {2}" -f $overallStatus, $lenovoModel, (Get-Date -Format "yyyy-MM-dd HH:mm") +Set-NinjaStatus $statusLine + +if ($TranscriptStarted) { Stop-Transcript } + +if ($overallStatus -eq "OK") { exit 0 } else { exit 1 } diff --git a/oem-lenovo/lenovo-bios-config.ps1 b/oem-lenovo/lenovo-bios-config.ps1 new file mode 100644 index 0000000..642c6b3 --- /dev/null +++ b/oem-lenovo/lenovo-bios-config.ps1 @@ -0,0 +1,175 @@ +## PLEASE COMMENT YOUR VARIABLES DIRECTLY BELOW HERE IF YOU'RE RUNNING FROM A RMM +## NinjaRMM passes script preset variables as environment variables, so each is read via $env: in this script. +## $env:RMM - Set to "1" by NinjaRMM to indicate RMM (non-interactive) mode +## $env:Description - Ticket # or initials for audit trail +## $env:RMMScriptPath - Optional log directory base provided by the RMM +## +## Per-script variables: +## $env:LenovoBiosPolicyURL - jsDelivr URL of the policy file to apply +## (default: dtc-baseline.lenovobios in this repo at pinned SHA) +## $env:LenovoBiosPolicySha256 - SHA256 of the policy file (TODO: capture and pin) +## $env:LenovoBiosSupervisorPassword - Optional current BIOS supervisor password. +## Appended as ",,ascii,us" to the SetBiosSetting / SaveBiosSettings +## tuple when set. Required on machines with a supervisor password. + +$ScriptLogName = "lenovo-bios-config.log" + +# --- Default optional RMM environment variables -------------------------- + +# === LIB BOOTSTRAP === +# Libs are pinned to a commit SHA in the jsDelivr URL. jsDelivr serves +# immutable content per SHA, so the URL itself is the integrity check. +# TODO: bump SHA when oem-shared/lib or oem-lenovo/lib changes. +$libBaseUrl = "https://cdn.jsdelivr.net/gh/dtc-inc/msp-script-library@5b1b16c4b19816343145138941c3c5fa51a095e8" +$libs = @( + "$libBaseUrl/oem-shared/lib/oem-manufacturer-detect.ps1", + "$libBaseUrl/oem-lenovo/lib/lenovo-detection.ps1" +) +foreach ($url in $libs) { + $libPath = Join-Path $env:TEMP ([System.IO.Path]::GetFileName($url)) + Invoke-WebRequest -Uri $url -OutFile $libPath -UseBasicParsing -ErrorAction Stop + . $libPath +} + +if ([string]::IsNullOrEmpty($env:LenovoBiosPolicyURL)) { + $env:LenovoBiosPolicyURL = "$libBaseUrl/oem-lenovo/policy/dtc-baseline.lenovobios" +} +# TODO: Capture SHA256 of dtc-baseline.lenovobios once the policy file is authored and +# committed at the URL above. The policy file is NOT included in this PR. +if ([string]::IsNullOrEmpty($env:LenovoBiosPolicySha256)) { + $env:LenovoBiosPolicySha256 = "REPLACE_WITH_REAL_HASH" +} + +# --- Input handling: RMM vs interactive ---------------------------------- + +if ($env:RMM -ne "1") { + $ValidInput = 0 + while ($ValidInput -ne 1) { + $env:Description = Read-Host "Please enter the ticket # and/or your initials for audit trail" + if ($env:Description) { + $ValidInput = 1 + } else { + Write-Host "Invalid input. Please try again." + } + } + $LogPath = "$env:WINDIR\logs\$ScriptLogName" +} else { + if (-not [string]::IsNullOrEmpty($env:RMMScriptPath)) { + $LogPath = "$env:RMMScriptPath\logs\$ScriptLogName" + } else { + $LogPath = "$env:WINDIR\logs\$ScriptLogName" + } + if ([string]::IsNullOrEmpty($env:Description)) { + Write-Host "Description is null. This was most likely run automatically from the RMM and no information was passed." + $env:Description = "Lenovo BIOS Config" + } +} + +$logDir = Split-Path -Path $LogPath -Parent +if (-not (Test-Path -Path $logDir)) { + New-Item -Path $logDir -ItemType Directory -Force | Out-Null +} + +# --- Script logic -------------------------------------------------------- + +$TranscriptStarted = $false +try { + Start-Transcript -Path $LogPath -ErrorAction Stop + $TranscriptStarted = $true +} catch { + Write-Host "Warning: Could not start transcript logging to $LogPath - $($_.Exception.Message)" +} + +Write-Host "Description: $env:Description" +Write-Host "Log path: $LogPath" +Write-Host "RMM: $env:RMM" + +if (-not (Test-LenovoHardware)) { + Write-Host "Not a Lenovo endpoint. Exiting." + if ($TranscriptStarted) { Stop-Transcript } + exit 0 +} + +# Lenovo has no cctk-equivalent CLI. BIOS settings are applied via the +# root\wmi Lenovo_SetBiosSetting / Lenovo_SaveBiosSettings classes. +# Reference: https://docs.lenovocdrt.com/ref/bios/wmi/wmi_guide/ +$setBiosClass = Get-WmiObject -Class Lenovo_SetBiosSetting -Namespace root\wmi -ErrorAction SilentlyContinue +$saveBiosClass = Get-WmiObject -Class Lenovo_SaveBiosSettings -Namespace root\wmi -ErrorAction SilentlyContinue + +if (-not $setBiosClass -or -not $saveBiosClass) { + Write-Host "Lenovo BIOS WMI classes not available (root\wmi Lenovo_SetBiosSetting / Lenovo_SaveBiosSettings)." + Write-Host "This endpoint may not support WMI BIOS configuration." + if ($TranscriptStarted) { Stop-Transcript } + exit 2 +} + +$policyPath = Join-Path $env:TEMP "dtc-lenovo-baseline.lenovobios" +try { + Get-VerifiedDownload -Url $env:LenovoBiosPolicyURL -OutFile $policyPath -Sha256 $env:LenovoBiosPolicySha256 | Out-Null +} catch { + Write-Host "Failed to fetch BIOS policy: $_" + if ($TranscriptStarted) { Stop-Transcript } + exit 1 +} + +# Policy file format: one "name,value" tuple per line. Comments start with '#'. +# Example tuples (TODO: authoritative list lives in dtc-baseline.lenovobios): +# WakeOnLAN,Disable +# SecureBoot,Enable +# SecureRollBackPrevention,Enable +# BootMode,UEFI +# Settings and values are case-sensitive. Reboot required for changes to take effect. + +$pwd = $env:LenovoBiosSupervisorPassword +$pwdSuffix = if ([string]::IsNullOrEmpty($pwd)) { "" } else { ",$pwd,ascii,us" } + +$applied = 0 +$failed = 0 +$lines = Get-Content -Path $policyPath -ErrorAction Stop +foreach ($line in $lines) { + $trimmed = $line.Trim() + if ([string]::IsNullOrEmpty($trimmed) -or $trimmed.StartsWith("#")) { continue } + + # Tuple: "Name,Value" optionally followed by ",password,ascii,us" (built from $pwdSuffix). + $tuple = "$trimmed$pwdSuffix" + Write-Host "SetBiosSetting: $trimmed" + try { + $result = $setBiosClass.SetBiosSetting($tuple) + if ($result.return -eq "Success") { + $applied++ + } else { + Write-Host " return=$($result.return)" + $failed++ + } + } catch { + Write-Host " threw: $_" + $failed++ + } +} + +Remove-Item -Path $policyPath -Force -ErrorAction SilentlyContinue + +if ($applied -gt 0) { + Write-Host "`nCommitting changes via Lenovo_SaveBiosSettings" + $saveTuple = if ([string]::IsNullOrEmpty($pwd)) { "" } else { "$pwd,ascii,us" } + try { + if ([string]::IsNullOrEmpty($saveTuple)) { + # TODO: Confirm Lenovo_SaveBiosSettings accepts no-arg call on test endpoint. + # Older models require a tuple arg even with empty password; newer models tolerate empty call. + $save = $saveBiosClass.SaveBiosSettings("") + } else { + $save = $saveBiosClass.SaveBiosSettings($saveTuple) + } + Write-Host "SaveBiosSettings return: $($save.return)" + if ($save.return -ne "Success") { $failed++ } + } catch { + Write-Host "SaveBiosSettings threw: $_" + $failed++ + } +} + +Write-Host "`nSummary: applied=$applied failed=$failed" + +if ($TranscriptStarted) { Stop-Transcript } + +if ($failed -gt 0) { exit 1 } else { exit 0 } diff --git a/oem-lenovo/lenovo-debloat.ps1 b/oem-lenovo/lenovo-debloat.ps1 new file mode 100644 index 0000000..a0d6ff8 --- /dev/null +++ b/oem-lenovo/lenovo-debloat.ps1 @@ -0,0 +1,206 @@ +## PLEASE COMMENT YOUR VARIABLES DIRECTLY BELOW HERE IF YOU'RE RUNNING FROM A RMM +## NinjaRMM passes script preset variables as environment variables, so each is read via $env: in this script. +## $env:RMM - Set to "1" by NinjaRMM to indicate RMM (non-interactive) mode +## $env:Description - Ticket # or initials for audit trail +## $env:RMMScriptPath - Optional log directory base provided by the RMM + +$ScriptLogName = "lenovo-debloat.log" + +# --- Default optional RMM environment variables -------------------------- + +# === LIB BOOTSTRAP === +# Libs are pinned to a commit SHA in the jsDelivr URL. jsDelivr serves +# immutable content per SHA, so the URL itself is the integrity check. +# TODO: bump SHA when oem-shared/lib changes. +$libBaseUrl = "https://cdn.jsdelivr.net/gh/dtc-inc/msp-script-library@5b1b16c4b19816343145138941c3c5fa51a095e8" +$libs = @( + "$libBaseUrl/oem-shared/lib/oem-manufacturer-detect.ps1" +) +foreach ($url in $libs) { + $libPath = Join-Path $env:TEMP ([System.IO.Path]::GetFileName($url)) + Invoke-WebRequest -Uri $url -OutFile $libPath -UseBasicParsing -ErrorAction Stop + . $libPath +} + +# --- Input handling: RMM vs interactive ---------------------------------- + +if ($env:RMM -ne "1") { + $ValidInput = 0 + while ($ValidInput -ne 1) { + $env:Description = Read-Host "Please enter the ticket # and/or your initials for audit trail" + if ($env:Description) { + $ValidInput = 1 + } else { + Write-Host "Invalid input. Please try again." + } + } + $LogPath = "$env:WINDIR\logs\$ScriptLogName" +} else { + if (-not [string]::IsNullOrEmpty($env:RMMScriptPath)) { + $LogPath = "$env:RMMScriptPath\logs\$ScriptLogName" + } else { + $LogPath = "$env:WINDIR\logs\$ScriptLogName" + } + if ([string]::IsNullOrEmpty($env:Description)) { + Write-Host "Description is null. This was most likely run automatically from the RMM and no information was passed." + $env:Description = "Lenovo Debloat" + } +} + +$logDir = Split-Path -Path $LogPath -Parent +if (-not (Test-Path -Path $logDir)) { + New-Item -Path $logDir -ItemType Directory -Force | Out-Null +} + +# --- Script logic -------------------------------------------------------- + +$TranscriptStarted = $false +try { + Start-Transcript -Path $LogPath -ErrorAction Stop + $TranscriptStarted = $true +} catch { + Write-Host "Warning: Could not start transcript logging to $LogPath - $($_.Exception.Message)" +} + +Write-Host "Description: $env:Description" +Write-Host "Log path: $LogPath" +Write-Host "RMM: $env:RMM" + +$manufacturer = Get-OEMManufacturer +if ($manufacturer -ne "Lenovo") { + Write-Host "Not a Lenovo endpoint. Exiting." + if ($TranscriptStarted) { Stop-Transcript } + exit 0 +} + +# Lenovo consumer software to remove. Lenovo System Update (the enterprise tool) +# and Commercial Vantage / ThinkShield / enterprise security agents are intentionally +# NOT in this list ... they are the vendor lifecycle and security tools the baseline +# preserves. Consumer Vantage IS removed; Commercial Vantage is kept. +$lenovoBloatPatterns = @( + "Lenovo Vantage", + "Lenovo Vantage Service", + "Lenovo Smart Communication", + "Lenovo Smart Privacy", + "Lenovo Smart Appearance", + "Lenovo Voice", + "Lenovo Now", + "Lenovo Welcome", + "Lenovo Migration Assistant", + "Lenovo Utility", + "McAfee Personal Security", + "McAfee LiveSafe", + "McAfee WebAdvisor", + "Norton 360" +) + +# Patterns to exclude even if they match a bloat pattern (keep these installed). +$keepPatterns = @( + "Lenovo System Update", + "Lenovo Commercial Vantage", + "Lenovo Patch", + "Lenovo ThinkShield", + "Lenovo Endpoint Security" +) + +function Test-ShouldKeep { + param([string] $Name) + foreach ($keep in $keepPatterns) { + if ($Name -match $keep) { return $true } + } + return $false +} + +# Build the registry survey for both 64-bit and 32-bit uninstall hives. +$uninstallPaths = @( + "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*", + "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*" +) + +$removedCount = 0 +$failedCount = 0 + +foreach ($pattern in $lenovoBloatPatterns) { + $matched = foreach ($path in $uninstallPaths) { + Get-ItemProperty -Path $path -ErrorAction SilentlyContinue | + Where-Object { $_.DisplayName -and ($_.DisplayName -match [regex]::Escape($pattern)) } + } + foreach ($app in $matched) { + if (Test-ShouldKeep -Name $app.DisplayName) { + Write-Host "Keeping: $($app.DisplayName)" + continue + } + Write-Host "Removing: $($app.DisplayName) [$($app.DisplayVersion)]" + $uninstallString = $app.UninstallString + if ([string]::IsNullOrEmpty($uninstallString)) { + Write-Host " No uninstall string; skipping." + continue + } + try { + if ($uninstallString -match "msiexec") { + if ($app.PSChildName -match "^\{[0-9A-Fa-f-]+\}$") { + $proc = Start-Process -FilePath "msiexec.exe" -ArgumentList "/x $($app.PSChildName) /qn /norestart" -Wait -PassThru -NoNewWindow + if ($proc.ExitCode -eq 0) { $removedCount++ } else { $failedCount++; Write-Host " msiexec exit: $($proc.ExitCode)" } + } else { + Write-Host " Unknown product code shape: $($app.PSChildName)" + $failedCount++ + } + } else { + # Non-MSI uninstaller; append silent flags if possible. + $cmd = $uninstallString + if ($cmd -notmatch "/S" -and $cmd -notmatch "/silent" -and $cmd -notmatch "/quiet") { + $cmd = "$cmd /S" + } + $proc = Start-Process -FilePath "cmd.exe" -ArgumentList "/c $cmd" -Wait -PassThru -NoNewWindow + if ($proc.ExitCode -eq 0) { $removedCount++ } else { $failedCount++; Write-Host " uninstaller exit: $($proc.ExitCode)" } + } + } catch { + Write-Host " Removal threw: $_" + $failedCount++ + } + } +} + +# Provisioned UWP / AppX consumer bundles ship preinstalled on Lenovo consumer SKUs. +# Publisher prefix E0469640.* is Lenovo consumer. LenovoCorporation.LenovoVantage is the +# Store-distributed consumer Vantage. Commercial Vantage publisher (LenovoCorporation.LenovoSettings) +# is NOT in this list. +$appxPatterns = @( + "*E0469640.LenovoSmartCommunication*", + "*E0469640.LenovoSmartAppearance*", + "*E0469640.LenovoUtility*", + "*E0469640.LenovoCompanion*", + "*LenovoCorporation.LenovoVantage*" +) + +foreach ($pattern in $appxPatterns) { + $pkgs = Get-AppxPackage -AllUsers -Name $pattern -ErrorAction SilentlyContinue + foreach ($pkg in $pkgs) { + Write-Host "Removing AppX: $($pkg.Name)" + try { + Remove-AppxPackage -Package $pkg.PackageFullName -AllUsers -ErrorAction Stop + $removedCount++ + } catch { + Write-Host " Remove-AppxPackage failed: $_" + $failedCount++ + } + } + $provisioned = Get-AppxProvisionedPackage -Online -ErrorAction SilentlyContinue | + Where-Object { $_.DisplayName -like $pattern.Trim('*') -or $_.PackageName -like $pattern } + foreach ($prov in $provisioned) { + Write-Host "Removing provisioned AppX: $($prov.DisplayName)" + try { + Remove-AppxProvisionedPackage -Online -PackageName $prov.PackageName -ErrorAction Stop | Out-Null + $removedCount++ + } catch { + Write-Host " Remove-AppxProvisionedPackage failed: $_" + $failedCount++ + } + } +} + +Write-Host "`nSummary: removed=$removedCount failed=$failedCount" + +if ($TranscriptStarted) { Stop-Transcript } + +if ($failedCount -gt 0) { exit 1 } else { exit 0 } diff --git a/oem-lenovo/lenovo-system-update-install.ps1 b/oem-lenovo/lenovo-system-update-install.ps1 new file mode 100644 index 0000000..f379597 --- /dev/null +++ b/oem-lenovo/lenovo-system-update-install.ps1 @@ -0,0 +1,136 @@ +## PLEASE COMMENT YOUR VARIABLES DIRECTLY BELOW HERE IF YOU'RE RUNNING FROM A RMM +## NinjaRMM passes script preset variables as environment variables, so each is read via $env: in this script. +## $env:RMM - Set to "1" by NinjaRMM to indicate RMM (non-interactive) mode +## $env:Description - Ticket # or initials for audit trail +## $env:RMMScriptPath - Optional log directory base provided by the RMM +## +## Per-script variables: +## $env:LSUTargetVersion - Minimum acceptable LSU version (default: "5.08.03.59") +## $env:LSUInstallerURL - Override Lenovo-hosted installer URL (default: see below) +## $env:LSUInstallerSha256 - SHA256 of the installer EXE (TODO: capture and pin) +## +## NOTE: This script installs Lenovo System Update (LSU), the public driver/BIOS/firmware +## lifecycle tool that does not require Lenovo Partner Hub enrollment. If DTC enrolls in +## Partner Hub, a preferred alternative is Lenovo Commercial Vantage (no ads, integrates +## with the same LSU update pipeline). Gate the swap on $env:LenovoVantageCommercialAvailable +## from the baseline orchestrator. TODO: implement Commercial Vantage install path. + +$ScriptLogName = "lenovo-system-update-install.log" + +# --- Default optional RMM environment variables -------------------------- + +if ([string]::IsNullOrEmpty($env:LSUTargetVersion)) { $env:LSUTargetVersion = "5.08.03.59" } +# TODO: Confirm the direct Lenovo CDN download URL for LSU 5.08.03.59 from +# https://support.lenovo.com/us/en/downloads/ds012808 and pin it. The download.lenovo.com URL +# is timestamped; capture the exact URL the support page resolves to. +if ([string]::IsNullOrEmpty($env:LSUInstallerURL)) { + $env:LSUInstallerURL = "https://download.lenovo.com/pccbbs/thinkvantage_en/system_update_5.08.03.59.exe" +} +# TODO: Capture SHA256 of system_update_5.08.03.59.exe once the canonical URL is confirmed. +if ([string]::IsNullOrEmpty($env:LSUInstallerSha256)) { + $env:LSUInstallerSha256 = "REPLACE_WITH_REAL_HASH" +} + +# === LIB BOOTSTRAP === +# Libs are pinned to a commit SHA in the jsDelivr URL. jsDelivr serves +# immutable content per SHA, so the URL itself is the integrity check. +# TODO: bump SHA when oem-shared/lib or oem-lenovo/lib changes. +$libBaseUrl = "https://cdn.jsdelivr.net/gh/dtc-inc/msp-script-library@5b1b16c4b19816343145138941c3c5fa51a095e8" +$libs = @( + "$libBaseUrl/oem-shared/lib/oem-manufacturer-detect.ps1", + "$libBaseUrl/oem-lenovo/lib/lenovo-detection.ps1" +) +foreach ($url in $libs) { + $libPath = Join-Path $env:TEMP ([System.IO.Path]::GetFileName($url)) + Invoke-WebRequest -Uri $url -OutFile $libPath -UseBasicParsing -ErrorAction Stop + . $libPath +} + +# --- Input handling: RMM vs interactive ---------------------------------- + +if ($env:RMM -ne "1") { + $ValidInput = 0 + while ($ValidInput -ne 1) { + $env:Description = Read-Host "Please enter the ticket # and/or your initials for audit trail" + if ($env:Description) { + $ValidInput = 1 + } else { + Write-Host "Invalid input. Please try again." + } + } + $LogPath = "$env:WINDIR\logs\$ScriptLogName" +} else { + if (-not [string]::IsNullOrEmpty($env:RMMScriptPath)) { + $LogPath = "$env:RMMScriptPath\logs\$ScriptLogName" + } else { + $LogPath = "$env:WINDIR\logs\$ScriptLogName" + } + if ([string]::IsNullOrEmpty($env:Description)) { + Write-Host "Description is null. This was most likely run automatically from the RMM and no information was passed." + $env:Description = "Lenovo System Update Install" + } +} + +$logDir = Split-Path -Path $LogPath -Parent +if (-not (Test-Path -Path $logDir)) { + New-Item -Path $logDir -ItemType Directory -Force | Out-Null +} + +# --- Script logic -------------------------------------------------------- + +$TranscriptStarted = $false +try { + Start-Transcript -Path $LogPath -ErrorAction Stop + $TranscriptStarted = $true +} catch { + Write-Host "Warning: Could not start transcript logging to $LogPath - $($_.Exception.Message)" +} + +Write-Host "Description: $env:Description" +Write-Host "Log path: $LogPath" +Write-Host "RMM: $env:RMM" + +if (-not (Test-LenovoHardware)) { + Write-Host "Not a Lenovo endpoint. Exiting." + if ($TranscriptStarted) { Stop-Transcript } + exit 0 +} + +$targetVersion = [version]$env:LSUTargetVersion +$installedVersion = Get-LSUVersion + +if ($installedVersion -ne $null -and $installedVersion -ge $targetVersion) { + Write-Host "LSU $installedVersion already meets target $targetVersion. Skipping install." + if ($TranscriptStarted) { Stop-Transcript } + exit 0 +} + +Write-Host "LSU installed: $installedVersion. Target: $targetVersion. Installing." + +$installerPath = Join-Path $env:TEMP "lenovo-system-update-installer.exe" +try { + Get-VerifiedDownload -Url $env:LSUInstallerURL -OutFile $installerPath -Sha256 $env:LSUInstallerSha256 | Out-Null +} catch { + Write-Host "Failed to download LSU installer: $_" + if ($TranscriptStarted) { Stop-Transcript } + exit 1 +} + +# LSU uses an Inno Setup installer. /verysilent suppresses UI; /norestart leaves +# reboot decisions to the RMM. Reference: Lenovo CDRT LSU deployment guide. +Write-Host "Running silent install: $installerPath /verysilent /norestart" +$proc = Start-Process -FilePath $installerPath -ArgumentList "/verysilent","/norestart" -Wait -PassThru -NoNewWindow +Write-Host "Installer exit code: $($proc.ExitCode)" + +Remove-Item -Path $installerPath -Force -ErrorAction SilentlyContinue + +$postVersion = Get-LSUVersion +if ($postVersion -ne $null -and $postVersion -ge $targetVersion) { + Write-Host "LSU $postVersion installed successfully." + if ($TranscriptStarted) { Stop-Transcript } + exit 0 +} else { + Write-Host "LSU install verification failed. Installed version: $postVersion" + if ($TranscriptStarted) { Stop-Transcript } + exit 1 +} diff --git a/oem-lenovo/lenovo-system-update-run.ps1 b/oem-lenovo/lenovo-system-update-run.ps1 new file mode 100644 index 0000000..307aa18 --- /dev/null +++ b/oem-lenovo/lenovo-system-update-run.ps1 @@ -0,0 +1,140 @@ +## PLEASE COMMENT YOUR VARIABLES DIRECTLY BELOW HERE IF YOU'RE RUNNING FROM A RMM +## NinjaRMM passes script preset variables as environment variables, so each is read via $env: in this script. +## $env:RMM - Set to "1" by NinjaRMM to indicate RMM (non-interactive) mode +## $env:Description - Ticket # or initials for audit trail +## $env:RMMScriptPath - Optional log directory base provided by the RMM +## +## Per-script variables: +## $env:LSUScanOnly - "1" to scan but not apply updates (default: "0") + +$ScriptLogName = "lenovo-system-update-run.log" + +# --- Default optional RMM environment variables -------------------------- + +if ([string]::IsNullOrEmpty($env:LSUScanOnly)) { $env:LSUScanOnly = "0" } + +# === LIB BOOTSTRAP === +# Libs are pinned to a commit SHA in the jsDelivr URL. jsDelivr serves +# immutable content per SHA, so the URL itself is the integrity check. +# TODO: bump SHA when oem-shared/lib or oem-lenovo/lib changes. +$libBaseUrl = "https://cdn.jsdelivr.net/gh/dtc-inc/msp-script-library@5b1b16c4b19816343145138941c3c5fa51a095e8" +$libs = @( + "$libBaseUrl/oem-shared/lib/oem-manufacturer-detect.ps1", + "$libBaseUrl/oem-lenovo/lib/lenovo-detection.ps1" +) +foreach ($url in $libs) { + $libPath = Join-Path $env:TEMP ([System.IO.Path]::GetFileName($url)) + Invoke-WebRequest -Uri $url -OutFile $libPath -UseBasicParsing -ErrorAction Stop + . $libPath +} + +# --- Input handling: RMM vs interactive ---------------------------------- + +if ($env:RMM -ne "1") { + $ValidInput = 0 + while ($ValidInput -ne 1) { + $env:Description = Read-Host "Please enter the ticket # and/or your initials for audit trail" + if ($env:Description) { + $ValidInput = 1 + } else { + Write-Host "Invalid input. Please try again." + } + } + $LogPath = "$env:WINDIR\logs\$ScriptLogName" +} else { + if (-not [string]::IsNullOrEmpty($env:RMMScriptPath)) { + $LogPath = "$env:RMMScriptPath\logs\$ScriptLogName" + } else { + $LogPath = "$env:WINDIR\logs\$ScriptLogName" + } + if ([string]::IsNullOrEmpty($env:Description)) { + Write-Host "Description is null. This was most likely run automatically from the RMM and no information was passed." + $env:Description = "Lenovo System Update Run" + } +} + +$logDir = Split-Path -Path $LogPath -Parent +if (-not (Test-Path -Path $logDir)) { + New-Item -Path $logDir -ItemType Directory -Force | Out-Null +} + +# --- Script logic -------------------------------------------------------- + +$TranscriptStarted = $false +try { + Start-Transcript -Path $LogPath -ErrorAction Stop + $TranscriptStarted = $true +} catch { + Write-Host "Warning: Could not start transcript logging to $LogPath - $($_.Exception.Message)" +} + +Write-Host "Description: $env:Description" +Write-Host "Log path: $LogPath" +Write-Host "RMM: $env:RMM" + +if (-not (Test-LenovoHardware)) { + Write-Host "Not a Lenovo endpoint. Exiting." + if ($TranscriptStarted) { Stop-Transcript } + exit 0 +} + +if (-not (Test-LSUInstalled)) { + Write-Host "Lenovo System Update is not installed. Run lenovo-system-update-install.ps1 first." + if ($TranscriptStarted) { Stop-Transcript } + exit 2 +} + +$lsu = Get-LSUCliPath +Write-Host "LSU CLI: $lsu (version $(Get-LSUVersion))" + +# Reference: https://docs.lenovocdrt.com/guides/sus/su_dg/su_dg_ch5/ +# Tvsu.exe flags used: +# /CM command-line mode (required) +# -search A All updates (Critical + Recommended + Optional) ... mirrors DCU's applyUpdates breadth +# -action LIST scan only (list available updates, no download/install) +# -action INSTALL download + install +# -includerebootpackages 1,3,4 include reboot types 1 (normal), 3 (force-reboot), 4 (defer-reboot). +# without this, BIOS and reboot-required firmware are skipped. +# -noreboot never auto-reboot (RMM controls reboots) +# -nolicense auto-accept EULAs +# -noicon suppress system tray icon +# +# TODO: Tvsu.exe exit codes are thinly documented. Observed: 0 = success, non-zero = error. +# Post-run "what installed / what failed" is in %PROGRAMDATA%\Lenovo\SystemUpdate\session.xml. +# Capture real exit codes during endpoint testing and expand this mapping. + +$commonArgs = @("/CM","-search","A","-includerebootpackages","1,3,4","-noreboot","-nolicense","-noicon") + +if ($env:LSUScanOnly -eq "1") { + Write-Host "`n--- Tvsu.exe -action LIST (scan only) ---" + $scanArgs = $commonArgs + @("-action","LIST") + $scan = Start-Process -FilePath $lsu -ArgumentList $scanArgs -Wait -PassThru -NoNewWindow + Write-Host "Scan exit code: $($scan.ExitCode)" + if ($TranscriptStarted) { Stop-Transcript } + exit $scan.ExitCode +} + +Write-Host "`n--- Tvsu.exe -action INSTALL ---" +$installArgs = $commonArgs + @("-action","INSTALL") +$run = Start-Process -FilePath $lsu -ArgumentList $installArgs -Wait -PassThru -NoNewWindow +$runExit = $run.ExitCode +Write-Host "Tvsu.exe exit code: $runExit" + +# Session XML is the source of truth for per-package install status. +$sessionXml = "$env:ProgramData\Lenovo\SystemUpdate\session.xml" +if (Test-Path -Path $sessionXml) { + Write-Host "Session report: $sessionXml" + # TODO: Parse session.xml for the Ninja custom field summary (installed / failed counts, + # reboot-required packages, etc.) once the XML schema is confirmed on a live endpoint. +} else { + Write-Host "Session report not found at $sessionXml" +} + +if ($runExit -eq 0) { + Write-Host "LSU run completed successfully." +} else { + Write-Host "LSU run returned non-zero. See session.xml for per-package detail." +} + +if ($TranscriptStarted) { Stop-Transcript } +exit $runExit diff --git a/oem-lenovo/lib/lenovo-detection.ps1 b/oem-lenovo/lib/lenovo-detection.ps1 new file mode 100644 index 0000000..d49c16f --- /dev/null +++ b/oem-lenovo/lib/lenovo-detection.ps1 @@ -0,0 +1,48 @@ +# oem-lenovo/lib/lenovo-detection.ps1 +# +# Lenovo-specific detection helpers. Fetched at runtime alongside the shared +# OEM lib by the lib bootstrap block in each Lenovo leaf script. +# +# Provides: +# Test-LenovoHardware True if this machine is Lenovo. +# Get-LenovoInstalledModel Returns Win32_ComputerSystem.SystemFamily (e.g. "ThinkPad T14") or $null. +# Get-LenovoMachineType Returns Win32_ComputerSystem.Model (4-char machine type prefix) or $null. +# Test-LSUInstalled True if Tvsu.exe is present at the canonical path. +# Get-LSUVersion Returns [version] of installed LSU (Tvsu.exe ProductVersion), or $null. +# Get-LSUCliPath Returns the canonical Tvsu.exe path. + +$script:lsuCliPath = "${env:ProgramFiles(x86)}\Lenovo\System Update\Tvsu.exe" + +function Test-LenovoHardware { + $manufacturer = (Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction SilentlyContinue).Manufacturer + return ($manufacturer -like "LENOVO*") +} + +function Get-LenovoInstalledModel { + return (Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction SilentlyContinue).SystemFamily +} + +function Get-LenovoMachineType { + return (Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction SilentlyContinue).Model +} + +function Test-LSUInstalled { + return (Test-Path -Path $script:lsuCliPath) +} + +function Get-LSUVersion { + if (-not (Test-LSUInstalled)) { return $null } + try { + $info = (Get-Item -Path $script:lsuCliPath -ErrorAction Stop).VersionInfo + if ($info.ProductVersion) { + return [version]$info.ProductVersion + } + return $null + } catch { + return $null + } +} + +function Get-LSUCliPath { + return $script:lsuCliPath +}