seccomp-tools 1.6.1 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +153 -0
  3. data/LICENSE +21 -0
  4. data/README.md +267 -46
  5. data/completions/_seccomp-tools +83 -0
  6. data/completions/seccomp-tools.bash +59 -0
  7. data/completions/seccomp-tools.fish +52 -0
  8. data/ext/ptrace/ptrace.c +2 -2
  9. data/lib/seccomp-tools/asm/asm.rb +9 -4
  10. data/lib/seccomp-tools/asm/compiler.rb +39 -9
  11. data/lib/seccomp-tools/asm/sasm.tab.rb +31 -21
  12. data/lib/seccomp-tools/asm/sasm.y +15 -7
  13. data/lib/seccomp-tools/asm/scalar.rb +50 -7
  14. data/lib/seccomp-tools/asm/scanner.rb +40 -8
  15. data/lib/seccomp-tools/asm/statement.rb +14 -5
  16. data/lib/seccomp-tools/asm/token.rb +19 -1
  17. data/lib/seccomp-tools/audit/catalog.rb +66 -0
  18. data/lib/seccomp-tools/audit/checks/arch_unchecked.rb +41 -0
  19. data/lib/seccomp-tools/audit/checks/dangerous_allow.rb +34 -0
  20. data/lib/seccomp-tools/audit/checks/orw_chain.rb +41 -0
  21. data/lib/seccomp-tools/audit/checks/permissive_default.rb +29 -0
  22. data/lib/seccomp-tools/audit/checks/syscall_alt_gap.rb +44 -0
  23. data/lib/seccomp-tools/audit/checks/x32_guard.rb +46 -0
  24. data/lib/seccomp-tools/audit/checks.rb +42 -0
  25. data/lib/seccomp-tools/audit/finding.rb +25 -0
  26. data/lib/seccomp-tools/audit/policy.rb +126 -0
  27. data/lib/seccomp-tools/audit/report.rb +98 -0
  28. data/lib/seccomp-tools/audit.rb +48 -0
  29. data/lib/seccomp-tools/bpf.rb +27 -17
  30. data/lib/seccomp-tools/cli/asm.rb +7 -3
  31. data/lib/seccomp-tools/cli/audit.rb +86 -0
  32. data/lib/seccomp-tools/cli/base.rb +51 -8
  33. data/lib/seccomp-tools/cli/cli.rb +10 -6
  34. data/lib/seccomp-tools/cli/completion.rb +40 -0
  35. data/lib/seccomp-tools/cli/disasm.rb +10 -6
  36. data/lib/seccomp-tools/cli/dump.rb +37 -52
  37. data/lib/seccomp-tools/cli/dumpable.rb +79 -0
  38. data/lib/seccomp-tools/cli/emu.rb +32 -9
  39. data/lib/seccomp-tools/cli/explain.rb +51 -0
  40. data/lib/seccomp-tools/cli/filter_input.rb +130 -0
  41. data/lib/seccomp-tools/const.rb +98 -15
  42. data/lib/seccomp-tools/consts/sys_nr/amd64.rb +1 -1
  43. data/lib/seccomp-tools/consts/sys_nr/riscv64.rb +332 -0
  44. data/lib/seccomp-tools/disasm/disasm.rb +31 -13
  45. data/lib/seccomp-tools/dumper.rb +67 -35
  46. data/lib/seccomp-tools/emulator.rb +41 -16
  47. data/lib/seccomp-tools/error.rb +4 -2
  48. data/lib/seccomp-tools/explain/analysis.rb +67 -0
  49. data/lib/seccomp-tools/explain/path_facts.rb +110 -0
  50. data/lib/seccomp-tools/explain/qword.rb +204 -0
  51. data/lib/seccomp-tools/explain/renderer.rb +128 -0
  52. data/lib/seccomp-tools/explain/summary.rb +218 -0
  53. data/lib/seccomp-tools/explain/verdict.rb +44 -0
  54. data/lib/seccomp-tools/explain.rb +38 -0
  55. data/lib/seccomp-tools/instruction/alu.rb +14 -9
  56. data/lib/seccomp-tools/instruction/base.rb +39 -10
  57. data/lib/seccomp-tools/instruction/jmp.rb +50 -23
  58. data/lib/seccomp-tools/instruction/ld.rb +46 -21
  59. data/lib/seccomp-tools/instruction/ldx.rb +4 -3
  60. data/lib/seccomp-tools/instruction/misc.rb +11 -9
  61. data/lib/seccomp-tools/instruction/ret.rb +12 -6
  62. data/lib/seccomp-tools/instruction/st.rb +15 -6
  63. data/lib/seccomp-tools/instruction/stx.rb +4 -3
  64. data/lib/seccomp-tools/logger.rb +15 -2
  65. data/lib/seccomp-tools/symbolic/constraint.rb +80 -0
  66. data/lib/seccomp-tools/symbolic/executor.rb +210 -0
  67. data/lib/seccomp-tools/symbolic/expr.rb +185 -0
  68. data/lib/seccomp-tools/symbolic/state.rb +82 -0
  69. data/lib/seccomp-tools/syscall.rb +74 -22
  70. data/lib/seccomp-tools/util.rb +70 -11
  71. data/lib/seccomp-tools/version.rb +1 -1
  72. data/lib/seccomp-tools.rb +10 -1
  73. metadata +83 -11
  74. data/lib/seccomp-tools/disasm/context.rb +0 -171
