Skip to content
Open
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
5 changes: 5 additions & 0 deletions descriptions/edges/GH_AssignedTo.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
## General Information

The non-traversable GH_AssignedTo edge represents an enterprise-scoped object being assigned to an organization. It is currently used for enterprise teams and enterprise runner groups. For runner groups, this edge indicates that an organization is allowed to inherit and use the enterprise-owned runner group.

This edge is not traversable because assignment alone does not directly grant a principal a privilege path.
5 changes: 5 additions & 0 deletions descriptions/edges/GH_CanUseRunner.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
## General Information

The non-traversable GH_CanUseRunner edge represents that a repository can dispatch GitHub Actions jobs to a self-hosted runner. Repository-scoped runners receive this edge directly from their containing repository. Organization and enterprise runners receive composed GH_CanUseRunner edges derived from runner group containment and repository access policy.

For native organization runner groups, the composition path is `GH_Repository <- GH_GrantsAccessTo - GH_OrgRunnerGroup - GH_Contains -> GH_OrgRunner`. For inherited groups, the path continues through `GH_InheritedFrom` to the enterprise runner group and its contained GH_EnterpriseRunner nodes.
2 changes: 1 addition & 1 deletion descriptions/edges/GH_Contains.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
## General Information

The non-traversable GH_Contains edge represents structural containment within the GitHub resource hierarchy. The organization serves as the top-level container for users, teams, repositories, roles, secrets, app installations, and personal access tokens. Repositories contain branches, workflows, branch protection rules, environments, and repo-level secrets and variables. 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, runner groups, and enterprise runners through their groups. The organization serves as a top-level container for users, teams, repositories, roles, secrets, app installations, 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.
5 changes: 5 additions & 0 deletions descriptions/edges/GH_GrantsAccessTo.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
## General Information

The non-traversable GH_GrantsAccessTo edge represents that a GH_OrgRunnerGroup allows a repository to use the runners exposed by that group. For native groups, those runners are GH_OrgRunner nodes contained by the group. For inherited groups, those runners are GH_EnterpriseRunner nodes reached through GH_InheritedFrom.

This edge is used as part of the composition path for GH_CanUseRunner and is not traversable on its own because repository eligibility alone is not a privilege escalation path.
5 changes: 5 additions & 0 deletions descriptions/edges/GH_InheritedFrom.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
## General Information

The non-traversable GH_InheritedFrom edge links an inherited GH_OrgRunnerGroup to the GH_EnterpriseRunnerGroup that owns the underlying runner set. This preserves the organization-local view of a runner group while still identifying the enterprise source that provides the runners.

This edge is not traversable because inheritance describes resource provenance rather than a direct privilege path.
5 changes: 5 additions & 0 deletions descriptions/nodes/GH_EnterpriseRunner.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
## Description

Represents a self-hosted runner owned at the GitHub Enterprise level. Enterprise runners are contained by GH_EnterpriseRunnerGroup nodes and become usable by repositories when an inherited organization runner group grants access to those repositories.

The node captures runner metadata such as operating system, status, busy state, labels, and whether the runner is ephemeral when GitHub returns that property.
5 changes: 5 additions & 0 deletions descriptions/nodes/GH_EnterpriseRunnerGroup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
## Description

Represents a self-hosted runner group owned by a GitHub Enterprise account. Enterprise runner groups control which organizations may use a shared set of enterprise runners. Groups with `visibility=all` are assigned to every organization in the enterprise, while groups with `visibility=selected` are assigned only to explicitly selected organizations.

Enterprise runner groups contain GH_EnterpriseRunner nodes and may be projected into organizations as inherited GH_OrgRunnerGroup nodes. The GH_InheritedFrom edge links the organization view back to the enterprise-owned group.
5 changes: 5 additions & 0 deletions descriptions/nodes/GH_OrgRunner.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
## Description

