mirror of
https://github.com/roddhjav/apparmor.d.git
synced 2024-12-27 07:27:24 +01:00
694 lines
16 KiB
Go
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
|
|
}
|