As Salesforce developers, we're building some of the most critical applications for our businesses. We've embraced DevOps practices to speed up our development and delivery processes. But in our rush to automate, have we overlooked some fundamental security principles?
Today, let's talk about a principle that's often violated in Salesforce CI/CD setups: the principle of least privilege access control.
Let's start with a somewhat alarming statement: CI/CD credentials are the scariest credentials in your Salesforce ecosystem. Why? These credentials often grant full admin rights to enable metadata deployments and configuration of orgs through builds. For packaging, allowing a developer to extract the package promotion credential is essentially giving them direct access and control over the path to production - a key control in SOC compliance.
Yet, many Salesforce teams are using overly broad credentials in their CI/CD builds. It's time we took a closer look at this practice.
Here's a fundamental truth about build jobs: they typically only need temporary access to the target org during the job duration. If something is needed from another org for the job, it should be fetched via another job for that org.
But how many of us are actually implementing this? More often than not, we're giving our build jobs perpetual access to our orgs. This is a violation of the principle of least privilege, and it's putting our systems at unnecessary risk.
I hear this a lot, and I get it. It feels secure. But let's break down what's actually in that sfdxAuthUrl:
That's everything needed to get a fresh OAuth access token at any point in the future. Even worse, if you're using a custom Connected App with Salesforce CLI, you're exposing the client id and client secret of that app.
Let me demonstrate how easy it is to exploit this. Here's a simple command to parse the sfdxAuthUrl and use curl to refresh the token:
parsed_url=$(echo $SFDX_AUTH_URL | base64 --decode)
client_id=$(echo $parsed_url | sed 's/.*client_id=\([^&]*\).*/\1/')
client_secret=$(echo $parsed_url | sed 's/.*client_secret=\([^&]*\).*/\1/')
refresh_token=$(echo $parsed_url | sed 's/.*refresh_token=\([^&]*\).*/\1/')
instance_url=$(echo $parsed_url | sed 's/.*instance_url=\([^&]*\).*/\1/')
curl $instance_url/services/oauth2/token -d grant_type=refresh_token -d client_id=$client_id -d client_secret=$client_secret -d refresh_token=$refresh_token
With this simple script, anyone who gets access to your sfdxAuthUrl can perpetually access your org. Scary, right?
Another common practice that violates least privilege is granting DevHub access via a single high-privilege credential. DevHub comes with Limited Access - Free user licenses designed to be used for service accounts with different permissions or by developers who don't need a full Salesforce license.
Instead of a single all-powerful credential, consider setting up multiple users with specific permissions:
This granular approach significantly reduces the potential damage if any single credential is compromised.
There's another issue with our current setup: the use of a static sfdxAuthUrl used by each worker to refresh the token. This approach blocks the implementation of an important security feature on Salesforce Connected Apps: refresh token rotation.
Ideally, each time a refresh token is used, a new one should be issued. But our current setup doesn't allow the worker to write back to the secret it was given (nor should it be able to). This leaves us stuck with long-lived refresh tokens, increasing our vulnerability.
So, how do we fix this? Here are a few steps to move towards least privilege access control:
Implementing these changes isn't trivial, but the security benefits are substantial. Remember, in the world of DevOps, speed shouldn't come at the cost of security.
In the next post in this series, we'll dive deeper into implementing these principles in practice. Stay tuned, and in the meantime, take a hard look at your CI/CD credential practices. Are you following the principle of least privilege?