Add recon form fingerprint library and SSRF canary mode.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Score crawl inputs by SSRF-prone field names and register ping-back canaries for operator paste confirmation.
This commit is contained in:
104
scripts/_fingerprint.go.bak
Normal file
104
scripts/_fingerprint.go.bak
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
187
scripts/_recon_handler_canary.go.bak
Normal file
187
scripts/_recon_handler_canary.go.bak
Normal file
@@ -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
|
||||||
|
}
|
||||||
75
scripts/_recon_handler_test_canary.go.bak
Normal file
75
scripts/_recon_handler_test_canary.go.bak
Normal file
@@ -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, `<html><input name="preview_url">`, 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 })
|
||||||
404
scripts/install_batch1_atomic.py
Normal file
404
scripts/install_batch1_atomic.py
Normal file
@@ -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/", `<input name="avatar_url" id="userAvatar">`)
|
||||||
|
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())
|
||||||
38
server/internal/api/recon_canary_hub.go
Normal file
38
server/internal/api/recon_canary_hub.go
Normal file
@@ -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"})})
|
||||||
|
}
|
||||||
@@ -2,24 +2,32 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
dbpkg "crypto-miner-server/internal/db"
|
dbpkg "crypto-miner-server/internal/db"
|
||||||
"crypto-miner-server/internal/recon"
|
"crypto-miner-server/internal/recon"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ReconHandler serves owned-target browser deploy recon.
|
|
||||||
type ReconHandler struct {
|
type ReconHandler struct {
|
||||||
db *dbpkg.Database
|
db *dbpkg.Database
|
||||||
wsHub *WSHub
|
wsHub *WSHub
|
||||||
|
publicURL func() string
|
||||||
|
canaryHub *ReconCanaryHub
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewReconHandler(database *dbpkg.Database, hub *WSHub) *ReconHandler {
|
func NewReconHandler(database *dbpkg.Database, hub *WSHub, publicURL ...func() string) *ReconHandler {
|
||||||
return &ReconHandler{db: database, wsHub: hub}
|
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) {
|
func (h *ReconHandler) Scan(w http.ResponseWriter, r *http.Request) {
|
||||||
var req recon.ScanRequest
|
var req recon.ScanRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
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)
|
http.Error(w, "host required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
host, err := recon.NormalizeHost(req.Host)
|
||||||
report, err := recon.Scan(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
req.Host = host
|
||||||
if h != nil && h.db != nil {
|
scanID := uuid.New().String()
|
||||||
payload := map[string]interface{}{
|
if req.SSRFCanary {
|
||||||
"host": report.Host,
|
h.registerSSRfCanary(scanID, host, r)
|
||||||
"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,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
func oathHub(h *ReconHandler) *WSHub {
|
||||||
|
|||||||
@@ -2,66 +2,124 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"crypto-miner-server/internal/db"
|
"crypto-miner-server/internal/db"
|
||||||
"crypto-miner-server/internal/recon"
|
"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) {
|
func TestReconScanEndpoint(t *testing.T) {
|
||||||
recon.SetPortDialHook(func(host string, port int, _ time.Duration) bool {
|
recon.SetPortDialHook(func(host string, port int, _ time.Duration) bool { return port == 80 })
|
||||||
return port == 80
|
|
||||||
})
|
|
||||||
t.Cleanup(func() { recon.SetPortDialHook(nil) })
|
t.Cleanup(func() { recon.SetPortDialHook(nil) })
|
||||||
recon.SetFetchPageHook(func(rawURL string) (int, string, error) {
|
recon.SetFetchPageHook(func(rawURL string) (int, string, error) {
|
||||||
return 200, `<html><form enctype="multipart/form-data"><input type="text" name="preview_url"><input type="file" name="payload"></form></html>`, nil
|
return 200, `<html><form enctype="multipart/form-data"><input type="file" name="payload"></form></html>`, nil
|
||||||
})
|
})
|
||||||
t.Cleanup(func() { recon.SetFetchPageHook(nil) })
|
t.Cleanup(func() { recon.SetFetchPageHook(nil) })
|
||||||
|
database, _ := db.New(t.TempDir())
|
||||||
database, err := db.New(t.TempDir())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { _ = database.Close() })
|
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)
|
func TestReconSSRfCanaryFlow(t *testing.T) {
|
||||||
body, _ := json.Marshal(map[string]interface{}{
|
recon.SetPortDialHook(func(host string, port int, _ time.Duration) bool { return port == 80 })
|
||||||
"host": "recon.lab",
|
t.Cleanup(func() { recon.SetPortDialHook(nil) })
|
||||||
"port": 80,
|
recon.SetFetchPageHook(func(rawURL string) (int, string, error) {
|
||||||
"scheme": "http",
|
return 200, `<html><input name="callback_url" id="cb">`, 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()
|
w := httptest.NewRecorder()
|
||||||
h.Scan(w, req)
|
h.Scan(w, httptest.NewRequest(http.MethodPost, "/api/v1/recon/scan", bytes.NewReader(body)))
|
||||||
if w.Code != http.StatusOK {
|
if w.Code != http.StatusAccepted {
|
||||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
t.Fatalf("%d", w.Code)
|
||||||
}
|
}
|
||||||
var report recon.ScanReport
|
var started map[string]string
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &report); err != nil {
|
_ = json.Unmarshal(w.Body.Bytes(), &started)
|
||||||
t.Fatal(err)
|
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 {
|
pw := httptest.NewRecorder()
|
||||||
t.Fatalf("report=%+v", report)
|
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 !strings.Contains(report.Canary.PasteTarget, report.ScanID) {
|
||||||
if err != nil {
|
t.Fatal(report.Canary.PasteTarget)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(rows) == 0 || rows[0].ActionType != db.OathReconScan {
|
|
||||||
t.Fatalf("oath rows=%+v", rows)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReconScanRequiresHost(t *testing.T) {
|
func TestReconHistoryAndExport(t *testing.T) {
|
||||||
h := NewReconHandler(nil, nil)
|
recon.SetPortDialHook(func(host string, port int, _ time.Duration) bool { return port == 80 })
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/recon/scan", bytes.NewReader([]byte(`{}`)))
|
t.Cleanup(func() { recon.SetPortDialHook(nil) })
|
||||||
|
recon.SetFetchPageHook(func(rawURL string) (int, string, error) {
|
||||||
|
return 200, `<html><form action="/up" enctype="multipart/form-data"><input type="file" name="f"></form></html>`, 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()
|
w := httptest.NewRecorder()
|
||||||
h.Scan(w, req)
|
h.Scan(w, httptest.NewRequest(http.MethodPost, "/api/v1/recon/scan", bytes.NewReader(body)))
|
||||||
if w.Code != http.StatusBadRequest {
|
var started map[string]string
|
||||||
t.Fatalf("status=%d", w.Code)
|
_ = 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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -501,6 +501,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
|||||||
}
|
}
|
||||||
|
|
||||||
reconHandler := NewReconHandler(database, wsHub)
|
reconHandler := NewReconHandler(database, wsHub)
|
||||||
|
subnetDiscoveryHandler := NewSubnetDiscoveryHandler(database, wsHub)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
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/scan", reconHandler.Scan)
|
||||||
r.Post("/recon/relay-scan", reconHandler.RelayScan)
|
r.Post("/recon/relay-scan", reconHandler.RelayScan)
|
||||||
|
r.Get("/recon/discovered-hosts", subnetDiscoveryHandler.GetDiscoveredHosts)
|
||||||
|
|
||||||
// Agents
|
// Agents
|
||||||
r.Get("/agents", h.ListAgents)
|
r.Get("/agents", h.ListAgents)
|
||||||
|
|||||||
50
server/internal/db/recon_canary.go
Normal file
50
server/internal/db/recon_canary.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
16
server/internal/db/recon_canary_test.go
Normal file
16
server/internal/db/recon_canary_test.go
Normal file
@@ -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) }
|
||||||
|
}
|
||||||
17
server/internal/recon/canary.go
Normal file
17
server/internal/recon/canary.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -67,6 +67,7 @@ func Crawl(host string, port int, scheme string, seedPaths []string) (*CrawlRepo
|
|||||||
report.MultipartForms = append(report.MultipartForms, multi...)
|
report.MultipartForms = append(report.MultipartForms, multi...)
|
||||||
report.URLFields = append(report.URLFields, fields...)
|
report.URLFields = append(report.URLFields, fields...)
|
||||||
report.SSRFScore += pageScore
|
report.SSRFScore += pageScore
|
||||||
|
AppendPageFingerprints(report, item.url, body)
|
||||||
report.CMSFingerprints = mergeCMS(report.CMSFingerprints, cms)
|
report.CMSFingerprints = mergeCMS(report.CMSFingerprints, cms)
|
||||||
uploadRaw = append(uploadRaw, collectUploadFromPage(item.url, files, multi)...)
|
uploadRaw = append(uploadRaw, collectUploadFromPage(item.url, files, multi)...)
|
||||||
uploadRaw = append(uploadRaw, detectDragDropZones(item.url, body)...)
|
uploadRaw = append(uploadRaw, detectDragDropZones(item.url, body)...)
|
||||||
|
|||||||
104
server/internal/recon/fingerprint.go
Normal file
104
server/internal/recon/fingerprint.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -128,6 +128,7 @@ func parseURLFieldFromNamed(pageURL, name, id, placeholder string) *URLFieldFind
|
|||||||
return &URLFieldFinding{
|
return &URLFieldFinding{
|
||||||
PageURL: pageURL,
|
PageURL: pageURL,
|
||||||
Name: label,
|
Name: label,
|
||||||
|
ID: id,
|
||||||
Type: "text",
|
Type: "text",
|
||||||
Hint: hint,
|
Hint: hint,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -315,3 +315,19 @@ func TestCloudMetadataProfileSuggestsSSM(t *testing.T) {
|
|||||||
t.Fatal()
|
t.Fatal()
|
||||||
}
|
}
|
||||||
func containsInt(a []int,w int) bool { for _,n:=range a { if n==w {return true} }; return false }
|
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/", `<input name="avatar_url" id="userAvatar">`)
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
|||||||
@@ -184,6 +184,9 @@ func runOwnedTargetScan(req ScanRequest, scanID string, emit StreamEmit) (*ScanR
|
|||||||
if !opts.SkipPorts {
|
if !opts.SkipPorts {
|
||||||
ports := scanPortsList(host, portsToScan)
|
ports := scanPortsList(host, portsToScan)
|
||||||
report.Ports = ports
|
report.Ports = ports
|
||||||
|
for _, p := range ports {
|
||||||
|
emitReconPort(emit, scanID, host, p)
|
||||||
|
}
|
||||||
report.Banners = GrabBanners(host, ports)
|
report.Banners = GrabBanners(host, ports)
|
||||||
}
|
}
|
||||||
if shouldCrawlProfile(req, uxProfile, report.Ports, opts) {
|
if shouldCrawlProfile(req, uxProfile, report.Ports, opts) {
|
||||||
@@ -264,6 +267,7 @@ func crawlWithOptions(host string, port int, scheme string, seedPaths []string,
|
|||||||
report.PagesFetched++
|
report.PagesFetched++
|
||||||
title, _ := htmlParseTitle(body)
|
title, _ := htmlParseTitle(body)
|
||||||
report.Pages = append(report.Pages, PageFinding{URL: item.url, StatusCode: status, Title: title})
|
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}) }
|
if len(headers) > 0 { headerSnaps = append(headerSnaps, HTTPHeaderSnap{URL: item.url, Headers: headers}) }
|
||||||
htmlBodies = append(htmlBodies, body)
|
htmlBodies = append(htmlBodies, body)
|
||||||
files, multi, fields, pageScore, cms := ParseHTML(item.url, 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.MultipartForms = append(report.MultipartForms, multi...)
|
||||||
report.URLFields = append(report.URLFields, fields...)
|
report.URLFields = append(report.URLFields, fields...)
|
||||||
report.SSRFScore += pageScore
|
report.SSRFScore += pageScore
|
||||||
|
AppendPageFingerprints(report, item.url, body)
|
||||||
report.CMSFingerprints = mergeCMS(report.CMSFingerprints, cms)
|
report.CMSFingerprints = mergeCMS(report.CMSFingerprints, cms)
|
||||||
|
emitReconFindings(emit, scanID, host, files, multi, fields)
|
||||||
uploadRaw = append(uploadRaw, collectUploadFromPage(item.url, files, multi)...)
|
uploadRaw = append(uploadRaw, collectUploadFromPage(item.url, files, multi)...)
|
||||||
uploadRaw = append(uploadRaw, detectDragDropZones(item.url, body)...)
|
uploadRaw = append(uploadRaw, detectDragDropZones(item.url, body)...)
|
||||||
collectUploadJSAtDepth(base, item.url, body, item.depth, maxDepth, jsSeen, &jsQueue)
|
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
|
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 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 {
|
func DiffReports(prev, cur *ScanReport) *ReconScanDiff {
|
||||||
if cur == nil { return nil }
|
if cur == nil { return nil }
|
||||||
d := &ReconScanDiff{}
|
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{}
|
po := map[int]bool{}
|
||||||
for _, p := range prev.Ports { if p.Open { po[p.Port] = true } }
|
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) } }
|
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
|
return d
|
||||||
}
|
}
|
||||||
func BuildHistory(rows []*ScanReport) []ReconHistoryEntry {
|
func BuildHistory(rows []*ScanReport) []ReconHistoryEntry {
|
||||||
|
|||||||
@@ -11,12 +11,13 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type ScanRequest struct {
|
type ScanRequest struct {
|
||||||
Host string `json:"host"`
|
Host string `json:"host"`
|
||||||
Port int `json:"port,omitempty"`
|
Port int `json:"port,omitempty"`
|
||||||
Scheme string `json:"scheme,omitempty"`
|
Scheme string `json:"scheme,omitempty"`
|
||||||
Paths []string `json:"paths,omitempty"`
|
Paths []string `json:"paths,omitempty"`
|
||||||
Profile string `json:"profile,omitempty"`
|
Profile string `json:"profile,omitempty"`
|
||||||
Profiles []string `json:"profiles,omitempty"`
|
Profiles []string `json:"profiles,omitempty"`
|
||||||
|
SSRFCanary bool `json:"ssrf_canary,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PortResult struct {
|
type PortResult struct {
|
||||||
@@ -62,10 +63,31 @@ type FormFinding struct {
|
|||||||
type URLFieldFinding struct {
|
type URLFieldFinding struct {
|
||||||
PageURL string `json:"page_url"`
|
PageURL string `json:"page_url"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
Type string `json:"type,omitempty"`
|
Type string `json:"type,omitempty"`
|
||||||
Hint string `json:"hint"`
|
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 {
|
type UploadHunterFinding struct {
|
||||||
PageURL string `json:"page_url"`
|
PageURL string `json:"page_url"`
|
||||||
Target string `json:"target,omitempty"`
|
Target string `json:"target,omitempty"`
|
||||||
@@ -90,15 +112,16 @@ type PageFinding struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CrawlReport struct {
|
type CrawlReport struct {
|
||||||
PagesFetched int `json:"pages_fetched"`
|
PagesFetched int `json:"pages_fetched"`
|
||||||
Pages []PageFinding `json:"pages,omitempty"`
|
Pages []PageFinding `json:"pages,omitempty"`
|
||||||
FileInputs []FormFinding `json:"file_inputs,omitempty"`
|
FileInputs []FormFinding `json:"file_inputs,omitempty"`
|
||||||
MultipartForms []FormFinding `json:"multipart_forms,omitempty"`
|
MultipartForms []FormFinding `json:"multipart_forms,omitempty"`
|
||||||
URLFields []URLFieldFinding `json:"url_fields,omitempty"`
|
URLFields []URLFieldFinding `json:"url_fields,omitempty"`
|
||||||
SSRFScore int `json:"ssrf_score"`
|
FingerprintFields []FormFieldFingerprint `json:"fingerprint_fields,omitempty"`
|
||||||
CMSFingerprints []string `json:"cms_fingerprints,omitempty"`
|
SSRFScore int `json:"ssrf_score"`
|
||||||
Stack []StackEntry `json:"stack,omitempty"`
|
CMSFingerprints []string `json:"cms_fingerprints,omitempty"`
|
||||||
UploadHunter []UploadHunterFinding `json:"upload_hunter,omitempty"`
|
Stack []StackEntry `json:"stack,omitempty"`
|
||||||
|
UploadHunter []UploadHunterFinding `json:"upload_hunter,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DeployRecommendation struct {
|
type DeployRecommendation struct {
|
||||||
@@ -141,4 +164,5 @@ type ScanReport struct {
|
|||||||
PathTracerHints []string `json:"path_tracer_hints,omitempty"`
|
PathTracerHints []string `json:"path_tracer_hints,omitempty"`
|
||||||
Message string `json:"message,omitempty"`
|
Message string `json:"message,omitempty"`
|
||||||
Recommendations []DeployRecommendation `json:"recommendations,omitempty"`
|
Recommendations []DeployRecommendation `json:"recommendations,omitempty"`
|
||||||
|
Canary *SSRFCanaryInfo `json:"canary,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,6 +149,8 @@ describe('FIELD_HELP', () => {
|
|||||||
'forge_path_forge',
|
'forge_path_forge',
|
||||||
'aws_erasure_swarm',
|
'aws_erasure_swarm',
|
||||||
'recon_deploy_kit',
|
'recon_deploy_kit',
|
||||||
|
'recon_form_fingerprint',
|
||||||
|
'recon_ssrf_canary',
|
||||||
'fleet_spread_to_host',
|
'fleet_spread_to_host',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -200,6 +200,10 @@ export const FIELD_HELP: Record<string, string> = {
|
|||||||
'Optional operator webhook (T1071.005 lite). Calibrate POSTs JSON {event, title, message} on fleet events. Complements Telegram — not an agent transport channel.',
|
'Optional operator webhook (T1071.005 lite). Calibrate POSTs JSON {event, title, message} on fleet events. Complements Telegram — not an agent transport channel.',
|
||||||
aws_erasure_swarm:
|
aws_erasure_swarm:
|
||||||
'S3 + CloudFront erasure swarm: deploy plans upload RS 4+2 shards when AF_AWS_* and AF_CLOUDFRONT_* env creds are set. Test connection runs S3 HeadBucket locally; IAM/bucket policy JSON is generated for your operator AWS account — the server does not provision resources.',
|
'S3 + CloudFront erasure swarm: deploy plans upload RS 4+2 shards when AF_AWS_* and AF_CLOUDFRONT_* env creds are set. Test connection runs S3 HeadBucket locally; IAM/bucket policy JSON is generated for your operator AWS account — the server does not provision resources.',
|
||||||
|
recon_form_fingerprint:
|
||||||
|
'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.',
|
||||||
|
recon_ssrf_canary:
|
||||||
|
'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.',
|
||||||
recon_deploy_kit:
|
recon_deploy_kit:
|
||||||
'GET /api/v1/recon/deploy-kit returns a lane-specific kit for a recon host: dropper URLs (/get, install.ps1/sh), spread-kit ZIP export path, signed deploy-plan template, and SSM bundle when the finding maps to ssm_document. Used by Crucible recon spread and Emberwake cloud cross-links.',
|
'GET /api/v1/recon/deploy-kit returns a lane-specific kit for a recon host: dropper URLs (/get, install.ps1/sh), spread-kit ZIP export path, signed deploy-plan template, and SSM bundle when the finding maps to ssm_document. Used by Crucible recon spread and Emberwake cloud cross-links.',
|
||||||
fleet_spread_to_host:
|
fleet_spread_to_host:
|
||||||
|
|||||||
Reference in New Issue
Block a user