Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions audit-access/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Binary
audit-access

# Test coverage
*.out
*.test

# Build artifacts
dist/
build/

# IDE
.vscode/
.idea/
*.swp
*.swo
*~
.claude
173 changes: 173 additions & 0 deletions audit-access/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
# GitHub Organization Access Audit Tool

A CLI tool to audit GitHub organization membership and repository access permissions. This tool helps with governance and security auditing by providing visibility into who has elevated access across all repositories in an organization.

## Purpose

This tool is designed to help the cert-manager project (and other organizations) maintain proper governance by regularly auditing access levels. It lists all users who have triage, write, maintain, or admin permissions across repositories, excluding read-only access.

## Prerequisites

- Go 1.21 or later
- GitHub Personal Access Token with appropriate scopes

## GitHub Token Setup

You need a GitHub Personal Access Token with the following scopes:

- `repo` - Full control of private repositories (required to read collaborators)
- `read:org` - Read org and team membership, read org projects

The token can be created in: https://github.com/settings/personal-access-tokens

It must be "owned" by the org you're auditing, and you must set the token as an environment variable:

```bash
export GITHUB_TOKEN=ghp_your_token_here
```

## Usage

### Basic Usage

Audit all repositories in an organization:

```bash
export GITHUB_TOKEN=ghp_your_token_here
./audit-access org cert-manager
```

### Command-line Options

```bash
./audit-access org [organization-name] [flags]

Flags:
-f, --format string Output format: table, json, or csv (default "table")
-p, --permission string Filter by permission level: triage, write, maintain, or admin
-r, --repo string Audit specific repository instead of all
-a, --include-archived Include archived repositories
-s, --sort-by string Sort output by 'user' or 'repo' (default "user")
-h, --help Help for org
--version Show version information
```

### Examples

**Audit with table output (default):**
```bash
./audit-access org cert-manager
```

**Export to JSON:**
```bash
./audit-access org cert-manager --format json > access-report.json
```

**Export to CSV:**
```bash
./audit-access org cert-manager --format csv > access-report.csv
```

**Filter by permission level:**
```bash
# Show only users with admin access
./audit-access org cert-manager --permission admin

# Show only users with write access
./audit-access org cert-manager --permission write
```

**Audit a specific repository:**
```bash
./audit-access org cert-manager --repo trust-manager
```

**Include archived repositories:**
```bash
./audit-access org cert-manager --include-archived
```

**Sort by repository instead of user:**
```bash
# Group results by repository (default is by user)
./audit-access org cert-manager --sort-by repo
```

## Output Formats

### Table (default)

```
+----------+------------------+------------+
| USERNAME | REPOSITORY | PERMISSION |
+----------+------------------+------------+
| alice | cert-manager | admin |
| alice | trust-manager | write |
| bob | cert-manager | maintain |
| charlie | approver-policy | triage |
+----------+------------------+------------+
```

### JSON

**Sorted by user (default):**
```json
[
{
"username": "alice",
"repositories": [
{"name": "cert-manager", "permission": "admin"},
{"name": "trust-manager", "permission": "write"}
]
},
{
"username": "bob",
"repositories": [
{"name": "cert-manager", "permission": "maintain"}
]
}
]
```

**Sorted by repository (`--sort-by repo`):**
```json
[
{
"repository": "cert-manager",
"users": [
{"username": "alice", "permission": "admin"},
{"username": "bob", "permission": "maintain"}
]
},
{
"repository": "trust-manager",
"users": [
{"username": "alice", "permission": "write"}
]
}
]
```

### CSV

```csv
Username,Repository,Permission
alice,cert-manager,admin
alice,trust-manager,write
bob,cert-manager,maintain
charlie,approver-policy,triage
```

## Troubleshooting

### Rate Limiting

GitHub API has rate limits (5,000 requests/hour for authenticated requests). The tool displays your current rate limit status when it starts. If you hit the rate limit, wait until the reset time shown in the error message.

### Permission Denied

If you see a 403 or 404 error:

1. Verify your token has the required scopes (`repo` and `read:org`)
2. Verify you have sufficient access to the organization
104 changes: 104 additions & 0 deletions audit-access/cmd/audit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package cmd

