blob: 536bcba5b2e9249f17db68b1f3fd2bddacb7bfc1 [file] [edit]
// Copyright 2021 Google LLC
//
// 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 repeatconfig provides a utility for applying the RepeatConfig proto to config checks.
package repeatconfig
import (
"bufio"
"context"
"fmt"
"path"
"regexp"
"strconv"
"strings"
"google.golang.org/protobuf/proto"
"github.com/google/localtoast/scanapi"
ipb "github.com/google/localtoast/scannerlib/proto/scan_instructions_go_proto"
)
const (
usernameWildcard = "$user"
uidWildcard = "$uid"
gidWildcard = "$gid"
homeDirWildcard = "$home"
portWildcard = "$port"
defaultUIDMin = 1000
)
var (
// Regexp for capturing the local ip:port and TCP state from the
// /proc/net/tcp(6) file. See https://regex101.com/r/MFk3qd/1 for how to
// read this regex.
procTCPRe = regexp.MustCompile("^\\s*[0-9]+:\\s+([0-9A-F]+):([0-9A-F]+)\\s+[0-9A-F]+:[0-9A-F]+\\s+([0-9A-F]+)\\s+.*$")
// Regexp for capturing the UID_MIN value from the /etc/login.defs file.
uidMinRe = regexp.MustCompile("^UID_MIN\\s+(\\d+)$")
// Regexp for capturing the SYS_UID_MIN value from the /etc/login.defs file.
sysUIDMinRe = regexp.MustCompile("^SYS_UID_MIN\\s+(\\d+)$")
// Regexp for capturing the SYS_UID_MAX value from the /etc/login.defs file.
sysUIDMaxRe = regexp.MustCompile("^SYS_UID_MAX\\s+(\\d+)$")
)
// RepeatConfig is a single repeat config that specifies what tokens to replace
// in the files/instructions in this iteration of the check.
type RepeatConfig struct {
TokenReplacements []*TokenReplacement
Err error
}
// TokenReplacement describes a single token to replace.
type TokenReplacement struct {
TextToReplace string
ReplaceWith string
}
// CreateRepeatConfigs creates a list of configs with the appropriate token
// substitutions based on the supplied repeat config enum.
func CreateRepeatConfigs(ctx context.Context, repeatOptions *ipb.RepeatConfig, fs scanapi.Filesystem) ([]*RepeatConfig, error) {
var rc []*RepeatConfig
var err error
switch repeatOptions.GetType() {
case ipb.RepeatConfig_ONCE:
rc, err = []*RepeatConfig{&RepeatConfig{}}, nil
case ipb.RepeatConfig_FOR_EACH_USER:
rc, err = createRepeatConfigForEachUser(ctx, userRepeatConfigOptions{
fileReader: fs, loginOnly: false, systemOnly: false,
})
case ipb.RepeatConfig_FOR_EACH_USER_WITH_LOGIN:
rc, err = createRepeatConfigForEachUser(ctx, userRepeatConfigOptions{
fileReader: fs, loginOnly: true, systemOnly: false,
})
case ipb.RepeatConfig_FOR_EACH_SYSTEM_USER_WITH_LOGIN:
rc, err = createRepeatConfigForEachUser(ctx, userRepeatConfigOptions{
fileReader: fs, loginOnly: true, systemOnly: true,
})
case ipb.RepeatConfig_FOR_EACH_OPEN_IPV4_PORT:
rc, err = createRepeatConfigForEachOpenTCPPort(ctx, fs, false)
case ipb.RepeatConfig_FOR_EACH_OPEN_IPV6_PORT:
rc, err = createRepeatConfigForEachOpenTCPPort(ctx, fs, true)
default:
return nil, fmt.Errorf("unknown repeat option type %s", repeatOptions)
}
if err != nil {
return repeatConfigWithError(err), nil
}
return applyOptOutConfig(rc, repeatOptions.GetOptOut()), nil
}
func repeatConfigWithError(err error) []*RepeatConfig {
return []*RepeatConfig{{Err: fmt.Errorf("error creating RepeatConfig: %v", err)}}
}
type userRepeatConfigOptions struct {
fileReader scanapi.Filesystem
loginOnly bool
systemOnly bool
}
// createRepeatConfigForEachUser finds all users in /etc/passwd and creates
// repeat configs that have the usernames as the substitution. if systemOnly is
// true, only the system users are included in the config.
func createRepeatConfigForEachUser(ctx context.Context, opt userRepeatConfigOptions) ([]*RepeatConfig, error) {
uidMin, sysUIDMin, sysUIDMax := -1, -1, -1
if opt.systemOnly {
var err error
uidMin, sysUIDMin, sysUIDMax, err = readUID(ctx, opt.fileReader)
if err != nil {
return nil, err
}
}
r, err := opt.fileReader.OpenFile(ctx, "/etc/passwd")
if err != nil {
return nil, err
}
defer r.Close()
scanner := bufio.NewScanner(r)
scanner.Split(bufio.ScanLines)
result := []*RepeatConfig{}
i := 0
for scanner.Scan() {
i++
if scanner.Err() != nil {
return nil, scanner.Err()
}
line := scanner.Text()
tokens := strings.Split(line, ":")
if len(tokens) < 7 {
return nil, fmt.Errorf("can't parse line %d in /etc/passwd: expected at least 7 tokens, got %d", i, len(tokens))
}
user := tokens[0]
uid := tokens[2]
gid := tokens[3]
homeDir := tokens[5]
shell := tokens[6]
if opt.systemOnly {
uidInt, err := strconv.Atoi(uid)
if err != nil {
return nil, err
}
if sysUIDMin == -1 || sysUIDMax == -1 {
if uidInt >= uidMin { // Non-system users' uid starts from UID_MIN.
continue
}
} else if uidInt < sysUIDMin || uidInt > sysUIDMax {
continue
}
}
// Ignore users with no shell.
if opt.loginOnly && (shell == "/bin/false" || path.Base(shell) == "nologin") {
continue
}
result = append(result, &RepeatConfig{
TokenReplacements: []*TokenReplacement{
{
TextToReplace: usernameWildcard,
ReplaceWith: user,
},
{
TextToReplace: uidWildcard,
ReplaceWith: uid,
},
{
TextToReplace: gidWildcard,
ReplaceWith: gid,
},
{
TextToReplace: homeDirWildcard,
ReplaceWith: homeDir,
},
},
})
}
return result, nil
}
func readUID(ctx context.Context, f scanapi.Filesystem) (uidMin, sysUIDMin, sysUIDMax int, err error) {
r, err := f.OpenFile(ctx, "/etc/login.defs")
if err != nil {
return 0, 0, 0, err
}
defer r.Close()
uidMin = defaultUIDMin
sysUIDMin = -1
sysUIDMax = -1
scanner := bufio.NewScanner(r)
scanner.Split(bufio.ScanLines)
for scanner.Scan() {
if scanner.Err() != nil {
return 0, 0, 0, scanner.Err()
}
line := scanner.Text()
// Look for UID_MIN value
groups := uidMinRe.FindStringSubmatch(line)
if groups != nil {
uidMin, err = strconv.Atoi(groups[1])
if err != nil {
return 0, 0, 0, err
}
}
// Look for SYS_UID_MIN value
groups = sysUIDMinRe.FindStringSubmatch(line)
if groups != nil {
sysUIDMin, err = strconv.Atoi(groups[1])
if err != nil {
return 0, 0, 0, err
}
}
// Look for SYS_UID_MAX value
groups = sysUIDMaxRe.FindStringSubmatch(line)
if groups != nil {
sysUIDMax, err = strconv.Atoi(groups[1])
if err != nil {
return 0, 0, 0, err
}
}
}
return uidMin, sysUIDMin, sysUIDMax, nil
}
// createRepeatConfigForEachPort creates repeat configs that have the currently
// open TCP ports as the substitution.
func createRepeatConfigForEachOpenTCPPort(ctx context.Context, f scanapi.Filesystem, isIpv6 bool) ([]*RepeatConfig, error) {
tcpFile := "/proc/net/tcp"
if isIpv6 {
tcpFile = "/proc/net/tcp6"
}
r, err := f.OpenFile(ctx, tcpFile)
if err != nil {
return nil, err
}
defer r.Close()
scanner := bufio.NewScanner(r)
scanner.Split(bufio.ScanLines)
scanner.Scan() // Skip the header line.
ports := make(map[int]bool)
for scanner.Scan() {
if scanner.Err() != nil {
return nil, scanner.Err()
}
line := scanner.Text()
if !procTCPRe.MatchString(line) {
return nil, fmt.Errorf("unable to parse %s", tcpFile)
}
groups := procTCPRe.FindStringSubmatch(line)
address := groups[1]
port, err := strconv.ParseInt(groups[2], 16, 32)
if err != nil {
return nil, err
}
state, err := strconv.ParseInt(groups[3], 16, 32)
if err != nil {
return nil, err
}
// We only care about ports in state LISTENING (0xa).
if state != 0xa {
continue
}
// Skip loopback addresses.
// 127.0.0.0/8 for IPv4. The address consists of one little-endian word.
if !isIpv6 && strings.HasSuffix(address, "7F") {
continue
}
// ::1 for IPv6. The address consists of 4 little-endian words.
if isIpv6 && address == "00000000000000000000000001000000" {
continue
}
ports[int(port)] = true
}
result := []*RepeatConfig{}
for port, _ := range ports {
result = append(result, &RepeatConfig{
TokenReplacements: []*TokenReplacement{
&TokenReplacement{
TextToReplace: portWildcard,
ReplaceWith: strconv.Itoa(port),
},
},
})
}
return result, nil
}
// ApplyRepeatConfigToInstruction applies the substitutions in the given repeat config to the
// given instruction. The returned instruction proto is a copy of the original.
func ApplyRepeatConfigToInstruction(instruction *ipb.FileCheck, config *RepeatConfig) *ipb.FileCheck {
if len(config.TokenReplacements) == 0 {
return instruction
}
result := proto.Clone(instruction).(*ipb.FileCheck)
for _, r := range config.TokenReplacements {
switch {
case instruction.GetPermission() != nil:
applyRepeatConfigToPermissionCheck(result.GetPermission(), r)
case instruction.GetContent() != nil:
applyRepeatConfigToContentCheck(result.GetContent(), r)
case instruction.GetContentEntry() != nil:
applyRepeatConfigToContentEntryCheck(result.GetContentEntry(), r)
}
}
return result
}
// ApplyRepeatConfigToFile applies the substitutions in the given repeat config
// to the given FileSet. The returned FileSet proto is a copy of the original.
func ApplyRepeatConfigToFile(fileSet *ipb.FileSet, config *RepeatConfig) *ipb.FileSet {
if len(config.TokenReplacements) == 0 {
return fileSet
}
result := proto.Clone(fileSet).(*ipb.FileSet)
for _, r := range config.TokenReplacements {
switch {
case fileSet.GetSingleFile() != nil:
result.GetSingleFile().Path = applyReplacement(result.GetSingleFile().GetPath(), r)
case fileSet.GetFilesInDir() != nil:
f := result.GetFilesInDir()
f.DirPath = applyReplacement(f.GetDirPath(), r)
f.FilenameRegex = applyReplacement(f.GetFilenameRegex(), r)
optOut := f.GetOptOutPathRegexes()
for i, o := range optOut {
optOut[i] = applyReplacement(o, r)
}
f.OptOutPathRegexes = optOut
}
}
return result
}
func applyRepeatConfigToPermissionCheck(check *ipb.PermissionCheck, replacement *TokenReplacement) {
if check.GetUser() != nil {
check.GetUser().Name = applyReplacement(check.GetUser().GetName(), replacement)
}
if check.GetGroup() != nil {
check.GetGroup().Name = applyReplacement(check.GetGroup().GetName(), replacement)
}
}
func applyRepeatConfigToContentCheck(check *ipb.ContentCheck, replacement *TokenReplacement) {
check.Content = applyReplacement(check.GetContent(), replacement)
}
func applyRepeatConfigToContentEntryCheck(check *ipb.ContentEntryCheck, replacement *TokenReplacement) {
for _, mc := range check.GetMatchCriteria() {
mc.FilterRegex = applyReplacement(mc.GetFilterRegex(), replacement)
mc.ExpectedRegex = applyReplacement(mc.GetExpectedRegex(), replacement)
}
}
func applyReplacement(str string, replacement *TokenReplacement) string {
return strings.ReplaceAll(str, replacement.TextToReplace, replacement.ReplaceWith)
}
func applyOptOutConfig(rcs []*RepeatConfig, optOut []*ipb.RepeatConfig_OptOutSubstitution) []*RepeatConfig {
filteredRcs := make([]*RepeatConfig, 0, len(rcs))
for _, rc := range rcs {
filter := false
for _, r := range rc.TokenReplacements {
for _, o := range optOut {
if o.GetWildcard() == r.TextToReplace && o.GetValue() == r.ReplaceWith {
filter = true
break
}
}
}
if !filter {
filteredRcs = append(filteredRcs, rc)
}
}
return filteredRcs
}