diff --git a/README.md b/README.md index 3f2d6a9..1d0cf58 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,8 @@ GitHub cannot post confidential advisory comments yet, so with `comment: true` t ## Quick start +`tamis init` runs these three steps for you, all but installing the App, as [the setup guide](docs/setup.md#setup-in-one-command) shows. By hand: + 1. Create a GitHub App for your organisation with the "Repository security advisories" permission in read and write. Install it on the project repository only. 2. In a private repository, store the App's client ID and private key, and an Anthropic or OpenAI API key or a [Codex access token](docs/configuration.md#codex-access-token). 3. Add this workflow to the private repository: diff --git a/cmd/tamis/main.go b/cmd/tamis/main.go index f7ae00f..379047a 100644 --- a/cmd/tamis/main.go +++ b/cmd/tamis/main.go @@ -4,17 +4,23 @@ import ( "cmp" "context" "errors" + "flag" "fmt" + "io" "log/slog" "os" + "os/exec" "os/signal" + "runtime" "strconv" "strings" "time" + "github.com/php/tamis/internal/board" "github.com/php/tamis/internal/engine" "github.com/php/tamis/internal/github" "github.com/php/tamis/internal/sandbox" + "github.com/php/tamis/internal/setup" "github.com/php/tamis/internal/triage" ) @@ -37,12 +43,109 @@ func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() - if err := run(ctx, log); err != nil { + var err error + switch { + case len(os.Args) > 1 && os.Args[1] == "create-board": + err = createBoard(ctx, os.Args[2:]) + case len(os.Args) > 1 && os.Args[1] == "init": + err = initProject(ctx, os.Args[2:], os.Stdout) + default: + err = run(ctx, log) + } + if err != nil { log.Error(err.Error()) os.Exit(1) } } +// createBoard creates the project board of an organisation and prints the value of the project input. +func createBoard(ctx context.Context, args []string) error { + if len(args) != 1 { + return errors.New("usage: tamis create-board ORG") + } + token := os.Getenv("GH_TOKEN") + if token == "" { + return errors.New("GH_TOKEN is required") + } + project, err := board.Create(ctx, github.New(token), args[0], "tamis") + if err != nil { + return err + } + fmt.Println(project) + + return nil +} + +// initProject installs tamis for a project, then prints what is left to do by hand. +func initProject(ctx context.Context, args []string, out io.Writer) error { + fs := flag.NewFlagSet("init", flag.ContinueOnError) + repository := fs.String("repository", "", `project whose advisories are triaged, as "org/name"`) + runner := fs.String("runner", "tamis-runner", "private repository created in the same organisation to run the workflow") + references := fs.String("references", "", "whitespace-separated git references of every supported branch, the analysed one first") + provider := fs.String("engine", "claude", "LLM provider, claude or openai") + appName := fs.String("app-name", "", `name of the GitHub App, "-tamis" by default`) + withBoard := fs.Bool("board", false, "also create a private project board and fill it instead of commenting") + err := fs.Parse(args) + if errors.Is(err, flag.ErrHelp) { + return nil + } + if err != nil { + return err + } + org, _, _ := strings.Cut(*repository, "/") + refs := strings.Fields(*references) + if org == "" || len(refs) == 0 { + return errors.New("usage: tamis init -repository org/name -references \"main ...\" [-runner name] [-engine claude|openai] [-app-name name] [-board]") + } + if len(refs) > 10 { + return errors.New("-references must list at most ten references") + } + if *provider != "claude" && *provider != "openai" { + return fmt.Errorf("unknown engine %q, expected claude or openai", *provider) + } + token, apiKey := os.Getenv("GH_TOKEN"), os.Getenv("LLM_API_KEY") + if token == "" || apiKey == "" { + return errors.New("GH_TOKEN and LLM_API_KEY are required") + } + cfg := setup.Config{ + Repository: *repository, + Runner: *runner, + References: refs, + Engine: *provider, + APIKey: apiKey, + AppName: cmp.Or(*appName, org+"-tamis"), + Since: time.Now().UTC().Format(time.DateOnly), + Board: *withBoard, + } + app := func(ctx context.Context, org string, manifest map[string]any) (string, error) { + return setup.AppCode(ctx, org, manifest, func(u string) { + fmt.Fprintf(out, "Confirm the GitHub App in your browser, or open %s\n", u) + openBrowser(u) + }) + } + install, err := setup.Run(ctx, cfg, github.New(token), app, out) + if err != nil { + return err + } + fmt.Fprintf(out, "\nInstall the App on %s only, with \"Only select repositories\": %s\n", *repository, install) + fmt.Fprintf(out, "Then trigger a first run on a test advisory: gh workflow run tamis.yml -R %s/%s -f advisory=GHSA-xxxx-xxxx-xxxx\n", org, *runner) + openBrowser(install) + + return nil +} + +// openBrowser does its best, the address is printed anyway. +func openBrowser(u string) { + switch runtime.GOOS { + case "darwin": + exec.Command("open", u).Start() + case "windows": + exec.Command("rundll32", "url.dll,FileProtocolHandler", u).Start() + default: + exec.Command("xdg-open", u).Start() + } +} + func run(ctx context.Context, log *slog.Logger) error { cfg := triage.Config{ Repo: os.Getenv("REPOSITORY"), diff --git a/cmd/tamis/main_test.go b/cmd/tamis/main_test.go index 2790570..301b943 100644 --- a/cmd/tamis/main_test.go +++ b/cmd/tamis/main_test.go @@ -83,6 +83,52 @@ func TestRunValidatesConfiguration(t *testing.T) { } } +func TestCreateBoardValidatesArguments(t *testing.T) { + for name, tc := range map[string]struct { + args []string + token string + want string + }{ + "no organisation": {nil, "t", "usage: tamis create-board ORG"}, + "two arguments": {[]string{"acme", "extra"}, "t", "usage: tamis create-board ORG"}, + "token required": {[]string{"acme"}, "", "GH_TOKEN is required"}, + } { + t.Run(name, func(t *testing.T) { + t.Setenv("GH_TOKEN", tc.token) + + require.EqualError(t, createBoard(context.Background(), tc.args), tc.want) + }) + } +} + +func TestInitValidatesArguments(t *testing.T) { + for name, tc := range map[string]struct { + args []string + token, key string + want string + }{ + "no repository": {[]string{"-references", "main"}, "t", "k", "usage: tamis init"}, + "no org": {[]string{"-repository", "/project", "-references", "main"}, "t", "k", "usage: tamis init"}, + "no references": {[]string{"-repository", "acme/project"}, "t", "k", "usage: tamis init"}, + "11 references": {[]string{"-repository", "acme/project", "-references", "a b c d e f g h i j k"}, "t", "k", "-references must list at most ten references"}, + "unknown engine": {[]string{"-repository", "acme/project", "-references", "main", "-engine", "gemini"}, "t", "k", `unknown engine "gemini"`}, + "no token": {[]string{"-repository", "acme/project", "-references", "main"}, "", "k", "GH_TOKEN and LLM_API_KEY are required"}, + "no key": {[]string{"-repository", "acme/project", "-references", "main"}, "t", "", "GH_TOKEN and LLM_API_KEY are required"}, + "unknown flag": {[]string{"-repo", "acme/project"}, "t", "k", "flag provided but not defined: -repo"}, + } { + t.Run(name, func(t *testing.T) { + t.Setenv("GH_TOKEN", tc.token) + t.Setenv("LLM_API_KEY", tc.key) + + require.ErrorContains(t, initProject(context.Background(), tc.args, io.Discard), tc.want) + }) + } +} + +func TestInitHelpIsNotAnError(t *testing.T) { + require.NoError(t, initProject(context.Background(), []string{"-h"}, io.Discard)) +} + // GitHub refuses to run an action whose manifest does not parse, with no test of its own to tell. func TestActionManifestParses(t *testing.T) { data, err := os.ReadFile("../../action.yml") diff --git a/docs/setup.md b/docs/setup.md index 088655e..b36a000 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -2,6 +2,17 @@ tamis runs as a scheduled GitHub Actions workflow. You need a GitHub App, a private repository and a model provider key. +## Setup in one command + +The tamis binary of a [release](https://github.com/php/tamis/releases) does steps 1 to 3, and step 5 with `-board`, as an organisation owner: + +```bash +gh auth refresh -s project,workflow +GH_TOKEN=$(gh auth token) LLM_API_KEY=sk-... tamis init -repository your-org/your-project -references "master origin/release-branch" +``` + +It opens your browser to confirm the GitHub App, then creates the private `your-org/tamis-runner` repository with its `tamis` environment, the App credentials and the provider key as secrets, and the workflow pinned to the latest release. `-runner` names another repository, `-engine openai` takes an OpenAI key, `-app-name` replaces the `your-org-tamis` App name and `-board` creates the project board instead of posting comments. Install the App from the address it prints, then go on with [the first run](#4-first-run). The sections below do the same by hand. + ## 1. Create the GitHub App Open this address with `YOUR-ORG` replaced by your organisation name. The form comes pre-filled with the only permission tamis needs, "Repository security advisories" in read and write, and no webhook. @@ -57,7 +68,14 @@ GitHub Actions has no trigger for security advisories, so the schedule polls eve ## 5. Project board -tamis can keep a private organisation project in sync with the advisories, one draft item each, so the security team sorts them by priority. Give the App the organisation "Projects" permission in read and write, create a private project and add these fields by hand: +tamis can keep a private organisation project in sync with the advisories, one draft item each, so the security team sorts them by priority. Give the App the organisation "Projects" permission in read and write, then create the project with the tamis binary of a [release](https://github.com/php/tamis/releases), as an organisation member allowed to create projects: + +```bash +gh auth refresh -s project +GH_TOKEN=$(gh auth token) tamis create-board your-org +``` + +The command prints the project as `your-org/NUMBER`. It creates a private project named `tamis` with the fields below and a single `Triage` view of the advisories in `triage` or `draft`, sorted by `Priority` then `Reported`. To lay out a project by hand instead, add these fields: | Field | Type | Options, in this order | |---|---|---| diff --git a/go.mod b/go.mod index d070c21..d13cc33 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/openai/openai-go/v3 v3.85.1 github.com/stretchr/testify v1.12.1 go.yaml.in/yaml/v3 v3.0.5 + golang.org/x/crypto v0.57.0 golang.org/x/sync v0.24.0 ) diff --git a/internal/board/board.go b/internal/board/board.go index 2e65fff..67fe0b2 100644 --- a/internal/board/board.go +++ b/internal/board/board.go @@ -43,7 +43,7 @@ const ( fieldAttempts = "Attempts" ) -// layout lists the fields the project must have, created by hand as docs/setup.md describes. +// layout lists the fields the project must have, created by Create or by hand as docs/setup.md describes. var layout = []struct { name, dataType string options []string @@ -71,6 +71,52 @@ type GitHub interface { UpdateProjectItem(ctx context.Context, org string, number int, item int64, fields []github.FieldValue) error } +type Creator interface { + CreateProject(ctx context.Context, org, title string) (int, string, error) + CreateProjectField(ctx context.Context, org string, number int, name, dataType string, options []string) error + ListProjectFields(ctx context.Context, org string, number int) ([]github.ProjectField, error) + CreateProjectView(ctx context.Context, org string, number int, view github.ProjectView) error + DeleteProjectView(ctx context.Context, view string) error +} + +// Create makes a project of org laid out as Open expects, with one view of the advisories still in triage sorted +// by priority, and returns it as "org/number". +func Create(ctx context.Context, gh Creator, org, title string) (string, error) { + number, view, err := gh.CreateProject(ctx, org, title) + if err != nil { + return "", fmt.Errorf("creating a project of %s: %w", org, err) + } + project := fmt.Sprintf("%s/%d", org, number) + for _, f := range layout { + if err := gh.CreateProjectField(ctx, org, number, f.name, f.dataType, f.options); err != nil { + return "", fmt.Errorf("creating the %q field of %s: %w", f.name, project, err) + } + } + fields, err := gh.ListProjectFields(ctx, org, number) + if err != nil { + return "", fmt.Errorf("listing the fields of %s: %w", project, err) + } + ids := map[string]int64{} + for _, f := range fields { + ids[f.Name] = f.ID + } + triage := github.ProjectView{ + Name: "Triage", + Filter: "State:triage,draft", + Fields: []int64{ids["Title"], ids[fieldPriority], ids[fieldSeverity], ids[fieldComponent], ids[fieldDuplicates], ids[fieldReported]}, + SortBy: []int64{ids[fieldPriority], ids[fieldReported]}, + } + if err := gh.CreateProjectView(ctx, org, number, triage); err != nil { + return "", fmt.Errorf("creating the view of %s: %w", project, err) + } + // The view GitHub adds lists every item unsorted, and a view gets its sort only when created. + if err := gh.DeleteProjectView(ctx, view); err != nil { + return "", fmt.Errorf("deleting the default view of %s: %w", project, err) + } + + return project, nil +} + // Issues writes the issues of the board with a token of its own, which only reaches the issues repository. type Issues interface { InstallationRepositories(ctx context.Context) ([]string, error) diff --git a/internal/board/board_test.go b/internal/board/board_test.go index c8b0979..9ea2231 100644 --- a/internal/board/board_test.go +++ b/internal/board/board_test.go @@ -765,3 +765,43 @@ func TestSyncCountsTheSubIssuesOfEachIssue(t *testing.T) { require.Equal(t, want, p.Value(p.Find(g), "Duplicates"), "%s does not count its sub-issues", g) } } + +func TestCreateLaysOutTheProjectOpenExpects(t *testing.T) { + p := boardtest.New() + p.Fields = p.Fields[:1] + + project, err := Create(t.Context(), p, "acme", "tamis") + + require.NoError(t, err) + require.Equal(t, "acme/16", project) + _, err = Open(t.Context(), p, p, project, "", "php/php-src") + require.NoError(t, err) + require.Equal(t, []github.ProjectView{{ + Name: "Triage", + Filter: "State:triage,draft", + Fields: []int64{100, 105, 106, 110, 103, 104}, + SortBy: []int64{105, 104}, + }}, p.Views) + require.Equal(t, []string{"PVTV_default"}, p.Deleted) +} + +func TestCreateReportsAFailure(t *testing.T) { + for name, tc := range map[string]struct { + change func(*boardtest.Project) + err string + }{ + "project": {func(p *boardtest.Project) { p.Err = errors.New("boom") }, "creating a project of acme: boom"}, + "fields": {func(p *boardtest.Project) { p.ListErr = errors.New("boom") }, "listing the fields of acme/16: boom"}, + } { + t.Run(name, func(t *testing.T) { + p := boardtest.New() + p.Fields = p.Fields[:1] + tc.change(p) + + _, err := Create(t.Context(), p, "acme", "tamis") + + require.EqualError(t, err, tc.err) + require.Empty(t, p.Views) + }) + } +} diff --git a/internal/board/boardtest/boardtest.go b/internal/board/boardtest/boardtest.go index a55507b..a0be9cb 100644 --- a/internal/board/boardtest/boardtest.go +++ b/internal/board/boardtest/boardtest.go @@ -26,6 +26,9 @@ type Project struct { UpdateHook func() error Public bool next int64 + // Views holds the views Create added, Deleted the node IDs of the views it removed. + Views []github.ProjectView + Deleted []string // Repos says which repositories exist and whether they are private. Issues holds their issues by number, // and issueOf the issue of each item that is one. Repos map[string]bool @@ -44,7 +47,7 @@ type Issue struct { func New() *Project { p := &Project{Items: map[int64]map[int64]any{}, Titles: map[int64]string{}, Bodies: map[int64]string{}, Repos: map[string]bool{"acme/private-issues": true, "php/php-src": false}, Issues: map[int]*Issue{}, issueOf: map[int64]int{}, Reached: []string{"acme/private-issues"}} - for i, f := range []struct { + for _, f := range []struct { name, dataType string options []string }{ @@ -62,23 +65,27 @@ func New() *Project { {"Original report", "text", nil}, {"Attempts", "number", nil}, } { - field := github.ProjectField{ID: int64(100 + i), Name: f.name, DataType: f.dataType} - for _, o := range f.options { - option := struct { - ID string `json:"id"` - Name struct { - Raw string `json:"raw"` - } `json:"name"` - }{ID: "opt-" + o} - option.Name.Raw = o - field.Options = append(field.Options, option) - } - p.Fields = append(p.Fields, field) + p.addField(f.name, f.dataType, f.options) } return p } +func (p *Project) addField(name, dataType string, options []string) { + field := github.ProjectField{ID: int64(100 + len(p.Fields)), Name: name, DataType: dataType} + for _, o := range options { + option := struct { + ID string `json:"id"` + Name struct { + Raw string `json:"raw"` + } `json:"name"` + }{ID: "opt-" + o} + option.Name.Raw = o + field.Options = append(field.Options, option) + } + p.Fields = append(p.Fields, field) +} + func (p *Project) field(name string) github.ProjectField { return p.Fields[slices.IndexFunc(p.Fields, func(f github.ProjectField) bool { return f.Name == name })] } @@ -131,6 +138,32 @@ func (p *Project) GetProject(context.Context, string, int) (github.Project, erro return github.Project{Public: p.Public}, p.ListErr } +// CreateProject numbers the project 16 and names its default view "PVTV_default". +func (p *Project) CreateProject(context.Context, string, string) (int, string, error) { + return 16, "PVTV_default", p.Err +} + +func (p *Project) CreateProjectField(_ context.Context, _ string, _ int, name, dataType string, options []string) error { + if p.Err != nil { + return p.Err + } + p.addField(name, dataType, options) + + return nil +} + +func (p *Project) CreateProjectView(_ context.Context, _ string, _ int, view github.ProjectView) error { + p.Views = append(p.Views, view) + + return p.Err +} + +func (p *Project) DeleteProjectView(_ context.Context, view string) error { + p.Deleted = append(p.Deleted, view) + + return p.Err +} + func (p *Project) ListProjectFields(context.Context, string, int) ([]github.ProjectField, error) { return p.Fields, p.ListErr } diff --git a/internal/github/client.go b/internal/github/client.go index 1d7fdc5..93730a0 100644 --- a/internal/github/client.go +++ b/internal/github/client.go @@ -4,6 +4,8 @@ package github import ( "bytes" "context" + "crypto/rand" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -14,6 +16,8 @@ import ( "slices" "strings" "time" + + "golang.org/x/crypto/nacl/box" ) var ErrNotFound = errors.New("not found") @@ -298,29 +302,112 @@ func (c *Client) CreateDraft(ctx context.Context, org string, number int, title, // UpdateDraft replaces the body of a draft issue, which the REST API cannot edit. func (c *Client) UpdateDraft(ctx context.Context, draft, body string) error { + return c.graphql(ctx, "mutation($id: ID!, $body: String!) { updateProjectV2DraftIssue(input: {draftIssueId: $id, body: $body}) { clientMutationId } }", map[string]any{"id": draft, "body": body}, nil) +} + +func (c *Client) UpdateProjectItem(ctx context.Context, org string, number int, item int64, fields []FieldValue) error { + _, err := c.do(ctx, http.MethodPatch, fmt.Sprintf("%s/orgs/%s/projectsV2/%d/items/%d", c.BaseURL, url.PathEscape(org), number, item), map[string]any{"fields": fields}, nil) + + return err +} + +// CreateProject creates a project of org, private by default, and returns its number and the node ID of the +// view GitHub adds to it. Only GraphQL creates a project. +func (c *Client) CreateProject(ctx context.Context, org, title string) (int, string, error) { + var owner struct { + Organization struct { + ID string `json:"id"` + } `json:"organization"` + } + if err := c.graphql(ctx, "query($org: String!) { organization(login: $org) { id } }", map[string]any{"org": org}, &owner); err != nil { + return 0, "", err + } var out struct { - Errors []json.RawMessage `json:"errors"` + CreateProjectV2 struct { + ProjectV2 struct { + Number int `json:"number"` + Views struct { + Nodes []struct { + ID string `json:"id"` + } `json:"nodes"` + } `json:"views"` + } `json:"projectV2"` + } `json:"createProjectV2"` } - _, err := c.do(ctx, http.MethodPost, c.BaseURL+"/graphql", map[string]any{ - "query": "mutation($id: ID!, $body: String!) { updateProjectV2DraftIssue(input: {draftIssueId: $id, body: $body}) { clientMutationId } }", - "variables": map[string]string{"id": draft, "body": body}, - }, &out) - // GraphQL reports its errors with a 200, and their messages may echo the body. - if err == nil && len(out.Errors) > 0 { - return errors.New("github: GraphQL request failed") + if err := c.graphql(ctx, "mutation($owner: ID!, $title: String!) { createProjectV2(input: {ownerId: $owner, title: $title}) { projectV2 { number views(first: 1) { nodes { id } } } } }", map[string]any{"owner": owner.Organization.ID, "title": title}, &out); err != nil { + return 0, "", err + } + p := out.CreateProjectV2.ProjectV2 + if len(p.Views.Nodes) == 0 { + return p.Number, "", nil + } + + return p.Number, p.Views.Nodes[0].ID, nil +} + +// CreateProjectField adds a field of the REST data type, with the options of a single select field in order. +func (c *Client) CreateProjectField(ctx context.Context, org string, number int, name, dataType string, options []string) error { + body := map[string]any{"name": name, "data_type": dataType} + if options != nil { + opts := make([]map[string]string, len(options)) + for i, o := range options { + opts[i] = map[string]string{"name": o, "color": "GRAY", "description": ""} + } + body["single_select_options"] = opts } + _, err := c.do(ctx, http.MethodPost, fmt.Sprintf("%s/orgs/%s/projectsV2/%d/fields", c.BaseURL, url.PathEscape(org), number), body, nil) return err } -func (c *Client) UpdateProjectItem(ctx context.Context, org string, number int, item int64, fields []FieldValue) error { - _, err := c.do(ctx, http.MethodPatch, fmt.Sprintf("%s/orgs/%s/projectsV2/%d/items/%d", c.BaseURL, url.PathEscape(org), number, item), map[string]any{"fields": fields}, nil) +// ProjectView is a table view sorted in ascending order on the SortBy fields. GitHub shows the Fields in the +// order of the project's fields, whatever their order here. +type ProjectView struct { + Name, Filter string + Fields, SortBy []int64 +} + +// CreateProjectView adds a table view. Only the REST API sets the sort of a view, and only when creating it. +func (c *Client) CreateProjectView(ctx context.Context, org string, number int, view ProjectView) error { + sort := make([][]any, len(view.SortBy)) + for i, id := range view.SortBy { + sort[i] = []any{id, "asc"} + } + _, err := c.do(ctx, http.MethodPost, fmt.Sprintf("%s/orgs/%s/projectsV2/%d/views", c.BaseURL, url.PathEscape(org), number), map[string]any{"name": view.Name, "layout": "table", "filter": view.Filter, "visible_fields": view.Fields, "sort_by": sort}, nil) return err } +func (c *Client) DeleteProjectView(ctx context.Context, view string) error { + return c.graphql(ctx, "mutation($id: ID!) { deleteProjectV2View(input: {viewId: $id}) { clientMutationId } }", map[string]any{"id": view}, nil) +} + +// graphql decodes the data of a GraphQL answer into out. GraphQL reports its errors with a 200, and their +// messages may echo content, so they are left out. +func (c *Client) graphql(ctx context.Context, query string, variables map[string]any, out any) error { + var resp struct { + Data json.RawMessage `json:"data"` + Errors []json.RawMessage `json:"errors"` + } + if _, err := c.do(ctx, http.MethodPost, c.BaseURL+"/graphql", map[string]any{"query": query, "variables": variables}, &resp); err != nil { + return err + } + if len(resp.Errors) > 0 { + return errors.New("github: GraphQL request failed") + } + if out == nil { + return nil + } + if err := json.Unmarshal(resp.Data, out); err != nil { + return errors.New("github: invalid response") + } + + return nil +} + type Repository struct { - Private bool `json:"private"` + Private bool `json:"private"` + DefaultBranch string `json:"default_branch"` } // InstallationRepositories names the repositories a GitHub App installation token reaches. Other tokens get an @@ -354,6 +441,102 @@ func (c *Client) GetRepository(ctx context.Context, repo string) (Repository, er return r, err } +// CreateRepository creates a private repository of org with a first commit on its default branch. +func (c *Client) CreateRepository(ctx context.Context, org, name string) (Repository, error) { + var r Repository + _, err := c.do(ctx, http.MethodPost, fmt.Sprintf("%s/orgs/%s/repos", c.BaseURL, url.PathEscape(org)), map[string]any{"name": name, "private": true, "auto_init": true}, &r) + + return r, err +} + +// CreateEnvironment creates an environment whose secrets only workflows of branch can read. +func (c *Client) CreateEnvironment(ctx context.Context, repo, env, branch string) error { + u := fmt.Sprintf("%s/repos/%s/environments/%s", c.BaseURL, repo, url.PathEscape(env)) + if _, err := c.do(ctx, http.MethodPut, u, map[string]any{"deployment_branch_policy": map[string]bool{"protected_branches": false, "custom_branch_policies": true}}, nil); err != nil { + return err + } + _, err := c.do(ctx, http.MethodPost, u+"/deployment-branch-policies", map[string]string{"name": branch, "type": "branch"}, nil) + + return err +} + +func (c *Client) SetEnvironmentVariable(ctx context.Context, repo, env, name, value string) error { + _, err := c.do(ctx, http.MethodPost, fmt.Sprintf("%s/repos/%s/environments/%s/variables", c.BaseURL, repo, url.PathEscape(env)), map[string]string{"name": name, "value": value}, nil) + + return err +} + +// SetEnvironmentSecret seals value with the public key of the environment, as GitHub requires. +func (c *Client) SetEnvironmentSecret(ctx context.Context, repo, env, name, value string) error { + u := fmt.Sprintf("%s/repos/%s/environments/%s/secrets", c.BaseURL, repo, url.PathEscape(env)) + var key struct { + KeyID string `json:"key_id"` + Key string `json:"key"` + } + if _, err := c.do(ctx, http.MethodGet, u+"/public-key", nil, &key); err != nil { + return err + } + raw, err := base64.StdEncoding.DecodeString(key.Key) + if err != nil || len(raw) != 32 { + return errors.New("github: invalid environment public key") + } + sealed, err := box.SealAnonymous(nil, []byte(value), (*[32]byte)(raw), rand.Reader) + if err != nil { + return err + } + _, err = c.do(ctx, http.MethodPut, u+"/"+url.PathEscape(name), map[string]string{"encrypted_value": base64.StdEncoding.EncodeToString(sealed), "key_id": key.KeyID}, nil) + + return err +} + +// CreateFile commits a new file on the default branch. +func (c *Client) CreateFile(ctx context.Context, repo, path, message string, content []byte) error { + _, err := c.do(ctx, http.MethodPut, fmt.Sprintf("%s/repos/%s/contents/%s", c.BaseURL, repo, path), map[string]string{"message": message, "content": base64.StdEncoding.EncodeToString(content)}, nil) + + return err +} + +// LatestRelease returns the tag of the latest release of repo and the commit it points at. +func (c *Client) LatestRelease(ctx context.Context, repo string) (string, string, error) { + var release struct { + TagName string `json:"tag_name"` + } + if _, err := c.do(ctx, http.MethodGet, fmt.Sprintf("%s/repos/%s/releases/latest", c.BaseURL, repo), nil, &release); err != nil { + return "", "", err + } + var commit struct { + SHA string `json:"sha"` + } + _, err := c.do(ctx, http.MethodGet, fmt.Sprintf("%s/repos/%s/commits/%s", c.BaseURL, repo, url.PathEscape(release.TagName)), nil, &commit) + + return release.TagName, commit.SHA, err +} + +// AppExists says whether a GitHub App answers to slug, which GitHub matches whatever its case. +func (c *Client) AppExists(ctx context.Context, slug string) (bool, error) { + _, err := c.do(ctx, http.MethodGet, fmt.Sprintf("%s/apps/%s", c.BaseURL, url.PathEscape(slug)), nil, nil) + if errors.Is(err, ErrNotFound) { + return false, nil + } + + return err == nil, err +} + +// App holds the credentials of a GitHub App, which GitHub shows only once. +type App struct { + Slug string `json:"slug"` + ClientID string `json:"client_id"` + PEM string `json:"pem"` +} + +// ConvertManifest exchanges the code GitHub returns at the end of the manifest flow for the App it created. +func (c *Client) ConvertManifest(ctx context.Context, code string) (App, error) { + var a App + _, err := c.do(ctx, http.MethodPost, fmt.Sprintf("%s/app-manifests/%s/conversions", c.BaseURL, url.PathEscape(code)), nil, &a) + + return a, err +} + // ListIssues returns every issue of repo, open or closed, and its pull requests. func (c *Client) ListIssues(ctx context.Context, repo string) ([]Issue, error) { return list[Issue](ctx, c, fmt.Sprintf("%s/repos/%s/issues?state=all&per_page=100", c.BaseURL, repo)) diff --git a/internal/github/client_test.go b/internal/github/client_test.go index 28e55d6..803a576 100644 --- a/internal/github/client_test.go +++ b/internal/github/client_test.go @@ -2,14 +2,18 @@ package github import ( "context" + "crypto/rand" + "encoding/base64" "encoding/json" "fmt" "net/http" "net/http/httptest" + "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/crypto/nacl/box" ) func newTestClient(t *testing.T, h http.HandlerFunc) *Client { @@ -368,3 +372,178 @@ func TestHTTPErrorUnwrapsOnlyNotFoundAndGone(t *testing.T) { require.NotErrorIs(t, &HTTPError{Status: 403}, ErrNotFound, "only a 404 or a 410 is ErrNotFound") require.NotErrorIs(t, &HTTPError{}, ErrNotFound, "only a 404 or a 410 is ErrNotFound") } + +func TestCreateProjectBoard(t *testing.T) { + c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + assert.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + switch r.Method + " " + r.URL.Path { + case "POST /graphql": + query, vars := body["query"].(string), body["variables"].(map[string]any) + switch { + case strings.Contains(query, "organization(login: $org)"): + assert.Equal(t, map[string]any{"org": "acme"}, vars) + fmt.Fprint(w, `{"data":{"organization":{"id":"O_1"}}}`) + case strings.Contains(query, "createProjectV2("): + assert.Equal(t, map[string]any{"owner": "O_1", "title": "tamis"}, vars) + fmt.Fprint(w, `{"data":{"createProjectV2":{"projectV2":{"number":16,"views":{"nodes":[{"id":"PVTV_1"}]}}}}}`) + case strings.Contains(query, "deleteProjectV2View("): + assert.Equal(t, map[string]any{"id": "PVTV_1"}, vars) + fmt.Fprint(w, `{"data":{"deleteProjectV2View":{"clientMutationId":null}}}`) + default: + t.Errorf("unexpected query %s", query) + } + case "POST /orgs/acme/projectsV2/16/fields": + if body["name"] == "Severity" { + assert.Equal(t, map[string]any{"name": "Severity", "data_type": "single_select", "single_select_options": []any{ + map[string]any{"name": "High", "color": "GRAY", "description": ""}, + map[string]any{"name": "Low", "color": "GRAY", "description": ""}, + }}, body) + } else { + assert.Equal(t, map[string]any{"name": "GHSA", "data_type": "text"}, body) + } + w.WriteHeader(http.StatusCreated) + fmt.Fprint(w, `{"id":1}`) + case "POST /orgs/acme/projectsV2/16/views": + assert.Equal(t, map[string]any{"name": "Triage", "layout": "table", "filter": "State:triage", "visible_fields": []any{float64(1), float64(2)}, "sort_by": []any{[]any{float64(2), "asc"}}}, body) + w.WriteHeader(http.StatusCreated) + fmt.Fprint(w, `{"id":2}`) + default: + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + } + }) + ctx := context.Background() + + number, view, err := c.CreateProject(ctx, "acme", "tamis") + require.NoError(t, err) + require.Equal(t, 16, number) + require.Equal(t, "PVTV_1", view) + require.NoError(t, c.CreateProjectField(ctx, "acme", 16, "GHSA", "text", nil)) + require.NoError(t, c.CreateProjectField(ctx, "acme", 16, "Severity", "single_select", []string{"High", "Low"})) + require.NoError(t, c.CreateProjectView(ctx, "acme", 16, ProjectView{Name: "Triage", Filter: "State:triage", Fields: []int64{1, 2}, SortBy: []int64{2}})) + require.NoError(t, c.DeleteProjectView(ctx, "PVTV_1")) +} + +func TestCreateProjectBoardErrors(t *testing.T) { + graphQL := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"errors":[{"message":"SECRET-PAYLOAD"}]}`) + }) + _, _, err := graphQL.CreateProject(context.Background(), "acme", "tamis") + require.EqualError(t, err, "github: GraphQL request failed") + require.EqualError(t, graphQL.DeleteProjectView(context.Background(), "PVTV_1"), "github: GraphQL request failed") + + forbidden := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "forbidden", http.StatusForbidden) + }) + _, _, err = forbidden.CreateProject(context.Background(), "acme", "tamis") + require.Error(t, err) + require.Error(t, forbidden.CreateProjectField(context.Background(), "acme", 16, "GHSA", "text", nil)) + require.Error(t, forbidden.CreateProjectView(context.Background(), "acme", 16, ProjectView{})) +} + +func TestSetupEndpoints(t *testing.T) { + pub, priv, err := box.GenerateKey(rand.Reader) + require.NoError(t, err) + var secret string + c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + if r.ContentLength > 0 { + assert.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + } + switch r.Method + " " + r.URL.Path { + case "POST /orgs/acme/repos": + assert.Equal(t, map[string]any{"name": "tamis-runner", "private": true, "auto_init": true}, body) + fmt.Fprint(w, `{"private":true,"default_branch":"main"}`) + case "PUT /repos/acme/tamis-runner/environments/tamis": + assert.Equal(t, map[string]any{"deployment_branch_policy": map[string]any{"protected_branches": false, "custom_branch_policies": true}}, body) + case "POST /repos/acme/tamis-runner/environments/tamis/deployment-branch-policies": + assert.Equal(t, map[string]any{"name": "main", "type": "branch"}, body) + case "POST /repos/acme/tamis-runner/environments/tamis/variables": + assert.Equal(t, map[string]any{"name": "TAMIS_CLIENT_ID", "value": "Iv1"}, body) + case "GET /repos/acme/tamis-runner/environments/tamis/secrets/public-key": + fmt.Fprintf(w, `{"key_id":"k1","key":%q}`, base64.StdEncoding.EncodeToString(pub[:])) + case "PUT /repos/acme/tamis-runner/environments/tamis/secrets/TAMIS_PRIVATE_KEY": + assert.Equal(t, "k1", body["key_id"]) + sealed, err := base64.StdEncoding.DecodeString(body["encrypted_value"].(string)) + assert.NoError(t, err) + plain, ok := box.OpenAnonymous(nil, sealed, pub, priv) + assert.True(t, ok) + secret = string(plain) + case "PUT /repos/acme/tamis-runner/contents/.github/workflows/tamis.yml": + assert.Equal(t, map[string]any{"message": "m", "content": base64.StdEncoding.EncodeToString([]byte("name: tamis\n"))}, body) + w.WriteHeader(http.StatusCreated) + case "GET /repos/php/tamis/releases/latest": + fmt.Fprint(w, `{"tag_name":"v2026.10.30.1"}`) + case "GET /repos/php/tamis/commits/v2026.10.30.1": + fmt.Fprint(w, `{"sha":"0123456789abcdef0123456789abcdef01234567"}`) + case "POST /app-manifests/c0de/conversions": + w.WriteHeader(http.StatusCreated) + fmt.Fprint(w, `{"id":1,"slug":"acme-tamis","client_id":"Iv1","pem":"PEM","client_secret":"s"}`) + default: + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + } + }) + ctx := context.Background() + + repo, err := c.CreateRepository(ctx, "acme", "tamis-runner") + require.NoError(t, err) + require.Equal(t, Repository{Private: true, DefaultBranch: "main"}, repo) + require.NoError(t, c.CreateEnvironment(ctx, "acme/tamis-runner", "tamis", "main")) + require.NoError(t, c.SetEnvironmentVariable(ctx, "acme/tamis-runner", "tamis", "TAMIS_CLIENT_ID", "Iv1")) + require.NoError(t, c.SetEnvironmentSecret(ctx, "acme/tamis-runner", "tamis", "TAMIS_PRIVATE_KEY", "PEM")) + require.Equal(t, "PEM", secret) + require.NoError(t, c.CreateFile(ctx, "acme/tamis-runner", ".github/workflows/tamis.yml", "m", []byte("name: tamis\n"))) + tag, sha, err := c.LatestRelease(ctx, "php/tamis") + require.NoError(t, err) + require.Equal(t, "v2026.10.30.1", tag) + require.Equal(t, "0123456789abcdef0123456789abcdef01234567", sha) + app, err := c.ConvertManifest(ctx, "c0de") + require.NoError(t, err) + require.Equal(t, App{Slug: "acme-tamis", ClientID: "Iv1", PEM: "PEM"}, app) +} + +func TestSetupEndpointsErrors(t *testing.T) { + badKey := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPut { + t.Error("a secret sealed with an invalid key must not be sent") + } + fmt.Fprint(w, `{"key_id":"k1","key":"c2hvcnQ="}`) + }) + require.EqualError(t, badKey.SetEnvironmentSecret(context.Background(), "acme/tamis-runner", "tamis", "S", "v"), "github: invalid environment public key") + + forbidden := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "forbidden", http.StatusForbidden) + }) + ctx := context.Background() + _, err := forbidden.CreateRepository(ctx, "acme", "tamis-runner") + require.Error(t, err) + require.Error(t, forbidden.CreateEnvironment(ctx, "acme/tamis-runner", "tamis", "main")) + require.Error(t, forbidden.SetEnvironmentVariable(ctx, "acme/tamis-runner", "tamis", "V", "v")) + require.Error(t, forbidden.SetEnvironmentSecret(ctx, "acme/tamis-runner", "tamis", "S", "v")) + require.Error(t, forbidden.CreateFile(ctx, "acme/tamis-runner", "f", "m", nil)) + _, _, err = forbidden.LatestRelease(ctx, "php/tamis") + require.Error(t, err) + _, err = forbidden.ConvertManifest(ctx, "c0de") + require.Error(t, err) +} + +func TestAppExists(t *testing.T) { + for status, want := range map[int]bool{http.StatusOK: true, http.StatusNotFound: false} { + c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET /apps/acme-tamis", r.Method+" "+r.URL.Path) + w.WriteHeader(status) + fmt.Fprint(w, `{}`) + }) + + exists, err := c.AppExists(context.Background(), "acme-tamis") + + require.NoError(t, err, "%d", status) + require.Equal(t, want, exists, "%d", status) + } + + forbidden := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "forbidden", http.StatusForbidden) + }) + _, err := forbidden.AppExists(context.Background(), "acme-tamis") + require.Error(t, err) +} diff --git a/internal/setup/manifest.go b/internal/setup/manifest.go new file mode 100644 index 0000000..5cc687f --- /dev/null +++ b/internal/setup/manifest.go @@ -0,0 +1,75 @@ +package setup + +import ( + "context" + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "html/template" + "maps" + "net" + "net/http" + "net/url" + "time" +) + +var manifestPage = template.Must(template.New("manifest").Parse(` +
+ + +
+ +`)) + +// AppCode registers a GitHub App of org from manifest through the browser and returns the code GitHub redirects +// back with. GitHub only takes a manifest posted by the browser of a signed-in owner, so a local page posts it. +func AppCode(ctx context.Context, org string, manifest map[string]any, open func(string)) (string, error) { + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return "", err + } + defer l.Close() + state, base := rand.Text(), "http://"+l.Addr().String() + m := map[string]any{"redirect_url": base + "/callback"} + maps.Copy(m, manifest) + data, err := json.Marshal(m) + if err != nil { + return "", err + } + action := fmt.Sprintf("https://github.com/organizations/%s/settings/apps/new?state=%s", url.PathEscape(org), state) + + codes := make(chan string, 1) + mux := http.NewServeMux() + mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { + manifestPage.Execute(w, map[string]string{"Action": action, "Manifest": string(data)}) + }) + mux.HandleFunc("GET /callback", func(w http.ResponseWriter, r *http.Request) { + // Only GitHub knows the state, so another local page cannot hand over a code of its own. + if r.URL.Query().Get("state") != state || r.URL.Query().Get("code") == "" { + http.Error(w, "unexpected callback", http.StatusBadRequest) + return + } + fmt.Fprintln(w, "The GitHub App is created, you can close this tab.") + select { + case codes <- r.URL.Query().Get("code"): + default: + } + }) + srv := &http.Server{Handler: mux} + go srv.Serve(l) + // Close would cut the callback page the browser is still reading, Shutdown lets it finish. + defer func() { + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + defer cancel() + srv.Shutdown(ctx) + }() + + open(base + "/") + select { + case code := <-codes: + return code, nil + case <-ctx.Done(): + return "", errors.New("the GitHub App was not created") + } +} diff --git a/internal/setup/manifest_test.go b/internal/setup/manifest_test.go new file mode 100644 index 0000000..8699316 --- /dev/null +++ b/internal/setup/manifest_test.go @@ -0,0 +1,53 @@ +package setup + +import ( + "context" + "encoding/json" + "html" + "io" + "net/http" + "regexp" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func get(t *testing.T, u string) (int, string) { + t.Helper() + resp, err := http.Get(u) + require.NoError(t, err) + defer resp.Body.Close() + b, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + return resp.StatusCode, string(b) +} + +func TestAppCodeReturnsTheCodeOfTheRedirect(t *testing.T) { + code, err := AppCode(t.Context(), "acme", map[string]any{"name": "acme-tamis"}, func(base string) { + status, page := get(t, base) + require.Equal(t, http.StatusOK, status) + action := regexp.MustCompile(`action="https://github.com/organizations/acme/settings/apps/new\?state=([A-Z2-7]{26})"`).FindStringSubmatch(page) + require.NotNil(t, action, page) + var manifest map[string]any + require.NoError(t, json.Unmarshal([]byte(html.UnescapeString(regexp.MustCompile(`name="manifest" value="([^"]*)"`).FindStringSubmatch(page)[1])), &manifest)) + assert.Equal(t, map[string]any{"name": "acme-tamis", "redirect_url": base + "callback"}, manifest) + + status, _ = get(t, base+"callback?code=forged&state=0123") + assert.Equal(t, http.StatusBadRequest, status, "a callback without the state is refused") + status, _ = get(t, base+"callback?code=c0de&state="+action[1]) + assert.Equal(t, http.StatusOK, status) + }) + + require.NoError(t, err) + require.Equal(t, "c0de", code) +} + +func TestAppCodeStopsWithItsContext(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + + _, err := AppCode(ctx, "acme", nil, func(string) { cancel() }) + + require.EqualError(t, err, "the GitHub App was not created") +} diff --git a/internal/setup/setup.go b/internal/setup/setup.go new file mode 100644 index 0000000..d530fad --- /dev/null +++ b/internal/setup/setup.go @@ -0,0 +1,195 @@ +// Package setup installs tamis for a project: GitHub App, private workflow repository, secrets and workflow. +package setup + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + "text/template" + + "github.com/php/tamis/internal/board" + "github.com/php/tamis/internal/github" +) + +type GitHub interface { + board.Creator + GetRepository(ctx context.Context, repo string) (github.Repository, error) + LatestRelease(ctx context.Context, repo string) (string, string, error) + AppExists(ctx context.Context, slug string) (bool, error) + ConvertManifest(ctx context.Context, code string) (github.App, error) + CreateRepository(ctx context.Context, org, name string) (github.Repository, error) + CreateEnvironment(ctx context.Context, repo, env, branch string) error + SetEnvironmentVariable(ctx context.Context, repo, env, name, value string) error + SetEnvironmentSecret(ctx context.Context, repo, env, name, value string) error + CreateFile(ctx context.Context, repo, path, message string, content []byte) error +} + +type Config struct { + // Repository is the project, as "org/name", and Runner the name of the private repository created next to it. + Repository, Runner string + References []string + // Engine is "claude" or "openai", APIKey its key. + Engine, APIKey string + AppName string + // Since is the date the workflow starts from, as YYYY-MM-DD. + Since string + Board bool +} + +// AppFlow registers a GitHub App from a manifest and returns the code to convert, as AppCode does. +type AppFlow func(ctx context.Context, org string, manifest map[string]any) (string, error) + +// Run creates the App, the runner repository with its tamis environment, and the workflow, then returns the +// address the App is installed from. The App reaches no repository until a maintainer installs it. +func Run(ctx context.Context, cfg Config, gh GitHub, app AppFlow, out io.Writer) (string, error) { + org, name, _ := strings.Cut(cfg.Repository, "/") + if org == "" || name == "" { + return "", errors.New(`the repository must be given as "org/name"`) + } + repo, err := gh.GetRepository(ctx, cfg.Repository) + if err != nil { + return "", fmt.Errorf("reading %s: %w", cfg.Repository, err) + } + // GitHub serves repository security advisories for public repositories only. + if repo.Private { + return "", fmt.Errorf("%s is private, and only public repositories have security advisories", cfg.Repository) + } + runner := org + "/" + cfg.Runner + if _, err := gh.GetRepository(ctx, runner); err == nil { + return "", fmt.Errorf("%s already exists", runner) + } else if !errors.Is(err, github.ErrNotFound) { + return "", fmt.Errorf("reading %s: %w", runner, err) + } + // GitHub only tells a taken App name on its creation page, once the browser is open. The name is looked up as + // the slug, so a name GitHub turns into another slug is still left to that page. + taken, err := gh.AppExists(ctx, cfg.AppName) + if err != nil { + return "", fmt.Errorf("reading the GitHub App %s: %w", cfg.AppName, err) + } + if taken { + return "", fmt.Errorf("the GitHub App %s already exists", cfg.AppName) + } + tag, sha, err := gh.LatestRelease(ctx, "php/tamis") + if err != nil { + return "", fmt.Errorf("reading the latest tamis release: %w", err) + } + + permissions := map[string]string{"repository_advisories": "write"} + if cfg.Board { + permissions["organization_projects"] = "write" + } + code, err := app(ctx, org, map[string]any{ + "name": cfg.AppName, + "url": "https://github.com/php/tamis", + "description": "First-pass triage of new security advisories", + "public": false, + "hook_attributes": map[string]any{"url": "https://github.com/php/tamis", "active": false}, + "default_permissions": permissions, + }) + if err != nil { + return "", err + } + a, err := gh.ConvertManifest(ctx, code) + if err != nil { + return "", fmt.Errorf("reading the credentials of the GitHub App: %w", err) + } + fmt.Fprintf(out, "Created the GitHub App %s\n", a.Slug) + + r, err := gh.CreateRepository(ctx, org, cfg.Runner) + if err != nil { + return "", fmt.Errorf("creating %s: %w", runner, err) + } + if err := gh.CreateEnvironment(ctx, runner, "tamis", r.DefaultBranch); err != nil { + return "", fmt.Errorf("creating the tamis environment of %s: %w", runner, err) + } + secret := "ANTHROPIC_API_KEY" + if cfg.Engine == "openai" { + secret = "OPENAI_API_KEY" + } + if err := gh.SetEnvironmentVariable(ctx, runner, "tamis", "TAMIS_CLIENT_ID", a.ClientID); err != nil { + return "", fmt.Errorf("setting TAMIS_CLIENT_ID: %w", err) + } + for _, s := range [][2]string{{"TAMIS_PRIVATE_KEY", a.PEM}, {secret, cfg.APIKey}} { + if err := gh.SetEnvironmentSecret(ctx, runner, "tamis", s[0], s[1]); err != nil { + return "", fmt.Errorf("setting %s: %w", s[0], err) + } + } + fmt.Fprintf(out, "Created the private repository %s and its tamis environment\n", runner) + + project := "" + if cfg.Board { + if project, err = board.Create(ctx, gh, org, "tamis"); err != nil { + return "", err + } + fmt.Fprintf(out, "Created the project board %s\n", project) + } + var w strings.Builder + if err := workflow.Execute(&w, map[string]any{ + "Org": org, "Name": name, "Repository": cfg.Repository, "SHA": sha, "Tag": tag, "Since": cfg.Since, + "Engine": cfg.Engine, "Secret": secret, "Slug": a.Slug, "References": strings.Join(cfg.References, " "), "Project": project, + }); err != nil { + return "", err + } + if err := gh.CreateFile(ctx, runner, ".github/workflows/tamis.yml", "Add the tamis workflow", []byte(w.String())); err != nil { + return "", fmt.Errorf("adding the workflow to %s: %w", runner, err) + } + fmt.Fprintf(out, "Added the workflow, pinned to tamis %s\n", tag) + + return fmt.Sprintf("https://github.com/apps/%s/installations/new", a.Slug), nil +} + +// workflow is the workflow of the README quick start, filled in. +var workflow = template.Must(template.New("workflow").Delims("[[", "]]").Parse(`name: tamis + +on: + schedule: + - cron: "*/15 * * * *" + workflow_dispatch: + inputs: + advisory: + description: GHSA to triage + required: false + +concurrency: + group: tamis + +jobs: + triage: + runs-on: ubuntu-latest + timeout-minutes: 240 + environment: tamis + permissions: + contents: read + steps: + - uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + id: app + with: + client-id: ${{ vars.TAMIS_CLIENT_ID }} + private-key: ${{ secrets.TAMIS_PRIVATE_KEY }} + owner: [[.Org]] + repositories: [[.Name]] + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: [[.Repository]] + fetch-depth: 0 + persist-credentials: false + - uses: php/tamis@[[.SHA]] # [[.Tag]] + with: + repository: [[.Repository]] + since: "[[.Since]]" + advisory: ${{ inputs.advisory }} +[[- if eq .Engine "openai"]] + engine: openai +[[- end]] + api-key: ${{ secrets.[[.Secret]] }} + github-token: ${{ steps.app.outputs.token }} + bot-login: [[.Slug]][bot] + references: [[.References]] +[[- if .Project]] + project: [[.Project]] +[[- else]] + comment: true +[[- end]] +`)) diff --git a/internal/setup/setup_test.go b/internal/setup/setup_test.go new file mode 100644 index 0000000..0a3ecac --- /dev/null +++ b/internal/setup/setup_test.go @@ -0,0 +1,238 @@ +package setup + +import ( + "context" + "errors" + "io" + "net/http" + "os" + "regexp" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "go.yaml.in/yaml/v3" + + "github.com/php/tamis/internal/board/boardtest" + "github.com/php/tamis/internal/github" +) + +type fakeGitHub struct { + *boardtest.Project + // repos says which repositories exist and whether they are private. + repos map[string]bool + apps map[string]bool + created []string + branch string + variables map[string]string + secrets map[string]string + files map[string]string + secretErr error +} + +func newFake() *fakeGitHub { + p := boardtest.New() + p.Fields = p.Fields[:1] + + return &fakeGitHub{Project: p, repos: map[string]bool{"acme/project": false}, variables: map[string]string{}, secrets: map[string]string{}, files: map[string]string{}} +} + +func (f *fakeGitHub) GetRepository(_ context.Context, repo string) (github.Repository, error) { + private, ok := f.repos[repo] + if !ok { + return github.Repository{}, &github.HTTPError{Status: http.StatusNotFound} + } + + return github.Repository{Private: private}, nil +} + +func (f *fakeGitHub) AppExists(_ context.Context, slug string) (bool, error) { + return f.apps[slug], nil +} + +func (f *fakeGitHub) LatestRelease(context.Context, string) (string, string, error) { + return "v2026.10.30.1", "0123456789abcdef0123456789abcdef01234567", nil +} + +func (f *fakeGitHub) ConvertManifest(_ context.Context, code string) (github.App, error) { + if code != "c0de" { + return github.App{}, errors.New("unknown code") + } + + return github.App{Slug: "acme-tamis", ClientID: "Iv1", PEM: "PEM"}, nil +} + +func (f *fakeGitHub) CreateRepository(_ context.Context, org, name string) (github.Repository, error) { + f.created = append(f.created, org+"/"+name) + + return github.Repository{Private: true, DefaultBranch: "main"}, nil +} + +func (f *fakeGitHub) CreateEnvironment(_ context.Context, _, _, branch string) error { + f.branch = branch + + return nil +} + +func (f *fakeGitHub) SetEnvironmentVariable(_ context.Context, _, _, name, value string) error { + f.variables[name] = value + + return nil +} + +func (f *fakeGitHub) SetEnvironmentSecret(_ context.Context, _, _, name, value string) error { + f.secrets[name] = value + + return f.secretErr +} + +func (f *fakeGitHub) CreateFile(_ context.Context, repo, path, _ string, content []byte) error { + f.files[repo+"/"+path] = string(content) + + return nil +} + +func config() Config { + return Config{Repository: "acme/project", Runner: "tamis-runner", References: []string{"main", "origin/release-1"}, Engine: "claude", APIKey: "sk-test", AppName: "acme-tamis", Since: "2026-10-31"} +} + +type flow struct { + org string + manifest map[string]any + err error +} + +func (fl *flow) run(_ context.Context, org string, manifest map[string]any) (string, error) { + fl.org, fl.manifest = org, manifest + + return "c0de", fl.err +} + +// step reads the with: inputs of the tamis step of a workflow. +func step(t *testing.T, workflow string) map[string]string { + t.Helper() + var w struct { + Jobs map[string]struct { + Environment string `yaml:"environment"` + Steps []struct { + Uses string `yaml:"uses"` + With map[string]string `yaml:"with"` + } `yaml:"steps"` + } `yaml:"jobs"` + } + require.NoError(t, yaml.Unmarshal([]byte(workflow), &w)) + require.Equal(t, "tamis", w.Jobs["triage"].Environment) + for _, s := range w.Jobs["triage"].Steps { + if strings.HasPrefix(s.Uses, "php/tamis@") { + require.Equal(t, "php/tamis@0123456789abcdef0123456789abcdef01234567", s.Uses) + return s.With + } + } + t.Fatal("no tamis step") + + return nil +} + +func TestRunInstallsTamisWithComments(t *testing.T) { + gh, fl := newFake(), &flow{} + + install, err := Run(t.Context(), config(), gh, fl.run, io.Discard) + + require.NoError(t, err) + require.Equal(t, "https://github.com/apps/acme-tamis/installations/new", install) + require.Equal(t, "acme", fl.org) + require.Equal(t, map[string]string{"repository_advisories": "write"}, fl.manifest["default_permissions"]) + require.Equal(t, []string{"acme/tamis-runner"}, gh.created) + require.Equal(t, "main", gh.branch) + require.Equal(t, map[string]string{"TAMIS_CLIENT_ID": "Iv1"}, gh.variables) + require.Equal(t, map[string]string{"TAMIS_PRIVATE_KEY": "PEM", "ANTHROPIC_API_KEY": "sk-test"}, gh.secrets) + with := step(t, gh.files["acme/tamis-runner/.github/workflows/tamis.yml"]) + require.Equal(t, map[string]string{ + "repository": "acme/project", + "since": "2026-10-31", + "advisory": "${{ inputs.advisory }}", + "api-key": "${{ secrets.ANTHROPIC_API_KEY }}", + "github-token": "${{ steps.app.outputs.token }}", + "bot-login": "acme-tamis[bot]", + "references": "main origin/release-1", + "comment": "true", + }, with) + require.Empty(t, gh.Views, "no board without -board") +} + +func TestRunInstallsTamisWithABoardAndOpenAI(t *testing.T) { + gh, fl := newFake(), &flow{} + cfg := config() + cfg.Board, cfg.Engine = true, "openai" + + _, err := Run(t.Context(), cfg, gh, fl.run, io.Discard) + + require.NoError(t, err) + require.Equal(t, map[string]string{"repository_advisories": "write", "organization_projects": "write"}, fl.manifest["default_permissions"]) + require.Equal(t, map[string]string{"TAMIS_PRIVATE_KEY": "PEM", "OPENAI_API_KEY": "sk-test"}, gh.secrets) + with := step(t, gh.files["acme/tamis-runner/.github/workflows/tamis.yml"]) + require.Equal(t, "acme/16", with["project"]) + require.Equal(t, "openai", with["engine"]) + require.Equal(t, "${{ secrets.OPENAI_API_KEY }}", with["api-key"]) + require.NotContains(t, with, "comment") + require.Len(t, gh.Views, 1) +} + +func TestRunStopsBeforeCreatingAnything(t *testing.T) { + for name, tc := range map[string]struct { + change func(*Config, *fakeGitHub, *flow) + err string + }{ + "no org": {func(c *Config, _ *fakeGitHub, _ *flow) { c.Repository = "project" }, `the repository must be given as "org/name"`}, + "no project": {func(c *Config, _ *fakeGitHub, _ *flow) { c.Repository = "acme/missing" }, "reading acme/missing: github: HTTP 404"}, + "private project": {func(_ *Config, gh *fakeGitHub, _ *flow) { gh.repos["acme/project"] = true }, "acme/project is private, and only public repositories have security advisories"}, + "app exists": {func(_ *Config, gh *fakeGitHub, _ *flow) { gh.apps = map[string]bool{"acme-tamis": true} }, "the GitHub App acme-tamis already exists"}, + "runner exists": {func(_ *Config, gh *fakeGitHub, _ *flow) { gh.repos["acme/tamis-runner"] = true }, "acme/tamis-runner already exists"}, + "app not created": {func(_ *Config, _ *fakeGitHub, fl *flow) { fl.err = errors.New("the GitHub App was not created") }, "the GitHub App was not created"}, + } { + t.Run(name, func(t *testing.T) { + cfg, gh, fl := config(), newFake(), &flow{} + tc.change(&cfg, gh, fl) + + _, err := Run(t.Context(), cfg, gh, fl.run, io.Discard) + + require.EqualError(t, err, tc.err) + require.Empty(t, gh.created) + }) + } +} + +func TestRunReportsAFailedSecret(t *testing.T) { + gh := newFake() + gh.secretErr = errors.New("boom") + + _, err := Run(t.Context(), config(), gh, (&flow{}).run, io.Discard) + + require.EqualError(t, err, "setting TAMIS_PRIVATE_KEY: boom") + require.Empty(t, gh.files) +} + +func TestRunReportsAFailedBoard(t *testing.T) { + gh := newFake() + gh.Err = errors.New("boom") + cfg := config() + cfg.Board = true + + _, err := Run(t.Context(), cfg, gh, (&flow{}).run, io.Discard) + + require.EqualError(t, err, "creating a project of acme: boom") + require.Empty(t, gh.files) +} + +// The README workflow and the generated one pin the same actions, which Renovate updates in neither. +func TestWorkflowPinsTheActionsOfTheREADME(t *testing.T) { + readme, err := os.ReadFile("../../README.md") + require.NoError(t, err) + var w strings.Builder + require.NoError(t, workflow.Execute(&w, map[string]any{"Engine": "claude"})) + pins := regexp.MustCompile(`uses: (actions/\S+ # \S+)`) + + want := pins.FindAllStringSubmatch(string(readme), -1) + require.NotEmpty(t, want) + require.Equal(t, want, pins.FindAllStringSubmatch(w.String(), -1)) +} diff --git a/setup-skill/SKILL.md b/setup-skill/SKILL.md new file mode 100644 index 0000000..91ea4a4 --- /dev/null +++ b/setup-skill/SKILL.md @@ -0,0 +1,70 @@ +--- +name: tamis-setup +description: Install tamis, the first-pass triage of GitHub security advisories, for an open source project. Gathers what the setup needs, composes the `tamis init` command for the user to run, and walks them through installing the GitHub App and the first run. Use when someone wants to set up, install or try tamis on their project or organisation. +--- + +# tamis setup + +You help a maintainer install tamis on their project. `tamis init` does almost everything: it registers the GitHub App, creates a private runner repository with its `tamis` environment and secrets, writes the workflow pinned to the latest release and, with `-board`, creates the project board. Your job is to gather its inputs, check the prerequisites, hand over the command and guide the two clicks it cannot do. [docs/setup.md](https://github.com/php/tamis/blob/main/docs/setup.md) is the reference when something below is not enough. + +Never ask for the model provider key, never put it in a command you run or show it in the conversation. The user types it in their own terminal. + +## 1. Gather the inputs + +Ask only for what you cannot find yourself: + +1. The project, as `org/name`. Check it with `gh repo view org/name --json visibility,defaultBranchRef`. It must be public, since GitHub only serves security advisories for public repositories, and it must belong to an organisation, since the App is created there. `tamis init` refuses a private project anyway. +2. The supported branches. Read the security policy (`SECURITY.md`, `.github/SECURITY.md`) or the release branches with `gh api repos/org/name/branches --paginate --jq '.[].name'`, then confirm them with the user. The workflow checks the project out with its full history, so every branch other than the default one is written `origin/`, the analysed one first: `-references "master origin/PHP-8.4 origin/PHP-8.3"`, or `-references main` for a project that only supports its default branch. +3. The engine: `claude` with an Anthropic API key, the default, or `openai` with an OpenAI API key or a Codex access token. +4. Comments or board. With comments, the reporter reads the triage on the advisory, since GitHub cannot post confidential comments yet. With `-board`, the triage only lands on a private organisation project the security team reads. Make sure the user knows the reporter reads the comment before they pick comments. `-board` always creates a new project. To reuse an existing tamis board, run `init` without `-board`, then replace `comment: true` with `project: org/NUMBER` in the workflow it writes and give the App the organisation "Projects" permission in read and write, which only `-board` asks for. + +The runner repository defaults to `org/tamis-runner` and the App to `org-tamis`. Change them with `-runner` and `-app-name` only if those names are taken or the user asks. + +A name can be taken by an earlier install or attempt. `gh repo view org/tamis-runner` and `gh api apps/org-tamis` both succeed then, and `init` stops before creating anything. Ask the user whether that leftover is still in use. If it is not, the user deletes it, the App from the organisation settings under "GitHub Apps", rather than adding a second one: an App left installed on the project keeps its write access to the advisories. If it is, pick other names. + +## 2. Check the prerequisites + +- The user is an owner of the organisation, which creating an organisation App needs. `gh api user/memberships/orgs/ORG --jq .role` answers `admin`. +- The runner repository and the App name are free, as section 1 describes. +- The `gh` token has the `project` and `workflow` scopes. `gh auth status` lists them, `gh auth refresh -s project,workflow` adds them. +- The provider key comes from a dedicated project or account with a hard spending limit, since alerts alone do not stop charges, and the user accepts that reports are sent to that provider. +- The tamis binary of the latest release. Pick the asset from `uname -sm`, `Darwin arm64` giving `tamis-darwin-arm64`, download it and check its build attestation: + +```bash +gh release download -R php/tamis --pattern 'tamis-darwin-arm64' --output tamis +gh attestation verify tamis -R php/tamis --signer-workflow php/tamis/.github/workflows/release.yml +chmod +x tamis +``` + +The assets are `tamis-{darwin,linux}-{amd64,arm64}` and `tamis-windows-{amd64,arm64}.exe`. `./tamis init -h` lists the flags of `init`. A release without `init` answers `REPOSITORY is required` instead, and the setup then follows docs/setup.md by hand. + +## 3. Hand over the command + +Write the command with the inputs filled in and ask the user to run it in their own terminal, not through you, so the key stays out of the conversation and the browser step runs on their machine: + +```bash +read -rs LLM_API_KEY && export LLM_API_KEY && GH_TOKEN=$(gh auth token) ./tamis init -repository org/name -references "main origin/release-1" -board +``` + +`read -rs` waits for the key without echoing it. Add `-engine openai` for an OpenAI key or a Codex access token. + +The command opens the browser on a GitHub page that creates the App. The user checks the organisation and the name, then clicks "Create GitHub App". The command goes on alone and prints what it created, then the installation address. + +When it fails before the App is created, fix the cause and run it again. When it fails after, the App and maybe the runner repository already exist. Have the user delete them, the App from the organisation settings under "GitHub Apps", before running it again, or finish the remaining steps by hand from docs/setup.md. + +## 4. Install the App + +The installation address opens by itself. The user picks "Only select repositories" and selects the project only. Never "All repositories": the App can write advisories wherever it is installed. + +## 5. First run + +Trigger the workflow on a test advisory of the project, in any state. A project without one can get a draft from its Security tab, under "Advisories" then "New draft security advisory", which the user closes once the test is over: + +```bash +gh workflow run tamis.yml -R org/tamis-runner -f advisory=GHSA-xxxx-xxxx-xxxx +gh run watch -R org/tamis-runner +``` + +Without a test advisory, a run without `advisory` only triages the advisories created since the setup day, usually none, which still checks the App, the secrets and the checkout. Then check the result where it lands: the comment on the advisory, or the item on the board. The private `tamis-reports` artifact of the run holds the full report, and access to it follows the permissions of the runner repository, so grant that repository to the security team only. + +After that the schedule polls every 15 minutes. `.github/tamis.md` in the project tells the agent what it cannot guess, such as the scope or how the project scores CVSS, and is worth writing once the first triages are in.