Skip to content

sf-audit 1.17: 14 Salesforce Security Checks for Inbound Email, Platform Events, Data Cloud and Package Installs

sf-audit 1.17 adds 14 read-only checks and two critical attack chains for inbound email, Platform Events, Data Cloud, package installs and Apex system mode.

For Salesforce admins, architects and security reviewers who run sf-audit or are deciding whether to. This covers what 1.17 checks that 1.16 did not, and one finding type that changed meaning.

TL;DR

  • 14 new read-only checks cover surfaces most org reviews skip: inbound email services, Platform Event subscribers, Data Cloud data spaces, InstallHandler scripts in packages, delegated admin groups, Partner Super Users and Restriction Rules. The tool now runs 109 checks.
  • Two new CRITICAL attack chains: an unauthenticated email that ends in privileged Apex, and a guest or portal user publishing a Platform Event that runs as Automated Process or an admin.
  • Apex findings now name the control a class bypasses. WITH SYSTEM_MODE drops CRUD and FLS but leaves record sharing to the class's sharing keyword, so it is no longer reported as a CRITICAL sharing bypass.
  • CI support: SARIF output for GitHub code scanning, accepted-risk waivers that expire within 365 days, and no local history written on CI runners.

What You'll Learn

  • Which Salesforce entry points let an outsider run code without logging in, and how sf-audit detects them
  • Where privilege hides from a normal permission set review
  • Why WITH SYSTEM_MODE and without sharing are different findings, and how severities changed because of it
  • How to wire the audit into a pipeline without letting accepted risks fail every build

The Problem

Most Salesforce security reviews start from users, profiles and permission sets, then work outward to sharing and connected apps. That covers how people get in through the front door. It misses the side doors that never involve a login page.

An inbound email service runs Apex when a message arrives. A Platform Event subscriber runs when an event is published, often as Automated Process. A package's InstallHandler runs in system context on every install and push upgrade. None of these show up when you list who has Modify All Data.

Common questions this article answers:

  • Can an anonymous outsider run Apex in my org without a login?
  • Does WITH SYSTEM_MODE mean my query ignores sharing?
  • Which admin-level access does a permission set query not show?
  • How do I fail a CI build on new findings but not on risks we already accepted?

Quick Answer

Install or update the plugin and run it against a sandbox first:

sf plugins install @cclabsnz/sf-audit
sf audit security --target-org mySandbox

Everything is read-only: SOQL, Tooling API and REST GETs, plus Metadata API reads. The report lists each finding with a severity, the evidence it found and the compliance controls it maps to, then correlates findings into attack chains. If you already run 1.16, expect new findings in the areas below and some Apex findings to change id and severity.

The 14 New Checks

I grouped them by the question they answer rather than by API.

Can someone run code here without logging in?

Check What it looks for
inbound-email-handler-exposure Email services that accept messages from any sender and run Apex
platform-event-subscriber-context Platform Event subscribers running in system context or as an admin while low-trust or guest users can publish the event
auth-provider-registration-handler A RegistrationHandler running as an admin that links accounts by email address, which opens the door to pre-auth account takeover
delegated-authentication-exposure Delegated Authentication over unencrypted or non-mTLS endpoints, or assigned to System Administrator profiles
oauth-device-and-asset-token-flows The OAuth Device Flow and Username-Password Flow enabled in OAuth and OpenID Connect Settings

The OAuth check reads org settings only. If the audit cannot read a setting, it says so as an advisory instead of guessing "disabled". If you are planning for the Username-Password flow's retirement, the migration guide covers the timeline.

Who has admin power that a permission set query does not show?

Check What it looks for
delegated-administration-groups Delegated admin groups that can manage users, log in as them or assign permission sets
external-account-hierarchy-exposure Partner Super Users and External Account Hierarchies that widen portal record reach past the external org-wide default
restriction-rules-integrity Inactive Restriction Rules leaving records exposed, and conflicting active rules on the same object

Delegated administration is the one I see missed most. A delegated admin can reset passwords and assign permission sets for the users in their group, and none of that appears when you query PermissionSetAssignment for admin permissions.

What runs with more privilege than it should?

Check What it looks for
package-post-install-supply-chain Packaged Apex implementing InstallHandler. Managed namespaces whose source is hidden are listed as an advisory
flow-http-callout-credential-bypass Flow HTTP callouts with embedded API tokens, or callouts that go around Named Credentials
genai-prompt-template-grounding Active Prompt Templates grounded on system-mode flows or unshared Apex, or mixing untrusted external fields with sensitive CRM data

The prompt template check fits the same pattern as Agentforce agent user least privilege: an AI feature inherits whatever access its grounding runs with, and system mode grounding hands a user's prompt records they cannot open themselves.

Is the data plane outside core CRM locked down?

Check What it looks for
data-cloud-data-space-isolation A default Data Space shared with integrations, partner users or agent users, and unconstrained Data Share Targets
shield-tenant-secret-lifecycle Shield Platform Encryption tenant secrets not rotated in 365 days
org-wide-email-spoofing Org-Wide Email Addresses available to every profile without SPF and DKIM alignment

Two New Attack Chains

