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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ A token with enterprise SCIM access is used to collect both `/scim/v2/enterprise

`SOURCES__GITHUB__EMIT_LEGACY_SCIM_CORRELATIONS=true` temporarily reproduces GitHound-style Okta-to-SCIM correlation relationships. It defaults to false because a dedicated hybrid correlator should own IdP-to-SCIM matching; GitHub remains authoritative for GitHub's SCIM resources and target-system provisioning relationships.

In enterprise app authentication mode, the collector separately retrieves its own enterprise installation for configuration troubleshooting, including granted permissions, timestamps, and suspension status. It also retrieves the parent app's requested permissions and owner metadata using the enterprise installation token. This does not enumerate other enterprise-target app installations or imply organization/repository access. App and installation records use `environment_slug` for the organization login or enterprise slug. Installations also retain `target_type` to distinguish targets; older collected records remain compatible. Existing DLT app schemas are migrated to allow empty legacy `org_login` fields and absent `repository_selection`. A load already stuck in normalization retains its saved schema and needs recovery or restart; do not delete the pipeline or collected output to apply this change.

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
Expand Down
4 changes: 3 additions & 1 deletion descriptions/edges/GH_Contains.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@

## General Information

The non-traversable GH_Contains edge represents structural containment within the GitHub resource hierarchy. The enterprise contains enterprise teams, roles, managed users, classic personal access tokens, runner groups, and enterprise runners through their groups. The organization serves as a top-level container for users, teams, repositories, roles, scopes, secrets, app installations, fine-grained personal access tokens, and organization runner groups. Native organization runner groups contain organization runners. Repositories contain branches, workflows, branch protection rules, environments, repo-level secrets and variables, and repository-scoped runners. Environments contain environment branch policies, environment-scoped secrets, and environment-scoped variables. This edge is created by the collector to establish the resource hierarchy and is not traversable because containment alone does not imply privilege escalation.
The non-traversable GH_Contains edge represents structural containment within the GitHub resource hierarchy. The enterprise contains enterprise teams, roles, managed users, classic personal access tokens, the collector app installation, runner groups, and enterprise runners through their groups. The organization serves as a top-level container for users, teams, repositories, roles, scopes, secrets, app installations, fine-grained personal access tokens, and organization runner groups. Native organization runner groups contain organization runners. Repositories contain branches, workflows, branch protection rules, environments, repo-level secrets and variables, and repository-scoped runners. Environments contain environment branch policies, environment-scoped secrets, and environment-scoped variables. This edge is created by the collector to establish the resource hierarchy and is not traversable because containment alone does not imply privilege escalation.

## Edge Schema

| Source | Destination | Traversable |
| --- | --- | --- |
| `GH_Enterprise` | `GH_AppInstallation` | `false` |
| `GH_Enterprise` | `GH_ClassicPersonalAccessToken` | `false` |
| `GH_Enterprise` | `GH_EnterpriseRole` | `false` |
| `GH_Enterprise` | `GH_EnterpriseRunnerGroup` | `false` |
Expand Down Expand Up @@ -75,6 +76,7 @@ graph LR
n29["GH_WorkflowJob"]
n30["GH_WorkflowStep"]
n0 -.->|GH_Contains| n1
n0 -.->|GH_Contains| n13
n0 -.->|GH_Contains| n2
n0 -.->|GH_Contains| n3
n0 -.->|GH_Contains| n4
Expand Down
6 changes: 4 additions & 2 deletions descriptions/nodes/GH_App.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,17 @@

## General Information

Represents a GitHub App definition — the registered application entity. The app owner holds the private key that can generate installation access tokens for **every** GH_AppInstallation of this app. If the private key is compromised, all installations across all organizations are affected.
Represents a GitHub App definition — the registered application entity. The app owner holds the private key that can generate installation access tokens for **every** GH_AppInstallation of this app. If the private key is compromised, all installations across all organizations and enterprises are affected.

App definitions are retrieved via the public `GET /apps/{app_slug}` endpoint (no authentication required) after discovering unique app slugs from the organization's app installations.
App definitions are retrieved via `GET /apps/{app_slug}` after discovering app slugs from organization installations or the collector app's enterprise installation. Authenticated lookup can also retrieve private/internal apps where GitHub permits access. The `permissions` property records requested app permissions; each installation separately records its granted permissions.

## Properties

| Property | Type | Description |
| --- | --- | --- |
| `name` | `string` | The node name used for matching and display. |
| `displayname` | `string` | The human-readable display name. |
| `environment_slug` | `string` | The organization login or enterprise slug where the app was collected. |
| `environmentid` | `string` | The identifier of the GitHub environment where this node was collected. |
| `last_seen` | `datetime` | The timestamp when this node was last observed during collection. |
| `node_id` | `string` | The stable identifier used as the OpenGraph node ID; this is the native GitHub node ID where available. |
Expand All @@ -24,6 +25,7 @@ App definitions are retrieved via the public `GET /apps/{app_slug}` endpoint (no
| `owner_login` | `string` | The login of the user or organization that owns the app. |
| `owner_node_id` | `string` | The node_id of the user or organization that owns the app. |
| `owner_type` | `string` | The type of the owner (e.g., `User`, `Organization`). |
| `permissions` | `list[string]` | Requested app permissions in `scope:access` form. |
| `registration_origin` | `string` | Whether the app is tenant-owned, third-party, or unknown. |
| `events` | `list[string]` | JSON string of the default webhook events the app subscribes to. |
| `installations_count` | `integer` | The total number of installations of this app across all organizations. |
Expand Down
15 changes: 11 additions & 4 deletions descriptions/nodes/GH_AppInstallation.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,23 @@

## General Information

Represents a GitHub App installed on an organization. App installations have specific permissions and can be scoped to all repositories or a selection of repositories. The permissions granted to the installation are stored as `scope:access` values such as `contents:write` in the `permissions` property.
Represents a GitHub App installed on an organization or enterprise. Organization app installations have specific permissions and can be scoped to all repositories or a selection of repositories. The permissions granted to the installation are stored as `scope:access` values such as `contents:write` in the `permissions` property.

Unlike fine-grained personal access tokens, GitHub does not expose separate organization and repository permission buckets for app installations, so this property remains a single flat permission list.

Each installation is linked to its parent GH_App via a GH_InstalledAs edge. Installations with `repository_selection` set to `all` use one GH_CanAccess edge to the organization's repository/all GH_Scope; GH_ScopedTo edges enumerate the repositories in that reusable scope. For installations with `repository_selection` set to `selected`, the collector uses the enterprise organization-installation repositories API to enumerate the granted repositories when the enterprise installation has `Enterprise organization installations: read` or `Enterprise organization installation repositories: read`. If that permission is unavailable, selected repository edges are skipped.
Each installation is linked to its parent GH_App via a GH_InstalledAs edge. Organization installations with `repository_selection` set to `all` use one GH_CanAccess edge to the organization's repository/all GH_Scope; GH_ScopedTo edges enumerate the repositories in that reusable scope. For installations with `repository_selection` set to `selected`, the collector uses the enterprise organization-installation repositories API to enumerate the granted repositories when the enterprise installation has `Enterprise organization installations: read` or `Enterprise organization installation repositories: read`. If that permission is unavailable, selected repository edges are skipped.

Enterprise collection includes the authenticating collector app's installation, fetched independently of authentication discovery with `GET /enterprises/{enterprise}/installation`. It records granted enterprise permissions, timestamps, and suspension status, and is contained by `GH_Enterprise`. This is not a complete inventory of apps installed on the enterprise. Enterprise installations do not produce repository access edges. Parent app enrichment failures do not discard the installation.

`environment_slug` identifies the target account for both installation types. `target_type` distinguishes organization and enterprise targets. Older records using `org_login` or `enterprise_name` remain readable; these fields are no longer used for new installation context. Organization repository access is evaluated only for organization targets.

## Properties

| Property | Type | Description |
| --- | --- | --- |
| `name` | `string` | The node name used for matching and display. |
| `displayname` | `string` | The human-readable display name. |
| `environment_slug` | `string` | The organization login or enterprise slug of the installation target. |
| `environmentid` | `string` | The identifier of the GitHub environment where this node was collected. |
| `last_seen` | `datetime` | The timestamp when this node was last observed during collection. |
| `node_id` | `string` | The stable identifier used as the OpenGraph node ID; this is the native GitHub node ID where available. |
Expand All @@ -25,13 +30,13 @@ Each installation is linked to its parent GH_App via a GH_InstalledAs edge. Inst
| `access_tokens_url` | `string` | API URL to create installation access tokens. |
| `repositories_url` | `string` | API URL to list repositories accessible to this installation. |
| `repository_selection` | `string` | Whether the app has access to `all` repositories or `selected` repositories. |
| `target_type` | `string` | The target type of the installation (e.g., `Organization`). |
| `target_type` | `string` | The target type of the installation (e.g., `Organization` or `Enterprise`). |
| `permissions` | `list[string]` | Permissions granted to the installation in `scope:access` form. |
| `events` | `string` | JSON string of the webhook events the app subscribes to. |
| `created_at` | `datetime` | When the app was installed. |
| `updated_at` | `datetime` | When the installation was last updated. |
| `suspended_at` | `datetime` | When the installation was suspended, if applicable. |
| `environment_name` | `string` | The name of the environment (GitHub organization) where the app is installed. |
| `environment_name` | `string` | The name of the environment (GitHub organization or enterprise) where the app is installed. |
| `query_repositories` | `string` | Query for repositories. |
| `query_app` | `string` | Query for app. |

Expand All @@ -48,4 +53,6 @@ graph LR
n1 -.->|GH_CanAccess| n2
n1 -.->|GH_CanAccess| n3
n4 -.->|GH_Contains| n1
n5["GH_Enterprise"]
n5 -.->|GH_Contains| n1
```
22 changes: 18 additions & 4 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.2",
"version": "v1.4.3",
"namespace": "GH"
},
"node_kinds": [
Expand Down Expand Up @@ -83,6 +83,13 @@
"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_Contains]->(:GH_ClassicPersonalAccessToken)\\nRETURN p\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View classic personal access tokens in 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 }}"
}
},
"collector_app_installation": {
"title": "Collector App Installation",
"position": 11,
"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_Contains]->(:GH_AppInstallation)\\nRETURN p\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View the collector app installation in 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 }}"
}
}
}
},
Expand Down Expand Up @@ -1740,7 +1747,7 @@
"title": "Description",
"position": 1,
"markdown": {
"content": "## Overview\n\nRepresents a GitHub App definition. The app owner controls the private key used to generate installation tokens for every installation of this app, so compromise of the app can affect every organization where it is installed."
"content": "## Overview\n\nRepresents a GitHub App definition. The `permissions` property records requested app permissions. Each installation records its own granted permissions. The app owner controls the private key used to generate installation tokens for its installations on organizations and enterprises.\n\n`environment_slug` records the organization login or enterprise slug for this collection context."
}
},
"inbound_traversable_relationships": {
Expand Down Expand Up @@ -1821,7 +1828,7 @@
{
"name": "GH_AppInstallation",
"display_name": "GitHub App Installation",
"description": "A GitHub App installed on the organization with specific permissions and repository access",
"description": "A GitHub App installed on an organization or enterprise with a specific set of granted permissions",
"is_display_kind": true,
"icon": "plug",
"color": "#A8D8EA",
Expand All @@ -1830,7 +1837,7 @@
"title": "Description",
"position": 1,
"markdown": {
"content": "## Overview\n\nRepresents a GitHub App installed on an organization. Installations have a permission set and repository scope, and are linked back to the parent `GH_App` that owns the private key used to mint installation tokens."
"content": "## Overview\n\nRepresents a GitHub App installed on an organization or enterprise. The `permissions` property records permissions granted to this installation; the parent `GH_App` records requested permissions.\n\nEnterprise collection currently includes only the authenticating collector app installation. Enterprise installations do not automatically grant access to organizations or repositories.\n\n`environment_slug` records the organization login or enterprise slug for this collection context."
}
},
"inbound_traversable_relationships": {
Expand Down Expand Up @@ -1867,6 +1874,13 @@
"markdown": {
"content": "{{ $objectID := .Properties.objectid | default \"\" | trim }}\n{{ $escapedObjectID := $objectID | replace \"\\\\\" \"\\\\\\\\\" | replace \"'\" \"\\\\'\" }}\n{{ if ne $objectID \"\" }}\n{{ $query := printf \"MATCH p = (selected:GH_AppInstallation {objectid: '%s'})-[:GH_CanAccess|GH_ScopedTo*1..2]->(:GH_Repository)\\nRETURN p\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View repositories accessible to this app installation 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 }}"
}
},
"enterprise_context": {
"title": "Enterprise Context",
"position": 7,
"markdown": {
"content": "{{ $objectID := .Properties.objectid | default \"\" | trim }}\n{{ $escapedObjectID := $objectID | replace \"\\\\\" \"\\\\\\\\\" | replace \"'\" \"\\\\'\" }}\n{{ if ne $objectID \"\" }}\n{{ $query := printf \"MATCH p = (:GH_Enterprise)-[:GH_Contains]->(selected:GH_AppInstallation {objectid: '%s'})\\nRETURN p\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View the enterprise containing this app installation 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 }}"
}
}
}
},
Expand Down
13 changes: 4 additions & 9 deletions src/openhound_github/lookup.py
Original file line number Diff line number Diff line change
Expand Up @@ -850,16 +850,11 @@ def identity_providers_for_environment(self, environment_slug: str) -> list:
return rows

@lru_cache
def app_node_id(self, app_slug: str, org_login: str | None = None) -> str | None:
if org_login is None:
return self._find_single_object(
f"""SELECT node_id FROM {self.schema}.applications WHERE slug = ?""",
[app_slug],
)

def app_node_id(self, app_slug: str) -> str | None:
"""Resolve the registration shared by its organization and enterprise installations."""
return self._find_single_object(
f"""SELECT node_id FROM {self.schema}.applications WHERE slug = ? AND org_login = ?""",
[app_slug, org_login],
f"""SELECT node_id FROM {self.schema}.applications WHERE slug = ?""",
[app_slug],
)

@lru_cache
Expand Down
Loading
Loading