baseline-rb 0.1.0 → 0.2.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.
@@ -1,30 +1,32 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../parser"
4
+
3
5
  require "shellwords"
4
- require_relative "../hillstone_document"
6
+ require_relative "document"
5
7
 
6
8
  module Baseline
7
- module Parsers
9
+ module Hillstone
8
10
  # StoneOS 管理端点与原生主机允许列表不同于路由器 VTY。
9
- class Hillstone < Base
11
+ class Parser < Baseline::Parser
10
12
  MANAGEMENT_SERVICES = %w[ssh telnet http https ftp snmp].freeze
11
13
  HOST_PROTOCOLS = %w[ssh telnet http https netconf any].freeze
12
14
 
13
15
  private
14
16
 
15
17
  # 选择适合当前平台语法的文档词法解析器。
16
- def configuration_document_class = HillstoneDocument
18
+ def document_class = Document
17
19
 
18
- # 解析管理访问暴露检查使用的有序 ACL 定义。
19
- def access_lists
20
+ # 初始化当前配置的词元缓存,供各领域解析阶段共享。
21
+ def prepare_configuration
20
22
  @tokens = {}
21
23
  end
22
24
 
23
25
  # 已配置主机 ACL 本身不能证明管理监听服务已启用。
24
26
  def management
25
- interfaces = sections(/\Ainterface\s+/)
27
+ interfaces = matching_blocks(/\Ainterface\s+/)
26
28
  states = interfaces.map do |section|
27
- matching(/\A(?:no )?manage\s+/, section.children).each_with_object({}) do |entry, values|
29
+ matching_blocks(/\A(?:no )?manage\s+/, section.children).each_with_object({}) do |entry, values|
28
30
  parts = tokens(entry)
29
31
  negative = parts.first == "no"
30
32
  parts = parts.drop(1) if negative
@@ -48,7 +50,7 @@ module Baseline
48
50
 
49
51
  # 信任主机可使用 CIDR、掩码、区间或 any;跨协议按保守并集处理。
50
52
  def management_sources
51
- entries = matching(/\A(?:no )?admin host\b/)
53
+ entries = matching_blocks(/\A(?:no )?admin host\b/)
52
54
  sources = entries.flat_map do |entry|
53
55
  parts = tokens(entry)
54
56
  raise ArgumentError unless parts.take(2) == %w[admin host] && HOST_PROTOCOLS.include?(parts.last)
@@ -76,7 +78,7 @@ module Baseline
76
78
 
77
79
  # 提取时间同步服务器及相关认证配置。
78
80
  def ntp
79
- entries = matching(/\Antp server\b/)
81
+ entries = matching_blocks(/\Antp server\b/)
80
82
  sources = []
81
83
  servers = entries.filter_map do |entry|
82
84
  parts = tokens(entry)
@@ -93,14 +95,14 @@ module Baseline
93
95
  analyze(entry) { parts[2] }
94
96
  end
95
97
  record("ntp.servers", servers.uniq, entries)
96
- record("ntp.authentication", state(/\Antp authentication\z/, /\Ano ntp authentication\z/))
98
+ record("ntp.authentication", configured_state(/\Antp authentication\z/, /\Ano ntp authentication\z/))
97
99
  record("ntp.source", sources.first) if sources.any? && sources.first && sources.uniq.one?
98
- unknown("ntp", matching(/\Ano ntp server\b/)) if matching(/\Ano ntp server\b/).any?
100
+ unknown("ntp", matching_blocks(/\Ano ntp server\b/)) if matching_blocks(/\Ano ntp server\b/).any?
99
101
  end
100
102
 
101
103
  # 同一目标可供多个日志类别使用,统计时只计一次。
102
104
  def syslog
103
- entries = matching(/\Alogging syslog\b/)
105
+ entries = matching_blocks(/\Alogging syslog\b/)
104
106
  sources = []
105
107
  servers = entries.filter_map do |entry|
106
108
  parts = tokens(entry)
@@ -123,13 +125,13 @@ module Baseline
123
125
  end
124
126
  record("syslog.servers", servers.uniq, entries)
125
127
  record("syslog.source", sources.first) if sources.any? && sources.first && sources.uniq.one?
126
- unknown("syslog", matching(/\Ano logging syslog\b/)) if matching(/\Ano logging syslog\b/).any?
128
+ unknown("syslog", matching_blocks(/\Ano logging syslog\b/)) if matching_blocks(/\Ano logging syslog\b/).any?
127
129
  end