@@ -1,57 +1,64 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'os'
4
+ require 'timeout'
4
5
 
5
6
  require 'seccomp-tools/logger'
6
7
  require 'seccomp-tools/ptrace' if OS.linux?
7
8
  require 'seccomp-tools/syscall'
9
+ require 'seccomp-tools/util'
8
10
 
9
11
  module SeccompTools
10
12
  # Dump seccomp-bpf using ptrace of binary.
11
13
  module Dumper
12
14
  # Whether the dumper is supported.
13
- # Dumper works based on ptrace, so we need the platform be Linux.
15
+ # Dumper works based on ptrace, so we need the platform to be Linux.
14
16
  SUPPORTED = OS.linux?
15
17
 
16
18
  module_function
17
19
 
18
20
  # Main bpf dump function.
19
- # Yield seccomp bpf whenever find a +prctl(SET_SECCOMP)+ call.
21
+ # Yields seccomp bpf whenever a +prctl(SET_SECCOMP)+ call is found.
20
22
  #
21
23
  # @param [Array<String>] args
22
- # The arguments for target execution file.
24
+ # The command to be executed, i.e. the target executable followed by its arguments.
23
25
  # @param [Integer] limit
24
26
  # By default, +dump+ will only dump the first +SET_SECCOMP+ call.
25
27
  # Set +limit+ to the number of calling +prctl(SET_SECCOMP)+ then the child process will be killed when number of
26
28
  # calling +prctl+ reaches +limit+.
27
29
  #
28
30
  # Negative number for unlimited.
31
+ # @param [Float?] timeout
32
+ # Number of seconds to wait for the target process. When the timeout is reached, the target
33
+ # process is killed and the filters dumped so far are returned. +nil+ for no timeout.
29
34
  # @yieldparam [String] bpf
30
35
  # Seccomp bpf in raw bytes.
31
- # @yieldparam [Symbol] arch
32
- # Architecture of the target process.
36
+ # @yieldparam [Symbol?] arch
37
+ # Architecture of the target process, +nil+ when it cannot be determined.
38
+ # See {SeccompTools::Util.process_arch}.
33
39
  # @return [Array<Object>, Array<String>]
34
- # Return the block returned. If block is not given, array of raw bytes will be returned.
40
+ # One entry per dumped filter: the block's return values when a block is given, otherwise the
41
+ # raw bytes. Empty on a non-Linux platform, where dumping is unsupported.
35
42
  # @example
36
43
  # dump('ls', '-l', '-a')
37
44
  # #=> []
38
45
  # dump('spec/binary/twctf-2016-diary') { |c| c[0, 10] }
39
46
  # #=> [" \x00\x00\x00\x00\x00\x00\x00\x15\x00"]
40
- # @todo
41
- # +timeout+ option.
42
- def dump(*args, limit: 1, &block)
47
+ def dump(*args, limit: 1, timeout: nil, &block)
43
48
  return [] unless SUPPORTED
44
49
 
45
50
  pid = fork { handle_child(*args) }
46
- Handler.new(pid).handle(limit, &block)
51
+ Handler.new(pid).handle(limit, timeout: timeout, &block)
47
52
  end
48
53
 
49
- # Do the tracer things.
54
+ # Traces a forked child, single-stepping it through its syscalls and capturing the seccomp
55
+ # filters it installs.
50
56
  class Handler