Represents a self-hosted runner owned by a GitHub organization. Organization runners are contained by native GH_OrgRunnerGroup nodes and become usable by repositories through the runner group's repository access policy.

The node captures runner metadata such as operating system, status, busy state, labels, and whether the runner is ephemeral when GitHub returns that property.
5 changes: 5 additions & 0 deletions descriptions/nodes/GH_OrgRunnerGroup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
## Description

Represents a self-hosted runner group visible within a GitHub organization. Organization runner groups may either be native to the organization or inherited from an enterprise runner group.

Native organization runner groups contain GH_OrgRunner nodes directly. Inherited organization runner groups do not directly contain organization runners; instead, they link to the source GH_EnterpriseRunnerGroup through GH_InheritedFrom and gain access to the enterprise runners contained there. GH_GrantsAccessTo edges describe which repositories may use the runners exposed by the group.
5 changes: 5 additions & 0 deletions descriptions/nodes/GH_RepoRunner.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
## Description

Represents a self-hosted runner registered directly to a single GitHub repository. Repository runners are contained by that repository and may only be used by workflows in that repository.

The node captures runner metadata such as operating system, status, busy state, labels, and whether the runner is ephemeral when GitHub returns that property.
75 changes: 66 additions & 9 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.3.0",
"version": "v1.3.1",
"namespace": "GH"
},
"node_kinds": [
Expand All @@ -12,7 +12,7 @@
"description": "A GitHub Enterprise account that contains organizations, enterprise teams, roles, and managed users",
"is_display_kind": true,
"icon": "globe",
"color": "#6EA8FE"
"color": "#7C6FF2"
},
{
"name": "GH_EnterpriseTeam",
Expand Down Expand Up @@ -180,23 +180,63 @@
"description": "An organization-level GitHub Actions variable that can be scoped to all, private, or selected repositories. Unlike secrets, variable values are readable.",
"is_display_kind": true,
"icon": "lock-open",
"color": "#E8B84D"
"color": "#78D98B"
},
{
"name": "GH_RepoVariable",
"display_name": "GitHub Repo Variable",
"description": "A repository-level GitHub Actions variable accessible only to workflows in that repository. Unlike secrets, variable values are readable.",
"is_display_kind": true,
"icon": "lock-open",
"color": "#E89B5C"
"color": "#66C7E8"
},
{
"name": "GH_Secret",
"display_name": "GitHub Secret",
"description": "Generic label applied to GitHub secret nodes across organization, repository, and environment scope",
"is_display_kind": false
},
{
"name": "GH_Variable",
"display_name": "GitHub Variable",
"description": "Generic label applied to GitHub variable nodes across organization, repository, and environment scope",
"is_display_kind": false
},
{
"name": "GH_RunnerGroup",
"display_name": "GitHub Runner Group",
"description": "A GitHub self-hosted runner group that controls runner access and visibility",
"description": "Generic label applied to GitHub self-hosted runner group nodes across enterprise and organization scope",
"is_display_kind": false
},
{
"name": "GH_Runner",
"display_name": "GitHub Runner",
"description": "Generic label applied to GitHub self-hosted runner nodes across enterprise, organization, and repository scope",
"is_display_kind": false
},
{
"name": "GH_EnterpriseRunnerGroup",
"display_name": "GitHub Enterprise Runner Group",
"description": "An enterprise-scoped GitHub self-hosted runner group that controls runner access and visibility",
"is_display_kind": true,
"icon": "server",
"color": "#94A3B8"
"color": "#9A8CF5"
},
{
"name": "GH_OrgRunnerGroup",
"display_name": "GitHub Org Runner Group",
"description": "An organization-scoped GitHub self-hosted runner group that controls runner access and visibility",
"is_display_kind": true,
"icon": "server",
"color": "#7BE89A"
},
{
"name": "GH_EnterpriseRunner",
"display_name": "GitHub Enterprise Runner",
"description": "An enterprise-scoped GitHub self-hosted runner available to organizations through enterprise runner groups",
"is_display_kind": true,
"icon": "microchip",
"color": "#5F54D9"
},
{
"name": "GH_OrgRunner",
Expand All @@ -220,7 +260,7 @@
"description": "An environment-level GitHub Actions variable scoped to a specific deployment environment. Unlike secrets, variable values are readable.",
"is_display_kind": true,
"icon": "lock-open",
"color": "#D4A84B"
"color": "#A7D77B"
},
{
"name": "GH_SecretScanningAlert",
Expand Down Expand Up @@ -402,7 +442,17 @@
},
{
"name": "GH_AssignedTo",
"description": "Enterprise team is assigned to an organization",
"description": "Enterprise-scoped team or runner group is assigned to an organization",
"is_traversable": false
},
{
"name": "GH_InheritedFrom",
"description": "Organization runner group is inherited from an enterprise runner group",
"is_traversable": false
},
{
"name": "GH_GrantsAccessTo",
"description": "Organization runner group grants a repository access to its available runners",
"is_traversable": false
},
{
Expand Down Expand Up @@ -917,7 +967,7 @@
},
{
"name": "GH_CanUseRunner",
"description": "Repository can dispatch jobs to this runner",
"description": "Repository can dispatch jobs to this self-hosted runner",
"is_traversable": false
},
{
Expand Down Expand Up @@ -1008,6 +1058,13 @@
"principal_kinds": [
"GH_User"
]
},
{
"environment_kind": "GH_Enterprise",
"source_kind": "GitHub",
"principal_kinds": [
"GH_User"
]
}
],
"relationship_findings": []
Expand Down
2 changes: 2 additions & 0 deletions src/openhound_github/kinds/edges.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Generic
CONTAINS = "GH_Contains"
ASSIGNED_TO = "GH_AssignedTo"
INHERITED_FROM = "GH_InheritedFrom"
GRANTS_ACCESS_TO = "GH_GrantsAccessTo"

