Files
CodeNavigator/github_test.go
T
Andy Dixon c605e2b61d 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.
2026-06-27 15:33:55 +01:00

286 lines
11 KiB
Go

package main
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
func fakeGitHub(t *testing.T) *httptest.Server {
gh := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/login/oauth/access_token":
r.ParseForm()
if r.Form.Get("client_secret") != "secret" || r.Form.Get("code") != "good" {
json.NewEncoder(w).Encode(map[string]string{"error": "bad_verification_code"})
return
}
json.NewEncoder(w).Encode(map[string]any{"access_token": "ghu_token", "expires_in": 28800})
case "/user":
if r.Header.Get("Authorization") != "Bearer ghu_token" {
w.WriteHeader(401)
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", `<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:
w.WriteHeader(404)
}
}))
t.Cleanup(gh.Close)
return gh
}
func (c client) noRedirects() client {
jarClient := *c.http
jarClient.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
return client{c.t, c.url, &jarClient}
}
func (c client) location(path string) *url.URL {
c.t.Helper()
res, err := c.http.Get(c.url + path)
if err != nil {
c.t.Fatal(err)
}
res.Body.Close()
if res.StatusCode != http.StatusFound {
c.t.Fatalf("GET %s = %d, want redirect", path, res.StatusCode)
}
loc, _ := url.Parse(res.Header.Get("Location"))
return loc
}
type githubStatus struct {
Configured, Connected bool
Login, InstallURL string
}
func TestGitHubSignIn(t *testing.T) {
server, c := newTestServer(t)
c = c.noRedirects()
gh := fakeGitHub(t)
server.github = githubApp{clientID: "client", clientSecret: "secret", slug: "code-nav", webURL: gh.URL, apiURL: gh.URL}
var status githubStatus
c.json("GET", "/api/github/status", "", 200, &status)
if !status.Configured || status.Connected || status.InstallURL != gh.URL+"/apps/code-nav/installations/new" {
t.Fatalf("status before sign-in = %+v", status)
}
login := c.location("/api/github/login")
state := login.Query().Get("state")
if login.Path != "/login/oauth/authorize" || login.Query().Get("client_id") != "client" || state == "" {
t.Fatalf("authorize redirect = %s", login)
}
if got := c.location("/api/github/callback?code=good&state=forged").String(); got != "/?github=error" {
t.Fatalf("forged state redirect = %s", got)
}
// State is single use, so the forged attempt burned it.
if got := c.location("/api/github/callback?code=good&state=" + state).String(); got != "/?github=error" {
t.Fatalf("reused state redirect = %s", got)
}
state = c.location("/api/github/login").Query().Get("state")
if got := c.stranger().noRedirects().location("/api/github/callback?code=good&state=" + state).String(); got != "/?github=error" {
t.Fatalf("callback from another session = %s", got)
}
state = c.location("/api/github/login").Query().Get("state")
if got := c.location("/api/github/callback?code=good&state=" + state).String(); got != "/?github=connected" {
t.Fatalf("callback redirect = %s", got)
}
c.json("GET", "/api/github/status", "", 200, &status)
if !status.Connected || status.Login != "octocat" {
t.Fatalf("status after sign-in = %+v", status)
}
if raw, _ := json.Marshal(status); strings.Contains(string(raw), "ghu_token") {
t.Fatal("status leaked the token")
}
c.stranger().json("GET", "/api/github/status", "", 200, &status)
if status.Connected {
t.Fatal("another session sees the sign-in")
}
server.expire(time.Now().Add(9 * time.Hour))
c.json("GET", "/api/github/status", "", 200, &status)
if status.Connected || len(server.auth) != 0 {
t.Fatalf("expired token still present: %+v, %d entries", status, len(server.auth))
}
state = c.location("/api/github/login").Query().Get("state")
c.location("/api/github/callback?code=good&state=" + state)
c.json("POST", "/api/github/logout", "", 200, nil)
c.json("GET", "/api/github/status", "", 200, &status)
if status.Connected {
t.Fatal("still connected after logout")
}
}
func TestGitHubRoutesWhenUnconfigured(t *testing.T) {
server, c := newTestServer(t)
server.github = githubApp{}
var status githubStatus
c.json("GET", "/api/github/status", "", 200, &status)
if status.Configured {
t.Fatal("reported configured without credentials")
}
c.json("GET", "/api/github/login", "", 404, nil)
}
func TestGitCloneKeepsTokenOutOfArgsAndIgnoresHostCredentials(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not installed")
}
cmd := gitClone(t.Context(), "https://github.com/o/r", t.TempDir(), "ghu_secret")
if strings.Contains(strings.Join(cmd.Args, " "), "ghu_secret") {
t.Fatal("token appears in git arguments")
}
read := func(key string) string {
probe := exec.Command("git", "config", "--get-all", key)
probe.Env = cmd.Env
out, _ := probe.Output()
return strings.TrimSpace(string(out))
}
if got := read("http.https://github.com/.extraheader"); got != "Authorization: Basic eC1hY2Nlc3MtdG9rZW46Z2h1X3NlY3JldA==" {
t.Errorf("extraheader = %q", got)
}
if got := read("credential.helper"); got != "" {
t.Errorf("credential.helper = %q, want empty", got)
}
}
func TestCloneFailureMessages(t *testing.T) {
configured := githubApp{clientID: "id", clientSecret: "secret"}
private := "fatal: could not read Username for 'https://github.com': terminal prompts disabled"
for _, c := range []struct {
app githubApp
output string
signedIn bool
want string
}{
{configured, private, false, "signin"},
{configured, "remote: Repository not found.\nfatal: repository 'x' not found", true, "install"},
{githubApp{}, private, false, ""},
{configured, "fatal: unable to access: Could not resolve host: github.com", false, ""},
} {
message, got := c.app.cloneFailure(c.output, c.signedIn)
if got != c.want || message == "" {
t.Errorf("cloneFailure(%q, %v) = %q, %q; want %q", c.output, c.signedIn, message, got, c.want)
}
}
}
func TestCloneRepo(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not installed")
}
source := t.TempDir()
for _, args := range [][]string{{"init", "-q"}, {"-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "--allow-empty", "-m", "x"}} {
if out, err := exec.Command("git", append([]string{"-C", source}, args...)...).CombinedOutput(); err != nil {
t.Fatalf("git %v: %v %s", args, err, out)
}
}
if out, err := cloneRepo(source, filepath.Join(t.TempDir(), "ok"), ""); err != nil {
t.Fatalf("local clone failed: %v %s", err, out)
}
out, err := cloneRepo(filepath.Join(source, "missing-ghu_secret"), filepath.Join(t.TempDir(), "x"), "ghu_secret")
if err == nil || strings.Contains(out, "timed out") || strings.Contains(out, "ghu_secret") || !strings.Contains(out, "***") {
t.Fatalf("failed clone = %v %q; want a masked, non-timeout error", err, out)
}
defer func(old time.Duration) { cloneTimeout = old }(cloneTimeout)
cloneTimeout = time.Nanosecond
if out, err := cloneRepo(source, filepath.Join(t.TempDir(), "slow"), ""); err == nil || !strings.HasPrefix(out, "timed 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)
}
}