12AYN1upw3mzIdGBLn6Ewx0Fw
In this blog post, Chad and Tyler from our Information Security (InfoSec) team discuss the evolution of Palantir’s approach to managing browser extensions across the enterprise and walk through how this low-friction process works today.
Web browsers are the primary interface for many sensitive business tasks; they are so commonplace that it can be easy to overlook security risks associated with them. Almost every user of web browsers utilizes browser extensions as well. Familiar extensions include password managers, which can populate credentials on a user’s behalf, and ad blockers, which can prevent certain content (i.e., ads) from loading according to a rules list. Helpful as they may seem, browser extensions pose potentially disastrous risks to both individuals and enterprises.
There are two significant unresolved issues regarding extensions:
This means it is trivially easy for malicious extensions to collect sensitive information about users, monitor keystrokes and file uploads, rewrite page content for nefarious purposes, or otherwise abuse the ability to see and manipulate web traffic. Even extensions that start out benign can become malicious: it’s common for moderately popular extensions to be transferred or sold to other parties, where an existing target audience is one software update away from instantaneous and broad distribution.
As an example, Google makes administration of extensions relatively simple in an admin dashboard — assuming the organization has Google Workspace accounts for their users and exclusively uses Chrome, which is not the case for the vast majority of organizations. Microsoft Edge and Mozilla Firefox rely exclusively on security policies deployed to workstations directly, meaning that any changes to which extensions are allowed or denied is restricted to domain administrators managing manual updates based on nothing but extension IDs — an effort highly susceptible to human error.
In the absence of lower friction options, it’s no surprise that organizations concerned about the risk posed by browser extensions tend to end up blocking them outright rather than attempting to keep up with the administrative overhead seemingly required.
Palantir had historically maintained a denylist of known bad extensions, sourced largely from security industry news stories and other advisories. For a time, this seemed like the only viable option, as migrating to a deny-by-default model would break a material number of established user workflows unless we were willing to devote a significant amount of administrative overhead to manually maintaining independent allowlists for Edge, Chrome, Firefox in both Group Policy (for Windows endpoints) and Jamf plists (for macOS endpoints).
Even so, we began investigating the extensions already present across the fleet. We turned to our osquery dataset, which contained a table for installed browser extensions for all three browsers on both operating systems. After running a quick query and rank-ordering the installations, it was easy to see which extensions were common and which were one-offs. There were a few surprises in the data: hundreds of unique extensions only installed on one or two workstations, extensions that had been pulled from the web store years earlier but were still enabled, and one employee who had more than 40 extensions installed (when asked, they were completely unaware of most of them).
In addition to these surprises, this investigation revealed a few key takeaways:
With our osquery metrics in hand, we curated an initial allowlist of acceptable extensions based on criteria such as number of unique installations, advertised function, and utility in an enterprise setting. We then turned our attention to the question of how to efficiently maintain this list over time.
We realized early in the process that we would probably never be able to set an extension allowlist in stone: users frequently identify new extensions that enable critical business processes, and previously-approved extensions may need to be banned quickly if there is suspected abuse.
We decided the best way to declare a single source of truth while giving our employees the ability to request changes was to define it as code in an internal GitHub code repository. In this repo, we crafted a relatively simple json file for each of the three web browsers, along these lines:
[
{
"ExtensionDescription": "Approved Password manager",
"ExtensionId": "dppgmdbiimibapkepcbdbmkaabgiofem",
"ExtensionName": "1Password – Password Manager",
"ExtensionType": "Security"
},
{
"ExtensionDescription": "Another Extension - used by the frontend dev team",
"ExtensionId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaam",
"ExtensionName": "Another Extension",
"ExtensionType": "Productivity"
}
]
Ultimately, ExtensionId is the only field that matters to the allowlist (as the allowlist is nothing more than a simple list of extension IDs), but we included the other fields in order to be more human-friendly when parsing the source of truth file.
With this list in place, employees are able to directly open a Pull Request (PR) to update the list; if they’re not comfortable using GitHub, they can submit a ticket to have our team open a PR on their behalf. This not only provides employees with a degree of autonomy and a stake in the process (more on that later), but also allows us time to investigate proposed extensions before making a decision.
Our typical review process involves these general steps:
steps:
- checkout
- run:
name: Parse JSON file for syntax errors
command: cat *.json | jq
- run:
name: Check extensions are not duplicated
command: |
set +e
extensions=$(jq -r ".[] | .ExtensionId" chrome_extensions.json)
duplicates=$(echo "$extensions" | sort | uniq -c | sort -nr | grep -v '^ *1 ' | cat)
set -e
if [[ -n "$duplicates" ]]; then
echo "There are duplicate extension ids"
echo -n "$duplicates"
exit 1
fi
- run:
name: Check all extensions exist
command: |
extensions=$(jq -r ".[] | .ExtensionId" chrome_extensions.json)
rc=0
# These extensions are not required to be in the Chrome Web Store
exceptions=(space_separated_extensions_list)
for extension in $extensions; do
if [[ " ${exceptions[@]} " =~ " ${extension} " ]]; then
echo "$extension is not required to be in the Chrome Web Store"
continue
fi
extension_name=$(jq ".[] | select(.ExtensionId=="$extension").ExtensionName" chrome_extensions.json)
detail_url="https://chrome.google.com/webstore/detail/$extension"
# Validate that the chrome webstore knows about this extension id
http_code=$(curl "$detail_url" -s -o /dev/null -w "%{http_code}")
if [[ $http_code -gt 399 ]]; then
rc=$((rc+1))
echo "Could not validate $extension_name ($extension)"
exit $rc
fi
# Validate that the extension names matches the value in the chrome webstore
effective_url=$(curl "$detail_url" -Ls -o /dev/null -w %{url_effective})
effective_name=$(echo $effective_url | sed 's#https://chrome.google.com/webstore/detail/##g' | sed "s#/$extension##g")
# some postprocessing to make the human readable name match the extension webstore
converted_extension_name=$(echo "${extension_name}" | tr '[:upper:]' '[:lower:]' | sed 's/(//g' | sed 's/)//g' | sed 's/ & /-/g' | sed 's/://g' | sed 's/ – /-%E2%80%93-/g' | sed 's/ - /-/g' | sed 's/ /-/g')
if ! [[ "$converted_extension_name" =~ "$effective_name" ]]; then
echo "${converted_extension_name} does not belong with ${effective_name}"
fi
done
exit $rc
With this process in place, requesting a browser extension often becomes a surprisingly personal undertaking, as the requester needs to have sufficient conviction and be willing to make an argument as to why a certain extension should be allowed. We try to bias towards approval whenever possible, as we value acting as trusted partners rather than gatekeepers. When we do reject a request, we don’t do so unilaterally — we always engage in a conversation about why we believe the risks of a certain extension outweigh the benefits. We also try to suggest alternatives: we’ve found that the actual number of unique use cases is surprisingly small, and it’s likely we’ve already allowed a similar extension with equal or less risk. As long as we can give the requester the outcome they’re seeking, they generally don’t mind how it is actually provided.
At this stage of the process, we were confident that we could adequately support and engage with our end users, but another question remained: how could we make sure our workstation policy administrators were happy as well? We thought requesting that they cross-reference and copy/paste individual extension IDs to the controlling Group Policy and Jamf profile dashboards was too onerous, but at the same time, we couldn’t find examples of automating this type of workflow. Undeterred, we looked to our other corporate infrastructure-as-code for inspiration.
Our breakthrough on this came when we found the PowerShell cmdlet Set-GPRegistryValue. We already knew that in Windows, the allowlist policy for the three browsers was simply a numbered list of registry keys. After significant trial and error, we developed a pipeline around this PowerShell command that automatically deploys a new allowlist to all Windows endpoints within about an hour of the PR being approved and merged, with no human intervention required.
Here’s how it works:
Step One: Publish to Artifactory
Once a PR is merged and the json list is updated, CircleCI automatically publishes the new version to our internal Artifactory. An example for Edge is below; Chrome and Firefox are nearly identical.
publish:
docker:
- image: container.palantir.internal/circle-build/ubuntu
steps:
- checkout
- run:
name: Publish extensions
command: |
curl --fail -u "${USERNAME}:${PASSWORD}"
-X PUT "https://files.palantir.internal/files/extensions/latest/edge_extensions.json"
-T edge_extensions.json
Step Two: Scheduled PowerShell scripts compare and update Group Policy
We built PowerShell scripts that inspect the json lists published in Artifactory and perform a comparison to the current Group Policies; if there are any differences, these scripts rebuild the relevant list from scratch (this facilitates both additions and removals).
These PowerShell scripts are baked into a Docker image, which is deployed via Hashistack into our Tier-0 (domain controller-equivalent) network, and which run the scripts every five minutes via Scheduled Task. This single-function machine executes the scripts in the context of a group Managed Service Account (gMSA) which has UPDATE permissions for the Group Policy that controls browser policies (and absolutely nothing else). As this worker machine is able to modify a Group Policy that can influence many machines, we handle it with similar security considerations as our actual Domain Controllers and other Tier-0 services.
# Applying Registry w/ PowerShell:
# https://docs.microsoft.com/en-us/powershell/module/grouppolicy/set-gpregistryvalue?view=win10-ps
# Limited Scope GPO:
# "{YOUR GPO GUID}"
# Allowlist location:
# ComputerHKEY_CURRENT_USERSoftwarePoliciesGoogleChromeExtensionInstallAllowlist
# The GPO operates as a simple ordered list of REG_SZ incrementing from one (not zero).
$GPO_Id = "{YOUR GPO GUID HERE}"
$chrome_rules_file = "chrome_extensions.json"
$extensions_uri = "https://files.palantir.internal/files/extensions/latest/" + $chrome_rules_file
$RegistryPath = "HKEY_CURRENT_USERSoftwarePoliciesGoogleChromeExtensionInstallAllowlist"
Import-Module GroupPolicy
Write-Host $(Get-Date -Format u) "[info] Chrome Policy Comparison Starting"
function Get-CurrentJSON {
Try {
(Get-Content -Raw -Path ($chrome_rules_file) | ConvertFrom-Json) | Select -ExpandProperty ExtensionId
Write-Host $(Get-Date -Format u) "[info] Retrieved Settings from the JSON input file"
} Catch {
Write-Host $(Get-Date -Format u) "[warning] Failed to read JSON input file"
}
}
function Get-CurrentGPOExtensions {
Try {
Get-GPRegistryValue -Id $GPO_Id -Key $RegistryPath | Select -ExpandProperty Value
Write-Host $(Get-Date -Format u) "[info] Retrieved group policy settings"
} Catch {
Write-Host $(Get-Date -Format u) "[warning] Failed to read group policy settings"
}
}
#DOWNLOAD THE EXTENSIONS FILE FROM ARTIFACTORY
Invoke-WebRequest -Uri $extensions_uri -OutFile $chrome_rules_file
$f = get-item $chrome_rules_file
if ($f.Length -lt 500){
Write-Host $(Get-Date -Format u) "[warning] Something is wrong with the JSON download. Exiting."
Exit 1
}
# CHECK IF ANY CHANGES HAVE BEEN MADE,
# EXIT HERE IF NO CHANGES DETECTED:
$current_gpo_state = @(Get-CurrentGPOExtensions)
$current_JSON = @(Get-CurrentJSON)
$differences = Compare-Object $current_gpo_state $current_JSON
if ($null -eq $differences){
Write-Host $(Get-Date -Format u) "[info] No changes in the JSON. Exiting."
Exit 0
}
#CONTINUE, JSON INPUT IS DIFFERENT THAN GPO
#LAST SAFETY CHECK: Is the current JSON input valid?
if ($current_JSON.Length -lt 20){
Write-Host $(Get-Date -Format u) "[warning] Failed safety check. JSON input invalid. Aborted before making changes."
Exit 1
}
#DROP EXISTING SETTINGS:
Remove-GPRegistryValue -Id $GPO_Id -Key $RegistryPath
#APPLY THE NEW SETTINGS AS A SINGLE GPO UPDATE:
Try {
Set-GPRegistryValue -Id $GPO_Id -Key $RegistryPath -ValuePrefix 0 -Type String -Value $current_JSON
Write-Host $(Get-Date -Format u) "[info] Applied registry changes to GPO $GPO_Id"
} Catch {
Write-Host $(Get-Date -Format u) "[critical] Failed to apply registry changes to GPO $GPO_Id"
Write-Host $(Get-Date -Format u) "[critical] Old settings were deleted and new settings have not been applied"
}
The script for Edge is almost identical. The key difference is the registry path being updated:
$RegistryPath = "HKEY_CURRENT_USERSoftwarePoliciesMicrosoftEdgeExtension
Step Three: Don’t forget macOS
While we were thrilled to have a highly functional pipeline for our Windows workstations, we were also aware that the majority of our employees are on macOS and therefore wouldn’t benefit from this automation. In the end, we were able to leverage portions of the Windows pipeline to streamline macOS profile administration, but not automate it outright.
Palantir previously developed a service called Excavator that executes arbitrary scripts against internal GitHub repositories and can open PRs based on the results. Using Excavator, we built a script that would parse the json file from Artifactory and automatically convert any changes into a preformatted plist file for our Jamf Pro administrators to easily apply. The process is ultimately manual, but still streamlined, as they can upload the entire updated plist file at once rather than picking through arrays looking for what has changed.
The script run by Excavator to generate a plist for Edge looks like this:
/bin/env python3
''' Generate a plist file in the endpoint configuration profile repository. '''
import datetime
import logging
import optparse
import sys
import os
import requests
import jinja2
import plistlib
BASEPATH = 'https://files.palantir.internal/files/extensions/latest'
EDGEFILE = 'edge_extensions.json'
directory = os.path.dirname(os.path.realpath('__file__'))
file = '/Microsoft Edge/com.microsoft.Edge.plist'
filedir = directory + file
def render_template(path, extensionids):
""" Render the template from the list of extension ids """
templateloader = jinja2.FileSystemLoader(searchpath=os.path.dirname(path))
templateenv = jinja2.Environment(loader=templateloader, trim_blocks=True, keep_trailing_newline=True)
templatefile = os.path.basename(path)
template = templateenv.get_template(templatefile)
outputtext = template.render(extensionids=extensionids)
logging.info("file directory detected as %s", filedir)
logging.info("writing file")
with open(filedir, 'w') as filewriter:
filewriter.write(outputtext)
def test_plist():
try:
with open(filedir, "rb") as plist:
plistlib.load(plist)
except:
logging.critical("Plist is not valid, aborting: %s")
sys.exit(1)
def get_published_file():
""" Pull down file from Files"""
logging.info("pulling down Edge extension file from Files...")
logging.debug(BASEPATH + EDGEFILE)
## try or die
try:
results = requests.get(BASEPATH + EDGEFILE)
except requests.exceptions.RequestException as message:
logging.critical("Was unable to get policy: %s", message)
sys.exit(1)
return results.json()
def parse_args():
"""Parse the arguments"""
parser = optparse.OptionParser()
parser.add_option("-v", "--verbose", help="increase logging output",
action="store_true")
# pylint: enable=line-too-long
parser.description = """
Generate new Edge plist file from published policy
"""
(options, args) = parser.parse_args()
if options.verbose:
level = logging.DEBUG
else:
level = logging.INFO
# pylint: disable=line-too-long
logging.basicConfig(level=level,
format='%(asctime)s %(levelname)s %(name)s: %(message)s',
datefmt='%m/%d/%Y %H:%M:%S')
# pylint: enable=line-too-long
return (options, args[0])
def get_ordered_ids(blob):
""" Take json blob and return ordered list """
ids = []
for extension in blob:
ids.append(extension['ExtensionId'])
return ids
def main():
""" Main function """
start = datetime.datetime.utcnow()
# options and args
(options, path) = parse_args()
logging.info("Starting Jamf Pro policy updater: %s", start)
edge_blob = get_published_file()
ids = get_ordered_ids(edge_blob)
render_template(path, ids)
test_plist()
if __name__ == '__main__':
main()
The script relies on a template file to correctly insert the extension IDs into the existing profile. One of the limitations of this method is that Excavator assumes that only the extension IDs are dynamic, and all other components are relatively static. In our experience this has been true, but there are edge cases we have to account for. An example template plist file is below:
Name: edge_plist.tpl
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>ExtensionInstallAllowlist</key>
<array>
{% for extensionid in extensionids %}
<string>{{ extensionid }}</string>
{% endfor %}
</array>
<key>ExtensionInstallBlocklist</key>
<array>
<string>*</string>
</array>
<key>SyncDisabled</key>
<true/>
</dict>
</plist>
Jamf Pro has an API that allows for direct interaction and manipulation of existing profile plist configurations, which could theoretically allow automation of the macOS security policies to match the Windows automation, but we have so far been unable to execute that function in a manner safe enough to deploy into production.
Browser extensions provide a useful productivity boost but also add to the list of risks that security teams need to manage. We hope that this provided insight into the Palantir approach to the problem. We realize that the code-repository-driven workflow described above probably can’t be simply dropped in to other organizations without some modification, but we are hopeful that the examples and the context from this post provide a handy starting point.
Managing and Automating Browser Extensions at Scale was originally published in Palantir Blog on Medium, where people are continuing the conversation by highlighting and responding to this story.
Jasper Research Lab’s new shadow generation research and model enable brands to create more photorealistic…
We’re announcing new updates to Gemini 2.0 Flash, plus introducing Gemini 2.0 Flash-Lite and Gemini…
Interactive digital agents (IDAs) leverage APIs of stateful digital environments to perform tasks in response…
This post is co-written with Martin Holste from Trellix. Security teams are dealing with an…
As AI continues to unlock new opportunities for business growth and societal benefits, we’re working…
An internal email obtained by WIRED shows that NOAA workers received orders to pause “ALL…