diff --git a/descriptions/edges/GH_CanAccessSecret.md b/descriptions/edges/GH_CanAccessSecret.md new file mode 100644 index 0000000..0c8efbf --- /dev/null +++ b/descriptions/edges/GH_CanAccessSecret.md @@ -0,0 +1,30 @@ +# GH_CanAccessSecret + +## 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. + +## Edge Schema + +| Source | Destination | Traversable | +| --- | --- | --- | +| `GH_WorkflowJob` | `GH_EnvironmentSecret` | `true` | +| `GH_WorkflowJob` | `GH_OrgSecret` | `true` | +| `GH_WorkflowJob` | `GH_RepoSecret` | `true` | + +## Diagram + +```mermaid +graph LR + n0["GH_WorkflowJob"] + n1["GH_EnvironmentSecret"] + n2["GH_OrgSecret"] + n3["GH_RepoSecret"] + n0 -->|GH_CanAccessSecret| n1 + n0 -->|GH_CanAccessSecret| n2 + n0 -->|GH_CanAccessSecret| n3 +``` diff --git a/descriptions/edges/GH_CanInterceptJob.md b/descriptions/edges/GH_CanInterceptJob.md new file mode 100644 index 0000000..aa90647 --- /dev/null +++ b/descriptions/edges/GH_CanInterceptJob.md @@ -0,0 +1,24 @@ +# GH_CanInterceptJob + +## 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. + +## Edge Schema + +| Source | Destination | Traversable | +| --- | --- | --- | +| `GH_Runner` | `GH_WorkflowJob` | `true` | + +## Diagram + +```mermaid +graph LR + n0["GH_Runner"] + n1["GH_WorkflowJob"] + n0 -->|GH_CanInterceptJob| n1 +``` 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..c400b36 100644 --- a/descriptions/nodes/GH_EnterpriseRunner.md +++ b/descriptions/nodes/GH_EnterpriseRunner.md @@ -6,6 +6,10 @@ 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. + ## Properties | Property | Type | Description | @@ -31,6 +35,8 @@ 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. | +| `query_interceptable_jobs` | `string` | Query for workflow jobs the runner can intercept. | ## Diagram @@ -38,6 +44,9 @@ 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 + n1 -->|GH_CanInterceptJob| n2 ``` diff --git a/descriptions/nodes/GH_OrgRunner.md b/descriptions/nodes/GH_OrgRunner.md index dff2bdb..07d8d3e 100644 --- a/descriptions/nodes/GH_OrgRunner.md +++ b/descriptions/nodes/GH_OrgRunner.md @@ -6,6 +6,10 @@ 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. + ## Properties | Property | Type | Description | @@ -31,6 +35,8 @@ 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. | +| `query_interceptable_jobs` | `string` | Query for workflow jobs the runner can intercept. | ## Diagram @@ -38,6 +44,9 @@ 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 + n1 -->|GH_CanInterceptJob| n2 ``` diff --git a/descriptions/nodes/GH_RepoRunner.md b/descriptions/nodes/GH_RepoRunner.md index b8bdad6..e2c09ba 100644 --- a/descriptions/nodes/GH_RepoRunner.md +++ b/descriptions/nodes/GH_RepoRunner.md @@ -6,6 +6,10 @@ 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. + ## Properties | Property | Type | Description | @@ -31,6 +35,8 @@ 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. | +| `query_interceptable_jobs` | `string` | Query for workflow jobs the runner can intercept. | ## Diagram @@ -38,6 +44,9 @@ 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 + n1 -->|GH_CanInterceptJob| n2 ``` 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..7c919dd 100644 --- a/descriptions/nodes/GH_WorkflowJob.md +++ b/descriptions/nodes/GH_WorkflowJob.md @@ -4,6 +4,14 @@ 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. + +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 | @@ -15,10 +23,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 +40,8 @@ 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. | +| `query_accessible_secrets` | `string` | Query for secrets accessible to the job execution context. | ## Diagram @@ -41,7 +56,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 +66,12 @@ graph LR n1 -.->|GH_UsesVariable| n6 n1 -.->|GH_UsesSecret| n7 n1 -.->|GH_UsesVariable| n8 + n1 -.->|GH_RunsOn| n9 + n1 -->|GH_CanAccessSecret| n3 + n1 -->|GH_CanAccessSecret| n5 + n1 -->|GH_CanAccessSecret| n7 + n9 -->|GH_CanInterceptJob| n1 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..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 | @@ -79,21 +88,28 @@ 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 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 | |---|------|------|-------------| -| 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. | +| 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-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..50eea1c --- /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 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/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..fa63a17 100644 --- a/extension/schema.json +++ b/extension/schema.json @@ -1030,6 +1030,16 @@ "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_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", @@ -1040,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 9f717eb..da2a377 100644 --- a/src/openhound_github/kinds/edges.py +++ b/src/openhound_github/kinds/edges.py @@ -18,6 +18,9 @@ # Access and capability edges 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 6783e05..68beac1 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,275 @@ 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_matches( + self, + repository_node_id: str, + org_login: str, + group_name: str | None, + labels: tuple[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 [] + + 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_runners: list[tuple[str, bool | None]] = [] + seen_runner_node_ids: set[str] = set() + + 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_runners.append((node_id, ephemeral)) + + if group_name is None: + for runner_id, raw_labels, ephemeral in self._find_all_objects( + f""" + SELECT id, labels, ephemeral + 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, + ephemeral, + ) + + 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, ephemeral in self._find_all_objects( + f""" + 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 + 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, + ephemeral, + ) + continue + + for runner_id, raw_labels, ephemeral in self._find_all_objects( + f""" + 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 + 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, + ephemeral, + ) + + 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( self, enterprise_node_id: str @@ -313,6 +583,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..619e24f 100644 --- a/src/openhound_github/models/runner.py +++ b/src/openhound_github/models/runner.py @@ -316,6 +316,8 @@ 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. + query_interceptable_jobs: Query for workflow jobs the runner can intercept. """ scope: str | None = None @@ -334,6 +336,8 @@ class GHRunnerProperties(GHNodeProperties): environment_name: str | None = None query_group: str | None = None query_repositories: str | None = None + query_jobs: str | None = None + query_interceptable_jobs: str | None = None @app.asset( @@ -384,6 +388,8 @@ 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", + query_interceptable_jobs=f"MATCH p=(:GH_Runner {{node_id:'{rid}'}})-[:GH_CanInterceptJob]->(:GH_WorkflowJob) RETURN p", ), ) @@ -437,6 +443,8 @@ 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", + query_interceptable_jobs=f"MATCH p=(:GH_Runner {{node_id:'{rid}'}})-[:GH_CanInterceptJob]->(:GH_WorkflowJob) RETURN p", ), ) @@ -873,6 +881,8 @@ 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", + 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.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..741f0f2 100644 --- a/src/openhound_github/models/workflow_job.py +++ b/src/openhound_github/models/workflow_job.py @@ -15,22 +15,25 @@ EdgeProperties, PropertyMatch, ) -from pydantic import BaseModel, Field, field_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 -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, +) +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. @@ -38,10 +41,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 +58,21 @@ 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. + query_accessible_secrets: Query for secrets accessible to the job execution context. """ 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,8 @@ class GHWorkflowJobProperties(GHNodeProperties): query_repository: str | None = None query_steps: str | None = None query_references: str | None = None + query_runners: str | None = None + query_accessible_secrets: str | None = None @app.asset( @@ -146,6 +163,41 @@ 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, + ), + 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): @@ -161,9 +213,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 +230,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] + return normalize_permission_declaration(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()] + @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 +290,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,66 +308,35 @@ 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", + 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): @@ -425,6 +454,114 @@ 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 _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 @@ -433,3 +570,6 @@ 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 + 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/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..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 ( @@ -137,6 +138,55 @@ def ensure_optional_input_tables( runner_id BIGINT, enterprise_node_id VARCHAR ); + 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 ( + id BIGINT, + name VARCHAR, + org_login VARCHAR + ); + 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 ( + 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, + ephemeral BOOLEAN, + 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 + ); + CREATE TABLE IF NOT EXISTS {schema}.workflow_steps ( + job_node_id VARCHAR, + secret_references JSON + ); """) con.execute(f""" ALTER TABLE {schema}.branches @@ -284,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; @@ -300,6 +352,80 @@ 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 ephemeral BOOLEAN; + 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 ephemeral BOOLEAN; + 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 ephemeral BOOLEAN; + 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; + + 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_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..f203329 100644 --- a/tests/test_runner_models.py +++ b/tests/test_runner_models.py @@ -18,6 +18,164 @@ ) +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, 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("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"}]', 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"}]', 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 + (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"}]', false, '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_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() + + 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") @@ -116,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..1637ed3 --- /dev/null +++ b/tests/test_workflow_interception_path.py @@ -0,0 +1,264 @@ +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 +from openhound_github.transforms import ensure_optional_input_tables + + +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", + } + + +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") diff --git a/tests/test_workflow_model.py b/tests/test_workflow_model.py index fe27ca7..775d324 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,519 @@ 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_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) + ) + + 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_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", + 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_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", + 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() + 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: workflow = _make_pwn_request_workflow() @@ -72,6 +591,8 @@ 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.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 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"]