seccomp-tools 1.6.2 → 1.7.1

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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +167 -0
  3. data/README.md +280 -42
  4. data/completions/_seccomp-tools +83 -0
  5. data/completions/seccomp-tools.bash +59 -0
  6. data/completions/seccomp-tools.fish +52 -0
  7. data/ext/ptrace/ptrace.c +2 -2
  8. data/lib/seccomp-tools/asm/asm.rb +9 -4
  9. data/lib/seccomp-tools/asm/compiler.rb +32 -2
  10. data/lib/seccomp-tools/asm/sasm.tab.rb +27 -19
  11. data/lib/seccomp-tools/asm/sasm.y +15 -7
  12. data/lib/seccomp-tools/asm/scalar.rb +50 -7
  13. data/lib/seccomp-tools/asm/scanner.rb +33 -1
  14. data/lib/seccomp-tools/asm/statement.rb +14 -5
  15. data/lib/seccomp-tools/asm/token.rb +19 -1
  16. data/lib/seccomp-tools/audit/catalog.rb +69 -0
  17. data/lib/seccomp-tools/audit/checks/arch_unchecked.rb +41 -0
  18. data/lib/seccomp-tools/audit/checks/dangerous_allow.rb +34 -0
  19. data/lib/seccomp-tools/audit/checks/orw_chain.rb +41 -0
  20. data/lib/seccomp-tools/audit/checks/permissive_default.rb +29 -0
  21. data/lib/seccomp-tools/audit/checks/syscall_alt_gap.rb +44 -0
  22. data/lib/seccomp-tools/audit/checks/x32_guard.rb +46 -0
  23. data/lib/seccomp-tools/audit/checks.rb +42 -0
  24. data/lib/seccomp-tools/audit/finding.rb +25 -0
  25. data/lib/seccomp-tools/audit/policy.rb +126 -0
  26. data/lib/seccomp-tools/audit/report.rb +98 -0
  27. data/lib/seccomp-tools/audit.rb +48 -0
  28. data/lib/seccomp-tools/bpf.rb +27 -17
  29. data/lib/seccomp-tools/cli/asm.rb +6 -2
  30. data/lib/seccomp-tools/cli/audit.rb +86 -0
  31. data/lib/seccomp-tools/cli/base.rb +51 -8
  32. data/lib/seccomp-tools/cli/cli.rb +9 -3
  33. data/lib/seccomp-tools/cli/completion.rb +40 -0
  34. data/lib/seccomp-tools/cli/disasm.rb +9 -5
  35. data/lib/seccomp-tools/cli/dump.rb +37 -52
  36. data/lib/seccomp-tools/cli/dumpable.rb +79 -0
  37. data/lib/seccomp-tools/cli/emu.rb +20 -5
  38. data/lib/seccomp-tools/cli/explain.rb +51 -0
  39. data/lib/seccomp-tools/cli/filter_input.rb +130 -0
  40. data/lib/seccomp-tools/const.rb +98 -15
  41. data/lib/seccomp-tools/consts/sys_arg.rb +43 -5
  42. data/lib/seccomp-tools/consts/sys_nr/aarch64.rb +52 -1
  43. data/lib/seccomp-tools/consts/sys_nr/amd64.rb +53 -1
  44. data/lib/seccomp-tools/consts/sys_nr/i386.rb +83 -2
  45. data/lib/seccomp-tools/consts/sys_nr/riscv64.rb +332 -0
  46. data/lib/seccomp-tools/consts/sys_nr/s390x.rb +30 -1
  47. data/lib/seccomp-tools/disasm/disasm.rb +30 -12
  48. data/lib/seccomp-tools/dumper.rb +67 -36
  49. data/lib/seccomp-tools/emulator.rb +40 -15
  50. data/lib/seccomp-tools/error.rb +4 -2
  51. data/lib/seccomp-tools/explain/analysis.rb +67 -0
  52. data/lib/seccomp-tools/explain/path_facts.rb +110 -0
  53. data/lib/seccomp-tools/explain/qword.rb +204 -0
  54. data/lib/seccomp-tools/explain/renderer.rb +128 -0
  55. data/lib/seccomp-tools/explain/summary.rb +218 -0
  56. data/lib/seccomp-tools/explain/verdict.rb +44 -0
  57. data/lib/seccomp-tools/explain.rb +38 -0
  58. data/lib/seccomp-tools/instruction/alu.rb +14 -9
  59. data/lib/seccomp-tools/instruction/base.rb +39 -10
  60. data/lib/seccomp-tools/instruction/jmp.rb +50 -23
  61. data/lib/seccomp-tools/instruction/ld.rb +46 -21
  62. data/lib/seccomp-tools/instruction/ldx.rb +4 -3
  63. data/lib/seccomp-tools/instruction/misc.rb +11 -9
  64. data/lib/seccomp-tools/instruction/ret.rb +12 -6
  65. data/lib/seccomp-tools/instruction/st.rb +15 -6
  66. data/lib/seccomp-tools/instruction/stx.rb +4 -3
  67. data/lib/seccomp-tools/logger.rb +14 -1
  68. data/lib/seccomp-tools/symbolic/constraint.rb +80 -0
  69. data/lib/seccomp-tools/symbolic/executor.rb +210 -0
  70. data/lib/seccomp-tools/symbolic/expr.rb +185 -0
  71. data/lib/seccomp-tools/symbolic/state.rb +82 -0
  72. data/lib/seccomp-tools/syscall.rb +73 -23
  73. data/lib/seccomp-tools/util.rb +76 -11
  74. data/lib/seccomp-tools/version.rb +1 -1
  75. data/lib/seccomp-tools.rb +10 -1
  76. metadata +36 -24
  77. data/lib/seccomp-tools/disasm/context.rb +0 -171
