baseline-rb 0.2.1 → 0.3.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.
@@ -3,12 +3,24 @@
3
3
  require "ctx_parser/configuration"
4
4
  require "ctx_parser/input"
5
5
  require_relative "document"
6
- require_relative "facts"
6
+ require_relative "fact_builder"
7
7
  require_relative "credential"
8
8
 
9
9
  module Baseline
10
- # 共享输入与提取边界;CtxParser 跟踪来源,Facts 归纳领域证据。
10
+ # 共享输入与提取边界;CtxParser 跟踪来源,FactBuilder 归纳领域证据。
11
11
  class Parser < CtxParser::Configuration
12
+ # 只有明确声明的事实才属于适配器契约;新增规则不能默认获得实现能力。
13
+ SUPPORTED_FACTS = %w[
14
+ snmp.enabled snmp.versions snmp.write_enabled snmp.v3_auth_priv snmp.acl.bound snmp.acl.restricted snmp.acl.sources
15
+ ntp.servers ntp.authentication ntp.source syslog.servers syslog.source
16
+ ssh.enabled ssh.versions ssh.vty_ssh_only ssh.vty_aaa management.acl.bound management.acl.restricted management.acl.sources
17
+ services credentials.storage_safe credentials.records tacacs.servers tacacs.login tacacs.authorization tacacs.accounting tacacs.keys_present
18
+ ].freeze
19
+
20
+ # 声明尚未建模的事实;未知能力不能通过空值或默认值伪装成已支持。
21
+ UNSUPPORTED_FACTS = {}.freeze
22
+ PARTIAL_FACTS = {}.freeze
23
+
12
24
  SERVICE_PORTS = { "ssh" => 22, "telnet" => 23, "snmp" => 161, "snmptrap" => 162 }.freeze
13
25
  SNMP_OPTIONS = %w[auth priv authentication-mode privacy-mode acl access use-ipv4acl group local engineid engineID
14
26
  encrypted localizedkey localizedV2key remote udp-port].freeze
@@ -28,28 +40,38 @@ module Baseline
28
40
  issues << { code: :incomplete_final_line, line: cursor.lines.last.number }
29
41
  end
30
42
  @source_issues = issues
31
- @facts = Facts.new(complete: complete, issues: issues)
43
+ @fact_builder = FactBuilder.new(complete: complete, issues: issues)
32
44
  if roots.empty?
33
45
  return CtxParser::Result.new(status: :failed, issues: issues + [ { code: :empty_configuration } ])
34
46
  end
35
47
  prepare_configuration
36
- if (hostname = matching_blocks(hostname_pattern).last)
37
- name = analyze(hostname) { hostname.text[hostname_pattern, 1] }
38
- record("hostname", name)
39
- capture(hostname, name, [ "facts", "hostname" ])
48
+ if (hostname = matching_blocks(hostname_family_pattern).last)
49
+ name = effective_configured_value(hostname_pattern, entries: [ hostname ], family: hostname_family_pattern, fact: "hostname")
50
+ if name
51
+ record_fact("hostname", name)
52
+ capture(hostname, name, [ "facts", "hostname" ])
53
+ end
40
54
  end
41
- access_lists
42
- management
43
- services
44
- authentication
45
- @facts.result
55
+ parse_access_lists
56
+ parse_management
57
+ parse_services
58
+ parse_authentication
59
+ self.class::UNSUPPORTED_FACTS.each_key { |path| record_unknown_fact(path, code: :unsupported_capability) }
60
+ @fact_builder.build
61
+ end
62
+
63
+ # 以相同顺序编排各厂商的原子领域能力,语法提取由适配器实现。
64
+ def parse_services
65
+ parse_ntp
66
+ parse_syslog
67
+ parse_snmp
46
68
  end
47
69
 
48
70
  # 在领域解析前准备当前配置的词元和引用索引。
49
71
  def prepare_configuration; end
50
72
 
51
73
  # 不使用命名 ACL 的平台由原生管理来源声明提供事实。
52
- def access_lists; end
74
+ def parse_access_lists; end
53
75
 
54
76
  # 返回本次解析的根配置块。
55
77
  def roots = @blocks
@@ -57,6 +79,8 @@ module Baseline
57
79
  def document_class = Document
58
80
  # 匹配当前平台显式声明的主机名。
59
81
  def hostname_pattern = /\A(?:hostname|sysname)\s+(\S+)\z/
