From 0c752b1b7afaaa85ae3d7b3735c54b96ee649728 Mon Sep 17 00:00:00 2001 From: RedBoardDev Date: Sat, 16 May 2026 19:38:54 +0200 Subject: [PATCH 01/39] chore: checkpoint github app auth refactor Stages the in-progress GitHub App login flow (installations listing, JWT signing, interactive/non-interactive wizards) as the starting point before applying the audit fix series. --- internal/auth/credentials.go | 1 + internal/auth/installations.go | 166 +++++++++++++++++++++++ internal/auth/installations_test.go | 196 ++++++++++++++++++++++++++++ internal/auth/jwt.go | 47 +++++++ internal/auth/jwt_test.go | 118 +++++++++++++++++ internal/cli/auth.go | 5 +- internal/cli/login.go | 142 +++++++++++--------- internal/cli/login_app.go | 138 ++++++++++++++++++++ internal/cli/login_wizard.go | 114 ++++++++-------- 9 files changed, 804 insertions(+), 123 deletions(-) create mode 100644 internal/auth/installations.go create mode 100644 internal/auth/installations_test.go create mode 100644 internal/auth/jwt.go create mode 100644 internal/auth/jwt_test.go create mode 100644 internal/cli/login_app.go diff --git a/internal/auth/credentials.go b/internal/auth/credentials.go index fc5997f..13a0bef 100644 --- a/internal/auth/credentials.go +++ b/internal/auth/credentials.go @@ -14,6 +14,7 @@ type GitHubAppCreds struct { ClientID string `json:"client_id"` InstallationID int64 `json:"installation_id"` PrivateKeyPath string `json:"private_key_path"` + Account string `json:"account,omitempty"` } type LoadOpts struct { diff --git a/internal/auth/installations.go b/internal/auth/installations.go new file mode 100644 index 0000000..0877872 --- /dev/null +++ b/internal/auth/installations.go @@ -0,0 +1,166 @@ +package auth + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +type Installation struct { + ID int64 + Account string + AccountType string + TargetType string + HTMLURL string +} + +type InstallationToken struct { + Token string + ExpiresAt string + Permissions map[string]string +} + +const ( + permAdministration = "administration" + permOrgRunners = "organization_self_hosted_runners" +) + +func ListAppInstallations(ctx context.Context, apiBaseURL, appJWT string) ([]Installation, error) { + endpoint := strings.TrimRight(apiBaseURL, "/") + "/app/installations?per_page=100" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody) + if err != nil { + return nil, fmt.Errorf("create installations request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+appJWT) + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("list installations: %w", err) + } + defer drainBody(resp) + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read installations response: %w", err) + } + if resp.StatusCode == http.StatusUnauthorized { + return nil, fmt.Errorf("list installations: GitHub rejected the JWT (check Client ID and private key belong to the same App)") + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("list installations: HTTP %d: %s", resp.StatusCode, truncateBody(string(body))) + } + + var raw []struct { + ID int64 `json:"id"` + Account struct { + Login string `json:"login"` + Type string `json:"type"` + } `json:"account"` + TargetType string `json:"target_type"` + HTMLURL string `json:"html_url"` + } + if err := json.Unmarshal(body, &raw); err != nil { + return nil, fmt.Errorf("decode installations: %w", err) + } + + out := make([]Installation, 0, len(raw)) + for _, r := range raw { + out = append(out, Installation{ + ID: r.ID, + Account: r.Account.Login, + AccountType: r.Account.Type, + TargetType: r.TargetType, + HTMLURL: r.HTMLURL, + }) + } + return out, nil +} + +func IssueInstallationToken(ctx context.Context, apiBaseURL, appJWT string, installationID int64) (*InstallationToken, error) { + endpoint := fmt.Sprintf("%s/app/installations/%d/access_tokens", strings.TrimRight(apiBaseURL, "/"), installationID) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, http.NoBody) + if err != nil { + return nil, fmt.Errorf("create access_tokens request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+appJWT) + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("issue installation token: %w", err) + } + defer drainBody(resp) + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read access_tokens response: %w", err) + } + if resp.StatusCode != http.StatusCreated { + return nil, fmt.Errorf("issue installation token: HTTP %d: %s", resp.StatusCode, truncateBody(string(body))) + } + + var raw struct { + Token string `json:"token"` + ExpiresAt string `json:"expires_at"` + Permissions map[string]string `json:"permissions"` + } + if err := json.Unmarshal(body, &raw); err != nil { + return nil, fmt.Errorf("decode installation token: %w", err) + } + return &InstallationToken{ + Token: raw.Token, + ExpiresAt: raw.ExpiresAt, + Permissions: raw.Permissions, + }, nil +} + +func CheckRunnerPermissions(perms map[string]string) error { + if hasWrite(perms, permAdministration) || hasWrite(perms, permOrgRunners) { + return nil + } + return fmt.Errorf( + "GitHub App lacks runner permissions: enable %q OR %q with 'write' access in the App settings", + permAdministration, permOrgRunners, + ) +} + +func APIBaseURL(githubURL string) (string, error) { + if githubURL == "" { + return "https://api.github.com", nil + } + u, err := url.Parse(githubURL) + if err != nil { + return "", fmt.Errorf("parse github URL %q: %w", githubURL, err) + } + host := strings.ToLower(u.Host) + if host == "" { + return "", fmt.Errorf("github URL %q has no host", githubURL) + } + if host == "github.com" || host == "api.github.com" { + return "https://api.github.com", nil + } + return fmt.Sprintf("%s://%s/api/v3", u.Scheme, u.Host), nil +} + +func hasWrite(perms map[string]string, key string) bool { + v, ok := perms[key] + return ok && (v == "write" || v == "admin") +} + +func drainBody(resp *http.Response) { + _, _ = io.Copy(io.Discard, resp.Body) + resp.Body.Close() +} + +func truncateBody(s string) string { + const maxBodyLen = 500 + if len(s) > maxBodyLen { + return s[:maxBodyLen] + "..." + } + return s +} diff --git a/internal/auth/installations_test.go b/internal/auth/installations_test.go new file mode 100644 index 0000000..a521f2f --- /dev/null +++ b/internal/auth/installations_test.go @@ -0,0 +1,196 @@ +package auth + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestListAppInstallations(t *testing.T) { + t.Run("returns installations on 200", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/app/installations" { + t.Errorf("path = %q", r.URL.Path) + } + if got := r.Header.Get("Authorization"); !strings.HasPrefix(got, "Bearer ") { + t.Errorf("Authorization = %q, want Bearer prefix", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[ + {"id": 100, "account": {"login": "akord-securite", "type": "Organization"}, "target_type": "Organization", "html_url": "https://github.com/akord-securite"}, + {"id": 200, "account": {"login": "personal", "type": "User"}, "target_type": "User", "html_url": "https://github.com/personal"} + ]`)) + })) + defer srv.Close() + + got, err := ListAppInstallations(context.Background(), srv.URL, "fake-jwt") + if err != nil { + t.Fatalf("ListAppInstallations: %v", err) + } + if len(got) != 2 { + t.Fatalf("len = %d, want 2", len(got)) + } + if got[0].ID != 100 || got[0].Account != "akord-securite" || got[0].AccountType != "Organization" { + t.Errorf("got[0] = %+v", got[0]) + } + if got[1].ID != 200 || got[1].Account != "personal" { + t.Errorf("got[1] = %+v", got[1]) + } + }) + + t.Run("401 returns user-friendly error", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message":"bad credentials"}`)) + })) + defer srv.Close() + + _, err := ListAppInstallations(context.Background(), srv.URL, "wrong-jwt") + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "rejected the JWT") { + t.Errorf("error = %q, want contain 'rejected the JWT'", err) + } + }) + + t.Run("500 returns wrapped HTTP error", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("oops")) + })) + defer srv.Close() + + _, err := ListAppInstallations(context.Background(), srv.URL, "x") + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "HTTP 500") { + t.Errorf("error = %q, want contain 'HTTP 500'", err) + } + }) + + t.Run("empty list returns empty slice", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`[]`)) + })) + defer srv.Close() + + got, err := ListAppInstallations(context.Background(), srv.URL, "x") + if err != nil { + t.Fatalf("ListAppInstallations: %v", err) + } + if len(got) != 0 { + t.Errorf("len = %d, want 0", len(got)) + } + }) +} + +func TestIssueInstallationToken(t *testing.T) { + t.Run("returns token and permissions on 201", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/app/installations/12345/access_tokens" { + t.Errorf("path = %q", r.URL.Path) + } + if r.Method != http.MethodPost { + t.Errorf("method = %q, want POST", r.Method) + } + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{ + "token": "ghs_abc", + "expires_at": "2026-05-16T12:34:56Z", + "permissions": {"administration": "write", "metadata": "read"} + }`)) + })) + defer srv.Close() + + got, err := IssueInstallationToken(context.Background(), srv.URL, "jwt", 12345) + if err != nil { + t.Fatalf("IssueInstallationToken: %v", err) + } + if got.Token != "ghs_abc" { + t.Errorf("token = %q", got.Token) + } + if got.Permissions["administration"] != "write" { + t.Errorf("permissions = %v", got.Permissions) + } + }) + + t.Run("404 returns wrapped error", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"not found"}`)) + })) + defer srv.Close() + + _, err := IssueInstallationToken(context.Background(), srv.URL, "jwt", 999) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "HTTP 404") { + t.Errorf("error = %q", err) + } + }) +} + +func TestCheckRunnerPermissions(t *testing.T) { + tests := []struct { + name string + perms map[string]string + wantErr bool + }{ + {"administration:write passes", map[string]string{"administration": "write"}, false}, + {"administration:admin passes", map[string]string{"administration": "admin"}, false}, + {"org runners write passes", map[string]string{"organization_self_hosted_runners": "write"}, false}, + {"administration:read fails", map[string]string{"administration": "read"}, true}, + {"only metadata fails", map[string]string{"metadata": "read"}, true}, + {"empty fails", map[string]string{}, true}, + {"nil fails", nil, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := CheckRunnerPermissions(tc.perms) + if tc.wantErr && err == nil { + t.Error("expected error, got nil") + } + if !tc.wantErr && err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + } +} + +func TestAPIBaseURL(t *testing.T) { + tests := []struct { + name string + githubURL string + want string + wantErr bool + }{ + {"empty defaults to github.com API", "", "https://api.github.com", false}, + {"github.com org", "https://github.com/akord-securite", "https://api.github.com", false}, + {"github.com repo", "https://github.com/akord-securite/repo", "https://api.github.com", false}, + {"GHES org", "https://ghe.corp.example/myorg", "https://ghe.corp.example/api/v3", false}, + {"invalid URL", "://broken", "", true}, + {"missing host", "noscheme", "", true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := APIBaseURL(tc.githubURL) + if tc.wantErr { + if err == nil { + t.Errorf("expected error for %q, got nil (result=%q)", tc.githubURL, got) + } + return + } + if err != nil { + t.Fatalf("APIBaseURL(%q): %v", tc.githubURL, err) + } + if got != tc.want { + t.Errorf("APIBaseURL(%q) = %q, want %q", tc.githubURL, got, tc.want) + } + }) + } +} diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go new file mode 100644 index 0000000..62552a6 --- /dev/null +++ b/internal/auth/jwt.go @@ -0,0 +1,47 @@ +package auth + +import ( + "fmt" + "os" + "time" + + "github.com/golang-jwt/jwt/v4" +) + +func SignAppJWT(clientID string, pemBytes []byte) (string, error) { + key, err := jwt.ParseRSAPrivateKeyFromPEM(pemBytes) + if err != nil { + return "", fmt.Errorf("parse RSA private key: %w", err) + } + + now := time.Now().Add(-30 * time.Second) + claims := jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(9 * time.Minute)), + Issuer: clientID, + } + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + signed, err := token.SignedString(key) + if err != nil { + return "", fmt.Errorf("sign app JWT: %w", err) + } + return signed, nil +} + +func LoadPrivateKey(path string) ([]byte, error) { + info, err := os.Stat(path) + if err != nil { + return nil, fmt.Errorf("stat private key %s: %w", path, err) + } + if mode := info.Mode().Perm(); mode&0o077 != 0 { + return nil, fmt.Errorf("private key %s has insecure permissions %#o (run: chmod 600 %s)", path, mode, path) + } + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read private key %s: %w", path, err) + } + if _, err := jwt.ParseRSAPrivateKeyFromPEM(data); err != nil { + return nil, fmt.Errorf("parse private key %s: %w", path, err) + } + return data, nil +} diff --git a/internal/auth/jwt_test.go b/internal/auth/jwt_test.go new file mode 100644 index 0000000..9dcfe94 --- /dev/null +++ b/internal/auth/jwt_test.go @@ -0,0 +1,118 @@ +package auth + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/golang-jwt/jwt/v4" +) + +func genTestKey(t *testing.T) (privPEM []byte) { + t.Helper() + priv, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("rsa.GenerateKey: %v", err) + } + return pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(priv), + }) +} + +func writeTempKey(t *testing.T, pemBytes []byte, mode os.FileMode) string { + t.Helper() + path := filepath.Join(t.TempDir(), "test.pem") + if err := os.WriteFile(path, pemBytes, mode); err != nil { + t.Fatalf("write key: %v", err) + } + return path +} + +func TestSignAppJWT(t *testing.T) { + t.Run("valid PEM produces parseable JWT", func(t *testing.T) { + pemBytes := genTestKey(t) + + signed, err := SignAppJWT("Iv23liClient", pemBytes) + if err != nil { + t.Fatalf("SignAppJWT: %v", err) + } + + parsed, _, err := jwt.NewParser().ParseUnverified(signed, &jwt.RegisteredClaims{}) + if err != nil { + t.Fatalf("parse signed JWT: %v", err) + } + claims, ok := parsed.Claims.(*jwt.RegisteredClaims) + if !ok { + t.Fatalf("claims wrong type") + } + if claims.Issuer != "Iv23liClient" { + t.Errorf("Issuer = %q, want %q", claims.Issuer, "Iv23liClient") + } + if parsed.Method.Alg() != "RS256" { + t.Errorf("alg = %q, want RS256", parsed.Method.Alg()) + } + }) + + t.Run("garbage PEM returns parse error", func(t *testing.T) { + _, err := SignAppJWT("any", []byte("not a pem")) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "parse RSA private key") { + t.Errorf("error = %q, want contain 'parse RSA private key'", err) + } + }) +} + +func TestLoadPrivateKey(t *testing.T) { + pemBytes := genTestKey(t) + + t.Run("valid key with 0600 returns bytes", func(t *testing.T) { + path := writeTempKey(t, pemBytes, 0o600) + got, err := LoadPrivateKey(path) + if err != nil { + t.Fatalf("LoadPrivateKey: %v", err) + } + if len(got) == 0 { + t.Error("got empty bytes") + } + }) + + t.Run("insecure permissions are rejected", func(t *testing.T) { + path := writeTempKey(t, pemBytes, 0o644) + _, err := LoadPrivateKey(path) + if err == nil { + t.Fatal("expected error for 0644 perms, got nil") + } + if !strings.Contains(err.Error(), "insecure permissions") { + t.Errorf("error = %q, want contain 'insecure permissions'", err) + } + }) + + t.Run("non-existent file returns stat error", func(t *testing.T) { + _, err := LoadPrivateKey("/nope/missing.pem") + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "stat private key") { + t.Errorf("error = %q, want contain 'stat private key'", err) + } + }) + + t.Run("garbage content is rejected", func(t *testing.T) { + path := writeTempKey(t, []byte("not a pem at all"), 0o600) + _, err := LoadPrivateKey(path) + if err == nil { + t.Fatal("expected parse error, got nil") + } + if !strings.Contains(err.Error(), "parse private key") { + t.Errorf("error = %q, want contain 'parse private key'", err) + } + }) +} diff --git a/internal/cli/auth.go b/internal/cli/auth.go index a41e1ff..00ea3f5 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -32,13 +32,16 @@ func newAuthStatusCmd() *cobra.Command { fmt.Printf("Method: %s\n", creds.Method) fmt.Printf("Source: %s\n", source) if creds.GitHubURL != "" { - fmt.Printf("GitHub: %s\n", creds.GitHubURL) + fmt.Printf("URL: %s\n", creds.GitHubURL) } if creds.Method == "pat" && creds.PAT != "" { fmt.Printf("Token: %s\n", auth.MaskedPAT(creds.PAT)) } if creds.GitHubApp != nil { fmt.Printf("Client: %s\n", creds.GitHubApp.ClientID) + if creds.GitHubApp.Account != "" { + fmt.Printf("Account: @%s\n", creds.GitHubApp.Account) + } fmt.Printf("Install: %d\n", creds.GitHubApp.InstallationID) fmt.Printf("Key: %s\n", creds.GitHubApp.PrivateKeyPath) } diff --git a/internal/cli/login.go b/internal/cli/login.go index 7de8644..403959f 100644 --- a/internal/cli/login.go +++ b/internal/cli/login.go @@ -4,7 +4,6 @@ import ( "bufio" "fmt" "os" - "strings" "github.com/RedBoardDev/gh-runners-tool/v2/internal/auth" "github.com/spf13/cobra" @@ -14,16 +13,16 @@ func newLoginCmd() *cobra.Command { cmd := &cobra.Command{ Use: "login", Short: "Authenticate with GitHub", - Long: "Interactive wizard to configure GitHub authentication. Supports PAT and GitHub App.", + Long: "Interactive wizard to configure GitHub authentication. GitHub App (recommended) or PAT.", RunE: runLogin, } - cmd.Flags().String("method", "", "auth method: pat or app") - cmd.Flags().String("url", "", "GitHub URL (org, repo, or enterprise)") + cmd.Flags().String("method", "", "auth method: app or pat (interactive if empty)") + cmd.Flags().String("url", "", "GitHub URL for PAT mode (org or repo)") + cmd.Flags().String("host", "", "GitHub host URL for App mode (default https://github.com)") cmd.Flags().String("client-id", "", "GitHub App client ID") - cmd.Flags().Int64("installation-id", 0, "GitHub App installation ID") + cmd.Flags().Int64("installation-id", 0, "GitHub App installation ID (auto-detected if only one)") cmd.Flags().String("private-key", "", "path to GitHub App private key (.pem)") - return cmd } @@ -32,68 +31,79 @@ func runLogin(cmd *cobra.Command, _ []string) error { if err != nil { return fmt.Errorf("get method flag: %w", err) } - if method == "" { - reader := bufio.NewReader(os.Stdin) - return interactiveLogin(cmd, reader) + return interactiveLogin(cmd, bufio.NewReader(os.Stdin)) } - return nonInteractiveLogin(cmd, method) } func nonInteractiveLogin(cmd *cobra.Command, method string) error { + switch method { + case "pat": + return nonInteractivePAT(cmd) + case "app", "github_app": + return nonInteractiveApp(cmd) + default: + return fmt.Errorf("unknown method %q (expected 'app' or 'pat')", method) + } +} + +func nonInteractivePAT(cmd *cobra.Command) error { + if tokenFlag == "" { + return fmt.Errorf("--token is required for PAT method") + } url, err := cmd.Flags().GetString("url") if err != nil { return fmt.Errorf("get url flag: %w", err) } + if url == "" { + return fmt.Errorf("--url is required for PAT method") + } + creds := &auth.Credentials{ + Method: "pat", + GitHubURL: url, + PAT: tokenFlag, + } + return validateAndSave(cmd, creds) +} - var creds *auth.Credentials - - switch method { - case "pat": - if tokenFlag == "" { - return fmt.Errorf("--token is required for PAT authentication") - } - if url == "" { - return fmt.Errorf("--url is required") - } - creds = &auth.Credentials{ - Method: "pat", - GitHubURL: url, - PAT: tokenFlag, - } - - case "app": - clientID, flagErr := cmd.Flags().GetString("client-id") - if flagErr != nil { - return fmt.Errorf("get client-id flag: %w", flagErr) - } - installationID, flagErr := cmd.Flags().GetInt64("installation-id") - if flagErr != nil { - return fmt.Errorf("get installation-id flag: %w", flagErr) - } - privateKey, flagErr := cmd.Flags().GetString("private-key") - if flagErr != nil { - return fmt.Errorf("get private-key flag: %w", flagErr) - } - if clientID == "" || installationID == 0 || privateKey == "" || url == "" { - return fmt.Errorf("--client-id, --installation-id, --private-key, and --url are all required for GitHub App authentication") - } - creds = &auth.Credentials{ - Method: "github_app", - GitHubURL: url, - GitHubApp: &auth.GitHubAppCreds{ - ClientID: clientID, - InstallationID: installationID, - PrivateKeyPath: privateKey, - }, - } - - default: - return fmt.Errorf("unknown method %q: must be 'pat' or 'app'", method) +func nonInteractiveApp(cmd *cobra.Command) error { + clientID, err := cmd.Flags().GetString("client-id") + if err != nil { + return fmt.Errorf("get client-id flag: %w", err) + } + privateKey, err := cmd.Flags().GetString("private-key") + if err != nil { + return fmt.Errorf("get private-key flag: %w", err) + } + host, err := cmd.Flags().GetString("host") + if err != nil { + return fmt.Errorf("get host flag: %w", err) + } + installationID, err := cmd.Flags().GetInt64("installation-id") + if err != nil { + return fmt.Errorf("get installation-id flag: %w", err) } - return validateAndSave(cmd, creds) + in := appLoginInput{ + clientID: clientID, + privateKeyPath: expandHome(privateKey), + hostURL: host, + installationID: installationID, + } + prep, err := prepareAppLogin(cmd.Context(), in) + if err != nil { + return err + } + inst, err := resolveInstallation(prep.installations, in.installationID) + if err != nil { + return err + } + creds, err := finalizeAppLogin(cmd.Context(), prep, inst, in) + if err != nil { + return err + } + return saveCreds(creds) } func validateAndSave(cmd *cobra.Command, creds *auth.Credentials) error { @@ -102,22 +112,32 @@ func validateAndSave(cmd *cobra.Command, creds *auth.Credentials) error { if err != nil { return fmt.Errorf("validation failed: %w", err) } - if !result.Valid { return fmt.Errorf("credentials are not valid") } - if err := auth.Save(creds); err != nil { return fmt.Errorf("save credentials: %w", err) } - if creds.Method == "pat" && result.Username != "" { fmt.Printf("✓ Authenticated as @%s\n", result.Username) } - if creds.Method == "pat" && len(result.Scopes) > 0 { - fmt.Printf("✓ Scopes: %s\n", strings.Join(result.Scopes, ", ")) - } fmt.Printf("✓ Credentials saved to %s\n", auth.FilePath()) + return nil +} +func saveCreds(creds *auth.Credentials) error { + if err := auth.Save(creds); err != nil { + return fmt.Errorf("save credentials: %w", err) + } + fmt.Println() + fmt.Println("✓ Authentication successful") + if creds.GitHubApp != nil { + fmt.Printf(" Method: github_app\n") + fmt.Printf(" Account: @%s\n", creds.GitHubApp.Account) + fmt.Printf(" Installation: %d\n", creds.GitHubApp.InstallationID) + fmt.Printf(" URL: %s\n", creds.GitHubURL) + fmt.Printf(" Key: %s\n", creds.GitHubApp.PrivateKeyPath) + } + fmt.Printf(" Saved to: %s\n", auth.FilePath()) return nil } diff --git a/internal/cli/login_app.go b/internal/cli/login_app.go new file mode 100644 index 0000000..569b067 --- /dev/null +++ b/internal/cli/login_app.go @@ -0,0 +1,138 @@ +package cli + +import ( + "bufio" + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/RedBoardDev/gh-runners-tool/v2/internal/auth" +) + +type appLoginInput struct { + clientID string + privateKeyPath string + hostURL string + installationID int64 +} + +type appLoginPrepared struct { + apiBase string + jwt string + installations []auth.Installation +} + +func prepareAppLogin(ctx context.Context, in appLoginInput) (*appLoginPrepared, error) { + if in.clientID == "" { + return nil, fmt.Errorf("client ID is required") + } + if in.privateKeyPath == "" { + return nil, fmt.Errorf("private key path is required") + } + if in.hostURL == "" { + in.hostURL = "https://github.com" + } + + pemBytes, err := auth.LoadPrivateKey(in.privateKeyPath) + if err != nil { + return nil, err + } + jwtToken, err := auth.SignAppJWT(in.clientID, pemBytes) + if err != nil { + return nil, err + } + apiBase, err := auth.APIBaseURL(in.hostURL) + if err != nil { + return nil, err + } + installations, err := auth.ListAppInstallations(ctx, apiBase, jwtToken) + if err != nil { + return nil, err + } + if len(installations) == 0 { + return nil, fmt.Errorf("the GitHub App has no installations — install it on an org or repo first at https://github.com/settings/installations") + } + return &appLoginPrepared{apiBase: apiBase, jwt: jwtToken, installations: installations}, nil +} + +func resolveInstallation(installations []auth.Installation, requestedID int64) (*auth.Installation, error) { + if requestedID != 0 { + for i := range installations { + if installations[i].ID == requestedID { + return &installations[i], nil + } + } + return nil, fmt.Errorf("installation %d not found (available: %s)", requestedID, formatInstallationList(installations)) + } + if len(installations) == 1 { + return &installations[0], nil + } + return nil, fmt.Errorf("multiple installations found, pass --installation-id (available: %s)", formatInstallationList(installations)) +} + +func selectInstallation(reader *bufio.Reader, installations []auth.Installation) (*auth.Installation, error) { + if len(installations) == 1 { + fmt.Printf(" Using installation @%s (id %d)\n", installations[0].Account, installations[0].ID) + return &installations[0], nil + } + fmt.Println() + fmt.Println("Available installations:") + for i, inst := range installations { + fmt.Printf(" %d) @%s (%s, id %d)\n", i+1, inst.Account, strings.ToLower(inst.AccountType), inst.ID) + } + fmt.Print("? Select installation: ") + raw, err := reader.ReadString('\n') + if err != nil { + return nil, fmt.Errorf("read selection: %w", err) + } + idx := 0 + if _, err := fmt.Sscanf(strings.TrimSpace(raw), "%d", &idx); err != nil || idx < 1 || idx > len(installations) { + return nil, fmt.Errorf("invalid selection %q", strings.TrimSpace(raw)) + } + return &installations[idx-1], nil +} + +func finalizeAppLogin(ctx context.Context, prep *appLoginPrepared, inst *auth.Installation, in appLoginInput) (*auth.Credentials, error) { + token, err := auth.IssueInstallationToken(ctx, prep.apiBase, prep.jwt, inst.ID) + if err != nil { + return nil, err + } + if err := auth.CheckRunnerPermissions(token.Permissions); err != nil { + return nil, err + } + host := strings.TrimRight(in.hostURL, "/") + return &auth.Credentials{ + Method: "github_app", + GitHubURL: fmt.Sprintf("%s/%s", host, inst.Account), + GitHubApp: &auth.GitHubAppCreds{ + ClientID: in.clientID, + InstallationID: inst.ID, + PrivateKeyPath: in.privateKeyPath, + Account: inst.Account, + }, + }, nil +} + +func formatInstallationList(installations []auth.Installation) string { + parts := make([]string, len(installations)) + for i, inst := range installations { + parts[i] = fmt.Sprintf("%d (@%s)", inst.ID, inst.Account) + } + return strings.Join(parts, ", ") +} + +func expandHome(path string) string { + if !strings.HasPrefix(path, "~/") && path != "~" { + return path + } + home, err := os.UserHomeDir() + if err != nil { + return path + } + if path == "~" { + return home + } + return filepath.Join(home, path[2:]) +} diff --git a/internal/cli/login_wizard.go b/internal/cli/login_wizard.go index 27ab4ac..63d6279 100644 --- a/internal/cli/login_wizard.go +++ b/internal/cli/login_wizard.go @@ -3,7 +3,6 @@ package cli import ( "bufio" "fmt" - "strconv" "strings" "github.com/RedBoardDev/gh-runners-tool/v2/internal/auth" @@ -12,107 +11,100 @@ import ( func interactiveLogin(cmd *cobra.Command, reader *bufio.Reader) error { fmt.Println() - fmt.Println("? Authentication method") - fmt.Println(" 1) Personal Access Token (PAT)") - fmt.Println(" 2) GitHub App") - fmt.Print("> ") - - choice, err := reader.ReadString('\n') + fmt.Println("Authentication method:") + fmt.Println(" 1) GitHub App (recommended — short-lived tokens, scoped permissions)") + fmt.Println(" 2) Personal Access Token") + choice, err := readLine(reader, "Choose [1]") if err != nil { - return fmt.Errorf("read choice: %w", err) + return err } - choice = strings.TrimSpace(choice) - switch choice { - case "1": - return interactivePAT(cmd, reader) - case "2": + case "", "1": return interactiveApp(cmd, reader) + case "2": + return interactivePAT(cmd, reader) default: - return fmt.Errorf("invalid choice: %q (expected 1 or 2)", choice) + return fmt.Errorf("invalid choice %q (expected 1 or 2)", choice) } } func interactivePAT(cmd *cobra.Command, reader *bufio.Reader) error { - fmt.Print("? GitHub PAT: ") - token, err := reader.ReadString('\n') + token, err := readLine(reader, "GitHub PAT") if err != nil { - return fmt.Errorf("read token: %w", err) + return err } - token = strings.TrimSpace(token) if token == "" { return fmt.Errorf("token cannot be empty") } - - fmt.Print("? GitHub URL (org or repo): ") - url, err := reader.ReadString('\n') + url, err := readLine(reader, "GitHub URL (org or repo)") if err != nil { - return fmt.Errorf("read url: %w", err) + return err } - url = strings.TrimSpace(url) if url == "" { return fmt.Errorf("URL cannot be empty") } - creds := &auth.Credentials{ Method: "pat", GitHubURL: url, PAT: token, } - return validateAndSave(cmd, creds) } func interactiveApp(cmd *cobra.Command, reader *bufio.Reader) error { - fmt.Print("? GitHub App Client ID: ") - clientID, err := reader.ReadString('\n') - if err != nil { - return fmt.Errorf("read client ID: %w", err) - } - clientID = strings.TrimSpace(clientID) - if clientID == "" { - return fmt.Errorf("client ID cannot be empty") - } + fmt.Println() + fmt.Println("Don't have a GitHub App yet? Create one at:") + fmt.Println(" https://github.com/organizations/YOUR_ORG/settings/apps/new") + fmt.Println("Required: Organization permissions → Self-hosted runners → Read & Write") + fmt.Println("Then generate a .pem private key (chmod 600) and install the App.") + fmt.Println() - fmt.Print("? Installation ID: ") - installIDStr, err := reader.ReadString('\n') + clientID, err := readLine(reader, "GitHub App Client ID") if err != nil { - return fmt.Errorf("read installation ID: %w", err) + return err } - installID, err := strconv.ParseInt(strings.TrimSpace(installIDStr), 10, 64) + pemPath, err := readLine(reader, "Path to private key (.pem)") if err != nil { - return fmt.Errorf("parse installation ID: %w", err) + return err } - - fmt.Print("? Private key path (.pem): ") - keyPath, err := reader.ReadString('\n') + hostURL, err := readLine(reader, "GitHub host URL [https://github.com]") if err != nil { - return fmt.Errorf("read private key path: %w", err) + return err } - keyPath = strings.TrimSpace(keyPath) - if keyPath == "" { - return fmt.Errorf("private key path cannot be empty") + + in := appLoginInput{ + clientID: clientID, + privateKeyPath: expandHome(pemPath), + hostURL: hostURL, } - fmt.Print("? GitHub URL: ") - url, err := reader.ReadString('\n') + fmt.Println(" Validating credentials...") + prep, err := prepareAppLogin(cmd.Context(), in) if err != nil { - return fmt.Errorf("read url: %w", err) + return err } - url = strings.TrimSpace(url) - if url == "" { - return fmt.Errorf("URL cannot be empty") + fmt.Printf(" Found %d installation(s)\n", len(prep.installations)) + + inst, err := selectInstallation(reader, prep.installations) + if err != nil { + return err } - creds := &auth.Credentials{ - Method: "github_app", - GitHubURL: url, - GitHubApp: &auth.GitHubAppCreds{ - ClientID: clientID, - InstallationID: installID, - PrivateKeyPath: keyPath, - }, + fmt.Println(" Generating installation token...") + creds, err := finalizeAppLogin(cmd.Context(), prep, inst, in) + if err != nil { + return err } + return saveCreds(creds) +} - return validateAndSave(cmd, creds) +func readLine(reader *bufio.Reader, label string) (string, error) { + if label != "" { + fmt.Printf("? %s: ", label) + } + raw, err := reader.ReadString('\n') + if err != nil { + return "", fmt.Errorf("read input: %w", err) + } + return strings.TrimSpace(raw), nil } From 78b91e8f242dd8272ddc4c98f7ad2528e1c36cd1 Mon Sep 17 00:00:00 2001 From: RedBoardDev Date: Sat, 16 May 2026 19:40:33 +0200 Subject: [PATCH 02/39] fix(controller): index loop to avoid implicit loop-var capture (II.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Range copies the iteration value, so the previous code passed &g (the loop variable's address) to each goroutine. Go 1.22+ scopes loop vars per iteration, masking the bug — but the pattern is fragile. Index the slice directly so each goroutine captures a stable pointer to the underlying GroupConfig. --- audit.md | 1319 +++++++++++++++++++++++++++++ internal/controller/controller.go | 7 +- 2 files changed, 1323 insertions(+), 3 deletions(-) create mode 100644 audit.md diff --git a/audit.md b/audit.md new file mode 100644 index 0000000..6cd9d53 --- /dev/null +++ b/audit.md @@ -0,0 +1,1319 @@ +# Audit ghr — Code Review & Plan d'amélioration + +> **Périmètre** : audit complet du dépôt `gh-runners-tool` (~5 537 LOC Go hors tests, 4 347 LOC de tests). Toutes les références code utilisent le format `path:line`. Les recommandations sont classées par sévérité ; les features proposées sont en fin de document. +> +> **Méthodologie** : exploration symbolique via Serena, lecture ciblée des chemins critiques (auth, runner, controller, api, launchd), revue croisée avec les rules du projet (`.claude/rules/security.md`, `architecture.md`, `code-cleanliness.md`, `go-style.md`) et les conventions de `CLAUDE.md`. + +--- + +## 0. Synthèse exécutive + +### Forces + +- Architecture **package-by-feature** propre, interfaces consumer-side, DI manuelle lisible dans `cmd/ghr/main.go` → `internal/cli/daemon.go:buildDaemon`. +- Bon usage de `oklog/run` pour le lifecycle daemon. +- Structure de tests honnête sur les packages bas-niveau (auth, runner, notification, logging, config, health). +- Conventions de logging structuré (`log/slog`) cohérentes, avec rotation par date et multi-handler. +- Linter strict (`gocritic`, `errorlint`, `nilerr`, `prealloc`, `unparam`, `exhaustive`, `contextcheck`…) et `govulncheck` câblé. +- Configuration YAML + env propre, avec validation explicite et messages d'erreur agrégés (`errors.Join`). +- Retry exponentiel sur le listener de groupe (`internal/controller/group.go:nextBackoff`). + +### Faiblesses majeures + +1. **Path traversal exploitable** dans l'extraction tar du runner (`internal/runner/download.go:69-71`) — une archive malicieusement fabriquée peut créer un symlink hors du dossier de cache. +2. **Aucune vérification d'intégrité** du binaire runner téléchargé (pas de SHA-256, pas de signature). Le code fait confiance aveugle au tarball GitHub. +3. **`pgrep -f workdirBase`** (`internal/runner/cleanup.go:KillOrphanRunners`) peut tuer des processus utilisateur non-ghr si `workdir_base` est court ou trop large (ex. `/tmp`). +4. **Métriques de santé jamais alimentées** : `UpdateGroupStats`, `RecordStartFailure`, `RecordStartSuccess` n'ont aucun call-site en production → `checkGroupDivergence` et `checkConsecutiveFailures` ne déclenchent jamais. Sécurité by-design désactivée silencieusement. +5. **Notifications synchrones sous mutex** dans `internal/health/checks.go:runChecks` — un Discord lent bloque toute la boucle de health. +6. **Race condition sur le cache** : 2 groupes lançant `EnsureBits` en parallèle pour la même version peuvent corrompre le cache. Détection « cached » basée sur `run.sh` qui apparaît avant la fin de l'extraction. +7. **Unix socket sans permissions explicites** (`internal/api/server.go:Run`) — autres utilisateurs locaux peuvent lire le statut + PIDs. +8. **Aucune commande de réelle observabilité** : pas de `/metrics`, pas d'audit log, pas de `ghr logs`. + +### Vue d'ensemble (heatmap) + +| Catégorie | Critique | Haute | Moyenne | Basse | +|-------------------|:--------:|:-----:|:-------:|:-----:| +| Sécurité | 3 | 6 | 7 | 5 | +| Bugs / Correctness| 2 | 5 | 8 | 6 | +| Architecture | 0 | 2 | 9 | 7 | +| Résilience | 1 | 4 | 6 | 3 | +| Tests | 0 | 3 | 5 | 2 | +| Observabilité | 1 | 3 | 4 | 2 | +| Doc | 0 | 1 | 3 | 4 | + +--- + +## I. Sécurité + +### I.1 ⛔ CRITIQUE — Path traversal via symlinks dans l'extraction tar + +**Fichier** : `internal/runner/download.go:68-75` + +```go +case tar.TypeSymlink: + linkTarget, linkErr := sanitizeTarPath(destDir, header.Linkname) + if linkErr != nil { + linkTarget = header.Linkname // ⚠️ on ignore la violation et on utilise tel quel + } + if err := os.Symlink(linkTarget, target); err != nil { + return fmt.Errorf("create symlink %s: %w", target, err) + } +``` + +Quand `sanitizeTarPath` détecte que `Linkname` sort de `destDir`, le code retombe silencieusement sur le chemin original — exactement le payload de l'attaque. Un tarball forgé peut planter un symlink `runner → /etc/shadow` dans le cache, qui sera ensuite copié par `copyDir` (`internal/runner/copy.go:23-26`) et l'écriture éventuelle dessus suivra le lien. + +**Recommandation** : faire `return linkErr` quand la cible n'est pas dans `destDir`. Idem côté `extractFile` : utiliser `os.OpenFile(path, O_CREATE|O_WRONLY|O_TRUNC|O_NOFOLLOW, mode)` pour refuser tout descripteur si la cible est un symlink déjà créé par une entrée précédente. + +```go +case tar.TypeSymlink: + if !filepath.IsLocal(header.Linkname) { + return fmt.Errorf("tar entry %q symlink %q is not local", header.Name, header.Linkname) + } + if err := os.Symlink(header.Linkname, target); err != nil { ... } +``` + +`filepath.IsLocal` (Go 1.20+) fait exactement le job demandé et rejette `..`, les chemins absolus et les noms réservés Windows. + +### I.2 ⛔ CRITIQUE — Pas de vérification d'intégrité du binaire runner + +**Fichier** : `internal/runner/download.go:17-36` (`downloadAndExtract`). + +Le code télécharge `https://github.com/actions/runner/releases/download/v.../actions-runner-osx-...-VERSION.tar.gz` et l'extrait, sans vérifier ni le checksum SHA-256 publié dans la release GitHub, ni la signature détachée. Un MITM (DNS empoisonné, proxy d'entreprise modifié, mirror compromis, etc.) peut substituer un binaire malicieux qui sera ensuite **exécuté avec les permissions du daemon** (`run.sh` lancé via `exec.CommandContext`). + +**Recommandation** : pour chaque version résolue, télécharger d'abord `https://github.com/actions/runner/releases/download/v{version}/actions-runner-osx-{arch}-{version}.tar.gz.sha256` (publié par GitHub), comparer au hash calculé en streaming. Idéalement, valider aussi la signature Sigstore (cosign) si disponible. + +```go +hasher := sha256.New() +tee := io.TeeReader(resp.Body, hasher) +// extract from tee... +if !bytes.Equal(hasher.Sum(nil), expected) { return ErrChecksumMismatch } +``` + +### I.3 ⛔ CRITIQUE — `pgrep -f workdirBase` peut tuer des processus arbitraires + +**Fichier** : `internal/runner/cleanup.go:91-104` (`KillOrphanRunners`). + +```go +out, err := exec.CommandContext(ctx, "pgrep", "-f", m.workdirBase).Output() +``` + +`workdirBase` est lu de la config (`runner.workdir_base`). Si un opérateur configure `runner.workdir_base: "/tmp"` ou `"."` (chemin court ou commun), `pgrep -f` matchera tous les processus dont la commande contient ce substring → SIGKILL massif sur les processus utilisateur. + +Pire : la valeur par défaut pour un user non-root est `~/.local/share/ghr/runners` (`internal/config/loader.go:applyDefaults`). Si quelqu'un lance `ghr` à la racine du `$HOME` (`~`) ou pointe la config sur un dossier partagé, le risque est concret. + +**Recommandations** : +1. Valider à `config.validate()` que `workdir_base` est absolu, n'est pas `/`, `/tmp`, `/var`, `$HOME`, et qu'il fait plus de N caractères. +2. Préférer parser `ps -eo pid,command` et matcher exactement le chemin complet du `run.sh` (ou utiliser le `.ghr-pid` déjà écrit + vérification du `comm`). +3. Avant `Kill`, lire `/proc/PID/exe` (Linux) ou `proc_pidpath` (macOS via `lsof -p PID` ou `ps -p PID -o comm=`) et confirmer que la cible est bien `run.sh`/`Runner.Listener` dans un dossier sous `workdir_base`. + +### I.4 🔴 HAUTE — Unix socket sans permissions restreintes + +**Fichier** : `internal/api/server.go:46-50`. + +```go +ln, err := net.Listen("unix", s.socketPath) +``` + +Le socket hérite de l'umask du processus (typiquement `0o022` → `0o755`). N'importe quel utilisateur local peut faire `GET /status` ou `GET /health` et lire les PIDs, noms de groupes, et issues de santé. + +**Recommandation** : +```go +ln, err := net.Listen("unix", s.socketPath) +if err != nil { return ... } +if err := os.Chmod(s.socketPath, 0o600); err != nil { + ln.Close() + return fmt.Errorf("chmod socket: %w", err) +} +``` + +Ou mieux : créer le socket dans un dossier déjà `0o700` (`stateDir`) et permettre `0o660` pour permettre une intégration multi-utilisateur volontaire. + +### I.5 🔴 HAUTE — Pas de TLS/connection timeout sur les clients HTTP de `auth/` + +**Fichier** : `internal/auth/installations.go:41,93`, `internal/auth/validate.go:32`. + +`http.DefaultClient.Do(req)` n'a **pas de timeout** par défaut. Un GitHub lent (ou un attaquant lent-loris contrôlant la résolution DNS) bloquera indéfiniment l'opération `login` ou `auth status`. + +**Recommandation** : créer un client local avec `Timeout: 30 * time.Second` (ou par requête via `context.WithTimeout`) et le partager entre les fonctions du package. + +```go +var httpClient = &http.Client{Timeout: 30 * time.Second} +``` + +Pareil pour `internal/runner/binary.go:25` (`httpClient: &http.Client{}`) — le DL d'un tarball de 100 Mo doit avoir un timeout généreux mais borné. + +### I.6 🔴 HAUTE — Stockage des credentials en clair + +**Fichier** : `internal/auth/store.go:Save` écrit `~/.config/ghr/credentials.json` avec `0o600`, mais en clair. + +Pour un daemon long-running c'est attendu (pas de re-prompt). Mais sur macOS, la **Keychain** est l'idiom. Stocker au moins le PAT dans la Keychain via `security add-generic-password` ou `github.com/keybase/go-keychain` réduit la surface : un dump disque ne suffit plus. + +**Recommandation court terme** : à la lecture (`loadFromFile`), vérifier les permissions et émettre un warning visible si elles ne sont pas `0o600` (le code le fait déjà pour la private key dans `internal/auth/jwt.go:LoadPrivateKey:34-36` mais pas pour le credentials file). + +**Recommandation moyen terme** : flag opt-in `--use-keychain` puis migration douce. + +### I.7 🔴 HAUTE — Injection XML possible dans le plist launchd + +**Fichier** : `internal/launchd/plist.go:8-41` (template `text/template`). + +```go +plistTemplate = `... + Label + {{.Label}} + ProgramArguments + + {{.BinaryPath}} + ... + {{.ConfigPath}} + + ... +``` + +`text/template` n'échappe pas le XML. Si un opérateur passe un `--config "/tmp/xRunAtLoad..."` (peu réaliste, mais), ou si `BinaryPath`/`StateDir`/`LogDir` contient `<`, `>`, `&`, le plist devient un fichier XML structuré différemment, exécutant éventuellement d'autres commandes. + +**Recommandation** : registrer une fonction `xml` : + +```go +funcs := template.FuncMap{"xml": func(s string) string { + var buf bytes.Buffer + _ = xml.EscapeText(&buf, []byte(s)) + return buf.String() +}} +tmpl, _ := template.New("plist").Funcs(funcs).Parse(plistTemplate) +// puis: {{xml .Label}} +``` + +Et valider en amont que les chemins ne contiennent que `[A-Za-z0-9_./-]`. + +### I.8 🔴 HAUTE — `launchctl load/unload` est déprécié depuis macOS 10.10 + +**Fichier** : `internal/launchd/launchctl.go:7-38`. + +Les sous-commandes `load`/`unload`/`start`/`stop` sont remplacées par `bootstrap gui/`, `bootout`, `kickstart`. Sur macOS 15+ certaines commandes peuvent émettre des warnings ou changer de comportement. + +**Recommandation** : +- `launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/