@@ -11,9 +11,14 @@ 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
@@ -40,14 +45,20 @@ module SeccompTools
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]
@@ -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,69 @@
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
+ io_uring_setup: {
22
+ severity: :high, why: 'reads/writes/opens as ring operations, bypassing filters on those syscalls'
23
+ },
24
+ socket: { severity: :medium, why: 'network access (exfiltration)' },
25
+ connect: { severity: :medium, why: 'network access (exfiltration)' }
26
+ }.freeze
27
+
28
+ # Per-arch additions to {DANGEROUS} (e.g. i386 multiplexes the socket API through +socketcall+).
29
+ DANGEROUS_BY_ARCH = {
30
+ i386: { socketcall: { severity: :medium, why: 'multiplexed socket API (exfiltration)' } }
31
+ }.freeze
32
+
33
+ # Equivalent-syscall groups: denying one member while another is allowed is a bypass gap.
34
+ ALT_GROUPS = {
35
+ exec: %i[execve execveat],
36
+ open: %i[open openat openat2],
37
+ read: %i[read readv pread64 preadv preadv2],
38
+ write: %i[write writev pwrite64 pwritev sendfile],
39
+ fork: %i[fork vfork clone clone3]
40
+ }.freeze
41
+
42
+ # Per-arch additions to {ALT_GROUPS}.
43
+ ALT_GROUPS_BY_ARCH = {}.freeze
44
+
45
+ # The open/read/write families whose joint availability is a file read/exfil chain.
46
+ ORW = {
47
+ open: %i[open openat openat2],
48
+ read: %i[read readv pread64 preadv preadv2],
49
+ write: %i[write writev pwrite64 pwritev sendfile]
50
+ }.freeze
51
+
52
+ module_function
53
+
54
+ # {DANGEROUS} plus any per-arch additions for +arch_sym+.
55
+ # @param [Symbol?] arch_sym
56
+ # @return [Hash{Symbol=>Hash}]
57
+ def dangerous(arch_sym)
58
+ DANGEROUS.merge(DANGEROUS_BY_ARCH.fetch(arch_sym, {}))
59
+ end
60
+
61
+ # {ALT_GROUPS} plus any per-arch additions for +arch_sym+.
62
+ # @param [Symbol?] arch_sym
63
+ # @return [Hash{Symbol=>Array<Symbol>}]
64
+ def alt_groups(arch_sym)
65
+ ALT_GROUPS.merge(ALT_GROUPS_BY_ARCH.fetch(arch_sym, {}))
66
+ end
67
+ end
68
+ end
69
+ 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
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'seccomp-tools/audit/checks/arch_unchecked'
4
+ require 'seccomp-tools/audit/checks/dangerous_allow'
5
+ require 'seccomp-tools/audit/checks/orw_chain'
6
+ require 'seccomp-tools/audit/checks/permissive_default'
7
+ require 'seccomp-tools/audit/checks/syscall_alt_gap'
8
+ require 'seccomp-tools/audit/checks/x32_guard'
9
+
10
+ module SeccompTools
11
+ class Audit
12
+ # The registries the {Audit} engine runs. A check is anything answering +call+ and returning
13
+ # {Finding}s; which registry it is listed in decides both how often it runs and what it is
14
+ # handed, so adding one is a single entry here rather than a change to the engine.
15
+ #
16
+ # * {FILTER} - asked once about the whole filter, and handed the {Explain::Analysis}. For
17
+ # questions no single architecture can answer, such as whether the filter checks the
18
+ # architecture at all.
19
+ # * {SECTION} / {SECTION_BY_ARCH} - asked once per architecture, and handed that architecture's
20
+ # {Policy}. {SECTION_BY_ARCH} keeps a quirk confined to the architecture that has it, so it is
21
+ # a one-line entry rather than a branch inside a check.
22
+ module Checks
23
+ # Whole-filter checks; each takes the {Explain::Analysis}.
24
+ FILTER = [ArchUnchecked].freeze
25
+
26
+ # Per-architecture checks that apply everywhere; each takes a {Policy}.
27
+ SECTION = [PermissiveDefault, SyscallAltGap, OrwChain, DangerousAllow].freeze
28
+
29
+ # Per-architecture checks that apply only to the keyed architecture (amd64's x32 is the exemplar).
30
+ SECTION_BY_ARCH = { amd64: [X32Guard] }.freeze
31
+
32
+ module_function
33
+
34
+ # The per-architecture checks to run for +arch_sym+: the common ones plus any it alone has.
35
+ # @param [Symbol?] arch_sym
36
+ # @return [Array]
37
+ def section_checks(arch_sym)
38
+ SECTION + SECTION_BY_ARCH.fetch(arch_sym, [])
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SeccompTools
4
+ class Audit
5
+ # Severities, most to least severe. Drives ordering and (for +human+ output) coloring.
6
+ SEVERITIES = %i[high medium low].freeze
7
+
8
+ # One weakness reported by a {Check}: a stable +id+, a +severity+, a human +title+/+detail+, the
9
+ # architecture it applies to, the syscalls involved, an optional argument +condition+ under which
10
+ # it holds, and a one-line +remediation+.
11
+ Finding = Struct.new(:id, :severity, :title, :detail, :arch, :syscalls, :condition, :remediation,
12
+ keyword_init: true) do
13
+ # Sort key: by severity, then id, then the affected syscalls.
14
+ # @return [Array]
15
+ def rank
16
+ [SEVERITIES.index(severity) || SEVERITIES.size, id, Array(syscalls).join(',')]
17
+ end
18
+
19
+ # @return [Hash] JSON-ready shape.
20
+ def to_h
21
+ { id:, severity:, title:, detail:, arch:, syscalls: Array(syscalls), condition:, remediation: }
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'seccomp-tools/const'
4
+ require 'seccomp-tools/explain/qword'
5
+ require 'seccomp-tools/explain/renderer'
6
+ require 'seccomp-tools/explain/verdict'
7
+ require 'seccomp-tools/symbolic/constraint'
8
+
9
+ module SeccompTools
10
+ class Audit
11
+ # One architecture's view of a filter: given a syscall number, which actions can an attacker
12
+ # reach? Built from that arch's section leaves (already arch-filtered by {Explain::Analysis}).
13
+ #
14
+ # A leaf is reachable for syscall +nr+ when every plain +sys_number+ fact on its path is satisfied
15
+ # by +nr+ - this one predicate covers +==+, ranges, and +jset+ bit-tests (so it sees an x32 guard
16
+ # written either way). Argument and opaque facts are *not* consulted: arguments are
17
+ # attacker-controlled and the executor already dropped self-contradictory paths, so a surviving
18
+ # leaf is reachable under some argument choice. The stance is deliberately conservative - "could an
19
+ # attacker reach this action?".
20
+ class Policy
21
+ SYS = Const::BPF::SeccompData::SYS_NUMBER
22
+
23
+ # @return [Integer?] The +AUDIT_ARCH+ value (+nil+ when the filter never branches on +arch+).
24
+ attr_reader :arch_val
25
+ # @return [Symbol?] The architecture symbol whose syscall names apply (+nil+ when unknown).
26
+ attr_reader :arch_sym
27
+ # @return [Object] Display title for this section (arch symbol or +0x... (unknown)+).
28
+ attr_reader :title
29
+ # @return [Array<Symbolic::Executor::Leaf>] This section's leaves.
30
+ attr_reader :leaves
31
+
32
+ # @param [Explain::Analysis] analysis
33
+ # @param [Array] section One +[arch_val, arch_sym, title, leaves]+ entry from {Explain::Analysis#sections}.
34
+ def initialize(analysis, section)
35
+ @analysis = analysis
36
+ @arch_val, @arch_sym, @title, @leaves = section
37
+ @fusion = @arch_sym && Explain::QwordFusion.new(@arch_sym)
38
+ @renderer = @fusion && Explain::Renderer.new(@fusion)
39
+ end
40
+
41
+ # A display name for this section's architecture (+"amd64"+, or +"0x... (unknown)"+).
42
+ # @return [String]
43
+ def arch_name
44
+ (@arch_sym || @title).to_s
45
+ end
46
+
47
+ # This arch's +name => number+ table, or +nil+ when the section has no known architecture.
48
+ #
49
+ # +@arch_sym+ is always +nil+ or a supported architecture (the CLI rejects an undetectable host
50
+ # before we get here), so the table lookup never fails.
51
+ # @return [Hash{Symbol=>Integer}?]
52
+ def table
53
+ return @table if defined?(@table)
54
+
55
+ @table = @arch_sym && Const::Syscall.const_get(@arch_sym.upcase)
56
+ end
57
+
58
+ # The number of syscall +name+ on this arch, or +nil+ if the arch lacks it.
59
+ # @param [Symbol] name
60
+ # @return [Integer?]
61
+ def number(name)
62
+ table && table[name]
63
+ end
64
+
65
+ # The action of the catch-all (default) path, e.g. +"ALLOW"+ / +"ERRNO(5)"+.
66
+ # @return [String?]
67
+ def default_label
68
+ @analysis.default_label(@leaves)
69
+ end
70
+
71
+ # The distinct actions reachable for syscall number +nr+.
72
+ # @param [Integer] nr
73
+ # @return [Array<String>]
74
+ def reachable_actions(nr)
75
+ reachable_leaves(nr).map { |l| Explain::Verdict.label(l.ret) }.uniq
76
+ end
77
+
78
+ # Can syscall +nr+ reach +ALLOW+?
79
+ # @param [Integer] nr
80
+ # @return [Boolean]
81
+ def reachable_as_allow?(nr)
82
+ reachable_leaves(nr).any? { |l| Explain::Verdict.label(l.ret) == 'ALLOW' }
83
+ end
84
+
85
+ # Was syscall +nr+ singled out for denial (a rule pins +sys_number == nr+ to a non-+ALLOW+
86
+ # action), as opposed to merely being absent from an allowlist? Distinguishes a real
87
+ # "blocked one equivalent but not another" gap from allowlist omissions.
88
+ # @param [Integer] nr
89
+ # @return [Boolean]
90
+ def explicitly_denied?(nr)
91
+ !reachable_as_allow?(nr) &&
92
+ @leaves.any? { |l| @analysis.facts(l).sys_eq == nr && Explain::Verdict.label(l.ret) != 'ALLOW' }
93
+ end
94
+
95
+ # The argument condition under which +nr+ reaches +action+, rendered like +explain+
96
+ # (+"filename == 0x..."+), or +nil+ when it is unconditional or the arch is unknown.
97
+ # @param [Integer] nr
98
+ # @param [String] action
99
+ # @return [String?]
100
+ def condition_for(nr, action = 'ALLOW')
101
+ return nil unless @renderer
102
+
103
+ ls = reachable_leaves(nr).select { |l| Explain::Verdict.label(l.ret) == action }
104
+ conds = @fusion.merge_or(ls.map { |l| @analysis.facts(l).residual })
105
+ .map { |list| @renderer.conjunction(@fusion.fold(list), name_of(nr)) }.uniq
106
+ conds.include?('') ? nil : conds.join(' or ')
107
+ end
108
+
109
+ private
110
+
111
+ def name_of(nr)
112
+ table && table.invert[nr]
113
+ end
114
+
115
+ def reachable_leaves(nr)
116
+ @leaves.select { |l| sys_satisfied?(l, nr) }
117
+ end
118
+
119
+ def sys_satisfied?(leaf, nr)
120
+ leaf.path.all? do |c|
121
+ !c.plain_data_fact?(SYS) || Symbolic::Constraint.evaluate(nr, c.op, c.rhs.val)
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end