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

This commit is contained in:
dela
2026-10-09 08:38:16 +08:00
commit 0335d572de
756 changed files with 201663 additions and 0 deletions
+272
View File
@@ -0,0 +1,272 @@
package db
import (
"fmt"
"net"
"net/url"
"strings"
"unicode"
"golang.org/x/net/idna"
"golang.org/x/net/publicsuffix"
)
// ParsedScope is one parsed asset-scope entry. Its kind selects Domain, Net, or Value.
// Used internally by the company scope parsers and 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
// uses the same automatic classification as the single-textarea UI.
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), " "))
}
func looksLikeIPAddress(value string) bool {
value = strings.TrimSpace(value)
if strings.Contains(value, "://") || strings.IndexFunc(value, unicode.IsSpace) >= 0 {
return false
}
if strings.Count(value, ":") >= 2 {
// Require an IPv6-looking prefix. This still catches malformed values such
// as 2001:db8::zz without treating ordinary colon-delimited keywords as IPs.
parts := strings.Split(value, ":")
validSegments := 0
for _, part := range parts {
if part == "" {
if validSegments > 0 || strings.HasPrefix(value, "::") {
return true
}
continue
}
if len(part) > 4 {
return false
}
for _, r := range part {
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
return false
}
}
validSegments++
if validSegments >= 2 {
return true
}
}
return false
}
if !strings.Contains(value, ".") {
return false
}
for _, r := range value {
if r != '.' && (r < '0' || r > '9') {
return false
}
}
return true
}
// ParseScopeInput validates an explicitly typed rule. Legacy callers can omit
// Kind and use the same automatic classification as the single-textarea UI.
func ParseScopeInput(input ScopeInput) (ParsedScope, error) {
kind := strings.ToLower(strings.TrimSpace(input.Kind))
raw := strings.TrimSpace(input.Value)
if kind == "" {
return ParseAutoScopeLine(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)
}
}
// ParseAutoScopeLine classifies one untyped textarea line. Network-looking and
// domain-looking values remain strict so malformed ranges do not silently become
// Agent keywords; all other non-empty text is a keyword.
func ParseAutoScopeLine(line string) (ParsedScope, error) {
raw := strings.TrimSpace(line)
if raw == "" {
return ParsedScope{}, fmt.Errorf("空行")
}
if _, _, err := net.ParseCIDR(raw); err == nil {
return ParseScopeLine(raw)
}
if ip := net.ParseIP(raw); ip != nil {
return ParseScopeLine(raw)
}
if slash := strings.LastIndexByte(raw, '/'); slash > 0 {
address := strings.TrimSpace(raw[:slash])
if net.ParseIP(address) != nil || looksLikeIPAddress(address) {
return ParsedScope{Raw: raw}, fmt.Errorf("无效 CIDR: %s", raw)
}
}
if looksLikeIPAddress(raw) {
return ParsedScope{Raw: raw}, fmt.Errorf("无效 IP: %s", raw)
}
looksLikeDomain := strings.Contains(raw, "://") ||
(strings.Contains(raw, ".") && strings.IndexFunc(raw, unicode.IsSpace) < 0)
if looksLikeDomain {
return ParseScopeLine(raw)
}
// 备案号本身不含点号(如 京ICP备12345678号-1)。带点的文本多半掺了域名或版本号,
// 按 ICP 存下来只会得到一条永远匹配不上任何资产的死规则 —— ICP 归属走的是精确
// 相等比较(见 companies.go 的 kind='icp' 归属查询),所以这类文本归为关键词。
lower := strings.ToLower(raw)
if !strings.ContainsAny(raw, "..。") &&
(strings.Contains(lower, "icp") || strings.Contains(raw, "备案")) {
return ParseScopeInput(ScopeInput{Kind: "icp", Value: raw})
}
return ParseScopeInput(ScopeInput{Kind: "keyword", Value: raw})
}
func scopeHostname(raw string) (string, error) {
candidate := strings.TrimSpace(raw)
if candidate == "" {
return "", fmt.Errorf("主机名为空")
}
if strings.HasPrefix(candidate, "//") {
candidate = "http:" + candidate
} else if !strings.Contains(candidate, "://") {
candidate = "http://" + candidate
}
parsed, err := url.Parse(candidate)
if err != nil || parsed.Host == "" {
if err == nil {
err = fmt.Errorf("缺少主机名")
}
return "", err
}
host := strings.TrimSuffix(strings.TrimSpace(parsed.Hostname()), ".")
if host == "" {
return "", fmt.Errorf("主机名为空")
}
if ip := net.ParseIP(host); ip != nil {
return ip.String(), nil
}
host, err = idna.Lookup.ToASCII(host)
if err != nil {
return "", err
}
host = strings.ToLower(host)
if len(host) > 253 {
return "", fmt.Errorf("域名超过 253 个字符")
}
labels := strings.Split(host, ".")
if len(labels) < 2 {
return "", fmt.Errorf("域名至少需要两个标签")
}
for _, label := range labels {
if label == "" || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
return "", fmt.Errorf("域名标签无效")
}
for _, r := range label {
if !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-') {
return "", fmt.Errorf("域名包含无效字符")
}
}
}
return host, nil
}
// 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("空行")
}
// CIDR first because URL parsing treats its slash as a path separator.
if _, ipnet, err := net.ParseCIDR(raw); 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
}
// Single IP.
if ip := net.ParseIP(raw); ip != nil {
r.Kind = "ip"
if ip.To4() != nil {
r.Net = ip.String() + "/32"
} else {
r.Net = ip.String() + "/128"
}
return r, nil
}
host, err := scopeHostname(raw)
if err != nil {
return r, fmt.Errorf("无法识别为有效域名/IP/CIDR: %s", raw)
}
if ip := net.ParseIP(host); ip != nil {
r.Kind = "ip"
if ip.To4() != nil {
r.Net = ip.String() + "/32"
} else {
r.Net = ip.String() + "/128"
}
return r, nil
}
if looksLikeIPAddress(host) {
return r, fmt.Errorf("无效 IP: %s", raw)
}
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(host)
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
}