mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-02-12 16:12:44 +00:00
381 lines
9.1 KiB
Go
381 lines
9.1 KiB
Go
package model
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-errors/errors"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/oapi-codegen/nullable"
|
|
"github.com/phishingclub/phishingclub/errs"
|
|
"github.com/phishingclub/phishingclub/validate"
|
|
"github.com/phishingclub/phishingclub/vo"
|
|
)
|
|
|
|
// AllowDeny is a model for allow deny listing
|
|
type AllowDeny struct {
|
|
ID nullable.Nullable[uuid.UUID] `json:"id"`
|
|
CreatedAt *time.Time `json:"createdAt"`
|
|
UpdatedAt *time.Time `json:"updatedAt"`
|
|
Name nullable.Nullable[vo.String127] `json:"name"`
|
|
Cidrs nullable.Nullable[vo.IPNetSlice] `json:"cidrs"`
|
|
JA4Fingerprints nullable.Nullable[string] `json:"ja4Fingerprints"`
|
|
CountryCodes nullable.Nullable[string] `json:"countryCodes"`
|
|
Allowed nullable.Nullable[bool] `json:"allowed"`
|
|
CompanyID nullable.Nullable[uuid.UUID] `json:"companyID"`
|
|
}
|
|
|
|
// Validate checks if the allow deny list has a valid state
|
|
func (r *AllowDeny) Validate() error {
|
|
if err := validate.NullableFieldRequired("name", r.Name); err != nil {
|
|
return err
|
|
}
|
|
if err := validate.NullableFieldRequired("filter type", r.Allowed); err != nil {
|
|
return err
|
|
}
|
|
|
|
// at least one of cidrs, ja4 fingerprints, or country codes must be provided
|
|
hasCidrs := false
|
|
if r.Cidrs.IsSpecified() {
|
|
if cidrs, err := r.Cidrs.Get(); err == nil && len(cidrs) > 0 {
|
|
hasCidrs = true
|
|
}
|
|
}
|
|
|
|
hasJA4 := false
|
|
if r.JA4Fingerprints.IsSpecified() {
|
|
if ja4, err := r.JA4Fingerprints.Get(); err == nil && ja4 != "" {
|
|
hasJA4 = true
|
|
}
|
|
}
|
|
|
|
hasCountryCodes := false
|
|
if r.CountryCodes.IsSpecified() {
|
|
if codes, err := r.CountryCodes.Get(); err == nil && codes != "" {
|
|
hasCountryCodes = true
|
|
}
|
|
}
|
|
|
|
if !hasCidrs && !hasJA4 && !hasCountryCodes {
|
|
return errs.NewValidationError(
|
|
errors.New("at least one of CIDRs, JA4 fingerprints, or country codes must be provided"),
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ToDBMap converts the fields that can be stored or updated to a map
|
|
// if the value is nullable and not set, it is not included
|
|
// if the value is nullable and set, it is included, if it is null, it is set to nil
|
|
func (r *AllowDeny) ToDBMap() map[string]any {
|
|
m := map[string]any{}
|
|
if r.Name.IsSpecified() {
|
|
m["name"] = nil
|
|
if name, err := r.Name.Get(); err == nil {
|
|
m["name"] = name.String()
|
|
}
|
|
}
|
|
if r.Cidrs.IsSpecified() {
|
|
m["cidrs"] = nil
|
|
if cidrs, err := r.Cidrs.Get(); err == nil {
|
|
cidrsStr := ""
|
|
cidrsLen := len(cidrs)
|
|
for i, cidr := range cidrs {
|
|
if i == cidrsLen {
|
|
cidrsStr += fmt.Sprintf("%s", cidr.String())
|
|
|
|
} else {
|
|
cidrsStr += fmt.Sprintf("%s\n", cidr.String())
|
|
}
|
|
}
|
|
m["cidrs"] = cidrsStr
|
|
}
|
|
}
|
|
if r.JA4Fingerprints.IsSpecified() {
|
|
m["ja4_fingerprints"] = ""
|
|
if ja4, err := r.JA4Fingerprints.Get(); err == nil {
|
|
m["ja4_fingerprints"] = ja4
|
|
}
|
|
}
|
|
if r.CountryCodes.IsSpecified() {
|
|
m["country_codes"] = ""
|
|
if codes, err := r.CountryCodes.Get(); err == nil {
|
|
m["country_codes"] = codes
|
|
}
|
|
}
|
|
if r.Allowed.IsSpecified() {
|
|
m["allowed"] = nil
|
|
if allowed, err := r.Allowed.Get(); err == nil {
|
|
m["allowed"] = allowed
|
|
}
|
|
}
|
|
if r.CompanyID.IsSpecified() {
|
|
if r.CompanyID.IsNull() {
|
|
m["company_id"] = nil
|
|
} else {
|
|
m["company_id"] = r.CompanyID.MustGet()
|
|
}
|
|
}
|
|
|
|
return m
|
|
}
|
|
|
|
func (r *AllowDeny) IsIPAllowed(ip string) (bool, error) {
|
|
isTypeAllowList := r.Allowed.MustGet()
|
|
|
|
// if no cidrs configured, skip ip check (always pass)
|
|
cidrs, err := r.Cidrs.Get()
|
|
if err != nil || len(cidrs) == 0 {
|
|
return true, nil
|
|
}
|
|
|
|
netIP := net.ParseIP(ip)
|
|
if netIP == nil {
|
|
return false, fmt.Errorf("invalid ip address: %s", ip)
|
|
}
|
|
|
|
for _, cidr := range cidrs {
|
|
isInRange := cidr.Contains(netIP)
|
|
// if allow list and ip is within range
|
|
if isTypeAllowList && isInRange {
|
|
return true, nil
|
|
}
|
|
// if deny list and ip is within range
|
|
if !isTypeAllowList && isInRange {
|
|
return false, nil
|
|
}
|
|
}
|
|
|
|
// If this is an allow list and we didn't find the IP, it's not allowed
|
|
if isTypeAllowList {
|
|
return false, nil
|
|
}
|
|
|
|
// If this is a deny list and we didn't find the IP, it is allowed
|
|
return true, nil
|
|
}
|
|
|
|
// IsJA4Allowed checks if a JA4 fingerprint is allowed based on the filter rules
|
|
func (r *AllowDeny) IsJA4Allowed(ja4 string) (bool, error) {
|
|
if ja4 == "" {
|
|
// if no ja4 fingerprint available, skip ja4 check
|
|
return true, nil
|
|
}
|
|
|
|
isTypeAllowList := r.Allowed.MustGet()
|
|
|
|
// get ja4 fingerprints list
|
|
ja4FingerprintsStr, err := r.JA4Fingerprints.Get()
|
|
if err != nil || ja4FingerprintsStr == "" {
|
|
// if no ja4 fingerprints configured, skip ja4 check
|
|
return true, nil
|
|
}
|
|
|
|
// parse fingerprints (newline separated)
|
|
fingerprints := parseFingerprints(ja4FingerprintsStr)
|
|
|
|
// check if ja4 matches any fingerprint (supports wildcard patterns with *)
|
|
isMatch := false
|
|
for _, fp := range fingerprints {
|
|
if matchJA4Pattern(fp, ja4) {
|
|
isMatch = true
|
|
break
|
|
}
|
|
}
|
|
|
|
// if allow list and ja4 matches
|
|
if isTypeAllowList && isMatch {
|
|
return true, nil
|
|
}
|
|
// if deny list and ja4 matches
|
|
if !isTypeAllowList && isMatch {
|
|
return false, nil
|
|
}
|
|
|
|
// If this is an allow list and ja4 didn't match, not allowed
|
|
if isTypeAllowList {
|
|
return false, nil
|
|
}
|
|
|
|
// If this is a deny list and ja4 didn't match, it is allowed
|
|
return true, nil
|
|
}
|
|
|
|
// matchJA4Pattern checks if a JA4 fingerprint matches a pattern with wildcard support
|
|
// supports * as wildcard to match any characters
|
|
// examples:
|
|
//
|
|
// t13d151*h2_8daaf6152771_* matches any fingerprint with that prefix and cipher hash
|
|
// t13d*_*_* matches any TLS 1.3 fingerprint with SNI
|
|
// * matches everything
|
|
func matchJA4Pattern(pattern, ja4 string) bool {
|
|
// exact match (no wildcards)
|
|
if pattern == ja4 {
|
|
return true
|
|
}
|
|
|
|
// if pattern doesn't contain *, no match
|
|
if !strings.Contains(pattern, "*") {
|
|
return false
|
|
}
|
|
|
|
// wildcard-only pattern matches everything
|
|
if pattern == "*" {
|
|
return true
|
|
}
|
|
|
|
// split pattern by * and check each part exists in order
|
|
parts := strings.Split(pattern, "*")
|
|
pos := 0
|
|
|
|
for i, part := range parts {
|
|
if part == "" {
|
|
continue
|
|
}
|
|
|
|
// find the part in the remaining string
|
|
idx := strings.Index(ja4[pos:], part)
|
|
if idx == -1 {
|
|
return false
|
|
}
|
|
|
|
// for first part, must match at beginning
|
|
if i == 0 && idx != 0 {
|
|
return false
|
|
}
|
|
|
|
pos += idx + len(part)
|
|
}
|
|
|
|
// for last part, must match at end
|
|
lastPart := parts[len(parts)-1]
|
|
if lastPart != "" && !strings.HasSuffix(ja4, lastPart) {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// parseFingerprints splits newline-separated fingerprints and trims whitespace
|
|
func parseFingerprints(input string) []string {
|
|
var result []string
|
|
lines := splitLines(input)
|
|
for _, line := range lines {
|
|
trimmed := trimSpace(line)
|
|
if trimmed != "" {
|
|
result = append(result, trimmed)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// splitLines splits a string by newlines
|
|
func splitLines(s string) []string {
|
|
var lines []string
|
|
start := 0
|
|
for i := 0; i < len(s); i++ {
|
|
if s[i] == '\n' {
|
|
lines = append(lines, s[start:i])
|
|
start = i + 1
|
|
}
|
|
}
|
|
if start < len(s) {
|
|
lines = append(lines, s[start:])
|
|
}
|
|
return lines
|
|
}
|
|
|
|
// trimSpace removes leading and trailing whitespace
|
|
func trimSpace(s string) string {
|
|
start := 0
|
|
end := len(s)
|
|
|
|
for start < end && isSpace(s[start]) {
|
|
start++
|
|
}
|
|
for end > start && isSpace(s[end-1]) {
|
|
end--
|
|
}
|
|
|
|
return s[start:end]
|
|
}
|
|
|
|
// isSpace checks if a byte is whitespace
|
|
func isSpace(b byte) bool {
|
|
return b == ' ' || b == '\t' || b == '\n' || b == '\r'
|
|
}
|
|
|
|
// IsCountryAllowed checks if a country code is allowed based on the filter rules
|
|
func (r *AllowDeny) IsCountryAllowed(countryCode string) bool {
|
|
if countryCode == "" {
|
|
// if no country code available, skip country check
|
|
return true
|
|
}
|
|
|
|
isTypeAllowList := r.Allowed.MustGet()
|
|
|
|
// get country codes list
|
|
countryCodesStr, err := r.CountryCodes.Get()
|
|
if err != nil || countryCodesStr == "" {
|
|
// if no country codes configured, skip country check
|
|
return true
|
|
}
|
|
|
|
// if country code is empty but we have country filters configured
|
|
if countryCode == "" {
|
|
// in allow list mode: unknown country should be denied
|
|
// in deny list mode: unknown country should be allowed
|
|
if isTypeAllowList {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// parse country codes (newline separated)
|
|
codes := parseCountryCodes(countryCodesStr)
|
|
|
|
// check if country code matches any in the list (case-insensitive)
|
|
isMatch := false
|
|
countryCodeUpper := strings.ToUpper(countryCode)
|
|
for _, code := range codes {
|
|
if strings.ToUpper(code) == countryCodeUpper {
|
|
isMatch = true
|
|
break
|
|
}
|
|
}
|
|
|
|
// if allow list and country matches
|
|
if isTypeAllowList && isMatch {
|
|
return true
|
|
}
|
|
// if deny list and country matches
|
|
if !isTypeAllowList && isMatch {
|
|
return false
|
|
}
|
|
|
|
// If this is an allow list and country didn't match, not allowed
|
|
if isTypeAllowList {
|
|
return false
|
|
}
|
|
|
|
// If this is a deny list and country didn't match, it is allowed
|
|
return true
|
|
}
|
|
|
|
// parseCountryCodes splits newline-separated country codes and trims whitespace
|
|
func parseCountryCodes(input string) []string {
|
|
var result []string
|
|
lines := splitLines(input)
|
|
for _, line := range lines {
|
|
trimmed := trimSpace(line)
|
|
if trimmed != "" {
|
|
result = append(result, trimmed)
|
|
}
|
|
}
|
|
return result
|
|
}
|