From a0c8180f4c731d89e2b562a8396202f11f1da28e Mon Sep 17 00:00:00 2001 From: GRMrGecko Date: Mon, 29 Apr 2024 12:12:27 -0500 Subject: [PATCH] First commit --- .gitignore | 4 ++ LICENSE.txt | 19 +++++++ README.md | 2 + config.go | 70 +++++++++++++++++++++++++ flags.go | 40 +++++++++++++++ go.mod | 14 +++++ go.sum | 31 +++++++++++ ip.go | 121 +++++++++++++++++++++++++++++++++++++++++++ main.go | 144 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 445 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 config.go create mode 100644 flags.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 ip.go create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..16fd851 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +list +config.yaml +dnsbl-scanner +*.csv \ No newline at end of file diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..ade9c16 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,19 @@ +Copyright (c) 2024 Mr. Gecko's Media (James Coleman). http://mrgeckosmedia.com/ + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..defa2d0 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# DNS Blocklist Scanner +This was written out of the need of scanning DNS blocklist formatted (also known as RBL) files for IP addresses in network ranges. May as well open source it for the case where its helpful to others. diff --git a/config.go b/config.go new file mode 100644 index 0000000..dfb6d5e --- /dev/null +++ b/config.go @@ -0,0 +1,70 @@ +package main + +import ( + "log" + "os" + "os/user" + "path" + "path/filepath" + + "github.com/kkyr/fig" +) + +// Main configuration structure. +type Config struct { + DNSBLFiles []string `fig:"dnsbl_files"` + IPAddresses []string `fig:"ip_addresses"` +} + +// Read the configuration file. +func (a *App) ReadConfig() { + // Set defaults. + config := &Config{} + + // Gets the current user for getting the home directory. + usr, err := user.Current() + if err != nil { + log.Fatal(err) + } + + // Configuration paths. + localConfig, _ := filepath.Abs("./config.yaml") + homeDirConfig := usr.HomeDir + "/.config/dnsbl-scanner/config.yaml" + etcConfig := "/etc/dnsbl-scanner/config.yaml" + + // Determine which configuration to use. + var configFile string + if _, err := os.Stat(app.flags.Config); err == nil && app.flags.Config != "" { + configFile = app.flags.Config + } else if _, err := os.Stat(localConfig); err == nil { + configFile = localConfig + } else if _, err := os.Stat(homeDirConfig); err == nil { + configFile = homeDirConfig + } else if _, err := os.Stat(etcConfig); err == nil { + configFile = etcConfig + } + + // Load configurations from file if exists. + if configFile != "" { + filePath, fileName := path.Split(configFile) + err = fig.Load(config, + fig.File(fileName), + fig.Dirs(filePath), + ) + if err != nil { + log.Printf("Error parsing configuration: %s\n", err) + return + } + } + + // Set overrides from flags. + if len(app.flags.DNSBLFiles) != 0 { + config.DNSBLFiles = app.flags.DNSBLFiles + } + if len(app.flags.IPAddresses) != 0 { + config.IPAddresses = app.flags.IPAddresses + } + + // Set global config structure. + app.config = config +} diff --git a/flags.go b/flags.go new file mode 100644 index 0000000..82cdfea --- /dev/null +++ b/flags.go @@ -0,0 +1,40 @@ +package main + +import ( + "fmt" + + "github.com/alecthomas/kong" +) + +type VersionFlag bool + +func (v VersionFlag) Decode(ctx *kong.DecodeContext) error { return nil } +func (v VersionFlag) IsBool() bool { return true } +func (v VersionFlag) BeforeApply(app *kong.Kong, vars kong.Vars) error { + fmt.Println(appName + ": " + appVersion) + app.Exit(0) + return nil +} + +// Flags supplied to cli. +type Flags struct { + Config string `help:"Location of config file" type:"existingfile"` + IPAddresses []string `help:"List of IP addresses to find."` + DNSBLFiles []string `help:"List of DNSBL files to scan." type:"path"` + Version VersionFlag `name:"version" help:"Print version information and quit"` +} + +// Parse the supplied flags. +func (a *App) ParseFlags() *kong.Context { + app.flags = &Flags{} + + ctx := kong.Parse(app.flags, + kong.Name(appName), + kong.Description(appDescription), + kong.UsageOnError(), + kong.ConfigureHelp(kong.HelpOptions{ + Compact: true, + }), + ) + return ctx +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3884924 --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module github.com/grmrgecko/dnsbl-scanner + +go 1.22.1 + +require ( + github.com/alecthomas/kong v0.9.0 + github.com/kkyr/fig v0.4.0 +) + +require ( + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pelletier/go-toml/v2 v2.1.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..28f7064 --- /dev/null +++ b/go.sum @@ -0,0 +1,31 @@ +github.com/alecthomas/assert/v2 v2.6.0 h1:o3WJwILtexrEUk3cUVal3oiQY2tfgr/FHWiz/v2n4FU= +github.com/alecthomas/assert/v2 v2.6.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/kong v0.9.0 h1:G5diXxc85KvoV2f0ZRVuMsi45IrBgx9zDNGNj165aPA= +github.com/alecthomas/kong v0.9.0/go.mod h1:Y47y5gKfHp1hDc7CH7OeXgLIpp+Q2m1Ni0L5s3bI8Os= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/kkyr/fig v0.4.0 h1:4D/g72a8ij1fgRypuIbEoqIT7ukf2URVBtE777/gkbc= +github.com/kkyr/fig v0.4.0/go.mod h1:U4Rq/5eUNJ8o5UvOEc9DiXtNf41srOLn2r/BfCyuc58= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= +github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/ip.go b/ip.go new file mode 100644 index 0000000..7a2618f --- /dev/null +++ b/ip.go @@ -0,0 +1,121 @@ +package main + +import ( + "bytes" + "fmt" + "net" + "strings" +) + +type IPAddr struct { + ip net.IP + ipNet *net.IPNet + broadcast net.IP +} + +// Calculate broadcast address for an provided IP network. +func getBroadcast(n *net.IPNet) net.IP { + // Make new IP. + l := len(n.IP) + out := make(net.IP, l) + + // For each octet, mask with inverse of mask to get the broadcast. + for i := 0; i < l; i++ { + out[i] = n.IP[i] | ^n.Mask[i] + } + return out +} + +// Parse an IP address with or without CIDR. +func ParseIPAddr(ip string) (*IPAddr, error) { + var err error + out := new(IPAddr) + + // If doesn't contain CIDR, do a normal IPv4 address parse. + if !strings.Contains(ip, "/") { + out.ip = net.ParseIP(ip) + // If IP is nil, there was an parse error. + if out.ip == nil { + return nil, fmt.Errorf("ip address parse error") + } + // Convert to IPv4 space as we do not care about keeping IPv4 in IPv6. + i4 := out.ip.To4() + if i4 != nil { + out.ip = i4 + } + return out, nil + } + + // Parse CIDR, and return error if error parsing. + out.ip, out.ipNet, err = net.ParseCIDR(ip) + if err != nil { + return nil, err + } + + // Convert to IPv4 space as we do not care about keeping IPv4 in IPv6. + // This also makes broadcast calculation work better. + i4 := out.ip.To4() + if i4 != nil { + out.ip = i4 + } + i4 = out.ipNet.IP.To4() + if i4 != nil { + // Convert IPv6 mask to IPv4 mask as this is an IPv4 address. + if len(out.ipNet.Mask) == net.IPv6len { + out.ipNet.Mask = out.ipNet.Mask[12:] + } + out.ipNet.IP = i4 + } + + // Calculate broadcast. + out.broadcast = getBroadcast(out.ipNet) + return out, nil +} + +// Return string represtation of IP address/CIDR. +func (n *IPAddr) String() string { + if n.ipNet == nil { + return n.ip.String() + } + return n.ipNet.String() +} + +// Compare IP addreseses to see if the networks contain or is equal the IP address provided. +func (n *IPAddr) Contains(ip *IPAddr) bool { + // If both are just IP addresses, do an equal operation. + if n.ipNet == nil && ip.ipNet == nil { + return n.ip.Equal(ip.ip) + } + + // If this is an network, but provided is not, use ip network contains. + if n.ipNet != nil && ip.ipNet == nil { + return n.ipNet.Contains(ip.ip) + } + + // If this is not an IP net, but provided address is... Return false. + if n.ipNet == nil && ip.ipNet != nil { + return false + } + + // If provided network addr is within IP range of this IP network, return true. + if bytes.Compare(ip.ipNet.IP, n.ipNet.IP) >= 0 && bytes.Compare(ip.ipNet.IP, n.broadcast) <= 0 { + return true + } + + // If provided broadcast addr is within IP range of this IP network, return true. + if bytes.Compare(ip.broadcast, n.ipNet.IP) >= 0 && bytes.Compare(ip.broadcast, n.broadcast) <= 0 { + return true + } + + return false +} + +// If IP addresses intercept. +func (n *IPAddr) Intercepts(ip *IPAddr) bool { + // If this IP contains provided, it intercepts. + if n.Contains(ip) { + return true + } + // If provided IP contains this IP, it intercepts. + return ip.Contains(n) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..04a9f28 --- /dev/null +++ b/main.go @@ -0,0 +1,144 @@ +package main + +import ( + "bufio" + "fmt" + "log" + "os" + "strings" + "unicode" +) + +// Basic application info. +const ( + appName = "dnsbl-scanner" + appDescription = "Scans DNSBL formatted files for IP ranges." + appVersion = "0.1" +) + +// Structure for the App. +type App struct { + flags *Flags + config *Config +} + +var app *App + +// Main program function. +func main() { + // Parse flags and config. + app = new(App) + app.ParseFlags() + app.ReadConfig() + + // Confirm blocklist files are provided. + if len(app.config.DNSBLFiles) == 0 { + log.Fatalln("no DNSBL files to scan, please provide in either a config file or via flags.") + } + // Confirm IP addresses to check are provided. + if len(app.config.IPAddresses) == 0 { + log.Fatalln("no IP addresses to search, please provide in either a config file or via flags.") + } + + // Parse provided IP addresses into networks. + var networks []*IPAddr + for _, ipAddr := range app.config.IPAddresses { + ip, err := ParseIPAddr(ipAddr) + if err != nil { + log.Fatal("Unable to parse provided IP address:", ipAddr) + } + networks = append(networks, ip) + } + + // Print CSV header. + fmt.Println("IP Address,Network,DNSBL File") + + // Read each DNS blocklist file, parse and check networks. + for _, dnsblFile := range app.config.DNSBLFiles { + // Open file, continue to next file if failure opening. + file, err := os.Open(dnsblFile) + if err != nil { + log.Println("Unable to open file:", dnsblFile, err) + continue + } + + // Networks to exclude. + var excluded []*IPAddr + + // Scan each line of the file and check if IP is in networks. + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + + // Remove comments. + commentI := strings.Index(line, "#") + if commentI != -1 { + line = line[:commentI] + } + + // Trim whitespace. + line = strings.TrimSpace(line) + + // Ignore variables. + if strings.HasPrefix(line, "$") { + continue + } + + // Ignore descriptions. + if strings.HasPrefix(line, ":") { + continue + } + + // Ignore empty lines. + if line == "" { + continue + } + + // Only need first field. + spaceI := strings.IndexFunc(line, unicode.IsSpace) + if spaceI != -1 { + line = line[:spaceI] + } + + // If excluded, add to exclude list. + if strings.HasPrefix(line, "!") { + // Remove exclamation mark. + line = line[1:] + + // Parse IP, failures should move to next line. + ipAddr, err := ParseIPAddr(line) + if err != nil { + continue + } + + // Add to excluded list and move to next line. + excluded = append(excluded, ipAddr) + continue + } + + // This should be an IP address that is block listed. + // Parse the IP address, and move to next line on parse failures. + ipAddr, err := ParseIPAddr(line) + if err != nil { + continue + } + + // If excluded, move to next line. + for _, exclude := range excluded { + if exclude.Contains(ipAddr) { + continue + } + } + + // Check networks to see if IP block list is intercepted. + for _, network := range networks { + if network.Intercepts(ipAddr) { + // If intercepts, print IP address, network, and block list file name in CSV format. + fmt.Printf("%s,%s,%s\n", ipAddr, network, dnsblFile) + // We can move on to next line now. + continue + } + } + } + } +}