82
+ # 损坏的最终身份声明也属于同族,不能继续使用旧身份选择设备策略。
83
+ def hostname_family_pattern = /\A(?:hostname|sysname)(?:\s|\z)/
60
84
  # 保留原始文本,但不将可能截断的词元纳入事实构建树。
61
85
  def exclude_incomplete_final_line
62
86
  line = cursor.lines.last
@@ -90,6 +114,57 @@ module Baseline
90
114
  end.uniq
91
115
  end
92
116
 
117
+ # 地址语法在输入边界校验;损坏的服务器声明不能变成已知的空集或数量。
118
+ def configured_server_addresses(scope, pattern, entries, negate: nil)
119
+ values = {}
120
+ entries.each do |entry|
121
+ negative = negate && entry.text.start_with?("#{negate} ")
122
+ text = negative ? entry.text.delete_prefix("#{negate} ") : entry.text
123
+ match = pattern.match(text)
124
+ address = match && match[1]
125
+ unless server_address?(address)
126
+ record_unknown_fact(scope, [ entry ], code: :invalid_server_address)
127
+ next
128
+ end
129
+ identity = block_given? ? yield(text, match) : address
130
+ unless identity
131
+ record_unknown_fact(scope, [ entry ], code: :unsupported_server_options)
132
+ next
133
+ end
134
+ analyze(entry) { address }
135
+ negative ? values.delete(identity) : values[identity] = address
136
+ end
137
+ values.values.uniq
138
+ end
139
+
140
+ # 服务器可选项必须完整消费;未知参数、重复项及缺失值不能作为有效配置。
141
+ def parse_server_options(text, flags: [], integers: {}, names: [], interfaces: [])
142
+ parts, values = text.split, {}
143
+ keywords = flags + integers.keys + names + interfaces
144
+ until parts.empty?
145
+ key = parts.shift
146
+ return if values.key?(key) || !keywords.include?(key)
147
+ if flags.include?(key)
148
+ values[key] = true
149
+ next
150
+ end
151
+ value = parts.shift
152
+ return if value.nil? || keywords.include?(value)
153
+ if integers.key?(key)
154
+ return unless /\A\d+\z/.match?(value) && integers.fetch(key).cover?(value.to_i)
155
+ value = value.to_i
156
+ elsif interfaces.include?(key)
157
+ return unless /\A[a-zA-Z][\w.:\/-]*\z/.match?(value)
158
+ value += parts.shift if !/\d/.match?(value) && /\A\d[\d.:\/-]*\z/.match?(parts.first.to_s)
159
+ return unless /\d/.match?(value)
160
+ end
161
+ values[key] = value
162
+ end
163
+ return if values.key?("minpoll") && values.key?("maxpoll") && values["minpoll"] > values["maxpoll"]
164
+
165
+ values
166
+ end
167
+
93
168
  # 按配置顺序选取最后一条匹配声明。
94
169
  def last_configured_value(pattern, entries = roots)
95
170
  entry = entries.reverse.find { |candidate| !candidate.text[pattern, 1].nil? }
@@ -98,39 +173,51 @@ module Baseline
98
173
  analyze(entry) { entry.text[pattern, 1] }
99
174
  end
100
175
 
176
+ # 先选取最后一条同族声明,再校验语法,避免回落到已被撤销或损坏的旧值。
177
+ def effective_configured_value(pattern, entries: roots, family:, fact:, reset: nil)
178
+ entry = entries.reverse.find { |candidate| family.match?(candidate.text) }
179
+ return unless entry
180
+ return analyze(entry) { nil } if reset&.match?(entry.text)
181
+ match = pattern.match(entry.text)
182
+ return analyze(entry) { match[1] } if match
183
+
184
+ record_unknown_fact(fact, [ entry ])
185
+ nil
186
+ end
187
+
101
188
  # 保存最后生效的标量声明及对应来源,不将被覆盖的声明作为当前证据。
102
189
  def record_configured_value(path, pattern, entries = roots)
103
190
  entry = entries.reverse.find { |candidate| !candidate.text[pattern, 1].nil? }
104
191
  return unless entry
105
192
 
106
- record(path, analyze(entry) { entry.text[pattern, 1] }, [ entry ])
193
+ record_fact(path, analyze(entry) { entry.text[pattern, 1] }, [ entry ])
107
194
  end
108
195
 
