Scanning a Windows Machine with SBOM: I Got Visibility, but Not Control

Scanning a Windows Machine with SBOM: I Got Visibility, but Not Control


An investigation that started from idle chatter — "come to think of it, how does SBOM even work on Windows?" — landed within half a day on a quantitative conclusion: my home development machine produced 2,437 vulnerability matches, and of those, only 407 were ones I could remove at my own discretion. This is a record of the whole thing.

Windows and SBOM: Three Layers

Bring the topic of SBOM (Software Bill of Materials) into a Windows environment and it actually splits into three layers.

  • The layer you build yourself: NuGet and npm metadata is all there, so you can produce an SBOM at the same quality as on Linux
  • The layer of third-party apps you procure: closed software arriving as MSI/EXE depends on vendor disclosure or binary analysis
  • The OS itself: Microsoft generates an SBOM internally and even COSE-signs it, but it is not publicly disclosed

On Linux the distro's package manager centrally manages dependencies, so you can mechanically enumerate "what is installed on the system." Windows has no such world. Each app brings in its runtime and libraries bundled whole. I verified on real hardware what kind of result this structure produces.

The Test Setup and Tools

  • Target: a Windows 11 development machine (a set of homemade .NET 8 tools, many Electron apps, a PowerShell/Node.js scripting environment)
  • SBOM generation: syft (by Anchore, installable via winget install Anchore.Syft)
  • Vulnerability matching: grype (also by Anchore)
  • Supplementing the asset ledger: winget export

The scan targets were three places: %LocalAppData%\Programs (app bodies), the user's scripts folder, and npm global. As a warm-up I started with a single command.

syft scan dir:"$env:LOCALAPPDATA\Programs" -o cyclonedx-json="$env:USERPROFILE\Desktop\test.cdx.json"

The First Scan: Breaking Down 1,025 Components

In a few seconds, 1,025 components were detected. Aggregate the CycloneDX JSON by ecosystem in PowerShell.

$sbom = Get-Content "$env:USERPROFILE\Desktop\test.cdx.json" -Raw | ConvertFrom-Json
$sbom.components |
  Where-Object purl |
  Group-Object { $_.purl -replace '^pkg:([^/]+)/.*','$1' } |
  Sort-Object Count -Descending |
  Format-Table Name, Count

The results were as follows.

  • nuget: 550 — from the deps.json of .NET tools. Dependencies are fully transparent
  • golang: 48 — Go binaries embed module info at build time, so dependencies are visible even from the binary alone; the honor student
  • pypi: 1 — an amusing stray, discussed later
  • npm: 0 — zero, despite many Electron apps being installed

The remaining 426 were application-type components without a purl — i.e., the Windows executables that syft read out of PE binaries' version resources.

The Mystery of Zero npm: The asar Problem

Electron apps bundle node_modules into an archive called resources\app.asar for distribution. syft does not unpack asar, so the npm dependencies of a packaged Electron app vanish entirely from the SBOM. What is visible if you scan the development directory becomes invisible in the build artifact. It is a live demonstration of "the moment it becomes a binary, control stops working."

There is an exception, though. Native modules must be extracted as asar.unpacked, so that part alone remains transparent. This exception matters later.

Dating Electron Apps via ANGLE

