package analysis import ( "context" "encoding/hex" "errors" "regexp" "strconv" "strings" "github.com/andydixon/nexus/internal/leviathan/document" ) // Rule represents a lightweight YARA-like rule. A rule matches when // Condition evaluates to true for the document. Phase 1 supports: // - "all of them" (every string pattern must appear) // - "any of them" (at least one pattern must appear) // - " of them" (at least N patterns) // - named identifiers referencing specific strings with $x or !$x type Rule struct { Name string Tags []string Strings []RuleString Condition string } // RuleString is one string directive inside a rule. type RuleString struct { ID string Kind string // "ascii", "hex", "regex" Value string // original source text // compiled: raw []byte regex *regexp.Regexp nocase bool ascii bool wide bool fullword bool } // ParseRules parses a simplified YARA-like source: // // rule Name : tag1 tag2 { // strings: // $a = "hello" nocase // $b = { 4d 5a ?? ?? } // $c = /foo[0-9]+/ // condition: // any of them // } // // Hex patterns support `??` wildcards by being converted to a regex. func ParseRules(src string) ([]Rule, error) { rules := []Rule{} s := strings.TrimSpace(src) if s == "" { return rules, nil } // Split on "rule " keywords from start of line. entries := splitRules(s) for _, e := range entries { r, err := parseRule(e) if err != nil { return rules, err } rules = append(rules, r) } return rules, nil } func splitRules(src string) []string { out := []string{} i := 0 for i < len(src) { idx := strings.Index(src[i:], "rule ") if idx < 0 { break } j := i + idx // find matching close brace (naïve but workable for phase 1) depth := 0 start := j k := j for k < len(src) { c := src[k] if c == '{' { depth++ } else if c == '}' { depth-- if depth == 0 { out = append(out, src[start:k+1]) k++ break } } k++ } i = k } return out } var ruleHeaderRe = regexp.MustCompile(`(?s)^\s*rule\s+([A-Za-z_][A-Za-z0-9_]*)(?:\s*:\s*([^{]*))?\s*\{(.*)\}\s*$`) func parseRule(src string) (Rule, error) { m := ruleHeaderRe.FindStringSubmatch(src) if m == nil { return Rule{}, errors.New("malformed rule") } r := Rule{Name: m[1]} if tags := strings.TrimSpace(m[2]); tags != "" { r.Tags = strings.Fields(tags) } body := m[3] if i := strings.Index(body, "strings:"); i >= 0 { sEnd := strings.Index(body, "condition:") sect := body[i+len("strings:") : sEnd] for _, line := range strings.Split(sect, "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "//") { continue } rs, err := parseRuleString(line) if err != nil { return r, err } r.Strings = append(r.Strings, rs) } } if i := strings.Index(body, "condition:"); i >= 0 { r.Condition = strings.TrimSpace(body[i+len("condition:"):]) } else { return r, errors.New("rule missing condition") } return r, nil } var ruleStringRe = regexp.MustCompile(`^\s*(\$[A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+?)\s*$`) func parseRuleString(line string) (RuleString, error) { m := ruleStringRe.FindStringSubmatch(line) if m == nil { return RuleString{}, errors.New("bad string line: " + line) } id := m[1] rest := strings.TrimSpace(m[2]) value, modifiers, err := splitRuleValueModifiers(rest) if err != nil { return RuleString{}, err } rs := RuleString{ID: id, Value: value} for _, mod := range modifiers { switch strings.ToLower(mod) { case "nocase": rs.nocase = true case "ascii": rs.ascii = true case "wide": rs.wide = true case "fullword": rs.fullword = true default: return rs, errors.New("unknown string modifier: " + mod) } } switch { case strings.HasPrefix(value, `"`) && strings.HasSuffix(value, `"`) && len(value) >= 2: rs.Kind = "ascii" rs.raw = []byte(value[1 : len(value)-1]) if !rs.ascii && !rs.wide { rs.ascii = true } case strings.HasPrefix(value, "{") && strings.HasSuffix(value, "}"): rs.Kind = "hex" pattern := regexFromHexPattern(value[1 : len(value)-1]) re, err := regexp.Compile("(?s)" + pattern) if err != nil { return rs, err } rs.regex = re case strings.HasPrefix(value, "/") && strings.HasSuffix(value, "/"): rs.Kind = "regex" pat := value[1 : len(value)-1] if rs.nocase { pat = "(?i)" + pat } re, err := regexp.Compile(pat) if err != nil { return rs, err } rs.regex = re default: return rs, errors.New("unknown string form: " + value) } return rs, nil } func splitRuleValueModifiers(rest string) (string, []string, error) { rest = strings.TrimSpace(rest) if rest == "" { return "", nil, errors.New("empty string value") } var end int switch rest[0] { case '"': escaped := false end = -1 for i := 1; i < len(rest); i++ { c := rest[i] if escaped { escaped = false continue } if c == '\\' { escaped = true continue } if c == '"' { end = i + 1 break } } if end < 0 { return "", nil, errors.New("unterminated string literal") } case '{': idx := strings.IndexByte(rest, '}') if idx < 0 { return "", nil, errors.New("unterminated hex string") } end = idx + 1 case '/': escaped := false end = -1 for i := 1; i < len(rest); i++ { c := rest[i] if escaped { escaped = false continue } if c == '\\' { escaped = true continue } if c == '/' { end = i + 1 break } } if end < 0 { return "", nil, errors.New("unterminated regex") } default: return "", nil, errors.New("unknown string form: " + rest) } return strings.TrimSpace(rest[:end]), strings.Fields(strings.TrimSpace(rest[end:])), nil } func regexFromHexPattern(hexPat string) string { var sb strings.Builder hexPat = strings.ReplaceAll(hexPat, " ", "") for i := 0; i < len(hexPat); { if i+1 >= len(hexPat) { break } a, b := hexPat[i], hexPat[i+1] if a == '?' && b == '?' { sb.WriteString(`[\x00-\xff]`) } else { decoded, err := hex.DecodeString(string([]byte{a, b})) if err != nil { // bail to literal to avoid panic sb.WriteString(regexp.QuoteMeta(string([]byte{a, b}))) } else { sb.WriteString(regexp.QuoteMeta(string(decoded))) } } i += 2 } return sb.String() } // RuleMatch is a per-rule result. type RuleMatch struct { Name string Tags []string Matched bool Hits map[string][]SearchHit } // RunRules scans rules against the document range and returns matches. func RunRules(d *document.Document, start, length int64, rules []Rule, maxPerString int) ([]map[string]any, error) { return RunRulesContext(context.Background(), d, start, length, rules, maxPerString) } func RunRulesContext(ctx context.Context, d *document.Document, start, length int64, rules []Rule, maxPerString int) ([]map[string]any, error) { if maxPerString <= 0 { maxPerString = 1000 } out := make([]map[string]any, 0, len(rules)) for _, r := range rules { if err := ctx.Err(); err != nil { return nil, err } hits := map[string][]SearchHit{} for _, s := range r.Strings { if err := ctx.Err(); err != nil { return nil, err } switch s.Kind { case "ascii": found, err := searchRuleTextContext(ctx, d, start, length, s, maxPerString) if err != nil { return nil, err } hits[s.ID] = found case "hex", "regex": found, err := SearchRegexContext(ctx, d, start, length, s.regex.String(), maxPerString) if err != nil { return nil, err } if s.fullword { found = filterFullWordHits(d, found, false) } hits[s.ID] = found } } matched := evalCondition(r, hits) var hitFlat []map[string]any for id, hs := range hits { for _, h := range hs { hitFlat = append(hitFlat, map[string]any{ "id": id, "offset": h.Offset, "length": h.Length, "sample": h.Sample, }) } } out = append(out, map[string]any{ "name": r.Name, "tags": toAnySlice(r.Tags), "matched": matched, "hits": hitFlat, }) } return out, nil } func searchRuleText(d *document.Document, start, length int64, s RuleString, max int) ([]SearchHit, error) { return searchRuleTextContext(context.Background(), d, start, length, s, max) } func searchRuleTextContext(ctx context.Context, d *document.Document, start, length int64, s RuleString, max int) ([]SearchHit, error) { out := []SearchHit{} if s.ascii { var ( found []SearchHit err error ) if s.nocase { found, err = SearchRegexContext(ctx, d, start, length, "(?i)"+regexp.QuoteMeta(string(s.raw)), max) } else { found, err = SearchBytesContext(ctx, d, start, length, s.raw, max) } if err != nil { return nil, err } if s.fullword { found = filterFullWordHits(d, found, false) } out = append(out, found...) } if s.wide { wide := utf16LEBytes(s.raw) var ( found []SearchHit err error ) if s.nocase { pat := wideNoCasePattern(s.raw) found, err = SearchRegexContext(ctx, d, start, length, pat, max) } else { found, err = SearchBytesContext(ctx, d, start, length, wide, max) } if err != nil { return nil, err } if s.fullword { found = filterFullWordHits(d, found, true) } out = append(out, found...) } if len(out) > max { out = out[:max] } return out, nil } func utf16LEBytes(in []byte) []byte { out := make([]byte, 0, len(in)*2) for _, b := range in { out = append(out, b, 0) } return out } func wideNoCasePattern(in []byte) string { var sb strings.Builder sb.WriteString("(?i)") for _, b := range in { sb.WriteString(regexp.QuoteMeta(string([]byte{b, 0}))) } return sb.String() } func filterFullWordHits(d *document.Document, hits []SearchHit, wide bool) []SearchHit { out := hits[:0] for _, h := range hits { if isFullWordHit(d, h, wide) { out = append(out, h) } } return out } func isFullWordHit(d *document.Document, h SearchHit, wide bool) bool { if wide { beforeOff := h.Offset - 2 afterOff := h.Offset + int64(h.Length) beforeWord := false afterWord := false if beforeOff >= 0 { if b, err := d.Range(beforeOff, 2); err == nil && len(b) == 2 && b[1] == 0 { beforeWord = isWordByte(b[0]) } } if afterOff+1 < d.Size() { if b, err := d.Range(afterOff, 2); err == nil && len(b) == 2 && b[1] == 0 { afterWord = isWordByte(b[0]) } } return !beforeWord && !afterWord } beforeWord := false afterWord := false if h.Offset > 0 { if b, err := d.At(h.Offset - 1); err == nil { beforeWord = isWordByte(b) } } if h.Offset+int64(h.Length) < d.Size() { if b, err := d.At(h.Offset + int64(h.Length)); err == nil { afterWord = isWordByte(b) } } return !beforeWord && !afterWord } func isWordByte(b byte) bool { return (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') || (b >= '0' && b <= '9') || b == '_' } func toAnySlice(in []string) []any { out := make([]any, len(in)) for i, s := range in { out[i] = s } return out } func evalCondition(r Rule, hits map[string][]SearchHit) bool { cond := strings.TrimSpace(r.Condition) condLower := strings.ToLower(cond) present := 0 for _, s := range r.Strings { if len(hits[s.ID]) > 0 { present++ } } switch { case condLower == "any of them": return present > 0 case condLower == "all of them": return present == len(r.Strings) case strings.HasSuffix(condLower, "of them"): // " of them" parts := strings.Fields(condLower) if len(parts) >= 3 { n, ok := parseRuleCount(parts[0]) return ok && present >= n } } if ok, matched := evalRuleSetCondition(cond, hits); ok { return matched } p := conditionParser{tokens: tokenizeCondition(cond), hits: hits} return p.parse() } func evalRuleSetCondition(cond string, hits map[string][]SearchHit) (bool, bool) { tokens := tokenizeCondition(cond) if len(tokens) < 4 || strings.ToLower(tokens[1]) != "of" || tokens[2] != "(" || tokens[len(tokens)-1] != ")" { return false, false } ids := []string{} for _, tok := range tokens[3 : len(tokens)-1] { if tok == "," { continue } if strings.HasPrefix(tok, "$") { ids = append(ids, tok) } } if len(ids) == 0 { return true, false } count := 0 for _, id := range ids { if len(hits[id]) > 0 { count++ } } switch strings.ToLower(tokens[0]) { case "any": return true, count > 0 case "all": return true, count == len(ids) default: n, ok := parseRuleCount(strings.ToLower(tokens[0])) return true, ok && count >= n } } func parseRuleCount(s string) (int, bool) { switch s { case "1", "one": return 1, true case "2", "two": return 2, true case "3", "three": return 3, true default: n, err := strconv.Atoi(s) return n, err == nil } } type conditionParser struct { tokens []string pos int hits map[string][]SearchHit } func (p *conditionParser) parse() bool { v := p.parseOr() return v } func (p *conditionParser) parseOr() bool { v := p.parseAnd() for strings.EqualFold(p.peek(), "or") { p.next() v = p.parseAnd() || v } return v } func (p *conditionParser) parseAnd() bool { v := p.parseNot() for strings.EqualFold(p.peek(), "and") { p.next() v = p.parseNot() && v } return v } func (p *conditionParser) parseNot() bool { if strings.EqualFold(p.peek(), "not") || p.peek() == "!" { p.next() return !p.parseNot() } return p.parsePrimary() } func (p *conditionParser) parsePrimary() bool { tok := p.next() switch { case tok == "": return false case tok == "(": v := p.parseOr() if p.peek() == ")" { p.next() } return v case strings.EqualFold(tok, "true"): return true case strings.EqualFold(tok, "false"): return false case strings.HasPrefix(tok, "$"): return len(p.hits[tok]) > 0 default: return false } } func (p *conditionParser) peek() string { if p.pos >= len(p.tokens) { return "" } return p.tokens[p.pos] } func (p *conditionParser) next() string { if p.pos >= len(p.tokens) { return "" } tok := p.tokens[p.pos] p.pos++ return tok } func tokenizeCondition(cond string) []string { out := []string{} for i := 0; i < len(cond); { c := cond[i] if c == ' ' || c == '\t' || c == '\n' || c == '\r' { i++ continue } if strings.ContainsRune("(),!", rune(c)) { out = append(out, string(c)) i++ continue } start := i for i < len(cond) { c = cond[i] if c == ' ' || c == '\t' || c == '\n' || c == '\r' || strings.ContainsRune("(),!", rune(c)) { break } i++ } out = append(out, cond[start:i]) } return out }