109
196
  # 启用状态可能由正向或撤销声明决定,保留所有参与归约的来源。
110
197
  def record_configured_state(path, on, off)
111
198
  entries = roots.select { |entry| on.match?(entry.text) || off.match?(entry.text) }
112
- record(path, configured_state(on, off, entries), entries)
199
+ record_fact(path, configured_state(on, off, entries), entries)
113
200
  end
114
201
 
115
202
  # 保存归一化事实及其来源证据。
116
- def record(path, value, entries = [])
117
- return unless @facts.record(path, value, lines: entries.map(&:line))
203
+ def record_fact(path, value, entries = [])
204
+ return unless @fact_builder.record_fact(path, value, lines: entries.map(&:line))
118
205
 
119
206
  # 来源块中尚未解释的命令不能标记为解析成功。
120
207
  cursor.capture_many(entries.select { |entry| cursor.parsed?(entry.index) }, data: value, path: [ "facts", path ])
121
208
  end
122
209
 
123
210
  # 记录事实不可用的原因,并保留对应的来源行。
124
- def unknown(path, entries = [], code: :unsupported_syntax)
125
- @facts.unknown(path, lines: entries.map(&:line), code: code)
211
+ def record_unknown_fact(path, entries = [], code: :unsupported_syntax)
212
+ @fact_builder.record_unknown_fact(path, lines: entries.map(&:line), code: code)
126
213
  entries.each { |entry| cursor.mark(entry.index, state: :unsupported) }
127
214
  end
128
215
 
129
216
  # 未解析的访问声明不能抹除其他已确认的写权限。
130
- def unknown_snmp_access(entries, code: :unsupported_syntax)
131
- @facts.unknown_snmp_permission(lines: entries.map(&:line), code: code)
217
+ def record_unknown_snmp_access(entries, code: :unsupported_syntax)
218
+ @fact_builder.record_unknown_snmp_permission(lines: entries.map(&:line), code: code)
132
219
  %w[snmp.communities snmp.community_present snmp.users snmp.versions snmp.v3_auth_priv snmp.acl].each do |path|
133
- unknown(path, entries, code: code)
220
+ record_unknown_fact(path, entries, code: code)
134
221
  end
135
222
  end
136
223
 
@@ -150,13 +237,19 @@ module Baseline
150
237
  # 记录凭据属性,不保留凭据秘密值。
151
238
  def record_credential(owner, kind, format, secret, entry)
152
239
  value = analyze(entry) { Credential.new(owner: owner, kind: kind, format: format, secret: secret, line: entry.line) }
153
- index = @facts.record_credential(value)
240
+ index = @fact_builder.record_credential(value)
154
241
  capture(entry, value.to_h, [ "facts", "credentials.records", index ])
155
242
  end
156
243
 
157
244
  # 注册有序 ACL 规则与别名,同时保留不支持的语法。
158
245
  def register_acl(name, entries, aliases: [], supported: true, source: nil, &block)
159
246
  commands = entries.reject { |entry| /\A(?:\d+\s+)?(?:description|remark|step|statistics|hardware-count)\b/.match?(entry.text) }
247
+ # 厂商显式序号是规则身份;重复身份不能靠来源先后消解。无序号规则保留原有顺序语义。
248
+ sequences = commands.filter_map do |entry|
249
+ match = /\A(?:access-list\s+\S+\s+)?(?:rule\s+)?(\d+)\s+(?:permit|deny)\b/.match(entry.text)
250
+ Integer(match[1], 10) if match
251
+ end
252
+ duplicate_sequences = sequences.uniq.size != sequences.size
160
253
  parsed = commands.map.with_index do |entry, index|
161
254
  value = analyze(entry) { block.call(entry, index) }
162
255
  cursor.mark(entry.index, state: :unsupported) unless value
@@ -169,8 +262,9 @@ module Baseline
169
262
  source ? source.line_range.cover?(line) : damaged_numbered_acl?(cursor.lines.fetch(line - 1).text, name)
170
263
  end
171
264
  acl = Acl.new(name: name, line: entries.first&.line, rules: parsed.compact,
172
- supported: supported && !damaged && parsed.none?(&:nil?))
173
- if @facts.register_acl(acl, aliases: aliases, lines: entries.map(&:line)).any?
265
+ supported: supported && !damaged && !duplicate_sequences && parsed.none?(&:nil?))
266
+ conflicts = @fact_builder.register_acl(acl, aliases: aliases, lines: entries.map(&:line))
267
+ if duplicate_sequences || conflicts.any?
174
268
  entries.each { |entry| cursor.mark(entry.index, state: :unsupported) }
