Lenovo Warranty Enrichment¶
The one opt-in tool that writes — enriching Lenovo warranty into Intune device Notes, surgically and report-only by default.
Download the script View on GitHub
This is the one script that can write
Everything else in the project is read-only. This tool is report-only by default
(-ReportOnly $true) — it looks up warranty and writes a CSV, touching nothing in Intune.
Only -ReportOnly $false (update mode) PATCHes device Notes, and that needs
DeviceManagementManagedDevices.ReadWrite.All — the single write scope in the whole project.
The append is surgical: it only adds warranty lines if absent and never overwrites
existing Notes.
1 · The script¶
It authenticates with a Managed Identity, filters to Lenovo Windows devices, looks each up by
serial against Lenovo's support endpoint, and (in report mode) writes a warranty CSV locally (no Azure storage).
Set the CONFIG values at the top — including $ReportOnly — then run.
View the full script
#requires -Version 7.0
<#
.SYNOPSIS
Enrich-LenovoWarrantyToNotes.ps1 — OPTIONAL source-enrichment utility.
Looks up Lenovo warranty by serial number and (in update mode) writes it into each
device's Intune Notes field as WarrantyStartDate= / WarrantyEndDate= lines.
.DESCRIPTION
This is NOT a read-only collector, and it is NOT part of the zero-access collection
layer. It is the one tool in this project that can WRITE to the tenant, and it is kept
deliberately separate for that reason.
- Default mode (-ReportOnly $true): read-only. Looks up warranty and writes a CSV
report; touches nothing in Intune.
- Update mode (-ReportOnly $false): PATCHes managedDevices notes to append warranty
fields. "Surgical" append — it only ADDS WarrantyStartDate/WarrantyEndDate if
absent and never rewrites existing Notes content.
Why it exists: persisting warranty into Notes once means the read-only inventory
collector (and Power BI) can simply READ it later, instead of every consumer scraping
Lenovo. The trade-off is honest — that persistence is a write, so this utility lives
outside the "read-only by construction" guarantee that covers the collection→agent path.
.PARAMETER ReportOnly
$true (default) = read-only report. $false = write warranty fields into device Notes.
.NOTES
SCOPES:
Report-only mode : DeviceManagementManagedDevices.Read.All (Microsoft Graph only - no Azure storage)
Update mode : DeviceManagementManagedDevices.ReadWrite.All (the ONLY place this
project uses a write scope — grant it only if you run update mode)
LENOVO LOOKUP CAVEAT: this calls an undocumented Lenovo support endpoint and parses the
warranty HTML with regex. It is fragile — Lenovo can change or block it without notice,
and you should check Lenovo's terms of use before running it at scale. Lenovo also offers
an official Warranty API (requires a key) which is the robust long-term option.
GENERIC / PARAMETERIZED: no resource group, storage account, or container is hardcoded.
Run against a personal lab tenant only.
MIT licensed. Microsoft/Intune/Entra/Graph/Azure and Lenovo are trademarks of their
respective owners. Independent content; not endorsed by Microsoft or Lenovo. Verify in
your own lab tenant before relying on it.
#>
# ===========================================================================
# CONFIGURE ME -> set these to your own values, then run.
# ===========================================================================
$ReportOnly = $true # $true = READ-ONLY report. $false = WRITE warranty into device Notes (needs ReadWrite scope)
$BatchSize = 25 # progress-log interval
$OutputFileName = "LenovoWarrantyReport_IntuneNotes.csv" # local CSV report name
$ExportLocation = "$env:TEMP" # where the local CSV report is written
# ===========================================================================
# This tool is pure Microsoft Graph — it needs NO Azure storage. Report mode
# writes a local CSV you inspect; update mode PATCHes device Notes. Warranty
# reaches Power BI later via the inventory collector reading the Notes field.
# Enhanced logging with mode awareness
$LogTime = { Get-Date -Format "yyyy-MM-dd HH:mm:ss" }
function Write-Log {
param([string]$Message, [string]$Level = "INFO")
$timestamp = & $LogTime
$mode = if ($ReportOnly) { "[REPORT-ONLY]" } else { "[UPDATE-MODE]" }
$logMessage = "[$timestamp] $mode [$Level] $Message"
switch ($Level) {
"ERROR" { Write-Host $logMessage -ForegroundColor Red }
"WARN" { Write-Host $logMessage -ForegroundColor Yellow }
"INFO" { Write-Host $logMessage -ForegroundColor Green }
"DEBUG" { Write-Host $logMessage -ForegroundColor Gray }
default { Write-Host $logMessage }
}
}
if ($ReportOnly) {
Write-Log "STARTING IN REPORT-ONLY MODE - NO DEVICE NOTES WILL BE MODIFIED" "INFO"
} else {
Write-Log "STARTING IN UPDATE MODE - INDIVIDUAL DEVICE QUERIES WITH SURGICAL NOTES APPEND" "WARN"
}
Write-Log "Using individual device queries with `$select to avoid API inconsistencies" "INFO"
# Get individual device with accurate notes using $select
function Get-DeviceWithAccurateNotes {
param([string]$DeviceId)
$uri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices/$DeviceId`?`$select=id,deviceName,serialNumber,notes"
try {
$device = Invoke-RestMethod -Uri $uri -Headers $script:Headers -Method GET -TimeoutSec 30
Write-Log "INDIVIDUAL QUERY: Retrieved accurate notes for device $($device.deviceName)" "DEBUG"
return $device
} catch {
Write-Log "FAILED to get individual device $DeviceId : $_" "ERROR"
return $null
}
}
# SURGICAL WARRANTY APPEND: only add warranty fields, never modify existing content
function Add-WarrantyFieldsSurgically {
param([string]$ExistingNotes, [hashtable]$WarrantyInfo)
Write-Log "SURGICAL APPEND: Adding warranty fields without touching existing content" "DEBUG"
$updatedNotes = $ExistingNotes
$changesMade = $false
if ([string]::IsNullOrEmpty($updatedNotes)) {
$notesLines = @()
} else {
$notesLines = @($updatedNotes -split "`r`n|`r|`n")
}
if ($WarrantyInfo.Start) {
$hasStartDate = $false
$targetStartLine = "WarrantyStartDate=$($WarrantyInfo.Start)"
foreach ($line in $notesLines) {
if ($line -match "^WarrantyStartDate\s*=") { $hasStartDate = $true; break }
}
if (-not $hasStartDate) {
$notesLines += $targetStartLine
$changesMade = $true
Write-Log "SURGICAL: Added WarrantyStartDate=$($WarrantyInfo.Start)" "DEBUG"
} else {
Write-Log "SURGICAL: WarrantyStartDate already exists, skipping" "DEBUG"
}
}
if ($WarrantyInfo.End) {
$hasEndDate = $false
$targetEndLine = "WarrantyEndDate=$($WarrantyInfo.End)"
foreach ($line in $notesLines) {
if ($line -match "^WarrantyEndDate\s*=") { $hasEndDate = $true; break }
}
if (-not $hasEndDate) {
$notesLines += $targetEndLine
$changesMade = $true
Write-Log "SURGICAL: Added WarrantyEndDate=$($WarrantyInfo.End)" "DEBUG"
} else {
Write-Log "SURGICAL: WarrantyEndDate already exists, skipping" "DEBUG"
}
}
$finalNotes = $notesLines -join "`n"
return @{ Notes = $finalNotes; ChangesMade = $changesMade }
}
# WARRANTY LOOKUP — undocumented Lenovo endpoint + HTML scrape (see caveat in header)
function Get-LenovoWarranty {
param([string]$SerialNumber, [string]$DeviceName)
if ([string]::IsNullOrEmpty($SerialNumber)) {
Write-Log "No serial number for device: $DeviceName" "WARN"
return @{ Status = "No Serial"; Start = ""; End = "" }
}
Write-Log "Getting warranty for $DeviceName (Serial: $SerialNumber)" "DEBUG"
try {
$Device_Info = Invoke-RestMethod "https://pcsupport.lenovo.com/us/en/api/v4/mse/getproducts?productId=$SerialNumber" -TimeoutSec 15
$Device_ID = $Device_Info.id
if ([string]::IsNullOrEmpty($Device_ID)) {
Write-Log "Can not get device ID for serial number: $SerialNumber" "WARN"
return @{ Status = "No Device ID"; Start = ""; End = "" }
}
Write-Log "Found device ID: $Device_ID" "DEBUG"
$Warranty_url = "https://pcsupport.lenovo.com/us/en/products/$Device_ID/warranty"
} catch {
Write-Log "Can not get information for the serial number: $SerialNumber - $_" "ERROR"
return @{ Status = "API Error"; Start = ""; End = "" }
}
try {
$Web_Response = Invoke-WebRequest -Uri $Warranty_url -Method GET -TimeoutSec 20
} catch {
Write-Log "Can not get warranty info for the serial number: $SerialNumber - $_" "ERROR"
return @{ Status = "Page Error"; Start = ""; End = "" }
}
if ($Web_Response.StatusCode -eq 200) {
$HTML_Content = $Web_Response.Content
$Pattern_Status = '"warrantystatus":"(.*?)"'
$Pattern_Status2 = '"StatusV2":"(.*?)"'
$Pattern_StartDate = '"Start":"(.*?)"'
$Pattern_EndDate = '"End":"(.*?)"'
$Status_Matches = [regex]::Matches($HTML_Content, $Pattern_Status)
$Statusv2_Matches = [regex]::Matches($HTML_Content, $Pattern_Status2)
$StartDate_Matches = [regex]::Matches($HTML_Content, $Pattern_StartDate)
$EndDate_Matches = [regex]::Matches($HTML_Content, $Pattern_EndDate)
$Status_Result = if ($Status_Matches.Count -gt 0) { $Status_Matches[0].Groups[1].Value.Trim() } else { "Can not get status info" }
$Statusv2_Result = if ($Statusv2_Matches.Count -gt 0) { $Statusv2_Matches[0].Groups[1].Value.Trim() } else { "Can not get status info" }
if ($StartDate_Matches.Count -gt 0) {
$StartDate_Result = ($StartDate_Matches[0].Groups[1].Value.Trim() -split 'T')[0]
} else { $StartDate_Result = "" }
if ($EndDate_Matches.Count -gt 0) {
$EndDate_Result = ($EndDate_Matches[0].Groups[1].Value.Trim() -split 'T')[0]
} else { $EndDate_Result = "" }
$FinalStatus = "Unknown"
if ($EndDate_Result -and $EndDate_Result -match '^\d{4}-\d{2}-\d{2}') {
try {
$EndDate = [DateTime]::Parse($EndDate_Result)
$FinalStatus = if ((Get-Date) -lt $EndDate) { "Active" } else { "Expired" }
} catch { $FinalStatus = $Status_Result }
}
elseif ($Statusv2_Result -eq "true") { $FinalStatus = "Active" }
elseif ($Status_Result -ne "Can not get status info") { $FinalStatus = $Status_Result }
Write-Log "WARRANTY RESULT for '$DeviceName': Status=$FinalStatus, Start=$StartDate_Result, End=$EndDate_Result" "INFO"
return @{ Status = $FinalStatus; Start = $StartDate_Result; End = $EndDate_Result }
} else {
Write-Log "Warranty page returned status $($Web_Response.StatusCode) for serial $SerialNumber" "ERROR"
return @{ Status = "HTTP Error $($Web_Response.StatusCode)"; Start = ""; End = "" }
}
}
# Authentication (Managed Identity)
Write-Log "Authenticating with Azure Managed Identity" "INFO"
try {
$url = $env:IDENTITY_ENDPOINT
$headers = @{ "X-IDENTITY-HEADER" = $env:IDENTITY_HEADER; "Metadata" = "True" }
$body = @{ resource = 'https://graph.microsoft.com/' }
$accessToken = (Invoke-RestMethod $url -Method 'POST' -Headers $headers -ContentType 'application/x-www-form-urlencoded' -Body $body).access_token
$script:Headers = @{ 'Authorization' = "Bearer $accessToken" }
Write-Log "Authentication successful" "INFO"
} catch {
Write-Log "Authentication failed: $_" "ERROR"
exit 1
}
# Graph API paged GET
Function Invoke-MyGraphGetRequest {
Param ($URL)
Write-Log "Graph API request: $URL" "DEBUG"
$AllResults = @()
do {
$ResponseData = Invoke-RestMethod -Uri $URL -Method GET -Headers $script:Headers -TimeoutSec 30
$AllResults += $ResponseData.value
$URL = $ResponseData.'@odata.nextLink'
if ($URL) { Start-Sleep -Milliseconds 200 }
} while ($URL)
Write-Log "Graph API completed: $($AllResults.Count) total items" "INFO"
return $AllResults
}
# Fetch managed-device IDs with manufacturer + OS, then filter CLIENT-SIDE to Lenovo Windows.
# Server-side $filter on managedDevices manufacturer/operatingSystem is undocumented and unreliable:
# it can silently return the WHOLE fleet, or match nothing on a case mismatch ('lenovo' vs 'LENOVO').
# Filtering in code is safe and case-insensitive — this matters because update mode PATCHes Notes,
# so we must never process a non-Lenovo device.
Write-Log "Fetching managed devices; filtering to Lenovo Windows client-side" "INFO"
$Devices_URL = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$select=id,manufacturer,operatingSystem"
$AllDevices = Invoke-MyGraphGetRequest -URL $Devices_URL
$Lenovo_DeviceIds = @($AllDevices | Where-Object { $_.manufacturer -match 'lenovo' -and $_.operatingSystem -match 'Windows' })
if ($Lenovo_DeviceIds.Count -eq 0) {
Write-Log "No Lenovo Windows devices found - exiting" "WARN"
exit 0
}
Write-Log "Found $($Lenovo_DeviceIds.Count) Lenovo Windows devices to process" "INFO"
$Results = @()
$processed = 0
$successCount = 0
$failureCount = 0
foreach ($DeviceId in $Lenovo_DeviceIds) {
$processed++
$progress = [math]::Round(($processed / $Lenovo_DeviceIds.Count) * 100, 1)
Write-Log "PROCESSING $processed/$($Lenovo_DeviceIds.Count) ($progress%): Getting device $($DeviceId.id)" "INFO"
# Individual query with $select for accurate notes
$Device = Get-DeviceWithAccurateNotes -DeviceId $DeviceId.id
if (-not $Device) {
Write-Log "SKIPPING device $($DeviceId.id) - could not retrieve details" "WARN"
continue
}
Write-Log "DEVICE DETAILS: Name=$($Device.deviceName), Serial=$($Device.serialNumber)" "INFO"
Write-Log "EXISTING NOTES: $($Device.notes)" "DEBUG"
$WarrantyInfo = Get-LenovoWarranty -SerialNumber $Device.serialNumber -DeviceName $Device.deviceName
if ($WarrantyInfo.Status -notin @("Unknown", "API Error", "Page Error", "No Device ID", "No Serial")) {
$successCount++
} else {
$failureCount++
}
$hasExistingModel = if ($Device.notes) { $Device.notes -match "Model\s*=" } else { $false }
$hasExistingWarrantyStart = if ($Device.notes) { $Device.notes -match "WarrantyStartDate\s*=" } else { $false }
$hasExistingWarrantyEnd = if ($Device.notes) { $Device.notes -match "WarrantyEndDate\s*=" } else { $false }
$currentModel = if ($Device.notes -match "Model\s*=\s*(.+)") { $matches[1].Trim() } else { "Not Found" }
$currentWarrantyStart = if ($Device.notes -match "WarrantyStartDate\s*=\s*(.+)") { $matches[1].Trim() } else { "Not Found" }
$currentWarrantyEnd = if ($Device.notes -match "WarrantyEndDate\s*=\s*(.+)") { $matches[1].Trim() } else { "Not Found" }
$proposedAction = "No Change"
if (!$hasExistingWarrantyStart -and $WarrantyInfo.Start) { $proposedAction = "Add WarrantyStartDate" }
if (!$hasExistingWarrantyEnd -and $WarrantyInfo.End) {
$proposedAction = if ($proposedAction -eq "No Change") { "Add WarrantyEndDate" } else { "$proposedAction + WarrantyEndDate" }
}
$Results += [PSCustomObject]@{
DeviceName = $Device.deviceName
SerialNumber = $Device.serialNumber
RawExistingNotes = $Device.notes
CurrentModel = $currentModel
CurrentWarrantyStart = $currentWarrantyStart
CurrentWarrantyEnd = $currentWarrantyEnd
NewWarrantyStatus = $WarrantyInfo.Status
NewWarrantyStartDate = $WarrantyInfo.Start
NewWarrantyEndDate = $WarrantyInfo.End
HasModel = $hasExistingModel
HasWarrantyStart = $hasExistingWarrantyStart
HasWarrantyEnd = $hasExistingWarrantyEnd
ProposedAction = $proposedAction
}
# WRITE step — only in update mode. This is the one place the project writes to Intune.
if (-not $ReportOnly) {
Write-Log "UPDATE: Surgically adding warranty fields for '$($Device.deviceName)'" "DEBUG"
$updateResult = Add-WarrantyFieldsSurgically -ExistingNotes $Device.notes -WarrantyInfo $WarrantyInfo
if ($updateResult.ChangesMade) {
try {
$patchUri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices/$($Device.id)"
$patchBody = @{ notes = $updateResult.Notes } | ConvertTo-Json
Invoke-RestMethod -Uri $patchUri -Headers $script:Headers -Method PATCH -Body $patchBody -ContentType "application/json" -TimeoutSec 30
Write-Log "UPDATE SUCCESS: appended warranty data to '$($Device.deviceName)' - existing content preserved" "INFO"
} catch {
Write-Log "FAILED to update notes for '$($Device.deviceName)': $_" "ERROR"
}
} else {
Write-Log "NO UPDATE NEEDED: warranty fields already present for '$($Device.deviceName)'" "DEBUG"
}
} else {
Write-Log "REPORT-ONLY MODE: Skipping notes update for '$($Device.deviceName)'" "DEBUG"
}
Start-Sleep -Seconds 2 # rate limiting (be a good citizen to the warranty endpoint)
if ($processed % $BatchSize -eq 0) {
$successRate = [math]::Round(($successCount / $processed) * 100, 1)
Write-Log "Progress: $processed/$($Lenovo_DeviceIds.Count) processed, $successCount successful ($successRate%)" "INFO"
}
}
$successRate = [math]::Round(($successCount / [math]::Max($processed,1)) * 100, 1)
$mode = if ($ReportOnly) { "REPORT-ONLY" } else { "UPDATE" }
Write-Log "=============== FINAL STATISTICS ===============" "INFO"
Write-Log "EXECUTION MODE: $mode" "INFO"
Write-Log "Total devices processed: $processed" "INFO"
Write-Log "Successful warranty lookups: $successCount ($successRate%)" "INFO"
Write-Log "Failed warranty lookups: $failureCount" "INFO"
if ($ReportOnly) {
Write-Log "NO DEVICE NOTES WERE MODIFIED (Report-only mode)" "INFO"
} else {
Write-Log "UPDATE COMPLETE - existing Notes content (including Model= fields) preserved" "INFO"
}
Write-Log "===============================================" "INFO"
# Export CSV
Write-Log "Exporting warranty data to CSV" "INFO"
$OutputFilePath = Join-Path -Path $ExportLocation -ChildPath $OutputFileName
if (!(Test-Path $ExportLocation)) { New-Item $ExportLocation -ItemType Directory | Out-Null }
$Results | Export-Csv -Path $OutputFilePath -NoTypeInformation
Write-Log "CSV exported: $OutputFilePath ($($Results.Count) records)" "INFO"
Write-Log "Report written locally (no Azure storage used): $OutputFilePath" "INFO"
Write-Log "Script execution completed in $mode MODE" "INFO"
2 · Permissions¶
- Report-only mode (default):
DeviceManagementManagedDevices.Read.All— Microsoft Graph only, no Azure storage (the report is written locally) - Update mode:
DeviceManagementManagedDevices.ReadWrite.All— grant only if you run update mode
Related¶
- The story → The one script that writes: enriching Lenovo warranty into device Notes
- The full teardown → Enrichment tool — the one that writes