51
57
  # Instantiate a {Handler} object.
52
58
  # @param [Integer] pid
53
59
  # The process id after fork.
54
60
  def initialize(pid)
61
+ @pids = [pid]
55
62
  Process.waitpid(pid)
56
63
  opt = Ptrace::O_TRACESYSGOOD | Ptrace::O_TRACECLONE | Ptrace::O_TRACEFORK | Ptrace::O_TRACEVFORK
57
64
  Ptrace.setoptions(pid, 0, opt)
@@ -62,46 +69,60 @@ module SeccompTools
62
69
  #
63
70
  # @param [Integer] limit
64
71
  # Child will be killed when number of calling +prctl(SET_SECCOMP)+ reaches +limit+.
72
+ # @param [Float?] timeout
73
+ # Kill the child processes when +timeout+ seconds have elapsed. +nil+ for no timeout.
65
74
  # @yieldparam [String] bpf
66
75
  # Seccomp bpf in raw bytes.
67
76
  # @yieldparam [Symbol] arch
68
77
  # Architecture. See {SeccompTools::Syscall::ABI} for supported architectures.
69
78
  # @return [Array<Object>, Array<String>]
70
- # Return the block returned. If block is not given, array of raw bytes will be returned.
71
- def handle(limit, &block)
79
+ # One entry per dumped filter: the block's return values when a block is given, otherwise
80
+ # the raw bytes.
81
+ def handle(limit, timeout: nil, &block)
72
82
  collect = []
73
83
  syscalls = {} # record last syscall
74
- loop while wait_syscall do |child|
75
- if syscalls[child].nil? # invoke syscall
76
- syscalls[child] = syscall(child)
77
- next true
84
+ begin
85
+ Timeout.timeout(timeout) do
86
+ loop while wait_syscall do |child|
87
+ if syscalls[child].nil? # invoke syscall
88
+ syscalls[child] = syscall(child)
89
+ next true
90
+ end
91
+ # syscall finished
92
+ sys = syscalls[child]
93
+ syscalls[child] = nil
94
+ if sys.set_seccomp? && syscall(child).ret.zero? # consider successful call only
95
+ bpf = sys.dump_bpf
96
+ collect << (block.nil? ? bpf : yield(bpf, sys.arch))
97
+ limit -= 1
98
+ end
99
+ !limit.zero?
100
+ end
78
101
  end
79
- # syscall finished
80
- sys = syscalls[child]
81
- syscalls[child] = nil
82
- if sys.set_seccomp? && syscall(child).ret.zero? # consider successful call only
83
- bpf = sys.dump_bpf
84
- collect << (block.nil? ? bpf : yield(bpf, sys.arch))
85
- limit -= 1
86
- end
87
- !limit.zero?
102
+ rescue Timeout::Error
103
+ # keep the filters dumped so far; fall through to kill the children
88
104
  end
89
- syscalls.each_key { |cpid| Process.kill('KILL', cpid) if alive?(cpid) }
105
+ @pids.each { |cpid| Process.kill('KILL', cpid) if alive?(cpid) }
90
106
  Process.waitall
91
107
  collect
92
108
  end
93
109
 
94
110
  private
95
111
 
112
+ # Waits until a traced child enters or leaves a syscall, then resumes it.
113
+ #
96
114
  # @yieldparam [Integer] pid
115
+ # Id of the child that stopped.
116
+ # @yieldreturn [Boolean]
117
+ # Whether tracing should continue.
97
118
  # @return [Boolean]
98
119
  # +true+ for continue,
99
- # +false+ for break.
120
+ # +false+ for break. Also +false+ once no children are left.
100
121
  def wait_syscall
101
122
  child, status = Process.wait2
123
+ @pids << child unless @pids.include?(child)
102
124
  cont = true
103
- # TODO: Test if clone / vfork works
104
- if [Ptrace::EVENT_CLONE, Ptrace::EVENT_FORK, Ptrace::EVENT_VFORK].include?(status >> 16)
125
+ if [Ptrace::EVENT_CLONE, Ptrace::EVENT_FORK, Ptrace::EVENT_VFORK].include?(status.to_i >> 16)
105
126
  # New child launched!
106
127
  # newpid = SeccompTools::Ptrace.geteventmsg(child)
107
128
  elsif status.stopped? && status.stopsig & 0x80 != 0
@@ -113,11 +134,20 @@ module SeccompTools
113
134
  false
