- Stats, keys, providers, scan, recon, dorks, config endpoints - JSON response wrappers with proper tags for all entities - Filtering, pagination, 404/204/202 status codes - SSE hub stub (full impl in task 2) - Resolved merge conflict in schema.sql - 16 passing tests covering all endpoints
482 lines
13 KiB
Go
482 lines
13 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/salvacybersec/keyhunter/pkg/dorks"
|
|
"github.com/salvacybersec/keyhunter/pkg/providers"
|
|
"github.com/salvacybersec/keyhunter/pkg/recon"
|
|
"github.com/salvacybersec/keyhunter/pkg/storage"
|
|
"github.com/spf13/viper"
|
|
)
|
|
|
|
// apiKey is the JSON-friendly representation of a finding with masked key value.
|
|
type apiKey struct {
|
|
ID int64 `json:"id"`
|
|
ScanID int64 `json:"scanId,omitempty"`
|
|
ProviderName string `json:"providerName"`
|
|
KeyValue string `json:"keyValue"`
|
|
KeyMasked string `json:"keyMasked"`
|
|
Confidence string `json:"confidence"`
|
|
SourcePath string `json:"sourcePath"`
|
|
SourceType string `json:"sourceType"`
|
|
LineNumber int `json:"lineNumber"`
|
|
Verified bool `json:"verified"`
|
|
VerifyStatus string `json:"verifyStatus,omitempty"`
|
|
VerifyHTTPCode int `json:"verifyHttpCode,omitempty"`
|
|
VerifyMetadata map[string]string `json:"verifyMetadata,omitempty"`
|
|
CreatedAt string `json:"createdAt"`
|
|
}
|
|
|
|
// mountAPI registers all /api/v1/* routes on the given router.
|
|
func (s *Server) mountAPI(r chi.Router) {
|
|
r.Route("/api/v1", func(r chi.Router) {
|
|
// Stats
|
|
r.Get("/stats", s.handleAPIStats)
|
|
|
|
// Keys
|
|
r.Get("/keys", s.handleAPIListKeys)
|
|
r.Get("/keys/{id}", s.handleAPIGetKey)
|
|
r.Delete("/keys/{id}", s.handleAPIDeleteKey)
|
|
|
|
// Providers
|
|
r.Get("/providers", s.handleAPIListProviders)
|
|
r.Get("/providers/{name}", s.handleAPIGetProvider)
|
|
|
|
// Scan
|
|
r.Post("/scan", s.handleAPIScan)
|
|
r.Get("/scan/progress", s.handleSSEScanProgress)
|
|
|
|
// Recon
|
|
r.Post("/recon", s.handleAPIRecon)
|
|
r.Get("/recon/progress", s.handleSSEReconProgress)
|
|
|
|
// Dorks
|
|
r.Get("/dorks", s.handleAPIListDorks)
|
|
r.Post("/dorks", s.handleAPIAddDork)
|
|
|
|
// Config
|
|
r.Get("/config", s.handleAPIGetConfig)
|
|
r.Put("/config", s.handleAPIUpdateConfig)
|
|
})
|
|
}
|
|
|
|
// --- Stats ---
|
|
|
|
func (s *Server) handleAPIStats(w http.ResponseWriter, r *http.Request) {
|
|
var totalKeys int
|
|
if s.cfg.DB != nil {
|
|
row := s.cfg.DB.SQL().QueryRow("SELECT COUNT(*) FROM findings")
|
|
_ = row.Scan(&totalKeys)
|
|
}
|
|
|
|
totalProviders := 0
|
|
if s.cfg.Providers != nil {
|
|
totalProviders = len(s.cfg.Providers.List())
|
|
}
|
|
|
|
reconSources := 0
|
|
if s.cfg.ReconEngine != nil {
|
|
reconSources = len(s.cfg.ReconEngine.List())
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"totalKeys": totalKeys,
|
|
"totalProviders": totalProviders,
|
|
"reconSources": reconSources,
|
|
})
|
|
}
|
|
|
|
// --- Keys ---
|
|
|
|
func (s *Server) handleAPIListKeys(w http.ResponseWriter, r *http.Request) {
|
|
if s.cfg.DB == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database not available"})
|
|
return
|
|
}
|
|
|
|
q := r.URL.Query()
|
|
f := storage.Filters{
|
|
Provider: q.Get("provider"),
|
|
Limit: intParam(q.Get("limit"), 50),
|
|
Offset: intParam(q.Get("offset"), 0),
|
|
}
|
|
|
|
if v := q.Get("verified"); v != "" {
|
|
b := v == "true" || v == "1"
|
|
f.Verified = &b
|
|
}
|
|
|
|
findings, err := s.cfg.DB.ListFindingsFiltered(s.cfg.EncKey, f)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
keys := make([]apiKey, 0, len(findings))
|
|
for _, f := range findings {
|
|
keys = append(keys, apiKey{
|
|
ID: f.ID,
|
|
ScanID: f.ScanID,
|
|
ProviderName: f.ProviderName,
|
|
KeyValue: "", // always masked
|
|
KeyMasked: f.KeyMasked,
|
|
Confidence: f.Confidence,
|
|
SourcePath: f.SourcePath,
|
|
SourceType: f.SourceType,
|
|
LineNumber: f.LineNumber,
|
|
Verified: f.Verified,
|
|
VerifyStatus: f.VerifyStatus,
|
|
VerifyHTTPCode: f.VerifyHTTPCode,
|
|
VerifyMetadata: f.VerifyMetadata,
|
|
CreatedAt: f.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
|
})
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, keys)
|
|
}
|
|
|
|
func (s *Server) handleAPIGetKey(w http.ResponseWriter, r *http.Request) {
|
|
if s.cfg.DB == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database not available"})
|
|
return
|
|
}
|
|
|
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
|
return
|
|
}
|
|
|
|
finding, err := s.cfg.DB.GetFinding(id, s.cfg.EncKey)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Return masked via apiKey struct
|
|
writeJSON(w, http.StatusOK, apiKey{
|
|
ID: finding.ID,
|
|
ScanID: finding.ScanID,
|
|
ProviderName: finding.ProviderName,
|
|
KeyValue: "", // always masked
|
|
KeyMasked: finding.KeyMasked,
|
|
Confidence: finding.Confidence,
|
|
SourcePath: finding.SourcePath,
|
|
SourceType: finding.SourceType,
|
|
LineNumber: finding.LineNumber,
|
|
Verified: finding.Verified,
|
|
VerifyStatus: finding.VerifyStatus,
|
|
VerifyHTTPCode: finding.VerifyHTTPCode,
|
|
VerifyMetadata: finding.VerifyMetadata,
|
|
CreatedAt: finding.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleAPIDeleteKey(w http.ResponseWriter, r *http.Request) {
|
|
if s.cfg.DB == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database not available"})
|
|
return
|
|
}
|
|
|
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
|
return
|
|
}
|
|
|
|
n, err := s.cfg.DB.DeleteFinding(id)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
if n == 0 {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// --- Providers ---
|
|
|
|
// apiProvider is the JSON-friendly representation of a provider.
|
|
type apiProvider struct {
|
|
Name string `json:"name"`
|
|
DisplayName string `json:"displayName"`
|
|
Tier int `json:"tier"`
|
|
LastVerified string `json:"lastVerified,omitempty"`
|
|
Keywords []string `json:"keywords"`
|
|
}
|
|
|
|
func toAPIProvider(p providers.Provider) apiProvider {
|
|
return apiProvider{
|
|
Name: p.Name,
|
|
DisplayName: p.DisplayName,
|
|
Tier: p.Tier,
|
|
LastVerified: p.LastVerified,
|
|
Keywords: p.Keywords,
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleAPIListProviders(w http.ResponseWriter, r *http.Request) {
|
|
if s.cfg.Providers == nil {
|
|
writeJSON(w, http.StatusOK, []interface{}{})
|
|
return
|
|
}
|
|
list := s.cfg.Providers.List()
|
|
out := make([]apiProvider, len(list))
|
|
for i, p := range list {
|
|
out[i] = toAPIProvider(p)
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
func (s *Server) handleAPIGetProvider(w http.ResponseWriter, r *http.Request) {
|
|
if s.cfg.Providers == nil {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
|
return
|
|
}
|
|
|
|
name := chi.URLParam(r, "name")
|
|
p, ok := s.cfg.Providers.Get(name)
|
|
if !ok {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, toAPIProvider(p))
|
|
}
|
|
|
|
// --- Scan ---
|
|
|
|
type scanRequest struct {
|
|
Path string `json:"path"`
|
|
Verify bool `json:"verify"`
|
|
Workers int `json:"workers"`
|
|
}
|
|
|
|
func (s *Server) handleAPIScan(w http.ResponseWriter, r *http.Request) {
|
|
var req scanRequest
|
|
if err := readJSON(r, &req); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Launch scan in background -- actual scan wiring happens when ScanEngine
|
|
// is available. For now, return 202 to indicate the request was accepted.
|
|
if s.cfg.ScanEngine != nil {
|
|
go func() {
|
|
// Background scan execution with SSE progress broadcasting.
|
|
// Full wiring deferred to serve command integration.
|
|
s.sse.Broadcast(SSEEvent{Type: "scan:started", Data: map[string]string{
|
|
"path": req.Path,
|
|
}})
|
|
}()
|
|
}
|
|
|
|
writeJSON(w, http.StatusAccepted, map[string]string{
|
|
"status": "started",
|
|
"message": "scan initiated",
|
|
})
|
|
}
|
|
|
|
// --- Recon ---
|
|
|
|
type reconRequest struct {
|
|
Query string `json:"query"`
|
|
Sources []string `json:"sources"`
|
|
Stealth bool `json:"stealth"`
|
|
}
|
|
|
|
func (s *Server) handleAPIRecon(w http.ResponseWriter, r *http.Request) {
|
|
var req reconRequest
|
|
if err := readJSON(r, &req); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
if s.cfg.ReconEngine != nil {
|
|
go func() {
|
|
cfg := recon.Config{
|
|
Query: req.Query,
|
|
EnabledSources: req.Sources,
|
|
Stealth: req.Stealth,
|
|
}
|
|
s.sse.Broadcast(SSEEvent{Type: "recon:started", Data: map[string]string{
|
|
"query": req.Query,
|
|
}})
|
|
findings, err := s.cfg.ReconEngine.SweepAll(context.Background(), cfg)
|
|
if err != nil {
|
|
s.sse.Broadcast(SSEEvent{Type: "recon:error", Data: map[string]string{
|
|
"error": err.Error(),
|
|
}})
|
|
return
|
|
}
|
|
for _, f := range findings {
|
|
s.sse.Broadcast(SSEEvent{Type: "recon:finding", Data: f})
|
|
}
|
|
s.sse.Broadcast(SSEEvent{Type: "recon:complete", Data: map[string]int{
|
|
"total": len(findings),
|
|
}})
|
|
}()
|
|
}
|
|
|
|
writeJSON(w, http.StatusAccepted, map[string]string{
|
|
"status": "started",
|
|
"message": "recon initiated",
|
|
})
|
|
}
|
|
|
|
// --- Dorks ---
|
|
|
|
// apiDork is the JSON-friendly representation of a dork.
|
|
type apiDork struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Source string `json:"source"`
|
|
Category string `json:"category"`
|
|
Query string `json:"query"`
|
|
Description string `json:"description"`
|
|
Tags []string `json:"tags,omitempty"`
|
|
}
|
|
|
|
func toAPIDork(d dorks.Dork) apiDork {
|
|
return apiDork{
|
|
ID: d.ID,
|
|
Name: d.Name,
|
|
Source: d.Source,
|
|
Category: d.Category,
|
|
Query: d.Query,
|
|
Description: d.Description,
|
|
Tags: d.Tags,
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleAPIListDorks(w http.ResponseWriter, r *http.Request) {
|
|
source := r.URL.Query().Get("source")
|
|
|
|
var list []dorks.Dork
|
|
if source != "" && s.cfg.Dorks != nil {
|
|
list = s.cfg.Dorks.ListBySource(source)
|
|
} else if s.cfg.Dorks != nil {
|
|
list = s.cfg.Dorks.List()
|
|
}
|
|
|
|
out := make([]apiDork, len(list))
|
|
for i, d := range list {
|
|
out[i] = toAPIDork(d)
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
type addDorkRequest struct {
|
|
DorkID string `json:"dorkId"`
|
|
Name string `json:"name"`
|
|
Source string `json:"source"`
|
|
Category string `json:"category"`
|
|
Query string `json:"query"`
|
|
Description string `json:"description"`
|
|
Tags []string `json:"tags"`
|
|
}
|
|
|
|
func (s *Server) handleAPIAddDork(w http.ResponseWriter, r *http.Request) {
|
|
if s.cfg.DB == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database not available"})
|
|
return
|
|
}
|
|
|
|
var req addDorkRequest
|
|
if err := readJSON(r, &req); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
id, err := s.cfg.DB.SaveCustomDork(storage.CustomDork{
|
|
DorkID: req.DorkID,
|
|
Name: req.Name,
|
|
Source: req.Source,
|
|
Category: req.Category,
|
|
Query: req.Query,
|
|
Description: req.Description,
|
|
Tags: req.Tags,
|
|
})
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusCreated, map[string]interface{}{"id": id})
|
|
}
|
|
|
|
// --- Config ---
|
|
|
|
func (s *Server) handleAPIGetConfig(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, viper.AllSettings())
|
|
}
|
|
|
|
func (s *Server) handleAPIUpdateConfig(w http.ResponseWriter, r *http.Request) {
|
|
var settings map[string]interface{}
|
|
if err := readJSON(r, &settings); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
for k, v := range settings {
|
|
viper.Set(k, v)
|
|
}
|
|
|
|
// Attempt to persist; ignore error if no config file is set.
|
|
_ = viper.WriteConfig()
|
|
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
// writeJSON marshals v to JSON and writes it with the given HTTP status code.
|
|
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
if err := json.NewEncoder(w).Encode(v); err != nil {
|
|
// Best effort — headers already sent.
|
|
fmt.Fprintf(w, `{"error":"encode: %s"}`, err)
|
|
}
|
|
}
|
|
|
|
// readJSON decodes the request body into v.
|
|
func readJSON(r *http.Request, v interface{}) error {
|
|
if r.Body == nil {
|
|
return fmt.Errorf("empty request body")
|
|
}
|
|
defer r.Body.Close()
|
|
return json.NewDecoder(r.Body).Decode(v)
|
|
}
|
|
|
|
// intParam parses a query param as int, returning defaultVal on empty or error.
|
|
func intParam(s string, defaultVal int) int {
|
|
if s == "" {
|
|
return defaultVal
|
|
}
|
|
v, err := strconv.Atoi(s)
|
|
if err != nil || v < 0 {
|
|
return defaultVal
|
|
}
|
|
return v
|
|
}
|
|
|
|
// itoa is a small helper for int64 to string conversion.
|
|
func itoa(v int64) string {
|
|
return strconv.FormatInt(v, 10)
|
|
}
|