175
269
  end
176
270
  end
@@ -186,16 +280,20 @@ module Baseline
186
280
  end
187
281
 
188
282
  # 归一化 ACL 来源地址;不推测不支持语法的含义。
189
- def acl_source_network(parts)
283
+ def parse_acl_source_network(parts)
190
284
  token = parts.shift
191
285
  return "0.0.0.0/0" if token == "any"
192
- token = parts.shift if token == "host"
286
+ if token == "host"
287
+ address = parts.shift
288
+ raise ArgumentError, "A host requires one IPv4 address" unless address&.match?(/\A\d+(?:\.\d+){3}\z/)
289
+ return Acl.source_network(address)
290
+ end
193
291
  wildcard = parts.shift if parts.first&.match?(/\A(?:\d+(?:\.\d+){3}|0)\z/)
194
292
  Acl.source_network(token, wildcard)
195
293
  end
196
294
 
197
295
  # 显式端口名称仅属于配置语法,不代表组织批准值。
198
- def service_port(token)
296
+ def parse_service_port(token)
199
297
  return SERVICE_PORTS[token] unless token&.match?(/\A\d+\z/)
200
298
  value = token.to_i
201
299
  value if value.between?(0, 65_535)
@@ -220,19 +318,19 @@ module Baseline
220
318
  entry = entries.last
221
319
  return analyze(entry) { default } if entry.text == "#{negate} #{command}"
