Tip
Want to read the new documentation? Head over to sfhound.kaibersec.com
SalesforceHound collects the data necessary to:
-
Identify, Analyze, and Execute Salesforce Attack Paths Discover attack paths that may degrade the security posture of a Salesforce org.
-
Easily Audit Identity and Access Management Observe both the configurations and the outcomes created by Salesforce RBAC mechanics — Profiles, Permission Sets, Permission Set Groups, Role Hierarchies, Public Groups, and Queues.
-
Map Field and Object-Level Permissions Surface which users can read, modify, or delete sensitive SObjects and Fields through any combination of permission assignments.
-
Enumerate OAuth and API Attack Surface Trace which identities can authorize Connected Apps and which admins created them.
SalesforceHound has only been tested against Developer Edition and sandbox orgs. It may not scale to large production environments as written. I would greatly appreciate your feedback on any performance or visibility issues you encounter.
- Individual SharingRule metadata (AccountSharingRule, etc.) requires the Salesforce Metadata API and is not currently extracted. OWD settings are captured per object.
- Queue and group membership in very large orgs (100,000+ GroupMember records) may be slow to extract.
- Field permission extraction may produce a large number of edges; consider scoping to custom fields for initial analysis.
System Requirements:
- Windows, Linux, or macOS (tested on Windows 10/11 and Ubuntu 20.04+)
- Python 3.8 or higher
- pip (Python package manager)
- 2 GB RAM minimum; 4 GB+ recommended for large orgs (10,000+ users)
Clone the repository and install dependencies:
git clone https://github.com/Khadinxc/sfhound.git
cd sfhound/sf-opengraph
pip install -r requirements.txtSalesforceHound authenticates via the JWT OAuth flow. You will need to create a Connected App and a certificate.
Generate a JWT certificate:
openssl genrsa -out salesforce_jwt.key 2048
openssl req -new -x509 -key salesforce_jwt.key -out salesforce_jwt.crt -days 365Create the Connected App:
- Setup → App Manager → New Connected App
- Enable OAuth Settings, set Callback URL to
https://login.salesforce.com/services/oauth2/callback - Enable "Use digital signatures" and upload
salesforce_jwt.crt - Add OAuth Scopes:
api,refresh_token, offline_access - Save and wait 2–10 minutes for changes to propagate
Pre-authorize your integration user:
- Setup → Connected Apps → Manage Connected Apps → SalesforceHound → Edit Policies
- Set Permitted Users to
Admin approved users are pre-authorized - Add your integration user's Profile or Permission Set under Manage Profiles / Manage Permission Sets
| Permission | Purpose |
|---|---|
| API Enabled | Required for REST/Tooling API access |
| View Setup and Configuration | Query Profiles, PermissionSets, Roles, ConnectedApps, EntityDefinitions, ObjectPermissions |
| View All Data | Query Users, Groups, PermissionSetAssignments, GroupMembers, and all record data |
Recommended: System Administrator profile, or a custom profile with the three permissions above.
| Permission | Purpose |
|---|---|
| API Enabled | Required |
| View Setup and Configuration | Required |
| Read on User, Group, PermissionSetAssignment, GroupMember | Minimum data access |
The collector will extract whatever data is visible to the user. The resulting graph will be incomplete but still useful for analyzing visible attack paths.
Creating a custom profile:
- Setup → Profiles → Clone "Standard User"
- Enable "API Enabled" and "View Setup and Configuration"
- Grant Read on: User, Group, PermissionSetAssignment, GroupMember
- Assign to your integration user
Copy config.yaml.example to config.yaml and fill in your values:
salesforce:
client_id: "YOUR_CONNECTED_APP_CONSUMER_KEY"
username: "your.integration.user@example.com"
private_key_path: "./salesforce_jwt.key"
login_url: "https://login.salesforce.com" # Use https://test.salesforce.com for sandboxes
api_version: "v56.0"
# Optional: BloodHound CE auto-ingest
# Set auto-ingest: true to always upload after every run, or leave false and use --auto-ingest flag
bloodhound:
url: "http://127.0.0.1:8080"
username: "admin"
password: "YOUR_BLOODHOUND_PASSWORD"
auto-ingest: false
env:
output_path: "./opengraph_output"Note:
client_idis the "Consumer Key" from your Connected App's "Manage Consumer Details" page.
cd salesforce-opengraph
python sfhound.pyThis produces a BloodHound-compatible JSON file in ./opengraph_output/.
All config.yaml values can be overridden at the command line:
# View all options
python sfhound.py --help
# Override credentials
python sfhound.py --client-id YOUR_CLIENT_ID --username user@example.com --private-key /path/to/key.pem
# Override output directory
python sfhound.py --output-path /custom/output/directory
# Complete override (no config.yaml needed)
python sfhound.py \
--client-id YOUR_CLIENT_ID \
--client-secret YOUR_CLIENT_SECRET \
--username user@example.com \
--private-key /path/to/key.pem \
--login-url https://orgname.my.salesforce.com \
--api-version v56.0 \
--output-path ./my_output| Argument | Description | Default |
|---|---|---|
--config |
Path to config YAML file | config.yaml |
--client-id |
Connected App Consumer Key | From config |
--client-secret |
Connected App Consumer Secret | From config |
--username |
Salesforce username | From config |
--private-key |
Path to private key for JWT auth | From config |
--login-url |
Salesforce login URL | https://login.salesforce.com |
--api-version |
Salesforce API version | v56.0 |
--output-path |
Output directory for JSON files | ./opengraph_output |
--auto-ingest |
Upload graph to BloodHound CE after export | Off |
--bh-url |
BloodHound CE base URL | http://127.0.0.1:8080 |
--bh-username |
BloodHound CE admin username | From config |
--bh-password |
BloodHound CE admin password | From config |
python examples/post_custom_icons.pyPass --auto-ingest to extract and upload in a single command. BloodHound credentials can come from config.yaml or the command line:
# Credentials in config.yaml
python sfhound.py --auto-ingest
# All credentials on the command line (no bloodhound block needed in config.yaml)
python sfhound.py --auto-ingest \
--bh-url http://127.0.0.1:8080 \
--bh-username admin \
--bh-password YOUR_BLOODHOUND_PASSWORDWhat auto-ingest does:
- Validates the exported JSON against the OpenGraph schema
- Checks for stuck/active jobs in BloodHound and aborts if any exist
- Creates a BloodHound file-upload job, uploads the graph, and signals ingestion start
- Polls until ingestion completes, printing status every 15 seconds
- Prints completed task details including any errors or warnings
Note: Auto-ingest does not clear the BloodHound database. If you need a clean slate, clear it manually in the BloodHound UI first.
Drag and drop the output JSON file from ./opengraph_output/ into BloodHound's file upload modal.
You can define the Tier Zero zone rule in the Bloodhound GUI for System Level permissions capable of compromising a Salesforce Organisation with this Cypher Query:
MATCH (u:SFUser)-[:AssignedProfile|AssignedPermissionSet]->(ps)-[:ModifyAllData|ManageUsers|ManageProfilesPermissionsets|AuthorApex|CustomizeApplication|ManageSharing]->(:SFOrganization)
WHERE ps:SFProfile OR ps:SFPermissionSet
RETURN DISTINCT u;
You can also define Tier zero users who have access to your highest value objects, just continue to add values to the obj.name IN ["SECRETDATA__C","SENSITIVEDATA__C","HIDDENDATA__C"] and so on, with the following query and set as a cypher rule type:
MATCH (u:SFUser)-[:AssignedProfile|AssignedPermissionSet|AssignedPermissionSetGroup|HasPermissionSet|IncludesPermissionSet*1..5]->(ps)-[:CanCreate|CanRead|CanEdit|CanDelete|CanViewAll|CanModifyAll]->(obj:SFSObject)
WHERE obj.name IN ["SECRETDATA__C", "SENSITIVEDATA__C"]
AND (ps:SFPermissionSet OR ps:SFProfile)
RETURN DISTINCT u;
Same with your Tier zero users with access to highest value fields:
MATCH (u:SFUser)-[:AssignedProfile|AssignedPermissionSet|AssignedPermissionSetGroup|HasPermissionSet|IncludesPermissionSet|CanCreate|CanRead|CanEdit|CanDelete|CanViewAll|CanModifyAll|IsVisible|ReadOnly|Contains*1..10]->(f:SFField)
WHERE f.name IN ["SECRETDATA__C.HIGHLYSENSITIVEFIELD__C","SECRETDATA__C.OTHERSENSITIVEFIELD__C","SENSITIVEDATA__C.HIGHLYSENSITIVEFIELD__C"]
RETURN DISTINCT u
LIMIT 1000;
---
config:
layout: elk
---
flowchart TD
SFOrganization[fa:fa-building SFOrganization]
SFUser[fa:fa-user SFUser]
SFProfile[fa:fa-user-gear SFProfile]
SFPermissionSet[fa:fa-id-badge SFPermissionSet]
SFPermissionSetGroup[fa:fa-users SFPermissionSetGroup]
SFRole[fa:fa-sitemap SFRole]
SFGroup[fa:fa-users SFGroup]
SFQueue[fa:fa-inbox SFQueue]
SFConnectedApp[fa:fa-plug SFConnectedApp]
SFSObject[fa:fa-database SFSObject]
SFField[fa:fa-list-check SFField]
style SFOrganization fill:#2d3436,color:#fff
style SFUser fill:#00b894,color:#fff
style SFProfile fill:#0984e3,color:#fff
style SFPermissionSet fill:#7f8c8d,color:#fff
style SFPermissionSetGroup fill:#fdcb6e
style SFRole fill:#6c5ce7,color:#fff
style SFGroup fill:#fdcb6e
style SFQueue fill:#e17055,color:#fff
style SFConnectedApp fill:#00cec9,color:#fff
style SFSObject fill:#636e72,color:#fff
style SFField fill:#e84393,color:#fff
SFUser -->|AssignedProfile| SFProfile
SFUser -->|AssignedPermissionSet| SFPermissionSet
SFUser -->|AssignedPermissionSetGroup| SFPermissionSetGroup
SFUser -->|HasRole| SFRole
SFUser -->|MemberOfGroup| SFGroup
SFUser -->|MemberOfGroup| SFQueue
SFProfile -->|HasPermissionSet| SFPermissionSet
SFPermissionSetGroup -->|IncludesPermissionSet| SFPermissionSet
SFRole -->|InheritsRole| SFRole
SFGroup -->|HasMember| SFUser
SFGroup -->|HasMember| SFGroup
SFGroup -->|MemberOfGroup| SFGroup
SFQueue -->|HasMember| SFUser
SFQueue -->|HasMember| SFGroup
SFQueue -->|CanOwnObject| SFSObject
SFProfile -->|CanCreate / CanRead / CanEdit / CanDelete / CanViewAll / CanModifyAll| SFSObject
SFPermissionSet -->|CanCreate / CanRead / CanEdit / CanDelete / CanViewAll / CanModifyAll| SFSObject
SFProfile -->|IsVisible / ReadOnly| SFField
SFPermissionSet -->|IsVisible / ReadOnly| SFField
SFProfile -->|ModifyAllData / ManageUsers / ManageRoles / ManageSharing / ManageProfilesPermissionsets / CustomizeApplication / AuthorApex / ViewSetup / ...| SFOrganization
SFPermissionSet -->|ModifyAllData / ManageUsers / ManageRoles / ManageSharing / ManageProfilesPermissionsets / CustomizeApplication / AuthorApex / ViewSetup / ...| SFOrganization
SFProfile -->|CanAuthorize| SFConnectedApp
SFPermissionSet -->|CanAuthorize| SFConnectedApp
SFConnectedApp -->|CreatedBy| SFUser
| Node Class | Description | Icon | Color |
|---|---|---|---|
SFOrganization |
The top-level org container. System permissions are modeled as edges to this node. | building | #2d3436 |
SFUser |
A Salesforce user principal | user | #00b894 |
SFProfile |
A Profile: the baseline permission assignment for every user. Every user must have exactly one Profile. | user-gear | #0984e3 |
SFPermissionSet |
A Permission Set: additive permissions that can be stacked on a user above their Profile | id-badge | #7f8c8d |
SFPermissionSetGroup |
A Permission Set Group: a named bundle containing one or more Permission Sets | users | #fdcb6e |
SFRole |
A role in the org's role hierarchy. Determines record visibility upward in the hierarchy. | sitemap | #6c5ce7 |
SFGroup |
A Public Group: a named collection of users and/or nested groups used in sharing rules | users | #fdcb6e |
SFQueue |
A Queue: a group-like object that can own records of configured SObject types | inbox | #e17055 |
SFConnectedApp |
A Connected App (OAuth application) registered in the org | plug | #00cec9 |
SFSObject |
A Salesforce object (standard or custom) with CRUD and sharing model metadata | database | #636e72 |
SFField |
A field on an SObject. Field-Level Security (FLS) edges target these nodes. | list-check | #e84393 |
| Edge Type | Source | Target | Description | Traversable |
|---|---|---|---|---|
AssignedProfile |
SFUser |
SFProfile |
User is assigned to this Profile | Yes |
AssignedPermissionSet |
SFUser |
SFPermissionSet |
User has been directly assigned this Permission Set | Yes |
AssignedPermissionSetGroup |
SFUser |
SFPermissionSetGroup |
User has been assigned this Permission Set Group | Yes |
HasPermissionSet |
SFProfile |
SFPermissionSet |
Profile is backed by its own PermissionSet record (IsOwnedByProfile=true) | Yes |
IncludesPermissionSet |
SFPermissionSetGroup |
SFPermissionSet |
Permission Set Group includes this Permission Set | Yes |
HasRole |
SFUser |
SFRole |
User is assigned to this role in the role hierarchy | Yes |
InheritsRole |
SFRole |
SFRole |
Child role — users in the parent role can see records owned by users in this child role | Yes |
MemberOfGroup |
SFUser |
SFGroup |
User is a direct member of this Public Group | Yes |
MemberOfGroup |
SFUser |
SFQueue |
User is a member of this Queue | Yes |
MemberOfGroup |
SFGroup |
SFGroup |
Nested group membership — this group is a member of another group | Yes |
HasMember |
SFGroup |
SFUser |
Group contains this user (inverse of MemberOfGroup) | Yes |
HasMember |
SFGroup |
SFGroup |
Group contains this nested group (inverse of MemberOfGroup) | Yes |
HasMember |
SFQueue |
SFUser |
Queue contains this user (inverse of MemberOfGroup) | Yes |
HasMember |
SFQueue |
SFGroup |
Queue contains this group (inverse of MemberOfGroup) | Yes |
CanOwnObject |
SFQueue |
SFSObject |
Queue is configured to own records of this SObject type | Yes |
CanCreate |
SFProfile / SFPermissionSet |
SFSObject |
Can create new records on this object | Yes |
CanRead |
SFProfile / SFPermissionSet |
SFSObject |
Can read records on this object (subject to sharing) | Yes |
CanEdit |
SFProfile / SFPermissionSet |
SFSObject |
Can edit records on this object (subject to sharing) | Yes |
CanDelete |
SFProfile / SFPermissionSet |
SFSObject |
Can delete records on this object (subject to sharing) | Yes |
CanViewAll |
SFProfile / SFPermissionSet |
SFSObject |
Can view ALL records on this object — bypasses sharing rules | Yes |
CanModifyAll |
SFProfile / SFPermissionSet |
SFSObject |
Can edit/delete ALL records on this object — bypasses sharing rules | Yes |
IsVisible |
SFProfile / SFPermissionSet |
SFField |
Field is readable and editable (PermissionsEdit=true) | Yes |
ReadOnly |
SFProfile / SFPermissionSet |
SFField |
Field is readable but not editable (PermissionsRead=true, PermissionsEdit=false) | Yes |
ModifyAllData |
SFProfile / SFPermissionSet |
SFOrganization |
System permission: modify all records in the org (bypass all sharing) | Yes |
ManageUsers |
SFProfile / SFPermissionSet |
SFOrganization |
System permission: create, edit, activate, and deactivate users | Yes |
ManageRoles |
SFProfile / SFPermissionSet |
SFOrganization |
System permission: create and edit the role hierarchy | Yes |
ManageSharing |
SFProfile / SFPermissionSet |
SFOrganization |
System permission: manage sharing rules and OWD settings | Yes |
ManageProfilesPermissionsets |
SFProfile / SFPermissionSet |
SFOrganization |
System permission: manage profiles and permission sets | Yes |
CustomizeApplication |
SFProfile / SFPermissionSet |
SFOrganization |
System permission: customize Salesforce application metadata | Yes |
AuthorApex |
SFProfile / SFPermissionSet |
SFOrganization |
System permission: create and deploy Apex code | Yes |
ViewSetup |
SFProfile / SFPermissionSet |
SFOrganization |
System permission: view setup and configuration | Yes |
ViewAllData |
SFProfile / SFPermissionSet |
SFOrganization |
System permission: read every record in the org regardless of sharing or OWD — exfiltration risk | Yes |
ApiEnabled |
SFProfile / SFPermissionSet |
SFOrganization |
System permission: allows all programmatic API access (REST, SOAP, Bulk, Metadata, Tooling) | Yes |
ManageTranslation |
SFProfile / SFPermissionSet |
SFOrganization |
System permission: manage the Translation Workbench — can rename fields and labels org-wide | Yes |
EditTask |
SFProfile / SFPermissionSet |
SFOrganization |
System permission: edit Task records owned by other users — sharing-gated: only tasks already visible via OWD/role hierarchy. Blast radius expands to all tasks when combined with ViewAllData |
Yes |
EditEvent |
SFProfile / SFPermissionSet |
SFOrganization |
System permission: edit Event (calendar) records owned by other users — sharing-gated via ControlledByParent OWD. Blast radius expands to all events when combined with ViewAllData |
Yes |
| (other system permissions) | SFProfile / SFPermissionSet |
SFOrganization |
Additional Permissions* flags captured as edge types when true | Yes |
CanAuthorize |
SFProfile / SFPermissionSet |
SFConnectedApp |
Profile or Permission Set grants users the right to OAuth-authorize this Connected App | Yes |
CreatedBy |
SFConnectedApp |
SFUser |
Records the admin who created this Connected App — audit/provenance edge | No |
All named edges in the table above carry the following contextual properties, visible in the BloodHound edge panel:
| Property | Description |
|---|---|
General |
What the edge represents and how the permission or relationship works |
AbuseInfo |
How an attacker can exploit this edge — escalation paths, blast radius, and prerequisites |
RemediationInfo |
Actionable steps to restrict or remediate this access, including specific SOQL audit queries where applicable |
OPSEC |
What is and is not logged when this edge is exercised — gaps in standard audit visibility an attacker could exploit |
References |
MITRE ATT&CK technique mapping and Salesforce documentation URLs for the underlying permission or relationship |
Properties are populated for all edges that have a named entry in
edges.py(system permissions, object CRUD, assignment, group/access edges). Structural edges emitted dynamically for everyPermissions*flag not in the named context dictionary carry only theSystemPermissionproperty.
This section documents intentional architectural choices in the graph model, including the tradeoffs involved and why alternatives were rejected.
Salesforce exposes hundreds of Permissions* boolean fields on Profile and PermissionSet records (e.g., PermissionsModifyAllData, PermissionsAuthorApex). Rather than creating a separate node for each system permission, SalesforceHound models each enabled permission as a typed edge from the granting SFProfile/SFPermissionSet to the central SFOrganization node.
Why: BloodHound path traversal queries work best when privilege escalation is represented as edge relationships rather than node properties. Modeling system permissions as edges means standard shortest-path queries (-[:ModifyAllData]->) immediately surface every user who holds that permission through any assignment chain — no additional filtering on properties is required post-traversal.
Tradeoff: Queries that ask "list all permissions on a PermissionSet" require reading outgoing edge types to SFOrganization rather than reading a single node's property bag. This is natural in Cypher (MATCH (ps)-[r]->(org:SFOrganization) RETURN type(r)) and does not impose meaningful overhead.
Salesforce QueueSobject records contain only the API name of the object type a Queue can own (e.g., Case, Incident), not a Salesforce record ID. An early implementation created virtual destination nodes with a synthetic SOBJECT::{TYPE} identifier, which resulted in dangling edges because those virtual IDs had no matching SFSObject node in the graph.
Current approach: build_queue_object_access receives the same sobject_lookup dictionary used by CRUD permission edges (maps QualifiedApiName → DurableId). CanOwnObject edges are only emitted when the SObject type is present in that lookup — i.e., it exists in the org's EntityDefinition and was extracted. Queue-to-SObject relationships are therefore consistent with the rest of the object permission graph, and no virtual nodes are needed.
Tradeoff: Queues that own SObject types absent from EntityDefinition (e.g., deprecated or inaccessible objects) will not have CanOwnObject edges in the graph. This is intentional — edges to non-existent nodes provide no traversal value and inflate the dangling edge warning count.
Salesforce internally generates a hidden PermissionSet record for every PermissionSetGroup (identifiable by the 0PSG... ID prefix). These aggregate records accumulate the union of all constituent PermissionSet permissions and appear as ParentId values in ObjectPermissions and FieldPermissions, but they are not returned by a standard SELECT ... FROM PermissionSet query.
Without handling this, every CRUD and FLS edge sourced from an aggregate PermSet would dangle (source node missing).
Current approach: After the main node-building phase, sfhound.py scans all ObjectPermissions and FieldPermissions source IDs against the set of already-built nodes. Any 0PSG... ID (or any other ParentId) not yet present is materialized as a minimal placeholder SFPermissionSet node labelled [AggregatePermSet] <ID>. This keeps all edges fully anchored.
Tradeoff: Placeholder nodes carry only an ID and a synthetic name — they have none of the rich metadata a normally-queried PermissionSet would have. If a future version can extract aggregate PermSet metadata directly, the placeholder hydration step can be removed or merged with the normal extraction path.
Both build_queue_object_access and build_object_permissions need to map an SObject API name to a graph node ID. The sobject_lookup dictionary (QualifiedApiName → DurableId) is constructed once in sfhound.py and passed to both builders. This ensures:
- No duplication of the lookup construction logic
- Both edge types point to the exact same
SFSObjectnode IDs - Filtering of unresolvable SObject types is consistent across both builders
Relationships to the 6th degree for a specific user:
MATCH (u:SFUser)
WHERE u.name = "PETER WIENER"
MATCH p = (u)-[*1..6]->(n)
RETURN DISTINCT pUsers with access to crown jewels:
MATCH path = (u:SFUser)-[:AssignedProfile|AssignedPermissionSet|AssignedPermissionSetGroup|HasPermissionSet|IncludesPermissionSet*1..5]->(ps)-[r:CanCreate|CanRead|CanEdit|CanDelete|CanViewAll|CanModifyAll]->(obj:SFSObject)
WHERE u.name = "PETER WIENER" AND obj.name = "SECRETDATA__C"
AND (ps:SFPermissionSet OR ps:SFProfile)
RETURN pathShortest path to any custom Salesforce objects:
MATCH p=(u:SFUser)-[:AssignedProfile|AssignedPermissionSet|AssignedPermissionSetGroup|HasPermissionSet|IncludesPermissionSet|CanCreate|CanRead|CanEdit|CanDelete|CanViewAll|CanModifyAll|IsVisible|ReadOnly|Contains*1..10]->(f:SFSObject)
WHERE f.name ENDS WITH '__C'
AND u <> f
RETURN p
LIMIT 1000MATCH p=allShortestPaths((u:SFUser)-[:AssignedProfile|AssignedPermissionSet|AssignedPermissionSetGroup|HasPermissionSet|IncludesPermissionSet|CanCreate|CanRead|CanEdit|CanDelete|CanViewAll|CanModifyAll|IsVisible|ReadOnly|Contains*1..10]->(f:SFSObject))
WHERE f.name ENDS WITH '__C'
AND u <> f
RETURN p
LIMIT 1000Shortest path from a user to a specific field:
MATCH p=shortestPath((u:SFUser)-[:AssignedProfile|AssignedPermissionSet|AssignedPermissionSetGroup|HasPermissionSet|IncludesPermissionSet|CanCreate|CanRead|CanEdit|CanDelete|CanViewAll|CanModifyAll|IsVisible|ReadOnly|Contains*1..10]->(f:SFField))
WHERE u.name = "PETER WIENER"
AND f.name = "SECRETDATA__C.HIGHLYSENSITIVEFIELD__C"
AND u <> f
RETURN p
LIMIT 1000Shortest path to crown jewel fields:
MATCH p=shortestPath((u:SFUser)-[:AssignedProfile|AssignedPermissionSet|AssignedPermissionSetGroup|HasPermissionSet|IncludesPermissionSet|CanCreate|CanRead|CanEdit|CanDelete|CanViewAll|CanModifyAll|IsVisible|ReadOnly|Contains*1..10]->(f:SFField))
WHERE f.name = "SECRETDATA__C.HIGHLYSENSITIVEFIELD__C"
AND u <> f
RETURN p
LIMIT 1000All users with access to any custom fields:
MATCH p=(u:SFUser)-[:AssignedProfile|AssignedPermissionSet|AssignedPermissionSetGroup|HasPermissionSet|IncludesPermissionSet|CanCreate|CanRead|CanEdit|CanDelete|CanViewAll|CanModifyAll|IsVisible|ReadOnly|Contains*1..10]->(f:SFField)
WHERE f.name ENDS WITH '__C'
AND u <> f
RETURN p
LIMIT 1000All Users:
MATCH (m:SFUser) RETURN mAll Permission Sets:
MATCH (m:SFPermissionSet) RETURN mAll Nodes and Relationships (expensive):
MATCH (n)
OPTIONAL MATCH (n)-[r]->(m)
RETURN n, r, m
LIMIT 500Note: System permissions (e.g.,
ModifyAllData,ViewSetup) are modeled as edges to theSFOrganizationnode rather than as separate permission nodes. Each system permission is an edge type fromSFProfile/SFPermissionSettoSFOrganization.
Available system permissions in graph:
ModifyAllData, ViewAllData, ViewSetup, ManageUsers, ManageRoles, ManageSharing, ManageProfilesPermissionsets, ManageTranslation, CustomizeApplication, AuthorApex, ApiEnabled, EditTask, EditEvent
High-Risk Permissions (Tier 0):
Shortest path to org compromise:
MATCH p=(u:SFUser)-[:AssignedProfile|AssignedPermissionSet|AssignedPermissionSetGroup|HasPermissionSet|IncludesPermissionSet*1..5]->(ps)-[r:ModifyAllData|ManageSharing|ManageProfilesPermissionsets|CustomizeApplication|AuthorApex|ManageUsers|ManageRoles]->(org:SFOrganization)
WHERE (ps:SFProfile OR ps:SFPermissionSet)
AND u <> org
RETURN p
LIMIT 1000All users with a specific high-risk permission (replace ModifyAllData as needed):
MATCH path = (u:SFUser)-[:AssignedProfile|AssignedPermissionSet]->(ps)-[:ModifyAllData]->(org:SFOrganization)
WHERE ps:SFProfile OR ps:SFPermissionSet
RETURN pathAll system permissions held by a specific user:
MATCH (u:SFUser {Username: "username@example.com"})-[:AssignedProfile|AssignedPermissionSet]->(ps)-[perm]->(org:SFOrganization)
WHERE ps:SFProfile OR ps:SFPermissionSet
RETURN u.name AS UserName,
ps.name AS PermissionSetOrProfile,
type(perm) AS SystemPermissionCount how many users have each system permission:
MATCH (u:SFUser)-[:AssignedProfile|AssignedPermissionSet]->(ps)-[perm]->(org:SFOrganization)
WHERE ps:SFProfile OR ps:SFPermissionSet
RETURN type(perm) AS Permission, COUNT(DISTINCT u) AS UserCount
ORDER BY UserCount DESCAll users and their role assignments:
MATCH (u:SFUser)-[h:HasRole]->(r:SFRole)
RETURN u, h, rRole hierarchy tree (up to 5 levels):
MATCH path = (child:SFRole)-[:InheritsRole*1..5]->(ancestor:SFRole)
WHERE NOT (ancestor)-[:InheritsRole]->()
RETURN pathPortal role users:
MATCH (u:SFUser)-[:HasRole]->(r:SFRole)
WHERE r.IsPortalRole = True
RETURN u, rAll Public Groups:
MATCH (g:SFGroup)
RETURN gPublic Group membership (including nested groups):
MATCH (g:SFGroup)-[h:HasMember]->(m)
RETURN g, h, mUsers in a specific Public Group (direct and via nested groups):
MATCH path = (g:SFGroup {name: 'KaiberSecInternalUsers'})-[:HasMember*1..3]->(u:SFUser)
RETURN pathAll Queues:
MATCH (q:SFQueue)
RETURN qQueue members:
MATCH (q:SFQueue)-[h:HasMember]->(m)
RETURN q, h, mAll ConnectedApps:
MATCH (app:SFConnectedApp)
RETURN appConnectedApps and their creators:
MATCH p = (app:SFConnectedApp)-[:CreatedBy]->(u:SFUser)
RETURN pWhich admin created the most apps:
MATCH (app:SFConnectedApp)-[:CreatedBy]->(u:SFUser)
RETURN u.name AS Admin, count(app) AS AppCount
ORDER BY AppCount DESCWhich Profiles/PermissionSets can authorize which ConnectedApps:
MATCH p = (ps)-[:CanAuthorize]->(app:SFConnectedApp)
RETURN pComplete attack path: user to ConnectedApp authorization:
MATCH path = (u:SFUser)-[:AssignedProfile|AssignedPermissionSet*1..2]->(ps)-[:CanAuthorize]->(app:SFConnectedApp)
RETURN pathConnectedApps that allow self-authorization (security risk):
MATCH (app:SFConnectedApp)
WHERE app.AdminApprovedUsersOnly = False
RETURN app.name, app.AdminApprovedUsersOnly, app.CreatedDateAll custom objects (potential sensitive data):
MATCH (obj:SFSObject)
WHERE obj.IsCustom = True
RETURN obj.name, obj.Label, obj.InternalSharingModelAll users who can delete a specific object:
MATCH (u:SFUser)-[:AssignedProfile|AssignedPermissionSet|AssignedPermissionSetGroup|HasPermissionSet|IncludesPermissionSet*1..5]->(p)-[:CanDelete]->(obj:SFSObject {name: "SECRETDATA__C"})
WHERE (p:SFPermissionSet OR p:SFProfile)
RETURN DISTINCT u.name as User, p.name as GrantedBy, obj.Label as ObjectFind users with ModifyAll (bypass sharing rules):
MATCH (u:SFUser)-[:AssignedProfile|AssignedPermissionSet|AssignedPermissionSetGroup|HasPermissionSet|IncludesPermissionSet*1..5]->(p)-[:CanModifyAll]->(obj:SFSObject)
WHERE obj.IsCustom = True
AND (p:SFPermissionSet OR p:SFProfile)
RETURN DISTINCT u.name as User, obj.name as CustomObject, obj.Label
ORDER BY u.name, obj.nameUsers with suspicious permissions (Create + Delete + ModifyAll on same object):
MATCH (u:SFUser)-[:AssignedProfile|AssignedPermissionSet|AssignedPermissionSetGroup|HasPermissionSet|IncludesPermissionSet*1..5]->(p1)-[:CanCreate]->(obj:SFSObject),
(u)-[:AssignedProfile|AssignedPermissionSet|AssignedPermissionSetGroup|HasPermissionSet|IncludesPermissionSet*1..5]->(p2)-[:CanDelete]->(obj),
(u)-[:AssignedProfile|AssignedPermissionSet|AssignedPermissionSetGroup|HasPermissionSet|IncludesPermissionSet*1..5]->(p3)-[:CanModifyAll]->(obj)
WHERE (p1:SFPermissionSet OR p1:SFProfile)
AND (p2:SFPermissionSet OR p2:SFProfile)
AND (p3:SFPermissionSet OR p3:SFProfile)
RETURN DISTINCT u.name, obj.name, obj.LabelNote: OWD sharing models are captured in
InternalSharingModelandExternalSharingModelonSFSObjectnodes. Individual SharingRule records require the Metadata API and are not currently extracted.
Objects with most restrictive sharing (Private):
MATCH (obj:SFSObject)
WHERE obj.InternalSharingModel = "Private"
RETURN obj
ORDER BY obj.name
LIMIT 50Custom objects with public access (potential data leak):
MATCH (obj:SFSObject)
WHERE obj.IsCustom = True
AND obj.InternalSharingModel IN ["Public Read/Write", "ReadWrite", "Public Read Only", "Read"]
RETURN objSharing model mismatch (internal vs external):
MATCH (obj:SFSObject)
WHERE obj.InternalSharingModel <> obj.ExternalSharingModel
RETURN obj
LIMIT 50Adding new node and edge types is now a three-step process thanks to bhopengraph native classes.
1. Add a builder method in graph/nodes.py:
def build_my_thing(self, records: list) -> list:
nodes = []
for r in records:
props = {"name": r.get("Name"), "someField": r.get("SomeField__c")}
nodes.append(make_node(r["Id"], "SFMyThing", props))
return nodesmake_node() automatically normalises the ID (strip + uppercase), drops None and non-primitive values, and sets objectid.
2. Call it in the main pipeline in sfhound.py:
for node in node_builder.build_my_thing(my_thing_data):
graph.add_or_merge_node(node)add_or_merge_node() handles deduplication automatically — if the same ID is emitted by multiple builders the kinds are unioned and properties are merged (last-write wins).
3. Optionally register the kind label for summary output in graph/sfgraph.py:
"SFMyThing": "my things",1. Register the edge kind string in graph/edges.py under EdgeKinds:
MY_THING_RELATION = "SFMyThingRelation"2. Add a builder method in the same file:
def build_my_thing_edges(self, records: list) -> list:
edges = []
for r in records:
edges.append(_make_edge(r["Id"], r["RelatedId"], EdgeKinds.MY_THING_RELATION))
return edges_make_edge() normalises start/end IDs and filters non-primitive properties. Pass an optional properties dict as the fourth argument for context that should appear in the BloodHound edge panel.
3. Call it in the main pipeline in sfhound.py:
for edge in edge_builder.build_my_thing_edges(my_thing_data):
graph.add_edge_without_validation(edge)check_dangling() runs automatically at export time and will flag any edges whose endpoint nodes are missing from the graph, so wiring mistakes are caught immediately.
We welcome and appreciate contributions! To make the process smooth and efficient, please follow these steps:
-
Discuss Your Idea
- If you've found a bug or want to propose a new feature, open an issue in this repo. Describe the problem or enhancement clearly so we can discuss the best approach before work begins.
-
Fork & Create a Branch
- Fork this repository to your own account.
- Create a topic branch for your work:
git checkout -b feat/my-new-feature
-
Implement & Test
- Follow the existing style and patterns in the codebase.
- Add or update examples to cover your changes.
- Verify your code runs successfully:
cd salesforce-opengraph python sfhound.py - Confirm the output JSON is valid and loads into BloodHound.
-
Submit a Pull Request
- Push your branch to your fork:
git push origin feat/my-new-feature
- Open a Pull Request against the
mainbranch. - In the PR description include:
- What you've changed and why
- How to reproduce/test your changes
- A sample of the output JSON for any new node or edge types
- Push your branch to your fork:
-
Review & Merge
- Your PR will be reviewed, feedback given if needed, and merged once everything checks out.
- Larger or more complex changes may take longer to review — thank you in advance for your patience!
Copyright 2025 Khadinxc
This program is free software: you can redistribute it and/or modify it under
the terms of the GNU General Public License as published by the Free Software
Foundation, either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
this program. If not, see https://www.gnu.org/licenses/.