Salesforce DevSecOps: a security pipeline for Apex, LWC and Flows
Salesforce DevSecOps has a problem that application teams do not: half of what decides your security never passes through the pipeline. Apex, Lightning components and Flows live in a repository and can be scanned like any other code. But a profile edited in Setup, a connected app approved for a vendor, or a guest user given one more object never becomes a commit. A pipeline that only scans pull requests sees the code and misses the org.
So a working Salesforce security pipeline has two halves. One runs on every change, from the developer's laptop to the merge. The other runs on a schedule against the live org, because the org changes whether or not anyone deploys.
This post lays out both halves, stage by stage, with the commands we use in Vulkro for Salesforce (vulkro-sf). The order matters more than the tools: each stage catches what is cheapest to fix at that point, and the gate at the end only has to say no to things that are new.
What does a Salesforce DevSecOps pipeline need to cover?
More than Apex. The surfaces that carry real risk in a Salesforce project are:
- Apex: SOQL and SOSL injection, missing CRUD and field-level security,
without sharingclasses behind public entry points. - Lightning Web Components and Aura: HTML written into the DOM, relaxed Lightning Web Security settings, vulnerable libraries in static resources. We covered these in LWC security.
- Flows: flows that run in system context on records chosen by user input.
- Metadata: profiles, permission sets, sharing settings, connected apps, guest access. A one-line change to a permission set can matter more than a hundred lines of Apex.
- The live org: everything above as it actually is in production, plus users, sessions, MFA and OAuth tokens that exist nowhere in source.
A pipeline that scans only .cls files covers the first bullet.
Stage 1: in the editor
The cheapest fix is the one made before the file is saved twice. The VS Code extension runs the same checks as the command line on Apex, LWC, Aura, Visualforce and Flow files as you open and save them. It is not a pipeline stage in the CI sense, but it is why the later stages rarely have to block anything. Security where Salesforce developers write code covers it in detail.
Stage 2: pre-commit
vulkro-sf init --pre-commit
This installs a git hook (in .husky/pre-commit when the project uses Husky, otherwise .git/hooks/pre-commit). On each commit it does two things:
vulkro-sf ctx check --stagedlists what the change broke: call sites still passing the old arguments to a method whose parameters changed, removed methods still in use, and syntax errors. By default it warns. Install with--block-on-ctx-checkto make it stop the commit.vulkro-sf scan --gate-vs HEADruns on staged Apex and metadata files and fails the commit only on findings that are new compared withHEAD. Existing findings never block a commit.
For an emergency commit, VULKRO_SF_SKIP_PRECOMMIT=1 bypasses the hook. If a machine has no licence, the hook checks nothing, says so in one line, and lets the commit through, so a hook never blocks work for a reason unrelated to the code.
Stage 3: the pull request
This is the main gate. Two commands do different jobs here.
How do you gate on new findings only?
vulkro-sf scan ./force-app --gate-vs origin/main
--gate-vs scans the project at the base ref as well, compares the two sets of findings by file, line and rule, and exits 1 only for findings the pull request introduced. Findings already on main are reported but do not block. This is what keeps a security gate switched on: a team adopting it on a mature codebase does not have to fix every old finding before anyone can merge. The base ref has to resolve with git rev-parse, so check out the full history (fetch-depth: 0 on GitHub Actions). The baselines guide covers the details.
How do you review metadata changes in a pull request?
vulkro-sf review --range origin/main...HEAD --fail-on high
review shows only the findings on lines the change touched, tagged NEW or existing. For metadata it also reports what the change grants and to whom, by comparing the metadata before and after: a guest or external user gaining read or edit on a sensitive field, a permission set gaining a dangerous system permission, a connected app losing PKCE. Those access changes count as new and are listed first. This is the stage that catches a permission set change before it reaches production, which a code-only scan never would.
Both commands can annotate the pull request directly (--format github-annotations or --format gh-pr-inline), carrying only the new rows.
Stage 4: SARIF to code scanning
Keep a full scan as a SARIF file on every run, whatever the gate decided:
vulkro-sf scan ./force-app --format sarif > vulkro-sf.sarif
SARIF is the OASIS standard format for static analysis results. On GitHub you can upload a SARIF file to code scanning, and the findings appear in the repository's security tab with their history. GitLab and Bitbucket keep it as a pipeline artifact. Upload it in a step that runs even when the scan step fails, so a red build still records what it found.
Here is the shape of a GitHub Actions job:
name: Vulkro for Salesforce
on:
pull_request:
branches: [main]
permissions:
contents: read
security-events: write
jobs:
scan:
runs-on: ubuntu-latest
env:
VULKRO_ACCOUNT_TOKEN: ${{ secrets.VULKRO_ACCOUNT_TOKEN }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- run: curl -fsSL https://dist.vulkro.com/install-sf.sh | bash
- run: vulkro-sf login
- name: Gate on new findings
run: vulkro-sf scan ./force-app --gate-vs origin/${{ github.base_ref }}
- name: Review metadata changes
run: vulkro-sf review --range origin/${{ github.base_ref }}...HEAD --fail-on high
- name: Full scan as SARIF
if: always()
run: vulkro-sf scan ./force-app --format sarif > vulkro-sf.sarif || true
# then upload vulkro-sf.sarif with GitHub's SARIF upload action
The CI key comes from your account portal and is stored as the VULKRO_ACCOUNT_TOKEN secret. The Salesforce CI/CD docs have the same job for GitLab CI and Bitbucket Pipelines.
Exit codes are the contract: 0 no findings, 1 findings (or no licence, with license_required on stderr), 2 the scan could not run. A crash never reads as a clean build.
For a managed package, add the AgentExchange readiness check as its own gate:
vulkro-sf appexchange-report ./force-app --format table --gate
It exits 1 when a pre-submit checklist item fails.
Stage 5: scheduled live org scans
Everything above sees the repository. Nothing above sees the org. In March 2026 Salesforce described a campaign against Experience Cloud sites that worked through customer-configured guest user settings, not a platform flaw. Settings like those are edited in Setup and never show up in a pull request. We wrote more about that gap in why Salesforce org security needs scheduled scans.
Vulkro Cloud scans each live org you add on a schedule (every 6 hours, daily, weekly or monthly, depending on your plan) through an External Client App you own, read-only. It never reads the contents of business records and never changes the org. Each scan is compared with the last, so the output is what changed: new administrators, new connected apps, relaxed settings, wider guest access, new Critical and High issues. Notifications go to Slack, Microsoft Teams, Jira and others.
The workspace's triage queue can gate a pipeline too:
vulkro-sf cloud todo --fail-on critical
That exits 1 when open Critical issues exist in the workspace, so a release can be held while the org it deploys into has an unresolved problem. Without a workspace, vulkro-sf org report --target-org prod reads one org on demand through your own sf login and writes a posture report.
Salesforce DevSecOps checklist
- Checks in the editor on every save.
- A pre-commit hook that fails only on new findings.
- A pull request gate with
--gate-vsagainst the target branch. - A metadata review that reports what each change grants and to whom.
- A full SARIF scan kept on every run, pass or fail.
- Exit code
2treated as a failure, never as clean. - Every production org scanned on a schedule, with changes compared to the last scan.
Where to see it
Release gating shows how the gate works on the pull request. The CI/CD integration docs have copy-paste jobs for each CI system, and Vulkro Cloud covers the scheduled org side.
To set it up on your own project, get started.
Frequently asked questions
What is Salesforce DevSecOps?
It is building security checks into the way Salesforce changes are written, reviewed and deployed, rather than auditing once a year. In practice that means checks in the editor, at commit, on every pull request, and on a schedule against the live org, because many Salesforce changes are made in Setup and never pass through source control.
How do I stop a security gate from blocking every pull request?
Gate on new findings only. vulkro-sf scan --gate-vs origin/main compares the change with its base branch and fails only on findings the change introduced, so existing findings are reported without blocking. Fix the backlog separately with full scans.
Can a pipeline catch permission changes in metadata?
Yes, if it compares metadata before and after the change. vulkro-sf review reports what a change grants and to whom, such as a guest user gaining read on a sensitive field. Changes made directly in Setup need a scan of the live org.
Do I need to scan the live org if I scan every deployment?
Yes. Profiles, permission sets, connected apps, sessions and guest settings are often changed in Setup without a deployment, and users and OAuth tokens exist only in the org. A scheduled, read-only scan of the org is the only way to see them.
