mirror of
https://github.com/OliveTin/OliveTin
synced 2026-08-26 12:36:30 +00:00
664 lines
18 KiB
Go
664 lines
18 KiB
Go
package executor
|
|
|
|
import (
|
|
config "github.com/OliveTin/OliveTin/internal/config"
|
|
"github.com/OliveTin/OliveTin/internal/entities"
|
|
"github.com/OliveTin/OliveTin/internal/tpl"
|
|
log "github.com/sirupsen/logrus"
|
|
|
|
"fmt"
|
|
"net/mail"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
var (
|
|
typecheckRegex = map[string]string{
|
|
"very_dangerous_raw_string": "",
|
|
"int": `^\d+$`,
|
|
"unicode_identifier": `^[\w\-\.\_\d]+$`,
|
|
"ascii": `^[a-zA-Z0-9]+$`,
|
|
"ascii_identifier": `^[a-zA-Z0-9\-\._]+$`,
|
|
"shell_safe_identifier": `^[a-zA-Z0-9@\.\_\+\-]+$`,
|
|
"ascii_sentence": `^[a-zA-Z0-9\-\._, ]+$`,
|
|
}
|
|
|
|
dnsNameLabelPattern = regexp.MustCompile(`^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$`)
|
|
dnsNameAllNumericPattern = regexp.MustCompile(`^[0-9]+$`)
|
|
)
|
|
|
|
// parseExecArray parses all exec arguments in the action.
|
|
func parseExecArray(action *config.Action, values map[string]string, entity *entities.Entity) ([]string, error) {
|
|
parsed := make([]string, len(action.Exec))
|
|
|
|
for i, segment := range action.Exec {
|
|
out, err := parseExecSegment(segment, values, entity)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
parsed[i] = out
|
|
}
|
|
return parsed, nil
|
|
}
|
|
|
|
func parseActionExec(values map[string]string, action *config.Action, entity *entities.Entity) ([]string, error) {
|
|
if action == nil {
|
|
return nil, fmt.Errorf("action is nil")
|
|
}
|
|
if err := validateArguments(values, action); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
parsed, err := parseExecArray(action, values, entity)
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
logParsedExec(action, parsed, values)
|
|
return parsed, nil
|
|
}
|
|
|
|
func parseExecSegment(arg string, values map[string]string, entity *entities.Entity) (string, error) {
|
|
return tpl.ParseTemplateWithActionContext(arg, entity, values)
|
|
}
|
|
|
|
func validateArguments(values map[string]string, action *config.Action) error {
|
|
for _, arg := range action.Arguments {
|
|
if err := typecheckActionArgument(&arg, values[arg.Name]); err != nil {
|
|
return err
|
|
}
|
|
log.WithFields(log.Fields{"name": arg.Name, "value": values[arg.Name]}).Debugf("Arg assigned")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func logParsedExec(action *config.Action, parsed []string, values map[string]string) {
|
|
redacted := redactExecArgs(parsed, action.Arguments, values)
|
|
log.WithFields(log.Fields{"actionTitle": action.Title, "cmd": redacted}).Infof("Action parse args - After (Exec)")
|
|
}
|
|
|
|
func parseActionArguments(req *ExecutionRequest) (string, error) {
|
|
log.WithFields(log.Fields{
|
|
"actionTitle": req.Binding.Action.Title,
|
|
"cmd": req.Binding.Action.Shell,
|
|
}).Infof("Action parse args - Before")
|
|
|
|
for _, arg := range req.Binding.Action.Arguments {
|
|
argName := arg.Name
|
|
argValue := req.Arguments[argName]
|
|
|
|
err := typecheckActionArgument(&arg, argValue)
|
|
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
log.WithFields(log.Fields{
|
|
"name": argName,
|
|
"value": argValue,
|
|
}).Debugf("Arg assigned")
|
|
}
|
|
|
|
parsedShellCommand, err := tpl.ParseTemplateWithActionContext(req.Binding.Action.Shell, req.Binding.Entity, req.Arguments)
|
|
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
redactedShellCommand := redactShellCommand(parsedShellCommand, req.Binding.Action.Arguments, req.Arguments)
|
|
|
|
log.WithFields(log.Fields{
|
|
"actionTitle": req.Binding.Action.Title,
|
|
"cmd": redactedShellCommand,
|
|
}).Infof("Action parse args - After")
|
|
|
|
return parsedShellCommand, nil
|
|
}
|
|
|
|
//gocyclo:ignore
|
|
func redactShellCommand(shellCommand string, arguments []config.ActionArgument, argumentValues map[string]string) string {
|
|
for _, arg := range arguments {
|
|
if arg.Type == "password" {
|
|
argValue, exists := argumentValues[arg.Name]
|
|
|
|
if !exists {
|
|
log.Warnf("Redact shell command: Argument %s not found in values", arg.Name)
|
|
continue
|
|
}
|
|
|
|
if argValue == "" {
|
|
continue
|
|
}
|
|
|
|
shellCommand = strings.ReplaceAll(shellCommand, argValue, "<redacted>")
|
|
}
|
|
}
|
|
|
|
return shellCommand
|
|
}
|
|
|
|
//gocyclo:ignore
|
|
func redactExecArgs(execArgs []string, arguments []config.ActionArgument, argumentValues map[string]string) []string {
|
|
redacted := make([]string, len(execArgs))
|
|
for i, arg := range execArgs {
|
|
redacted[i] = redactShellCommand(arg, arguments, argumentValues)
|
|
}
|
|
return redacted
|
|
}
|
|
|
|
func argumentSkipsValidation(arg *config.ActionArgument) bool {
|
|
return arg.Type == "html"
|
|
}
|
|
|
|
func typecheckActionArgument(arg *config.ActionArgument, value string) error {
|
|
if argumentSkipsValidation(arg) {
|
|
return nil
|
|
}
|
|
|
|
if arg.Type == "confirmation" {
|
|
return typecheckConfirmation(arg, value)
|
|
}
|
|
|
|
if arg.Name == "" {
|
|
return fmt.Errorf("argument name cannot be empty")
|
|
}
|
|
|
|
return typecheckActionArgumentFound(value, arg)
|
|
}
|
|
|
|
// typecheckConfirmation allows unnamed confirmation args as UI-only gates.
|
|
// Named confirmation values are only ever "0" or "1", matching the web UI.
|
|
func typecheckConfirmation(arg *config.ActionArgument, value string) error {
|
|
if arg.Name == "" {
|
|
return nil
|
|
}
|
|
|
|
if value == "0" || value == "1" {
|
|
return nil
|
|
}
|
|
|
|
return fmt.Errorf("argument %q of type confirmation must be \"0\" or \"1\"", arg.Name)
|
|
}
|
|
|
|
// ValidateArgument validates a single argument value using the same logic as the executor.
|
|
// It applies mangling transformations and performs full validation including null checks,
|
|
// choice validation, and type safety checks.
|
|
func ValidateArgument(arg *config.ActionArgument, value string, action *config.Action) error {
|
|
if arg == nil {
|
|
return fmt.Errorf("ValidateArgument: arg is nil")
|
|
}
|
|
|
|
if action == nil {
|
|
return fmt.Errorf("ValidateArgument: action is nil")
|
|
}
|
|
|
|
// Apply mangling transformations
|
|
mangledValue := MangleArgumentValue(arg, value, action.Title)
|
|
|
|
// Use the same validation path as the executor
|
|
return typecheckActionArgument(arg, mangledValue)
|
|
}
|
|
|
|
func typecheckActionArgumentFound(value string, arg *config.ActionArgument) error {
|
|
if value == "" {
|
|
return typecheckNull(arg)
|
|
}
|
|
|
|
if arg.Type == "checklist" {
|
|
return typecheckChecklist(value, arg)
|
|
}
|
|
|
|
if len(arg.Choices) > 0 {
|
|
return typecheckChoice(value, arg)
|
|
}
|
|
|
|
return TypeSafetyCheck(arg.Name, value, arg.Type)
|
|
}
|
|
|
|
// TypeSafetyCheck checks argument values match a specific type. The types are
|
|
// defined in typecheckRegex, and, you guessed it, uses regex to check for allowed
|
|
// characters.
|
|
//
|
|
//gocyclo:ignore
|
|
func TypeSafetyCheck(name string, value string, argumentType string) error {
|
|
switch argumentType {
|
|
case "password":
|
|
return nil
|
|
case "raw_string_multiline":
|
|
return nil
|
|
case "checkbox":
|
|
return nil
|
|
case "checklist":
|
|
return nil
|
|
case "email":
|
|
return typeSafetyCheckEmail(value)
|
|
case "url":
|
|
return typeSafetyCheckUrl(value)
|
|
case "datetime":
|
|
return typeSafetyCheckDatetime(value)
|
|
case "dnsname":
|
|
return typeSafetyCheckDnsName(value)
|
|
}
|
|
|
|
return typeSafetyCheckRegex(name, value, argumentType)
|
|
}
|
|
|
|
func typecheckNull(arg *config.ActionArgument) error {
|
|
if arg.RejectNull {
|
|
return fmt.Errorf("null values are not allowed")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func typecheckChecklist(value string, arg *config.ActionArgument) error {
|
|
if len(arg.Choices) == 0 {
|
|
return fmt.Errorf("checklist argument %q requires choices", arg.Name)
|
|
}
|
|
|
|
segments, err := config.ParseChecklistValue(value)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return typecheckChecklistSegments(segments, arg)
|
|
}
|
|
|
|
func typecheckChecklistSegments(segments []string, arg *config.ActionArgument) error {
|
|
for _, segment := range segments {
|
|
if err := typecheckChecklistSegment(segment, arg); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func typecheckChecklistSegment(segment string, arg *config.ActionArgument) error {
|
|
if segment == "" {
|
|
return fmt.Errorf("checklist argument %q contains an empty segment", arg.Name)
|
|
}
|
|
|
|
return typecheckChoice(segment, arg)
|
|
}
|
|
|
|
func typecheckChoice(value string, arg *config.ActionArgument) error {
|
|
if arg.Entity != "" {
|
|
return typecheckChoiceEntity(value, arg)
|
|
}
|
|
|
|
for _, choice := range arg.Choices {
|
|
if value == choice.Value {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
return fmt.Errorf("argument value is not one of the predefined choices")
|
|
}
|
|
|
|
func typecheckChoiceEntity(value string, arg *config.ActionArgument) error {
|
|
if len(arg.Choices) != 1 {
|
|
return fmt.Errorf("entity-backed argument must define exactly one choice template")
|
|
}
|
|
|
|
templateChoice := arg.Choices[0].Value
|
|
|
|
for _, ent := range entities.GetEntityInstances(arg.Entity) {
|
|
choice := tpl.ParseTemplateOfActionBeforeExec(templateChoice, ent)
|
|
|
|
if value == choice {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
return fmt.Errorf("argument value cannot be found in entities")
|
|
}
|
|
|
|
func typeSafetyCheckEmail(value string) error {
|
|
_, err := mail.ParseAddress(value)
|
|
if err != nil {
|
|
log.WithField("type", "email").Debugf("Email argument type check failed")
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// typeSafetyCheckDnsName validates a DNS hostname (RFC 1123 LDH labels).
|
|
// Accepts short names (e.g. webserver) and FQDNs (e.g. webserver.example.com).
|
|
// An optional trailing dot is allowed.
|
|
func typeSafetyCheckDnsName(value string) error {
|
|
hostname := strings.TrimSuffix(value, ".")
|
|
if hostname == "" || len(hostname) > 253 {
|
|
return fmt.Errorf("invalid dnsname length")
|
|
}
|
|
|
|
return typeSafetyCheckDnsNameLabels(strings.Split(hostname, "."))
|
|
}
|
|
|
|
func typeSafetyCheckDnsNameLabels(labels []string) error {
|
|
for _, label := range labels {
|
|
if !dnsNameLabelPattern.MatchString(label) {
|
|
return fmt.Errorf("invalid dnsname label %q", label)
|
|
}
|
|
}
|
|
|
|
tld := labels[len(labels)-1]
|
|
if dnsNameAllNumericPattern.MatchString(tld) {
|
|
return fmt.Errorf("dnsname top-level label must not be all-numeric")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func typeSafetyCheckDatetime(value string) error {
|
|
_, err := time.Parse("2006-01-02T15:04:05", value)
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func anchorCustomRegexPattern(pattern string) string {
|
|
return "^(?:" + pattern + ")$"
|
|
}
|
|
|
|
func typeSafetyCheckRegex(name string, value string, argumentType string) error {
|
|
pattern := ""
|
|
isCustomRegex := strings.HasPrefix(argumentType, "regex:")
|
|
|
|
if isCustomRegex {
|
|
pattern = strings.TrimPrefix(argumentType, "regex:")
|
|
pattern = anchorCustomRegexPattern(pattern)
|
|
} else {
|
|
found := false
|
|
pattern, found = typecheckRegex[argumentType]
|
|
|
|
if !found {
|
|
return fmt.Errorf("argument type not implemented %v for arg: %v", argumentType, name)
|
|
}
|
|
}
|
|
|
|
matches, _ := regexp.MatchString(pattern, value)
|
|
|
|
if !matches {
|
|
log.WithFields(log.Fields{
|
|
"name": name,
|
|
"value": value,
|
|
"type": argumentType,
|
|
"pattern": pattern,
|
|
}).Warn("Arg type check safety failure")
|
|
|
|
return fmt.Errorf("invalid argument %v, doesn't match %v", name, argumentType)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func typeSafetyCheckUrl(value string) error {
|
|
parsed, err := url.ParseRequestURI(value)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
scheme := strings.ToLower(parsed.Scheme)
|
|
if scheme != "http" && scheme != "https" {
|
|
return fmt.Errorf("url scheme %q is not allowed; only http and https are permitted", parsed.Scheme)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
var shellUnsafeArgumentTypes = map[string]struct{}{
|
|
"url": {},
|
|
"email": {},
|
|
"raw_string_multiline": {},
|
|
"very_dangerous_raw_string": {},
|
|
"password": {},
|
|
"html": {},
|
|
}
|
|
|
|
func isUnsafeShellArgumentType(arg *config.ActionArgument) bool {
|
|
if strings.HasPrefix(arg.Type, "regex:") {
|
|
return true
|
|
}
|
|
|
|
_, inMap := shellUnsafeArgumentTypes[arg.Type]
|
|
return inMap || (arg.Type == "checkbox" && len(arg.Choices) == 0)
|
|
}
|
|
|
|
func checkShellArgumentSafety(action *config.Action) error {
|
|
if action.Shell == "" {
|
|
return nil
|
|
}
|
|
|
|
for i := range action.Arguments {
|
|
arg := &action.Arguments[i]
|
|
if isUnsafeShellArgumentType(arg) {
|
|
return fmt.Errorf("unsafe argument type '%s' cannot be used with Shell execution. Use 'exec' instead. See https://docs.olivetin.app/action_execution/shellvsexec.html", arg.Type)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func mangleInvalidArgumentValues(req *ExecutionRequest) {
|
|
for _, arg := range req.Binding.Action.Arguments {
|
|
if arg.Type == "datetime" {
|
|
mangleInvalidDatetimeValues(req, &arg)
|
|
}
|
|
|
|
mangleCheckboxValues(req, &arg)
|
|
mangleChecklistValues(req, &arg)
|
|
}
|
|
}
|
|
|
|
func mangleCheckboxValues(req *ExecutionRequest, arg *config.ActionArgument) {
|
|
if arg.Type != "checkbox" {
|
|
return
|
|
}
|
|
|
|
log.Infof("Checking checkbox values for argument %s in action %s", arg.Name, req.Binding.Action.Title)
|
|
|
|
for i, v := range arg.Choices {
|
|
choice := &arg.Choices[i]
|
|
|
|
if req.Arguments[arg.Name] == choice.Title {
|
|
log.WithFields(log.Fields{
|
|
"arg": arg.Name,
|
|
"choice": v,
|
|
"oldValue": req.Arguments[arg.Name],
|
|
"newValue": choice.Value,
|
|
"actionTitle": req.Binding.Action.Title,
|
|
}).Infof("Mangled checkbox value")
|
|
|
|
req.Arguments[arg.Name] = choice.Value
|
|
}
|
|
}
|
|
}
|
|
|
|
func mangleInvalidDatetimeValues(req *ExecutionRequest, arg *config.ActionArgument) {
|
|
value, exists := req.Arguments[arg.Name]
|
|
|
|
if !exists || value == "" {
|
|
return
|
|
}
|
|
|
|
timestamp, err := time.Parse("2006-01-02T15:04", value)
|
|
|
|
if err == nil {
|
|
log.WithFields(log.Fields{
|
|
"arg": arg.Name,
|
|
"value": value,
|
|
"actionTitle": req.Binding.Action.Title,
|
|
}).Warnf("Mangled invalid datetime value without seconds to :00 seconds, this issue is commonly caused by Android browsers.")
|
|
|
|
req.Arguments[arg.Name] = timestamp.Format("2006-01-02T15:04:05")
|
|
}
|
|
}
|
|
|
|
// MangleArgumentValue applies mangling transformations to a single argument value.
|
|
// This is used by the validation API to ensure the value matches what would be
|
|
// used during actual execution.
|
|
func MangleArgumentValue(arg *config.ActionArgument, value string, actionTitle string) string {
|
|
if arg == nil {
|
|
log.Debugf("MangleArgumentValue called with nil arg, returning value unchanged")
|
|
return value
|
|
}
|
|
|
|
return mangleArgumentValueByType(arg, value, actionTitle)
|
|
}
|
|
|
|
func mangleArgumentValueByType(arg *config.ActionArgument, value string, actionTitle string) string {
|
|
switch arg.Type {
|
|
case "datetime":
|
|
return mangleDatetimeValue(arg, value, actionTitle)
|
|
case "checkbox":
|
|
return mangleCheckboxValue(arg, value, actionTitle)
|
|
case "checklist":
|
|
return mangleChecklistValue(arg, value, actionTitle)
|
|
default:
|
|
return value
|
|
}
|
|
}
|
|
|
|
func mangleDatetimeValue(arg *config.ActionArgument, value string, actionTitle string) string {
|
|
if arg == nil {
|
|
log.Debugf("mangleDatetimeValue called with nil arg, returning value unchanged")
|
|
return value
|
|
}
|
|
|
|
if value == "" {
|
|
return value
|
|
}
|
|
|
|
timestamp, err := time.Parse("2006-01-02T15:04", value)
|
|
if err != nil {
|
|
return value
|
|
}
|
|
|
|
log.WithFields(log.Fields{
|
|
"arg": arg.Name,
|
|
"value": value,
|
|
"actionTitle": actionTitle,
|
|
}).Warnf("Mangled invalid datetime value without seconds to :00 seconds, this issue is commonly caused by Android browsers.")
|
|
|
|
return timestamp.Format("2006-01-02T15:04:05")
|
|
}
|
|
|
|
func mangleCheckboxValue(arg *config.ActionArgument, value string, actionTitle string) string {
|
|
if arg == nil {
|
|
log.Debugf("mangleCheckboxValue called with nil arg, returning value unchanged")
|
|
return value
|
|
}
|
|
|
|
return mangleChoiceSegment(arg, value, actionTitle)
|
|
}
|
|
|
|
func mangleChecklistValues(req *ExecutionRequest, arg *config.ActionArgument) {
|
|
if arg.Type != "checklist" {
|
|
return
|
|
}
|
|
|
|
value, exists := req.Arguments[arg.Name]
|
|
if !exists || value == "" {
|
|
return
|
|
}
|
|
|
|
req.Arguments[arg.Name] = mangleChecklistValue(arg, value, req.Binding.Action.Title)
|
|
}
|
|
|
|
func mangleChecklistValue(arg *config.ActionArgument, value string, actionTitle string) string {
|
|
if arg == nil || value == "" {
|
|
return value
|
|
}
|
|
|
|
segments, err := config.ParseChecklistValue(value)
|
|
if err != nil {
|
|
return value
|
|
}
|
|
|
|
return mangleChecklistSegments(arg, segments, value, actionTitle)
|
|
}
|
|
|
|
func mangleChecklistSegments(arg *config.ActionArgument, segments []string, fallback string, actionTitle string) string {
|
|
mangled := make([]string, len(segments))
|
|
for i, segment := range segments {
|
|
mangled[i] = mangleChecklistSegment(arg, segment, actionTitle)
|
|
}
|
|
|
|
formatted, err := config.FormatChecklistValue(mangled)
|
|
if err != nil {
|
|
return fallback
|
|
}
|
|
|
|
return formatted
|
|
}
|
|
|
|
func mangleChecklistSegment(arg *config.ActionArgument, segment string, actionTitle string) string {
|
|
trimmed := strings.TrimSpace(segment)
|
|
if trimmed == "" {
|
|
return ""
|
|
}
|
|
|
|
return mangleChoiceSegment(arg, trimmed, actionTitle)
|
|
}
|
|
|
|
func mangleChoiceSegment(arg *config.ActionArgument, value string, actionTitle string) string {
|
|
if mapped, ok := mangleChoiceSegmentEntity(arg, value, actionTitle); ok {
|
|
return mapped
|
|
}
|
|
|
|
return mangleChoiceSegmentStatic(arg, value, actionTitle)
|
|
}
|
|
|
|
func mangleChoiceSegmentEntity(arg *config.ActionArgument, value string, actionTitle string) (string, bool) {
|
|
if arg.Entity == "" || len(arg.Choices) != 1 {
|
|
return value, false
|
|
}
|
|
|
|
return mangleEntityTemplateChoiceSegment(arg.Choices[0], arg.Entity, arg.Name, value, actionTitle)
|
|
}
|
|
|
|
func mangleEntityTemplateChoiceSegment(templateChoice config.ActionArgumentChoice, entityName string, argName string, value string, actionTitle string) (string, bool) {
|
|
for _, ent := range entities.GetEntityInstancesOrdered(entityName) {
|
|
expandedTitle := tpl.ParseTemplateOfActionBeforeExec(templateChoice.Title, ent)
|
|
if value != expandedTitle {
|
|
continue
|
|
}
|
|
|
|
expandedValue := tpl.ParseTemplateOfActionBeforeExec(templateChoice.Value, ent)
|
|
log.WithFields(log.Fields{
|
|
"arg": argName,
|
|
"oldValue": value,
|
|
"newValue": expandedValue,
|
|
"actionTitle": actionTitle,
|
|
}).Infof("Mangled entity choice segment")
|
|
|
|
return expandedValue, true
|
|
}
|
|
|
|
return value, false
|
|
}
|
|
|
|
func mangleChoiceSegmentStatic(arg *config.ActionArgument, value string, actionTitle string) string {
|
|
for _, choice := range arg.Choices {
|
|
if value == choice.Title {
|
|
log.WithFields(log.Fields{
|
|
"arg": arg.Name,
|
|
"oldValue": value,
|
|
"newValue": choice.Value,
|
|
"actionTitle": actionTitle,
|
|
}).Infof("Mangled choice segment")
|
|
|
|
return choice.Value
|
|
}
|
|
}
|
|
|
|
return value
|
|
}
|