First Commit
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
detections / detections (push) Canceled after 0s
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
detections / detections (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
// Package enrich is the engine-side (non-AI) asset auto-completion layer described
|
||||
// in docs/资产模型与自动关联设计.md §5: an async worker pool that resolves domains
|
||||
// (dnsx) and probes web assets (HTTP, through the recording proxy) and writes the
|
||||
// results back into the asset graph — creating IP/port nodes, resolves/exposes
|
||||
// edges, and filling attrs.dns / attrs.http. DNS is ungated; HTTP probing is gated
|
||||
// by RoE (§5.2).
|
||||
package enrich
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"html"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
"github.com/projectdiscovery/dnsx/libs/dnsx"
|
||||
)
|
||||
|
||||
type jobKind int
|
||||
|
||||
const (
|
||||
jobDNS jobKind = iota // resolve a domain
|
||||
jobHTTP // probe a web asset (site)
|
||||
)
|
||||
|
||||
type job struct {
|
||||
kind jobKind
|
||||
id int64 // asset id (domain for DNS, site for HTTP)
|
||||
arg string // host (DNS) or url (HTTP)
|
||||
}
|
||||
|
||||
// Engine owns the resolver, the proxy-routed HTTP client, and the worker pool.
|
||||
type Engine struct {
|
||||
as *db.AssetStore
|
||||
resolv *dnsx.DNSX
|
||||
client *http.Client
|
||||
|
||||
jobs chan job
|
||||
cool sync.Map // dedup/cooldown: "kind:id" -> time.Time (last run)
|
||||
once sync.Once
|
||||
closed chan struct{}
|
||||
}
|
||||
|
||||
const (
|
||||
cooldown = 5 * time.Minute
|
||||
httpTimeout = 12 * time.Second
|
||||
queueSize = 1024
|
||||
)
|
||||
|
||||
// New builds the engine. proxy() returns the recording-proxy address to route HTTP
|
||||
// probes through (so they land in the traffic store), evaluated per request so the
|
||||
// runtime traffic-capture toggle takes effect live; "" = direct. Returns a usable
|
||||
// engine even if the resolver fails to init (DNS becomes a no-op).
|
||||
func New(as *db.AssetStore, proxy func() string, workers int) *Engine {
|
||||
if workers <= 0 {
|
||||
workers = 4
|
||||
}
|
||||
resolv, err := dnsx.New(dnsx.Options{
|
||||
BaseResolvers: dnsx.DefaultResolvers,
|
||||
MaxRetries: 3,
|
||||
QuestionTypes: []uint16{dns.TypeA, dns.TypeAAAA, dns.TypeCNAME},
|
||||
Timeout: 4 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[enrich] dnsx 初始化失败,DNS 解析停用:%v", err)
|
||||
resolv = nil
|
||||
}
|
||||
e := &Engine{
|
||||
as: as,
|
||||
resolv: resolv,
|
||||
client: buildClient(proxy),
|
||||
jobs: make(chan job, queueSize),
|
||||
closed: make(chan struct{}),
|
||||
}
|
||||
for i := 0; i < workers; i++ {
|
||||
go e.worker()
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// buildClient returns an HTTP client that dials via the recording proxy (resolved
|
||||
// per-request via proxy(), so the traffic-capture toggle applies live) and skips
|
||||
// TLS verification (the proxy re-signs with its MITM CA; targets are often
|
||||
// self-signed — this is a pentest probe).
|
||||
func buildClient(proxy func() string) *http.Client {
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
DisableKeepAlives: true,
|
||||
TLSHandshakeTimeout: httpTimeout,
|
||||
}
|
||||
if proxy != nil {
|
||||
tr.Proxy = func(*http.Request) (*url.URL, error) {
|
||||
p := proxy()
|
||||
if p == "" {
|
||||
return nil, nil // direct
|
||||
}
|
||||
return url.Parse(p)
|
||||
}
|
||||
}
|
||||
return &http.Client{
|
||||
Transport: tr,
|
||||
Timeout: httpTimeout,
|
||||
// cap redirects; keep them within scope by re-checking at probe time
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveDomain enqueues a DNS resolution for a domain asset (ungated). host is the
|
||||
// FQDN. No-op for an empty engine.
|
||||
func (e *Engine) ResolveDomain(id int64, host string) { e.enqueue(job{jobDNS, id, host}) }
|
||||
|
||||
// ProbeSite enqueues an HTTP probe for a web-asset (site). rawURL is the site URL.
|
||||
func (e *Engine) ProbeSite(id int64, rawURL string) { e.enqueue(job{jobHTTP, id, rawURL}) }
|
||||
|
||||
func (e *Engine) enqueue(j job) {
|
||||
if e == nil || id0(j.id) || j.arg == "" {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case e.jobs <- j:
|
||||
default: // queue full → drop (best-effort enrichment)
|
||||
log.Printf("[enrich] 队列已满,丢弃任务 kind=%d id=%d", j.kind, j.id)
|
||||
}
|
||||
}
|
||||
|
||||
func id0(id int64) bool { return id <= 0 }
|
||||
|
||||
// Close stops the workers (idempotent).
|
||||
func (e *Engine) Close() {
|
||||
if e == nil {
|
||||
return
|
||||
}
|
||||
e.once.Do(func() { close(e.closed) })
|
||||
}
|
||||
|
||||
func (e *Engine) worker() {
|
||||
for {
|
||||
select {
|
||||
case <-e.closed:
|
||||
return
|
||||
case j := <-e.jobs:
|
||||
if e.onCooldown(j) {
|
||||
continue
|
||||
}
|
||||
switch j.kind {
|
||||
case jobDNS:
|
||||
e.doDNS(j.id, j.arg)
|
||||
case jobHTTP:
|
||||
e.doHTTP(j.id, j.arg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// onCooldown returns true (skip) if this (kind,id) ran within the cooldown window.
|
||||
func (e *Engine) onCooldown(j job) bool {
|
||||
key := string(rune(j.kind)) + ":" + itoa(j.id)
|
||||
if v, ok := e.cool.Load(key); ok {
|
||||
if t, ok := v.(time.Time); ok && time.Since(t) < cooldown {
|
||||
return true
|
||||
}
|
||||
}
|
||||
e.cool.Store(key, time.Now())
|
||||
return false
|
||||
}
|
||||
|
||||
func itoa(n int64) string { return strconv.FormatInt(n, 10) }
|
||||
|
||||
// ---- DNS ----
|
||||
|
||||
func (e *Engine) doDNS(id int64, host string) {
|
||||
if e.resolv == nil {
|
||||
return
|
||||
}
|
||||
data, err := e.resolv.QueryMultiple(host)
|
||||
if err != nil || data == nil {
|
||||
return
|
||||
}
|
||||
ips := uniq(append(append([]string{}, data.A...), data.AAAA...))
|
||||
// Upsert resolved IPs into the asset store.
|
||||
for _, ip := range ips {
|
||||
_, _ = e.as.UpsertIP(db.UpsertIPReq{
|
||||
IP: ip,
|
||||
BoundDomains: []string{host},
|
||||
})
|
||||
}
|
||||
// Record A records as subdomains if host looks like a subdomain.
|
||||
for _, a := range data.A {
|
||||
_, _ = e.as.UpsertSubdomain(db.UpsertSubdomainReq{
|
||||
Domain: host,
|
||||
RecordType: "A",
|
||||
RecordValue: []string{a},
|
||||
})
|
||||
}
|
||||
for _, aaaa := range data.AAAA {
|
||||
_, _ = e.as.UpsertSubdomain(db.UpsertSubdomainReq{
|
||||
Domain: host,
|
||||
RecordType: "AAAA",
|
||||
RecordValue: []string{aaaa},
|
||||
})
|
||||
}
|
||||
for _, cname := range data.CNAME {
|
||||
_, _ = e.as.UpsertSubdomain(db.UpsertSubdomainReq{
|
||||
Domain: host,
|
||||
RecordType: "CNAME",
|
||||
RecordValue: []string{cname},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---- HTTP probe ----
|
||||
|
||||
var reTitle = regexp.MustCompile(`(?is)<title[^>]*>(.*?)</title>`)
|
||||
|
||||
func (e *Engine) doHTTP(id int64, rawURL string) {
|
||||
host := hostOf(rawURL)
|
||||
if host == "" {
|
||||
return
|
||||
}
|
||||
req, err := http.NewRequest("GET", rawURL, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
req.Header.Set("User-Agent", "artex-enrich/1.0")
|
||||
resp, err := e.client.Do(req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // cap 1 MiB
|
||||
statusCode := resp.StatusCode
|
||||
bodyLen := int64(len(body))
|
||||
title := extractTitle(body)
|
||||
_, _ = e.as.UpsertHTTPService(db.UpsertHTTPServiceReq{
|
||||
URL: rawURL,
|
||||
StatusCode: &statusCode,
|
||||
ContentLength: &bodyLen,
|
||||
PageTitle: title,
|
||||
})
|
||||
}
|
||||
|
||||
func extractTitle(body []byte) string {
|
||||
m := reTitle.FindSubmatch(body)
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(html.UnescapeString(string(m[1])))
|
||||
}
|
||||
|
||||
func hostOf(rawURL string) string {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return u.Hostname()
|
||||
}
|
||||
|
||||
func isBlank(v any) bool {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
return true
|
||||
case string:
|
||||
return x == ""
|
||||
case float64:
|
||||
return x == 0
|
||||
case int:
|
||||
return x == 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func uniq(in []string) []string {
|
||||
seen := map[string]struct{}{}
|
||||
out := in[:0]
|
||||
for _, s := range in {
|
||||
if _, ok := seen[s]; ok {
|
||||
continue
|
||||
}
|
||||
seen[s] = struct{}{}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user