Skip to content

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

Merged
ashishkurmi merged 15 commits into
step-security:mainfrom
raysubham:feat/browser-extension-inventory
Aug 19, 2026
Merged

ashishkurmi merged 15 commits into
step-security:mainfrom
raysubham:feat/browser-extension-inventory

Conversation

@raysubham

@raysubham raysubham commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

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:

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. 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.
  • Metadata vs membership - manifest.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 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.

raysubham and others added 13 commits August 16, 2026 14:52
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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/browserext to 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 new browser_extension_scan section, including golden/shape tests.
  • Enhances internal/safepath with NewNoFollow behavior 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 browsers coverage 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 thread internal/scan/scanner.go
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"},
Comment thread internal/detector/browserext/detector.go
Comment thread internal/detector/browserext/chromium.go
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
ashishkurmi merged commit 458a507 into step-security:main Aug 19, 2026
12 checks passed
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.

3 participants