DEV Community

Cover image for Case study: "We patched it." "Prove it." Remediation evidence in Defender Vulnerability Management
cuong do
cuong do

Posted on Edited on Originally published at gitlab.com

Case study: "We patched it." "Prove it." Remediation evidence in Defender Vulnerability Management

A lab company, Contoso, patches a fresh CVE on Wednesday. On Friday the auditor asks a simple question: which devices were exposed, and when was each one fixed? The Defender portal answers with a polite, empty stare.

Rebuilt in my lab tenant with made-up data, checked against Microsoft Learn (October 2026). All names (Contoso, LAB-WIN11-01, app-mdvm-evidence) and numbers are lab examples. The diagram and the "portal" table are drawings, not screenshots.

TL;DR

  • Vulnerability management in Defender shows now, not history. Once a device is patched it simply drops out of the exposed list, and DeviceTvmSoftwareVulnerabilities only has what is still vulnerable.
  • The proof lives in the delta export API: it returns every change with a Status of New, Fixed or Updated and an EventTimestamp.
  • Delta export only looks back 14 days, and nothing is kept for you. Pull it on a schedule and store it yourself, before the auditor shows up.

The case at a glance

Situation Contoso lab tenant, 6 devices onboarded to Defender for Endpoint Plan 2, one new CVE on 4 of them
Goal Patch within the week, then hand the auditor a list: device, CVE, first seen, fixed on
What went wrong After patching, the portal, advanced hunting and Resource Graph all showed "nothing". No history, so no proof
Fix An Entra app with Vulnerability.Read.All, a script that pulls the delta export, keeps the Fixed rows and saves them every day
Result A CSV per day with exposure and fix events, plus a weekly full snapshot as a baseline

Before and after: before, the patched devices vanish from the portal and the audit question has no answer; after, a scheduled script stores the delta export so every Fixed event is kept as evidence

Table of contents

1. The situation

The lab:

Item Lab value
Tenant Contoso lab tenant
License Defender for Endpoint Plan 2 (vulnerability management included)
Devices LAB-WIN11-01 to LAB-WIN11-04, LAB-SRV22-01, LAB-SRV22-02
The CVE One made-up browser CVE, found on the four Windows 11 devices
Roles Me as Security Administrator, plus an Application Administrator to register an app

Monday: the CVE shows up on four devices. Wednesday: the browser update goes out and all four are patched. Friday: the auditor, coffee in hand, asks for the list.

2. What I tried first, and why it failed

Try 1: the portal. Vulnerability management → Weaknesses → the CVE. Exposed devices: 0. Great news for security, useless for the audit. A device that is fixed is no longer exposed, so it is no longer listed.

Try 2: advanced hunting.

DeviceTvmSoftwareVulnerabilities
| where CveId == "<CVE-ID>"
| project DeviceName, SoftwareName, SoftwareVersion, CveId
Enter fullscreen mode Exit fullscreen mode

Zero rows. Same reason: the table holds the vulnerabilities that are on devices now.

Try 3: Azure Resource Graph. Also current state only.

Three tools, three empty answers. The auditor's coffee is getting cold.

3. Why it happens

The vulnerability data in Defender is state-based. Microsoft Learn says it directly for the export APIs: the data is the current snapshot and doesn't contain historic data, so if you want history you must save it in your own storage.

The one place that talks about changes is the delta export of the software vulnerabilities assessment API:

Full export Delta export
Endpoint GET /api/machines/SoftwareVulnerabilitiesByMachine GET /api/machines/SoftwareVulnerabilityChangesByMachine?sinceTime=...
What you get Everything vulnerable right now, per device Only what changed since sinceTime
Extra fields none Status (New, Fixed, Updated) and EventTimestamp
Refresh Every 6 hours After each full snapshot
How far back n/a (now) sinceTime up to 14 days

Status = Fixed means the vulnerability no longer exists on that device. That row is your evidence.

4. The fix, step by step

Step 1. Register an app that can read vulnerability data

  1. Entra admin center (or Azure portal) → App registrations → New registration: app-mdvm-evidence.
  2. API permissions → Add a permission → APIs my organization uses → WindowsDefenderATP.
  3. Application permissions → Vulnerability.Read.All → Add permissions, then Grant admin consent.
  4. Add a credential. A certificate is better than a secret; in the lab I used a short-lived secret stored outside the script.

Gotcha: request the token for https://api.securitycenter.microsoft.com/.default. Some Defender for Endpoint APIs still expect that legacy audience and answer 403 Forbidden otherwise, even though you call api.security.microsoft.com.

Step 2. Pull the delta export

export-vuln-delta.ps1 (see Scripts) does four things:

  1. Signs in as the app with Az.Accounts and gets a token for https://api.securitycenter.microsoft.com.
  2. Calls SoftwareVulnerabilityChangesByMachine with sinceTime set to the last run (never more than 14 days ago).
  3. Follows @odata.nextLink until all pages are read.
  4. Saves all rows as JSON, and the Fixed rows as a CSV: device, CVE, software, version, first seen, event time.
.\export-vuln-delta.ps1 -TenantId '<TENANT-ID>' -AppId '<APP-ID>' -SinceDays 1 -OutDir 'D:\mdvm-evidence'
Enter fullscreen mode Exit fullscreen mode

Step 3. Keep a baseline too

Once a week, save a full export (SoftwareVulnerabilitiesByMachine) next to the deltas. Microsoft's own recommended pattern is exactly this: a full snapshot as the baseline, deltas in between. The full export also answers "what is still open?" on the day of the audit.

Step 4. Run it on a schedule

Run the script every day (Task Scheduler, an Azure Automation runbook, or a Logic App). Daily is safe: the data refreshes every 6 hours, and the 14-day window gives you room if one run fails. Write the files somewhere with retention, not on someone's desktop.

Step 5. Answer the auditor

From the Fixed CSV:

  • Exposed since: FirstSeenTimestamp
  • Fixed on: EventTimestamp (good to about 6 hours, because of the refresh interval)
  • Time to fix: EventTimestamp - FirstSeenTimestamp

5. The result

In the lab (made-up numbers): the CVE appeared on 4 devices on Monday, the next day's delta showed 4 New rows, and after Wednesday's patch the delta showed 4 Fixed rows. The evidence CSV had one row per device with both timestamps, and the "time to fix" column read roughly two days for each.

Before After
"Which devices were exposed?" Unknown after patching 4 devices, from the New rows
"When was each fixed?" Unknown EventTimestamp of each Fixed row
Effort for the next audit Panic Open the folder

Lessons learned

  1. Exposure views show now, not history. "Not in the portal" is not proof of anything.
  2. Decide your evidence source before the audit. The API exists, it is just easy to miss.
  3. 14 days is the hard limit. Miss two weeks of runs and those changes are gone from the delta.
  4. Store a weekly full snapshot. Deltas tell you what changed, the snapshot tells you what is.

Gotchas

  • Software updated but still vulnerable? You get two events: Fixed for the old version and New for the new one. Your report must not count that as fixed.
  • Device moved to another device group? With the global view it can show up twice: Fixed under the old group, New under the new one. De-duplicate on device ID and record ID.
  • Wrong token audience. 403 Forbidden with a valid token usually means the token was issued for the wrong resource.
  • Rate limits. 30 calls per minute and 1,000 per hour for the JSON exports. Use a large pageSize (up to 200,000) instead of many small calls.
  • Big tenants. Above about 100,000 devices, use the via files export for the full snapshot.
  • Not in Sentinel. The TVM hunting tables aren't ingested into Microsoft Sentinel, so a Sentinel query against them returns nothing. Query them in Defender advanced hunting.

