Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 28 additions & 14 deletions docs/enforcement.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,20 +76,34 @@ literal input and can include comments, quoted examples, or other text the
shell would not execute. Use the parsed `shell_commands` view when a deny
depends on executable command semantics.

Detection parses a broad set of shell structures. When a match is derived from
`shell_commands`, blocking uses a smaller static subset:

- POSIX shells: one simple command or one pipeline of simple commands
- PowerShell and `cmd.exe`: one simple command
- supported transparent launchers, only when the final child command meets the
same rules

Every projected command must have static arguments, assignments, and redirect
targets. Multiple statements, conditionals, loops, shell background syntax,
substitutions, same-script functions, inline child interpreters, `eval`,
`Invoke-Expression`, PowerShell or CMD pipelines, previews, parser diagnostics,
and truncated projections stay detection-only. This event-wide gate avoids
denying an action based on a command that may not execute.
Detection parses a broad set of shell structures. Blocking keeps the existing
static requirements for executable tokens, arguments, assignments, redirect
targets, wrappers, previews, parser diagnostics, and projection limits.

For compound POSIX input, `numbat` forms candidates from the existing
`mvdan.cc/sh/v3/syntax` result. One parsed command forms one candidate. Direct
members of one `|` or `|&` pipeline form one candidate together and keep the
existing pipeline safety checks.

The forms `;`, `&&`, `||`, groups, subshells, background commands, negation,
substitutions, and heredocs do not disable an otherwise eligible candidate.
Both sides of `&&` and `||` are checked because the input requests both
commands, even when one side might not run. Statically resolved shell function
calls remain detection-only; eligible commands in an invoked function body are
considered separately.

For an `enforce: true` rule that uses `shell_commands`, CEL evaluates the
complete rule against eligible candidates until one returns true. A true result
denies the complete tool input. A candidate error suppresses enforcement only
when no candidate returns true. Detection still evaluates the complete command
list. A full-list detection error remains a diagnostic, but it does not
suppress a clean candidate deny. Rules that do not use `shell_commands` keep
their existing behavior.

An unsafe command or direct pipeline remains detection-only. A substitution
inside an unsafe direct pipeline cannot become an independent enforcement
candidate. `eval`, inline child interpreters such as `sh -c`, and PowerShell or
`cmd.exe` compound input remain detection-only.

numbat recognizes explicit `-WhatIf` and a statically visible
`$WhatIfPreference = $true` for known cmdlets. It does not infer ambient
Expand Down
20 changes: 7 additions & 13 deletions docs/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -389,16 +389,11 @@ visible `$WhatIfPreference = $true` for known cmdlet names and exact
module-qualified forms. Ambient preference and command-resolution state are not
inferred.

For a shell-derived match, blocking has a narrower eligibility boundary than
detection: the complete shell program must be one static simple command or one
static POSIX pipeline. Supported transparent launchers are allowed only when
their final child command is also in that subset. Multiple statements, control
flow, same-script functions, inline child interpreters, `eval` or
`Invoke-Expression`, substitutions, runtime-dependent values, PowerShell or CMD
pipelines, previews, parser diagnostics, and truncated projections remain
detection-only. A rule may still use `shell_commands` alongside structured
fields such as `event.file_path`; a matching commandless structured event does
not require a shell projection. See [Enforcement](enforcement.md).
For shell-derived blocking, `numbat` evaluates the rule against eligible
parser-derived candidates. Both sides of `&&` and `||` are checked. A rule can
still use `shell_commands` with fields such as `event.file_path`. A matching
commandless structured event does not need a shell projection. See
[Enforcement](enforcement.md) for candidate eligibility.

## Enforcement rules

Expand All @@ -410,9 +405,8 @@ Enforcement uses the same CEL expressions as detection; there is no separate
rule language or required predicate shape. Raw `event.command` remains
available, but it matches literal input and can therefore match text that the
shell would not execute. Use `shell_commands` when blocking depends on parsed
command semantics. A shell-derived match can detect broad shell syntax, but a
deny requires the complete shell program to be inside the static subset
described above.
command semantics. A shell-derived deny requires one eligible parser-derived
candidate as described above.

