Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,28 @@ A token with enterprise SCIM access is used to collect both `/scim/v2/enterprise

Enterprise roles, including the built-in `members` role, are emitted through `GH_HasRole` and granular enterprise capability relationships. Only capability relationships with a confirmed privilege path are traversable; descriptive permissions such as `GH_WriteEnterpriseSso` remain non-traversable.

### Enterprise OIDC collection

A supplemental classic PAT owned by an enterprise owner with `read:enterprise`
enables discovery of `enterprise.ownerInfo.oidcProvider`. The collector stores
provider metadata in `oidc_provider` and emits `GH_OidcIdentityProvider` with
`provider_type` and `tenant_id`, linked to the enterprise through the
non-traversable `GH_HasOidcIdentityProvider` relationship.

SAML and OIDC providers both carry the secondary kind `GH_IdentityProvider`.
Provider queries run independently, so an unavailable OIDC field or denied
request does not prevent existing SAML collection. An absent OIDC provider is
logged separately from query failures.

External identities from both enterprise provider types are paginated into the
existing `external_identities` table. Conversion resolves their provider through
the SAML and OIDC metadata tables and emits `GH_HasExternalIdentity` and, when a
linked user exists, `GH_MapsToUser`. These edges remain non-traversable. Nullable
identity attributes and unlinked identities are retained. OIDC identities do not
produce SAML account relationships. If provider lookup is missing or ambiguous,
conversion omits the provider edge while preserving the identity and user mapping;
ambiguous matches are logged.

### Optional privileged collection

The collector runs successfully with the documented read-only permission set. Some higher-fidelity relationships require optional non-read-only permissions because GitHub exposes the supporting read APIs behind privileged permission names:
Expand Down
8 changes: 5 additions & 3 deletions descriptions/edges/GH_HasExternalIdentity.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,21 @@

## General Information

The non-traversable GH_HasExternalIdentity edge represents the relationship between a SAML identity provider and the external identities (SSO users) it manages. This edge links each external identity to the SAML provider that authenticated it. External identities are a key component in cross-platform attack path analysis because they bridge the gap between corporate identity providers and GitHub user accounts via the GH_MapsToUser edge. Enumerating external identities reveals which corporate users have linked GitHub accounts and enables mapping from IdP compromise to GitHub access.
The non-traversable GH_HasExternalIdentity edge represents the relationship between a SAML or OIDC identity provider and the external identities (SSO users) it manages. This edge links each external identity to its identity provider. External identities are a key component in cross-platform attack path analysis because they bridge the gap between corporate identity providers and GitHub user accounts via the GH_MapsToUser edge. Enumerating external identities reveals which corporate users have linked GitHub accounts and enables mapping from IdP compromise to GitHub access.

## Edge Schema

| Source | Destination | Traversable |
| --- | --- | --- |
| `GH_SamlIdentityProvider` | `GH_ExternalIdentity` | `false` |
| `GH_IdentityProvider` | `GH_ExternalIdentity` | `false` |

## Diagram

```mermaid
graph LR
n0["GH_SamlIdentityProvider"]
n0["GH_IdentityProvider"]
n1["GH_ExternalIdentity"]
n0 -.->|GH_HasExternalIdentity| n1
```

`GH_IdentityProvider` is a shared secondary kind on SAML and OIDC providers. Provider association is resolved from collected provider metadata, without duplicating provider properties on external identity nodes.
20 changes: 20 additions & 0 deletions descriptions/edges/GH_HasOidcIdentityProvider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# GH_HasOidcIdentityProvider

## General Information

Links a GitHub Enterprise account to its configured OIDC identity provider.

## Edge Schema

| Source | Destination | Traversable |
| --- | --- | --- |
| `GH_Enterprise` | `GH_OidcIdentityProvider` | `false` |

This relationship provides configuration context and does not itself represent an attack path.

## Diagram

```mermaid
graph LR
enterprise["GH_Enterprise"] -.->|GH_HasOidcIdentityProvider| provider["GH_OidcIdentityProvider"]
```
2 changes: 1 addition & 1 deletion descriptions/nodes/GH_ExternalIdentity.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## General Information

Represents an external identity from a SAML or SCIM identity provider that is linked to a GitHub user. External identities map corporate user accounts (from providers like Okta, Azure AD, etc.) to GitHub user accounts, enabling single sign-on authentication. Each external identity can have both SAML and SCIM identity attributes.
Represents an external identity collected through a SAML or OIDC provider, with a linked GitHub user when available. External identities map corporate user accounts (from providers like Okta, Azure AD, etc.) to GitHub user accounts, enabling single sign-on authentication. SAML attributes, SCIM attributes, and the linked user may be absent.

## Properties

Expand Down
24 changes: 24 additions & 0 deletions descriptions/nodes/GH_IdentityProvider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# GH_IdentityProvider

## General Information

A shared, non-display secondary kind on `GH_SamlIdentityProvider` and `GH_OidcIdentityProvider` nodes. Provider-specific kinds retain their display settings and protocol-specific properties.

Use this kind to query providers across both federation strategies:

```cypher
MATCH (provider:GH_IdentityProvider)
RETURN provider
```

## Properties

This secondary kind adds no properties. Nodes retain the properties defined by their provider-specific kind.

## Diagram

```mermaid
graph LR
provider["GH_IdentityProvider"] -.->|GH_HasExternalIdentity| identity["GH_ExternalIdentity"]
identity -.->|GH_MapsToUser| user["GH_User"]
```
34 changes: 34 additions & 0 deletions descriptions/nodes/GH_OidcIdentityProvider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# GH_OidcIdentityProvider

## General Information

Represents an OIDC identity provider configured for a GitHub Enterprise account. Collection queries `enterprise.ownerInfo.oidcProvider` using the supplemental classic PAT. The PAT must belong to an enterprise owner and have `read:enterprise` access.

The node carries `GH_OidcIdentityProvider`, the shared secondary kind `GH_IdentityProvider`, and the source kind `GitHub`.

## Properties

| Property | Description |
| --- | --- |
| `node_id` | GitHub's native GraphQL provider ID. |
| `provider_type` | The OIDC provider type returned by GitHub. |
| `tenant_id` | The identity provider's tenant ID. |
| `environmentid` | The GitHub Enterprise node ID. |
| `environment_name` | The enterprise name. |
| `github_deployment_id` | The GitHub deployment identifier. |
| `github_web_origin` | The GitHub web origin. |

## Relationships

`GH_Enterprise` connects to this node through the non-traversable `GH_HasOidcIdentityProvider` relationship. Provider collection does not emit SAML service provider or SAML account relationships.

The provider connects to its `GH_ExternalIdentity` nodes through non-traversable `GH_HasExternalIdentity` edges. Those identities map to linked `GH_User` nodes through `GH_MapsToUser` when available.

## Diagram

```mermaid
graph LR
enterprise["GH_Enterprise"] -.->|GH_HasOidcIdentityProvider| provider["GH_OidcIdentityProvider"]
provider -.->|GH_HasExternalIdentity| identity["GH_ExternalIdentity"]
identity -.->|GH_MapsToUser| user["GH_User"]
```
2 changes: 2 additions & 0 deletions descriptions/nodes/GH_SamlIdentityProvider.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,5 @@ graph LR
n2 -.->|GH_HasSamlIdentityProvider| n1
n1 -.->|GH_HasExternalIdentity| n3
```