128
130
 
129
131
  # 轮询主机权限独立于出站 Trap 接收端。
130
132
  def snmp
131
- record("snmp.enabled", state(/\Asnmp-server manager\z/, /\Ano snmp-server manager\z/))
132
- entries = matching(/\Asnmp-server host\b/)
133
+ record("snmp.enabled", configured_state(/\Asnmp-server manager\z/, /\Ano snmp-server manager\z/))
134
+ entries = matching_blocks(/\Asnmp-server host\b/)
133
135
  versions, sources, communities = [], [], []
134
136
  entries.each do |entry|
135
137
  parts = tokens(entry)
@@ -139,7 +141,7 @@ module Baseline
139
141
  settings = parts.drop(offset + 1)
140
142
  if %w[1 2c].include?(settings.first) && settings[1] == "community" && !settings[2].to_s.empty? &&
141
143
  (settings.size == 3 || settings.size == 4 && %w[ro rw].include?(settings[3]))
142
- credential("snmp-community", "snmp", secret_format(settings[2]), settings[2], entry)
144
+ record_credential("snmp-community", "snmp", secret_format(settings[2]), settings[2], entry)
143
145
  versions << (settings.first == "1" ? "v1" : "v2c")
144
146
  communities << { write: settings[3] == "rw", line: entry.line }
145
147
  sources.concat(networks)
@@ -154,7 +156,7 @@ module Baseline
154
156
  unknown_snmp_access([ entry ], code: :unsupported_snmp_host)
155
157
  end
156
158
  record("snmp.versions", versions.uniq, entries)
157
- unsupported = matching(/\A(?:snmp-server (?:user|group)\b|no snmp-server (?:host|user|group)\b)/)
159
+ unsupported = matching_blocks(/\A(?:snmp-server (?:user|group)\b|no snmp-server (?:host|user|group)\b)/)
158
160
  unknown_snmp_access(unsupported, code: :unsupported_snmp_security) if unsupported.any? || versions.include?("v3")
159
161
  @facts.snmp(communities: communities, users: [])
160
162
  @facts.direct_acl(fact: "snmp.acl", sources: sources, bound: true, lines: entries.map(&:line)) if entries.any?
@@ -162,13 +164,13 @@ module Baseline
162
164
 
163
165
  # 分别记录 TACACS 定义与管理认证引用。
164
166
  def authentication
165
- definitions = sections(/\Aaaa-server\s+/).filter_map do |section|
167
+ definitions = matching_blocks(/\Aaaa-server\s+/).filter_map do |section|
166
168
  parts = tokens(section)
167
169
  unless parts.size == 4 && parts[2..] == %w[type tacacs+]
168
170
  unknown("tacacs", [ section ]) if parts.include?("tacacs+")
169
171
  next
170
172
  end
171
- endpoints = matching(/\A(?:host|backup[12])\b/, section.children)
173
+ endpoints = matching_blocks(/\A(?:host|backup[12])\b/, section.children)
172
174
  servers = endpoints.filter_map do |entry|
173
175
  values = tokens(entry)
174
176
  if server_address?(values[1]) && (values.size == 2 || values.size == 4 && values[2] == "vrouter")
@@ -178,7 +180,7 @@ module Baseline
178
180
  nil
179
181
  end
180
182
  end
181
- secrets = matching(/\Asecret\b/, section.children)
183
+ secrets = matching_blocks(/\Asecret\b/, section.children)
182
184
  keys = secrets.map { |entry| stored_secret("tacacs:#{parts[1]}", "tacacs", entry) }
183
185
  [ parts[1], { servers: servers.uniq, keys_present: keys.any? && keys.all?, line: section.line,
184
186
  declaration: section.children.empty? } ]
@@ -197,7 +199,7 @@ module Baseline
197
199
  record("tacacs.servers", schemes.values.flat_map { |scheme| scheme[:servers] }.uniq)
198
200
  record("tacacs.keys_present", schemes.values.all? { |scheme| scheme[:keys_present] }) if schemes.any?
199
201
  { "authentication-server" => "login", "authorization-mode" => "authorization" }.each do |command, fact|