All built-ins ship monitor-only. To enforce one, copy its complete YAML into an
operator directory, keep the same ID, set `enforce: true`, and bump the rule
Expand Down
1 change: 1 addition & 0 deletions internal/rule/checked_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ func TestCheckedExpressionsPreserveEvaluation(t *testing.T) {
}
for _, event := range []model.Event{
{EventType: model.EventCommandExec, Command: "echo ready"},
{EventType: model.EventCommandExec, Command: "true; echo ready"},
{EventType: model.EventCommandExec, Command: "go test ./..."},
{EventType: model.EventFileRead, FilePath: "README.md"},
} {
Expand Down
128 changes: 108 additions & 20 deletions internal/rule/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ type compiledRule struct {

type compiledExpression struct {
program cel.Program
candidateProgram cel.Program
usesShellCommands bool
usesContent bool
}
Expand Down Expand Up @@ -80,19 +81,32 @@ func (s *SequenceRule) WithinEvents() int { return s.withinEvents }
// MaxMatches returns the per-(rule, session) finding cap, always >= 1.
func (s *SequenceRule) MaxMatches() int { return s.maxMatches }

// EvalStep evaluates one step predicate against a prebuilt activation. An eval
// error reports false: an erroring predicate must never fabricate a link in a
// chain, so the failure surfaces as a diagnostic and, at worst, a documented
// false negative.
func (s *SequenceRule) EvalStep(i int, activation map[string]any) (bool, error) {
type StepEvaluation struct {
Match bool
EnforcementMatch bool
}

func (s *SequenceRule) EvalStep(i int, activations SequenceActivations) (StepEvaluation, error) {
if i < 0 || i >= len(s.steps) {
return false, fmt.Errorf("rule %q: step index %d out of range", s.rule.ID, i)
return StepEvaluation{}, fmt.Errorf("rule %q: step index %d out of range", s.rule.ID, i)
}
out, _, err := s.steps[i].program.Eval(activation)
if err != nil {
return false, fmt.Errorf("rule %q step %d: evaluation failed", s.rule.ID, i+1)
step := s.steps[i]
prepared := activations.prepared
if prepared.err != nil && step.usesShellCommands && !prepared.shellUsable {
return StepEvaluation{}, nil
}
evaluation := evaluateExpression(step, prepared, s.rule.IsEnforceEligible())
var errs []error
if evaluation.detectionErr != nil {
errs = append(errs, fmt.Errorf("rule %q step %d: evaluation failed", s.rule.ID, i+1))
}
if evaluation.candidateErr != nil {
errs = append(errs, fmt.Errorf("rule %q step %d: candidate evaluation failed", s.rule.ID, i+1))
}
return asBool(out), nil
return StepEvaluation{
Match: evaluation.detectionMatch || evaluation.enforcementMatch,
EnforcementMatch: evaluation.enforcementMatch,
}, errors.Join(errs...)
}

// StepUsesShellCommands reports whether step i depends on the derived command
Expand All @@ -119,6 +133,7 @@ func newEnv() (*cel.Env, error) {
ext.Lists(),
cel.Variable("event", cel.MapType(cel.StringType, cel.DynType)),
cel.Variable(shellCommandsVariable, cel.ListType(cel.ObjectType("rule.ShellCommand"))),
cel.Variable(shellCommandCandidatesVariable, cel.ListType(cel.ListType(cel.ObjectType("rule.ShellCommand")))),
)
}

Expand Down Expand Up @@ -377,6 +392,7 @@ func validateRuleAST(ast *cel.Ast) error {
}

func programExpr(env *cel.Env, ast *cel.Ast) (compiledExpression, error) {
usesShellCommands := astReferencesGlobal(ast, shellCommandsVariable)
usesContent := astReferencesEventField(ast, "content") ||
astReferencesEventField(ast, "content_bytes") ||
astReferencesEventField(ast, "content_truncated")
Expand All @@ -388,13 +404,63 @@ func programExpr(env *cel.Env, ast *cel.Ast) (compiledExpression, error) {
if err != nil {
return compiledExpression{}, fmt.Errorf("program expr: %w", err)
}
var candidateProgram cel.Program
if usesShellCommands {
candidateAST, err := shellCandidateAST(env, ast)
if err != nil {
return compiledExpression{}, err
}
candidateProgram, err = env.Program(candidateAST, programOptions...)
if err != nil {
return compiledExpression{}, fmt.Errorf("program candidate enforcement expr: %w", err)
}
}
return compiledExpression{
program: prg,
usesShellCommands: astReferencesGlobal(ast, shellCommandsVariable),
candidateProgram: candidateProgram,
usesShellCommands: usesShellCommands,
usesContent: usesContent,
}, nil
}

const shellCandidateTemplate = shellCommandCandidatesVariable + ".exists(" + shellCommandsVariable + ", true)"

func shellCandidateAST(env *cel.Env, predicate *cel.Ast) (*cel.Ast, error) {
template, issues := env.Compile(shellCandidateTemplate)
if issues != nil && issues.Err() != nil {
return nil, fmt.Errorf("compile candidate enforcement template: %w", issues.Err())
}
optimizer, err := cel.NewStaticOptimizer(shellCandidateOptimizer{predicate: predicate})
if err != nil {
return nil, fmt.Errorf("build candidate enforcement optimizer: %w", err)
}
optimized, issues := optimizer.Optimize(env, template)
if issues != nil && issues.Err() != nil {
return nil, fmt.Errorf("check candidate enforcement expr: %w", issues.Err())
}
return optimized, nil
}

type shellCandidateOptimizer struct {
predicate *cel.Ast
}

func (o shellCandidateOptimizer) Optimize(ctx *cel.OptimizerContext, wrapper *celast.AST) *celast.AST {
root := wrapper.Expr()
if root.Kind() != celast.ComprehensionKind {
ctx.ReportErrorAtID(root.ID(), "candidate enforcement template did not expand to a comprehension")
return wrapper
}
loopStep := root.AsComprehension().LoopStep()
if loopStep.Kind() != celast.CallKind || len(loopStep.AsCall().Args()) != 2 {
ctx.ReportErrorAtID(loopStep.ID(), "candidate enforcement template has an invalid loop step")
return wrapper
}
predicate := loopStep.AsCall().Args()[1]
ctx.UpdateExpr(predicate, ctx.CopyASTAndMetadata(o.predicate.NativeRep()))
return wrapper
}

func astReferencesEventField(ast *cel.Ast, field string) bool {
found := false
walkEventFields(ast.NativeRep(), ast.NativeRep().Expr(), false, func(name string, literal bool) {
Expand Down Expand Up @@ -665,26 +731,48 @@ func (e *Engine) Eval(ev model.Event) ([]Match, error) {
if activations.err != nil && c.program.usesShellCommands && !activations.shellUsable {
continue
}
out, _, err := c.program.program.Eval(activations.detection)
if err != nil {
evaluation := evaluateExpression(c.program, activations, c.rule.IsEnforceEligible())
if evaluation.detectionErr != nil {
errs = append(errs, fmt.Errorf("rule %q: evaluation failed", c.rule.ID))
continue
}
if asBool(out) {
enforcementMatch := c.rule.IsEnforceEligible()
if enforcementMatch && c.program.usesShellCommands && !activations.shellEnforcementSafe {
enforcementMatch = false
}
if evaluation.candidateErr != nil {
errs = append(errs, fmt.Errorf("rule %q: candidate evaluation failed", c.rule.ID))
}
if evaluation.detectionMatch || evaluation.enforcementMatch {
matches = append(matches, Match{
Rule: cloneRule(c.rule),
Event: ev,
EnforcementMatch: enforcementMatch,
EnforcementMatch: evaluation.enforcementMatch,
})
}
}
return matches, errors.Join(errs...)
}

type expressionEvaluation struct {
detectionMatch bool
enforcementMatch bool
detectionErr error
candidateErr error
}

func evaluateExpression(expr compiledExpression, activations sequenceActivations, enforceEligible bool) expressionEvaluation {
out, _, detectionErr := expr.program.Eval(activations.detection)
detectionMatch := detectionErr == nil && asBool(out)
evaluation := expressionEvaluation{
detectionMatch: detectionMatch,
enforcementMatch: detectionMatch && enforceEligible,
detectionErr: detectionErr,
}
if !enforceEligible || !expr.usesShellCommands || activations.shellEnforcementSafe {
return evaluation
}
candidate, _, candidateErr := expr.candidateProgram.Eval(activations.detection)
evaluation.enforcementMatch = candidateErr == nil && asBool(candidate)
evaluation.candidateErr = candidateErr
return evaluation
}

// asBool reports whether a CEL result is a true boolean. Any non-bool result
// (which compile-time type checking already forbids) is treated as no match.
func asBool(v ref.Val) bool {
Expand Down
29 changes: 14 additions & 15 deletions internal/rule/engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -444,8 +444,8 @@ func TestEngineDoesNotEnforceRuntimeDependentCommands(t *testing.T) {
ToolName: "bash",
Command: `run(){ wipefs -a /dev/sda; }; run`,
})
if err != nil || len(staticBody) != 1 || staticBody[0].EnforcementMatch {
t.Fatalf("static function body = (%+v, %v), want detection-only match", staticBody, err)
if err != nil || len(staticBody) != 1 || !staticBody[0].EnforcementMatch {
t.Fatalf("static function body = (%+v, %v), want enforceable request match", staticBody, err)
}
}

Expand Down Expand Up @@ -532,7 +532,7 @@ func TestEngineDoesNotInferPowerShellPreviewForNativeCommandsOrAmbientState(t *t
}
}

func TestEngineDoesNotEnforcePartialShellAnalysis(t *testing.T) {
func TestEngineEnforcesCompleteCandidateWithDynamicSibling(t *testing.T) {
enforce := true
eng := mustEngine(t, Rule{
ID: "t.remove",
Expand All @@ -550,8 +550,8 @@ func TestEngineDoesNotEnforcePartialShellAnalysis(t *testing.T) {
if err == nil {
t.Fatal("Eval succeeded, want dynamic-command diagnostic")
}
if len(matches) != 1 || matches[0].EnforcementMatch {
t.Fatalf("partial static match = %+v, want detection-only rm witness", matches)
if len(matches) != 1 || !matches[0].EnforcementMatch {
t.Fatalf("partial static match = %+v, want enforceable rm candidate", matches)
}

eng = mustEngine(t, Rule{
Expand Down Expand Up @@ -600,6 +600,14 @@ func TestEngineLimitsEnforcementToStaticShellSubset(t *testing.T) {
{EventType: model.EventCommandExec, ToolName: "bash", Command: `command wipefs -a /dev/sda`},
{EventType: model.EventCommandExec, ToolName: "bash", Command: `exec wipefs -a /dev/sda`},
{EventType: model.EventCommandExec, ToolName: "bash", Command: `nohup wipefs -a /dev/sda`},
{EventType: model.EventCommandExec, ToolName: "bash", Command: `false && wipefs -a /dev/sda`},
{EventType: model.EventCommandExec, ToolName: "bash", Command: `true || wipefs -a /dev/sda`},
{EventType: model.EventCommandExec, ToolName: "bash", Command: `if false; then wipefs -a /dev/sda; fi`},
{EventType: model.EventCommandExec, ToolName: "bash", Command: `for x in one; do wipefs -a /dev/sda; done`},
{EventType: model.EventCommandExec, ToolName: "bash", Command: `echo ready; wipefs -a /dev/sda`},
{EventType: model.EventCommandExec, ToolName: "bash", Command: `! wipefs -a /dev/sda`},
{EventType: model.EventCommandExec, ToolName: "bash", Command: `wipefs -a /dev/sda &`},
{EventType: model.EventCommandExec, ToolName: "bash", Command: `echo "$(wipefs -a /dev/sda)"`},
{EventType: model.EventCommandExec, ToolName: "PowerShell", Command: `Stop-Process -Name target -Force`},
{EventType: model.EventCommandExec, ToolName: "PowerShell", Command: `Stop-Process -Name target -Force 2>&1`},
{EventType: model.EventCommandExec, ToolName: "cmd.exe", Command: `del C:\target`},
Expand All @@ -613,11 +621,7 @@ func TestEngineLimitsEnforcementToStaticShellSubset(t *testing.T) {
}

detectionOnly := []model.Event{
{EventType: model.EventCommandExec, ToolName: "bash", Command: `false && wipefs -a /dev/sda`},
{EventType: model.EventCommandExec, ToolName: "bash", Command: `true || wipefs -a /dev/sda`},
{EventType: model.EventCommandExec, ToolName: "bash", Command: `if false; then wipefs -a /dev/sda; fi`},
{EventType: model.EventCommandExec, ToolName: "bash", Command: `for x in one; do wipefs -a /dev/sda; done`},
{EventType: model.EventCommandExec, ToolName: "bash", Command: `run(){ wipefs -a /dev/sda; }; run`},
{EventType: model.EventCommandExec, ToolName: "bash", Command: `wipefs(){ echo safe; }; wipefs -a /dev/sda`},
{EventType: model.EventCommandExec, ToolName: "bash", Command: `eval 'wipefs -a /dev/sda'`},
{EventType: model.EventCommandExec, ToolName: "bash", Command: `command eval 'wipefs -a /dev/sda'`},
{EventType: model.EventCommandExec, ToolName: "bash", Command: `command exec wipefs -a /dev/sda`},
Expand Down Expand Up @@ -647,13 +651,8 @@ func TestEngineLimitsEnforcementToStaticShellSubset(t *testing.T) {
{EventType: model.EventCommandExec, ToolName: "bash", Command: `env -u PATH wipefs -a /dev/sda`},
{EventType: model.EventCommandExec, ToolName: "bash", Command: `exec -a wipe wipefs -a /dev/sda`},
{EventType: model.EventCommandExec, ToolName: "bash", Command: `pwsh -Command 'Stop-Process -Name target -Force'`},
{EventType: model.EventCommandExec, ToolName: "bash", Command: `echo "$(wipefs -a /dev/sda)"`},
{EventType: model.EventCommandExec, ToolName: "bash", Command: `wipefs -a /dev/sda --no-*`},
{EventType: model.EventCommandExec, ToolName: "bash", Command: `wipefs -a /dev/sda --{no-act,other}`},
{EventType: model.EventCommandExec, ToolName: "bash", Command: `echo ready; wipefs -a /dev/sda`},
{EventType: model.EventCommandExec, ToolName: "bash", Command: `"$next"; wipefs -a /dev/sda`},
{EventType: model.EventCommandExec, ToolName: "bash", Command: `! wipefs -a /dev/sda`},
{EventType: model.EventCommandExec, ToolName: "bash", Command: `wipefs -a /dev/sda &`},
{EventType: model.EventCommandExec, ToolName: "PowerShell", Command: `Write-Output ready; Stop-Process -Name target -Force`},
{EventType: model.EventCommandExec, ToolName: "PowerShell", Command: `if ($false) { Stop-Process -Name target -Force }`},
{EventType: model.EventCommandExec, ToolName: "PowerShell", Command: `Write-Output target | Stop-Process -Force`},
Expand Down
Loading