Add ability to perform zone transfers (#48)

* Store zone transfer records

Co-Authored-By: moth <25512187+0x6d6f7468@users.noreply.github.com>

* Update config

* Add tests

* Tests, connectivity test

* Update tests

---------

Co-authored-by: moth <25512187+0x6d6f7468@users.noreply.github.com>
This commit is contained in:
Naomi Kramer
2025-05-13 18:31:40 -04:00
committed by GitHub
parent 3555d9b095
commit b23387c2e2
12 changed files with 688 additions and 27 deletions
+1
View File
@@ -26,6 +26,7 @@ func Commands() []*cli.Command {
DeleteCommand,
ListCommand,
ValidateConfigCommand,
ZoneTransferCommand,
}
}
+74
View File
@@ -0,0 +1,74 @@
package cmd
import (
"context"
"github.com/activecm/rita/v5/config"
"github.com/activecm/rita/v5/database"
"github.com/activecm/rita/v5/zonetransfer"
"github.com/spf13/afero"
"github.com/urfave/cli/v2"
)
var ZoneTransferCommand = &cli.Command{
Name: "zone-transfer",
Usage: "perform a zone transfer on the configured domain",
UsageText: "zone-transfer",
Description: "performs a zone transfer on the configured domain",
Args: false,
Flags: []cli.Flag{
ConfigFlag(false),
},
Action: func(cCtx *cli.Context) error {
// check if too many arguments were provided
if cCtx.NArg() > 0 {
return ErrTooManyArguments
}
// set up file system interface
afs := afero.NewOsFs()
// load config file
cfg, err := config.ReadFileConfig(afs, cCtx.String("config"))
if err != nil {
return err
}
// run the delete command
if err := runZoneTransferCmd(cfg); err != nil {
return err
}
// check for updates after running the command
if err := CheckForUpdate(cfg); err != nil {
return err
}
return nil
},
}
func runZoneTransferCmd(cfg *config.Config) error {
// connect to server
server, err := database.ConnectToServer(context.Background(), cfg)
if err != nil {
return err
}
// create tables if they don't already exist
if err := server.CreateServerDBTables(); err != nil {
return err
}
zt, err := zonetransfer.NewZoneTransfer(server, cfg)
if err != nil {
return err
}
if err := zt.PerformZoneTransfer(); err != nil {
return err
}
return nil
}
+38
View File
@@ -0,0 +1,38 @@
package cmd_test
import (
"github.com/activecm/rita/v5/cmd"
"github.com/urfave/cli/v2"
"github.com/stretchr/testify/require"
)
func (c *CmdTestSuite) TestZoneTransfer() {
commands := []*cli.Command{cmd.ZoneTransferCommand}
flags := []cli.Flag{}
// create a new app and context
app, ctx := setupTestApp(commands, flags)
tests := []struct {
name string
args []string
expectedError error
}{
{name: "Too Many Arguments", args: []string{"app", "zone-transfer", "bingbong"}, expectedError: cmd.ErrTooManyArguments},
}
for _, test := range tests {
c.Run(test.name, func() {
require := require.New(c.T())
// run app with test.args
err := app.RunContext(ctx, test.args)
if test.expectedError != nil {
require.Error(err, "error should not be nil")
require.Contains(err.Error(), test.expectedError.Error(), "error should contain expected value")
} else {
require.NoError(err, "error should be nil")
}
})
}
}
+17 -5
View File
@@ -39,11 +39,12 @@ const (
type (
Config struct {
Env Env `json:"env" validate:"required"`
RITA `validate:"required"`
Filtering Filtering `json:"filtering" validate:"required"`
Scoring Scoring `json:"scoring" validate:"required"`
Modifiers Modifiers `json:"modifiers" validate:"required"`
Env Env `json:"env" validate:"required"`
RITA `validate:"required"`
Filtering Filtering `json:"filtering" validate:"required"`
Scoring Scoring `json:"scoring" validate:"required"`
Modifiers Modifiers `json:"modifiers" validate:"required"`
ZoneTransfer ZoneTransfer `json:"zone_transfer"`
}
Env struct { // set by .env file
@@ -82,6 +83,12 @@ type (
ThreatScoring `validate:"required"`
}
ZoneTransfer struct {
Enabled bool `ch:"enabled" json:"enabled"`
DomainName string `ch:"domain_name" json:"domain_name" validate:"required_if=Enabled true,omitempty,fqdn"`
NameServer string `ch:"name_server" json:"name_server" validate:"required_if=Enabled true,omitempty,hostname_port"`
}
BeaconScoring struct {
UniqueConnectionThreshold int64 `ch:"unique_connection_threshold" json:"unique_connection_threshold" validate:"gte=4"`
TimestampScoreWeight float64 `ch:"timestamp_score_weight" json:"timestamp_score_weight" validate:"gte=0,lte=1"`
@@ -592,6 +599,11 @@ func defaultConfig() Config {
MIMETypeMismatchScoreIncrease: 0.15, // +15% score for connections with mismatched MIME type/URI
},
ZoneTransfer: ZoneTransfer{
Enabled: false,
DomainName: "",
NameServer: "",
},
}
}
+20
View File
@@ -470,6 +470,26 @@ func TestConfig_Validate(t *testing.T) {
{name: "MIMETypeMismatchScoreIncrease > Range", config: func(cfg *Config) { cfg.Modifiers.MIMETypeMismatchScoreIncrease = 1.1 }, expectedErrs: []string{"'MIMETypeMismatchScoreIncrease' failed on the 'lte' tag"}},
{name: "Empty Struct", config: func(cfg *Config) { cfg.Modifiers = Modifiers{} }, expectedErrs: []string{"'Modifiers' failed on the 'required' tag"}},
}},
{"Zone Transfer", []testCase{
{name: "Disabled, Empty Domain Name", config: func(cfg *Config) { cfg.ZoneTransfer.DomainName = "" }},
{name: "Disabled, Empty Name Server", config: func(cfg *Config) { cfg.ZoneTransfer.NameServer = "" }},
{name: "Enabled, Empty Domain Name", config: func(cfg *Config) { cfg.ZoneTransfer.DomainName = ""; cfg.ZoneTransfer.Enabled = true }, expectedErrs: []string{"'DomainName' failed on the 'required_if' tag", "'NameServer' failed on the 'required_if' tag"}},
{name: "Enabled, Empty Name Server", config: func(cfg *Config) { cfg.ZoneTransfer.NameServer = ""; cfg.ZoneTransfer.Enabled = true }, expectedErrs: []string{"'DomainName' failed on the 'required_if' tag", "'NameServer' failed on the 'required_if' tag"}},
{name: "Invalid Domain Name", config: func(cfg *Config) {
cfg.ZoneTransfer.DomainName = "192.168.0.1"
cfg.ZoneTransfer.NameServer = "dc1.bug.corp:53"
cfg.ZoneTransfer.Enabled = true
}, expectedErrs: []string{"'DomainName' failed on the 'fqdn' tag"}},
{name: "Invalid Name Server", config: func(cfg *Config) {
cfg.ZoneTransfer.DomainName = "bug.corp."
cfg.ZoneTransfer.NameServer = "bug.corp"
cfg.ZoneTransfer.Enabled = true
}, expectedErrs: []string{"'NameServer' failed on the 'hostname_port' tag"}},
{name: "Disabled but Valid", config: func(cfg *Config) {
cfg.ZoneTransfer.DomainName = "bug.corp."
cfg.ZoneTransfer.NameServer = "dc1.bug.corp:53"
}},
}},
}
for _, test := range tests {
t.Run(test.group, func(t *testing.T) {
+42
View File
@@ -61,6 +61,14 @@ func (server *ServerConn) createMetaDatabase() error {
return err
}
if err := server.createMetaDatabasePerformedZoneTransfersTable(); err != nil {
return err
}
if err := server.createMetaDatabaseZoneTransferTable(); err != nil {
return err
}
if err := server.createThreatIntelTables(); err != nil {
return err
}
@@ -166,6 +174,40 @@ func (server *ServerConn) createMetaDatabaseMinMaxTable() error {
return nil
}
func (server *ServerConn) createMetaDatabasePerformedZoneTransfersTable() error {
err := server.Conn.Exec(server.ctx, `
CREATE TABLE IF NOT EXISTS metadatabase.performed_zone_transfers (
domain_name String,
name_server String,
serial_soa UInt32,
mbox String,
is_ixfr Bool, -- for debugging
performed_at DateTime()
)
ENGINE = ReplacingMergeTree(performed_at)
PRIMARY KEY (domain_name, name_server)
`)
return err
}
func (server *ServerConn) createMetaDatabaseZoneTransferTable() error {
err := server.Conn.Exec(server.ctx, `
CREATE TABLE IF NOT EXISTS metadatabase.zone_transfer (
performed_at DateTime(),
domain_name String,
name_server String,
hostname String,
ip IPv6,
ttl Int32, -- RFC2181 defines TTLs as int32
) ENGINE = MergeTree()
PRIMARY KEY (domain_name, name_server, hostname, ip)
ORDER BY (domain_name, name_server, hostname, ip, performed_at)
`)
return err
}
// createMetaDatabaseFilesTable creates the metadatabase.files table
func (server *ServerConn) createMetaDatabaseSampleDBsTable() error {
err := server.Conn.Exec(server.ctx, `
+22
View File
@@ -1447,6 +1447,23 @@ func (db *DB) createExplodedDNSTable(ctx context.Context) error {
return err
}
func (db *DB) createIPToHostnameTable(ctx context.Context) error {
if err := db.Conn.Exec(ctx, `--sql
CREATE TABLE IF NOT EXISTS {database:Identifier}.ip_to_hostname (
import_time DateTime(),
import_id FixedString(16),
hostname String,
ip IPv6,
ttl Int32, -- RFC2181 defines TTLs as int32
) ENGINE = MergeTree()
PRIMARY KEY (import_id, hostname, ip)
`); err != nil {
return err
}
return nil
}
func (db *DB) createSensorDBTables() error {
ctx := db.QueryParameters(clickhouse.Parameters{
"database": db.selected,
@@ -1569,6 +1586,11 @@ func (db *DB) createSensorDBTables() error {
return err
}
err = db.createIPToHostnameTable(ctx)
if err != nil {
return err
}
if err := db.createMinMaxMaterializedView(); err != nil {
return err
}
+13
View File
@@ -21,6 +21,7 @@ var AnalysisSnapshotHourTTLs = []string{"big_ol_histogram", "tls_proto", "http_p
var AnalysisSnapshotAnalyzedAtTTLs = []string{"threat_mixtape"}
var MetaDatabaseTTLs = []string{"historical_first_seen", "files"}
var MetaDatabaseYearTTLS = []string{"imports"}
var ZoneTransferTTLs = []string{"performed_zone_transfers", "zone_transfer"}
func (db *DB) createLogTableTTLs() error {
if !db.Rolling {
@@ -171,5 +172,17 @@ func (server *ServerConn) createMetaDatabaseTTLs(monthsToKeepHistoricalFirstSeen
return err
}
err = server.Conn.Exec(ctx, `--sql
ALTER TABLE metadatabase.performed_zone_transfers MODIFY TTL performed_at + INTERVAL 90 DAYS`)
if err != nil {
return err
}
err = server.Conn.Exec(ctx, `--sql
ALTER TABLE metadatabase.zone_transfer MODIFY TTL performed_at + INTERVAL 90 DAYS`)
if err != nil {
return err
}
return nil
}
+9 -6
View File
@@ -12,6 +12,7 @@ require (
github.com/hjson/hjson-go/v4 v4.4.0
github.com/joho/godotenv v1.5.1
github.com/json-iterator/go v1.1.12
github.com/miekg/dns v1.1.65
github.com/montanaflynn/stats v0.7.1
github.com/muesli/reflow v0.3.0
github.com/rs/zerolog v1.33.0
@@ -22,8 +23,8 @@ require (
github.com/testcontainers/testcontainers-go/modules/compose v0.31.0
github.com/urfave/cli/v2 v2.27.2
github.com/vbauerster/mpb/v8 v8.7.3
golang.org/x/sync v0.7.0
golang.org/x/text v0.16.0
golang.org/x/sync v0.11.0
golang.org/x/text v0.22.0
golang.org/x/time v0.5.0
)
@@ -33,6 +34,8 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
golang.org/x/mod v0.23.0 // indirect
golang.org/x/tools v0.30.0 // indirect
)
require (
@@ -225,12 +228,12 @@ require (
go.opentelemetry.io/otel/trace v1.24.0 // indirect
go.opentelemetry.io/proto/otlp v1.0.0 // indirect
go.uber.org/mock v0.4.0 // indirect
golang.org/x/crypto v0.22.0 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3 // indirect
golang.org/x/net v0.23.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/oauth2 v0.15.0 // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/term v0.20.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/term v0.29.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17 // indirect
+18 -16
View File
@@ -408,6 +408,8 @@ github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zk
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/miekg/dns v1.1.65 h1:0+tIPHzUW0GCge7IiK3guGP57VAw7hoPDfApjkMD1Fc=
github.com/miekg/dns v1.1.65/go.mod h1:Dzw9769uoKVaLuODMDZz9M6ynFU6Em65csPuoi8G0ck=
github.com/miekg/pkcs11 v1.0.2/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs=
github.com/miekg/pkcs11 v1.1.1 h1:Ugu9pdy6vAYku5DEpVWVFPYnzV+bxB+iRdbuFSu7TvU=
github.com/miekg/pkcs11 v1.1.1/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs=
@@ -695,8 +697,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20201117144127-c1f2f97bffc9/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3 h1:hNQpMuAJe5CtcUqCXaWga3FHu+kQvCqcsoVaQgSV60o=
golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08=
@@ -706,8 +708,8 @@ golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHl
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM=
golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -724,8 +726,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.15.0 h1:s8pnnxNVzjWyrvYdFUQq5llS1PX2zhPXmccZv99h7uQ=
golang.org/x/oauth2 v0.15.0/go.mod h1:q48ptWNTY5XWf+JNten23lcvHpLJ0ZSxF5ttTHKVCAM=
@@ -737,8 +739,8 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -772,21 +774,21 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -798,8 +800,8 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY=
golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+139
View File
@@ -0,0 +1,139 @@
package integration_test
import (
"context"
"testing"
"time"
"github.com/activecm/rita/v5/config"
"github.com/activecm/rita/v5/database"
"github.com/activecm/rita/v5/zonetransfer"
"github.com/spf13/afero"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
type ZoneTransferSuite FilterTestSuite
func TestZoneTransfer(t *testing.T) {
suite.Run(t, new(ZoneTransferSuite))
}
// Reset config after each test since these tests load the config from a file
func (it *ZoneTransferSuite) SetupSuite() {
t := it.T()
afs := afero.NewOsFs()
cfg, err := config.ReadFileConfig(afs, ConfigPath)
require.NoError(t, err)
cfg.Env.DBConnection = dockerInfo.clickhouseConnection
it.cfg = cfg
db, err := database.ConnectToServer(context.Background(), it.cfg)
require.NoError(t, err)
require.NoError(t, db.Conn.Exec(db.GetContext(), "TRUNCATE TABLE metadatabase.performed_zone_transfers"))
}
func (it *ZoneTransferSuite) SetupTest() {
err := it.cfg.Reset()
it.Require().NoError(err)
}
func (it *ZoneTransferSuite) TearDownSuite() {
err := it.cfg.Reset()
it.Require().NoError(err)
}
func (it *ZoneTransferSuite) TestRecordZoneTransferPerformed() {
t := it.T()
it.cfg.ZoneTransfer.Enabled = true
it.cfg.ZoneTransfer.DomainName = "bug.corp."
it.cfg.ZoneTransfer.NameServer = "dc1.bug.corp:53"
// connect to database
db, err := database.ConnectToServer(context.Background(), it.cfg)
require.NoError(t, err)
tests := []struct {
label string
toCreate zonetransfer.PerformedZoneTransfer
changeConfig bool
expectedIndex int
}{
{
label: "First entry should return itself",
toCreate: zonetransfer.PerformedZoneTransfer{
PerformedAt: time.Unix(1515531081, 0).UTC(),
DomainName: "bug.corp.",
NameServer: "dc1.bug.corp:53",
Serial: 100,
MBox: "example@bug.corp",
},
expectedIndex: 0,
},
{
label: "Newer timestamp should return itself",
toCreate: zonetransfer.PerformedZoneTransfer{
PerformedAt: time.Unix(1615531081, 0).UTC(),
DomainName: "bug.corp.",
NameServer: "dc1.bug.corp:53",
Serial: 100,
MBox: "example@bug.corp",
},
expectedIndex: 1,
},
{
label: "Older timestamp shouldn't return itself",
toCreate: zonetransfer.PerformedZoneTransfer{
PerformedAt: time.Unix(1415531081, 0).UTC(),
DomainName: "bug.corp.",
NameServer: "dc1.bug.corp:53",
Serial: 100,
MBox: "example@bug.corp",
},
expectedIndex: 1,
},
{
label: "It should filter by domain name and name server",
changeConfig: true,
toCreate: zonetransfer.PerformedZoneTransfer{
PerformedAt: time.Unix(1415531081, 0).UTC(),
DomainName: "bug2.corp2.",
NameServer: "dc1.bug2.corp2:53",
Serial: 100,
MBox: "example@bug.corp",
},
expectedIndex: 3,
},
}
zt, err := zonetransfer.NewZoneTransfer(db, it.cfg)
require.NoError(t, err)
// there should be no error if no zone transfer was performed yet, but result should be nil
latestZT, err := zt.FindLastZoneTransfer()
require.NoError(t, err)
require.Nil(t, latestZT)
for _, tc := range tests {
// switch to a different domain/name server for this
if tc.changeConfig {
it.cfg.ZoneTransfer.DomainName = tc.toCreate.DomainName
it.cfg.ZoneTransfer.NameServer = tc.toCreate.NameServer
}
zt, err := zonetransfer.NewZoneTransfer(db, it.cfg)
require.NoError(t, err)
zt.SetTransferInfo(tc.toCreate)
require.NoError(t, zt.RecordZoneTransferPerformed(), "recording zone transfer shouldn't fail: %s", tc.label)
latestZT, err := zt.FindLastZoneTransfer()
require.NoError(t, err)
require.NotNil(t, latestZT)
require.Equal(t, tests[tc.expectedIndex].toCreate, *latestZT, "latest zone transfer found should match expected performed zone transfer: %s", tc.label)
}
}
+295
View File
@@ -0,0 +1,295 @@
package zonetransfer
import (
"database/sql"
"errors"
"fmt"
"net"
"strconv"
"strings"
"time"
"github.com/ClickHouse/clickhouse-go/v2"
"github.com/activecm/rita/v5/config"
"github.com/activecm/rita/v5/database"
"github.com/activecm/rita/v5/logger"
"github.com/miekg/dns"
"golang.org/x/time/rate"
)
var ErrDomainNotConfigured = errors.New("domain name or name server has not been configured for zone transfer")
var ErrZoneTransferNotEnabled = errors.New("zone transfers are not enabled for RITA")
type Record struct {
PerformedAt time.Time `ch:"performed_at"`
Hostname string `ch:"hostname"`
IP net.IP `ch:"ip"`
TTL uint32 `ch:"ttl"`
// domain forest info
DomainName string `ch:"domain_name"`
NameServer string `ch:"name_server"`
}
type PerformedZoneTransfer struct {
PerformedAt time.Time `ch:"performed_at"`
DomainName string `ch:"domain_name"`
NameServer string `ch:"name_server"`
Serial uint32 `ch:"serial_soa"`
MBox string `ch:"mbox"`
IsIXFR bool `ch:"is_ixfr"`
}
type ZoneTransferConnectivityErrors struct {
NameServerUnreachableUDPError error `json:"name_server_unreachable_udp"`
NameServerUnreachableTCPError error `json:"name_server_unreachable_tcp"`
UDPQueryFailedError error `json:"udp_query_failed"`
AXFRFailedError error `json:"axfr_failed"`
}
type ZoneTransfer struct {
domainName string
nameServer string
latestSOA dns.SOA
db *database.ServerConn
cfg *config.Config
performedAt time.Time
}
// NewZoneTransfer creates the struct needed for handling zone transfers
func NewZoneTransfer(db *database.ServerConn, cfg *config.Config) (*ZoneTransfer, error) {
if len(cfg.ZoneTransfer.DomainName) == 0 || len(cfg.ZoneTransfer.NameServer) == 0 {
return nil, ErrDomainNotConfigured
}
if db == nil {
return nil, database.ErrInvalidDatabaseConnection
}
if err := db.CreateServerDBTables(); err != nil {
return nil, fmt.Errorf("unable to setup system for zone transfers, err: %w", err)
}
return &ZoneTransfer{
domainName: cfg.ZoneTransfer.DomainName,
nameServer: cfg.ZoneTransfer.NameServer,
db: db,
cfg: cfg,
performedAt: time.Now().UTC(),
}, nil
}
// DoZT handles a zone transfer
func (zt *ZoneTransfer) DoZT(axfr bool) error {
t := new(dns.Transfer)
m := new(dns.Msg)
if axfr {
// Set up an AXFR if requested
m.SetAxfr(zt.domainName)
} else {
// Otherwise, set up an IXFR, using the domain name and latest SOA Serial and Mbox values
m.SetIxfr(zt.domainName, zt.latestSOA.Serial, zt.domainName, zt.latestSOA.Mbox)
}
// create a channel for the dns "envelope"
ch, err := t.In(m, zt.nameServer)
if err != nil {
return err
}
// create a rate limiter to control the rate of writing to the database
limiter := rate.NewLimiter(5, 5)
// create a writer for the zone_transfer table
writer := database.NewBulkWriter(zt.db, zt.cfg, 1, "metadatabase.zone_transfer", "zone_transfer", "INSERT INTO metadatabase.zone_transfer", limiter, false)
writer.Start(0)
// since there could be a large volume of records coming in for a large domain, these records should be streamed via the dns envelope
// and then written into batches as the results come in
for env := range ch {
if env.Error != nil {
err = env.Error
break
}
// Type switch to handle the DNS envelope types we care about (SOA for IXFR info, A and AAAA for mapping)
for _, rr := range env.RR {
// create record with standard metadata
record := Record{PerformedAt: zt.performedAt, DomainName: zt.domainName, NameServer: zt.nameServer}
switch rec := rr.(type) {
case *dns.SOA:
// contains the serial and mbox info to store
zt.latestSOA = *rec
case *dns.A:
record.Hostname = strings.TrimSuffix(rec.Header().Name, ".")
record.IP = rec.A
record.TTL = rec.Header().Ttl
writer.WriteChannel <- &record
case *dns.AAAA:
record.Hostname = strings.TrimSuffix(rec.Header().Name, ".")
record.IP = rec.AAAA
record.TTL = rec.Header().Ttl
writer.WriteChannel <- &record
}
}
}
if err != nil {
return err
}
writer.Close()
// record that a zone transfer occurred
zt.RecordZoneTransferPerformed()
return nil
}
// RecordZoneTransferPerformed marks a completed zone transfer in the metadatabase and stores the most recent serial (SOA) found in the dns query
func (zt *ZoneTransfer) RecordZoneTransferPerformed() error {
chCtx := zt.db.QueryParameters(clickhouse.Parameters{
"performed_at": fmt.Sprintf("%d", zt.performedAt.UTC().Unix()),
"domain_name": zt.domainName,
"name_server": zt.nameServer,
"serial_soa": strconv.FormatUint(uint64(zt.latestSOA.Serial), 10),
"mbox": zt.latestSOA.Mbox,
})
if err := zt.db.Conn.Exec(chCtx, `
INSERT INTO metadatabase.performed_zone_transfers (performed_at, domain_name, name_server, serial_soa, mbox)
VALUES ( fromUnixTimestamp({performed_at:Int64}), {domain_name:String}, {name_server:String}, {serial_soa:UInt32}, {mbox:String} )
`); err != nil {
return err
}
return nil
}
// FindLastZoneTransfer finds the last zone transfer that was performed for this zt's domain and name server
func (zt *ZoneTransfer) FindLastZoneTransfer() (*PerformedZoneTransfer, error) {
chCtx := zt.db.QueryParameters(clickhouse.Parameters{
"domain_name": zt.domainName,
"name_server": zt.nameServer,
})
var lastZoneTransfer PerformedZoneTransfer
if err := zt.db.Conn.QueryRow(chCtx, `
SELECT max(performed_at) AS performed_at, domain_name, name_server, serial_soa, mbox, is_ixfr
FROM metadatabase.performed_zone_transfers
WHERE domain_name = {domain_name:String} AND name_server = {name_server:String}
GROUP BY domain_name, name_server, serial_soa, mbox, is_ixfr
ORDER BY performed_at DESC
LIMIT 1
`).ScanStruct(&lastZoneTransfer); err != nil {
// return nil PerformedZoneTransfer with no error if no zone transfer was found (ignore error)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &lastZoneTransfer, nil
}
// PerformZoneTransfer performs either an AXFR or IXFR zone transfer
func (zt *ZoneTransfer) PerformZoneTransfer() error {
// skip if zone transfers are not enabled
if !zt.cfg.ZoneTransfer.Enabled {
return ErrZoneTransferNotEnabled
}
/* ==== ZONE TRANSFERS ====
AXFR zone transfers do a transfer of the entire domain,
IXFR zone transfers do incremental zone transfers. They update the portions of the domain that have updated since the
Serial (SOA) that was provided with the transfer request. For example, on Monday a zone transfer was performed and we noted that
the current Serial is 100. On Tuesday, we perform an IXFR zone transfer and pass the Serial of 100 with the request. We will only
get back the results that have changed since Monday instead of the entire domain forest.
Performed zone transfers are stored in metadatabase.performed_zone_transfers, which tracks when a zone transfer was performed on a domain/name server and
what the latest Serial was at the time. If no results match in performed_zone_transfers, then we do an AXFR transfer.
*/
// Try to find last zone transfer that was performed for this domain & name server
lastZoneTransfer, err := zt.FindLastZoneTransfer()
if err != nil {
return fmt.Errorf("unable to find last performed zone transfer on domain '%s' via name server '%s': %w", zt.domainName, zt.nameServer, err)
}
zlog := logger.GetLogger()
transferType := "AXFR"
// There was a match in performed_zone_transfers, do an IXFR transfer
if lastZoneTransfer != nil {
// Do an IXFR
transferType = "IXFR"
if err := zt.DoZT(false); err != nil {
return fmt.Errorf("unable to perform IXFR zone transfer on domain '%s' via name server '%s': %w", zt.domainName, zt.nameServer, err)
}
} else {
// No match, do an AXFR
if err := zt.DoZT(true); err != nil {
return fmt.Errorf("unable to perform AXFR zone transfer on domain '%s' via name server '%s': %w", zt.domainName, zt.nameServer, err)
}
}
zlog.Info().Str("domain", zt.domainName).Str("name_server", zt.nameServer).Uint32("lastest_soa", zt.latestSOA.Serial).Msg(fmt.Sprintf("Successfully performed %s zone transfer", transferType))
return nil
}
func (zt *ZoneTransfer) TestNetConnectivity(protocol string) error {
conn, err := net.DialTimeout(protocol, zt.cfg.ZoneTransfer.NameServer, 5*time.Second)
if err != nil {
return err
}
conn.Close()
return nil
}
func (zt *ZoneTransfer) TestConnectivity() ZoneTransferConnectivityErrors {
var result ZoneTransferConnectivityErrors
zlog := logger.GetLogger()
if err := zt.TestNetConnectivity("udp"); err != nil {
result.NameServerUnreachableUDPError = err
zlog.Error().Err(err).Str("name_server", zt.nameServer).Msg("name server unreachable via UDP")
}
if err := zt.TestNetConnectivity("tcp"); err != nil {
result.NameServerUnreachableTCPError = err
zlog.Error().Err(err).Str("name_server", zt.nameServer).Msg("name server unreachable via TCP")
}
client := new(dns.Client)
client.Net = "udp"
msg := new(dns.Msg)
msg.SetQuestion(zt.domainName, dns.TypeSOA)
_, _, err := client.Exchange(msg, zt.nameServer)
if err != nil {
result.UDPQueryFailedError = err
zlog.Error().Err(err).Str("name_server", zt.nameServer).Str("domain_name", zt.domainName).Msg("failed to perform SOA query over UDP")
}
t := new(dns.Transfer)
m := new(dns.Msg)
m.SetAxfr(zt.domainName)
_, err = t.In(m, zt.nameServer)
if err != nil {
result.AXFRFailedError = err
zlog.Error().Err(err).Str("name_server", zt.nameServer).Str("domain_name", zt.domainName).Msg("failed to perform AXFR zone transfer")
}
zlog.Info().Str("name_server", zt.nameServer).Str("domain_name", zt.domainName).Msg("connectivity check to domain for zone transfers was successful")
return result
}
// for testing
func (zt *ZoneTransfer) SetTransferInfo(transferInfo PerformedZoneTransfer) {
zt.performedAt = transferInfo.PerformedAt
zt.domainName = transferInfo.DomainName
zt.nameServer = transferInfo.NameServer
zt.latestSOA.Serial = transferInfo.Serial
zt.latestSOA.Mbox = transferInfo.MBox
}