114
135
  end
115
136
 
137
+ # Reads the syscall the stopped child is currently invoking.
138
+ #
139
+ # @param [Integer] pid
140
+ # Id of the stopped child.
116
141
  # @return [SeccompTools::Syscall]
117
142
  def syscall(pid)
118
143
  SeccompTools::Syscall.new(pid)
119
144
  end
120
145
 
146
+ # Is the process still running?
147
+ #
148
+ # @param [Integer] pid
149
+ # Id of the process to be checked.
150
+ # @return [Boolean]
121
151
  def alive?(pid)
122
152
  Process.getpgid(pid)
123
153
  true
@@ -132,7 +162,7 @@ module SeccompTools
132
162
  def handle_child(*args)
133
163
  Ptrace.traceme_and_stop
134
164
  exec(*args)
135
- rescue # rubocop:disable Style/RescueStandardError # exec fail
165
+ rescue # rubocop:disable Style/RescueStandardError
136
166
  Logger.error("Failed to execute #{args.join(' ')}")
137
167
  exit(1)
138
168
  end
@@ -149,10 +179,11 @@ module SeccompTools
149
179
  # Number of filters to dump. Negative number for unlimited.
150
180
  # @yieldparam [String] bpf
151
181
  # Seccomp bpf in raw bytes.
152
- # @yieldparam [Symbol] arch
153
- # Architecture of the target process (always nil right now).
182
+ # @yieldparam [Symbol?] arch
183
+ # Architecture of the target process, +nil+ when it cannot be determined.
184
+ # See {SeccompTools::Util.process_arch}.
154
185
  # @return [Array<Object>, Array<String>]
155
- # Return the block returned. If block is not given, array of raw bytes will be returned.
186
+ # Returns what the block returned. If a block is not given, an array of raw bytes will be returned.
156
187
  # @raise [Errno::ESRCH]
157
188
  # Raises when the target process does not exist.
158
189
  # @raise [Errno::EPERM]
@@ -174,6 +205,7 @@ module SeccompTools
174
205
  return [] unless SUPPORTED
175
206
 
176
207
  collect = []
208
+ arch = Util.process_arch(pid)
177
209
  Ptrace.attach_and_wait(pid)
178
210
  begin
179
211
  i = 0
@@ -183,7 +215,7 @@ module SeccompTools
183
215
  rescue Errno::ENOENT, Errno::EINVAL
184
216
  break
185
217
  end
186
- collect << (block.nil? ? bpf : yield(bpf, nil))
218
+ collect << (block.nil? ? bpf : yield(bpf, arch))
187
219
  i += 1
188
220
  end
189
221
  ensure
@@ -3,20 +3,21 @@
3
3
  require 'seccomp-tools/const'
4
4
 
5
5
  module SeccompTools
6
- # For emulating seccomp.
6
+ # Runs a seccomp filter against a hypothetical syscall to find out which action it returns.
7
7
  class Emulator
8
8
  # Instantiate a {Emulator} object.
9
9
  #
10
10
  # All parameters except +instructions+ are optional. A warning is shown when uninitialized data is accessed.
11
11
  # @param [Array<Instruction::Base>] instructions
12
- # @param [Integer] sys_nr
12
+ # The filter to be emulated, as returned by +SeccompTools::Disasm.to_bpf(raw, arch).map(&:inst)+.
13
+ # @param [Integer?] sys_nr
13
14
  # Syscall number.
14
15
  # @param [Array<Integer>] args
15
- # Syscall arguments
16
- # @param [Integer] instruction_pointer
17
- # Program counter address when this syscall invoked.
16
+ # Syscall arguments.
17
+ # @param [Integer?] instruction_pointer
18
+ # Program counter address when this syscall is invoked.
18
19
  # @param [Symbol?] arch
19
- # System architecture is used when this parameter is not provided.
20
+ # Defaults to the system architecture when not provided.
20
21
  #
21
22
  # See {SeccompTools::Util.supported_archs} for list of supported architectures.
22
23
  def initialize(instructions, sys_nr: nil, args: [], instruction_pointer: nil, arch: nil)
@@ -24,16 +25,27 @@ module SeccompTools
24
25
  @sys_nr = sys_nr
25
26
  @args = args
26
27
  @ip = instruction_pointer