Checklist

  • [ ] App registered with Vulnerability.Read.All (application) and admin consent
  • [ ] Credential stored safely (certificate preferred), not in the script
  • [ ] Token requested for https://api.securitycenter.microsoft.com/.default
  • [ ] Daily delta export with sinceTime within 14 days
  • [ ] Weekly full snapshot saved as a baseline
  • [ ] Evidence stored with retention
  • [ ] Report de-duplicates version changes and device-group moves

What's your evidence source when an auditor asks "prove it"? I'd like to hear what works for you.

Scripts

The full scripts used in this post.

export-vuln-delta.ps1

<#
.SYNOPSIS
  Save Defender Vulnerability Management delta export rows as remediation evidence.

.DESCRIPTION
  Calls GET /api/machines/SoftwareVulnerabilityChangesByMachine (delta export), follows @odata.nextLink,
  saves every row as JSON and the Status = Fixed rows as CSV, one pair of files per run.
  Lab script with lab names. Test it in a lab tenant first.

.NOTES
  App registration: WindowsDefenderATP application permission Vulnerability.Read.All, admin consent granted.
  sinceTime can be at most 14 days in the past. Data refreshes about every 6 hours, so run it daily.
  Docs: https://learn.microsoft.com/en-us/defender-endpoint/api/get-assessment-software-vulnerabilities
#>

param(
    [Parameter(Mandatory)] [string] $TenantId,
    [Parameter(Mandatory)] [string] $AppId,
    [ValidateRange(1, 14)] [int] $SinceDays = 1,
    [string] $OutDir = '.\mdvm-evidence'
)

# 1. Sign in as the app (Az.Accounts). The app key comes from an environment variable; a certificate is better.
#    Never paste the key into the script.
if (-not $env:MDVM_APP_KEY) { throw 'Set the MDVM_APP_KEY environment variable first.' }
$cred = New-Object System.Management.Automation.PSCredential($AppId, (ConvertTo-SecureString $env:MDVM_APP_KEY -AsPlainText -Force))
Connect-AzAccount -ServicePrincipal -Tenant $TenantId -Credential $cred | Out-Null

# App-only token for the legacy audience: some Defender for Endpoint APIs return 403 with any other.
$token = (Get-AzAccessToken -ResourceUrl 'https://api.securitycenter.microsoft.com').Token
if ($token -is [securestring]) {
    $token = [Runtime.InteropServices.Marshal]::PtrToStringBSTR([Runtime.InteropServices.Marshal]::SecureStringToBSTR($token))
}
$headers = @{ Authorization = "Bearer $token" }

# 2. Delta export since N days ago, all pages
$since = (Get-Date).ToUniversalTime().AddDays(-$SinceDays).ToString('yyyy-MM-ddTHH:mm:ssZ')
$uri = "https://api.security.microsoft.com/api/machines/SoftwareVulnerabilityChangesByMachine?pageSize=200000&sinceTime=$since"
$rows = @()
while ($uri) {
    $page = Invoke-RestMethod -Method Get -Uri $uri -Headers $headers
    $rows += $page.value
    $uri = $page.'@odata.nextLink'
}

# 3. Save everything, plus the Fixed rows as the evidence file
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
$stamp = Get-Date -Format 'yyyyMMdd-HHmm'
$rows | ConvertTo-Json -Depth 5 | Set-Content -Path (Join-Path $OutDir "delta-$stamp.json") -Encoding utf8

$fixed = $rows | Where-Object { $_.status -eq 'Fixed' -and $_.cveId } |
    Select-Object deviceName, deviceId, rbacGroupName, cveId, softwareVendor, softwareName, softwareVersion,
        firstSeenTimestamp, eventTimestamp, status
$fixed | Export-Csv -Path (Join-Path $OutDir "fixed-$stamp.csv") -NoTypeInformation -Encoding utf8

$counts = $rows | Group-Object status | ForEach-Object { "$($_.Name)=$($_.Count)" }
Write-Host "Delta since $since : $($rows.Count) rows ($($counts -join ', ')). Fixed CVE rows saved: $($fixed.Count)"
Enter fullscreen mode Exit fullscreen mode

Sources

Top comments (0)