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
@@ -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
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'seccomp-tools/audit/finding'
4
+ require 'seccomp-tools/util'
5
+
6
+ module SeccompTools
7
+ class Audit
8
+ # The findings for one filter, rendered either as a human report or a JSON-ready hash.
9
+ class Report
10
+ # Severity => {Util.colorize} theme for the +[SEVERITY]+ tag.
11
+ SEVERITY_THEME = { high: :error, medium: :warn, low: :info }.freeze
12
+
13
+ # Widest a rendered line may get, so a finding reads without wrapping on a standard terminal.
14
+ WIDTH = 120
15
+
16
+ # @param [String?] source Label for the audited filter.
17
+ # @param [Array<String>] arches The architectures covered.
18
+ # @param [Array<Finding>] findings
19
+ # @param [Boolean] truncated Whether the symbolic walk was cut short.
20
+ def initialize(source:, arches:, findings:, truncated:)
21
+ @source = source
22
+ @arches = arches
23
+ @findings = findings.sort_by(&:rank)
24
+ @truncated = truncated
25
+ end
26
+
27
+ # @return [Array<Finding>]
28
+ attr_reader :findings
29
+ # @return [Array<String>] The architectures covered.
30
+ attr_reader :arches
31
+
32
+ # The human report.
33
+ # @return [String]
34
+ def to_s
35
+ out = +''
36
+ out << "Seccomp audit of #{@source}\n" if @source
37
+ out << "Architectures: #{@arches.join(', ')}\n" unless @arches.empty?
38
+ # A truncated walk can only hide weaknesses, so it qualifies the whole report rather than
39
+ # being a finding of its own.
40
+ out << "WARNING: analysis truncated (filter too large); results may be incomplete.\n" if @truncated
41
+ return out << "\nNo weaknesses found.\n" if @findings.empty?
42
+
43
+ @findings.each { |f| out << render(f) }
44
+ out
45
+ end
46
+
47
+ # @return [Hash] JSON-ready shape for one filter.
48
+ def to_h
49
+ { source: @source, arches: @arches, truncated: @truncated, findings: @findings.map(&:to_h) }
50
+ end
51
+
52
+ private
53
+
54
+ def render(finding)
55
+ tag = Util.colorize("[#{finding.severity.to_s.upcase}]", t: SEVERITY_THEME[finding.severity])
56
+ arch = finding.arch ? " (#{Util.colorize(finding.arch, t: :arch)})" : ''
57
+ names = finding.syscalls
58
+ out = "\n#{tag} #{highlight(finding.title, names)}#{arch}\n"
59
+ out << paragraph(finding.detail, names, ' ', ' ')
60
+ out << paragraph(finding.condition, names, ' when: ', ' ') if finding.condition
61
+ out << paragraph(finding.remediation, names, ' fix: ', ' ') if finding.remediation
62
+ out
63
+ end
64
+
65
+ # +text+ wrapped to {WIDTH} columns, its first line prefixed with +first+ and the rest with
66
+ # +hang+ so they read as one block. Wrapping is measured before coloring, so the invisible
67
+ # escape codes never count towards the width.
68
+ # @return [String]
69
+ def paragraph(text, names, first, hang)
70
+ lines = []
71
+ indent = first
72
+ line = nil
73
+ text.to_s.split(/\s+/).each do |word|
74
+ if line.nil?
75
+ line = word
76
+ elsif indent.size + line.size + 1 + word.size <= WIDTH
77
+ line = "#{line} #{word}"
78
+ else
79
+ lines << (indent + line)
80
+ indent = hang
81
+ line = word
82
+ end
83
+ end
84
+ lines << (indent + line) if line
85
+ lines.map { |l| "#{highlight(l, names)}\n" }.join
86
+ end
87
+
88
+ # Paints the syscall names a finding is about wherever they appear in +text+, in the same color
89
+ # disasm gives them. Whole words only, so +read+ leaves +process_vm_readv+ alone.
90
+ # @return [String]
91
+ def highlight(text, names)
92
+ Array(names).uniq.reduce(text) do |painted, name|
93
+ painted.gsub(/\b#{Regexp.escape(name)}\b/) { Util.colorize(name, t: :syscall) }
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'seccomp-tools/audit/checks'
4
+ require 'seccomp-tools/audit/policy'
5
+ require 'seccomp-tools/audit/report'
6
+ require 'seccomp-tools/explain/analysis'
7
+ require 'seccomp-tools/symbolic/executor'
8
+
9
+ module SeccompTools
10
+ # Assesses a seccomp filter for weaknesses / escape routes and reports them as a {Audit::Report}.
11
+ #
12
+ # It runs the generic {Symbolic::Executor} over the filter (the same walk {Explain} uses), splits
13
+ # the leaves per architecture with {Explain::Analysis}, and runs each {Checks} rule against the
14
+ # per-arch {Policy}. Every supported architecture is assessed; architecture-specific quirks (e.g.
15
+ # amd64's x32 ABI) are registered per arch in {Checks}, never special-cased inline.
16
+ #
17
+ # @example
18
+ # insts = SeccompTools::Disasm.to_bpf(raw, :amd64).map(&:inst)
19
+ # puts SeccompTools::Audit.new(insts, arch: :amd64, source: 'a.out').audit
20
+ class Audit
21
+ # @param [Array<Instruction::Base>] instructions
22
+ # The filter, as +SeccompTools::Disasm.to_bpf(raw, arch).map(&:inst)+.
23
+ # @param [Symbol] arch
24
+ # The architecture the filter is written for, used when it does not itself branch on +arch+.
25
+ # @param [String?] source
26
+ # A label for the filter (e.g. a filename) shown in the report.
27
+ def initialize(instructions, arch:, source: nil)
28
+ @instructions = instructions
29
+ @arch = arch
30
+ @source = source
31
+ end
32
+
33
+ # Walks the filter, runs every check, and returns the {Report}.
34
+ # @return [Report]
35
+ def audit
36
+ leaves, truncated = Symbolic::Executor.new(@instructions).run
37
+ analysis = Explain::Analysis.new(leaves)
38
+ policies = analysis.sections(@arch).map { |section| Policy.new(analysis, section) }
39
+
40
+ findings = Checks::FILTER.flat_map { |check| check.call(analysis) }
41
+ policies.each do |policy|
42
+ Checks.section_checks(policy.arch_sym).each { |check| findings.concat(check.call(policy)) }
43
+ end
44
+
45
+ Report.new(source: @source, arches: policies.map(&:arch_name), findings:, truncated:)
46
+ end
47
+ end
48
+ end
@@ -7,7 +7,10 @@ require 'seccomp-tools/const'
7
7
  require 'seccomp-tools/instruction/instruction'
8
8
 
9
9
  module SeccompTools
10
- # Define the +struct sock_filter+, while more powerful.
10
+ # One BPF instruction, i.e. a +struct sock_filter+.
11
+ #
12
+ # Beyond the four fields of the C struct, a {BPF} also carries the architecture it belongs to and
13
+ # its line number, which together allow it to be disassembled into readable assembly.
11
14
  class BPF
12
15
  # @return [Integer] Line number.
13
16
  attr_reader :line
@@ -21,12 +24,13 @@ module SeccompTools
21
24
  attr_reader :k
22
25
  # @return [Symbol] Architecture.
23
26
  attr_reader :arch
24
- # @return [Set<Context>] Possible contexts before this instruction.
25
- attr_accessor :contexts
27
+ # @return [Set<SeccompTools::Symbolic::State>] Possible states before this instruction.
28
+ attr_accessor :states
26
29
 
27
30
  # Instantiate a {BPF} object.
28
- # @param [String] raw
29
- # One +struct sock_filter+ in bytes, should be 8 bytes long.
31
+ # @param [String, {Symbol => Integer}] raw
32
+ # One +struct sock_filter+, either as 8 raw bytes or as a hash of the +:code+, +:jt+, +:jf+
33
+ # and +:k+ fields.
30
34
  # @param [Symbol] arch
31
35
  # Architecture, for showing constant names in decompile.
32
36
  # @param [Integer] line
@@ -47,7 +51,7 @@ module SeccompTools
47
51
  end
48
52
  @arch = arch
49
53
  @line = line
50
- @contexts = Set.new
54
+ @states = Set.new
51
55
  @disasm_setting = {
52
56
  code: true,
53
57
  arg_infer: true
@@ -56,8 +60,11 @@ module SeccompTools
56
60
 
57
61
  # Pretty display the disassemble result.
58
62
  # @param [{Symbol => Boolean}] options
59
- # Set display settings.
63
+ # Display settings, merged into the current ones. Supports +:code+, whether to show the raw
64
+ # +code+, +jt+, +jf+ and +k+ fields, and +:arg_infer+, whether to annotate the line with the
65
+ # inferred syscall argument.
60
66
  # @return [String]
67
+ # One line of disassembly, without a trailing newline.
61
68
  def disasm(**options)
62
69
  @disasm_setting.merge!(options)
63
70
  if show_code?
@@ -69,12 +76,14 @@ module SeccompTools
69
76
  end
70
77
  end
71
78
 
72
- # Whether needs to dump code, jt, jf, k.
79
+ # Whether the raw +code+, +jt+, +jf+, +k+ fields need to be dumped.
80
+ # @return [Boolean]
73
81
  def show_code?
74
82
  @disasm_setting[:code]
75
83
  end
76
84
 
77
- # Whether needs to infer the syscall argument names.
85
+ # Whether the syscall argument names need to be inferred.
86
+ # @return [Boolean]
78
87
  def show_arg_infer?
79
88
  @disasm_setting[:arg_infer]
80
89
  end
@@ -88,8 +97,8 @@ module SeccompTools
88
97
  end
89
98
 
90
99
  # Command according to +code+.
91
- # @return [Symbol]
92
- # See {Const::BPF::COMMAND} for list of commands.
100
+ # @return [Symbol?]
101
+ # See {Const::BPF::COMMAND} for the list of commands, +nil+ if +code+ is invalid.
93
102
  def command
94
103
  Const::BPF::COMMAND.invert[code & 7]
95
104
  end
@@ -101,15 +110,16 @@ module SeccompTools
101
110
  inst.decompile
102
111
  end
103
112
 
104
- # @param [Context] context
105
- # Current context.
113
+ # Yields every branch that may be taken after executing this instruction.
114
+ # @param [SeccompTools::Symbolic::State] state
115
+ # Current state.
106
116
  # @yieldparam [Integer] pc
107
117
  # Program counter after this instruction.
108
- # @yieldparam [Context] ctx
109
- # Context after this instruction.
118
+ # @yieldparam [SeccompTools::Symbolic::State] st
119
+ # State after this instruction.
110
120
  # @return [void]
111
- def branch(context, &block)
112
- inst.branch(context).each(&block)
121
+ def branch(state, &)
122
+ inst.branch(state).each(&)
113
123
  end
114
124
 
115
125
  # Corresponding instruction object.
@@ -10,8 +10,11 @@ module SeccompTools
10
10
  # Summary of this command.
11
11
  SUMMARY = 'Seccomp bpf assembler.'
12
12
  # Usage of this command.
13
- USAGE = "asm - #{SUMMARY}\n\nUsage: seccomp-tools asm IN_FILE [options]"
13
+ USAGE = "asm - #{SUMMARY}\n\nUsage: seccomp-tools asm IN_FILE [options]".freeze
14
14
 
15
+ # Instantiate an {Asm} object, defaulting the output format to +:inspect+.
16
+ #
17
+ # Takes the same arguments as {Base#initialize}.
15
18
  def initialize(*)
16
19
  super
17
20
  option[:format] = :inspect
@@ -19,10 +22,11 @@ module SeccompTools
19
22
 
20
23
  # Define option parser.
21
24
  # @return [OptionParser]
25
+ # The parser of this command's options.
22
26
  def parser
23
27
  @parser ||= OptionParser.new do |opt|
24
28
  opt.banner = usage
25
- opt.on('-o', '--output FILE', 'Output result into FILE instead of stdout.') do |o|
29
+ opt.on('-o', '--output FILE', 'Write output to FILE instead of stdout.') do |o|
26
30
  option[:ofile] = o
27
31
  end
28
32
 
@@ -36,7 +40,7 @@ module SeccompTools
36
40
  end
37
41
  end
38
42
 
39
- # Handle options.
43
+ # Assembles the input file and writes the result in the requested format.
40
44
  # @return [void]
41
45
  def handle
42
46
  return unless super
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ require 'seccomp-tools/audit'
6
+ require 'seccomp-tools/cli/base'
7
+ require 'seccomp-tools/cli/filter_input'
8
+ require 'seccomp-tools/disasm/disasm'
9
+ require 'seccomp-tools/logger'
10
+
11
+ module SeccompTools
12
+ module CLI
13
+ # Handle 'audit' command.
14
+ class Audit < Base
15
+ include FilterInput
16
+
17
+ # Summary of this command.
18
+ SUMMARY = 'Assess a seccomp filter for weaknesses and escape routes.'
19
+ # Usage of this command.
20
+ USAGE = "audit - #{SUMMARY}\n\nUsage: seccomp-tools audit [options] [BPF_FILE|EXEC]".freeze
21
+
22
+ # Instantiate an {Audit} object.
23
+ #
24
+ # Takes the same arguments as {Base#initialize}.
25
+ def initialize(*)
26
+ super
27
+ option[:format] = :human
28
+ end
29
+
30
+ # Define option parser.
31
+ # @return [OptionParser]
32
+ # The parser of this command's options.
33
+ def parser
34
+ @parser ||= OptionParser.new do |opt|
35
+ opt.banner = usage
36
+ option_filter_source(opt, 'audit')
37
+ option_arch(opt, 'With an executable or --pid the architecture is auto-detected instead.')
38
+
39
+ opt.on('-f', '--format FORMAT', %i[human json], 'Output format, one of <human|json>.',
40
+ 'Default: human') do |f|
41
+ option[:format] = f
42
+ end
43
+ end
44
+ end
45
+
46
+ # Reads the filter(s) from a BPF file, an executable, or an existing process, then reports the
47
+ # weaknesses of each.
48
+ # @return [void]
49
+ def handle
50
+ return unless super
51
+
52
+ filters = collect_filters
53
+ return if filters.empty?
54
+
55
+ option[:format] == :json ? emit_json(filters) : emit_human(filters)
56
+ end
57
+
58
+ private
59
+
60
+ # Prints each filter's report, warning first when several filters stack.
61
+ def emit_human(filters)
62
+ if filters.size > 1
63
+ Logger.warn("#{filters.size} filters are installed; they stack, so a syscall must pass every one " \
64
+ '(most restrictive wins). Each is audited separately below.')
65
+ end
66
+ each_report(filters) { |report| output { report.to_s } }
67
+ end
68
+
69
+ # Prints one JSON document describing every stacked filter.
70
+ def emit_json(filters)
71
+ reports = []
72
+ each_report(filters) { |report| reports << report.to_h }
73
+ output { "#{JSON.pretty_generate(stacked_filters: filters.size, reports:)}\n" }
74
+ end
75
+
76
+ # Yields the {Audit::Report} of each filter, labelling stacked filters like +explain+ does.
77
+ def each_report(filters)
78
+ filters.each_with_index do |(raw, arch, source), idx|
79
+ label = filters.size > 1 ? "#{source} (filter ##{idx})" : source
80
+ insts = SeccompTools::Disasm.to_bpf(raw, arch).map(&:inst)
81
+ yield SeccompTools::Audit.new(insts, arch:, source: label).audit
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -2,13 +2,19 @@
2
2
 
3
3
  require 'optparse'
4
4
 
5
+ require 'seccomp-tools/logger'
5
6
  require 'seccomp-tools/util'
6
7
 
7
8
  module SeccompTools
8
9
  module CLI
9
10
  # Base class for handlers.
11
+ #
12
+ # Each subcommand is a subclass that defines a +USAGE+ constant and a +parser+, and overrides
13
+ # {#handle} to do its work.
10
14
  class Base
11
- # @return [{Symbol => Object}] Options.
15
+ # @return [{Symbol => Object}]
16
+ # Options parsed from the command line. Common keys are +:arch+, +:ifile+, +:ofile+,
17
+ # +:format+, +:limit+, +:pid+ and +:verbose+, depending on the subcommand.
12
18
  attr_reader :option
13
19
  # @return [Array<String>] Arguments array.
14
20
  attr_reader :argv
@@ -23,14 +29,29 @@ module SeccompTools
23
29
 
24
30
  private
25
31
 
26
- # Handle show help message.
32
+ # Parses the common options, and shows the help message when asked to.
27
33
  # @return [Boolean]
28
- # For decestors to check whether need to continue.
34
+ # For descendants to check whether they need to continue. +false+ when the help message
35
+ # was shown and there is nothing left to do.
29
36
  def handle
30
- return CLI.show(parser.help) if argv.empty? || %w[-h --help].any? { |h| argv.include?(h) }
37
+ return CLI.show(parser.help) if argv.empty? || %w[-h --help].intersect?(argv)
31
38
 
32
39
  parser.parse!(argv)
33
- option[:arch] ||= Util.system_arch
40
+
41
+ # Fill in the architecture from the host when --arch was not given. A command that offers
42
+ # --arch needs a concrete architecture to name syscalls; if the host CPU is one seccomp-tools
43
+ # does not recognize, auto-detection cannot supply one, so fail with a clear message instead
44
+ # of letting :unknown reach a syscall-table lookup and raise deep down.
45
+ return true unless option[:arch].nil?
46
+
47
+ arch = Util.system_arch
48
+ if arch == :unknown && @arch_option_offered
49
+ Logger.error('Could not detect the host architecture; specify one with ' \
50
+ "--arch <#{Util.supported_archs.join('|')}>.")
51
+ return false
52
+ end
53
+
54
+ option[:arch] = arch
34
55
  true
35
56
  end
36
57
 
@@ -43,6 +64,9 @@ module SeccompTools
43
64
  end
44
65
 
45
66
  # Write data to stdout or file(s).
67
+ #
68
+ # When +option[:ofile]+ is set, each call writes to a new file named after it with an
69
+ # incrementing serial number, see {#file_of}. Colors are disabled while writing to a file.
46
70
  # @yieldreturn [String]
47
71
  # The data to be written.
48
72
  # @return [void]
@@ -65,7 +89,7 @@ module SeccompTools
65
89
  # @param [String] file
66
90
  # Filename.
67
91
  # @param [Integer] serial
68
- # serial number, starts from zero.
92
+ # Serial number, starts from zero.
69
93
  # @return [String]
70
94
  # Result filename.
71
95
  # @example
@@ -89,11 +113,30 @@ module SeccompTools
89
113
  self.class.const_get(:USAGE)
90
114
  end
91
115
 
92
- def option_arch(opt)
116
+ # Warns about positional arguments left over in {#argv} after the command has taken what it
117
+ # needs, e.g. an exec given together with +-c+, or anything after +--pid+. The command still
118
+ # proceeds.
119
+ # @return [void]
120
+ def warn_ignored_arguments
121
+ return if argv.empty?
122
+
123
+ Logger.warn("ignoring unused argument#{'s' if argv.size > 1}: #{argv.join(' ')}")
124
+ end
125
+
126
+ # Registers the common +--arch+ option on +opt+.
127
+ #
128
+ # @param [OptionParser] opt
129
+ # The parser to add the option to.
130
+ # @param [Array<String>] extra_desc
131
+ # Extra description lines appended after the default ones, for command-specific guidance.
132
+ # @return [void]
133
+ def option_arch(opt, *extra_desc)
134
+ @arch_option_offered = true
93
135
  supported = Util.supported_archs
94
136
  opt.on('-a', '--arch ARCH', supported, 'Specify architecture.',
95
137
  "Supported architectures are <#{supported.join('|')}>.",
96
- "Default: #{Util.system_arch}") do |a|
138
+ 'Default: auto-detected from the host machine.',
139
+ 'Set it when the filter targets an architecture other than the host.', *extra_desc) do |a|
97
140
  option[:arch] = a
98
141
  end
99
142
  end