27
- @arch = audit(arch || Util.system_arch)
28
+ arch ||= Util.system_arch
29
+ @arch = audit(arch)
30
+ # On a big-endian architecture the high 32-bit word of a 64-bit field comes first.
31
+ @big_endian = Const::Endian.big?(arch)
28
32
  end
29
33
 
30
34
  # Run emulation!
35
+ #
36
+ # Executes the filter until it returns, then reports the final machine state.
37
+ # @yieldparam [{Symbol, Integer => Integer}] values
38
+ # If a block is given, it is invoked before each instruction with the current machine state.
31
39
  # @return [{Symbol, Integer => Integer}]
40
+ # The final state: +:ret+ is the action the filter returned, +:pc+ the line it returned from,
41
+ # +:a+ and +:x+ the registers, and Integer keys the scratch memory slots.
42
+ # @example
43
+ # insts = SeccompTools::Disasm.to_bpf(raw, :amd64).map(&:inst)
44
+ # SeccompTools::Emulator.new(insts, sys_nr: 0).run[:ret]
45
+ # #=> 2147418112 # SECCOMP_RET_ALLOW
32
46
  def run
33
47
  @values = { pc: 0, a: 0, x: 0 }
34
48
  loop do
35
- break if @values[:ret] # break when returned
36
-
37
49
  yield(@values) if block_given?
38
50
  inst = @instructions[pc]
39
51
  op, *args = inst.symbolize
@@ -46,6 +58,8 @@ module SeccompTools
46
58
  when :alu then alu(args[0], args[1]) # alu
47
59
  when :misc then misc(args[0]) # misc: txa/tax
48
60
  end
61
+ break if @values[:ret] # break when returned
62
+
49
63
  set(:pc, get(:pc) + 1) if %i[ld st alu misc].include?(op)
50
64
  end
51
65
  @values
@@ -98,9 +112,12 @@ module SeccompTools
98
112
 
99
113
  def alu(op, src)
100
114
  if op == :neg
101
- set(:a, 2**32 - get(:a))
115
+ set(:a, (2**32) - get(:a))
102
116
  else
103
117
  src = get(:x) if src == :x
118
+ # Classic BPF aborts the whole program on division by zero.
119
+ return set(:ret, Const::BPF::ACTION[:KILL_THREAD]) if op == :/ && src.zero?
120
+
104
121
  set(:a, get(:a).__send__(op, src))
105
122
  end
106
123
  end
@@ -140,20 +157,28 @@ module SeccompTools
140
157
  end
141
158
 
142
159
  def data_of(index)
143
- raise IndexError, "Invalid index: #{index}" unless (index & 3).zero? && index.between?(0, 63)
160
+ data = Const::BPF::SeccompData
161
+ raise IndexError, "Invalid index: #{index}" unless index.nobits?(3) && index.between?(0, data::SIZE - 1)
144
162
 
145
163
  index /= 4
146
164
  case index
147
- when 0 then @sys_nr || undefined('sys_number')
148
- when 1 then @arch || undefined('arch')
149
- when 2 then @ip & 0xffffffff || undefined('instruction_pointer')
150
- when 3 then @ip >> 32 || undefined('instruction_pointer')
165
+ when 0 then @sys_nr || undefined(data::NAMES[data::SYS_NUMBER])
166
+ when 1 then @arch || undefined(data::NAMES[data::ARCH])
167
+ when 2, 3 then word_of(@ip || undefined(data::NAMES[data::INSTRUCTION_POINTER]), index)
151
168
  else
152
169
  val = @args[(index - 4) / 2] || undefined("args[#{(index - 4) / 2}]")
153
- (val >> (index.even? ? 0 : 32)) & 0xffffffff
170
+ word_of(val, index)
154
171
  end
155
172
  end
156
173
 
174
+ # The 32-bit word of the 64-bit value +val+ that lives at word-index +index+ of
175
+ # +seccomp_data+: the high word comes second on little-endian architectures but first on
176
+ # big-endian ones (s390x).
177
+ def word_of(val, index)
178
+ hi = index.odd? ^ @big_endian
179
+ (val >> (hi ? 32 : 0)) & 0xffffffff
180
+ end
181
+
157
182
  def undefined(var)
158
183
  raise format("Undefined Variable\n\t%04d: %s <- `%s` is undefined", pc, @instructions[pc].decompile, var)
159
184
  end
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SeccompTools
4
- # Base error class.
4
+ # Base class of all errors raised by this library.
5
+ #
6
+ # Rescue this class to catch every assembler error at once.
5
7
  class Error < StandardError
6
8
  end
7
9
 
@@ -9,7 +11,7 @@ module SeccompTools
9
11
  class UnrecognizedTokenError < Error
10
12
  end
11
13
 
12
- # Raised when a referred label is defined no where on compiling seccomp assembly.
14
+ # Raised when a referenced label is defined nowhere on compiling seccomp assembly.
13
15
  class UndefinedLabelError < Error
14
16
  end
15
17
 
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'seccomp-tools/const'
4
+ require 'seccomp-tools/explain/path_facts'
5
+ require 'seccomp-tools/explain/verdict'
6
+
7
+ module SeccompTools
8
+ class Explain
9
+ # What a walk of one filter amounts to: the reachable returns, each one's facts read once, and
10
+ # the split into the architectures the filter distinguishes.
11
+ #
12
+ # Everything that reads a walk needs the same groundwork - {Summary} to render a per-architecture
13
+ # policy, {Audit} to assess each architecture's reachable syscalls - so it is derived here once
14
+ # rather than in each of them.
15
+ class Analysis
16
+ # @param [Array<Symbolic::Executor::Leaf>] leaves
17
+ def initialize(leaves)
18
+ @leaves = leaves
19
+ @facts = Hash.new { |h, leaf| h[leaf] = PathFacts.new(leaf.path) }
20
+ end
21
+
22
+ # The {PathFacts} of +leaf+, computed once and shared across all consumers.
23
+ # @param [Symbolic::Executor::Leaf] leaf
24
+ # @return [PathFacts]
25
+ def facts(leaf)
26
+ @facts[leaf]
27
+ end
28
+
29
+ # The distinct architecture values (+AUDIT_ARCH_*+) the filter explicitly branches on.
30
+ # @return [Array<Integer>]
31
+ def arch_values
32
+ @arch_values ||= @leaves.filter_map { |l| facts(l).arch_eq }.uniq
33
+ end
34
+
35
+ # One entry per architecture section: its +AUDIT_ARCH+ value (+nil+ when the filter never
36
+ # branches on +arch+), the architecture symbol whose syscall names apply (+nil+ when the checked
37
+ # value is not one seccomp-tools knows), a display title, and the leaves reachable on it.
38
+ # @param [Symbol] declared_arch
39
+ # The architecture assumed when the filter itself does not branch on +arch+.
40
+ # @return [Array<Array(Integer?, Symbol?, Object, Array<Symbolic::Executor::Leaf>)>]
41
+ def sections(declared_arch)
42
+ vals = arch_values
43
+ return [[nil, declared_arch, declared_arch, @leaves]] if vals.empty?
44
+
45
+ vals.map do |v|
46
+ sym = Const::Audit.arch_symbol(v)
47
+ [v, sym, sym || format('0x%x (unknown)', v), @leaves.select { |l| facts(l).arch_consistent?(v) }]
48
+ end
49
+ end
50
+
51
+ # Leaves reachable when +arch+ is none of the explicitly-checked values.
52
+ # @return [Array<Symbolic::Executor::Leaf>]
53
+ def other_leaves
54
+ @leaves.reject { |l| facts(l).arch_eq }
55
+ end
56
+
57
+ # The catch-all action of +leaves+: the verdict of a leaf that matches no syscall, no range and
58
+ # no arguments (or the first leaf, if none is a pure catch-all), or +nil+ when +leaves+ is empty.
59
+ # @param [Array<Symbolic::Executor::Leaf>] leaves
60
+ # @return [String?]
61
+ def default_label(leaves)
62
+ catch_all = leaves.find { |l| facts(l).catch_all? }
63
+ (catch_all || leaves.first)&.then { |l| Verdict.label(l.ret) }
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'seccomp-tools/const'
4
+ require 'seccomp-tools/symbolic/constraint'
5
+
6
+ module SeccompTools
7
+ class Explain
8
+ # The seccomp reading of one leaf's path condition: which syscall number it pins or bounds,
9
+ # which architecture value it pins, and which facts remain for the rule's +when+ clause. The
10
+ # path is immutable, so every query is derived once, eagerly.
11
+ class PathFacts
12
+ SYS = Const::BPF::SeccompData::SYS_NUMBER
13
+ ARCH = Const::BPF::SeccompData::ARCH
14
+ # Largest 32-bit value, the upper end of an unconstrained syscall-number range.
15
+ U32_MAX = 0xffffffff
16
+
17
+ # The syscall number the path pins with +==+, or +nil+.
18
+ # @return [Integer?]
19
+ attr_reader :sys_eq
20
+ # The architecture value the path pins with +==+, or +nil+.
21
+ # @return [Integer?]
22
+ attr_reader :arch_eq
23
+ # The +[lo, hi]+ range (inclusive; +hi+ is +nil+ when unbounded) all bound facts restrict the
24
+ # syscall number to, or +nil+ when there is no lower bound. An upper bound alone does not
25
+ # make a range rule: it is the complement of one (e.g. the +sys < 0x40000000+ side of an x32
26
+ # guard) and reads naturally as part of the default bucket.
27
+ # @return [Array(Integer, Integer?)?]
28
+ attr_reader :sys_range
29
+ # Constraints not already conveyed by the syscall-number / architecture presentation.
30
+ #
31
+ # Consumed (dropped): +==+, +!=+ and range facts on +sys_number+ - the named/ranged buckets
32
+ # and the "any other syscall" default wording express them; +==+/+!=+ facts on +arch+ - the
33
+ # per-architecture sections and the "any other" fall-through express them; and any non-+==+
34
+ # fact on a word that some +==+ on the same path already pins (it is then redundant - a
35
+ # contradicting combination would have been pruned as infeasible).
36
+ #
37
+ # Everything else is kept so a kernel-valid check is never silently dropped: bit-tests on an
38
+ # unpinned +sys_number+ (e.g. an odd/even dispatch), bit-tests or ranges on +arch+ (e.g.
39
+ # testing the +__AUDIT_ARCH_64BIT+ flag instead of pinning one value), and any comparison
40
+ # against a register rather than a constant.
41
+ # @return [Array<Symbolic::Constraint>]
42
+ attr_reader :residual
43
+
44
+ # @param [Array<Symbolic::Constraint>] path
45
+ def initialize(path)
46
+ @path = path
47
+ @sys_eq = eq(SYS)
48
+ @arch_eq = eq(ARCH)
49
+ @sys_range = compute_sys_range
50
+ @residual = compute_residual
51
+ end
52
+
53
+ # Is the path consistent with the architecture being +val+? Every constant arch fact is
54
+ # evaluated against +val+.
55
+ # @param [Integer] val
56
+ # @return [Boolean]
57
+ def arch_consistent?(val)
58
+ @path.all? do |c|
59
+ next true unless c.plain_data_fact?(ARCH)
60
+
61
+ Symbolic::Constraint.evaluate(val, c.op, c.rhs.val)
62
+ end
63
+ end
64
+
65
+ # Does the path match no syscall, no range, and no arguments - i.e. describe the filter's
66
+ # catch-all behavior?
67
+ # @return [Boolean]
68
+ def catch_all?
69
+ sys_eq.nil? && sys_range.nil? && residual.empty?
70
+ end
71
+
72
+ private
73
+
74
+ # The value of the single +data[offset] == k+ fact, if any.
75
+ def eq(offset)
76
+ @path.find { |c| c.plain_data_eq?(offset) }&.rhs&.val
77
+ end
78
+
79
+ def compute_sys_range
80
+ lo = nil
81
+ hi = nil
82
+ @path.each do |c|
83
+ next unless c.plain_data_fact?(SYS)
84
+
85
+ case c.op
86
+ when :> then lo = [lo || 0, c.rhs.val + 1].max
87
+ when :>= then lo = [lo || 0, c.rhs.val].max
88
+ when :< then hi = [hi || U32_MAX, c.rhs.val - 1].min
89
+ when :<= then hi = [hi || U32_MAX, c.rhs.val].min
90
+ end
91
+ end
92
+ lo && [lo, hi]
93
+ end
94
+
95
+ def compute_residual
96
+ pinned = @path.filter_map { |c| c.lhs.offset if c.plain_data_eq? }
97
+ @path.reject do |c|
98
+ next false unless c.plain_data_fact?
99
+
100
+ redundant = c.op != :== && pinned.include?(c.lhs.offset)
101
+ case c.lhs.offset
102
+ when SYS then redundant || !%i[set unset].include?(c.op)
103
+ when ARCH then redundant || %i[== !=].include?(c.op)
104
+ else redundant
105
+ end
106
+ end.uniq(&:key)
107
+ end
108
+ end
109
+ end
110
+ end