apparmor.d/pkg/aa/parse.go
2024-09-27 12:20:48 +01:00

694 lines
16 KiB
Go

// apparmor.d - Full set of apparmor profiles
// Copyright (C) 2021-2024 Alexandre Pujol <alexandre@pujol.io>
// 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 <tunables/global>`
//
// Gives:
//
// Get(0): "include"
// Get(1): "<tunables/global>"
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 {
if len(str) < 1 {
return false
}
switch str[0] {
case '@', '/', '"':
return true
default:
return false
}
}
// 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 purposely 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) (ParaRules, []string, error) {
paragraphRules := ParaRules{}
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
}