// apparmor.d - Full set of apparmor profiles // Copyright (C) 2021-2024 Alexandre Pujol // SPDX-License-Identifier: GPL-2.0-only package aa import ( "fmt" "regexp" "slices" "strings" ) const ( tokALLOW = "allow" tokAUDIT = "audit" tokDENY = "deny" tokARROW = "->" tokEQUAL = "=" tokLESS = "<" tokPLUS = "+" tokCLOSEBRACE = '}' tokCLOSEBRACKET = ']' tokCLOSEPAREN = ')' tokCOLON = ',' tokOPENBRACE = '{' tokOPENBRACKET = '[' tokOPENPAREN = '(' ) var ( newRuleMap = map[string]func(q Qualifier, r rule) (Rule, error){ ABI.Tok(): newAbi, ALIAS.Tok(): newAlias, ALL.Tok(): newAll, "set": newRlimit, USERNS.Tok(): newUserns, CAPABILITY.Tok(): newCapability, NETWORK.Tok(): newNetwork, MOUNT.Tok(): newMount, UMOUNT.Tok(): newUmount, REMOUNT.Tok(): newRemount, MQUEUE.Tok(): newMqueue, IOURING.Tok(): newIOUring, PIVOTROOT.Tok(): newPivotRoot, CHANGEPROFILE.Tok(): newChangeProfile, SIGNAL.Tok(): newSignal, PTRACE.Tok(): newPtrace, UNIX.Tok(): newUnix, DBUS.Tok(): newDbus, FILE.Tok(): newFile, LINK.Tok(): newLink, } tok = map[Kind]string{ COMMENT: "#", VARIABLE: "@{", HAT: "^", } openBlocks = []rune{tokOPENPAREN, tokOPENBRACE, tokOPENBRACKET} closeBlocks = []rune{tokCLOSEPAREN, tokCLOSEBRACE, tokCLOSEBRACKET} inHeader = false regParagraph = regexp.MustCompile(`(?s).*?\n\n|$`) ) // Parse the line rule from a raw string. func parseLineRules(isPreamble bool, input string) (string, Rules, error) { var res Rules var r Rule var err error for _, line := range strings.Split(input, "\n") { tmp := strings.TrimLeft(line, "\t ") switch { case strings.HasPrefix(tmp, COMMENT.Tok()): r, err = newComment(rule{kv{comment: tmp[1:]}}) if err != nil { return "", nil, err } res = append(res, r) input = strings.Replace(input, line, "", 1) case strings.HasPrefix(tmp, INCLUDE.Tok()): r, err = newInclude(parseRule(line)[1:]) if err != nil { return "", nil, err } res = append(res, r) input = strings.Replace(input, line, "", 1) case strings.HasPrefix(tmp, VARIABLE.Tok()) && isPreamble: r, err = newVariable(parseRule(line)) if err != nil { return "", nil, err } res = append(res, r) input = strings.Replace(input, line, "", 1) } } return input, res, nil } // Parse the comma rules from a raw string. It splits rules string into tokens // separated by "," but ignore comma inside comments, quotes, brakets, and parentheses. // Warning: the input string should only contain comma rules. // Return a pre-parsed rule struct representation of a profile/block. func parseCommaRules(input string) ([]rule, error) { rules := []rule{} blockStart := 0 blockCounter := 0 comment := false aare := false canHaveInlineComment := false size := len(input) for idx, r := range input { switch r { case tokOPENBRACE, tokOPENBRACKET, tokOPENPAREN: if !comment { blockCounter++ } case tokCLOSEBRACE, tokCLOSEBRACKET, tokCLOSEPAREN: if !comment { blockCounter-- } case '#': if !comment && canHaveInlineComment { comment = true blockStart = idx + 1 } case '\n': if comment { comment = !comment if canHaveInlineComment { commentRaw := input[blockStart:idx] // Inline comments belong to the previous rule (in the same line) lastRule := rules[len(rules)-1] lastRule[len(lastRule)-1].comment = commentRaw // Ignore the collected comment for the next rule blockStart = idx } } canHaveInlineComment = false case tokCOLON: if blockCounter == 0 && !comment { if idx+1 < size && !strings.ContainsRune(" \n", rune(input[idx+1])) { // Colon in AARE, it is valid, not a separator aare = true } if !aare { ruleRaw := input[blockStart:idx] ruleRaw = strings.Trim(ruleRaw, "\n ") rules = append(rules, parseRule(ruleRaw)) blockStart = idx + 1 canHaveInlineComment = true } aare = false } } } return rules, nil } func parseParagraph(input string) (Rules, error) { // Line rules var raw string raw, res, err := parseLineRules(false, input) if err != nil { return nil, err } // Comma rules rules, err := parseCommaRules(raw) if err != nil { return nil, err } rrr, err := newRules(rules) if err != nil { return nil, err } res = append(res, rrr...) for _, r := range res { if r.Constraint() == PreambleRule { return nil, fmt.Errorf("Rule not allowed in block: %s", r) } } return res, nil } // Split a raw input rule string into tokens by space or =, but ignore spaces // within quotes, brakets, or parentheses. // // Example: // // `owner @{user_config_dirs}/powerdevilrc{,.@{rand6}} rwl -> @{user_config_dirs}/#@{int}` // // Returns: // // []string{"owner", "@{user_config_dirs}/powerdevilrc{,.@{rand6}}", "rwl", "->", "@{user_config_dirs}/#@{int}"} func tokenizeRule(str string) []string { var currentToken strings.Builder isVariable, wasTokPLUS, quoted := false, false, false blockStack := []rune{} tokens := make([]string, 0, len(str)/2) if inHeader && len(str) > 2 && str[0:2] == VARIABLE.Tok() { isVariable = true } for _, r := range str { switch { case (r == ' ' || r == '\t') && len(blockStack) == 0 && !quoted: // Split on space/tab if not in a block or quoted if currentToken.Len() != 0 { tokens = append(tokens, currentToken.String()) currentToken.Reset() } case (r == '+' || r == '=') && len(blockStack) == 0 && !quoted && isVariable: // Handle variable assignment if currentToken.Len() != 0 { tokens = append(tokens, currentToken.String()) currentToken.Reset() } if wasTokPLUS { tokens[len(tokens)-1] = tokPLUS + tokEQUAL } else { tokens = append(tokens, string(r)) } wasTokPLUS = (r == '+') case r == '"' && len(blockStack) == 0: quoted = !quoted currentToken.WriteRune(r) case slices.Contains(openBlocks, r): blockStack = append(blockStack, r) currentToken.WriteRune(r) case slices.Contains(closeBlocks, r): if len(blockStack) > 0 { blockStack = blockStack[:len(blockStack)-1] } else { panic(fmt.Sprintf("Unbalanced block, missing '{' or '}' on: %s\n", str)) } currentToken.WriteRune(r) default: currentToken.WriteRune(r) } } if currentToken.Len() != 0 { tokens = append(tokens, currentToken.String()) } return tokens } // Parse a string into a rule struct. // The input string must be a tokenised raw line rule (using parseCommaRules or // parseBlock). // Example: // // `unix (send receive) type=stream addr="@/tmp/.ICE[0-9]-unix/19 5" peer=(label=gnome-shell, addr=none)` // // Returns: // // rule{ // {key: "unix"}, {key: "send"}, {key: "receive"}, // {key: "type", values: rule{{key: "stream"}}}, // {key: "addr", values: rule{ // {key: `"@/tmp/.ICE[0-9]*-unix/19 5"`}, // }}, // {key: "peer", values: rule{ // {key: "label", values: rule{{key: `"@{p_systemd}"`}}}, // {key: "addr", values: rule{{key: "none"}}}, // }}, // }, func parseRule(str string) rule { res := make(rule, 0, len(str)/2) tokens := tokenizeRule(str) inAare := false if len(tokens) > 0 && (isAARE(tokens[0]) || tokens[0] == tokOWNER) { inAare = true } for idx, token := range tokens { switch { case token == tokEQUAL, token == tokPLUS+tokEQUAL, token == tokLESS+tokEQUAL: // Variable & Rlimit res = append(res, kv{key: token}) case strings.Contains(token, "=") && !inAare: // Map items := strings.SplitN(token, "=", 2) key := items[0] if len(items) > 1 { values := strings.Trim(items[1], ",") if strings.Contains(values, "=") || !strings.ContainsAny(values, ", ") { values = strings.Trim(values, "()\n") } res = append(res, kv{key: key, values: parseRule(values)}) } else { res = append(res, kv{key: key}) } case strings.Contains(token, "(") && !inAare: // List token = strings.Trim(token, "()\n") var sep string switch { case strings.Contains(token, ","): sep = "," case strings.Contains(token, " "): sep = " " } var values rule if sep == "" { values = append(values, kv{key: token}) } else { for _, v := range strings.Split(token, sep) { values = append(values, kv{ key: strings.Trim(v, " "), }) } } res = append(res, values...) case strings.HasPrefix(token, COMMENT.Tok()): // Comment if idx > 0 && idx < len(tokens)-1 { res[len(res)-1].comment = " " + strings.Join(tokens[idx+1:], " ") return res } default: // Single value token = strings.Trim(token, "\n") res = append(res, kv{key: token}) } } return res } // Intermediate token for the representation of a rule. All comma and line // rules are parsed into this structure. Then, they are converted into the actual // rule struct using basic constructor functions. type rule []kv type kv struct { key string values rule comment string } // Get return the value of a key from a rule. // // Example: // // `include ` // // Gives: // // Get(0): "include" // Get(1): "" func (r rule) Get(idx int) string { return r[idx].key } // GetString return string representation of a rule. // // Example: // // `profile foo @{exec_path} flags=(complain attach_disconnected)` // // Gives: // // "profile foo @{exec_path}" func (r rule) GetString() string { return strings.Join(r.GetSlice(), " ") } // GetSlice return a slice of all non map value of a rule. // // Example: // // `profile foo @{exec_path} flags=(complain attach_disconnected)` // // Gives: // // []string{"profile", "foo", "@{exec_path}"} func (r rule) GetSlice() []string { res := []string{} for _, kv := range r { if kv.values == nil { res = append(res, kv.key) } } return res } // GetAsMap return a map of slice of all map value of a rule. // // Example: // // `profile foo @{exec_path} flags=(complain attach_disconnected)` // // Gives: // // map[string]string{"flags": {"complain", "attach_disconnected"}} func (r rule) GetAsMap() map[string][]string { res := map[string][]string{} for _, kv := range r { if kv.values != nil { res[kv.key] = kv.values.GetSlice() } } return res } // GetValues return the values from a key. // // Example: // // `dbus receive peer=(name=:1.3, label=power-profiles-daemon)` // // Gives: // // GetValues("peer"): // rule{ // {key: "name", values: rule{{Key: ":1.3"}}}, // {key: "label", values: rule{{Key: "power-profiles-daemon"}}}, // }}, func (r rule) GetValues(key string) rule { for _, kv := range r { if kv.key == key { return kv.values } } return nil } // GetValuesAsSlice return the values from a key as a slice. // // Example: // // `mount options=(rw silent rprivate) -> /oldroot/` // // Gives: // // GetValuesAsSlice("options"): // []string{"rw", "silent", "rprivate"} func (r rule) GetValuesAsSlice(key string) []string { return r.GetValues(key).GetSlice() } // GetValuesAsString return the values from a key as a string. // // Example: // // `signal (receive) set=(term) peer=at-spi-bus-launcher` // // Gives: // // GetValuesAsString("peer"): "at-spi-bus-launcher" func (r rule) GetValuesAsString(key string) string { return r.GetValues(key).GetString() } // String return a generic representation of a rule. func (r rule) String() string { var res strings.Builder for _, kv := range r { if kv.values == nil { if res.Len() > 0 { res.WriteString(" ") } res.WriteString(kv.key) } else { res.WriteString(" " + kv.key) v := strings.TrimLeft(kv.values.String(), " ") if strings.Contains(v, " ") || strings.Contains(v, "=") { res.WriteString("=(" + v + ")") } else { res.WriteString("=" + v) } } if kv.comment != "" { res.WriteString(COMMENT.Tok() + " " + kv.comment) } } return res.String() } func isAARE(str string) bool { return strings.HasPrefix(str, "@") || strings.HasPrefix(str, "/") || strings.HasPrefix(str, "\"") } // Convert a slice of internal rules to a slice of ApparmorRule. func newRules(rules []rule) (Rules, error) { var err error var r Rule res := make(Rules, 0, len(rules)) for _, rule := range rules { if len(rule) == 0 { return nil, fmt.Errorf("Empty rule") } owner := false q := Qualifier{} qualifier: switch rule.Get(0) { // File & Link prefix case tokOWNER: owner = true rule = rule[1:] goto qualifier // Qualifier case tokALLOW, tokDENY: q.AccessType = rule.Get(0) rule = rule[1:] goto qualifier case tokAUDIT: q.Audit = true rule = rule[1:] goto qualifier default: // Line rules if newRule, ok := newRuleMap[rule.Get(0)]; ok { r, err = newRule(q, rule[1:]) if err != nil { return nil, err } if owner && r.Kind() == LINK { r.(*Link).Owner = owner } res = append(res, r) } else { raw := rule.Get(0) if raw != "" { // File if isAARE(raw) || owner { r, err = newFile(q, rule) if err != nil { return nil, err } r.(*File).Owner = owner res = append(res, r) } else { fmt.Printf("Unknown rule: %s", rule) // return nil, fmt.Errorf("Unknown rule: %s", rule) } } else { return nil, fmt.Errorf("Unrecognized rule: %s", rule) } } } } return res, nil } func (f *AppArmorProfileFile) parsePreamble(preamble string) error { var err error inHeader = true // Line rules preamble, lineRules, err := parseLineRules(true, preamble) if err != nil { return err } f.Preamble = append(f.Preamble, lineRules...) // Comma rules r, err := parseCommaRules(preamble) if err != nil { return err } commaRules, err := newRules(r) if err != nil { return err } f.Preamble = append(f.Preamble, commaRules...) for _, r := range f.Preamble { if r.Constraint() == BlockRule { f.Preamble = nil return fmt.Errorf("Rule not allowed in preamble: %s", r) } } inHeader = false return err } // Parse an apparmor profile file. // // Warning: It is purposelly an uncomplete basic parser for apparmor profile, // it is only aimed for internal tooling purpose. For "simplicity", it is not // using antlr / participle. It is only used for experimental feature in the // apparmor.d project. // // Very basic: // - Only supports parsing of preamble and profile headers. // - Stop at the first profile header. // - Does not support multiline coma rules. // - Does not support multiple profiles by file. // // Current use case: // - Parse include and tunables // - Parse variable in profile preamble and in tunable files // - Parse (sub) profiles header to edit flags func (f *AppArmorProfileFile) Parse(input string) (int, error) { var raw strings.Builder rawHeader := "" nb := 0 done: for i, line := range strings.Split(input, "\n") { tmp := strings.TrimLeft(line, "\t ") switch { case tmp == "": continue case strings.HasPrefix(tmp, PROFILE.Tok()): rawHeader = strings.TrimRight(tmp, "{") nb = i break done case strings.HasPrefix(tmp, HAT.String()), strings.HasPrefix(tmp, HAT.Tok()): nb = i break done default: raw.WriteString(tmp + "\n") } } if err := f.parsePreamble(raw.String()); err != nil { return nb, err } if rawHeader != "" { header, err := newHeader(parseRule(rawHeader)) if err != nil { return nb, err } profile := &Profile{Header: header} f.Profiles = append(f.Profiles, profile) } return nb, nil } // Parse apparmor profile rules by paragraphs func ParseRules(input string) ([]Rules, []string, error) { paragraphRules := []Rules{} paragraphs := []string{} for _, match := range regParagraph.FindAllStringSubmatch(input, -1) { if len(match[0]) == 0 { continue } // Ignore blocks header tmp := strings.TrimLeft(match[0], "\t ") tmp = strings.TrimRight(tmp, "\n") var paragraph string switch { case strings.HasPrefix(tmp, PROFILE.Tok()): _, paragraph, _ = strings.Cut(match[0], "\n") case strings.HasPrefix(tmp, HAT.String()), strings.HasPrefix(tmp, HAT.Tok()): _, paragraph, _ = strings.Cut(match[0], "\n") case strings.HasSuffix(tmp, "}"): paragraph = strings.Replace(match[0], "}\n", "\n", 1) default: paragraph = match[0] } paragraphs = append(paragraphs, paragraph) rules, err := parseParagraph(paragraph) if err != nil { return nil, nil, err } paragraphRules = append(paragraphRules, rules) } return paragraphRules, paragraphs, nil }