feat(browserext): add browser extension inventory scan - #192
Merged
ashishkurmi merged 15 commits intoAug 19, 2026
Merged
Conversation
Inventories installed browser extensions across Chrome, Edge, Brave, Chromium and Firefox, reporting per-browser coverage alongside the findings so the backend can tell an empty browser from an unreadable one. Coverage is membership-complete: only scanned and partial browsers carry an authoritative extension list, while failed and not_present ship none. Chromium profiles are read from Local State plus Secure Preferences and Preferences; enabled state comes from the disable_reasons bitmask. Gecko profiles come from profiles.ini and extensions.json, carrying the signature state instead of the store fields. Extension name and locale are attribute reads, so a missing or unreadable manifest reduces the finding and marks the browser partial rather than discarding it. Adds a no-follow mode to safepath: an openat chain with O_NOFOLLOW on unix, and an unfollowed open verified against the kernel's final path on Windows. All reads are byte-capped and the phase carries its own deadline. Per-browser and per-profile caps bound one browser; the total finding cap and the deadline end the payload. Behind the browser_extensions_scan feature gate, off by default.
Restricting an extension's site access does not rewrite its up-front grant. The browser sets withholding_permissions and moves the hosts that survive into the runtime record, leaving the withheld ones in granted_permissions as a record of what was originally handed over. Unioning the two records therefore reported access the user had taken back, so a restricted extension and an unrestricted one produced identical host lists. Hosts now come from the runtime record alone once the flag is set. API permissions are never withheld, so that union is unchanged, and a browser that does not write the flag keeps today's behaviour.
…ked paths Chromium collapsed granted_permissions.explicit_host and scriptable_host into one list, so an extension that can inject code into a host looked the same as one that can only reach it with a request. Split them: scriptable_host_permissions is derived from the already-capped host union, so it is a subset by construction. The field is a pointer, since an empty list means the extension injects nowhere and an absent one means the engine does not record the distinction. Manifest version now ships on both engines. A version 2 extension can hold blocking request interception, which version 3 removed, so the two are not the same risk class. Unpacked extensions no longer degrade a browser to partial for ever. The refusal to resolve an absolute path stays, but nothing was read and failed, so there is nothing to report as degraded. Their load path ships as install_path, capped and omitted rather than shortened, because an unreviewed extension running out of a user directory is the signal and a blank name is not. Gecko read runtime grants from nowhere: Firefox writes them only to extension-preferences.json, which was never opened. Union it into each add-on already present in extensions.json, dropping internal: bookkeeping and routing host patterns to origins. The file can only add attributes, so a missing one is silence while an unparseable or refused one degrades. Data collection comes across with it, keeping ["none"] distinct from nothing declared. Also carries the Chromium disable_reasons actor map, which reads the reason bits rather than the aggregate state so a policy-disabled extension is not reported as a user choice.
The shared fixture carried scriptable hosts, manifest version, data collection and install path, but nothing checked them, so a regression that stopped emitting one would have passed here and only shown up as a missing column. Coverage now requires the fixture to keep exercising all three scriptable states, since an empty list and an absent one are different answers, both manifest versions plus an unrecorded one, and a declared data collection. The invariants a reader rejects over are checked alongside them: a scriptable host absent from host_permissions, a scriptable list on a gecko browser, and an install path on anything but an unpacked extension.
An extension installed in several profiles reduces to one finding, and the whole record was taken from the profile whose path sorted first. A profile with site access withheld could therefore supply the permission lists while another profile held broad access, so the finding under-reported what the machine can reach. Rank occurrences instead: enabled state first, then broad host access, broad scriptable access, host count, permission count, and the profile path last as a total tiebreak so repeat scans stay identical. Ranking by state first makes the winning record's own state equal the state the row reports, so version, store and permissions always describe a profile the row really is in. Broad host access uses the same rule the reader applies, including the http and https pattern pair, so both sides agree on what breadth means.
The v1 catalog is Chrome, Edge and Firefox. Brave held 0.9% of desktop share, which did not earn a row in the first release, and the catalog is data on both sides: the device carries one browserSpec row and the parser dispatches on engine, so removing it costs no parser work. catalog_version stays "1". It exists so a reader can tell a list narrower than it expects from a browser that ran and found nothing, and no production catalog contract has shipped yet. Bumping it would claim a revision no reader ever contracted against. An unknown browser_id makes the reader reject the whole payload rather than the one row, so any binary built before this change has its entire scan discarded on ingest. Rebuild before the next upload. Two test consequences: The cap test needs a browser after the overflowing one to prove the cut lands on a browser boundary. That was Brave; it is now Firefox, which is stronger, because the browser after the cap is on the other engine. The golden fixture's Brave row was its only not_present coverage entry, and three browsers cannot exhibit four statuses in one payload while the other three are each load-bearing. The vocabulary test no longer requires not_present, which keeps dedicated coverage in the detector's no-installation and no-browsers-at-all cases.
The browser extension scan is generally available from its first release, so FeatureBrowserExtensionsScan and both of its call sites come out entirely rather than being flipped on. This is the credential precedent rather than the skills one. The gate existed because the collector and its reader release independently, and that risk does not disappear with the gate. It moves into deploy order, which is now a hard prerequisite instead of a switch we hold: the backend reader must be live in production before the agent release that carries this collector. A reader that does not know the block discards it silently, which reads as "the feature is live and this machine has no browser extensions", and a reader that knows it but carries a narrower catalog rejects the whole payload. The nil section contract is unchanged. A nil BrowserExtensionScanInfo still means the phase declined, which is what a service identity with no interactive user produces, and it stays distinct from a section present with zero findings. The gate was a third reason the section could be nil, so removing it narrows what nil can mean rather than changing it. The smoke assertion inverts with it. It checked that the section was absent while gated; it now checks the section is either absent or carries its coverage list. Not presence outright, because a run with no interactive user legitimately omits it. SCAN_COVERAGE.md and README.md pick up the feature now that it ships on by default. The coverage section names the snap and flatpak roots per browser rather than in general, since only Firefox carries both and only Edge carries a flatpak root, and it states that any other packaging or a custom data directory reports as not present.
The wire was built from granted_permissions, which Chromium defines as the maximum an extension has ever held and not had globally revoked. A permission a later version stopped asking for, or one handed back through the permissions API, stays there after the browser stopped honouring it, so the payload could report access the extension no longer has. Read active_permissions instead, with no fallback to history: a historical grant is not weaker evidence of present access, it is evidence of something else. The granted record is not parsed at all now, so a browser version that writes it differently cannot cost us a row we can otherwise read. Each permission record is decoded on its own. One of them arriving in a shape this parser does not know now costs what that record holds rather than the whole row, so identity, install source, store disposition and enabled state survive and membership stays complete. The runtime store is read only when the withholding flag is set, since that is the only time its contents matter. Under withholding the browser keeps the request in the active set and records the granted origins in the runtime store alone, so the hosts it honours are drawn from the runtime side: a granted pattern counts where the request names it exactly, or where the request covers the whole web and the pattern is a website it covers. Whole-web authority is the same test the occurrence ranking applies, so the http and https wildcard pair counts alongside either single form, and a granted file pattern is admitted only under <all_urls>, the one form that reaches local files. Anything else reports no host: working out whether one pattern covers another in the general case is the browser's own matching logic, and guessing at it would report reach that was never granted. Explicit and scriptable hosts are narrowed against their own side of the runtime store. The browser writes a granted origin into both buckets whether or not the extension declares a content script, so crossing them would invent provenance. file://*/* is no longer counted as broad host access when ranking profile occurrences. Reach over local files is not reach over websites, and the browser gates it behind a separate per-extension setting. payload_schema_version stays 1.
Bring the comments on the browser extension types and detectors down to the density of the code around them. Field-level commentary that restated the field name is gone, enum blocks carry one line each, and the multi-paragraph function docs keep the fact the code depends on rather than the argument for it. No code changes: the non-comment token stream of every file is unchanged.
There was a problem hiding this comment.
Pull request overview
Adds a new browser extension inventory scan to Dev Machine Guard, wiring it into both community-mode scans (internal/scan) and enterprise telemetry payloads (internal/telemetry), with new model wire types and pretty/HTML renderers. The scan inventories installed extensions and per-browser coverage status using browser state files, while strengthening safepath with a “no-follow” mode for symlink rejection.
Changes:
- Introduces
internal/detector/browserextto enumerate Chromium- and Gecko-family extension inventories plus per-browser coverage semantics. - Extends the wire model (
internal/model) and renderers (internal/output) to surface the newbrowser_extension_scansection, including golden/shape tests. - Enhances
internal/safepathwithNewNoFollowbehavior and additional refusal reasons, plus updated open verification behavior and tests.
Reviewed changes
Copilot reviewed 29 out of 29 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/test_smoke_go.sh | Adds a smoke assertion that browser_extension_scan is either omitted or includes a non-empty coverage list. |
| SCAN_COVERAGE.md | Documents the new browser extensions scan and supported browser data directory locations. |
| README.md | Updates supported detector catalog and scan output description to include browser extensions. |
| internal/telemetry/telemetry.go | Adds browser extension scan phase and includes results in the enterprise telemetry payload. |
| internal/telemetry/phase_deadline.go | Allocates a per-phase deadline for browser_extensions_scan. |
| internal/scan/scanner.go | Runs the browser extension scan in community-mode scan orchestration and adds it to ScanResult. |
| internal/safepath/safepath.go | Adds NewNoFollow, symlink refusal reason, and case-insensitive root containment comparisons on Windows. |
| internal/safepath/safepath_test.go | Adds tests for NewNoFollow behavior and updates openVerified call sites for signature change. |
| internal/safepath/open_windows.go | Extends openVerified/openErr to vary refusal reason based on noFollow intent and improves UNC final path normalization. |
| internal/safepath/open_unix.go | Extends openVerified/openErr to vary refusal reason based on noFollow intent. |
| internal/output/pretty.go | Adds pretty-console rendering for the browser extensions tri-state (not scanned vs none detected vs list). |
| internal/output/pretty_test.go | Adds coverage tests for the pretty output’s browser extension tri-state and failure/partial rendering. |
| internal/output/html.go | Adds HTML rendering for browser extension findings and per-browser coverage summary. |
| internal/model/testdata/browser_extension_scan_golden.json | Adds a golden JSON payload fixture for browser extension scan wire shape. |
| internal/model/scanresult_jsonshape_test.go | Ensures browser_extension_scan is omitted when nil (to preserve “did not run” signaling). |
| internal/model/model.go | Adds BrowserExtensionScan pointer field to ScanResult. |
| internal/model/browserext.go | Introduces the browser extension scan wire types, enums, and schema versioning. |
| internal/model/browserext_golden_test.go | Validates the golden payload round-trip, vocabulary coverage, and payload invariants. |
| internal/detector/browserext/session_windows.go | Implements Windows session-0 detection to decline scans in non-interactive service contexts. |
| internal/detector/browserext/session_other.go | Provides a non-Windows stub for session detection. |
| internal/detector/browserext/gecko.go | Implements Firefox/Gecko profile discovery and extension inventory parsing from state files. |
| internal/detector/browserext/gecko_test.go | Adds extensive Gecko parsing, invariants, and edge-case tests. |
| internal/detector/browserext/fifo_unix_test.go | Provides Unix FIFO creation helper for hostile-object tests. |
| internal/detector/browserext/fifo_other_test.go | Provides a Windows stub for FIFO helper (skipping unsupported behavior). |
| internal/detector/browserext/detector.go | Orchestrates per-browser scanning, coverage commitment, refusal mapping, caps, and target-user resolution/decline logic. |
| internal/detector/browserext/detector_test.go | Adds broad end-to-end detector tests for invariants, caps, symlink refusal, consent behavior, determinism, and reduction rules. |
| internal/detector/browserext/chromium.go | Implements Chromium-family parsing (profiles, preferences, store disposition, enablement reasons, permissions/host grants). |
| internal/detector/browserext/catalog.go | Defines the browser catalog (currently Chrome, Edge, Firefox) and caps/budgets used by the detector. |
Suppressed comments (1)
internal/detector/browserext/detector.go:109
- On an unsupported platform (where none of the catalog roots apply), this returns a non-nil section with an empty
browserscoverage list. That violates the invariant enforced in smoke tests/PR description (section present => non-empty coverage list) and is risky for backend reconciliation.
scan.commit(spec.ID, d.scanBrowser(ctx, scan, spec, roots))
}
return info
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+272
to
+276
| log.StepStart("Inventorying browser extensions") | ||
| start = time.Now() | ||
| browserTarget, _ := exec.LoggedInUser() | ||
| browserExtensionScan := browserext.New(exec).WithSkipper(tccSkipper).Detect(ctx, browserTarget) | ||
| log.StepDone(time.Since(start)) |
Comment on lines
+1020
to
+1024
| phaseCtx, phaseCancel = startPhase(ctx, tracker, "browser_extensions_scan") | ||
| log.Progress("Inventorying browser extensions...") | ||
| browserTarget, _ := exec.LoggedInUser() | ||
| browserExtensionScan := browserext.New(userExec).WithSkipper(tccSkipper).Detect(phaseCtx, browserTarget) | ||
| if browserExtensionScan == nil { |
Comment on lines
+86
to
+90
| var catalog = []browserSpec{ | ||
| { | ||
| ID: browserChrome, | ||
| Engine: engineChromium, | ||
| Darwin: []string{"Library/Application Support/Google/Chrome"}, |
…' into feat/browser-extension-inventory
The comment justifying why an unpacked extension does not degrade the browser sat inside the branch that runs when the extension is not unpacked, which reads as though the condition is inverted. Move it above the branch and name the case it excludes.
ashishkurmi
approved these changes
Aug 19, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Inventories installed browser extensions across Chrome, Edge and Firefox, and reports per-browser coverage alongside the findings. Brave is deliberately out of the v1 catalog: a browser id the backend catalog does not carry gets the whole payload rejected, so browsers are added on the reader's schedule rather than the collector's.
Coverage answers whether the reported set of extension identities is complete for that browser:
scannedpartialfailednot_presentHow
Local State, extensions fromSecure PreferencesthenPreferences. Enabled state derives from thedisable_reasonsbitmask, which also gives the reason an extension is off (user, browser, policy).profiles.ini, extensions fromextensions.json, carrying the signature state instead of the store listing fields. The signature is reported as a signature and never as provenance, since a signed add-on may be self-distributed, so every state other than unsigned leaves the store unknown.manifest.jsonand_locales/<loc>/messages.jsonare attribute reads. If one is unreadable the finding is kept without its name and the browser degrades topartial; only a failed membership document fails the browser.openatchain withO_NOFOLLOWper component on unix; on Windows a single unfollowed open whose final path is verified against the resolved path. Windows therefore catches an ancestor junction after traversal rather than before, which is a stated ceiling shared with the existing credential scan.Testing
gofmt,go vet,go mod tidy(no drift),golangci-lint(0 issues),go test -race ./...(45/45),make smoke(45/45), andCGO_ENABLED=0cross-compiles for windows/amd64 and linux/amd64 all pass.gosecreports only pre-existing findings; none are in the added or changed lines.A golden payload under
internal/model/testdata/pins the wire shape: it round-trips withDisallowUnknownFields, exercises every enum value a single valid payload can carry, and asserts the coverage invariants a reader rejects the block over.Validated on macOS, Windows and Linux hosts: per-browser coverage, delisted rows and enablement reasons all correct, and no consent prompt on a macOS laptop without Full Disk Access.
Rollout
Not gated. The scan is generally available from its first release, which follows the credential inventory rather than the agent skills scan.
That makes deploy order a hard prerequisite instead of a switch we hold: the backend reader must be live in production before the agent release carrying this collector. A reader that does not know the block discards it silently, which reads as "the feature is live and this machine has no browser extensions", and a reader carrying a narrower catalog rejects the whole payload.