Metadata-Version: 2.4
Name: sfhound
Version: 2.0.3
Summary: Six Degrees of System Administrator — Salesforce attack path analysis for BloodHound
Author-email: Khadinxc <contact@kaibersec.com>
License: MIT License
        
        Copyright (c) 2026 Khadinxc
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all
        copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
        SOFTWARE.
        
Project-URL: Homepage, https://sfhound.kaibersec.com
Project-URL: Repository, https://github.com/Khadinxc/sfhound
Project-URL: Bug Tracker, https://github.com/Khadinxc/sfhound/issues
Keywords: salesforce,bloodhound,attack-path,security,pentest
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Information Technology
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Security
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: requests>=2.32.5
Requires-Dist: PyJWT>=2.10.1
Requires-Dist: pyaml>=26.2.1
Requires-Dist: cryptography>=45.0.6
Requires-Dist: bhopengraph>=1.3.1
Provides-Extra: dev
Requires-Dist: pytest>=7.4; extra == "dev"
Requires-Dist: pytest-timeout>=2.3; extra == "dev"
Dynamic: license-file

# **SFHound**

> [!TIP]
> Want to read the new documentation? Head over to [sfhound.kaibersec.com](https://sfhound.kaibersec.com/)


## Brief

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.

## Limitations

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.

## Collector Setup & Usage

### 1. Install Prerequisites

**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:

```bash
git clone https://github.com/Khadinxc/sfhound.git
cd sfhound/sf-opengraph
pip install -r requirements.txt
```

### 2. Create a Salesforce Connected App
#### certificate_and_secret method:

SalesforceHound authenticates via the JWT OAuth flow. You will need to create a Connected App and a certificate.

**Generate a JWT certificate:**

```bash
openssl genrsa -out salesforce_jwt.key 2048
openssl req -new -x509 -key salesforce_jwt.key -out salesforce_jwt.crt -days 365
```

**Create the Connected App:**

1. **Setup → Apps → App Manager → External Client Apps → External Client App Manager → New External Client App**
2. Enable OAuth Settings, set Callback URL to `https:/localhost`
3. Enable "Use digital signatures" and upload `salesforce_jwt.crt`
4. Add OAuth Scopes: `api`, `refresh_token, offline_access`
5. Make sure to export you Consumer Key and Secret!
6. Save and wait 2–10 minutes for changes to propagate

**Pre-authorize your integration user:**

1. **Setup → Connected Apps → Manage Connected Apps → SalesforceHound → Edit Policies**
2. Set Permitted Users to `Admin approved users are pre-authorized`
3. Add your integration user's Profile or Permission Set under **Manage Profiles** / **Manage Permission Sets**

#### client_credential_flow method:

**Create the Connected App:**

1. **Setup → Apps → App Manager → External Client Apps → External Client App Manager → New External Client App**
2. Enable OAuth Settings, set Callback URL to `https:/localhost`
4. Add OAuth Scopes: `api`, `refresh_token, offline_access`
5. Make sure to export you Consumer Key and Secret!
6. Save and wait 2–10 minutes for changes to propagate

**Pre-authorize your integration user:**

1. **Setup → Connected Apps → Manage Connected Apps → SalesforceHound → Edit Policies**
2. Set Permitted Users to `Admin approved users are pre-authorized`
3. Add your integration user's Profile or Permission Set under **Manage Profiles** / **Manage Permission Sets**

### 3. Configure Minimum Permissions

#### Privileged User (Recommended — Complete Extraction)

| 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.

#### Unprivileged User (Limited Extraction)

| 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:**

1. Setup → Profiles → Clone "Standard User"
2. Enable "API Enabled" and "View Setup and Configuration"
3. Grant Read on: User, Group, PermissionSetAssignment, GroupMember
4. Assign to your integration user

### 4. Configure config.yaml

Copy `config.yaml.example` to `config.yaml` and fill in your values:

```yaml
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_id` is the "Consumer Key" from your Connected App's "Manage Consumer Details" page.

### 5. Run the Collector

```bash
cd salesforce-opengraph
python sfhound.py
```

This produces a BloodHound-compatible JSON file in `./opengraph_output/`.

#### Command-Line Overrides

All `config.yaml` values can be overridden at the command line:

```bash
# 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 |

### 6. Register Custom Icons in BloodHound

```python
python examples/post_custom_icons.py
```

### 7. Load Data into BloodHound

#### Option A — Auto-ingest (recommended)

Pass `--auto-ingest` to extract and upload in a single command. BloodHound credentials can come from `config.yaml` or the command line:

```bash
# 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_PASSWORD
```

What auto-ingest does:
1. Validates the exported JSON against the OpenGraph schema
2. Checks for stuck/active jobs in BloodHound and aborts if any exist
3. Creates a BloodHound file-upload job, uploads the graph, and signals ingestion start
4. Polls until ingestion completes, printing status every 15 seconds
5. 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.

#### Option B — Manual upload

Drag and drop the output JSON file from `./opengraph_output/` into BloodHound's file upload modal.


### 8. Setup Tier Zero Privilege Zones:

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*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;
```

---

## Schema

```mermaid
---
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
```

### Nodes

| 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 |

### Edges

| Edge Type | Source | Target | Description | Traversable |
|---|---|---|---|---|
| `SF_AssignedProfile` | `SFUser` | `SFProfile` | User is assigned to this Profile | Yes |
| `SF_AssignedPermissionSet` | `SFUser` | `SFPermissionSet` | User has been directly assigned this Permission Set | Yes |
| `SF_AssignedPermissionSetGroup` | `SFUser` | `SFPermissionSetGroup` | User has been assigned this Permission Set Group | Yes |
| `SF_HasPermissionSet` | `SFProfile` | `SFPermissionSet` | Profile is backed by its own PermissionSet record (IsOwnedByProfile=true) | Yes |
| `SF_IncludesPermissionSet` | `SFPermissionSetGroup` | `SFPermissionSet` | Permission Set Group includes this Permission Set | Yes |
| `SF_HasRole` | `SFUser` | `SFRole` | User is assigned to this role in the role hierarchy | Yes |
| `SF_InheritsRole` | `SFRole` | `SFRole` | Child role — users in the parent role can see records owned by users in this child role | Yes |
| `SF_MemberOfGroup` | `SFUser` | `SFGroup` | User is a direct member of this Public Group | Yes |
| `SF_MemberOfGroup` | `SFUser` | `SFQueue` | User is a member of this Queue | Yes |
| `SF_MemberOfGroup` | `SFGroup` | `SFGroup` | Nested group membership — this group is a member of another group | Yes |
| `SF_HasMember` | `SFGroup` | `SFUser` | Group contains this user (inverse of SF_MemberOfGroup) | Yes |
| `SF_HasMember` | `SFGroup` | `SFGroup` | Group contains this nested group (inverse of SF_MemberOfGroup) | Yes |
| `SF_HasMember` | `SFQueue` | `SFUser` | Queue contains this user (inverse of SF_MemberOfGroup) | Yes |
| `SF_HasMember` | `SFQueue` | `SFGroup` | Queue contains this group (inverse of SF_MemberOfGroup) | Yes |
| `SF_CanOwnObject` | `SFQueue` | `SFSObject` | Queue is configured to own records of this SObject type | Yes |
| `SF_CanCreate` | `SFProfile` / `SFPermissionSet` | `SFSObject` | Can create new records on this object | Yes |
| `SF_CanRead` | `SFProfile` / `SFPermissionSet` | `SFSObject` | Can read records on this object (subject to sharing) | Yes |
| `SF_CanEdit` | `SFProfile` / `SFPermissionSet` | `SFSObject` | Can edit records on this object (subject to sharing) | Yes |
| `SF_CanDelete` | `SFProfile` / `SFPermissionSet` | `SFSObject` | Can delete records on this object (subject to sharing) | Yes |
| `SF_CanViewAll` | `SFProfile` / `SFPermissionSet` | `SFSObject` | Can view ALL records on this object — bypasses sharing rules | Yes |
| `SF_CanModifyAll` | `SFProfile` / `SFPermissionSet` | `SFSObject` | Can edit/delete ALL records on this object — bypasses sharing rules | Yes |
| `SF_IsVisible` | `SFProfile` / `SFPermissionSet` | `SFField` | Field is readable and editable (PermissionsEdit=true) | Yes |
| `SF_ReadOnly` | `SFProfile` / `SFPermissionSet` | `SFField` | Field is readable but not editable (PermissionsRead=true, PermissionsEdit=false) | Yes |
| `SF_ModifyAllData` | `SFProfile` / `SFPermissionSet` | `SFOrganization` | System permission: modify all records in the org (bypass all sharing) | Yes |
| `SF_ManageUsers` | `SFProfile` / `SFPermissionSet` | `SFOrganization` | System permission: create, edit, activate, and deactivate users | Yes |
| `SF_ManageRoles` | `SFProfile` / `SFPermissionSet` | `SFOrganization` | System permission: create and edit the role hierarchy | Yes |
| `SF_ManageSharing` | `SFProfile` / `SFPermissionSet` | `SFOrganization` | System permission: manage sharing rules and OWD settings | Yes |
| `SF_ManageProfilesPermissionsets` | `SFProfile` / `SFPermissionSet` | `SFOrganization` | System permission: manage profiles and permission sets | Yes |
| `SF_CustomizeApplication` | `SFProfile` / `SFPermissionSet` | `SFOrganization` | System permission: customize Salesforce application metadata | Yes |
| `SF_AuthorApex` | `SFProfile` / `SFPermissionSet` | `SFOrganization` | System permission: create and deploy Apex code | Yes |
| `SF_ViewSetup` | `SFProfile` / `SFPermissionSet` | `SFOrganization` | System permission: view setup and configuration | Yes |
| `SF_ViewAllData` | `SFProfile` / `SFPermissionSet` | `SFOrganization` | System permission: read every record in the org regardless of sharing or OWD — exfiltration risk | Yes |
| `SF_ApiEnabled` | `SFProfile` / `SFPermissionSet` | `SFOrganization` | System permission: allows all programmatic API access (REST, SOAP, Bulk, Metadata, Tooling) | Yes |
| `SF_ManageTranslation` | `SFProfile` / `SFPermissionSet` | `SFOrganization` | System permission: manage the Translation Workbench — can rename fields and labels org-wide | Yes |
| `SF_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 `SF_ViewAllData` | Yes |
| `SF_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 `SF_ViewAllData` | Yes |
| *(other system permissions)* | `SFProfile` / `SFPermissionSet` | `SFOrganization` | Additional Permissions* flags captured as edge types when true | Yes |
| `SF_CanAuthorize` | `SFProfile` / `SFPermissionSet` | `SFConnectedApp` | Profile or Permission Set grants users the right to OAuth-authorize this Connected App | Yes |
| `SF_CreatedBy` | `SFConnectedApp` | `SFUser` | Records the admin who created this Connected App — audit/provenance edge | No |

### Edge Context Properties

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 every `Permissions*` flag not in the named context dictionary carry only the `SystemPermission` property.

---

## Design Decisions

This section documents intentional architectural choices in the graph model, including the tradeoffs involved and why alternatives were rejected.

### System Permissions as Edges, Not Nodes

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 (`-[:SF_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.

### SF_CanOwnObject Targets Resolved via SObject Lookup

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`). `SF_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 `SF_CanOwnObject` edges in the graph. This is intentional — edges to non-existent nodes provide no traversal value and inflate the dangling edge warning count.

### Aggregate PermissionSet Nodes Hydrated as Placeholders

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.

### SF_CanOwnObject and CRUD Edges Share the Same sobject_lookup

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 `SFSObject` node IDs
- Filtering of unresolvable SObject types is consistent across both builders

---

## Custom Cypher Queries

### User Hunting

Relationships to the 6th degree for a specific user:
```cypher
MATCH (u:SFUser)
WHERE u.name = "PETER WIENER"
MATCH p = (u)-[*1..6]->(n)
RETURN DISTINCT p
```

### Object Permission Hunting

Users with access to crown jewels:
```cypher
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 path
```

Shortest path to any custom Salesforce objects:
```cypher
MATCH p=(u:SFUser)-[:AssignedProfile|AssignedPermissionSet|AssignedPermissionSetGroup|HasPermissionSet|IncludesPermissionSet|CanCreate|CanRead|CanEdit|CanDelete|CanViewAll|CanModifyAll|IsVisible|ReadOnly*1..10]->(f:SFSObject)
WHERE f.name ENDS WITH '__C'
  AND u <> f
RETURN p
LIMIT 1000
```

```cypher
MATCH p=allShortestPaths((u:SFUser)-[:AssignedProfile|AssignedPermissionSet|AssignedPermissionSetGroup|HasPermissionSet|IncludesPermissionSet|CanCreate|CanRead|CanEdit|CanDelete|CanViewAll|CanModifyAll|IsVisible|ReadOnly*1..10]->(f:SFSObject))
WHERE f.name ENDS WITH '__C'
  AND u <> f
RETURN p
LIMIT 1000
```

### Field Permission Hunting

Shortest path from a user to a specific field:
```cypher
MATCH p=shortestPath((u:SFUser)-[:AssignedProfile|AssignedPermissionSet|AssignedPermissionSetGroup|HasPermissionSet|IncludesPermissionSet|CanCreate|CanRead|CanEdit|CanDelete|CanViewAll|CanModifyAll|IsVisible|ReadOnly*1..10]->(f:SFField))
WHERE u.name = "PETER WIENER" 
  AND f.name = "SECRETDATA__C.HIGHLYSENSITIVEFIELD__C"
  AND u <> f
RETURN p
LIMIT 1000
```

Shortest path to crown jewel fields:
```cypher
MATCH p=shortestPath((u:SFUser)-[:AssignedProfile|AssignedPermissionSet|AssignedPermissionSetGroup|HasPermissionSet|IncludesPermissionSet|CanCreate|CanRead|CanEdit|CanDelete|CanViewAll|CanModifyAll|IsVisible|ReadOnly*1..10]->(f:SFField))
WHERE f.name = "SECRETDATA__C.HIGHLYSENSITIVEFIELD__C"
  AND u <> f
RETURN p
LIMIT 1000
```

All users with access to any specific fields:
```cypher
MATCH p=(u:SFUser)-[:AssignedProfile|AssignedPermissionSet|AssignedPermissionSetGroup|HasPermissionSet|IncludesPermissionSet|CanCreate|CanRead|CanEdit|CanDelete|CanViewAll|CanModifyAll|IsVisible|ReadOnly*1..10]->(f:SFField)
WHERE f.name = 'SECRETDATA__C.HIGHLYSENSITIVEFIELD__C'
  AND u <> f
RETURN p
LIMIT 1000
```

All users with access to any custom fields:
```cypher
MATCH p=(u:SFUser)-[:AssignedProfile|AssignedPermissionSet|AssignedPermissionSetGroup|HasPermissionSet|IncludesPermissionSet|CanCreate|CanRead|CanEdit|CanDelete|CanViewAll|CanModifyAll|IsVisible|ReadOnly*1..10]->(f:SFField)
WHERE f.name ENDS WITH '__C'
  AND u <> f
RETURN p
LIMIT 1000
```

### Generic Counts

All Users:
```cypher
MATCH (m:SFUser) RETURN m
```

All Permission Sets:
```cypher
MATCH (m:SFPermissionSet) RETURN m
```

All Nodes and Relationships (expensive):
```cypher
MATCH (n)
OPTIONAL MATCH (n)-[r]->(m)
RETURN n, r, m
LIMIT 500
```

### System Permission Queries

> **Note:** System permissions (e.g., `SF_ModifyAllData`, `SF_ViewSetup`) are modeled as edges to the `SFOrganization` node rather than as separate permission nodes. Each system permission is an edge type from `SFProfile`/`SFPermissionSet` to `SFOrganization`.

**Available system permissions in graph:**
`SF_ModifyAllData`, `SF_ViewAllData`, `SF_ViewSetup`, `SF_ManageUsers`, `SF_ManageRoles`, `SF_ManageSharing`, `SF_ManageProfilesPermissionsets`, `SF_ManageTranslation`, `SF_CustomizeApplication`, `SF_AuthorApex`, `SF_ApiEnabled`, `SF_EditTask`, `SF_EditEvent`

**High-Risk Permissions (Tier 0):**

Shortest path to org compromise:
```cypher
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 1000
```

All users with a specific high-risk permission (replace `SF_ModifyAllData` as needed):
```cypher
MATCH path = (u:SFUser)-[:AssignedProfile|AssignedPermissionSet]->(ps)-[:ModifyAllData]->(org:SFOrganization)
WHERE ps:SFProfile OR ps:SFPermissionSet
RETURN path
```

All system permissions held by a specific user:
```cypher
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 SystemPermission
```

Count how many users have each system permission:
```cypher
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 DESC
```

### Role Hierarchy Queries

All users and their role assignments:
```cypher
MATCH (u:SFUser)-[h:HasRole]->(r:SFRole)
RETURN u, h, r
```

Role hierarchy tree (up to 5 levels):
```cypher
MATCH path = (child:SFRole)-[:InheritsRole*1..5]->(ancestor:SFRole)
WHERE NOT (ancestor)-[:InheritsRole]->()
RETURN path
```

Portal role users:
```cypher
MATCH (u:SFUser)-[:HasRole]->(r:SFRole)
WHERE r.IsPortalRole = True
RETURN u, r
```

### Group & Queue Queries

All Public Groups:
```cypher
MATCH (g:SFGroup)
RETURN g
```

Public Group membership (including nested groups):
```cypher
MATCH (g:SFGroup)-[h:HasMember]->(m)
RETURN g, h, m
```

Users in a specific Public Group (direct and via nested groups):
```cypher
MATCH path = (g:SFGroup {name: 'KaiberSecInternalUsers'})-[:HasMember*1..3]->(u:SFUser)
RETURN path
```

All Queues:
```cypher
MATCH (q:SFQueue)
RETURN q
```

Queue members:
```cypher
MATCH (q:SFQueue)-[h:HasMember]->(m)
RETURN q, h, m
```

### Connected App Queries

All ConnectedApps:
```cypher
MATCH (app:SFConnectedApp)
RETURN app
```

ConnectedApps and their creators:
```cypher
MATCH p = (app:SFConnectedApp)-[:CreatedBy]->(u:SFUser)
RETURN p
```

Which admin created the most apps:
```cypher
MATCH (app:SFConnectedApp)-[:CreatedBy]->(u:SFUser)
RETURN u.name AS Admin, count(app) AS AppCount
ORDER BY AppCount DESC
```

Which Profiles/PermissionSets can authorize which ConnectedApps:
```cypher
MATCH p = (ps)-[:CanAuthorize]->(app:SFConnectedApp)
RETURN p
```

Complete attack path: user to ConnectedApp authorization:
```cypher
MATCH path = (u:SFUser)-[:AssignedProfile|AssignedPermissionSet*1..2]->(ps)-[:CanAuthorize]->(app:SFConnectedApp)
RETURN path
```

ConnectedApps that allow self-authorization (security risk):
``` cypher
MATCH (app:SFConnectedApp)
WHERE app.AdminApprovedUsersOnly = False
RETURN app.name, app.AdminApprovedUsersOnly, app.CreatedDate
```

### SObject & CRUD Permission Queries

All custom objects (potential sensitive data):
```cypher
MATCH (obj:SFSObject)
WHERE obj.IsCustom = True
RETURN obj.name, obj.Label, obj.InternalSharingModel
```

All users who can delete a specific object:
```cypher
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 Object
```

Find users with ModifyAll (bypass sharing rules):
```cypher
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.name
```

Users with suspicious permissions (Create + Delete + ModifyAll on same object):
```cypher
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.Label
```

### Sharing Model Analysis

> **Note:** OWD sharing models are captured in `InternalSharingModel` and `ExternalSharingModel` on `SFSObject` nodes. Individual SharingRule records require the Metadata API and are not currently extracted.

Objects with most restrictive sharing (Private):
```cypher
MATCH (obj:SFSObject)
WHERE obj.InternalSharingModel = "Private"
RETURN obj
ORDER BY obj.name
LIMIT 50
```

Custom objects with public access (potential data leak):
```cypher
MATCH (obj:SFSObject)
WHERE obj.IsCustom = True
  AND obj.InternalSharingModel IN ["Public Read/Write", "ReadWrite", "Public Read Only", "Read"]
RETURN obj
```

Sharing model mismatch (internal vs external):
```cypher
MATCH (obj:SFSObject)
WHERE obj.InternalSharingModel <> obj.ExternalSharingModel
RETURN obj
LIMIT 50
```

## Extending the Graph

Adding new node and edge types is now a three-step process thanks to [bhopengraph](https://github.com/p0dalirius/bhopengraph) native classes. 

### Adding a new Node type

**1. Add a builder method in [`graph/nodes.py`](sf-opengraph/graph/nodes.py):**

```python
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 nodes
```

`make_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`](sf-opengraph/sfhound.py):**

```python
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`](sf-opengraph/graph/sfgraph.py):**

```python
"SFMyThing": "my things",
```

---

### Adding a new Edge type

**1. Register the edge kind string in [`graph/edges.py`](sf-opengraph/graph/edges.py) under `EdgeKinds`:**

```python
MY_THING_RELATION = "SFMyThingRelation"
```

**2. Add a builder method in the same file:**

```python
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`](sf-opengraph/sfhound.py):**

```python
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.

---

## Contributing

We welcome and appreciate contributions! To make the process smooth and efficient, please follow these steps:

1. **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.

2. **Fork & Create a Branch**
   - Fork this repository to your own account.
   - Create a topic branch for your work:
     ```bash
     git checkout -b feat/my-new-feature
     ```

3. **Implement & Test**
   - Follow the existing style and patterns in the codebase.
   - Add or update examples to cover your changes.
   - Verify your code runs successfully:
     ```bash
     cd salesforce-opengraph
     python sfhound.py
     ```
   - Confirm the output JSON is valid and loads into BloodHound.

4. **Submit a Pull Request**
   - Push your branch to your fork:
     ```bash
     git push origin feat/my-new-feature
     ```
   - Open a Pull Request against the `main` branch.
   - 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

5. **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!

---

## Licensing

```
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/.
```

---

## Release Notes

## sfhound v2.0.1

### Bug fixes and improvements

#### Fixed: `--throttle` now controls page size, not record cap
Previously, `-r`/`--throttle <n>` appended `LIMIT <n>` to every SOQL query and disabled `queryMore()` pagination entirely. This meant only the first `<n>` records were ever returned, making the flag unusable for large orgs.

`--throttle` now sets the `Sforce-Query-Options: batchSize=<n>` HTTP header, which controls how many records Salesforce returns **per page**. Full `queryMore()` pagination is always followed, so all records are retrieved regardless of org size. The flag is now genuinely useful for reducing per-request payload on large orgs without dropping data.

```bash
sfhound --throttle 200    # fetch all records in pages of 200
sfhound --throttle 500    # fetch all records in pages of 500
```

#### Fixed: `EntityDefinition` no longer raises `EXCEEDED_ID_LIMIT`
`EntityDefinition` (used for SObject metadata) is a Salesforce virtual object that does not support cursor-based `queryMore()` pagination. Querying it without a `LIMIT` clause on large orgs previously caused an `EXCEEDED_ID_LIMIT` API error.

The extractor now uses `LIMIT + OFFSET` iteration for `EntityDefinition`, which is the correct workaround for non-`queryMore` objects. Page size respects `--throttle` when set, otherwise defaults to 2000 (Salesforce's maximum).

#### CI/CD: eliminated duplicate pipeline steps
The `lint` and `security` jobs were defined independently in both `ci.yml` and `release.yml`. They are now defined once in a reusable `_checks.yml` workflow called by both pipelines, ensuring checks are consistent and maintained in a single place.

#### New: bulk test data script (`examples/create_test_data.py`)
A utility script for verifying pagination against a live Salesforce org. Creates a configurable number of `PermissionSet` records (default 250, spanning multiple pages at 200/page), queries them back page-by-page with verbose output, then cleans up.

```bash
python -m examples.create_test_data --config path/to/config.yaml           # create 250 records
python -m examples.create_test_data --config path/to/config.yaml --verify  # verify pagination
python -m examples.create_test_data --config path/to/config.yaml --cleanup # delete test records
```

#### pytest now always shows skip reasons
`addopts = "-rs"` added to `[tool.pytest.ini_options]` in `pyproject.toml` so skipped tests always report their reason without requiring `-v`.

## sfhound v2.0

### What's new

#### Installable Python package
sfhound is now published to PyPI and installable with `pip install sfhound`. Both `sfhound` (CLI entry point) and `python -m sfhound` work after install. Previously the tool was only usable by cloning the repo and running `sf-opengraph/sfhound.py` directly.

#### OAuth 2.0 Client Credentials flow (`--auth-type`)
A second authentication method is now supported alongside the existing JWT Bearer (certificate) flow. Set `type: client_credential_flow` in your config with a `client_id` and `client_secret` — no certificate or private key file required. Pass `--auth-type` on the CLI to override the config file value.

#### Scoped collection (`--scope`)
Extract only the node types you need and write one output file per scope rather than a single monolithic export. Useful for large orgs or incremental BloodHound ingestion.

```bash
sfhound --scope users
sfhound --scope users,groups,profiles
```

Valid values: `users`, `groups`, `queues`, `profiles`, `permissionsets`, `roles`, `connectedapps`, `objects`, `fields`.

#### Field permission filtering (`--fields`)
Control how much field-level permission data is collected.

```bash
sfhound --fields all                               # default — all field permissions
sfhound --fields none                              # skip field permissions entirely (faster)
sfhound --fields Account.Industry,Contact.Email    # specific fields only
```

Skipping or narrowing field permissions significantly reduces extraction time and output size on orgs with large object schemas.

#### Throttle / single-page mode (`--throttle`)
Rate-limit SOQL queries by appending `LIMIT <n>` and disabling `queryMore()` pagination. Useful for avoiding API governor limits or for quick test runs.

```bash
sfhound --throttle        # default 200 records per query
sfhound --throttle 500
```

#### Verbose extraction progress (`--verbose`)
Print per-query progress with record counts across both extraction phases, making it easy to see where time is being spent.

```
[*] Phase 1 — Metadata extraction
  [ 1/17] profiles... 12 records
  [ 2/17] permission sets... 48 records
  ...
[*] Phase 2 — Assignment extraction
  [14/17] users... 31 records
  ...
```

#### BloodHound API client
`sfhound.bloodhound_api.BloodHoundAPI` is now a first-class importable module covering login, graph upload, OpenGraph schema validation, and Cypher query execution — available as a Python API in addition to the CLI `--auto-ingest` flag.

#### CI/CD pipelines
Two GitHub Actions workflows are included:
- **`ci.yml`** — runs `ruff` lint and `bandit` + `pip-audit` security checks on every pull request to `main`
- **`release.yml`** — triggered by a `v*.*.*` tag; runs security checks, builds the sdist and wheel, publishes to PyPI via OIDC trusted publishing, and creates a GitHub Release with auto-generated notes

#### Integration test suite
A full pytest-based integration test suite under `tests/`. 68 tests cover every CLI flag and scope, BloodHound upload, and all 52 Cypher queries in the library. See `docs/test-suite-usage.md` for setup instructions.

### Other changes

- `requirements.txt` now pins minimum versions for all dependencies (`requests>=2.32.5`, `PyJWT>=2.10.1`, `pyaml>=26.2.1`, `cryptography>=45.0.6`, `bhopengraph>=1.3.1`)
- `pyproject.toml` includes authors, homepage, repository URL, bug tracker URL, and PyPI classifiers
- Example scripts, schema dumps, and the Cypher query library bundled under `sfhound/examples/`
- `sfhound.egg-info/` and `test_output/` added to `.gitignore`

## sfhound v1.2
### Minor Release
- Added graphing for Record Ownership when modelling a Salesforce Organisation.
- New cypher query to query ownership of records within a Salesforce Object.

## sfhound v1.1
### Minor Release
- Aligning with bhopengraph Python3 classes for best practices as recommended by SpecterOps
- Updated documentation with guidance on new nodes and edges

## sfhound v1.1
### Initial Release

Collector:
- JWT Bearer OAuth authentication via Salesforce Connected App
- config.yaml and full command-line override support
- Extraction of Users, Profiles, Permission Sets, Permission Set Groups
- Extraction of Role Hierarchy (roles and InheritsRole edges)
- Extraction of Public Groups and Queues with nested membership resolution
- Extraction of Object Permissions (CRUD, ViewAll, ModifyAll) per Profile/PermissionSet
- Extraction of Field-Level Security (IsVisible, ReadOnly) per Profile/PermissionSet
- Extraction of Connected Apps with CanAuthorize edge resolution
- OWD (InternalSharingModel, ExternalSharingModel) capture on all SObjects
- Aggregate PermissionSet placeholder hydration for 0PSG… IDs
- BloodHound OpenGraph v2 output with full schema validation
- Auto-ingest to BloodHound CE (upload, poll, status reporting)
- Custom icon registration script (examples/post_custom_icons.py)

Graph model:
- 11 node types with coloured icons
- 40+ typed edge types
- Full edge context properties: General, AbuseInfo, RemediationInfo, OPSEC, References
- MITRE ATT&CK mappings on all named system permission edges

Documentation:
- Collector setup guide (Connected App creation, JWT cert generation, minimum permissions)
- Schema reference (all nodes, edges, properties)
- Custom Cypher query library
- Tier Zero Cypher rules for BloodHound CE
- Design decisions document