Looking over the 426 purl-less components, ANGLE (Chromium's graphics layer, libEGL/libGLESv2) appeared in nine different versions. Since ANGLE is always bundled with an Electron app, the distribution of versions is itself a "Chromium carbon-dating of the Electron apps installed on the machine." Following the detection paths, these nine lined up.

  • 2.1.15383: balena-etcher (the elder; famous for dragging an old Electron along for ages)
  • 2.1.21845: icat
  • 2.1.25521: Notion, Evernote
  • 2.1.26233: Canva
  • 2.1.27040: signal-desktop
  • 2.1.27047: Trello (a homemade Electron wrapper)
  • 2.1.27056: kimi-desktop
  • 2.1.27515: SparkDesktop (the newest)

Electron apps that had disappeared as npm packages were made visible, era by era, via the binary metadata of their shells. One important caveat here: application-type components without a purl have no key to join against the vulnerability DB, so while you can eyeball that the oldest Electron is present, automatic matching against CVEs does not reach them. An SBOM is a tool for visibility before it is a tool for automation.

Grype: How to Read the Number 2,437

Feeding the generated SBOM to grype produced this.

✔ Scanned for vulnerabilities     [2437 vulnerability matches]
  ├── by severity: 204 critical, 1206 high, 992 medium, 31 low, 2 negligible
  └── by status:   1302 fixed, 1135 not-fixed, 0 ignored

204 critical. The number alone is startling, but three discounts are needed here.

  • This is a match count, not the number of real vulnerabilities. If five apps bundle the same vulnerable library, it is counted five times
  • Go binaries lack function symbols and fall back to module-level matching, so they tend to be over-detected (grype itself emits a WARN)
  • A vulnerable DLL "existing" and being "attackable" are different. Bridging that distance is properly the role of VEX

Meanwhile, my own scripts folder (zero-dependency Node.js ESM and PowerShell) had an empty SBOM and zero vulnerabilities. npm global was also zero. The layer I manage myself is healthy; the layer where I keep other people's build artifacts has 2,437. The "boundary where control works" was quantified as a vulnerability count.

Deduplicated, It's Really 8 Packages

Make the 204 critical matches unique by package.

$v.matches |
  Where-Object { $_.vulnerability.severity -eq 'Critical' } |
  Group-Object { $_.artifact.name + '@' + $_.artifact.version } |
  Sort-Object Count -Descending |
  Format-Table Name, Count

As a result, the terrifying 204 compressed into 8 kinds.

  • FFmpeg (N-109970 unofficial-build family): 98
  • ImageMagick 7.0.10: 48
  • Qt5 5.15.2: 30
  • golang.org/x/crypto v0.28.0: 11
  • golang.org/x/crypto v0.41.0: 10
  • Python 3.8.13: 4
  • FreeType 2.10.0: 2
  • Go stdlib 1.23.12: 1

The practical work of SBOM operation is not being frightened by a number but this act of compression.

Tracing Locations: Three Groups of Culprits

Following each package's detection path, the true identities converged into three groups.

First, FFmpeg, 98 matches. Electron apps ship ffmpeg.dll as standard for media codecs, and the nine apps I just carbon-dated each carried one. Nine apps x a dozen-plus CVEs = 98 matches. The structure visible via ANGLE bounced right back as a vulnerability count.

Second, SparkDesktop's ImageMagick. The detection path is eloquent.

\SparkDesktop\resources\app.asar.unpacked\node_modules\@readdle\sparkcore-win\bin\Release\SparkCore.bundle\ImageMagick...

I wrote that the inside of asar is invisible, but the asar.unpacked portion that native modules require is transparent. Through that gap, an ImageMagick 7.0.10 from around 2020 was excavated.

Third, dupeGuru. This duplicate-file finder bundles Python 3.8.13 (already EOL as of October 2024) and Qt 5.15.2 as a full stack, and all of Qt5's 30, Python's 4, and even the pypi 1 from the opening (semantic-version 2.10.0) came from here. The SBOM taught me that "I had been using dupeGuru without knowing it was a Python app" — and this is exactly the visibility everyone wanted during the Log4j uproar.

Remediation and Re-measurement: −407 by Deletion

Of the 2,437 matches, almost none can be fixed immediately on the user side. For both Canva's ffmpeg and Spark's ImageMagick, the only options are "wait for an app update" or "delete the app." The one thing within reach was deleting dupeGuru, which I had already migrated away from and whose development had mostly stopped.

Here was one more small discovery. dupeGuru was in neither winget list nor the registry's Uninstall key. It was a portable deployment. It does not appear in Windows' concept of "installed apps," yet its actual body existed carrying over 1,000 DLLs and over 50 vulnerability matches. Only an SBOM scan that looks directly at the file system told the truth.

Deleting the whole folder and rescanning gave this.

  • Total matches: 2,437 → 2,030 (−407, 17% of the whole)
  • critical: 204 → 170
  • components: 1,025 → 996

Visibility (scan) → compression (dedup) → identification (location trace) → remediation (deletion) → re-measurement (effect check). A record of one full lap of the SBOM lifecycle.

Conclusion: Visibility and Control Are Different Things

The measured answer to the opening question — "does SBOM control not work on Windows?" — comes out like this.

  • You can produce an SBOM. syft is plenty practical on Windows too, and .NET and Go dependencies are fully transparent
  • Electron makes npm dependencies invisible via asar, but PE metadata and asar.unpacked serve as a partial safety net
  • Vulnerability matching only reaches the layer that has a purl (nuget/golang/pypi/npm); the 426 closed binaries pass right through
  • And the biggest asymmetry: of the 2,437 matches, the ones I could remove at my own discretion numbered 407. The authority to fix the remaining 2,030 is held by the app vendors that bundled the libraries

An SBOM for a Windows environment is not something you "cannot make." Rather, "you can make and visualize it, but the initiative for remediation is dispersed among the bundlers." Until the EU CRA applies in earnest and disclosure pressure for SBOM/VEX falls on commercial vendors, this asymmetry will continue. In the context of public procurement, the practical implication I got from this experiment is that the control point is less the SBOM itself than "whether you can write SBOM submission and VEX provision into the procurement spec as required clauses."

Bonus: Traps I Stepped On Along the Way

For the reproducibility of the investigation, I also record how I tripped along the way.

  • syft is not recognized as a command right after installing it with winget. An existing PowerShell session keeps the old PATH, so open a new window or reload PATH
  • A downloaded .ps1 is refused with "is not digitally signed." The cause is the Mark of the Web. Viewing it with Get-Content -Stream Zone.Identifier shows that, on top of ZoneId=3, NTFS even records the source URL. Whereas an SBOM deals with "what it is made of," MotW is provenance management dealing with "where it came from" — you could call them the two wheels of supply-chain security. The fix is Unblock-File
  • $null.Count returns 0. Since PowerShell 3.0, null too gets a pseudo Count property, so you cannot notice a forgotten variable load (the variable is gone in a new session, or closed off in script scope). I stepped on this twice in this session alone. It is safer to make interactive investigation scripts self-contained, starting from a file read every time

Report Page