diff --git a/descriptions/edges/GH_CanPwnRequest.md b/descriptions/edges/GH_CanPwnRequest.md index a916f8e..974c67c 100644 --- a/descriptions/edges/GH_CanPwnRequest.md +++ b/descriptions/edges/GH_CanPwnRequest.md @@ -41,7 +41,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. ## Edge Schema diff --git a/descriptions/edges/GH_RunsOn.md b/descriptions/edges/GH_RunsOn.md new file mode 100644 index 0000000..e198fa1 --- /dev/null +++ b/descriptions/edges/GH_RunsOn.md @@ -0,0 +1,24 @@ +# GH_RunsOn + +## 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. + +## Edge Schema + +| Source | Destination | Traversable | +| --- | --- | --- | +| `GH_WorkflowJob` | `GH_Runner` | `false` | + +## Diagram + +```mermaid +graph LR + n0["GH_WorkflowJob"] + n1["GH_Runner"] + n0 -.->|GH_RunsOn| n1 +``` diff --git a/descriptions/nodes/GH_EnterpriseRunner.md b/descriptions/nodes/GH_EnterpriseRunner.md index cf0da26..b2db0da 100644 --- a/descriptions/nodes/GH_EnterpriseRunner.md +++ b/descriptions/nodes/GH_EnterpriseRunner.md @@ -6,6 +6,8 @@ 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. + ## Properties | Property | Type | Description | @@ -31,6 +33,7 @@ The node captures runner metadata such as operating system, status, busy state, | `environment_name` | `string` | The name of the environment (GitHub organization). | | `query_group` | `string` | Query for group. | | `query_repositories` | `string` | Query for repositories. | +| `query_jobs` | `string` | Query for workflow jobs that can be scheduled on the runner. | ## Diagram @@ -38,6 +41,8 @@ The node captures runner metadata such as operating system, status, busy state, graph LR n0["GH_EnterpriseRunnerGroup"] n1["GH_EnterpriseRunner"] + n2["GH_WorkflowJob"] n0 -.->|GH_Contains| n1 n0 -->|GH_HasRunner| n1 + n2 -.->|GH_RunsOn| n1 ``` diff --git a/descriptions/nodes/GH_OrgRunner.md b/descriptions/nodes/GH_OrgRunner.md index dff2bdb..3b00be4 100644 --- a/descriptions/nodes/GH_OrgRunner.md +++ b/descriptions/nodes/GH_OrgRunner.md @@ -6,6 +6,8 @@ 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. + ## Properties | Property | Type | Description | @@ -31,6 +33,7 @@ The node captures runner metadata such as operating system, status, busy state, | `environment_name` | `string` | The name of the environment (GitHub organization). | | `query_group` | `string` | Query for group. | | `query_repositories` | `string` | Query for repositories. | +| `query_jobs` | `string` | Query for workflow jobs that can be scheduled on the runner. | ## Diagram @@ -38,6 +41,8 @@ The node captures runner metadata such as operating system, status, busy state, graph LR n0["GH_OrgRunnerGroup"] n1["GH_OrgRunner"] + n2["GH_WorkflowJob"] n0 -.->|GH_Contains| n1 n0 -->|GH_HasRunner| n1 + n2 -.->|GH_RunsOn| n1 ``` diff --git a/descriptions/nodes/GH_RepoRunner.md b/descriptions/nodes/GH_RepoRunner.md index b8bdad6..e0e53ad 100644 --- a/descriptions/nodes/GH_RepoRunner.md +++ b/descriptions/nodes/GH_RepoRunner.md @@ -6,6 +6,8 @@ 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. + ## Properties | Property | Type | Description | @@ -31,6 +33,7 @@ The node captures runner metadata such as operating system, status, busy state, | `environment_name` | `string` | The name of the environment (GitHub organization). | | `query_group` | `string` | Query for group. | | `query_repositories` | `string` | Query for repositories. | +| `query_jobs` | `string` | Query for workflow jobs that can be scheduled on the runner. | ## Diagram @@ -38,6 +41,8 @@ The node captures runner metadata such as operating system, status, busy state, graph LR n0["GH_Repository"] n1["GH_RepoRunner"] + n2["GH_WorkflowJob"] n0 -.->|GH_CanUseRunner| n1 n0 -.->|GH_Contains| n1 + n2 -.->|GH_RunsOn| n1 ``` diff --git a/descriptions/nodes/GH_Repository.md b/descriptions/nodes/GH_Repository.md index 5cf28e0..8c5ffb6 100644 --- a/descriptions/nodes/GH_Repository.md +++ b/descriptions/nodes/GH_Repository.md @@ -4,6 +4,8 @@ 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. + ## Properties | Property | Type | Description | @@ -40,6 +42,8 @@ Represents a GitHub repository within the organization. Repository nodes capture | `secret_scanning` | `string` | Status of secret scanning (e.g., `enabled`, `disabled`). | | `branch_ruleset_count` | `integer` | Number of branch-targeted rulesets that apply to this repository. | | `has_branch_rulesets` | `boolean` | Whether at least one branch-targeted ruleset applies to this repository. | +| `default_workflow_permissions` | `string` | The repository's applicable default GITHUB_TOKEN workflow permissions. | +| `can_approve_pull_request_reviews` | `boolean` | Whether workflows may approve pull request reviews. | | `query_branches` | `string` | Query for branches. | | `query_protected_branches` | `string` | Query for protected branches. | | `query_branch_protection_rules` | `string` | Query for branch protection rules. | diff --git a/descriptions/nodes/GH_Workflow.md b/descriptions/nodes/GH_Workflow.md index 309a2c5..5d2d951 100644 --- a/descriptions/nodes/GH_Workflow.md +++ b/descriptions/nodes/GH_Workflow.md @@ -4,6 +4,8 @@ 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. + ## Properties | Property | Type | Description | @@ -25,6 +27,7 @@ Represents a GitHub Actions workflow defined in a repository. Workflow nodes cap | `triggers` | `list[string]` | The triggers value. | | `trigger_dispatch_inputs` | `list[string]` | The trigger dispatch inputs value. | | `is_pwn_requestable` | `boolean` | The is pwn requestable value. | +| `workflow_permissions` | `list[string]` | Permissions declared at the workflow level. | | `query_repository` | `string` | Query for repository. | | `query_jobs` | `string` | Query for workflow jobs. | | `query_execution` | `string` | Query for workflow executions. | diff --git a/descriptions/nodes/GH_WorkflowJob.md b/descriptions/nodes/GH_WorkflowJob.md index faf9a59..aa36983 100644 --- a/descriptions/nodes/GH_WorkflowJob.md +++ b/descriptions/nodes/GH_WorkflowJob.md @@ -4,6 +4,10 @@ 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. + ## Properties | Property | Type | Description | @@ -15,10 +19,15 @@ Represents a single job within a GitHub Actions workflow. Jobs are the top-level | `node_id` | `string` | The stable identifier used as the OpenGraph node ID; this is the native GitHub node ID where available. | | `job_key` | `string` | The YAML key for the job. | | `runs_on` | `list[string]` | The runner label expression for the job. | +| `runs_on_group` | `string` | The statically declared runner group, if any. | +| `runs_on_labels` | `list[string]` | The normalized runner labels from runs-on. | +| `runs_on_is_dynamic` | `boolean` | Whether runs-on contains a GitHub Actions expression. | | `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. | +| `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. | @@ -27,6 +36,7 @@ Represents a single job within a GitHub Actions workflow. Jobs are the top-level | `query_repository` | `string` | Query for repository. | | `query_steps` | `string` | Query for workflow steps. | | `query_references` | `string` | Query for workflow references (secrets and variables). | +| `query_runners` | `string` | Query for eligible self-hosted runners. | ## Diagram @@ -41,7 +51,8 @@ graph LR n6["GH_OrgVariable"] n7["GH_RepoSecret"] n8["GH_RepoVariable"] - n9["GH_WorkflowStep"] + n9["GH_Runner"] + n10["GH_WorkflowStep"] n0 -.->|GH_Contains| n1 n1 -.->|GH_DeploysTo| n2 n1 -.->|GH_UsesSecret| n3 @@ -50,7 +61,8 @@ graph LR n1 -.->|GH_UsesVariable| n6 n1 -.->|GH_UsesSecret| n7 n1 -.->|GH_UsesVariable| n8 + n1 -.->|GH_RunsOn| n9 n1 -.->|GH_CallsWorkflow| n0 n1 -.->|GH_DependsOn| n1 - n1 -.->|GH_Contains| n9 + n1 -.->|GH_Contains| n10 ``` 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/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..8bba5fb 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 [] + + 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 @@ -313,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/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/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/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..4c9c7e6 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,180 @@ 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 + + +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"{key!s}:{item!s}" 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: + 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 +324,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") @@ -245,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. @@ -262,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 @@ -325,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: @@ -381,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 @@ -498,6 +688,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,11 +705,20 @@ 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 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, @@ -604,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 cb42a04..228964a 100644 --- a/src/openhound_github/models/workflow_job.py +++ b/src/openhound_github/models/workflow_job.py @@ -15,13 +15,17 @@ 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 ( + normalize_permission_declaration, + parse_runs_on_selector, + resolve_effective_github_token_permissions, +) TEMPLATE_RE = re.compile(r"\$\{\{\s*[^}]+?\s*\}\}") @@ -38,10 +42,15 @@ 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. - permissions: Effective job 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. @@ -50,14 +59,20 @@ 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 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 @@ -66,6 +81,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 +162,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,9 +184,14 @@ 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 + 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) @@ -173,56 +201,54 @@ 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] + return normalize_permission_declaration(value) - if isinstance(value, dict): - return [f"{str(key)}:{str(value)}" for key, value in value.items()] + @model_validator(mode="before") + @classmethod + def populate_runs_on_selector_fields(cls, value: Any) -> Any: + if not isinstance(value, dict): + return value - return [str(value)] + 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 @field_validator("runs_on", 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() - - 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)] - - return [str(value)] + 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 { + 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: @@ -235,10 +261,15 @@ 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, 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, @@ -248,6 +279,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 +457,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 +483,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/resources/organization.py b/src/openhound_github/resources/organization.py index 3f42c3c..eaf4a4d 100644 --- a/src/openhound_github/resources/organization.py +++ b/src/openhound_github/resources/organization.py @@ -122,6 +122,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: @@ -230,6 +233,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]]: @@ -1360,7 +1376,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: @@ -1382,15 +1401,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 e6dc788..9f0fddb 100644 --- a/src/openhound_github/source.py +++ b/src/openhound_github/source.py @@ -177,6 +177,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 4aa3bee..c3b4f6a 100644 --- a/src/openhound_github/transforms.py +++ b/src/openhound_github/transforms.py @@ -137,6 +137,48 @@ 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 + ); + 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 + ); """) con.execute(f""" ALTER TABLE {schema}.branches @@ -300,6 +342,69 @@ 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; + + 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 + 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_runner_models.py b/tests/test_runner_models.py index 2418bc4..d8c6d4d 100644 --- a/tests/test_runner_models.py +++ b/tests/test_runner_models.py @@ -18,6 +18,150 @@ ) +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), ('REPO_3', 'acme', 'private', false)" + ) + 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')""" + ) + 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_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") diff --git a/tests/test_workflow_model.py b/tests/test_workflow_model.py index fe27ca7..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 @@ -12,8 +15,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 +43,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 +58,320 @@ 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: + 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_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", + 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_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", + 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() @@ -72,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"]