How We Built a Native sf Plugin for Salesforce Security

We started with a Python script. It worked, but the friction was high: separate authentication, manual installs, and "ensure you have Python 3.11" requirements. When your team already runs sf, that's a barrier to adoption.

We rewrote it as a native sf plugin using TypeScript. Here is the architecture that made 22 parallel security checks practical.


Six Layers, One Job Each

The plugin is built on top of @salesforce/sf-plugins-core and oclif. We split the logic into six layers with a strict downward dependency rule:

  1. API Client: Wraps Salesforce REST and Tooling APIs.
  2. QueryRegistry: Loads SOQL definitions from JSON config. Checks don't write inline queries.
  3. AuditContext: A single object containing the connection, registry, and org metadata.
  4. CheckEngine: Orchestrates the execution of checks and collects findings.
  5. Scoring Layer: Assigns risk levels and calculates the final health score.
  6. Renderers: Implements AuditRenderer for HTML, Markdown, and JSON outputs.

Solving the Cache Problem

Running 22 checks means the same data is often requested multiple times. For example, HardcodedCredentialsCheck and ApexSharingCheck both need Apex class bodies.

We implemented a Dependency-Aware Cache:

  • populatesCache: A check declares what data it provides.
  • dependsOnCache: A check declares what data it needs.

The CheckEngine validates this order at startup. If ApexSharingCheck runs before the cache is populated, the plugin fails with a clear error before making a single API call. This ensures we never issue redundant scans for the same resources.


Scoring Without Hardcoding

Risk weights are decoupled from the code. They live in a config/scoring.json file, allowing users to tune the audit to their specific risk appetite:

{
  "riskScores": {
    "CRITICAL": 10,
    "HIGH": 7,
    "MEDIUM": 4,
    "LOW": 1,
    "INFO": 0
  }
}

Design Choice: Any CRITICAL finding results in an immediate Grade F, regardless of the numerical score. This reflects the reality that one open door is all an attacker needs.


Lessons Learned: The Abstraction Trap

Lesson Learned: Our QueryRegistry separated SOQL from logic by storing queries in JSON files. While it felt "clean," it made the code harder to navigate. In a future refactor, we would move the SOQL inline to the checks. The abstraction didn't pull its weight.


Extensible Output

The AuditRenderer interface is a single method: render(result: AuditResult): string.

Adding a new format (like a Slack-friendly summary or a CSV for auditors) requires zero changes to the core engine. You just implement the interface and register the new renderer.


Want to run this against your own org? Start with the usage guide.

Comments (0)

Loading comments...