From 4ccbc9b67da13c853b88ad454f0c30474af3e2c7 Mon Sep 17 00:00:00 2001 From: ofirgalcon Date: Mon, 10 Mar 2025 10:04:24 +0000 Subject: [PATCH 1/3] Safari support, tabbed display * List button in tab * Safari extensions support (see readme) * Browser profiles support * Improved UI with a tabbed display --- README.md | 69 ++++- browser_extensions_controller.php | 3 +- browser_extensions_factory.php | 1 + browser_extensions_model.php | 1 + locales/en.json | 1 + ..._000001_browser_extensions_add_profile.php | 28 ++ scripts/browser_extensions.py | 234 +++++++++++++++-- views/browser_extensions_listing.yml | 1 + views/browser_extensions_tab.php | 247 ++++++++++++------ 9 files changed, 466 insertions(+), 119 deletions(-) create mode 100644 migrations/2025_03_09_000001_browser_extensions_add_profile.php diff --git a/README.md b/README.md index b700749..287ba92 100755 --- a/README.md +++ b/README.md @@ -1,27 +1,68 @@ Browser Extensions module ============== -Browser extensions module for MunkiReport. Reports on users' installed extensions for Firefox, Google Chrome and Microsoft Edge. +Browser extensions module for MunkiReport. Reports on users' installed extensions for Firefox, Google Chrome, Microsoft Edge, and Safari. -### Config Options +### Configuration Options -Module contains two config options to filter reported extensions. By default, the default installed Firefox/Google Chrome/Microsoft Edge extensions are not reported. Filtering can be done by extension name or ID using the `BROWSER_EXTENSION_ID_IGNORELIST` and `BROWSER_EXTENSION_NAME_IGNORELIST` in the .env file. +The module provides configuration options to filter out unwanted browser extensions from your reports. By default, common system extensions for Firefox, Google Chrome, and Microsoft Edge are not reported. -#### Note: Does NOT report on Safari extensions as they are in a protected folder and require TCC permissions to access. +#### Environment Variables Configuration +To customize which extensions are filtered out, add the following variables to your `.env` file: + +``` +# Filter extensions by ID (comma-separated list of extension IDs) +BROWSER_EXTENSION_ID_IGNORELIST=["extension_id_1","extension_id_2"] + +# Filter extensions by name (comma-separated list of extension names) +BROWSER_EXTENSION_NAME_IGNORELIST=["Extension Name 1","Extension Name 2"] +``` + +#### Default Ignored Extensions + +The module comes pre-configured to ignore common system extensions: + +- Chrome Web Store Payments (`nmmhkkegccagdldgiimedpiccmgmieda`) +- Chrome Media Router (`pkedcjkdefgpdelpbcmbmeomcjbeemfm`) +- Various Firefox system add-ons (e.g., `default-theme@mozilla.org`, `screenshots@mozilla.org`) + +### Safari Extensions Permissions + +Safari extensions may not be available on all systems due to permission restrictions. To enable Safari extension reporting: + +1. **Full Disk Access**: Grant Full Disk Access to the MunkiReport Python executable: + - Open System Preferences/Settings > Security & Privacy/Privacy > Full Disk Access + - Add `/usr/local/munkireport/munkireport-python3` to the list of allowed applications + +2. **MDM Configuration**: For managed devices, you can use an MDM solution to grant the necessary permissions: + - Create a Privacy Preferences Policy Control (PPPC) profile + - Allow Full Disk Access for `/usr/local/munkireport/munkireport-python3` + - Deploy the profile to your managed devices + +These permissions are required because Safari extensions are stored in protected locations that require special access privileges to read. + +### Features + +* Displays browser extensions organized alphabetically by browser type (Chrome, Edge, Firefox, Safari) +* Extensions within each browser category are sorted alphabetically by name +* Supports browser profiles, showing which profile each extension belongs to +* Deduplicates extensions to avoid showing the same extension multiple times +* Shows detailed information about each extension including version, installation date, and description +* Tracks extension installation paths for troubleshooting and management Table Schema ----- Database: -* name - varchar(255) - Name of extension -* extension_id - varchar(255) - Extension ID -* version - varchar(255) - Extension version -* description - text - Extension's description -* browser - varchar(255) - Firefox or Google Chrome or Microsoft Edge -* date_installed - bigint - Date extension was updated/installed -* developer - varchar(255) - Name of extension developer, Firefox only +* name - varchar(255) - name of extension +* extension_id - varchar(255) - extension ID +* version - varchar(255) - extension version +* description - text - extension's description +* browser - varchar(255) - Firefox, Google Chrome, Microsoft Edge, or Safari +* profile - varchar(255) - browser profile that contains the extension (e.g., Default, Profile 1) +* date_installed - bigint - date extension was updated/installed +* developer - varchar(255) - name of extension developer, Firefox only * enabled - boolean - If extension is enabled, Firefox only -* user - varchar(255) - User profile that contains extension -* extension_path - text - Path to extension folder - +* user - varchar(255) - user profile that contains extension +* extension_path - text - file system path to the extension files diff --git a/browser_extensions_controller.php b/browser_extensions_controller.php index 4c08db5..f92e4d1 100755 --- a/browser_extensions_controller.php +++ b/browser_extensions_controller.php @@ -33,8 +33,9 @@ function index() public function get_data($serial_number) { jsonView( - Browser_extensions_model::selectRaw('name, version, extension_id, user, browser, date_installed, developer, enabled, extension_path, description') + Browser_extensions_model::selectRaw('name, version, extension_id, user, browser, profile, date_installed, developer, enabled, description, extension_path') ->where('browser_extensions.serial_number', $serial_number) + ->orderBy('name', 'asc') ->filter() ->get() ->toArray() diff --git a/browser_extensions_factory.php b/browser_extensions_factory.php index e64d136..b282188 100644 --- a/browser_extensions_factory.php +++ b/browser_extensions_factory.php @@ -11,6 +11,7 @@ 'description' => $faker->company(), 'developer' => $faker->word(), 'user' => $faker->word(), + 'profile' => $faker->randomElement(['Default', 'Profile 1', 'Profile 2']), 'extension_path' => $faker->word(), ]; }); diff --git a/browser_extensions_model.php b/browser_extensions_model.php index 93e8992..a2a7674 100755 --- a/browser_extensions_model.php +++ b/browser_extensions_model.php @@ -13,6 +13,7 @@ class Browser_extensions_model extends Eloquent 'version', 'description', 'browser', + 'profile', 'date_installed', 'developer', 'enabled', diff --git a/locales/en.json b/locales/en.json index 246739c..ba0c0b1 100755 --- a/locales/en.json +++ b/locales/en.json @@ -6,6 +6,7 @@ "version": "Version", "description": "Description", "browser": "Browser", + "profile": "Profile", "date_installed": "Install Date", "developer": "Developer", "enabled": "Enabled", diff --git a/migrations/2025_03_09_000001_browser_extensions_add_profile.php b/migrations/2025_03_09_000001_browser_extensions_add_profile.php new file mode 100644 index 0000000..957100e --- /dev/null +++ b/migrations/2025_03_09_000001_browser_extensions_add_profile.php @@ -0,0 +1,28 @@ +table($this->tableName, function (Blueprint $table) { + $table->string('profile')->nullable()->after('browser'); + $table->index('profile'); + }); + } + + public function down() + { + $capsule = new Capsule(); + + $capsule::schema()->table($this->tableName, function (Blueprint $table) { + $table->dropColumn('profile'); + }); + } +} \ No newline at end of file diff --git a/scripts/browser_extensions.py b/scripts/browser_extensions.py index 07d2a94..4a21e08 100755 --- a/scripts/browser_extensions.py +++ b/scripts/browser_extensions.py @@ -30,7 +30,7 @@ def get_users(): return users -def process_chrome(chrome_extension, user, browser): +def process_chrome(chrome_extension, user, browser, profile=None): extension_manifest = json.loads(open(chrome_extension, 'r').read().strip()) @@ -69,14 +69,21 @@ def process_chrome(chrome_extension, user, browser): extension_info['user'] = user extension_info['date_installed'] = str(int(os.path.getmtime(chrome_extension))) extension_info['browser'] = browser - + + # Store profile in its own field + if profile: + extension_info['profile'] = profile + + # Store extension path extension_info['extension_path'] = chrome_extension.replace("manifest.json","") + return extension_info -def process_firefox(firefox_extension, user, firefox_extension_path): +def process_firefox(firefox_extension, user, firefox_extension_path, profile=None): extension_info = {} - + + # Store extension path extension_info['extension_path'] = firefox_extension_path for item in firefox_extension: @@ -91,7 +98,7 @@ def process_firefox(firefox_extension, user, firefox_extension_path): elif item == "id": extension_info['extension_id'] = firefox_extension[item] - + elif item == "path": if firefox_extension[item] is None: extension_info['extension_path'] = firefox_extension_path.replace("extensions.json","") @@ -110,38 +117,223 @@ def process_firefox(firefox_extension, user, firefox_extension_path): extension_info['user'] = user extension_info['browser'] = "Firefox" - + + # Store profile in its own field + if profile: + extension_info['profile'] = profile + return extension_info +def process_safari(safari_plist_path, user): + """Process Safari extensions from the Extensions.plist file""" + + extensions_list = [] + + try: + # Read the plist file - handle the specific "stream had too few bytes" error + try: + safari_extensions = FoundationPlist.readPlist(safari_plist_path) + except Exception as read_error: + # If we get the specific "stream had too few bytes" error, just return empty list without logging + if "stream had too few bytes" in str(read_error): + return extensions_list + # Otherwise, re-raise the exception + raise + + # Process each extension in the plist + for extension_id, extension_data in safari_extensions.items(): + extension_info = {} + + # Keep the original extension ID + extension_info['extension_id'] = extension_id + + # Extract name from the bundle identifier (com.example.extension -> extension) + # Try to get a more user-friendly name + clean_id = extension_id.split(' (')[0] if ' (' in extension_id else extension_id + name_parts = clean_id.split('.') + if len(name_parts) > 1: + # Get the last meaningful part of the bundle ID + extension_info['name'] = name_parts[-2].capitalize() + else: + extension_info['name'] = clean_id + + # Check if extension is enabled + if 'Enabled' in extension_data: + extension_info['enabled'] = extension_data['Enabled'] + + # Get installation date + if 'AddedDate' in extension_data: + # Convert date to timestamp + try: + # For __NSTaggedDate objects, convert to string first + date_obj = extension_data['AddedDate'] + if hasattr(date_obj, 'timeIntervalSince1970'): + # Use timeIntervalSince1970 method if available + extension_info['date_installed'] = str(int(date_obj.timeIntervalSince1970())) + elif hasattr(date_obj, 'strftime'): + # If it has strftime, use it to get timestamp + import time + extension_info['date_installed'] = str(int(time.mktime(date_obj.strftime("%Y-%m-%d %H:%M:%S")))) + else: + # Convert to string and parse + import time + import datetime + date_str = str(date_obj) + try: + # Try to parse ISO format date string + dt = datetime.datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S %z") + extension_info['date_installed'] = str(int(time.mktime(dt.timetuple()))) + except: + # If all else fails, use file modification time + extension_info['date_installed'] = str(int(os.path.getmtime(safari_plist_path))) + except: + # Use file modification time as fallback + extension_info['date_installed'] = str(int(os.path.getmtime(safari_plist_path))) + else: + # Use file modification time if no date is available + extension_info['date_installed'] = str(int(os.path.getmtime(safari_plist_path))) + + # Set browser and user info + extension_info['browser'] = "Safari" + extension_info['user'] = user + + # Add description based on WebsiteAccess information if available + description = "" + if 'WebsiteAccess' in extension_data: + website_access = extension_data['WebsiteAccess'] + if 'Level' in website_access: + access_level = website_access['Level'] + description += f"Access Level: {access_level}. " + + if 'Has Injected Content' in website_access: + has_injected = website_access['Has Injected Content'] + if has_injected: + description += "Can inject content into websites. " + + if 'Allowed Domains' in website_access and website_access['Allowed Domains']: + domains = website_access['Allowed Domains'] + if domains: + description += f"Allowed on {len(domains)} specific domains." + + # If we have permission information, add it to the description + if 'GrantedPermissionOrigins' in extension_data and extension_data['GrantedPermissionOrigins']: + permissions = extension_data['GrantedPermissionOrigins'] + if permissions: + if description: + description += " " + description += f"Has {len(permissions)} granted permissions." + + # If we still don't have a description, use a generic one + if not description: + description = "Safari extension" + + extension_info['description'] = description + + # Add to the list + extensions_list.append(extension_info) + + except Exception as e: + # Only log serious errors, not just empty files + if "stream had too few bytes" not in str(e): + print(f"Error processing Safari extensions: {str(e)}") + + return extensions_list + def process_browsers(users): if users == "": return [] - out = [] + # First collect all extensions with deduplication for Chrome, Edge, and Firefox + unique_extensions = {} + for user in users: - # Check for Chrome extensions - chrome_extension_path = user+"/Library/Application Support/Google/Chrome/Default/Extensions/" - if os.path.isdir(chrome_extension_path): - for chrome_extension in glob.glob(chrome_extension_path+'*/*/manifest.json'): - out.append(process_chrome(chrome_extension, user.replace("/Users/",""), "Google Chrome")) - - # Check for Edge extensions - edge_extension_path = user+"/Library/Application Support/Microsoft Edge/Default/Extensions/" - if os.path.isdir(edge_extension_path): - for edge_extension in glob.glob(edge_extension_path+'*/*/manifest.json'): - out.append(process_chrome(edge_extension, user.replace("/Users/",""), "Microsoft Edge")) + # Check for Chrome extensions in all profiles + chrome_base_path = user+"/Library/Application Support/Google/Chrome/" + if os.path.isdir(chrome_base_path): + # Get all profile directories (Default and any named profiles) + chrome_profiles = [d for d in os.listdir(chrome_base_path) + if os.path.isdir(os.path.join(chrome_base_path, d)) + and (d == "Default" or d.startswith("Profile"))] + + for profile in chrome_profiles: + chrome_extension_path = os.path.join(chrome_base_path, profile, "Extensions") + if os.path.isdir(chrome_extension_path): + # Fix the glob pattern by adding a trailing slash and proper path joining + for chrome_extension in glob.glob(os.path.join(chrome_extension_path, "*", "*", "manifest.json")): + profile_info = "Default" if profile == "Default" else profile + extension_data = process_chrome(chrome_extension, user.replace("/Users/",""), "Google Chrome", profile_info) + + # Create a unique key for this extension + unique_key = f"{extension_data['user']}|{extension_data['browser']}|{extension_data['profile']}|{extension_data['extension_id']}" + + # Only add if we haven't seen this extension before, or if it's newer + if unique_key not in unique_extensions or int(extension_data['date_installed']) > int(unique_extensions[unique_key]['date_installed']): + unique_extensions[unique_key] = extension_data + + # Check for Edge extensions in all profiles + edge_base_path = user+"/Library/Application Support/Microsoft Edge/" + if os.path.isdir(edge_base_path): + # Get all profile directories (Default and any named profiles) + edge_profiles = [d for d in os.listdir(edge_base_path) + if os.path.isdir(os.path.join(edge_base_path, d)) + and (d == "Default" or d.startswith("Profile"))] + + for profile in edge_profiles: + edge_extension_path = os.path.join(edge_base_path, profile, "Extensions") + if os.path.isdir(edge_extension_path): + # Fix the glob pattern by adding a trailing slash and proper path joining + for edge_extension in glob.glob(os.path.join(edge_extension_path, "*", "*", "manifest.json")): + profile_info = "Default" if profile == "Default" else profile + extension_data = process_chrome(edge_extension, user.replace("/Users/",""), "Microsoft Edge", profile_info) + + # Create a unique key for this extension + unique_key = f"{extension_data['user']}|{extension_data['browser']}|{extension_data['profile']}|{extension_data['extension_id']}" + + # Only add if we haven't seen this extension before, or if it's newer + if unique_key not in unique_extensions or int(extension_data['date_installed']) > int(unique_extensions[unique_key]['date_installed']): + unique_extensions[unique_key] = extension_data # Check for Firefox extensions firefox_path = user+"/Library/Application Support/Firefox/Profiles/" if os.path.isdir(firefox_path): for firefox_extension_json_path in glob.glob(firefox_path+'*/extensions.json'): + # Extract profile name from path + profile_name = firefox_extension_json_path.split('/')[-2] firefox_extension_json = json.loads(open(firefox_extension_json_path, 'r').read().strip()) for firefox_extension in firefox_extension_json['addons']: - if "path" in firefox_extension and firefox_extension["path"] is not None and "/Applications/Firefox.app/Contents/Resources/browser/features/" not in firefox_extension["path"]: - out.append(process_firefox(firefox_extension, user.replace("/Users/",""), firefox_extension_json_path)) - + extension_data = process_firefox(firefox_extension, user.replace("/Users/",""), firefox_extension_json_path, profile_name) + + # Create a unique key for this extension + unique_key = f"{extension_data['user']}|{extension_data['browser']}|{extension_data['profile']}|{extension_data['extension_id']}" + + # Only add if we haven't seen this extension before, or if it's newer + if unique_key not in unique_extensions or int(extension_data['date_installed']) > int(unique_extensions[unique_key]['date_installed']): + unique_extensions[unique_key] = extension_data + + # Check for Safari extensions - using the original approach + safari_extension_path = user+"/Library/Containers/com.apple.Safari/Data/Library/Safari/AppExtensions/Extensions.plist" + if os.path.isfile(safari_extension_path): + try: + safari_extensions = process_safari(safari_extension_path, user.replace("/Users/","")) + # Add profile field for consistency with our new approach + for extension in safari_extensions: + extension['profile'] = "Default" + # Add Safari extensions to the unique extensions dictionary + for extension in safari_extensions: + # Create a unique key for this extension + unique_key = f"{extension['user']}|{extension['browser']}|{extension['profile']}|{extension['extension_id']}" + # Only add if we haven't seen this extension before, or if it's newer + if unique_key not in unique_extensions or int(extension['date_installed']) > int(unique_extensions[unique_key]['date_installed']): + unique_extensions[unique_key] = extension + except Exception as e: + # Only log serious errors, not just empty files + if "stream had too few bytes" not in str(e): + print(f"Error processing Safari extensions for {user}: {str(e)}") + + # Convert the dictionary of unique extensions to a list + out = list(unique_extensions.values()) return out def main(): diff --git a/views/browser_extensions_listing.yml b/views/browser_extensions_listing.yml index 0b37a04..f709cbc 100755 --- a/views/browser_extensions_listing.yml +++ b/views/browser_extensions_listing.yml @@ -12,6 +12,7 @@ table: - {i18n_header: browser_extensions.extension_id, column: browser_extensions.extension_id} - {i18n_header: browser_extensions.user, column: browser_extensions.user} - {i18n_header: browser_extensions.browser, column: browser_extensions.browser} + - {i18n_header: browser_extensions.profile, column: browser_extensions.profile} - {i18n_header: browser_extensions.date_installed, column: browser_extensions.date_installed, formatter: timestampToMoment} - {i18n_header: browser_extensions.developer, column: browser_extensions.developer} - {i18n_header: browser_extensions.enabled, column: browser_extensions.enabled, formatter: binaryYesNo} diff --git a/views/browser_extensions_tab.php b/views/browser_extensions_tab.php index 973d212..b80ea55 100755 --- a/views/browser_extensions_tab.php +++ b/views/browser_extensions_tab.php @@ -1,94 +1,175 @@
-

