/* Copyright 2026 Google Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package gonids
import (
"bufio"
"io"
)
// Format reads an IDS rule file or stream from r and writes canonically formatted output to w.
// Non-rule lines (such as comments and blank lines) are preserved as-is.
// Active and disabled rules (including multi-line rules) are formatted using Rule.String().
func Format(r io.Reader, w io.Writer) error {
scanner := NewRuleScanner(r)
scanner.IncludeComments = true
writer := bufio.NewWriter(w)
for scanner.Scan() {
if rule := scanner.Rule(); rule != nil {
if _, err := writer.WriteString(rule.String() + "\n"); err != nil {
return err
}
} else {
if _, err := writer.WriteString(scanner.Raw() + "\n"); err != nil {
return err
}
}
}
if err := scanner.Err(); err != nil {
return err
}
return writer.Flush()
}
/* Copyright 2019 Google Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package gonids
import (
"fmt"
"os"
)
var fuzzInit = false
// FuzzParseRule is used by OSS-Fuzz to fuzz the library.
func FuzzParseRule(data []byte) int {
if !fuzzInit {
fmt.Printf("GODEBUG=%s", os.Getenv("GODEBUG"))
fuzzInit = true
}
r, err := ParseRule(string(data))
if err != nil {
// Handle parse error
return 0
}
r.OptimizeHTTP()
_ = r.String()
return 1
}
/* Copyright 2016 Google Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package gonids
import (
"errors"
"fmt"
"strings"
"unicode"
"unicode/utf8"
)
// item represents a token or text string returned from the lexer.
type item struct {
typ itemType // The type of this item.
value string // The value of this item.
}
// String returns a string describing an item.
func (i item) String() string {
switch i.typ {
case itemEOF:
return "EOF"
case itemError:
return i.value
}
return fmt.Sprintf("%q: %s", i.typ, i.value)
}
type itemType int
const (
itemError itemType = iota
itemComment
itemAction
itemProtocol
itemSourceAddress
itemSourcePort
itemDirection
itemDestinationAddress
itemDestinationPort
itemNot
itemOptionKey
itemOptionValue
itemOptionNoValue
itemOptionValueString
itemEOR
itemEOF
)
const eof = -1
// stateFn represents the state of the scanner as a function that returns the next state.
type stateFn func(*lexer) stateFn
// lexer holds the state of the scanner.
type lexer struct {
input string // the string being scanned
state stateFn // the next lexing function to enter
pos int // current position in the input
start int // start position of this item
width int // width of last rune read from input
items chan item // channel of scanned items
}
// next returns the next rune in the input.
func (l *lexer) next() rune {
if l.pos >= len(l.input) {
l.width = 0
return eof
}
r, w := utf8.DecodeRuneInString(l.input[l.pos:])
if r == utf8.RuneError && w == 1 {
// The whole input string has been validated at init.
panic("invalid UTF-8 character")
}
l.width = w
l.pos += l.width
return r
}
// skipNext skips over the next rune in the input.
func (l *lexer) skipNext() {
l.next()
l.ignore()
}
// len returns the current length of the item in processing.
func (l *lexer) len() int {
if l.pos >= len(l.input) {
return -1
}
return l.pos - l.start
}
// backup steps back one rune. Can only be called once per call of next.
func (l *lexer) backup() {
if l.width == -1 {
panic("double backup")
}
l.pos -= l.width
l.width = -1
}
// emit passes an item back to the client, trimSpaces can be used to trim spaces around item
// value before emiting.
func (l *lexer) emit(t itemType, trimSpaces bool) {
input := l.input[l.start:l.pos]
if trimSpaces {
input = strings.TrimSpace(input)
}
l.items <- item{t, input}
l.start = l.pos
}
// ignore skips over the pending input before this point.
func (l *lexer) ignore() {
l.start = l.pos
}
// acceptRun consumes a run of runes from the valid set.
func (l *lexer) acceptRun(valid string) {
for strings.ContainsRune(valid, l.next()) {
}
l.backup()
}
// ignoreSpaces ignores all spaces at the start of the input.
func (l *lexer) ignoreSpaces() {
for unicode.IsSpace(l.next()) {
l.ignore()
}
l.backup()
}
// errorf returns an error token and terminates the scan by passing
// back a nil pointer that will be the next state, terminating l.nextItem.
func (l *lexer) errorf(format string, args ...interface{}) stateFn {
l.items <- item{itemError, fmt.Sprintf(format, args...)}
return nil
}
func (l *lexer) unexpectedEOF() stateFn {
return nil
}
// nextItem returns the next item from the input.
func (l *lexer) nextItem() item {
r, more := <-l.items
if !more {
return item{itemError, "unexpected EOF"}
}
return r
}
// lex initializes and runs a new scanner for the input string.
func lex(input string) (*lexer, error) {
if !utf8.ValidString(input) {
return nil, errors.New("input is not a valid UTF-8 string")
}
l := &lexer{
input: input,
items: make(chan item, 0x1000),
}
go l.run()
return l, nil
}
// TODO: handle error and corner case in all states.
// run runs the state machine for the lexer.
func (l *lexer) run() {
for l.state = lexRule; l.state != nil; {
l.state = l.state(l)
}
close(l.items)
}
func (l *lexer) close() {
// Reads all items until channel close to be sure goroutine has ended.
more := true
for more {
_, more = <-l.items
}
}
// lexRule starts the scan of a rule.
func lexRule(l *lexer) stateFn {
r := l.next()
switch {
case unicode.IsSpace(r):
l.ignore()
return lexRule
case r == '#':
return lexComment
case r == eof:
l.emit(itemEOF, false)
return nil
}
return lexAction
}
// lexComment consumes a commented rule.
func lexComment(l *lexer) stateFn {
// Ignore leading spaces and #.
l.ignore()
for {
r := l.next()
if unicode.IsSpace(r) || r == '#' {
l.ignore()
} else {
break
}
}
l.backup()
for {
switch l.next() {
case '\r', '\n':
l.emit(itemComment, false)
return lexRule
case eof:
l.backup()
l.emit(itemComment, false)
return lexRule
}
}
}
// lexAction consumes a rule action.
func lexAction(l *lexer) stateFn {
for {
r := l.next()
switch {
case r == ' ':
l.emit(itemAction, true)
return lexProtocol
case !unicode.IsLetter(r):
return l.errorf("invalid character %q for a rule action", r)
}
}
}
// lexProtocol consumes a rule protocol.
func lexProtocol(l *lexer) stateFn {
l.ignoreSpaces()
for {
r := l.next()
switch {
case r == ' ':
l.emit(itemProtocol, true)
return lexSourceAddress
case r == eof:
return l.unexpectedEOF()
case !unicode.IsLetter(r) && !unicode.IsDigit(r) && l.len() > 0 && r != '-':
return l.errorf("invalid character %q for a rule protocol", r)
}
}
}
// lexSourceAddress consumes a source address.
func lexSourceAddress(l *lexer) stateFn {
l.ignoreSpaces()
var depth int
for {
switch l.next() {
case '[':
depth++
case ']':
depth--
case ' ':
if depth <= 0 {
l.emit(itemSourceAddress, true)
return lexSourcePort
}
case eof:
return l.unexpectedEOF()
}
}
}
// lexSourcePort consumes a source port.
func lexSourcePort(l *lexer) stateFn {
l.ignoreSpaces()
var depth int
for {
switch l.next() {
case '[':
depth++
case ']':
depth--
case ' ':
if depth <= 0 {
l.emit(itemSourcePort, true)
return lexDirection
}
case eof:
return l.unexpectedEOF()
}
}
}
// lexDirection consumes a rule direction.
func lexDirection(l *lexer) stateFn {
l.ignoreSpaces()
l.acceptRun("<->=")
if r := l.next(); r != ' ' {
return l.errorf("invalid character %q for a rule direction", r)
}
l.emit(itemDirection, true)
return lexDestinationAddress
}
// lexDestinationAddress consumes a destination address.
func lexDestinationAddress(l *lexer) stateFn {
l.ignoreSpaces()
var depth int
for {
switch l.next() {
case '[':
depth++
case ']':
depth--
case ' ':
if depth <= 0 {
l.emit(itemDestinationAddress, true)
return lexDestinationPort
}
case eof:
return l.unexpectedEOF()
}
}
}
// lexDestinationPort consumes a destination port.
func lexDestinationPort(l *lexer) stateFn {
var depth int
for {
switch l.next() {
case '[':
depth++
case ']':
depth--
case '(':
if depth <= 0 {
l.backup()
l.emit(itemDestinationPort, true)
l.skipNext()
return lexOptionKey
}
case eof:
return l.unexpectedEOF()
}
}
}
// lexOptionKey scans a key from the rule options.
func lexOptionKey(l *lexer) stateFn {
for {
switch l.next() {
case ':':
l.backup()
l.emit(itemOptionKey, true)
l.skipNext()
return lexOptionValueBegin
case ';':
l.backup()
if strings.TrimSpace(l.input[l.start:l.pos]) != "" {
l.emit(itemOptionKey, true)
l.emit(itemOptionNoValue, true)
}
l.skipNext()
return lexOptionKey
case ')':
l.backup()
if strings.TrimSpace(l.input[l.start:l.pos]) != "" {
l.emit(itemOptionKey, true)
}
l.skipNext()
return lexRuleEnd
case eof:
return l.unexpectedEOF()
}
}
}
// lexOptionValueBegin scans the beginning of a value from the rule option.
func lexOptionValueBegin(l *lexer) stateFn {
switch l.next() {
case '"':
l.ignore()
return lexOptionValueString
case ' ':
l.ignore()
return lexOptionValueBegin
case '!':
l.emit(itemNot, true)
return lexOptionValueBegin
}
return lexOptionValue
}
// lexOptionValueString consumes the inner content of a string value from the rule options.
func lexOptionValueString(l *lexer) stateFn {
escaped := false
for {
switch l.next() {
case ';':
l.backup()
end := l.pos
trimmed := strings.TrimRightFunc(l.input[l.start:l.pos], unicode.IsSpace)
if strings.HasSuffix(trimmed, `"`) {
l.pos = l.start + len(trimmed) - 1
}
l.emit(itemOptionValueString, false)
l.pos = end
l.skipNext()
return lexOptionKey
case '\\':
escaped = !escaped
if l.next() != ';' || !escaped {
l.backup()
}
case eof:
return l.unexpectedEOF()
default:
escaped = false
}
}
}
// lexOptionValue scans a value from the rule options.
func lexOptionValue(l *lexer) stateFn {
for {
switch l.next() {
case ';':
l.backup()
l.emit(itemOptionValue, true)
l.skipNext()
return lexOptionKey
case eof:
return l.unexpectedEOF()
}
}
}
// lexOptionEnd marks the end of a rule.
func lexRuleEnd(l *lexer) stateFn {
l.emit(itemEOR, false)
return lexRule
}
/* Copyright 2016 Google Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package gonids
import (
"bytes"
"strings"
)
// ShouldBeHTTP returns true if a rule looks like the protocol should be http, but is not.
func (r *Rule) ShouldBeHTTP() bool {
// If the rule is already HTTP, then stop looking.
if r.Protocol == "http" {
return false
}
// If we look at http buffers or sticky buffers, we should use the HTTP protocol.
for _, c := range r.Contents() {
if strings.HasPrefix(c.DataPosition.String(), "http_") {
return true
}
for _, co := range c.Options {
if strings.HasPrefix(co.Name, "http_") {
return true
}
}
}
return false
}
// TODO: See if ET folks have any data around this.
// Minimum length of a content to be considered safe for use with a PCRE.
const minPCREContentLen = 5
// Some of these may be caught by min length check, but including for completeness.
// All lower case for case insenstive checks.
// Many of this come from: https://github.com/EmergingThreats/IDSDeathBlossom/blob/master/config/fpblacklist.txt
var bannedContents = []string{"get",
"post",
"/",
"user-agent",
"user-agent: mozilla",
"host",
"index.php",
"index.php?id=",
"index.html",
"content-length",
".htm",
".html",
".php",
".asp",
".aspx",
"content-disposition",
"wp-content/plugins",
"wp-content/themes",
"activexobject",
"default.asp",
"default.aspx",
"default.asp",
}
// ExpensivePCRE returns true if a rule appears to use a PCRE without
// conditions that make it expensive to compute.
func (r *Rule) ExpensivePCRE() bool {
// No PCRE, not expensive.
if len(r.PCREs()) < 1 {
return false
}
// If we have PCRE, but no contents, this is probably expensive.
cs := r.Contents()
if len(cs) < 1 {
return true
}
// Look for a content with sufficient length to make performance acceptable.
short := true
for _, c := range cs {
// TODO: Identify a reasonable length.
if len(c.Pattern) >= minPCREContentLen {
short = false
}
}
if short {
return true
}
// If all content matches are common strings, also not good.
common := true
for _, c := range cs {
if !inSlice(strings.ToLower(strings.Trim(string(c.Pattern), "\r\n :/?")), bannedContents) {
common = false
}
}
return common
}
// SnortHTTPHeader returns true if any content contains double CRLF at the end.
func (r *Rule) SnortHTTPHeader() bool {
cs := r.Contents()
if len(cs) < 1 {
return false
}
for _, c := range cs {
if c.SnortHTTPHeader() {
return true
}
}
return false
}
// SnortHTTPHeader returns true if a specific content contains double CRLF at the end.
func (c Content) SnortHTTPHeader() bool {
for _, o := range c.Options {
if o.Name == "http_header" {
if bytes.HasSuffix(c.Pattern, []byte("\r\n\r\n")) {
return true
}
}
}
return false
}
// NoReferences returns true if there are no references in the rule.
func (r *Rule) NoReferences() bool {
return len(r.References) == 0
}
// Length at which we warn if all matchers are this Contents with length or shorter.
// Possibly align this with the minPCREContentLength.
const shortContentLen = 4
// OnlyShortContents returns true if all Matchers are Contents and all matches are very short.
func (r *Rule) OnlyShortContents() bool {
// There are non-Content matches in the rule.
cs := r.Contents()
if len(r.Matchers) != len(cs) {
return false
}
for _, c := range cs {
// Some content is longer than the minimum.
if len(c.Pattern) > shortContentLen {
return false
}
}
return true
}
/* Copyright 2016 Google Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package gonids
import (
"bytes"
)
// Suricata 4.x content options mapped to Suricata 5.0 sticky buffers.
var cOptToStickyBuffer = map[string]DataPos{
// HTTP Content Modifiers
"http_client_body": httpClientBody,
"http_cookie": httpCookie,
"http_header": httpHeader,
"http_host": httpHost,
"http_method": httpMethod,
"http_raw_header": httpHeaderRaw,
"http_raw_host": httpHostRaw,
"http_raw_uri": httpURIRaw,
"http_request_line": httpRequestLine5,
"http_server_body": httpServerBody,
"http_stat_code": httpStatCode,
"http_stat_msg": httpStatMsg,
"http_uri": httpURI,
"http_user_agent": httpUserAgent,
}
var suri4StickyTo5Sticky = map[DataPos]DataPos{
fileData: fileData5,
// HTTP
httpAccept: httpAccept5,
httpAcceptEnc: httpAcceptEnc5,
httpAcceptLang: httpAcceptLang5,
httpConnection: httpConnection5,
httpContentLen: httpContentLen5,
httpContentType: httpContentType5,
httpHeaderNames: httpHeaderNames5,
httpProtocol: httpProtocol5,
httpReferer: httpReferer5,
httpRequestLine: httpRequestLine5,
httpResponseLine: httpResponseLine5,
httpStart: httpStart5,
// TLS
tlsCertSubject: tlsCertSubject5,
tlsCertIssuer: tlsCertIssuer5,
tlsCertSerial: tlsCertSerial5,
tlsCertFingerprint: tlsCertFingerprint5,
tlsSNI: tlsSNI5,
// JA3
ja3Hash: ja3Hash5,
ja3String: ja3String5,
// SSH
sshProto: sshProto5,
sshSoftware: sshSoftware5,
// DNS
dnsQuery: dnsQuery5,
}
// OptimizeHTTP tunes an old style rule to leverage port independent HTTP detection.
func (r *Rule) OptimizeHTTP() bool {
if !r.ShouldBeHTTP() {
return false
}
// Switch protocol to HTTP.
r.Protocol = "http"
// Make detection port independent.
for i, p := range r.Source.Ports {
if p == "$HTTP_PORTS" {
r.Source.Ports[i] = "any"
}
}
for i, p := range r.Destination.Ports {
if p == "$HTTP_PORTS" {
r.Destination.Ports[i] = "any"
}
}
// Annotate rule to indicate modification
r.Metas = append(r.Metas, MetadataModifier("http_optimize"))
return true
}
// SnortURILenFix will optimize a urilen keyword from a Snort rule for Suricata.
func (r *Rule) SnortURILenFix() bool {
var modified bool
// Update this once we parse urilen in a better structure.
for _, l := range r.LenMatchers() {
if l.Kind == uriLen && l.Operator == "<>" {
l.Min--
l.Max++
modified = true
}
setRaw := true
for _, o := range l.Options {
if o == "norm" || o == "raw" {
// If Snort rule specified norm or raw, trust author.
setRaw = false
break
}
}
// If author did not specify, set 'raw'.
if setRaw {
modified = true
l.Options = append(l.Options, "raw")
}
}
if modified {
r.Metas = append(r.Metas, MetadataModifier("snort_urilen"))
}
return modified
}
// SnortHTTPHeaderFix will fix broken http_header matches.
func (r *Rule) SnortHTTPHeaderFix() bool {
var modified bool
if !r.SnortHTTPHeader() {
return false
}
// Index r.Matchers directly: InsertMatcher grows the slice, so a range
// over it would keep using stale indexes and a stale backing array.
for i := 0; i < len(r.Matchers); i++ {
// If this is a content, check it out.
if c, ok := r.Matchers[i].(*Content); ok {
if c.SnortHTTPHeader() {
modified = true
c.Pattern = bytes.TrimSuffix(c.Pattern, []byte("\r\n"))
if err := r.InsertMatcher(&ByteMatch{Kind: isDataAt, Negate: true, NumBytes: "1"}, i+1); err != nil {
return false
}
// Skip the isdataat we just inserted.
i++
}
}
}
if modified {
r.Metas = append(r.Metas, MetadataModifier("snort_http_header"))
}
return modified
}
// UpgradeToSuri5 optimizes a Suricata 4.x rule to Suricata 5.x features.
func (r *Rule) UpgradeToSuri5() bool {
var modified bool
for _, c := range r.Contents() {
// Filter the old modifiers out in place. Removing them while ranging
// over c.Options skips the following option and reads the nil'd tail.
opts := c.Options[:0]
for _, opt := range c.Options {
if sticky, ok := cOptToStickyBuffer[opt.Name]; ok {
c.DataPosition = sticky
modified = true
continue
}
opts = append(opts, opt)
}
for i := len(opts); i < len(c.Options); i++ {
c.Options[i] = nil
}
c.Options = opts
// old sticky buffer to new sticky buffer
if sticky, ok := suri4StickyTo5Sticky[c.DataPosition]; ok {
c.DataPosition = sticky
modified = true
}
}
if modified {
r.Metas = append(r.Metas, MetadataModifier("upgrade_to_suri5"))
}
return modified
}
// MetadataModifier returns a metadata that identifies a given modification.
func MetadataModifier(s string) *Metadata {
return &Metadata{Key: "gonids", Value: s}
}
/* Copyright 2016 Google Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Package gonids implements a basic parser of IDS rules.
//
// For now the parser is very basic and it only parses a subset of fields.
// We intentionally omit http_encode as it doesn't seem to be used in practice.
package gonids
import (
"encoding/hex"
"errors"
"fmt"
"io"
"net"
"regexp"
"strconv"
"strings"
)
// hexRE matches on hexadecimal content like |41 41 41| for example. It is
// anchored so parseContent only tries a match at the current pipe instead of
// scanning the rest of the content.
var hexRE = regexp.MustCompile(`(?i)^(\|(?:\s*[a-f0-9]{2}\s*)+\|)`)
// metaSplitRE matches string in metadata.
var metaSplitRE = regexp.MustCompile(`,\s*`)
// nestedNetRE matches nested network groups.
var nestedNetRE = regexp.MustCompile(`,(!?\[[^]]*\])`)
// portSplitRE splits port lists and ranges for validation.
var portSplitRE = regexp.MustCompile(`[:,]`)
var appLayerProtocols = []string{
"bittorrent-dht",
"dcerpc",
"dhcp",
"dnp3",
"dns",
"enip",
"ftp",
"ftp-data",
"http",
"http1",
"http2",
"icmp",
"icmpv4",
"icmpv6",
"igmp",
"ike",
"ikev2",
"imap",
"ip",
"ip4",
"ip6",
"ipv4",
"ipv6",
"irc",
"jabber",
"krb5",
"ldap",
"mdns",
"modbus",
"mqtt",
"nfs",
"ntp",
"pgsql",
"pkthdr",
"pop3",
"quic",
"rdp",
"rfb",
"sctp",
"sip",
"smb",
"smtp",
"snmp",
"ssh",
"tcp",
"tcp-pkt",
"tcp-stream",
"telnet",
"tftp",
"tls",
"udp",
"websocket",
}
// parseContent decodes rule content match. For now it only takes care of escaped and hex
// encoded content.
func parseContent(content string) ([]byte, error) {
if containsUnescaped(content) {
return nil, fmt.Errorf("invalid special characters escaping")
}
// Walk the content once so that escaped characters and hex blocks are
// resolved in a single pass. An escaped pipe (\|) is literal data and must
// not open a hex block: decoding hex before resolving escapes would turn
// e.g. content:"\|41 41\|" into "AA" instead of the literal "|41 41|".
var b []byte
for i := 0; i < len(content); {
switch content[i] {
case '\\':
// containsUnescaped guarantees a valid escapable byte follows, so
// emit it literally without interpreting it further.
if i+1 < len(content) {
b = append(b, content[i+1])
}
i += 2
case '|':
// A hex block only starts at an unescaped pipe; anything that
// doesn't match hexRE here is a literal pipe.
if loc := hexRE.FindStringIndex(content[i:]); loc != nil {
r, err := hex.DecodeString(strings.ReplaceAll(strings.Trim(content[i:i+loc[1]], "|"), " ", ""))
if err != nil {
return nil, fmt.Errorf("invalid hex content %q: %v", content[i:i+loc[1]], err)
}
b = append(b, r...)
i += loc[1]
} else {
b = append(b, content[i])
i++
}
default:
b = append(b, content[i])
i++
}
}
return b, nil
}
// parsePCRE parses the components of a PCRE. Returns PCRE struct.
func parsePCRE(s string) (*PCRE, error) {
c := strings.Count(s, "/")
if c < 2 {
return nil, fmt.Errorf("all pcre patterns must contain at least 2 '/', found: %d", c)
}
l := strings.LastIndex(s, "/")
if l < 0 {
return nil, fmt.Errorf("couldn't find options in PCRE")
}
i := strings.Index(s, "/")
if l < 0 {
return nil, fmt.Errorf("couldn't find start of pattern")
}
return &PCRE{
Pattern: []byte(s[i+1 : l]),
Options: []byte(s[l+1:]),
}, nil
}
// parseLenMatch parses a LenMatch (like urilen).
func parseLenMatch(k lenMatchType, s string) (*LenMatch, error) {
m := new(LenMatch)
m.Kind = k
switch {
// Simple case, no operators.
case !strings.ContainsAny(s, "><"):
// Ignore options after ','.
numTmp := strings.Split(s, ",")[0]
num, err := strconv.Atoi(strings.TrimSpace(numTmp))
if err != nil {
return nil, fmt.Errorf("%v is not an integer", s)
}
m.Num = num
// Leading operator, single number.
case strings.HasPrefix(s, ">") || strings.HasPrefix(s, "<"):
if strings.HasPrefix(s, ">=") || strings.HasPrefix(s, "<=") {
m.Operator = s[:2]
} else {
m.Operator = s[:1]
}
numTmp := strings.Split(s[len(m.Operator):], ",")[0]
num, err := strconv.Atoi(strings.TrimSpace(numTmp))
if err != nil {
return nil, fmt.Errorf("%v is not an integer", s)
}
m.Num = num
// Min/Max center operator.
case strings.Contains(s, "<>"):
m.Operator = "<>"
parts := strings.Split(s, "<>")
if len(parts) != 2 {
return nil, fmt.Errorf("must have exactly 2 parts for min/max operator. got %d", len(parts))
}
var min, max int
var err error
min, err = strconv.Atoi(strings.TrimSpace(parts[0]))
if err != nil {
return nil, fmt.Errorf("%v is not an integer", strings.TrimSpace(parts[0]))
}
maxTmp := strings.Split(parts[1], ",")[0]
max, err = strconv.Atoi(strings.TrimSpace(maxTmp))
if err != nil {
return nil, fmt.Errorf("%v is not an integer", strings.TrimSpace(maxTmp))
}
// Do stuff to handle options here.
m.Min = min
m.Max = max
}
// Parse options:
if strings.Contains(s, ",") {
opts := strings.Split(s, ",")[1:]
for i, o := range opts {
opts[i] = strings.TrimSpace(o)
}
m.Options = opts
}
return m, nil
}
func parseBase64Decode(k byteMatchType, s string) (*ByteMatch, error) {
if k != b64Decode {
return nil, fmt.Errorf("kind %v is not base64_decode", k)
}
b := new(ByteMatch)
b.Kind = k
// All options to base64_decode are optional, and specified by their keyword.
for _, p := range strings.Split(s, ",") {
v := strings.TrimSpace(p)
switch {
case strings.HasPrefix(v, "bytes"):
b.NumBytes = strings.TrimSpace(strings.SplitAfter(v, "bytes")[1])
case strings.HasPrefix(v, "offset"):
val := strings.TrimSpace(strings.SplitAfter(v, "offset")[1])
b.Offset = val
if i, err := strconv.Atoi(val); err == nil {
if i < 0 {
return nil, fmt.Errorf("offset must be non-negative: %d", i)
}
}
case strings.HasPrefix(v, "relative"):
b.Options = []string{"relative"}
}
}
return b, nil
}
// parseByteMatch parses a ByteMatch.
func parseByteMatch(k byteMatchType, s string) (*ByteMatch, error) {
b := new(ByteMatch)
b.Kind = k
parts := strings.Split(s, ",")
if k == bMath {
for _, p := range parts {
p = strings.TrimSpace(p)
switch {
case strings.HasPrefix(p, "bytes "):
b.NumBytes = strings.TrimSpace(strings.TrimPrefix(p, "bytes "))
case strings.HasPrefix(p, "offset "):
b.Offset = strings.TrimSpace(strings.TrimPrefix(p, "offset "))
case strings.HasPrefix(p, "oper "):
b.Operator = strings.TrimSpace(strings.TrimPrefix(p, "oper "))
case strings.HasPrefix(p, "rvalue "):
b.Value = strings.TrimSpace(strings.TrimPrefix(p, "rvalue "))
case strings.HasPrefix(p, "result "):
b.Variable = strings.TrimSpace(strings.TrimPrefix(p, "result "))
default:
b.Options = append(b.Options, p)
}
}
return b, nil
}
// Num bytes is required for all byteMatchType keywords.
if len(parts) < 1 {
return nil, fmt.Errorf("%s keyword has %d parts", s, len(parts))
}
b.NumBytes = strings.TrimSpace(parts[0])
if len(parts) < b.Kind.minLen() {
return nil, fmt.Errorf("invalid %s length: %d", b.Kind, len(parts))
}
if k == bExtract || k == bJump {
// Parse offset.
b.Offset = strings.TrimSpace(parts[1])
}
if k == bExtract {
// Parse variable name.
b.Variable = strings.TrimSpace(parts[2])
}
if k == bTest {
// Parse operator.
b.Operator = strings.TrimSpace(parts[1])
// Parse value. Can use a variable.
b.Value = strings.TrimSpace(parts[2])
// Parse offset.
b.Offset = strings.TrimSpace(parts[3])
}
// The rest of the options, for all types not b64decode
for i, l := b.Kind.minLen(), len(parts); i < l; i++ {
parts[i] = strings.TrimSpace(parts[i])
b.Options = append(b.Options, parts[i])
}
return b, nil
}
// parseFlowbit parses a flowbit.
func parseFlowbit(s string) (*Flowbit, error) {
parts := strings.Split(s, ",")
if len(parts) < 1 {
return nil, fmt.Errorf("couldn't parse flowbit string: %s", s)
}
// Ensure all actions are of valid type.
a := strings.ToLower(strings.TrimSpace(parts[0]))
if !inSlice(a, []string{"noalert", "isset", "isnotset", "set", "unset", "toggle", "reset"}) {
return nil, fmt.Errorf("invalid action for flowbit: %s", a)
}
fb := &Flowbit{
Action: a,
}
switch fb.Action {
case "noalert", "reset":
if len(parts) > 1 && strings.TrimSpace(parts[1]) != "" {
return nil, fmt.Errorf("%s shouldn't have a value", fb.Action)
}
default:
if len(parts) < 2 || strings.TrimSpace(parts[1]) == "" {
return nil, fmt.Errorf("%s requires a value", fb.Action)
}
if len(parts) > 2 {
return nil, fmt.Errorf("wrong number of parts for flowbit: %s", s)
}
fb.Value = strings.TrimSpace(parts[1])
}
return fb, nil
}
// parseXbit parses an xbit.
func parseXbit(s string) (*Xbit, error) {
parts := strings.Split(s, ",")
// All xbits must have an action, name and track
if len(parts) < 3 {
return nil, fmt.Errorf("not enough parts for xbits: %s", s)
}
// Ensure all actions are of valid type.
a := strings.ToLower(strings.TrimSpace(parts[0]))
if !inSlice(a, []string{"set", "unset", "isset", "isnotset", "toggle"}) {
return nil, fmt.Errorf("invalid action for xbits: %s", a)
}
xb := &Xbit{
Action: a,
Name: strings.TrimSpace(parts[1]),
}
// Track.
t := strings.Fields(parts[2])
if len(t) != 2 {
return nil, fmt.Errorf("wrong number of parts for track: %v", t)
}
if strings.ToLower(t[0]) != "track" {
return nil, fmt.Errorf("%s should be 'track'", t[0])
}
xb.Track = t[1]
// Expire
if len(parts) == 4 {
e := strings.Fields(parts[3])
if len(e) != 2 {
return nil, fmt.Errorf("wrong number of parts for expire: %v", e)
}
if strings.ToLower(e[0]) != "expire" {
return nil, fmt.Errorf("%s should be 'expire'", e[0])
}
xb.Expire = e[1]
}
return xb, nil
}
// parseFlowint parses a flowint.
func parseFlowint(s string) (*Flowint, error) {
parts := strings.Split(s, ",")
// All flowints must have a name and modifier
if len(parts) < 2 {
return nil, fmt.Errorf("not enough parts for flowint: %s", s)
}
// Ensure all actions are of valid type.
m := strings.ToLower(strings.TrimSpace(parts[1]))
if !inSlice(m, []string{"+", "-", "=", ">", "<", ">=", "<=", "==", "!=", "isset", "isnotset"}) {
return nil, fmt.Errorf("invalid modifier for flowint: %s", m)
}
fi := &Flowint{
Name: strings.TrimSpace(parts[0]),
Modifier: m,
}
if len(parts) == 3 {
fi.Value = strings.TrimSpace(parts[2])
}
return fi, nil
}
// parseDataset parses a dataset option.
func parseDataset(s string) (*Dataset, error) {
parts := strings.Split(s, ",")
if len(parts) < 2 {
return nil, fmt.Errorf("not enough parts for dataset: %s", s)
}
cmd := strings.ToLower(strings.TrimSpace(parts[0]))
if !inSlice(cmd, []string{"isset", "isnotset", "set", "unset"}) {
return nil, fmt.Errorf("invalid command for dataset: %s", cmd)
}
name := strings.TrimSpace(parts[1])
if name == "" {
return nil, fmt.Errorf("dataset requires a name: %s", s)
}
ds := &Dataset{
Cmd: cmd,
Name: name,
}
for _, opt := range parts[2:] {
opt = strings.TrimSpace(opt)
if opt == "" {
return nil, fmt.Errorf("empty option in dataset: %s", s)
}
ds.Options = append(ds.Options, opt)
}
return ds, nil
}
// parseDataRep parses a datarep option.
func parseDataRep(s string) (*DataRep, error) {
parts := strings.Split(s, ",")
if len(parts) < 3 {
return nil, fmt.Errorf("not enough parts for datarep: %s", s)
}
name := strings.TrimSpace(parts[0])
if name == "" {
return nil, fmt.Errorf("datarep requires a name: %s", s)
}
op := strings.TrimSpace(parts[1])
if !inSlice(op, []string{">", "<", ">=", "<=", "==", "!="}) {
return nil, fmt.Errorf("invalid operator for datarep: %s", op)
}
val, err := strconv.Atoi(strings.TrimSpace(parts[2]))
if err != nil {
return nil, fmt.Errorf("invalid value for datarep: %v", err)
}
dr := &DataRep{
Name: name,
Operator: op,
Value: val,
}
for _, opt := range parts[3:] {
opt = strings.TrimSpace(opt)
if opt == "" {
return nil, fmt.Errorf("empty option in datarep: %s", s)
}
dr.Options = append(dr.Options, opt)
}
return dr, nil
}
// parseThreshold parses a threshold option.
func parseThreshold(s string) (*Threshold, error) {
t := &Threshold{}
for _, p := range strings.Split(s, ",") {
kv := strings.Fields(strings.TrimSpace(p))
if len(kv) < 2 {
return nil, fmt.Errorf("invalid threshold key-value pair: %q", p)
}
key := strings.ToLower(kv[0])
val := kv[1]
switch key {
case "type":
switch strings.ToLower(val) {
case "limit", "threshold", "both", "backoff":
t.Type = strings.ToLower(val)
default:
return nil, fmt.Errorf("invalid threshold type: %s", val)
}
case "track":
switch strings.ToLower(val) {
case "by_src", "by_dst", "by_both", "by_flow", "by_rule":
t.Track = strings.ToLower(val)
default:
return nil, fmt.Errorf("invalid threshold track: %s", val)
}
case "count":
c, err := strconv.Atoi(val)
if err != nil {
return nil, fmt.Errorf("invalid threshold count: %v", err)
}
if c <= 0 {
return nil, fmt.Errorf("threshold count must be positive, non-zero: %d", c)
}
t.Count = c
case "seconds":
sec, err := strconv.Atoi(val)
if err != nil {
return nil, fmt.Errorf("invalid threshold seconds: %v", err)
}
if sec <= 0 {
return nil, fmt.Errorf("threshold seconds must be positive, non-zero: %d", sec)
}
t.Seconds = sec
case "multiplier":
m, err := strconv.Atoi(val)
if err != nil {
return nil, fmt.Errorf("invalid threshold multiplier: %v", err)
}
if m <= 0 {
return nil, fmt.Errorf("threshold multiplier must be positive, non-zero: %d", m)
}
t.Multiplier = m
default:
return nil, fmt.Errorf("unknown threshold parameter: %s", key)
}
}
if t.Type == "" {
return nil, fmt.Errorf("threshold requires a type")
}
if t.Track == "" {
return nil, fmt.Errorf("threshold requires a track option")
}
if t.Count == 0 {
return nil, fmt.Errorf("threshold requires a count option")
}
if t.Type == "backoff" {
if t.Track != "by_flow" {
return nil, fmt.Errorf("type backoff can only be used with track by_flow, got: %s", t.Track)
}
if t.Multiplier == 0 {
return nil, fmt.Errorf("type backoff requires a multiplier")
}
if t.Seconds > 0 {
return nil, fmt.Errorf("seconds cannot be used with type backoff")
}
} else {
if t.Multiplier > 0 {
return nil, fmt.Errorf("multiplier can only be used with type backoff")
}
}
return t, nil
}
// parseDetectionFilter parses a detection_filter option.
func parseDetectionFilter(s string) (*DetectionFilter, error) {
d := &DetectionFilter{}
for _, p := range strings.Split(s, ",") {
kv := strings.Fields(strings.TrimSpace(p))
if len(kv) < 2 {
return nil, fmt.Errorf("invalid detection_filter key-value pair: %q", p)
}
key := strings.ToLower(kv[0])
val := kv[1]
switch key {
case "track":
switch strings.ToLower(val) {
case "by_src", "by_dst", "by_both", "by_flow", "by_rule":
d.Track = strings.ToLower(val)
default:
return nil, fmt.Errorf("invalid detection_filter track: %s", val)
}
case "count":
c, err := strconv.Atoi(val)
if err != nil {
return nil, fmt.Errorf("invalid detection_filter count: %v", err)
}
if c <= 0 {
return nil, fmt.Errorf("detection_filter count must be positive, non-zero: %d", c)
}
d.Count = c
case "seconds":
sec, err := strconv.Atoi(val)
if err != nil {
return nil, fmt.Errorf("invalid detection_filter seconds: %v", err)
}
if sec <= 0 {
return nil, fmt.Errorf("detection_filter seconds must be positive, non-zero: %d", sec)
}
d.Seconds = sec
case "unique_on":
switch strings.ToLower(val) {
case "src_port", "dst_port":
d.UniqueOn = strings.ToLower(val)
default:
return nil, fmt.Errorf("invalid detection_filter unique_on: %s", val)
}
default:
return nil, fmt.Errorf("unknown detection_filter parameter: %s", key)
}
}
if d.Track == "" {
return nil, fmt.Errorf("detection_filter requires a track option")
}
if d.Count == 0 {
return nil, fmt.Errorf("detection_filter requires a count option")
}
if d.Seconds == 0 {
return nil, fmt.Errorf("detection_filter requires a seconds option")
}
return d, nil
}
var versionOperators = []string{">=", "<=", "!=", "==", "=", ">", "<"}
func validVersionString(v string) bool {
parts := strings.Split(v, ".")
if len(parts) < 1 || len(parts) > 3 {
return false
}
for _, p := range parts {
if p == "" {
return false
}
n, err := strconv.Atoi(p)
if err != nil || n < 0 {
return false
}
}
return true
}
func parseVersionConstraints(s string) ([][]VersionConstraint, error) {
orParts := strings.Split(s, "|")
var result [][]VersionConstraint
for _, orPart := range orParts {
rem := strings.TrimSpace(orPart)
if rem == "" {
return nil, fmt.Errorf("empty version constraint in %q", s)
}
var andGroup []VersionConstraint
for rem != "" {
var op string
for _, candidate := range versionOperators {
if strings.HasPrefix(rem, candidate) {
op = candidate
rem = strings.TrimSpace(rem[len(candidate):])
break
}
}
if op == "" {
return nil, fmt.Errorf("invalid version operator in %q", s)
}
fields := strings.Fields(rem)
if len(fields) == 0 {
return nil, fmt.Errorf("missing version number after %q in %q", op, s)
}
ver := fields[0]
if !validVersionString(ver) {
return nil, fmt.Errorf("invalid version %q in %q", ver, s)
}
andGroup = append(andGroup, VersionConstraint{
Operator: op,
Version: ver,
})
rem = strings.TrimSpace(rem[len(ver):])
}
result = append(result, andGroup)
}
return result, nil
}
// parseRequires parses a requires option.
func parseRequires(req *Requires, s string) (*Requires, error) {
if req == nil {
req = &Requires{}
}
for _, part := range strings.Split(s, ",") {
part = strings.TrimSpace(part)
kv := strings.SplitN(part, " ", 2)
if len(kv) != 2 {
return nil, fmt.Errorf("invalid requires parameter: %q", part)
}
key := strings.ToLower(strings.TrimSpace(kv[0]))
val := strings.TrimSpace(kv[1])
if val == "" {
return nil, fmt.Errorf("empty value for requires %s", key)
}
switch key {
case "version":
if len(req.Version) > 0 {
return nil, fmt.Errorf("requires version specified multiple times")
}
vc, err := parseVersionConstraints(val)
if err != nil {
return nil, err
}
req.Version = vc
case "feature":
if len(strings.Fields(val)) != 1 {
return nil, fmt.Errorf("invalid requires feature %q", val)
}
req.Features = append(req.Features, val)
case "keyword":
if len(strings.Fields(val)) != 1 {
return nil, fmt.Errorf("invalid requires keyword %q", val)
}
req.Keywords = append(req.Keywords, val)
default:
return nil, fmt.Errorf("unknown requires key: %s", key)
}
}
return req, nil
}
// containsUnescaped checks content whether special characters are properly escaped.
func containsUnescaped(s string) bool {
esc := false
for _, b := range s {
if esc {
switch b {
case '\\', ';', '"', ':', '|', '[', ']', '+', '.', ' ':
esc = false
default:
return true
}
} else {
switch b {
case '\\':
esc = true
case ';', '"':
return true
}
}
}
return esc
}
func unquote(s string) string {
if strings.IndexByte(s, '"') < 0 {
return s
}
return strings.ReplaceAll(s, `\"`, `"`)
}
func inSlice(str string, strings []string) bool {
for _, k := range strings {
if str == k {
return true
}
}
return false
}
// comment decodes a comment (commented rule, or just a comment.)
func (r *Rule) comment(key item) error {
if key.typ != itemComment {
panic("item is not a comment")
}
if r.Disabled {
// ignoring comment for rule with empty action
return nil
}
rule, err := parseRuleAux(key.value, true)
// If there was an error this means the comment is not a rule.
if err != nil {
return fmt.Errorf("this is not a rule: %s", err)
}
// We parsed a rule, this was a comment so set the rule to disabled.
rule.Disabled = true
// Overwrite the rule we're working on with the recently parsed, disabled rule.
*r = *rule
return nil
}
// action decodes an IDS rule option based on its key.
func (r *Rule) action(key item) error {
if key.typ != itemAction {
panic("item is not an action")
}
if !inSlice(key.value, []string{"alert", "drop", "pass", "reject", "rejectsrc", "rejectdst", "rejectboth", "config"}) {
return fmt.Errorf("invalid action: %v", key.value)
}
r.Action = key.value
return nil
}
// protocol decodes an IDS rule protocol based on its key.
func (r *Rule) protocol(key item) error {
if key.typ != itemProtocol {
panic("item is not a protocol")
}
if !inSlice(key.value, appLayerProtocols) {
return fmt.Errorf("invalid protocol: %v", key.value)
}
r.Protocol = key.value
return nil
}
// network decodes an IDS rule network (networks and ports) based on its key.
func (r *Rule) network(key item) error {
val := strings.ReplaceAll(key.value, " ", "")
// Identify if the whole network component is negated.
tmp := strings.TrimPrefix(val, "!")
negated := len(tmp) < len(val)
// This is a hack. We use a regexp to replace the outer `,` with `___`
// to give us a discrete string to split on, avoiding the inner `,`.
// Specify TrimSuffix and TrimPrefix to ensure only one instance of `[` and `]` are trimmed.
tmp = strings.TrimSuffix(strings.TrimPrefix(tmp, "["), "]")
items := strings.Split(nestedNetRE.ReplaceAllString(tmp, "___${1}"), "___")
// Validate that no items contain spaces.
for _, i := range items {
if len(strings.Fields(i)) > 1 || len(strings.TrimSpace(i)) != len(i) {
return fmt.Errorf("network component contains spaces: %v", i)
}
}
switch key.typ {
case itemSourceAddress:
r.Source.NegateNets = negated
if validNetworks(items) {
r.Source.Nets = append(r.Source.Nets, items...)
} else {
return fmt.Errorf("some or all source ips are invalid: %v", items)
}
case itemSourcePort:
r.Source.NegatePorts = negated
if portsValid(items) {
r.Source.Ports = append(r.Source.Ports, items...)
} else {
return fmt.Errorf("some or all source ports are invalid: %v", items)
}
case itemDestinationAddress:
r.Destination.NegateNets = negated
if validNetworks(items) {
r.Destination.Nets = append(r.Destination.Nets, items...)
} else {
return fmt.Errorf("some or all destination ips are invalid: %v", items)
}
case itemDestinationPort:
r.Destination.NegatePorts = negated
if portsValid(items) {
r.Destination.Ports = append(r.Destination.Ports, items...)
} else {
return fmt.Errorf("some or all destination ports are invalid: %v", items)
}
default:
panic("item is not a network component")
}
return nil
}
// Validate that every item is between 1 and 65535.
func portsValid(p []string) bool {
for _, u := range p {
if strings.Count(u, "[") != strings.Count(u, "]") {
// unbalanced groups.
return false
}
u = strings.TrimPrefix(u, "!")
// If this port range is a grouping, check the inner group.
if strings.HasPrefix(u, "[") {
if portsValid(strings.Split(strings.Trim(u, "[]"), ",")) {
continue
}
return false
}
ports := portSplitRE.Split(u, -1)
for _, port := range ports {
port = strings.TrimPrefix(port, "!")
if port == "any" || port == "" || strings.HasPrefix(port, "$") {
continue
}
x, err := strconv.Atoi(port)
if err != nil {
return false
}
if x > 65535 || x < 0 {
return false
}
}
}
return true
}
// Validate item is either a valid ip or ip range.
func validNetwork(i string) bool {
_, _, err := net.ParseCIDR(i)
if err == nil {
return true
}
if net.ParseIP(i) != nil {
return true
}
return false
}
// Validate every item is either a valid ip or ip range.
func validNetworks(nets []string) bool {
for _, net := range nets {
if strings.Count(net, "[") != strings.Count(net, "]") {
// unbalanced groups.
return false
}
net = strings.TrimPrefix(net, "!")
// If this network is a grouping, check the inner group.
if strings.HasPrefix(net, "[") || strings.Contains(net, ",") {
if validNetworks(strings.Split(strings.Trim(net, "[]"), ",")) {
continue
}
return false
}
switch {
case net == "any":
continue
case strings.HasPrefix(net, "$"):
continue
case !validNetwork(net):
return false
}
}
return true
}
// direction decodes an IDS rule direction based on its key.
func (r *Rule) direction(key item) error {
if key.typ != itemDirection {
panic("item is not a direction")
}
switch key.value {
case "->":
r.Bidirectional = false
r.Transactional = false
case "<>":
r.Bidirectional = true
r.Transactional = false
case "=>":
r.Bidirectional = false
r.Transactional = true
default:
return fmt.Errorf("invalid direction operator %q", key.value)
}
return nil
}
// parseState holds the state carried between the options of a single rule.
type parseState struct {
// dataPosition is the sticky buffer applied to the matchers that follow.
dataPosition DataPos
// frameName is the frame name when dataPosition is framePos.
frameName string
// dataDirection is the optional direction modifier ("to_server" or "to_client") on a sticky buffer.
dataDirection string
// activeTransforms are the transforms applied to the matchers that follow.
activeTransforms []*Transform
}
// cloneTransforms returns a deep copy of the active transforms slice so that
// matchers do not share underlying backing arrays or Transform pointers.
func (st *parseState) cloneTransforms() []*Transform {
if len(st.activeTransforms) == 0 {
return nil
}
dst := make([]*Transform, len(st.activeTransforms))
for i, t := range st.activeTransforms {
cp := *t
dst[i] = &cp
}
return dst
}
// option decodes an IDS rule option based on its key.
func (r *Rule) option(key item, l *lexer, st *parseState) error {
if key.typ != itemOptionKey {
panic("item is not an option key")
}
key.value = strings.ToLower(key.value)
switch {
// TODO: Many of these simple tags could be factored into nicer structures.
case inSlice(key.value, []string{"classtype", "flow", "tag", "priority", "app-layer-protocol", "app-layer-state",
"flags", "ipopts", "ip_proto", "geoip", "fragbits", "fragoffset", "tos",
"window", "app-layer-event", "decode-event", "stream-event", "engine-event", "snmp.version",
"dnp3_func", "dnp3_ind", "krb5_err_code", "dns.opcode",
"ipv4-csum", "tcpv4-csum", "udpv4-csum", "icmpv4-csum", "tcpv6-csum", "udpv6-csum", "icmpv6-csum", "igmp-csum",
"filemagic", "fileext", "filemd5", "filesha1", "filesha256", "filename", "ftpdata_command",
"dce_iface", "dce_opnum", "dcerpc.iface", "dcerpc.opnum",
"lua", "luajit",
"asn1", "entropy",
"config", "iprep", "hostbits", "rpc"}):
nextItem := l.nextItem()
negate := false
if nextItem.typ == itemNot {
negate = true
nextItem = l.nextItem()
}
if nextItem.typ != itemOptionValue && nextItem.typ != itemOptionValueString {
return fmt.Errorf("no valid value for %s tag", key.value)
}
if r.Tags == nil {
r.Tags = make(map[string]string)
}
if negate {
r.Tags[key.value] = "!" + nextItem.value
} else {
r.Tags[key.value] = nextItem.value
}
case key.value == "target":
nextItem := l.nextItem()
val := strings.ToLower(nextItem.value)
if val != "src_ip" && val != "dest_ip" {
return fmt.Errorf("invalid target value: %q, must be 'src_ip' or 'dest_ip'", nextItem.value)
}
r.Target = val
case key.value == "threshold":
nextItem := l.nextItem()
t, err := parseThreshold(nextItem.value)
if err != nil {
return fmt.Errorf("error parsing threshold: %v", err)
}
r.Threshold = t
case key.value == "detection_filter":
nextItem := l.nextItem()
df, err := parseDetectionFilter(nextItem.value)
if err != nil {
return fmt.Errorf("error parsing detection_filter: %v", err)
}
r.DetectionFilter = df
case key.value == "requires":
nextItem := l.nextItem()
if nextItem.typ != itemOptionValue {
return errors.New("no valid value for requires")
}
req, err := parseRequires(r.Requires, nextItem.value)
if err != nil {
return fmt.Errorf("error parsing requires: %v", err)
}
r.Requires = req
case inSlice(key.value, []string{"sameip", "tls.store", "ftpbounce", "noalert", "alert", "bypass", "filestore",
"tls_cert_expired", "tls_cert_valid", "tls.cert_expired", "tls.cert_valid", "prefilter"}):
r.Statements = append(r.Statements, key.value)
case inSlice(key.value, tlsTags):
t := &TLSTag{
Key: key.value,
}
nextItem := l.nextItem()
if nextItem.typ == itemNot {
t.Negate = true
nextItem = l.nextItem()
}
t.Value = nextItem.value
r.TLSTags = append(r.TLSTags, t)
case key.value == "stream_size":
nextItem := l.nextItem()
parts := strings.Split(nextItem.value, ",")
if len(parts) != 3 {
return fmt.Errorf("invalid number of parts for stream_size: %d", len(parts))
}
num, err := strconv.Atoi(strings.TrimSpace(parts[2]))
if err != nil {
return fmt.Errorf("comparison number is not an integer: %v", parts[2])
}
r.StreamMatch = &StreamCmp{
Direction: strings.TrimSpace(parts[0]),
Operator: strings.TrimSpace(parts[1]),
Number: num,
}
case key.value == "reference":
nextItem := l.nextItem()
if nextItem.typ != itemOptionValue {
return errors.New("no valid value for reference")
}
refs := strings.SplitN(nextItem.value, ",", 2)
if len(refs) != 2 {
return fmt.Errorf("invalid reference definition: %s", refs)
}
r.References = append(r.References, &Reference{Type: refs[0], Value: refs[1]})
case key.value == "metadata":
nextItem := l.nextItem()
if nextItem.typ != itemOptionValue {
return errors.New("no valid value for metadata")
}
metas := metaSplitRE.Split(nextItem.value, -1)
for _, kv := range metas {
metaTmp := strings.SplitN(kv, " ", 2)
if len(metaTmp) != 2 {
return fmt.Errorf("invalid metadata definition: %s", metaTmp)
}
r.Metas = append(r.Metas, &Metadata{Key: strings.TrimSpace(metaTmp[0]), Value: strings.TrimSpace(metaTmp[1])})
}
case key.value == "sid":
nextItem := l.nextItem()
if nextItem.typ != itemOptionValue {
return errors.New("no value for option sid")
}
sid, err := strconv.Atoi(nextItem.value)
if err != nil {
return fmt.Errorf("invalid sid %s", nextItem.value)
}
r.SID = sid
case key.value == "rev":
nextItem := l.nextItem()
if nextItem.typ != itemOptionValue {
return errors.New("no value for option rev")
}
rev, err := strconv.Atoi(nextItem.value)
if err != nil {
return fmt.Errorf("invalid rev %s", nextItem.value)
}
r.Revision = rev
case key.value == "msg":
nextItem := l.nextItem()
if nextItem.typ != itemOptionValueString {
return errors.New("no value for option msg")
}
r.Description = nextItem.value
case key.value == "frame":
nextItem := l.nextItem()
if nextItem.typ != itemOptionValue || strings.TrimSpace(nextItem.value) == "" {
return errors.New("no valid value for frame")
}
st.activeTransforms = nil
st.dataPosition = framePos
st.frameName = strings.TrimSpace(nextItem.value)
st.dataDirection = ""
case isStickyBuffer(key.value):
st.activeTransforms = nil
st.frameName = ""
st.dataDirection = ""
var d DataPos
var err error
if d, err = StickyBuffer(key.value); err != nil {
return err
}
st.dataPosition = d
nextItem := l.nextItem()
if nextItem.typ == itemOptionValue {
dir := strings.ToLower(strings.TrimSpace(nextItem.value))
if dir != "to_server" && dir != "to_client" {
return fmt.Errorf("invalid direction %q for sticky buffer %s", nextItem.value, key.value)
}
st.dataDirection = dir
}
case inSlice(key.value, []string{"dotprefix", "to_lowercase", "to_uppercase", "header_lowercase", "url_decode", "strip_whitespace", "compress_whitespace", "strip_pseudo_headers", "to_sha1", "to_sha256", "to_md5", "domain", "tld"}):
st.activeTransforms = append(st.activeTransforms, &Transform{Name: key.value})
case inSlice(key.value, []string{"from_base64", "gunzip", "zlib_deflate"}):
nextItem := l.nextItem()
var val string
if nextItem.typ == itemOptionValue || nextItem.typ == itemOptionValueString {
val = nextItem.value
}
st.activeTransforms = append(st.activeTransforms, &Transform{Name: key.value, Value: val})
case inSlice(key.value, []string{"pcrexform", "xor", "luaxform"}):
nextItem := l.nextItem()
val := nextItem.value
if nextItem.typ == itemOptionValueString {
val = `"` + val + `"`
}
st.activeTransforms = append(st.activeTransforms, &Transform{Name: key.value, Value: val})
case inSlice(key.value, []string{"content", "uricontent"}):
nextItem := l.nextItem()
negate := false
if nextItem.typ == itemNot {
nextItem = l.nextItem()
negate = true
}
if nextItem.typ == itemOptionValueString {
c, err := parseContent(nextItem.value)
if err != nil {
return err
}
var options []*ContentOption
if key.value == "uricontent" {
options = append(options, &ContentOption{Name: "http_uri"})
}
con := &Content{
DataPosition: st.dataPosition,
FrameName: st.frameName,
DataDirection: st.dataDirection,
Pattern: c,
Negate: negate,
Options: options,
Transforms: st.cloneTransforms(),
}
r.Matchers = append(r.Matchers, con)
} else {
return fmt.Errorf("invalid type %q for option content", nextItem.typ)
}
case inSlice(key.value, []string{"http_cookie", "http_raw_cookie", "http_method", "http_header", "http_raw_header",
"http_uri", "http_raw_uri", "http_user_agent", "http_stat_code", "http_stat_msg",
"http_client_body", "http_server_body", "http_host", "nocase", "rawbytes", "startswith", "endswith"}):
lastContent := r.LastContent()
if lastContent == nil {
return fmt.Errorf("invalid content option %q with no content match", key.value)
}
lastContent.Options = append(lastContent.Options, &ContentOption{Name: key.value})
case inSlice(key.value, []string{"depth", "distance", "offset", "within"}):
lastContent := r.LastContent()
if lastContent == nil {
return fmt.Errorf("invalid content option %q with no content match", key.value)
}
nextItem := l.nextItem()
if nextItem.typ != itemOptionValue {
return fmt.Errorf("no value for content option %s", key.value)
}
lastContent.Options = append(lastContent.Options, &ContentOption{Name: key.value, Value: nextItem.value})
case key.value == "replace":
lastContent := r.LastContent()
if lastContent == nil {
return fmt.Errorf("invalid content option %q with no content match", key.value)
}
nextItem := l.nextItem()
if nextItem.typ != itemOptionValueString {
return fmt.Errorf("no value for content option %s", key.value)
}
lastContent.Options = append(lastContent.Options, &ContentOption{Name: key.value, Value: nextItem.value})
case key.value == "fast_pattern":
lastContent := r.LastContent()
if lastContent == nil {
return fmt.Errorf("invalid content option %q with no content match", key.value)
}
var (
only bool
offset int
length int
)
nextItem := l.nextItem()
if nextItem.typ == itemOptionValue {
v := nextItem.value
switch {
case v == "only":
only = true
case strings.Contains(v, ","):
s := strings.Split(v, ",")
i, err := strconv.Atoi(s[0])
if err != nil {
return fmt.Errorf("fast_pattern offset is not an int: %s; %s", s[0], err)
}
offset = i
i, err = strconv.Atoi(s[1])
if err != nil {
return fmt.Errorf("fast_pattern length is not an int: %s; %s", s[1], err)
}
length = i
}
}
lastContent.FastPattern = FastPattern{true, only, offset, length}
case key.value == "pcre":
nextItem := l.nextItem()
negate := false
if nextItem.typ == itemNot {
nextItem = l.nextItem()
negate = true
}
if nextItem.typ == itemOptionValueString {
p, err := parsePCRE(unquote(nextItem.value))
if err != nil {
return err
}
p.DataPosition = st.dataPosition
p.FrameName = st.frameName
p.DataDirection = st.dataDirection
p.Negate = negate
p.Transforms = st.cloneTransforms()
r.Matchers = append(r.Matchers, p)
} else {
return fmt.Errorf("invalid type %q for option content", nextItem.typ)
}
case inSlice(key.value, allbyteMatchTypeNames()):
k, err := byteMatcher(key.value)
if err != nil {
return fmt.Errorf("%s is not a supported byteMatchType keyword", key.value)
}
// Handle negation logic here, don't want to pass lexer to parseByteMatch.
nextItem := l.nextItem()
var negate bool
if k == isDataAt && nextItem.typ == itemNot {
negate = true
nextItem = l.nextItem()
}
var b *ByteMatch
// Parse base64_decode differently as it has odd semantics.
if k == b64Decode {
b, err = parseBase64Decode(k, nextItem.value)
if err != nil {
return fmt.Errorf("could not parse base64Decode: %v", err)
}
// base64_decode allows NumBytes to be empty, an int or a variable.
if b.NumBytes != "" {
if i, err := strconv.Atoi(b.NumBytes); err != nil {
// NumBytes is not an int, check if it is a variable from byte_extract.
if !r.HasVar(b.NumBytes) {
return fmt.Errorf("number of bytes is not an int, or an extracted variable: %s; %s", b.NumBytes, err)
}
} else if i < 1 {
return fmt.Errorf("bytes must be positive, non-zero values only: %d", i)
}
}
} else {
b, err = parseByteMatch(k, nextItem.value)
if err != nil {
return fmt.Errorf("could not parse byteMatch: %v", err)
}
if _, err := strconv.Atoi(b.NumBytes); err != nil {
// NumBytes is not an int, check if it is a variable from byte_extract.
if !r.HasVar(b.NumBytes) {
return fmt.Errorf("number of bytes is not an int, or an extracted variable: %s; %s", b.NumBytes, err)
}
}
}
if b.Offset != "" {
if _, err := strconv.Atoi(b.Offset); err != nil {
if !r.HasVar(b.Offset) {
return fmt.Errorf("offset is not an int, or an extracted variable: %s; %s", b.Offset, err)
}
}
}
b.Negate = negate
b.DataPosition = st.dataPosition
b.FrameName = st.frameName
b.DataDirection = st.dataDirection
r.Matchers = append(r.Matchers, b)
case inSlice(key.value, allLenMatchTypeNames()):
k, err := lenMatcher(key.value)
if err != nil {
return fmt.Errorf("%s is not a support lenMatch keyword", key.value)
}
nextItem := l.nextItem()
m, err := parseLenMatch(k, nextItem.value)
if err != nil {
return fmt.Errorf("could not parse LenMatch: %v", err)
}
m.DataPosition = st.dataPosition
m.FrameName = st.frameName
m.DataDirection = st.dataDirection
r.Matchers = append(r.Matchers, m)
case key.value == "flowbits":
nextItem := l.nextItem()
fb, err := parseFlowbit(nextItem.value)
if err != nil {
return fmt.Errorf("error parsing flowbit: %v", err)
}
r.Flowbits = append(r.Flowbits, fb)
case key.value == "xbits":
nextItem := l.nextItem()
xb, err := parseXbit(nextItem.value)
if err != nil {
return fmt.Errorf("error parsing xbits: %v", err)
}
r.Xbits = append(r.Xbits, xb)
case key.value == "flowint":
nextItem := l.nextItem()
fi, err := parseFlowint(nextItem.value)
if err != nil {
return fmt.Errorf("error parsing flowint: %v", err)
}
r.Flowints = append(r.Flowints, fi)
case key.value == "dataset":
nextItem := l.nextItem()
if nextItem.typ != itemOptionValue {
return errors.New("no valid value for dataset")
}
ds, err := parseDataset(nextItem.value)
if err != nil {
return fmt.Errorf("error parsing dataset: %v", err)
}
ds.DataPosition = st.dataPosition
ds.FrameName = st.frameName
ds.DataDirection = st.dataDirection
ds.Transforms = st.cloneTransforms()
r.Matchers = append(r.Matchers, ds)
case key.value == "datarep":
nextItem := l.nextItem()
if nextItem.typ != itemOptionValue {
return errors.New("no valid value for datarep")
}
dr, err := parseDataRep(nextItem.value)
if err != nil {
return fmt.Errorf("error parsing datarep: %v", err)
}
dr.DataPosition = st.dataPosition
dr.FrameName = st.frameName
dr.DataDirection = st.dataDirection
dr.Transforms = st.cloneTransforms()
r.Matchers = append(r.Matchers, dr)
case key.value == "absent":
nextItem := l.nextItem()
var orElse bool
switch nextItem.typ {
case itemOptionNoValue:
case itemOptionValue:
if strings.TrimSpace(nextItem.value) != "or_else" {
return fmt.Errorf("invalid value %q for absent", nextItem.value)
}
orElse = true
default:
return fmt.Errorf("invalid value %q for absent", nextItem.value)
}
r.Matchers = append(r.Matchers, &Absent{
DataPosition: st.dataPosition,
FrameName: st.frameName,
DataDirection: st.dataDirection,
Transforms: st.cloneTransforms(),
OrElse: orElse,
})
default:
return &UnsupportedOptionError{
Options: []string{key.value},
}
}
return nil
}
// UnsupportedOptionError contains a partially parsed rule, and the options that aren't
// supported for parsing.
type UnsupportedOptionError struct {
Rule *Rule
Options []string
}
// Error returns a string for UnsupportedOptionError
func (uoe *UnsupportedOptionError) Error() string {
return fmt.Sprintf("rule contains unsupported option(s): %s", strings.Join(uoe.Options, ","))
}
// parseRuleAux parses an IDS rule, optionally ignoring comments.
func parseRuleAux(rule string, commented bool) (*Rule, error) {
l, err := lex(rule)
if err != nil {
return nil, err
}
defer l.close()
st := &parseState{dataPosition: pktData}
r := &Rule{}
var unsupportedOptions = make([]string, 0, 3)
for item := l.nextItem(); item.typ != itemEOR && item.typ != itemEOF && err == nil; item = l.nextItem() {
switch item.typ {
case itemComment:
if r.Action != "" || commented {
// Ignore comment ending rule.
return r, nil
}
err = r.comment(item)
// Error here means that the comment was not a commented rule.
// So we're not parsing a rule and we need to break out.
if err != nil {
break
}
// This line was a commented rule.
return r, nil
case itemAction:
err = r.action(item)
case itemProtocol:
err = r.protocol(item)
case itemSourceAddress, itemDestinationAddress, itemSourcePort, itemDestinationPort:
err = r.network(item)
case itemDirection:
err = r.direction(item)
case itemOptionKey:
err = r.option(item, l, st)
// We will continue to parse a rule with unsupported options.
if uerr, ok := err.(*UnsupportedOptionError); ok {
unsupportedOptions = append(unsupportedOptions, uerr.Options...)
// This is ugly but allows the parsing to continue.
err = nil
}
case itemError:
err = errors.New(item.value)
}
// Unrecoverable parse error.
if err != nil {
return nil, err
}
}
// If we encountered one or more unsupported keys, return an UnsupportedOptionError.
if len(unsupportedOptions) > 0 {
return nil, &UnsupportedOptionError{
Rule: r,
Options: unsupportedOptions,
}
}
return r, nil
}
// ParseRule parses an IDS rule and returns a struct describing the rule.
func ParseRule(rule string) (*Rule, error) {
return parseRuleAux(rule, false)
}
// ParseRules reads from an io.Reader and returns all rules parsed from the stream.
func ParseRules(r io.Reader) ([]*Rule, error) {
scanner := NewRuleScanner(r)
var rules []*Rule
for scanner.Scan() {
rules = append(rules, scanner.Rule())
}
if err := scanner.Err(); err != nil {
return rules, err
}
return rules, nil
}
/* Copyright 2016 Google Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package gonids
import (
"bytes"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
)
// Rule describes an IDS rule.
type Rule struct {
// Disbled identifies if the rule is disabled/commented out.
Disabled bool
// Action is the action the rule will take (alert, pass, drop, etc.).
Action string
// Protocol is the protocol the rule looks at.
Protocol string
// Source is the address and ports for the source of the traffic.
Source Network
// Destination is the address and ports for the source of the traffic.
Destination Network
// Bidirectional indicates the directionality of a rule (-> or <>).
Bidirectional bool
// Transactional indicates the rule uses the transactional direction operator (=>).
Transactional bool
// SID is the identifier of the rule.
SID int
// Revision is the revision of the rule.
Revision int
// Description is the msg field of the rule.
Description string
// Target specifies the attack target (src_ip or dest_ip).
Target string
// References contains references associated to the rule (e.g. CVE number).
References []*Reference
// Contents are all the decoded content matches.
Tags map[string]string
// Statements is a slice of string. These items are similar to Tags, but have no value. (e.g. 'sameip;')
Statements []string
// TLSTags is a slice of TLS related matches.
TLSTags []*TLSTag
// StreamMatch holds stream_size parameters.
StreamMatch *StreamCmp
// Threshold holds threshold parameters.
Threshold *Threshold
// DetectionFilter holds detection_filter parameters.
DetectionFilter *DetectionFilter
// Requires holds Suricata engine/feature/keyword requirements.
Requires *Requires
// Metas is a slice of Metadata.
Metas Metadatas
// Flowbits is a slice of Flowbit.
Flowbits []*Flowbit
// Xbits is a slice of Xbit
Xbits []*Xbit
// Flowints is a slice of Flowint
Flowints []*Flowint
// Matchers are internally used to ensure relative matches are printed correctly.
// Make this private before checkin?
Matchers Matchers
}
type orderedMatcher interface {
String() string
stickyPos() stickyPos
transforms() Transforms
}
// Matchers allows for a Stringer on []orderedMatcher.
type Matchers []orderedMatcher
// stickyPos describes a sticky buffer position, optional frame name, and optional direction.
type stickyPos struct {
pos DataPos
frameName string
direction string
}
// String returns the rule representation of a sticky buffer selector
// (e.g. "http.uri;", "frame:dns.pdu;", "http.content_type:to_server;").
func (sp stickyPos) String() string {
switch {
case sp.pos == framePos && sp.frameName != "" && sp.direction != "":
return fmt.Sprintf("frame:%s:%s;", sp.frameName, sp.direction)
case sp.pos == framePos && sp.frameName != "":
return fmt.Sprintf("frame:%s;", sp.frameName)
case sp.direction != "":
return fmt.Sprintf("%s:%s;", sp.pos, sp.direction)
default:
return fmt.Sprintf("%s;", sp.pos)
}
}
// Metadata describes metadata tags in key-value struct.
type Metadata struct {
Key string
Value string
}
// Flowbit describes a flowbit. A flowbit consists of an Action, and optional Value.
type Flowbit struct {
Action string
Value string
}
// Flowint describes a flowint.
type Flowint struct {
Name string
Modifier string
Value string
}
// Xbit describes an Xbit.
// TODO: Consider adding more structure to Track and Expire.
type Xbit struct {
Action string
Name string
Track string
// Expire should be an int, default 0 value makes stringer difficult because this is an
// optional parameter. If we can confirm that this must be > 0 we can convert to int.
Expire string
}
// Metadatas allows for a Stringer on []*Metadata
type Metadatas []*Metadata
// Network describes the IP addresses and port numbers used in a rule.
// TODO: Ensure all values either begin with $ (variable) or they are valid IPNet/int.
// TODO: Refactor Nets and Ports into structs, each with their own bool for negation.
type Network struct {
NegateNets bool // Negate the full set of Networks.
Nets []string // Currently just []string because these can be variables $HOME_NET, not a valid IPNet.
NegatePorts bool // Negate the full set of ports.
Ports []string // Currently just []string because these can be variables $HTTP_PORTS, not just ints.
}
// DataPos indicates the data position for content matches. These should be referenced for creation
// by using their Suricata keywords and the StickyBuffer() function.
type DataPos int
const (
pktData DataPos = iota
fileData
base64Data
//
// Suricata 4.x Sticky Buffers
//
// HTTP Sticky buffers
httpAcceptEnc
httpAccept
httpAcceptLang
httpConnection
httpContentLen
httpContentType
httpHeaderNames
httpProtocol
httpReferer
httpRequestLine
httpResponseLine
httpStart
// TLS Sticky Buffers
tlsCertSubject
tlsCertIssuer
tlsCertSerial
tlsCertFingerprint
tlsSNI
// JA3 Sticky Buffers
ja3Hash
ja3String
// SSH Sticky Buffers
sshProto
sshSoftware
// Kerberos Sticky Buffers
krb5Cname
krb5Sname
// DNS Sticky Buffers
dnsQuery
// SMB Sticky Buffers
smbNamedPipe
smbShare
//
// Suricata 5.0 Sticky Buffers
//
fileData5
// HTTP Sticky Buffers
httpAccept5
httpAcceptEnc5
httpAcceptLang5
httpClientBody
httpConnection5
httpContentLen5
httpContentType5
httpCookie
httpHeader
httpHeaderNames5
httpHeaderRaw
httpHost
httpHostRaw
httpLocation
httpMethod
httpProtocol5
httpReferer5
httpRequestBody
httpRequestLine5
httpResponseBody
httpResponseLine5
httpServer
httpServerBody
httpStart5
httpStatCode
httpStatMsg
httpURI
httpURIRaw
httpUserAgent
// TLS Sticky Buffers
tlsCertSubject5
tlsCertIssuer5
tlsCertSerial5
tlsCertFingerprint5
tlsSNI5
// JA3 Sticky Buffers
ja3Hash5
ja3String5
ja3sHash
ja3sString
// SSH Sticky Buffers
sshProto5
sshSoftware5
// Kerberos Sticky Buffers - Unchanged from Suricata 4.x
// DNS Sticky Buffers
dnsQuery5
// SMB - Documentation lacking. Unknown.
//
// Suricata 7.x Sticky Buffers
//
tlsCerts
tlsRandom
httpRequestHeader
httpResponseHeader
fileMagic
tcpHdr
tcpFlags
udpHdr
icmpv4Hdr
icmpv6Hdr
ipv4Hdr
ipv6Hdr
dceStubData
dcerpcStubData
//
// Suricata 8.x Sticky Buffers
//
// QUIC Sticky Buffers
quicSNI
quicVersion
quicCyuHash
quicCyuString
quicUA
// JA4 Sticky Buffers
ja4Hash
ja4Raw
ja4sHash
ja4sRaw
// SSH HASSH Sticky Buffers
sshHassh
sshHasshString
sshHasshServer
sshHasshServerString
// SNMP Sticky Buffers
snmpCommunity
snmpPDUType
// IP Sticky Buffers
ipSrc
ipDst
// HTTP/2 Sticky Buffers
http2Frametype
http2Errorcode
http2Priority
http2Window
http2SizeUpdate
http2Settings
http2Header
http2HeaderName
http2HeaderValue
// Open-ended Frame Sticky Buffer
framePos
// DNS Sticky Buffers
dnsQueriesRrname
dnsAnswersRrname
dnsAuthoritiesRrname
dnsAdditionalsRrname
dnsResponseRrname
dnsQueryName
dnsAnswerName
// mDNS Sticky Buffers
mdnsQueriesRrname
mdnsAnswersRrname
mdnsAuthoritiesRrname
mdnsAdditionalsRrname
mdnsResponseRrname
// TLS Sticky Buffers
tlsSubjectAltName
tlsRandomTime
tlsRandomBytes
tlsALPN
// File, Kerberos & SMB Sticky Buffers
fileName
krb5Cname5
krb5Sname5
smbNamedPipe5
smbShare5
smbNtlmsspUser
smbNtlmsspDomain
// Email Sticky Buffers
emailFrom
emailSubject
emailTo
emailCc
emailDate
emailMessageID
emailXMailer
emailURL
emailReceived
// SMTP, FTP & PGSQL Sticky Buffers
smtpHelo
smtpMailFrom
smtpRcptTo
ftpCommand
ftpCommandData
ftpCompletionCode
ftpReply
pgsqlQuery
// LDAP Sticky Buffers
ldapRequestDN
ldapResponsesDN
ldapResponsesMessage
ldapRequestAttributeType
ldapResponsesAttributeType
// SIP Sticky Buffers
sipMethod
sipURI
sipRequestLine
sipStatCode
sipStatMsg
sipResponseLine
sipProtocol
sipFrom
sipTo
sipVia
sipUserAgent
sipContentType
sipContentLength
// SDP Sticky Buffers
sdpOrigin
sdpSessionName
sdpSessionInfo
sdpURI
sdpEmail
sdpPhoneNumber
sdpConnectionData
sdpBandwidth
sdpTime
sdpRepeatTime
sdpTimezone
sdpEncryptionKey
sdpAttribute
sdpMediaMedia
sdpMediaSessionInfo
sdpMediaMediaInfo
sdpMediaConnectionData
sdpMediaEncryptionKey
// WebSocket, NTP, SNMP, RFB Sticky Buffers
websocketPayload
ntpReferenceID
snmpUSM
rfbName
// IKE Sticky Buffers
ikeInitSPI
ikeRespSPI
ikeVendor
ikeKeyExchangePayload
ikeNoncePayload
// MQTT Sticky Buffers
mqttConnectClientID
mqttConnectPassword
mqttConnectProtocolString
mqttConnectUsername
mqttConnectWillMessage
mqttConnectWillTopic
mqttPublishMessage
mqttPublishTopic
mqttSubscribeTopic
mqttUnsubscribeTopic
// DNP3 & ENIP Sticky Buffers
dnp3Data
dnp3Data8
enipProductName
enipServiceName
)
// Contains both Suricata 4.x and 5.0 buffers. Some day we'll deprecate the 4.x ones.
var stickyBuffers = map[DataPos]string{
pktData: "pkt_data",
fileData: "file_data",
base64Data: "base64_data",
// Suricata 4.X Sticky Buffers
// HTTP Sticky Buffers
httpAcceptEnc: "http_accept_enc",
httpAccept: "http_accept",
httpAcceptLang: "http_accept_lang",
httpConnection: "http_connection",
httpContentLen: "http_content_len",
httpContentType: "http_content_type",
httpHeaderNames: "http_header_names",
httpProtocol: "http_protocol",
httpReferer: "http_referer",
httpRequestLine: "http_request_line",
httpResponseLine: "http_response_line",
httpStart: "http_start",
// TLS Sticky Buffers
tlsCertSubject: "tls_cert_subject",
tlsCertIssuer: "tls_cert_issuer",
tlsCertSerial: "tls_cert_serial",
tlsCertFingerprint: "tls_cert_fingerprint",
tlsSNI: "tls_sni",
// JA3 Sticky Buffers
ja3Hash: "ja3_hash",
ja3String: "ja3_string",
// SSH Sticky Buffers
sshProto: "ssh_proto",
sshSoftware: "ssh_software",
// Kerberos Sticky Buffers
krb5Cname: "krb5_cname",
krb5Sname: "krb5_sname",
// DNS Sticky Buffers
dnsQuery: "dns_query",
// SMB Sticky Buffers
smbNamedPipe: "smb_named_pipe",
smbShare: "smb_share",
// Suricata 5.0 Sticky Buffers
fileData5: "file.data",
// HTTP Sticky Buffers
httpAccept5: "http.accept",
httpAcceptEnc5: "http.accept_enc",
httpAcceptLang5: "http.accept_lang",
httpClientBody: "http.client_body",
httpConnection5: "http.connection",
httpContentLen5: "http.content_len",
httpContentType5: "http.content_type",
httpCookie: "http.cookie",
httpHeader: "http.header",
httpHeaderNames5: "http.header_names",
httpHeaderRaw: "http.header.raw",
httpHost: "http.host",
httpHostRaw: "http.host.raw",
httpLocation: "http.location",
httpMethod: "http.method",
httpProtocol5: "http.protocol",
httpReferer5: "http.referer",
httpRequestBody: "http.request_body",
httpRequestLine5: "http.request_line",
httpResponseBody: "http.response_body",
httpResponseLine5: "http.response_line",
httpServer: "http.server",
httpServerBody: "http.server_body",
httpStart5: "http.start",
httpStatCode: "http.stat_code",
httpStatMsg: "http.stat_msg",
httpURI: "http.uri",
httpURIRaw: "http.uri.raw",
httpUserAgent: "http.user_agent",
// TLS Sticky Buffers
tlsCertSubject5: "tls.cert_subject",
tlsCertIssuer5: "tls.cert_issuer",
tlsCertSerial5: "tls.cert_serial",
tlsCertFingerprint5: "tls.cert_fingerprint",
tlsSNI5: "tls.sni",
// JA3 Sticky Buffers
ja3Hash5: "ja3.hash",
ja3String5: "ja3.string",
ja3sHash: "ja3s.hash",
ja3sString: "ja3s.string",
// SSH Sticky Buffers
sshProto5: "ssh.proto",
sshSoftware5: "ssh.software",
// DNS Sticky Buffers
dnsQuery5: "dns.query",
// Suricata 7.x Sticky Buffers
tlsCerts: "tls.certs",
tlsRandom: "tls.random",
httpRequestHeader: "http.request_header",
httpResponseHeader: "http.response_header",
fileMagic: "file.magic",
tcpHdr: "tcp.hdr",
tcpFlags: "tcp.flags",
udpHdr: "udp.hdr",
icmpv4Hdr: "icmpv4.hdr",
icmpv6Hdr: "icmpv6.hdr",
ipv4Hdr: "ipv4.hdr",
ipv6Hdr: "ipv6.hdr",
dceStubData: "dce_stub_data",
dcerpcStubData: "dcerpc.stub_data",
// Suricata 8.x Sticky Buffers
// QUIC Sticky Buffers
quicSNI: "quic.sni",
quicVersion: "quic.version",
quicCyuHash: "quic.cyu.hash",
quicCyuString: "quic.cyu.string",
quicUA: "quic.ua",
// JA4 Sticky Buffers
ja4Hash: "ja4.hash",
ja4Raw: "ja4.raw",
ja4sHash: "ja4s.hash",
ja4sRaw: "ja4s.raw",
// SSH HASSH Sticky Buffers
sshHassh: "ssh.hassh",
sshHasshString: "ssh.hassh.string",
sshHasshServer: "ssh.hassh.server",
sshHasshServerString: "ssh.hassh.server.string",
// SNMP Sticky Buffers
snmpCommunity: "snmp.community",
snmpPDUType: "snmp.pdu_type",
// IP Sticky Buffers
ipSrc: "ip.src",
ipDst: "ip.dst",
// HTTP/2 Sticky Buffers
http2Frametype: "http2.frametype",
http2Errorcode: "http2.errorcode",
http2Priority: "http2.priority",
http2Window: "http2.window",
http2SizeUpdate: "http2.size_update",
http2Settings: "http2.settings",
http2Header: "http2.header",
http2HeaderName: "http2.header_name",
http2HeaderValue: "http2.header_value",
// Open-ended Frame Sticky Buffer
framePos: "frame",
// DNS Sticky Buffers
dnsQueriesRrname: "dns.queries.rrname",
dnsAnswersRrname: "dns.answers.rrname",
dnsAuthoritiesRrname: "dns.authorities.rrname",
dnsAdditionalsRrname: "dns.additionals.rrname",
dnsResponseRrname: "dns.response.rrname",
dnsQueryName: "dns.query.name",
dnsAnswerName: "dns.answer.name",
// mDNS Sticky Buffers
mdnsQueriesRrname: "mdns.queries.rrname",
mdnsAnswersRrname: "mdns.answers.rrname",
mdnsAuthoritiesRrname: "mdns.authorities.rrname",
mdnsAdditionalsRrname: "mdns.additionals.rrname",
mdnsResponseRrname: "mdns.response.rrname",
// TLS Sticky Buffers
tlsSubjectAltName: "tls.subjectaltname",
tlsRandomTime: "tls.random_time",
tlsRandomBytes: "tls.random_bytes",
tlsALPN: "tls.alpn",
// File, Kerberos & SMB Sticky Buffers
fileName: "file.name",
krb5Cname5: "krb5.cname",
krb5Sname5: "krb5.sname",
smbNamedPipe5: "smb.named_pipe",
smbShare5: "smb.share",
smbNtlmsspUser: "smb.ntlmssp_user",
smbNtlmsspDomain: "smb.ntlmssp_domain",
// Email Sticky Buffers
emailFrom: "email.from",
emailSubject: "email.subject",
emailTo: "email.to",
emailCc: "email.cc",
emailDate: "email.date",
emailMessageID: "email.message_id",
emailXMailer: "email.x_mailer",
emailURL: "email.url",
emailReceived: "email.received",
// SMTP, FTP & PGSQL Sticky Buffers
smtpHelo: "smtp.helo",
smtpMailFrom: "smtp.mail_from",
smtpRcptTo: "smtp.rcpt_to",
ftpCommand: "ftp.command",
ftpCommandData: "ftp.command_data",
ftpCompletionCode: "ftp.completion_code",
ftpReply: "ftp.reply",
pgsqlQuery: "pgsql.query",
// LDAP Sticky Buffers
ldapRequestDN: "ldap.request.dn",
ldapResponsesDN: "ldap.responses.dn",
ldapResponsesMessage: "ldap.responses.message",
ldapRequestAttributeType: "ldap.request.attribute_type",
ldapResponsesAttributeType: "ldap.responses.attribute_type",
// SIP Sticky Buffers
sipMethod: "sip.method",
sipURI: "sip.uri",
sipRequestLine: "sip.request_line",
sipStatCode: "sip.stat_code",
sipStatMsg: "sip.stat_msg",
sipResponseLine: "sip.response_line",
sipProtocol: "sip.protocol",
sipFrom: "sip.from",
sipTo: "sip.to",
sipVia: "sip.via",
sipUserAgent: "sip.user_agent",
sipContentType: "sip.content_type",
sipContentLength: "sip.content_length",
// SDP Sticky Buffers
sdpOrigin: "sdp.origin",
sdpSessionName: "sdp.session_name",
sdpSessionInfo: "sdp.session_info",
sdpURI: "sdp.uri",
sdpEmail: "sdp.email",
sdpPhoneNumber: "sdp.phone_number",
sdpConnectionData: "sdp.connection_data",
sdpBandwidth: "sdp.bandwidth",
sdpTime: "sdp.time",
sdpRepeatTime: "sdp.repeat_time",
sdpTimezone: "sdp.timezone",
sdpEncryptionKey: "sdp.encryption_key",
sdpAttribute: "sdp.attribute",
sdpMediaMedia: "sdp.media.media",
sdpMediaSessionInfo: "sdp.media.session_info",
sdpMediaMediaInfo: "sdp.media.media_info",
sdpMediaConnectionData: "sdp.media.connection_data",
sdpMediaEncryptionKey: "sdp.media.encryption_key",
// WebSocket, NTP, SNMP, RFB Sticky Buffers
websocketPayload: "websocket.payload",
ntpReferenceID: "ntp.reference_id",
snmpUSM: "snmp.usm",
rfbName: "rfb.name",
// IKE Sticky Buffers
ikeInitSPI: "ike.init_spi",
ikeRespSPI: "ike.resp_spi",
ikeVendor: "ike.vendor",
ikeKeyExchangePayload: "ike.key_exchange_payload",
ikeNoncePayload: "ike.nonce_payload",
// MQTT Sticky Buffers
mqttConnectClientID: "mqtt.connect.clientid",
mqttConnectPassword: "mqtt.connect.password",
mqttConnectProtocolString: "mqtt.connect.protocol_string",
mqttConnectUsername: "mqtt.connect.username",
mqttConnectWillMessage: "mqtt.connect.willmessage",
mqttConnectWillTopic: "mqtt.connect.willtopic",
mqttPublishMessage: "mqtt.publish.message",
mqttPublishTopic: "mqtt.publish.topic",
mqttSubscribeTopic: "mqtt.subscribe.topic",
mqttUnsubscribeTopic: "mqtt.unsubscribe.topic",
// DNP3 & ENIP Sticky Buffers
dnp3Data: "dnp3_data",
dnp3Data8: "dnp3.data",
enipProductName: "enip.product_name",
enipServiceName: "enip.service_name",
}
func (d DataPos) String() string {
return stickyBuffers[d]
}
// StickyBuffer returns the data position value for the string representation of a sticky buffer name (e.g. "file_data")
func StickyBuffer(s string) (DataPos, error) {
for k, v := range stickyBuffers {
if v == s {
return k, nil
}
}
return pktData, fmt.Errorf("%s is not a sticky buffer", s)
}
// isStickyBuffer returns true if the provided string is a known sticky buffer.
func isStickyBuffer(s string) bool {
_, err := StickyBuffer(s)
return err == nil
}
// Transform describes a sticky buffer transformation (e.g. dotprefix, to_lowercase, xor, pcrexform).
type Transform struct {
Name string
Value string
}
// String returns a string for a Transform.
func (t Transform) String() string {
if t.Value == "" {
return fmt.Sprintf("%s;", t.Name)
}
return fmt.Sprintf("%s:%s;", t.Name, t.Value)
}
// Transforms allows for a Stringer on []*Transform.
type Transforms []*Transform
// String returns a space-separated string for all transforms.
func (ts Transforms) String() string {
var parts []string
for _, t := range ts {
parts = append(parts, t.String())
}
return strings.Join(parts, " ")
}
// Content describes a rule content. A content is composed of a pattern followed by options.
type Content struct {
// DataPosition defaults to pkt_data state, can be modified to apply to file_data, base64_data locations.
// This value will apply to all following contents, to reset to default you must reset DataPosition during processing.
DataPosition DataPos
// FrameName holds the frame name when DataPosition is framePos (e.g. "dns.pdu" for frame:dns.pdu;).
FrameName string
// DataDirection holds an optional sticky buffer direction modifier ("to_server" or "to_client").
DataDirection string
// FastPattern settings for the content.
FastPattern FastPattern
// Pattern is the pattern match of a content (e.g. HTTP in content:"HTTP").
Pattern []byte
// Negate is true for negated content match.
Negate bool
// Options are the option associated to the content (e.g. http_header).
Options []*ContentOption
// Transforms is the list of transforms attached to this content (sticky buffer).
Transforms Transforms
}
// byteMatchType describes the kinds of byte matches and comparisons that are supported.
type byteMatchType int
const (
bUnknown byteMatchType = iota
bExtract
bTest
bJump
isDataAt
b64Decode
bMath
)
var byteMatchTypeVals = map[byteMatchType]string{
bExtract: "byte_extract",
bJump: "byte_jump",
bTest: "byte_test",
isDataAt: "isdataat",
b64Decode: "base64_decode",
bMath: "byte_math",
}
// allbyteMatchTypeNames returns a slice of valid byte_* keywords.
func allbyteMatchTypeNames() []string {
b := make([]string, len(byteMatchTypeVals))
var i int
for _, n := range byteMatchTypeVals {
b[i] = n
i++
}
return b
}
// String returns the string representation of a byte_* keyword.
func (b byteMatchType) String() string {
return byteMatchTypeVals[b]
}
// byteMatcher returns a byteMatchType iota for a provided String.
func byteMatcher(s string) (byteMatchType, error) {
for k, v := range byteMatchTypeVals {
if v == s {
return k, nil
}
}
return bUnknown, fmt.Errorf("%s is not a byteMatchType* keyword", s)
}
// lenMatcher returns an lenMatchType or an error for a given string.
func lenMatcher(s string) (lenMatchType, error) {
for k, v := range lenMatchTypeVals {
if v == s {
return k, nil
}
}
return lUnknown, fmt.Errorf("%s is not an lenMatch keyword", s)
}
// Returns the number of mandatory parameters for a byteMatchType keyword, -1 if unknown.
func (b byteMatchType) minLen() int {
switch b {
case bExtract:
return 3
case bJump:
return 2
case bTest:
return 4
case isDataAt:
return 1
case b64Decode, bMath:
return 0
}
return -1
}
// ByteMatch describes a byte matching operation, similar to a Content.
type ByteMatch struct {
// DataPosition defaults to pkt_data state, can be modified to apply to file_data, base64_data locations.
// This value will apply to all following contents, to reset to default you must reset DataPosition during processing.
DataPosition DataPos
FrameName string
DataDirection string
// Kind is a specific operation type we're taking.
Kind byteMatchType
// Negate indicates negation of a value, currently only used for isdataat.
Negate bool
// A variable name being extracted by byte_extract.
Variable string
// Number of bytes to operate on. "bytes to convert" in Snort Manual. This can be an int, or a var from byte_extract.
NumBytes string
// Operator for comparison in byte_test.
Operator string
// Value to compare against using byte_test.
Value string
// Offset within given buffer to operate on.
Offset string
// Other specifics required for jump/test here. This might make sense to pull out into a "ByteMatchOption" later.
Options []string
}
// lenMatchType describes the type of length matches and comparisons that are supported.
type lenMatchType int
const (
lUnknown lenMatchType = iota
iType
iCode
iID
iSeq
uriLen
dSize
ipTTL
ipID
tcpSeq
tcpACK
bSize
fileSize
flowBytesToServer
flowBytesToClient
flowPktsToServer
flowPktsToClient
flowAge
flowPkts
flowBytes
tcpMSS
tcpWScale
)
// lenMatchTypeVals map len types to string representations.
var lenMatchTypeVals = map[lenMatchType]string{
iType: "itype",
iCode: "icode",
iID: "icmp_id",
iSeq: "icmp_seq",
uriLen: "urilen",
dSize: "dsize",
ipTTL: "ttl",
ipID: "id",
tcpSeq: "seq",
tcpACK: "ack",
bSize: "bsize",
fileSize: "filesize",
flowBytesToServer: "flow.bytes_toserver",
flowBytesToClient: "flow.bytes_toclient",
flowPktsToServer: "flow.pkts_toserver",
flowPktsToClient: "flow.pkts_toclient",
flowAge: "flow.age",
flowPkts: "flow.pkts",
flowBytes: "flow.bytes",
tcpMSS: "tcp.mss",
tcpWScale: "tcp.wscale",
}
// allLenMatchTypeNames returns a slice of string containing all length match keywords.
func allLenMatchTypeNames() []string {
i := make([]string, len(lenMatchTypeVals))
var j int
for _, n := range lenMatchTypeVals {
i[j] = n
j++
}
return i
}
// String returns the string keyword for an lenMatchType.
func (i lenMatchType) String() string {
return lenMatchTypeVals[i]
}
// LenMatch holds the values to represent an Length Match.
type LenMatch struct {
// DataPosition defaults to pkt_data state, can be modified to apply to file_data, base64_data locations.
// This value will apply to all following contents, to reset to default you must reset DataPosition during processing.
DataPosition DataPos
FrameName string
DataDirection string
Kind lenMatchType
Min int
Max int
Num int
Operator string
Options []string
}
// PCRE describes a PCRE item of a rule.
type PCRE struct {
// DataPosition defaults to pkt_data state, can be modified to apply to file_data, base64_data locations.
// This value will apply to all following contents, to reset to default you must reset DataPosition during processing.
DataPosition DataPos
FrameName string
DataDirection string
Pattern []byte
Negate bool
Options []byte
Transforms Transforms
}
// Dataset describes a Suricata dataset rule option.
type Dataset struct {
DataPosition DataPos
FrameName string
DataDirection string
Transforms Transforms
Cmd string
Name string
Options []string
}
// DataRep describes a Suricata datarep rule option.
type DataRep struct {
DataPosition DataPos
FrameName string
DataDirection string
Transforms Transforms
Name string
Operator string
Value int
Options []string
}
// Absent describes a Suricata absent sticky-buffer rule option (e.g. "absent;" or "absent:or_else;").
type Absent struct {
DataPosition DataPos
FrameName string
DataDirection string
Transforms Transforms
OrElse bool
}
// FastPattern describes various properties of a fast_pattern value for a content.
type FastPattern struct {
Enabled bool
Only bool
Offset int
Length int
}
// ContentOption describes an option set on a rule content.
type ContentOption struct {
// Name is the name of the option (e.g. offset).
Name string
// Value is the value associated to the option, default to "" for option without value.
Value string
}
// Reference describes a gonids reference in a rule.
type Reference struct {
// Type is the system name for the reference: (url, cve, md5, etc.)
Type string
// Value is the identifier in the system: (address, cvd-id, hash)
Value string
}
// Valid keywords for extracting TLS matches. Does not include tls.store, or sticky buffers.
var tlsTags = []string{
"ssl_version",
"ssl_state",
"tls.version",
"tls.subject",
"tls.issuerdn",
"tls.fingerprint",
"tls_cert_notbefore",
"tls_cert_notafter",
"tls.cert_notbefore",
"tls.cert_notafter",
}
// TLSTag describes a TLS specific match (non-sticky buffer based).
type TLSTag struct {
// Is the match negated (!).
Negate bool
// Key holds the thing we're inspecting (tls.version, tls.fingerprint, etc.).
Key string
// TODO: Consider string -> []byte and handle hex input.
// TODO: Consider supporting []struct if we can support things like: tls.version:!1.2,!1.3
// Value holds the value for the match.
Value string
}
// StreamCmp represents a stream comparison (stream_size:>20).
type StreamCmp struct {
// Direction of traffic to inspect: server, client, both, either.
Direction string
// Operator is the comparison operator to apply >, <, !=, etc.
Operator string
// TODO: Can this number be a variable, if yes s/int/string.
// Number is the size to compare against
Number int
}
// within returns the within value for a specific content.
func within(options []*ContentOption) string {
for _, o := range options {
if o.Name == "within" {
return o.Value
}
}
return ""
}
// RE returns all content matches as a single and simple regexp.
func (r *Rule) RE() string {
var re string
for _, c := range r.Contents() {
// A negated content requires the pattern to be absent. A simple regexp
// cannot express absence, and emitting it as a positive match inverts the
// rule: the result misses traffic the rule alerts on and matches traffic it
// does not. Skip negated contents so RE stays a superset of the positive matches.
if c.Negate {
continue
}
// TODO: handle pcre, depth, offset, distance.
if d, err := strconv.Atoi(within(c.Options)); err == nil && d > 0 {
re += fmt.Sprintf(".{0,%d}", d)
} else {
re += ".*"
}
re += regexp.QuoteMeta(string(c.Pattern))
}
return re
}
// CVE extracts CVE from a rule.
func (r *Rule) CVE() string {
for _, ref := range r.References {
if ref.Type == "cve" {
return ref.Value
}
}
return ""
}
// LenMatchers returns all *LenMatch for a rule.
func (r *Rule) LenMatchers() []*LenMatch {
lms := make([]*LenMatch, 0, len(r.Matchers))
for _, m := range r.Matchers {
if lm, ok := m.(*LenMatch); ok {
lms = append(lms, lm)
}
}
return lms
}
// Contents returns all *Content for a rule.
func (r *Rule) Contents() []*Content {
cs := make([]*Content, 0, len(r.Matchers))
for _, m := range r.Matchers {
if c, ok := m.(*Content); ok {
cs = append(cs, c)
}
}
return cs
}
// LastContent returns the last *Content from Matchers
func (r *Rule) LastContent() *Content {
for i := range r.Matchers {
if co, ok := r.Matchers[len(r.Matchers)-i-1].(*Content); ok {
return co
}
}
return nil
}
// ByteMatchers returns all *ByteMatch for a rule.
func (r *Rule) ByteMatchers() []*ByteMatch {
bs := make([]*ByteMatch, 0, len(r.Matchers))
for _, m := range r.Matchers {
if b, ok := m.(*ByteMatch); ok {
bs = append(bs, b)
}
}
return bs
}
// PCREs returns all *PCRE for a rule.
func (r *Rule) PCREs() []*PCRE {
var ps []*PCRE
for _, m := range r.Matchers {
if p, ok := m.(*PCRE); ok {
ps = append(ps, p)
}
}
return ps
}
// Datasets returns all *Dataset for a rule.
func (r *Rule) Datasets() []*Dataset {
var ds []*Dataset
for _, m := range r.Matchers {
if d, ok := m.(*Dataset); ok {
ds = append(ds, d)
}
}
return ds
}
// DataReps returns all *DataRep for a rule.
func (r *Rule) DataReps() []*DataRep {
var drs []*DataRep
for _, m := range r.Matchers {
if d, ok := m.(*DataRep); ok {
drs = append(drs, d)
}
}
return drs
}
// Absents returns all *Absent for a rule.
func (r *Rule) Absents() []*Absent {
var as []*Absent
for _, m := range r.Matchers {
if a, ok := m.(*Absent); ok {
as = append(as, a)
}
}
return as
}
// String returns a string for a Dataset.
func (d Dataset) String() string {
var s strings.Builder
fmt.Fprintf(&s, "dataset:%s,%s", d.Cmd, d.Name)
for _, o := range d.Options {
fmt.Fprintf(&s, ",%s", o)
}
fmt.Fprintf(&s, ";")
return s.String()
}
func (d Dataset) stickyPos() stickyPos {
return stickyPos{pos: d.DataPosition, frameName: d.FrameName, direction: d.DataDirection}
}
func (d Dataset) transforms() Transforms {
return d.Transforms
}
// String returns a string for a DataRep.
func (d DataRep) String() string {
var s strings.Builder
fmt.Fprintf(&s, "datarep:%s,%s,%d", d.Name, d.Operator, d.Value)
for _, o := range d.Options {
fmt.Fprintf(&s, ",%s", o)
}
fmt.Fprintf(&s, ";")
return s.String()
}
func (d DataRep) stickyPos() stickyPos {
return stickyPos{pos: d.DataPosition, frameName: d.FrameName, direction: d.DataDirection}
}
func (d DataRep) transforms() Transforms {
return d.Transforms
}
// String returns a string for an Absent matcher.
func (a Absent) String() string {
if a.OrElse {
return "absent:or_else;"
}
return "absent;"
}
func (a Absent) stickyPos() stickyPos {
return stickyPos{pos: a.DataPosition, frameName: a.FrameName, direction: a.DataDirection}
}
func (a Absent) transforms() Transforms {
return a.Transforms
}
func netString(netPart []string) string {
var s strings.Builder
if len(netPart) > 1 {
fmt.Fprintf(&s, "[")
}
for i, n := range netPart {
fmt.Fprintf(&s, "%s", n)
if i < len(netPart)-1 {
fmt.Fprintf(&s, ",")
}
}
if len(netPart) > 1 {
fmt.Fprintf(&s, "]")
}
return s.String()
}
// String returns a string for a Network.
func (n Network) String() string {
var s strings.Builder
if n.NegateNets {
fmt.Fprintf(&s, "!")
}
fmt.Fprintf(&s, "%s", netString(n.Nets))
fmt.Fprintf(&s, " ")
if n.NegatePorts {
fmt.Fprintf(&s, "!")
}
fmt.Fprintf(&s, "%s", netString(n.Ports))
return s.String()
}
// String returns a string for a FastPattern.
func (f FastPattern) String() string {
if !f.Enabled {
return ""
}
// This is an invalid state.
if f.Only && (f.Offset != 0 || f.Length != 0) {
return ""
}
var s strings.Builder
fmt.Fprintf(&s, "fast_pattern")
if f.Only {
fmt.Fprintf(&s, ":only;")
return s.String()
}
// "only" and "chop" modes are mutually exclusive.
if f.Offset != 0 || f.Length != 0 {
fmt.Fprintf(&s, ":%d,%d", f.Offset, f.Length)
}
fmt.Fprintf(&s, ";")
return s.String()
}
// String returns a string for a ContentOption.
func (co ContentOption) String() string {
switch {
case inSlice(co.Name, []string{"depth", "distance", "offset", "within"}):
return fmt.Sprintf("%s:%v;", co.Name, co.Value)
case co.Name == "replace":
return fmt.Sprintf(`%s:"%s";`, co.Name, co.Value)
default:
return fmt.Sprintf("%s;", co.Name)
}
}
// String returns a string for a Reference.
func (r Reference) String() string {
return fmt.Sprintf("reference:%s,%s;", r.Type, r.Value)
}
// String returns a string for a Content (ignoring sticky buffers.)
func (c Content) String() string {
var s strings.Builder
fmt.Fprintf(&s, "content:")
if c.Negate {
fmt.Fprintf(&s, "!")
}
fmt.Fprintf(&s, `"%s";`, c.FormatPattern())
for _, o := range c.Options {
fmt.Fprintf(&s, " %s", o)
}
if c.FastPattern.Enabled {
fmt.Fprintf(&s, " %s", c.FastPattern)
}
return s.String()
}
func (c Content) stickyPos() stickyPos {
return stickyPos{pos: c.DataPosition, frameName: c.FrameName, direction: c.DataDirection}
}
func (c Content) transforms() Transforms {
return c.Transforms
}
// base64DecodeString returns a string for a base64_decode ByteMatch.
func (b ByteMatch) base64DecodeString() string {
var parts []string
if b.NumBytes != "" {
parts = append(parts, fmt.Sprintf("bytes %s", b.NumBytes))
}
if b.Offset != "" && b.Offset != "0" {
parts = append(parts, fmt.Sprintf("offset %s", b.Offset))
}
// This should only be "relative" but we'll support "anything"
parts = append(parts, b.Options...)
if len(parts) == 0 {
return fmt.Sprintf("%s;", byteMatchTypeVals[b.Kind])
}
return fmt.Sprintf("%s:%s;", byteMatchTypeVals[b.Kind], strings.Join(parts, ","))
}
// byteMathString returns a string for a byte_math ByteMatch.
func (b ByteMatch) byteMathString() string {
var parts []string
if b.NumBytes != "" {
parts = append(parts, fmt.Sprintf("bytes %s", b.NumBytes))
}
if b.Offset != "" {
parts = append(parts, fmt.Sprintf("offset %s", b.Offset))
}
if b.Operator != "" {
parts = append(parts, fmt.Sprintf("oper %s", b.Operator))
}
if b.Value != "" {
parts = append(parts, fmt.Sprintf("rvalue %s", b.Value))
}
if b.Variable != "" {
parts = append(parts, fmt.Sprintf("result %s", b.Variable))
}
parts = append(parts, b.Options...)
return fmt.Sprintf("byte_math:%s;", strings.Join(parts, ","))
}
// String returns a string for a ByteMatch.
func (b ByteMatch) String() string {
// TODO: Support dataPos?
// TODO: Write tests.
var s strings.Builder
fmt.Fprintf(&s, "%s:", byteMatchTypeVals[b.Kind])
switch b.Kind {
case bExtract:
fmt.Fprintf(&s, "%s,%s,%s", b.NumBytes, b.Offset, b.Variable)
case bJump:
fmt.Fprintf(&s, "%s,%s", b.NumBytes, b.Offset)
case bTest:
fmt.Fprintf(&s, "%s,%s,%s,%s", b.NumBytes, b.Operator, b.Value, b.Offset)
case isDataAt:
if b.Negate {
fmt.Fprintf(&s, "!")
}
fmt.Fprintf(&s, "%s", b.NumBytes)
// Logic for this case is a bit different so it's handled outside.
case b64Decode:
return b.base64DecodeString()
case bMath:
return b.byteMathString()
}
for _, o := range b.Options {
fmt.Fprintf(&s, ",%s", o)
}
fmt.Fprintf(&s, ";")
return s.String()
}
func (b ByteMatch) stickyPos() stickyPos {
return stickyPos{pos: b.DataPosition, frameName: b.FrameName, direction: b.DataDirection}
}
func (b ByteMatch) transforms() Transforms {
return nil
}
// String returns a string for an length match.
func (i LenMatch) String() string {
var s strings.Builder
fmt.Fprintf(&s, "%s:", i.Kind)
switch {
case i.Operator == "<>":
fmt.Fprintf(&s, "%d%s%d", i.Min, i.Operator, i.Max)
case i.Operator != "":
fmt.Fprintf(&s, "%s%d", i.Operator, i.Num)
default:
fmt.Fprintf(&s, "%d", i.Num)
}
for _, o := range i.Options {
fmt.Fprintf(&s, ",%s", o)
}
fmt.Fprintf(&s, ";")
return s.String()
}
func (i LenMatch) stickyPos() stickyPos {
return stickyPos{pos: i.DataPosition, frameName: i.FrameName, direction: i.DataDirection}
}
func (i LenMatch) transforms() Transforms {
return nil
}
// String returns a string for all of the metadata values.
func (ms Metadatas) String() string {
var s strings.Builder
if len(ms) < 1 {
return ""
}
fmt.Fprintf(&s, "metadata:")
for i, m := range ms {
if i < len(ms)-1 {
fmt.Fprintf(&s, "%s %s, ", m.Key, m.Value)
continue
}
fmt.Fprintf(&s, "%s %s;", m.Key, m.Value)
}
return s.String()
}
func (t *TLSTag) String() string {
var s strings.Builder
fmt.Fprintf(&s, "%s:", t.Key)
if t.Negate {
fmt.Fprintf(&s, "!")
}
// Values for these get wrapped in `"`.
if inSlice(t.Key, []string{"tls.issuerdn", "tls.subject", "tls.fingerprint"}) {
fmt.Fprintf(&s, `"%s";`, t.Value)
} else {
fmt.Fprintf(&s, "%s;", t.Value)
}
return s.String()
}
func (s *StreamCmp) String() string {
return fmt.Sprintf("stream_size:%s,%s,%d;", s.Direction, s.Operator, s.Number)
}
// Threshold describes a rule threshold option.
type Threshold struct {
Type string
Track string
Count int
Seconds int
Multiplier int
}
// String returns a string for a Threshold.
func (t *Threshold) String() string {
var parts []string
if t.Type != "" {
parts = append(parts, fmt.Sprintf("type %s", t.Type))
}
if t.Track != "" {
parts = append(parts, fmt.Sprintf("track %s", t.Track))
}
if t.Count > 0 {
parts = append(parts, fmt.Sprintf("count %d", t.Count))
}
if t.Seconds > 0 {
parts = append(parts, fmt.Sprintf("seconds %d", t.Seconds))
}
if t.Multiplier > 0 {
parts = append(parts, fmt.Sprintf("multiplier %d", t.Multiplier))
}
return fmt.Sprintf("threshold:%s;", strings.Join(parts, ", "))
}
// DetectionFilter describes a rule detection_filter option.
type DetectionFilter struct {
Track string
Count int
Seconds int
UniqueOn string
}
// String returns a string for a DetectionFilter.
func (d *DetectionFilter) String() string {
var parts []string
if d.Track != "" {
parts = append(parts, fmt.Sprintf("track %s", d.Track))
}
if d.Count > 0 {
parts = append(parts, fmt.Sprintf("count %d", d.Count))
}
if d.Seconds > 0 {
parts = append(parts, fmt.Sprintf("seconds %d", d.Seconds))
}
if d.UniqueOn != "" {
parts = append(parts, fmt.Sprintf("unique_on %s", d.UniqueOn))
}
return fmt.Sprintf("detection_filter:%s;", strings.Join(parts, ", "))
}
// VersionConstraint describes a single version comparison within a Suricata requires option
// (e.g. ">= 8" or "< 8.0.3").
type VersionConstraint struct {
Operator string
Version string
}
// String returns the string representation of a single VersionConstraint.
func (vc VersionConstraint) String() string {
return fmt.Sprintf("%s %s", vc.Operator, vc.Version)
}
// Requires describes a Suricata requires rule option.
// Version is a slice of OR-separated branches, where each branch is a slice of
// AND-combined VersionConstraints (e.g. "version >= 7.0.4 < 8 | >= 8.0.3").
type Requires struct {
Version [][]VersionConstraint
Features []string
Keywords []string
}
// String returns a string for a Requires option.
func (req *Requires) String() string {
if req == nil {
return ""
}
var parts []string
if len(req.Version) > 0 {
var orBranches []string
for _, andGroup := range req.Version {
var andParts []string
for _, vc := range andGroup {
andParts = append(andParts, vc.String())
}
if len(andParts) > 0 {
orBranches = append(orBranches, strings.Join(andParts, " "))
}
}
if len(orBranches) > 0 {
parts = append(parts, "version "+strings.Join(orBranches, " | "))
}
}
for _, f := range req.Features {
parts = append(parts, fmt.Sprintf("feature %s", f))
}
for _, k := range req.Keywords {
parts = append(parts, fmt.Sprintf("keyword %s", k))
}
if len(parts) == 0 {
return ""
}
return fmt.Sprintf("requires:%s;", strings.Join(parts, ", "))
}
// String returns a string for a PCRE.
func (p PCRE) String() string {
pattern := p.Pattern
if len(pattern) < 1 {
return ""
}
// escape quote signs, if necessary
if bytes.IndexByte(pattern, '"') > -1 {
pattern = bytes.ReplaceAll(pattern, []byte(`"`), []byte(`\"`))
}
var s strings.Builder
fmt.Fprintf(&s, "pcre:")
if p.Negate {
fmt.Fprintf(&s, "!")
}
fmt.Fprintf(&s, `"/%s/%s";`, pattern, p.Options)
return s.String()
}
func (p PCRE) stickyPos() stickyPos {
return stickyPos{pos: p.DataPosition, frameName: p.FrameName, direction: p.DataDirection}
}
func (p PCRE) transforms() Transforms {
return p.Transforms
}
// String returns a string for a Flowbit.
func (fb Flowbit) String() string {
if !inSlice(fb.Action, []string{"noalert", "isset", "isnotset", "set", "unset", "toggle", "reset"}) {
return ""
}
var s strings.Builder
fmt.Fprintf(&s, "flowbits:%s", fb.Action)
if fb.Value != "" {
fmt.Fprintf(&s, ",%s", fb.Value)
}
fmt.Fprintf(&s, ";")
return s.String()
}
// String returns a string for a Flowbit.
func (fi Flowint) String() string {
var s strings.Builder
fmt.Fprintf(&s, "flowint:%s", fi.Name)
if inSlice(fi.Modifier, []string{"isset", "isnotset"}) {
fmt.Fprintf(&s, ",%s", fi.Modifier)
}
if inSlice(fi.Modifier, []string{"+", "-", "=", ">", "<", ">=", "<=", "==", "!="}) && fi.Value != "" {
fmt.Fprintf(&s, ",%s,%s", fi.Modifier, fi.Value)
}
fmt.Fprintf(&s, ";")
return s.String()
}
// String returns a string for a Flowbit.
func (xb Xbit) String() string {
var s strings.Builder
fmt.Fprintf(&s, "xbits:%s,%s,track %s", xb.Action, xb.Name, xb.Track)
if xb.Expire != "" {
fmt.Fprintf(&s, ",expire %s", xb.Expire)
}
fmt.Fprintf(&s, ";")
return s.String()
}
// String returns a string for all ordered matchers in a rule, including sticky buffers and transforms.
func (ms Matchers) String() string {
if len(ms) < 1 {
return ""
}
var parts []string
curPos := stickyPos{pos: pktData}
for _, m := range ms {
if sp := m.stickyPos(); sp != curPos {
curPos = sp
parts = append(parts, sp.String())
if ts := m.transforms(); len(ts) > 0 {
parts = append(parts, ts.String())
}
}
parts = append(parts, m.String())
}
return strings.Join(parts, " ")
}
// String returns a string for a rule.
func (r Rule) String() string {
var s strings.Builder
if r.Disabled {
fmt.Fprintf(&s, "#")
}
fmt.Fprintf(&s, "%s %s %s ", r.Action, r.Protocol, r.Source)
switch {
case r.Transactional:
fmt.Fprintf(&s, "=> ")
case r.Bidirectional:
fmt.Fprintf(&s, "<> ")
default:
fmt.Fprintf(&s, "-> ")
}
fmt.Fprintf(&s, `%s (msg:"%s"; `, r.Destination, r.Description)
// Pull flow out of tags if it exists, we like flow at the beginning of rules.
if v, ok := r.Tags["flow"]; ok {
fmt.Fprintf(&s, "flow:%s; ", v)
}
// Write out matchers in order (because things can be relative.)
if len(r.Matchers) > 0 {
fmt.Fprintf(&s, "%s ", r.Matchers)
}
if r.StreamMatch != nil {
fmt.Fprintf(&s, "%s ", r.StreamMatch)
}
if r.Threshold != nil {
fmt.Fprintf(&s, "%s ", r.Threshold)
}
if r.DetectionFilter != nil {
fmt.Fprintf(&s, "%s ", r.DetectionFilter)
}
if r.Requires != nil {
fmt.Fprintf(&s, "%s ", r.Requires)
}
if r.Target != "" {
fmt.Fprintf(&s, "target:%s; ", r.Target)
}
if len(r.TLSTags) > 0 {
for _, t := range r.TLSTags {
fmt.Fprintf(&s, "%s ", t)
}
}
if len(r.Metas) > 0 {
fmt.Fprintf(&s, "%s ", r.Metas)
}
// Sort the tag names so the output does not depend on map iteration order.
tagNames := make([]string, 0, len(r.Tags))
for k := range r.Tags {
if k == "flow" {
continue
}
tagNames = append(tagNames, k)
}
sort.Strings(tagNames)
for _, k := range tagNames {
fmt.Fprintf(&s, "%s:%s; ", k, r.Tags[k])
}
for _, v := range r.Statements {
fmt.Fprintf(&s, "%s; ", v)
}
for _, fb := range r.Flowbits {
fmt.Fprintf(&s, "%s ", fb)
}
for _, fi := range r.Flowints {
fmt.Fprintf(&s, "%s ", fi)
}
for _, xb := range r.Xbits {
fmt.Fprintf(&s, "%s ", xb)
}
for _, ref := range r.References {
fmt.Fprintf(&s, "%s ", ref)
}
fmt.Fprintf(&s, "sid:%d; rev:%d;)", r.SID, r.Revision)
return s.String()
}
// ToRegexp returns a string that can be used as a regular expression
// to identify content matches in an ASCII dump of a packet capture (tcpdump -A).
func (c *Content) ToRegexp() string {
var buffer bytes.Buffer
for _, b := range c.Pattern {
if b > 126 || b < 32 {
fmt.Fprintf(&buffer, ".")
} else {
fmt.Fprintf(&buffer, "%c", b)
}
}
return regexp.QuoteMeta(buffer.String())
}
// FormatPattern returns a string for a Pattern in a content
func (c *Content) FormatPattern() string {
var buffer bytes.Buffer
pipe := false
for _, b := range c.Pattern {
if b != ' ' && (b > 126 || b < 35 || b == ':' || b == ';' || b == '|' || b == '\\') {
if !pipe {
buffer.WriteByte('|')
pipe = true
} else {
fmt.Fprintf(&buffer, " ")
}
fmt.Fprintf(&buffer, "%.2X", b)
} else {
if pipe {
buffer.WriteByte('|')
pipe = false
}
buffer.WriteByte(b)
}
}
if pipe {
buffer.WriteByte('|')
}
return buffer.String()
}
// InsertMatcher will insert an ordered matcher at a position specified.
func (r *Rule) InsertMatcher(m orderedMatcher, pos int) error {
if pos < 0 {
return fmt.Errorf("cannot insert matcher, position %d < 0", pos)
}
if pos > len(r.Matchers) {
return fmt.Errorf("cannot insert matcher, position %d > %d", pos, len(r.Matchers))
}
r.Matchers = append(r.Matchers, &Content{})
copy(r.Matchers[pos+1:], r.Matchers[pos:])
r.Matchers[pos] = m
return nil
}
// HasVar returns true if a variable with the provided name exists.
func (r *Rule) HasVar(s string) bool {
for _, m := range r.Matchers {
if b, ok := m.(*ByteMatch); ok {
if b.Variable == s {
return true
}
}
}
return false
}
// GetSidMsg returns a string representing a sidmsg.map entry.
func (r *Rule) GetSidMsg() string {
var sidmsg strings.Builder
fmt.Fprintf(&sidmsg, "%s || %s", strconv.Itoa(r.SID), r.Description)
for _, ref := range r.References {
fmt.Fprintf(&sidmsg, " || %s,%s", ref.Type, ref.Value)
}
return sidmsg.String()
}
/* Copyright 2026 Google Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package gonids
import (
"bufio"
"fmt"
"io"
"strings"
)
// RuleScanner provides a convenient interface for reading IDS rules sequentially from an io.Reader.
// It transparently handles multi-line rules ending with backslash (\), blank lines, and comments.
type RuleScanner struct {
reader *bufio.Reader
currRule *Rule
currRaw string
currLine int
startLine int
err error
eof bool
IncludeComments bool
}
// NewRuleScanner returns a new RuleScanner to read from r.
func NewRuleScanner(r io.Reader) *RuleScanner {
return &RuleScanner{
reader: bufio.NewReader(r),
}
}
// isRuleCandidate checks if a line looks like an active or disabled IDS rule.
func isRuleCandidate(line string) bool {
t := strings.TrimSpace(line)
if t == "" {
return false
}
trimmed := strings.TrimLeft(t, "# \t")
fields := strings.Fields(trimmed)
if len(fields) < 2 {
return false
}
action := strings.ToLower(fields[0])
if !inSlice(action, []string{"alert", "drop", "pass", "reject", "sdrop", "log"}) {
return false
}
protocol := strings.ToLower(fields[1])
return inSlice(protocol, appLayerProtocols)
}
// Scan advances the scanner to the next rule in the input stream.
// It returns false when the scan stops, either by reaching the end of the input or encountering an error.
func (s *RuleScanner) Scan() bool {
if s.eof || s.err != nil {
return false
}
for {
line, err := s.reader.ReadString('\n')
if len(line) > 0 {
s.currLine++
}
if err != nil {
if err == io.EOF {
s.eof = true
if len(line) == 0 {
return false
}
} else {
s.err = fmt.Errorf("read error at line %d: %w", s.currLine, err)
return false
}
}
cleanLine := strings.TrimRight(line, "\r\n")
trimmedEnd := strings.TrimRight(cleanLine, " \t")
hasContinuation := strings.HasSuffix(trimmedEnd, "\\")
if hasContinuation {
cleanLine = strings.TrimSuffix(trimmedEnd, "\\")
}
trimmed := strings.TrimSpace(cleanLine)
if trimmed == "" {
if s.IncludeComments {
s.currRule = nil
s.currRaw = ""
s.startLine = s.currLine
return true
}
continue
}
if !isRuleCandidate(trimmed) {
if s.IncludeComments {
s.currRule = nil
s.currRaw = cleanLine
s.startLine = s.currLine
return true
}
continue
}
var combined strings.Builder
ruleStartLine := s.currLine
combined.WriteString(trimmed)
for hasContinuation && !s.eof {
line, err := s.reader.ReadString('\n')
if len(line) > 0 {
s.currLine++
}
if err != nil {
if err == io.EOF {
s.eof = true
} else {
s.err = fmt.Errorf("read error at line %d: %w", s.currLine, err)
return false
}
}
cleanContLine := strings.TrimRight(line, "\r\n")
trimmedContEnd := strings.TrimRight(cleanContLine, " \t")
hasContinuation = strings.HasSuffix(trimmedContEnd, "\\")
if hasContinuation {
cleanContLine = strings.TrimSuffix(trimmedContEnd, "\\")
}
continuationLine := strings.TrimSpace(cleanContLine)
if strings.HasPrefix(continuationLine, "#") {
continuationLine = strings.TrimLeft(continuationLine, "# \t")
}
if continuationLine != "" {
combined.WriteString(" ")
combined.WriteString(continuationLine)
}
}
raw := combined.String()
rule, err := ParseRule(raw)
if err != nil {
s.err = fmt.Errorf("line %d: %w", ruleStartLine, err)
return false
}
s.currRule = rule
s.currRaw = raw
s.startLine = ruleStartLine
return true
}
}
// Rule returns the most recent rule generated by a call to Scan.
func (s *RuleScanner) Rule() *Rule {
return s.currRule
}
// Raw returns the unparsed raw rule string for the most recent rule.
func (s *RuleScanner) Raw() string {
return s.currRaw
}
// LineNumber returns the starting line number of the most recent rule.
func (s *RuleScanner) LineNumber() int {
return s.startLine
}
// Err returns the first non-EOF error that was encountered by the scanner.
func (s *RuleScanner) Err() error {
return s.err
}