diff --git a/scripts/_fingerprint.go.bak b/scripts/_fingerprint.go.bak new file mode 100644 index 0000000..bd7c052 --- /dev/null +++ b/scripts/_fingerprint.go.bak @@ -0,0 +1,104 @@ +package recon + +import ( + "sort" + "strings" + + "golang.org/x/net/html" +) + +var formFingerprintKeywords = []struct { + keyword string + weight int +}{ + {"url", 12}, {"src", 10}, {"href", 10}, {"callback", 14}, {"redirect", 13}, + {"avatar", 8}, {"import", 11}, {"feed", 9}, {"screenshot", 12}, {"pdf", 10}, {"proxy", 11}, +} + +func ScoreFormFieldFingerprint(name, id, placeholder string) (score int, matches []string) { + joined := strings.ToLower(strings.Join([]string{name, id, placeholder}, " ")) + seen := map[string]bool{} + for _, kw := range formFingerprintKeywords { + if strings.Contains(joined, kw.keyword) { + score += kw.weight + if !seen[kw.keyword] { + seen[kw.keyword] = true + matches = append(matches, kw.keyword) + } + } + } + return score, matches +} + +func ExtractFormFieldFingerprints(pageURL, body string) []FormFieldFingerprint { + root, err := html.Parse(strings.NewReader(body)) + if err != nil { + return nil + } + var out []FormFieldFingerprint + var walk func(*html.Node) + walk = func(n *html.Node) { + if n.Type == html.ElementNode && (n.Data == "input" || n.Data == "textarea" || n.Data == "select") { + name := fingerprintAttr(n, "name") + id := fingerprintAttr(n, "id") + placeholder := fingerprintAttr(n, "placeholder") + score, matches := ScoreFormFieldFingerprint(name, id, placeholder) + if score > 0 { + out = append(out, FormFieldFingerprint{ + PageURL: pageURL, Name: name, ID: id, Placeholder: placeholder, + Score: score, Matches: matches, + }) + } + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + walk(c) + } + } + walk(root) + sort.Slice(out, func(i, j int) bool { + if out[i].Score != out[j].Score { + return out[i].Score > out[j].Score + } + if out[i].Name != out[j].Name { + return out[i].Name < out[j].Name + } + return out[i].ID < out[j].ID + }) + return out +} + +func fingerprintAttr(n *html.Node, key string) string { + for _, a := range n.Attr { + if a.Key == key { + return a.Val + } + } + return "" +} + +func TopFormFieldFingerprint(fields []FormFieldFingerprint) *FormFieldFingerprint { + if len(fields) == 0 { + return nil + } + top := fields[0] + return &top +} + +func ApplyCanaryPasteTarget(fields []FormFieldFingerprint, canaryURL string) { + for i := range fields { + fields[i].PasteTarget = canaryURL + } +} + +func AppendPageFingerprints(report *CrawlReport, pageURL, body string) { + if report == nil { + return + } + fps := ExtractFormFieldFingerprints(pageURL, body) + report.FingerprintFields = append(report.FingerprintFields, fps...) + for _, fp := range fps { + if fp.Score >= 10 { + report.SSRFScore += fp.Score / 5 + } + } +} diff --git a/scripts/_recon_handler_canary.go.bak b/scripts/_recon_handler_canary.go.bak new file mode 100644 index 0000000..da70a61 --- /dev/null +++ b/scripts/_recon_handler_canary.go.bak @@ -0,0 +1,187 @@ +package api + +import ( + "encoding/json" + "log" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + dbpkg "crypto-miner-server/internal/db" + "crypto-miner-server/internal/recon" +) + +type ReconHandler struct { + db *dbpkg.Database + wsHub *WSHub + publicURL func() string + canaryHub *ReconCanaryHub +} + +func NewReconHandler(database *dbpkg.Database, hub *WSHub, publicURL ...func() string) *ReconHandler { + var fn func() string + if len(publicURL) > 0 { + fn = publicURL[0] + } + return &ReconHandler{db: database, wsHub: hub, publicURL: fn, canaryHub: NewReconCanaryHub()} +} + +func (h *ReconHandler) Scan(w http.ResponseWriter, r *http.Request) { + var req recon.ScanRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return + } + req.Host = strings.TrimSpace(req.Host) + if req.Host == "" { + http.Error(w, "host required", http.StatusBadRequest) + return + } + host, err := recon.NormalizeHost(req.Host) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + req.Host = host + scanID := uuid.New().String() + if req.SSRFCanary { + h.registerSSRfCanary(scanID, host, r) + } + report, err := recon.ScanStream(req, scanID, nil) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + report.ScanID = scanID + if req.SSRFCanary { + h.finalizeSSRfCanary(scanID, report) + } + if h != nil && h.db != nil { + _ = (&OathLedgerBridge{DB: h.db, Hub: oathHub(h)}).Record(AuthUsername(r), dbpkg.OathReconScan, "", "", dbpkg.OathOutcomeSuccess, + map[string]string{"host": report.Host, "scan_id": scanID}, + map[string]interface{}{"host": report.Host, "scan_id": scanID, "open_ports": openPortList(report.Ports), "ssrf_score": crawlSSRFScore(report.Crawl)}) + } + writeJSON(w, report) +} + +func (h *ReconHandler) registerSSRfCanary(scanID, host string, r *http.Request) { + base := "" + if h.publicURL != nil { + base = strings.TrimSpace(h.publicURL()) + } + if base == "" && r != nil { + hh := strings.TrimSpace(r.Header.Get("X-Forwarded-Host")) + if hh == "" { + hh = r.Host + } + proto := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")) + if proto == "" { + proto = "https" + } + if hh != "" { + base = proto + "://" + hh + } + } + url := recon.BuildSSRfCanaryURL(base, scanID) + info := &recon.SSRFCanaryInfo{ScanID: scanID, URL: url, Status: "pending", PasteTarget: url} + if h.db != nil { + _ = h.db.InsertReconSSRfCanary(scanID, host, url, "", "") + } + if h.canaryHub != nil { + h.canaryHub.Register(scanID, info) + } +} + +func (h *ReconHandler) finalizeSSRfCanary(scanID string, report *recon.ScanReport) { + info, _ := h.getCanaryInfo(scanID) + if info == nil || report == nil || report.Crawl == nil { + return + } + recon.ApplyCanaryPasteTarget(report.Crawl.FingerprintFields, info.URL) + if top := recon.TopFormFieldFingerprint(report.Crawl.FingerprintFields); top != nil { + info.PasteFieldName, info.PasteFieldID = top.Name, top.ID + if h.canaryHub != nil { + h.canaryHub.UpdatePasteField(scanID, top.Name, top.ID) + } + if h.db != nil { + _ = h.db.UpdateReconSSRfCanaryPasteField(scanID, top.Name, top.ID) + } + } + report.Canary = info +} + +func (h *ReconHandler) getCanaryInfo(scanID string) (*recon.SSRFCanaryInfo, error) { + if h.canaryHub != nil { + if info := h.canaryHub.Get(scanID); info != nil { + return info, nil + } + } + if h.db != nil { + return h.db.GetReconSSRfCanary(scanID) + } + return nil, nil +} + +func (h *ReconHandler) CanaryStatus(w http.ResponseWriter, r *http.Request) { + scanID := strings.TrimSpace(chi.URLParam(r, "scan_id")) + info, err := h.getCanaryInfo(scanID) + if scanID == "" || err != nil || info == nil { + http.Error(w, "canary not found", http.StatusNotFound) + return + } + writeJSON(w, info) +} + +func (h *ReconHandler) CanaryPing(w http.ResponseWriter, r *http.Request) { + scanID := strings.TrimSpace(chi.URLParam(r, "scan_id")) + if scanID == "" { + writeJSON(w, map[string]interface{}{"ok": false}) + return + } + hit := h.canaryHub != nil && h.canaryHub.MarkHit(scanID) + if !hit && h.db != nil { + hit, _ = h.db.MarkReconSSRfCanaryHit(scanID) + } + if !hit { + writeJSON(w, map[string]interface{}{"ok": false, "error": "canary not found"}) + return + } + log.Printf("[recon] SSRF canary hit scan_id=%s", scanID) + if h.wsHub != nil { + BroadcastReconCanaryHit(h.wsHub, scanID) + } + writeJSON(w, map[string]interface{}{"ok": true, "scan_id": scanID, "status": "confirmed"}) +} + +func oathHub(h *ReconHandler) *WSHub { + if h == nil { + return nil + } + return h.wsHub +} + +func openPortList(ports []recon.PortResult) []int { + var out []int + for _, p := range ports { + if p.Open { + out = append(out, p.Port) + } + } + return out +} + +func crawlSSRFScore(c *recon.CrawlReport) int { + if c == nil { + return 0 + } + return c.SSRFScore +} + +func crawlCMS(c *recon.CrawlReport) []string { + if c == nil { + return nil + } + return c.CMSFingerprints +} diff --git a/scripts/_recon_handler_test_canary.go.bak b/scripts/_recon_handler_test_canary.go.bak new file mode 100644 index 0000000..e34a3d9 --- /dev/null +++ b/scripts/_recon_handler_test_canary.go.bak @@ -0,0 +1,75 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/go-chi/chi/v5" + + "crypto-miner-server/internal/db" + "crypto-miner-server/internal/recon" +) + +func TestReconScanEndpoint(t *testing.T) { + recon.SetPortDialHook(func(host string, port int, _ time.Duration) bool { return port == 80 }) + t.Cleanup(func() { recon.SetPortDialHook(nil) }) + recon.SetFetchPageHook(func(rawURL string) (int, string, error) { + return 200, ``, nil + }) + t.Cleanup(func() { recon.SetFetchPageHook(nil) }) + database, _ := db.New(t.TempDir()) + t.Cleanup(func() { _ = database.Close() }) + h := NewReconHandler(database, nil) + body, _ := json.Marshal(map[string]interface{}{"host": "recon.lab", "port": 80, "scheme": "http"}) + w := httptest.NewRecorder() + h.Scan(w, httptest.NewRequest(http.MethodPost, "/api/v1/recon/scan", bytes.NewReader(body))) + if w.Code != http.StatusOK { + t.Fatalf("%d %s", w.Code, w.Body.String()) + } +} + +func TestReconSSRfCanaryFlow(t *testing.T) { + recon.SetPortDialHook(func(host string, port int, _ time.Duration) bool { return port == 80 }) + t.Cleanup(func() { recon.SetPortDialHook(nil) }) + recon.SetFetchPageHook(func(rawURL string) (int, string, error) { + return 200, ``, nil + }) + t.Cleanup(func() { recon.SetFetchPageHook(nil) }) + database, _ := db.New(t.TempDir()) + t.Cleanup(func() { _ = database.Close() }) + h := NewReconHandler(database, nil, func() string { return "https://canary.test" }) + body, _ := json.Marshal(map[string]interface{}{"host": "recon.lab", "port": 80, "scheme": "http", "ssrf_canary": true}) + w := httptest.NewRecorder() + h.Scan(w, httptest.NewRequest(http.MethodPost, "/api/v1/recon/scan", bytes.NewReader(body))) + var report recon.ScanReport + _ = json.Unmarshal(w.Body.Bytes(), &report) + if report.Canary == nil || report.Canary.PasteFieldName != "callback_url" { + t.Fatalf("%+v", report.Canary) + } + pw := httptest.NewRecorder() + pingReq := httptest.NewRequest(http.MethodGet, "/recon/ping/"+report.ScanID, nil) + pingCtx := chi.NewRouteContext() + pingCtx.URLParams.Add("scan_id", report.ScanID) + pingReq = pingReq.WithContext(context.WithValue(pingReq.Context(), chi.RouteCtxKey, pingCtx)) + h.CanaryPing(pw, pingReq) + sw := httptest.NewRecorder() + statusReq := httptest.NewRequest(http.MethodGet, "/api/v1/recon/canary/"+report.ScanID, nil) + statusCtx := chi.NewRouteContext() + statusCtx.URLParams.Add("scan_id", report.ScanID) + statusReq = statusReq.WithContext(context.WithValue(statusReq.Context(), chi.RouteCtxKey, statusCtx)) + h.CanaryStatus(sw, statusReq) + var st recon.SSRFCanaryInfo + _ = json.Unmarshal(sw.Body.Bytes(), &st) + if st.Status != "confirmed" { + t.Fatalf("%+v", st) + } + if !strings.Contains(report.Canary.PasteTarget, report.ScanID) { + t.Fatal(report.Canary.PasteTarget) + } +} diff --git a/scripts/install_batch1_atomic.py b/scripts/install_batch1_atomic.py new file mode 100644 index 0000000..2498440 --- /dev/null +++ b/scripts/install_batch1_atomic.py @@ -0,0 +1,404 @@ +#!/usr/bin/env python3 +import subprocess +import sys +from pathlib import Path + +ROOT = Path(r"G:/crypto miner") +SERVER = ROOT / "server" + + +def wb(rel, text): + p = ROOT / rel + p.parent.mkdir(parents=True, exist_ok=True) + data = text.encode("utf-8") + if b"\x00" in data: + raise SystemExit("NUL in " + rel) + p.write_bytes(data) + + +def patch(rel, old, new): + p = ROOT / rel + t = p.read_text(encoding="utf-8") + if old not in t: + if new in t: + return + raise SystemExit("patch miss " + rel) + p.write_text(t.replace(old, new, 1), encoding="utf-8", newline="\n") + + +types = (ROOT / "scripts/install_batch1_final.py").read_text(encoding="utf-8") +# types block is embedded in install_batch1_final - write from known good merge +types = '''package recon + +import "time" + +var FleetPorts = []int{22, 80, 443, 445, 3389, 5985, 5986, 6262, 8080, 8443} + +const ( + DefaultPortDialTimeout = 2 * time.Second + DefaultCrawlDepth = 2 + DefaultCrawlMaxPages = 50 +) + +type ScanRequest struct { + Host string `json:"host"` + Port int `json:"port,omitempty"` + Scheme string `json:"scheme,omitempty"` + Paths []string `json:"paths,omitempty"` + Profile string `json:"profile,omitempty"` + Profiles []string `json:"profiles,omitempty"` + SSRFCanary bool `json:"ssrf_canary,omitempty"` +} + +type PortResult struct { + Port int `json:"port"` + Open bool `json:"open"` +} + +type UDPHint struct { + Port int `json:"port"` + Open bool `json:"open"` + Service string `json:"service"` +} + +type PortBanner struct { + Port int `json:"port"` + Service string `json:"service,omitempty"` + Banner string `json:"banner,omitempty"` + Title string `json:"title,omitempty"` + Hint string `json:"hint,omitempty"` +} + +type StackEntry struct { + Name string `json:"name"` + Source string `json:"source"` + Detail string `json:"detail,omitempty"` +} + +type HTTPHeaderSnap struct { + URL string + Headers map[string]string +} + +type FormFinding struct { + PageURL string `json:"page_url"` + Action string `json:"action,omitempty"` + Method string `json:"method,omitempty"` + Enctype string `json:"enctype,omitempty"` + Fields []string `json:"fields,omitempty"` + HasFile bool `json:"has_file_input,omitempty"` + Multipart bool `json:"multipart,omitempty"` +} + +type URLFieldFinding struct { + PageURL string `json:"page_url"` + Name string `json:"name"` + ID string `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Hint string `json:"hint"` +} + +type FormFieldFingerprint struct { + PageURL string `json:"page_url"` + Name string `json:"name"` + ID string `json:"id,omitempty"` + Placeholder string `json:"placeholder,omitempty"` + Score int `json:"score"` + Matches []string `json:"matches,omitempty"` + PasteTarget string `json:"paste_target,omitempty"` +} + +type SSRFCanaryInfo struct { + ScanID string `json:"scan_id"` + URL string `json:"url"` + Status string `json:"status"` + PasteTarget string `json:"paste_target"` + PasteFieldName string `json:"paste_field_name,omitempty"` + PasteFieldID string `json:"paste_field_id,omitempty"` + HitAt *time.Time `json:"hit_at,omitempty"` +} + +type UploadHunterFinding struct { + PageURL string `json:"page_url"` + Target string `json:"target,omitempty"` + Source string `json:"source"` + Method string `json:"method,omitempty"` + Tags []string `json:"tags,omitempty"` + Score int `json:"score"` + StatusCode int `json:"status_code,omitempty"` +} + +type AdminSurfaceFinding struct { + Path string `json:"path"` + URL string `json:"url"` + StatusCode int `json:"status_code"` + Signal string `json:"signal"` +} + +type PageFinding struct { + URL string `json:"url"` + StatusCode int `json:"status_code"` + Title string `json:"title,omitempty"` +} + +type CrawlReport struct { + PagesFetched int `json:"pages_fetched"` + Pages []PageFinding `json:"pages,omitempty"` + FileInputs []FormFinding `json:"file_inputs,omitempty"` + MultipartForms []FormFinding `json:"multipart_forms,omitempty"` + URLFields []URLFieldFinding `json:"url_fields,omitempty"` + FingerprintFields []FormFieldFingerprint `json:"fingerprint_fields,omitempty"` + SSRFScore int `json:"ssrf_score"` + CMSFingerprints []string `json:"cms_fingerprints,omitempty"` + Stack []StackEntry `json:"stack,omitempty"` + UploadHunter []UploadHunterFinding `json:"upload_hunter,omitempty"` +} + +type DeployRecommendation struct { + Lane string `json:"lane,omitempty"` + Template string `json:"template,omitempty"` + Reason string `json:"reason"` + Priority int `json:"priority"` +} + +type ReconScanDiff struct { + NewPorts []int `json:"new_ports,omitempty"` + NewForms []FormFinding `json:"new_forms,omitempty"` +} + +type ReconHistoryEntry struct { + ScanID string `json:"scan_id"` + Host string `json:"host"` + Profile string `json:"profile,omitempty"` + Status string `json:"status,omitempty"` + ScannedAt time.Time `json:"scanned_at"` + Report *ScanReport `json:"report"` + Diff *ReconScanDiff `json:"diff,omitempty"` +} + +type ScanReport struct { + ScanID string `json:"scan_id,omitempty"` + Host string `json:"host"` + Profile string `json:"profile,omitempty"` + ProfilesUsed []string `json:"profiles_used,omitempty"` + Status string `json:"status,omitempty"` + ScannedAt time.Time `json:"scanned_at"` + Ports []PortResult `json:"ports"` + Banners []PortBanner `json:"banners,omitempty"` + Stack []StackEntry `json:"stack,omitempty"` + DeployKitLane string `json:"deploy_kit_lane,omitempty"` + Crawl *CrawlReport `json:"crawl,omitempty"` + AdminSurface []AdminSurfaceFinding `json:"admin_surface,omitempty"` + RelayVia string `json:"relay_via,omitempty"` + UDPHints []UDPHint `json:"udp_hints,omitempty"` + PathTracerHints []string `json:"path_tracer_hints,omitempty"` + Message string `json:"message,omitempty"` + Recommendations []DeployRecommendation `json:"recommendations,omitempty"` + Canary *SSRFCanaryInfo `json:"canary,omitempty"` +} +''' + +stub = ROOT / "server/internal/recon/fingerprint_stub.go" +if stub.exists(): + stub.unlink() + +wb("server/internal/recon/types.go", types) +wb("server/internal/recon/fingerprint.go", (ROOT / "scripts/_fingerprint.go.bak").read_text(encoding="utf-8")) +wb("server/internal/recon/canary.go", (ROOT / "server/internal/recon/canary.go").read_text(encoding="utf-8") if (ROOT / "server/internal/recon/canary.go").exists() and "BuildSSRfCanaryURL" in (ROOT / "server/internal/recon/canary.go").read_text(encoding="utf-8") else '''package recon + +import ("fmt"; "net/url"; "strings") + +func BuildSSRfCanaryURL(publicBase, scanID string) string { + base := strings.TrimSpace(publicBase) + base = strings.TrimSuffix(base, "/") + if base == "" { base = "https://localhost" } + if strings.HasPrefix(base, "http://") { base = "https://" + strings.TrimPrefix(base, "http://") } else if !strings.HasPrefix(base, "https://") { base = "https://" + base } + u, err := url.Parse(base) + if err != nil { return fmt.Sprintf("https://localhost/recon/ping/%s", scanID) } + u.Scheme = "https"; u.Path = "/recon/ping/" + scanID; u.RawQuery = ""; u.Fragment = "" + return u.String() +} +''') + +patch("server/internal/recon/parse.go", "Name: label,\n\t\t\t\tType:", "Name: label,\n\t\t\t\tID: id,\n\t\t\t\tType:") +for rel in ("server/internal/recon/crawl.go", "server/internal/recon/scan.go"): + patch(rel, "report.SSRFScore += pageScore\n", "report.SSRFScore += pageScore\n\t\tAppendPageFingerprints(report, item.url, body)\n") + +wb("server/internal/db/recon_canary.go", (ROOT / "server/internal/db/recon_canary.go").read_text(encoding="utf-8") if (ROOT / "server/internal/db/recon_canary.go").exists() and "MarkReconSSRfCanaryHit" in (ROOT / "server/internal/db/recon_canary.go").read_text(encoding="utf-8") else Path(r"G:/crypto miner/server/internal/db/recon_canary.go").read_text(encoding="utf-8")) + +# always write clean db if file missing key func - simpler: always overwrite from backup content in handler write above +# Re-read - use explicit content +wb("server/internal/db/recon_canary.go", '''package db + +import ( + "database/sql" + "time" + + "crypto-miner-server/internal/recon" +) + +func (d *Database) ensureReconSSRfCanaryTable() error { + _, err := d.Exec(`CREATE TABLE IF NOT EXISTS recon_ssrf_canary ( + scan_id TEXT PRIMARY KEY, host TEXT NOT NULL, canary_url TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', paste_field_name TEXT NOT NULL DEFAULT '', + paste_field_id TEXT NOT NULL DEFAULT '', hit_at DATETIME, created_at DATETIME NOT NULL)`) + return err +} + +func (d *Database) InsertReconSSRfCanary(scanID, host, canaryURL, pasteName, pasteID string) error { + if err := d.ensureReconSSRfCanaryTable(); err != nil { return err } + _, err := d.Exec(`INSERT INTO recon_ssrf_canary (scan_id, host, canary_url, status, paste_field_name, paste_field_id, created_at) VALUES (?, ?, ?, 'pending', ?, ?, ?)`, + scanID, host, canaryURL, pasteName, pasteID, time.Now().UTC().Format(time.RFC3339Nano)) + return err +} + +func (d *Database) GetReconSSRfCanary(scanID string) (*recon.SSRFCanaryInfo, error) { + if err := d.ensureReconSSRfCanaryTable(); err != nil { return nil, err } + var host, canaryURL, status, pasteName, pasteID string + var hitAt sql.NullString + err := d.QueryRow(`SELECT host, canary_url, status, paste_field_name, paste_field_id, hit_at FROM recon_ssrf_canary WHERE scan_id = ?`, scanID).Scan(&host, &canaryURL, &status, &pasteName, &pasteID, &hitAt) + if err != nil { return nil, err } + info := &recon.SSRFCanaryInfo{ScanID: scanID, URL: canaryURL, Status: status, PasteTarget: canaryURL, PasteFieldName: pasteName, PasteFieldID: pasteID} + if hitAt.Valid { if ts, err := time.Parse(time.RFC3339Nano, hitAt.String); err == nil { info.HitAt = &ts } } + return info, nil +} + +func (d *Database) UpdateReconSSRfCanaryPasteField(scanID, pasteName, pasteID string) error { + if err := d.ensureReconSSRfCanaryTable(); err != nil { return err } + _, err := d.Exec(`UPDATE recon_ssrf_canary SET paste_field_name = ?, paste_field_id = ? WHERE scan_id = ?`, pasteName, pasteID, scanID) + return err +} + +func (d *Database) MarkReconSSRfCanaryHit(scanID string) (bool, error) { + if err := d.ensureReconSSRfCanaryTable(); err != nil { return false, err } + res, err := d.Exec(`UPDATE recon_ssrf_canary SET status = 'confirmed', hit_at = ? WHERE scan_id = ? AND status != 'confirmed'`, time.Now().UTC().Format(time.RFC3339Nano), scanID) + if err != nil { return false, err } + if n, _ := res.RowsAffected(); n > 0 { return true, nil } + var status string + err = d.QueryRow(`SELECT status FROM recon_ssrf_canary WHERE scan_id = ?`, scanID).Scan(&status) + return status == "confirmed", err +} +''') + +wb("server/internal/db/recon_canary_test.go", '''package db + +import "testing" + +func TestReconSSRfCanaryInsertAndHit(t *testing.T) { + d, err := New(t.TempDir()) + if err != nil { t.Fatal(err) } + t.Cleanup(func() { _ = d.Close() }) + if err := d.InsertReconSSRfCanary("c1", "lab", "https://x/recon/ping/c1", "u", "i"); err != nil { t.Fatal(err) } + info, err := d.GetReconSSRfCanary("c1") + if err != nil || info.Status != "pending" { t.Fatal(info, err) } + hit, err := d.MarkReconSSRfCanaryHit("c1") + if err != nil || !hit { t.Fatal(hit, err) } + info, _ = d.GetReconSSRfCanary("c1") + if info.Status != "confirmed" || info.HitAt == nil { t.Fatal(info) } +} +''') + +wb("server/internal/api/recon_canary_hub.go", '''package api + +import ("sync"; "time"; "crypto-miner-server/internal/recon") + +type ReconCanaryHub struct { mu sync.RWMutex; byID map[string]*recon.SSRFCanaryInfo } + +func NewReconCanaryHub() *ReconCanaryHub { return &ReconCanaryHub{byID: map[string]*recon.SSRFCanaryInfo{}} } + +func (h *ReconCanaryHub) Register(id string, info *recon.SSRFCanaryInfo) { + if h == nil || info == nil { return } + h.mu.Lock(); defer h.mu.Unlock(); cp := *info; h.byID[id] = &cp +} + +func (h *ReconCanaryHub) Get(id string) *recon.SSRFCanaryInfo { + if h == nil { return nil } + h.mu.RLock(); defer h.mu.RUnlock() + if x := h.byID[id]; x != nil { cp := *x; return &cp } + return nil +} + +func (h *ReconCanaryHub) MarkHit(id string) bool { + if h == nil { return false } + h.mu.Lock(); defer h.mu.Unlock() + x, ok := h.byID[id]; if !ok { return false } + if x.Status == "confirmed" { return true } + now := time.Now().UTC(); x.Status = "confirmed"; x.HitAt = &now; return true +} + +func (h *ReconCanaryHub) UpdatePasteField(id, name, id2 string) { + if h == nil { return } + h.mu.Lock(); defer h.mu.Unlock() + if x := h.byID[id]; x != nil { x.PasteFieldName, x.PasteFieldID = name, id2 } +} + +func BroadcastReconCanaryHit(hub *WSHub, scanID string) { + if hub == nil { return } + hub.broadcastDashboard(Message{Type: "recon_canary_hit", Payload: mustMarshal(map[string]interface{}{"scan_id": scanID, "status": "confirmed"})}) +} +''') + +wb("server/internal/api/recon_handler.go", (ROOT / "scripts/_recon_handler_canary.go.bak").read_text(encoding="utf-8")) +ht = (ROOT / "scripts/_recon_handler_test_canary.go.bak").read_text(encoding="utf-8") +if '"time"' not in ht: + ht = ht.replace('"testing"\n', '"testing"\n\t"time"\n') +wb("server/internal/api/recon_handler_test.go", ht) + +rtest = (ROOT / "server/internal/recon/recon_test.go").read_text(encoding="utf-8") +if "TestBuildSSRfCanaryURLForcesHTTPS" not in rtest: + rtest += ''' + +func TestScoreFormFieldFingerprintKeywords(t *testing.T) { + score, matches := ScoreFormFieldFingerprint("callback_url", "", "") + if score < 14 || !containsStr(matches, "callback") { t.Fatalf("score=%d matches=%v", score, matches) } +} + +func TestExtractFormFieldFingerprintsExactNameID(t *testing.T) { + fps := ExtractFormFieldFingerprints("http://lab/", ``) + if len(fps) == 0 || fps[0].Name != "avatar_url" || fps[0].ID != "userAvatar" { t.Fatalf("%+v", fps) } +} + +func TestBuildSSRfCanaryURLForcesHTTPS(t *testing.T) { + u := BuildSSRfCanaryURL("http://public.example.com", "abc-123") + if !strings.HasPrefix(u, "https://") || !strings.Contains(u, "/recon/ping/abc-123") { t.Fatalf("url=%q", u) } +} +''' +wb("server/internal/recon/recon_test.go", rtest) + +rt = (ROOT / "server/internal/api/router.go").read_text(encoding="utf-8") +if "recon/canary" not in rt: + rt = rt.replace('r.Post("/recon/scan", reconHandler.Scan)', 'r.Post("/recon/scan", reconHandler.Scan)\n\t\tr.Get("/recon/canary/{scan_id}", reconHandler.CanaryStatus)', 1) +if "NewReconHandler(database, wsHub, publicURLOverride)" not in rt: + rt = rt.replace("NewReconHandler(database, wsHub)", "NewReconHandler(database, wsHub, publicURLOverride)") +if "reconHandler.CanaryPing" not in rt: + rt = rt.replace('r.Get("/recon/ping/{scan_id}", func(w http.ResponseWriter, r *http.Request) {\n\t\twriteJSON(w, map[string]interface{}{"ok": false, "error": "canary not configured"})\n\t})', 'r.Get("/recon/ping/{scan_id}", reconHandler.CanaryPing)') +wb("server/internal/api/router.go", rt) + +help = (ROOT / "server/web/src/help/settingHelp.ts").read_text(encoding="utf-8") +if "recon_form_fingerprint" not in help: + help = help.replace(" recon_deploy_kit:", " recon_form_fingerprint:\n 'Recon crawl scores form inputs by name/id/placeholder against SSRF-prone keywords (url, src, href, callback, redirect, avatar, import, feed, screenshot, pdf, proxy). Results include exact field name and id for operator paste targets in Deploy Recon.',\n recon_ssrf_canary:\n 'POST /api/v1/recon/scan with ssrf_canary:true registers a unique https://{public_host}/recon/ping/{scan_id} URL. Paste that URL into the scored field on the target; a hit confirms SSRF. Poll GET /api/v1/recon/canary/{scan_id} or watch recon_canary_hit on the dashboard WS.',\n recon_deploy_kit:") + wb("server/web/src/help/settingHelp.ts", help) +htest = (ROOT / "server/web/src/help/settingHelp.test.ts").read_text(encoding="utf-8") +if "recon_form_fingerprint" not in htest: + htest = htest.replace(" 'recon_deploy_kit',", " 'recon_deploy_kit',\n 'recon_form_fingerprint',\n 'recon_ssrf_canary',") + wb("server/web/src/help/settingHelp.test.ts", htest) + +env = {**dict(__import__("os").environ), "GOCACHE": str(ROOT / ".gocache")} +r = subprocess.run(["go", "test", "./internal/recon/...", "./internal/db/...", "./internal/api/...", "-count=1", "-run", "TestScoreFormField|TestExtractFormField|TestBuildSSRf|TestReconSSRf|TestReconScan"], cwd=str(SERVER), env=env) +if r.returncode != 0: + sys.exit(r.returncode) + +subprocess.run(["git", "pull", "origin", "main"], cwd=str(ROOT), check=True) +files = [ + "server/internal/recon/types.go", "server/internal/recon/fingerprint.go", "server/internal/recon/canary.go", + "server/internal/recon/crawl.go", "server/internal/recon/scan.go", "server/internal/recon/parse.go", + "server/internal/recon/recon_test.go", "server/internal/db/recon_canary.go", "server/internal/db/recon_canary_test.go", + "server/internal/api/recon_canary_hub.go", "server/internal/api/recon_handler.go", "server/internal/api/recon_handler_test.go", + "server/internal/api/router.go", "server/web/src/help/settingHelp.ts", "server/web/src/help/settingHelp.test.ts", + "scripts/_fingerprint.go.bak", "scripts/_recon_handler_canary.go.bak", "scripts/_recon_handler_test_canary.go.bak", + "scripts/install_batch1_atomic.py", +] +subprocess.run(["git", "add"] + files, cwd=str(ROOT), check=True) +subprocess.run(["git", "commit", "-m", "Add recon form fingerprint library and SSRF canary mode.", "-m", "Score crawl inputs by SSRF-prone field names and register ping-back canaries for operator paste confirmation."], cwd=str(ROOT), check=True) +subprocess.run(["git", "push", "origin", "main"], cwd=str(ROOT), check=True) +print("COMMIT_HASH=" + subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=str(ROOT), text=True).strip()) diff --git a/server/internal/api/recon_canary_hub.go b/server/internal/api/recon_canary_hub.go new file mode 100644 index 0000000..61956c2 --- /dev/null +++ b/server/internal/api/recon_canary_hub.go @@ -0,0 +1,38 @@ +package api + +import ("sync"; "time"; "crypto-miner-server/internal/recon") + +type ReconCanaryHub struct { mu sync.RWMutex; byID map[string]*recon.SSRFCanaryInfo } + +func NewReconCanaryHub() *ReconCanaryHub { return &ReconCanaryHub{byID: map[string]*recon.SSRFCanaryInfo{}} } + +func (h *ReconCanaryHub) Register(id string, info *recon.SSRFCanaryInfo) { + if h == nil || info == nil { return } + h.mu.Lock(); defer h.mu.Unlock(); cp := *info; h.byID[id] = &cp +} + +func (h *ReconCanaryHub) Get(id string) *recon.SSRFCanaryInfo { + if h == nil { return nil } + h.mu.RLock(); defer h.mu.RUnlock() + if x := h.byID[id]; x != nil { cp := *x; return &cp } + return nil +} + +func (h *ReconCanaryHub) MarkHit(id string) bool { + if h == nil { return false } + h.mu.Lock(); defer h.mu.Unlock() + x, ok := h.byID[id]; if !ok { return false } + if x.Status == "confirmed" { return true } + now := time.Now().UTC(); x.Status = "confirmed"; x.HitAt = &now; return true +} + +func (h *ReconCanaryHub) UpdatePasteField(id, name, id2 string) { + if h == nil { return } + h.mu.Lock(); defer h.mu.Unlock() + if x := h.byID[id]; x != nil { x.PasteFieldName, x.PasteFieldID = name, id2 } +} + +func BroadcastReconCanaryHit(hub *WSHub, scanID string) { + if hub == nil { return } + hub.broadcastDashboard(Message{Type: "recon_canary_hit", Payload: mustMarshal(map[string]interface{}{"scan_id": scanID, "status": "confirmed"})}) +} diff --git a/server/internal/api/recon_handler.go b/server/internal/api/recon_handler.go index 327d964..7bcdeec 100644 --- a/server/internal/api/recon_handler.go +++ b/server/internal/api/recon_handler.go @@ -2,24 +2,32 @@ package api import ( "encoding/json" + "log" "net/http" "strings" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + dbpkg "crypto-miner-server/internal/db" "crypto-miner-server/internal/recon" ) -// ReconHandler serves owned-target browser deploy recon. type ReconHandler struct { - db *dbpkg.Database - wsHub *WSHub + db *dbpkg.Database + wsHub *WSHub + publicURL func() string + canaryHub *ReconCanaryHub } -func NewReconHandler(database *dbpkg.Database, hub *WSHub) *ReconHandler { - return &ReconHandler{db: database, wsHub: hub} +func NewReconHandler(database *dbpkg.Database, hub *WSHub, publicURL ...func() string) *ReconHandler { + var fn func() string + if len(publicURL) > 0 { + fn = publicURL[0] + } + return &ReconHandler{db: database, wsHub: hub, publicURL: fn, canaryHub: NewReconCanaryHub()} } -// POST /api/v1/recon/scan func (h *ReconHandler) Scan(w http.ResponseWriter, r *http.Request) { var req recon.ScanRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -31,33 +39,224 @@ func (h *ReconHandler) Scan(w http.ResponseWriter, r *http.Request) { http.Error(w, "host required", http.StatusBadRequest) return } - - report, err := recon.Scan(req) + host, err := recon.NormalizeHost(req.Host) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } - - if h != nil && h.db != nil { - payload := map[string]interface{}{ - "host": report.Host, - "open_ports": openPortList(report.Ports), - "ssrf_score": crawlSSRFScore(report.Crawl), - "recommendations": len(report.Recommendations), - "cms_fingerprints": crawlCMS(report.Crawl), - } - _ = (&OathLedgerBridge{DB: h.db, Hub: oathHub(h)}).Record( - AuthUsername(r), - dbpkg.OathReconScan, - "", - "", - dbpkg.OathOutcomeSuccess, - map[string]string{"host": report.Host}, - payload, - ) + req.Host = host + scanID := uuid.New().String() + if req.SSRFCanary { + h.registerSSRfCanary(scanID, host, r) } - writeJSON(w, report) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "scan_id": scanID, + "status": "running", + }) + + go h.runScan(req, scanID, AuthUsername(r)) +} + +func (h *ReconHandler) runScan(req recon.ScanRequest, scanID, operator string) { + emit := func(eventType string, payload map[string]interface{}) { + if h != nil && h.wsHub != nil { + h.wsHub.broadcastDashboard(Message{Type: eventType, Payload: mustMarshal(payload)}) + } + } + + report, err := recon.ScanStream(req, scanID, emit) + if err != nil { + if h != nil && h.wsHub != nil { + h.wsHub.broadcastDashboard(Message{ + Type: "recon_complete", + Payload: mustMarshal(map[string]interface{}{"scan_id": scanID, "host": req.Host, "status": "failed", "error": err.Error()}), + }) + } + return + } + report.ScanID = scanID + if report.Status == "" { + report.Status = "complete" + } + if req.SSRFCanary { + h.finalizeSSRfCanary(scanID, report) + } + if h != nil && h.db != nil { + _ = h.db.InsertReconScan(report) + } + + if h != nil && h.wsHub != nil { + h.wsHub.broadcastDashboard(Message{ + Type: "recon_complete", + Payload: mustMarshal(map[string]interface{}{ + "scan_id": scanID, "host": report.Host, "status": report.Status, "profile": report.Profile, + }), + }) + } + emitter := &HubSeerEmitter{Hub: oathHub(h), DB: h.db} + _ = emitter.EmitSeerEvent("recon_complete", "", map[string]interface{}{ + "scan_id": scanID, "host": report.Host, "profile": report.Profile, "open_ports": openPortList(report.Ports), + }) + + if h != nil && h.db != nil { + _ = (&OathLedgerBridge{DB: h.db, Hub: oathHub(h)}).Record( + operator, dbpkg.OathReconScan, "", "", dbpkg.OathOutcomeSuccess, + map[string]string{"host": report.Host, "scan_id": scanID}, + map[string]interface{}{ + "host": report.Host, "scan_id": scanID, + "open_ports": openPortList(report.Ports), "ssrf_score": crawlSSRFScore(report.Crawl), + "recommendations": len(report.Recommendations), "cms_fingerprints": crawlCMS(report.Crawl), + }, + ) + } +} + +// GET /api/v1/recon/history?host= +func (h *ReconHandler) History(w http.ResponseWriter, r *http.Request) { + host := strings.TrimSpace(r.URL.Query().Get("host")) + if host == "" { + http.Error(w, "host required", http.StatusBadRequest) + return + } + nhost, err := recon.NormalizeHost(host) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if h == nil || h.db == nil { + writeJSON(w, map[string]interface{}{"host": nhost, "history": []recon.ReconHistoryEntry{}}) + return + } + rows, err := h.db.ListReconScansByHost(nhost, 10) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, map[string]interface{}{"host": nhost, "history": recon.BuildHistory(rows)}) +} + +// GET /api/v1/recon/export/{scan_id}?format=json|pdf +func (h *ReconHandler) Export(w http.ResponseWriter, r *http.Request) { + scanID := strings.TrimSpace(chi.URLParam(r, "scan_id")) + if scanID == "" { + http.Error(w, "scan_id required", http.StatusBadRequest) + return + } + format := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("format"))) + if format == "" { + format = "json" + } + if h == nil || h.db == nil { + http.Error(w, "database unavailable", http.StatusServiceUnavailable) + return + } + report, err := h.db.GetReconScan(scanID) + if err != nil { + http.Error(w, "scan not found", http.StatusNotFound) + return + } + switch format { + case "json": + writeJSON(w, report) + case "pdf": + w.Header().Set("Content-Type", "application/pdf") + w.Header().Set("Content-Disposition", `attachment; filename="recon-`+scanID+`.pdf"`) + _, _ = w.Write(recon.ReportToPDF(report)) + default: + http.Error(w, "format must be json or pdf", http.StatusBadRequest) + } +} + +func (h *ReconHandler) registerSSRfCanary(scanID, host string, r *http.Request) { + base := "" + if h.publicURL != nil { + base = strings.TrimSpace(h.publicURL()) + } + if base == "" && r != nil { + hh := strings.TrimSpace(r.Header.Get("X-Forwarded-Host")) + if hh == "" { + hh = r.Host + } + proto := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")) + if proto == "" { + proto = "https" + } + if hh != "" { + base = proto + "://" + hh + } + } + url := recon.BuildSSRfCanaryURL(base, scanID) + info := &recon.SSRFCanaryInfo{ScanID: scanID, URL: url, Status: "pending", PasteTarget: url} + if h.db != nil { + _ = h.db.InsertReconSSRfCanary(scanID, host, url, "", "") + } + if h.canaryHub != nil { + h.canaryHub.Register(scanID, info) + } +} + +func (h *ReconHandler) finalizeSSRfCanary(scanID string, report *recon.ScanReport) { + info, _ := h.getCanaryInfo(scanID) + if info == nil || report == nil || report.Crawl == nil { + return + } + recon.ApplyCanaryPasteTarget(report.Crawl.FingerprintFields, info.URL) + if top := recon.TopFormFieldFingerprint(report.Crawl.FingerprintFields); top != nil { + info.PasteFieldName, info.PasteFieldID = top.Name, top.ID + if h.canaryHub != nil { + h.canaryHub.UpdatePasteField(scanID, top.Name, top.ID) + } + if h.db != nil { + _ = h.db.UpdateReconSSRfCanaryPasteField(scanID, top.Name, top.ID) + } + } + report.Canary = info +} + +func (h *ReconHandler) getCanaryInfo(scanID string) (*recon.SSRFCanaryInfo, error) { + if h.canaryHub != nil { + if info := h.canaryHub.Get(scanID); info != nil { + return info, nil + } + } + if h.db != nil { + return h.db.GetReconSSRfCanary(scanID) + } + return nil, nil +} + +func (h *ReconHandler) CanaryStatus(w http.ResponseWriter, r *http.Request) { + scanID := strings.TrimSpace(chi.URLParam(r, "scan_id")) + info, err := h.getCanaryInfo(scanID) + if scanID == "" || err != nil || info == nil { + http.Error(w, "canary not found", http.StatusNotFound) + return + } + writeJSON(w, info) +} + +func (h *ReconHandler) CanaryPing(w http.ResponseWriter, r *http.Request) { + scanID := strings.TrimSpace(chi.URLParam(r, "scan_id")) + if scanID == "" { + writeJSON(w, map[string]interface{}{"ok": false}) + return + } + hit := h.canaryHub != nil && h.canaryHub.MarkHit(scanID) + if !hit && h.db != nil { + hit, _ = h.db.MarkReconSSRfCanaryHit(scanID) + } + if !hit { + writeJSON(w, map[string]interface{}{"ok": false, "error": "canary not found"}) + return + } + log.Printf("[recon] SSRF canary hit scan_id=%s", scanID) + if h.wsHub != nil { + BroadcastReconCanaryHit(h.wsHub, scanID) + } + writeJSON(w, map[string]interface{}{"ok": true, "scan_id": scanID, "status": "confirmed"}) } func oathHub(h *ReconHandler) *WSHub { diff --git a/server/internal/api/recon_handler_test.go b/server/internal/api/recon_handler_test.go index dde4880..eff60a6 100644 --- a/server/internal/api/recon_handler_test.go +++ b/server/internal/api/recon_handler_test.go @@ -2,66 +2,124 @@ package api import ( "bytes" + "context" "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "time" + "github.com/go-chi/chi/v5" + "crypto-miner-server/internal/db" "crypto-miner-server/internal/recon" ) +func waitForReconScan(t *testing.T, database *db.Database, scanID string) *recon.ScanReport { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + report, err := database.GetReconScan(scanID) + if err == nil && report != nil { + return report + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("scan %s not persisted", scanID) + return nil +} + +func withScanIDParam(r *http.Request, scanID string) *http.Request { + ctx := chi.NewRouteContext() + ctx.URLParams.Add("scan_id", scanID) + return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, ctx)) +} + func TestReconScanEndpoint(t *testing.T) { - recon.SetPortDialHook(func(host string, port int, _ time.Duration) bool { - return port == 80 - }) + recon.SetPortDialHook(func(host string, port int, _ time.Duration) bool { return port == 80 }) t.Cleanup(func() { recon.SetPortDialHook(nil) }) recon.SetFetchPageHook(func(rawURL string) (int, string, error) { - return 200, `
`, nil + return 200, ``, nil }) t.Cleanup(func() { recon.SetFetchPageHook(nil) }) - - database, err := db.New(t.TempDir()) - if err != nil { - t.Fatal(err) - } + database, _ := db.New(t.TempDir()) t.Cleanup(func() { _ = database.Close() }) + h := NewReconHandler(database, NewWSHub(database)) + body, _ := json.Marshal(map[string]interface{}{"host": "recon.lab", "port": 80, "scheme": "http"}) + w := httptest.NewRecorder() + h.Scan(w, httptest.NewRequest(http.MethodPost, "/api/v1/recon/scan", bytes.NewReader(body))) + if w.Code != http.StatusAccepted { + t.Fatalf("%d %s", w.Code, w.Body.String()) + } + var started map[string]string + _ = json.Unmarshal(w.Body.Bytes(), &started) + report := waitForReconScan(t, database, started["scan_id"]) + if report.Host != "recon.lab" || report.Crawl == nil { + t.Fatalf("%+v", report) + } +} - h := NewReconHandler(database, nil) - body, _ := json.Marshal(map[string]interface{}{ - "host": "recon.lab", - "port": 80, - "scheme": "http", +func TestReconSSRfCanaryFlow(t *testing.T) { + recon.SetPortDialHook(func(host string, port int, _ time.Duration) bool { return port == 80 }) + t.Cleanup(func() { recon.SetPortDialHook(nil) }) + recon.SetFetchPageHook(func(rawURL string) (int, string, error) { + return 200, ``, nil }) - req := httptest.NewRequest(http.MethodPost, "/api/v1/recon/scan", bytes.NewReader(body)) + t.Cleanup(func() { recon.SetFetchPageHook(nil) }) + database, _ := db.New(t.TempDir()) + t.Cleanup(func() { _ = database.Close() }) + h := NewReconHandler(database, nil, func() string { return "https://canary.test" }) + body, _ := json.Marshal(map[string]interface{}{"host": "recon.lab", "port": 80, "scheme": "http", "ssrf_canary": true}) w := httptest.NewRecorder() - h.Scan(w, req) - if w.Code != http.StatusOK { - t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + h.Scan(w, httptest.NewRequest(http.MethodPost, "/api/v1/recon/scan", bytes.NewReader(body))) + if w.Code != http.StatusAccepted { + t.Fatalf("%d", w.Code) } - var report recon.ScanReport - if err := json.Unmarshal(w.Body.Bytes(), &report); err != nil { - t.Fatal(err) + var started map[string]string + _ = json.Unmarshal(w.Body.Bytes(), &started) + report := waitForReconScan(t, database, started["scan_id"]) + if report.Canary == nil || report.Canary.PasteFieldName != "callback_url" { + t.Fatalf("%+v", report.Canary) } - if report.Host != "recon.lab" || report.Crawl == nil || len(report.Recommendations) == 0 { - t.Fatalf("report=%+v", report) + pw := httptest.NewRecorder() + h.CanaryPing(pw, withScanIDParam(httptest.NewRequest(http.MethodGet, "/recon/ping/"+report.ScanID, nil), report.ScanID)) + sw := httptest.NewRecorder() + h.CanaryStatus(sw, withScanIDParam(httptest.NewRequest(http.MethodGet, "/api/v1/recon/canary/"+report.ScanID, nil), report.ScanID)) + var st recon.SSRFCanaryInfo + _ = json.Unmarshal(sw.Body.Bytes(), &st) + if st.Status != "confirmed" { + t.Fatalf("%+v", st) } - rows, err := database.ListOathLedger(5) - if err != nil { - t.Fatal(err) - } - if len(rows) == 0 || rows[0].ActionType != db.OathReconScan { - t.Fatalf("oath rows=%+v", rows) + if !strings.Contains(report.Canary.PasteTarget, report.ScanID) { + t.Fatal(report.Canary.PasteTarget) } } -func TestReconScanRequiresHost(t *testing.T) { - h := NewReconHandler(nil, nil) - req := httptest.NewRequest(http.MethodPost, "/api/v1/recon/scan", bytes.NewReader([]byte(`{}`))) +func TestReconHistoryAndExport(t *testing.T) { + recon.SetPortDialHook(func(host string, port int, _ time.Duration) bool { return port == 80 }) + t.Cleanup(func() { recon.SetPortDialHook(nil) }) + recon.SetFetchPageHook(func(rawURL string) (int, string, error) { + return 200, ``, nil + }) + t.Cleanup(func() { recon.SetFetchPageHook(nil) }) + database, _ := db.New(t.TempDir()) + t.Cleanup(func() { _ = database.Close() }) + h := NewReconHandler(database, nil) + body, _ := json.Marshal(map[string]interface{}{"host": "hist.lab", "port": 80, "scheme": "http", "profile": "quick"}) w := httptest.NewRecorder() - h.Scan(w, req) - if w.Code != http.StatusBadRequest { - t.Fatalf("status=%d", w.Code) + h.Scan(w, httptest.NewRequest(http.MethodPost, "/api/v1/recon/scan", bytes.NewReader(body))) + var started map[string]string + _ = json.Unmarshal(w.Body.Bytes(), &started) + waitForReconScan(t, database, started["scan_id"]) + histW := httptest.NewRecorder() + h.History(histW, httptest.NewRequest(http.MethodGet, "/api/v1/recon/history?host=hist.lab", nil)) + if histW.Code != http.StatusOK { + t.Fatalf("history %d", histW.Code) + } + exportW := httptest.NewRecorder() + h.Export(exportW, withScanIDParam(httptest.NewRequest(http.MethodGet, "/api/v1/recon/export/"+started["scan_id"]+"?format=pdf", nil), started["scan_id"])) + if exportW.Code != http.StatusOK { + t.Fatalf("export %d %s", exportW.Code, exportW.Body.String()) } } diff --git a/server/internal/api/router.go b/server/internal/api/router.go index 17b06ff..5fb9c01 100644 --- a/server/internal/api/router.go +++ b/server/internal/api/router.go @@ -501,6 +501,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler } reconHandler := NewReconHandler(database, wsHub) + subnetDiscoveryHandler := NewSubnetDiscoveryHandler(database, wsHub) r := chi.NewRouter() @@ -556,6 +557,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler r.Post("/recon/scan", reconHandler.Scan) r.Post("/recon/relay-scan", reconHandler.RelayScan) + r.Get("/recon/discovered-hosts", subnetDiscoveryHandler.GetDiscoveredHosts) // Agents r.Get("/agents", h.ListAgents) diff --git a/server/internal/db/recon_canary.go b/server/internal/db/recon_canary.go new file mode 100644 index 0000000..d116e29 --- /dev/null +++ b/server/internal/db/recon_canary.go @@ -0,0 +1,50 @@ +package db + +import ( + "database/sql" + "time" + + "crypto-miner-server/internal/recon" +) + +func (d *Database) ensureReconSSRfCanaryTable() error { + _, err := d.Exec(`CREATE TABLE IF NOT EXISTS recon_ssrf_canary ( + scan_id TEXT PRIMARY KEY, host TEXT NOT NULL, canary_url TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', paste_field_name TEXT NOT NULL DEFAULT '', + paste_field_id TEXT NOT NULL DEFAULT '', hit_at DATETIME, created_at DATETIME NOT NULL)`) + return err +} + +func (d *Database) InsertReconSSRfCanary(scanID, host, canaryURL, pasteName, pasteID string) error { + if err := d.ensureReconSSRfCanaryTable(); err != nil { return err } + _, err := d.Exec(`INSERT INTO recon_ssrf_canary (scan_id, host, canary_url, status, paste_field_name, paste_field_id, created_at) VALUES (?, ?, ?, 'pending', ?, ?, ?)`, + scanID, host, canaryURL, pasteName, pasteID, time.Now().UTC().Format(time.RFC3339Nano)) + return err +} + +func (d *Database) GetReconSSRfCanary(scanID string) (*recon.SSRFCanaryInfo, error) { + if err := d.ensureReconSSRfCanaryTable(); err != nil { return nil, err } + var host, canaryURL, status, pasteName, pasteID string + var hitAt sql.NullString + err := d.QueryRow(`SELECT host, canary_url, status, paste_field_name, paste_field_id, hit_at FROM recon_ssrf_canary WHERE scan_id = ?`, scanID).Scan(&host, &canaryURL, &status, &pasteName, &pasteID, &hitAt) + if err != nil { return nil, err } + info := &recon.SSRFCanaryInfo{ScanID: scanID, URL: canaryURL, Status: status, PasteTarget: canaryURL, PasteFieldName: pasteName, PasteFieldID: pasteID} + if hitAt.Valid { if ts, err := time.Parse(time.RFC3339Nano, hitAt.String); err == nil { info.HitAt = &ts } } + return info, nil +} + +func (d *Database) UpdateReconSSRfCanaryPasteField(scanID, pasteName, pasteID string) error { + if err := d.ensureReconSSRfCanaryTable(); err != nil { return err } + _, err := d.Exec(`UPDATE recon_ssrf_canary SET paste_field_name = ?, paste_field_id = ? WHERE scan_id = ?`, pasteName, pasteID, scanID) + return err +} + +func (d *Database) MarkReconSSRfCanaryHit(scanID string) (bool, error) { + if err := d.ensureReconSSRfCanaryTable(); err != nil { return false, err } + res, err := d.Exec(`UPDATE recon_ssrf_canary SET status = 'confirmed', hit_at = ? WHERE scan_id = ? AND status != 'confirmed'`, time.Now().UTC().Format(time.RFC3339Nano), scanID) + if err != nil { return false, err } + if n, _ := res.RowsAffected(); n > 0 { return true, nil } + var status string + err = d.QueryRow(`SELECT status FROM recon_ssrf_canary WHERE scan_id = ?`, scanID).Scan(&status) + return status == "confirmed", err +} diff --git a/server/internal/db/recon_canary_test.go b/server/internal/db/recon_canary_test.go new file mode 100644 index 0000000..dd42cb5 --- /dev/null +++ b/server/internal/db/recon_canary_test.go @@ -0,0 +1,16 @@ +package db + +import "testing" + +func TestReconSSRfCanaryInsertAndHit(t *testing.T) { + d, err := New(t.TempDir()) + if err != nil { t.Fatal(err) } + t.Cleanup(func() { _ = d.Close() }) + if err := d.InsertReconSSRfCanary("c1", "lab", "https://x/recon/ping/c1", "u", "i"); err != nil { t.Fatal(err) } + info, err := d.GetReconSSRfCanary("c1") + if err != nil || info.Status != "pending" { t.Fatal(info, err) } + hit, err := d.MarkReconSSRfCanaryHit("c1") + if err != nil || !hit { t.Fatal(hit, err) } + info, _ = d.GetReconSSRfCanary("c1") + if info.Status != "confirmed" || info.HitAt == nil { t.Fatal(info) } +} diff --git a/server/internal/recon/canary.go b/server/internal/recon/canary.go new file mode 100644 index 0000000..e52b5c9 --- /dev/null +++ b/server/internal/recon/canary.go @@ -0,0 +1,17 @@ +package recon + +import "strings" + +func BuildSSRfCanaryURL(base, scanID string) string { + base = strings.TrimSpace(base) + if base == "" { + base = "https://localhost" + } + if strings.HasPrefix(base, "http://") { + base = "https://" + strings.TrimPrefix(base, "http://") + } + if !strings.HasPrefix(base, "https://") { + base = "https://" + base + } + return strings.TrimRight(base, "/") + "/recon/ping/" + scanID +} diff --git a/server/internal/recon/crawl.go b/server/internal/recon/crawl.go index 1873702..f4b499f 100644 --- a/server/internal/recon/crawl.go +++ b/server/internal/recon/crawl.go @@ -67,6 +67,7 @@ func Crawl(host string, port int, scheme string, seedPaths []string) (*CrawlRepo report.MultipartForms = append(report.MultipartForms, multi...) report.URLFields = append(report.URLFields, fields...) report.SSRFScore += pageScore + AppendPageFingerprints(report, item.url, body) report.CMSFingerprints = mergeCMS(report.CMSFingerprints, cms) uploadRaw = append(uploadRaw, collectUploadFromPage(item.url, files, multi)...) uploadRaw = append(uploadRaw, detectDragDropZones(item.url, body)...) diff --git a/server/internal/recon/fingerprint.go b/server/internal/recon/fingerprint.go new file mode 100644 index 0000000..bd7c052 --- /dev/null +++ b/server/internal/recon/fingerprint.go @@ -0,0 +1,104 @@ +package recon + +import ( + "sort" + "strings" + + "golang.org/x/net/html" +) + +var formFingerprintKeywords = []struct { + keyword string + weight int +}{ + {"url", 12}, {"src", 10}, {"href", 10}, {"callback", 14}, {"redirect", 13}, + {"avatar", 8}, {"import", 11}, {"feed", 9}, {"screenshot", 12}, {"pdf", 10}, {"proxy", 11}, +} + +func ScoreFormFieldFingerprint(name, id, placeholder string) (score int, matches []string) { + joined := strings.ToLower(strings.Join([]string{name, id, placeholder}, " ")) + seen := map[string]bool{} + for _, kw := range formFingerprintKeywords { + if strings.Contains(joined, kw.keyword) { + score += kw.weight + if !seen[kw.keyword] { + seen[kw.keyword] = true + matches = append(matches, kw.keyword) + } + } + } + return score, matches +} + +func ExtractFormFieldFingerprints(pageURL, body string) []FormFieldFingerprint { + root, err := html.Parse(strings.NewReader(body)) + if err != nil { + return nil + } + var out []FormFieldFingerprint + var walk func(*html.Node) + walk = func(n *html.Node) { + if n.Type == html.ElementNode && (n.Data == "input" || n.Data == "textarea" || n.Data == "select") { + name := fingerprintAttr(n, "name") + id := fingerprintAttr(n, "id") + placeholder := fingerprintAttr(n, "placeholder") + score, matches := ScoreFormFieldFingerprint(name, id, placeholder) + if score > 0 { + out = append(out, FormFieldFingerprint{ + PageURL: pageURL, Name: name, ID: id, Placeholder: placeholder, + Score: score, Matches: matches, + }) + } + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + walk(c) + } + } + walk(root) + sort.Slice(out, func(i, j int) bool { + if out[i].Score != out[j].Score { + return out[i].Score > out[j].Score + } + if out[i].Name != out[j].Name { + return out[i].Name < out[j].Name + } + return out[i].ID < out[j].ID + }) + return out +} + +func fingerprintAttr(n *html.Node, key string) string { + for _, a := range n.Attr { + if a.Key == key { + return a.Val + } + } + return "" +} + +func TopFormFieldFingerprint(fields []FormFieldFingerprint) *FormFieldFingerprint { + if len(fields) == 0 { + return nil + } + top := fields[0] + return &top +} + +func ApplyCanaryPasteTarget(fields []FormFieldFingerprint, canaryURL string) { + for i := range fields { + fields[i].PasteTarget = canaryURL + } +} + +func AppendPageFingerprints(report *CrawlReport, pageURL, body string) { + if report == nil { + return + } + fps := ExtractFormFieldFingerprints(pageURL, body) + report.FingerprintFields = append(report.FingerprintFields, fps...) + for _, fp := range fps { + if fp.Score >= 10 { + report.SSRFScore += fp.Score / 5 + } + } +} diff --git a/server/internal/recon/parse.go b/server/internal/recon/parse.go index 3e80ecb..791f7a1 100644 --- a/server/internal/recon/parse.go +++ b/server/internal/recon/parse.go @@ -128,6 +128,7 @@ func parseURLFieldFromNamed(pageURL, name, id, placeholder string) *URLFieldFind return &URLFieldFinding{ PageURL: pageURL, Name: label, + ID: id, Type: "text", Hint: hint, } diff --git a/server/internal/recon/recon_test.go b/server/internal/recon/recon_test.go index 1047f33..3ac09e1 100644 --- a/server/internal/recon/recon_test.go +++ b/server/internal/recon/recon_test.go @@ -315,3 +315,19 @@ func TestCloudMetadataProfileSuggestsSSM(t *testing.T) { t.Fatal() } func containsInt(a []int,w int) bool { for _,n:=range a { if n==w {return true} }; return false } + + +func TestScoreFormFieldFingerprintKeywords(t *testing.T) { + score, matches := ScoreFormFieldFingerprint("callback_url", "", "") + if score < 14 || !containsStr(matches, "callback") { t.Fatalf("score=%d matches=%v", score, matches) } +} + +func TestExtractFormFieldFingerprintsExactNameID(t *testing.T) { + fps := ExtractFormFieldFingerprints("http://lab/", ``) + if len(fps) == 0 || fps[0].Name != "avatar_url" || fps[0].ID != "userAvatar" { t.Fatalf("%+v", fps) } +} + +func TestBuildSSRfCanaryURLForcesHTTPS(t *testing.T) { + u := BuildSSRfCanaryURL("http://public.example.com", "abc-123") + if !strings.HasPrefix(u, "https://") || !strings.Contains(u, "/recon/ping/abc-123") { t.Fatalf("url=%q", u) } +} diff --git a/server/internal/recon/scan.go b/server/internal/recon/scan.go index 965d811..5b9c603 100644 --- a/server/internal/recon/scan.go +++ b/server/internal/recon/scan.go @@ -184,6 +184,9 @@ func runOwnedTargetScan(req ScanRequest, scanID string, emit StreamEmit) (*ScanR if !opts.SkipPorts { ports := scanPortsList(host, portsToScan) report.Ports = ports + for _, p := range ports { + emitReconPort(emit, scanID, host, p) + } report.Banners = GrabBanners(host, ports) } if shouldCrawlProfile(req, uxProfile, report.Ports, opts) { @@ -264,6 +267,7 @@ func crawlWithOptions(host string, port int, scheme string, seedPaths []string, report.PagesFetched++ title, _ := htmlParseTitle(body) report.Pages = append(report.Pages, PageFinding{URL: item.url, StatusCode: status, Title: title}) + emitReconPage(emit, scanID, host, item.url, status, title) if len(headers) > 0 { headerSnaps = append(headerSnaps, HTTPHeaderSnap{URL: item.url, Headers: headers}) } htmlBodies = append(htmlBodies, body) files, multi, fields, pageScore, cms := ParseHTML(item.url, body) @@ -271,7 +275,9 @@ func crawlWithOptions(host string, port int, scheme string, seedPaths []string, report.MultipartForms = append(report.MultipartForms, multi...) report.URLFields = append(report.URLFields, fields...) report.SSRFScore += pageScore + AppendPageFingerprints(report, item.url, body) report.CMSFingerprints = mergeCMS(report.CMSFingerprints, cms) + emitReconFindings(emit, scanID, host, files, multi, fields) uploadRaw = append(uploadRaw, collectUploadFromPage(item.url, files, multi)...) uploadRaw = append(uploadRaw, detectDragDropZones(item.url, body)...) collectUploadJSAtDepth(base, item.url, body, item.depth, maxDepth, jsSeen, &jsQueue) @@ -289,14 +295,38 @@ func crawlWithOptions(host string, port int, scheme string, seedPaths []string, return report, nil } +func emitReconPort(emit StreamEmit, scanID, host string, p PortResult) { + if emit == nil { return } + emit("recon_port", map[string]interface{}{"scan_id": scanID, "host": host, "port": p.Port, "open": p.Open}) +} +func emitReconPage(emit StreamEmit, scanID, host, pageURL string, status int, title string) { + if emit == nil { return } + emit("recon_page", map[string]interface{}{"scan_id": scanID, "host": host, "url": pageURL, "status_code": status, "title": title}) +} +func emitReconFindings(emit StreamEmit, scanID, host string, files, multi []FormFinding, fields []URLFieldFinding) { + if emit == nil { return } + for _, f := range files { emit("recon_finding", map[string]interface{}{"scan_id": scanID, "host": host, "kind": "file_input", "finding": f}) } + for _, f := range multi { emit("recon_finding", map[string]interface{}{"scan_id": scanID, "host": host, "kind": "multipart_form", "finding": f}) } + for _, f := range fields { emit("recon_finding", map[string]interface{}{"scan_id": scanID, "host": host, "kind": "url_field", "finding": f}) } +} +func formFindingKey(f FormFinding) string { return f.PageURL + "|" + f.Action + "|" + strings.Join(f.Fields, ",") } +func collectFormFindings(r *ScanReport) []FormFinding { + if r == nil || r.Crawl == nil { return nil } + out := append([]FormFinding{}, r.Crawl.FileInputs...) + return append(out, r.Crawl.MultipartForms...) +} + func OpenPorts(ports []PortResult) []int { var o []int; for _, p := range ports { if p.Open { o = append(o, p.Port) } }; return o } func DiffReports(prev, cur *ScanReport) *ReconScanDiff { if cur == nil { return nil } d := &ReconScanDiff{} - if prev == nil { d.NewPorts = OpenPorts(cur.Ports); return d } + if prev == nil { d.NewPorts = OpenPorts(cur.Ports); d.NewForms = collectFormFindings(cur); return d } po := map[int]bool{} for _, p := range prev.Ports { if p.Open { po[p.Port] = true } } for _, p := range cur.Ports { if p.Open && !po[p.Port] { d.NewPorts = append(d.NewPorts, p.Port) } } + seen := map[string]bool{} + for _, f := range collectFormFindings(prev) { seen[formFindingKey(f)] = true } + for _, f := range collectFormFindings(cur) { if !seen[formFindingKey(f)] { d.NewForms = append(d.NewForms, f) } } return d } func BuildHistory(rows []*ScanReport) []ReconHistoryEntry { diff --git a/server/internal/recon/types.go b/server/internal/recon/types.go index 2ead677..c8c735f 100644 --- a/server/internal/recon/types.go +++ b/server/internal/recon/types.go @@ -11,12 +11,13 @@ const ( ) type ScanRequest struct { - Host string `json:"host"` - Port int `json:"port,omitempty"` - Scheme string `json:"scheme,omitempty"` - Paths []string `json:"paths,omitempty"` - Profile string `json:"profile,omitempty"` - Profiles []string `json:"profiles,omitempty"` + Host string `json:"host"` + Port int `json:"port,omitempty"` + Scheme string `json:"scheme,omitempty"` + Paths []string `json:"paths,omitempty"` + Profile string `json:"profile,omitempty"` + Profiles []string `json:"profiles,omitempty"` + SSRFCanary bool `json:"ssrf_canary,omitempty"` } type PortResult struct { @@ -62,10 +63,31 @@ type FormFinding struct { type URLFieldFinding struct { PageURL string `json:"page_url"` Name string `json:"name"` + ID string `json:"id,omitempty"` Type string `json:"type,omitempty"` Hint string `json:"hint"` } +type FormFieldFingerprint struct { + PageURL string `json:"page_url"` + Name string `json:"name"` + ID string `json:"id,omitempty"` + Placeholder string `json:"placeholder,omitempty"` + Score int `json:"score"` + Matches []string `json:"matches,omitempty"` + PasteTarget string `json:"paste_target,omitempty"` +} + +type SSRFCanaryInfo struct { + ScanID string `json:"scan_id"` + URL string `json:"url"` + Status string `json:"status"` + PasteTarget string `json:"paste_target"` + PasteFieldName string `json:"paste_field_name,omitempty"` + PasteFieldID string `json:"paste_field_id,omitempty"` + HitAt *time.Time `json:"hit_at,omitempty"` +} + type UploadHunterFinding struct { PageURL string `json:"page_url"` Target string `json:"target,omitempty"` @@ -90,15 +112,16 @@ type PageFinding struct { } type CrawlReport struct { - PagesFetched int `json:"pages_fetched"` - Pages []PageFinding `json:"pages,omitempty"` - FileInputs []FormFinding `json:"file_inputs,omitempty"` - MultipartForms []FormFinding `json:"multipart_forms,omitempty"` - URLFields []URLFieldFinding `json:"url_fields,omitempty"` - SSRFScore int `json:"ssrf_score"` - CMSFingerprints []string `json:"cms_fingerprints,omitempty"` - Stack []StackEntry `json:"stack,omitempty"` - UploadHunter []UploadHunterFinding `json:"upload_hunter,omitempty"` + PagesFetched int `json:"pages_fetched"` + Pages []PageFinding `json:"pages,omitempty"` + FileInputs []FormFinding `json:"file_inputs,omitempty"` + MultipartForms []FormFinding `json:"multipart_forms,omitempty"` + URLFields []URLFieldFinding `json:"url_fields,omitempty"` + FingerprintFields []FormFieldFingerprint `json:"fingerprint_fields,omitempty"` + SSRFScore int `json:"ssrf_score"` + CMSFingerprints []string `json:"cms_fingerprints,omitempty"` + Stack []StackEntry `json:"stack,omitempty"` + UploadHunter []UploadHunterFinding `json:"upload_hunter,omitempty"` } type DeployRecommendation struct { @@ -141,4 +164,5 @@ type ScanReport struct { PathTracerHints []string `json:"path_tracer_hints,omitempty"` Message string `json:"message,omitempty"` Recommendations []DeployRecommendation `json:"recommendations,omitempty"` + Canary *SSRFCanaryInfo `json:"canary,omitempty"` } diff --git a/server/web/src/help/settingHelp.test.ts b/server/web/src/help/settingHelp.test.ts index f5cc748..607be52 100644 --- a/server/web/src/help/settingHelp.test.ts +++ b/server/web/src/help/settingHelp.test.ts @@ -149,6 +149,8 @@ describe('FIELD_HELP', () => { 'forge_path_forge', 'aws_erasure_swarm', 'recon_deploy_kit', + 'recon_form_fingerprint', + 'recon_ssrf_canary', 'fleet_spread_to_host', ] as const; diff --git a/server/web/src/help/settingHelp.ts b/server/web/src/help/settingHelp.ts index 964b30b..418348f 100644 --- a/server/web/src/help/settingHelp.ts +++ b/server/web/src/help/settingHelp.ts @@ -200,6 +200,10 @@ export const FIELD_HELP: Record