152 lines
4.7 KiB
Go
152 lines
4.7 KiB
Go
package db
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"golang.org/x/net/publicsuffix"
|
|
)
|
|
|
|
// ParsedScope is one parsed asset-scope entry. Its kind selects Domain, Net, or Value.
|
|
// Used internally by ParseScopeLine / ParseScopeLines (passed to CompanyStore).
|
|
type ParsedScope struct {
|
|
Kind string // "domain" | "ip" | "cidr" | "icp" | "keyword"
|
|
Domain string // normalized registrable/root domain (kind=domain)
|
|
Net string // normalized CIDR, single IP as /32 or /128 (kind=ip|cidr)
|
|
Value string // normalized text (kind=icp|keyword)
|
|
Raw string // original input line
|
|
}
|
|
|
|
// ScopeInput is the structured API form for a company scope rule. Empty Kind
|
|
// preserves the legacy one-line auto detection for domain/IP/CIDR clients.
|
|
type ScopeInput struct {
|
|
Kind string `json:"kind,omitempty"`
|
|
Value string `json:"value"`
|
|
}
|
|
|
|
// NormalizeICP removes every Unicode whitespace character and folds case. ICP
|
|
// matching intentionally performs no fuzzy or punctuation normalization.
|
|
func NormalizeICP(value string) string {
|
|
return strings.ToLower(strings.Map(func(r rune) rune {
|
|
if unicode.IsSpace(r) {
|
|
return -1
|
|
}
|
|
return r
|
|
}, strings.TrimSpace(value)))
|
|
}
|
|
|
|
func normalizeKeyword(value string) string {
|
|
return strings.ToLower(strings.Join(strings.Fields(value), " "))
|
|
}
|
|
|
|
// ParseScopeInput validates an explicitly typed rule. Legacy callers can omit
|
|
// Kind and retain the existing domain/IP/CIDR auto-detection behavior.
|
|
func ParseScopeInput(input ScopeInput) (ParsedScope, error) {
|
|
kind := strings.ToLower(strings.TrimSpace(input.Kind))
|
|
raw := strings.TrimSpace(input.Value)
|
|
if kind == "" {
|
|
return ParseScopeLine(raw)
|
|
}
|
|
switch kind {
|
|
case "domain", "ip", "cidr":
|
|
rule, err := ParseScopeLine(raw)
|
|
if err != nil {
|
|
return rule, err
|
|
}
|
|
if rule.Kind != kind {
|
|
return ParsedScope{Kind: kind, Raw: raw}, fmt.Errorf("%q 不是有效的 %s 范围", raw, kind)
|
|
}
|
|
return rule, nil
|
|
case "icp":
|
|
value := NormalizeICP(raw)
|
|
if value == "" {
|
|
return ParsedScope{Kind: kind, Raw: raw}, fmt.Errorf("ICP 不能为空")
|
|
}
|
|
return ParsedScope{Kind: kind, Value: value, Raw: raw}, nil
|
|
case "keyword":
|
|
value := normalizeKeyword(raw)
|
|
if value == "" {
|
|
return ParsedScope{Kind: kind, Raw: raw}, fmt.Errorf("企业关键词不能为空")
|
|
}
|
|
return ParsedScope{Kind: kind, Value: value, Raw: raw}, nil
|
|
default:
|
|
return ParsedScope{Kind: kind, Raw: raw}, fmt.Errorf("不支持的范围类型: %s", kind)
|
|
}
|
|
}
|
|
|
|
// ParseScopeLine classifies and validates one scope line (root domain / IP /
|
|
// CIDR). Guardrails reject bare TLDs and over-broad networks so a rule can never
|
|
// swallow the internet. IP ranges must be expressed as CIDR.
|
|
func ParseScopeLine(line string) (ParsedScope, error) {
|
|
raw := strings.TrimSpace(line)
|
|
r := ParsedScope{Raw: raw}
|
|
if raw == "" {
|
|
return r, fmt.Errorf("空行")
|
|
}
|
|
s := raw
|
|
if i := strings.Index(s, "://"); i >= 0 { // strip scheme if a URL was pasted
|
|
s = s[i+3:]
|
|
}
|
|
// CIDR first — it contains '/', which the path-strip below would remove.
|
|
if _, ipnet, err := net.ParseCIDR(s); err == nil {
|
|
ones, bits := ipnet.Mask.Size()
|
|
if bits == 32 && ones < 16 {
|
|
return r, fmt.Errorf("网段过宽(IPv4 需 >= /16): %s", raw)
|
|
}
|
|
if bits == 128 && ones < 32 {
|
|
return r, fmt.Errorf("网段过宽(IPv6 需 >= /32): %s", raw)
|
|
}
|
|
r.Kind, r.Net = "cidr", ipnet.String()
|
|
return r, nil
|
|
}
|
|
// Strip any path and :port remnants for host-like inputs.
|
|
if i := strings.IndexByte(s, '/'); i >= 0 {
|
|
s = s[:i]
|
|
}
|
|
// Single IP.
|
|
if ip := net.ParseIP(strings.TrimSpace(s)); ip != nil {
|
|
r.Kind = "ip"
|
|
if ip.To4() != nil {
|
|
r.Net = ip.String() + "/32"
|
|
} else {
|
|
r.Net = ip.String() + "/128"
|
|
}
|
|
return r, nil
|
|
}
|
|
if host, _, ok := strings.Cut(s, ":"); ok { // host:port → host
|
|
s = host
|
|
}
|
|
if strings.Contains(raw, "-") && strings.Count(raw, ".") >= 6 {
|
|
return r, fmt.Errorf("IP 段请用 CIDR 表示(如 1.2.3.0/24): %s", raw)
|
|
}
|
|
// Domain (registrable). Reject bare TLDs / public suffixes.
|
|
d := DomainKey(s)
|
|
if d == "" || !strings.Contains(d, ".") {
|
|
return r, fmt.Errorf("无法识别为根域名/IP/CIDR: %s", raw)
|
|
}
|
|
if suf, icann := publicsuffix.PublicSuffix(d); icann && suf == d {
|
|
return r, fmt.Errorf("不能用裸 TLD 作为范围: %s", raw)
|
|
}
|
|
r.Kind, r.Domain = "domain", d
|
|
return r, nil
|
|
}
|
|
|
|
// ParseScopeLines parses a whole text block (one entry per line), returning the
|
|
// valid rules and the invalid lines (with reasons) so the caller can report both.
|
|
func ParseScopeLines(text string) (rules []ParsedScope, invalid []string) {
|
|
for _, ln := range strings.Split(text, "\n") {
|
|
if strings.TrimSpace(ln) == "" {
|
|
continue
|
|
}
|
|
r, err := ParseScopeLine(ln)
|
|
if err != nil {
|
|
invalid = append(invalid, err.Error())
|
|
continue
|
|
}
|
|
rules = append(rules, r)
|
|
}
|
|
return rules, invalid
|
|
}
|