A finding on its own tells you a setting is wrong. A chain tells you that several findings together make a path an attacker can walk. sf-audit only reports a chain when every ingredient is present, so a clean org shows none.

Unauthenticated inbound email to privileged code execution (CRITICAL). An open email service address with no sender allowlist and no SPF/DKIM verification accepts mail from anyone, and the message triggers Apex or DML as a privileged user or in a class without sharing. The attacker needs your service address and nothing else.

Guest or low-trust Platform Event publish to automated process escalation (CRITICAL). A guest or portal user can publish a custom Platform Event, and a trigger or Flow subscribed to it runs as Automated Process or an admin. The publisher's own permissions and sharing stop mattering once the subscriber runs.

Either chain breaks if you fix any one step: restrict the email service's senders, take publish access away from the guest profile, or make the subscriber run as a user with only the access it needs.

The Apex Finding That Changed Meaning

Earlier versions reported WITH SYSTEM_MODE and AccessLevel.SYSTEM_MODE as a CRITICAL sharing bypass. That was wrong, and fixing it changed severities, so it is worth knowing before you compare reports.

System mode in SOQL and DML switches off the running user's object and field permissions. It does not switch off record sharing: which records the query can see is still decided by the class's sharing keyword. A with sharing class running a WITH SYSTEM_MODE query still respects sharing; it only stops checking CRUD and FLS.

So 1.17 reports these separately:

  • apex-crud-fls-system-mode (MEDIUM) for system-mode queries and DML, and guest-executable-apex-system-mode (HIGH) when the class is callable by guests
  • a CRITICAL guest-executable-apex-unprotected when a guest-callable class calls a helper declared without sharing, naming the helper, because the helper's keyword governs while its code runs
  • inherited sharing classes no longer flagged CRITICAL as guest entry points, since at an entry point they run with sharing
  • @RestResource classes without sharing reported alongside @AuraEnabled and @RemoteAction ones

USER_MODE counts as CRUD and FLS enforcement. The audit also reads a class's sharing keyword from its declaration only, so a without sharing class that mentions "with sharing" in a comment is no longer read as protected.

This change was built with Intent, Spec, Test. Its three must-nevers are: never report a class as bypassing record sharing when its sharing is enforced; never report a guest-callable class as protected when it, or a helper it calls, is declared without sharing; and never let system mode pass as CRUD/FLS enforcement. Each has held-out tests that CI runs and the coding agent never sees.

A Bug Worth Admitting

The first version of the 14 checks passed every unit test and still could not see real evidence. Several checks reported a pass when their query failed. The tests were green because the mocks returned fields the real queries never selected. One Flow query asked for metadata that the bulk query cannot return, so formula scanning never ran.

The fix made each check read what the org actually returns, and made a failed read inconclusive instead of a pass. Every report now states its evidence coverage, the share of checks that gathered evidence. Below 90%, a would-be A grade is capped at B, and the report says why. A clean report from an audit that could not see half the org is worse than no report.

Running It in CI

1.17 adds three things for pipelines:

sf audit security --target-org ciSandbox --format sarif --output ./reports --fail-on HIGH
  • --format sarif writes SARIF 2.1.0 for GitHub code scanning. Each result carries a stable fingerprint, so a recurring finding stays one alert instead of a new one per run.
  • Accepted-risk waivers (--waivers, or .sfaudit-waivers.json in the working directory) take a reviewed finding out of the score and --fail-on. Matching is exact, a waiver runs at most 365 days, and a CRITICAL finding needs acknowledgeCritical. There is no wildcard to hide behind.
  • History is skipped by default when CI, GITHUB_ACTIONS, TF_BUILD or JENKINS_URL is set, so shared runners keep no org data. --keep-history turns it back on.

Frequently Asked Questions

Does sf-audit change anything in my org?

No. Every check is a read: SOQL, Tooling API, REST GETs and Metadata API reads. A unit test scans the check code and fails the build if a check tries to write.

Why did some of my Apex findings drop from CRITICAL after upgrading?

System-mode queries were reported as sharing bypasses before 1.17. They bypass CRUD and FLS, not sharing, so they are now MEDIUM (apex-crud-fls-system-mode), or HIGH for guest-callable classes. Classes that really run without sharing behind a guest entry point are still CRITICAL.

What does an advisory finding mean?

The audit could not read the setting through the APIs it uses, so it reports that as INFO rather than guessing. INFO findings carry no weight in the default scoring, so an advisory does not lower your grade unless you change the weights with --scoring-config. The OAuth flows check and some package checks produce them when settings or source are not readable.

Can I run it against production?

Yes, it only reads. Run it against a sandbox first to see what it reports and how long it takes. On a CI runner, skip the local history so no org data is left on the machine.

Key Takeaways

  • Inbound email services, Platform Event subscribers and package InstallHandler scripts can all run code without anyone logging in. Review them like you review login access.
  • Delegated admin groups and Partner Super Users grant real power that permission set queries do not show.
  • WITH SYSTEM_MODE skips CRUD and FLS; the class's sharing keyword still decides record access. Treat them as separate risks.
  • A green test suite proves nothing if the mocks return data the real query never selects. Coverage reporting in the audit exists because of that.

What's Next?

Resources & References

Responses

Checking your session.

Loading responses.