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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
105 changes: 104 additions & 1 deletion cmd/tamis/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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, "<org>-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"),
Expand Down
46 changes: 46 additions & 0 deletions cmd/tamis/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
20 changes: 19 additions & 1 deletion docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 |
|---|---|---|
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand Down
48 changes: 47 additions & 1 deletion internal/board/board.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
40 changes: 40 additions & 0 deletions internal/board/board_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}
}
Loading
Loading