222
320
  token = entry.text[/\A#{prefix}\s+(\d+)\z/, 1]
223
- port = service_port(token)
321
+ port = parse_service_port(token)
224
322
  return analyze(entry) { port } if port && port.positive?
225
- unknown_listener_port(scope, entries)
323
+ record_unknown_listener_port(scope, entries)
226
324
  nil
227
325
  end
228
326
 
229
327
  # 监听端口无法解析时,将依赖该端口的事实保留为未知。
230
- def unknown_listener_port(scope, entries)
231
- %w[sources restricted].each { |field| unknown("#{scope}.#{field}", entries, code: :unsupported_service_port) }
328
+ def record_unknown_listener_port(scope, entries)
329
+ %w[sources restricted].each { |field| record_unknown_fact("#{scope}.#{field}", entries, code: :unsupported_service_port) }
232
330
  end
233
331
 
234
332
  # 区分模式未声明、格式无效及包含完整算法与秘密值的声明。
235
- def snmp_secret(settings, keyword, algorithms:, optional_algorithm: false, aes_size: false)
333
+ def parse_snmp_secret(settings, keyword, algorithms:, optional_algorithm: false, aes_size: false)
236
334
  tokens = settings.split
237
335
  index = tokens.index(keyword)
238
336
  return false unless index
@@ -250,23 +348,23 @@ module Baseline
250
348
  end
251
349
 
252
350
  # 提取 SNMP 凭据属性,不持久化秘密值。
253
- def snmp_credentials(owner, modes, entry, format: nil)
351
+ def record_snmp_credentials(owner, modes, entry, format: nil)
254
352
  modes.each_value do |value|
255
353
  next if value == false
256
354
  stored_format, secret = value || [ "unknown", nil ]
257
355
  record_credential(owner, "snmp_user", stored_format || format || "unknown", secret, entry)
258
356
  end
259
- unknown("snmp.v3_auth_priv", [ entry ], code: :invalid_snmp_security) if modes.value?(nil)
357
+ record_unknown_fact("snmp.v3_auth_priv", [ entry ], code: :invalid_snmp_security) if modes.value?(nil)
260
358
  modes.transform_values { |value| value ? true : value }
261
359
  end
262
360
 
263
361
  # 同名组可出现在不同安全级别;后续只读声明不能消除已有写权限。
264
- def snmp_groups(entries)
362
+ def configured_snmp_groups(entries)
265
363
  entries.map { |entry| yield(entry) }.group_by(&:first).transform_values do |definitions|
266
364
  records = definitions.map(&:last)
267
365
  acls = records.map { |record| record[:acl] }.uniq
268
366
  { acl: acls.one? ? acls.first : nil, acl_ambiguous: acls.size > 1,
269
- write: Facts.write_permission(records.map { |record| record[:write] }),
367
+ write: FactBuilder.write_permission_state(records.map { |record| record[:write] }),
270
368
  line: records.first[:line], lines: records.map { |record| record[:line] } }
271
369
  end
272
370
  end
@@ -64,12 +64,12 @@ module Baseline
64
64
 
65
65
  # 解析上下文对应的策略值,并保留选择路径。
66
66
  def resolve(rule, context)
67
- resolution = resolve_with_source(rule, context)
67
+ resolution = resolve_with_path(rule, context)
68
68
  [ resolution.value, resolution.reason ]
69
69
  end
70
70
 
71
71
  # 保留相对于 expected 的命中范围、默认分支或未解析选择器路径。
72
- def resolve_with_source(rule, context)
72
+ def resolve_with_path(rule, context)
73
73
  value = rule.expected
74
74
  path = []
75
75
  while selector?(value)
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "configuration"
3
+ require_relative "evidence"
4
4
 
5
5
  require "ctx_parser/immutable"
6
6
  require "json"
@@ -12,6 +12,7 @@ module Baseline
12
12
  input_incomplete: "配置不完整,需提供完整备份后重查", unsupported_platform: "尚未支持此平台的配置语法",
13
13
  scope_mismatch: "不在策略适用范围内", scope_unknown: "缺少判断适用范围或选择批准值所需的上下文",
14
14
  policy_missing: "未定义当前范围的批准值,请完善基线策略", fact_unavailable: "配置事实缺失或包含尚未支持的语法,请查看解析诊断",
15
+ unsupported_capability: "此厂商的当前适配器尚未建模该能力,不能据此声明合规",
15
16
  fact_type_mismatch: "解析事实类型与检查要求不一致", unnecessary_service_enabled: "存在策略要求关闭的服务",
16
17
  service_state_unknown: "配置未明确声明部分服务状态,不能推断厂商默认值", deviation: "实际配置偏离批准值",
17
18
  unsupported_address_family: "地址格式超出当前 IPv4 检查能力", weak_password: "发现未满足强度要求的明文凭据",
data/lib/baseline/rule.rb CHANGED
@@ -90,7 +90,7 @@ module Baseline
90
90
  when :string_set, :network_set then string_set?(value)
91
91
  end
92
92
  raise ArgumentError, "Invalid baseline expected value for #{id}" unless valid
93
- value.each { |network| Acl.address_range(network) } if value_type == :network_set
93
+ value.each { |network| Acl.ipv4_range(network) } if value_type == :network_set
94
94
  end
95
95
 
96
96
  # 展示对象身份与状态,避免泄露原始回显或凭据。
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Baseline
4
4
  # 公开包版本,供 RubyGems 元数据和运行时调用方共用。
5
- VERSION = "0.2.1"
5
+ VERSION = "0.3.1"
6
6
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: baseline-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gatework contributors
@@ -13,16 +13,16 @@ dependencies:
13
13
  name: ctx_parser
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
- - - "~>"
16
+ - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: 0.2.0
18
+ version: '0'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
- - - "~>"
23
+ - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: 0.2.0
25
+ version: '0'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: digest
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -113,20 +113,21 @@ files:
113
113
  - lib/baseline/cisco_ios/parser.rb
114
114
  - lib/baseline/cisco_nxos/parser.rb
115
115
  - lib/baseline/comware/parser.rb
116
- - lib/baseline/configuration.rb
117
116
  - lib/baseline/credential.rb
118
117
  - lib/baseline/document.rb
119
118
  - lib/baseline/evaluator.rb
120
- - lib/baseline/facts.rb
119
+ - lib/baseline/evidence.rb
120
+ - lib/baseline/fact_builder.rb
121
121
  - lib/baseline/h3c/parser.rb
122
122
  - lib/baseline/hillstone/document.rb
123
123
  - lib/baseline/hillstone/parser.rb
124
124
  - lib/baseline/huawei/parser.rb
125
+ - lib/baseline/multiline_document.rb
126
+ - lib/baseline/palo_alto/command_index.rb
125
127
  - lib/baseline/palo_alto/document.rb
126
128
  - lib/baseline/palo_alto/parser.rb
127
129
  - lib/baseline/parser.rb
128
130
  - lib/baseline/policy.rb
129
- - lib/baseline/quoted_document.rb
130
131
  - lib/baseline/report.rb
131
132
  - lib/baseline/rule.rb
132
133
  - lib/baseline/version.rb