Serve GitHub code scanning and Dependabot alerts per snapshot
GET /api/snapshots/{id}/alerts fetches open code scanning alerts
(located at the flagged file and line) and open Dependabot alerts
(located at the vulnerable manifest) for the snapshot's repository,
using the viewer's own GitHub token, in parallel and following Link
pagination only while it stays on the API host. Severities are
normalised to critical/high/medium/low. Each set reports ok, forbidden
(permission not granted or feature disabled) or unavailable; signed-out
viewers and local folders get a reason instead. Snapshots now remember
their GitHub repository URL.
This commit is contained in:
@@ -7,11 +7,13 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"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"
|
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 ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -28,6 +29,23 @@ func fakeGitHub(t *testing.T) *httptest.Server {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
json.NewEncoder(w).Encode(map[string]string{"login": "octocat"})
|
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", `<https://evil.example/steal>; 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:
|
default:
|
||||||
w.WriteHeader(404)
|
w.WriteHeader(404)
|
||||||
}
|
}
|
||||||
@@ -200,3 +218,68 @@ func TestCloneRepo(t *testing.T) {
|
|||||||
t.Fatalf("expired clone = %v %q; want timeout", err, out)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ type Edge struct {
|
|||||||
type Snapshot struct {
|
type Snapshot struct {
|
||||||
ID, Name, Source string
|
ID, Name, Source string
|
||||||
Owner string // session that created the snapshot
|
Owner string // session that created the snapshot
|
||||||
|
Repo string // GitHub repository URL, empty for local folders
|
||||||
Files []FileRecord
|
Files []FileRecord
|
||||||
Edges []Edge
|
Edges []Edge
|
||||||
TotalLines int
|
TotalLines int
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ type Job struct {
|
|||||||
// AuthRequired is "signin" or "install" when GitHub access would fix a failed clone.
|
// AuthRequired is "signin" or "install" when GitHub access would fix a failed clone.
|
||||||
AuthRequired string `json:"authRequired,omitempty"`
|
AuthRequired string `json:"authRequired,omitempty"`
|
||||||
root string
|
root string
|
||||||
|
repo string // https://github.com/owner/repo for GitHub jobs
|
||||||
owner string // session that created the job
|
owner string // session that created the job
|
||||||
updated time.Time // last progress, used to expire abandoned jobs
|
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())
|
writeError(w, 500, err.Error())
|
||||||
return
|
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 {
|
if request.Name != nil {
|
||||||
job.Name = *request.Name
|
job.Name = *request.Name
|
||||||
}
|
}
|
||||||
@@ -317,6 +318,10 @@ func (s *Server) snapshotRoute(w http.ResponseWriter, r *http.Request, parts []s
|
|||||||
}
|
}
|
||||||
s.snapshotsMu.Unlock()
|
s.snapshotsMu.Unlock()
|
||||||
}
|
}
|
||||||
|
case "alerts":
|
||||||
|
repo := snapshot.Repo
|
||||||
|
s.snapshotsMu.Unlock()
|
||||||
|
s.serveAlerts(w, r, repo)
|
||||||
case "search":
|
case "search":
|
||||||
hits := search(snapshot, r.URL.Query().Get("q"))
|
hits := search(snapshot, r.URL.Query().Get("q"))
|
||||||
s.snapshotsMu.Unlock()
|
s.snapshotsMu.Unlock()
|
||||||
@@ -568,7 +573,7 @@ func (s *Server) indexJob(id, root string) {
|
|||||||
}
|
}
|
||||||
s.jobsMu.Lock()
|
s.jobsMu.Lock()
|
||||||
if job := s.jobs[id]; job != nil {
|
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()
|
s.jobsMu.Unlock()
|
||||||
snapshot.ID = uniqueID("snapshot")
|
snapshot.ID = uniqueID("snapshot")
|
||||||
|
|||||||
Reference in New Issue
Block a user