# Administrative edges
ADMIN_TO = "GH_AdminTo"
Expand Down
3 changes: 3 additions & 0 deletions src/openhound_github/kinds/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@
# Runner nodes
RUNNER = "GH_Runner"
RUNNER_GROUP = "GH_RunnerGroup"
ENTERPRISE_RUNNER = "GH_EnterpriseRunner"
ENTERPRISE_RUNNER_GROUP = "GH_EnterpriseRunnerGroup"
ORG_RUNNER = "GH_OrgRunner"
ORG_RUNNER_GROUP = "GH_OrgRunnerGroup"

# Security nodes
SECRET_SCANNING_ALERT = "GH_SecretScanningAlert"
Expand Down
80 changes: 80 additions & 0 deletions src/openhound_github/lookup.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
from duckdb import DuckDBPyConnection
from openhound.core.lookup import LookupManager, logger

from openhound_github.runner_ids import runner_group_node_id, runner_node_id


class GithubLookup(LookupManager):
def __init__(self, client: DuckDBPyConnection, schema: str = "github"):
Expand Down Expand Up @@ -51,6 +53,84 @@ def enterprise_id(self) -> str | None:
res = self._find_single_object(f"""SELECT id FROM {self.schema}.enterprise""")
return res

@lru_cache
def enterprise_organization_node_ids(self, enterprise_node_id: str):
return self._find_all_objects(
f"""SELECT id FROM {self.schema}.enterprise_organizations WHERE enterprise_node_id = ?""",
[enterprise_node_id],
)