This node also carries the non-display secondary kind `GH_IdentityProvider`, shared with enterprise OIDC providers.
75 changes: 69 additions & 6 deletions extension/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"schema": {
"name": "SOGitHub",
"display_name": "GitHub Extension (by SpecterOps)",
"version": "v1.4.0",
"version": "v1.4.2",
"namespace": "GH"
},
"node_kinds": [
Expand Down Expand Up @@ -74,7 +74,7 @@
"title": "Identity Provider",
"position": 9,
"markdown": {
"content": "{{ $objectID := .Properties.objectid | default \"\" | trim }}\n{{ $escapedObjectID := $objectID | replace \"\\\\\" \"\\\\\\\\\" | replace \"'\" \"\\\\'\" }}\n{{ if ne $objectID \"\" }}\n{{ $query := printf \"MATCH p = (selected:GH_Enterprise {objectid: '%s'})-[:GH_HasSamlIdentityProvider]->(idp:GH_SamlIdentityProvider)\\nOPTIONAL MATCH p1 = (idp)-[:GH_HasExternalIdentity]->(:GH_ExternalIdentity)-[:GH_MapsToUser]->(:GH_User)\\nRETURN p, p1\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View the identity provider and mapped users for this enterprise in Explore](/ui/explore?exploreSearchTab=cypher&searchType=cypher&cypherSearch={{ $encodedQuery }})\n{{ else }}\nExplore navigation is unavailable because this object has no stable identifier.\n{{ end }}"
"content": "{{ $objectID := .Properties.objectid | default \"\" | trim }}\n{{ $escapedObjectID := $objectID | replace \"\\\\\" \"\\\\\\\\\" | replace \"'\" \"\\\\'\" }}\n{{ if ne $objectID \"\" }}\n{{ $query := printf \"MATCH p = (selected:GH_Enterprise {objectid: '%s'})-[:GH_HasSamlIdentityProvider|GH_HasOidcIdentityProvider]->(idp:GH_IdentityProvider)\\nOPTIONAL MATCH p1 = (idp)-[:GH_HasExternalIdentity]->(:GH_ExternalIdentity)-[:GH_MapsToUser]->(:GH_User)\\nRETURN p, p1\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View the identity provider and mapped users for this enterprise in Explore](/ui/explore?exploreSearchTab=cypher&searchType=cypher&cypherSearch={{ $encodedQuery }})\n{{ else }}\nExplore navigation is unavailable because this object has no stable identifier.\n{{ end }}"
}
},
"classic_personal_access_tokens": {
Expand Down Expand Up @@ -1631,10 +1631,62 @@
}
}
},
{
"name": "GH_OidcIdentityProvider",
"display_name": "GitHub OIDC Identity Provider",
"description": "An OIDC identity provider configured for a GitHub Enterprise account",
"is_display_kind": true,
"icon": "id-badge",
"color": "#5A6C8F",
"info": {
"description": {
"title": "Description",
"position": 1,
"markdown": {
"content": "## Overview\n\nRepresents an enterprise OIDC identity provider. The provider_type and tenant_id properties identify the provider and its tenant."
}
},
"inbound_traversable_relationships": {
"title": "Inbound Traversable GH Relationships",
"position": 2,
"markdown": {
"content": "{{ $objectID := .Properties.objectid | default \"\" | trim }}\n{{ $escapedObjectID := $objectID | replace \"\\\\\" \"\\\\\\\\\" | replace \"'\" \"\\\\'\" }}\n{{ if ne $objectID \"\" }}\n{{ $query := printf \"MATCH p = (selected:GH_OidcIdentityProvider {objectid: '%s'})\\nWHERE false\\nRETURN p\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View direct inbound relationships in Explore](/ui/explore?exploreSearchTab=cypher&searchType=cypher&cypherSearch={{ $encodedQuery }})\n{{ else }}\nExplore navigation is unavailable because this object has no stable identifier.\n{{ end }}"
}
},
"outbound_traversable_relationships": {
"title": "Outbound Traversable GH Relationships",
"position": 3,
"markdown": {
"content": "{{ $objectID := .Properties.objectid | default \"\" | trim }}\n{{ $escapedObjectID := $objectID | replace \"\\\\\" \"\\\\\\\\\" | replace \"'\" \"\\\\'\" }}\n{{ if ne $objectID \"\" }}\n{{ $query := printf \"MATCH p = (selected:GH_OidcIdentityProvider {objectid: '%s'})\\nWHERE false\\nRETURN p\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View direct outbound relationships in Explore](/ui/explore?exploreSearchTab=cypher&searchType=cypher&cypherSearch={{ $encodedQuery }})\n{{ else }}\nExplore navigation is unavailable because this object has no stable identifier.\n{{ end }}"
}
},
"environment_context": {
"title": "Environment Context",
"position": 4,
"markdown": {
"content": "{{ $objectID := .Properties.objectid | default \"\" | trim }}\n{{ $escapedObjectID := $objectID | replace \"\\\\\" \"\\\\\\\\\" | replace \"'\" \"\\\\'\" }}\n{{ if ne $objectID \"\" }}\n{{ $query := printf \"MATCH p = (environment)-[:GH_HasOidcIdentityProvider]->(selected:GH_OidcIdentityProvider {objectid: '%s'})\\nWHERE environment:GH_Enterprise\\nRETURN p\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View the enterprise using this identity provider in Explore](/ui/explore?exploreSearchTab=cypher&searchType=cypher&cypherSearch={{ $encodedQuery }})\n{{ else }}\nExplore navigation is unavailable because this object has no stable identifier.\n{{ end }}"
}
},
"external_identities": {
"title": "External Identities",
"position": 5,
"markdown": {
"content": "{{ $objectID := .Properties.objectid | default \"\" | trim }}\n{{ $escapedObjectID := $objectID | replace \"\\\\\" \"\\\\\\\\\" | replace \"'\" \"\\\\'\" }}\n{{ if ne $objectID \"\" }}\n{{ $query := printf \"MATCH p = (selected:GH_OidcIdentityProvider {objectid: '%s'})-[:GH_HasExternalIdentity]->(:GH_ExternalIdentity)\\nRETURN p\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View external identities from this provider in Explore](/ui/explore?exploreSearchTab=cypher&searchType=cypher&cypherSearch={{ $encodedQuery }})\n{{ else }}\nExplore navigation is unavailable because this object has no stable identifier.\n{{ end }}"
}
},
"mapped_users": {
"title": "Mapped Users",
"position": 6,
"markdown": {
"content": "{{ $objectID := .Properties.objectid | default \"\" | trim }}\n{{ $escapedObjectID := $objectID | replace \"\\\\\" \"\\\\\\\\\" | replace \"'\" \"\\\\'\" }}\n{{ if ne $objectID \"\" }}\n{{ $query := printf \"MATCH p = (selected:GH_OidcIdentityProvider {objectid: '%s'})-[:GH_HasExternalIdentity]->(:GH_ExternalIdentity)-[:GH_MapsToUser]->(:GH_User)\\nRETURN p\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View GitHub users mapped through this identity provider in Explore](/ui/explore?exploreSearchTab=cypher&searchType=cypher&cypherSearch={{ $encodedQuery }})\n{{ else }}\nExplore navigation is unavailable because this object has no stable identifier.\n{{ end }}"
}
}
}
},
{
"name": "GH_ExternalIdentity",
"display_name": "GitHub External Identity",
"description": "An external identity from a SAML/SCIM provider linked to a GitHub user for SSO authentication",
"description": "An external identity from a SAML or OIDC provider with optional SCIM attributes and a linked GitHub user when available",
"is_display_kind": true,
"icon": "arrows-left-right",
"color": "#8A8F98",
Expand All @@ -1643,7 +1695,7 @@
"title": "Description",
"position": 1,
"markdown": {
"content": "## Overview\n\nRepresents an external identity from a SAML or SCIM identity provider that is linked to a GitHub user. This node preserves the identity-provider attributes used to understand how a corporate identity maps into GitHub."
"content": "## Overview\n\nRepresents an external identity collected through a SAML or OIDC provider, with a linked GitHub user when available. This node preserves the identity-provider attributes used to understand how a corporate identity maps into GitHub."
}
},
"inbound_traversable_relationships": {
Expand All @@ -1664,7 +1716,7 @@
"title": "Identity Provider",
"position": 4,
"markdown": {
"content": "{{ $objectID := .Properties.objectid | default \"\" | trim }}\n{{ $escapedObjectID := $objectID | replace \"\\\\\" \"\\\\\\\\\" | replace \"'\" \"\\\\'\" }}\n{{ if ne $objectID \"\" }}\n{{ $query := printf \"MATCH p = (:GH_SamlIdentityProvider)-[:GH_HasExternalIdentity]->(selected:GH_ExternalIdentity {objectid: '%s'})\\nRETURN p\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View the identity provider containing this external identity in Explore](/ui/explore?exploreSearchTab=cypher&searchType=cypher&cypherSearch={{ $encodedQuery }})\n{{ else }}\nExplore navigation is unavailable because this object has no stable identifier.\n{{ end }}"
"content": "{{ $objectID := .Properties.objectid | default \"\" | trim }}\n{{ $escapedObjectID := $objectID | replace \"\\\\\" \"\\\\\\\\\" | replace \"'\" \"\\\\'\" }}\n{{ if ne $objectID \"\" }}\n{{ $query := printf \"MATCH p = (:GH_IdentityProvider)-[:GH_HasExternalIdentity]->(selected:GH_ExternalIdentity {objectid: '%s'})\\nRETURN p\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View the identity provider containing this external identity in Explore](/ui/explore?exploreSearchTab=cypher&searchType=cypher&cypherSearch={{ $encodedQuery }})\n{{ else }}\nExplore navigation is unavailable because this object has no stable identifier.\n{{ end }}"
}
},
"mapped_user": {
Expand Down Expand Up @@ -1966,6 +2018,12 @@
}
}
}
},
{
"name": "GH_IdentityProvider",
"display_name": "GitHub Identity Provider",
"description": "Shared secondary kind for GitHub SAML and OIDC identity providers",
"is_display_kind": false
}
],
"relationship_kinds": [
Expand Down Expand Up @@ -2601,7 +2659,7 @@
},
{
"name": "GH_HasExternalIdentity",
"description": "SAML identity provider has this external identity",
"description": "SAML or OIDC identity provider has this external identity",
"is_traversable": false
},
{
Expand Down Expand Up @@ -2748,6 +2806,11 @@
"name": "GH_ApprovesDeploymentTo",
"description": "User or team is configured as a required reviewer for this environment",
"is_traversable": false
},
{
"name": "GH_HasOidcIdentityProvider",
"description": "Enterprise has this OIDC identity provider configured",
"is_traversable": false
}
],
"environments": [
Expand Down
30 changes: 27 additions & 3 deletions src/openhound_github/graphql.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,12 +131,19 @@
}
"""

ENTERPRISE_SAML_QUERY = """
query EnterpriseSAML($slug: String!, $count: Int = 100, $after: String = null) {
def enterprise_external_identity_query(provider_field: str) -> str:
"""Build the common paginated identity query for an enterprise provider."""
if provider_field not in {"samlIdentityProvider", "oidcProvider"}:
raise ValueError(f"Unsupported identity provider: {provider_field}")
return _ENTERPRISE_EXTERNAL_IDENTITY_QUERY.replace("PROVIDER_FIELD", provider_field)


_ENTERPRISE_EXTERNAL_IDENTITY_QUERY = """
query EnterpriseExternalIdentities($slug: String!, $count: Int = 100, $after: String = null) {
enterprise(slug: $slug) {
slug
ownerInfo {
samlIdentityProvider {
PROVIDER_FIELD {
externalIdentities(first: $count, after: $after) {
totalCount
nodes {
Expand Down Expand Up @@ -179,6 +186,9 @@
}
"""

ENTERPRISE_SAML_QUERY = enterprise_external_identity_query("samlIdentityProvider")
ENTERPRISE_OIDC_QUERY = enterprise_external_identity_query("oidcProvider")

ENTERPRISE_SAML_PROVIDER_QUERY = """
query EnterpriseSAMLProvider($slug: String!) {
enterprise(slug: $slug) {
Expand All @@ -199,6 +209,20 @@
}
"""

ENTERPRISE_OIDC_PROVIDER_QUERY = """
query EnterpriseOIDCProvider($slug: String!) {
enterprise(slug: $slug) {
ownerInfo {
oidcProvider {
id
providerType
tenantId
}
}
}
}
"""

TEAMS_QUERY = """
query Teams($login: String!, $count: Int!, $after: String) {
organization(login: $login) {
Expand Down
1 change: 1 addition & 0 deletions src/openhound_github/kinds/edges.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
RESTRICTIONS_CAN_PUSH = "GH_RestrictionsCanPush"

# Identity and authentication edges
HAS_OIDC_IDENTITY_PROVIDER = "GH_HasOidcIdentityProvider"
HAS_EXTERNAL_IDENTITY = "GH_HasExternalIdentity"
HAS_SAML_IDENTITY_PROVIDER = "GH_HasSamlIdentityProvider"
MAPS_TO_USER = "GH_MapsToUser"
Expand Down
Loading
Loading