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.
379 lines
13 KiB
Go
379 lines
13 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// GitHub App user authorization: the browser is sent to GitHub, the callback exchanges the
|
|
// code for a user token held in memory against the session, and clones use that token.
|
|
type githubApp struct {
|
|
clientID, clientSecret string
|
|
slug string // optional; enables the "grant access to more repositories" link
|
|
webURL, apiURL string // github.com endpoints, overridden in tests
|
|
}
|
|
|
|
type githubAuth struct {
|
|
token string
|
|
login string
|
|
expires time.Time
|
|
state string
|
|
stateExpires time.Time
|
|
}
|
|
|
|
var githubClient = &http.Client{Timeout: 15 * time.Second}
|
|
|
|
func githubAppFromEnv() githubApp {
|
|
return githubApp{
|
|
clientID: os.Getenv("GITHUB_CLIENT_ID"),
|
|
clientSecret: os.Getenv("GITHUB_CLIENT_SECRET"),
|
|
slug: os.Getenv("GITHUB_APP_SLUG"),
|
|
webURL: "https://github.com",
|
|
apiURL: "https://api.github.com",
|
|
}
|
|
}
|
|
|
|
func (g githubApp) configured() bool { return g.clientID != "" && g.clientSecret != "" }
|
|
|
|
func (g githubApp) installURL() string {
|
|
if g.slug == "" {
|
|
return ""
|
|
}
|
|
return g.webURL + "/apps/" + url.PathEscape(g.slug) + "/installations/new"
|
|
}
|
|
|
|
func (s *Server) githubRoute(w http.ResponseWriter, r *http.Request, action string) {
|
|
switch {
|
|
case action == "status" && r.Method == http.MethodGet:
|
|
auth := s.githubAuthFor(sessionID(nil, r))
|
|
writeJSON(w, 200, map[string]any{
|
|
"configured": s.github.configured(),
|
|
"connected": auth.token != "",
|
|
"login": auth.login,
|
|
"installUrl": s.github.installURL(),
|
|
})
|
|
case !s.github.configured():
|
|
writeError(w, 404, "GitHub sign-in is not configured")
|
|
case action == "login" && r.Method == http.MethodGet:
|
|
sid := sessionID(w, r)
|
|
state := rand.Text()
|
|
s.authMu.Lock()
|
|
auth := s.auth[sid]
|
|
if auth == nil {
|
|
auth = &githubAuth{}
|
|
s.auth[sid] = auth
|
|
}
|
|
auth.state, auth.stateExpires = state, time.Now().Add(10*time.Minute)
|
|
s.authMu.Unlock()
|
|
query := url.Values{"client_id": {s.github.clientID}, "state": {state}}
|
|
http.Redirect(w, r, s.github.webURL+"/login/oauth/authorize?"+query.Encode(), http.StatusFound)
|
|
case action == "callback" && r.Method == http.MethodGet:
|
|
http.Redirect(w, r, "/?github="+s.githubCallback(r), http.StatusFound)
|
|
case action == "logout" && r.Method == http.MethodPost:
|
|
s.authMu.Lock()
|
|
delete(s.auth, sessionID(nil, r))
|
|
s.authMu.Unlock()
|
|
writeJSON(w, 200, map[string]bool{"ok": true})
|
|
default:
|
|
writeError(w, 404, "Unknown GitHub route")
|
|
}
|
|
}
|
|
|
|
// githubCallback completes sign-in and returns the outcome shown to the frontend.
|
|
func (s *Server) githubCallback(r *http.Request) string {
|
|
sid, query := sessionID(nil, r), r.URL.Query()
|
|
s.authMu.Lock()
|
|
auth := s.auth[sid]
|
|
valid := auth != nil && auth.state != "" && time.Now().Before(auth.stateExpires) &&
|
|
subtle.ConstantTimeCompare([]byte(auth.state), []byte(query.Get("state"))) == 1
|
|
if auth != nil {
|
|
auth.state = "" // single use
|
|
}
|
|
s.authMu.Unlock()
|
|
if !valid {
|
|
return "error"
|
|
}
|
|
if query.Get("code") == "" {
|
|
return "denied"
|
|
}
|
|
|
|
token, lifetime, err := s.github.exchangeCode(query.Get("code"))
|
|
if err != nil {
|
|
return "error"
|
|
}
|
|
login, err := s.github.userLogin(token)
|
|
if err != nil {
|
|
return "error"
|
|
}
|
|
s.authMu.Lock()
|
|
s.auth[sid] = &githubAuth{token: token, login: login, expires: time.Now().Add(lifetime)}
|
|
s.authMu.Unlock()
|
|
return "connected"
|
|
}
|
|
|
|
func (g githubApp) exchangeCode(code string) (string, time.Duration, error) {
|
|
form := url.Values{"client_id": {g.clientID}, "client_secret": {g.clientSecret}, "code": {code}}
|
|
req, _ := http.NewRequest("POST", g.webURL+"/login/oauth/access_token", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.Header.Set("Accept", "application/json")
|
|
res, err := githubClient.Do(req)
|
|
if err != nil {
|
|
return "", 0, err
|
|
}
|
|
defer res.Body.Close()
|
|
var body struct {
|
|
AccessToken string `json:"access_token"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
Error string `json:"error"`
|
|
}
|
|
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
|
|
return "", 0, err
|
|
}
|
|
if body.AccessToken == "" {
|
|
return "", 0, errors.New("token exchange failed: " + body.Error)
|
|
}
|
|
lifetime := time.Duration(body.ExpiresIn) * time.Second
|
|
if lifetime <= 0 {
|
|
lifetime = 8 * time.Hour // apps with token expiry disabled; still forget it eventually
|
|
}
|
|
return body.AccessToken, lifetime, nil
|
|
}
|
|
|
|
func (g githubApp) userLogin(token string) (string, error) {
|
|
req, _ := http.NewRequest("GET", g.apiURL+"/user", 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 "", err
|
|
}
|
|
defer res.Body.Close()
|
|
var body struct{ Login string }
|
|
if res.StatusCode != 200 || json.NewDecoder(res.Body).Decode(&body) != nil || body.Login == "" {
|
|
return "", errors.New("could not read GitHub user")
|
|
}
|
|
return body.Login, nil
|
|
}
|
|
|
|
// githubAuthFor returns a copy of the session's GitHub sign-in; token is "" when absent or expired.
|
|
func (s *Server) githubAuthFor(sid string) githubAuth {
|
|
s.authMu.Lock()
|
|
defer s.authMu.Unlock()
|
|
auth := s.auth[sid]
|
|
if sid == "" || auth == nil || auth.token == "" || time.Now().After(auth.expires) {
|
|
return githubAuth{}
|
|
}
|
|
return *auth
|
|
}
|
|
|
|
// gitClone builds the clone command. The token travels in environment-provided git config,
|
|
// never in the URL or arguments, so it cannot leak through process lists or error output.
|
|
func gitClone(ctx context.Context, repoURL, dest, token string) *exec.Cmd {
|
|
cmd := exec.CommandContext(ctx, "git", "clone", "--depth=1", "--single-branch", "--quiet", "--", repoURL, dest)
|
|
// Ignore the host's git config and credential helpers: only the visitor's own token may
|
|
// ever authenticate a clone.
|
|
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null",
|
|
"GIT_CONFIG_COUNT=1", "GIT_CONFIG_KEY_0=credential.helper", "GIT_CONFIG_VALUE_0=")
|
|
if token != "" {
|
|
basic := base64.StdEncoding.EncodeToString([]byte("x-access-token:" + token))
|
|
cmd.Env = append(cmd.Env,
|
|
"GIT_CONFIG_COUNT=2",
|
|
"GIT_CONFIG_KEY_1=http.https://github.com/.extraHeader",
|
|
"GIT_CONFIG_VALUE_1=Authorization: Basic "+basic)
|
|
}
|
|
return cmd
|
|
}
|
|
|
|
// cloneRepo clones with a timeout and returns git's output with the token masked.
|
|
func cloneRepo(repoURL, dest, token string) (string, error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), cloneTimeout)
|
|
defer cancel()
|
|
output, err := gitClone(ctx, repoURL, dest, token).CombinedOutput()
|
|
text := string(output)
|
|
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
|
text = "timed out after " + cloneTimeout.String()
|
|
}
|
|
if token != "" {
|
|
text = strings.ReplaceAll(text, token, "***")
|
|
}
|
|
return text, err
|
|
}
|
|
|
|
// cloneFailure turns git output into a user-facing message and whether signing in (or
|
|
// granting the app access) could fix it.
|
|
func (g githubApp) cloneFailure(output string, signedIn bool) (message string, authRequired string) {
|
|
lower := strings.ToLower(output)
|
|
needsAuth := false
|
|
for _, hint := range []string{"could not read username", "authentication failed", "repository not found", "terminal prompts disabled", "returned error: 403", "returned error: 401"} {
|
|
needsAuth = needsAuth || strings.Contains(lower, hint)
|
|
}
|
|
switch {
|
|
case !needsAuth:
|
|
return "git clone failed: " + strings.TrimSpace(output), ""
|
|
case !g.configured():
|
|
return "Repository not found. It may be private; load it with Open local folder instead.", ""
|
|
case !signedIn:
|
|
return "This repository is private or does not exist. Sign in with GitHub to open private repositories.", "signin"
|
|
default:
|
|
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 ""
|
|
}
|