import (
"context"
"fmt"
"log/slog"
"time"

ghclient "github.com/cert-manager/community/security/audit-access/pkg/github"
"github.com/cert-manager/community/security/audit-access/pkg/report"
"github.com/spf13/cobra"
)

var (
format string
permissionFilter string
repoFilter string
includeArchived bool
sortBy string
)

func Execute(version string, log *slog.Logger) error {
rootCmd := &cobra.Command{
Use: "audit-access",
Short: "Audit GitHub organization access permissions",
Long: `A CLI tool to audit GitHub organization membership and repository access.
Lists all users with triage, write, maintain, or admin permissions across repositories.`,
Version: version,
}

auditCmd := &cobra.Command{
Use: "org [organization-name]",
Short: "Audit access permissions for a GitHub organization",
Long: `Audits all repositories in a GitHub organization and lists users with elevated permissions.

Requires GITHUB_TOKEN environment variable with 'repo' and 'read:org' scopes.`,
Args: cobra.ExactArgs(1),
RunE: runAudit(log),
}

auditCmd.Flags().StringVarP(&format, "format", "f", "table", "Output format (table, json, csv)")
auditCmd.Flags().StringVarP(&permissionFilter, "permission", "p", "", "Filter by permission level (triage, write, maintain, admin)")
auditCmd.Flags().StringVarP(&repoFilter, "repo", "r", "", "Audit specific repository instead of all")
auditCmd.Flags().BoolVarP(&includeArchived, "include-archived", "a", false, "Include archived repositories")
auditCmd.Flags().StringVarP(&sortBy, "sort-by", "s", "user", "Sort output by 'user' or 'repo'")

rootCmd.AddCommand(auditCmd)
return rootCmd.Execute()
}

func runAudit(logger *slog.Logger) func(cmd *cobra.Command, args []string) error {
return func(cmd *cobra.Command, args []string) error {
org := args[0]

// Validate permission filter if provided
if permissionFilter != "" {
validPerms := map[string]bool{
"triage": true,
"write": true,
"maintain": true,
"admin": true,
}
if !validPerms[permissionFilter] {
return fmt.Errorf("invalid permission filter: %s (valid: triage, write, maintain, admin)", permissionFilter)
}
}

// Validate sort-by option
if sortBy != "user" && sortBy != "repo" {
return fmt.Errorf("invalid sort-by option: %s (valid: user, repo)", sortBy)
}

ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Minute)
defer cancel()

// Create GitHub client
logger.Info("initializing GitHub client")
client, err := ghclient.NewClient(ctx, logger)
if err != nil {
return err
}

// Validate token
if err := client.ValidateToken(ctx); err != nil {
return err
}

// Build access map
userAccesses, err := client.BuildAccessMap(ctx, org, repoFilter, permissionFilter, includeArchived)
if err != nil {
return err
}

if len(userAccesses) == 0 {
logger.Info("no users found with elevated permissions")
return nil
}

logger.Info("found users with elevated permissions", "count", len(userAccesses))

// Format and output results
return report.Format(userAccesses, format, sortBy)
}
}
15 changes: 15 additions & 0 deletions audit-access/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
module github.com/cert-manager/community/security/audit-access

go 1.26.1

require (
github.com/google/go-github/v84 v84.0.0
github.com/spf13/cobra v1.10.2
golang.org/x/oauth2 v0.36.0
)

require (
github.com/google/go-querystring v1.2.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
)
19 changes: 19 additions & 0 deletions audit-access/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-github/v84 v84.0.0 h1:I/0Xn5IuChMe8TdmI2bbim5nyhaRFJ7DEdzmD2w+yVA=
github.com/google/go-github/v84 v84.0.0/go.mod h1:WwYL1z1ajRdlaPszjVu/47x1L0PXukJBn73xsiYrRRQ=
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
18 changes: 18 additions & 0 deletions audit-access/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package main

import (
"log/slog"
"os"

"github.com/cert-manager/community/security/audit-access/cmd"
)

var version = "dev"

func main() {
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
if err := cmd.Execute(version, logger); err != nil {
logger.Error("execution failed", "error", err)
os.Exit(1)
}
}
Loading