+
+ + + +
+

+ +
+ + +
+
+
+
+
+
+
+ From f69a398595503dd4b586d15d1f8f30f026764f68 Mon Sep 17 00:00:00 2001 From: ofirgalcon Date: Mon, 10 Mar 2025 18:35:33 +0000 Subject: [PATCH 2/3] Update browser_extensions.py * better safari extension search * enabled status for chrome and edge --- scripts/browser_extensions.py | 122 ++++++++++++++++++++++++---------- 1 file changed, 88 insertions(+), 34 deletions(-) diff --git a/scripts/browser_extensions.py b/scripts/browser_extensions.py index 4a21e08..c48addb 100755 --- a/scripts/browser_extensions.py +++ b/scripts/browser_extensions.py @@ -6,14 +6,17 @@ import re import glob import json +# import logging sys.path.insert(0, '/usr/local/munki') sys.path.insert(0, '/usr/local/munkireport') from munkilib import FoundationPlist -def get_users(): +# Configure logging +# logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s') +def get_users(): # Get all users' home folders cmd = ['dscl', '.', '-readall', '/Users', 'NFSHomeDirectory'] proc = subprocess.Popen(cmd, shell=False, bufsize=-1, @@ -26,7 +29,9 @@ def get_users(): for user in output.decode().split('\n'): if 'NFSHomeDirectory' in user and '/var/empty' not in user: userpath = user.replace("NFSHomeDirectory: ", "") - users.append(user.replace("NFSHomeDirectory: ", "")) + # Exclude system and service accounts + if userpath.startswith('/Users/') and not userpath.startswith('/Users/Shared'): + users.append(userpath) return users @@ -65,7 +70,8 @@ def process_chrome(chrome_extension, user, browser, profile=None): extension_info['name'] = extension_manifest[item] path_dict = chrome_extension.split('/') - extension_info['extension_id'] = path_dict[-3:][0] + extension_id = path_dict[-3:][0] + extension_info['extension_id'] = extension_id extension_info['user'] = user extension_info['date_installed'] = str(int(os.path.getmtime(chrome_extension))) extension_info['browser'] = browser @@ -76,6 +82,46 @@ def process_chrome(chrome_extension, user, browser, profile=None): # Store extension path extension_info['extension_path'] = chrome_extension.replace("manifest.json","") + + # Check for enabled status in Preferences files + try: + # Determine the profile directory path + profile_dir = os.path.dirname(os.path.dirname(os.path.dirname(chrome_extension))) + + # Default to enabled if we can't determine the state + extension_info['enabled'] = True + + # Try to read Secure Preferences file first (more likely to contain extension state) + secure_preferences_path = os.path.join(profile_dir, "Secure Preferences") + if os.path.exists(secure_preferences_path): + with open(secure_preferences_path, 'r') as f: + secure_preferences = json.loads(f.read()) + # Check if extension settings exist + if ('extensions' in secure_preferences and + 'settings' in secure_preferences['extensions'] and + extension_id in secure_preferences['extensions']['settings']): + if 'state' in secure_preferences['extensions']['settings'][extension_id]: + # State is 1 for enabled, 0 for disabled + extension_info['enabled'] = secure_preferences['extensions']['settings'][extension_id]['state'] == 1 + # If state is not found, keep the default (True) + + # If not found in Secure Preferences, try regular Preferences + if extension_info['enabled'] is True: # Only check if we haven't found a disabled state + preferences_path = os.path.join(profile_dir, "Preferences") + if os.path.exists(preferences_path): + with open(preferences_path, 'r') as f: + preferences = json.loads(f.read()) + # Check if extension settings exist + if ('extensions' in preferences and + 'settings' in preferences['extensions'] and + extension_id in preferences['extensions']['settings']): + if 'state' in preferences['extensions']['settings'][extension_id]: + # State is 1 for enabled, 0 for disabled + extension_info['enabled'] = preferences['extensions']['settings'][extension_id]['state'] == 1 + # If state is not found, keep the default (True) + except Exception as e: + # Default to True if there's any error reading the preferences + extension_info['enabled'] = True return extension_info @@ -147,16 +193,20 @@ def process_safari(safari_plist_path, user): # Keep the original extension ID extension_info['extension_id'] = extension_id - # Extract name from the bundle identifier (com.example.extension -> extension) - # Try to get a more user-friendly name - clean_id = extension_id.split(' (')[0] if ' (' in extension_id else extension_id - name_parts = clean_id.split('.') - if len(name_parts) > 1: - # Get the last meaningful part of the bundle ID - extension_info['name'] = name_parts[-2].capitalize() - else: - extension_info['name'] = clean_id - + # Extract name from the key (e.g., com.example.extension -> Example) + clean_id = extension_id.split(' ')[0].split('(')[0] + name_parts = clean_id.split('.')[:-1] # Ignore the last part + + # Remove specific words + unwanted_words = {'com', 'org', 'mac', 'macos', 'extension', 'safari'} + name_parts = [part for part in name_parts if part.lower() not in unwanted_words] + + # Use the remaining parts to construct the name + name = ' '.join(name_parts).replace('-', ' ') + + # Capitalize each word in the name + extension_info['name'] = ' '.join(word.capitalize() for word in name.split()) + # Check if extension is enabled if 'Enabled' in extension_data: extension_info['enabled'] = extension_data['Enabled'] @@ -269,7 +319,7 @@ def process_browsers(users): unique_key = f"{extension_data['user']}|{extension_data['browser']}|{extension_data['profile']}|{extension_data['extension_id']}" # Only add if we haven't seen this extension before, or if it's newer - if unique_key not in unique_extensions or int(extension_data['date_installed']) > int(unique_extensions[unique_key]['date_installed']): + if unique_key not in unique_extensions or int(float(extension_data['date_installed'])) > int(float(unique_extensions[unique_key]['date_installed'])): unique_extensions[unique_key] = extension_data # Check for Edge extensions in all profiles @@ -292,7 +342,7 @@ def process_browsers(users): unique_key = f"{extension_data['user']}|{extension_data['browser']}|{extension_data['profile']}|{extension_data['extension_id']}" # Only add if we haven't seen this extension before, or if it's newer - if unique_key not in unique_extensions or int(extension_data['date_installed']) > int(unique_extensions[unique_key]['date_installed']): + if unique_key not in unique_extensions or int(float(extension_data['date_installed'])) > int(float(unique_extensions[unique_key]['date_installed'])): unique_extensions[unique_key] = extension_data # Check for Firefox extensions @@ -309,28 +359,32 @@ def process_browsers(users): unique_key = f"{extension_data['user']}|{extension_data['browser']}|{extension_data['profile']}|{extension_data['extension_id']}" # Only add if we haven't seen this extension before, or if it's newer - if unique_key not in unique_extensions or int(extension_data['date_installed']) > int(unique_extensions[unique_key]['date_installed']): + if unique_key not in unique_extensions or int(float(extension_data['date_installed'])) > int(float(unique_extensions[unique_key]['date_installed'])): unique_extensions[unique_key] = extension_data # Check for Safari extensions - using the original approach - safari_extension_path = user+"/Library/Containers/com.apple.Safari/Data/Library/Safari/AppExtensions/Extensions.plist" - if os.path.isfile(safari_extension_path): - try: - safari_extensions = process_safari(safari_extension_path, user.replace("/Users/","")) - # Add profile field for consistency with our new approach - for extension in safari_extensions: - extension['profile'] = "Default" - # Add Safari extensions to the unique extensions dictionary - for extension in safari_extensions: - # Create a unique key for this extension - unique_key = f"{extension['user']}|{extension['browser']}|{extension['profile']}|{extension['extension_id']}" - # Only add if we haven't seen this extension before, or if it's newer - if unique_key not in unique_extensions or int(extension['date_installed']) > int(unique_extensions[unique_key]['date_installed']): - unique_extensions[unique_key] = extension - except Exception as e: - # Only log serious errors, not just empty files - if "stream had too few bytes" not in str(e): - print(f"Error processing Safari extensions for {user}: {str(e)}") + safari_extension_paths = [ + user+"/Library/Containers/com.apple.Safari/Data/Library/Safari/AppExtensions/Extensions.plist", + user+"/Library/Containers/com.apple.Safari/Data/Library/Safari/WebExtensions/Extensions.plist" + ] + + for safari_extension_path in safari_extension_paths: + if os.path.isfile(safari_extension_path): + try: + safari_extensions = process_safari(safari_extension_path, user.replace("/Users/","")) + # Add profile field for consistency with our new approach + for extension in safari_extensions: + extension['profile'] = "Default" + # Add Safari extensions to the unique extensions dictionary + for extension in safari_extensions: + # Create a unique key for this extension + unique_key = f"{extension['user']}|{extension['browser']}|{extension['profile']}|{extension['extension_id']}" + # Only add if we haven't seen this extension before, or if it's newer + if unique_key not in unique_extensions or int(float(extension['date_installed'])) > int(float(unique_extensions[unique_key]['date_installed'])): + unique_extensions[unique_key] = extension + except Exception as e: + # Log all errors + pass # Convert the dictionary of unique extensions to a list out = list(unique_extensions.values()) From 4543733d227d04b8769d30052eb53874eeb39a91 Mon Sep 17 00:00:00 2001 From: ofirgalcon Date: Thu, 13 Mar 2025 00:56:57 +0000 Subject: [PATCH 3/3] report page, widgets, improved extraction of developer name --- browser_extensions_controller.php | 25 +- browser_extensions_model.php | 11 + locales/en.json | 4 +- provides.yml | 13 +- scripts/browser_extensions.py | 416 +++++++++++++++++- views/browser_extensions_developer_widget.yml | 9 + views/browser_extensions_listing.yml | 4 +- views/browser_extensions_name_widget.yml | 9 + views/browser_extensions_report.yml | 4 + views/browser_extensions_tab.php | 5 + 10 files changed, 472 insertions(+), 28 deletions(-) create mode 100644 views/browser_extensions_developer_widget.yml create mode 100644 views/browser_extensions_name_widget.yml create mode 100644 views/browser_extensions_report.yml diff --git a/browser_extensions_controller.php b/browser_extensions_controller.php index f92e4d1..386a37e 100755 --- a/browser_extensions_controller.php +++ b/browser_extensions_controller.php @@ -40,5 +40,28 @@ public function get_data($serial_number) ->get() ->toArray() ); - } + } + + /** + * Get data for scroll widget + * + * @return void + * @author tuxudo + **/ + public function get_scroll_widget($column) + { + // Remove non-column name characters + $column = preg_replace("/[^A-Za-z0-9_\-]]/", '', $column); + + $sql = "SELECT COUNT(CASE WHEN ".$column." <> '' AND ".$column." IS NOT NULL THEN 1 END) AS count, ".$column." + FROM browser_extensions + LEFT JOIN reportdata USING (serial_number) + ".get_machine_group_filter()." + AND ".$column." <> '' AND ".$column." IS NOT NULL + GROUP BY ".$column." + ORDER BY count DESC"; + + $queryobj = new Browser_extensions_model; + jsonView($queryobj->rawQuery($sql)); + } } // End class Browser_extensions_controller \ No newline at end of file diff --git a/browser_extensions_model.php b/browser_extensions_model.php index a2a7674..a775aac 100755 --- a/browser_extensions_model.php +++ b/browser_extensions_model.php @@ -22,4 +22,15 @@ class Browser_extensions_model extends Eloquent ]; public $timestamps = false; + + /** + * Execute a raw query + * + * @param string $sql SQL query + * @return array result + */ + public function rawQuery($sql) + { + return $this->getConnection()->select($sql); + } } diff --git a/locales/en.json b/locales/en.json index ba0c0b1..4d718d4 100755 --- a/locales/en.json +++ b/locales/en.json @@ -11,5 +11,7 @@ "developer": "Developer", "enabled": "Enabled", "user": "User", - "extension_path": "Extension Path" + "extension_path": "Extension Path", + "extensions_by_name": "Browser Extensions", + "extensions_by_developer": "Extensions by Developer" } \ No newline at end of file diff --git a/provides.yml b/provides.yml index e5dab5f..36353b2 100755 --- a/provides.yml +++ b/provides.yml @@ -7,4 +7,15 @@ client_tabs: listings: browser_extensions: view: browser_extensions_listing - i18n: browser_extensions.browser_extensions \ No newline at end of file + i18n: browser_extensions.browser_extensions + +widgets: + browser_extensions_name: + view: browser_extensions_name_widget + browser_extensions_developer: + view: browser_extensions_developer_widget + +reports: + browser_extensions_report: + view: browser_extensions_report + i18n: browser_extensions.reporttitle \ No newline at end of file diff --git a/scripts/browser_extensions.py b/scripts/browser_extensions.py index c48addb..4fcadbe 100755 --- a/scripts/browser_extensions.py +++ b/scripts/browser_extensions.py @@ -41,9 +41,193 @@ def process_chrome(chrome_extension, user, browser, profile=None): extension_info = {} + # Much stricter Google detection + is_google = False + if extension_manifest.get('id', '').endswith('@google.com'): + # Only trust extensions with @google.com if they also have a verified Google OAuth2 client ID + if 'oauth2' in extension_manifest: + oauth = extension_manifest['oauth2'] + if isinstance(oauth, dict) and 'client_id' in oauth: + client_id = oauth['client_id'] + if isinstance(client_id, str) and client_id.endswith('.apps.googleusercontent.com'): + is_google = True + + def clean_and_reorder_name(name): + """Helper function to clean and reorder names properly""" + if not name: + return None + + # Split into words and clean each word + words = name.replace('-', ' ').replace('_', ' ').replace('.', ' ').split() + words = [w for w in words if w.lower() not in ['extension', 'plugin']] + + if not words: + return None + + # Special cases for reordering + if len(words) >= 2: + # If first word is 'agent' and there are other words, move it to the end + if words[0].lower() == 'agent': + words = words[1:] + [words[0]] + + # Capitalize each word + words = [word.capitalize() for word in words] + return ' '.join(words) + + # First try to get developer from creator field + if 'creator' in extension_manifest and extension_manifest['creator']: + creator = extension_manifest['creator'] + if isinstance(creator, str): + if '@' in creator: + # Handle email format like "Name " + if '<' in creator and '>' in creator: + extension_info['developer'] = creator.split('<')[0].strip() + else: + # Just use the part before @ if it's a plain email + name_part = creator.split('@')[0] + if 'superagent' in name_part.lower(): + extension_info['developer'] = 'superagent' + else: + clean_name = clean_and_reorder_name(name_part) + if clean_name: + extension_info['developer'] = clean_name + else: + extension_info['developer'] = creator + + # If no developer found yet, check author field + if 'developer' not in extension_info and 'author' in extension_manifest: + author_value = extension_manifest['author'] + if isinstance(author_value, list): + # Try to get a valid developer name from the list + for value in author_value: + if isinstance(value, str): + clean_name = clean_and_reorder_name(value) + if clean_name and clean_name.lower() not in ['plugin', 'agent', 'extension', 'userscripts']: + extension_info['developer'] = clean_name + break + elif isinstance(author_value, str): + if author_value.startswith('__MSG_'): + # Handle localized author names + try: + locale_file = open(chrome_extension.replace("manifest.json", "_locales/"+extension_manifest['default_locale']+"/messages.json"), 'r') + extension_localization = json.loads(locale_file.read().strip()) + extension_localization_lower = {k.lower():v for k,v in list(extension_localization.items())} + local_name = author_value.replace("__MSG_","").replace("__","").lower() + author_value = extension_localization_lower[local_name]["message"] + except: + author_value = None + + if author_value: + # Clean up common unwanted values + if author_value.lower() in ['plugin', 'agent', 'extension', 'userscripts']: + pass + # Handle email format + elif '@' in author_value: + # Special case for superagent + name_part = author_value.split('@')[0] + if 'superagent' in name_part.lower(): + extension_info['developer'] = 'superagent' + else: + clean_name = clean_and_reorder_name(name_part) + if clean_name: + extension_info['developer'] = clean_name + else: + # Special case for superagent + if 'superagent' in author_value.lower(): + extension_info['developer'] = 'superagent' + else: + clean_name = clean_and_reorder_name(author_value) + if clean_name: + extension_info['developer'] = clean_name + + elif isinstance(author_value, dict): + # Try different possible fields in the dictionary + for field in ['name', 'author', 'developer', 'email']: + if field in author_value and isinstance(author_value[field], str): + value = author_value[field] + if 'superagent' in value.lower(): + extension_info['developer'] = 'superagent' + break + elif value.lower() not in ['plugin', 'agent', 'extension', 'userscripts']: + clean_name = clean_and_reorder_name(value) + if clean_name: + extension_info['developer'] = clean_name + break + for item in extension_manifest: if item == "version": extension_info['version'] = extension_manifest[item] + + elif item in ["author", "developer"]: # Check both author and developer fields + # Handle different types of author/developer field + author_value = extension_manifest[item] + if isinstance(author_value, list): + # Lists are not automatically Google anymore + if any(isinstance(x, str) and x.endswith('@google.com') for x in author_value): + is_google = True + else: + # Try to get a valid developer name from the list + for value in author_value: + if isinstance(value, str): + clean_name = clean_and_reorder_name(value) + if clean_name and clean_name.lower() not in ['plugin', 'agent', 'extension', 'userscripts']: + extension_info['developer'] = clean_name + break + elif isinstance(author_value, str): + if author_value.startswith('__MSG_'): + # Handle localized author names + try: + locale_file = open(chrome_extension.replace("manifest.json", "_locales/"+extension_manifest['default_locale']+"/messages.json"), 'r') + extension_localization = json.loads(locale_file.read().strip()) + extension_localization_lower = {k.lower():v for k,v in list(extension_localization.items())} + local_name = author_value.replace("__MSG_","").replace("__","").lower() + author_value = extension_localization_lower[local_name]["message"] + except: + author_value = None + + if author_value: + # Clean up common unwanted values + if author_value.lower() in ['plugin', 'agent', 'extension', 'userscripts']: + continue + # Handle email format + if '@' in author_value: + if author_value.endswith('@google.com'): # Must end with @google.com + is_google = True + else: + # Try to extract name from email or use domain + name_part = author_value.split('@')[0] + # Special case for superagent + if 'superagent' in name_part.lower(): + extension_info['developer'] = 'superagent' + else: + clean_name = clean_and_reorder_name(name_part) + if clean_name: + extension_info['developer'] = clean_name + else: + # Special case for superagent + if 'superagent' in author_value.lower(): + extension_info['developer'] = 'superagent' + else: + clean_name = clean_and_reorder_name(author_value) + if clean_name: + extension_info['developer'] = clean_name + + elif isinstance(author_value, dict): + # Try different possible fields in the dictionary + for field in ['name', 'author', 'developer', 'email']: + if field in author_value and isinstance(author_value[field], str): + value = author_value[field] + if value.endswith('@google.com'): # Must end with @google.com + is_google = True + break + elif 'superagent' in value.lower(): + extension_info['developer'] = 'superagent' + break + elif value.lower() not in ['plugin', 'agent', 'extension', 'userscripts']: + clean_name = clean_and_reorder_name(value) + if clean_name: + extension_info['developer'] = clean_name + break elif item == "description" and extension_manifest[item].startswith('__MSG'): try: @@ -63,11 +247,13 @@ def process_chrome(chrome_extension, user, browser, profile=None): extension_localization = json.loads(locale_file.read().strip()) extension_localization_lower = {k.lower():v for k,v in list(extension_localization.items())} local_name = extension_manifest['name'].replace("__MSG_","").replace("__","").lower() - extension_info['name'] = extension_localization_lower[local_name]["message"] + raw_name = extension_localization_lower[local_name]["message"] + extension_info['name'] = clean_and_reorder_name(raw_name) or raw_name except: - extension_info['description'] = "" + extension_info['name'] = "" elif item == "name": - extension_info['name'] = extension_manifest[item] + raw_name = extension_manifest[item] + extension_info['name'] = clean_and_reorder_name(raw_name) or raw_name path_dict = chrome_extension.split('/') extension_id = path_dict[-3:][0] @@ -83,6 +269,42 @@ def process_chrome(chrome_extension, user, browser, profile=None): # Store extension path extension_info['extension_path'] = chrome_extension.replace("manifest.json","") + # Set Google as developer only if we're very confident + if is_google: + extension_info['developer'] = "Google" + elif 'developer' not in extension_info: + # First check for known patterns in the extension name (most reliable method) + if 'name' in extension_info: + name = extension_info['name'].lower() if isinstance(extension_info.get('name'), str) else "" + + # Check for known patterns + if 'okta' in name: + extension_info['developer'] = "Okta" + # Check for Google + elif ('google' in name or '(by google)' in name or name.startswith('google ') or name.endswith(' google')): + extension_info['developer'] = "Google" + # Check for Gmail, Docs, Sheets + elif any(x in name for x in ['gmail', 'google docs', 'google sheets']): + extension_info['developer'] = "Google" + # Check for Adobe + elif name.startswith('adobe '): + extension_info['developer'] = "Adobe" + # Check for Cisco + elif name.startswith('cisco '): + extension_info['developer'] = "Cisco" + + # Only if pattern matching fails, try to get developer from homepage URL + if 'developer' not in extension_info and 'homepage_url' in extension_manifest: + url = extension_manifest['homepage_url'].lower() + if 'github.com/' in url: + # Extract username from GitHub URL + try: + github_user = url.split('github.com/')[1].split('/')[0] + if github_user and github_user not in ['topics', 'search']: + extension_info['developer'] = github_user + except: + pass + # Check for enabled status in Preferences files try: # Determine the profile directory path @@ -132,34 +354,118 @@ def process_firefox(firefox_extension, user, firefox_extension_path, profile=Non # Store extension path extension_info['extension_path'] = firefox_extension_path + # First check defaultLocale for developer info + if 'defaultLocale' in firefox_extension: + for locale_item in firefox_extension['defaultLocale']: + if locale_item == "description" and firefox_extension['defaultLocale'][locale_item]: + extension_info['description'] = firefox_extension['defaultLocale'][locale_item] + elif locale_item == "name" and firefox_extension['defaultLocale'][locale_item]: + name = firefox_extension['defaultLocale'][locale_item] + extension_info['name'] = name + # Special case for 1Password + if '1password' in name.lower(): + extension_info['developer'] = "1Password" + elif locale_item == "creator" and firefox_extension['defaultLocale'][locale_item] and 'developer' not in extension_info: + # Handle email format: "Name " + creator = firefox_extension['defaultLocale'][locale_item] + if isinstance(creator, str): + # Special case for 1Password + if 'agilebits' in creator.lower() or '1password' in creator.lower(): + extension_info['developer'] = "1Password" + elif '<' in creator and '>' in creator: + extension_info['developer'] = creator.split('<')[0].strip() + else: + extension_info['developer'] = creator + elif locale_item == "homepageURL" and 'developer' not in extension_info: + homepage = firefox_extension['defaultLocale'][locale_item] + if 'github.com/' in homepage.lower(): + try: + github_user = homepage.split('github.com/')[1].split('/')[0] + if github_user and github_user not in ['topics', 'search']: + extension_info['developer'] = github_user + except: + pass + + # If no developer found in defaultLocale, check other locations + if 'developer' not in extension_info: + # First priority: Check for known patterns in the extension name + if 'name' in extension_info: + name = extension_info['name'].lower() if isinstance(extension_info.get('name'), str) else "" + + # Check for known patterns + if 'okta' in name: + extension_info['developer'] = "Okta" + elif ('google' in name or '(by google)' in name or name.startswith('google ') or name.endswith(' google')): + extension_info['developer'] = "Google" + elif any(x in name for x in ['gmail', 'google docs', 'google sheets']): + extension_info['developer'] = "Google" + elif name.startswith('adobe '): + extension_info['developer'] = "Adobe" + elif name.startswith('cisco '): + extension_info['developer'] = "Cisco" + + # Second priority: Check creator/developer/author fields directly + if 'developer' not in extension_info: + for field in ['creator', 'developer', 'author']: + if field in firefox_extension and firefox_extension[field]: + if isinstance(firefox_extension[field], str): + if '<' in firefox_extension[field] and '>' in firefox_extension[field]: + extension_info['developer'] = firefox_extension[field].split('<')[0].strip() + else: + extension_info['developer'] = firefox_extension[field] + break + elif isinstance(firefox_extension[field], dict) and 'name' in firefox_extension[field]: + extension_info['developer'] = firefox_extension[field]['name'] + break + + # Third priority: Try to get from extension ID if still no developer name found + if 'developer' not in extension_info and 'id' in firefox_extension: + ext_id = firefox_extension['id'] + if '@' in ext_id: + try: + # Extract developer from email format + developer = ext_id.split('@')[0] + if developer and developer.lower() not in ['addon', 'extension', 'firefox', 'mozilla', 'plugin', 'plugiin']: + # Convert dashes/underscores to spaces and capitalize + developer = ' '.join(word.capitalize() for word in developer.replace('-', ' ').replace('_', ' ').split()) + extension_info['developer'] = developer + except: + pass + elif '.' in ext_id: + # Try to extract organization name from ID + parts = ext_id.split('.') + filtered_parts = [p for p in parts if p.lower() not in ['com', 'org', 'net', 'addon', 'extension', 'firefox', 'mozilla', 'plugin', 'plugiin']] + if filtered_parts: + # Convert dashes/underscores to spaces and capitalize + developer = ' '.join(word.capitalize() for word in filtered_parts[0].replace('-', ' ').replace('_', ' ').split()) + extension_info['developer'] = developer + + # Check if it's a Mozilla extension as last resort + if 'developer' not in extension_info: + is_mozilla = False + if 'homepageURL' in firefox_extension: + if 'mozilla.com' in firefox_extension['homepageURL'].lower() or 'mozilla.org' in firefox_extension['homepageURL'].lower(): + is_mozilla = True + if 'id' in firefox_extension and ('@mozilla' in firefox_extension['id'].lower() or 'mozilla@' in firefox_extension['id'].lower()): + is_mozilla = True + if is_mozilla: + extension_info['developer'] = "Mozilla" + + # Process other standard fields for item in firefox_extension: if item == "version": extension_info['version'] = firefox_extension[item] - elif item == "active": extension_info['enabled'] = firefox_extension[item] - elif item == "installDate" or item == "updateDate": extension_info['date_installed'] = str(firefox_extension[item]/1000) - elif item == "id": extension_info['extension_id'] = firefox_extension[item] - elif item == "path": if firefox_extension[item] is None: extension_info['extension_path'] = firefox_extension_path.replace("extensions.json","") else: extension_info['extension_path'] = firefox_extension[item] - - elif item == "defaultLocale": - - for locale_item in firefox_extension[item]: - if locale_item == "description" and firefox_extension[item][locale_item]: - extension_info['description'] = firefox_extension[item][locale_item] - elif locale_item == "name" and firefox_extension[item][locale_item]: - extension_info['name'] = firefox_extension[item][locale_item] - elif locale_item == "creator" and firefox_extension[item][locale_item]: - extension_info['developer'] = firefox_extension[item][locale_item] extension_info['user'] = user extension_info['browser'] = "Firefox" @@ -197,15 +503,79 @@ def process_safari(safari_plist_path, user): clean_id = extension_id.split(' ')[0].split('(')[0] name_parts = clean_id.split('.')[:-1] # Ignore the last part + # Try to get the extension name first # Remove specific words unwanted_words = {'com', 'org', 'mac', 'macos', 'extension', 'safari'} - name_parts = [part for part in name_parts if part.lower() not in unwanted_words] - - # Use the remaining parts to construct the name - name = ' '.join(name_parts).replace('-', ' ') - + filtered_name_parts = [part for part in name_parts if part.lower() not in unwanted_words] + + # Remove duplicate words (case insensitive) + seen_words = set() + unique_parts = [] + for part in filtered_name_parts: + if part.lower() not in seen_words: + seen_words.add(part.lower()) + unique_parts.append(part) + + extension_name = ' '.join(unique_parts).replace('-', ' ') + # Capitalize each word in the name - extension_info['name'] = ' '.join(word.capitalize() for word in name.split()) + extension_info['name'] = ' '.join(word.capitalize() for word in extension_name.split()) + + # Handle special cases for known extensions + if 'lastpass' in extension_info['name'].lower() and 'macdesktop' in extension_info['name'].lower(): + extension_info['name'] = 'LastPass' + elif 'cisco' in extension_info['name'].lower() and 'webex' in extension_info['name'].lower() and 'start' in extension_info['name'].lower(): + extension_info['name'] = 'Cisco Webex' + + # Remove duplicate words in the final name (e.g., "Todoist Todoist" -> "Todoist") + if extension_info['name']: + name_words = extension_info['name'].split() + if len(name_words) > 1: + # Check for exact duplicates (case-sensitive) + if len(set(name_words)) < len(name_words): + unique_words = [] + seen_words = set() + for word in name_words: + if word.lower() not in seen_words: + seen_words.add(word.lower()) + unique_words.append(word) + extension_info['name'] = ' '.join(unique_words) + + # Now handle the developer name with the correct priority: + # 1. Check for pattern matches first (more reliable than ID-based extraction) + name = extension_info['name'].lower() if 'name' in extension_info else "" + developer_found = False + + # Check for known patterns + if 'okta' in name: + extension_info['developer'] = "Okta" + developer_found = True + elif ('google' in name or '(by google)' in name or name.startswith('google ') or name.endswith(' google')): + extension_info['developer'] = "Google" + developer_found = True + elif any(x in name for x in ['gmail', 'google docs', 'google sheets']): + extension_info['developer'] = "Google" + developer_found = True + elif name.startswith('adobe '): + extension_info['developer'] = "Adobe" + developer_found = True + elif name.startswith('cisco '): + extension_info['developer'] = "Cisco" + developer_found = True + elif 'lastpass' in name: + extension_info['developer'] = "LastPass" + developer_found = True + elif 'todoist' in name: + extension_info['developer'] = "Doist" + developer_found = True + + # 2. Only if pattern matching fails, try to extract from ID as last resort + if not developer_found and len(name_parts) > 1: + for part in name_parts[1:]: # Start from second part + if part.lower() not in ['com', 'org', 'userscripts', 'net', 'app', 'extension', 'chrome', 'plugin', 'plugiin']: + extension_info['developer'] = part.capitalize() + developer_found = True + break # Check if extension is enabled if 'Enabled' in extension_data: diff --git a/views/browser_extensions_developer_widget.yml b/views/browser_extensions_developer_widget.yml new file mode 100644 index 0000000..5bfbf0f --- /dev/null +++ b/views/browser_extensions_developer_widget.yml @@ -0,0 +1,9 @@ +type: scrollbox +widget_id: browser_extensions_developer-widget +api_url: /module/browser_extensions/get_scroll_widget/developer +i18n_title: browser_extensions.extensions_by_developer +icon: fa-code +listing_link: /show/listing/browser_extensions/browser_extensions +search_key: developer +badge: count +i18n_empty_result: no_clients \ No newline at end of file diff --git a/views/browser_extensions_listing.yml b/views/browser_extensions_listing.yml index f709cbc..97458a9 100755 --- a/views/browser_extensions_listing.yml +++ b/views/browser_extensions_listing.yml @@ -9,7 +9,6 @@ table: i18n_header: serial - {i18n_header: browser_extensions.name, column: browser_extensions.name} - {i18n_header: browser_extensions.version, column: browser_extensions.version} - - {i18n_header: browser_extensions.extension_id, column: browser_extensions.extension_id} - {i18n_header: browser_extensions.user, column: browser_extensions.user} - {i18n_header: browser_extensions.browser, column: browser_extensions.browser} - {i18n_header: browser_extensions.profile, column: browser_extensions.profile} @@ -17,4 +16,5 @@ table: - {i18n_header: browser_extensions.developer, column: browser_extensions.developer} - {i18n_header: browser_extensions.enabled, column: browser_extensions.enabled, formatter: binaryYesNo} - {i18n_header: browser_extensions.description, column: browser_extensions.description} - - {i18n_header: browser_extensions.extension_path, column: browser_extensions.extension_path} \ No newline at end of file + - {i18n_header: browser_extensions.extension_id, column: browser_extensions.extension_id} + - {i18n_header: browser_extensions.extension_path, column: browser_extensions.extension_path} diff --git a/views/browser_extensions_name_widget.yml b/views/browser_extensions_name_widget.yml new file mode 100644 index 0000000..6d73ffc --- /dev/null +++ b/views/browser_extensions_name_widget.yml @@ -0,0 +1,9 @@ +type: scrollbox +widget_id: browser_extensions_name-widget +api_url: /module/browser_extensions/get_scroll_widget/name +i18n_title: browser_extensions.extensions_by_name +icon: fa-puzzle-piece +listing_link: /show/listing/browser_extensions/browser_extensions +search_key: name +badge: count +i18n_empty_result: no_clients \ No newline at end of file diff --git a/views/browser_extensions_report.yml b/views/browser_extensions_report.yml new file mode 100644 index 0000000..b318a3e --- /dev/null +++ b/views/browser_extensions_report.yml @@ -0,0 +1,4 @@ +title: browser_extensions.reporttitle +row1: + browser_extensions_name: + browser_extensions_developer: \ No newline at end of file diff --git a/views/browser_extensions_tab.php b/views/browser_extensions_tab.php index b80ea55..133d8c7 100755 --- a/views/browser_extensions_tab.php +++ b/views/browser_extensions_tab.php @@ -4,6 +4,11 @@ +