From 5938e29613eff148c628cd308e3df54f871b565a Mon Sep 17 00:00:00 2001 From: Jared Atkinson Date: Sat, 5 Sep 2026 21:28:10 -0700 Subject: [PATCH 1/9] BED-9674: link workflow jobs to eligible runners --- descriptions/edges/GH_RunsOn.md | 7 + descriptions/nodes/GH_EnterpriseRunner.md | 2 + descriptions/nodes/GH_OrgRunner.md | 2 + descriptions/nodes/GH_RepoRunner.md | 2 + descriptions/nodes/GH_WorkflowJob.md | 2 + extension/schema.json | 5 + src/openhound_github/kinds/edges.py | 1 + src/openhound_github/lookup.py | 199 ++++++++++++++++++++ src/openhound_github/main.py | 6 + src/openhound_github/models/runner.py | 5 + src/openhound_github/models/workflow.py | 52 +++++ src/openhound_github/models/workflow_job.py | 90 ++++++--- src/openhound_github/transforms.py | 87 +++++++++ tests/test_runner_models.py | 126 +++++++++++++ tests/test_workflow_model.py | 147 +++++++++++++-- 15 files changed, 689 insertions(+), 44 deletions(-) create mode 100644 descriptions/edges/GH_RunsOn.md diff --git a/descriptions/edges/GH_RunsOn.md b/descriptions/edges/GH_RunsOn.md new file mode 100644 index 0000000..10648b6 --- /dev/null +++ b/descriptions/edges/GH_RunsOn.md @@ -0,0 +1,7 @@ +## General Information + +The non-traversable GH_RunsOn edge represents that a GitHub Actions workflow job can be scheduled on a self-hosted runner based on the job's statically declared `runs-on` selector and the runner topology visible to the containing repository. + +This edge is schedulability evidence, not historical execution evidence. It does not mean that the job has previously executed on the runner. It means that the runner satisfies the job's static label and runner-group requirements and is reachable through the repository's current runner access policy. + +The collector emits GH_RunsOn only for static selectors. Dynamic selectors that contain GitHub Actions expressions such as `${{ matrix.runner }}` or `${{ inputs.runner }}` are intentionally left unresolved in this first implementation. diff --git a/descriptions/nodes/GH_EnterpriseRunner.md b/descriptions/nodes/GH_EnterpriseRunner.md index 6de69ff..f1d5291 100644 --- a/descriptions/nodes/GH_EnterpriseRunner.md +++ b/descriptions/nodes/GH_EnterpriseRunner.md @@ -3,3 +3,5 @@ Represents a self-hosted runner owned at the GitHub Enterprise level. Enterprise runners are contained by GH_EnterpriseRunnerGroup nodes and exposed through GH_HasRunner. Repositories become eligible for the organization-facing runner group through GH_IsEligibleFor. Repositories and branches that can dispatch workflows then reach the runner through GH_CanUseRunner to an inherited GH_OrgRunnerGroup, GH_InheritedFrom to the enterprise group, and finally GH_HasRunner to the runner. The node captures runner metadata such as operating system, status, busy state, labels, and whether the runner is ephemeral when GitHub returns that property. + +GH_RunsOn edges from GH_WorkflowJob nodes identify statically resolvable jobs that GitHub could schedule on this runner through the inherited enterprise runner-group topology. These edges do not indicate that the job has actually executed on the runner. diff --git a/descriptions/nodes/GH_OrgRunner.md b/descriptions/nodes/GH_OrgRunner.md index 2cf50c3..f94363b 100644 --- a/descriptions/nodes/GH_OrgRunner.md +++ b/descriptions/nodes/GH_OrgRunner.md @@ -3,3 +3,5 @@ Represents a self-hosted runner owned by a GitHub organization. Organization runners are contained by native GH_OrgRunnerGroup nodes and exposed through GH_HasRunner. Repositories become eligible for those groups through GH_IsEligibleFor, while repositories and branches that can dispatch workflows to them are linked through GH_CanUseRunner. The node captures runner metadata such as operating system, status, busy state, labels, and whether the runner is ephemeral when GitHub returns that property. + +GH_RunsOn edges from GH_WorkflowJob nodes identify statically resolvable jobs that GitHub could schedule on this runner under the current runner-group access policy. These edges do not indicate that the job has actually executed on the runner. diff --git a/descriptions/nodes/GH_RepoRunner.md b/descriptions/nodes/GH_RepoRunner.md index f135f63..61077d4 100644 --- a/descriptions/nodes/GH_RepoRunner.md +++ b/descriptions/nodes/GH_RepoRunner.md @@ -3,3 +3,5 @@ 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. + +GH_RunsOn edges from GH_WorkflowJob nodes identify statically resolvable jobs in the containing repository that GitHub could schedule on this runner. These edges do not indicate that the job has actually executed on the runner. diff --git a/descriptions/nodes/GH_WorkflowJob.md b/descriptions/nodes/GH_WorkflowJob.md index b375adf..87a55cc 100644 --- a/descriptions/nodes/GH_WorkflowJob.md +++ b/descriptions/nodes/GH_WorkflowJob.md @@ -1,3 +1,5 @@ ## Description Represents a single job within a GitHub Actions workflow. Jobs are the top-level execution units of a workflow — they run on a runner, hold a set of steps, and can declare permissions, environments, and dependencies on other jobs. + +When the job has a statically resolvable self-hosted `runs-on` selector, GH_RunsOn edges identify each GH_Runner that currently satisfies the declared label and runner-group constraints under the repository's runner access policy. These edges represent schedulability, not historical execution. diff --git a/extension/schema.json b/extension/schema.json index c74a4d5..c0c67a9 100644 --- a/extension/schema.json +++ b/extension/schema.json @@ -1030,6 +1030,11 @@ "description": "[Workflow] Job deploys to a GitHub Environment — GH_WorkflowJob → GH_Environment", "is_traversable": false }, + { + "name": "GH_RunsOn", + "description": "[Workflow] Job can be scheduled on this self-hosted runner based on its static runs-on selector — GH_WorkflowJob → GH_Runner", + "is_traversable": false + }, { "name": "GH_HasMember", "description": "Enterprise or organization has this user as a member", diff --git a/src/openhound_github/kinds/edges.py b/src/openhound_github/kinds/edges.py index 9f717eb..a87d2d0 100644 --- a/src/openhound_github/kinds/edges.py +++ b/src/openhound_github/kinds/edges.py @@ -18,6 +18,7 @@ # Access and capability edges CAN_ACCESS = "GH_CanAccess" CAN_USE_RUNNER = "GH_CanUseRunner" +RUNS_ON = "GH_RunsOn" IS_ELIGIBLE_FOR = "GH_IsEligibleFor" CAN_CREATE_REPOSITORY_WITH_RUNNER_ACCESS = "GH_CanCreateRepositoryWithRunnerAccess" CAN_CREATE_BRANCH = "GH_CanCreateBranch" diff --git a/src/openhound_github/lookup.py b/src/openhound_github/lookup.py index 6783e05..b5e970e 100644 --- a/src/openhound_github/lookup.py +++ b/src/openhound_github/lookup.py @@ -1,6 +1,7 @@ import json import re from functools import lru_cache +from typing import Any import duckdb from duckdb import DuckDBPyConnection @@ -167,6 +168,204 @@ def enterprise_runner_node_ids_for_inherited_org_group( ) return [(runner_node_id(enterprise_node_id, int(runner_id)),) for (runner_id,) in rows] + @staticmethod + def _json_list(raw_value: Any) -> list[Any]: + if raw_value is None: + return [] + if isinstance(raw_value, str): + try: + raw_value = json.loads(raw_value) + except json.JSONDecodeError: + return [] + if isinstance(raw_value, list): + return raw_value + return [] + + @classmethod + def _runner_label_names(cls, raw_labels: Any) -> set[str]: + names: set[str] = set() + for label in cls._json_list(raw_labels): + if isinstance(label, dict): + name = label.get("name") + else: + name = label + if name is not None: + names.add(str(name).casefold()) + return names + + @staticmethod + def _runner_group_allows_repository( + *, + repository_node_id: str, + repository_visibility: str | None, + runner_group_visibility: str | None, + allows_public_repositories: bool | None, + accessible_repo_node_ids: set[str], + ) -> bool: + if runner_group_visibility == "all": + in_scope = True + elif runner_group_visibility == "private": + in_scope = repository_visibility in {"private", "internal"} + else: + in_scope = repository_node_id in accessible_repo_node_ids + + if not in_scope: + return False + + if allows_public_repositories is False: + return repository_visibility in {"private", "internal"} + + return True + + @lru_cache + def workflow_job_runner_node_ids( + self, + repository_node_id: str, + org_login: str, + group_name: str | None, + labels: tuple[str, ...], + ) -> list[str]: + """Return accessible self-hosted runners matching a static runs-on selector.""" + if not group_name and not labels: + return [] + + repository = self._find_single_row( + f""" + SELECT visibility, actions_enabled + FROM {self.schema}.repositories + WHERE node_id = ? + AND org_login = ? + """, + [repository_node_id, org_login], + ) + if not repository: + return [] + + repository_visibility, actions_enabled = repository + required_labels = {str(label).casefold() for label in labels} + matching_runner_node_ids: list[str] = [] + seen_runner_node_ids: set[str] = set() + + def add_matching_runner(node_id: str, raw_labels: Any) -> None: + if node_id in seen_runner_node_ids: + return + if not required_labels.issubset(self._runner_label_names(raw_labels)): + return + seen_runner_node_ids.add(node_id) + matching_runner_node_ids.append(node_id) + + if group_name is None: + for runner_id, raw_labels in self._find_all_objects( + f""" + SELECT id, labels + FROM {self.schema}.repo_runners + WHERE repository_node_id = ? + """, + [repository_node_id], + ): + add_matching_runner( + runner_node_id(repository_node_id, int(runner_id)), + raw_labels, + ) + + if actions_enabled is not True: + return matching_runner_node_ids + + for ( + runner_group_id, + runner_group_name, + runner_group_visibility, + allows_public_repositories, + restricted_to_workflows, + inherited, + raw_accessible_repo_node_ids, + ) in self._find_all_objects( + f""" + SELECT + runner_group_id, + runner_group_name, + runner_group_visibility, + allows_public_repositories, + restricted_to_workflows, + inherited, + accessible_repo_node_ids + FROM {self.schema}.org_runner_group_access + WHERE org_login = ? + """, + [org_login], + ): + if group_name is not None and runner_group_name != group_name: + continue + if restricted_to_workflows is not False: + continue + + accessible_repo_node_ids = { + str(node_id) + for node_id in self._json_list(raw_accessible_repo_node_ids) + } + if not self._runner_group_allows_repository( + repository_node_id=repository_node_id, + repository_visibility=repository_visibility, + runner_group_visibility=runner_group_visibility, + allows_public_repositories=allows_public_repositories, + accessible_repo_node_ids=accessible_repo_node_ids, + ): + continue + + if inherited: + org_node_id = self.org_id_for_login(org_login) + if not org_node_id: + continue + if ( + self.enterprise_runner_group_restricted_to_workflows_for_inherited_org_group( + org_node_id, runner_group_name + ) + is not False + ): + continue + identity = self._enterprise_runner_group_identity_for_inherited_org_group( + org_node_id, runner_group_name + ) + if not identity: + continue + enterprise_node_id, enterprise_runner_group_id = identity + for runner_id, raw_labels in self._find_all_objects( + f""" + SELECT r.id, r.labels + FROM {self.schema}.enterprise_runner_group_memberships m + JOIN {self.schema}.enterprise_runners r + ON r.enterprise_node_id = m.enterprise_node_id + AND r.id = m.runner_id + WHERE m.enterprise_node_id = ? + AND m.runner_group_id = ? + """, + [enterprise_node_id, enterprise_runner_group_id], + ): + add_matching_runner( + runner_node_id(enterprise_node_id, int(runner_id)), + raw_labels, + ) + continue + + for runner_id, raw_labels in self._find_all_objects( + f""" + SELECT r.id, r.labels + FROM {self.schema}.org_runner_group_memberships m + JOIN {self.schema}.org_runners r + ON r.org_login = m.org_login + AND r.id = m.runner_id + WHERE m.org_login = ? + AND m.runner_group_id = ? + """, + [org_login, runner_group_id], + ): + add_matching_runner( + runner_node_id(self.org_id_for_login(org_login), int(runner_id)), + raw_labels, + ) + + return matching_runner_node_ids + @lru_cache def enterprise_idp_for_scope( self, enterprise_node_id: str diff --git a/src/openhound_github/main.py b/src/openhound_github/main.py index b36636e..3775e59 100644 --- a/src/openhound_github/main.py +++ b/src/openhound_github/main.py @@ -72,6 +72,12 @@ def preproc(ctx: PreProcContext): "enterprise_runner_groups": "enterprise_runner_groups", "enterprise_runner_group_organizations": "enterprise_runner_group_organizations", "enterprise_runner_group_memberships": "enterprise_runner_group_memberships", + "enterprise_runners": "enterprise_runners", + "runner_groups": "runner_groups", + "org_runners": "org_runners", + "org_runner_group_access": "org_runner_group_access", + "org_runner_group_memberships": "org_runner_group_memberships", + "repo_runners": "repo_runners", "org_roles": "org_roles", "org_role_members": "org_role_members", "org_role_teams": "org_role_teams", diff --git a/src/openhound_github/models/runner.py b/src/openhound_github/models/runner.py index a810ae0..172a905 100644 --- a/src/openhound_github/models/runner.py +++ b/src/openhound_github/models/runner.py @@ -316,6 +316,7 @@ class GHRunnerProperties(GHNodeProperties): environment_name: The name of the environment (GitHub organization). query_group: Query for group. query_repositories: Query for repositories. + query_jobs: Query for workflow jobs that can be scheduled on the runner. """ scope: str | None = None @@ -334,6 +335,7 @@ class GHRunnerProperties(GHNodeProperties): environment_name: str | None = None query_group: str | None = None query_repositories: str | None = None + query_jobs: str | None = None @app.asset( @@ -384,6 +386,7 @@ def as_node(self) -> GHNode: environmentid=self.org_node_id, query_group=f"MATCH p=(:GH_OrgRunnerGroup)-[:GH_HasRunner]->(:GH_OrgRunner {{node_id:'{rid}'}}) RETURN p", query_repositories=f"MATCH p=(:GH_Repository)-[:GH_CanUseRunner]->(:GH_OrgRunnerGroup)-[:GH_HasRunner]->(:GH_OrgRunner {{node_id:'{rid}'}}) RETURN p", + query_jobs=f"MATCH p=(:GH_WorkflowJob)-[:GH_RunsOn]->(:GH_Runner {{node_id:'{rid}'}}) RETURN p", ), ) @@ -437,6 +440,7 @@ def as_node(self) -> GHNode: environmentid=self.enterprise_node_id, query_group=f"MATCH p=(:GH_EnterpriseRunnerGroup)-[:GH_HasRunner]->(:GH_EnterpriseRunner {{node_id:'{rid}'}}) RETURN p", query_repositories=f"MATCH p=(:GH_Repository)-[:GH_CanUseRunner]->(:GH_OrgRunnerGroup)-[:GH_InheritedFrom]->(:GH_EnterpriseRunnerGroup)-[:GH_HasRunner]->(:GH_EnterpriseRunner {{node_id:'{rid}'}}) RETURN p", + query_jobs=f"MATCH p=(:GH_WorkflowJob)-[:GH_RunsOn]->(:GH_Runner {{node_id:'{rid}'}}) RETURN p", ), ) @@ -873,6 +877,7 @@ def as_node(self) -> GHNode: environment_name=self.org_login, environmentid=self.org_node_id, query_repositories=f"MATCH p=(:GH_Repository {{node_id:'{self.repository_node_id}'}})-[:GH_CanUseRunner]->(:GH_RepoRunner {{node_id:'{rid}'}}) RETURN p", + query_jobs=f"MATCH p=(:GH_WorkflowJob)-[:GH_RunsOn]->(:GH_Runner {{node_id:'{rid}'}}) RETURN p", ), ) diff --git a/src/openhound_github/models/workflow.py b/src/openhound_github/models/workflow.py index ace1a1b..29543bb 100644 --- a/src/openhound_github/models/workflow.py +++ b/src/openhound_github/models/workflow.py @@ -43,6 +43,7 @@ class GithubActionsLoader(yaml.SafeLoader): VARIABLE_REFERENCE_RE = re.compile(r"\$\{\{\s*vars\.(\w+)\s*\}\}") ACTION_RE = re.compile(r"^(?P[^/]+)/(?P[^@]+)@(?P.+)$") PINNED_REF_RE = re.compile(r"^[0-9a-f]{40}$") +TEMPLATE_RE = re.compile(r"\$\{\{\s*[^}]+?\s*\}\}") class WorkflowStepDefinition(BaseModel): @@ -94,6 +95,49 @@ class RunsOn(BaseModel): labels: list[str] | str | None = None +class RunsOnSelector(BaseModel): + group: str | None = None + labels: list[str] = Field(default_factory=list) + is_dynamic: bool = False + + +def parse_runs_on_selector(value: Any) -> RunsOnSelector: + """Normalize a workflow job's runs-on declaration without losing group data.""" + if value is None: + return RunsOnSelector() + + if isinstance(value, RunsOn): + value = value.model_dump() + + group: str | None = None + labels: list[str] = [] + + if isinstance(value, str): + labels = [value] + elif isinstance(value, list): + labels = [str(item) for item in value] + elif isinstance(value, dict): + raw_group = value.get("group") + if raw_group is not None: + group = str(raw_group) + + raw_labels = value.get("labels") + if isinstance(raw_labels, str): + labels = [raw_labels] + elif isinstance(raw_labels, list): + labels = [str(item) for item in raw_labels] + elif raw_labels is not None: + labels = [str(raw_labels)] + else: + labels = [str(value)] + + is_dynamic = any(TEMPLATE_RE.search(item) for item in labels) + if group: + is_dynamic = is_dynamic or bool(TEMPLATE_RE.search(group)) + + return RunsOnSelector(group=group, labels=labels, is_dynamic=is_dynamic) + + class WorkflowJobDefinition(BaseModel): model_config = ConfigDict(extra="allow", populate_by_name=True) @@ -149,6 +193,10 @@ def environment_name(self) -> str | None: def container_value(self) -> str | None: return str(self.container) if self.container else None + @property + def runs_on_selector(self) -> RunsOnSelector: + return parse_runs_on_selector(self.runs_on) + class WorkflowDocument(BaseModel): model_config = ConfigDict(extra="allow") @@ -498,6 +546,7 @@ def workflow_job_rows(self) -> list[dict[str, Any]]: } rows = [] for job_key, job in document.jobs.items(): + runs_on_selector = job.runs_on_selector secret_refs = [] variable_refs = [] secret_refs.extend( @@ -514,6 +563,9 @@ def workflow_job_rows(self) -> list[dict[str, Any]]: "name": f"{self.repository_name}\\{job_key}", "job_key": job_key, "runs_on": job.runs_on, + "runs_on_group": runs_on_selector.group, + "runs_on_labels": runs_on_selector.labels or None, + "runs_on_is_dynamic": runs_on_selector.is_dynamic, "container": job.container_value, "environment": job.environment_name, "permissions": job.permissions diff --git a/src/openhound_github/models/workflow_job.py b/src/openhound_github/models/workflow_job.py index cb42a04..8be8629 100644 --- a/src/openhound_github/models/workflow_job.py +++ b/src/openhound_github/models/workflow_job.py @@ -15,13 +15,13 @@ EdgeProperties, PropertyMatch, ) -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field, field_validator, model_validator from openhound_github.graph import GHNode, GHNodeProperties from openhound_github.kinds import edges as ek from openhound_github.kinds import nodes as nk from openhound_github.main import app -from openhound_github.models.workflow import RunsOn +from openhound_github.models.workflow import parse_runs_on_selector TEMPLATE_RE = re.compile(r"\$\{\{\s*[^}]+?\s*\}\}") @@ -38,6 +38,9 @@ class GHWorkflowJobProperties(GHNodeProperties): Attributes: job_key: The YAML key for the job. runs_on: The runner label expression for the job. + runs_on_group: The statically declared runner group, if any. + runs_on_labels: The normalized runner labels from runs-on. + runs_on_is_dynamic: Whether runs-on contains a GitHub Actions expression. is_self_hosted: Whether the job targets self-hosted runners. container: The optional container configuration. environment: The deployment environment name. @@ -50,10 +53,14 @@ class GHWorkflowJobProperties(GHNodeProperties): query_repository: Query for repository. query_steps: Query for workflow steps. query_references: Query for workflow references (secrets and variables). + query_runners: Query for eligible self-hosted runners. """ job_key: str | None = None runs_on: list[str] | None = None + runs_on_group: str | None = None + runs_on_labels: list[str] | None = None + runs_on_is_dynamic: bool = False is_self_hosted: bool = False container: str | None = None environment: str | None = None @@ -66,6 +73,7 @@ class GHWorkflowJobProperties(GHNodeProperties): query_repository: str | None = None query_steps: str | None = None query_references: str | None = None + query_runners: str | None = None @app.asset( @@ -146,6 +154,13 @@ class GHWorkflowJobProperties(GHNodeProperties): description="Workflow job references environment secret", traversable=False, ), + EdgeDef( + start=nk.WORKFLOW_JOB, + end=nk.RUNNER, + kind=ek.RUNS_ON, + description="Workflow job can be scheduled on self-hosted runner", + traversable=False, + ), ], ) class WorkflowJob(BaseAsset): @@ -161,6 +176,9 @@ class WorkflowJob(BaseAsset): repository_node_id: str org_login: str runs_on: list[str] | None = None + runs_on_group: str | None = None + runs_on_labels: list[str] | None = None + runs_on_is_dynamic: bool = False container: str | None = None environment: str | None = None permissions: list[str] | None = None @@ -190,39 +208,30 @@ def normalize_permissions(cls, value: Any) -> list[str] | None: return [str(value)] - @field_validator("runs_on", mode="before") + @model_validator(mode="before") @classmethod - def normalize_runs_on(cls, value: Any) -> list[str] | None: - if value is None: - return None - - if isinstance(value, str): - return [value] - - if isinstance(value, list): - return [str(item) for item in value] - - if isinstance(value, RunsOn): - value = value.model_dump() + def populate_runs_on_selector_fields(cls, value: Any) -> Any: + if not isinstance(value, dict): + return value - if isinstance(value, dict): - labels = value.get("labels") - if labels is None: - return None - - if isinstance(labels, str): - return [labels] - - if isinstance(labels, list): - return [str(item) for item in labels] - - return [str(labels)] + selector = parse_runs_on_selector(value.get("runs_on")) + normalized = dict(value) + normalized.setdefault("runs_on_group", selector.group) + normalized.setdefault("runs_on_labels", selector.labels or None) + normalized.setdefault("runs_on_is_dynamic", selector.is_dynamic) + return normalized - return [str(value)] + @field_validator("runs_on", mode="before") + @classmethod + def normalize_runs_on(cls, value: Any) -> list[str] | None: + labels = parse_runs_on_selector(value).labels + return labels or None @property def is_self_hosted(self) -> bool: - return "self-hosted" in (self.runs_on or []) + return bool(self.runs_on_group) or "self-hosted" in ( + self.runs_on_labels or self.runs_on or [] + ) @property def as_node(self) -> GHNode: @@ -235,6 +244,9 @@ def as_node(self) -> GHNode: node_id=self.node_id, job_key=self.job_key, runs_on=self.runs_on, + runs_on_group=self.runs_on_group, + runs_on_labels=self.runs_on_labels, + runs_on_is_dynamic=self.runs_on_is_dynamic, is_self_hosted=self.is_self_hosted, container=self.container, environment=self.environment, @@ -248,6 +260,7 @@ def as_node(self) -> GHNode: query_repository=f"MATCH p=(repo:GH_Repository)-[:GH_Contains]->(:GH_Workflow)-[:GH_Contains]->(:GH_WorkflowJob {{node_id:'{jid}'}}) RETURN p", query_steps=f"MATCH p=(:GH_WorkflowJob {{node_id:'{jid}'}})-[:GH_Contains]->(:GH_WorkflowStep) RETURN p", query_references=f"MATCH p=(:GH_WorkflowJob {{node_id:'{jid}'}})-[:GH_Contains]->(step:GH_WorkflowStep) OPTIONAL MATCH p1=(step)-[:GH_UsesSecret]->() OPTIONAL MATCH p2=(step)-[:GH_UsesVariable]->() RETURN p,p1,p2", + query_runners=f"MATCH p=(:GH_WorkflowJob {{node_id:'{jid}'}})-[:GH_RunsOn]->(:GH_Runner) RETURN p", ), ) @@ -425,6 +438,24 @@ def _calls_workflows_edge(self): properties=EdgeProperties(traversable=False), ) + @property + def _runs_on_edges(self): + if self.runs_on_is_dynamic: + return + + for runner_node_id in self._lookup.workflow_job_runner_node_ids( + self.repository_node_id, + self.org_login, + self.runs_on_group, + tuple(self.runs_on_labels or ()), + ): + yield Edge( + kind=ek.RUNS_ON, + start=EdgePath(value=self.node_id, match_by="id"), + end=EdgePath(value=runner_node_id, match_by="id"), + properties=EdgeProperties(traversable=False), + ) + @property def edges(self): yield from self._calls_workflows_edge @@ -433,3 +464,4 @@ def edges(self): yield from self._has_job_edge yield from self._uses_secret_edges yield from self._uses_variable_edges + yield from self._runs_on_edges diff --git a/src/openhound_github/transforms.py b/src/openhound_github/transforms.py index 4aa3bee..b070d83 100644 --- a/src/openhound_github/transforms.py +++ b/src/openhound_github/transforms.py @@ -137,6 +137,41 @@ def ensure_optional_input_tables( runner_id BIGINT, enterprise_node_id VARCHAR ); + CREATE TABLE IF NOT EXISTS {schema}.enterprise_runners ( + id BIGINT, + labels JSON, + enterprise_node_id VARCHAR + ); + CREATE TABLE IF NOT EXISTS {schema}.runner_groups ( + id BIGINT, + name VARCHAR, + org_login VARCHAR + ); + CREATE TABLE IF NOT EXISTS {schema}.org_runners ( + id BIGINT, + labels JSON, + org_login VARCHAR + ); + CREATE TABLE IF NOT EXISTS {schema}.org_runner_group_access ( + runner_group_id BIGINT, + runner_group_name VARCHAR, + runner_group_visibility VARCHAR, + allows_public_repositories BOOLEAN, + restricted_to_workflows BOOLEAN, + inherited BOOLEAN, + accessible_repo_node_ids JSON, + org_login VARCHAR + ); + CREATE TABLE IF NOT EXISTS {schema}.org_runner_group_memberships ( + runner_group_id BIGINT, + runner_id BIGINT, + org_login VARCHAR + ); + CREATE TABLE IF NOT EXISTS {schema}.repo_runners ( + id BIGINT, + labels JSON, + repository_node_id VARCHAR + ); """) con.execute(f""" ALTER TABLE {schema}.branches @@ -300,6 +335,58 @@ def ensure_optional_input_tables( ADD COLUMN IF NOT EXISTS runner_id BIGINT; ALTER TABLE {schema}.enterprise_runner_group_memberships ADD COLUMN IF NOT EXISTS enterprise_node_id VARCHAR; + + ALTER TABLE {schema}.enterprise_runners + ADD COLUMN IF NOT EXISTS id BIGINT; + ALTER TABLE {schema}.enterprise_runners + ADD COLUMN IF NOT EXISTS labels JSON; + ALTER TABLE {schema}.enterprise_runners + ADD COLUMN IF NOT EXISTS enterprise_node_id VARCHAR; + + ALTER TABLE {schema}.runner_groups + ADD COLUMN IF NOT EXISTS id BIGINT; + ALTER TABLE {schema}.runner_groups + ADD COLUMN IF NOT EXISTS name VARCHAR; + ALTER TABLE {schema}.runner_groups + ADD COLUMN IF NOT EXISTS org_login VARCHAR; + + ALTER TABLE {schema}.org_runners + ADD COLUMN IF NOT EXISTS id BIGINT; + ALTER TABLE {schema}.org_runners + ADD COLUMN IF NOT EXISTS labels JSON; + ALTER TABLE {schema}.org_runners + ADD COLUMN IF NOT EXISTS org_login VARCHAR; + + ALTER TABLE {schema}.org_runner_group_access + ADD COLUMN IF NOT EXISTS runner_group_id BIGINT; + ALTER TABLE {schema}.org_runner_group_access + ADD COLUMN IF NOT EXISTS runner_group_name VARCHAR; + ALTER TABLE {schema}.org_runner_group_access + ADD COLUMN IF NOT EXISTS runner_group_visibility VARCHAR; + ALTER TABLE {schema}.org_runner_group_access + ADD COLUMN IF NOT EXISTS allows_public_repositories BOOLEAN; + ALTER TABLE {schema}.org_runner_group_access + ADD COLUMN IF NOT EXISTS restricted_to_workflows BOOLEAN; + ALTER TABLE {schema}.org_runner_group_access + ADD COLUMN IF NOT EXISTS inherited BOOLEAN; + ALTER TABLE {schema}.org_runner_group_access + ADD COLUMN IF NOT EXISTS accessible_repo_node_ids JSON; + ALTER TABLE {schema}.org_runner_group_access + ADD COLUMN IF NOT EXISTS org_login VARCHAR; + + ALTER TABLE {schema}.org_runner_group_memberships + ADD COLUMN IF NOT EXISTS runner_group_id BIGINT; + ALTER TABLE {schema}.org_runner_group_memberships + ADD COLUMN IF NOT EXISTS runner_id BIGINT; + ALTER TABLE {schema}.org_runner_group_memberships + ADD COLUMN IF NOT EXISTS org_login VARCHAR; + + ALTER TABLE {schema}.repo_runners + ADD COLUMN IF NOT EXISTS id BIGINT; + ALTER TABLE {schema}.repo_runners + ADD COLUMN IF NOT EXISTS labels JSON; + ALTER TABLE {schema}.repo_runners + ADD COLUMN IF NOT EXISTS repository_node_id VARCHAR; """) # TODO: diff --git a/tests/test_runner_models.py b/tests/test_runner_models.py index 2418bc4..f21b06f 100644 --- a/tests/test_runner_models.py +++ b/tests/test_runner_models.py @@ -18,6 +18,132 @@ ) +def _workflow_runner_lookup() -> GithubLookup: + connection = duckdb.connect(":memory:") + connection.execute("CREATE SCHEMA github") + connection.execute( + "CREATE TABLE github.organizations (login VARCHAR, node_id VARCHAR)" + ) + connection.execute( + "CREATE TABLE github.repositories (node_id VARCHAR, org_login VARCHAR, visibility VARCHAR, actions_enabled BOOLEAN)" + ) + connection.execute( + "CREATE TABLE github.repo_runners (id BIGINT, labels JSON, repository_node_id VARCHAR)" + ) + connection.execute( + "CREATE TABLE github.org_runners (id BIGINT, labels JSON, org_login VARCHAR)" + ) + connection.execute( + "CREATE TABLE github.org_runner_group_access (runner_group_id BIGINT, runner_group_name VARCHAR, runner_group_visibility VARCHAR, allows_public_repositories BOOLEAN, restricted_to_workflows BOOLEAN, inherited BOOLEAN, accessible_repo_node_ids JSON, org_login VARCHAR)" + ) + connection.execute( + "CREATE TABLE github.org_runner_group_memberships (runner_group_id BIGINT, runner_id BIGINT, org_login VARCHAR)" + ) + connection.execute( + "CREATE TABLE github.enterprise_organizations (id VARCHAR, enterprise_node_id VARCHAR)" + ) + connection.execute( + "CREATE TABLE github.enterprise_runner_groups (id BIGINT, name VARCHAR, visibility VARCHAR, restricted_to_workflows BOOLEAN, enterprise_node_id VARCHAR)" + ) + connection.execute( + "CREATE TABLE github.enterprise_runner_group_organizations (node_id VARCHAR, runner_group_id BIGINT, enterprise_node_id VARCHAR)" + ) + connection.execute( + "CREATE TABLE github.enterprise_runner_group_memberships (runner_group_id BIGINT, runner_id BIGINT, enterprise_node_id VARCHAR)" + ) + connection.execute( + "CREATE TABLE github.enterprise_runners (id BIGINT, labels JSON, enterprise_node_id VARCHAR)" + ) + connection.execute("INSERT INTO github.organizations VALUES ('acme', 'ORG_1')") + connection.execute( + "INSERT INTO github.repositories VALUES ('REPO_1', 'acme', 'private', true), ('REPO_2', 'acme', 'private', true)" + ) + connection.execute( + """INSERT INTO github.repo_runners VALUES + (21, '[{"name":"self-hosted"},{"name":"Linux"},{"name":"X64"}]', 'REPO_1')""" + ) + connection.execute( + """INSERT INTO github.org_runners VALUES + (11, '[{"name":"self-hosted"},{"name":"Linux"},{"name":"X64"}]', 'acme'), + (12, '[{"name":"self-hosted"},{"name":"Linux"},{"name":"ARM64"}]', 'acme'), + (13, '[{"name":"self-hosted"},{"name":"Windows"},{"name":"X64"}]', 'acme')""" + ) + connection.execute( + """INSERT INTO github.org_runner_group_access VALUES + (1, 'Default', 'all', true, false, false, '[]', 'acme'), + (2, 'prod-runners', 'selected', true, false, false, '["REPO_1"]', 'acme'), + (3, 'restricted-runners', 'all', true, true, false, '[]', 'acme'), + (4, 'enterprise-prod', 'selected', true, false, true, '["REPO_1"]', 'acme')""" + ) + connection.execute( + "INSERT INTO github.org_runner_group_memberships VALUES (1, 11, 'acme'), (1, 12, 'acme'), (1, 13, 'acme'), (2, 11, 'acme'), (2, 12, 'acme'), (3, 11, 'acme')" + ) + connection.execute( + "INSERT INTO github.enterprise_organizations VALUES ('ORG_1', 'ENT_1')" + ) + connection.execute( + "INSERT INTO github.enterprise_runner_groups VALUES (4, 'enterprise-prod', 'selected', false, 'ENT_1')" + ) + connection.execute( + "INSERT INTO github.enterprise_runner_group_organizations VALUES ('ORG_1', 4, 'ENT_1')" + ) + connection.execute( + "INSERT INTO github.enterprise_runner_group_memberships VALUES (4, 31, 'ENT_1')" + ) + connection.execute( + """INSERT INTO github.enterprise_runners VALUES + (31, '[{"name":"self-hosted"},{"name":"Linux"},{"name":"X64"}]', 'ENT_1')""" + ) + return GithubLookup(connection) + + +def test_workflow_job_runner_lookup_matches_all_static_labels_across_accessible_runners() -> None: + lookup = _workflow_runner_lookup() + + assert lookup.workflow_job_runner_node_ids( + "REPO_1", "acme", None, ("self-hosted", "linux", "x64") + ) == ["REPO_1_runner_21", "ORG_1_runner_11", "ENT_1_runner_31"] + + +def test_workflow_job_runner_lookup_filters_to_named_group_and_allows_multiple_matches() -> None: + lookup = _workflow_runner_lookup() + + assert lookup.workflow_job_runner_node_ids( + "REPO_1", "acme", "prod-runners", ("self-hosted", "linux") + ) == ["ORG_1_runner_11", "ORG_1_runner_12"] + + +def test_workflow_job_runner_lookup_filters_non_matching_labels_and_unauthorized_groups() -> None: + lookup = _workflow_runner_lookup() + + assert ( + lookup.workflow_job_runner_node_ids( + "REPO_1", "acme", "prod-runners", ("self-hosted", "windows") + ) + == [] + ) + assert ( + lookup.workflow_job_runner_node_ids( + "REPO_2", "acme", "prod-runners", ("self-hosted",) + ) + == [] + ) + assert ( + lookup.workflow_job_runner_node_ids( + "REPO_1", "acme", "restricted-runners", ("self-hosted",) + ) + == [] + ) + + +def test_workflow_job_runner_lookup_resolves_inherited_enterprise_group() -> None: + lookup = _workflow_runner_lookup() + + assert lookup.workflow_job_runner_node_ids( + "REPO_1", "acme", "enterprise-prod", ("self-hosted", "linux") + ) == ["ENT_1_runner_31"] + + def test_org_runner_group_keeps_generic_runner_group_label() -> None: group = OrgRunnerGroup(id=1, name="Default", org_login="acme") group._lookup = SimpleNamespace(org_id_for_login=lambda _login: "ORG_1") diff --git a/tests/test_workflow_model.py b/tests/test_workflow_model.py index fe27ca7..7e9f765 100644 --- a/tests/test_workflow_model.py +++ b/tests/test_workflow_model.py @@ -12,8 +12,25 @@ ORG_NODE_ID = "MDEyOk9yZ2FuaXphdGlvbjE=" +def _workflow_from_yaml(contents: bytes) -> Workflow: + return Workflow( + id=1, + node_id="W_1", + name="workflow.yml", + path=".github/workflows/workflow.yml", + state="active", + created_at=datetime(2026, 1, 1), + updated_at=datetime(2026, 1, 1), + url="https://api.github.test/repos/org/repo/actions/workflows/1", + contents=base64.b64encode(contents).decode(), + org_login="org", + repository_name="repo", + repository_node_id="R_1", + ) + + def _make_pwn_request_workflow() -> Workflow: - contents = base64.b64encode( + workflow = _workflow_from_yaml( b"""on: pull_request_target: jobs: @@ -23,20 +40,6 @@ def _make_pwn_request_workflow() -> Workflow: with: ref: ${{ github.event.pull_request.head.sha }} """ - ).decode() - workflow = Workflow( - id=1, - node_id="W_1", - name="pr.yml", - path=".github/workflows/pr.yml", - state="active", - created_at=datetime(2026, 1, 1), - updated_at=datetime(2026, 1, 1), - url="https://api.github.test/repos/org/repo/actions/workflows/1", - contents=contents, - org_login="org", - repository_name="repo", - repository_node_id="R_1", ) lookup = MagicMock() lookup.repository_allow_forking.return_value = ("public", True) @@ -52,6 +55,120 @@ def _make_pwn_request_workflow() -> Workflow: return workflow +def test_workflow_job_rows_preserve_runs_on_selector_shape() -> None: + workflow = _workflow_from_yaml( + b"""jobs: + plain: + runs-on: self-hosted + labels: + runs-on: [self-hosted, linux, x64] + group_only: + runs-on: + group: prod-runners + group_and_label: + runs-on: + group: prod-runners + labels: linux-x64 + dynamic: + runs-on: "${{ matrix.runner }}" +""" + ) + + rows = {row["job_key"]: row for row in workflow.workflow_job_rows()} + + assert rows["plain"]["runs_on_labels"] == ["self-hosted"] + assert rows["plain"]["runs_on_group"] is None + assert rows["plain"]["runs_on_is_dynamic"] is False + + assert rows["labels"]["runs_on_labels"] == ["self-hosted", "linux", "x64"] + assert rows["labels"]["runs_on_group"] is None + assert rows["labels"]["runs_on_is_dynamic"] is False + + assert rows["group_only"]["runs_on_labels"] is None + assert rows["group_only"]["runs_on_group"] == "prod-runners" + assert rows["group_only"]["runs_on_is_dynamic"] is False + + assert rows["group_and_label"]["runs_on_labels"] == ["linux-x64"] + assert rows["group_and_label"]["runs_on_group"] == "prod-runners" + assert rows["group_and_label"]["runs_on_is_dynamic"] is False + + assert rows["dynamic"]["runs_on_labels"] == ["${{ matrix.runner }}"] + assert rows["dynamic"]["runs_on_group"] is None + assert rows["dynamic"]["runs_on_is_dynamic"] is True + + +def test_workflow_job_group_selector_counts_as_self_hosted() -> None: + job = WorkflowJob( + node_id="JOB_1", + name="build", + job_key="build", + workflow_node_id="WORKFLOW_1", + repository_name="repo", + repository_node_id="REPO_1", + org_login="github", + runs_on={"group": "prod-runners"}, + ) + job._lookup = _org_reference_lookup() + + assert job.runs_on is None + assert job.runs_on_group == "prod-runners" + assert job.runs_on_labels is None + assert job.runs_on_is_dynamic is False + assert job.is_self_hosted is True + assert job.as_node.properties.is_self_hosted is True + + +def test_workflow_job_emits_runs_on_edges_for_static_selector_matches() -> None: + job = WorkflowJob( + node_id="JOB_1", + name="build", + job_key="build", + workflow_node_id="WORKFLOW_1", + repository_name="repo", + repository_node_id="REPO_1", + org_login="github", + runs_on=["self-hosted", "linux", "x64"], + ) + lookup = _org_reference_lookup() + lookup.workflow_job_runner_node_ids.return_value = [ + "REPO_1_runner_1", + "ORG_1_runner_2", + ] + job._lookup = lookup + + edges = list(job._runs_on_edges) + + assert [(edge.kind, edge.start.value, edge.end.value) for edge in edges] == [ + (ek.RUNS_ON, "JOB_1", "REPO_1_runner_1"), + (ek.RUNS_ON, "JOB_1", "ORG_1_runner_2"), + ] + assert all(edge.properties.traversable is False for edge in edges) + lookup.workflow_job_runner_node_ids.assert_called_once_with( + "REPO_1", + "github", + None, + ("self-hosted", "linux", "x64"), + ) + + +def test_workflow_job_dynamic_runs_on_selector_emits_no_edge() -> None: + job = WorkflowJob( + node_id="JOB_1", + name="build", + job_key="build", + workflow_node_id="WORKFLOW_1", + repository_name="repo", + repository_node_id="REPO_1", + org_login="github", + runs_on="${{ matrix.runner }}", + ) + lookup = _org_reference_lookup() + job._lookup = lookup + + assert list(job._runs_on_edges) == [] + lookup.workflow_job_runner_node_ids.assert_not_called() + + def test_pwn_request_edges_support_branch_lookup_protection_flag() -> None: workflow = _make_pwn_request_workflow() From 11e7731b44c189e627fbaba134e318c424e809b7 Mon Sep 17 00:00:00 2001 From: Jared Atkinson Date: Sat, 5 Sep 2026 21:38:16 -0700 Subject: [PATCH 2/9] BED-9674: suppress runner matches when actions disabled --- src/openhound_github/lookup.py | 2 +- tests/test_runner_models.py | 22 ++++++++++++++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/openhound_github/lookup.py b/src/openhound_github/lookup.py index b5e970e..d21304c 100644 --- a/src/openhound_github/lookup.py +++ b/src/openhound_github/lookup.py @@ -269,7 +269,7 @@ def add_matching_runner(node_id: str, raw_labels: Any) -> None: ) if actions_enabled is not True: - return matching_runner_node_ids + return [] for ( runner_group_id, diff --git a/tests/test_runner_models.py b/tests/test_runner_models.py index f21b06f..d8c6d4d 100644 --- a/tests/test_runner_models.py +++ b/tests/test_runner_models.py @@ -56,11 +56,12 @@ def _workflow_runner_lookup() -> GithubLookup: ) connection.execute("INSERT INTO github.organizations VALUES ('acme', 'ORG_1')") connection.execute( - "INSERT INTO github.repositories VALUES ('REPO_1', 'acme', 'private', true), ('REPO_2', 'acme', 'private', true)" + "INSERT INTO github.repositories VALUES ('REPO_1', 'acme', 'private', true), ('REPO_2', 'acme', 'private', true), ('REPO_3', 'acme', 'private', false)" ) connection.execute( """INSERT INTO github.repo_runners VALUES - (21, '[{"name":"self-hosted"},{"name":"Linux"},{"name":"X64"}]', 'REPO_1')""" + (21, '[{"name":"self-hosted"},{"name":"Linux"},{"name":"X64"}]', 'REPO_1'), + (22, '[{"name":"self-hosted"},{"name":"Linux"},{"name":"X64"}]', 'REPO_3')""" ) connection.execute( """INSERT INTO github.org_runners VALUES @@ -144,6 +145,23 @@ def test_workflow_job_runner_lookup_resolves_inherited_enterprise_group() -> Non ) == ["ENT_1_runner_31"] +def test_workflow_job_runner_lookup_returns_no_runners_when_actions_disabled() -> None: + lookup = _workflow_runner_lookup() + + assert ( + lookup.workflow_job_runner_node_ids( + "REPO_3", "acme", None, ("self-hosted", "linux", "x64") + ) + == [] + ) + assert ( + lookup.workflow_job_runner_node_ids( + "REPO_3", "acme", "Default", ("self-hosted", "linux", "x64") + ) + == [] + ) + + def test_org_runner_group_keeps_generic_runner_group_label() -> None: group = OrgRunnerGroup(id=1, name="Default", org_login="acme") group._lookup = SimpleNamespace(org_id_for_login=lambda _login: "ORG_1") From ddc6f46fe4969864795c9e9bc9f176db37b84df0 Mon Sep 17 00:00:00 2001 From: Jared Atkinson Date: Sat, 5 Sep 2026 21:40:56 -0700 Subject: [PATCH 3/9] BED-9674: normalize self-hosted selector casing --- src/openhound_github/models/workflow_job.py | 6 +++--- tests/test_workflow_model.py | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/openhound_github/models/workflow_job.py b/src/openhound_github/models/workflow_job.py index 8be8629..b82e903 100644 --- a/src/openhound_github/models/workflow_job.py +++ b/src/openhound_github/models/workflow_job.py @@ -229,9 +229,9 @@ def normalize_runs_on(cls, value: Any) -> list[str] | None: @property def is_self_hosted(self) -> bool: - return bool(self.runs_on_group) or "self-hosted" in ( - self.runs_on_labels or self.runs_on or [] - ) + return bool(self.runs_on_group) or "self-hosted" in { + str(label).casefold() for label in (self.runs_on_labels or self.runs_on or []) + } @property def as_node(self) -> GHNode: diff --git a/tests/test_workflow_model.py b/tests/test_workflow_model.py index 7e9f765..563b2de 100644 --- a/tests/test_workflow_model.py +++ b/tests/test_workflow_model.py @@ -118,6 +118,24 @@ def test_workflow_job_group_selector_counts_as_self_hosted() -> None: assert job.as_node.properties.is_self_hosted is True +def test_workflow_job_uppercase_self_hosted_label_counts_as_self_hosted() -> None: + job = WorkflowJob( + node_id="JOB_1", + name="build", + job_key="build", + workflow_node_id="WORKFLOW_1", + repository_name="repo", + repository_node_id="REPO_1", + org_login="github", + runs_on=["SELF-HOSTED", "Linux", "X64"], + ) + job._lookup = _org_reference_lookup() + + assert job.runs_on_labels == ["SELF-HOSTED", "Linux", "X64"] + assert job.is_self_hosted is True + assert job.as_node.properties.is_self_hosted is True + + def test_workflow_job_emits_runs_on_edges_for_static_selector_matches() -> None: job = WorkflowJob( node_id="JOB_1", From 682c93a30f4e8ec10509d9bce408eba5d766c6bf Mon Sep 17 00:00:00 2001 From: Jared Atkinson Date: Sun, 6 Sep 2026 16:47:22 -0700 Subject: [PATCH 4/9] BED-9675: model effective GitHub token permissions --- descriptions/edges/GH_CanPwnRequest.md | 4 +- descriptions/nodes/GH_Repository.md | 2 + descriptions/nodes/GH_Workflow.md | 2 + descriptions/nodes/GH_WorkflowJob.md | 2 + extension/saved_searches/README.md | 30 +-- ...bs-with-broad-token-write-permissions.json | 5 + ...id-token-write-on-self-hosted-runners.json | 5 + .../workflow-jobs-with-id-token-write.json | 5 + ...ow-jobs-with-observed-oidc-auth-steps.json | 5 + src/openhound_github/lookup.py | 28 +++ src/openhound_github/models/repository.py | 10 + src/openhound_github/models/workflow.py | 149 ++++++++++++++ src/openhound_github/models/workflow_job.py | 51 +++-- .../resources/organization.py | 36 +++- src/openhound_github/source.py | 3 + src/openhound_github/transforms.py | 12 ++ tests/test_repository_rulesets.py | 24 +++ tests/test_workflow_model.py | 188 +++++++++++++++++- tests/test_workflow_resources.py | 98 +++++++++ 19 files changed, 625 insertions(+), 34 deletions(-) create mode 100644 extension/saved_searches/workflow-jobs-with-broad-token-write-permissions.json create mode 100644 extension/saved_searches/workflow-jobs-with-id-token-write-on-self-hosted-runners.json create mode 100644 extension/saved_searches/workflow-jobs-with-id-token-write.json create mode 100644 extension/saved_searches/workflow-jobs-with-observed-oidc-auth-steps.json create mode 100644 tests/test_workflow_resources.py diff --git a/descriptions/edges/GH_CanPwnRequest.md b/descriptions/edges/GH_CanPwnRequest.md index 3c0e4d6..22e8190 100644 --- a/descriptions/edges/GH_CanPwnRequest.md +++ b/descriptions/edges/GH_CanPwnRequest.md @@ -39,7 +39,7 @@ An attacker who exploits a pwn request gains code execution in the workflow runn ### Caveats -- **OIDC traversal requires `id-token: write`**: The attack chain from GH_CanPwnRequest through GH_CanAssumeIdentity to a cloud role is only valid if the pwn-requestable workflow (or job) explicitly declares `id-token: write` in its `permissions:` block. The `id-token` permission defaults to `none` and is never implicitly granted — even when the workflow has no `permissions:` block at all. The `permissions` property on the GH_WorkflowJob node can be inspected to verify this. +- **OIDC traversal requires `id-token: write`**: The attack chain from GH_CanPwnRequest through GH_CanAssumeIdentity to a cloud role is only valid if the pwn-requestable job's calculated `effective_github_token_permissions` includes `id-token:write`. The `id-token` permission defaults to `none` and is never implicitly granted — even when the workflow has no `permissions:` block at all. Inspect `workflow_permissions` on GH_Workflow, `job_permissions` on GH_WorkflowJob, and the job's `effective_github_token_permissions` to understand how the effective value was derived. - **GITHUB_TOKEN permissions**: The `permissions:` block controls what the `GITHUB_TOKEN` can do (e.g., push commits, create releases), but has no effect on secret access, OIDC token requests (governed separately by `id-token`), or arbitrary code execution. A workflow with `contents: read` is still fully exploitable via pwn request for secret exfiltration and lateral movement — only write-back to the repository is limited. ```mermaid @@ -56,4 +56,4 @@ graph LR repo -.- |GH_HasWorkflow| wf repo -.- |GH_Contains| secret branch -- GH_CanAssumeIdentity --> cloud -``` \ No newline at end of file +``` diff --git a/descriptions/nodes/GH_Repository.md b/descriptions/nodes/GH_Repository.md index 82569a3..ed131e7 100644 --- a/descriptions/nodes/GH_Repository.md +++ b/descriptions/nodes/GH_Repository.md @@ -1,3 +1,5 @@ ## Description Represents a GitHub repository within the organization. Repository nodes capture metadata about the repo including visibility, Actions enablement status, and security configuration. Repository role nodes (GH_RepoRole) are created alongside each repository to represent the permission levels available. + +For repositories with active workflows, the collector records the applicable default workflow permissions and whether workflows may approve pull request reviews. These properties preserve the repository-level policy input later used to derive effective GITHUB_TOKEN permissions for GH_WorkflowJob nodes. diff --git a/descriptions/nodes/GH_Workflow.md b/descriptions/nodes/GH_Workflow.md index ade0df3..070e9b9 100644 --- a/descriptions/nodes/GH_Workflow.md +++ b/descriptions/nodes/GH_Workflow.md @@ -1,3 +1,5 @@ ## Description Represents a GitHub Actions workflow defined in a repository. Workflow nodes capture the workflow definition metadata including its file path, state, containing repository, and the full YAML contents of the workflow file. Only repositories with GitHub Actions enabled are queried for workflows. + +When present, `workflow_permissions` captures the top-level `permissions` declaration from the workflow YAML. diff --git a/descriptions/nodes/GH_WorkflowJob.md b/descriptions/nodes/GH_WorkflowJob.md index 87a55cc..95f5250 100644 --- a/descriptions/nodes/GH_WorkflowJob.md +++ b/descriptions/nodes/GH_WorkflowJob.md @@ -3,3 +3,5 @@ Represents a single job within a GitHub Actions workflow. Jobs are the top-level execution units of a workflow — they run on a runner, hold a set of steps, and can declare permissions, environments, and dependencies on other jobs. When the job has a statically resolvable self-hosted `runs-on` selector, GH_RunsOn edges identify each GH_Runner that currently satisfies the declared label and runner-group constraints under the repository's runner access policy. These edges represent schedulability, not historical execution. + +When present, `job_permissions` captures the job-level `permissions` declaration from the workflow YAML. `effective_github_token_permissions` captures the calculated static `GITHUB_TOKEN` permissions after applying the repository default, workflow-level declaration, and job-level declaration. diff --git a/extension/saved_searches/README.md b/extension/saved_searches/README.md index f7416b6..555943d 100644 --- a/extension/saved_searches/README.md +++ b/extension/saved_searches/README.md @@ -79,21 +79,25 @@ Pre-built Cypher queries for identifying security-relevant configurations across | 39 | `dangerous-branch-perms.json` | Dangerous Branch Permissions | Identifies users with dangerous branch permissions in a GitHub organization, including bypass allowances on protection rules. | | 40 | `org-roles-bypass-security-scanning.json` | Org Roles That Can Bypass Security Scanning | Finds organization roles with permissions to bypass or manage security scanning dismissals. These roles can suppress secret scanning and code scanning findings. | | 41 | `github-to-azure-identity.json` | GitHub-to-Azure Identity Assumptions | Finds GitHub entities (repositories, branches, environments) that can assume Azure identities via OIDC federation. Verify that each trust relationship is intentional and scoped appropriately. | +| 42 | `workflow-jobs-with-id-token-write.json` | Workflow Jobs with OIDC Token Permission | Returns workflow jobs whose effective GITHUB_TOKEN permissions include `id-token:write`. | +| 43 | `workflow-jobs-with-id-token-write-on-self-hosted-runners.json` | OIDC-Capable Workflow Jobs on Self-Hosted Runners | Returns OIDC-capable jobs that can be scheduled on collected self-hosted runners. | +| 44 | `workflow-jobs-with-broad-token-write-permissions.json` | Workflow Jobs with Broad GITHUB_TOKEN Write Permissions | Returns jobs with effective write access to repository contents, Actions, or pull requests. | +| 45 | `workflow-jobs-with-observed-oidc-auth-steps.json` | Workflow Jobs with Observed OIDC Authentication Steps | Returns OIDC-capable jobs with descendant steps that show likely token consumption. | ### :white_circle: Low — Hygiene & Governance | # | File | Name | Description | |---|------|------|-------------| -| 42 | `environments-admin-bypass.json` | Environments Where Admins Can Bypass Protections | Finds deployment environments where administrators can bypass protection rules such as required reviewers and wait timers. Admins can deploy to these environments without any approval. | -| 43 | `app-installations-all-repos.json` | App Installations with Access to All Repositories | Finds GitHub App installations that have access to every repository in the organization. A compromised app credential would affect all repositories. | -| 44 | `users-without-external-identity.json` | GitHub Users Without External Identity Mapping | Finds GitHub users that are not linked to any external identity via SAML or SCIM. These users cannot be centrally offboarded through the identity provider and may retain access after employment ends. | -| 45 | `external-identities-without-scim.json` | External Identities Without SCIM Provisioning | Finds external identities that lack SCIM synchronization. Without SCIM, user deprovisioning in the identity provider will not automatically revoke GitHub access. | -| 46 | `org-owners.json` | Organization Owners | Returns all users who hold the organization owners role. | -| 47 | `privileged-custom-org-roles.json` | Privileged Custom Org Roles | Returns all custom organization roles that are privileged (i.e., have permissions that are not default). | -| 48 | `global-repo-perms.json` | Global Repo Permissions | Returns all users who hold a global repository permission role (i.e., roles that are not default). | -| 49 | `hybrid-identities.json` | External Identities | Returns all external identities (e.g., Azure or Okta users) that are associated with GitHub users. | -| 50 | `privileged-hybrid-identities.json` | Privileged Hybrid Identities | Returns all hybrid identities (e.g., Azure or Okta users) that are associated with GitHub users who hold the organization owners role. | -| 51 | `saml-configuration.json` | SAML Configuration Mapping | Finds SAML Identity Providers, their external identities, and mapped users. | -| 52 | `team-membership-admin.json` | Team Membership Admins | Returns all users who hold the maintainer role over a team, including team nesting. | -| 53 | `team-structure.json` | Team Structure | Returns the structure of teams within organizations, including team roles and their members. | -| 54 | `repository-workflows.json` | Repository Workflows | Returns all repository workflows. | +| 46 | `environments-admin-bypass.json` | Environments Where Admins Can Bypass Protections | Finds deployment environments where administrators can bypass protection rules such as required reviewers and wait timers. Admins can deploy to these environments without any approval. | +| 47 | `app-installations-all-repos.json` | App Installations with Access to All Repositories | Finds GitHub App installations that have access to every repository in the organization. A compromised app credential would affect all repositories. | +| 48 | `users-without-external-identity.json` | GitHub Users Without External Identity Mapping | Finds GitHub users that are not linked to any external identity via SAML or SCIM. These users cannot be centrally offboarded through the identity provider and may retain access after employment ends. | +| 49 | `external-identities-without-scim.json` | External Identities Without SCIM Provisioning | Finds external identities that lack SCIM synchronization. Without SCIM, user deprovisioning in the identity provider will not automatically revoke GitHub access. | +| 50 | `org-owners.json` | Organization Owners | Returns all users who hold the organization owners role. | +| 51 | `privileged-custom-org-roles.json` | Privileged Custom Org Roles | Returns all custom organization roles that are privileged (i.e., have permissions that are not default). | +| 52 | `global-repo-perms.json` | Global Repo Permissions | Returns all users who hold a global repository permission role (i.e., roles that are not default). | +| 53 | `hybrid-identities.json` | External Identities | Returns all external identities (e.g., Azure or Okta users) that are associated with GitHub users. | +| 54 | `privileged-hybrid-identities.json` | Privileged Hybrid Identities | Returns all hybrid identities (e.g., Azure or Okta users) that are associated with GitHub users who hold the organization owners role. | +| 55 | `saml-configuration.json` | SAML Configuration Mapping | Finds SAML Identity Providers, their external identities, and mapped users. | +| 56 | `team-membership-admin.json` | Team Membership Admins | Returns all users who hold the maintainer role over a team, including team nesting. | +| 57 | `team-structure.json` | Team Structure | Returns the structure of teams within organizations, including team roles and their members. | +| 58 | `repository-workflows.json` | Repository Workflows | Returns all repository workflows. | diff --git a/extension/saved_searches/workflow-jobs-with-broad-token-write-permissions.json b/extension/saved_searches/workflow-jobs-with-broad-token-write-permissions.json new file mode 100644 index 0000000..b50d2ab --- /dev/null +++ b/extension/saved_searches/workflow-jobs-with-broad-token-write-permissions.json @@ -0,0 +1,5 @@ +{ + "name": "GitHub: Workflow Jobs with Broad GITHUB_TOKEN Write Permissions", + "query": "MATCH p=(repo:GH_Repository)-[:GH_Contains]->(:GH_Workflow)-[:GH_Contains]->(job:GH_WorkflowJob)\nWHERE 'contents:write' IN job.effective_github_token_permissions\nOR 'actions:write' IN job.effective_github_token_permissions\nOR 'pull-requests:write' IN job.effective_github_token_permissions\nRETURN p\nLIMIT 1000", + "description": "Returns workflow jobs whose calculated effective GITHUB_TOKEN permissions include write access to repository contents, Actions, or pull requests. These permissions can materially increase the impact of workflow compromise." +} diff --git a/extension/saved_searches/workflow-jobs-with-id-token-write-on-self-hosted-runners.json b/extension/saved_searches/workflow-jobs-with-id-token-write-on-self-hosted-runners.json new file mode 100644 index 0000000..202c171 --- /dev/null +++ b/extension/saved_searches/workflow-jobs-with-id-token-write-on-self-hosted-runners.json @@ -0,0 +1,5 @@ +{ + "name": "GitHub: OIDC-Capable Workflow Jobs on Self-Hosted Runners", + "query": "MATCH p=(repo:GH_Repository)-[:GH_Contains]->(:GH_Workflow)-[:GH_Contains]->(job:GH_WorkflowJob)-[:GH_RunsOn]->(:GH_Runner)\nWHERE 'id-token:write' IN job.effective_github_token_permissions\nRETURN p\nLIMIT 1000", + "description": "Returns OIDC-capable workflow jobs that can be scheduled on collected self-hosted runners. These jobs are especially important to review because runner compromise could expose short-lived cloud federation tokens." +} diff --git a/extension/saved_searches/workflow-jobs-with-id-token-write.json b/extension/saved_searches/workflow-jobs-with-id-token-write.json new file mode 100644 index 0000000..4302a65 --- /dev/null +++ b/extension/saved_searches/workflow-jobs-with-id-token-write.json @@ -0,0 +1,5 @@ +{ + "name": "GitHub: Workflow Jobs with OIDC Token Permission", + "query": "MATCH p=(repo:GH_Repository)-[:GH_Contains]->(:GH_Workflow)-[:GH_Contains]->(job:GH_WorkflowJob)\nWHERE 'id-token:write' IN job.effective_github_token_permissions\nRETURN p\nLIMIT 1000", + "description": "Returns workflow jobs whose calculated effective GITHUB_TOKEN permissions include id-token:write. These jobs can request GitHub OIDC tokens, though additional step analysis is needed to determine whether the token is actually used." +} diff --git a/extension/saved_searches/workflow-jobs-with-observed-oidc-auth-steps.json b/extension/saved_searches/workflow-jobs-with-observed-oidc-auth-steps.json new file mode 100644 index 0000000..7bd654b --- /dev/null +++ b/extension/saved_searches/workflow-jobs-with-observed-oidc-auth-steps.json @@ -0,0 +1,5 @@ +{ + "name": "GitHub: Workflow Jobs with Observed OIDC Authentication Steps", + "query": "MATCH p=(repo:GH_Repository)-[:GH_Contains]->(:GH_Workflow)-[:GH_Contains]->(job:GH_WorkflowJob)-[:GH_Contains]->(step:GH_WorkflowStep)\nWHERE 'id-token:write' IN job.effective_github_token_permissions\nAND (\n step.action_slug = 'aws-actions/configure-aws-credentials'\n OR step.action_slug = 'azure/login'\n OR step.action_slug = 'Azure/login'\n OR step.action_slug = 'actions/deploy-pages'\n OR step.contents CONTAINS 'getIDToken'\n OR step.contents CONTAINS 'ACTIONS_ID_TOKEN_REQUEST_URL'\n OR step.contents CONTAINS 'ACTIONS_ID_TOKEN_REQUEST_TOKEN'\n)\nRETURN p\nLIMIT 1000", + "description": "Returns OIDC-capable workflow jobs with direct descendant steps that show likely OIDC token consumption, including common AWS, Azure, GitHub Pages, and direct token-request patterns. This is evidence of likely use, not a complete classifier for every possible OIDC consumer; reusable workflow callers may require following GH_CallsWorkflow to find the consuming steps." +} diff --git a/src/openhound_github/lookup.py b/src/openhound_github/lookup.py index d21304c..8bba5fb 100644 --- a/src/openhound_github/lookup.py +++ b/src/openhound_github/lookup.py @@ -512,6 +512,34 @@ def repository_branch_ruleset_count(self, repository_node_id: str) -> int | None return None return int(row[0]) + @lru_cache + def repository_workflow_permissions( + self, repository_node_id: str + ) -> tuple[str | None, bool | None] | None: + row = self._find_single_row( + f""" + SELECT + repository_default_workflow_permissions, + repository_can_approve_pull_request_reviews + FROM {self.schema}.workflows + WHERE repository_node_id = ? + LIMIT 1 + """, + [repository_node_id], + ) + if row is None: + return None + + default_workflow_permissions, can_approve_pull_request_reviews = row + return ( + default_workflow_permissions, + ( + None + if can_approve_pull_request_reviews is None + else bool(can_approve_pull_request_reviews) + ), + ) + @lru_cache def repository_default_branch_collected(self, repository_node_id: str) -> bool: """Return whether the repository's REST default branch was collected.""" diff --git a/src/openhound_github/models/repository.py b/src/openhound_github/models/repository.py index 3293b79..1e57b8c 100644 --- a/src/openhound_github/models/repository.py +++ b/src/openhound_github/models/repository.py @@ -41,6 +41,8 @@ class GHRepositoryProperties(GHNodeProperties): environment_name: The name of the environment (GitHub organization). actions_enabled: Whether GitHub Actions is enabled for this repository. self_hosted_runners_enabled: Whether the repository may use self-hosted runners. + default_workflow_permissions: The repository's applicable default GITHUB_TOKEN workflow permissions. + can_approve_pull_request_reviews: Whether workflows may approve pull request reviews. secret_scanning: Status of secret scanning (e.g., `enabled`, `disabled`). branch_ruleset_count: Number of branch-targeted rulesets that apply to this repository. has_branch_rulesets: Whether at least one branch-targeted ruleset applies to this repository. @@ -87,6 +89,8 @@ class GHRepositoryProperties(GHNodeProperties): environment_name: str | None = None actions_enabled: bool | None = None self_hosted_runners_enabled: bool | None = None + default_workflow_permissions: str | None = None + can_approve_pull_request_reviews: bool | None = None secret_scanning: str | None = None branch_ruleset_count: int | None = None has_branch_rulesets: bool | None = None @@ -226,6 +230,10 @@ def owner_name(self) -> str: def as_node(self) -> GHNode: rid = self.node_id branch_ruleset_count = self._lookup.repository_branch_ruleset_count(rid) + workflow_permissions = self._lookup.repository_workflow_permissions(rid) + default_workflow_permissions, can_approve_pull_request_reviews = ( + workflow_permissions if workflow_permissions else (None, None) + ) return GHNode( kinds=[nk.REPOSITORY], properties=GHRepositoryProperties( @@ -256,6 +264,8 @@ def as_node(self) -> GHNode: environmentid=self.org_node_id, actions_enabled=self.actions_enabled, self_hosted_runners_enabled=self.self_hosted_runners_enabled, + default_workflow_permissions=default_workflow_permissions, + can_approve_pull_request_reviews=can_approve_pull_request_reviews, branch_ruleset_count=branch_ruleset_count, has_branch_rulesets=( branch_ruleset_count > 0 diff --git a/src/openhound_github/models/workflow.py b/src/openhound_github/models/workflow.py index 29543bb..ac8b2c5 100644 --- a/src/openhound_github/models/workflow.py +++ b/src/openhound_github/models/workflow.py @@ -101,6 +101,137 @@ class RunsOnSelector(BaseModel): is_dynamic: bool = False +GITHUB_TOKEN_PERMISSION_SCOPES = ( + "actions", + "artifact-metadata", + "attestations", + "checks", + "code-quality", + "contents", + "deployments", + "discussions", + "id-token", + "issues", + "models", + "packages", + "pages", + "pull-requests", + "security-events", + "statuses", + "vulnerability-alerts", +) +READ_ONLY_GITHUB_TOKEN_PERMISSION_SCOPES = {"models", "vulnerability-alerts"} +WRITE_ONLY_GITHUB_TOKEN_PERMISSION_SCOPES = {"id-token"} + + +def normalize_permission_declaration(value: Any) -> list[str] | None: + if value is None: + return None + + if isinstance(value, str): + return [value] + + if isinstance(value, list): + return [str(item) for item in value] + + if isinstance(value, dict): + return [f"{str(key)}:{str(item)}" for key, item in value.items()] + + return [str(value)] + + +def _empty_github_token_permissions() -> dict[str, str]: + return {scope: "none" for scope in GITHUB_TOKEN_PERMISSION_SCOPES} + + +def _all_github_token_permissions( + access: str, *, include_id_token: bool = True +) -> dict[str, str]: + permissions = _empty_github_token_permissions() + for scope in GITHUB_TOKEN_PERMISSION_SCOPES: + if scope in WRITE_ONLY_GITHUB_TOKEN_PERMISSION_SCOPES: + permissions[scope] = ( + "write" if access == "write" and include_id_token else "none" + ) + elif scope in READ_ONLY_GITHUB_TOKEN_PERMISSION_SCOPES: + permissions[scope] = "read" + else: + permissions[scope] = access + return permissions + + +def _default_github_token_permissions(default_workflow_permissions: str | None): + if default_workflow_permissions is None: + return None + + default = default_workflow_permissions.casefold() + if default == "read": + permissions = _empty_github_token_permissions() + permissions["contents"] = "read" + permissions["packages"] = "read" + return permissions + if default == "write": + return _all_github_token_permissions("write", include_id_token=False) + return None + + +def _expand_permission_declaration(value: Any) -> dict[str, str] | None: + if value is None: + return None + + if isinstance(value, str): + declaration = value.casefold() + if declaration == "read-all": + return _all_github_token_permissions("read") + if declaration == "write-all": + return _all_github_token_permissions("write") + return None + + if isinstance(value, list): + if not value: + return _empty_github_token_permissions() + if len(value) == 1 and ":" not in str(value[0]): + return _expand_permission_declaration(str(value[0])) + + normalized: dict[str, str] = {} + for item in value: + scope, separator, access = str(item).partition(":") + if not separator: + return None + normalized[scope] = access + value = normalized + + if not isinstance(value, dict): + return None + + permissions = _empty_github_token_permissions() + for raw_scope, raw_access in value.items(): + scope = str(raw_scope).casefold() + permissions[scope] = str(raw_access).casefold() + return permissions + + +def resolve_effective_github_token_permissions( + default_workflow_permissions: str | None, + workflow_permissions: Any, + job_permissions: Any, +) -> list[str] | None: + permissions = _default_github_token_permissions(default_workflow_permissions) + + for declaration in (workflow_permissions, job_permissions): + if declaration is None: + continue + expanded = _expand_permission_declaration(declaration) + if expanded is None: + return None + permissions = expanded + + if permissions is None: + return None + + return [f"{scope}:{access}" for scope, access in permissions.items()] + + def parse_runs_on_selector(value: Any) -> RunsOnSelector: """Normalize a workflow job's runs-on declaration without losing group data.""" if value is None: @@ -293,6 +424,7 @@ class GHWorkflowProperties(GHNodeProperties): html_url: The GitHub web URL for the workflow file. branch: The branch where the workflow file was found. contents: The content of the workflow file. + workflow_permissions: Permissions declared at the workflow level. query_repository: Query for repository. query_jobs: Query for workflow jobs. query_execution: Query for workflow executions. @@ -310,6 +442,7 @@ class GHWorkflowProperties(GHNodeProperties): html_url: str | None = None branch: str | None = None contents: str | None = None + workflow_permissions: list[str] | None = None triggers: list[str] | None = None trigger_dispatch_inputs: list[str] | None = None is_pwn_requestable: bool = False @@ -373,6 +506,8 @@ class Workflow(BaseAsset): org_login: str repository_name: str repository_node_id: str + repository_default_workflow_permissions: str | None = None + repository_can_approve_pull_request_reviews: bool | None = None @property def org_node_id(self) -> str | None: @@ -429,6 +564,13 @@ def workflow_dispatch_inputs(self) -> list[str] | None: return [str(key) for key in inputs.keys()] + @property + def workflow_permissions(self) -> list[str] | None: + document = self.document + if not document: + return None + return normalize_permission_declaration(document.permissions) + @property def pull_request_target_branches(self) -> list[str] | None: document = self.document @@ -571,6 +713,12 @@ def workflow_job_rows(self) -> list[dict[str, Any]]: "permissions": job.permissions if job.permissions is not None else document.permissions, + "job_permissions": job.permissions, + "effective_github_token_permissions": resolve_effective_github_token_permissions( + self.repository_default_workflow_permissions, + document.permissions, + job.permissions, + ), "uses_reusable": job.uses, "workflow_node_id": self.node_id, "repository_name": self.repository_name, @@ -656,6 +804,7 @@ def as_node(self) -> GHNode: html_url=self.html_url, branch=self.branch, contents=self._decoded_contents, + workflow_permissions=self.workflow_permissions, triggers=self.trigger_events, trigger_dispatch_inputs=self.workflow_dispatch_inputs, # is_pwn_requestable=self.is_pwn_requestable, diff --git a/src/openhound_github/models/workflow_job.py b/src/openhound_github/models/workflow_job.py index b82e903..6b256e8 100644 --- a/src/openhound_github/models/workflow_job.py +++ b/src/openhound_github/models/workflow_job.py @@ -21,7 +21,11 @@ from openhound_github.kinds import edges as ek from openhound_github.kinds import nodes as nk from openhound_github.main import app -from openhound_github.models.workflow import parse_runs_on_selector +from openhound_github.models.workflow import ( + normalize_permission_declaration, + parse_runs_on_selector, + resolve_effective_github_token_permissions, +) TEMPLATE_RE = re.compile(r"\$\{\{\s*[^}]+?\s*\}\}") @@ -44,7 +48,9 @@ class GHWorkflowJobProperties(GHNodeProperties): is_self_hosted: Whether the job targets self-hosted runners. container: The optional container configuration. environment: The deployment environment name. - permissions: Effective job permissions. + permissions: Permissions after workflow/job declaration precedence. + job_permissions: Permissions declared at the job level. + effective_github_token_permissions: Calculated GITHUB_TOKEN permissions. uses_reusable: The reusable workflow reference used by this job. workflow_node_id: The parent workflow node ID. repository_name: The containing repository name. @@ -65,6 +71,8 @@ class GHWorkflowJobProperties(GHNodeProperties): container: str | None = None environment: str | None = None permissions: list[str] | None = None + job_permissions: list[str] | None = None + effective_github_token_permissions: list[str] | None = None uses_reusable: str | None = None workflow_node_id: str | None = None repository_name: str | None = None @@ -182,6 +190,8 @@ class WorkflowJob(BaseAsset): container: str | None = None environment: str | None = None permissions: list[str] | None = None + job_permissions: list[str] | None = None + effective_github_token_permissions: list[str] | None = None uses_reusable: str | None = None dependency_node_ids: list[str] = Field(default_factory=list) secret_references: list[WorkflowReference] = Field(default_factory=list) @@ -191,22 +201,15 @@ class WorkflowJob(BaseAsset): def org_node_id(self) -> str | None: return self._lookup.org_id_for_login(self.org_login) - @field_validator("permissions", mode="before") + @field_validator( + "permissions", + "job_permissions", + "effective_github_token_permissions", + mode="before", + ) @classmethod def normalize_permissions(cls, value: Any) -> list[str] | None: - if value is None: - return None - - if isinstance(value, str): - return [value] - - if isinstance(value, list): - return [str(item) for item in value] - - if isinstance(value, dict): - return [f"{str(key)}:{str(value)}" for key, value in value.items()] - - return [str(value)] + return normalize_permission_declaration(value) @model_validator(mode="before") @classmethod @@ -233,6 +236,20 @@ def is_self_hosted(self) -> bool: str(label).casefold() for label in (self.runs_on_labels or self.runs_on or []) } + @property + def calculated_effective_github_token_permissions(self) -> list[str] | None: + workflow_permissions = self._lookup.repository_workflow_permissions( + self.repository_node_id + ) + default_workflow_permissions = ( + workflow_permissions[0] if workflow_permissions else None + ) + return resolve_effective_github_token_permissions( + default_workflow_permissions, + self.permissions, + None, + ) + @property def as_node(self) -> GHNode: jid = self.node_id @@ -251,6 +268,8 @@ def as_node(self) -> GHNode: container=self.container, environment=self.environment, permissions=self.permissions, + job_permissions=self.job_permissions, + effective_github_token_permissions=self.calculated_effective_github_token_permissions, uses_reusable=self.uses_reusable, workflow_node_id=self.workflow_node_id, repository_name=self.repository_name, diff --git a/src/openhound_github/resources/organization.py b/src/openhound_github/resources/organization.py index f0b5486..cd9694c 100644 --- a/src/openhound_github/resources/organization.py +++ b/src/openhound_github/resources/organization.py @@ -116,6 +116,9 @@ class SourceContext: actions_permissions_cache: dict[str, dict[str, Any]] = field(default_factory=dict) runner_permissions_cache: dict[str, dict[str, Any]] = field(default_factory=dict) workflow_permissions_cache: dict[str, dict[str, Any]] = field(default_factory=dict) + repository_workflow_permissions_cache: dict[str, dict[str, Any]] = field( + default_factory=dict + ) class RepositoryRoleCache: @@ -224,6 +227,19 @@ def _workflow_permissions( ) +def _repository_workflow_permissions( + ctx: SourceContext, client: RESTClient, repository_full_name: str +) -> dict[str, Any]: + cache_key = repository_full_name.casefold() + if cache_key not in ctx.repository_workflow_permissions_cache: + with ctx.cache_lock: + if cache_key not in ctx.repository_workflow_permissions_cache: + ctx.repository_workflow_permissions_cache[cache_key] = client.get( + f"/repos/{repository_full_name}/actions/permissions/workflow" + ).json() + return ctx.repository_workflow_permissions_cache[cache_key] + + def _rest_teams_for_org( ctx: SourceContext, client: RESTClient, org_name: str ) -> list[dict[str, Any]]: @@ -1352,7 +1368,10 @@ def workflows(repo: Repository, ctx: SourceContext): @app.defer def _workflow_file_contents( - client: RESTClient, repo: Repository, workflow: dict[str, Any] + client: RESTClient, + repo: Repository, + workflow: dict[str, Any], + workflow_permissions: dict[str, Any], ) -> dict | None: path = workflow.get("path") if not path: @@ -1374,15 +1393,28 @@ def _workflow_file_contents( "repository_name": repo.name, "repository_node_id": repo.node_id, "org_login": repo.org_login, + "repository_default_workflow_permissions": workflow_permissions.get( + "default_workflow_permissions" + ), + "repository_can_approve_pull_request_reviews": workflow_permissions.get( + "can_approve_pull_request_reviews" + ), } client = _client_for_org(ctx, repo.org_login) + workflow_permissions: dict[str, Any] | None = None for page in client.paginate( f"/repos/{repo.full_name}/actions/workflows", params={"per_page": 100} ): for workflow in page: if workflow.get("state") == "active": - yield _workflow_file_contents(client, repo, workflow) + if workflow_permissions is None: + workflow_permissions = _repository_workflow_permissions( + ctx, client, repo.full_name + ) + yield _workflow_file_contents( + client, repo, workflow, workflow_permissions + ) @app.transformer(name="workflow_jobs", columns=WorkflowJob, parallelized=True) diff --git a/src/openhound_github/source.py b/src/openhound_github/source.py index 7264f74..09abbb7 100644 --- a/src/openhound_github/source.py +++ b/src/openhound_github/source.py @@ -160,6 +160,9 @@ class SourceContext: actions_permissions_cache: dict[str, dict[str, Any]] = field(default_factory=dict) runner_permissions_cache: dict[str, dict[str, Any]] = field(default_factory=dict) workflow_permissions_cache: dict[str, dict[str, Any]] = field(default_factory=dict) + repository_workflow_permissions_cache: dict[str, dict[str, Any]] = field( + default_factory=dict + ) @property def org_names(self) -> list[str]: diff --git a/src/openhound_github/transforms.py b/src/openhound_github/transforms.py index b070d83..32416dc 100644 --- a/src/openhound_github/transforms.py +++ b/src/openhound_github/transforms.py @@ -172,6 +172,11 @@ def ensure_optional_input_tables( labels JSON, repository_node_id VARCHAR ); + CREATE TABLE IF NOT EXISTS {schema}.workflows ( + repository_node_id VARCHAR, + repository_default_workflow_permissions VARCHAR, + repository_can_approve_pull_request_reviews BOOLEAN + ); """) con.execute(f""" ALTER TABLE {schema}.branches @@ -387,6 +392,13 @@ def ensure_optional_input_tables( ADD COLUMN IF NOT EXISTS labels JSON; ALTER TABLE {schema}.repo_runners ADD COLUMN IF NOT EXISTS repository_node_id VARCHAR; + + ALTER TABLE {schema}.workflows + ADD COLUMN IF NOT EXISTS repository_node_id VARCHAR; + ALTER TABLE {schema}.workflows + ADD COLUMN IF NOT EXISTS repository_default_workflow_permissions VARCHAR; + ALTER TABLE {schema}.workflows + ADD COLUMN IF NOT EXISTS repository_can_approve_pull_request_reviews BOOLEAN; """) # TODO: diff --git a/tests/test_repository_rulesets.py b/tests/test_repository_rulesets.py index 05bb4c1..17f00ad 100644 --- a/tests/test_repository_rulesets.py +++ b/tests/test_repository_rulesets.py @@ -390,14 +390,18 @@ def test_repository_node_surfaces_branch_ruleset_presence() -> None: lookup = MagicMock() lookup.org_id_for_login.return_value = "O_1" lookup.repository_branch_ruleset_count.return_value = 2 + lookup.repository_workflow_permissions.return_value = ("read", False) repo._lookup = lookup node = repo.as_node assert node.properties.branch_ruleset_count == 2 assert node.properties.has_branch_rulesets is True + assert node.properties.default_workflow_permissions == "read" + assert node.properties.can_approve_pull_request_reviews is False assert node.properties.size == 0 lookup.repository_branch_ruleset_count.assert_called_once_with("R_1") + lookup.repository_workflow_permissions.assert_called_once_with("R_1") def test_repository_node_preserves_unknown_branch_ruleset_presence() -> None: @@ -405,12 +409,15 @@ def test_repository_node_preserves_unknown_branch_ruleset_presence() -> None: lookup = MagicMock() lookup.org_id_for_login.return_value = "O_1" lookup.repository_branch_ruleset_count.return_value = None + lookup.repository_workflow_permissions.return_value = None repo._lookup = lookup node = repo.as_node assert node.properties.branch_ruleset_count is None assert node.properties.has_branch_rulesets is None + assert node.properties.default_workflow_permissions is None + assert node.properties.can_approve_pull_request_reviews is None def test_repository_branch_ruleset_count_lookup_returns_int() -> None: @@ -427,3 +434,20 @@ def test_repository_branch_ruleset_count_lookup_returns_int() -> None: assert lookup.repository_branch_ruleset_count("R_1") == 2 assert lookup.repository_branch_ruleset_count("R_2") is None + + +def test_repository_workflow_permissions_lookup_returns_collected_policy() -> None: + connection = duckdb.connect(":memory:") + connection.execute("CREATE SCHEMA github") + connection.execute( + "CREATE TABLE github.workflows (repository_node_id VARCHAR, repository_default_workflow_permissions VARCHAR, repository_can_approve_pull_request_reviews BOOLEAN)" + ) + connection.execute( + "INSERT INTO github.workflows VALUES ('R_1', 'read', false), ('R_2', NULL, NULL)" + ) + + lookup = GithubLookup(connection) + + assert lookup.repository_workflow_permissions("R_1") == ("read", False) + assert lookup.repository_workflow_permissions("R_2") == (None, None) + assert lookup.repository_workflow_permissions("R_3") is None diff --git a/tests/test_workflow_model.py b/tests/test_workflow_model.py index 563b2de..a4576ff 100644 --- a/tests/test_workflow_model.py +++ b/tests/test_workflow_model.py @@ -4,7 +4,10 @@ from openhound_github.kinds import edges as ek from openhound_github.kinds import nodes as nk -from openhound_github.models.workflow import Workflow +from openhound_github.models.workflow import ( + Workflow, + resolve_effective_github_token_permissions, +) from openhound_github.models.workflow_job import WorkflowJob from openhound_github.models.workflow_step import WorkflowStep @@ -55,6 +58,11 @@ def _make_pwn_request_workflow() -> Workflow: return workflow +def _permissions_map(permissions: list[str] | None) -> dict[str, str]: + assert permissions is not None + return dict(permission.split(":", 1) for permission in permissions) + + def test_workflow_job_rows_preserve_runs_on_selector_shape() -> None: workflow = _workflow_from_yaml( b"""jobs: @@ -97,6 +105,183 @@ def test_workflow_job_rows_preserve_runs_on_selector_shape() -> None: assert rows["dynamic"]["runs_on_is_dynamic"] is True +def test_workflow_job_rows_preserve_workflow_and_job_permission_declarations() -> None: + workflow = _workflow_from_yaml( + b"""permissions: + contents: read +jobs: + inherited: + runs-on: ubuntu-latest + overridden: + runs-on: ubuntu-latest + permissions: + issues: write + explicit_empty: + runs-on: ubuntu-latest + permissions: {} + read_all: + runs-on: ubuntu-latest + permissions: read-all +""" + ) + workflow.repository_default_workflow_permissions = "read" + + rows = {row["job_key"]: row for row in workflow.workflow_job_rows()} + workflow._lookup = _org_reference_lookup() + + assert workflow.as_node.properties.workflow_permissions == ["contents:read"] + + assert rows["inherited"]["permissions"] == {"contents": "read"} + assert rows["inherited"]["job_permissions"] is None + assert _permissions_map(rows["inherited"]["effective_github_token_permissions"])[ + "contents" + ] == "read" + assert _permissions_map(rows["inherited"]["effective_github_token_permissions"])[ + "issues" + ] == "none" + + assert rows["overridden"]["permissions"] == {"issues": "write"} + assert rows["overridden"]["job_permissions"] == {"issues": "write"} + assert _permissions_map(rows["overridden"]["effective_github_token_permissions"])[ + "issues" + ] == "write" + assert _permissions_map(rows["overridden"]["effective_github_token_permissions"])[ + "contents" + ] == "none" + + assert rows["explicit_empty"]["permissions"] == {} + assert rows["explicit_empty"]["job_permissions"] == {} + assert set( + _permissions_map( + rows["explicit_empty"]["effective_github_token_permissions"] + ).values() + ) == {"none"} + + assert rows["read_all"]["permissions"] == "read-all" + assert rows["read_all"]["job_permissions"] == "read-all" + assert _permissions_map(rows["read_all"]["effective_github_token_permissions"])[ + "contents" + ] == "read" + assert _permissions_map(rows["read_all"]["effective_github_token_permissions"])[ + "id-token" + ] == "none" + + job = WorkflowJob.model_validate(rows["explicit_empty"]) + job._lookup = _org_reference_lookup() + + assert job.permissions == [] + assert job.job_permissions == [] + assert set(_permissions_map(job.effective_github_token_permissions).values()) == { + "none" + } + assert job.as_node.properties.job_permissions == [] + assert set( + _permissions_map( + job.as_node.properties.effective_github_token_permissions + ).values() + ) == {"none"} + + +def test_effective_github_token_permissions_use_repository_default_when_undeclared() -> None: + permissions = _permissions_map( + resolve_effective_github_token_permissions("read", None, None) + ) + + assert permissions["contents"] == "read" + assert permissions["packages"] == "read" + assert permissions["issues"] == "none" + assert permissions["id-token"] == "none" + + +def test_effective_github_token_permissions_do_not_inherit_id_token_from_write_default() -> None: + permissions = _permissions_map( + resolve_effective_github_token_permissions("write", None, None) + ) + + assert permissions["contents"] == "write" + assert permissions["pull-requests"] == "write" + assert permissions["id-token"] == "none" + + +def test_effective_github_token_permissions_allow_explicit_elevation_from_default() -> None: + permissions = _permissions_map( + resolve_effective_github_token_permissions( + "read", + {"contents": "read"}, + {"issues": "write"}, + ) + ) + + assert permissions["issues"] == "write" + assert permissions["contents"] == "none" + assert permissions["packages"] == "none" + + +def test_effective_github_token_permissions_expand_write_all() -> None: + permissions = _permissions_map( + resolve_effective_github_token_permissions("read", "write-all", None) + ) + + assert permissions["contents"] == "write" + assert permissions["id-token"] == "write" + assert permissions["models"] == "read" + assert permissions["vulnerability-alerts"] == "read" + + +def test_job_permissions_override_workflow_id_token_permission() -> None: + workflow = _workflow_from_yaml( + b"""permissions: + id-token: write + contents: read +jobs: + inherited: + runs-on: ubuntu-latest + overridden: + runs-on: ubuntu-latest + permissions: + contents: read +""" + ) + workflow.repository_default_workflow_permissions = "read" + + rows = {row["job_key"]: row for row in workflow.workflow_job_rows()} + + inherited = _permissions_map( + rows["inherited"]["effective_github_token_permissions"] + ) + overridden = _permissions_map( + rows["overridden"]["effective_github_token_permissions"] + ) + + assert inherited["id-token"] == "write" + assert inherited["contents"] == "read" + assert overridden["id-token"] == "none" + assert overridden["contents"] == "read" + + +def test_workflow_job_node_recalculates_effective_permissions_during_conversion() -> None: + job = WorkflowJob( + node_id="JOB_1", + name="build", + job_key="build", + workflow_node_id="WORKFLOW_1", + repository_name="repo", + repository_node_id="REPO_1", + org_login="github", + effective_github_token_permissions=["id-token:write"], + ) + lookup = _org_reference_lookup() + lookup.repository_workflow_permissions.return_value = ("write", False) + job._lookup = lookup + + permissions = _permissions_map( + job.as_node.properties.effective_github_token_permissions + ) + + assert permissions["contents"] == "write" + assert permissions["id-token"] == "none" + + def test_workflow_job_group_selector_counts_as_self_hosted() -> None: job = WorkflowJob( node_id="JOB_1", @@ -207,6 +392,7 @@ def test_pwn_request_edges_support_branch_lookup_protection_flag() -> None: def _org_reference_lookup() -> MagicMock: lookup = MagicMock() lookup.org_id_for_login.return_value = ORG_NODE_ID + lookup.repository_workflow_permissions.return_value = None lookup.repo_secret.return_value = None lookup.org_secret.return_value = ("DEPLOY_TOKEN",) lookup.repo_variable.return_value = None diff --git a/tests/test_workflow_resources.py b/tests/test_workflow_resources.py new file mode 100644 index 0000000..72e6c74 --- /dev/null +++ b/tests/test_workflow_resources.py @@ -0,0 +1,98 @@ +import inspect +from types import SimpleNamespace + +from openhound_github.resources.organization import OrgContext, SourceContext, workflows + + +class _FakeResponse: + def __init__(self, payload: dict): + self.payload = payload + + def json(self) -> dict: + return self.payload + + +class _FakeClient: + def __init__(self, workflow_pages: list[list[dict]]): + self.workflow_pages = workflow_pages + self.get_calls: list[tuple[str, dict]] = [] + self.paginate_calls: list[tuple[str, dict]] = [] + + def paginate(self, path: str, **kwargs): + self.paginate_calls.append((path, kwargs)) + return iter(self.workflow_pages) + + def get(self, path: str, **kwargs): + self.get_calls.append((path, kwargs)) + if path.endswith("/actions/permissions/workflow"): + return _FakeResponse( + { + "default_workflow_permissions": "read", + "can_approve_pull_request_reviews": False, + } + ) + return _FakeResponse({"content": "am9iczoge30="}) + + +def _repo() -> SimpleNamespace: + return SimpleNamespace( + full_name="acme/repo", + name="repo", + node_id="REPO_1", + org_login="acme", + default_branch="main", + ) + + +def _ctx(client: _FakeClient) -> SourceContext: + return SourceContext( + client=client, + organizations=[OrgContext(client=client, org_name="acme")], + ) + + +def _workflow_row(workflow_id: int, state: str = "active") -> dict: + return { + "id": workflow_id, + "node_id": f"W_{workflow_id}", + "name": f"workflow-{workflow_id}", + "path": f".github/workflows/{workflow_id}.yml", + "state": state, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "url": f"https://api.github.test/repos/acme/repo/actions/workflows/{workflow_id}", + } + + +def _collect_workflows(repo, ctx) -> list[dict]: + generator = inspect.unwrap(workflows._pipe.gen) + return [deferred() for deferred in generator(repo, ctx)] + + +def test_workflows_skip_repository_permission_lookup_without_active_workflows() -> None: + client = _FakeClient([[_workflow_row(1, state="disabled_manually")]]) + + rows = _collect_workflows(_repo(), _ctx(client)) + + assert rows == [] + assert client.get_calls == [] + + +def test_workflows_cache_repository_permissions_for_active_workflows() -> None: + client = _FakeClient([[_workflow_row(1), _workflow_row(2)]]) + ctx = _ctx(client) + + rows = _collect_workflows(_repo(), ctx) + _collect_workflows(_repo(), ctx) + + assert [row["repository_default_workflow_permissions"] for row in rows] == [ + "read", + "read", + ] + assert [row["repository_can_approve_pull_request_reviews"] for row in rows] == [ + False, + False, + ] + assert [ + path for path, _kwargs in client.get_calls if path.endswith("/permissions/workflow") + ] == ["/repos/acme/repo/actions/permissions/workflow"] From d97409430680867d66f7fd713a6eec2c822b89b4 Mon Sep 17 00:00:00 2001 From: Jared Atkinson Date: Sun, 6 Sep 2026 19:41:03 -0700 Subject: [PATCH 5/9] BED-9675: address workflow permission review feedback --- src/openhound_github/models/workflow.py | 2 +- src/openhound_github/transforms.py | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/openhound_github/models/workflow.py b/src/openhound_github/models/workflow.py index ac8b2c5..4c9c7e6 100644 --- a/src/openhound_github/models/workflow.py +++ b/src/openhound_github/models/workflow.py @@ -135,7 +135,7 @@ def normalize_permission_declaration(value: Any) -> list[str] | None: return [str(item) for item in value] if isinstance(value, dict): - return [f"{str(key)}:{str(item)}" for key, item in value.items()] + return [f"{key!s}:{item!s}" for key, item in value.items()] return [str(value)] diff --git a/src/openhound_github/transforms.py b/src/openhound_github/transforms.py index 32416dc..c3b4f6a 100644 --- a/src/openhound_github/transforms.py +++ b/src/openhound_github/transforms.py @@ -173,6 +173,8 @@ def ensure_optional_input_tables( repository_node_id VARCHAR ); CREATE TABLE IF NOT EXISTS {schema}.workflows ( + name VARCHAR, + path VARCHAR, repository_node_id VARCHAR, repository_default_workflow_permissions VARCHAR, repository_can_approve_pull_request_reviews BOOLEAN @@ -393,6 +395,10 @@ def ensure_optional_input_tables( ALTER TABLE {schema}.repo_runners ADD COLUMN IF NOT EXISTS repository_node_id VARCHAR; + ALTER TABLE {schema}.workflows + ADD COLUMN IF NOT EXISTS name VARCHAR; + ALTER TABLE {schema}.workflows + ADD COLUMN IF NOT EXISTS path VARCHAR; ALTER TABLE {schema}.workflows ADD COLUMN IF NOT EXISTS repository_node_id VARCHAR; ALTER TABLE {schema}.workflows From 0b37c0982817c469b6ff40cb2c1ad8a963d7fa26 Mon Sep 17 00:00:00 2001 From: Jared Atkinson Date: Sun, 6 Sep 2026 21:33:13 -0700 Subject: [PATCH 6/9] BED-9677: model workflow job interception by runners --- descriptions/edges/GH_CanAccessSecret.md | 7 + descriptions/edges/GH_CanInterceptJob.md | 7 + descriptions/nodes/GH_EnterpriseRunner.md | 2 + descriptions/nodes/GH_OrgRunner.md | 2 + descriptions/nodes/GH_RepoRunner.md | 2 + descriptions/nodes/GH_WorkflowJob.md | 2 + extension/saved_searches/README.md | 40 +-- ...unners-can-intercept-broad-token-jobs.json | 5 + ...ers-can-intercept-secret-bearing-jobs.json | 5 + ...-interceptable-by-self-hosted-runners.json | 5 + ...id-token-write-on-self-hosted-runners.json | 6 +- extension/schema.json | 10 + src/openhound_github/kinds/edges.py | 2 + src/openhound_github/lookup.py | 95 +++++++- src/openhound_github/models/runner.py | 5 + src/openhound_github/models/workflow_job.py | 213 +++++++++++----- .../models/workflow_reference.py | 58 +++++ src/openhound_github/models/workflow_step.py | 87 ++----- src/openhound_github/transforms.py | 18 ++ tests/test_lookup.py | 22 ++ tests/test_runner_models.py | 65 ++++- tests/test_workflow_interception_path.py | 230 ++++++++++++++++++ tests/test_workflow_model.py | 200 +++++++++++++++ 23 files changed, 928 insertions(+), 160 deletions(-) create mode 100644 descriptions/edges/GH_CanAccessSecret.md create mode 100644 descriptions/edges/GH_CanInterceptJob.md create mode 100644 extension/saved_searches/self-hosted-runners-can-intercept-broad-token-jobs.json create mode 100644 extension/saved_searches/shared-self-hosted-runners-can-intercept-secret-bearing-jobs.json create mode 100644 extension/saved_searches/workflow-jobs-interceptable-by-self-hosted-runners.json create mode 100644 src/openhound_github/models/workflow_reference.py create mode 100644 tests/test_workflow_interception_path.py diff --git a/descriptions/edges/GH_CanAccessSecret.md b/descriptions/edges/GH_CanAccessSecret.md new file mode 100644 index 0000000..3d94137 --- /dev/null +++ b/descriptions/edges/GH_CanAccessSecret.md @@ -0,0 +1,7 @@ +## General Information + +The traversable GH_CanAccessSecret edge represents that a GitHub Actions workflow job execution context can access a statically referenced secret. + +This edge is derived from the existing non-traversable GH_UsesSecret relationships on the job's contained steps and from job-level `env` declarations. It is intended for attack-path analysis from a compromised job execution context to the secrets that context can read. + +The collector only emits this edge when one of the workflow job's modeled steps or the job's `env` block statically references the secret. The existence of a secret in the repository, organization, or environment scope alone is not enough. For this initial implementation, secrets passed through `jobs..secrets` to reusable workflows are retained as structural references but are not projected as runtime access for the caller job. diff --git a/descriptions/edges/GH_CanInterceptJob.md b/descriptions/edges/GH_CanInterceptJob.md new file mode 100644 index 0000000..ea99ab1 --- /dev/null +++ b/descriptions/edges/GH_CanInterceptJob.md @@ -0,0 +1,7 @@ +## General Information + +The traversable GH_CanInterceptJob edge represents that a self-hosted runner not explicitly marked ephemeral can intercept a GitHub Actions workflow job that GitHub could schedule on it. + +This edge is derived from GH_RunsOn and is intended for attack-path analysis. It does not mean that the job has historically executed on the runner. It means that control of the runner may expose the future execution context of the job when the runner is not ephemeral. + +The collector does not emit this edge for runners GitHub explicitly marks as ephemeral. diff --git a/descriptions/nodes/GH_EnterpriseRunner.md b/descriptions/nodes/GH_EnterpriseRunner.md index f1d5291..4c819be 100644 --- a/descriptions/nodes/GH_EnterpriseRunner.md +++ b/descriptions/nodes/GH_EnterpriseRunner.md @@ -5,3 +5,5 @@ Represents a self-hosted runner owned at the GitHub Enterprise level. Enterprise The node captures runner metadata such as operating system, status, busy state, labels, and whether the runner is ephemeral when GitHub returns that property. GH_RunsOn edges from GH_WorkflowJob nodes identify statically resolvable jobs that GitHub could schedule on this runner through the inherited enterprise runner-group topology. These edges do not indicate that the job has actually executed on the runner. + +When the runner is not explicitly marked ephemeral, GH_CanInterceptJob edges identify workflow jobs whose future execution context may be exposed to an actor controlling the runner. diff --git a/descriptions/nodes/GH_OrgRunner.md b/descriptions/nodes/GH_OrgRunner.md index f94363b..70068b2 100644 --- a/descriptions/nodes/GH_OrgRunner.md +++ b/descriptions/nodes/GH_OrgRunner.md @@ -5,3 +5,5 @@ Represents a self-hosted runner owned by a GitHub organization. Organization run The node captures runner metadata such as operating system, status, busy state, labels, and whether the runner is ephemeral when GitHub returns that property. GH_RunsOn edges from GH_WorkflowJob nodes identify statically resolvable jobs that GitHub could schedule on this runner under the current runner-group access policy. These edges do not indicate that the job has actually executed on the runner. + +When the runner is not explicitly marked ephemeral, GH_CanInterceptJob edges identify workflow jobs whose future execution context may be exposed to an actor controlling the runner. diff --git a/descriptions/nodes/GH_RepoRunner.md b/descriptions/nodes/GH_RepoRunner.md index 61077d4..17290b4 100644 --- a/descriptions/nodes/GH_RepoRunner.md +++ b/descriptions/nodes/GH_RepoRunner.md @@ -5,3 +5,5 @@ Represents a self-hosted runner registered directly to a single GitHub repositor The node captures runner metadata such as operating system, status, busy state, labels, and whether the runner is ephemeral when GitHub returns that property. GH_RunsOn edges from GH_WorkflowJob nodes identify statically resolvable jobs in the containing repository that GitHub could schedule on this runner. These edges do not indicate that the job has actually executed on the runner. + +When the runner is not explicitly marked ephemeral, GH_CanInterceptJob edges identify workflow jobs whose future execution context may be exposed to an actor controlling the runner. diff --git a/descriptions/nodes/GH_WorkflowJob.md b/descriptions/nodes/GH_WorkflowJob.md index 95f5250..95bf400 100644 --- a/descriptions/nodes/GH_WorkflowJob.md +++ b/descriptions/nodes/GH_WorkflowJob.md @@ -5,3 +5,5 @@ Represents a single job within a GitHub Actions workflow. Jobs are the top-level When the job has a statically resolvable self-hosted `runs-on` selector, GH_RunsOn edges identify each GH_Runner that currently satisfies the declared label and runner-group constraints under the repository's runner access policy. These edges represent schedulability, not historical execution. When present, `job_permissions` captures the job-level `permissions` declaration from the workflow YAML. `effective_github_token_permissions` captures the calculated static `GITHUB_TOKEN` permissions after applying the repository default, workflow-level declaration, and job-level declaration. + +GH_CanAccessSecret edges identify secrets statically referenced by the job's modeled steps or job-level `env` block that the job execution context can access. GH_CanInterceptJob edges from GH_Runner nodes not explicitly marked ephemeral identify jobs whose future execution context may be exposed if that runner is controlled. diff --git a/extension/saved_searches/README.md b/extension/saved_searches/README.md index 555943d..27e887a 100644 --- a/extension/saved_searches/README.md +++ b/extension/saved_searches/README.md @@ -2,6 +2,15 @@ Pre-built Cypher queries for identifying security-relevant configurations across your GitHub organization. Each query is stored as an individual JSON file with `name`, `query`, and `description` fields, designed to be imported into BloodHound's saved queries feature. +## Runner Interception Analysis + +Use the runner interception searches as a progression: + +1. Start with `workflow-jobs-interceptable-by-self-hosted-runners.json` to inventory jobs whose future execution context may be exposed to collected self-hosted runners. +2. Prioritize `shared-self-hosted-runners-can-intercept-secret-bearing-jobs.json` because organization- and enterprise-scoped runners can create cross-repository blast radius. +3. Use `self-hosted-runners-can-intercept-broad-token-jobs.json` and `workflow-jobs-with-id-token-write-on-self-hosted-runners.json` to find interceptable jobs with high-impact GITHUB_TOKEN or OIDC permissions. +4. For a specific finding, follow `GH_RunsOn`, `GH_CanUseRunner`, `GH_CanAccessSecret`, and the workflow/job/step nodes to verify why the runner is eligible and what the job can access. + ## Severity Levels | Indicator | Severity | Description | @@ -80,24 +89,27 @@ Pre-built Cypher queries for identifying security-relevant configurations across | 40 | `org-roles-bypass-security-scanning.json` | Org Roles That Can Bypass Security Scanning | Finds organization roles with permissions to bypass or manage security scanning dismissals. These roles can suppress secret scanning and code scanning findings. | | 41 | `github-to-azure-identity.json` | GitHub-to-Azure Identity Assumptions | Finds GitHub entities (repositories, branches, environments) that can assume Azure identities via OIDC federation. Verify that each trust relationship is intentional and scoped appropriately. | | 42 | `workflow-jobs-with-id-token-write.json` | Workflow Jobs with OIDC Token Permission | Returns workflow jobs whose effective GITHUB_TOKEN permissions include `id-token:write`. | -| 43 | `workflow-jobs-with-id-token-write-on-self-hosted-runners.json` | OIDC-Capable Workflow Jobs on Self-Hosted Runners | Returns OIDC-capable jobs that can be scheduled on collected self-hosted runners. | +| 43 | `workflow-jobs-with-id-token-write-on-self-hosted-runners.json` | OIDC-Capable Workflow Jobs Interceptable by Self-Hosted Runners | Returns OIDC-capable jobs whose execution context may be exposed to collected self-hosted runners. | | 44 | `workflow-jobs-with-broad-token-write-permissions.json` | Workflow Jobs with Broad GITHUB_TOKEN Write Permissions | Returns jobs with effective write access to repository contents, Actions, or pull requests. | | 45 | `workflow-jobs-with-observed-oidc-auth-steps.json` | Workflow Jobs with Observed OIDC Authentication Steps | Returns OIDC-capable jobs with descendant steps that show likely token consumption. | +| 46 | `workflow-jobs-interceptable-by-self-hosted-runners.json` | Workflow Jobs Interceptable by Self-Hosted Runners | Returns workflow jobs whose future execution context may be exposed to collected self-hosted runners. | +| 47 | `shared-self-hosted-runners-can-intercept-secret-bearing-jobs.json` | Shared Self-Hosted Runners Can Intercept Secret-Bearing Jobs | Returns shared organization- and enterprise-scoped runners that can intercept jobs with secret access. | +| 48 | `self-hosted-runners-can-intercept-broad-token-jobs.json` | Self-Hosted Runners Can Intercept Broad-Token Jobs | Returns self-hosted runners that can intercept jobs with high-impact GITHUB_TOKEN write permissions. | ### :white_circle: Low — Hygiene & Governance | # | File | Name | Description | |---|------|------|-------------| -| 46 | `environments-admin-bypass.json` | Environments Where Admins Can Bypass Protections | Finds deployment environments where administrators can bypass protection rules such as required reviewers and wait timers. Admins can deploy to these environments without any approval. | -| 47 | `app-installations-all-repos.json` | App Installations with Access to All Repositories | Finds GitHub App installations that have access to every repository in the organization. A compromised app credential would affect all repositories. | -| 48 | `users-without-external-identity.json` | GitHub Users Without External Identity Mapping | Finds GitHub users that are not linked to any external identity via SAML or SCIM. These users cannot be centrally offboarded through the identity provider and may retain access after employment ends. | -| 49 | `external-identities-without-scim.json` | External Identities Without SCIM Provisioning | Finds external identities that lack SCIM synchronization. Without SCIM, user deprovisioning in the identity provider will not automatically revoke GitHub access. | -| 50 | `org-owners.json` | Organization Owners | Returns all users who hold the organization owners role. | -| 51 | `privileged-custom-org-roles.json` | Privileged Custom Org Roles | Returns all custom organization roles that are privileged (i.e., have permissions that are not default). | -| 52 | `global-repo-perms.json` | Global Repo Permissions | Returns all users who hold a global repository permission role (i.e., roles that are not default). | -| 53 | `hybrid-identities.json` | External Identities | Returns all external identities (e.g., Azure or Okta users) that are associated with GitHub users. | -| 54 | `privileged-hybrid-identities.json` | Privileged Hybrid Identities | Returns all hybrid identities (e.g., Azure or Okta users) that are associated with GitHub users who hold the organization owners role. | -| 55 | `saml-configuration.json` | SAML Configuration Mapping | Finds SAML Identity Providers, their external identities, and mapped users. | -| 56 | `team-membership-admin.json` | Team Membership Admins | Returns all users who hold the maintainer role over a team, including team nesting. | -| 57 | `team-structure.json` | Team Structure | Returns the structure of teams within organizations, including team roles and their members. | -| 58 | `repository-workflows.json` | Repository Workflows | Returns all repository workflows. | +| 49 | `environments-admin-bypass.json` | Environments Where Admins Can Bypass Protections | Finds deployment environments where administrators can bypass protection rules such as required reviewers and wait timers. Admins can deploy to these environments without any approval. | +| 50 | `app-installations-all-repos.json` | App Installations with Access to All Repositories | Finds GitHub App installations that have access to every repository in the organization. A compromised app credential would affect all repositories. | +| 51 | `users-without-external-identity.json` | GitHub Users Without External Identity Mapping | Finds GitHub users that are not linked to any external identity via SAML or SCIM. These users cannot be centrally offboarded through the identity provider and may retain access after employment ends. | +| 52 | `external-identities-without-scim.json` | External Identities Without SCIM Provisioning | Finds external identities that lack SCIM synchronization. Without SCIM, user deprovisioning in the identity provider will not automatically revoke GitHub access. | +| 53 | `org-owners.json` | Organization Owners | Returns all users who hold the organization owners role. | +| 54 | `privileged-custom-org-roles.json` | Privileged Custom Org Roles | Returns all custom organization roles that are privileged (i.e., have permissions that are not default). | +| 55 | `global-repo-perms.json` | Global Repo Permissions | Returns all users who hold a global repository permission role (i.e., roles that are not default). | +| 56 | `hybrid-identities.json` | External Identities | Returns all external identities (e.g., Azure or Okta users) that are associated with GitHub users. | +| 57 | `privileged-hybrid-identities.json` | Privileged Hybrid Identities | Returns all hybrid identities (e.g., Azure or Okta users) that are associated with GitHub users who hold the organization owners role. | +| 58 | `saml-configuration.json` | SAML Configuration Mapping | Finds SAML Identity Providers, their external identities, and mapped users. | +| 59 | `team-membership-admin.json` | Team Membership Admins | Returns all users who hold the maintainer role over a team, including team nesting. | +| 60 | `team-structure.json` | Team Structure | Returns the structure of teams within organizations, including team roles and their members. | +| 61 | `repository-workflows.json` | Repository Workflows | Returns all repository workflows. | diff --git a/extension/saved_searches/self-hosted-runners-can-intercept-broad-token-jobs.json b/extension/saved_searches/self-hosted-runners-can-intercept-broad-token-jobs.json new file mode 100644 index 0000000..f47717c --- /dev/null +++ b/extension/saved_searches/self-hosted-runners-can-intercept-broad-token-jobs.json @@ -0,0 +1,5 @@ +{ + "name": "GitHub: Self-Hosted Runners Can Intercept Broad-Token Jobs", + "query": "MATCH p=(runner:GH_Runner)-[:GH_CanInterceptJob]->(job:GH_WorkflowJob)\nWHERE 'contents:write' IN job.effective_github_token_permissions\nOR 'actions:write' IN job.effective_github_token_permissions\nOR 'pull-requests:write' IN job.effective_github_token_permissions\nRETURN p\nLIMIT 1000", + "description": "Returns self-hosted runners that can intercept workflow jobs whose effective GITHUB_TOKEN has high-impact write permissions. These jobs can materially increase the blast radius of runner compromise." +} diff --git a/extension/saved_searches/shared-self-hosted-runners-can-intercept-secret-bearing-jobs.json b/extension/saved_searches/shared-self-hosted-runners-can-intercept-secret-bearing-jobs.json new file mode 100644 index 0000000..deb8fcf --- /dev/null +++ b/extension/saved_searches/shared-self-hosted-runners-can-intercept-secret-bearing-jobs.json @@ -0,0 +1,5 @@ +{ + "name": "GitHub: Shared Self-Hosted Runners Can Intercept Secret-Bearing Jobs", + "query": "MATCH p=(runner:GH_Runner)-[:GH_CanInterceptJob]->(job:GH_WorkflowJob)-[:GH_CanAccessSecret]->(secret:GH_Secret)\nWHERE runner:GH_OrgRunner\nOR runner:GH_EnterpriseRunner\nRETURN p\nLIMIT 1000", + "description": "Returns organization- and enterprise-scoped self-hosted runners that can intercept workflow jobs with statically resolvable secret access. Shared runners are especially important to review because compromise can expose execution contexts across multiple repositories." +} diff --git a/extension/saved_searches/workflow-jobs-interceptable-by-self-hosted-runners.json b/extension/saved_searches/workflow-jobs-interceptable-by-self-hosted-runners.json new file mode 100644 index 0000000..c88845d --- /dev/null +++ b/extension/saved_searches/workflow-jobs-interceptable-by-self-hosted-runners.json @@ -0,0 +1,5 @@ +{ + "name": "GitHub: Workflow Jobs Interceptable by Self-Hosted Runners", + "query": "MATCH p=(runner:GH_Runner)-[:GH_CanInterceptJob]->(job:GH_WorkflowJob)\nRETURN p\nLIMIT 1000", + "description": "Returns workflow jobs whose future execution context may be exposed to an actor controlling a collected self-hosted runner that is not explicitly marked ephemeral." +} diff --git a/extension/saved_searches/workflow-jobs-with-id-token-write-on-self-hosted-runners.json b/extension/saved_searches/workflow-jobs-with-id-token-write-on-self-hosted-runners.json index 202c171..50eea1c 100644 --- a/extension/saved_searches/workflow-jobs-with-id-token-write-on-self-hosted-runners.json +++ b/extension/saved_searches/workflow-jobs-with-id-token-write-on-self-hosted-runners.json @@ -1,5 +1,5 @@ { - "name": "GitHub: OIDC-Capable Workflow Jobs on Self-Hosted Runners", - "query": "MATCH p=(repo:GH_Repository)-[:GH_Contains]->(:GH_Workflow)-[:GH_Contains]->(job:GH_WorkflowJob)-[:GH_RunsOn]->(:GH_Runner)\nWHERE 'id-token:write' IN job.effective_github_token_permissions\nRETURN p\nLIMIT 1000", - "description": "Returns OIDC-capable workflow jobs that can be scheduled on collected self-hosted runners. These jobs are especially important to review because runner compromise could expose short-lived cloud federation tokens." + "name": "GitHub: OIDC-Capable Workflow Jobs Interceptable by Self-Hosted Runners", + "query": "MATCH p=(runner:GH_Runner)-[:GH_CanInterceptJob]->(job:GH_WorkflowJob)\nWHERE 'id-token:write' IN job.effective_github_token_permissions\nRETURN p\nLIMIT 1000", + "description": "Returns OIDC-capable workflow jobs whose execution context may be exposed to an actor controlling a collected self-hosted runner that is not explicitly marked ephemeral. These jobs are especially important to review because runner compromise could expose short-lived cloud federation tokens." } diff --git a/extension/schema.json b/extension/schema.json index c0c67a9..fa63a17 100644 --- a/extension/schema.json +++ b/extension/schema.json @@ -1035,6 +1035,11 @@ "description": "[Workflow] Job can be scheduled on this self-hosted runner based on its static runs-on selector — GH_WorkflowJob → GH_Runner", "is_traversable": false }, + { + "name": "GH_CanInterceptJob", + "description": "[Computed] Persistent self-hosted runner can intercept a workflow job that may be scheduled on it — GH_Runner → GH_WorkflowJob", + "is_traversable": true + }, { "name": "GH_HasMember", "description": "Enterprise or organization has this user as a member", @@ -1045,6 +1050,11 @@ "description": "[Workflow] Job or step references a secret by name — GH_WorkflowJob / GH_WorkflowStep → GH_RepoSecret / GH_OrgSecret / GH_EnvironmentSecret (scope match)", "is_traversable": false }, + { + "name": "GH_CanAccessSecret", + "description": "[Computed] Workflow job execution context can access a statically referenced secret — GH_WorkflowJob → GH_RepoSecret / GH_OrgSecret / GH_EnvironmentSecret", + "is_traversable": true + }, { "name": "GH_UsesVariable", "description": "[Workflow] Job or step references a variable by name — GH_WorkflowJob / GH_WorkflowStep → GH_RepoVariable / GH_OrgVariable / GH_EnvironmentVariable (scope match)", diff --git a/src/openhound_github/kinds/edges.py b/src/openhound_github/kinds/edges.py index a87d2d0..da2a377 100644 --- a/src/openhound_github/kinds/edges.py +++ b/src/openhound_github/kinds/edges.py @@ -19,6 +19,8 @@ CAN_ACCESS = "GH_CanAccess" CAN_USE_RUNNER = "GH_CanUseRunner" RUNS_ON = "GH_RunsOn" +CAN_INTERCEPT_JOB = "GH_CanInterceptJob" +CAN_ACCESS_SECRET = "GH_CanAccessSecret" IS_ELIGIBLE_FOR = "GH_IsEligibleFor" CAN_CREATE_REPOSITORY_WITH_RUNNER_ACCESS = "GH_CanCreateRepositoryWithRunnerAccess" CAN_CREATE_BRANCH = "GH_CanCreateBranch" diff --git a/src/openhound_github/lookup.py b/src/openhound_github/lookup.py index 8bba5fb..68beac1 100644 --- a/src/openhound_github/lookup.py +++ b/src/openhound_github/lookup.py @@ -218,13 +218,13 @@ def _runner_group_allows_repository( return True @lru_cache - def workflow_job_runner_node_ids( + def _workflow_job_runner_matches( self, repository_node_id: str, org_login: str, group_name: str | None, labels: tuple[str, ...], - ) -> list[str]: + ) -> list[tuple[str, bool | None]]: """Return accessible self-hosted runners matching a static runs-on selector.""" if not group_name and not labels: return [] @@ -243,21 +243,23 @@ def workflow_job_runner_node_ids( repository_visibility, actions_enabled = repository required_labels = {str(label).casefold() for label in labels} - matching_runner_node_ids: list[str] = [] + matching_runners: list[tuple[str, bool | None]] = [] seen_runner_node_ids: set[str] = set() - def add_matching_runner(node_id: str, raw_labels: Any) -> None: + def add_matching_runner( + node_id: str, raw_labels: Any, ephemeral: bool | None + ) -> None: if node_id in seen_runner_node_ids: return if not required_labels.issubset(self._runner_label_names(raw_labels)): return seen_runner_node_ids.add(node_id) - matching_runner_node_ids.append(node_id) + matching_runners.append((node_id, ephemeral)) if group_name is None: - for runner_id, raw_labels in self._find_all_objects( + for runner_id, raw_labels, ephemeral in self._find_all_objects( f""" - SELECT id, labels + SELECT id, labels, ephemeral FROM {self.schema}.repo_runners WHERE repository_node_id = ? """, @@ -266,6 +268,7 @@ def add_matching_runner(node_id: str, raw_labels: Any) -> None: add_matching_runner( runner_node_id(repository_node_id, int(runner_id)), raw_labels, + ephemeral, ) if actions_enabled is not True: @@ -329,9 +332,9 @@ def add_matching_runner(node_id: str, raw_labels: Any) -> None: if not identity: continue enterprise_node_id, enterprise_runner_group_id = identity - for runner_id, raw_labels in self._find_all_objects( + for runner_id, raw_labels, ephemeral in self._find_all_objects( f""" - SELECT r.id, r.labels + SELECT r.id, r.labels, r.ephemeral FROM {self.schema}.enterprise_runner_group_memberships m JOIN {self.schema}.enterprise_runners r ON r.enterprise_node_id = m.enterprise_node_id @@ -344,12 +347,13 @@ def add_matching_runner(node_id: str, raw_labels: Any) -> None: add_matching_runner( runner_node_id(enterprise_node_id, int(runner_id)), raw_labels, + ephemeral, ) continue - for runner_id, raw_labels in self._find_all_objects( + for runner_id, raw_labels, ephemeral in self._find_all_objects( f""" - SELECT r.id, r.labels + SELECT r.id, r.labels, r.ephemeral FROM {self.schema}.org_runner_group_memberships m JOIN {self.schema}.org_runners r ON r.org_login = m.org_login @@ -362,9 +366,76 @@ def add_matching_runner(node_id: str, raw_labels: Any) -> None: add_matching_runner( runner_node_id(self.org_id_for_login(org_login), int(runner_id)), raw_labels, + ephemeral, ) - return matching_runner_node_ids + return matching_runners + + @lru_cache + def workflow_job_runner_node_ids( + self, + repository_node_id: str, + org_login: str, + group_name: str | None, + labels: tuple[str, ...], + ) -> list[str]: + """Return accessible self-hosted runner node IDs matching a static selector.""" + return [ + runner_node_id + for runner_node_id, _ephemeral in self._workflow_job_runner_matches( + repository_node_id, + org_login, + group_name, + labels, + ) + ] + + @lru_cache + def workflow_job_interceptable_runner_node_ids( + self, + repository_node_id: str, + org_login: str, + group_name: str | None, + labels: tuple[str, ...], + ) -> list[str]: + """Return matching runner node IDs not explicitly marked ephemeral.""" + return [ + runner_node_id + for runner_node_id, ephemeral in self._workflow_job_runner_matches( + repository_node_id, + org_login, + group_name, + labels, + ) + if ephemeral is not True + ] + + @lru_cache + def workflow_step_secret_reference_names(self, job_node_id: str) -> list[str]: + """Return unique secret names statically referenced by steps in a job.""" + names: list[str] = [] + seen: set[str] = set() + for (raw_references,) in self._find_all_objects( + f""" + SELECT secret_references + FROM {self.schema}.workflow_steps + WHERE job_node_id = ? + """, + [job_node_id], + ): + for reference in self._json_list(raw_references): + if not isinstance(reference, dict): + continue + name = reference.get("name") + if name is None: + continue + name = str(name) + key = name.casefold() + if key in seen: + continue + seen.add(key) + names.append(name) + return names @lru_cache def enterprise_idp_for_scope( diff --git a/src/openhound_github/models/runner.py b/src/openhound_github/models/runner.py index 172a905..619e24f 100644 --- a/src/openhound_github/models/runner.py +++ b/src/openhound_github/models/runner.py @@ -317,6 +317,7 @@ class GHRunnerProperties(GHNodeProperties): query_group: Query for group. query_repositories: Query for repositories. query_jobs: Query for workflow jobs that can be scheduled on the runner. + query_interceptable_jobs: Query for workflow jobs the runner can intercept. """ scope: str | None = None @@ -336,6 +337,7 @@ class GHRunnerProperties(GHNodeProperties): query_group: str | None = None query_repositories: str | None = None query_jobs: str | None = None + query_interceptable_jobs: str | None = None @app.asset( @@ -387,6 +389,7 @@ def as_node(self) -> GHNode: query_group=f"MATCH p=(:GH_OrgRunnerGroup)-[:GH_HasRunner]->(:GH_OrgRunner {{node_id:'{rid}'}}) RETURN p", query_repositories=f"MATCH p=(:GH_Repository)-[:GH_CanUseRunner]->(:GH_OrgRunnerGroup)-[:GH_HasRunner]->(:GH_OrgRunner {{node_id:'{rid}'}}) RETURN p", query_jobs=f"MATCH p=(:GH_WorkflowJob)-[:GH_RunsOn]->(:GH_Runner {{node_id:'{rid}'}}) RETURN p", + query_interceptable_jobs=f"MATCH p=(:GH_Runner {{node_id:'{rid}'}})-[:GH_CanInterceptJob]->(:GH_WorkflowJob) RETURN p", ), ) @@ -441,6 +444,7 @@ def as_node(self) -> GHNode: query_group=f"MATCH p=(:GH_EnterpriseRunnerGroup)-[:GH_HasRunner]->(:GH_EnterpriseRunner {{node_id:'{rid}'}}) RETURN p", query_repositories=f"MATCH p=(:GH_Repository)-[:GH_CanUseRunner]->(:GH_OrgRunnerGroup)-[:GH_InheritedFrom]->(:GH_EnterpriseRunnerGroup)-[:GH_HasRunner]->(:GH_EnterpriseRunner {{node_id:'{rid}'}}) RETURN p", query_jobs=f"MATCH p=(:GH_WorkflowJob)-[:GH_RunsOn]->(:GH_Runner {{node_id:'{rid}'}}) RETURN p", + query_interceptable_jobs=f"MATCH p=(:GH_Runner {{node_id:'{rid}'}})-[:GH_CanInterceptJob]->(:GH_WorkflowJob) RETURN p", ), ) @@ -878,6 +882,7 @@ def as_node(self) -> GHNode: environmentid=self.org_node_id, query_repositories=f"MATCH p=(:GH_Repository {{node_id:'{self.repository_node_id}'}})-[:GH_CanUseRunner]->(:GH_RepoRunner {{node_id:'{rid}'}}) RETURN p", query_jobs=f"MATCH p=(:GH_WorkflowJob)-[:GH_RunsOn]->(:GH_Runner {{node_id:'{rid}'}}) RETURN p", + query_interceptable_jobs=f"MATCH p=(:GH_Runner {{node_id:'{rid}'}})-[:GH_CanInterceptJob]->(:GH_WorkflowJob) RETURN p", ), ) diff --git a/src/openhound_github/models/workflow_job.py b/src/openhound_github/models/workflow_job.py index 6b256e8..4b48ec6 100644 --- a/src/openhound_github/models/workflow_job.py +++ b/src/openhound_github/models/workflow_job.py @@ -15,9 +15,9 @@ EdgeProperties, PropertyMatch, ) -from pydantic import BaseModel, Field, field_validator, model_validator +from pydantic import Field, field_validator, model_validator -from openhound_github.graph import GHNode, GHNodeProperties +from openhound_github.graph import GHEdgeProperties, GHNode, GHNodeProperties from openhound_github.kinds import edges as ek from openhound_github.kinds import nodes as nk from openhound_github.main import app @@ -26,15 +26,14 @@ parse_runs_on_selector, resolve_effective_github_token_permissions, ) +from openhound_github.models.workflow_reference import ( + WorkflowReference, + resolved_secret_targets, +) TEMPLATE_RE = re.compile(r"\$\{\{\s*[^}]+?\s*\}\}") -class WorkflowReference(BaseModel): - name: str - context: str | None = None - - @dataclass class GHWorkflowJobProperties(GHNodeProperties): """Workflow job-specific properties. @@ -60,6 +59,7 @@ class GHWorkflowJobProperties(GHNodeProperties): query_steps: Query for workflow steps. query_references: Query for workflow references (secrets and variables). query_runners: Query for eligible self-hosted runners. + query_accessible_secrets: Query for secrets accessible to the job execution context. """ job_key: str | None = None @@ -82,6 +82,7 @@ class GHWorkflowJobProperties(GHNodeProperties): query_steps: str | None = None query_references: str | None = None query_runners: str | None = None + query_accessible_secrets: str | None = None @app.asset( @@ -169,6 +170,34 @@ class GHWorkflowJobProperties(GHNodeProperties): description="Workflow job can be scheduled on self-hosted runner", traversable=False, ), + EdgeDef( + start=nk.RUNNER, + end=nk.WORKFLOW_JOB, + kind=ek.CAN_INTERCEPT_JOB, + description="Persistent self-hosted runner can intercept workflow job execution", + traversable=True, + ), + EdgeDef( + start=nk.WORKFLOW_JOB, + end=nk.REPO_SECRET, + kind=ek.CAN_ACCESS_SECRET, + description="Workflow job execution context can access repository secret", + traversable=True, + ), + EdgeDef( + start=nk.WORKFLOW_JOB, + end=nk.ORG_SECRET, + kind=ek.CAN_ACCESS_SECRET, + description="Workflow job execution context can access organization secret", + traversable=True, + ), + EdgeDef( + start=nk.WORKFLOW_JOB, + end=nk.ENVIRONMENT_SECRET, + kind=ek.CAN_ACCESS_SECRET, + description="Workflow job execution context can access environment secret", + traversable=True, + ), ], ) class WorkflowJob(BaseAsset): @@ -280,66 +309,34 @@ def as_node(self) -> GHNode: query_steps=f"MATCH p=(:GH_WorkflowJob {{node_id:'{jid}'}})-[:GH_Contains]->(:GH_WorkflowStep) RETURN p", query_references=f"MATCH p=(:GH_WorkflowJob {{node_id:'{jid}'}})-[:GH_Contains]->(step:GH_WorkflowStep) OPTIONAL MATCH p1=(step)-[:GH_UsesSecret]->() OPTIONAL MATCH p2=(step)-[:GH_UsesVariable]->() RETURN p,p1,p2", query_runners=f"MATCH p=(:GH_WorkflowJob {{node_id:'{jid}'}})-[:GH_RunsOn]->(:GH_Runner) RETURN p", + query_accessible_secrets=f"MATCH p=(:GH_WorkflowJob {{node_id:'{jid}'}})-[:GH_CanAccessSecret]->(:GH_Secret) RETURN p", ), ) + def _resolved_secret_targets(self, references: list[WorkflowReference]): + return resolved_secret_targets( + self._lookup, + references, + repository_node_id=self.repository_node_id, + org_login=self.org_login, + org_node_id=self.org_node_id, + environment=self.environment, + ) + @property def _uses_secret_edges(self): - for ref in self.secret_references: - if self._lookup.repo_secret(ref.name, self.repository_node_id): - yield Edge( - kind=ek.USES_SECRET, - start=EdgePath(value=self.node_id, match_by="id"), - end=ConditionalEdgePath( - kind=nk.REPO_SECRET, - property_matchers=[ - PropertyMatch(key="name", value=ref.name.upper()), - PropertyMatch( - key="repository_id", value=self.repository_node_id - ), - ], - ), - properties=EdgeProperties(traversable=False), - ) - - if self._lookup.org_secret(ref.name, self.org_login): - yield Edge( - kind=ek.USES_SECRET, - start=EdgePath(value=self.node_id, match_by="id"), - end=ConditionalEdgePath( - kind=nk.ORG_SECRET, - property_matchers=[ - PropertyMatch(key="name", value=ref.name.upper()), - PropertyMatch( - key="environmentid", value=self.org_node_id - ), - ], - ), - properties=EdgeProperties(traversable=False), - ) - - if self.environment and "${{" not in self.environment: - if self._lookup.environment_secret_for_environment( - ref.name, self.repository_node_id, self.environment - ): - yield Edge( - kind=ek.USES_SECRET, - start=EdgePath(value=self.node_id, match_by="id"), - end=ConditionalEdgePath( - kind=nk.ENVIRONMENT_SECRET, - property_matchers=[ - PropertyMatch(key="name", value=ref.name.upper()), - PropertyMatch( - key="deployment_environment_name", - value=self.environment, - ), - PropertyMatch( - key="repository_id", value=self.repository_node_id - ), - ], - ), - properties=EdgeProperties(traversable=False), - ) + for kind, property_matchers in self._resolved_secret_targets( + self.secret_references + ): + yield Edge( + kind=ek.USES_SECRET, + start=EdgePath(value=self.node_id, match_by="id"), + end=ConditionalEdgePath( + kind=kind, + property_matchers=property_matchers, + ), + properties=EdgeProperties(traversable=False), + ) @property def _uses_variable_edges(self): @@ -475,6 +472,96 @@ def _runs_on_edges(self): properties=EdgeProperties(traversable=False), ) + @property + def _can_intercept_job_edges(self): + if self.runs_on_is_dynamic: + return + + for runner_node_id in self._lookup.workflow_job_interceptable_runner_node_ids( + self.repository_node_id, + self.org_login, + self.runs_on_group, + tuple(self.runs_on_labels or ()), + ): + yield Edge( + kind=ek.CAN_INTERCEPT_JOB, + start=EdgePath(value=runner_node_id, match_by="id"), + end=EdgePath(value=self.node_id, match_by="id"), + properties=GHEdgeProperties( + traversable=True, + composed=True, + query_composition=( + f"MATCH p=(:GH_WorkflowJob {{node_id:'{self.node_id}'}})" + f"-[:GH_RunsOn]->(:GH_Runner {{node_id:'{runner_node_id}'}}) " + "RETURN p" + ), + ), + ) + + def _can_access_secret_query( + self, + secret_kind: str, + property_matchers: list[PropertyMatch], + source: str, + ) -> str: + properties = ", ".join( + f"{matcher.key}:'{matcher.value}'" for matcher in property_matchers + ) + if source == "job": + return ( + f"MATCH p=(:GH_WorkflowJob {{node_id:'{self.node_id}'}})" + f"-[:GH_UsesSecret]->(:{secret_kind} {{{properties}}}) RETURN p" + ) + return ( + f"MATCH p=(:GH_WorkflowJob {{node_id:'{self.node_id}'}})" + "-[:GH_Contains]->(:GH_WorkflowStep)" + f"-[:GH_UsesSecret]->(:{secret_kind} {{{properties}}}) RETURN p" + ) + + @property + def _job_runtime_secret_references(self) -> list[WorkflowReference]: + return [ + ref + for ref in self.secret_references + if ref.context is not None and ref.context.startswith("env:") + ] + + @property + def _can_access_secret_edges(self): + references = [ + ( + "step", + WorkflowReference(name=name), + ) + for name in self._lookup.workflow_step_secret_reference_names(self.node_id) + ] + references.extend(("job", ref) for ref in self._job_runtime_secret_references) + seen_targets: set[tuple[str, tuple[tuple[str, str | None], ...]]] = set() + for source, reference in references: + for kind, property_matchers in self._resolved_secret_targets([reference]): + target = ( + kind, + tuple((matcher.key, matcher.value) for matcher in property_matchers), + ) + if target in seen_targets: + continue + seen_targets.add(target) + yield Edge( + kind=ek.CAN_ACCESS_SECRET, + start=EdgePath(value=self.node_id, match_by="id"), + end=ConditionalEdgePath( + kind=kind, + property_matchers=property_matchers, + ), + properties=GHEdgeProperties( + traversable=True, + composed=True, + query_composition=self._can_access_secret_query( + kind, property_matchers, source + ), + ), + ) + @property def edges(self): yield from self._calls_workflows_edge @@ -484,3 +571,5 @@ def edges(self): yield from self._uses_secret_edges yield from self._uses_variable_edges yield from self._runs_on_edges + yield from self._can_intercept_job_edges + yield from self._can_access_secret_edges diff --git a/src/openhound_github/models/workflow_reference.py b/src/openhound_github/models/workflow_reference.py new file mode 100644 index 0000000..de235e9 --- /dev/null +++ b/src/openhound_github/models/workflow_reference.py @@ -0,0 +1,58 @@ +from collections.abc import Iterable, Iterator +from typing import Any + +from openhound.core.models.entries_dataclass import PropertyMatch # type: ignore[import-untyped] +from pydantic import BaseModel + +from openhound_github.kinds import nodes as nk + + +class WorkflowReference(BaseModel): + name: str + context: str | None = None + + +def resolved_secret_targets( + lookup: Any, + references: Iterable[WorkflowReference], + *, + repository_node_id: str, + org_login: str, + org_node_id: str | None, + environment: str | None, +) -> Iterator[tuple[str, list[PropertyMatch]]]: + """Resolve statically referenced secret names to graph targets by scope.""" + for ref in references: + if lookup.repo_secret(ref.name, repository_node_id): + yield ( + nk.REPO_SECRET, + [ + PropertyMatch(key="name", value=ref.name.upper()), + PropertyMatch(key="repository_id", value=repository_node_id), + ], + ) + + if lookup.org_secret(ref.name, org_login): + yield ( + nk.ORG_SECRET, + [ + PropertyMatch(key="name", value=ref.name.upper()), + PropertyMatch(key="environmentid", value=org_node_id), + ], + ) + + if environment and "${{" not in environment: + if lookup.environment_secret_for_environment( + ref.name, repository_node_id, environment + ): + yield ( + nk.ENVIRONMENT_SECRET, + [ + PropertyMatch(key="name", value=ref.name.upper()), + PropertyMatch( + key="deployment_environment_name", + value=environment, + ), + PropertyMatch(key="repository_id", value=repository_node_id), + ], + ) diff --git a/src/openhound_github/models/workflow_step.py b/src/openhound_github/models/workflow_step.py index c2ac6d7..3cd54b4 100644 --- a/src/openhound_github/models/workflow_step.py +++ b/src/openhound_github/models/workflow_step.py @@ -15,17 +15,16 @@ EdgeProperties, PropertyMatch, ) -from pydantic import BaseModel, Field +from pydantic import Field from openhound_github.graph import GHNode, GHNodeProperties from openhound_github.kinds import edges as ek from openhound_github.kinds import nodes as nk from openhound_github.main import app - - -class WorkflowReference(BaseModel): - name: str - context: str | None = None +from openhound_github.models.workflow_reference import ( + WorkflowReference, + resolved_secret_targets, +) @dataclass @@ -193,62 +192,30 @@ def as_node(self) -> GHNode: ), ) + def _resolved_secret_targets(self, references: list[WorkflowReference]): + return resolved_secret_targets( + self._lookup, + references, + repository_node_id=self.repository_node_id, + org_login=self.org_login, + org_node_id=self.org_node_id, + environment=self.job_environment, + ) + @property def _uses_secret_edges(self): - for ref in self.secret_references: - if self._lookup.repo_secret(ref.name, self.repository_node_id): - yield Edge( - kind=ek.USES_SECRET, - start=EdgePath(value=self.node_id, match_by="id"), - end=ConditionalEdgePath( - kind=nk.REPO_SECRET, - property_matchers=[ - PropertyMatch(key="name", value=ref.name.upper()), - PropertyMatch( - key="repository_id", value=self.repository_node_id - ), - ], - ), - properties=EdgeProperties(traversable=False), - ) - if self._lookup.org_secret(ref.name, self.org_login): - yield Edge( - kind=ek.USES_SECRET, - start=EdgePath(value=self.node_id, match_by="id"), - end=ConditionalEdgePath( - kind=nk.ORG_SECRET, - property_matchers=[ - PropertyMatch(key="name", value=ref.name.upper()), - PropertyMatch( - key="environmentid", value=self.org_node_id - ), - ], - ), - properties=EdgeProperties(traversable=False), - ) - - if self.job_environment and "${{" not in self.job_environment: - if self._lookup.environment_secret_for_environment( - ref.name, self.repository_node_id, self.job_environment - ): - yield Edge( - kind=ek.USES_SECRET, - start=EdgePath(value=self.node_id, match_by="id"), - end=ConditionalEdgePath( - kind=nk.ENVIRONMENT_SECRET, - property_matchers=[ - PropertyMatch(key="name", value=ref.name.upper()), - PropertyMatch( - key="deployment_environment_name", - value=self.job_environment, - ), - PropertyMatch( - key="repository_id", value=self.repository_node_id - ), - ], - ), - properties=EdgeProperties(traversable=False), - ) + for kind, property_matchers in self._resolved_secret_targets( + self.secret_references + ): + yield Edge( + kind=ek.USES_SECRET, + start=EdgePath(value=self.node_id, match_by="id"), + end=ConditionalEdgePath( + kind=kind, + property_matchers=property_matchers, + ), + properties=EdgeProperties(traversable=False), + ) @property def _uses_variable_edges(self): diff --git a/src/openhound_github/transforms.py b/src/openhound_github/transforms.py index c3b4f6a..2b5abf6 100644 --- a/src/openhound_github/transforms.py +++ b/src/openhound_github/transforms.py @@ -140,6 +140,7 @@ def ensure_optional_input_tables( CREATE TABLE IF NOT EXISTS {schema}.enterprise_runners ( id BIGINT, labels JSON, + ephemeral BOOLEAN, enterprise_node_id VARCHAR ); CREATE TABLE IF NOT EXISTS {schema}.runner_groups ( @@ -150,6 +151,7 @@ def ensure_optional_input_tables( CREATE TABLE IF NOT EXISTS {schema}.org_runners ( id BIGINT, labels JSON, + ephemeral BOOLEAN, org_login VARCHAR ); CREATE TABLE IF NOT EXISTS {schema}.org_runner_group_access ( @@ -170,6 +172,7 @@ def ensure_optional_input_tables( CREATE TABLE IF NOT EXISTS {schema}.repo_runners ( id BIGINT, labels JSON, + ephemeral BOOLEAN, repository_node_id VARCHAR ); CREATE TABLE IF NOT EXISTS {schema}.workflows ( @@ -179,6 +182,10 @@ def ensure_optional_input_tables( repository_default_workflow_permissions VARCHAR, repository_can_approve_pull_request_reviews BOOLEAN ); + CREATE TABLE IF NOT EXISTS {schema}.workflow_steps ( + job_node_id VARCHAR, + secret_references JSON + ); """) con.execute(f""" ALTER TABLE {schema}.branches @@ -347,6 +354,8 @@ def ensure_optional_input_tables( ADD COLUMN IF NOT EXISTS id BIGINT; ALTER TABLE {schema}.enterprise_runners ADD COLUMN IF NOT EXISTS labels JSON; + ALTER TABLE {schema}.enterprise_runners + ADD COLUMN IF NOT EXISTS ephemeral BOOLEAN; ALTER TABLE {schema}.enterprise_runners ADD COLUMN IF NOT EXISTS enterprise_node_id VARCHAR; @@ -361,6 +370,8 @@ def ensure_optional_input_tables( ADD COLUMN IF NOT EXISTS id BIGINT; ALTER TABLE {schema}.org_runners ADD COLUMN IF NOT EXISTS labels JSON; + ALTER TABLE {schema}.org_runners + ADD COLUMN IF NOT EXISTS ephemeral BOOLEAN; ALTER TABLE {schema}.org_runners ADD COLUMN IF NOT EXISTS org_login VARCHAR; @@ -392,6 +403,8 @@ def ensure_optional_input_tables( ADD COLUMN IF NOT EXISTS id BIGINT; ALTER TABLE {schema}.repo_runners ADD COLUMN IF NOT EXISTS labels JSON; + ALTER TABLE {schema}.repo_runners + ADD COLUMN IF NOT EXISTS ephemeral BOOLEAN; ALTER TABLE {schema}.repo_runners ADD COLUMN IF NOT EXISTS repository_node_id VARCHAR; @@ -405,6 +418,11 @@ def ensure_optional_input_tables( ADD COLUMN IF NOT EXISTS repository_default_workflow_permissions VARCHAR; ALTER TABLE {schema}.workflows ADD COLUMN IF NOT EXISTS repository_can_approve_pull_request_reviews BOOLEAN; + + ALTER TABLE {schema}.workflow_steps + ADD COLUMN IF NOT EXISTS job_node_id VARCHAR; + ALTER TABLE {schema}.workflow_steps + ADD COLUMN IF NOT EXISTS secret_references JSON; """) # TODO: diff --git a/tests/test_lookup.py b/tests/test_lookup.py index d5c9de5..472dc66 100644 --- a/tests/test_lookup.py +++ b/tests/test_lookup.py @@ -115,3 +115,25 @@ def test_scim_group_id_for_team_external_group_skips_org_only_context() -> None: lookup = GithubLookup(connection, schema="github_test") assert lookup.scim_group_id_for_team_external_group("acme", "Engineering") is None + + +def test_workflow_step_secret_reference_names_deduplicates_across_steps() -> None: + connection = duckdb.connect(":memory:") + connection.execute("CREATE SCHEMA github_test") + connection.execute( + "CREATE TABLE github_test.workflow_steps " + "(job_node_id VARCHAR, secret_references JSON)" + ) + connection.execute( + "INSERT INTO github_test.workflow_steps VALUES " + "('JOB_1', '[{\"name\":\"DEPLOY_TOKEN\",\"context\":\"run\"}]'), " + "('JOB_1', '[{\"name\":\"deploy_token\",\"context\":\"env\"}, {\"name\":\"API_KEY\",\"context\":\"with\"}]'), " + "('JOB_2', '[{\"name\":\"OTHER_TOKEN\",\"context\":\"run\"}]')" + ) + + lookup = GithubLookup(connection, schema="github_test") + + assert lookup.workflow_step_secret_reference_names("JOB_1") == [ + "DEPLOY_TOKEN", + "API_KEY", + ] diff --git a/tests/test_runner_models.py b/tests/test_runner_models.py index d8c6d4d..f203329 100644 --- a/tests/test_runner_models.py +++ b/tests/test_runner_models.py @@ -28,10 +28,10 @@ def _workflow_runner_lookup() -> GithubLookup: "CREATE TABLE github.repositories (node_id VARCHAR, org_login VARCHAR, visibility VARCHAR, actions_enabled BOOLEAN)" ) connection.execute( - "CREATE TABLE github.repo_runners (id BIGINT, labels JSON, repository_node_id VARCHAR)" + "CREATE TABLE github.repo_runners (id BIGINT, labels JSON, ephemeral BOOLEAN, repository_node_id VARCHAR)" ) connection.execute( - "CREATE TABLE github.org_runners (id BIGINT, labels JSON, org_login VARCHAR)" + "CREATE TABLE github.org_runners (id BIGINT, labels JSON, ephemeral BOOLEAN, org_login VARCHAR)" ) connection.execute( "CREATE TABLE github.org_runner_group_access (runner_group_id BIGINT, runner_group_name VARCHAR, runner_group_visibility VARCHAR, allows_public_repositories BOOLEAN, restricted_to_workflows BOOLEAN, inherited BOOLEAN, accessible_repo_node_ids JSON, org_login VARCHAR)" @@ -52,7 +52,7 @@ def _workflow_runner_lookup() -> GithubLookup: "CREATE TABLE github.enterprise_runner_group_memberships (runner_group_id BIGINT, runner_id BIGINT, enterprise_node_id VARCHAR)" ) connection.execute( - "CREATE TABLE github.enterprise_runners (id BIGINT, labels JSON, enterprise_node_id VARCHAR)" + "CREATE TABLE github.enterprise_runners (id BIGINT, labels JSON, ephemeral BOOLEAN, enterprise_node_id VARCHAR)" ) connection.execute("INSERT INTO github.organizations VALUES ('acme', 'ORG_1')") connection.execute( @@ -60,14 +60,14 @@ def _workflow_runner_lookup() -> GithubLookup: ) connection.execute( """INSERT INTO github.repo_runners VALUES - (21, '[{"name":"self-hosted"},{"name":"Linux"},{"name":"X64"}]', 'REPO_1'), - (22, '[{"name":"self-hosted"},{"name":"Linux"},{"name":"X64"}]', 'REPO_3')""" + (21, '[{"name":"self-hosted"},{"name":"Linux"},{"name":"X64"}]', false, 'REPO_1'), + (22, '[{"name":"self-hosted"},{"name":"Linux"},{"name":"X64"}]', false, 'REPO_3')""" ) connection.execute( """INSERT INTO github.org_runners VALUES - (11, '[{"name":"self-hosted"},{"name":"Linux"},{"name":"X64"}]', 'acme'), - (12, '[{"name":"self-hosted"},{"name":"Linux"},{"name":"ARM64"}]', 'acme'), - (13, '[{"name":"self-hosted"},{"name":"Windows"},{"name":"X64"}]', 'acme')""" + (11, '[{"name":"self-hosted"},{"name":"Linux"},{"name":"X64"}]', false, 'acme'), + (12, '[{"name":"self-hosted"},{"name":"Linux"},{"name":"ARM64"}]', true, 'acme'), + (13, '[{"name":"self-hosted"},{"name":"Windows"},{"name":"X64"}]', NULL, 'acme')""" ) connection.execute( """INSERT INTO github.org_runner_group_access VALUES @@ -93,7 +93,7 @@ def _workflow_runner_lookup() -> GithubLookup: ) connection.execute( """INSERT INTO github.enterprise_runners VALUES - (31, '[{"name":"self-hosted"},{"name":"Linux"},{"name":"X64"}]', 'ENT_1')""" + (31, '[{"name":"self-hosted"},{"name":"Linux"},{"name":"X64"}]', false, 'ENT_1')""" ) return GithubLookup(connection) @@ -114,6 +114,20 @@ def test_workflow_job_runner_lookup_filters_to_named_group_and_allows_multiple_m ) == ["ORG_1_runner_11", "ORG_1_runner_12"] +def test_workflow_job_interceptable_runner_lookup_excludes_only_explicitly_ephemeral_runners() -> None: + lookup = _workflow_runner_lookup() + + assert lookup.workflow_job_interceptable_runner_node_ids( + "REPO_1", "acme", None, ("self-hosted", "linux", "x64") + ) == ["REPO_1_runner_21", "ORG_1_runner_11", "ENT_1_runner_31"] + assert lookup.workflow_job_interceptable_runner_node_ids( + "REPO_1", "acme", "prod-runners", ("self-hosted", "linux") + ) == ["ORG_1_runner_11"] + assert lookup.workflow_job_interceptable_runner_node_ids( + "REPO_1", "acme", "Default", ("self-hosted", "windows", "x64") + ) == ["ORG_1_runner_13"] + + def test_workflow_job_runner_lookup_filters_non_matching_labels_and_unauthorized_groups() -> None: lookup = _workflow_runner_lookup() @@ -260,6 +274,39 @@ def test_runner_groups_and_runners_use_scope_owner_prefixes_with_generic_suffixe assert repo_runner.as_node.properties.displayname == "repo-runner-1" +def test_runner_nodes_expose_interceptable_job_query() -> None: + org_runner = OrgRunner(id=8, name="org-runner-1", org_login="acme") + org_runner._lookup = SimpleNamespace(org_id_for_login=lambda _login: "ORG_1") + enterprise_runner = EnterpriseRunner( + id=9, + name="enterprise-runner-1", + enterprise_node_id="ENT_1", + enterprise_slug="acme-enterprise", + ) + repo_runner = RepoRunner( + id=10, + name="repo-runner-1", + repository_name="repo", + repository_node_id="REPO_1", + repository_full_name="acme/repo", + org_login="acme", + ) + repo_runner._lookup = SimpleNamespace(org_id_for_login=lambda _login: "ORG_1") + + assert org_runner.as_node.properties.query_interceptable_jobs == ( + "MATCH p=(:GH_Runner {node_id:'ORG_1_runner_8'})" + "-[:GH_CanInterceptJob]->(:GH_WorkflowJob) RETURN p" + ) + assert enterprise_runner.as_node.properties.query_interceptable_jobs == ( + "MATCH p=(:GH_Runner {node_id:'ENT_1_runner_9'})" + "-[:GH_CanInterceptJob]->(:GH_WorkflowJob) RETURN p" + ) + assert repo_runner.as_node.properties.query_interceptable_jobs == ( + "MATCH p=(:GH_Runner {node_id:'REPO_1_runner_10'})" + "-[:GH_CanInterceptJob]->(:GH_WorkflowJob) RETURN p" + ) + + def test_enterprise_runner_group_with_all_visibility_emits_only_containment() -> None: group = EnterpriseRunnerGroup( id=2, diff --git a/tests/test_workflow_interception_path.py b/tests/test_workflow_interception_path.py new file mode 100644 index 0000000..75542e0 --- /dev/null +++ b/tests/test_workflow_interception_path.py @@ -0,0 +1,230 @@ +import duckdb + +from openhound_github.kinds import edges as ek +from openhound_github.kinds import nodes as nk +from openhound_github.lookup import GithubLookup +from openhound_github.models.runner import ( + EnterpriseRunnerGroupMembership, + OrgRunnerGroup, + OrgRunnerGroupAccess, +) +from openhound_github.models.workflow_job import WorkflowJob +from openhound_github.models.workflow_step import WorkflowStep + + +def _cross_org_enterprise_runner_lookup() -> GithubLookup: + connection = duckdb.connect(":memory:") + connection.execute("CREATE SCHEMA github") + connection.execute( + "CREATE TABLE github.organizations (login VARCHAR, node_id VARCHAR)" + ) + connection.execute( + "CREATE TABLE github.repositories " + "(node_id VARCHAR, org_login VARCHAR, visibility VARCHAR, actions_enabled BOOLEAN)" + ) + connection.execute( + "CREATE TABLE github.branches (id VARCHAR, repository_node_id VARCHAR)" + ) + connection.execute( + "CREATE TABLE github.repo_runners " + "(id BIGINT, labels JSON, ephemeral BOOLEAN, repository_node_id VARCHAR)" + ) + connection.execute( + "CREATE TABLE github.org_runners " + "(id BIGINT, labels JSON, ephemeral BOOLEAN, org_login VARCHAR)" + ) + connection.execute( + "CREATE TABLE github.org_runner_group_access " + "(runner_group_id BIGINT, runner_group_name VARCHAR, " + "runner_group_visibility VARCHAR, allows_public_repositories BOOLEAN, " + "restricted_to_workflows BOOLEAN, inherited BOOLEAN, " + "accessible_repo_node_ids JSON, org_login VARCHAR)" + ) + connection.execute( + "CREATE TABLE github.org_runner_group_memberships " + "(runner_group_id BIGINT, runner_id BIGINT, org_login VARCHAR)" + ) + connection.execute( + "CREATE TABLE github.enterprise_organizations " + "(id VARCHAR, enterprise_node_id VARCHAR)" + ) + connection.execute( + "CREATE TABLE github.enterprise_runner_groups " + "(id BIGINT, name VARCHAR, visibility VARCHAR, " + "restricted_to_workflows BOOLEAN, enterprise_node_id VARCHAR)" + ) + connection.execute( + "CREATE TABLE github.enterprise_runner_group_organizations " + "(node_id VARCHAR, runner_group_id BIGINT, enterprise_node_id VARCHAR)" + ) + connection.execute( + "CREATE TABLE github.enterprise_runner_group_memberships " + "(runner_group_id BIGINT, runner_id BIGINT, enterprise_node_id VARCHAR)" + ) + connection.execute( + "CREATE TABLE github.enterprise_runners " + "(id BIGINT, labels JSON, ephemeral BOOLEAN, enterprise_node_id VARCHAR)" + ) + connection.execute( + "CREATE TABLE github.workflow_steps " + "(job_node_id VARCHAR, secret_references JSON)" + ) + connection.execute( + "CREATE TABLE github.repository_secrets " + "(name VARCHAR, repository_node_id VARCHAR)" + ) + connection.execute( + "CREATE TABLE github.organization_secrets (name VARCHAR, org_login VARCHAR)" + ) + connection.execute( + "CREATE TABLE github.environment_secrets " + "(name VARCHAR, repository_node_id VARCHAR, environment_name VARCHAR)" + ) + connection.execute( + "INSERT INTO github.organizations VALUES " + "('attacker', 'ORG_A'), ('victim', 'ORG_B')" + ) + connection.execute( + "INSERT INTO github.repositories VALUES " + "('REPO_A', 'attacker', 'private', true), " + "('REPO_B', 'victim', 'private', true)" + ) + connection.execute( + "INSERT INTO github.org_runner_group_access VALUES " + "(4, 'enterprise-prod', 'selected', true, false, true, '[\"REPO_A\"]', 'attacker'), " + "(4, 'enterprise-prod', 'selected', true, false, true, '[\"REPO_B\"]', 'victim')" + ) + connection.execute( + "INSERT INTO github.enterprise_organizations VALUES " + "('ORG_A', 'ENT_1'), ('ORG_B', 'ENT_1')" + ) + connection.execute( + "INSERT INTO github.enterprise_runner_groups VALUES " + "(4, 'enterprise-prod', 'selected', false, 'ENT_1')" + ) + connection.execute( + "INSERT INTO github.enterprise_runner_group_organizations VALUES " + "('ORG_A', 4, 'ENT_1'), ('ORG_B', 4, 'ENT_1')" + ) + connection.execute( + "INSERT INTO github.enterprise_runner_group_memberships VALUES " + "(4, 31, 'ENT_1')" + ) + connection.execute( + "INSERT INTO github.enterprise_runners VALUES " + "(31, '[{\"name\":\"self-hosted\"},{\"name\":\"Linux\"}]', false, 'ENT_1')" + ) + connection.execute( + "INSERT INTO github.workflow_steps VALUES " + "('JOB_B', '[{\"name\":\"DEPLOY_TOKEN\",\"context\":\"run\"}]')" + ) + connection.execute( + "INSERT INTO github.repository_secrets VALUES ('DEPLOY_TOKEN', 'REPO_B')" + ) + return GithubLookup(connection) + + +def _find_edge(edges, kind: str, start: str, end: str | None = None): + return next( + edge + for edge in edges + if edge.kind == kind + and edge.start.value == start + and (end is None or getattr(edge.end, "value", None) == end) + ) + + +def test_cross_org_enterprise_runner_interception_path_is_traversable() -> None: + lookup = _cross_org_enterprise_runner_lookup() + + attacker_access = OrgRunnerGroupAccess( + runner_group_id=4, + runner_group_name="enterprise-prod", + runner_group_visibility="selected", + restricted_to_workflows=False, + inherited=True, + accessible_repo_node_ids=["REPO_A"], + org_login="attacker", + ) + attacker_access._lookup = lookup + attacker_group = OrgRunnerGroup( + id=4, + name="enterprise-prod", + visibility="selected", + inherited=True, + org_login="attacker", + ) + attacker_group._lookup = lookup + membership = EnterpriseRunnerGroupMembership( + runner_group_id=4, + runner_id=31, + enterprise_node_id="ENT_1", + enterprise_slug="enterprise", + ) + victim_job = WorkflowJob( + node_id="JOB_B", + name="victim\\deploy", + job_key="deploy", + workflow_node_id="WORKFLOW_B", + repository_name="victim-repo", + repository_node_id="REPO_B", + org_login="victim", + runs_on={"group": "enterprise-prod", "labels": ["self-hosted", "linux"]}, + ) + victim_job._lookup = lookup + victim_step = WorkflowStep( + node_id="STEP_B", + name="deploy", + step_index=0, + type="run", + job_node_id="JOB_B", + workflow_node_id="WORKFLOW_B", + repository_name="victim-repo", + repository_node_id="REPO_B", + org_login="victim", + secret_references=[{"name": "DEPLOY_TOKEN", "context": "run"}], + ) + victim_step._lookup = lookup + + access_edges = list(attacker_access.edges) + group_edges = list(attacker_group.edges) + membership_edges = list(membership.edges) + job_edges = list(victim_job.edges) + step_edges = list(victim_step.edges) + + can_use = _find_edge( + access_edges, ek.CAN_USE_RUNNER, "REPO_A", "ORG_A_runner_group_4" + ) + inherited_from = _find_edge( + group_edges, + ek.INHERITED_FROM, + "ORG_A_runner_group_4", + "ENT_1_runner_group_4", + ) + has_runner = _find_edge( + membership_edges, ek.HAS_RUNNER, "ENT_1_runner_group_4", "ENT_1_runner_31" + ) + runs_on = _find_edge(job_edges, ek.RUNS_ON, "JOB_B", "ENT_1_runner_31") + can_intercept = _find_edge( + job_edges, ek.CAN_INTERCEPT_JOB, "ENT_1_runner_31", "JOB_B" + ) + contains_step = _find_edge(job_edges + step_edges, ek.CONTAINS, "JOB_B", "STEP_B") + uses_secret = _find_edge(step_edges, ek.USES_SECRET, "STEP_B") + can_access_secret = _find_edge(job_edges, ek.CAN_ACCESS_SECRET, "JOB_B") + + assert [ + edge.properties.traversable + for edge in [can_use, inherited_from, has_runner, can_intercept, can_access_secret] + ] == [True, True, True, True, True] + assert runs_on.properties.traversable is False + assert contains_step.properties.traversable is False + assert uses_secret.properties.traversable is False + assert uses_secret.end.kind == nk.REPO_SECRET + assert can_access_secret.end.kind == nk.REPO_SECRET + assert { + matcher.key: matcher.value + for matcher in can_access_secret.end.property_matchers + } == { + "name": "DEPLOY_TOKEN", + "repository_id": "REPO_B", + } diff --git a/tests/test_workflow_model.py b/tests/test_workflow_model.py index a4576ff..775d324 100644 --- a/tests/test_workflow_model.py +++ b/tests/test_workflow_model.py @@ -182,6 +182,26 @@ def test_workflow_job_rows_preserve_workflow_and_job_permission_declarations() - ) == {"none"} +def test_workflow_job_rows_preserve_job_secret_reference_contexts() -> None: + workflow = _workflow_from_yaml( + b"""jobs: + build: + runs-on: ubuntu-latest + env: + DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }} + secrets: + forwarded_token: ${{ secrets.FORWARDED_TOKEN }} +""" + ) + + row = workflow.workflow_job_rows()[0] + + assert row["secret_references"] == [ + {"name": "FORWARDED_TOKEN", "context": "secrets:forwarded_token"}, + {"name": "DEPLOY_TOKEN", "context": "env:DEPLOY_TOKEN"}, + ] + + def test_effective_github_token_permissions_use_repository_default_when_undeclared() -> None: permissions = _permissions_map( resolve_effective_github_token_permissions("read", None, None) @@ -303,6 +323,24 @@ def test_workflow_job_group_selector_counts_as_self_hosted() -> None: assert job.as_node.properties.is_self_hosted is True +def test_workflow_job_node_exposes_accessible_secret_query() -> None: + job = WorkflowJob( + node_id="JOB_1", + name="build", + job_key="build", + workflow_node_id="WORKFLOW_1", + repository_name="repo", + repository_node_id="REPO_1", + org_login="github", + ) + job._lookup = _org_reference_lookup() + + assert job.as_node.properties.query_accessible_secrets == ( + "MATCH p=(:GH_WorkflowJob {node_id:'JOB_1'})" + "-[:GH_CanAccessSecret]->(:GH_Secret) RETURN p" + ) + + def test_workflow_job_uppercase_self_hosted_label_counts_as_self_hosted() -> None: job = WorkflowJob( node_id="JOB_1", @@ -354,6 +392,165 @@ def test_workflow_job_emits_runs_on_edges_for_static_selector_matches() -> None: ) +def test_workflow_job_emits_can_intercept_job_edges_for_interceptable_runner_matches() -> None: + job = WorkflowJob( + node_id="JOB_1", + name="build", + job_key="build", + workflow_node_id="WORKFLOW_1", + repository_name="repo", + repository_node_id="REPO_1", + org_login="github", + runs_on=["self-hosted", "linux", "x64"], + ) + lookup = _org_reference_lookup() + lookup.workflow_job_interceptable_runner_node_ids.return_value = [ + "REPO_1_runner_1", + "ORG_1_runner_2", + ] + job._lookup = lookup + + edges = list(job._can_intercept_job_edges) + + assert [(edge.kind, edge.start.value, edge.end.value) for edge in edges] == [ + (ek.CAN_INTERCEPT_JOB, "REPO_1_runner_1", "JOB_1"), + (ek.CAN_INTERCEPT_JOB, "ORG_1_runner_2", "JOB_1"), + ] + assert all(edge.properties.traversable is True for edge in edges) + assert all(edge.properties.composed is True for edge in edges) + assert edges[0].properties.query_composition == ( + "MATCH p=(:GH_WorkflowJob {node_id:'JOB_1'})" + "-[:GH_RunsOn]->(:GH_Runner {node_id:'REPO_1_runner_1'}) RETURN p" + ) + lookup.workflow_job_interceptable_runner_node_ids.assert_called_once_with( + "REPO_1", + "github", + None, + ("self-hosted", "linux", "x64"), + ) + + +def test_workflow_job_emits_can_access_secret_edges_for_step_references() -> None: + job = WorkflowJob( + node_id="JOB_1", + name="build", + job_key="build", + workflow_node_id="WORKFLOW_1", + repository_name="repo", + repository_node_id="REPO_1", + org_login="github", + environment="prod", + ) + lookup = _org_reference_lookup() + lookup.workflow_step_secret_reference_names.return_value = [ + "REPO_TOKEN", + "ORG_TOKEN", + "ENV_TOKEN", + ] + lookup.repo_secret.side_effect = lambda name, _repo_id: ( + (name,) if name == "REPO_TOKEN" else None + ) + lookup.org_secret.side_effect = lambda name, _org_login: ( + (name,) if name == "ORG_TOKEN" else None + ) + lookup.environment_secret_for_environment.side_effect = ( + lambda name, _repo_id, _environment: (name,) if name == "ENV_TOKEN" else None + ) + job._lookup = lookup + + edges = list(job._can_access_secret_edges) + + assert [edge.kind for edge in edges] == [ + ek.CAN_ACCESS_SECRET, + ek.CAN_ACCESS_SECRET, + ek.CAN_ACCESS_SECRET, + ] + assert [edge.end.kind for edge in edges] == [ + nk.REPO_SECRET, + nk.ORG_SECRET, + nk.ENVIRONMENT_SECRET, + ] + assert all(edge.properties.traversable is True for edge in edges) + assert all(edge.properties.composed is True for edge in edges) + assert "GH_Contains" in edges[0].properties.query_composition + assert "GH_UsesSecret" in edges[0].properties.query_composition + + +def test_workflow_job_can_access_secret_edges_deduplicate_step_references() -> None: + job = WorkflowJob( + node_id="JOB_1", + name="build", + job_key="build", + workflow_node_id="WORKFLOW_1", + repository_name="repo", + repository_node_id="REPO_1", + org_login="github", + ) + lookup = _org_reference_lookup() + lookup.workflow_step_secret_reference_names.return_value = [ + "DEPLOY_TOKEN", + "deploy_token", + ] + lookup.org_secret.return_value = ("DEPLOY_TOKEN",) + job._lookup = lookup + + edges = list(job._can_access_secret_edges) + + assert len(edges) == 1 + assert edges[0].end.kind == nk.ORG_SECRET + assert _matcher_values(edges[0]) == { + "name": "DEPLOY_TOKEN", + "environmentid": ORG_NODE_ID, + } + + +def test_workflow_job_job_level_secret_reference_alone_does_not_emit_can_access_secret() -> None: + job = WorkflowJob( + node_id="JOB_1", + name="build", + job_key="build", + workflow_node_id="WORKFLOW_1", + repository_name="repo", + repository_node_id="REPO_1", + org_login="github", + secret_references=[ + {"name": "FORWARDED_TOKEN", "context": "secrets:forwarded_token"} + ], + ) + lookup = _org_reference_lookup() + lookup.workflow_step_secret_reference_names.return_value = [] + job._lookup = lookup + + assert list(job._can_access_secret_edges) == [] + + +def test_workflow_job_job_env_secret_reference_emits_can_access_secret() -> None: + job = WorkflowJob( + node_id="JOB_1", + name="build", + job_key="build", + workflow_node_id="WORKFLOW_1", + repository_name="repo", + repository_node_id="REPO_1", + org_login="github", + secret_references=[{"name": "DEPLOY_TOKEN", "context": "env:DEPLOY_TOKEN"}], + ) + lookup = _org_reference_lookup() + lookup.workflow_step_secret_reference_names.return_value = [] + lookup.org_secret.return_value = ("DEPLOY_TOKEN",) + job._lookup = lookup + + edges = list(job._can_access_secret_edges) + + assert len(edges) == 1 + assert edges[0].end.kind == nk.ORG_SECRET + assert edges[0].properties.query_composition == ( + "MATCH p=(:GH_WorkflowJob {node_id:'JOB_1'})" + "-[:GH_UsesSecret]->(:GH_OrgSecret " + "{name:'DEPLOY_TOKEN', environmentid:'MDEyOk9yZ2FuaXphdGlvbjE='}) RETURN p" + ) + + def test_workflow_job_dynamic_runs_on_selector_emits_no_edge() -> None: job = WorkflowJob( node_id="JOB_1", @@ -370,6 +567,8 @@ def test_workflow_job_dynamic_runs_on_selector_emits_no_edge() -> None: assert list(job._runs_on_edges) == [] lookup.workflow_job_runner_node_ids.assert_not_called() + assert list(job._can_intercept_job_edges) == [] + lookup.workflow_job_interceptable_runner_node_ids.assert_not_called() def test_pwn_request_edges_support_branch_lookup_protection_flag() -> None: @@ -393,6 +592,7 @@ def _org_reference_lookup() -> MagicMock: lookup = MagicMock() lookup.org_id_for_login.return_value = ORG_NODE_ID lookup.repository_workflow_permissions.return_value = None + lookup.workflow_step_secret_reference_names.return_value = [] lookup.repo_secret.return_value = None lookup.org_secret.return_value = ("DEPLOY_TOKEN",) lookup.repo_variable.return_value = None From 80dc31e253b499f1c835c82fd998bdb0533f7c94 Mon Sep 17 00:00:00 2001 From: Jared Atkinson Date: Sun, 6 Sep 2026 22:00:15 -0700 Subject: [PATCH 7/9] BED-9677: preserve inherited runner group restriction lookup --- src/openhound_github/transforms.py | 3 +++ tests/test_workflow_interception_path.py | 34 ++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/src/openhound_github/transforms.py b/src/openhound_github/transforms.py index 2b5abf6..b34cc05 100644 --- a/src/openhound_github/transforms.py +++ b/src/openhound_github/transforms.py @@ -125,6 +125,7 @@ def ensure_optional_input_tables( id BIGINT, name VARCHAR, visibility VARCHAR, + restricted_to_workflows BOOLEAN, enterprise_node_id VARCHAR ); CREATE TABLE IF NOT EXISTS {schema}.enterprise_runner_group_organizations ( @@ -333,6 +334,8 @@ def ensure_optional_input_tables( ADD COLUMN IF NOT EXISTS name VARCHAR; ALTER TABLE {schema}.enterprise_runner_groups ADD COLUMN IF NOT EXISTS visibility VARCHAR; + ALTER TABLE {schema}.enterprise_runner_groups + ADD COLUMN IF NOT EXISTS restricted_to_workflows BOOLEAN; ALTER TABLE {schema}.enterprise_runner_groups ADD COLUMN IF NOT EXISTS enterprise_node_id VARCHAR; diff --git a/tests/test_workflow_interception_path.py b/tests/test_workflow_interception_path.py index 75542e0..1637ed3 100644 --- a/tests/test_workflow_interception_path.py +++ b/tests/test_workflow_interception_path.py @@ -10,6 +10,7 @@ ) from openhound_github.models.workflow_job import WorkflowJob from openhound_github.models.workflow_step import WorkflowStep +from openhound_github.transforms import ensure_optional_input_tables def _cross_org_enterprise_runner_lookup() -> GithubLookup: @@ -228,3 +229,36 @@ def test_cross_org_enterprise_runner_interception_path_is_traversable() -> None: "name": "DEPLOY_TOKEN", "repository_id": "REPO_B", } + + +def test_inherited_runner_lookup_survives_upgraded_enterprise_runner_group_stub() -> None: + lookup = _cross_org_enterprise_runner_lookup() + lookup.client.execute("DROP TABLE github.enterprise_runner_groups") + lookup.client.execute( + "CREATE TABLE github.enterprise_runner_groups " + "(id BIGINT, name VARCHAR, visibility VARCHAR, enterprise_node_id VARCHAR)" + ) + + ensure_optional_input_tables(lookup.client) + lookup.client.execute( + "INSERT INTO github.enterprise_runner_groups " + "(id, name, visibility, restricted_to_workflows, enterprise_node_id) " + "VALUES (4, 'enterprise-prod', 'selected', false, 'ENT_1')" + ) + + job = WorkflowJob( + node_id="JOB_B", + name="victim\\deploy", + job_key="deploy", + workflow_node_id="WORKFLOW_B", + repository_name="victim-repo", + repository_node_id="REPO_B", + org_login="victim", + runs_on={"group": "enterprise-prod", "labels": ["self-hosted", "linux"]}, + ) + job._lookup = lookup + + edges = list(job.edges) + + _find_edge(edges, ek.RUNS_ON, "JOB_B", "ENT_1_runner_31") + _find_edge(edges, ek.CAN_INTERCEPT_JOB, "ENT_1_runner_31", "JOB_B") From 13b104e645ad4e029e0b3f9bb1cfab23fa391b1c Mon Sep 17 00:00:00 2001 From: Jared Atkinson Date: Tue, 8 Sep 2026 08:42:37 -0700 Subject: [PATCH 8/9] BED-9675: clarify workflow job permission docs --- descriptions/nodes/GH_WorkflowJob.md | 6 +++--- src/openhound_github/models/workflow_job.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/descriptions/nodes/GH_WorkflowJob.md b/descriptions/nodes/GH_WorkflowJob.md index 1b1c8dc..aa36983 100644 --- a/descriptions/nodes/GH_WorkflowJob.md +++ b/descriptions/nodes/GH_WorkflowJob.md @@ -25,9 +25,9 @@ When present, `job_permissions` captures the job-level `permissions` declaration | `is_self_hosted` | `boolean` | Whether the job targets self-hosted runners. | | `container` | `string` | The optional container configuration. | | `environment` | `string` | The deployment environment name. | -| `permissions` | `list[string]` | Effective job permissions. | -| `job_permissions` | `list[string]` | Permissions declared at the job level. | -| `effective_github_token_permissions` | `list[string]` | Calculated GITHUB_TOKEN permissions. | +| `permissions` | `list[string]` | Applicable declared workflow or job permissions after job-over-workflow precedence. | +| `job_permissions` | `list[string]` | Optional permissions declared at the job level; absent when the job has no declaration. | +| `effective_github_token_permissions` | `list[string]` | Calculated GITHUB_TOKEN permissions after repository defaults and declarations are applied. | | `uses_reusable` | `string` | The reusable workflow reference used by this job. | | `workflow_node_id` | `string` | The parent workflow node ID. | | `repository_name` | `string` | The containing repository name. | diff --git a/src/openhound_github/models/workflow_job.py b/src/openhound_github/models/workflow_job.py index 6b256e8..228964a 100644 --- a/src/openhound_github/models/workflow_job.py +++ b/src/openhound_github/models/workflow_job.py @@ -48,9 +48,9 @@ class GHWorkflowJobProperties(GHNodeProperties): is_self_hosted: Whether the job targets self-hosted runners. container: The optional container configuration. environment: The deployment environment name. - permissions: Permissions after workflow/job declaration precedence. - job_permissions: Permissions declared at the job level. - effective_github_token_permissions: Calculated GITHUB_TOKEN permissions. + permissions: Applicable declared workflow or job permissions after job-over-workflow precedence. + job_permissions: Optional permissions declared at the job level; absent when the job has no declaration. + effective_github_token_permissions: Calculated GITHUB_TOKEN permissions after repository defaults and declarations are applied. uses_reusable: The reusable workflow reference used by this job. workflow_node_id: The parent workflow node ID. repository_name: The containing repository name. From dfb2685a450ce9b909dc0f186ae0e3372b2f5725 Mon Sep 17 00:00:00 2001 From: Jared Atkinson Date: Tue, 8 Sep 2026 09:23:40 -0700 Subject: [PATCH 9/9] BED-9677: clarify dynamic runner interception status --- descriptions/nodes/GH_WorkflowJob.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/descriptions/nodes/GH_WorkflowJob.md b/descriptions/nodes/GH_WorkflowJob.md index df7a31d..7c919dd 100644 --- a/descriptions/nodes/GH_WorkflowJob.md +++ b/descriptions/nodes/GH_WorkflowJob.md @@ -10,6 +10,8 @@ When present, `job_permissions` captures the job-level `permissions` declaration GH_CanAccessSecret edges identify secrets statically referenced by the job's modeled steps or job-level `env` block that the job execution context can access. GH_CanInterceptJob edges from GH_Runner nodes not explicitly marked ephemeral identify jobs whose future execution context may be exposed if that runner is controlled. +When `runs_on_is_dynamic` is true, runner matching and interception status remain unresolved: the collector does not emit GH_CanInterceptJob edges for the job, so `query_interceptable_jobs` cannot match it and the absence of an edge must not be treated as evidence that the job is definitively non-interceptable. + ## Properties | Property | Type | Description |