diff --git a/internal/sbom/chrome/preferences.go b/internal/sbom/chrome/preferences.go new file mode 100644 index 00000000..0e6cb009 --- /dev/null +++ b/internal/sbom/chrome/preferences.go @@ -0,0 +1,71 @@ +package chrome + +import ( + "encoding/json" + "log" + "os" + "path/filepath" + "strings" +) + +// Chrome persists extension state in two JSON files at the root of each profile: +// - Preferences: regular user-installed extensions +// - Secure Preferences: policy/force-installed extensions +type extensionSetting struct { + State *int `json:"state"` + DisableReasons []int `json:"disable_reasons"` +} + +type preferencesFile struct { + Extensions struct { + Settings map[string]extensionSetting `json:"settings"` + } `json:"extensions"` +} + +func isValidChromeProfileDir(profileDir string) bool { + cleanDir := filepath.Clean(profileDir) + name := filepath.Base(cleanDir) + if name != "Default" && !strings.HasPrefix(name, "Profile ") { + return false + } + + parent := filepath.Base(filepath.Dir(cleanDir)) + return parent != "." && parent != string(filepath.Separator) +} + +// readProfileExtensionStates returns a map from extension ID to true (enabled) +// or false (disabled) for extensions explicitly listed in the profile's +// Preferences or Secure Preferences. Secure Preferences wins on conflict. +func readProfileExtensionStates(profileDir string) map[string]bool { + states := map[string]bool{} + if !isValidChromeProfileDir(profileDir) { + log.Printf("Skipping Chrome preference scan for unexpected profile directory: %s", profileDir) + return states + } + + for _, name := range []string{"Preferences", "Secure Preferences"} { + path := filepath.Join(filepath.Clean(profileDir), name) + data, err := os.ReadFile(path) + if err != nil { + continue + } + + var prefs preferencesFile + if err := json.Unmarshal(data, &prefs); err != nil { + log.Printf("Failed to parse %s at %s: %v", name, profileDir, err) + continue + } + + for id, setting := range prefs.Extensions.Settings { + if len(setting.DisableReasons) > 0 { + states[id] = false + continue + } + if setting.State != nil { + states[id] = *setting.State == 1 + } + } + } + + return states +} diff --git a/internal/sbom/chrome/sbom.go b/internal/sbom/chrome/sbom.go index b66a5a7c..7bd6a5c4 100644 --- a/internal/sbom/chrome/sbom.go +++ b/internal/sbom/chrome/sbom.go @@ -27,20 +27,30 @@ func (c *ChromeExtensions) Installations(ctx context.Context) ([]sbom.InstalledV // SBOM scans all profiles within the browser data directory (DataPath) // and reports every installed version of each extension, deduplicating // identical (id, version) pairs that appear across multiple profiles. +// +// Each package carries a State of "enabled" or "disabled" reflecting the +// per-profile Preferences / Secure Preferences metadata. An extension enabled +// in any profile where it is installed is reported as enabled. Missing state +// entries still fail open to enabled so that extensions on disk but unknown to +// Chrome's metadata continue to surface in the SBOM. func (c *ChromeExtensions) SBOM(_ context.Context, installation sbom.InstalledVersion) ([]sbom.Package, error) { profiles := findProfilesWithExtensions(installation.DataPath) seen := make(map[string]bool) + enabledSomewhere := make(map[string]bool) var packages []sbom.Package for _, profile := range profiles { - extDir := filepath.Join(installation.DataPath, profile, "Extensions") + profileDir := filepath.Join(installation.DataPath, profile) + extDir := filepath.Join(profileDir, "Extensions") entries, err := os.ReadDir(extDir) if err != nil { log.Printf("Failed to read extensions directory for profile %s: %v", profile, err) continue } + states := readProfileExtensionStates(profileDir) + for _, entry := range entries { if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { continue @@ -49,6 +59,11 @@ func (c *ChromeExtensions) SBOM(_ context.Context, installation sbom.InstalledVe extensionID := entry.Name() extPath := filepath.Join(extDir, extensionID) + enabled, known := states[extensionID] + if !known || enabled { + enabledSomewhere[extensionID] = true + } + for _, pkg := range readExtensionVersions(extPath, extensionID) { key := pkg.Id + "@" + pkg.Version if seen[key] { @@ -60,5 +75,13 @@ func (c *ChromeExtensions) SBOM(_ context.Context, installation sbom.InstalledVe } } + for i := range packages { + if enabledSomewhere[packages[i].Id] { + packages[i].State = "enabled" + } else { + packages[i].State = "disabled" + } + } + return packages, nil } diff --git a/internal/sbom/chrome/sbom_test.go b/internal/sbom/chrome/sbom_test.go index 887b0562..f4b0758a 100644 --- a/internal/sbom/chrome/sbom_test.go +++ b/internal/sbom/chrome/sbom_test.go @@ -2,6 +2,7 @@ package chrome import ( "context" + "encoding/json" "os" "path/filepath" "testing" @@ -32,6 +33,57 @@ func addExtension(t *testing.T, dataDir, profile, extensionID, version, manifest } } +// writePreferences writes a Chrome Preferences (or Secure Preferences) file at +// the root of the given profile. states maps extension IDs to their Chrome +// state value (1 = enabled, 0 = disabled, etc.). +func writePreferences(t *testing.T, dataDir, profile, filename string, states map[string]int) { + t.Helper() + settings := map[string]map[string]any{} + for id, state := range states { + settings[id] = map[string]any{"state": state} + } + payload := map[string]any{ + "extensions": map[string]any{ + "settings": settings, + }, + } + data, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(dataDir, profile, filename) + if err := os.WriteFile(path, data, 0644); err != nil { + t.Fatal(err) + } +} + +func writePreferencesWithDisableReasons( + t *testing.T, + dataDir, profile, filename string, + disableReasons map[string][]int, +) { + t.Helper() + settings := map[string]map[string]any{} + for id, reasons := range disableReasons { + settings[id] = map[string]any{ + "disable_reasons": reasons, + } + } + payload := map[string]any{ + "extensions": map[string]any{ + "settings": settings, + }, + } + data, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(dataDir, profile, filename) + if err := os.WriteFile(path, data, 0644); err != nil { + t.Fatal(err) + } +} + func TestSBOM(t *testing.T) { dataDir := setupBrowserDataDir(t) @@ -190,6 +242,23 @@ func TestSBOMLocalizedName(t *testing.T) { } } +func TestReadProfileExtensionStatesRejectsUnexpectedProfileDir(t *testing.T) { + tmpDir := t.TempDir() + profileDir := filepath.Join(tmpDir, "NotAProfile") + if err := os.MkdirAll(profileDir, 0755); err != nil { + t.Fatal(err) + } + + writePreferences(t, tmpDir, "NotAProfile", "Preferences", map[string]int{ + "abcdefghijklmnopqrstuvwxyzzzzzzz": 1, + }) + + states := readProfileExtensionStates(profileDir) + if len(states) != 0 { + t.Fatalf("expected no states for unexpected profile dir, got %v", states) + } +} + func TestSBOMFallsBackToExtensionIDWhenNameEmpty(t *testing.T) { dataDir := setupBrowserDataDir(t) extID := "noname_extension_id_1234567890ab" @@ -344,3 +413,213 @@ func TestFindProfilesNonExistentDir(t *testing.T) { t.Fatalf("expected 0 profiles, got %d", len(profiles)) } } + +func runSBOM(t *testing.T, dataDir string) []sbom.Package { + t.Helper() + c := &ChromeExtensions{} + packages, err := c.SBOM(context.Background(), sbom.InstalledVersion{DataPath: dataDir}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return packages +} + +func TestSBOMReportsEnabledStateFromPreferences(t *testing.T) { + dataDir := setupBrowserDataDir(t) + extID := "enabledextensionid1234567890abcd" + + addExtension(t, dataDir, "Default", extID, "1.0.0", `{"name": "Enabled", "version": "1.0.0"}`) + writePreferences(t, dataDir, "Default", "Preferences", map[string]int{extID: 1}) + + packages := runSBOM(t, dataDir) + if len(packages) != 1 { + t.Fatalf("expected 1 package, got %d", len(packages)) + } + if packages[0].State != "enabled" { + t.Errorf("expected State 'enabled', got %q", packages[0].State) + } +} + +func TestSBOMReportsDisabledStateFromPreferences(t *testing.T) { + dataDir := setupBrowserDataDir(t) + extID := "disabledextensionid123456789abcd" + + addExtension(t, dataDir, "Default", extID, "1.0.0", `{"name": "Disabled", "version": "1.0.0"}`) + writePreferences(t, dataDir, "Default", "Preferences", map[string]int{extID: 0}) + + packages := runSBOM(t, dataDir) + if len(packages) != 1 { + t.Fatalf("expected 1 package, got %d", len(packages)) + } + if packages[0].State != "disabled" { + t.Errorf("expected State 'disabled', got %q", packages[0].State) + } +} + +func TestSBOMStateDefaultsToEnabledWhenPreferencesMissing(t *testing.T) { + dataDir := setupBrowserDataDir(t) + extID := "noprefsextensionid1234567890abcd" + + addExtension(t, dataDir, "Default", extID, "1.0.0", `{"name": "No Prefs", "version": "1.0.0"}`) + + packages := runSBOM(t, dataDir) + if len(packages) != 1 { + t.Fatalf("expected 1 package, got %d", len(packages)) + } + if packages[0].State != "enabled" { + t.Errorf("expected State 'enabled' (fail-open), got %q", packages[0].State) + } +} + +func TestSBOMStateDefaultsToEnabledWhenEntryMissing(t *testing.T) { + dataDir := setupBrowserDataDir(t) + extID := "unlistedextensionid1234567890abc" + otherID := "otherextensionid12345678901234ab" + + addExtension(t, dataDir, "Default", extID, "1.0.0", `{"name": "Unlisted", "version": "1.0.0"}`) + writePreferences(t, dataDir, "Default", "Preferences", map[string]int{otherID: 0}) + + packages := runSBOM(t, dataDir) + if len(packages) != 1 { + t.Fatalf("expected 1 package, got %d", len(packages)) + } + if packages[0].State != "enabled" { + t.Errorf("expected State 'enabled' (entry missing), got %q", packages[0].State) + } +} + +func TestSBOMStateEnabledInOneProfileWins(t *testing.T) { + dataDir := t.TempDir() + extID := "sharedextensionid12345678901234a" + + for _, profile := range []string{"Default", "Profile 1"} { + if err := os.MkdirAll(filepath.Join(dataDir, profile, "Extensions"), 0755); err != nil { + t.Fatal(err) + } + } + + addExtension(t, dataDir, "Default", extID, "1.0.0", `{"name": "Shared", "version": "1.0.0"}`) + addExtension(t, dataDir, "Profile 1", extID, "1.0.0", `{"name": "Shared", "version": "1.0.0"}`) + writePreferences(t, dataDir, "Default", "Preferences", map[string]int{extID: 0}) + writePreferences(t, dataDir, "Profile 1", "Preferences", map[string]int{extID: 1}) + + packages := runSBOM(t, dataDir) + if len(packages) != 1 { + t.Fatalf("expected 1 package (deduplicated), got %d", len(packages)) + } + if packages[0].State != "enabled" { + t.Errorf("expected State 'enabled' (enabled in any profile wins), got %q", packages[0].State) + } +} + +func TestSBOMStateDisabledInAllProfiles(t *testing.T) { + dataDir := t.TempDir() + extID := "offeverywhereid123456789012345ab" + + for _, profile := range []string{"Default", "Profile 1"} { + if err := os.MkdirAll(filepath.Join(dataDir, profile, "Extensions"), 0755); err != nil { + t.Fatal(err) + } + } + + addExtension(t, dataDir, "Default", extID, "1.0.0", `{"name": "Off", "version": "1.0.0"}`) + addExtension(t, dataDir, "Profile 1", extID, "1.0.0", `{"name": "Off", "version": "1.0.0"}`) + writePreferences(t, dataDir, "Default", "Preferences", map[string]int{extID: 0}) + writePreferences(t, dataDir, "Profile 1", "Preferences", map[string]int{extID: 0}) + + packages := runSBOM(t, dataDir) + if len(packages) != 1 { + t.Fatalf("expected 1 package, got %d", len(packages)) + } + if packages[0].State != "disabled" { + t.Errorf("expected State 'disabled', got %q", packages[0].State) + } +} + +func TestSBOMStateReadFromSecurePreferences(t *testing.T) { + dataDir := setupBrowserDataDir(t) + extID := "policyinstalledextid12345678901" + + addExtension(t, dataDir, "Default", extID, "1.0.0", `{"name": "Policy", "version": "1.0.0"}`) + writePreferences(t, dataDir, "Default", "Secure Preferences", map[string]int{extID: 0}) + + packages := runSBOM(t, dataDir) + if len(packages) != 1 { + t.Fatalf("expected 1 package, got %d", len(packages)) + } + if packages[0].State != "disabled" { + t.Errorf("expected State 'disabled' from Secure Preferences, got %q", packages[0].State) + } +} + +func TestSBOMStateReadFromSecurePreferencesDisableReasons(t *testing.T) { + dataDir := setupBrowserDataDir(t) + extID := "disabledbyreasons12345678901234" + + addExtension(t, dataDir, "Default", extID, "1.0.0", `{"name": "Disabled", "version": "1.0.0"}`) + writePreferencesWithDisableReasons(t, dataDir, "Default", "Secure Preferences", map[string][]int{ + extID: {1}, + }) + + packages := runSBOM(t, dataDir) + if len(packages) != 1 { + t.Fatalf("expected 1 package, got %d", len(packages)) + } + if packages[0].State != "disabled" { + t.Errorf("expected State 'disabled' from disable_reasons, got %q", packages[0].State) + } +} + +func TestSBOMStateSecurePreferencesOverridesPreferences(t *testing.T) { + dataDir := setupBrowserDataDir(t) + extID := "overrideextid1234567890123456789" + + addExtension(t, dataDir, "Default", extID, "1.0.0", `{"name": "Override", "version": "1.0.0"}`) + writePreferences(t, dataDir, "Default", "Preferences", map[string]int{extID: 1}) + writePreferences(t, dataDir, "Default", "Secure Preferences", map[string]int{extID: 0}) + + packages := runSBOM(t, dataDir) + if len(packages) != 1 { + t.Fatalf("expected 1 package, got %d", len(packages)) + } + if packages[0].State != "disabled" { + t.Errorf("expected State 'disabled' (Secure Preferences wins), got %q", packages[0].State) + } +} + +func TestSBOMStateBlocklistedTreatedAsDisabled(t *testing.T) { + dataDir := setupBrowserDataDir(t) + extID := "blocklistedextid1234567890abcdef" + + addExtension(t, dataDir, "Default", extID, "1.0.0", `{"name": "Blocked", "version": "1.0.0"}`) + // Chrome uses state values like 3 (blocklisted) or 6 (blocked by policy) + // for extensions that are installed but not runnable. Anything other + // than 1 collapses to "disabled" in the SBOM. + writePreferences(t, dataDir, "Default", "Preferences", map[string]int{extID: 3}) + + packages := runSBOM(t, dataDir) + if len(packages) != 1 { + t.Fatalf("expected 1 package, got %d", len(packages)) + } + if packages[0].State != "disabled" { + t.Errorf("expected State 'disabled' for blocklisted extension, got %q", packages[0].State) + } +} + +func TestSBOMStateMalformedPreferencesFailsOpen(t *testing.T) { + dataDir := setupBrowserDataDir(t) + extID := "malformedprefsextid123456789abcd" + + addExtension(t, dataDir, "Default", extID, "1.0.0", `{"name": "Malformed", "version": "1.0.0"}`) + if err := os.WriteFile(filepath.Join(dataDir, "Default", "Preferences"), []byte("{not json"), 0644); err != nil { + t.Fatal(err) + } + + packages := runSBOM(t, dataDir) + if len(packages) != 1 { + t.Fatalf("expected 1 package, got %d", len(packages)) + } + if packages[0].State != "enabled" { + t.Errorf("expected State 'enabled' (fail-open), got %q", packages[0].State) + } +} diff --git a/internal/sbom/package_manager.go b/internal/sbom/package_manager.go index 9a762a12..2deb1988 100644 --- a/internal/sbom/package_manager.go +++ b/internal/sbom/package_manager.go @@ -16,6 +16,7 @@ type Package struct { Id string `json:"id"` Name string `json:"name,omitempty"` Version string `json:"version"` + State string `json:"state,omitempty"` } // PackageManager represents a package ecosystem (e.g. "npm", "pip").