@lru_cache
def _enterprise_runner_group_identity_for_inherited_org_group(
self, org_node_id: str, group_name: str
) -> tuple[str, int] | None:
rows = self._find_all_objects(
f"""
WITH candidate_groups AS (
SELECT erg.enterprise_node_id, erg.id
FROM {self.schema}.enterprise_runner_groups erg
JOIN {self.schema}.enterprise_organizations eo
ON eo.enterprise_node_id = erg.enterprise_node_id
WHERE eo.id = ?
AND erg.visibility = 'all'
AND erg.name = ?

UNION

SELECT erg.enterprise_node_id, erg.id
FROM {self.schema}.enterprise_runner_groups erg
JOIN {self.schema}.enterprise_runner_group_organizations ergo
ON ergo.enterprise_node_id = erg.enterprise_node_id
AND ergo.runner_group_id = erg.id
WHERE ergo.node_id = ?
AND erg.name = ?
)
SELECT enterprise_node_id, id
FROM candidate_groups
""",
[org_node_id, group_name, org_node_id, group_name],
)
if not rows or len(rows) != 1:
return None

enterprise_node_id, runner_group_id = rows[0]
return str(enterprise_node_id), int(runner_group_id)

@lru_cache
def enterprise_runner_group_node_id_for_inherited_org_group(
self, org_node_id: str, group_name: str
) -> str | None:
identity = self._enterprise_runner_group_identity_for_inherited_org_group(
org_node_id, group_name
)
if not identity:
return None

enterprise_node_id, runner_group_id = identity
return runner_group_node_id(enterprise_node_id, runner_group_id)

@lru_cache
def enterprise_runner_node_ids_for_inherited_org_group(
self, org_node_id: str, group_name: str
):
identity = self._enterprise_runner_group_identity_for_inherited_org_group(
org_node_id, group_name
)
if not identity:
return []

enterprise_node_id, runner_group_id = identity
rows = self._find_all_objects(
f"""
SELECT runner_id
FROM {self.schema}.enterprise_runner_group_memberships
WHERE enterprise_node_id = ?
AND runner_group_id = ?
""",
[enterprise_node_id, runner_group_id],
)
return [(runner_node_id(enterprise_node_id, int(runner_id)),) for (runner_id,) in rows]

@lru_cache
def enterprise_idp_for_scope(
self, enterprise_node_id: str
Expand Down
4 changes: 4 additions & 0 deletions src/openhound_github/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ def preproc(ctx: PreProcContext):
"saml_provider": "saml_provider",
"applications": "applications",
"enterprise": "enterprise",
"enterprise_organizations": "enterprise_organizations",
"enterprise_runner_groups": "enterprise_runner_groups",
"enterprise_runner_group_organizations": "enterprise_runner_group_organizations",
"enterprise_runner_group_memberships": "enterprise_runner_group_memberships",
"org_roles": "org_roles",
"projected_enterprise_teams": "projected_enterprise_teams",
"environments": "environments",
Expand Down
19 changes: 17 additions & 2 deletions src/openhound_github/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,17 @@
from .repository_role import BaseRepoRole, RepoRole
from .repository_secret import RepoSecret
from .repository_variable import RepoVariable
from .runner import OrgRunner, OrgRunnerGroupMembership, RepoRunner, RunnerGroup
from .runner import (
EnterpriseRunner,
EnterpriseRunnerGroup,
EnterpriseRunnerGroupMembership,
EnterpriseRunnerGroupOrganization,
OrgRunner,
OrgRunnerGroup,
OrgRunnerGroupAccess,
OrgRunnerGroupMembership,
RepoRunner,
)
Comment thread
jaredcatkinson marked this conversation as resolved.
from .saml_assertion_consumer_service import SamlAssertionConsumerService
from .saml_provider import SamlProvider
from .saml_service_provider import SamlServiceProvider
Expand Down Expand Up @@ -79,8 +89,13 @@
"SelectedOrgVariable",
"RepoSecret",
"RepoVariable",
"RunnerGroup",
"EnterpriseRunner",
"EnterpriseRunnerGroup",
"EnterpriseRunnerGroupMembership",
"EnterpriseRunnerGroupOrganization",
"OrgRunner",
"OrgRunnerGroup",
"OrgRunnerGroupAccess",
"OrgRunnerGroupMembership",
"RepoRunner",
"SecretScanningAlert",
Expand Down
Loading