Skip to content

Commit

Permalink
Convert IOCs into query language
Browse files Browse the repository at this point in the history
  • Loading branch information
mtnmunuklu committed Jul 17, 2024
1 parent 55cf012 commit 6be5655
Show file tree
Hide file tree
Showing 9 changed files with 404 additions and 6 deletions.
43 changes: 43 additions & 0 deletions ioc/config_parser.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package ioc

import (
"gopkg.in/yaml.v3"
)

// Config is a struct that defines the IOC configuration
type Config struct {
Title string // A short description of what this configuration does
Order int // Defines the order of expansion when multiple config files are applicable
FieldMappings map[string]FieldMapping
Placeholders map[string][]interface{} // Defines values for placeholders that might appear in IOCs
}

// FieldMapping is a struct that defines the target fields to be matched in IOCs
type FieldMapping struct {
TargetNames []string // The name(s) that appear in the events being matched
}

// UnmarshalYAML is a custom method for unmarshaling YAML data into FieldMapping
func (f *FieldMapping) UnmarshalYAML(value *yaml.Node) error {
switch value.Kind {
case yaml.ScalarNode:
// If the YAML value is a scalar (single value), set it as the only element in the slice
f.TargetNames = []string{value.Value}

case yaml.SequenceNode:
// If the YAML value is a sequence (list), decode it into a slice
var values []string
err := value.Decode(&values)
if err != nil {
return err
}
f.TargetNames = values
}
return nil
}

// ParseConfig takes a byte slice of YAML data and returns a Config struct or an error if unmarshaling fails
func ParseConfig(contents []byte) (Config, error) {
config := Config{}
return config, yaml.Unmarshal(contents, &config)
}
7 changes: 7 additions & 0 deletions ioc/data/configs/ioc.config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
title: Conversion of IOCs into CRYPTTECH Specific Queries
order: 10
fieldmappings:
ip: dst_ip
domain: dst_host
hash: hash
url: url
2 changes: 2 additions & 0 deletions ioc/data/iocs/simple_ioc.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
95.8.80.107
https://www.whatismyip.com
13 changes: 13 additions & 0 deletions ioc/data/output/IOC Rule ip, domain, url.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"Name": "IOC Rule ip, domain, url",
"Description": "",
"Query": "sourcetype=\"*\" eql select * from _source_ where _condition_ and (dst_ip = '95.8.80.107' OR dst_host = 'www.whatismyip.com' OR url = 'https://www.whatismyip.com')",
"InsertDate": "2024-07-17T20:25:33Z",
"LastUpdateDate": "2024-07-17T20:25:33Z",
"Tags": [
"ip",
"domain",
"url"
],
"Level": "info"
}
101 changes: 101 additions & 0 deletions ioc/ievaluator/evaluate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package ievaluator

import (
"fmt"
"strings"

"github.com/mtnmunuklu/alterix/ioc"
)

type IOCEvaluator struct {
*ioc.IOC
config []ioc.Config
fieldmappings map[string][]string
}

// ForIOC constructs a new IOCEvaluator with the given IOC and evaluation options.
// It applies any provided options to the new IOCEvaluator and returns it.
func ForIOC(ioc *ioc.IOC, options ...Option) *IOCEvaluator {
e := &IOCEvaluator{IOC: ioc}
for _, option := range options {
option(e)
}
return e
}

// Result represents the evaluation result of an IOC.
type Result struct {
QueryResult string // The query result as a string
Tags []string // The list of tags associated with the query
}

// Alters function generates a query string using the IOC information.
func (ioc IOCEvaluator) Alters() (Result, error) {
var conditions []string
var tags []string

// Generate query parts for each field type
ipConditions := generateCondition(ioc.fieldmappings["ip"], ioc.IOC.IPs)
domainConditions := generateCondition(ioc.fieldmappings["domain"], ioc.IOC.Domains)
urlConditions := generateCondition(ioc.fieldmappings["url"], ioc.IOC.URLs)
hashConditions := generateCondition(ioc.fieldmappings["hash"], ioc.IOC.Hashes)

conditions = append(conditions, ipConditions...)
conditions = append(conditions, domainConditions...)
conditions = append(conditions, urlConditions...)
conditions = append(conditions, hashConditions...)

// Add tags based on the conditions present
if len(ipConditions) > 0 {
tags = append(tags, "ip")
}
if len(domainConditions) > 0 {
tags = append(tags, "domain")
}
if len(urlConditions) > 0 {
tags = append(tags, "url")
}
if len(hashConditions) > 0 {
tags = append(tags, "hash")
}

// Join the conditions with " OR " and wrap in parentheses if there are multiple conditions
condition := strings.Join(conditions, " OR ")
if len(conditions) > 1 {
condition = fmt.Sprintf("(%s)", condition)
}

query := fmt.Sprintf(`sourcetype="*" eql select * from _source_ where _condition_ and %s`, condition)
result := Result{
QueryResult: query,
Tags: tags,
}

return result, nil
}

