diff --git a/descriptions/edges/GH_CanAccess.md b/descriptions/edges/GH_CanAccess.md index 4929ab8..7ef87f1 100644 --- a/descriptions/edges/GH_CanAccess.md +++ b/descriptions/edges/GH_CanAccess.md @@ -2,16 +2,18 @@ ## General Information -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. +The non-traversable GH_CanAccess edge indicates that a personal access token, app installation, or deploy key has been granted access to a repository, reusable scope, or organization. All-repository app installations and PATs point to an organization-scoped GH_Scope with `scope_type=repository` instead of repeating an edge to every repository. This edge represents access scope rather than a direct attack path. ## Edge Schema | Source | Destination | Traversable | | --- | --- | --- | | `GH_AppInstallation` | `GH_Repository` | `false` | +| `GH_AppInstallation` | `GH_Scope` | `false` | | `GH_DeployKey` | `GH_Repository` | `false` | | `GH_PersonalAccessToken` | `GH_Organization` | `false` | | `GH_PersonalAccessToken` | `GH_Repository` | `false` | +| `GH_PersonalAccessToken` | `GH_Scope` | `false` | ## Diagram @@ -19,11 +21,14 @@ The non-traversable GH_CanAccess edge indicates that a personal access token, ap graph LR n0["GH_AppInstallation"] n1["GH_Repository"] - n2["GH_DeployKey"] - n3["GH_PersonalAccessToken"] - n4["GH_Organization"] + n2["GH_Scope"] + n3["GH_DeployKey"] + n4["GH_PersonalAccessToken"] + n5["GH_Organization"] n0 -.->|GH_CanAccess| n1 - n2 -.->|GH_CanAccess| n1 - n3 -.->|GH_CanAccess| n4 + n0 -.->|GH_CanAccess| n2 n3 -.->|GH_CanAccess| n1 + n4 -.->|GH_CanAccess| n5 + n4 -.->|GH_CanAccess| n1 + n4 -.->|GH_CanAccess| n2 ``` diff --git a/descriptions/edges/GH_CanReadSecret.md b/descriptions/edges/GH_CanReadSecret.md index 73c60fd..c846380 100644 --- a/descriptions/edges/GH_CanReadSecret.md +++ b/descriptions/edges/GH_CanReadSecret.md @@ -2,19 +2,24 @@ ## General Information -Org role can read an organization secret by creating a repository in scope. +Org role can read organization secrets by creating a repository in the matching organization-secret scope. The collector emits this relationship from the role to a reusable `GH_Scope`; `GH_ScopedTo` expands the scope to its `all` or `private_or_internal` organization secrets. Selected secrets do not receive this derived capability because creating a repository does not add that repository to an arbitrary selected set. ## Edge Schema | Source | Destination | Traversable | | --- | --- | --- | | `GH_OrgRole` | `GH_OrgSecret` | `true` | +| `GH_OrgRole` | `GH_Scope` | `true` | + +`GH_OrgRole` to `GH_OrgSecret` remains an accepted legacy schema endpoint. Current OpenHound collections emit `GH_OrgRole` to `GH_Scope` instead. ## Diagram ```mermaid graph LR n0["GH_OrgRole"] - n1["GH_OrgSecret"] + n1["GH_Scope"] + n2["GH_OrgSecret"] n0 -->|GH_CanReadSecret| n1 + n1 -->|GH_ScopedTo| n2 ``` diff --git a/descriptions/edges/GH_CanUseRunner.md b/descriptions/edges/GH_CanUseRunner.md index 4900887..fdb8a3c 100644 --- a/descriptions/edges/GH_CanUseRunner.md +++ b/descriptions/edges/GH_CanUseRunner.md @@ -4,7 +4,7 @@ For runner-group-backed access, the traversable GH_CanUseRunner edge is a computed edge representing that a repository or branch can dispatch workflows to a self-hosted runner execution surface under the modeled runner-group controls. -The collector derives this edge from GH_IsEligibleFor rather than directly from repository visibility. It emits GH_CanUseRunner only when the repository is within the runner group's repository-access scope, GitHub Actions is enabled for the repository, and `restricted_to_workflows=false` on the organization-facing runner group. Inherited enterprise-backed access also requires `restricted_to_workflows=false` on the source GH_EnterpriseRunnerGroup. Every collected branch in a repository that satisfies those conditions receives the same edge so branch write paths can reach the execution surface. +The collector derives this edge from the same underlying eligibility policy represented by GH_IsEligibleFor. It emits GH_CanUseRunner only when the repository is within the runner group's repository-access scope, GitHub Actions is enabled for the repository, and `restricted_to_workflows=false` on the organization-facing runner group. Inherited enterprise-backed access also requires `restricted_to_workflows=false` on the source GH_EnterpriseRunnerGroup. Every collected branch in a repository that satisfies those conditions receives the same edge so branch write paths can reach the execution surface. Organization and inherited enterprise-backed access terminate at the organization-facing GH_OrgRunnerGroup, then continue through GH_HasRunner for native organization runners or through GH_InheritedFrom and GH_HasRunner for inherited enterprise runners. Repository-scoped runners currently receive GH_CanUseRunner directly from their containing repository and are not part of this runner-group traversability change. diff --git a/descriptions/edges/GH_Contains.md b/descriptions/edges/GH_Contains.md index 7950e5a..1b6b889 100644 --- a/descriptions/edges/GH_Contains.md +++ b/descriptions/edges/GH_Contains.md @@ -2,7 +2,7 @@ ## General Information -The non-traversable GH_Contains edge represents structural containment within the GitHub resource hierarchy. The enterprise contains enterprise teams, roles, managed users, runner groups, and enterprise runners through their groups. The organization serves as a top-level container for users, teams, repositories, roles, secrets, app installations, personal access tokens, and organization runner groups. Native organization runner groups contain organization runners. Repositories contain branches, workflows, branch protection rules, environments, repo-level secrets and variables, and repository-scoped runners. Environments contain environment branch policies, environment-scoped secrets, and environment-scoped variables. This edge is created by the collector to establish the resource hierarchy and is not traversable because containment alone does not imply privilege escalation. +The non-traversable GH_Contains edge represents structural containment within the GitHub resource hierarchy. The enterprise contains enterprise teams, roles, managed users, runner groups, and enterprise runners through their groups. The organization serves as a top-level container for users, teams, repositories, roles, scopes, secrets, app installations, personal access tokens, and organization runner groups. Native organization runner groups contain organization runners. Repositories contain branches, workflows, branch protection rules, environments, repo-level secrets and variables, and repository-scoped runners. Environments contain environment branch policies, environment-scoped secrets, and environment-scoped variables. This edge is created by the collector to establish the resource hierarchy and is not traversable because containment alone does not imply privilege escalation. ## Edge Schema @@ -24,6 +24,7 @@ The non-traversable GH_Contains edge represents structural containment within th | `GH_Organization` | `GH_OrgVariable` | `false` | | `GH_Organization` | `GH_PersonalAccessToken` | `false` | | `GH_Organization` | `GH_PersonalAccessTokenRequest` | `false` | +| `GH_Organization` | `GH_Scope` | `false` | | `GH_Organization` | `GH_SecretScanningAlert` | `false` | | `GH_Repository` | `GH_Branch` | `false` | | `GH_Repository` | `GH_BranchProtectionRule` | `false` | @@ -59,17 +60,18 @@ graph LR n15["GH_OrgVariable"] n16["GH_PersonalAccessToken"] n17["GH_PersonalAccessTokenRequest"] - n18["GH_SecretScanningAlert"] - n19["GH_Repository"] - n20["GH_Branch"] - n21["GH_BranchProtectionRule"] - n22["GH_DeployKey"] - n23["GH_RepoRunner"] - n24["GH_RepoSecret"] - n25["GH_RepoVariable"] - n26["GH_Workflow"] - n27["GH_WorkflowJob"] - n28["GH_WorkflowStep"] + n18["GH_Scope"] + n19["GH_SecretScanningAlert"] + n20["GH_Repository"] + n21["GH_Branch"] + n22["GH_BranchProtectionRule"] + n23["GH_DeployKey"] + n24["GH_RepoRunner"] + n25["GH_RepoSecret"] + n26["GH_RepoVariable"] + n27["GH_Workflow"] + n28["GH_WorkflowJob"] + n29["GH_WorkflowStep"] n0 -.->|GH_Contains| n1 n0 -.->|GH_Contains| n2 n0 -.->|GH_Contains| n3 @@ -87,15 +89,16 @@ graph LR n4 -.->|GH_Contains| n16 n4 -.->|GH_Contains| n17 n4 -.->|GH_Contains| n18 - n19 -.->|GH_Contains| n20 - n19 -.->|GH_Contains| n21 - n19 -.->|GH_Contains| n22 - n19 -.->|GH_Contains| n6 - n19 -.->|GH_Contains| n23 - n19 -.->|GH_Contains| n24 - n19 -.->|GH_Contains| n25 - n19 -.->|GH_Contains| n18 - n19 -.->|GH_Contains| n26 - n26 -.->|GH_Contains| n27 + n4 -.->|GH_Contains| n19 + n20 -.->|GH_Contains| n21 + n20 -.->|GH_Contains| n22 + n20 -.->|GH_Contains| n23 + n20 -.->|GH_Contains| n6 + n20 -.->|GH_Contains| n24 + n20 -.->|GH_Contains| n25 + n20 -.->|GH_Contains| n26 + n20 -.->|GH_Contains| n19 + n20 -.->|GH_Contains| n27 n27 -.->|GH_Contains| n28 + n28 -.->|GH_Contains| n29 ``` diff --git a/descriptions/edges/GH_HasSecret.md b/descriptions/edges/GH_HasSecret.md index df1ff1c..fc115fe 100644 --- a/descriptions/edges/GH_HasSecret.md +++ b/descriptions/edges/GH_HasSecret.md @@ -2,7 +2,7 @@ ## General Information -The traversable GH_HasSecret edge represents the relationship between a repository or environment and the secrets accessible within that context. This edge shows which secrets are available in which scopes. Repositories can have access to both organization-level secrets (scoped to selected repositories) and repository-level secrets, while environments expose their own environment-scoped secrets to jobs that target them. This edge is traversable because any principal that can execute a workflow in the relevant context may be able to exfiltrate secret values at runtime, making this a meaningful link in attack path analysis. +The traversable GH_HasSecret edge represents the relationship between a repository or environment and the secrets accessible within that context. Canonical all/private organization-secret availability is factored through a GH_Scope; selected organization secrets and repository/environment secrets remain direct. This edge is traversable because any principal that can execute a workflow in the relevant context may be able to exfiltrate secret values at runtime, making this a meaningful link in attack path analysis. ## Edge Schema @@ -11,6 +11,7 @@ The traversable GH_HasSecret edge represents the relationship between a reposito | `GH_Environment` | `GH_EnvironmentSecret` | `true` | | `GH_Repository` | `GH_OrgSecret` | `true` | | `GH_Repository` | `GH_RepoSecret` | `true` | +| `GH_Repository` | `GH_Scope` | `true` | ## Diagram @@ -21,7 +22,9 @@ graph LR n2["GH_Repository"] n3["GH_OrgSecret"] n4["GH_RepoSecret"] + n5["GH_Scope"] n0 -->|GH_HasSecret| n1 n2 -->|GH_HasSecret| n3 n2 -->|GH_HasSecret| n4 + n2 -->|GH_HasSecret| n5 ``` diff --git a/descriptions/edges/GH_HasVariable.md b/descriptions/edges/GH_HasVariable.md index d8bdfac..922f3dd 100644 --- a/descriptions/edges/GH_HasVariable.md +++ b/descriptions/edges/GH_HasVariable.md @@ -2,7 +2,7 @@ ## General Information -The traversable GH_HasVariable edge represents the relationship between a repository or environment and the variables accessible within that context. This edge shows which variables are available in which scopes. Repositories can have access to both organization-level variables (scoped by visibility to all, private, or selected repositories) and repository-level variables defined directly on the repo, while environments expose their own environment-scoped variables to jobs that target them. This edge is traversable because any principal that can execute a workflow in the relevant context may be able to read variable values at runtime, and variables may contain configuration data useful for lateral movement such as deployment URLs, service names, or environment identifiers. +The traversable GH_HasVariable edge represents the relationship between a repository or environment and the variables accessible within that context. Canonical all/private organization-variable availability is factored through a GH_Scope; selected organization variables and repository/environment variables remain direct. ## Edge Schema @@ -11,6 +11,7 @@ The traversable GH_HasVariable edge represents the relationship between a reposi | `GH_Environment` | `GH_EnvironmentVariable` | `true` | | `GH_Repository` | `GH_OrgVariable` | `true` | | `GH_Repository` | `GH_RepoVariable` | `true` | +| `GH_Repository` | `GH_Scope` | `true` | ## Diagram @@ -21,7 +22,9 @@ graph LR n2["GH_Repository"] n3["GH_OrgVariable"] n4["GH_RepoVariable"] + n5["GH_Scope"] n0 -->|GH_HasVariable| n1 n2 -->|GH_HasVariable| n3 n2 -->|GH_HasVariable| n4 + n2 -->|GH_HasVariable| n5 ``` diff --git a/descriptions/edges/GH_IsEligibleFor.md b/descriptions/edges/GH_IsEligibleFor.md index 2ed887b..9052ea4 100644 --- a/descriptions/edges/GH_IsEligibleFor.md +++ b/descriptions/edges/GH_IsEligibleFor.md @@ -2,7 +2,7 @@ ## General Information -The non-traversable GH_IsEligibleFor edge represents that a repository is within the repository-access scope of an organization runner group. +The non-traversable GH_IsEligibleFor edge represents that a repository is within the repository-access policy of an organization runner group. Canonical `all` and `private_or_internal` eligibility is factored through a GH_Scope; arbitrary selected eligibility remains a direct relationship. For runner groups, this edge evaluates the group's `visibility`, selected repository assignments, and `allows_public_repositories` setting. It does not prove that workflows in the repository can dispatch to the group's runners, because GitHub Actions may be disabled for the repository or the runner group may be restricted to selected workflows. @@ -11,6 +11,7 @@ For runner groups, this edge evaluates the group's `visibility`, selected reposi | Source | Destination | Traversable | | --- | --- | --- | | `GH_Repository` | `GH_OrgRunnerGroup` | `false` | +| `GH_Repository` | `GH_Scope` | `false` | ## Diagram @@ -18,5 +19,7 @@ For runner groups, this edge evaluates the group's `visibility`, selected reposi graph LR n0["GH_Repository"] n1["GH_OrgRunnerGroup"] + n2["GH_Scope"] n0 -.->|GH_IsEligibleFor| n1 + n0 -.->|GH_IsEligibleFor| n2 ``` diff --git a/descriptions/edges/GH_RequestsAccessTo.md b/descriptions/edges/GH_RequestsAccessTo.md new file mode 100644 index 0000000..62173cd --- /dev/null +++ b/descriptions/edges/GH_RequestsAccessTo.md @@ -0,0 +1,20 @@ +# GH_RequestsAccessTo + +## General Information + +The non-traversable GH_RequestsAccessTo edge records access sought by a pending fine-grained personal access token request. It is distinct from GH_CanAccess, which represents access GitHub has already granted. An all-repository request targets the organization's reusable repository/all GH_Scope. + +## Edge Schema + +| Source | Destination | Traversable | +| --- | --- | --- | +| `GH_PersonalAccessTokenRequest` | `GH_Scope` | `false` | + +## Diagram + +```mermaid +graph LR + n0["GH_PersonalAccessTokenRequest"] + n1["GH_Scope"] + n0 -.->|GH_RequestsAccessTo| n1 +``` diff --git a/descriptions/edges/GH_ScopedTo.md b/descriptions/edges/GH_ScopedTo.md new file mode 100644 index 0000000..2488552 --- /dev/null +++ b/descriptions/edges/GH_ScopedTo.md @@ -0,0 +1,29 @@ +# GH_ScopedTo + +## General Information + +The traversable GH_ScopedTo edge enumerates an asset included in a reusable GH_Scope. It carries an incoming capability from a target scope to each asset in the represented set. Repository scopes contain collected repositories, runner-group scopes contain organization-facing runner groups sharing repository eligibility, and organization-secret/variable scopes contain assets sharing repository availability. + +## Edge Schema + +| Source | Destination | Traversable | +| --- | --- | --- | +| `GH_Scope` | `GH_OrgRunnerGroup` | `true` | +| `GH_Scope` | `GH_OrgSecret` | `true` | +| `GH_Scope` | `GH_OrgVariable` | `true` | +| `GH_Scope` | `GH_Repository` | `true` | + +## Diagram + +```mermaid +graph LR + n0["GH_Scope"] + n1["GH_OrgRunnerGroup"] + n2["GH_OrgSecret"] + n3["GH_OrgVariable"] + n4["GH_Repository"] + n0 -->|GH_ScopedTo| n1 + n0 -->|GH_ScopedTo| n2 + n0 -->|GH_ScopedTo| n3 + n0 -->|GH_ScopedTo| n4 +``` diff --git a/descriptions/nodes/GH_AppInstallation.md b/descriptions/nodes/GH_AppInstallation.md index 953610c..83e17eb 100644 --- a/descriptions/nodes/GH_AppInstallation.md +++ b/descriptions/nodes/GH_AppInstallation.md @@ -6,7 +6,7 @@ Represents a GitHub App installed on an organization. App installations have spe 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). +Each installation is linked to its parent GH_App via a GH_InstalledAs edge. Installations with `repository_selection` set to `all` use one GH_CanAccess edge to the organization's repository/all GH_Scope; GH_ScopedTo edges enumerate the repositories in that reusable scope. For installations with `repository_selection` set to `selected`, repository-level edges cannot be enumerated with a PAT (requires app installation token authentication). ## Properties @@ -42,8 +42,10 @@ graph LR n0["GH_App"] n1["GH_AppInstallation"] n2["GH_Repository"] - n3["GH_Organization"] + n3["GH_Scope"] + n4["GH_Organization"] n0 -->|GH_InstalledAs| n1 n1 -.->|GH_CanAccess| n2 - n3 -.->|GH_Contains| n1 + n1 -.->|GH_CanAccess| n3 + n4 -.->|GH_Contains| n1 ``` diff --git a/descriptions/nodes/GH_Environment.md b/descriptions/nodes/GH_Environment.md index d71f8e5..f591d76 100644 --- a/descriptions/nodes/GH_Environment.md +++ b/descriptions/nodes/GH_Environment.md @@ -58,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_OrgRole.md b/descriptions/nodes/GH_OrgRole.md index 30085a5..b9cef6b 100644 --- a/descriptions/nodes/GH_OrgRole.md +++ b/descriptions/nodes/GH_OrgRole.md @@ -29,12 +29,12 @@ graph LR n1["GH_OrgRunnerGroup"] n2["GH_OrgSecret"] n3["GH_Organization"] - n4["GH_SecretScanningAlert"] - n5["GH_Team"] - n6["GH_User"] + n4["GH_Scope"] + n5["GH_SecretScanningAlert"] + n6["GH_Team"] + n7["GH_User"] n0 -->|GH_HasBaseRole| n0 n0 -->|GH_CanCreateRepositoryWithRunnerAccess| n1 - n0 -->|GH_CanReadSecret| n2 n0 -.->|GH_AddCollaborator| n3 n0 -.->|GH_CanCreateInternalRepositories| n3 n0 -.->|GH_CanCreatePrivateRepositories| n3 @@ -45,8 +45,10 @@ graph LR n0 -.->|GH_ResolveSecretScanningAlerts| n3 n0 -.->|GH_TransferRepository| n3 n0 -.->|GH_ViewSecretScanningAlerts| n3 - n0 -->|GH_CanReadSecretScanningAlert| n4 + n0 -->|GH_CanReadSecret| n4 + n4 -->|GH_ScopedTo| n2 + n0 -->|GH_CanReadSecretScanningAlert| n5 n3 -.->|GH_Contains| n0 - n5 -->|GH_HasRole| n0 n6 -->|GH_HasRole| n0 + n7 -->|GH_HasRole| n0 ``` diff --git a/descriptions/nodes/GH_OrgRunnerGroup.md b/descriptions/nodes/GH_OrgRunnerGroup.md index 10833d6..e12fa26 100644 --- a/descriptions/nodes/GH_OrgRunnerGroup.md +++ b/descriptions/nodes/GH_OrgRunnerGroup.md @@ -4,7 +4,7 @@ Represents a self-hosted runner group visible within a GitHub organization. Organization runner groups may either be native to the organization or inherited from an enterprise runner group. -Native organization runner groups contain GH_OrgRunner nodes directly. Direct memberships also emit GH_HasRunner to represent the traversable capability hop from the group to its runners. Inherited organization runner groups do not directly contain organization runners; instead, they link to the source GH_EnterpriseRunnerGroup through GH_InheritedFrom and gain access to the enterprise runners contained there. GH_IsEligibleFor edges from repositories describe repository access policy scope, while GH_CanUseRunner edges identify repositories and branches that can dispatch workflows to the group under the collected Actions and workflow-restriction settings. +Native organization runner groups contain GH_OrgRunner nodes directly. Direct memberships also emit GH_HasRunner to represent the traversable capability hop from the group to its runners. Inherited organization runner groups do not directly contain organization runners; instead, they link to the source GH_EnterpriseRunnerGroup through GH_InheritedFrom and gain access to the enterprise runners contained there. GH_IsEligibleFor uses shared runner-group scopes for canonical policies and direct edges for selected policies, while GH_CanUseRunner identifies repositories and branches that can dispatch workflows to the group under collected Actions and workflow-restriction settings. ## Properties @@ -42,6 +42,7 @@ graph LR n4["GH_OrgRunner"] n5["GH_Organization"] n6["GH_Repository"] + n7["GH_Scope"] n0 -->|GH_CanUseRunner| n1 n2 -->|GH_CanCreateRepositoryWithRunnerAccess| n1 n1 -->|GH_InheritedFrom| n3 @@ -50,4 +51,5 @@ graph LR n5 -.->|GH_Contains| n1 n6 -->|GH_CanUseRunner| n1 n6 -.->|GH_IsEligibleFor| n1 + n7 -->|GH_ScopedTo| n1 ``` diff --git a/descriptions/nodes/GH_OrgSecret.md b/descriptions/nodes/GH_OrgSecret.md index 27595b9..41f0e7a 100644 --- a/descriptions/nodes/GH_OrgSecret.md +++ b/descriptions/nodes/GH_OrgSecret.md @@ -2,7 +2,7 @@ ## General Information -Represents an organization-level GitHub Actions secret. Organization secrets can be scoped to all repositories, only private/internal repositories, or a specific set of selected repositories. The visibility property determines how GH_HasSecret edges are resolved to repository nodes. +Represents an organization-level GitHub Actions secret. All and private/internal visibility uses shared organization-secret GH_Scope nodes; arbitrary selected visibility uses direct GH_HasSecret relationships from repositories. ## Properties @@ -28,12 +28,14 @@ graph LR n1["GH_OrgSecret"] n2["GH_Organization"] n3["GH_Repository"] - n4["GH_WorkflowJob"] - n5["GH_WorkflowStep"] - n0 -->|GH_CanReadSecret| n1 + n4["GH_Scope"] + n5["GH_WorkflowJob"] + n6["GH_WorkflowStep"] n2 -.->|GH_Contains| n1 n3 -->|GH_HasSecret| n1 - n4 -->|GH_CanAccessSecret| n1 - n4 -.->|GH_UsesSecret| n1 + n0 -->|GH_CanReadSecret| n4 + n4 -->|GH_ScopedTo| n1 + n5 -->|GH_CanAccessSecret| n1 n5 -.->|GH_UsesSecret| n1 + n6 -.->|GH_UsesSecret| n1 ``` diff --git a/descriptions/nodes/GH_OrgVariable.md b/descriptions/nodes/GH_OrgVariable.md index d20ca24..f198017 100644 --- a/descriptions/nodes/GH_OrgVariable.md +++ b/descriptions/nodes/GH_OrgVariable.md @@ -2,7 +2,7 @@ ## General Information -Represents an organization-level GitHub Actions variable. Organization variables can be scoped to all repositories, only private/internal repositories, or a specific set of selected repositories. The visibility property determines how GH_HasVariable edges are resolved to repository nodes. Unlike secrets, variable values are readable via the API. +Represents an organization-level GitHub Actions variable. All and private/internal visibility uses shared organization-variable GH_Scope nodes; arbitrary selected visibility uses direct GH_HasVariable relationships from repositories. Unlike secrets, variable values are readable via the API. ## Properties @@ -27,10 +27,12 @@ graph LR n0["GH_Organization"] n1["GH_OrgVariable"] n2["GH_Repository"] - n3["GH_WorkflowJob"] - n4["GH_WorkflowStep"] + n3["GH_Scope"] + n4["GH_WorkflowJob"] + n5["GH_WorkflowStep"] n0 -.->|GH_Contains| n1 n2 -->|GH_HasVariable| n1 - n3 -.->|GH_UsesVariable| n1 + n3 -->|GH_ScopedTo| n1 n4 -.->|GH_UsesVariable| n1 + n5 -.->|GH_UsesVariable| n1 ``` diff --git a/descriptions/nodes/GH_Organization.md b/descriptions/nodes/GH_Organization.md index c429333..44570a6 100644 --- a/descriptions/nodes/GH_Organization.md +++ b/descriptions/nodes/GH_Organization.md @@ -102,7 +102,8 @@ graph LR n9["GH_PersonalAccessTokenRequest"] n10["GH_Repository"] n11["GH_SamlIdentityProvider"] - n12["GH_SecretScanningAlert"] + n12["GH_Scope"] + n13["GH_SecretScanningAlert"] n0 -.->|GH_Contains| n1 n2 -.->|GH_AssignedTo| n1 n3 -.->|GH_AddCollaborator| n1 @@ -125,5 +126,6 @@ graph LR n1 -->|GH_Owns| n10 n1 -.->|GH_HasSamlIdentityProvider| n11 n1 -.->|GH_Contains| n12 + n1 -.->|GH_Contains| n13 n8 -.->|GH_CanAccess| n1 ``` diff --git a/descriptions/nodes/GH_PersonalAccessToken.md b/descriptions/nodes/GH_PersonalAccessToken.md index ca1a9f8..5fc784b 100644 --- a/descriptions/nodes/GH_PersonalAccessToken.md +++ b/descriptions/nodes/GH_PersonalAccessToken.md @@ -2,7 +2,7 @@ ## 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. +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. PATs with `repository_selection` set to `all` use the organization's reusable repository/all GH_Scope; subset access remains represented by direct GH_CanAccess edges. 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. @@ -38,9 +38,11 @@ graph LR n0["GH_Organization"] n1["GH_PersonalAccessToken"] n2["GH_Repository"] - n3["GH_User"] + n3["GH_Scope"] + n4["GH_User"] n0 -.->|GH_Contains| n1 n1 -.->|GH_CanAccess| n0 n1 -.->|GH_CanAccess| n2 - n3 -.->|GH_HasPersonalAccessToken| n1 + n1 -.->|GH_CanAccess| n3 + n4 -.->|GH_HasPersonalAccessToken| n1 ``` diff --git a/descriptions/nodes/GH_PersonalAccessTokenRequest.md b/descriptions/nodes/GH_PersonalAccessTokenRequest.md index fd42491..0213493 100644 --- a/descriptions/nodes/GH_PersonalAccessTokenRequest.md +++ b/descriptions/nodes/GH_PersonalAccessTokenRequest.md @@ -2,7 +2,7 @@ ## 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. +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. An all-repository request also has a non-traversable GH_RequestsAccessTo edge to the organization's repository/all GH_Scope. 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. @@ -32,7 +32,9 @@ The requested permissions are stored separately as `organization_permissions` an graph LR n0["GH_Organization"] n1["GH_PersonalAccessTokenRequest"] - n2["GH_User"] + n2["GH_Scope"] + n3["GH_User"] n0 -.->|GH_Contains| n1 - n2 -.->|GH_HasPersonalAccessTokenRequest| n1 + n1 -.->|GH_RequestsAccessTo| n2 + n3 -.->|GH_HasPersonalAccessTokenRequest| n1 ``` diff --git a/descriptions/nodes/GH_Repository.md b/descriptions/nodes/GH_Repository.md index bc35d2e..6621928 100644 --- a/descriptions/nodes/GH_Repository.md +++ b/descriptions/nodes/GH_Repository.md @@ -8,6 +8,14 @@ For repositories with active workflows, the collector records the applicable def 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. +Every repository is linked from its organization's reusable repository/all GH_Scope. Private and internal repositories are also linked from repository/private_or_internal. This supports compact one- or two-hop access queries without repeating one GH_CanAccess edge per credential and repository. + +Repositories also point to runner_group/all GH_Scope nodes through GH_IsEligibleFor; private and internal repositories additionally point to runner_group/private_or_internal. Those scopes enumerate canonical runner groups through GH_ScopedTo, while groups with arbitrary selected repository policies remain direct. + +Organization secrets and variables with canonical all/private visibility are similarly reached through organization_secret and organization_variable scopes. Repository-owned, environment-owned, and arbitrarily selected organization assets remain direct targets. + +Runner, secret, and variable boundary edges are emitted only when the organization has a matching canonical target, so empty scopes do not add per-repository edges. + ## Properties | Property | Type | Description | @@ -87,8 +95,9 @@ graph LR n12["GH_RepoRunner"] n13["GH_RepoSecret"] n14["GH_RepoVariable"] - n15["GH_SecretScanningAlert"] - n16["GH_Workflow"] + n15["GH_Scope"] + n16["GH_SecretScanningAlert"] + n17["GH_Workflow"] n0 -.->|GH_CanAccess| n1 n2 -.->|GH_CanAccess| n1 n3 -->|GH_Owns| n1 @@ -169,6 +178,10 @@ graph LR n1 -->|GH_HasSecret| n13 n1 -.->|GH_Contains| n14 n1 -->|GH_HasVariable| n14 - n1 -.->|GH_Contains| n15 + n1 -->|GH_HasSecret| n15 + n1 -->|GH_HasVariable| n15 + n1 -.->|GH_IsEligibleFor| n15 n1 -.->|GH_Contains| n16 + n1 -.->|GH_Contains| n17 + n15 -->|GH_ScopedTo| n1 ``` diff --git a/descriptions/nodes/GH_Scope.md b/descriptions/nodes/GH_Scope.md new file mode 100644 index 0000000..ea771fc --- /dev/null +++ b/descriptions/nodes/GH_Scope.md @@ -0,0 +1,52 @@ +# GH_Scope + +## General Information + +Represents a reusable, organization-scoped asset set. Its `scope_type` identifies the kind of asset being grouped and its `scope` identifies the canonical selector. Repository, runner-group, organization-secret, and organization-variable scopes support `all` and `private_or_internal`, matching GitHub's organization-policy semantics. GH_ScopedTo is traversable so an incoming traversable capability reaches each member. + +Arbitrary app `selected` and PAT `subset` selections are not repository-scope nodes. Those selections remain direct GH_CanAccess relationships. A pending all-repository PAT request uses GH_RequestsAccessTo rather than GH_CanAccess because approval has not granted the requested access. + +Repositories receive capability boundary edges only when the target scope has at least one matching collected runner group, organization secret, or organization variable. Populated organization-secret scopes similarly receive GH_CanReadSecret from the owners role and, where repository creation policy permits it, the members role. This avoids expanding empty scope families in sparse organizations while retaining owner exposure when the organization currently has no repositories. + +## 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. | +| `collected` | `boolean` | The collected value. | +| `scope_type` | `string` | The scope type value. | +| `scope` | `string` | The scope value. | +| `member_count` | `integer` | Number of collected assets reached through outgoing `GH_ScopedTo` edges. | +| `environment_name` | `string` | The environment name value. | + +## Diagram + +```mermaid +graph LR + n0["GH_AppInstallation"] + n1["GH_Scope"] + n2["GH_OrgRole"] + n3["GH_Organization"] + n4["GH_PersonalAccessToken"] + n5["GH_PersonalAccessTokenRequest"] + n6["GH_Repository"] + n7["GH_OrgRunnerGroup"] + n8["GH_OrgSecret"] + n9["GH_OrgVariable"] + n0 -.->|GH_CanAccess| n1 + n2 -->|GH_CanReadSecret| n1 + n3 -.->|GH_Contains| n1 + n4 -.->|GH_CanAccess| n1 + n5 -.->|GH_RequestsAccessTo| n1 + n6 -->|GH_HasSecret| n1 + n6 -->|GH_HasVariable| n1 + n6 -.->|GH_IsEligibleFor| n1 + n1 -->|GH_ScopedTo| n7 + n1 -->|GH_ScopedTo| n8 + n1 -->|GH_ScopedTo| n9 + n1 -->|GH_ScopedTo| n6 +``` 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 046b9ac..af8bbde 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 repository-control write permissions. A compromised app credential may grant code, workflow, Actions, or administrative control across every repository. Installations with only read or lower-impact write permissions are excluded.", - "cypher": "MATCH (n:GH_AppInstallation {repository_selection:'all'})\nWHERE ANY(permission IN n.permissions WHERE permission IN ['contents:write', 'administration:write', 'workflows:write', 'actions:write'])\nRETURN n", + "cypher": "MATCH (n:GH_AppInstallation)-[:GH_CanAccess]->(:GH_Scope {scope_type:'repository', scope:'all'})\nWHERE ANY(permission IN n.permissions WHERE permission IN ['contents:write', 'administration:write', 'workflows:write', 'actions: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 1dfb7a9..274fc2a 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 repository-control write permissions across all repositories. The app owner controls the private key that can generate tokens for any installation. Compromise of the app's private key may grant code, workflow, Actions, or administrative control across every repository in organizations where it is installed. Apps whose installations have only read or lower-impact write permissions are excluded.", - "cypher": "MATCH (n:GH_App)-[:GH_InstalledAs]->(i:GH_AppInstallation {repository_selection:'all'})\nWHERE ANY(permission IN i.permissions WHERE permission IN ['contents:write', 'administration:write', 'workflows:write', 'actions:write'])\nRETURN n", + "cypher": "MATCH (n:GH_App)-[:GH_InstalledAs]->(i:GH_AppInstallation)-[:GH_CanAccess]->(:GH_Scope {scope_type:'repository', scope:'all'})\nWHERE ANY(permission IN i.permissions WHERE permission IN ['contents:write', 'administration:write', 'workflows:write', 'actions: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 f1b9201..4eadaed 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 repository-control write permissions. A single compromised token may grant code, workflow, Actions, or administrative control across every repository. PATs with only read or lower-impact write permissions are excluded.", - "cypher": "MATCH (n:GH_PersonalAccessToken {repository_selection:'all'})\nWHERE ANY(permission IN n.repository_permissions WHERE permission IN ['contents:write', 'administration:write', 'workflows:write', 'actions:write'])\nRETURN n", + "cypher": "MATCH (n:GH_PersonalAccessToken)-[:GH_CanAccess]->(:GH_Scope {scope_type:'repository', scope:'all'})\nWHERE ANY(permission IN n.repository_permissions WHERE permission IN ['contents:write', 'administration:write', 'workflows:write', 'actions:write'])\nRETURN n", "enabled": true, "zone": "Tier Zero", "allow_disable": true diff --git a/extension/saved_searches/repos-vulnerable-to-workflow-secret-exfil.json b/extension/saved_searches/repos-vulnerable-to-workflow-secret-exfil.json index 55f0a4e..0c56df1 100644 --- a/extension/saved_searches/repos-vulnerable-to-workflow-secret-exfil.json +++ b/extension/saved_searches/repos-vulnerable-to-workflow-secret-exfil.json @@ -1,5 +1,5 @@ { "name": "GitHub: Repos Vulnerable to Workflow Secret Exfiltration", - "query": "MATCH p1=(:GH_User)-[:GH_HasRole|GH_HasBaseRole|GH_MemberOf*1..]->(:GH_RepoRole)-[:GH_CanCreateBranch]->(repo:GH_Repository)-[:GH_HasSecret]->(s)\nWHERE (s:GH_RepoSecret\nOR s:GH_OrgSecret)\nOPTIONAL MATCH p2=(repo)<-[:GH_CanCreateBranch]-(:GH_User)\nOPTIONAL MATCH p3=(repo)<-[:GH_CanCreateBranch]-(:GH_Team)<-[:GH_HasRole|GH_MemberOf|GH_AddMember*1..]-(:GH_User)\nRETURN p1, p2, p3\nLIMIT 1000", + "query": "MATCH p1=(:GH_User)-[:GH_HasRole|GH_HasBaseRole|GH_MemberOf*1..]->(:GH_RepoRole)-[:GH_CanCreateBranch]->(repo:GH_Repository)-[:GH_HasSecret|GH_ScopedTo*1..2]->(s)\nWHERE (s:GH_RepoSecret\nOR s:GH_OrgSecret)\nOPTIONAL MATCH p2=(repo)<-[:GH_CanCreateBranch]-(:GH_User)\nOPTIONAL MATCH p3=(repo)<-[:GH_CanCreateBranch]-(:GH_Team)<-[:GH_HasRole|GH_MemberOf|GH_AddMember*1..]-(:GH_User)\nRETURN p1, p2, p3\nLIMIT 1000", "description": "Secrets reachable by users who can create new branches. The GH_CanCreateBranch edge accounts for branch protection rules, push restrictions, blocks_creations settings, and all bypass mechanisms (admin, push_protected_branch, pushAllowances). Edges emit from RepoRole in the common case; per-actor edges from User/Team are only present when per-rule allowances grant additional access beyond the role." } diff --git a/extension/saved_searches/secrets-reachable-by-user.json b/extension/saved_searches/secrets-reachable-by-user.json index 77004e5..3a2bd85 100644 --- a/extension/saved_searches/secrets-reachable-by-user.json +++ b/extension/saved_searches/secrets-reachable-by-user.json @@ -1,5 +1,5 @@ { "name": "GitHub: Secrets Reachable by User", - "query": "MATCH p=(:GH_User)-[:GH_HasRole|GH_HasBaseRole|GH_MemberOf*1..]->(:GH_RepoRole)-[:GH_WriteRepoContents]->(:GH_Repository)-[:GH_HasSecret]->(s)\nWHERE s:GH_RepoSecret\nOR s:GH_OrgSecret\nRETURN p\nLIMIT 1000", + "query": "MATCH p=(:GH_User)-[:GH_HasRole|GH_HasBaseRole|GH_MemberOf*1..]->(:GH_RepoRole)-[:GH_WriteRepoContents]->(:GH_Repository)-[:GH_HasSecret|GH_ScopedTo*1..2]->(s)\nWHERE s:GH_RepoSecret\nOR s:GH_OrgSecret\nRETURN p\nLIMIT 1000", "description": "Returns all repo and org secrets reachable by users through write access. Users with write access can create GitHub Actions workflows to access secrets." } diff --git a/extension/schema.json b/extension/schema.json index 1569478..5e63ce1 100644 --- a/extension/schema.json +++ b/extension/schema.json @@ -2,7 +2,7 @@ "schema": { "name": "SOGitHub", "display_name": "GitHub Extension (by SpecterOps)", - "version": "v1.3.3", + "version": "v1.4.0", "namespace": "GH" }, "node_kinds": [ @@ -515,7 +515,7 @@ "title": "Stored Credentials", "position": 9, "markdown": { - "content": "{{ $objectID := .Properties.objectid | default \"\" | trim }}\n{{ $escapedObjectID := $objectID | replace \"\\\\\" \"\\\\\\\\\" | replace \"'\" \"\\\\'\" }}\n{{ if ne $objectID \"\" }}\n{{ $query := printf \"MATCH p = (selected:GH_Repository {objectid: '%s'})-[:GH_HasSecret|GH_HasVariable]->()\\nRETURN p\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View secrets and variables available to this repository in Explore](/ui/explore?exploreSearchTab=cypher&searchType=cypher&cypherSearch={{ $encodedQuery }})\n{{ else }}\nExplore navigation is unavailable because this object has no stable identifier.\n{{ end }}" + "content": "{{ $objectID := .Properties.objectid | default \"\" | trim }}\n{{ $escapedObjectID := $objectID | replace \"\\\\\" \"\\\\\\\\\" | replace \"'\" \"\\\\'\" }}\n{{ if ne $objectID \"\" }}\n{{ $query := printf \"MATCH p = (selected:GH_Repository {objectid: '%s'})-[:GH_HasSecret|GH_HasVariable|GH_ScopedTo*1..2]->(target)\\nWHERE target:GH_Secret OR target:GH_Variable\\nRETURN p\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View secrets and variables available to this repository in Explore](/ui/explore?exploreSearchTab=cypher&searchType=cypher&cypherSearch={{ $encodedQuery }})\n{{ else }}\nExplore navigation is unavailable because this object has no stable identifier.\n{{ end }}" } }, "roles_and_teams": { @@ -720,7 +720,7 @@ "title": "Secret and Runner Exposure", "position": 7, "markdown": { - "content": "{{ $objectID := .Properties.objectid | default \"\" | trim }}\n{{ $escapedObjectID := $objectID | replace \"\\\\\" \"\\\\\\\\\" | replace \"'\" \"\\\\'\" }}\n{{ if ne $objectID \"\" }}\n{{ $query := printf \"MATCH p = (selected:GH_OrgRole {objectid: '%s'})-[:GH_CanReadSecret|GH_CanReadSecretScanningAlert|GH_CanCreateRepositoryWithRunnerAccess]->()\\nRETURN p\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View secret and runner exposure granted by this role in Explore](/ui/explore?exploreSearchTab=cypher&searchType=cypher&cypherSearch={{ $encodedQuery }})\n{{ else }}\nExplore navigation is unavailable because this object has no stable identifier.\n{{ end }}" + "content": "{{ $objectID := .Properties.objectid | default \"\" | trim }}\n{{ $escapedObjectID := $objectID | replace \"\\\\\" \"\\\\\\\\\" | replace \"'\" \"\\\\'\" }}\n{{ if ne $objectID \"\" }}\n{{ $query := printf \"MATCH p = (selected:GH_OrgRole {objectid: '%s'})-[:GH_CanReadSecret|GH_ScopedTo*1..2]->(:GH_OrgSecret)\\nRETURN p\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View organization secrets exposed through this role in Explore](/ui/explore?exploreSearchTab=cypher&searchType=cypher&cypherSearch={{ $encodedQuery }})\n{{ else }}\nExplore navigation is unavailable because this object has no stable identifier.\n{{ end }}" } } } @@ -1196,7 +1196,7 @@ "title": "Inbound Traversable GH Relationships", "position": 2, "markdown": { - "content": "{{ $objectID := .Properties.objectid | default \"\" | trim }}\n{{ $escapedObjectID := $objectID | replace \"\\\\\" \"\\\\\\\\\" | replace \"'\" \"\\\\'\" }}\n{{ if ne $objectID \"\" }}\n{{ $query := printf \"MATCH p = ()-[:GH_HasSecret|GH_CanReadSecret|GH_CanAccessSecret]->(selected:GH_Secret {objectid: '%s'})\\nRETURN p\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View direct inbound relationships in Explore](/ui/explore?exploreSearchTab=cypher&searchType=cypher&cypherSearch={{ $encodedQuery }})\n{{ else }}\nExplore navigation is unavailable because this object has no stable identifier.\n{{ end }}" + "content": "{{ $objectID := .Properties.objectid | default \"\" | trim }}\n{{ $escapedObjectID := $objectID | replace \"\\\\\" \"\\\\\\\\\" | replace \"'\" \"\\\\'\" }}\n{{ if ne $objectID \"\" }}\n{{ $query := printf \"MATCH p = ()-[:GH_HasSecret|GH_CanReadSecret|GH_CanAccessSecret|GH_ScopedTo*1..2]->(selected:GH_Secret {objectid: '%s'})\\nRETURN p\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View inbound relationships in Explore](/ui/explore?exploreSearchTab=cypher&searchType=cypher&cypherSearch={{ $encodedQuery }})\n{{ else }}\nExplore navigation is unavailable because this object has no stable identifier.\n{{ end }}" } }, "outbound_traversable_relationships": { @@ -1217,7 +1217,7 @@ "title": "Repositories With Access", "position": 5, "markdown": { - "content": "{{ $objectID := .Properties.objectid | default \"\" | trim }}\n{{ $escapedObjectID := $objectID | replace \"\\\\\" \"\\\\\\\\\" | replace \"'\" \"\\\\'\" }}\n{{ if ne $objectID \"\" }}\n{{ $query := printf \"MATCH p = (:GH_Repository)-[:GH_HasSecret]->(selected:GH_Secret {objectid: '%s'})\\nRETURN p\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View repositories that can receive this secret in Explore](/ui/explore?exploreSearchTab=cypher&searchType=cypher&cypherSearch={{ $encodedQuery }})\n{{ else }}\nExplore navigation is unavailable because this object has no stable identifier.\n{{ end }}" + "content": "{{ $objectID := .Properties.objectid | default \"\" | trim }}\n{{ $escapedObjectID := $objectID | replace \"\\\\\" \"\\\\\\\\\" | replace \"'\" \"\\\\'\" }}\n{{ if ne $objectID \"\" }}\n{{ $query := printf \"MATCH p = (:GH_Repository)-[:GH_HasSecret|GH_ScopedTo*1..2]->(selected:GH_Secret {objectid: '%s'})\\nRETURN p\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View repositories that can receive this secret in Explore](/ui/explore?exploreSearchTab=cypher&searchType=cypher&cypherSearch={{ $encodedQuery }})\n{{ else }}\nExplore navigation is unavailable because this object has no stable identifier.\n{{ end }}" } }, "workflow_jobs_with_access": { @@ -1231,7 +1231,7 @@ "title": "Role-Based Read Paths", "position": 7, "markdown": { - "content": "{{ $objectID := .Properties.objectid | default \"\" | trim }}\n{{ $escapedObjectID := $objectID | replace \"\\\\\" \"\\\\\\\\\" | replace \"'\" \"\\\\'\" }}\n{{ if ne $objectID \"\" }}\n{{ $query := printf \"MATCH (selected:GH_Secret {objectid: '%s'})\\nOPTIONAL MATCH p1 = (:GH_OrgRole)-[:GH_CanReadSecret]->(selected)\\nOPTIONAL MATCH p2 = (:GH_RepoRole)-[:GH_CanCreateBranch]->(:GH_Repository)-[:GH_HasSecret]->(selected)\\nRETURN p1, p2\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View role-based paths that can expose this secret in Explore](/ui/explore?exploreSearchTab=cypher&searchType=cypher&cypherSearch={{ $encodedQuery }})\n{{ else }}\nExplore navigation is unavailable because this object has no stable identifier.\n{{ end }}" + "content": "{{ $objectID := .Properties.objectid | default \"\" | trim }}\n{{ $escapedObjectID := $objectID | replace \"\\\\\" \"\\\\\\\\\" | replace \"'\" \"\\\\'\" }}\n{{ if ne $objectID \"\" }}\n{{ $query := printf \"MATCH (selected:GH_Secret {objectid: '%s'})\\nOPTIONAL MATCH p1 = (:GH_OrgRole)-[:GH_CanReadSecret|GH_ScopedTo*1..2]->(selected)\\nOPTIONAL MATCH p2 = (:GH_RepoRole)-[:GH_CanCreateBranch]->(:GH_Repository)-[:GH_HasSecret|GH_ScopedTo*1..2]->(selected)\\nRETURN p1, p2\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View role-based paths that can expose this secret in Explore](/ui/explore?exploreSearchTab=cypher&searchType=cypher&cypherSearch={{ $encodedQuery }})\n{{ else }}\nExplore navigation is unavailable because this object has no stable identifier.\n{{ end }}" } } } @@ -1253,7 +1253,7 @@ "title": "Inbound Traversable GH Relationships", "position": 2, "markdown": { - "content": "{{ $objectID := .Properties.objectid | default \"\" | trim }}\n{{ $escapedObjectID := $objectID | replace \"\\\\\" \"\\\\\\\\\" | replace \"'\" \"\\\\'\" }}\n{{ if ne $objectID \"\" }}\n{{ $query := printf \"MATCH p = ()-[:GH_HasVariable]->(selected:GH_Variable {objectid: '%s'})\\nRETURN p\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View direct inbound relationships in Explore](/ui/explore?exploreSearchTab=cypher&searchType=cypher&cypherSearch={{ $encodedQuery }})\n{{ else }}\nExplore navigation is unavailable because this object has no stable identifier.\n{{ end }}" + "content": "{{ $objectID := .Properties.objectid | default \"\" | trim }}\n{{ $escapedObjectID := $objectID | replace \"\\\\\" \"\\\\\\\\\" | replace \"'\" \"\\\\'\" }}\n{{ if ne $objectID \"\" }}\n{{ $query := printf \"MATCH p = ()-[:GH_HasVariable|GH_ScopedTo*1..2]->(selected:GH_Variable {objectid: '%s'})\\nRETURN p\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View inbound relationships in Explore](/ui/explore?exploreSearchTab=cypher&searchType=cypher&cypherSearch={{ $encodedQuery }})\n{{ else }}\nExplore navigation is unavailable because this object has no stable identifier.\n{{ end }}" } }, "outbound_traversable_relationships": { @@ -1274,7 +1274,7 @@ "title": "Repositories With Access", "position": 5, "markdown": { - "content": "{{ $objectID := .Properties.objectid | default \"\" | trim }}\n{{ $escapedObjectID := $objectID | replace \"\\\\\" \"\\\\\\\\\" | replace \"'\" \"\\\\'\" }}\n{{ if ne $objectID \"\" }}\n{{ $query := printf \"MATCH p = (:GH_Repository)-[:GH_HasVariable]->(selected:GH_Variable {objectid: '%s'})\\nRETURN p\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View repositories that can receive this variable in Explore](/ui/explore?exploreSearchTab=cypher&searchType=cypher&cypherSearch={{ $encodedQuery }})\n{{ else }}\nExplore navigation is unavailable because this object has no stable identifier.\n{{ end }}" + "content": "{{ $objectID := .Properties.objectid | default \"\" | trim }}\n{{ $escapedObjectID := $objectID | replace \"\\\\\" \"\\\\\\\\\" | replace \"'\" \"\\\\'\" }}\n{{ if ne $objectID \"\" }}\n{{ $query := printf \"MATCH p = (:GH_Repository)-[:GH_HasVariable|GH_ScopedTo*1..2]->(selected:GH_Variable {objectid: '%s'})\\nRETURN p\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View repositories that can receive this variable in Explore](/ui/explore?exploreSearchTab=cypher&searchType=cypher&cypherSearch={{ $encodedQuery }})\n{{ else }}\nExplore navigation is unavailable because this object has no stable identifier.\n{{ end }}" } }, "workflow_steps_using_value": { @@ -1461,7 +1461,7 @@ "title": "Eligible Repositories", "position": 8, "markdown": { - "content": "{{ $objectID := .Properties.objectid | default \"\" | trim }}\n{{ $escapedObjectID := $objectID | replace \"\\\\\" \"\\\\\\\\\" | replace \"'\" \"\\\\'\" }}\n{{ if ne $objectID \"\" }}\n{{ $query := printf \"MATCH p = (:GH_Repository)-[:GH_IsEligibleFor]->(:GH_OrgRunnerGroup)-[:GH_InheritedFrom]->(selected:GH_EnterpriseRunnerGroup {objectid: '%s'})\\nRETURN p\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View repositories within this enterprise runner group's access scope in Explore](/ui/explore?exploreSearchTab=cypher&searchType=cypher&cypherSearch={{ $encodedQuery }})\n{{ else }}\nExplore navigation is unavailable because this object has no stable identifier.\n{{ end }}" + "content": "{{ $objectID := .Properties.objectid | default \"\" | trim }}\n{{ $escapedObjectID := $objectID | replace \"\\\\\" \"\\\\\\\\\" | replace \"'\" \"\\\\'\" }}\n{{ if ne $objectID \"\" }}\n{{ $query := printf \"MATCH p = (:GH_Repository)-[:GH_IsEligibleFor|GH_ScopedTo*1..2]->(:GH_OrgRunnerGroup)-[:GH_InheritedFrom]->(selected:GH_EnterpriseRunnerGroup {objectid: '%s'})\\nRETURN p\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View repositories within this enterprise runner group's access scope in Explore](/ui/explore?exploreSearchTab=cypher&searchType=cypher&cypherSearch={{ $encodedQuery }})\n{{ else }}\nExplore navigation is unavailable because this object has no stable identifier.\n{{ end }}" } }, "dispatchable_repositories": { @@ -1702,7 +1702,52 @@ "title": "Repository Reach", "position": 5, "markdown": { - "content": "{{ $objectID := .Properties.objectid | default \"\" | trim }}\n{{ $escapedObjectID := $objectID | replace \"\\\\\" \"\\\\\\\\\" | replace \"'\" \"\\\\'\" }}\n{{ if ne $objectID \"\" }}\n{{ $query := printf \"MATCH p = (selected:GH_App {objectid: '%s'})-[:GH_InstalledAs]->(:GH_AppInstallation)-[:GH_CanAccess]->(:GH_Repository)\\nRETURN p\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View repositories reachable through this app's installations in Explore](/ui/explore?exploreSearchTab=cypher&searchType=cypher&cypherSearch={{ $encodedQuery }})\n{{ else }}\nExplore navigation is unavailable because this object has no stable identifier.\n{{ end }}" + "content": "{{ $objectID := .Properties.objectid | default \"\" | trim }}\n{{ $escapedObjectID := $objectID | replace \"\\\\\" \"\\\\\\\\\" | replace \"'\" \"\\\\'\" }}\n{{ if ne $objectID \"\" }}\n{{ $query := printf \"MATCH p = (selected:GH_App {objectid: '%s'})-[:GH_InstalledAs|GH_CanAccess|GH_ScopedTo*2..3]->(:GH_Repository)\\nRETURN p\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View repositories reachable through this app's installations in Explore](/ui/explore?exploreSearchTab=cypher&searchType=cypher&cypherSearch={{ $encodedQuery }})\n{{ else }}\nExplore navigation is unavailable because this object has no stable identifier.\n{{ end }}" + } + } + } + }, + { + "name": "GH_Scope", + "display_name": "GitHub Scope", + "description": "A reusable asset selection within one GitHub organization", + "is_display_kind": true, + "icon": "boxes-stacked", + "color": "#6B7280", + "info": { + "description": { + "title": "Description", + "position": 1, + "markdown": { + "content": "## Overview\n\nRepresents a reusable asset selection within one GitHub organization. `scope_type` identifies the family of assets and `scope` identifies the canonical selector. Repository, runner-group, organization-secret, and organization-variable scopes support `all` and `private_or_internal`. `member_count` reports collected outgoing membership. Per-repository capability boundaries are emitted only for non-empty target scopes." + } + }, + "inbound_traversable_relationships": { + "title": "Inbound Traversable GH Relationships", + "position": 2, + "markdown": { + "content": "{{ $objectID := .Properties.objectid | default \"\" | trim }}\n{{ $escapedObjectID := $objectID | replace \"\\\\\" \"\\\\\\\\\" | replace \"'\" \"\\\\'\" }}\n{{ if ne $objectID \"\" }}\n{{ $query := printf \"MATCH p = (source)-[:GH_HasSecret|GH_HasVariable|GH_CanReadSecret]->(selected:GH_Scope {objectid: '%s'})\\nRETURN p\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View direct inbound relationships in Explore](/ui/explore?exploreSearchTab=cypher&searchType=cypher&cypherSearch={{ $encodedQuery }})\n{{ else }}\nExplore navigation is unavailable because this object has no stable identifier.\n{{ end }}" + } + }, + "outbound_traversable_relationships": { + "title": "Outbound Traversable GH Relationships", + "position": 3, + "markdown": { + "content": "{{ $objectID := .Properties.objectid | default \"\" | trim }}\n{{ $escapedObjectID := $objectID | replace \"\\\\\" \"\\\\\\\\\" | replace \"'\" \"\\\\'\" }}\n{{ if ne $objectID \"\" }}\n{{ $query := printf \"MATCH p = (selected:GH_Scope {objectid: '%s'})-[:GH_ScopedTo]->()\\nRETURN p\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View direct outbound relationships in Explore](/ui/explore?exploreSearchTab=cypher&searchType=cypher&cypherSearch={{ $encodedQuery }})\n{{ else }}\nExplore navigation is unavailable because this object has no stable identifier.\n{{ end }}" + } + }, + "credentials": { + "title": "Credentials and Requests", + "position": 4, + "markdown": { + "content": "{{ $objectID := .Properties.objectid | default \"\" | trim }}\n{{ $escapedObjectID := $objectID | replace \"\\\\\" \"\\\\\\\\\" | replace \"'\" \"\\\\'\" }}\n{{ if ne $objectID \"\" }}\n{{ $query := printf \"MATCH p = (source)-[:GH_CanAccess|GH_RequestsAccessTo]->(selected:GH_Scope {objectid: '%s'})\\nWHERE source:GH_AppInstallation OR source:GH_PersonalAccessToken OR source:GH_PersonalAccessTokenRequest\\nRETURN p\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View credentials and pending requests associated with this scope in Explore](/ui/explore?exploreSearchTab=cypher&searchType=cypher&cypherSearch={{ $encodedQuery }})\n{{ else }}\nExplore navigation is unavailable because this object has no stable identifier.\n{{ end }}" + } + }, + "repositories": { + "title": "Scoped Assets", + "position": 5, + "markdown": { + "content": "{{ $objectID := .Properties.objectid | default \"\" | trim }}\n{{ $escapedObjectID := $objectID | replace \"\\\\\" \"\\\\\\\\\" | replace \"'\" \"\\\\'\" }}\n{{ if ne $objectID \"\" }}\n{{ $query := printf \"MATCH p = (selected:GH_Scope {objectid: '%s'})-[:GH_ScopedTo]->()\\nRETURN p\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View assets in this scope in Explore](/ui/explore?exploreSearchTab=cypher&searchType=cypher&cypherSearch={{ $encodedQuery }})\n{{ else }}\nExplore navigation is unavailable because this object has no stable identifier.\n{{ end }}" } } } @@ -1754,7 +1799,7 @@ "title": "Repository Scope", "position": 6, "markdown": { - "content": "{{ $objectID := .Properties.objectid | default \"\" | trim }}\n{{ $escapedObjectID := $objectID | replace \"\\\\\" \"\\\\\\\\\" | replace \"'\" \"\\\\'\" }}\n{{ if ne $objectID \"\" }}\n{{ $query := printf \"MATCH p = (selected:GH_AppInstallation {objectid: '%s'})-[:GH_CanAccess]->(:GH_Repository)\\nRETURN p\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View repositories accessible to this app installation in Explore](/ui/explore?exploreSearchTab=cypher&searchType=cypher&cypherSearch={{ $encodedQuery }})\n{{ else }}\nExplore navigation is unavailable because this object has no stable identifier.\n{{ end }}" + "content": "{{ $objectID := .Properties.objectid | default \"\" | trim }}\n{{ $escapedObjectID := $objectID | replace \"\\\\\" \"\\\\\\\\\" | replace \"'\" \"\\\\'\" }}\n{{ if ne $objectID \"\" }}\n{{ $query := printf \"MATCH p = (selected:GH_AppInstallation {objectid: '%s'})-[:GH_CanAccess|GH_ScopedTo*1..2]->(:GH_Repository)\\nRETURN p\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View repositories accessible to this app installation in Explore](/ui/explore?exploreSearchTab=cypher&searchType=cypher&cypherSearch={{ $encodedQuery }})\n{{ else }}\nExplore navigation is unavailable because this object has no stable identifier.\n{{ end }}" } } } @@ -1806,7 +1851,7 @@ "title": "Repository Scope", "position": 6, "markdown": { - "content": "{{ $objectID := .Properties.objectid | default \"\" | trim }}\n{{ $escapedObjectID := $objectID | replace \"\\\\\" \"\\\\\\\\\" | replace \"'\" \"\\\\'\" }}\n{{ if ne $objectID \"\" }}\n{{ $query := printf \"MATCH p = (selected:GH_PersonalAccessToken {objectid: '%s'})-[:GH_CanAccess]->(:GH_Repository)\\nRETURN p\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View repositories accessible to this token in Explore](/ui/explore?exploreSearchTab=cypher&searchType=cypher&cypherSearch={{ $encodedQuery }})\n{{ else }}\nExplore navigation is unavailable because this object has no stable identifier.\n{{ end }}" + "content": "{{ $objectID := .Properties.objectid | default \"\" | trim }}\n{{ $escapedObjectID := $objectID | replace \"\\\\\" \"\\\\\\\\\" | replace \"'\" \"\\\\'\" }}\n{{ if ne $objectID \"\" }}\n{{ $query := printf \"MATCH p = (selected:GH_PersonalAccessToken {objectid: '%s'})-[:GH_CanAccess|GH_ScopedTo*1..2]->(:GH_Repository)\\nRETURN p\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View repositories accessible to this token in Explore](/ui/explore?exploreSearchTab=cypher&searchType=cypher&cypherSearch={{ $encodedQuery }})\n{{ else }}\nExplore navigation is unavailable because this object has no stable identifier.\n{{ end }}" } } } @@ -1823,7 +1868,7 @@ "title": "Description", "position": 1, "markdown": { - "content": "## Overview\n\nRepresents a pending request from an organization member to access organization resources with a fine-grained personal access token. Requested permissions are stored on the node, but no access edges are projected until GitHub reports an approved token." + "content": "## Overview\n\nRepresents a pending request from an organization member to access organization resources with a fine-grained personal access token. Requested permissions are stored on the node. An all-repository request uses non-traversable GH_RequestsAccessTo rather than granted-access GH_CanAccess." } }, "inbound_traversable_relationships": { @@ -1853,6 +1898,13 @@ "markdown": { "content": "{{ $objectID := .Properties.objectid | default \"\" | trim }}\n{{ $escapedObjectID := $objectID | replace \"\\\\\" \"\\\\\\\\\" | replace \"'\" \"\\\\'\" }}\n{{ if ne $objectID \"\" }}\n{{ $query := printf \"MATCH p = (:GH_Organization)-[:GH_Contains]->(selected:GH_PersonalAccessTokenRequest {objectid: '%s'})\\nRETURN p\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View the organization containing this request in Explore](/ui/explore?exploreSearchTab=cypher&searchType=cypher&cypherSearch={{ $encodedQuery }})\n{{ else }}\nExplore navigation is unavailable because this object has no stable identifier.\n{{ end }}" } + }, + "requested_repositories": { + "title": "Requested Repositories", + "position": 6, + "markdown": { + "content": "{{ $objectID := .Properties.objectid | default \"\" | trim }}\n{{ $escapedObjectID := $objectID | replace \"\\\\\" \"\\\\\\\\\" | replace \"'\" \"\\\\'\" }}\n{{ if ne $objectID \"\" }}\n{{ $query := printf \"MATCH p = (selected:GH_PersonalAccessTokenRequest {objectid: '%s'})-[:GH_RequestsAccessTo|GH_ScopedTo*1..2]->(:GH_Repository)\\nRETURN p\\nLIMIT 500\" $escapedObjectID }}\n{{ $encodedQuery := $query | b64enc | urlquery }}\n[View repositories requested by this token request in Explore](/ui/explore?exploreSearchTab=cypher&searchType=cypher&cypherSearch={{ $encodedQuery }})\n{{ else }}\nExplore navigation is unavailable because this object has no stable identifier.\n{{ end }}" + } } } } @@ -1860,7 +1912,17 @@ "relationship_kinds": [ { "name": "GH_Contains", - "description": "Container relationship for organizational hierarchy (org contains secrets/variables, repo contains secrets/variables, environment contains secrets/variables)", + "description": "Container relationship for GitHub resource hierarchy, including organization-owned scopes", + "is_traversable": false + }, + { + "name": "GH_ScopedTo", + "description": "Reusable scope applies to a repository, organization runner group, organization secret, or organization variable", + "is_traversable": true + }, + { + "name": "GH_RequestsAccessTo", + "description": "Pending fine-grained PAT request requests access to this repository scope", "is_traversable": false }, { @@ -2445,7 +2507,7 @@ }, { "name": "GH_CanReadSecret", - "description": "Org role can read an organization secret by creating a repository in scope", + "description": "Org role can read organization secrets by creating a repository in scope", "is_traversable": true }, { @@ -2460,12 +2522,12 @@ }, { "name": "GH_HasSecret", - "description": "Repository or environment has access to this secret", + "description": "Repository or environment has access to this secret or organization-secret scope", "is_traversable": true }, { "name": "GH_HasVariable", - "description": "Repository or environment has access to this variable", + "description": "Repository or environment has access to this variable or organization-variable scope", "is_traversable": true }, { @@ -2505,7 +2567,7 @@ }, { "name": "GH_CanAccess", - "description": "Personal access token, app installation, or deploy key can access this repository or organization", + "description": "Personal access token, app installation, or deploy key can access this repository, reusable repository scope, or organization", "is_traversable": false }, { @@ -2515,7 +2577,7 @@ }, { "name": "GH_IsEligibleFor", - "description": "Repository is within the repository-access scope of this runner group", + "description": "Repository is eligible for this runner-group scope or selected runner group", "is_traversable": false }, { diff --git a/src/openhound_github/kinds/edges.py b/src/openhound_github/kinds/edges.py index bcbff94..add524c 100644 --- a/src/openhound_github/kinds/edges.py +++ b/src/openhound_github/kinds/edges.py @@ -1,5 +1,7 @@ # Generic CONTAINS = "GH_Contains" +SCOPED_TO = "GH_ScopedTo" +REQUESTS_ACCESS_TO = "GH_RequestsAccessTo" ASSIGNED_TO = "GH_AssignedTo" INHERITED_FROM = "GH_InheritedFrom" diff --git a/src/openhound_github/kinds/nodes.py b/src/openhound_github/kinds/nodes.py index beddeab..970d2bd 100644 --- a/src/openhound_github/kinds/nodes.py +++ b/src/openhound_github/kinds/nodes.py @@ -29,6 +29,7 @@ # Repository nodes REPOSITORY = "GH_Repository" +SCOPE = "GH_Scope" REPO_ROLE = "GH_RepoRole" REPO_SECRET = "GH_RepoSecret" REPO_VARIABLE = "GH_RepoVariable" diff --git a/src/openhound_github/lookup.py b/src/openhound_github/lookup.py index 6d8ec6b..feac656 100644 --- a/src/openhound_github/lookup.py +++ b/src/openhound_github/lookup.py @@ -601,6 +601,61 @@ def actions_enabled_repository_node_ids_for_org(self, org_login: str): [org_login], ) + @lru_cache + def canonical_scope_target_count( + self, org_login: str, scope_type: str, scope: str + ) -> int: + predicates = { + ("repository", "all"): ("repositories", "true"), + ("repository", "private_or_internal"): ( + "repositories", + "visibility IN ('private', 'internal')", + ), + ("runner_group", "all"): ( + "org_runner_group_access", + "runner_group_visibility = 'all' " + "AND allows_public_repositories IS NOT FALSE", + ), + ("runner_group", "private_or_internal"): ( + "org_runner_group_access", + "runner_group_visibility = 'private' OR " + "(runner_group_visibility = 'all' " + "AND allows_public_repositories = false)", + ), + ("organization_secret", "all"): ( + "organization_secrets", + "visibility = 'all'", + ), + ("organization_secret", "private_or_internal"): ( + "organization_secrets", + "visibility = 'private'", + ), + ("organization_variable", "all"): ( + "organization_variables", + "visibility = 'all'", + ), + ("organization_variable", "private_or_internal"): ( + "organization_variables", + "visibility = 'private'", + ), + } + target = predicates.get((scope_type, scope)) + if target is None: + return 0 + table, predicate = target + row = self._find_single_row( + f"SELECT COUNT(*) FROM {self.schema}.{table} " + f"WHERE org_login = ? AND ({predicate})", + [org_login], + ) + return int(row[0]) if row else 0 + + @lru_cache + def canonical_scope_has_targets( + self, org_login: str, scope_type: str, scope: str + ) -> bool: + return self.canonical_scope_target_count(org_login, scope_type, scope) > 0 + @lru_cache def actions_enabled_repositories_for_org(self, org_login: str) -> str | None: return self._find_single_object( diff --git a/src/openhound_github/models/__init__.py b/src/openhound_github/models/__init__.py index 38c8b6e..bc6c3e1 100644 --- a/src/openhound_github/models/__init__.py +++ b/src/openhound_github/models/__init__.py @@ -36,6 +36,7 @@ from .projected_enterprise_team import ProjectedEnterpriseTeam from .repo_role_assignment import RepoRoleAssignment from .repository import Repository, RepositoryQL +from .scope import Scope from .repository_role import BaseRepoRole, RepoRole from .repository_secret import RepoSecret from .repository_variable import RepoVariable @@ -83,6 +84,7 @@ "TeamRole", "TeamMember", "Repository", + "Scope", "RepoRole", "Branch", "BranchProtectionRule", diff --git a/src/openhound_github/models/app_installation.py b/src/openhound_github/models/app_installation.py index 1ce5c55..01f6cd2 100644 --- a/src/openhound_github/models/app_installation.py +++ b/src/openhound_github/models/app_installation.py @@ -13,6 +13,11 @@ 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.scope import ( + ALL_REPOSITORIES_SCOPE, + REPOSITORY_SCOPE_TYPE, + scope_node_id, +) @dataclass @@ -149,7 +154,7 @@ def as_node(self) -> GHNode: suspended_at=self.suspended_at, environment_name=self.org_login, environmentid=self.org_node_id, - query_repositories=f"MATCH p=(:GH_AppInstallation {{node_id:'{self.node_id}'}})-[:GH_CanAccess]->(:GH_Repository) RETURN p LIMIT 1000", + query_repositories=f"MATCH p=(:GH_AppInstallation {{node_id:'{self.node_id}'}})-[:GH_CanAccess|GH_ScopedTo*1..2]->(:GH_Repository) RETURN p LIMIT 1000", query_app=f"MATCH p=(:GH_App)-[:GH_InstalledAs]->(:GH_AppInstallation {{node_id:'{self.node_id}'}}) RETURN p", ), ) @@ -169,15 +174,19 @@ def _app_edges(self): @property def _can_access_edges(self): if self.repository_selection == "all": - for (repo_node_id,) in self._lookup.repository_node_ids_for_org( - self.org_login - ): - yield Edge( - kind=ek.CAN_ACCESS, - start=EdgePath(value=self.node_id, match_by="id"), - end=EdgePath(value=repo_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=scope_node_id( + self.org_node_id, + REPOSITORY_SCOPE_TYPE, + ALL_REPOSITORIES_SCOPE, + ), + match_by="id", + ), + properties=EdgeProperties(traversable=False), + ) @property def edges(self): diff --git a/src/openhound_github/models/org_secret.py b/src/openhound_github/models/org_secret.py index 6c79e18..9dbb47a 100644 --- a/src/openhound_github/models/org_secret.py +++ b/src/openhound_github/models/org_secret.py @@ -6,21 +6,15 @@ from openhound.core.asset import BaseAsset, EdgeDef, NodeDef from openhound.core.models.entries_dataclass import Edge, EdgePath, EdgeProperties -from openhound_github.graph import GHEdgeProperties, GHNode, GHNodeProperties +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 - - -_ALL_REPOSITORY_CREATION_EDGE_KINDS = ( - ek.CAN_CREATE_REPOSITORIES, - ek.CAN_CREATE_PUBLIC_REPOSITORIES, - ek.CAN_CREATE_INTERNAL_REPOSITORIES, - ek.CAN_CREATE_PRIVATE_REPOSITORIES, -) -_PRIVATE_REPOSITORY_CREATION_EDGE_KINDS = ( - ek.CAN_CREATE_INTERNAL_REPOSITORIES, - ek.CAN_CREATE_PRIVATE_REPOSITORIES, +from openhound_github.models.scope import ( + ALL_REPOSITORIES_SCOPE, + ORGANIZATION_SECRET_SCOPE_TYPE, + PRIVATE_OR_INTERNAL_REPOSITORIES_SCOPE, + scope_node_id, ) @@ -67,6 +61,20 @@ class GHOrgSecretProperties(GHNodeProperties): description="Repository can access org secret", traversable=True, ), + EdgeDef( + start=nk.SCOPE, + end=nk.ORG_SECRET, + kind=ek.SCOPED_TO, + description="Organization-secret scope applies to organization secret", + traversable=True, + ), + EdgeDef( + start=nk.REPOSITORY, + end=nk.SCOPE, + kind=ek.HAS_SECRET, + description="Repository can access organization-secret scope", + traversable=True, + ), EdgeDef( start=nk.ORG_ROLE, end=nk.ORG_SECRET, @@ -111,64 +119,43 @@ def as_node(self) -> GHNode: environmentid=self.org_node_id, created_at=str(self.created_at) if self.created_at else None, updated_at=str(self.updated_at) if self.updated_at else None, - query_visible_repositories=f"MATCH p=(:GH_OrgSecret {{node_id:'{sid}'}})<-[:GH_HasSecret]-(:GH_Repository) RETURN p", + query_visible_repositories=f"MATCH p=(:GH_Repository)-[:GH_HasSecret|GH_ScopedTo*1..2]->(:GH_OrgSecret {{node_id:'{sid}'}}) RETURN p", ), ) - @property - def _repository_creation_edge_kinds(self) -> tuple[str, ...]: - if self.visibility == "all": - return _ALL_REPOSITORY_CREATION_EDGE_KINDS - if self.visibility == "private": - return _PRIVATE_REPOSITORY_CREATION_EDGE_KINDS - return () - - def _read_secret_query( - self, role_node_id: str, edge_kinds: tuple[str, ...] - ) -> str: - creation_edges = "|".join(edge_kinds) - return ( - f"MATCH p=(:GH_OrgRole {{node_id:'{role_node_id}'}})" - f"-[:{creation_edges}]->" - f"(:GH_Organization)-[:GH_Contains]->" - f"(:GH_OrgSecret {{node_id:'{self.node_id}'}}) RETURN p" - ) - - def _members_can_create_repository_in_scope( - self, edge_kinds: tuple[str, ...] - ) -> bool: - creation_flags = self._lookup.members_can_create_repository(self.org_login) - if not creation_flags: - return False - - permissions = dict(zip(_ALL_REPOSITORY_CREATION_EDGE_KINDS, creation_flags)) - return any(bool(permissions.get(edge_kind)) for edge_kind in edge_kinds) - @property def _all_repo_edges(self): if self.visibility == "all": - for repo in self._lookup.repository_node_ids_for_org(self.org_login): - for repo_node_id in repo: - yield Edge( - kind=ek.HAS_SECRET, - start=EdgePath(value=repo_node_id, match_by="id"), - end=EdgePath(value=self.node_id, match_by="id"), - properties=EdgeProperties(traversable=True), - ) + yield Edge( + kind=ek.SCOPED_TO, + start=EdgePath( + value=scope_node_id( + self.org_node_id, + ORGANIZATION_SECRET_SCOPE_TYPE, + ALL_REPOSITORIES_SCOPE, + ), + match_by="id", + ), + end=EdgePath(value=self.node_id, match_by="id"), + properties=EdgeProperties(traversable=True), + ) @property def _private_repo_edges(self): if self.visibility == "private": - for repo in self._lookup.private_repository_node_ids_for_org( - self.org_login - ): - for repo_node_id in repo: - yield Edge( - kind=ek.HAS_SECRET, - start=EdgePath(value=repo_node_id, match_by="id"), - end=EdgePath(value=self.node_id, match_by="id"), - properties=EdgeProperties(traversable=True), - ) + yield Edge( + kind=ek.SCOPED_TO, + start=EdgePath( + value=scope_node_id( + self.org_node_id, + ORGANIZATION_SECRET_SCOPE_TYPE, + PRIVATE_OR_INTERNAL_REPOSITORIES_SCOPE, + ), + match_by="id", + ), + end=EdgePath(value=self.node_id, match_by="id"), + properties=EdgeProperties(traversable=True), + ) @property def _contains_edge(self): @@ -179,47 +166,11 @@ def _contains_edge(self): properties=EdgeProperties(traversable=False), ) - @property - def _composed_read_secret_edges(self): - edge_kinds = self._repository_creation_edge_kinds - if not edge_kinds: - return - - owners_role_id = f"{self.org_node_id}_owners" - yield Edge( - kind=ek.CAN_READ_SECRET, - start=EdgePath(value=owners_role_id, match_by="id"), - end=EdgePath(value=self.node_id, match_by="id"), - properties=GHEdgeProperties( - traversable=True, - composed=True, - query_composition=self._read_secret_query( - owners_role_id, edge_kinds - ), - ), - ) - - if self._members_can_create_repository_in_scope(edge_kinds): - members_role_id = f"{self.org_node_id}_members" - yield Edge( - kind=ek.CAN_READ_SECRET, - start=EdgePath(value=members_role_id, match_by="id"), - end=EdgePath(value=self.node_id, match_by="id"), - properties=GHEdgeProperties( - traversable=True, - composed=True, - query_composition=self._read_secret_query( - members_role_id, edge_kinds - ), - ), - ) - @property def edges(self): yield from self._contains_edge yield from self._all_repo_edges yield from self._private_repo_edges - yield from self._composed_read_secret_edges @app.asset( @@ -253,15 +204,6 @@ def node_id(self) -> str: def as_node(self) -> None: return None - @property - def _contains_edge(self): - yield Edge( - kind=ek.CONTAINS, - start=EdgePath(value=self.org_node_id, match_by="id"), - end=EdgePath(value=self.node_id, match_by="id"), - properties=EdgeProperties(traversable=False), - ) - @property def _has_secret_edge(self): yield Edge( @@ -273,5 +215,4 @@ def _has_secret_edge(self): @property def edges(self): - yield from self._contains_edge yield from self._has_secret_edge diff --git a/src/openhound_github/models/org_variable.py b/src/openhound_github/models/org_variable.py index 1cdcdef..765b8a3 100644 --- a/src/openhound_github/models/org_variable.py +++ b/src/openhound_github/models/org_variable.py @@ -10,6 +10,12 @@ 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.scope import ( + ALL_REPOSITORIES_SCOPE, + ORGANIZATION_VARIABLE_SCOPE_TYPE, + PRIVATE_OR_INTERNAL_REPOSITORIES_SCOPE, + scope_node_id, +) @dataclass @@ -55,6 +61,20 @@ class GHOrgVariableProperties(GHNodeProperties): description="Repository can access org variable", traversable=True, ), + EdgeDef( + start=nk.SCOPE, + end=nk.ORG_VARIABLE, + kind=ek.SCOPED_TO, + description="Organization-variable scope applies to organization variable", + traversable=True, + ), + EdgeDef( + start=nk.REPOSITORY, + end=nk.SCOPE, + kind=ek.HAS_VARIABLE, + description="Repository can access organization-variable scope", + traversable=True, + ), ], ) class OrgVariable(BaseAsset): @@ -94,35 +114,43 @@ def as_node(self) -> GHNode: value=self.value, created_at=str(self.created_at) if self.created_at else None, updated_at=str(self.updated_at) if self.updated_at else None, - query_visible_repositories=f"MATCH p=(:GH_OrgVariable {{node_id:'{vid}'}})<-[:GH_HasVariable]-(:GH_Repository) RETURN p", + query_visible_repositories=f"MATCH p=(:GH_Repository)-[:GH_HasVariable|GH_ScopedTo*1..2]->(:GH_OrgVariable {{node_id:'{vid}'}}) RETURN p", ), ) @property def _all_repo_edges(self): if self.visibility == "all": - for repo in self._lookup.repository_node_ids_for_org(self.org_login): - for repo_node_id in repo: - yield Edge( - kind=ek.HAS_VARIABLE, - start=EdgePath(value=repo_node_id, match_by="id"), - end=EdgePath(value=self.node_id, match_by="id"), - properties=EdgeProperties(traversable=True), - ) + yield Edge( + kind=ek.SCOPED_TO, + start=EdgePath( + value=scope_node_id( + self.org_node_id, + ORGANIZATION_VARIABLE_SCOPE_TYPE, + ALL_REPOSITORIES_SCOPE, + ), + match_by="id", + ), + end=EdgePath(value=self.node_id, match_by="id"), + properties=EdgeProperties(traversable=True), + ) @property def _private_repo_edges(self): if self.visibility == "private": - for repo in self._lookup.private_repository_node_ids_for_org( - self.org_login - ): - for repo_node_id in repo: - yield Edge( - kind=ek.HAS_VARIABLE, - start=EdgePath(value=repo_node_id, match_by="id"), - end=EdgePath(value=self.node_id, match_by="id"), - properties=EdgeProperties(traversable=True), - ) + yield Edge( + kind=ek.SCOPED_TO, + start=EdgePath( + value=scope_node_id( + self.org_node_id, + ORGANIZATION_VARIABLE_SCOPE_TYPE, + PRIVATE_OR_INTERNAL_REPOSITORIES_SCOPE, + ), + match_by="id", + ), + end=EdgePath(value=self.node_id, match_by="id"), + properties=EdgeProperties(traversable=True), + ) @property def _contains_edge(self): diff --git a/src/openhound_github/models/personal_access_token.py b/src/openhound_github/models/personal_access_token.py index 9171ecb..8abbe63 100644 --- a/src/openhound_github/models/personal_access_token.py +++ b/src/openhound_github/models/personal_access_token.py @@ -12,6 +12,11 @@ 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.scope import ( + ALL_REPOSITORIES_SCOPE, + REPOSITORY_SCOPE_TYPE, + scope_node_id, +) class Permissions(BaseModel): @@ -153,7 +158,7 @@ def as_node(self) -> GHNode: 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", query_user=f"MATCH p=(:GH_User)-[:GH_HasPersonalAccessToken]->(:GH_PersonalAccessToken {{node_id:'{pid}'}}) RETURN p", - query_repositories=f"MATCH p=(:GH_PersonalAccessToken {{node_id:'{pid}'}})-[:GH_CanAccess]->(:GH_Repository) RETURN p LIMIT 1000", + query_repositories=f"MATCH p=(:GH_PersonalAccessToken {{node_id:'{pid}'}})-[:GH_CanAccess|GH_ScopedTo*1..2]->(:GH_Repository) RETURN p LIMIT 1000", ), ) @@ -181,4 +186,18 @@ def edges(self): end=EdgePath(value=self.org_node_id, match_by="id"), properties=EdgeProperties(traversable=False), ) + if self.repository_selection == "all": + yield Edge( + kind=ek.CAN_ACCESS, + start=EdgePath(value=self.node_id, match_by="id"), + end=EdgePath( + value=scope_node_id( + self.org_node_id, + REPOSITORY_SCOPE_TYPE, + ALL_REPOSITORIES_SCOPE, + ), + match_by="id", + ), + properties=EdgeProperties(traversable=False), + ) yield from self._owner_edge diff --git a/src/openhound_github/models/personal_access_token_request.py b/src/openhound_github/models/personal_access_token_request.py index 5e76c5e..2ed732b 100644 --- a/src/openhound_github/models/personal_access_token_request.py +++ b/src/openhound_github/models/personal_access_token_request.py @@ -10,6 +10,11 @@ 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.scope import ( + ALL_REPOSITORIES_SCOPE, + REPOSITORY_SCOPE_TYPE, + scope_node_id, +) class Owner(BaseModel): @@ -128,7 +133,7 @@ def as_node(self) -> GHNode: ), 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", + query_repositories=f"MATCH p=(:GH_PersonalAccessTokenRequest {{node_id:'{rid}'}})-[:GH_RequestsAccessTo|GH_ScopedTo*1..2]->(:GH_Repository) RETURN p LIMIT 1000", ), ) @@ -150,3 +155,17 @@ def edges(self): end=EdgePath(value=self.node_id, match_by="id"), properties=EdgeProperties(traversable=False), ) + if self.repository_selection == "all": + yield Edge( + kind=ek.REQUESTS_ACCESS_TO, + start=EdgePath(value=self.node_id, match_by="id"), + end=EdgePath( + value=scope_node_id( + self.org_node_id, + REPOSITORY_SCOPE_TYPE, + ALL_REPOSITORIES_SCOPE, + ), + match_by="id", + ), + properties=EdgeProperties(traversable=False), + ) diff --git a/src/openhound_github/models/repository.py b/src/openhound_github/models/repository.py index 90f4b49..91d9bce 100644 --- a/src/openhound_github/models/repository.py +++ b/src/openhound_github/models/repository.py @@ -10,6 +10,15 @@ 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.scope import ( + ALL_REPOSITORIES_SCOPE, + ORGANIZATION_SECRET_SCOPE_TYPE, + ORGANIZATION_VARIABLE_SCOPE_TYPE, + PRIVATE_OR_INTERNAL_REPOSITORIES_SCOPE, + REPOSITORY_SCOPE_TYPE, + RUNNER_GROUP_SCOPE_TYPE, + scope_node_id, +) @dataclass @@ -194,6 +203,13 @@ class RepositoryQL(BaseModel): description="Org owns repository", traversable=True, ), + EdgeDef( + start=nk.SCOPE, + end=nk.REPOSITORY, + kind=ek.SCOPED_TO, + description="Repository scope applies to repository", + traversable=True, + ), ], ) class Repository(BaseAsset): @@ -316,8 +332,8 @@ def as_node(self) -> GHNode: f"MATCH p=(:GH_Repository {{node_id:'{rid}'}})-[:GH_CanUseRunner]->(:GH_OrgRunnerGroup)-[:GH_InheritedFrom]->(:GH_EnterpriseRunnerGroup)-[:GH_HasRunner]->(:GH_EnterpriseRunner) RETURN p" ), 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_secrets=f"MATCH p=(:GH_Repository {{node_id:'{rid}'}})-[:GH_HasSecret|GH_ScopedTo*1..2]->(:GH_Secret) RETURN p", + query_variables=f"MATCH p=(:GH_Repository {{node_id:'{rid}'}})-[:GH_HasVariable|GH_ScopedTo*1..2]->(: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", @@ -329,6 +345,40 @@ def as_node(self) -> GHNode: @property def edges(self): + scopes = [ALL_REPOSITORIES_SCOPE] + if self.visibility in {"private", "internal"}: + scopes.append(PRIVATE_OR_INTERNAL_REPOSITORIES_SCOPE) + for scope in scopes: + yield Edge( + kind=ek.SCOPED_TO, + start=EdgePath( + value=scope_node_id( + self.org_node_id, REPOSITORY_SCOPE_TYPE, scope + ), + match_by="id", + ), + end=EdgePath(value=self.node_id, match_by="id"), + properties=EdgeProperties(traversable=True), + ) + for scope_type, edge_kind, traversable in ( + (RUNNER_GROUP_SCOPE_TYPE, ek.IS_ELIGIBLE_FOR, False), + (ORGANIZATION_SECRET_SCOPE_TYPE, ek.HAS_SECRET, True), + (ORGANIZATION_VARIABLE_SCOPE_TYPE, ek.HAS_VARIABLE, True), + ): + if self._lookup.canonical_scope_has_targets( + self.org_login, scope_type, scope + ): + yield Edge( + kind=edge_kind, + start=EdgePath(value=self.node_id, match_by="id"), + end=EdgePath( + value=scope_node_id( + self.org_node_id, scope_type, scope + ), + match_by="id", + ), + properties=EdgeProperties(traversable=traversable), + ) if self.owner_id: yield Edge( kind=ek.OWNS, diff --git a/src/openhound_github/models/runner.py b/src/openhound_github/models/runner.py index 619e24f..a557ef0 100644 --- a/src/openhound_github/models/runner.py +++ b/src/openhound_github/models/runner.py @@ -11,6 +11,12 @@ 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.scope import ( + ALL_REPOSITORIES_SCOPE, + PRIVATE_OR_INTERNAL_REPOSITORIES_SCOPE, + RUNNER_GROUP_SCOPE_TYPE, + scope_node_id, +) from openhound_github.runner_ids import runner_group_node_id, runner_node_id @@ -167,7 +173,7 @@ def as_node(self) -> GHNode: environment_name=self.org_login, environmentid=self.org_node_id, query_runners=query_runners, - query_repositories=f"MATCH p=(:GH_Repository)-[:GH_IsEligibleFor]->(:GH_OrgRunnerGroup {{node_id:'{gid}'}}) RETURN p", + query_repositories=f"MATCH p=(:GH_Repository)-[:GH_IsEligibleFor|GH_ScopedTo*1..2]->(:GH_OrgRunnerGroup {{node_id:'{gid}'}}) RETURN p", ), ) @@ -258,7 +264,7 @@ def as_node(self) -> GHNode: environmentid=self.enterprise_node_id, query_runners=f"MATCH p=(:GH_EnterpriseRunnerGroup {{node_id:'{gid}'}})-[:GH_HasRunner]->(:GH_EnterpriseRunner) RETURN p", query_organizations=f"MATCH p=(:GH_Organization)-[:GH_Contains]->(:GH_OrgRunnerGroup)-[:GH_InheritedFrom]->(:GH_EnterpriseRunnerGroup {{node_id:'{gid}'}}) RETURN p", - query_repositories=f"MATCH p=(:GH_Repository)-[:GH_IsEligibleFor]->(:GH_OrgRunnerGroup)-[:GH_InheritedFrom]->(:GH_EnterpriseRunnerGroup {{node_id:'{gid}'}}) RETURN p", + query_repositories=f"MATCH p=(:GH_Repository)-[:GH_IsEligibleFor|GH_ScopedTo*1..2]->(:GH_OrgRunnerGroup)-[:GH_InheritedFrom]->(:GH_EnterpriseRunnerGroup {{node_id:'{gid}'}}) RETURN p", ), ) @@ -541,6 +547,16 @@ def _members_can_create_repository_in_scope( permissions = dict(zip(_ALL_REPOSITORY_CREATION_EDGE_KINDS, creation_flags)) return any(bool(permissions.get(edge_kind)) for edge_kind in edge_kinds) + @property + def canonical_scope(self) -> str | None: + if self.runner_group_visibility == "all": + if self.allows_public_repositories is False: + return PRIVATE_OR_INTERNAL_REPOSITORIES_SCOPE + return ALL_REPOSITORIES_SCOPE + if self.runner_group_visibility == "private": + return PRIVATE_OR_INTERNAL_REPOSITORIES_SCOPE + return None + def _can_create_repository_with_runner_access_query( self, role_node_id: str, edge_kinds: tuple[str, ...] ) -> str: @@ -611,6 +627,21 @@ def _new_repositories_can_dispatch_workflows(self) -> bool: @property def _is_eligible_for_edges(self): + if self.canonical_scope: + yield Edge( + kind=ek.SCOPED_TO, + start=EdgePath( + value=scope_node_id( + self.org_node_id, + RUNNER_GROUP_SCOPE_TYPE, + self.canonical_scope, + ), + match_by="id", + ), + end=EdgePath(value=self.runner_group_node_id, match_by="id"), + properties=EdgeProperties(traversable=True), + ) + return for (repo_node_id,) in self.repository_node_ids: yield Edge( kind=ek.IS_ELIGIBLE_FOR, diff --git a/src/openhound_github/models/scope.py b/src/openhound_github/models/scope.py new file mode 100644 index 0000000..80e9e6b --- /dev/null +++ b/src/openhound_github/models/scope.py @@ -0,0 +1,248 @@ +from dataclasses import dataclass + +from openhound.core.asset import BaseAsset, EdgeDef, NodeDef +from openhound.core.models.entries_dataclass import Edge, EdgePath, EdgeProperties + +from openhound_github.graph import GHEdgeProperties, GHNode, GHNodeProperties +from openhound_github.kinds import edges as ek +from openhound_github.kinds import nodes as nk +from openhound_github.main import app + + +ALL_REPOSITORIES_SCOPE = "all" +PRIVATE_OR_INTERNAL_REPOSITORIES_SCOPE = "private_or_internal" +REPOSITORY_SCOPE_TYPE = "repository" +RUNNER_GROUP_SCOPE_TYPE = "runner_group" +ORGANIZATION_SECRET_SCOPE_TYPE = "organization_secret" +ORGANIZATION_VARIABLE_SCOPE_TYPE = "organization_variable" + +_ALL_REPOSITORY_CREATION_EDGE_KINDS = ( + ek.CAN_CREATE_REPOSITORIES, + ek.CAN_CREATE_PUBLIC_REPOSITORIES, + ek.CAN_CREATE_INTERNAL_REPOSITORIES, + ek.CAN_CREATE_PRIVATE_REPOSITORIES, +) +_PRIVATE_REPOSITORY_CREATION_EDGE_KINDS = ( + ek.CAN_CREATE_INTERNAL_REPOSITORIES, + ek.CAN_CREATE_PRIVATE_REPOSITORIES, +) + + +def scope_node_id(org_node_id: str, scope_type: str, scope: str) -> str: + return f"GH_Scope_{org_node_id}_{scope_type}_{scope}" + + +@dataclass +class GHScopeProperties(GHNodeProperties): + collected: bool = True + scope_type: str | None = None + scope: str | None = None + member_count: int | None = None + environment_name: str | None = None + + +@app.asset( + node=NodeDef( + kind=nk.SCOPE, + description="Reusable asset selection within a GitHub organization", + icon="boxes-stacked", + properties=GHScopeProperties, + ), + edges=[ + EdgeDef( + start=nk.ORGANIZATION, + end=nk.SCOPE, + kind=ek.CONTAINS, + description="Organization contains repository scope", + traversable=False, + ), + EdgeDef( + start=nk.SCOPE, + end=nk.REPOSITORY, + kind=ek.SCOPED_TO, + description="Repository scope applies to repository", + traversable=True, + ), + EdgeDef( + start=nk.SCOPE, + end=nk.ORG_RUNNER_GROUP, + kind=ek.SCOPED_TO, + description="Runner-group scope applies to organization runner group", + traversable=True, + ), + EdgeDef( + start=nk.SCOPE, + end=nk.ORG_SECRET, + kind=ek.SCOPED_TO, + description="Organization-secret scope applies to organization secret", + traversable=True, + ), + EdgeDef( + start=nk.REPOSITORY, + end=nk.SCOPE, + kind=ek.HAS_SECRET, + description="Repository has access to organization-secret scope", + traversable=True, + ), + EdgeDef( + start=nk.SCOPE, + end=nk.ORG_VARIABLE, + kind=ek.SCOPED_TO, + description="Organization-variable scope applies to organization variable", + traversable=True, + ), + EdgeDef( + start=nk.REPOSITORY, + end=nk.SCOPE, + kind=ek.HAS_VARIABLE, + description="Repository has access to organization-variable scope", + traversable=True, + ), + EdgeDef( + start=nk.REPOSITORY, + end=nk.SCOPE, + kind=ek.IS_ELIGIBLE_FOR, + description="Repository is eligible for runner-group scope", + traversable=False, + ), + EdgeDef( + start=nk.APP_INSTALLATION, + end=nk.SCOPE, + kind=ek.CAN_ACCESS, + description="App installation can access repository scope", + traversable=False, + ), + EdgeDef( + start=nk.PERSONAL_ACCESS_TOKEN, + end=nk.SCOPE, + kind=ek.CAN_ACCESS, + description="PAT can access repository scope", + traversable=False, + ), + EdgeDef( + start=nk.PERSONAL_ACCESS_TOKEN_REQUEST, + end=nk.SCOPE, + kind=ek.REQUESTS_ACCESS_TO, + description="Pending PAT request requests access to repository scope", + traversable=False, + ), + EdgeDef( + start=nk.ORG_ROLE, + end=nk.SCOPE, + kind=ek.CAN_READ_SECRET, + description=( + "Org role can read secrets in an organization-secret scope by " + "creating a repository in scope" + ), + traversable=True, + ), + ], +) +class Scope(BaseAsset): + org_node_id: str + org_login: str + scope_type: str + scope: str + + @property + def node_id(self) -> str: + return scope_node_id(self.org_node_id, self.scope_type, self.scope) + + @property + def as_node(self) -> GHNode: + qualified_name = f"{self.org_login}/{self.scope_type}/{self.scope}" + return GHNode( + kinds=[nk.SCOPE], + properties=GHScopeProperties( + name=qualified_name, + displayname=qualified_name, + node_id=self.node_id, + scope_type=self.scope_type, + scope=self.scope, + member_count=self._lookup.canonical_scope_target_count( + self.org_login, self.scope_type, self.scope + ), + environment_name=self.org_login, + environmentid=self.org_node_id, + ), + ) + + @property + def _repository_creation_edge_kinds(self) -> tuple[str, ...]: + if self.scope == ALL_REPOSITORIES_SCOPE: + return _ALL_REPOSITORY_CREATION_EDGE_KINDS + if self.scope == PRIVATE_OR_INTERNAL_REPOSITORIES_SCOPE: + return _PRIVATE_REPOSITORY_CREATION_EDGE_KINDS + return () + + def _read_secret_query( + self, role_node_id: str, edge_kinds: tuple[str, ...] + ) -> str: + creation_edges = "|".join(edge_kinds) + return ( + f"MATCH p=(:GH_OrgRole {{node_id:'{role_node_id}'}})" + f"-[:{creation_edges}]->" + f"(:GH_Organization)-[:GH_Contains]->" + f"(:GH_Scope {{node_id:'{self.node_id}'}}) RETURN p" + ) + + def _members_can_create_repository_in_scope( + self, edge_kinds: tuple[str, ...] + ) -> bool: + creation_flags = self._lookup.members_can_create_repository(self.org_login) + if not creation_flags: + return False + + permissions = dict(zip(_ALL_REPOSITORY_CREATION_EDGE_KINDS, creation_flags)) + return any(bool(permissions.get(edge_kind)) for edge_kind in edge_kinds) + + @property + def _can_read_secret_edges(self): + if self.scope_type != ORGANIZATION_SECRET_SCOPE_TYPE: + return + if not self._lookup.canonical_scope_has_targets( + self.org_login, self.scope_type, self.scope + ): + return + + edge_kinds = self._repository_creation_edge_kinds + if not edge_kinds: + return + owners_role_id = f"{self.org_node_id}_owners" + yield Edge( + kind=ek.CAN_READ_SECRET, + start=EdgePath(value=owners_role_id, match_by="id"), + end=EdgePath(value=self.node_id, match_by="id"), + properties=GHEdgeProperties( + traversable=True, + composed=True, + query_composition=self._read_secret_query( + owners_role_id, edge_kinds + ), + ), + ) + + if self._members_can_create_repository_in_scope(edge_kinds): + members_role_id = f"{self.org_node_id}_members" + yield Edge( + kind=ek.CAN_READ_SECRET, + start=EdgePath(value=members_role_id, match_by="id"), + end=EdgePath(value=self.node_id, match_by="id"), + properties=GHEdgeProperties( + traversable=True, + composed=True, + query_composition=self._read_secret_query( + members_role_id, edge_kinds + ), + ), + ) + + @property + def edges(self): + yield Edge( + kind=ek.CONTAINS, + start=EdgePath(value=self.org_node_id, match_by="id"), + end=EdgePath(value=self.node_id, match_by="id"), + properties=EdgeProperties(traversable=False), + ) + yield from self._can_read_secret_edges diff --git a/src/openhound_github/resources/organization.py b/src/openhound_github/resources/organization.py index 09a8998..a17fbe3 100644 --- a/src/openhound_github/resources/organization.py +++ b/src/openhound_github/resources/organization.py @@ -64,6 +64,7 @@ RepoRunner, RepoSecret, Repository, + Scope, RepositoryQL, RepoVariable, SamlProvider, @@ -2367,6 +2368,27 @@ def org_scim_organizations(org: Organization, ctx: SourceContext): } +@app.transformer( + name="scopes", + columns=Scope, + parallelized=True, +) +def scopes(org: Organization): + for scope_type in ( + "repository", + "runner_group", + "organization_secret", + "organization_variable", + ): + for scope in ("all", "private_or_internal"): + yield { + "org_login": org.login, + "org_node_id": org.node_id, + "scope_type": scope_type, + "scope": scope, + } + + def organization_resources(ctx: SourceContext): org_resource = organizations(ctx) roles_resource = org_roles(ctx) @@ -2390,9 +2412,11 @@ def organization_resources(ctx: SourceContext): projected_enterprise_teams_resource = projected_enterprise_teams(ctx) saml_resource = saml_provider(ctx) org_scim_organizations_resource = org_resource | org_scim_organizations(ctx) + scopes_resource = org_resource | scopes() return ( org_resource, + scopes_resource, org_resource | roles_resource, org_resource | roles_resource | org_role_teams(ctx), org_resource | roles_resource | org_role_members(ctx), diff --git a/tests/test_lookup.py b/tests/test_lookup.py index ca1ca5b..29a0897 100644 --- a/tests/test_lookup.py +++ b/tests/test_lookup.py @@ -29,6 +29,50 @@ def test_github_lookup_rejects_untrusted_schema_identifiers() -> None: GithubLookup(connection, schema="github; DROP SCHEMA github") +def test_canonical_scope_activation_requires_matching_target() -> None: + connection = duckdb.connect(":memory:") + connection.execute("CREATE SCHEMA github_test") + connection.execute( + "CREATE TABLE github_test.organization_secrets " + "(org_login VARCHAR, visibility VARCHAR)" + ) + connection.execute( + "CREATE TABLE github_test.organization_variables " + "(org_login VARCHAR, visibility VARCHAR)" + ) + connection.execute( + "CREATE TABLE github_test.org_runner_group_access " + "(org_login VARCHAR, runner_group_visibility VARCHAR, " + "allows_public_repositories BOOLEAN)" + ) + connection.execute( + "INSERT INTO github_test.organization_secrets VALUES ('acme', 'all')" + ) + connection.execute( + "INSERT INTO github_test.organization_variables VALUES ('acme', 'private')" + ) + connection.execute( + "INSERT INTO github_test.org_runner_group_access VALUES " + "('acme', 'all', false)" + ) + lookup = GithubLookup(connection, schema="github_test") + + assert lookup.canonical_scope_has_targets("acme", "organization_secret", "all") + assert lookup.canonical_scope_target_count( + "acme", "organization_secret", "all" + ) == 1 + assert not lookup.canonical_scope_has_targets( + "acme", "organization_secret", "private_or_internal" + ) + assert lookup.canonical_scope_has_targets( + "acme", "organization_variable", "private_or_internal" + ) + assert not lookup.canonical_scope_has_targets("acme", "runner_group", "all") + assert lookup.canonical_scope_has_targets( + "acme", "runner_group", "private_or_internal" + ) + + def test_external_group_for_team_is_scoped_to_org_login() -> None: connection = duckdb.connect(":memory:") connection.execute("CREATE SCHEMA github_test") diff --git a/tests/test_org_secret_models.py b/tests/test_org_secret_models.py index dc789f3..c998403 100644 --- a/tests/test_org_secret_models.py +++ b/tests/test_org_secret_models.py @@ -4,6 +4,14 @@ from openhound_github.kinds import edges as ek from openhound_github.models.org_role import OrgRole from openhound_github.models.org_secret import OrgSecret +from openhound_github.models.org_secret import SelectedOrgSecret +from openhound_github.models.scope import ( + ALL_REPOSITORIES_SCOPE, + ORGANIZATION_SECRET_SCOPE_TYPE, + PRIVATE_OR_INTERNAL_REPOSITORIES_SCOPE, + Scope, + scope_node_id, +) def _secret( @@ -23,18 +31,39 @@ def _secret( return secret -def test_all_visibility_secret_requires_an_actual_member_creation_capability() -> None: - secret = _secret("all", (False, False, False, False)) +def _secret_scope( + scope: str, + creation_flags: tuple[bool, bool, bool, bool], + target_count: int = 1, +) -> Scope: + secret_scope = Scope( + org_node_id="O_1", + org_login="acme", + scope_type=ORGANIZATION_SECRET_SCOPE_TYPE, + scope=scope, + ) + lookup = MagicMock() + lookup.canonical_scope_has_targets.return_value = target_count > 0 + lookup.members_can_create_repository.return_value = creation_flags + secret_scope._lookup = lookup + return secret_scope - edges = list(secret._composed_read_secret_edges) + +def test_all_secret_scope_requires_an_actual_member_creation_capability() -> None: + scope = _secret_scope(ALL_REPOSITORIES_SCOPE, (False, False, False, False)) + + edges = list(scope._can_read_secret_edges) assert [edge.start.value for edge in edges] == ["O_1_owners"] + assert edges[0].end.value == scope.node_id + assert scope.node_id in edges[0].properties.query_composition + assert ":GH_Scope" in edges[0].properties.query_composition -def test_all_visibility_secret_allows_any_repository_creation_capability() -> None: - secret = _secret("all", (True, False, False, False)) +def test_all_secret_scope_allows_any_repository_creation_capability() -> None: + scope = _secret_scope(ALL_REPOSITORIES_SCOPE, (True, False, False, False)) - edges = list(secret._composed_read_secret_edges) + edges = list(scope._can_read_secret_edges) assert [edge.start.value for edge in edges] == ["O_1_owners", "O_1_members"] assert "GH_CanCreateRepositories" in edges[1].properties.query_composition @@ -43,10 +72,12 @@ def test_all_visibility_secret_allows_any_repository_creation_capability() -> No assert "GH_CanCreatePrivateRepositories" in edges[1].properties.query_composition -def test_private_visibility_secret_only_allows_private_or_internal_creation() -> None: - secret = _secret("private", (True, True, False, False)) +def test_private_secret_scope_only_allows_private_or_internal_creation() -> None: + scope = _secret_scope( + PRIVATE_OR_INTERNAL_REPOSITORIES_SCOPE, (True, True, False, False) + ) - edges = list(secret._composed_read_secret_edges) + edges = list(scope._can_read_secret_edges) assert [edge.start.value for edge in edges] == ["O_1_owners"] query = edges[0].properties.query_composition @@ -56,18 +87,56 @@ def test_private_visibility_secret_only_allows_private_or_internal_creation() -> assert "GH_CanCreatePublicRepositories" not in query -def test_private_visibility_secret_allows_internal_repository_creation() -> None: - secret = _secret("private", (False, False, True, False)) +def test_private_secret_scope_allows_internal_repository_creation() -> None: + scope = _secret_scope( + PRIVATE_OR_INTERNAL_REPOSITORIES_SCOPE, (False, False, True, False) + ) - edges = list(secret._composed_read_secret_edges) + edges = list(scope._can_read_secret_edges) assert [edge.start.value for edge in edges] == ["O_1_owners", "O_1_members"] -def test_selected_visibility_secret_does_not_emit_latent_read_edges() -> None: - secret = _secret("selected", (True, True, True, True)) +def test_empty_secret_scope_does_not_emit_latent_read_edges() -> None: + scope = _secret_scope(ALL_REPOSITORIES_SCOPE, (True, True, True, True), 0) + + assert list(scope._can_read_secret_edges) == [] + + +def test_canonical_secret_visibility_uses_target_scope_without_repository_fanout() -> None: + all_secret = _secret("all", (False, False, False, False)) + private_secret = _secret("private", (False, False, False, False)) + + all_edge = next(iter(all_secret._all_repo_edges)) + private_edge = next(iter(private_secret._private_repo_edges)) + + assert all_edge.kind == ek.SCOPED_TO + assert all_edge.start.value == scope_node_id( + "O_1", ORGANIZATION_SECRET_SCOPE_TYPE, ALL_REPOSITORIES_SCOPE + ) + assert private_edge.start.value == scope_node_id( + "O_1", ORGANIZATION_SECRET_SCOPE_TYPE, PRIVATE_OR_INTERNAL_REPOSITORIES_SCOPE + ) + assert all_edge.end.value == all_secret.node_id + assert all_edge.properties.traversable is True + all_secret._lookup.repository_node_ids_for_org.assert_not_called() + private_secret._lookup.private_repository_node_ids_for_org.assert_not_called() + + +def test_selected_secret_keeps_direct_relationship_without_repeated_containment() -> None: + selected = SelectedOrgSecret( + name="DEPLOY_TOKEN", + repository_node_id="R_1", + repository_full_name="acme/repo", + org_login="acme", + ) + selected._lookup = MagicMock(org_id_for_login=lambda _login: "O_1") + + edges = list(selected.edges) - assert list(secret._composed_read_secret_edges) == [] + assert [edge.kind for edge in edges] == [ek.HAS_SECRET] + assert edges[0].start.value == "R_1" + assert edges[0].properties.traversable is True def test_owners_always_emit_repository_creation_edges_needed_for_secret_paths() -> None: diff --git a/tests/test_repository_rulesets.py b/tests/test_repository_rulesets.py index b9b6f80..9d63310 100644 --- a/tests/test_repository_rulesets.py +++ b/tests/test_repository_rulesets.py @@ -484,6 +484,10 @@ def test_repository_node_surfaces_branch_ruleset_presence() -> None: assert node.properties.node_id == "R_1" assert node.properties.owner_id == "O_1" assert [(edge.start.value, edge.end.value) for edge in repo.edges] == [ + ("GH_Scope_O_1_repository_all", "R_1"), + ("R_1", "GH_Scope_O_1_runner_group_all"), + ("R_1", "GH_Scope_O_1_organization_secret_all"), + ("R_1", "GH_Scope_O_1_organization_variable_all"), ("O_1", "R_1") ] lookup.repository_branch_ruleset_count.assert_called_once_with("R_1") diff --git a/tests/test_repository_scopes.py b/tests/test_repository_scopes.py new file mode 100644 index 0000000..6543e3e --- /dev/null +++ b/tests/test_repository_scopes.py @@ -0,0 +1,310 @@ +import json +from datetime import datetime +from pathlib import Path +from unittest.mock import MagicMock + +from openhound_github.kinds import edges as ek +from openhound_github.kinds import nodes as nk +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, +) +from openhound_github.models.repository import Owner as RepositoryOwner +from openhound_github.models.repository import Repository +from openhound_github.models.scope import ( + ALL_REPOSITORIES_SCOPE, + PRIVATE_OR_INTERNAL_REPOSITORIES_SCOPE, + REPOSITORY_SCOPE_TYPE, + RUNNER_GROUP_SCOPE_TYPE, + Scope, + scope_node_id, +) + + +ORG_NODE_ID = "O_1" +SCOPE_NODE_ID = scope_node_id( + ORG_NODE_ID, REPOSITORY_SCOPE_TYPE, ALL_REPOSITORIES_SCOPE +) + + +def _lookup() -> MagicMock: + lookup = MagicMock() + lookup.org_id_for_login.return_value = ORG_NODE_ID + lookup.repository_graphql_counts.return_value = (None, None, None) + lookup.repository_workflow_permissions.return_value = None + lookup.canonical_scope_target_count.return_value = 4 + return lookup + + +def _installation(installation_id: int, selection: str = "all") -> AppInstallation: + installation = AppInstallation( + id=installation_id, + repository_selection=selection, + app_id=42, + target_type="Organization", + permissions={"contents": "read"}, + created_at=datetime(2026, 1, 1), + org_login="acme", + ) + installation._lookup = _lookup() + return installation + + +def _pat(selection: str) -> PersonalAccessToken: + token = PersonalAccessToken( + id=1, + owner=PersonalAccessTokenOwner( + login="octocat", + id=1, + type="User", + node_id="U_1", + ), + repository_selection=selection, + permissions=Permissions(repository={"contents": "read"}), + token_id=1, + token_name="automation", + token_expired=False, + org_login="acme", + ) + token._lookup = _lookup() + return token + + +def _repository(repo_number: int, visibility: str | None = None) -> Repository: + repository = Repository( + id=repo_number, + node_id=f"R_{repo_number}", + name=f"repo-{repo_number}", + full_name=f"acme/repo-{repo_number}", + private=True, + owner=RepositoryOwner( + login="acme", + id=1, + node_id=ORG_NODE_ID, + avatar_url="", + gravatar_id="", + url="", + html_url="", + followers_url="", + following_url="", + gists_url="", + starred_url="", + subscriptions_url="", + organizations_url="", + repos_url="", + events_url="", + received_events_url="", + type="Organization", + site_admin=False, + ), + org_login="acme", + visibility=visibility, + ) + repository._lookup = _lookup() + return repository + + +def _pat_request(selection: str) -> PersonalAccessTokenRequest: + request = PersonalAccessTokenRequest( + id=2, + owner=PersonalAccessTokenRequestOwner( + login="octocat", id=1, type="User", node_id="U_1", site_admin=False + ), + repository_selection=selection, + token_name="requested automation", + token_expired=False, + org_login="acme", + ) + request._lookup = _lookup() + return request + + +def test_all_repository_scope_is_stable_and_organization_scoped() -> None: + scope = Scope( + org_node_id=ORG_NODE_ID, + org_login="acme", + scope_type=REPOSITORY_SCOPE_TYPE, + scope=ALL_REPOSITORIES_SCOPE, + ) + scope._lookup = _lookup() + + assert scope.node_id == SCOPE_NODE_ID + assert scope.as_node.kinds == [nk.SCOPE, "GitHub"] + assert scope.as_node.properties.scope_type == REPOSITORY_SCOPE_TYPE + assert scope.as_node.properties.member_count == 4 + assert scope.as_node.properties.scope == "all" + assert scope.as_node.properties.displayname == "acme/repository/all" + edge = next(iter(scope.edges)) + assert edge.kind == ek.CONTAINS + assert edge.start.value == ORG_NODE_ID + assert edge.end.value == SCOPE_NODE_ID + assert edge.properties.traversable is False + + +def test_all_installations_and_pat_share_scope_without_repository_fanout() -> None: + access_edges = [] + for installation_id in range(1, 4): + installation = _installation(installation_id) + edges = list(installation.edges) + access_edges.extend(edge for edge in edges if edge.kind == ek.CAN_ACCESS) + installation._lookup.repository_node_ids_for_org.assert_not_called() + + pat_edges = list(_pat("all").edges) + pat_repository_access = [ + edge + for edge in pat_edges + if edge.kind == ek.CAN_ACCESS and edge.end.value == SCOPE_NODE_ID + ] + + assert len(access_edges) == 3 + assert {edge.end.value for edge in access_edges} == {SCOPE_NODE_ID} + assert len(pat_repository_access) == 1 + assert all(edge.properties.traversable is False for edge in access_edges) + + +def test_repository_scope_reduces_shared_policy_edges_to_sources_plus_repositories() -> None: + all_access_edges = [ + edge + for installation_id in range(1, 4) + for edge in _installation(installation_id).edges + if edge.kind == ek.CAN_ACCESS + ] + scoped_to_edges = [ + edge + for repo_number in range(1, 5) + for edge in _repository(repo_number).edges + if edge.kind == ek.SCOPED_TO + ] + + assert len(all_access_edges) + len(scoped_to_edges) == 3 + 4 + assert {edge.start.value for edge in scoped_to_edges} == {SCOPE_NODE_ID} + assert all(edge.properties.traversable is True for edge in scoped_to_edges) + + +def test_private_or_internal_scope_contains_only_non_public_repositories() -> None: + private_scope_id = scope_node_id( + ORG_NODE_ID, + REPOSITORY_SCOPE_TYPE, + PRIVATE_OR_INTERNAL_REPOSITORIES_SCOPE, + ) + scoped_edges = [ + edge + for repo_number, visibility in enumerate(("public", "private", "internal"), 1) + for edge in _repository(repo_number, visibility).edges + if edge.kind == ek.SCOPED_TO and edge.start.value == private_scope_id + ] + + assert {edge.end.value for edge in scoped_edges} == {"R_2", "R_3"} + + +def test_repositories_join_the_matching_runner_group_scopes() -> None: + all_scope_id = scope_node_id( + ORG_NODE_ID, RUNNER_GROUP_SCOPE_TYPE, ALL_REPOSITORIES_SCOPE + ) + private_scope_id = scope_node_id( + ORG_NODE_ID, + RUNNER_GROUP_SCOPE_TYPE, + PRIVATE_OR_INTERNAL_REPOSITORIES_SCOPE, + ) + eligible_targets = { + edge.end.value + for edge in _repository(1, "private").edges + if edge.kind == ek.IS_ELIGIBLE_FOR + } + + assert eligible_targets == {all_scope_id, private_scope_id} + + +def test_repository_does_not_join_empty_target_scopes() -> None: + repository = _repository(1, "private") + repository._lookup.canonical_scope_has_targets.return_value = False + + boundary_kinds = {ek.IS_ELIGIBLE_FOR, ek.HAS_SECRET, ek.HAS_VARIABLE} + + assert not [edge for edge in repository.edges if edge.kind in boundary_kinds] + + +def test_arbitrary_selections_do_not_use_repository_scope() -> None: + assert ek.CAN_ACCESS not in {edge.kind for edge in _installation(1, "selected").edges} + pat_access_targets = { + edge.end.value for edge in _pat("subset").edges if edge.kind == ek.CAN_ACCESS + } + + assert SCOPE_NODE_ID not in pat_access_targets + + +def test_all_pat_request_uses_pending_access_edge_without_granting_access() -> None: + all_request = _pat_request("all") + access_edges = [ + edge + for edge in all_request.edges + if edge.kind in {ek.REQUESTS_ACCESS_TO, ek.CAN_ACCESS} + ] + + assert [(edge.kind, edge.end.value) for edge in access_edges] == [ + (ek.REQUESTS_ACCESS_TO, SCOPE_NODE_ID) + ] + assert access_edges[0].properties.traversable is False + assert ek.REQUESTS_ACCESS_TO not in { + edge.kind for edge in _pat_request("subset").edges + } + + +def test_credential_repository_queries_support_direct_and_scoped_access() -> None: + expected_pattern = "[:GH_CanAccess|GH_ScopedTo*1..2]->(:GH_Repository)" + + assert expected_pattern in _installation(1).as_node.properties.query_repositories + assert expected_pattern in _pat("all").as_node.properties.query_repositories + + +def test_schema_registers_repository_scope_and_compatible_panel_queries() -> None: + schema_path = Path(__file__).resolve().parents[1] / "extension" / "schema.json" + schema = json.loads(schema_path.read_text()) + node_names = {node["name"] for node in schema["node_kinds"]} + relationship_kinds = { + relationship["name"]: relationship + for relationship in schema["relationship_kinds"] + } + + assert nk.SCOPE in node_names + assert relationship_kinds[ek.SCOPED_TO]["is_traversable"] is True + assert relationship_kinds[ek.REQUESTS_ACCESS_TO]["is_traversable"] is False + + schema_text = schema_path.read_text() + assert "GH_CanAccess|GH_ScopedTo*1..2" in schema_text + assert "GH_InstalledAs|GH_CanAccess|GH_ScopedTo*2..3" in schema_text + assert "GH_RequestsAccessTo|GH_ScopedTo*1..2" in schema_text + assert "GH_IsEligibleFor|GH_ScopedTo*1..2" in schema_text + assert "GH_HasSecret|GH_ScopedTo*1..2" in schema_text + assert "GH_HasVariable|GH_ScopedTo*1..2" in schema_text + + +def test_secret_entity_panel_resolves_direct_and_scoped_relationships() -> None: + schema_path = Path(__file__).resolve().parents[1] / "extension" / "schema.json" + schema = json.loads(schema_path.read_text()) + secret = next(node for node in schema["node_kinds"] if node["name"] == nk.SECRET) + inbound_query = secret["info"]["inbound_traversable_relationships"]["markdown"][ + "content" + ] + + assert ( + "GH_HasSecret|GH_CanReadSecret|GH_CanAccessSecret|GH_ScopedTo*1..2" + in inbound_query + ) + + +def test_secret_saved_searches_resolve_direct_and_scoped_secrets() -> None: + saved_searches = Path(__file__).resolve().parents[1] / "extension" / "saved_searches" + + for name in ( + "repos-vulnerable-to-workflow-secret-exfil.json", + "secrets-reachable-by-user.json", + ): + query = json.loads((saved_searches / name).read_text())["query"] + assert "GH_HasSecret|GH_ScopedTo*1..2" in query diff --git a/tests/test_runner_models.py b/tests/test_runner_models.py index 496373e..b0d7bdf 100644 --- a/tests/test_runner_models.py +++ b/tests/test_runner_models.py @@ -16,6 +16,12 @@ OrgRunnerGroupMembership, RepoRunner, ) +from openhound_github.models.scope import ( + ALL_REPOSITORIES_SCOPE, + PRIVATE_OR_INTERNAL_REPOSITORIES_SCOPE, + RUNNER_GROUP_SCOPE_TYPE, + scope_node_id, +) def _workflow_runner_lookup() -> GithubLookup: @@ -200,6 +206,10 @@ def test_org_runner_group_keeps_generic_runner_group_label() -> None: assert node.kinds == [nk.ORG_RUNNER_GROUP, nk.RUNNER_GROUP, "GitHub"] assert node.properties.scope == "organization" assert node.id == "ORG_1_runner_group_1" + assert ( + "[:GH_IsEligibleFor|GH_ScopedTo*1..2]->(:GH_OrgRunnerGroup" + in node.properties.query_repositories + ) def test_inherited_org_runner_group_emits_inherited_from_edge() -> None: @@ -397,10 +407,13 @@ def test_org_runner_group_access_emits_repository_access_to_inherited_group() -> edges = list(access.edges) - assert [edge.kind for edge in edges] == [ek.IS_ELIGIBLE_FOR] - assert edges[0].start.value == "REPO_1" + assert [edge.kind for edge in edges] == [ek.SCOPED_TO] + assert edges[0].start.value == scope_node_id( + "ORG_1", RUNNER_GROUP_SCOPE_TYPE, ALL_REPOSITORIES_SCOPE + ) assert edges[0].end.value == "ORG_1_runner_group_1" - assert edges[0].properties.traversable is False + assert edges[0].properties.traversable is True + lookup.repository_node_ids_for_org.assert_not_called() def test_org_runner_group_access_all_visibility_excludes_public_repositories_when_disabled() -> None: @@ -420,10 +433,12 @@ def test_org_runner_group_access_all_visibility_excludes_public_repositories_whe edges = list(access.edges) - assert [edge.kind for edge in edges] == [ek.IS_ELIGIBLE_FOR] - assert edges[0].start.value == "REPO_PRIVATE" + assert [edge.kind for edge in edges] == [ek.SCOPED_TO] + assert edges[0].start.value == scope_node_id( + "ORG_1", RUNNER_GROUP_SCOPE_TYPE, PRIVATE_OR_INTERNAL_REPOSITORIES_SCOPE + ) assert edges[0].end.value == "ORG_1_runner_group_1" - lookup.private_repository_node_ids_for_org.assert_called_with("acme") + lookup.private_repository_node_ids_for_org.assert_not_called() lookup.repository_node_ids_for_org.assert_not_called() @@ -547,7 +562,11 @@ def test_org_runner_group_access_all_visibility_emits_traversable_create_access_ lookup.members_can_create_repository.return_value = (True, False, False, False) access._lookup = lookup - edges = list(access.edges) + edges = [ + edge + for edge in access.edges + if edge.kind == ek.CAN_CREATE_REPOSITORY_WITH_RUNNER_ACCESS + ] assert [edge.kind for edge in edges] == [ ek.CAN_CREATE_REPOSITORY_WITH_RUNNER_ACCESS, @@ -582,7 +601,11 @@ def test_org_runner_group_access_without_public_access_requires_private_or_inter lookup.members_can_create_repository.return_value = (True, True, False, False) access._lookup = lookup - edges = list(access.edges) + edges = [ + edge + for edge in access.edges + if edge.kind == ek.CAN_CREATE_REPOSITORY_WITH_RUNNER_ACCESS + ] assert [edge.kind for edge in edges] == [ ek.CAN_CREATE_REPOSITORY_WITH_RUNNER_ACCESS @@ -612,7 +635,11 @@ def test_org_runner_group_access_private_visibility_requires_private_or_internal lookup.members_can_create_repository.return_value = (False, False, True, False) access._lookup = lookup - edges = list(access.edges) + edges = [ + edge + for edge in access.edges + if edge.kind == ek.CAN_CREATE_REPOSITORY_WITH_RUNNER_ACCESS + ] assert [edge.kind for edge in edges] == [ ek.CAN_CREATE_REPOSITORY_WITH_RUNNER_ACCESS, @@ -641,7 +668,9 @@ def test_org_runner_group_access_selected_visibility_does_not_emit_latent_access lookup.members_can_create_repository.return_value = (True, True, True, True) access._lookup = lookup - assert list(access.edges) == [] + assert ek.CAN_CREATE_REPOSITORY_WITH_RUNNER_ACCESS not in { + edge.kind for edge in access.edges + } def test_org_runner_group_access_does_not_emit_create_access_when_new_repositories_do_not_have_actions_enabled() -> None: @@ -660,7 +689,9 @@ def test_org_runner_group_access_does_not_emit_create_access_when_new_repositori lookup.members_can_create_repository.return_value = (True, True, True, True) access._lookup = lookup - assert list(access.edges) == [] + assert ek.CAN_CREATE_REPOSITORY_WITH_RUNNER_ACCESS not in { + edge.kind for edge in access.edges + } def test_inherited_org_runner_group_create_access_requires_enterprise_workflow_policy_to_be_open() -> None: @@ -683,7 +714,11 @@ def test_inherited_org_runner_group_create_access_requires_enterprise_workflow_p ) access._lookup = lookup - edges = list(access.edges) + edges = [ + edge + for edge in access.edges + if edge.kind == ek.CAN_CREATE_REPOSITORY_WITH_RUNNER_ACCESS + ] assert [edge.kind for edge in edges] == [ ek.CAN_CREATE_REPOSITORY_WITH_RUNNER_ACCESS @@ -714,7 +749,9 @@ def test_inherited_org_runner_group_create_access_is_not_emitted_when_enterprise ) access._lookup = lookup - assert list(access.edges) == [] + assert ek.CAN_CREATE_REPOSITORY_WITH_RUNNER_ACCESS not in { + edge.kind for edge in access.edges + } lookup.actions_enabled_repositories_for_org.assert_not_called() diff --git a/tests/test_variable_models.py b/tests/test_variable_models.py index 2937f62..3e88758 100644 --- a/tests/test_variable_models.py +++ b/tests/test_variable_models.py @@ -1,7 +1,15 @@ from datetime import datetime +from unittest.mock import MagicMock from openhound_github.kinds import edges as ek from openhound_github.models.repository_variable import RepoVariable +from openhound_github.models.org_variable import OrgVariable, SelectedOrgVariable +from openhound_github.models.scope import ( + ALL_REPOSITORIES_SCOPE, + ORGANIZATION_VARIABLE_SCOPE_TYPE, + PRIVATE_OR_INTERNAL_REPOSITORIES_SCOPE, + scope_node_id, +) def test_repository_variable_access_edge_is_traversable() -> None: @@ -17,3 +25,52 @@ def test_repository_variable_access_edge_is_traversable() -> None: edge = next(edge for edge in variable.edges if edge.kind == ek.HAS_VARIABLE) assert edge.properties.traversable is True + + +def test_canonical_org_variable_visibility_uses_target_scope() -> None: + all_variable = OrgVariable( + name="DEPLOY_TARGET", + value="prod", + created_at=datetime.now(), + visibility="all", + org_login="acme", + ) + private_variable = OrgVariable( + name="PRIVATE_TARGET", + value="private", + created_at=datetime.now(), + visibility="private", + org_login="acme", + ) + lookup = MagicMock() + lookup.org_id_for_login.return_value = "O_1" + all_variable._lookup = lookup + private_variable._lookup = lookup + + all_edge = next(iter(all_variable._all_repo_edges)) + private_edge = next(iter(private_variable._private_repo_edges)) + + assert all_edge.kind == ek.SCOPED_TO + assert all_edge.start.value == scope_node_id( + "O_1", ORGANIZATION_VARIABLE_SCOPE_TYPE, ALL_REPOSITORIES_SCOPE + ) + assert private_edge.start.value == scope_node_id( + "O_1", + ORGANIZATION_VARIABLE_SCOPE_TYPE, + PRIVATE_OR_INTERNAL_REPOSITORIES_SCOPE, + ) + assert all_edge.properties.traversable is True + lookup.repository_node_ids_for_org.assert_not_called() + lookup.private_repository_node_ids_for_org.assert_not_called() + + +def test_selected_org_variable_remains_direct() -> None: + selected = SelectedOrgVariable( + name="DEPLOY_TARGET", repository_node_id="R_1", org_login="acme" + ) + selected._lookup = MagicMock(org_id_for_login=lambda _login: "O_1") + + edges = list(selected.edges) + + assert [edge.kind for edge in edges] == [ek.HAS_VARIABLE] + assert edges[0].start.value == "R_1"