diff --git a/README.md b/README.md index b99713e..e124ffd 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ The collector runs successfully with the documented read-only permission set. So - Organization `Members: write` enables external group mapping collection for IdP-synced teams, which allows the converter to emit `SCIM_Provisioned` edges from `SCIM_Group` to `GH_Team`. This is useful when the organization uses team synchronization with an external identity provider; if no GitHub teams are linked to external groups, this permission does not add graph data. - Classic PAT scope `manage_runners:enterprise` enables enterprise self-hosted runner group and runner collection. This is useful when the enterprise has enterprise-scoped runner groups or runners, especially runner groups shared into organizations; if all runners are organization- or repository-scoped, this scope does not add graph data. +- Repository `Administration: read` enables deploy key collection. GitHub exposes repository deploy keys through an administration-scoped read endpoint, and the collector emits `GH_DeployKey` nodes only when that endpoint is accessible. If these optional permissions are not granted, OpenHound skips the affected resources and continues collecting the rest of the GitHub environment. diff --git a/descriptions/edges/GH_AddedDeployKey.md b/descriptions/edges/GH_AddedDeployKey.md new file mode 100644 index 0000000..400abd2 --- /dev/null +++ b/descriptions/edges/GH_AddedDeployKey.md @@ -0,0 +1,20 @@ +# GH_AddedDeployKey + +## General Information + +User added this repository deploy key. + +## Edge Schema + +| Source | Destination | Traversable | +| --- | --- | --- | +| `GH_User` | `GH_DeployKey` | `false` | + +## Diagram + +```mermaid +graph LR + n0["GH_User"] + n1["GH_DeployKey"] + n0 -.->|GH_AddedDeployKey| n1 +``` diff --git a/descriptions/edges/GH_CanAccess.md b/descriptions/edges/GH_CanAccess.md index 399f927..4929ab8 100644 --- a/descriptions/edges/GH_CanAccess.md +++ b/descriptions/edges/GH_CanAccess.md @@ -2,13 +2,14 @@ ## General Information -The non-traversable GH_CanAccess edge indicates that a personal access token or app installation has been granted access to specific repositories. This edge represents the scope of access granted to a token or app rather than a direct attack path, providing visibility into which repositories are reachable through non-human credentials. It is non-traversable because token and app access does not transitively extend to other principals. +The non-traversable GH_CanAccess edge indicates that a personal access token, app installation, or deploy key has been granted access to a repository or organization. This edge represents the scope of access granted to a non-human credential rather than a direct attack path, providing visibility into which repositories are reachable through that credential. It is non-traversable because credential access does not transitively extend to other principals. ## Edge Schema | Source | Destination | Traversable | | --- | --- | --- | | `GH_AppInstallation` | `GH_Repository` | `false` | +| `GH_DeployKey` | `GH_Repository` | `false` | | `GH_PersonalAccessToken` | `GH_Organization` | `false` | | `GH_PersonalAccessToken` | `GH_Repository` | `false` | @@ -18,9 +19,11 @@ The non-traversable GH_CanAccess edge indicates that a personal access token or graph LR n0["GH_AppInstallation"] n1["GH_Repository"] - n2["GH_PersonalAccessToken"] - n3["GH_Organization"] + n2["GH_DeployKey"] + n3["GH_PersonalAccessToken"] + n4["GH_Organization"] n0 -.->|GH_CanAccess| n1 - n2 -.->|GH_CanAccess| n3 n2 -.->|GH_CanAccess| n1 + n3 -.->|GH_CanAccess| n4 + n3 -.->|GH_CanAccess| n1 ``` diff --git a/descriptions/edges/GH_CanRequestOIDCTokenFor.md b/descriptions/edges/GH_CanRequestOIDCTokenFor.md new file mode 100644 index 0000000..e435197 --- /dev/null +++ b/descriptions/edges/GH_CanRequestOIDCTokenFor.md @@ -0,0 +1,24 @@ +# GH_CanRequestOIDCTokenFor + +## General Information + +The traversable GH_CanRequestOIDCTokenFor edge represents that a GitHub Actions workflow job execution context can request a GitHub-signed OIDC token containing claims for its associated GitHub Environment. + +This edge is derived from the existing GH_DeploysTo relationship and the job's calculated `effective_github_token_permissions`. The collector emits it only when the job targets a statically resolved environment and its effective permissions include `id-token:write`. + +This is a capability edge, not evidence that the workflow has historically requested a token or contains an explicit OIDC-related step. Code executing in a job with `id-token:write` can request the token directly. + +## Edge Schema + +| Source | Destination | Traversable | +| --- | --- | --- | +| `GH_WorkflowJob` | `GH_Environment` | `true` | + +## Diagram + +```mermaid +graph LR + n0["GH_WorkflowJob"] + n1["GH_Environment"] + n0 -->|GH_CanRequestOIDCTokenFor| n1 +``` diff --git a/descriptions/edges/GH_Contains.md b/descriptions/edges/GH_Contains.md index 9a588a6..7950e5a 100644 --- a/descriptions/edges/GH_Contains.md +++ b/descriptions/edges/GH_Contains.md @@ -27,6 +27,7 @@ The non-traversable GH_Contains edge represents structural containment within th | `GH_Organization` | `GH_SecretScanningAlert` | `false` | | `GH_Repository` | `GH_Branch` | `false` | | `GH_Repository` | `GH_BranchProtectionRule` | `false` | +| `GH_Repository` | `GH_DeployKey` | `false` | | `GH_Repository` | `GH_Environment` | `false` | | `GH_Repository` | `GH_RepoRunner` | `false` | | `GH_Repository` | `GH_RepoSecret` | `false` | @@ -62,12 +63,13 @@ graph LR n19["GH_Repository"] n20["GH_Branch"] n21["GH_BranchProtectionRule"] - n22["GH_RepoRunner"] - n23["GH_RepoSecret"] - n24["GH_RepoVariable"] - n25["GH_Workflow"] - n26["GH_WorkflowJob"] - n27["GH_WorkflowStep"] + n22["GH_DeployKey"] + n23["GH_RepoRunner"] + n24["GH_RepoSecret"] + n25["GH_RepoVariable"] + n26["GH_Workflow"] + n27["GH_WorkflowJob"] + n28["GH_WorkflowStep"] n0 -.->|GH_Contains| n1 n0 -.->|GH_Contains| n2 n0 -.->|GH_Contains| n3 @@ -87,12 +89,13 @@ graph LR n4 -.->|GH_Contains| n18 n19 -.->|GH_Contains| n20 n19 -.->|GH_Contains| n21 - n19 -.->|GH_Contains| n6 n19 -.->|GH_Contains| n22 + n19 -.->|GH_Contains| n6 n19 -.->|GH_Contains| n23 n19 -.->|GH_Contains| n24 - n19 -.->|GH_Contains| n18 n19 -.->|GH_Contains| n25 - n25 -.->|GH_Contains| n26 + n19 -.->|GH_Contains| n18 + n19 -.->|GH_Contains| n26 n26 -.->|GH_Contains| n27 + n27 -.->|GH_Contains| n28 ``` diff --git a/descriptions/nodes/GH_AppInstallation.md b/descriptions/nodes/GH_AppInstallation.md index 9b71e9d..953610c 100644 --- a/descriptions/nodes/GH_AppInstallation.md +++ b/descriptions/nodes/GH_AppInstallation.md @@ -2,7 +2,9 @@ ## General Information -Represents a GitHub App installed on an organization. App installations have specific permissions and can be scoped to all repositories or a selection of repositories. The permissions granted to the app are captured as a JSON string in the properties. +Represents a GitHub App installed on an organization. App installations have specific permissions and can be scoped to all repositories or a selection of repositories. The permissions granted to the installation are stored as `scope:access` values such as `contents:write` in the `permissions` property. + +Unlike fine-grained personal access tokens, GitHub does not expose separate organization and repository permission buckets for app installations, so this property remains a single flat permission list. Each installation is linked to its parent GH_App via a GH_InstalledAs edge. For installations with `repository_selection` set to `all`, GH_CanAccess edges are created to every repository in the organization. For installations with `repository_selection` set to `selected`, repository-level edges cannot be enumerated with a PAT (requires app installation token authentication). @@ -24,7 +26,7 @@ Each installation is linked to its parent GH_App via a GH_InstalledAs edge. For | `repositories_url` | `string` | API URL to list repositories accessible to this installation. | | `repository_selection` | `string` | Whether the app has access to `all` repositories or `selected` repositories. | | `target_type` | `string` | The target type of the installation (e.g., `Organization`). | -| `permissions` | `string` | JSON string of the permissions granted to the app (e.g., `{"contents": "read", "metadata": "read"}`). | +| `permissions` | `list[string]` | Permissions granted to the installation in `scope:access` form. | | `events` | `string` | JSON string of the webhook events the app subscribes to. | | `created_at` | `datetime` | When the app was installed. | | `updated_at` | `datetime` | When the installation was last updated. | diff --git a/descriptions/nodes/GH_DeployKey.md b/descriptions/nodes/GH_DeployKey.md new file mode 100644 index 0000000..3d8f60b --- /dev/null +++ b/descriptions/nodes/GH_DeployKey.md @@ -0,0 +1,43 @@ +# GH_DeployKey + +## General Information + +A repository-scoped SSH deploy key that grants read-only or read-write access to repository contents. + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| `name` | `string` | The node name used for matching and display. | +| `displayname` | `string` | The human-readable display name. | +| `environmentid` | `string` | The identifier of the GitHub environment where this node was collected. | +| `last_seen` | `datetime` | The timestamp when this node was last observed during collection. | +| `node_id` | `string` | The stable identifier used as the OpenGraph node ID; this is the native GitHub node ID where available. | +| `github_id` | `integer` | The numeric GitHub deploy key ID. | +| `title` | `string` | The user-assigned deploy key title. | +| `key` | `string` | The public SSH key material. | +| `verified` | `boolean` | Whether GitHub has verified the deploy key. | +| `enabled` | `boolean` | Whether the deploy key is currently enabled. | +| `read_only` | `boolean` | Whether the deploy key is restricted to read-only repository access. | +| `repository_permissions` | `list[string]` | Repository-scoped permissions in `scope:access` form. | +| `created_at` | `string` | When the deploy key was created. | +| `last_used` | `string` | When the deploy key was last used. | +| `added_by_login` | `string` | The login of the user who added the deploy key. | +| `added_by_id` | `string` | The node ID of the user who added the deploy key. | +| `repository_name` | `string` | The full name of the containing repository. | +| `repository_id` | `string` | The node_id of the containing repository. | +| `environment_name` | `string` | The name of the environment (GitHub organization). | +| `query_repository` | `string` | Query for the containing repository. | +| `query_added_by` | `string` | Query for the user who added the deploy key. | + +## Diagram + +```mermaid +graph LR + n0["GH_DeployKey"] + n1["GH_Repository"] + n2["GH_User"] + n0 -.->|GH_CanAccess| n1 + n1 -.->|GH_Contains| n0 + n2 -.->|GH_AddedDeployKey| n0 +``` diff --git a/descriptions/nodes/GH_Enterprise.md b/descriptions/nodes/GH_Enterprise.md index 14504a3..f41d4e8 100644 --- a/descriptions/nodes/GH_Enterprise.md +++ b/descriptions/nodes/GH_Enterprise.md @@ -23,8 +23,9 @@ A GitHub Enterprise account that contains organizations, enterprise teams, roles | `created_at` | `string` | When the enterprise was created. | | `updated_at` | `string` | When the enterprise was last updated. | | `billing_email` | `string` | The enterprise billing email. | -| `security_contact_email` | `string` | The enterprise security contact email. | | `viewer_is_admin` | `boolean` | Whether the authenticated viewer is an enterprise admin. | +| `github_deployment_type` | `string` | The GitHub deployment type, such as `ghec` or `ghes`. | +| `ghes_version` | `string` | The GitHub Enterprise Server version, when applicable. | | `environment_name` | `string` | The enterprise environment name. | | `query_organizations` | `string` | Query for contained organizations. | diff --git a/descriptions/nodes/GH_EnterpriseRunner.md b/descriptions/nodes/GH_EnterpriseRunner.md index c400b36..be968cb 100644 --- a/descriptions/nodes/GH_EnterpriseRunner.md +++ b/descriptions/nodes/GH_EnterpriseRunner.md @@ -44,9 +44,6 @@ When the runner is not explicitly marked ephemeral, GH_CanInterceptJob edges ide 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_Environment.md b/descriptions/nodes/GH_Environment.md index ab8e14d..efe204d 100644 --- a/descriptions/nodes/GH_Environment.md +++ b/descriptions/nodes/GH_Environment.md @@ -6,6 +6,8 @@ Represents a GitHub Actions deployment environment configured on a repository. E Repositories always contain their environments. When custom branch policies are configured, the environment also contains one or more GH_EnvironmentBranchPolicy nodes that describe which branches are allowed to deploy. Environment-scoped secrets and variables are modeled as child nodes of the environment and become available to workflow jobs that reference it. +GH_CanRequestOIDCTokenFor edges from GH_WorkflowJob nodes identify jobs whose execution context can request a GitHub-signed OIDC token for this environment because their effective `GITHUB_TOKEN` permissions include `id-token:write`. + ## Properties | Property | Type | Description | @@ -56,5 +58,6 @@ graph LR n7 -->|GH_CanDeployToEnvironment| n1 n8 -.->|GH_ApprovesDeploymentTo| n1 n8 -->|GH_CanDeployToEnvironment| n1 + n9 -->|GH_CanRequestOIDCTokenFor| n1 n9 -.->|GH_DeploysTo| n1 ``` diff --git a/descriptions/nodes/GH_EnvironmentSecret.md b/descriptions/nodes/GH_EnvironmentSecret.md index 998b3b0..d33ed5b 100644 --- a/descriptions/nodes/GH_EnvironmentSecret.md +++ b/descriptions/nodes/GH_EnvironmentSecret.md @@ -33,6 +33,7 @@ graph LR n3["GH_WorkflowStep"] n0 -.->|GH_Contains| n1 n0 -->|GH_HasSecret| n1 + n2 -->|GH_CanAccessSecret| n1 n2 -.->|GH_UsesSecret| n1 n3 -.->|GH_UsesSecret| n1 ``` diff --git a/descriptions/nodes/GH_OrgRunner.md b/descriptions/nodes/GH_OrgRunner.md index 07d8d3e..227245d 100644 --- a/descriptions/nodes/GH_OrgRunner.md +++ b/descriptions/nodes/GH_OrgRunner.md @@ -44,9 +44,6 @@ When the runner is not explicitly marked ephemeral, GH_CanInterceptJob edges ide 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_OrgSecret.md b/descriptions/nodes/GH_OrgSecret.md index c367112..27595b9 100644 --- a/descriptions/nodes/GH_OrgSecret.md +++ b/descriptions/nodes/GH_OrgSecret.md @@ -33,6 +33,7 @@ graph LR n0 -->|GH_CanReadSecret| n1 n2 -.->|GH_Contains| n1 n3 -->|GH_HasSecret| n1 + n4 -->|GH_CanAccessSecret| n1 n4 -.->|GH_UsesSecret| n1 n5 -.->|GH_UsesSecret| n1 ``` diff --git a/descriptions/nodes/GH_Organization.md b/descriptions/nodes/GH_Organization.md index a7d8871..fda9f3a 100644 --- a/descriptions/nodes/GH_Organization.md +++ b/descriptions/nodes/GH_Organization.md @@ -72,6 +72,8 @@ Represents a GitHub organization. This is the root node of the graph and serves | `self_hosted_runners_enabled_repositories` | `string` | Which repositories may use self-hosted runners: `all`, `selected`, or `none`. | | `default_workflow_permissions` | `string` | The default workflow permissions property. | | `can_approve_pull_request_reviews` | `boolean` | The can approve pull request reviews property. | +| `github_deployment_type` | `string` | The GitHub deployment type, such as `ghec` or `ghes`. | +| `ghes_version` | `string` | The GitHub Enterprise Server version, when applicable. | | `query_organization_roles` | `string` | Query for organization roles. | | `query_users` | `string` | Query for users. | | `query_teams` | `string` | Query for teams. | diff --git a/descriptions/nodes/GH_PersonalAccessToken.md b/descriptions/nodes/GH_PersonalAccessToken.md index 28fe671..d3cc3b9 100644 --- a/descriptions/nodes/GH_PersonalAccessToken.md +++ b/descriptions/nodes/GH_PersonalAccessToken.md @@ -2,7 +2,9 @@ ## General Information -Represents a fine-grained personal access token that has been granted access to organization resources. PATs are linked to their owning user, the organization, and the repositories they can access. The permissions granted to the token are captured as a JSON string in the properties. +Represents a fine-grained personal access token that has been granted access to organization resources. PATs are linked to their owning user, the organization, and the repositories they can access. + +The granted permissions are stored separately as `organization_permissions` and `repository_permissions`. Each property is a list of `scope:access` values such as `members:read` or `contents:write`, matching the permission format used on GH_WorkflowJob nodes. ## Properties @@ -19,8 +21,8 @@ Represents a fine-grained personal access token that has been granted access to | `token_expires_at` | `datetime` | The ISO 8601 timestamp of when the token expires. | | `token_last_used_at` | `datetime` | The ISO 8601 timestamp of when the token was last used. | | `access_granted_at` | `datetime` | The ISO 8601 timestamp of when the token was granted to the organization. |. | -| `organization_permissions` | `string` | JSON string of the PAT's organization-scoped permissions. | -| `repository_permissions` | `string` | JSON string of the PAT's repository-scoped permissions. | +| `organization_permissions` | `list[string]` | Organization-scoped permissions in `scope:access` form. | +| `repository_permissions` | `list[string]` | Repository-scoped permissions in `scope:access` form. | | `token_name` | `string` | The user-assigned display name of the token. | | `owner_login` | `string` | The login handle of the user who owns the token. | | `repository_selection` | `string` | Whether the token has access to `all`, `subset`, or `none` of the organization's repositories. | diff --git a/descriptions/nodes/GH_PersonalAccessTokenRequest.md b/descriptions/nodes/GH_PersonalAccessTokenRequest.md index f6c7b3c..fd42491 100644 --- a/descriptions/nodes/GH_PersonalAccessTokenRequest.md +++ b/descriptions/nodes/GH_PersonalAccessTokenRequest.md @@ -2,7 +2,9 @@ ## General Information -Represents a pending request from an organization member to access organization resources with a fine-grained personal access token. PAT requests are linked to their owning user and the organization. The requested permissions are captured as a JSON string in the properties. +Represents a pending request from an organization member to access organization resources with a fine-grained personal access token. PAT requests are linked to their owning user and the organization. + +The requested permissions are stored separately as `organization_permissions` and `repository_permissions`. Each property is a list of `scope:access` values such as `members:read` or `contents:write`, matching the permission format used on GH_WorkflowJob nodes. ## Properties @@ -18,6 +20,8 @@ Represents a pending request from an organization member to access organization | `repository_selection` | `string` | Whether the request targets `all`, `subset`, or `none` of the organization's repositories. | | `reason` | `string` | The rationale provided by the requester for the access request. | | `org_name` | `string` | The org name property. | +| `organization_permissions` | `list[string]` | Requested organization-scoped permissions in `scope:access` form. | +| `repository_permissions` | `list[string]` | Requested repository-scoped permissions in `scope:access` form. | | `query_organization_permissions` | `string` | Query for organization permissions. | | `query_user` | `string` | Query for user. | | `query_repositories` | `string` | Query for repositories. | diff --git a/descriptions/nodes/GH_RepoRunner.md b/descriptions/nodes/GH_RepoRunner.md index e2c09ba..68cf820 100644 --- a/descriptions/nodes/GH_RepoRunner.md +++ b/descriptions/nodes/GH_RepoRunner.md @@ -44,9 +44,6 @@ When the runner is not explicitly marked ephemeral, GH_CanInterceptJob edges ide 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_RepoSecret.md b/descriptions/nodes/GH_RepoSecret.md index 8df6226..e837d8f 100644 --- a/descriptions/nodes/GH_RepoSecret.md +++ b/descriptions/nodes/GH_RepoSecret.md @@ -31,6 +31,7 @@ graph LR n3["GH_WorkflowStep"] n0 -.->|GH_Contains| n1 n0 -->|GH_HasSecret| n1 + n2 -->|GH_CanAccessSecret| n1 n2 -.->|GH_UsesSecret| n1 n3 -.->|GH_UsesSecret| n1 ``` diff --git a/descriptions/nodes/GH_Repository.md b/descriptions/nodes/GH_Repository.md index 8c5ffb6..e8f4321 100644 --- a/descriptions/nodes/GH_Repository.md +++ b/descriptions/nodes/GH_Repository.md @@ -6,6 +6,8 @@ Represents a GitHub repository within the organization. Repository nodes capture 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. +The `branch_count` and `environment_count` properties preserve GitHub-reported totals from the repository GraphQL response. These values can be compared to collected GH_Branch and GH_Environment children to identify incomplete collection before relying on branch- or environment-dependent analysis. + ## Properties | Property | Type | Description | @@ -39,11 +41,14 @@ For repositories with active workflows, the collector records the applicable def | `environment_name` | `string` | The name of the environment (GitHub organization). | | `actions_enabled` | `boolean` | Whether GitHub Actions is enabled for this repository. | | `self_hosted_runners_enabled` | `boolean` | Whether the repository may use self-hosted runners. | +| `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. | | `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. | +| `branch_count` | `integer` | Number of branch refs reported by GitHub for this repository. | +| `environment_count` | `integer` | Number of deployment environments reported by GitHub for this repository. | +| `deploy_key_count` | `integer` | Number of deploy keys reported by GitHub for this repository. | | `query_branches` | `string` | Query for branches. | | `query_protected_branches` | `string` | Query for protected branches. | | `query_branch_protection_rules` | `string` | Query for branch protection rules. | @@ -54,6 +59,7 @@ For repositories with active workflows, the collector records the applicable def | `query_environments` | `string` | Query for environments. | | `query_secrets` | `string` | Query for secrets. | | `query_variables` | `string` | Query for variables. | +| `query_deploy_keys` | `string` | Query for deploy keys. | | `query_secret_scanning_alerts` | `string` | Query for secret scanning alerts. | | `query_explicit_readers` | `string` | Query for explicit readers. | | `query_unrolled_readers` | `string` | Query for unrolled readers. | @@ -66,98 +72,101 @@ For repositories with active workflows, the collector records the applicable def graph LR n0["GH_AppInstallation"] n1["GH_Repository"] - n2["GH_Organization"] - n3["GH_PersonalAccessToken"] - n4["GH_RepoRole"] - n5["GH_Branch"] - n6["GH_BranchProtectionRule"] - n7["GH_Environment"] - n8["GH_OrgRunnerGroup"] - n9["GH_OrgSecret"] - n10["GH_OrgVariable"] - n11["GH_RepoRunner"] - n12["GH_RepoSecret"] - n13["GH_RepoVariable"] - n14["GH_SecretScanningAlert"] - n15["GH_Workflow"] + n2["GH_DeployKey"] + n3["GH_Organization"] + n4["GH_PersonalAccessToken"] + n5["GH_RepoRole"] + n6["GH_Branch"] + n7["GH_BranchProtectionRule"] + n8["GH_Environment"] + n9["GH_OrgRunnerGroup"] + n10["GH_OrgSecret"] + n11["GH_OrgVariable"] + n12["GH_RepoRunner"] + n13["GH_RepoSecret"] + n14["GH_RepoVariable"] + n15["GH_SecretScanningAlert"] + n16["GH_Workflow"] n0 -.->|GH_CanAccess| n1 - n2 -->|GH_Owns| n1 - n3 -.->|GH_CanAccess| n1 - n4 -.->|GH_AddAssignee| n1 - n4 -.->|GH_AddLabel| n1 - n4 -->|GH_AdminTo| n1 - n4 -.->|GH_BypassBranchProtection| n1 - n4 -.->|GH_CanCreateBranch| n1 - n4 -->|GH_CanCreateEnvironment| n1 - n4 -->|GH_CanPwnRequest| n1 - n4 -.->|GH_CloseDiscussion| n1 - n4 -.->|GH_CloseIssue| n1 - n4 -.->|GH_ClosePullRequest| n1 - n4 -.->|GH_ConvertIssuesToDiscussions| n1 - n4 -.->|GH_CreateDiscussionCategory| n1 - n4 -.->|GH_CreateSoloMergeQueueEntry| n1 - n4 -.->|GH_CreateTag| n1 - n4 -.->|GH_DeleteAlertsCodeScanning| n1 - n4 -.->|GH_DeleteDiscussion| n1 - n4 -.->|GH_DeleteDiscussionComment| n1 - n4 -.->|GH_DeleteIssue| n1 - n4 -.->|GH_DeleteTag| n1 - n4 -.->|GH_EditCategoryOnDiscussion| n1 - n4 -.->|GH_EditDiscussionCategory| n1 - n4 -.->|GH_EditDiscussionComment| n1 - n4 -.->|GH_EditRepoAnnouncementBanners| n1 - n4 -.->|GH_EditRepoCustomPropertiesValues| n1 - n4 -.->|GH_EditRepoMetadata| n1 - n4 -.->|GH_EditRepoProtections| n1 - n4 -.->|GH_JumpMergeQueue| n1 - n4 -.->|GH_ManageDeployKeys| n1 - n4 -.->|GH_ManageDiscussionBadges| n1 - n4 -.->|GH_ManageRepoSecurityProducts| n1 - n4 -.->|GH_ManageSecurityProducts| n1 - n4 -.->|GH_ManageSettingsMergeTypes| n1 - n4 -.->|GH_ManageSettingsPages| n1 - n4 -.->|GH_ManageSettingsProjects| n1 - n4 -.->|GH_ManageSettingsWiki| n1 - n4 -.->|GH_ManageTopics| n1 - n4 -.->|GH_ManageWebhooks| n1 - n4 -.->|GH_MarkAsDuplicate| n1 - n4 -.->|GH_PushProtectedBranch| n1 - n4 -.->|GH_ReadCodeScanning| n1 - n4 -.->|GH_ReadRepoContents| n1 - n4 -.->|GH_RemoveAssignee| n1 - n4 -.->|GH_RemoveLabel| n1 - n4 -.->|GH_ReopenDiscussion| n1 - n4 -.->|GH_ReopenIssue| n1 - n4 -.->|GH_ReopenPullRequest| n1 - n4 -.->|GH_RequestPrReview| n1 - n4 -.->|GH_ResolveDependabotAlerts| n1 - n4 -.->|GH_ResolveSecretScanningAlerts| n1 - n4 -.->|GH_RunOrgMigration| n1 - n4 -.->|GH_SetInteractionLimits| n1 - n4 -.->|GH_SetIssueType| n1 - n4 -.->|GH_SetMilestone| n1 - n4 -.->|GH_SetSocialPreview| n1 - n4 -.->|GH_ToggleDiscussionAnswer| n1 - n4 -.->|GH_ToggleDiscussionCommentMinimize| n1 - n4 -.->|GH_ViewDependabotAlerts| n1 - n4 -.->|GH_ViewSecretScanningAlerts| n1 - n4 -.->|GH_WriteCodeScanning| n1 - n4 -.->|GH_WriteRepoContents| n1 - n4 -.->|GH_WriteRepoPullRequests| n1 - n1 -.->|GH_Contains| n5 + n2 -.->|GH_CanAccess| n1 + n3 -->|GH_Owns| n1 + n4 -.->|GH_CanAccess| n1 + n5 -.->|GH_AddAssignee| n1 + n5 -.->|GH_AddLabel| n1 + n5 -->|GH_AdminTo| n1 + n5 -.->|GH_BypassBranchProtection| n1 + n5 -.->|GH_CanCreateBranch| n1 + n5 -->|GH_CanCreateEnvironment| n1 + n5 -->|GH_CanPwnRequest| n1 + n5 -.->|GH_CloseDiscussion| n1 + n5 -.->|GH_CloseIssue| n1 + n5 -.->|GH_ClosePullRequest| n1 + n5 -.->|GH_ConvertIssuesToDiscussions| n1 + n5 -.->|GH_CreateDiscussionCategory| n1 + n5 -.->|GH_CreateSoloMergeQueueEntry| n1 + n5 -.->|GH_CreateTag| n1 + n5 -.->|GH_DeleteAlertsCodeScanning| n1 + n5 -.->|GH_DeleteDiscussion| n1 + n5 -.->|GH_DeleteDiscussionComment| n1 + n5 -.->|GH_DeleteIssue| n1 + n5 -.->|GH_DeleteTag| n1 + n5 -.->|GH_EditCategoryOnDiscussion| n1 + n5 -.->|GH_EditDiscussionCategory| n1 + n5 -.->|GH_EditDiscussionComment| n1 + n5 -.->|GH_EditRepoAnnouncementBanners| n1 + n5 -.->|GH_EditRepoCustomPropertiesValues| n1 + n5 -.->|GH_EditRepoMetadata| n1 + n5 -.->|GH_EditRepoProtections| n1 + n5 -.->|GH_JumpMergeQueue| n1 + n5 -.->|GH_ManageDeployKeys| n1 + n5 -.->|GH_ManageDiscussionBadges| n1 + n5 -.->|GH_ManageRepoSecurityProducts| n1 + n5 -.->|GH_ManageSecurityProducts| n1 + n5 -.->|GH_ManageSettingsMergeTypes| n1 + n5 -.->|GH_ManageSettingsPages| n1 + n5 -.->|GH_ManageSettingsProjects| n1 + n5 -.->|GH_ManageSettingsWiki| n1 + n5 -.->|GH_ManageTopics| n1 + n5 -.->|GH_ManageWebhooks| n1 + n5 -.->|GH_MarkAsDuplicate| n1 + n5 -.->|GH_PushProtectedBranch| n1 + n5 -.->|GH_ReadCodeScanning| n1 + n5 -.->|GH_ReadRepoContents| n1 + n5 -.->|GH_RemoveAssignee| n1 + n5 -.->|GH_RemoveLabel| n1 + n5 -.->|GH_ReopenDiscussion| n1 + n5 -.->|GH_ReopenIssue| n1 + n5 -.->|GH_ReopenPullRequest| n1 + n5 -.->|GH_RequestPrReview| n1 + n5 -.->|GH_ResolveDependabotAlerts| n1 + n5 -.->|GH_ResolveSecretScanningAlerts| n1 + n5 -.->|GH_RunOrgMigration| n1 + n5 -.->|GH_SetInteractionLimits| n1 + n5 -.->|GH_SetIssueType| n1 + n5 -.->|GH_SetMilestone| n1 + n5 -.->|GH_SetSocialPreview| n1 + n5 -.->|GH_ToggleDiscussionAnswer| n1 + n5 -.->|GH_ToggleDiscussionCommentMinimize| n1 + n5 -.->|GH_ViewDependabotAlerts| n1 + n5 -.->|GH_ViewSecretScanningAlerts| n1 + n5 -.->|GH_WriteCodeScanning| n1 + n5 -.->|GH_WriteRepoContents| n1 + n5 -.->|GH_WriteRepoPullRequests| n1 n1 -.->|GH_Contains| n6 - n1 -->|GH_CanDeployToEnvironment| n7 n1 -.->|GH_Contains| n7 - n1 -->|GH_CanUseRunner| n8 - n1 -.->|GH_IsEligibleFor| n8 - n1 -->|GH_HasSecret| n9 - n1 -->|GH_HasVariable| n10 - n1 -.->|GH_CanUseRunner| n11 - n1 -.->|GH_Contains| n11 + n1 -.->|GH_Contains| n2 + n1 -->|GH_CanDeployToEnvironment| n8 + n1 -.->|GH_Contains| n8 + n1 -->|GH_CanUseRunner| n9 + n1 -.->|GH_IsEligibleFor| n9 + n1 -->|GH_HasSecret| n10 + n1 -->|GH_HasVariable| n11 + n1 -.->|GH_CanUseRunner| n12 n1 -.->|GH_Contains| n12 - n1 -->|GH_HasSecret| n12 n1 -.->|GH_Contains| n13 - n1 -->|GH_HasVariable| n13 + n1 -->|GH_HasSecret| n13 n1 -.->|GH_Contains| n14 + n1 -->|GH_HasVariable| n14 n1 -.->|GH_Contains| n15 + n1 -.->|GH_Contains| n16 ``` diff --git a/descriptions/nodes/GH_Runner.md b/descriptions/nodes/GH_Runner.md index c45d82e..f5973f0 100644 --- a/descriptions/nodes/GH_Runner.md +++ b/descriptions/nodes/GH_Runner.md @@ -19,4 +19,7 @@ Generic label applied to GitHub self-hosted runner nodes across enterprise, orga ```mermaid graph LR n0["GH_Runner"] + n1["GH_WorkflowJob"] + n0 -->|GH_CanInterceptJob| n1 + n1 -.->|GH_RunsOn| n0 ``` diff --git a/descriptions/nodes/GH_User.md b/descriptions/nodes/GH_User.md index 72821ae..ba3002c 100644 --- a/descriptions/nodes/GH_User.md +++ b/descriptions/nodes/GH_User.md @@ -36,13 +36,14 @@ graph LR n4["GH_SecretScanningAlert"] n5["GH_Branch"] n6["GH_BranchProtectionRule"] - n7["GH_EnterpriseRole"] - n8["GH_Environment"] - n9["GH_OrgRole"] - n10["GH_PersonalAccessToken"] - n11["GH_PersonalAccessTokenRequest"] - n12["GH_RepoRole"] - n13["GH_TeamRole"] + n7["GH_DeployKey"] + n8["GH_EnterpriseRole"] + n9["GH_Environment"] + n10["GH_OrgRole"] + n11["GH_PersonalAccessToken"] + n12["GH_PersonalAccessTokenRequest"] + n13["GH_RepoRole"] + n14["GH_TeamRole"] n0 -.->|GH_HasMember| n1 n2 -.->|GH_MapsToUser| n1 n3 -.->|GH_MapsToUser| n1 @@ -50,12 +51,13 @@ graph LR n1 -.->|GH_CanWriteBranch| n5 n1 -.->|GH_BypassPullRequestAllowances| n6 n1 -.->|GH_RestrictionsCanPush| n6 - n1 -->|GH_HasRole| n7 - n1 -.->|GH_ApprovesDeploymentTo| n8 - n1 -->|GH_CanDeployToEnvironment| n8 - n1 -->|GH_HasRole| n9 - n1 -.->|GH_HasPersonalAccessToken| n10 - n1 -.->|GH_HasPersonalAccessTokenRequest| n11 - n1 -->|GH_HasRole| n12 + n1 -.->|GH_AddedDeployKey| n7 + n1 -->|GH_HasRole| n8 + n1 -.->|GH_ApprovesDeploymentTo| n9 + n1 -->|GH_CanDeployToEnvironment| n9 + n1 -->|GH_HasRole| n10 + n1 -.->|GH_HasPersonalAccessToken| n11 + n1 -.->|GH_HasPersonalAccessTokenRequest| n12 n1 -->|GH_HasRole| n13 + n1 -->|GH_HasRole| n14 ``` diff --git a/descriptions/nodes/GH_Workflow.md b/descriptions/nodes/GH_Workflow.md index 5d2d951..672e506 100644 --- a/descriptions/nodes/GH_Workflow.md +++ b/descriptions/nodes/GH_Workflow.md @@ -24,10 +24,10 @@ When present, `workflow_permissions` captures the top-level `permissions` declar | `html_url` | `string` | The GitHub web URL for the workflow file. | | `branch` | `string` | The branch where the workflow file was found. | | `contents` | `string` | The content of the workflow file. | +| `workflow_permissions` | `list[string]` | Permissions declared at the workflow level. | | `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 7c919dd..5ab7a3c 100644 --- a/descriptions/nodes/GH_WorkflowJob.md +++ b/descriptions/nodes/GH_WorkflowJob.md @@ -8,7 +8,7 @@ When the job has a statically resolvable self-hosted `runs-on` selector, GH_Runs 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. +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 a job targets an environment and its effective permissions include `id-token:write`, GH_CanRequestOIDCTokenFor identifies the environment OIDC context that code executing in the job can request. 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. @@ -47,31 +47,32 @@ When `runs_on_is_dynamic` is true, runner matching and interception status remai ```mermaid graph LR - n0["GH_Workflow"] + n0["GH_Runner"] n1["GH_WorkflowJob"] - n2["GH_Environment"] - n3["GH_EnvironmentSecret"] - n4["GH_EnvironmentVariable"] - n5["GH_OrgSecret"] - n6["GH_OrgVariable"] - n7["GH_RepoSecret"] - n8["GH_RepoVariable"] - n9["GH_Runner"] + n2["GH_Workflow"] + n3["GH_Environment"] + n4["GH_EnvironmentSecret"] + n5["GH_EnvironmentVariable"] + n6["GH_OrgSecret"] + n7["GH_OrgVariable"] + n8["GH_RepoSecret"] + n9["GH_RepoVariable"] n10["GH_WorkflowStep"] - n0 -.->|GH_Contains| n1 - n1 -.->|GH_DeploysTo| n2 - n1 -.->|GH_UsesSecret| n3 - n1 -.->|GH_UsesVariable| n4 - n1 -.->|GH_UsesSecret| n5 - 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 + n0 -->|GH_CanInterceptJob| n1 + n2 -.->|GH_Contains| n1 + n1 -->|GH_CanRequestOIDCTokenFor| n3 + n1 -.->|GH_DeploysTo| n3 + n1 -->|GH_CanAccessSecret| n4 + n1 -.->|GH_UsesSecret| n4 + n1 -.->|GH_UsesVariable| n5 + n1 -->|GH_CanAccessSecret| n6 + n1 -.->|GH_UsesSecret| n6 + n1 -.->|GH_UsesVariable| n7 + n1 -->|GH_CanAccessSecret| n8 + n1 -.->|GH_UsesSecret| n8 + n1 -.->|GH_UsesVariable| n9 + n1 -.->|GH_RunsOn| n0 + n1 -.->|GH_CallsWorkflow| n2 n1 -.->|GH_DependsOn| n1 n1 -.->|GH_Contains| n10 ``` diff --git a/extension/privilege_zone_rules/t0-app-installations-all-repos.json b/extension/privilege_zone_rules/t0-app-installations-all-repos.json index 0ef7811..416d252 100644 --- a/extension/privilege_zone_rules/t0-app-installations-all-repos.json +++ b/extension/privilege_zone_rules/t0-app-installations-all-repos.json @@ -1,7 +1,7 @@ { "name": "GitHub: Tier Zero App Installations (All Repositories)", "description": "GitHub App installations scoped to all repositories in the organization that have at least one write permission. A compromised app credential grants write access to every repository. Installations with only read permissions are excluded — they pose a data exfiltration risk but do not grant control over the organization.", - "cypher": "MATCH (n:GH_AppInstallation {repository_selection:'all'})\nWHERE n.permissions CONTAINS '\"write\"'\nRETURN n", + "cypher": "MATCH (n:GH_AppInstallation {repository_selection:'all'})\nWHERE ANY(permission IN n.permissions WHERE permission ENDS WITH ':write')\nRETURN n", "enabled": true, "zone": "Tier Zero", "allow_disable": true diff --git a/extension/privilege_zone_rules/t0-apps-all-repos.json b/extension/privilege_zone_rules/t0-apps-all-repos.json index 040e7e8..19f6bac 100644 --- a/extension/privilege_zone_rules/t0-apps-all-repos.json +++ b/extension/privilege_zone_rules/t0-apps-all-repos.json @@ -1,7 +1,7 @@ { "name": "GitHub: Tier Zero Apps (All-Repository Installations)", "description": "GitHub App definitions whose installations have write access to all repositories. The app owner controls the private key that can generate tokens for any installation. Compromise of the app's private key grants write access to every repository in organizations where it is installed. Apps whose installations have only read permissions are excluded.", - "cypher": "MATCH (n:GH_App)-[:GH_InstalledAs]->(i:GH_AppInstallation {repository_selection:'all'})\nWHERE i.permissions CONTAINS '\"write\"'\nRETURN n", + "cypher": "MATCH (n:GH_App)-[:GH_InstalledAs]->(i:GH_AppInstallation {repository_selection:'all'})\nWHERE ANY(permission IN i.permissions WHERE permission ENDS WITH ':write')\nRETURN n", "enabled": true, "zone": "Tier Zero", "allow_disable": true diff --git a/extension/privilege_zone_rules/t0-pats-all-repos.json b/extension/privilege_zone_rules/t0-pats-all-repos.json index 7b3deca..b3bcfa8 100644 --- a/extension/privilege_zone_rules/t0-pats-all-repos.json +++ b/extension/privilege_zone_rules/t0-pats-all-repos.json @@ -1,7 +1,7 @@ { "name": "GitHub: Tier Zero PATs (All Repositories)", "description": "Fine-grained personal access tokens scoped to all repositories in the organization that have at least one write permission. A single compromised token grants write access to every repository. PATs with only read permissions are excluded — they pose a data exfiltration risk but do not grant control over the organization.", - "cypher": "MATCH (n:GH_PersonalAccessToken {repository_selection:'all'})\nWHERE n.permissions CONTAINS '\"write\"'\nRETURN n", + "cypher": "MATCH (n:GH_PersonalAccessToken {repository_selection:'all'})\nWHERE ANY(permission IN n.repository_permissions WHERE permission ENDS WITH ':write')\nRETURN n", "enabled": true, "zone": "Tier Zero", "allow_disable": true diff --git a/extension/schema.json b/extension/schema.json index fa63a17..be6dc42 100644 --- a/extension/schema.json +++ b/extension/schema.json @@ -190,6 +190,14 @@ "icon": "lock-open", "color": "#66C7E8" }, + { + "name": "GH_DeployKey", + "display_name": "GitHub Deploy Key", + "description": "A repository-scoped SSH deploy key that grants read-only or read-write access to repository contents", + "is_display_kind": true, + "icon": "key", + "color": "#F9C74F" + }, { "name": "GH_Secret", "display_name": "GitHub Secret", @@ -325,6 +333,11 @@ "description": "Container relationship for organizational hierarchy (org contains secrets/variables, repo contains secrets/variables, environment contains secrets/variables)", "is_traversable": false }, + { + "name": "GH_AddedDeployKey", + "description": "User added this repository deploy key", + "is_traversable": false + }, { "name": "GH_CreateEnterpriseOrganizations", "description": "[Enterprise] Enterprise role can create organizations", @@ -962,7 +975,7 @@ }, { "name": "GH_CanAccess", - "description": "Personal access token or app installation can access this repository or organization", + "description": "Personal access token, app installation, or deploy key can access this repository or organization", "is_traversable": false }, { @@ -1055,6 +1068,11 @@ "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_CanRequestOIDCTokenFor", + "description": "[Computed] Workflow job execution context can request a GitHub OIDC token for its deployment environment — GH_WorkflowJob → GH_Environment", + "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/scripts/generate_gh_description_docs.py b/scripts/generate_gh_description_docs.py index 3abb8da..3f39cc6 100644 --- a/scripts/generate_gh_description_docs.py +++ b/scripts/generate_gh_description_docs.py @@ -175,7 +175,12 @@ def extract_general_information(path: Path) -> str | None: text = text[match.end() :].strip() else: text = re.sub(r"^# .+?\n+", "", text, count=1).strip() - text = re.split(r"^## Diagram\s*$", text, maxsplit=1, flags=re.MULTILINE)[0].strip() + text = re.split( + r"^## (?:Properties|Edge Schema|Diagram)\s*$", + text, + maxsplit=1, + flags=re.MULTILINE, + )[0].strip() text = re.sub(r"\n```mermaid\n.*?\n```\s*$", "", text, flags=re.DOTALL).strip() return text or None diff --git a/src/openhound_github/graphql.py b/src/openhound_github/graphql.py index 16e07d3..87b9578 100644 --- a/src/openhound_github/graphql.py +++ b/src/openhound_github/graphql.py @@ -271,6 +271,7 @@ totalCount } refs(first: 100, refPrefix: "refs/heads/") { + totalCount nodes { id name @@ -279,6 +280,12 @@ } pageInfo { endCursor hasNextPage } } + environments(first: 1) { + totalCount + } + deployKeys(first: 1) { + totalCount + } } pageInfo { endCursor hasNextPage } } diff --git a/src/openhound_github/kinds/edges.py b/src/openhound_github/kinds/edges.py index da2a377..bcbff94 100644 --- a/src/openhound_github/kinds/edges.py +++ b/src/openhound_github/kinds/edges.py @@ -20,7 +20,8 @@ CAN_USE_RUNNER = "GH_CanUseRunner" RUNS_ON = "GH_RunsOn" CAN_INTERCEPT_JOB = "GH_CanInterceptJob" -CAN_ACCESS_SECRET = "GH_CanAccessSecret" +CAN_ACCESS_SECRET = "GH_CanAccessSecret" # noqa: S105 - OpenGraph edge kind +CAN_REQUEST_OIDC_TOKEN_FOR = "GH_CanRequestOIDCTokenFor" # noqa: S105 - OpenGraph edge kind IS_ELIGIBLE_FOR = "GH_IsEligibleFor" CAN_CREATE_REPOSITORY_WITH_RUNNER_ACCESS = "GH_CanCreateRepositoryWithRunnerAccess" CAN_CREATE_BRANCH = "GH_CanCreateBranch" @@ -86,6 +87,7 @@ # Secret and variable edges HAS_SECRET = "GH_HasSecret" HAS_VARIABLE = "GH_HasVariable" +ADDED_DEPLOY_KEY = "GH_AddedDeployKey" # App installation edges INSTALLED_AS = "GH_InstalledAs" diff --git a/src/openhound_github/kinds/nodes.py b/src/openhound_github/kinds/nodes.py index 022e8f8..beddeab 100644 --- a/src/openhound_github/kinds/nodes.py +++ b/src/openhound_github/kinds/nodes.py @@ -33,6 +33,7 @@ REPO_SECRET = "GH_RepoSecret" REPO_VARIABLE = "GH_RepoVariable" REPO_RUNNER = "GH_RepoRunner" +DEPLOY_KEY = "GH_DeployKey" DEFAULT_ROLE = "GH_RepoRole" diff --git a/src/openhound_github/lookup.py b/src/openhound_github/lookup.py index 68beac1..6d8ec6b 100644 --- a/src/openhound_github/lookup.py +++ b/src/openhound_github/lookup.py @@ -54,6 +54,35 @@ def org_id_for_login(self, org_login: str) -> str | None: [org_login], ) + @lru_cache + def user_node_id_for_login(self, org_login: str, user_login: str) -> str | None: + return self._find_single_object( + f""" + WITH candidate_users AS ( + SELECT 0 AS priority, u.id + FROM {self.schema}.users u + WHERE lower(u.org_login) = lower(?) + AND lower(u.login) = lower(?) + + UNION ALL + + SELECT 1 AS priority, eu.id + FROM {self.schema}.enterprise_users eu + JOIN {self.schema}.enterprise e + ON lower(e.slug) = lower(eu.enterprise_slug) + JOIN {self.schema}.enterprise_organizations eo + ON eo.enterprise_node_id = e.id + WHERE lower(eo.login) = lower(?) + AND lower(eu.login) = lower(?) + ) + SELECT id + FROM candidate_users + ORDER BY priority + LIMIT 1 + """, + [org_login, user_login, org_login, user_login], + ) + @lru_cache def org_login(self) -> str | None: res = self._find_single_object( @@ -243,18 +272,21 @@ def _workflow_job_runner_matches( repository_visibility, actions_enabled = repository required_labels = {str(label).casefold() for label in labels} - matching_runners: list[tuple[str, bool | None]] = [] + matching_runners: list[tuple[int, str, bool | None]] = [] seen_runner_node_ids: set[str] = set() def add_matching_runner( - node_id: str, raw_labels: Any, ephemeral: bool | None + source_order: int, + 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)) + matching_runners.append((source_order, node_id, ephemeral)) if group_name is None: for runner_id, raw_labels, ephemeral in self._find_all_objects( @@ -266,6 +298,7 @@ def add_matching_runner( [repository_node_id], ): add_matching_runner( + 0, runner_node_id(repository_node_id, int(runner_id)), raw_labels, ephemeral, @@ -274,6 +307,16 @@ def add_matching_runner( if actions_enabled is not True: return [] + org_node_id = self.org_id_for_login(org_login) + if not org_node_id: + return [ + (node_id, ephemeral) + for _source_order, node_id, ephemeral in sorted( + matching_runners, + key=lambda runner: (runner[0], runner[1]), + ) + ] + for ( runner_group_id, runner_group_name, @@ -316,9 +359,6 @@ def add_matching_runner( 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 @@ -345,6 +385,7 @@ def add_matching_runner( [enterprise_node_id, enterprise_runner_group_id], ): add_matching_runner( + 2, runner_node_id(enterprise_node_id, int(runner_id)), raw_labels, ephemeral, @@ -364,12 +405,19 @@ def add_matching_runner( [org_login, runner_group_id], ): add_matching_runner( - runner_node_id(self.org_id_for_login(org_login), int(runner_id)), + 1, + runner_node_id(org_node_id, int(runner_id)), raw_labels, ephemeral, ) - return matching_runners + return [ + (node_id, ephemeral) + for _source_order, node_id, ephemeral in sorted( + matching_runners, + key=lambda runner: (runner[0], runner[1]), + ) + ] @lru_cache def workflow_job_runner_node_ids( @@ -583,6 +631,26 @@ def repository_branch_ruleset_count(self, repository_node_id: str) -> int | None return None return int(row[0]) + @lru_cache + def repository_graphql_counts( + self, repository_node_id: str + ) -> tuple[int | None, int | None, int | None]: + row = self._find_single_row( + f""" + SELECT branch_count, environment_count, deploy_key_count + FROM {self.schema}.repositories_graphql + WHERE id = ? + """, + [repository_node_id], + ) + if row is None: + return None, None, None + return ( + None if row[0] is None else int(row[0]), + None if row[1] is None else int(row[1]), + None if row[2] is None else int(row[2]), + ) + @lru_cache def repository_workflow_permissions( self, repository_node_id: str @@ -594,6 +662,9 @@ def repository_workflow_permissions( repository_can_approve_pull_request_reviews FROM {self.schema}.workflows WHERE repository_node_id = ? + ORDER BY + repository_default_workflow_permissions IS NULL, + repository_can_approve_pull_request_reviews IS NULL LIMIT 1 """, [repository_node_id], diff --git a/src/openhound_github/main.py b/src/openhound_github/main.py index 3775e59..63d3b41 100644 --- a/src/openhound_github/main.py +++ b/src/openhound_github/main.py @@ -67,6 +67,7 @@ def preproc(ctx: PreProcContext): "external_identities": "external_identities", "applications": "applications", "enterprise": "enterprise", + "enterprise_users": "enterprise_users", "enterprise_organizations": "enterprise_organizations", "enterprise_scim_groups": "enterprise_scim_groups", "enterprise_runner_groups": "enterprise_runner_groups", diff --git a/src/openhound_github/models/__init__.py b/src/openhound_github/models/__init__.py index 8332d96..38c8b6e 100644 --- a/src/openhound_github/models/__init__.py +++ b/src/openhound_github/models/__init__.py @@ -22,6 +22,7 @@ from .env_variable import EnvironmentVariable from .environment import Environment from .environment_branch_policy import EnvironmentBranchPolicy +from .deploy_key import DeployKey from .external_identity import ExternalIdentity from .org import Organization from .org_role import OrgRole @@ -132,6 +133,7 @@ "EnvironmentSecret", "EnvironmentVariable", "EnvironmentBranchPolicy", + "DeployKey", "Enterprise", "EnterpriseAdmin", "EnterpriseManagedUser", diff --git a/src/openhound_github/models/app_installation.py b/src/openhound_github/models/app_installation.py index c8344cc..1ce5c55 100644 --- a/src/openhound_github/models/app_installation.py +++ b/src/openhound_github/models/app_installation.py @@ -12,6 +12,7 @@ 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.permissions import normalize_permission_declaration @dataclass @@ -28,7 +29,7 @@ class GHAppInstallationProperties(GHNodeProperties): repositories_url: API URL to list repositories accessible to this installation. repository_selection: Whether the app has access to `all` repositories or `selected` repositories. target_type: The target type of the installation (e.g., `Organization`). - permissions: JSON string of the permissions granted to the app (e.g., `{"contents": "read", "metadata": "read"}`). + permissions: Permissions granted to the installation in `scope:access` form. events: JSON string of the webhook events the app subscribes to. created_at: When the app was installed. updated_at: When the installation was last updated. @@ -47,7 +48,7 @@ class GHAppInstallationProperties(GHNodeProperties): repositories_url: str | None = None repository_selection: str | None = None target_type: str | None = None - permissions: str | None = None + permissions: list[str] | None = None events: str | None = None created_at: datetime | None = None updated_at: datetime | None = None @@ -141,7 +142,7 @@ def as_node(self) -> GHNode: repositories_url=self.repositories_url, repository_selection=self.repository_selection, target_type=self.target_type, - permissions=json.dumps(self.permissions) if self.permissions else None, + permissions=normalize_permission_declaration(self.permissions), events=json.dumps(self.events) if self.events else None, created_at=self.created_at, updated_at=self.updated_at, diff --git a/src/openhound_github/models/deploy_key.py b/src/openhound_github/models/deploy_key.py new file mode 100644 index 0000000..56d70f6 --- /dev/null +++ b/src/openhound_github/models/deploy_key.py @@ -0,0 +1,186 @@ +from dataclasses import dataclass +from datetime import datetime + +from openhound.core.asset import BaseAsset, EdgeDef, NodeDef +from openhound.core.models.entries_dataclass import Edge, EdgePath, EdgeProperties + +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 + + +@dataclass +class GHDeployKeyProperties(GHNodeProperties): + """Repository deploy key properties and accordion panel queries. + + Attributes: + github_id: The numeric GitHub deploy key ID. + title: The user-assigned deploy key title. + key: The public SSH key material. + verified: Whether GitHub has verified the deploy key. + enabled: Whether the deploy key is currently enabled. + read_only: Whether the deploy key is restricted to read-only repository access. + repository_permissions: Repository-scoped permissions in `scope:access` form. + created_at: When the deploy key was created. + last_used: When the deploy key was last used. + added_by_login: The login of the user who added the deploy key. + added_by_id: The node ID of the user who added the deploy key. + repository_name: The full name of the containing repository. + repository_id: The node_id of the containing repository. + environment_name: The name of the environment (GitHub organization). + query_repository: Query for the containing repository. + query_added_by: Query for the user who added the deploy key. + """ + + github_id: int | None = None + title: str | None = None + key: str | None = None + verified: bool | None = None + enabled: bool | None = None + read_only: bool | None = None + repository_permissions: list[str] | None = None + created_at: str | None = None + last_used: str | None = None + added_by_login: str | None = None + added_by_id: str | None = None + repository_name: str | None = None + repository_id: str | None = None + environment_name: str | None = None + query_repository: str | None = None + query_added_by: str | None = None + + +@app.asset( + node=NodeDef( + kind=nk.DEPLOY_KEY, + description="GitHub Repository Deploy Key", + icon="key", + properties=GHDeployKeyProperties, + ), + edges=[ + EdgeDef( + start=nk.REPOSITORY, + end=nk.DEPLOY_KEY, + kind=ek.CONTAINS, + description="Repository contains deploy key", + traversable=False, + ), + EdgeDef( + start=nk.DEPLOY_KEY, + end=nk.REPOSITORY, + kind=ek.CAN_ACCESS, + description="Deploy key can access repository", + traversable=False, + ), + EdgeDef( + start=nk.USER, + end=nk.DEPLOY_KEY, + kind=ek.ADDED_DEPLOY_KEY, + description="User added deploy key", + traversable=False, + ), + ], +) +class DeployKey(BaseAsset): + """One record from `repository_deploy_keys` -> one GH_DeployKey node and access edges.""" + + id: int + key: str | None = None + url: str | None = None + title: str + verified: bool | None = None + enabled: bool | None = None + created_at: datetime | None = None + read_only: bool | None = None + last_used: datetime | None = None + added_by: str | dict | None = None + + # Additional + org_login: str + repository_name: str + repository_node_id: str + + @property + def org_node_id(self) -> str | None: + return self._lookup.org_id_for_login(self.org_login) + + @property + def node_id(self) -> str: + return f"GH_DeployKey_{self.repository_node_id}_{self.id}" + + @property + def repository_permissions(self) -> list[str] | None: + if self.read_only is None: + return None + return ["contents:read" if self.read_only else "contents:write"] + + @property + def added_by_login(self) -> str | None: + if isinstance(self.added_by, dict): + return self.added_by.get("login") + if isinstance(self.added_by, str): + return self.added_by + return None + + @property + def added_by_node_id(self) -> str | None: + if self.added_by_login: + return self._lookup.user_node_id_for_login( + self.org_login, self.added_by_login + ) + return None + + @property + def as_node(self) -> GHNode: + return GHNode( + kinds=[nk.DEPLOY_KEY], + properties=GHDeployKeyProperties( + name=f"{self.repository_name}/{self.title}", + displayname=self.title, + node_id=self.node_id, + github_id=self.id, + title=self.title, + key=self.key, + verified=self.verified, + enabled=self.enabled, + read_only=self.read_only, + repository_permissions=self.repository_permissions, + created_at=str(self.created_at) if self.created_at else None, + last_used=str(self.last_used) if self.last_used else None, + added_by_login=self.added_by_login, + added_by_id=self.added_by_node_id, + repository_name=self.repository_name, + repository_id=self.repository_node_id, + environment_name=self.org_login, + environmentid=self.org_node_id, + query_repository=f"MATCH p=(:GH_DeployKey {{node_id:'{self.node_id}'}})-[:GH_CanAccess]->(:GH_Repository) RETURN p", + query_added_by=f"MATCH p=(:GH_User)-[:GH_AddedDeployKey]->(:GH_DeployKey {{node_id:'{self.node_id}'}}) RETURN p", + ), + ) + + @property + def _added_by_edge(self): + if self.added_by_node_id: + yield Edge( + kind=ek.ADDED_DEPLOY_KEY, + start=EdgePath(value=self.added_by_node_id, match_by="id"), + end=EdgePath(value=self.node_id, match_by="id"), + properties=EdgeProperties(traversable=False), + ) + + @property + def edges(self): + yield Edge( + kind=ek.CONTAINS, + start=EdgePath(value=self.repository_node_id, match_by="id"), + end=EdgePath(value=self.node_id, match_by="id"), + properties=EdgeProperties(traversable=False), + ) + yield Edge( + kind=ek.CAN_ACCESS, + start=EdgePath(value=self.node_id, match_by="id"), + end=EdgePath(value=self.repository_node_id, match_by="id"), + properties=EdgeProperties(traversable=False), + ) + yield from self._added_by_edge diff --git a/src/openhound_github/models/permissions.py b/src/openhound_github/models/permissions.py new file mode 100644 index 0000000..b41a416 --- /dev/null +++ b/src/openhound_github/models/permissions.py @@ -0,0 +1,18 @@ +from typing import Any + + +def normalize_permission_declaration(value: Any) -> list[str] | None: + """Normalize GitHub permission payloads into query-friendly scope:access values.""" + 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)] diff --git a/src/openhound_github/models/personal_access_token.py b/src/openhound_github/models/personal_access_token.py index 5d2e7b5..ab0f35b 100644 --- a/src/openhound_github/models/personal_access_token.py +++ b/src/openhound_github/models/personal_access_token.py @@ -2,7 +2,6 @@ from datetime import datetime from typing import ClassVar -from dlt.common import json from dlt.common.libs.pydantic import DltConfig from openhound.core.asset import BaseAsset, EdgeDef, NodeDef from openhound.core.models.entries_dataclass import Edge, EdgePath, EdgeProperties @@ -12,6 +11,7 @@ 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.permissions import normalize_permission_declaration class Permissions(BaseModel): @@ -35,8 +35,8 @@ class GHPersonalAccessTokenProperties(GHNodeProperties): owner_id: The GitHub ID of the token owner. owner_node_id: The GraphQL node ID of the token owner. token_expires_at: The ISO 8601 timestamp of when the token expires. - organization_permissions: JSON string of the PAT's organization-scoped permissions. - repository_permissions: JSON string of the PAT's repository-scoped permissions. + organization_permissions: Organization-scoped permissions in `scope:access` form. + repository_permissions: Repository-scoped permissions in `scope:access` form. token_last_used_at: The ISO 8601 timestamp of when the token was last used. access_granted_at: The ISO 8601 timestamp of when the token was granted to the organization. | token_name: The user-assigned display name of the token. @@ -55,8 +55,8 @@ class GHPersonalAccessTokenProperties(GHNodeProperties): token_expires_at: datetime | None = None token_last_used_at: datetime | None = None access_granted_at: datetime | None = None - organization_permissions: str | None = None - repository_permissions: str | None = None + organization_permissions: list[str] | None = None + repository_permissions: list[str] | None = None token_name: str | None = None owner_login: str | None = None repository_selection: str | None = None @@ -144,15 +144,11 @@ def as_node(self) -> GHNode: environment_name=self.org_login, token_expires_at=self.token_expires_at, owner_id=self.owner.id if self.owner else None, - organization_permissions=( - json.dumps(self.permissions.organization) - if self.permissions and self.permissions.organization - else None + organization_permissions=normalize_permission_declaration( + self.permissions.organization if self.permissions else None ), - repository_permissions=( - json.dumps(self.permissions.repository) - if self.permissions and self.permissions.repository - else None + repository_permissions=normalize_permission_declaration( + self.permissions.repository if self.permissions else None ), token_last_used_at=self.token_last_used_at, query_organization_permissions=f"MATCH p=(:GH_PersonalAccessToken {{node_id:'{pid}'}})-[:GH_CanAccess]->(:GH_Organization) RETURN p", diff --git a/src/openhound_github/models/personal_access_token_request.py b/src/openhound_github/models/personal_access_token_request.py index cfbc634..5e76c5e 100644 --- a/src/openhound_github/models/personal_access_token_request.py +++ b/src/openhound_github/models/personal_access_token_request.py @@ -9,6 +9,7 @@ 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.permissions import normalize_permission_declaration class Owner(BaseModel): @@ -29,19 +30,23 @@ class GHPersonalAccessTokenRequestProperties(GHNodeProperties): repository_selection: Whether the request targets `all`, `subset`, or `none` of the organization's repositories. reason: The rationale provided by the requester for the access request. org_name: The org name property. + organization_permissions: Requested organization-scoped permissions in `scope:access` form. + repository_permissions: Requested repository-scoped permissions in `scope:access` form. query_organization_permissions: Query for organization permissions. query_user: Query for user. query_repositories: Query for repositories. """ # TODO: Check for the following fields - # owner_id, owner_node_id, toke_id, token_expires_at, token_last_used_at, permissions, and environment_name + # owner_id, owner_node_id, toke_id, token_expires_at, token_last_used_at, and environment_name token_name: str | None = None owner_login: str | None = None repository_selection: str | None = None reason: str | None = None org_name: str | None = None + organization_permissions: list[str] | None = None + repository_permissions: list[str] | None = None query_organization_permissions: str | None = None query_user: str | None = None query_repositories: str | None = None @@ -102,6 +107,7 @@ def node_id(self) -> str: @property def as_node(self) -> GHNode: rid = self.node_id + permissions = self.permissions or {} return GHNode( kinds=[nk.PERSONAL_ACCESS_TOKEN_REQUEST], properties=GHPersonalAccessTokenRequestProperties( @@ -114,6 +120,12 @@ def as_node(self) -> GHNode: repository_selection=self.repository_selection, reason=self.reason, org_name=self.org_login, + organization_permissions=normalize_permission_declaration( + permissions.get("organization") + ), + repository_permissions=normalize_permission_declaration( + permissions.get("repository") + ), query_organization_permissions=f"MATCH p=(:GH_PersonalAccessTokenRequest {{node_id:'{rid}'}})-[:GH_CanAccess]->(:GH_Organization) RETURN p", query_user=f"MATCH p=(:GH_User)-[:GH_HasPersonalAccessTokenRequest]->(:GH_PersonalAccessTokenRequest {{node_id:'{rid}'}}) RETURN p", query_repositories=f"MATCH p=(:GH_PersonalAccessTokenRequest {{node_id:'{rid}'}})-[:GH_CanAccess]->(:GH_Repository) RETURN p LIMIT 1000", diff --git a/src/openhound_github/models/repository.py b/src/openhound_github/models/repository.py index 1e57b8c..02dfddd 100644 --- a/src/openhound_github/models/repository.py +++ b/src/openhound_github/models/repository.py @@ -46,6 +46,9 @@ class GHRepositoryProperties(GHNodeProperties): 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. + branch_count: Number of branch refs reported by GitHub for this repository. + environment_count: Number of deployment environments reported by GitHub for this repository. + deploy_key_count: Number of deploy keys reported by GitHub for this repository. query_branches: Query for branches. query_protected_branches: Query for protected branches. query_branch_protection_rules: Query for branch protection rules. @@ -56,6 +59,7 @@ class GHRepositoryProperties(GHNodeProperties): query_environments: Query for environments. query_secrets: Query for secrets. query_variables: Query for variables. + query_deploy_keys: Query for deploy keys. query_secret_scanning_alerts: Query for secret scanning alerts. query_explicit_readers: Query for explicit readers. query_unrolled_readers: Query for unrolled readers. @@ -94,6 +98,9 @@ class GHRepositoryProperties(GHNodeProperties): secret_scanning: str | None = None branch_ruleset_count: int | None = None has_branch_rulesets: bool | None = None + branch_count: int | None = None + environment_count: int | None = None + deploy_key_count: int | None = None query_branches: str | None = None query_protected_branches: str | None = None query_branch_protection_rules: str | None = None @@ -104,6 +111,7 @@ class GHRepositoryProperties(GHNodeProperties): query_environments: str | None = None query_secrets: str | None = None query_variables: str | None = None + query_deploy_keys: str | None = None query_secret_scanning_alerts: str | None = None query_explicit_readers: str | None = None query_unrolled_readers: str | None = None @@ -149,6 +157,7 @@ class Branch(BaseModel): class Ref(BaseModel): page_info: PageInfo = Field(alias="pageInfo") nodes: list[Branch] + total_count: int | None = Field(alias="totalCount", default=None) class RepositoryQL(BaseModel): @@ -158,6 +167,9 @@ class RepositoryQL(BaseModel): name: str refs: Ref branch_ruleset_count: int | None = None + branch_count: int | None = None + environment_count: int | None = None + deploy_key_count: int | None = None # Additional org_login: str @@ -230,6 +242,9 @@ def owner_name(self) -> str: def as_node(self) -> GHNode: rid = self.node_id branch_ruleset_count = self._lookup.repository_branch_ruleset_count(rid) + branch_count, environment_count, deploy_key_count = ( + self._lookup.repository_graphql_counts(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) @@ -272,6 +287,9 @@ def as_node(self) -> GHNode: if branch_ruleset_count is not None else None ), + branch_count=branch_count, + environment_count=environment_count, + deploy_key_count=deploy_key_count, # secret_scanning=self.secret_scanning, query_branches=f"MATCH p=(:GH_Repository {{node_id: '{rid}'}})-[:GH_Contains]->(:GH_Branch) RETURN p", query_protected_branches=f"MATCH p=(:GH_Repository {{node_id: '{rid}'}})-[:GH_Contains]->(:GH_Branch)<-[:GH_ProtectedBy]-(:GH_BranchProtectionRule) RETURN p", @@ -289,6 +307,7 @@ def as_node(self) -> GHNode: query_environments=f"MATCH p=(:GH_Repository {{node_id: '{rid}'}})-[:GH_Contains]->(:GH_Environment) RETURN p", query_secrets=f"MATCH p=(:GH_Repository {{node_id:'{rid}'}})-[:GH_HasSecret]->(:GH_Secret) RETURN p", query_variables=f"MATCH p=(:GH_Repository {{node_id:'{rid}'}})-[:GH_HasVariable]->(:GH_Variable) RETURN p", + query_deploy_keys=f"MATCH p=(:GH_Repository {{node_id:'{rid}'}})-[:GH_Contains]->(:GH_DeployKey) RETURN p", query_secret_scanning_alerts=f"MATCH p=(:GH_Repository {{node_id:'{rid}'}})-[:GH_Contains]->(:GH_SecretScanningAlert) RETURN p", query_explicit_readers=f"MATCH p=(role:GH_Role)-[:GH_HasBaseRole|GH_ReadRepoContents*1..]->(r:GH_Repository {{node_id:'{rid}'}}) MATCH p1=(:GH_User)-[:GH_HasRole]->(role) RETURN p,p1", query_unrolled_readers=f"MATCH p=(role:GH_Role)-[:GH_HasRole|GH_HasBaseRole|GH_MemberOf|GH_ReadRepoContents*1..]->(r:GH_Repository {{node_id:'{rid}'}}) MATCH p1=(:GH_User)-[:GH_HasRole]->(role) RETURN p,p1", diff --git a/src/openhound_github/models/workflow.py b/src/openhound_github/models/workflow.py index 4c9c7e6..d8051b1 100644 --- a/src/openhound_github/models/workflow.py +++ b/src/openhound_github/models/workflow.py @@ -24,6 +24,7 @@ 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.permissions import normalize_permission_declaration class GithubActionsLoader(yaml.SafeLoader): @@ -124,22 +125,6 @@ class RunsOnSelector(BaseModel): 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} diff --git a/src/openhound_github/models/workflow_job.py b/src/openhound_github/models/workflow_job.py index 741f0f2..e07c4f3 100644 --- a/src/openhound_github/models/workflow_job.py +++ b/src/openhound_github/models/workflow_job.py @@ -21,8 +21,8 @@ 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.permissions import normalize_permission_declaration from openhound_github.models.workflow import ( - normalize_permission_declaration, parse_runs_on_selector, resolve_effective_github_token_permissions, ) @@ -34,6 +34,10 @@ TEMPLATE_RE = re.compile(r"\$\{\{\s*[^}]+?\s*\}\}") +def _escape_cypher_string(value: Any) -> str: + return str(value).replace("\\", "\\\\").replace("'", "\\'") + + @dataclass class GHWorkflowJobProperties(GHNodeProperties): """Workflow job-specific properties. @@ -198,6 +202,13 @@ class GHWorkflowJobProperties(GHNodeProperties): description="Workflow job execution context can access environment secret", traversable=True, ), + EdgeDef( + start=nk.WORKFLOW_JOB, + end=nk.ENVIRONMENT, + kind=ek.CAN_REQUEST_OIDC_TOKEN_FOR, + description="Workflow job execution context can request an OIDC token for environment", + traversable=True, + ), ], ) class WorkflowJob(BaseAsset): @@ -419,7 +430,10 @@ def _dependency_edges(self): @property def _environment_edges(self): if self.environment and not TEMPLATE_RE.search(self.environment): - if self._lookup.environment(self.environment, self.repository_node_id): + persisted_environment_name = self._lookup.environment( + self.environment, self.repository_node_id + ) + if persisted_environment_name: yield Edge( kind=ek.DEPLOYS_TO, start=EdgePath(value=self.node_id, match_by="id"), @@ -429,12 +443,39 @@ def _environment_edges(self): PropertyMatch( key="repository_id", value=self.repository_node_id ), - PropertyMatch(key="name", value=self.environment.upper()), + PropertyMatch(key="name", value=persisted_environment_name), ], ), properties=EdgeProperties(traversable=False), ) + def _can_request_oidc_token_for_query(self) -> str: + return ( + f"MATCH p=(job:GH_WorkflowJob {{node_id:'{self.node_id}'}})" + "-[:GH_DeploysTo]->(:GH_Environment) " + "WHERE 'id-token:write' IN job.effective_github_token_permissions " + "RETURN p" + ) + + @property + def _can_request_oidc_token_for_edges(self): + if "id-token:write" not in ( + self.calculated_effective_github_token_permissions or [] + ): + return + + for environment_edge in self._environment_edges: + yield Edge( + kind=ek.CAN_REQUEST_OIDC_TOKEN_FOR, + start=environment_edge.start, + end=environment_edge.end, + properties=GHEdgeProperties( + traversable=True, + composed=True, + query_composition=self._can_request_oidc_token_for_query(), + ), + ) + @property def _calls_workflows_edge(self): if self.uses_reusable and self.uses_reusable.startswith("./.github/workflows/"): @@ -505,7 +546,8 @@ def _can_access_secret_query( source: str, ) -> str: properties = ", ".join( - f"{matcher.key}:'{matcher.value}'" for matcher in property_matchers + f"{matcher.key}:'{_escape_cypher_string(matcher.value)}'" + for matcher in property_matchers ) if source == "job": return ( @@ -573,3 +615,4 @@ def edges(self): yield from self._runs_on_edges yield from self._can_intercept_job_edges yield from self._can_access_secret_edges + yield from self._can_request_oidc_token_for_edges diff --git a/src/openhound_github/resources/organization.py b/src/openhound_github/resources/organization.py index eaf4a4d..3391468 100644 --- a/src/openhound_github/resources/organization.py +++ b/src/openhound_github/resources/organization.py @@ -38,6 +38,7 @@ BaseRepoRole, Branch, BranchProtectionRule, + DeployKey, Environment, EnvironmentBranchPolicy, EnvironmentSecret, @@ -240,9 +241,17 @@ def _repository_workflow_permissions( 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() + try: + ctx.repository_workflow_permissions_cache[cache_key] = client.get( + f"/repos/{repository_full_name}/actions/permissions/workflow" + ).json() + except Exception as e: + logger.warning( + "Unable to fetch workflow permissions for repository '%s': %s", + repository_full_name, + e, + ) + ctx.repository_workflow_permissions_cache[cache_key] = {} return ctx.repository_workflow_permissions_cache[cache_key] @@ -1215,10 +1224,16 @@ def repositories_graphql(ctx: SourceContext): for repo in repos_page["nodes"]: repo_record = {**repo} branch_rulesets = repo_record.pop("branchRulesets", None) or {} + environments = repo_record.pop("environments", None) or {} + deploy_keys = repo_record.pop("deployKeys", None) or {} + refs = repo_record.get("refs") or {} emitted_repositories += 1 yield { **repo_record, "branch_ruleset_count": branch_rulesets.get("totalCount"), + "branch_count": refs.get("totalCount"), + "environment_count": environments.get("totalCount"), + "deploy_key_count": deploy_keys.get("totalCount"), "org_login": org_name, } @@ -1438,20 +1453,23 @@ def workflow_steps(workflow: Workflow): @app.transformer(name="environments", columns=Environment, parallelized=True) -def environments(repo: Repository, ctx: SourceContext): +def environments(repo: RepositoryQL, ctx: SourceContext): """Fetch deployment environments for a repository. Args: - repo (Repository): The repository to fetch environments for. + repo (RepositoryQL): Repository metadata with the GitHub-reported environment count. ctx (SourceContext): The shared context containing the REST client and organization name. Yields: Environment (Environment): Deployment environment record. """ - full_name = repo.full_name + if repo.environment_count == 0: + return + + full_name = f"{repo.org_login}/{repo.name}" repo_name = repo.name - repo_node_id = repo.node_id + repo_node_id = repo.id client = _client_for_org(ctx, repo.org_login) for page in client.paginate( f"/repos/{full_name}/environments", @@ -1908,6 +1926,33 @@ def repository_variables(repo: Repository, ctx: SourceContext): } +@app.transformer(name="repository_deploy_keys", columns=DeployKey, parallelized=True) +def repository_deploy_keys(repo: RepositoryQL, ctx: SourceContext): + """Fetch repository deploy keys, including keys disabled by organization policy.""" + if repo.deploy_key_count == 0: + return + + client = _client_for_org(ctx, repo.org_login) + full_name = f"{repo.org_login}/{repo.name}" + + try: + for page in client.paginate( + f"/repos/{full_name}/keys", params={"per_page": 100} + ): + for deploy_key in page: + yield { + **deploy_key, + "org_login": repo.org_login, + "repository_name": full_name, + "repository_node_id": repo.id, + } + except Exception as e: + logger.error( + f"Error in resource 'repository_deploy_keys' processing repository '{full_name}': {e}", + extra={"resource": "repository_deploy_keys", "phase": "resource_iteration"}, + ) + + @app.resource( name="secret_scanning_alerts", columns=SecretScanningAlert, parallelized=True ) @@ -2325,12 +2370,12 @@ def organization_resources(ctx: SourceContext): repo_roles_base = RepositoryRoleCache(ctx) repos_resource = repositories(ctx) workflows_resource = repos_resource | workflows(ctx) - environments_resource = repos_resource | environments(ctx) personal_access_tokens_resource = personal_access_tokens(ctx) teams_resource = teams(ctx) team_external_groups_resource = team_external_groups(ctx) repositories_graphql_resource = repositories_graphql(ctx) + environments_resource = repositories_graphql_resource | environments(ctx) app_installs_resource = app_installations(ctx) runner_groups_resource = runner_groups(ctx) runner_group_access_resource = runner_groups_resource | org_runner_group_access(ctx) @@ -2367,6 +2412,7 @@ def organization_resources(ctx: SourceContext): teams_resource | team_repo_role_assignments(ctx, repo_roles_base), org_scim_organizations_resource | scim_users(ctx), repositories_graphql_resource, + repositories_graphql_resource | repository_deploy_keys(ctx), repositories_graphql_resource | branches(ctx), branch_prot_rules_resource, secret_scanning_alerts(ctx), diff --git a/src/openhound_github/transforms.py b/src/openhound_github/transforms.py index b34cc05..3142ee4 100644 --- a/src/openhound_github/transforms.py +++ b/src/openhound_github/transforms.py @@ -20,7 +20,10 @@ def ensure_optional_input_tables( ); CREATE TABLE IF NOT EXISTS {schema}.repositories_graphql ( id VARCHAR, - branch_ruleset_count BIGINT + branch_ruleset_count BIGINT, + branch_count BIGINT, + environment_count BIGINT, + deploy_key_count BIGINT ); CREATE TABLE IF NOT EXISTS {schema}.branch_protection_rules ( id VARCHAR, @@ -51,6 +54,7 @@ def ensure_optional_input_tables( ); CREATE TABLE IF NOT EXISTS {schema}.users ( id VARCHAR, + login VARCHAR, role VARCHAR, org_login VARCHAR ); @@ -111,11 +115,20 @@ def ensure_optional_input_tables( variable_name VARCHAR, repository_node_id VARCHAR ); + CREATE TABLE IF NOT EXISTS {schema}.enterprise ( + id VARCHAR, + slug VARCHAR + ); CREATE TABLE IF NOT EXISTS {schema}.enterprise_organizations ( id VARCHAR, login VARCHAR, enterprise_node_id VARCHAR ); + CREATE TABLE IF NOT EXISTS {schema}.enterprise_users ( + id VARCHAR, + login VARCHAR, + enterprise_slug VARCHAR + ); CREATE TABLE IF NOT EXISTS {schema}.enterprise_scim_groups ( id VARCHAR, display_name VARCHAR, @@ -198,6 +211,12 @@ def ensure_optional_input_tables( ADD COLUMN IF NOT EXISTS id VARCHAR; ALTER TABLE {schema}.repositories_graphql ADD COLUMN IF NOT EXISTS branch_ruleset_count BIGINT; + ALTER TABLE {schema}.repositories_graphql + ADD COLUMN IF NOT EXISTS branch_count BIGINT; + ALTER TABLE {schema}.repositories_graphql + ADD COLUMN IF NOT EXISTS environment_count BIGINT; + ALTER TABLE {schema}.repositories_graphql + ADD COLUMN IF NOT EXISTS deploy_key_count BIGINT; ALTER TABLE {schema}.branch_protection_rules ADD COLUMN IF NOT EXISTS id VARCHAR; @@ -242,6 +261,8 @@ def ensure_optional_input_tables( ALTER TABLE {schema}.users ADD COLUMN IF NOT EXISTS id VARCHAR; + ALTER TABLE {schema}.users + ADD COLUMN IF NOT EXISTS login VARCHAR; ALTER TABLE {schema}.users ADD COLUMN IF NOT EXISTS role VARCHAR; ALTER TABLE {schema}.users @@ -314,6 +335,11 @@ def ensure_optional_input_tables( ALTER TABLE {schema}.selected_organization_variables ADD COLUMN IF NOT EXISTS repository_node_id VARCHAR; + ALTER TABLE {schema}.enterprise + ADD COLUMN IF NOT EXISTS id VARCHAR; + ALTER TABLE {schema}.enterprise + ADD COLUMN IF NOT EXISTS slug VARCHAR; + ALTER TABLE {schema}.enterprise_organizations ADD COLUMN IF NOT EXISTS id VARCHAR; ALTER TABLE {schema}.enterprise_organizations @@ -321,6 +347,13 @@ def ensure_optional_input_tables( ALTER TABLE {schema}.enterprise_organizations ADD COLUMN IF NOT EXISTS enterprise_node_id VARCHAR; + ALTER TABLE {schema}.enterprise_users + ADD COLUMN IF NOT EXISTS id VARCHAR; + ALTER TABLE {schema}.enterprise_users + ADD COLUMN IF NOT EXISTS login VARCHAR; + ALTER TABLE {schema}.enterprise_users + ADD COLUMN IF NOT EXISTS enterprise_slug VARCHAR; + ALTER TABLE {schema}.enterprise_scim_groups ADD COLUMN IF NOT EXISTS id VARCHAR; ALTER TABLE {schema}.enterprise_scim_groups diff --git a/tests/test_credential_permission_models.py b/tests/test_credential_permission_models.py new file mode 100644 index 0000000..6cdf39b --- /dev/null +++ b/tests/test_credential_permission_models.py @@ -0,0 +1,112 @@ +from datetime import datetime +from unittest.mock import MagicMock + +from openhound_github.models.app_installation import AppInstallation +from openhound_github.models.personal_access_token import ( + Owner as PersonalAccessTokenOwner, + Permissions, + PersonalAccessToken, +) +from openhound_github.models.personal_access_token_request import ( + Owner as PersonalAccessTokenRequestOwner, + PersonalAccessTokenRequest, +) + + +def _lookup() -> MagicMock: + lookup = MagicMock() + lookup.org_id_for_login.return_value = "O_1" + return lookup + + +def test_personal_access_token_permissions_are_normalized_by_scope() -> None: + token = PersonalAccessToken( + id=1, + owner=PersonalAccessTokenOwner( + login="octocat", + id=1, + type="User", + node_id="U_1", + ), + permissions=Permissions( + organization={"members": "read"}, + repository={"contents": "write", "metadata": "read"}, + ), + token_id=1, + token_name="ci-token", + token_expired=False, + org_login="acme", + ) + token._lookup = _lookup() + + properties = token.as_node.properties + + assert properties.organization_permissions == ["members:read"] + assert properties.repository_permissions == ["contents:write", "metadata:read"] + + +def test_personal_access_token_request_permissions_are_normalized_by_scope() -> None: + request = PersonalAccessTokenRequest( + id=1, + owner=PersonalAccessTokenRequestOwner( + login="octocat", + id=1, + type="User", + node_id="U_1", + site_admin=False, + ), + token_name="requested-token", + token_expired=False, + permissions={ + "organization": {"members": "read"}, + "repository": {"contents": "write", "metadata": "read"}, + }, + org_login="acme", + ) + request._lookup = _lookup() + + properties = request.as_node.properties + + assert properties.organization_permissions == ["members:read"] + assert properties.repository_permissions == ["contents:write", "metadata:read"] + + +def test_personal_access_token_request_preserves_missing_permission_scope() -> None: + request = PersonalAccessTokenRequest( + id=1, + owner=PersonalAccessTokenRequestOwner( + login="octocat", + id=1, + type="User", + node_id="U_1", + site_admin=False, + ), + token_name="requested-token", + token_expired=False, + permissions={"repository": {"contents": "read"}}, + org_login="acme", + ) + request._lookup = _lookup() + + properties = request.as_node.properties + + assert properties.organization_permissions is None + assert properties.repository_permissions == ["contents:read"] + + +def test_app_installation_permissions_use_normalized_permission_shape() -> None: + installation = AppInstallation( + id=1, + repository_selection="all", + app_id=42, + target_type="Organization", + permissions={"contents": "write", "metadata": "read"}, + created_at=datetime(2026, 1, 1), + org_login="acme", + ) + installation._lookup = _lookup() + + assert installation.as_node.properties.permissions == [ + "contents:write", + "metadata:read", + ] diff --git a/tests/test_deploy_key_models.py b/tests/test_deploy_key_models.py new file mode 100644 index 0000000..6eb057d --- /dev/null +++ b/tests/test_deploy_key_models.py @@ -0,0 +1,81 @@ +from datetime import datetime +from unittest.mock import MagicMock + +from openhound_github.kinds import edges as ek +from openhound_github.models.deploy_key import DeployKey + + +def _deploy_key(read_only: bool | None) -> DeployKey: + deploy_key = DeployKey( + id=7, + title="ci-key", + key="ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ", + verified=True, + enabled=True, + created_at=datetime(2026, 1, 1), + read_only=read_only, + last_used=datetime(2026, 1, 2), + added_by={"login": "alice", "node_id": "USER_1"}, + org_login="acme", + repository_name="acme/repo", + repository_node_id="REPO_1", + ) + lookup = MagicMock() + lookup.org_id_for_login.return_value = "ORG_1" + lookup.user_node_id_for_login.return_value = "USER_1" + deploy_key._lookup = lookup + return deploy_key + + +def test_read_only_deploy_key_surfaces_repository_read_permission() -> None: + properties = _deploy_key(read_only=True).as_node.properties + + assert properties.name == "acme/repo/ci-key" + assert properties.repository_permissions == ["contents:read"] + assert properties.enabled is True + assert properties.added_by_login == "alice" + assert properties.added_by_id == "USER_1" + + +def test_writable_deploy_key_surfaces_repository_write_permission() -> None: + deploy_key = _deploy_key(read_only=False) + + assert deploy_key.as_node.properties.repository_permissions == ["contents:write"] + assert [edge.kind for edge in deploy_key.edges] == [ + ek.CONTAINS, + ek.CAN_ACCESS, + ek.ADDED_DEPLOY_KEY, + ] + assert list(deploy_key.edges)[1].end.value == "REPO_1" + + +def test_deploy_key_preserves_unknown_repository_permission() -> None: + properties = _deploy_key(read_only=None).as_node.properties + + assert properties.read_only is None + assert properties.repository_permissions is None + + +def test_deploy_key_accepts_string_added_by_login() -> None: + deploy_key = _deploy_key(read_only=True) + deploy_key.added_by = "alice" + + properties = deploy_key.as_node.properties + + assert properties.added_by_login == "alice" + assert properties.added_by_id == "USER_1" + assert [edge.kind for edge in deploy_key.edges] == [ + ek.CONTAINS, + ek.CAN_ACCESS, + ek.ADDED_DEPLOY_KEY, + ] + assert list(deploy_key.edges)[2].start.value == "USER_1" + deploy_key._lookup.user_node_id_for_login.assert_called_with("acme", "alice") + + +def test_deploy_key_skips_added_by_edge_when_user_was_not_collected() -> None: + deploy_key = _deploy_key(read_only=True) + deploy_key._lookup.user_node_id_for_login.return_value = None + + assert deploy_key.as_node.properties.added_by_id is None + assert [edge.kind for edge in deploy_key.edges] == [ek.CONTAINS, ek.CAN_ACCESS] diff --git a/tests/test_deploy_key_resources.py b/tests/test_deploy_key_resources.py new file mode 100644 index 0000000..b7c2ac5 --- /dev/null +++ b/tests/test_deploy_key_resources.py @@ -0,0 +1,66 @@ +from types import SimpleNamespace + +from openhound_github.resources.organization import ( + OrgContext, + SourceContext, + repository_deploy_keys, +) + + +class _FakeClient: + def __init__(self, error: Exception | None = None): + self.error = error + self.paginate_calls: list[tuple[str, dict]] = [] + + def paginate(self, path: str, **kwargs): + self.paginate_calls.append((path, kwargs)) + if self.error: + raise self.error + return iter([]) + + +def _ctx(client: _FakeClient) -> SourceContext: + return SourceContext( + client=client, + organizations=[OrgContext(client=client, org_name="acme")], + ) + + +def _repository(deploy_key_count: int | None = None) -> SimpleNamespace: + return SimpleNamespace( + id="REPO_1", + name="repo", + org_login="acme", + deploy_key_count=deploy_key_count, + ) + + +def test_repository_deploy_keys_queries_repository_keys_endpoint() -> None: + client = _FakeClient() + + rows = list(repository_deploy_keys.__wrapped__(_repository(1), _ctx(client))) + + assert rows == [] + assert client.paginate_calls == [ + ("/repos/acme/repo/keys", {"params": {"per_page": 100}}) + ] + + +def test_repository_deploy_keys_skips_repository_when_request_fails() -> None: + client = _FakeClient(error=PermissionError("403 Forbidden")) + + rows = list(repository_deploy_keys.__wrapped__(_repository(None), _ctx(client))) + + assert rows == [] + assert client.paginate_calls == [ + ("/repos/acme/repo/keys", {"params": {"per_page": 100}}) + ] + + +def test_repository_deploy_keys_skips_query_when_repository_has_no_keys() -> None: + client = _FakeClient() + + rows = list(repository_deploy_keys.__wrapped__(_repository(0), _ctx(client))) + + assert rows == [] + assert client.paginate_calls == [] diff --git a/tests/test_environment_resources.py b/tests/test_environment_resources.py index 65b30a9..efe56a5 100644 --- a/tests/test_environment_resources.py +++ b/tests/test_environment_resources.py @@ -5,6 +5,7 @@ from openhound_github.resources.organization import ( OrgContext, SourceContext, + environments, environment_branch_policies, environment_secrets, environment_variables, @@ -41,6 +42,41 @@ def _environment(name: str) -> SimpleNamespace: ) +def _repository(environment_count: int | None) -> SimpleNamespace: + return SimpleNamespace( + id="REPO_1", + name="repo", + org_login="acme", + environment_count=environment_count, + ) + + +@pytest.mark.parametrize("environment_count", [1, None]) +def test_environments_queries_when_repository_may_have_environments( + environment_count: int | None, +) -> None: + client = _FakeClient() + + rows = list(environments.__wrapped__(_repository(environment_count), _ctx(client))) + + assert rows == [] + assert client.paginate_calls == [ + ( + "/repos/acme/repo/environments", + {"params": {"per_page": 100}, "data_selector": "environments"}, + ) + ] + + +def test_environments_skips_query_when_repository_has_no_environments() -> None: + client = _FakeClient() + + rows = list(environments.__wrapped__(_repository(0), _ctx(client))) + + assert rows == [] + assert client.paginate_calls == [] + + @pytest.mark.parametrize( ("transformer", "suffix"), [ diff --git a/tests/test_lookup.py b/tests/test_lookup.py index 472dc66..ca1ca5b 100644 --- a/tests/test_lookup.py +++ b/tests/test_lookup.py @@ -117,6 +117,57 @@ def test_scim_group_id_for_team_external_group_skips_org_only_context() -> None: assert lookup.scim_group_id_for_team_external_group("acme", "Engineering") is None +def test_user_node_id_for_login_is_case_insensitive_and_scoped_to_org() -> None: + connection = duckdb.connect(":memory:") + connection.execute("CREATE SCHEMA github_test") + connection.execute( + "CREATE TABLE github_test.users (id VARCHAR, login VARCHAR, org_login VARCHAR)" + ) + connection.execute( + "CREATE TABLE github_test.enterprise (id VARCHAR, slug VARCHAR)" + ) + connection.execute( + "CREATE TABLE github_test.enterprise_organizations " + "(login VARCHAR, enterprise_node_id VARCHAR)" + ) + connection.execute( + "CREATE TABLE github_test.enterprise_users " + "(id VARCHAR, login VARCHAR, enterprise_slug VARCHAR)" + ) + connection.execute( + "INSERT INTO github_test.users VALUES " + "('USER_1', 'Alice', 'Acme'), " + "('USER_2', 'Alice', 'Other')" + ) + + lookup = GithubLookup(connection, schema="github_test") + + assert lookup.user_node_id_for_login("acme", "alice") == "USER_1" + assert lookup.user_node_id_for_login("other", "ALICE") == "USER_2" + + +def test_user_node_id_for_login_falls_back_to_enterprise_user_for_org() -> None: + connection = duckdb.connect(":memory:") + connection.execute("CREATE SCHEMA github_test") + ensure_optional_input_tables(connection, schema="github_test") + connection.execute("INSERT INTO github_test.enterprise VALUES ('ENT_1', 'acme-ent')") + connection.execute( + "INSERT INTO github_test.enterprise_organizations " + "(login, enterprise_node_id) VALUES " + "('acme', 'ENT_1'), ('other', 'ENT_2')" + ) + connection.execute( + "INSERT INTO github_test.enterprise_users VALUES " + "('USER_1', 'Alice', 'acme-ent'), " + "('USER_2', 'Alice', 'other-ent')" + ) + + lookup = GithubLookup(connection, schema="github_test") + + assert lookup.user_node_id_for_login("acme", "alice") == "USER_1" + assert lookup.user_node_id_for_login("other", "alice") is None + + def test_workflow_step_secret_reference_names_deduplicates_across_steps() -> None: connection = duckdb.connect(":memory:") connection.execute("CREATE SCHEMA github_test") diff --git a/tests/test_repository_rulesets.py b/tests/test_repository_rulesets.py index 17f00ad..89129f8 100644 --- a/tests/test_repository_rulesets.py +++ b/tests/test_repository_rulesets.py @@ -38,6 +38,9 @@ def _repository_page_data( repository_name: str, *, branch_ruleset_count: int | None = None, + branch_count: int | None = None, + environment_count: int | None = None, + deploy_key_count: int | None = None, repository_end_cursor: str | None = None, repositories_has_next_page: bool = False, ) -> dict: @@ -54,12 +57,15 @@ def _repository_page_data( "name": repository_name, "branchRulesets": {"totalCount": branch_ruleset_count}, "refs": { + "totalCount": branch_count, "nodes": [], "pageInfo": { "endCursor": None, "hasNextPage": False, }, }, + "environments": {"totalCount": environment_count}, + "deployKeys": {"totalCount": deploy_key_count}, } ] } @@ -122,8 +128,19 @@ def _make_repository() -> Repository: ) -def test_repositories_graphql_flattens_branch_ruleset_count() -> None: - client = _FakeClient() +def test_repositories_graphql_flattens_repository_counts() -> None: + client = _FakeClient( + _graphql_response( + _repository_page_data( + "R_1", + "repo", + branch_ruleset_count=2, + branch_count=7, + environment_count=3, + deploy_key_count=2, + ) + ) + ) ctx = SourceContext( client=client, organizations=[OrgContext(client=client, org_name="org")], @@ -136,10 +153,14 @@ def test_repositories_graphql_flattens_branch_ruleset_count() -> None: "id": "R_1", "name": "repo", "refs": { + "totalCount": 7, "nodes": [], "pageInfo": {"endCursor": None, "hasNextPage": False}, }, "branch_ruleset_count": 2, + "branch_count": 7, + "environment_count": 3, + "deploy_key_count": 2, "org_login": "org", } ] @@ -390,6 +411,7 @@ 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_graphql_counts.return_value = (7, 3, 2) lookup.repository_workflow_permissions.return_value = ("read", False) repo._lookup = lookup @@ -397,10 +419,14 @@ def test_repository_node_surfaces_branch_ruleset_presence() -> None: assert node.properties.branch_ruleset_count == 2 assert node.properties.has_branch_rulesets is True + assert node.properties.branch_count == 7 + assert node.properties.environment_count == 3 + assert node.properties.deploy_key_count == 2 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_graphql_counts.assert_called_once_with("R_1") lookup.repository_workflow_permissions.assert_called_once_with("R_1") @@ -409,6 +435,7 @@ 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_graphql_counts.return_value = (None, None, None) lookup.repository_workflow_permissions.return_value = None repo._lookup = lookup @@ -416,6 +443,9 @@ def test_repository_node_preserves_unknown_branch_ruleset_presence() -> None: assert node.properties.branch_ruleset_count is None assert node.properties.has_branch_rulesets is None + assert node.properties.branch_count is None + assert node.properties.environment_count is None + assert node.properties.deploy_key_count is None assert node.properties.default_workflow_permissions is None assert node.properties.can_approve_pull_request_reviews is None @@ -436,6 +466,23 @@ def test_repository_branch_ruleset_count_lookup_returns_int() -> None: assert lookup.repository_branch_ruleset_count("R_2") is None +def test_repository_graphql_counts_lookup_returns_ints() -> None: + connection = duckdb.connect(":memory:") + connection.execute("CREATE SCHEMA github") + connection.execute( + "CREATE TABLE github.repositories_graphql (id VARCHAR, branch_count BIGINT, environment_count BIGINT, deploy_key_count BIGINT)" + ) + connection.execute( + "INSERT INTO github.repositories_graphql VALUES ('R_1', 7, 3, 2), ('R_2', NULL, NULL, NULL)" + ) + + lookup = GithubLookup(connection) + + assert lookup.repository_graphql_counts("R_1") == (7, 3, 2) + assert lookup.repository_graphql_counts("R_2") == (None, None, None) + assert lookup.repository_graphql_counts("R_3") == (None, None, None) + + def test_repository_workflow_permissions_lookup_returns_collected_policy() -> None: connection = duckdb.connect(":memory:") connection.execute("CREATE SCHEMA github") @@ -443,7 +490,8 @@ def test_repository_workflow_permissions_lookup_returns_collected_policy() -> No "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)" + "INSERT INTO github.workflows VALUES " + "('R_1', NULL, NULL), ('R_1', 'read', false), ('R_2', NULL, NULL)" ) lookup = GithubLookup(connection) diff --git a/tests/test_runner_models.py b/tests/test_runner_models.py index f203329..92c6bdb 100644 --- a/tests/test_runner_models.py +++ b/tests/test_runner_models.py @@ -65,9 +65,9 @@ def _workflow_runner_lookup() -> GithubLookup: ) 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')""" + (13, '[{"name":"self-hosted"},{"name":"Windows"},{"name":"X64"}]', NULL, 'acme'), + (11, '[{"name":"self-hosted"},{"name":"Linux"},{"name":"X64"}]', false, 'acme')""" ) connection.execute( """INSERT INTO github.org_runner_group_access VALUES @@ -77,7 +77,7 @@ def _workflow_runner_lookup() -> GithubLookup: (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')" + "INSERT INTO github.org_runner_group_memberships VALUES (1, 13, 'acme'), (1, 12, 'acme'), (1, 11, 'acme'), (2, 12, 'acme'), (2, 11, 'acme'), (3, 11, 'acme')" ) connection.execute( "INSERT INTO github.enterprise_organizations VALUES ('ORG_1', 'ENT_1')" @@ -176,6 +176,21 @@ def test_workflow_job_runner_lookup_returns_no_runners_when_actions_disabled() - ) +def test_workflow_job_runner_lookup_skips_org_runners_when_org_id_is_missing() -> None: + lookup = _workflow_runner_lookup() + lookup.client.execute("DELETE FROM github.organizations WHERE login = 'acme'") + + assert lookup.workflow_job_runner_node_ids( + "REPO_1", "acme", None, ("self-hosted", "linux", "x64") + ) == ["REPO_1_runner_21"] + assert ( + lookup.workflow_job_runner_node_ids( + "REPO_1", "acme", "Default", ("self-hosted", "linux", "x64") + ) + == [] + ) + + def test_org_runner_group_keeps_generic_runner_group_label() -> None: group = OrgRunnerGroup(id=1, name="Default", org_login="acme") group._lookup = SimpleNamespace(org_id_for_login=lambda _login: "ORG_1") diff --git a/tests/test_workflow_interception_path.py b/tests/test_workflow_interception_path.py index 1637ed3..0e3fbde 100644 --- a/tests/test_workflow_interception_path.py +++ b/tests/test_workflow_interception_path.py @@ -81,6 +81,10 @@ def _cross_org_enterprise_runner_lookup() -> GithubLookup: "CREATE TABLE github.environment_secrets " "(name VARCHAR, repository_node_id VARCHAR, environment_name VARCHAR)" ) + connection.execute( + "CREATE TABLE github.environments " + "(name VARCHAR, repository_node_id VARCHAR)" + ) connection.execute( "INSERT INTO github.organizations VALUES " "('attacker', 'ORG_A'), ('victim', 'ORG_B')" @@ -122,6 +126,9 @@ def _cross_org_enterprise_runner_lookup() -> GithubLookup: connection.execute( "INSERT INTO github.repository_secrets VALUES ('DEPLOY_TOKEN', 'REPO_B')" ) + connection.execute( + "INSERT INTO github.environments VALUES ('prod', 'REPO_B')" + ) return GithubLookup(connection) @@ -171,6 +178,8 @@ def test_cross_org_enterprise_runner_interception_path_is_traversable() -> None: repository_node_id="REPO_B", org_login="victim", runs_on={"group": "enterprise-prod", "labels": ["self-hosted", "linux"]}, + environment="prod", + permissions={"id-token": "write"}, ) victim_job._lookup = lookup victim_step = WorkflowStep( @@ -209,15 +218,27 @@ def test_cross_org_enterprise_runner_interception_path_is_traversable() -> None: can_intercept = _find_edge( job_edges, ek.CAN_INTERCEPT_JOB, "ENT_1_runner_31", "JOB_B" ) + deploys_to = _find_edge(job_edges, ek.DEPLOYS_TO, "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") + can_request_oidc_token = _find_edge( + job_edges, ek.CAN_REQUEST_OIDC_TOKEN_FOR, "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] + for edge in [ + can_use, + inherited_from, + has_runner, + can_intercept, + can_access_secret, + can_request_oidc_token, + ] + ] == [True, True, True, True, True, True] assert runs_on.properties.traversable is False + assert deploys_to.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 @@ -229,6 +250,20 @@ def test_cross_org_enterprise_runner_interception_path_is_traversable() -> None: "name": "DEPLOY_TOKEN", "repository_id": "REPO_B", } + assert can_request_oidc_token.end.kind == nk.ENVIRONMENT + assert { + matcher.key: matcher.value + for matcher in can_request_oidc_token.end.property_matchers + } == { + "name": "prod", + "repository_id": "REPO_B", + } + assert { + matcher.key: matcher.value for matcher in deploys_to.end.property_matchers + } == { + "name": "prod", + "repository_id": "REPO_B", + } def test_inherited_runner_lookup_survives_upgraded_enterprise_runner_group_stub() -> None: diff --git a/tests/test_workflow_model.py b/tests/test_workflow_model.py index 775d324..d7282ce 100644 --- a/tests/test_workflow_model.py +++ b/tests/test_workflow_model.py @@ -476,6 +476,163 @@ def test_workflow_job_emits_can_access_secret_edges_for_step_references() -> Non assert "GH_UsesSecret" in edges[0].properties.query_composition +def test_workflow_job_can_access_secret_query_escapes_environment_matcher_values() -> 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=r"prod\east's", + ) + lookup = _org_reference_lookup() + lookup.workflow_step_secret_reference_names.return_value = ["ENV_TOKEN"] + lookup.org_secret.return_value = None + lookup.environment_secret_for_environment.return_value = ("ENV_TOKEN",) + job._lookup = lookup + + edge = next(job._can_access_secret_edges) + + assert edge.end.kind == nk.ENVIRONMENT_SECRET + assert edge.properties.query_composition == ( + "MATCH p=(:GH_WorkflowJob {node_id:'JOB_1'})" + "-[:GH_Contains]->(:GH_WorkflowStep)" + "-[:GH_UsesSecret]->(:GH_EnvironmentSecret " + "{name:'ENV_TOKEN', deployment_environment_name:'prod\\\\east\\'s', " + "repository_id:'REPO_1'}) RETURN p" + ) + + +def test_workflow_job_emits_can_request_oidc_token_for_environment_without_oidc_step() -> None: + job = WorkflowJob( + node_id="JOB_1", + name="deploy", + job_key="deploy", + workflow_node_id="WORKFLOW_1", + repository_name="repo", + repository_node_id="REPO_1", + org_login="github", + environment="prod", + permissions={"id-token": "write"}, + ) + lookup = _org_reference_lookup() + lookup.environment.return_value = "prod" + job._lookup = lookup + + environment_edges = list(job._environment_edges) + edges = list(job._can_request_oidc_token_for_edges) + + assert len(environment_edges) == 1 + assert _matcher_values(environment_edges[0]) == { + "repository_id": "REPO_1", + "name": "prod", + } + assert len(edges) == 1 + assert edges[0].kind == ek.CAN_REQUEST_OIDC_TOKEN_FOR + assert edges[0].start.value == "JOB_1" + assert edges[0].end.kind == nk.ENVIRONMENT + assert _matcher_values(edges[0]) == { + "repository_id": "REPO_1", + "name": "prod", + } + assert edges[0].properties.traversable is True + assert edges[0].properties.composed is True + assert edges[0].properties.query_composition == ( + "MATCH p=(job:GH_WorkflowJob {node_id:'JOB_1'})" + "-[:GH_DeploysTo]->(:GH_Environment) " + "WHERE 'id-token:write' IN job.effective_github_token_permissions " + "RETURN p" + ) + + +def test_workflow_job_can_request_oidc_token_for_requires_permission_and_environment() -> None: + no_permission = WorkflowJob( + node_id="JOB_NO_PERMISSION", + name="deploy", + job_key="deploy", + workflow_node_id="WORKFLOW_1", + repository_name="repo", + repository_node_id="REPO_1", + org_login="github", + environment="prod", + permissions={"contents": "read"}, + ) + no_environment = WorkflowJob( + node_id="JOB_NO_ENVIRONMENT", + name="deploy", + job_key="deploy", + workflow_node_id="WORKFLOW_1", + repository_name="repo", + repository_node_id="REPO_1", + org_login="github", + permissions={"id-token": "write"}, + ) + lookup = _org_reference_lookup() + lookup.environment.return_value = "prod" + no_permission._lookup = lookup + no_environment._lookup = lookup + + assert list(no_permission._can_request_oidc_token_for_edges) == [] + assert list(no_environment._can_request_oidc_token_for_edges) == [] + + +def test_workflow_job_can_request_oidc_token_for_edges_are_per_job_and_idempotent() -> None: + jobs = [ + WorkflowJob( + node_id="JOB_1", + name="deploy", + job_key="deploy", + workflow_node_id="WORKFLOW_1", + repository_name="repo", + repository_node_id="REPO_1", + org_login="github", + environment="prod", + permissions={"id-token": "write"}, + ), + WorkflowJob( + node_id="JOB_2", + name="publish", + job_key="publish", + workflow_node_id="WORKFLOW_1", + repository_name="repo", + repository_node_id="REPO_1", + org_login="github", + environment="prod", + permissions={"id-token": "write"}, + ), + WorkflowJob( + node_id="JOB_3", + name="build", + job_key="build", + workflow_node_id="WORKFLOW_1", + repository_name="repo", + repository_node_id="REPO_1", + org_login="github", + environment="prod", + permissions={"contents": "read"}, + ), + ] + lookup = _org_reference_lookup() + lookup.environment.return_value = "prod" + for job in jobs: + job._lookup = lookup + + edges = [ + edge + for job in jobs + for edge in job.edges + if edge.kind == ek.CAN_REQUEST_OIDC_TOKEN_FOR + ] + + assert [(edge.start.value, _matcher_values(edge)) for edge in edges] == [ + ("JOB_1", {"repository_id": "REPO_1", "name": "prod"}), + ("JOB_2", {"repository_id": "REPO_1", "name": "prod"}), + ] + assert len(list(jobs[0]._can_request_oidc_token_for_edges)) == 1 + + def test_workflow_job_can_access_secret_edges_deduplicate_step_references() -> None: job = WorkflowJob( node_id="JOB_1", @@ -592,6 +749,7 @@ def _org_reference_lookup() -> MagicMock: lookup = MagicMock() lookup.org_id_for_login.return_value = ORG_NODE_ID lookup.repository_workflow_permissions.return_value = None + lookup.environment.return_value = None lookup.workflow_step_secret_reference_names.return_value = [] lookup.repo_secret.return_value = None lookup.org_secret.return_value = ("DEPLOY_TOKEN",) diff --git a/tests/test_workflow_resources.py b/tests/test_workflow_resources.py index 72e6c74..550f3e0 100644 --- a/tests/test_workflow_resources.py +++ b/tests/test_workflow_resources.py @@ -13,8 +13,20 @@ def json(self) -> dict: class _FakeClient: - def __init__(self, workflow_pages: list[list[dict]]): + def __init__( + self, + workflow_pages: list[list[dict]], + *, + default_workflow_permissions: str = "read", + can_approve_pull_request_reviews: bool = False, + workflow_permission_responses: dict[str, dict] | None = None, + workflow_permission_errors: set[str] | None = None, + ): self.workflow_pages = workflow_pages + self.default_workflow_permissions = default_workflow_permissions + self.can_approve_pull_request_reviews = can_approve_pull_request_reviews + self.workflow_permission_responses = workflow_permission_responses or {} + self.workflow_permission_errors = workflow_permission_errors or set() self.get_calls: list[tuple[str, dict]] = [] self.paginate_calls: list[tuple[str, dict]] = [] @@ -25,10 +37,14 @@ def paginate(self, path: str, **kwargs): def get(self, path: str, **kwargs): self.get_calls.append((path, kwargs)) if path.endswith("/actions/permissions/workflow"): + if path in self.workflow_permission_errors: + raise RuntimeError("workflow permissions unavailable") + if path in self.workflow_permission_responses: + return _FakeResponse(self.workflow_permission_responses[path]) return _FakeResponse( { - "default_workflow_permissions": "read", - "can_approve_pull_request_reviews": False, + "default_workflow_permissions": self.default_workflow_permissions, + "can_approve_pull_request_reviews": self.can_approve_pull_request_reviews, } ) return _FakeResponse({"content": "am9iczoge30="}) @@ -44,6 +60,18 @@ def _repo() -> SimpleNamespace: ) +def _repo_for_org( + org_login: str, node_id: str, repository_name: str = "repo" +) -> SimpleNamespace: + return SimpleNamespace( + full_name=f"{org_login}/{repository_name}", + name=repository_name, + node_id=node_id, + org_login=org_login, + default_branch="main", + ) + + def _ctx(client: _FakeClient) -> SourceContext: return SourceContext( client=client, @@ -64,8 +92,18 @@ def _workflow_row(workflow_id: int, state: str = "active") -> dict: } +def _workflow_transformer_generator(): + """Return the raw workflow transformer generator for dlt 1.26.0 tests. + + dlt does not expose a public accessor for the wrapped generator. The + private pipe access stays isolated here so tests can preserve deferred() + invocation behavior without spreading that dependency. + """ + return inspect.unwrap(workflows._pipe.gen) + + def _collect_workflows(repo, ctx) -> list[dict]: - generator = inspect.unwrap(workflows._pipe.gen) + generator = _workflow_transformer_generator() return [deferred() for deferred in generator(repo, ctx)] @@ -96,3 +134,103 @@ def test_workflows_cache_repository_permissions_for_active_workflows() -> None: assert [ path for path, _kwargs in client.get_calls if path.endswith("/permissions/workflow") ] == ["/repos/acme/repo/actions/permissions/workflow"] + + +def test_workflows_cache_repository_permissions_by_organization_and_repository() -> None: + acme_client = _FakeClient( + [[_workflow_row(1)]], + default_workflow_permissions="read", + can_approve_pull_request_reviews=False, + ) + other_client = _FakeClient( + [[_workflow_row(2)]], + default_workflow_permissions="write", + can_approve_pull_request_reviews=True, + ) + ctx = SourceContext( + client=acme_client, + organizations=[ + OrgContext(client=acme_client, org_name="acme"), + OrgContext(client=other_client, org_name="other"), + ], + ) + + acme_rows = _collect_workflows(_repo_for_org("acme", "REPO_1"), ctx) + other_rows = _collect_workflows(_repo_for_org("other", "REPO_2"), ctx) + + assert acme_rows[0]["repository_default_workflow_permissions"] == "read" + assert acme_rows[0]["repository_can_approve_pull_request_reviews"] is False + assert other_rows[0]["repository_default_workflow_permissions"] == "write" + assert other_rows[0]["repository_can_approve_pull_request_reviews"] is True + assert ctx.repository_workflow_permissions_cache == { + "acme/repo": { + "default_workflow_permissions": "read", + "can_approve_pull_request_reviews": False, + }, + "other/repo": { + "default_workflow_permissions": "write", + "can_approve_pull_request_reviews": True, + }, + } + + +def test_workflows_cache_repository_permissions_by_repository_within_organization() -> None: + client = _FakeClient( + [[_workflow_row(1)]], + workflow_permission_responses={ + "/repos/acme/repo/actions/permissions/workflow": { + "default_workflow_permissions": "read", + "can_approve_pull_request_reviews": False, + }, + "/repos/acme/other-repo/actions/permissions/workflow": { + "default_workflow_permissions": "write", + "can_approve_pull_request_reviews": True, + }, + }, + ) + ctx = _ctx(client) + + repo_rows = _collect_workflows(_repo_for_org("acme", "REPO_1"), ctx) + other_repo_rows = _collect_workflows( + _repo_for_org("acme", "REPO_2", repository_name="other-repo"), ctx + ) + + assert repo_rows[0]["repository_default_workflow_permissions"] == "read" + assert repo_rows[0]["repository_can_approve_pull_request_reviews"] is False + assert other_repo_rows[0]["repository_default_workflow_permissions"] == "write" + assert other_repo_rows[0]["repository_can_approve_pull_request_reviews"] is True + assert [ + path for path, _kwargs in client.get_calls if path.endswith("/permissions/workflow") + ] == [ + "/repos/acme/repo/actions/permissions/workflow", + "/repos/acme/other-repo/actions/permissions/workflow", + ] + assert ctx.repository_workflow_permissions_cache == { + "acme/repo": { + "default_workflow_permissions": "read", + "can_approve_pull_request_reviews": False, + }, + "acme/other-repo": { + "default_workflow_permissions": "write", + "can_approve_pull_request_reviews": True, + }, + } + + +def test_workflows_continue_when_repository_permission_lookup_fails() -> None: + permission_path = "/repos/acme/repo/actions/permissions/workflow" + client = _FakeClient( + [[_workflow_row(1)]], + workflow_permission_errors={permission_path}, + ) + ctx = _ctx(client) + + rows = _collect_workflows(_repo(), ctx) + _collect_workflows(_repo(), ctx) + + assert rows[0]["repository_default_workflow_permissions"] is None + assert rows[0]["repository_can_approve_pull_request_reviews"] is None + assert ctx.repository_workflow_permissions_cache == {"acme/repo": {}} + assert [ + path for path, _kwargs in client.get_calls if path == permission_path + ] == [permission_path]