diff --git a/github.go b/github.go index e3a2c1a..035864b 100644 --- a/github.go +++ b/github.go @@ -7,11 +7,13 @@ import ( "encoding/base64" "encoding/json" "errors" + "io" "net/http" "net/url" "os" "os/exec" "strings" + "sync" "time" ) @@ -229,3 +231,148 @@ func (g githubApp) cloneFailure(output string, signedIn bool) (message string, a return "Your GitHub account cannot open this repository through CodeNavigator. Grant the app access to it, then try again.", "install" } } + +// Security alert on a file, normalised across code scanning and Dependabot. +type securityAlert struct { + Path string `json:"path"` + Title string `json:"title"` + Severity string `json:"severity"` // critical, high, medium or low + URL string `json:"url"` + Line int `json:"line,omitempty"` +} + +type alertSet struct { + Status string `json:"status"` // ok, forbidden (no permission or feature off), unavailable + Alerts []securityAlert `json:"alerts"` +} + +// serveAlerts reports open code scanning and Dependabot alerts for a snapshot's repository, +// fetched with the viewer's own GitHub token so they only ever see what they are allowed to. +func (s *Server) serveAlerts(w http.ResponseWriter, r *http.Request, repo string) { + ownerRepo, ok := strings.CutPrefix(repo, "https://github.com/") + if !ok || repo == "" { + writeJSON(w, 200, map[string]any{"available": false, "reason": "not-github"}) + return + } + token := s.githubAuthFor(sessionID(nil, r)).token + if !s.github.configured() || token == "" { + writeJSON(w, 200, map[string]any{"available": false, "reason": "signin"}) + return + } + var codeScanning, dependabot alertSet + var wg sync.WaitGroup + wg.Go(func() { codeScanning = s.github.codeScanningAlerts(ownerRepo, token) }) + wg.Go(func() { dependabot = s.github.dependabotAlerts(ownerRepo, token) }) + wg.Wait() + writeJSON(w, 200, map[string]any{"available": true, "codeScanning": codeScanning, "dependabot": dependabot}) +} + +type codeScanningAlert struct { + HTMLURL string `json:"html_url"` + Rule struct { + ID string `json:"id"` + Severity string `json:"severity"` + SecuritySeverityLevel string `json:"security_severity_level"` + Description string `json:"description"` + } `json:"rule"` + Instance struct { + Location struct { + Path string `json:"path"` + StartLine int `json:"start_line"` + } `json:"location"` + } `json:"most_recent_instance"` +} + +func (g githubApp) codeScanningAlerts(ownerRepo, token string) alertSet { + raw, status := fetchPages[codeScanningAlert](g, "/repos/"+ownerRepo+"/code-scanning/alerts?state=open&per_page=100", token) + set := alertSet{Status: status, Alerts: []securityAlert{}} + for _, a := range raw { + severity := strings.ToLower(a.Rule.SecuritySeverityLevel) + if severity == "" { + severity = map[string]string{"error": "high", "warning": "medium", "note": "low"}[strings.ToLower(a.Rule.Severity)] + } + title := a.Rule.Description + if title == "" { + title = a.Rule.ID + } + set.Alerts = append(set.Alerts, securityAlert{a.Instance.Location.Path, title, orLow(severity), a.HTMLURL, a.Instance.Location.StartLine}) + } + return set +} + +type dependabotAlert struct { + HTMLURL string `json:"html_url"` + Dependency struct { + Package struct { + Name string `json:"name"` + } `json:"package"` + ManifestPath string `json:"manifest_path"` + } `json:"dependency"` + Advisory struct { + Summary string `json:"summary"` + Severity string `json:"severity"` + } `json:"security_advisory"` +} + +func (g githubApp) dependabotAlerts(ownerRepo, token string) alertSet { + raw, status := fetchPages[dependabotAlert](g, "/repos/"+ownerRepo+"/dependabot/alerts?state=open&per_page=100", token) + set := alertSet{Status: status, Alerts: []securityAlert{}} + for _, a := range raw { + title := a.Dependency.Package.Name + if a.Advisory.Summary != "" { + title += ": " + a.Advisory.Summary + } + set.Alerts = append(set.Alerts, securityAlert{a.Dependency.ManifestPath, title, orLow(strings.ToLower(a.Advisory.Severity)), a.HTMLURL, 0}) + } + return set +} + +func orLow(severity string) string { + switch severity { + case "critical", "high", "medium", "low": + return severity + } + return "low" +} + +// fetchPages decodes every page of a GitHub list endpoint, following Link rel="next" only while it +// stays on the API host, up to 10 pages. Status is ok, forbidden (no permission or feature off) or +// unavailable (404, network or decode failure). +func fetchPages[T any](g githubApp, path, token string) ([]T, string) { + var all []T + next := g.apiURL + path + for page := 0; next != "" && page < 10; page++ { + req, _ := http.NewRequest("GET", next, nil) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + res, err := githubClient.Do(req) + if err != nil { + return all, "unavailable" + } + var items []T + decodeErr := json.NewDecoder(io.LimitReader(res.Body, 32<<20)).Decode(&items) + res.Body.Close() + switch { + case res.StatusCode == 401 || res.StatusCode == 403: + return all, "forbidden" + case res.StatusCode != 200 || decodeErr != nil: + return all, "unavailable" + } + all = append(all, items...) + if next = nextLink(res.Header.Get("Link")); !strings.HasPrefix(next, g.apiURL+"/") { + next = "" + } + } + return all, "ok" +} + +func nextLink(header string) string { + for part := range strings.SplitSeq(header, ",") { + target, rel, ok := strings.Cut(part, ";") + if ok && strings.Contains(rel, `rel="next"`) { + return strings.Trim(strings.TrimSpace(target), "<>") + } + } + return "" +} diff --git a/github_test.go b/github_test.go index 81607e2..b697666 100644 --- a/github_test.go +++ b/github_test.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "fmt" "net/http" "net/http/httptest" "net/url" @@ -28,6 +29,23 @@ func fakeGitHub(t *testing.T) *httptest.Server { return } json.NewEncoder(w).Encode(map[string]string{"login": "octocat"}) + case "/repos/o/r/code-scanning/alerts": + if r.Header.Get("Authorization") != "Bearer ghu_token" { + w.WriteHeader(401) + return + } + if r.URL.Query().Get("page") == "2" { + // A next link off the API host must not be followed (it would leak the token). + w.Header().Set("Link", `; rel="next"`) + json.NewEncoder(w).Encode([]map[string]any{{"html_url": "https://github.com/o/r/security/code-scanning/2", "rule": map[string]any{"id": "go/sql-injection", "severity": "error", "description": "SQL injection"}, "most_recent_instance": map[string]any{"location": map[string]any{"path": "db/query.go", "start_line": 42}}}}) + return + } + w.Header().Set("Link", fmt.Sprintf(`<%s/repos/o/r/code-scanning/alerts?page=2>; rel="next", <%s/repos/o/r/code-scanning/alerts?page=2>; rel="last"`, "http://"+r.Host, "http://"+r.Host)) + json.NewEncoder(w).Encode([]map[string]any{{"html_url": "https://github.com/o/r/security/code-scanning/1", "rule": map[string]any{"id": "js/xss", "severity": "warning", "security_severity_level": "critical", "description": "XSS"}, "most_recent_instance": map[string]any{"location": map[string]any{"path": "web/app.js", "start_line": 7}}}}) + case "/repos/o/r/dependabot/alerts": + w.WriteHeader(403) + case "/steal": + t.Error("followed a pagination link to another host") default: w.WriteHeader(404) } @@ -200,3 +218,68 @@ func TestCloneRepo(t *testing.T) { t.Fatalf("expired clone = %v %q; want timeout", err, out) } } + +func signIn(t *testing.T, server *Server, c client, gh *httptest.Server) { + t.Helper() + server.github = githubApp{clientID: "client", clientSecret: "secret", webURL: gh.URL, apiURL: gh.URL} + nr := c.noRedirects() + state := nr.location("/api/github/login").Query().Get("state") + if got := nr.location("/api/github/callback?code=good&state=" + state).String(); got != "/?github=connected" { + t.Fatalf("sign-in failed: %s", got) + } +} + +func TestSecurityAlerts(t *testing.T) { + server, c := newTestServer(t) + gh := fakeGitHub(t) + _, snapshot := c.indexLocal(map[string]string{"web/app.js": "x"}) + url := "/api/snapshots/" + snapshot + "/alerts" + + var body struct { + Available bool + Reason string + CodeScanning alertSet + Dependabot alertSet + } + c.json("GET", url, "", 200, &body) + if body.Available || body.Reason != "not-github" { + t.Fatalf("local snapshot = %+v", body) + } + + server.snapshotsMu.Lock() + server.snapshots[snapshot].Repo = "https://github.com/o/r" + server.snapshotsMu.Unlock() + server.github = githubApp{clientID: "client", clientSecret: "secret", webURL: gh.URL, apiURL: gh.URL} + c.json("GET", url, "", 200, &body) + if body.Available || body.Reason != "signin" { + t.Fatalf("signed out = %+v", body) + } + + signIn(t, server, c, gh) + c.json("GET", url, "", 200, &body) + if !body.Available || body.CodeScanning.Status != "ok" || len(body.CodeScanning.Alerts) != 2 { + t.Fatalf("code scanning = %+v", body.CodeScanning) + } + first, second := body.CodeScanning.Alerts[0], body.CodeScanning.Alerts[1] + if first.Path != "web/app.js" || first.Severity != "critical" || first.Line != 7 || second.Path != "db/query.go" || second.Severity != "high" || second.Title != "SQL injection" { + t.Fatalf("alerts = %+v", body.CodeScanning.Alerts) + } + if body.Dependabot.Status != "forbidden" || body.Dependabot.Alerts == nil || len(body.Dependabot.Alerts) != 0 { + t.Fatalf("dependabot = %+v", body.Dependabot) + } + c.stranger().json("GET", url, "", 404, nil) +} + +func TestDependabotAlertsMapToManifests(t *testing.T) { + gh := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode([]map[string]any{ + {"html_url": "https://github.com/o/r/security/dependabot/3", "dependency": map[string]any{"package": map[string]any{"name": "lodash"}, "manifest_path": "web/package-lock.json"}, "security_advisory": map[string]any{"summary": "Prototype pollution", "severity": "HIGH"}}, + {"html_url": "https://github.com/o/r/security/dependabot/4", "dependency": map[string]any{"package": map[string]any{"name": "x"}, "manifest_path": "go.mod"}, "security_advisory": map[string]any{"severity": "weird"}}, + }) + })) + defer gh.Close() + set := githubApp{apiURL: gh.URL}.dependabotAlerts("o/r", "t") + if set.Status != "ok" || len(set.Alerts) != 2 || set.Alerts[0].Path != "web/package-lock.json" || set.Alerts[0].Severity != "high" || set.Alerts[0].Title != "lodash: Prototype pollution" || set.Alerts[1].Severity != "low" { + t.Fatalf("dependabot = %+v", set) + } +} diff --git a/indexer.go b/indexer.go index f0e8d87..e4e00eb 100644 --- a/indexer.go +++ b/indexer.go @@ -48,6 +48,7 @@ type Edge struct { type Snapshot struct { ID, Name, Source string Owner string // session that created the snapshot + Repo string // GitHub repository URL, empty for local folders Files []FileRecord Edges []Edge TotalLines int diff --git a/main.go b/main.go index a754f40..b4ac37f 100644 --- a/main.go +++ b/main.go @@ -42,6 +42,7 @@ type Job struct { // AuthRequired is "signin" or "install" when GitHub access would fix a failed clone. AuthRequired string `json:"authRequired,omitempty"` root string + repo string // https://github.com/owner/repo for GitHub jobs owner string // session that created the job updated time.Time // last progress, used to expire abandoned jobs } @@ -181,7 +182,7 @@ func (s *Server) createJob(w http.ResponseWriter, r *http.Request) { writeError(w, 500, err.Error()) return } - job := &Job{ID: id, SourceKind: request.Kind, Name: "Local codebase", Phase: "receiving", Message: "Waiting for files…", Warnings: []string{}, root: root, owner: owner, updated: time.Now()} + job := &Job{ID: id, SourceKind: request.Kind, Name: "Local codebase", Phase: "receiving", Message: "Waiting for files…", Warnings: []string{}, root: root, owner: owner, updated: time.Now(), repo: url} if request.Name != nil { job.Name = *request.Name } @@ -317,6 +318,10 @@ func (s *Server) snapshotRoute(w http.ResponseWriter, r *http.Request, parts []s } s.snapshotsMu.Unlock() } + case "alerts": + repo := snapshot.Repo + s.snapshotsMu.Unlock() + s.serveAlerts(w, r, repo) case "search": hits := search(snapshot, r.URL.Query().Get("q")) s.snapshotsMu.Unlock() @@ -568,7 +573,7 @@ func (s *Server) indexJob(id, root string) { } s.jobsMu.Lock() if job := s.jobs[id]; job != nil { - snapshot.Name, snapshot.Source, snapshot.Owner = job.Name, job.SourceKind, job.owner + snapshot.Name, snapshot.Source, snapshot.Owner, snapshot.Repo = job.Name, job.SourceKind, job.owner, job.repo } s.jobsMu.Unlock() snapshot.ID = uniqueID("snapshot")