200
- entries = matching(/\Aadmin #{command}\b/)
202
+ entries = matching_blocks(/\Aadmin #{command}\b/)
201
203
  next if entries.empty?
202
204
  parts = tokens(entries.last)
203
205
  name = parts[3] if parts.size == 4 && parts[2] == "aaa-server"
@@ -207,10 +209,10 @@ module Baseline
207
209
  unknown("tacacs.#{fact}", entries, code: :tacacs_scheme_unresolved)
208
210
  end
209
211
  end
210
- sections(/\Aadmin user\s+/).each do |section|
212
+ matching_blocks(/\Aadmin user\s+/).each do |section|
211
213
  owner = tokens(section)[2]
212
- passwords = matching(/\Apassword\b/, section.children)
213
- credential(owner, "local_user", "unknown", nil, section) if passwords.empty?
214
+ passwords = matching_blocks(/\Apassword\b/, section.children)
215
+ record_credential(owner, "local_user", "unknown", nil, section) if passwords.empty?
214
216
  passwords.each { |entry| stored_secret(owner, "local_user", entry) }
215
217
  end
216
218
  end
@@ -224,11 +226,11 @@ module Baseline
224
226
  valid = parts.size == 1 && !%w[cipher hash plain].include?(parts.first) || parts.size == 2 && %w[cipher hash plain].include?(parts.first)
225
227
  unless valid && !parts.last.to_s.empty?
226
228
  @facts.incomplete!(code: :invalid_credential_statement, lines: [ entry.line ])
227
- credential(owner, kind, "unknown", nil, entry)
229
+ record_credential(owner, kind, "unknown", nil, entry)
228
230
  return false
229
231
  end
230
232
  format, secret = parts.size == 2 && %w[cipher hash plain].include?(parts.first) ? parts : [ secret_format(parts.first.to_s), parts.first ]
231
- credential(owner, kind, format, secret, entry)
233
+ record_credential(owner, kind, format, secret, entry)
232
234
  true
233
235
  end
234
236
 
@@ -264,7 +266,7 @@ module Baseline
264
266
  if parts.size == 3 && parts.first == "range"
265
267
  first, last = parts.drop(1).map { |value| IPAddr.new(value) }
266
268
  raise ArgumentError unless first.ipv4? && last.ipv4? && first.prefix == 32 && last.prefix == 32 && first.to_i <= last.to_i
267
- Acl.networks([ [ first.to_i, last.to_i ] ])
269
+ Acl.network_prefixes([ [ first.to_i, last.to_i ] ])
268
270
  elsif parts.size == 1 || parts.size == 2
269
271
  value = IPAddr.new(parts.join("/"))
270
272
  raise ArgumentError unless value.ipv4?
@@ -1,9 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../comware/parser"
4
+
3
5
  module Baseline
4
- module Parsers
6
+ module Huawei
5
7
  # 区分 Huawei VRP 的 AAA 嵌套、模板引用与 Comware 域语法。
6
- class Huawei < Comware
8
+ class Parser < Comware::Parser
7
9
  private
8
10
 
9
11
  # 匹配当前平台显式启用 SSH 的命令。
@@ -21,10 +23,10 @@ module Baseline
21
23
  # 返回当前平台修改 SNMP 监听端口的命令模式。
22
24
  def snmp_port_command = "snmp-agent udp-port"
23
25
  # 从实际所属配置作用域中选取 AAA 声明。
24
- def aaa_entries = sections(/\Aaaa\z/).flat_map(&:children)
26
+ def aaa_entries = matching_blocks(/\Aaaa\z/).flat_map(&:children)
25
27
  # 解析认证域引用及其服务器组。
26
28
  def authentication_domains
27
- name = last(/\Adomain\s+(\S+)\s+admin\z/)
29
+ name = last_configured_value(/\Adomain\s+(\S+)\s+admin\z/)
28
30
  unless name
29
31
  %w[login authorization accounting].each { |kind| unknown("tacacs.#{kind}", code: :authentication_domain_unknown) }
30
32
  return []
@@ -35,26 +37,26 @@ module Baseline
35
37
  # 解析 VRP 认证域引用,避免将 VRP 语法应用到 Comware。
36
38
  def vrp_tacacs_references(kind, domains)
37
39
  domains.filter_map do |domain|
38
- template = last(/\Ahwtacacs-server\s+(\S+)\z/, domain.children)
39
- scheme = last(/\A#{kind}-scheme\s+(\S+)\z/, domain.children)
40
+ template = last_configured_value(/\Ahwtacacs-server\s+(\S+)\z/, domain.children)
41
+ scheme = last_configured_value(/\A#{kind}-scheme\s+(\S+)\z/, domain.children)
40
42
  next unless template && scheme
41
43
  definition = aaa_entries.find { |entry| entry.text == "#{kind}-scheme #{scheme}" }
42
44
  unless definition
43
45
  unknown("tacacs.#{kind == 'authentication' ? 'login' : kind}", [ domain ], code: :tacacs_scheme_unresolved)
44
46
  next
45
47
  end
46
- template if last(/\A#{kind}-mode\s+(.+)\z/, definition.children).to_s.split.first == "hwtacacs"
48
+ template if last_configured_value(/\A#{kind}-mode\s+(.+)\z/, definition.children).to_s.split.first == "hwtacacs"
47
49
  end
48
50
  end
49
51
 
50
52
  # 提取本地账号角色及凭据存储属性。
51
53
  def accounts
52
- matching(/\Alocal-user\s+\S+\s+password\b/, aaa_entries).each do |entry|
54
+ matching_blocks(/\Alocal-user\s+\S+\s+password\b/, aaa_entries).each do |entry|
53
55
  match = /\Alocal-user\s+(\S+)\s+password\s+(cipher|irreversible-cipher|simple)\s+(.+)\z/.match(entry.text)
54
56
  if match
55
- credential(match[1], "local_user", match[2], match[3], entry)
57
+ record_credential(match[1], "local_user", match[2], match[3], entry)
56
58
  else
57
- credential("local-user", "local_user", "unknown", nil, entry)
59
+ record_credential("local-user", "local_user", "unknown", nil, entry)
58
60
  end
59
61
  end
60
62
  end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../quoted_document"
4
+
5
+ module Baseline
6
+ module PaloAlto
7
+ # PAN-OS 的 set 导出可能包含引号包裹的多行证书与描述。
8
+ class Document < QuotedDocument
9
+ protected
10
+
11
+ # 已建模的多行值不能被误判为配置缺失;证书正文不参与语义分析。
12
+ def multiline_fact?(text)
13
+ /\Aset (?:deviceconfig system (?:hostname|service|permitted-ip|ntp-servers|snmp-setting|authentication-profile|route service)\b|shared (?:server-profile tacplus|authentication-profile|authentication-sequence|log-settings syslog)\b|mgt-config users\b|network profiles interface-management-profile\b)/.match?(text)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -1,24 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../parser"
4
+
3
5
  require "shellwords"
4
- require_relative "../palo_alto_document"
6
+ require_relative "document"
5
7
 
6
8
  module Baseline
7
- module Parsers
9
+ module PaloAlto
8
10
  # 将 PAN-OS set 语法归纳为与配置块平台一致的事实。
9
- class PaloAlto < Base
11
+ class Parser < Baseline::Parser
10
12
  MANAGEMENT_SERVICES = %w[ssh telnet http https].freeze
11
13
  RELEVANT = /\Aset (?:deviceconfig system |network (?:profiles interface-management-profile |interface )|shared (?:server-profile tacplus |authentication-profile |authentication-sequence |log-settings syslog )|mgt-config users )/
12
14
 
13
15
  private
14
16
 
15
17
  # 选择适合当前平台语法的文档词法解析器。
16
- def configuration_document_class = PaloAltoDocument
18
+ def document_class = Document
17
19
  # 匹配当前平台显式声明的主机名。
18
20
  def hostname_pattern = /\Aset deviceconfig system hostname\s+(\S+)\z/
19
21
 
20
22
  # 仅对管理配置子集分词一次,不处理无关的安全规则。
21
- def access_lists
23
+ def prepare_configuration
22
24
  @statements = roots.filter_map do |entry|
23
25
  next unless RELEVANT.match?(entry.text)
24
26
  { entry: entry, tokens: Shellwords.shellsplit(entry.text).drop(1) }
@@ -187,7 +189,7 @@ module Baseline
187
189
  @facts.incomplete!(code: :invalid_set_statement, lines: [ row[:entry].line ])
188
190
  next
189
191
  end
190
- credential("snmp-community", "snmp", secret_format(row[:tokens].last), row[:tokens].last, row[:entry])
192
+ record_credential("snmp-community", "snmp", secret_format(row[:tokens].last), row[:tokens].last, row[:entry])
191
193
  { write: false, line: row[:entry].line }
192
194
  end
193
195
  users_prefix = prefix + %w[v3 users]
@@ -202,7 +204,7 @@ module Baseline
202
204
  next
203
205
  end
204
206
  user[key] = !row[:tokens].last.empty?
205
- credential("snmp:#{name}", "snmp_user", secret_format(row[:tokens].last), row[:tokens].last, row[:entry])
207
+ record_credential("snmp:#{name}", "snmp_user", secret_format(row[:tokens].last), row[:tokens].last, row[:entry])
206
208
  end
207
209
  user
208
210
  end
@@ -227,7 +229,7 @@ module Baseline
227
229
  keys = servers.values.map do |server|
228
230
  secret = server.reverse.find { |row| row[:tokens][6] == "secret" }
229
231
  value = secret && scalar(secret[:tokens].take(7))
230
- credential("tacacs:#{name}", "tacacs", secret_format(value), value, secret[:entry]) if value
232
+ record_credential("tacacs:#{name}", "tacacs", secret_format(value), value, secret[:entry]) if value
231
233
  !value.to_s.empty?
232
234
  end
233
235
  [ name, { servers: addresses, keys_present: keys.any? && keys.all?, line: entries.first[:entry].line } ]
@@ -278,7 +280,7 @@ module Baseline
278
280
  row = entries.reverse.find { |entry| entry[:tokens][3] == "phash" }
279
281
  unless row
280
282
  external_profile = scalar([ "mgt-config", "users", name, "authentication-profile" ])
281
- credential(name, "local_user", "unknown", nil, entries.first[:entry]) unless external_profile && !external_profile.empty?
283
+ record_credential(name, "local_user", "unknown", nil, entries.first[:entry]) unless external_profile && !external_profile.empty?
282
284
  next
283
285
  end
284
286
  secret = scalar(row[:tokens].take(4))
@@ -287,7 +289,7 @@ module Baseline
287
289
  else
288
290
  "unknown"
289
291
  end
290
- credential(name, "local_user", format, secret, row[:entry])
292
+ record_credential(name, "local_user", format, secret, row[:entry])
291
293
  end
292
294
  end
293
295
 
@@ -0,0 +1,260 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ctx_parser/configuration"
4
+ require "ctx_parser/input"
5
+ require_relative "document"
6
+ require_relative "facts"
7
+ require_relative "credential"
8
+
9
+ module Baseline
10
+ # 共享输入与提取边界;CtxParser 跟踪来源,Facts 归纳领域证据。
11
+ class Parser < CtxParser::Configuration
12
+ SERVICE_PORTS = { "ssh" => 22, "telnet" => 23, "snmp" => 161, "snmptrap" => 162 }.freeze
13
+ SNMP_OPTIONS = %w[auth priv authentication-mode privacy-mode acl access use-ipv4acl group local engineid engineID
14
+ encrypted localizedkey localizedV2key remote udp-port].freeze
15
+
16
+ private
17
+
18
+ # 解释当前命令回显,保留不支持或格式错误的证据。
19
+ def parse_output(text, complete:)
20
+ begin
21
+ text = CtxParser::Input.text(text, encoding: :utf8, max_bytes: 16 * 1024 * 1024)
22
+ rescue ArgumentError, EncodingError
23
+ return CtxParser::Result.new(status: :failed, issues: [ { code: :invalid_configuration } ])
24
+ end
25
+ read_configuration(text, document_class: document_class)
26
+ issues = document.issues.dup
27
+ if (!complete || issues.any?) && exclude_incomplete_final_line
28
+ issues << { code: :incomplete_final_line, line: cursor.lines.last.number }
29
+ end
30
+ @source_issues = issues
31
+ @facts = Facts.new(complete: complete, issues: issues)
32
+ if roots.empty?
33
+ return CtxParser::Result.new(status: :failed, issues: issues + [ { code: :empty_configuration } ])
34
+ end
35
+ 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" ])
40
+ end
41
+ access_lists
42
+ management
43
+ services
44
+ authentication
45
+ @facts.result
46
+ end
47
+
48
+ # 在领域解析前准备当前配置的词元和引用索引。
49
+ def prepare_configuration; end
50
+
51
+ # 不使用命名 ACL 的平台由原生管理来源声明提供事实。
52
+ def access_lists; end
53
+
54
+ # 返回本次解析的根配置块。
55
+ def roots = @blocks
56
+ # 选择适合当前平台语法的文档词法解析器。
57
+ def document_class = Document
58
+ # 匹配当前平台显式声明的主机名。
59
+ def hostname_pattern = /\A(?:hostname|sysname)\s+(\S+)\z/
60
+ # 保留原始文本,但不将可能截断的词元纳入事实构建树。
61
+ def exclude_incomplete_final_line
62
+ line = cursor.lines.last
63
+ return false unless line && !line.raw.end_with?("\n", "\r") && cursor.state(line.index) != :ignored
64
+
65
+ entries, ancestors = roots, []
66
+ while (entry = entries.last)
67
+ if entry.index == line.index
68
+ retained = entries[0...-1].freeze
69
+ ancestors.reverse_each do |siblings, parent|
70
+ block = CtxParser::Block.new(cursor: cursor, header: parent.header, children: retained, last_index: parent.last_index)
71
+ retained = (siblings[0...-1] + [ block ]).freeze
72
+ end
73
+ @blocks = retained
74
+ cursor.mark(line.index, state: :unsupported)
75
+ return true
76
+ end
77
+ ancestors << [ entries, entry ]
78
+ entries = entry.children
79
+ end
80
+ false
81
+ end
82
+
83
+ # 筛选匹配已支持命令模式的来源条目。
84
+ def matching_blocks(pattern, entries = roots) = entries.select { |entry| pattern.match?(entry.text) }
85
+ # 提取匹配值,并关联对应的来源条目。
86
+ def configured_values(pattern, entries = roots)
87
+ entries.filter_map do |entry|
88
+ value = entry.text[pattern, 1]
89
+ analyze(entry) { value } unless value.nil?
90
+ end.uniq
91
+ end
92
+
93
+ # 按配置顺序选取最后一条匹配声明。
94
+ def last_configured_value(pattern, entries = roots)
95
+ entry = entries.reverse.find { |candidate| !candidate.text[pattern, 1].nil? }
96
+ return unless entry
97
+
98
+ analyze(entry) { entry.text[pattern, 1] }
99
+ end
100
+
101
+ # 保存归一化事实及其来源证据。
102
+ def record(path, value, entries = [])
103
+ return unless @facts.record(path, value, lines: entries.map(&:line))
104
+
105
+ # 来源块中尚未解释的命令不能标记为解析成功。
106
+ cursor.capture_many(entries.select { |entry| cursor.parsed?(entry.index) }, data: value, path: [ "facts", path ])
107
+ end
108
+
109
+ # 记录事实不可用的原因,并保留对应的来源行。
110
+ def unknown(path, entries = [], code: :unsupported_syntax)
111
+ @facts.unknown(path, lines: entries.map(&:line), code: code)
112
+ entries.each { |entry| cursor.mark(entry.index, state: :unsupported) }
113
+ end
114
+
115
+ # 未解析的访问声明不能抹除其他已确认的写权限。
116
+ def unknown_snmp_access(entries, code: :unsupported_syntax)
117
+ @facts.unknown_snmp_permission(lines: entries.map(&:line), code: code)
118
+ %w[snmp.communities snmp.community_present snmp.users snmp.versions snmp.v3_auth_priv snmp.acl].each do |path|
119
+ unknown(path, entries, code: code)
120
+ end
121
+ end
122
+
123
+ # 按来源顺序确定最终生效的显式启用或禁用声明。
124
+ def configured_state(on, off, entries = roots)
125
+ entries.reduce(nil) do |value, entry|
126
+ if off.match?(entry.text)
127
+ analyze(entry) { false }
128
+ elsif on.match?(entry.text)
129
+ analyze(entry) { true }
130
+ else
131
+ value
132
+ end
133
+ end
134
+ end
135
+
136
+ # 记录凭据属性,不保留凭据秘密值。
137
+ def record_credential(owner, kind, format, secret, entry)
138
+ value = analyze(entry) { Credential.new(owner: owner, kind: kind, format: format, secret: secret, line: entry.line) }
139
+ index = @facts.record_credential(value)
140
+ capture(entry, value.to_h, [ "facts", "credentials.records", index ])
141
+ end
142
+
143
+ # 注册有序 ACL 规则与别名,同时保留不支持的语法。
144
+ def register_acl(name, entries, aliases: [], supported: true, source: nil, &block)
145
+ commands = entries.reject { |entry| /\A(?:\d+\s+)?(?:description|remark|step|statistics|hardware-count)\b/.match?(entry.text) }
146
+ parsed = commands.map.with_index do |entry, index|
147
+ value = analyze(entry) { block.call(entry, index) }
148
+ cursor.mark(entry.index, state: :unsupported) unless value
149
+ value
150
+ end
151
+ damaged = @source_issues.any? do |issue|
152
+ line = issue[:line]
153
+ next false unless line
154
+
155
+ source ? source.line_range.cover?(line) : damaged_numbered_acl?(cursor.lines.fetch(line - 1).text, name)
156
+ end
157
+ acl = Acl.new(name: name, line: entries.first&.line, rules: parsed.compact,
158
+ supported: supported && !damaged && parsed.none?(&:nil?))
159
+ if @facts.register_acl(acl, aliases: aliases, lines: entries.map(&:line)).any?
160
+ entries.each { |entry| cursor.mark(entry.index, state: :unsupported) }
161
+ end
162
+ end
163
+
164
+ # 名称被分页截断时,所有匹配其前缀的编号 ACL 都不能声明完整。
165
+ def damaged_numbered_acl?(text, name)
166
+ prefix = text.split(/--\s*More\s*--|\e\[/i, 2).first
167
+ match = /\Aaccess-list(?:\s+(\S*))?(.*)\z/.match(prefix)
168
+ return false unless match
169
+
170
+ identity, suffix = match.captures
171
+ identity.to_s.empty? || (suffix.empty? ? name.start_with?(identity) : name == identity)
172
+ end
173
+
174
+ # 归一化 ACL 来源地址;不推测不支持语法的含义。
175
+ def acl_source_network(parts)
176
+ token = parts.shift
177
+ return "0.0.0.0/0" if token == "any"
178
+ token = parts.shift if token == "host"
179
+ wildcard = parts.shift if parts.first&.match?(/\A(?:\d+(?:\.\d+){3}|0)\z/)
180
+ Acl.source_network(token, wildcard)
181
+ end
182
+
183
+ # 显式端口名称仅属于配置语法,不代表组织批准值。
184
+ def service_port(token)
185
+ return SERVICE_PORTS[token] unless token&.match?(/\A\d+\z/)
186
+ value = token.to_i
187
+ value if value.between?(0, 65_535)
188
+ end
189
+
190
+ # 服务器目标只接受 IP 地址或 DNS 名称,不接受网段或空词元。
191
+ def server_address?(value)
192
+ return false unless value.is_a?(String) && !value.empty?
193
+ IPAddr.new(value)
194
+ !value.include?("/")
195
+ rescue IPAddr::Error
196
+ value.bytesize <= 253 && !/\A[\d.]+\z/.match?(value) && value.delete_suffix(".").split(".", -1).all? do |label|
197
+ /\A[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\z/i.match?(label)
198
+ end
199
+ end
200
+
201
+ # 配置的监听端口仅替代当前服务的默认端口。
202
+ def listener_port(command, default:, scope:, negate: "undo")
203
+ prefix = Regexp.escape(command)
204
+ entries = matching_blocks(/\A(?:#{negate} )?#{prefix}(?:\s|\z)/)
205
+ return default if entries.empty?
206
+ entry = entries.last
207
+ return analyze(entry) { default } if entry.text == "#{negate} #{command}"
208
+ token = entry.text[/\A#{prefix}\s+(\d+)\z/, 1]
209
+ port = service_port(token)
210
+ return analyze(entry) { port } if port && port.positive?
211
+ unknown_listener_port(scope, entries)
212
+ nil
213
+ end
214
+
215
+ # 监听端口无法解析时,将依赖该端口的事实保留为未知。
216
+ def unknown_listener_port(scope, entries)
217
+ %w[sources restricted].each { |field| unknown("#{scope}.#{field}", entries, code: :unsupported_service_port) }
218
+ end
219
+
220
+ # 区分模式未声明、格式无效及包含完整算法与秘密值的声明。
221
+ def snmp_secret(settings, keyword, algorithms:, optional_algorithm: false, aes_size: false)
222
+ tokens = settings.split
223
+ index = tokens.index(keyword)
224
+ return false unless index
225
+ return if tokens.count(keyword) > 1
226
+ parameters = tokens.drop(index + 1).take_while { |token| !SNMP_OPTIONS.include?(token) }
227
+ if algorithms.include?(parameters.first)
228
+ algorithm = parameters.shift
229
+ elsif !optional_algorithm || parameters.size != 1 || %w[aes des md5 sha].include?(parameters.first)
230
+ return
231
+ end
232
+ return if algorithm == "aes" && aes_size && !%w[128 192 256].include?(parameters.shift)
233
+ format = parameters.shift if %w[cipher simple].include?(parameters.first)
234
+ return unless parameters.size == 1
235
+ [ format, parameters.first ]
236
+ end
237
+
238
+ # 提取 SNMP 凭据属性,不持久化秘密值。
239
+ def snmp_credentials(owner, modes, entry, format: nil)
240
+ modes.each_value do |value|
241
+ next if value == false
242
+ stored_format, secret = value || [ "unknown", nil ]
243
+ record_credential(owner, "snmp_user", stored_format || format || "unknown", secret, entry)
244
+ end
245
+ unknown("snmp.v3_auth_priv", [ entry ], code: :invalid_snmp_security) if modes.value?(nil)
246
+ modes.transform_values { |value| value ? true : value }
247
+ end
248
+
249
+ # 同名组可出现在不同安全级别;后续只读声明不能消除已有写权限。
250
+ def snmp_groups(entries)
251
+ entries.map { |entry| yield(entry) }.group_by(&:first).transform_values do |definitions|
252
+ records = definitions.map(&:last)
253
+ acls = records.map { |record| record[:acl] }.uniq
254
+ { acl: acls.one? ? acls.first : nil, acl_ambiguous: acls.size > 1,
255
+ write: Facts.write_permission(records.map { |record| record[:write] }),
256
+ line: records.first[:line], lines: records.map { |record| record[:line] } }
257
+ end
258
+ end
259
+ end
260
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "rule"
4
+
3
5
  require "yaml"
4
6
  require "digest"
5
7
  require "json"
@@ -8,11 +10,11 @@ module Baseline
8
10
  # 按主机、型号、站点、角色或厂商选择批准值;缺少批准值时不回退到当前配置。
9
11
  class Policy
10
12
  SCOPES = Rule::CONTEXT_FIELDS
11
- SELECTORS = Baseline.immutable(SCOPES.map { |scope| "by_#{scope}" } + [ "default" ])
13
+ SELECTORS = CtxParser::Immutable.copy(SCOPES.map { |scope| "by_#{scope}" } + [ "default" ])
12
14
  Resolution = Data.define(:value, :reason, :path) do
13
15
  # 冻结解析后的批准值、原因及策略选择路径。
14
16
  def initialize(value:, reason:, path:)
15
- super(value: Baseline.immutable(value), reason: Baseline.immutable(reason), path: Baseline.immutable(path))
17
+ super(value: CtxParser::Immutable.copy(value), reason: CtxParser::Immutable.copy(reason), path: CtxParser::Immutable.copy(path))
16
18
  end
17
19
  end
18
20
  attr_reader :rules, :sha256
@@ -1,5 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "configuration"
4
+
5
+ require "ctx_parser/immutable"
6
+ require "json"
7
+
3
8
  module Baseline
4
9
  # 分别报告评估结果与输入覆盖率;未知项不计分,也不视为合规。
5
10
  class Report
@@ -16,7 +21,7 @@ module Baseline
16
21
  Finding = Data.define(:id, :title, :severity, :status, :reason, :actual, :expected, :lines, :policy_path, :diagnostics, :details) do
17
22
  # 冻结单条检查结果的策略路径、诊断和支持细节。
18
23
  def initialize(policy_path: [], diagnostics: [], details: {}, **attributes)
19
- super(**Baseline.immutable(attributes.merge(policy_path: policy_path, diagnostics: diagnostics, details: details)))
24
+ super(**CtxParser::Immutable.copy(attributes.merge(policy_path: policy_path, diagnostics: diagnostics, details: details)))
20
25
  end
21
26
 
22
27
  # 将稳定的结果原因代码转换为易读说明。
@@ -35,7 +40,7 @@ module Baseline
35
40
  def initialize(configuration:, policy_sha256:, findings:, context:)
36
41
  @configuration, @policy_sha256 = configuration, policy_sha256
37
42
  @findings = findings.dup.freeze
38
- @context = Baseline.immutable(context)
43
+ @context = CtxParser::Immutable.copy(context)
39
44
  @counts = @findings.map(&:status).tally.freeze
40
45
  @applicable = @findings.reject { |finding| finding.status == :not_applicable }.freeze
41
46
  freeze
@@ -72,7 +77,7 @@ module Baseline
72
77
 
73
78
  # 限制摘要大小;完整事实由 to_h 提供,避免 ACL 或账号数量膨胀元数据。
74
79
  def summary
75
- metadata.merge(configuration: { platform: configuration.platform, sha256: configuration.sha256, status: configuration.status,
80
+ metadata.merge(configuration: { platform: configuration.platform, sha256: configuration.sha256, status: configuration.status, input_complete: configuration.input_complete?,
76
81
  issue_counts: configuration.issues.map { |issue| issue[:code] }.tally },
77
82
  findings: findings.map(&:summary))
78
83
  end