Skip to content

feat(browserext): add browser extension inventory scan - #192

Open
raysubham wants to merge 5 commits into
step-security:mainfrom
raysubham:feat/browser-extension-inventory
Open

feat(browserext): add browser extension inventory scan#192
raysubham wants to merge 5 commits into
step-security:mainfrom
raysubham:feat/browser-extension-inventory

Conversation

@raysubham

Copy link
Copy Markdown
Contributor

What

Inventories installed browser extensions across Chrome, Edge, Brave, Chromium and Firefox, and reports per-browser coverage alongside the findings.

Coverage answers whether the reported set of extension identities is complete for that browser:

status meaning
scanned authoritative and complete
partial authoritative, some metadata missing (carries a reason code)
failed not readable, ships zero findings so nothing stored is deleted
not_present no data directory, or one with no installation

How

  • Chromium — profiles from Local State, extensions from Secure Preferences then Preferences. Enabled state derives from the disable_reasons bitmask, which also gives the reason an extension is off (user, browser, policy).
  • Gecko — profiles from profiles.ini, extensions from extensions.json, carrying the signature state instead of the store listing fields.
  • Metadata vs membershipmanifest.json and _locales/<loc>/messages.json are attribute reads. If one is unreadable the finding is kept without its name and the browser degrades to partial; only a failed membership document fails the browser.
  • safepath no-follow mode — an openat chain with O_NOFOLLOW per 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.
  • Limits — every read is byte-capped and the phase carries its own deadline. Per-browser and per-profile caps bound the browser that overflows and scanning continues into the next; the total finding cap and the deadline end the payload.

Testing

gofmt, go vet, go mod tidy (no drift), golangci-lint (0 issues), go test -race ./... (45/45), make smoke (45/45), and CGO_ENABLED=0 cross-compiles for windows/amd64 and linux/amd64 all pass. gosec reports 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 with DisallowUnknownFields, exercises every enum value a single valid payload can carry, and asserts the coverage invariants a reader rejects the block over.

Validated on a macOS laptop with no Full Disk Access: Chrome and Brave detected, delisted rows correct, no prompts.

Rollout

Behind the browser_extensions_scan feature gate, off by default. The backend half lands separately and must deploy first.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant