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
@@ -10,116 +10,159 @@ module SeccompTools
10
10
  # @private
11
11
  module Scalar
12
12
  # To be used to denote a register (A / X), an argument (data[]), or a memory data (mem[]).
13
+ #
14
+ # Every predicate returns +false+ here, each subclass overrides exactly the one that
15
+ # identifies it. The registers and +len+ carry no payload and are singletons, the remaining
16
+ # subclasses keep theirs in {#val}.
13
17
  class Base
18
+ # @return [Integer?]
19
+ # The payload of this scalar, whose meaning depends on the subclass: the immediate value
20
+ # for {ConstVal}, the index for {Data} and {Mem}. +nil+ for the singletons.
14
21
  attr_reader :val
15
22
 
23
+ # Is this the A register?
24
+ # @return [Boolean]
16
25
  def a?
17
26
  false
18
27
  end
19
28
 
29
+ # Is this the X register?
30
+ # @return [Boolean]
20
31
  def x?
21
32
  false
22
33
  end
23
34
 
35
+ # Is this +len+?
36
+ # @return [Boolean]
24
37
  def len?
25
38
  false
26
39
  end
27
40
 
41
+ # Is this a +data[]+ access?
42
+ # @return [Boolean]
28
43
  def data?
29
44
  false
30
45
  end
31
46
 
47
+ # Is this a +mem[]+ access?
48
+ # @return [Boolean]
32
49
  def mem?
33
50
  false
34
51
  end
35
52
 
53
+ # Is this a constant value?
54
+ # @return [Boolean]
36
55
  def const?
37
56
  false
38
57
  end
39
58
  end
40
59
 
41
- # Register A.
60
+ # Register A, the accumulator. A singleton, use +A.instance+.
42
61
  class A < Base
43
62
  include Singleton
44
63
 
64
+ # @return [Boolean] Always +true+.
45
65
  def a?
46
66
  true
47
67
  end
48
68
  end
49
69
 
50
- # Register X.
70
+ # Register X, the index register. A singleton, use +X.instance+.
51
71
  class X < Base
52
72
  include Singleton
53
73
 
74
+ # @return [Boolean] Always +true+.
54
75
  def x?
55
76
  true
56
77
  end
57
78
  end
58
79
 
59
- # Len.
80
+ # The +len+ keyword, the size of +struct seccomp_data+. A singleton, use +Len.instance+.
60
81
  class Len < Base
61
82
  include Singleton
62
83
 
84
+ # @return [Boolean] Always +true+.
63
85
  def len?
64
86
  true
65
87
  end
66
88
  end
67
89
 
68
- # A constant value.
90
+ # A constant value, i.e. an immediate operand.
69
91
  class ConstVal < Base
92
+ # Instantiates a {ConstVal} object.
93
+ #
70
94
  # @param [Integer] val
95
+ # The constant value.
71
96
  def initialize(val)
72
97
  @val = val
73
98
  super()
74
99
  end
75
100
 
101
+ # Compares by value, so a {ConstVal} equals the bare Integer it wraps.
102
+ #
76
103
  # @param [ConstVal, Integer] other
104
+ # The object to be compared with.
105
+ # @return [Boolean]
77
106
  def ==(other)
78
107
  to_i == other.to_i
79
108
  end
80
109
 
110
+ # @return [Boolean] Always +true+.
81
111
  def const?
82
112
  true
83
113
  end
84
114
 
115
+ # @!method to_i
116
+ # The wrapped constant value.
117
+ # @return [Integer]
85
118
  alias to_i val
86
119
  end
87
120
 
88
- # data[]
121
+ # A +data[]+ access, one word of +struct seccomp_data+.
89
122
  class Data < Base
90
123
  # Instantiates a {Data} object.
91
124
  #
92
125
  # @param [Integer] index
126
+ # Byte offset into +struct seccomp_data+.
93
127
  def initialize(index)
94
128
  @val = index
95
129
  super()
96
130
  end
97
131
 
98
132
  # @param [Data] other
133
+ # The object to be compared with.
134
+ # @return [Boolean]
135
+ # +true+ only if +other+ is a {Data} with the same index.
99
136
  def ==(other)
100
137
  other.is_a?(Data) && val == other.val
101
138
  end
102
139
 
140
+ # @return [Boolean] Always +true+.
103
141
  def data?
104
142
  true
105
143
  end
106
144
  end
107
145
 
108
- # mem[]
146
+ # A +mem[]+ access, one slot of the scratch memory.
109
147
  class Mem < Base
110
148
  # Instantiates a {Mem} object.
111
149
  #
112
150
  # @param [Integer] index
151
+ # Index of the scratch memory slot.
113
152
  def initialize(index)
114
153
  @val = index
115
154
  super()
116
155
  end
117
156
 
118
- # @param [Data] other
157
+ # @param [Mem] other
158
+ # The object to be compared with.
159
+ # @return [Boolean]
160
+ # +true+ only if +other+ is a {Mem} with the same index.
119
161
  def ==(other)
120
162
  other.is_a?(Mem) && val == other.val
121
163
  end
122
164
 
165
+ # @return [Boolean] Always +true+.
123
166
  def mem?
124
167
  true
125
168
  end
@@ -11,43 +11,54 @@ module SeccompTools
11
11
  #
12
12
  # Maintains columns and rows to have informative error messages.
13
13
  #
14
- # Internally used by {SeccompAsmParser}.
14
+ # Internally used by the seccomp asm parser.
15
15
  class Scanner
16
+ # @return [{Symbol => Integer}]
17
+ # Syscall name to number, for the architecture this scanner was created with.
16
18
  attr_reader :syscalls
19
+ # @return [Symbol]
20
+ # The architecture this scanner was created with.
21
+ attr_reader :arch
17
22
 
18
23
  # Keywords with special meanings in our assembly. Keywords are all case-insensitive.
19
24
  KEYWORDS = %w[a x if else return mem args args_h data len sys_number arch instruction_pointer].freeze
20
25
  # Regexp for matching keywords.
21
- KEYWORD_MATCHER = /\A\b(#{KEYWORDS.join('|')})\b/i.freeze
26
+ KEYWORD_MATCHER = /\A\b(#{KEYWORDS.join('|')})\b/i
22
27
  # Action strings can be used in a return statement. Actions must be in upper case.
23
28
  # See {SeccompTools::Const::BPF::ACTION}.
24
29
  ACTIONS = Const::BPF::ACTION.keys.map(&:to_s)
25
30
  # Regexp for matching actions.
26
- ACTION_MATCHER = /\A\b(#{ACTIONS.join('|')})\b/.freeze
31
+ ACTION_MATCHER = /\A\b(#{ACTIONS.join('|')})\b/
27
32
  # Special constants for checking the current architecture. See {SeccompTools::Const::Audit::ARCH}. These constants
28
33
  # are case-insensitive.
29
34
  AUDIT_ARCHES = Const::Audit::ARCH.keys
30
35
  # Regexp for matching arch values.
31
- AUDIT_ARCH_MATCHER = /\A\b(#{AUDIT_ARCHES.join('|')})\b/i.freeze
36
+ AUDIT_ARCH_MATCHER = /\A\b(#{AUDIT_ARCHES.join('|')})\b/i
32
37
  # Comparisons.
33
38
  COMPARE = %w[== != >= <= > <].freeze
34
39
  # Regexp for matching comparisons.
35
- COMPARE_MATCHER = /\A(#{COMPARE.join('|')})/.freeze
40
+ COMPARE_MATCHER = /\A(#{COMPARE.join('|')})/
36
41
  # All valid arithmetic operators.
37
42
  ALU_OP = %w[+ - * / | ^ << >>].freeze
38
43
  # Regexp for matching ALU operators.
39
- ALU_OP_MATCHER = /\A(#{ALU_OP.map { |o| ::Regexp.escape(o) }.join('|')})/.freeze
44
+ ALU_OP_MATCHER = /\A(#{ALU_OP.map { |o| ::Regexp.escape(o) }.join('|')})/
40
45
  # Supported architectures
41
46
  ARCHES = SeccompTools::Syscall::ABI.keys.map(&:to_s)
42
47
 
48
+ # Instantiates a {Scanner} object.
49
+ #
43
50
  # @param [String] str
51
+ # The assembly source to be scanned.
44
52
  # @param [Symbol] arch
53
+ # Target architecture, decides which syscall names are recognized.
45
54
  # @param [String?] filename
55
+ # Only used for error messages, defaults to +<inline>+.
46
56
  # @example
47
57
  # Scanner.new('return ALLOW', :amd64)
48
58
  def initialize(str, arch, filename: nil)
49
59
  @filename = filename || '<inline>'
50
60
  @str = str
61
+ @arch = arch
51
62
  @syscalls =
52
63
  begin; Const::Syscall.const_get(arch.to_s.upcase); rescue NameError; []; end
53
64
  @syscall_all = ARCHES.each_with_object({}) do |ar, memo|
@@ -57,7 +68,9 @@ module SeccompTools
57
68
 
58
69
  # Scans the whole string and raises errors when there are unrecognized tokens.
59
70
  # @return [self]
71
+ # The scanner itself, so calls can be chained.
60
72
  # @raise [UnrecognizedTokenError]
73
+ # If any token could not be recognized. The message points at every such token.
61
74
  def validate!
62
75
  errors = validate
63
76
  return self if errors.empty?
@@ -68,11 +81,17 @@ module SeccompTools
68
81
  # Same as {#validate!} but returns the array of errors instead of raising an exception.
69
82
  #
70
83
  # @return [Array<Token>]
84
+ # The +:unknown+ tokens, empty when the source is valid.
71
85
  def validate
72
86
  scan.select { |t| t.sym == :unknown }
73
87
  end
74
88
 
89
+ # Scans the whole string into tokens.
90
+ #
91
+ # The result is memoized, so repeated calls are free.
75
92
  # @return [Array<Token>]
93
+ # All tokens in source order. Unrecognized text becomes +:unknown+ tokens rather than
94
+ # raising, see {#validate!}.
76
95
  def scan
77
96
  return @tokens if defined?(@tokens)
78
97
 
@@ -138,9 +157,22 @@ module SeccompTools
138
157
  # Let tab on terminal be 4 spaces wide.
139
158
  TAB_WIDTH = 4
140
159
 
160
+ # Formats an error message that points at a token in the source.
161
+ #
141
162
  # @param [Token] tok
163
+ # The token the message refers to.
142
164
  # @param [String] msg
165
+ # The message to be shown next to the source location.
143
166
  # @return [String]
167
+ # The location and message, the offending source line, and a row of carets underlining
168
+ # the token.
169
+ # @example
170
+ # scanner.format_error(tok, 'unknown token "foo"')
171
+ # #=> <<-EOS
172
+ # # <inline>:1:1 unknown token "foo"
173
+ # # foo
174
+ # # ^^^
175
+ # # EOS
144
176
  def format_error(tok, msg)
145
177
  @lines = @str.lines unless defined?(@lines)
146
178
  line = @lines[tok.line]
@@ -149,14 +181,14 @@ module SeccompTools
149
181
  <<-EOS
150
182
  #{@filename}:#{tok.line + 1}:#{tok.col + 1} #{msg}
151
183
  #{line}
152
- #{' ' * calculate_spaces(@lines[tok.line][0...tok.col]) + '^' * tok.str.size}
184
+ #{(' ' * calculate_spaces(@lines[tok.line][0...tok.col])) + ('^' * tok.str.size)}
153
185
  EOS
154
186
  end
155
187
 
156
188
  private
157
189
 
158
190
  def calculate_spaces(str)
159
- str.size + str.count("\t") * (TAB_WIDTH - 1)
191
+ str.size + (str.count("\t") * (TAB_WIDTH - 1))
160
192
  end
161
193
  end
162
194
  end
@@ -8,15 +8,21 @@ module SeccompTools
8
8
  # Internally used by sasm.y.
9
9
  # @private
10
10
  class Statement
11
- attr_reader :type, :data, :symbols
11
+ # @return [Symbol] Kind of this statement, one of +:alu+, +:assign+, +:if+ and +:ret+.
12
+ attr_reader :type
13
+ # @return [Object] The operands of this statement, see {#initialize}.
14
+ attr_reader :data
15
+ # @return [Array<Token>] Labels that refer to this statement.
16
+ attr_reader :symbols
12
17
 
13
18
  # Instantiates a {Statement} object.
14
19
  #
15
20
  # @param [:alu, :assign, :if, :ret] type
16
- # @param [Integer, Array<Integer, String>] data
17
- # The data for describing this statement. Type of +data+ is variant according to the value of +type+.
18
- # @param [Array<String>] symbols
19
- # Symbols that refer to this statement.
21
+ # Kind of this statement.
22
+ # @param [Object] data
23
+ # The data for describing this statement. Type of +data+ varies according to the value of +type+.
24
+ # @param [Array<Token>] symbols
25
+ # Label tokens that refer to this statement.
20
26
  def initialize(type, data, symbols)
21
27
  @type = type
22
28
  @data = data
@@ -24,6 +30,9 @@ module SeccompTools
24
30
  end
25
31
 
26
32
  # @param [Statement] other
33
+ # The statement to be compared with.
34
+ # @return [Boolean]
35
+ # +true+ only if the type, data and symbols are all equal.
27
36
  def ==(other)
28
37
  [type, data, symbols] == [other.type, other.data, other.symbols]
29
38
  end
@@ -3,14 +3,30 @@
3
3
  module SeccompTools
4
4
  module Asm
5
5
  # Records information of a token.
6
+ #
7
+ # Beyond the token itself, a {Token} remembers where it was found so that
8
+ # {Scanner#format_error} can point at it in the source.
6
9
  class Token
7
- attr_reader :sym, :str, :line, :col
10
+ # @return [Symbol]
11
+ # Type of this token, e.g. +:NEWLINE+, +:SYMBOL+, +:GOTO+, or +:unknown+ for text the
12
+ # scanner failed to recognize.
13
+ attr_reader :sym
14
+ # @return [String] The matched text.
15
+ attr_reader :str
16
+ # @return [Integer] Zero-based line number where this token starts.
17
+ attr_reader :line
18
+ # @return [Integer] Zero-based column number where this token starts.
19
+ attr_reader :col
8
20
 
9
21
  # Instantiates a {Token} object.
10
22
  # @param [Symbol] sym
23
+ # Type of this token.
11
24
  # @param [String] str
25
+ # The matched text.
12
26
  # @param [Integer] line
27
+ # Zero-based line number where this token starts.
13
28
  # @param [Integer] col
29
+ # Zero-based column number where this token starts.
14
30
  def initialize(sym, str, line, col)
15
31
  @sym = sym
16
32
  @str = str
@@ -20,7 +36,9 @@ module SeccompTools
20
36
 
21
37
  # To compare with another {Token} object.
22
38
  # @param [Token] other
39
+ # The token to be compared with.
23
40
  # @return [Boolean]
41
+ # +true+ only if all four attributes are equal.
24
42
  def ==(other)
25
43
  [other.sym, other.str, other.line, other.col] == [sym, str, line, col]
26
44
  end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SeccompTools
4
+ class Audit
5
+ # The seccomp-domain knowledge the checks classify against: which syscalls are dangerous, which
6
+ # are equivalents of each other, and which form a file read/write chain. Entries are by syscall
7
+ # *name*; each check resolves them against the target architecture's own syscall table (a name a
8
+ # given arch lacks is simply skipped), and the +*_BY_ARCH+ maps add arch-specific knowledge on a
9
+ # clean extension point - no arch is hardcoded in the checks themselves.
10
+ module Catalog
11
+ # Dangerous-if-reachable syscalls => +{severity:, why:}+. Applied on every architecture. Kept to
12
+ # a low-noise, high-signal set: syscalls a real sandbox almost never wants. Ubiquitous-but-risky
13
+ # ones (+mmap+/+mprotect+ RWX, decided by the +prot+ argument) are intentionally out of v1 -
14
+ # flagging them needs argument-flag analysis, else every normal allowlist trips them.
15
+ DANGEROUS = {
16
+ execve: { severity: :high, why: 'arbitrary program execution' },
17
+ execveat: { severity: :high, why: 'arbitrary program execution' },
18
+ ptrace: { severity: :high, why: 'inspect/inject into other processes' },
19
+ process_vm_readv: { severity: :high, why: "read another process's memory" },
20
+ process_vm_writev: { severity: :high, why: "write another process's memory" },
21
+ socket: { severity: :medium, why: 'network access (exfiltration)' },
22
+ connect: { severity: :medium, why: 'network access (exfiltration)' }
23
+ }.freeze
24
+
25
+ # Per-arch additions to {DANGEROUS} (e.g. i386 multiplexes the socket API through +socketcall+).
26
+ DANGEROUS_BY_ARCH = {
27
+ i386: { socketcall: { severity: :medium, why: 'multiplexed socket API (exfiltration)' } }
28
+ }.freeze
29
+
30
+ # Equivalent-syscall groups: denying one member while another is allowed is a bypass gap.
31
+ ALT_GROUPS = {
32
+ exec: %i[execve execveat],
33
+ open: %i[open openat openat2],
34
+ read: %i[read readv pread64 preadv preadv2],
35
+ write: %i[write writev pwrite64 pwritev sendfile],
36
+ fork: %i[fork vfork clone clone3]
37
+ }.freeze
38
+
39
+ # Per-arch additions to {ALT_GROUPS}.
40
+ ALT_GROUPS_BY_ARCH = {}.freeze
41
+
42
+ # The open/read/write families whose joint availability is a file read/exfil chain.
43
+ ORW = {
44
+ open: %i[open openat openat2],
45
+ read: %i[read readv pread64 preadv preadv2],
46
+ write: %i[write writev pwrite64 pwritev sendfile]
47
+ }.freeze
48
+
49
+ module_function
50
+
51
+ # {DANGEROUS} plus any per-arch additions for +arch_sym+.
52
+ # @param [Symbol?] arch_sym
53
+ # @return [Hash{Symbol=>Hash}]
54
+ def dangerous(arch_sym)
55
+ DANGEROUS.merge(DANGEROUS_BY_ARCH.fetch(arch_sym, {}))
56
+ end
57
+
58
+ # {ALT_GROUPS} plus any per-arch additions for +arch_sym+.
59
+ # @param [Symbol?] arch_sym
60
+ # @return [Hash{Symbol=>Array<Symbol>}]
61
+ def alt_groups(arch_sym)
62
+ ALT_GROUPS.merge(ALT_GROUPS_BY_ARCH.fetch(arch_sym, {}))
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'seccomp-tools/audit/finding'
4
+ require 'seccomp-tools/explain/verdict'
5
+
6
+ module SeccompTools
7
+ class Audit
8
+ module Checks
9
+ # The filter does not validate the architecture, or lets an unlisted one reach +ALLOW+. Syscall
10
+ # numbers are ABI-relative, so number-only rules are dodged by invoking under another ABI.
11
+ module ArchUnchecked
12
+ module_function
13
+
14
+ # @param [Explain::Analysis] analysis
15
+ # @return [Array<Audit::Finding>]
16
+ def call(analysis)
17
+ if analysis.arch_values.empty?
18
+ [finding('Architecture is never validated',
19
+ 'The filter checks syscall numbers without ever comparing data[4] (arch). ' \
20
+ 'Numbers mean different syscalls under another AUDIT_ARCH, so the checks can be ' \
21
+ 'dodged by invoking through a different ABI (e.g. i386 numbering on amd64).')]
22
+ elsif analysis.other_leaves.any? { |l| Explain::Verdict.label(l.ret) == 'ALLOW' }
23
+ [finding('Unlisted architectures reach ALLOW',
24
+ 'Some paths reach ALLOW on an architecture the filter does not explicitly ' \
25
+ 'check, so a different-ABI call can slip past the syscall-number rules.')]
26
+ else
27
+ []
28
+ end
29
+ end
30
+
31
+ # @!visibility private
32
+ def finding(title, detail)
33
+ Finding.new(id: 'arch-unchecked', severity: :high, arch: nil, title:, detail:,
34
+ syscalls: [], condition: nil,
35
+ remediation: 'Compare data[4] against your AUDIT_ARCH_* and KILL every ' \
36
+ 'architecture you do not explicitly handle.')
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'seccomp-tools/audit/catalog'
4
+ require 'seccomp-tools/audit/finding'
5
+
6
+ module SeccompTools
7
+ class Audit
8
+ module Checks
9
+ # A syscall a sandbox almost never wants ({Catalog#dangerous}) is reachable as +ALLOW+. One
10
+ # finding per syscall, carrying the argument condition (if any) under which it is allowed.
11
+ module DangerousAllow
12
+ module_function
13
+
14
+ # @param [Audit::Policy] policy
15
+ # @return [Array<Audit::Finding>]
16
+ def call(policy)
17
+ Catalog.dangerous(policy.arch_sym).filter_map do |name, meta|
18
+ nr = policy.number(name)
19
+ next unless nr && policy.reachable_as_allow?(nr)
20
+
21
+ cond = policy.condition_for(nr)
22
+ Finding.new(
23
+ id: 'dangerous-allow', severity: meta[:severity], arch: policy.arch_name,
24
+ title: "#{name} is allowed",
25
+ detail: "#{name} reaches ALLOW - #{meta[:why]}.",
26
+ syscalls: [name.to_s], condition: cond,
27
+ remediation: "Block #{name} unless the program genuinely needs it."
28
+ )
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'seccomp-tools/audit/catalog'
4
+ require 'seccomp-tools/audit/finding'
5
+
6
+ module SeccompTools
7
+ class Audit
8
+ module Checks
9
+ # An open-family, a read-family and a write/send-family syscall are all reachable as +ALLOW+ -
10
+ # the classic "open the flag, read it, write it out" chain.
11
+ module OrwChain
12
+ module_function
13
+
14
+ # @param [Audit::Policy] policy
15
+ # @return [Array<Audit::Finding>]
16
+ def call(policy)
17
+ o = first_allowed(policy, Catalog::ORW[:open])
18
+ r = first_allowed(policy, Catalog::ORW[:read])
19
+ w = first_allowed(policy, Catalog::ORW[:write])
20
+ return [] unless o && r && w
21
+
22
+ [Finding.new(
23
+ id: 'orw-chain', severity: :high, arch: policy.arch_name,
24
+ title: 'A file can be opened and its contents copied out',
25
+ # The syscall names are painted wherever they appear, so keep them out of prose that
26
+ # merely describes the actions - only name one where the syscall itself is meant.
27
+ detail: "#{o}, #{r} and #{w} all reach ALLOW, so the contents of an arbitrary file " \
28
+ '(e.g. the flag) can be copied straight back out.',
29
+ syscalls: [o, r, w].map(&:to_s), condition: nil,
30
+ remediation: 'Deny the open-family syscalls unless the program genuinely needs arbitrary files.'
31
+ )]
32
+ end
33
+
34
+ # @!visibility private
35
+ def first_allowed(policy, names)
36
+ names.find { |n| (nr = policy.number(n)) && policy.reachable_as_allow?(nr) }
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'seccomp-tools/audit/finding'
4
+
5
+ module SeccompTools
6
+ class Audit
7
+ module Checks
8
+ # The catch-all action is +ALLOW+: a denylist, bypassable by anything the author forgot.
9
+ module PermissiveDefault
10
+ module_function
11
+
12
+ # @param [Audit::Policy] policy
13
+ # @return [Array<Audit::Finding>]
14
+ def call(policy)
15
+ return [] unless policy.default_label == 'ALLOW'
16
+
17
+ [Finding.new(
18
+ id: 'permissive-default', severity: :high, arch: policy.arch_name,
19
+ title: 'Default action is ALLOW (denylist)',
20
+ detail: 'Any syscall the filter does not explicitly block is allowed; a denylist is ' \
21
+ 'bypassable by any syscall the author overlooked.',
22
+ syscalls: [], condition: nil,
23
+ remediation: 'Use an allowlist: default to KILL/ERRNO and permit only the needed syscalls.'
24
+ )]
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'seccomp-tools/audit/catalog'
4
+ require 'seccomp-tools/audit/finding'
5
+
6
+ module SeccompTools
7
+ class Audit
8
+ module Checks
9
+ # An equivalent-syscall gap: a group member is singled out for denial while a sibling with the
10
+ # same capability still reaches +ALLOW+ (blocked +execve+ but not +execveat+, +open+ but not
11
+ # +openat+, +fork+ but not +clone+).
12
+ module SyscallAltGap
13
+ module_function
14
+
15
+ # @param [Audit::Policy] policy
16
+ # @return [Array<Audit::Finding>]
17
+ def call(policy)
18
+ Catalog.alt_groups(policy.arch_sym).filter_map do |group, names|
19
+ present = names.filter_map { |n| [n, policy.number(n)] if policy.number(n) }
20
+ denied = present.select { |_n, nr| policy.explicitly_denied?(nr) }.map(&:first)
21
+ allowed = present.select { |_n, nr| policy.reachable_as_allow?(nr) }.map(&:first)
22
+ next if denied.empty? || allowed.empty?
23
+
24
+ finding(policy, group, denied, allowed)
25
+ end
26
+ end
27
+
28
+ # @!visibility private
29
+ def finding(policy, group, denied, allowed)
30
+ Finding.new(
31
+ id: 'syscall-alt-gap', severity: %i[exec open].include?(group) ? :high : :medium,
32
+ arch: policy.arch_name,
33
+ title: "#{denied.join('/')} blocked but #{allowed.join('/')} allowed",
34
+ detail: "#{denied.join(', ')} denied, but the equivalent #{allowed.join(', ')} reaches " \
35
+ 'ALLOW - same capability, different syscall number.',
36
+ # Both sides: the finding is about the pair, and the detail says which is which.
37
+ syscalls: (denied + allowed).map(&:to_s), condition: nil,
38
+ remediation: "Deny every equivalent in the group: also block #{allowed.join(', ')}."
39
+ )
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'seccomp-tools/audit/finding'
4
+
5
+ module SeccompTools
6
+ class Audit
7
+ module Checks
8
+ # amd64's x32 ABI quirk: x32 syscalls share +AUDIT_ARCH_X86_64+ but number as +nr | 0x40000000+.
9
+ # Without a +sys_number >= 0x40000000+ (or +jset 0x40000000+) guard, a syscall blocked by its
10
+ # native number is still reachable through its x32 number. Registered only for amd64, so no
11
+ # other architecture is ever mis-flagged.
12
+ module X32Guard
13
+ module_function
14
+
15
+ # @param [Audit::Policy] policy
16
+ # @return [Array<Audit::Finding>]
17
+ def call(policy)
18
+ sharp = policy.table.filter_map do |name, nr|
19
+ next if name.to_s.start_with?('x32_')
20
+
21
+ x = policy.number(:"x32_#{name}")
22
+ name if x && policy.reachable_as_allow?(x) && !policy.reachable_as_allow?(nr)
23
+ end
24
+ return [] if sharp.empty?
25
+
26
+ [finding(policy, sharp)]
27
+ end
28
+
29
+ # @!visibility private
30
+ def finding(policy, sharp)
31
+ shown = sharp.first(8).map(&:to_s)
32
+ more = sharp.size > shown.size ? ", ... (+#{sharp.size - shown.size} more)" : ''
33
+ Finding.new(
34
+ id: 'x32-guard', severity: :high, arch: policy.arch_name,
35
+ title: 'x32 ABI is not guarded',
36
+ detail: 'Syscalls blocked by their native number are reachable via their x32 number ' \
37
+ "(nr | 0x40000000): #{shown.join(', ')}#{more}.",
38
+ syscalls: shown, condition: nil,
39
+ remediation: 'After the arch check, KILL when sys_number >= 0x40000000 ' \
40
+ '(or jset 0x40000000).'
41
+ )
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end