// generateCondition generates the condition part of the query for a given field and values.
func generateCondition(fields []string, values []string) []string {
var conditions []string
for _, field := range fields {
var condition string
if len(values) == 1 {
condition = fmt.Sprintf("%s = '%s'", field, values[0])
} else if len(values) > 1 {
condition = fmt.Sprintf("%s in (%s)", field, joinValues(values))
}
if condition != "" {
conditions = append(conditions, condition)
}
}
return conditions
}

// joinValues joins a slice of values into a single comma-separated string with quotes.
func joinValues(values []string) string {
quotedValues := make([]string, len(values))
for i, v := range values {
quotedValues[i] = fmt.Sprintf("'%s'", v)
}
return strings.Join(quotedValues, ", ")
}
24 changes: 24 additions & 0 deletions ioc/ievaluator/fieldmappings.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package ievaluator

// calculateFieldMappings compiles a mapping from the ioc fieldnames to possible event fieldnames
func (ioc *IOCEvaluator) calculateFieldMappings() {
// If no config is supplied, no field mapping is needed.
if ioc.config == nil {
return
}

// mappings is a map from ioc fieldnames to possible event fieldnames.
mappings := map[string][]string{}

// Loop through each config that is supplied.
for _, config := range ioc.config {
// For each field in the config, add the mapping target names to the mappings.
for field, mapping := range config.FieldMappings {
// TODO: trim duplicates and only care about fields that are actually checked by this ioc
mappings[field] = append(mappings[field], mapping.TargetNames...)
}
}

// Set the field mappings of the RuleEvaluator to the compiled mappings.
ioc.fieldmappings = mappings
}
17 changes: 17 additions & 0 deletions ioc/ievaluator/options.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package ievaluator

import (
"github.com/mtnmunuklu/alterix/ioc"
)

// Option is a function that takes a IOCEvaluator pointer and modifies its configuration
type Option func(*IOCEvaluator)

// WithConfig returns an Option that sets the provided IOC configs to the IOCEvaluator.
func WithConfig(config ...ioc.Config) Option {
return func(e *IOCEvaluator) {
// TODO: assert that the configs are in the correct order
e.config = append(e.config, config...)
e.calculateFieldMappings()
}
}
106 changes: 106 additions & 0 deletions ioc/ioc_parser.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package ioc

import (
"errors"
"net"
"net/url"
"regexp"
"strings"
)

// IOC struct contains IPs, domains, URLs, and hashes.
type IOC struct {
IPs []string
Domains []string
URLs []string
Hashes []string
}

// checkIfIP checks if the input string is a valid IP address.
func checkIfIP(input string) bool {
return net.ParseIP(input) != nil
}

// checkIfDomain checks if the input string is a valid domain.
func checkIfDomain(input string) bool {
domainRegex := regexp.MustCompile(`^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$`)
return domainRegex.MatchString(input)
}

// checkIfURL checks if the input string is a valid URL.
func checkIfURL(input string) bool {
u, err := url.ParseRequestURI(input)
return err == nil && (u.Scheme == "http" || u.Scheme == "https")
}

// checkIfHash checks if the input string is a valid hash (MD5, SHA1, SHA256).
func checkIfHash(input string) bool {
hashRegex := regexp.MustCompile(`^[a-fA-F0-9]{32}$|^[a-fA-F0-9]{40}$|^[a-fA-F0-9]{64}$`)
return hashRegex.MatchString(input)
}

func classifyInput(input string) (string, map[string][]string) {
extra := make(map[string][]string)
switch {
case checkIfIP(input):
return "ip", extra
case checkIfDomain(input):
return "domain", extra
case checkIfURL(input):
u, _ := url.Parse(input)
host := u.Hostname()
if checkIfIP(host) {
extra["ip"] = append(extra["ip"], host)
} else if checkIfDomain(host) {
extra["domain"] = append(extra["domain"], host)
}
return "url", extra
case checkIfHash(input):
return "hash", extra
default:
return "unknown", extra
}
}

func processData(data []byte) (*IOC, error) {
if len(data) == 0 {
return nil, errors.New("input data is empty")
}

result := &IOC{}
input := string(data)
parts := strings.Fields(input)

if len(parts) == 0 {
return nil, errors.New("no valid input found")
}

for _, part := range parts {
classification, extra := classifyInput(part)
switch classification {
case "ip":
result.IPs = append(result.IPs, part)
case "domain":
result.Domains = append(result.Domains, part)
case "url":
result.URLs = append(result.URLs, part)
case "hash":
result.Hashes = append(result.Hashes, part)
}
for key, items := range extra {
switch key {
case "ip":
result.IPs = append(result.IPs, items...)
case "domain":
result.Domains = append(result.Domains, items...)
}
}
}

return result, nil
}

// ParseIOC parses the input byte slice and returns an IOC struct.
func ParseIOC(input []byte) (*IOC, error) {
return processData(input)
}
Loading

0 comments on commit 6be5655

Please sign in to comment.