baseline-rb 0.1.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.
@@ -0,0 +1,324 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Baseline
4
+ module Parsers
5
+ # 复用 Cisco 管理服务、IPv4 ACL、SNMP 和 AAA 的公共语法。
6
+ class Cisco < Base
7
+ SNMP_ROLE_WRITE = { "network-admin" => true, "vdc-admin" => true,
8
+ "network-operator" => false, "vdc-operator" => false }.freeze
9
+
10
+ private
11
+
12
+ # 解析管理访问暴露检查使用的有序 ACL 定义。
13
+ def access_lists
14
+ sections(/\Aip access-list\s+/).each do |section|
15
+ name = section.text.split.last
16
+ extended = section.text.start_with?("ip access-list extended ") || nxos?
17
+ register_acl(name, section.children, source: section) { |entry, index| cisco_rule(entry, index, extended: extended) }
18
+ end
19
+ matching(/\Aaccess-list\s+/).group_by { |entry| entry.text.split[1] }.each do |name, entries|
20
+ extended = name.to_i.between?(100, 199) || name.to_i.between?(2000, 2699)
21
+ register_acl(name, entries) { |entry, index| cisco_rule(entry, index, extended: extended, prefix: /\Aaccess-list\s+\S+\s+/) }
22
+ end
23
+ end
24
+
25
+ # 解析 Cisco ACL 条目,保留规则顺序及地址语义。
26
+ def cisco_rule(entry, index, extended:, prefix: /\A/)
27
+ text = entry.text.sub(prefix, "")
28
+ match = /\A(?:(\d+)\s+)?(permit|deny)\s+(.+)\z/.match(text)
29
+ return unless match
30
+ parts = match[3].split
31
+ protocol = extended ? parts.shift : "ip"
32
+ return unless %w[ip tcp udp icmp].include?(protocol)
33
+ network = source(parts)
34
+ return if extended && source(parts) != "0.0.0.0/0"
35
+ if extended && parts.first == "eq"
36
+ return unless %w[tcp udp].include?(protocol)
37
+ parts.shift
38
+ port = service_port(parts.shift)
39
+ return if port.nil?
40
+ end
41
+ return unless (parts - %w[log log-input]).empty?
42
+ rule = { sequence: match[1]&.to_i || index + 1, action: match[2], protocol: protocol, source: network, line: entry.line }
43
+ port.nil? ? rule : rule.merge(destination_port: port)
44
+ rescue ArgumentError, IPAddr::InvalidAddressError
45
+ nil
46
+ end
47
+
48
+ # 根据监听服务、认证方式与 ACL 绑定关系归纳管理访问事实。
49
+ def management
50
+ version = last(/\Aip ssh version\s+([12])\z/)
51
+ record("ssh.versions", [ version ]) if version
52
+ ssh = nxos? ? state(/\Afeature ssh\z/, /\Ano feature ssh\z/) : version ? true : nil
53
+ record("ssh.enabled", ssh, matching(/\A(?:ip ssh|(?:no )?feature ssh)/))
54
+ default_aaa = nxos? || state(/\Aaaa new-model\z/, /\Ano aaa new-model\z/) == true
55
+ vtys = sections(/\Aline vty(?:\s|\z)/).map do |section|
56
+ authentication = last(/\Alogin authentication\s+(\S+)\z/, section.children)
57
+ local = section.children.any? { |entry| entry.text == "login local" }
58
+ { name: section.text, protocols: last(/\Atransport input\s+(.+)\z/, section.children)&.split,
59
+ authentication: local ? "local" : (default_aaa ? "aaa" : nil),
60
+ authentication_list: authentication || (default_aaa ? "default" : nil),
61
+ acl: last(/\Aaccess-class\s+(\S+)\s+in(?:\s+vrf-also)?\z/, section.children), line: section.line }
62
+ end
63
+ rotary = matching(/\A(?:no )?ip ssh port(?:\s|\z)/)
64
+ rotary.concat(sections(/\Aline vty(?:\s|\z)/).flat_map { |section| matching(/\A(?:no )?rotary(?:\s|\z)/, section.children) })
65
+ unknown_listener_port("management.acl", rotary) unless rotary.empty?
66
+ telnet_ports = matching(/\A(?:no )?(?:ip )?telnet(?: server)? port(?:\s|\z)/)
67
+ unknown_listener_port("management.acl", telnet_ports) unless telnet_ports.empty?
68
+ @facts.management(vtys: vtys,
69
+ ssh_port: rotary.empty? ? listener_port("ssh port", default: 22, scope: "management.acl", negate: "no") : nil,
70
+ telnet_port: telnet_ports.empty? ? 23 : nil)
71
+ telnet = if nxos?
72
+ state(/\Afeature telnet\z/, /\Ano feature telnet\z/)
73
+ elsif vtys.any? && vtys.all? { |entry| entry[:protocols] }
74
+ vtys.any? { |entry| (entry[:protocols] & %w[all telnet]).any? }
75
+ end
76
+ record("services", { "telnet" => telnet, "http" => state(/\Aip http server\z/, /\Ano ip http server\z/),
77
+ "https" => state(/\Aip http secure-server\z/, /\Ano ip http secure-server\z/),
78
+ "ftp" => state(/\A(?:feature ftp-server|ftp-server enable)\z/, /\Ano (?:feature ftp-server|ftp-server enable)\z/),
79
+ "tftp" => state(/\A(?:feature tftp-server|tftp-server\s+.+)\z/, /\Ano (?:feature tftp-server|tftp-server\s+.+)\z/),
80
+ "finger" => state(/\A(?:ip finger|service finger)\z/, /\Ano (?:ip finger|service finger)\z/) })
81
+ end
82
+
83
+ # 采集明确启用或禁用的管理服务。
84
+ def services
85
+ record("ntp.servers", values(/\Antp server(?: vrf \S+)?\s+(\S+)/), matching(/\Antp server/))
86
+ record("ntp.authentication", state(/\Antp authenticate\z/, /\Ano ntp authenticate\z/))
87
+ record("ntp.source", last(/\Antp source(?:-interface)?\s+(\S+)\z/))
88
+ hosts = values(nxos? ? /\Alogging server\s+(\S+)/ : /\Alogging host(?: ipv6)?\s+(\S+)/)
89
+ hosts |= values(/\Alogging\s+(\d+(?:\.\d+){3})\z/) unless nxos?
90
+ record("syslog.servers", hosts, matching(/\Alogging (?:host|server)/))
91
+ record("syslog.source", last(/\Alogging source-interface\s+(\S+)\z/))
92
+ snmp = matching(/\Asnmp-server\b/)
93
+ enabled = nxos? ? state(/\Asnmp-server protocol enable\z/, /\Ano snmp-server protocol enable\z/) : nil
94
+ enabled = nxos? || snmp.any? if enabled.nil? && (@facts.complete? || snmp.any?)
95
+ record("snmp.enabled", enabled)
96
+ communities = {}
97
+ matching(/\Asnmp-server community\b/).each do |entry|
98
+ match = /\Asnmp-server community\s+(\S+)(?:\s+(.*))?\z/.match(entry.text)
99
+ unless match
100
+ unknown_snmp_access([ entry ])
101
+ next
102
+ end
103
+ settings = match[2].to_s
104
+ community = communities[match[1]] ||= { line: entry.line }
105
+ permission = settings[/\A(ro|rw)\b/i, 1]&.downcase
106
+ role = settings[/\bgroup\s+(\S+)/, 1]
107
+ community[:write] = permission == "rw" if permission
108
+ community[:write] = SNMP_ROLE_WRITE[role] if role
109
+ acl = nxos? ? settings[/\buse-ipv4acl\s+(\S+)/, 1] : settings[/\A(?:ro|rw)\s+(\S+)\z/i, 1]
110
+ community[:acl] = acl if acl
111
+ credential("snmp-community", "snmp", "plain", match[1], entry) unless @facts.credential_recorded?(community[:line])
112
+ end
113
+ versions = communities.any? ? %w[v1 v2c] : []
114
+ groups = snmp_groups(matching(/\Asnmp-server group\s+.+?\s+v3\b/)) do |entry|
115
+ name, settings = entry.text.match(/\Asnmp-server group\s+(.+?)\s+v3(?:\s+(.*))?\z/).captures
116
+ write = name.include?('"') ? nil : snmp_group_write(settings.to_s)
117
+ [ name, { acl: entry.text[/\baccess\s+(\S+)/, 1], line: entry.line, write: write } ]
118
+ end
119
+ record("snmp.groups", groups)
120
+ users = {}
121
+ matching(/\Asnmp-server user\b/).each do |entry|
122
+ match = /\Asnmp-server user\s+(\S+)\s+(\S+)(.*)\z/.match(entry.text)
123
+ unless match
124
+ unknown_snmp_access([ entry ])
125
+ next
126
+ end
127
+ if nxos? && match[2] == "use-ipv4acl"
128
+ user = users[match[1]] ||= { name: match[1], line: entry.line }
129
+ user[:acl] = match[3].strip
130
+ next
131
+ end
132
+ settings = match[3]
133
+ if /\bv[12]c?\b/.match?(settings)
134
+ unknown_snmp_access([ entry ], code: :unsupported_snmp_user)
135
+ next
136
+ end
137
+ unless nxos? || /\bv3\b/.match?(settings)
138
+ unknown_snmp_access([ entry ])
139
+ next
140
+ end
141
+ versions |= [ "v3" ]
142
+ user = users[match[1]] ||= { name: match[1], line: entry.line }
143
+ if nxos?
144
+ (user[:roles] ||= []) << match[2]
145
+ user[:roles].uniq!
146
+ user[:write] = Facts.write_permission(user[:roles].map { |role| SNMP_ROLE_WRITE[role] })
147
+ next if settings.strip.empty?
148
+ end
149
+ modes = { auth: snmp_secret(settings, "auth", algorithms: %w[md5 sha sha-224 sha-256 sha-384 sha-512]),
150
+ priv: snmp_secret(settings, "priv", algorithms: %w[des 3des aes aes-128 aes-192 aes-256], optional_algorithm: nxos?, aes_size: !nxos?) }
151
+ security = snmp_credentials("snmp:#{match[1]}", modes, entry, format: /\bencrypted\b/.match?(settings) ? "cipher" : "unknown")
152
+ user.merge!(security).merge!(group: match[2])
153
+ acl = settings[/\b(?:access|use-ipv4acl)\s+(\S+)/, 1]
154
+ user[:acl] = acl if acl
155
+ end
156
+ port_overrides = matching(/\A(?:no )?snmp-server (?:listen-port|udp-port|port)(?:\s|\z)/)
157
+ unknown_listener_port("snmp.acl", port_overrides) unless port_overrides.empty?
158
+ @facts.snmp(communities: communities.values, users: users.values, groups: nxos? ? nil : groups,
159
+ port: port_overrides.empty? ? 161 : nil)
160
+ nxos_snmp_coverage(users, enabled: enabled) if nxos?
161
+ unknown("snmp.v3_auth_priv", code: :snmp_user_unresolved) if users.values.any? { |user| !user.key?(:auth) }
162
+ versions |= values(/\Asnmp-server group\s+\S+\s+(v1|v2c|v3)\b/)
163
+ legacy_groups = matching(/\Asnmp-server group\s+\S+\s+v(?:1|2c)\b/)
164
+ if legacy_groups.any?
165
+ @facts.unknown_snmp_permission(lines: legacy_groups.map(&:line), code: :unsupported_snmp_group)
166
+ unknown("snmp.acl", legacy_groups, code: :unsupported_snmp_group)
167
+ end
168
+ record("snmp.versions", versions) unless versions.empty?
169
+ end
170
+
171
+ # NX-OS 通常会同步 CLI 与 SNMP 账号;缺少证据时不能构造虚拟用户。
172
+ def nxos_snmp_coverage(users, enabled:)
173
+ return if enabled == false
174
+ sync_disabled = state(/\Asnmp-server disable snmp-aaa sync\z/, /\Ano snmp-server disable snmp-aaa sync\z/)
175
+ return if sync_disabled == true
176
+
177
+ matching(/\Ausername\s+/).group_by { |entry| entry.text.split[1] }.each do |name, entries|
178
+ roles = entries.filter_map { |entry| entry.text[/\brole\s+(\S+)\s*\z/, 1] }.uniq
179
+ covered = users[name]&.fetch(:roles, []) || []
180
+ next if roles.any? && (roles - covered).empty?
181
+
182
+ @facts.unknown_snmp_permission(lines: entries.map(&:line), code: :snmp_user_sync_unconfirmed)
183
+ end
184
+ end
185
+
186
+ # IOS 默认不授予写视图;不熟悉的组配置项不能证明只读权限。
187
+ def snmp_group_write(settings)
188
+ syntax = /\A(?:auth|noauth|priv)(?:\s+(?:read|(?<write>write)|notify|context)\s+[^\s"]+|\s+match\s+(?:exact|prefix)|\s+access(?:\s+ipv6)?\s+[^\s"]+)*\z/
189
+ match = syntax.match(settings)
190
+ return unless match
191
+
192
+ !match[:write].nil?
193
+ end
194
+
195
+ # 解析实际生效的认证方法及外部服务器引用。
196
+ def authentication
197
+ servers, aliases, keyed = [], {}, []
198
+ global_key = matching(/\Atacacs-server key\s+/)
199
+ global_key.each { |entry| parse_key("tacacs", entry.text.sub(/\Atacacs-server key\s+/, ""), entry) }
200
+ matching(/\Atacacs-server host\s+/).each do |entry|
201
+ host = entry.text.split[2]
202
+ servers << host
203
+ key = entry.text[/\bkey\s+(.+)\z/, 1]
204
+ if key
205
+ parse_key("tacacs:#{host}", key, entry)
206
+ keyed << host
207
+ end
208
+ end
209
+ sections(/\Atacacs server\s+/).each do |section|
210
+ hosts = values(/\Aaddress ipv[46]\s+(\S+)/, section.children)
211
+ aliases[section.text.split.last] = hosts
212
+ servers |= hosts
213
+ matching(/\Akey\s+/, section.children).each do |entry|
214
+ parse_key("tacacs:#{section.text.split.last}", entry.text.delete_prefix("key "), entry)
215
+ keyed |= hosts
216
+ end
217
+ end
218
+ groups = sections(/\Aaaa group server tacacs\+\s+/).to_h do |section|
219
+ members = values(/\Aserver(?: name)?\s+(\S+)/, section.children)
220
+ [ section.text.split.last, members.flat_map { |member| aliases.fetch(member, [ member ]) } ]
221
+ end
222
+ record("tacacs.servers", servers.uniq)
223
+ record("tacacs.groups", groups)
224
+ tacacs_login(servers, groups)
225
+ patterns = { "authorization" => /\Aaaa authorization (?:exec|commands(?: \d+)?) default\s+(.+)\z/,
226
+ "accounting" => /\Aaaa accounting (?:(?:exec|commands(?: \d+)?) )?default\s+(.+)\z/ }
227
+ patterns.each do |kind, pattern|
228
+ references = values(pattern).flat_map { |methods| methods.scan(/\bgroup\s+(\S+)/).flatten }
229
+ selected = references.flat_map { |name| name == "tacacs+" ? servers : groups.fetch(name, []) }.uniq
230
+ unresolved = references.any? { |name| name != "radius" && name != "tacacs+" && !groups.key?(name) } || (selected - servers).any?
231
+ if unresolved
232
+ unknown("tacacs.#{kind}", matching(pattern), code: :tacacs_group_unresolved)
233
+ else
234
+ used = selected.any?
235
+ record("tacacs.#{kind}", used, matching(pattern)) if @facts.complete? || used
236
+ end
237
+ end
238
+ record("tacacs.keys_present", global_key.any? || (servers - keyed).empty?) unless servers.empty?
239
+ accounts
240
+ end
241
+
242
+ # 解析各 VTY 实际选用的登录列表,包括命名列表。
243
+ def tacacs_login(servers, groups)
244
+ pattern = /\Aaaa authentication login\s+(\S+)\s+(.+)\z/
245
+ lists = matching(pattern).each_with_object({}) do |entry, result|
246
+ name, methods = analyze(entry) { entry.text.match(pattern).captures }
247
+ result[name] = { methods: methods, entry: entry }
248
+ end
249
+ entries = @facts.vtys.filter_map { |vty| lists[vty[:authentication_list]]&.fetch(:entry) }.uniq
250
+ results = @facts.vtys.map do |vty|
251
+ if vty[:authentication].nil?
252
+ :vty_authentication_unknown
253
+ elsif vty[:authentication] != "aaa"
254
+ false
255
+ elsif (list = lists[vty[:authentication_list]])
256
+ tacacs_login_method(list[:methods], servers, groups)
257
+ else
258
+ :authentication_list_unresolved
259
+ end
260
+ end
261
+ results << :vty_authentication_unknown if results.empty?
262
+ if results.include?(false)
263
+ record("tacacs.login", false, entries) if @facts.complete?
264
+ elsif (reason = results.find { |value| value.is_a?(Symbol) })
265
+ unknown("tacacs.login", entries, code: reason)
266
+ else
267
+ record("tacacs.login", true, entries)
268
+ end
269
+ end
270
+
271
+ # 仅当首选认证方法引用可解析的 TACACS 组时,才确认使用 TACACS。
272
+ def tacacs_login_method(methods, servers, groups)
273
+ tokens = methods.split
274
+ outcomes = []
275
+ until tokens.empty?
276
+ case tokens.shift
277
+ when "group"
278
+ name = tokens.shift
279
+ if name == "radius"
280
+ outcomes << false
281
+ next
282
+ end
283
+ return :tacacs_group_unresolved unless name == "tacacs+" || groups.key?(name)
284
+ selected = name == "tacacs+" ? servers : groups.fetch(name)
285
+ return :tacacs_group_unresolved unless (selected - servers).empty?
286
+ outcomes << selected.any?
287
+ when "local", "local-case", "none", "enable", "line"
288
+ outcomes << false
289
+ else
290
+ return :unsupported_authentication_method
291
+ end
292
+ end
293
+ outcomes.first
294
+ end
295
+
296
+ # 提取已配置的凭据编码方式,不保留秘密值。
297
+ def parse_key(owner, text, entry)
298
+ match = /\A(?:(\d+)\s+)?(\S+)/.match(text)
299
+ credential(owner, "tacacs", match&.[](1) || "plain", match&.[](2), entry)
300
+ end
301
+
302
+ # 提取本地账号角色及凭据存储属性。
303
+ def accounts
304
+ matching(/\Ausername\s+/).group_by { |entry| entry.text.split[1] }.each do |name, entries|
305
+ passwords = entries.filter_map do |entry|
306
+ match = /\b(?:password|secret)\s+(?:(\d+)\s+)?(\S+)/.match(entry.text)
307
+ credential(name, "local_user", match[1] || "plain", match[2], entry) if match
308
+ end
309
+ credential(name, "local_user", "unknown", nil, entries.first) if passwords.empty?
310
+ end
311
+ matching(/\Aenable (?:password|secret)\s+/).each do |entry|
312
+ match = /\Aenable (?:password|secret)(?: level \d+)?\s+(?:(\d+)\s+)?(\S+)/.match(entry.text)
313
+ credential("enable", "enable", match ? match[1] || "plain" : "unknown", match&.[](2), entry)
314
+ end
315
+ sections(/\Aline\b/).each do |section|
316
+ matching(/\Apassword\b/, section.children).each do |entry|
317
+ match = /\Apassword\s+(?:(\d+)\s+)?(\S+)/.match(entry.text)
318
+ credential(section.text, "line", match ? match[1] || "plain" : "unknown", match&.[](2), entry)
319
+ end
320
+ end
321
+ end
322
+ end
323
+ end
324
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Baseline
4
+ module Parsers
5
+ # 解析 IOS、IOS-XE 的 VTY 协议和 AAA 登录列表。
6
+ class CiscoIos < Cisco
7
+ private
8
+
9
+ # 选择共享 Cisco 解析逻辑中的 NX-OS 分支。
10
+ def nxos? = false
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Baseline
4
+ module Parsers
5
+ # 解析 NX-OS 功能开关、角色权限及命名扩展 ACL。
6
+ class CiscoNxos < Cisco
7
+ private
8
+
9
+ # 选择共享 Cisco 解析逻辑中的 NX-OS 分支。
10
+ def nxos? = true
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Baseline
4
+ module Parsers
5
+ # 复用 Comware 与 VRP 结构,由厂商子类选择账号、SSH 和 TACACS 语法。
6
+ class Comware < Base
7
+ private
8
+
9
+ # 解析管理访问暴露检查使用的有序 ACL 定义。
10
+ def access_lists
11
+ sections(/\Aacl\s+/).each do |section|
12
+ # IPv6 ACL 不能误建为名为 ipv6 的 IPv4 ACL,也不能覆盖其别名。
13
+ if /\Aacl\s+ipv6\b/.match?(section.text)
14
+ unknown("ipv6.acls", [ section ], code: :unsupported_acl_family)
15
+ next
16
+ end
17
+ match = /\Aacl\s+(?:number\s+|basic\s+|advanced\s+)?(?:name\s+)?(\S+)(?:\s+name\s+(\S+))?(?:\s+.*)?\z/.match(section.text)
18
+ next unless match
19
+ register_acl(match[2] || match[1], section.children, source: section, aliases: [ match[1] ], supported: !section.text.include?("match-order auto")) do |entry, index|
20
+ comware_rule(entry, index)
21
+ end
22
+ end
23
+ end
24
+
25
+ # 解析单条有序 Comware ACL 规则及来源限制。
26
+ def comware_rule(entry, _index)
27
+ match = /\Arule\s+(\d+)\s+(permit|deny)(?:\s+(.*))?\z/.match(entry.text)
28
+ return unless match
29
+ parts = match[3].to_s.split
30
+ protocol = %w[ip tcp udp icmp].include?(parts.first) ? parts.shift : "ip"
31
+ network = if parts.first == "source"
32
+ parts.shift
33
+ source(parts)
34
+ else
35
+ "0.0.0.0/0"
36
+ end
37
+ if parts.first == "destination"
38
+ parts.shift
39
+ return unless source(parts) == "0.0.0.0/0"
40
+ end
41
+ if parts.first == "destination-port"
42
+ return unless %w[tcp udp].include?(protocol)
43
+ parts.shift
44
+ return unless parts.shift == "eq"
45
+ port = service_port(parts.shift)
46
+ return if port.nil?
47
+ end
48
+ return unless (parts - %w[logging counting]).empty?
49
+ rule = { sequence: match[1].to_i, action: match[2], protocol: protocol, source: network, line: entry.line }
50
+ port.nil? ? rule : rule.merge(destination_port: port)
51
+ rescue ArgumentError, IPAddr::InvalidAddressError
52
+ nil
53
+ end
54
+
55
+ # 根据监听服务、认证方式与 ACL 绑定关系归纳管理访问事实。
56
+ def management
57
+ record("ssh.enabled", state(ssh_enable, ssh_disable), matching(/\A(?:undo )?(?:ssh|stelnet)/))
58
+ compatible = state(/\Assh server compatible-ssh1x enable\z/, /\A(?:undo ssh server compatible-ssh1x enable|ssh server compatible-ssh1x disable)\z/)
59
+ record("ssh.versions", compatible.nil? ? nil : compatible ? [ "1", "2" ] : [ "2" ])
60
+ ssh_acl = last(/\Assh server acl(?: ipv4)?\s+(?:name\s+)?(\S+)\z/)
61
+ defaults = sections(/\Aline class vty\z/).flat_map(&:children)
62
+ vtys = sections(/\A(?:line|user-interface) vty\b/).map do |section|
63
+ entries = defaults + section.children
64
+ { name: section.text, protocols: last(/\Aprotocol inbound\s+(.+)\z/, entries)&.split,
65
+ authentication: last(/\Aauthentication-mode\s+(\S+)\z/, entries),
66
+ acl: last(/\Aacl\s+(?:name\s+)?(\S+)(?:\s+inbound)?\z/, entries), line: section.line }
67
+ end
68
+ @facts.management(vtys: vtys, ssh_acl: ssh_acl,
69
+ ssh_port: listener_port("ssh server port", default: 22, scope: "management.acl"),
70
+ telnet_port: listener_port("telnet server port", default: 23, scope: "management.acl"))
71
+ definitions = { "telnet" => "telnet server", "http" => "ip http", "https" => "ip https", "ftp" => "ftp server", "tftp" => "tftp server" }
72
+ record("services", definitions.to_h do |name, command|
73
+ [ name, state(/\A#{command} enable\z/, /\A(?:undo #{command} enable|#{command} disable)\z/) ]
74
+ end)
75
+ end
76
+
77
+ # 采集明确启用或禁用的管理服务。
78
+ def services
79
+ record("ntp.servers", values(/\Antp-service unicast-server\s+(\S+)/), matching(/\Antp-service unicast-server/))
80
+ record("ntp.authentication", state(/\Antp-service authentication enable\z/, /\Aundo ntp-service authentication enable\z/))
81
+ record("ntp.source", last(/\Antp-service source(?:-interface)?\s+(\S+)\z/))
82
+ # 从日志服务器中排除 source 子命令,并保留评估所需的归纳事实。
83
+ hosts = values(/\Ainfo-center loghost\s+(\S+)/) - [ "source" ]
84
+ record("syslog.servers", hosts, matching(/\Ainfo-center loghost/))
85
+ record("syslog.source", last(/\Ainfo-center loghost source\s+(\S+)\z/))
86
+ enabled = state(/\Asnmp-agent(?:\s|\z)/, /\Aundo snmp-agent\z/)
87
+ record("snmp.enabled", enabled.nil? && @facts.complete? ? false : enabled)
88
+ versions = last(/\Asnmp-agent sys-info version\s+(.+)\z/)&.split
89
+ versions = %w[v1 v2c v3] if versions == [ "all" ]
90
+ record("snmp.versions", versions)
91
+ communities = matching(/\Asnmp-agent community\b/).filter_map do |entry|
92
+ match = /\Asnmp-agent community (read|write)\s+(?:(simple|cipher)\s+)?(\S+)(.*)\z/.match(entry.text)
93
+ unless match
94
+ unknown_snmp_access([ entry ])
95
+ next
96
+ end
97
+ credential("snmp-community", "snmp", match[2] || "plain", match[3], entry)
98
+ { write: match[1] == "write", acl: match[4][/\bacl\s+(?:name\s+)?(\S+)/, 1], line: entry.line }
99
+ end
100
+ groups = snmp_groups(matching(/\Asnmp-agent group v3\s+/)) do |entry|
101
+ settings = entry.text.sub(/\Asnmp-agent group v3\s+\S+\s*/, "")
102
+ [ entry.text.split[3], { acl: entry.text[/\bacl\s+(?:name\s+)?(\S+)/, 1], line: entry.line, write: snmp_group_write(settings) } ]
103
+ end
104
+ record("snmp.groups", groups)
105
+ users = {}
106
+ matching(/\Asnmp-agent usm-user\b/).each do |entry|
107
+ match = /\Asnmp-agent usm-user v3\s+(\S+)(?:\s+(.*))?\z/.match(entry.text)
108
+ unless match
109
+ unknown_snmp_access([ entry ], code: :unsupported_snmp_user)
110
+ next
111
+ end
112
+ settings = match[2].to_s
113
+ user = users[match[1]] ||= { name: match[1], auth: false, priv: false, line: entry.line }
114
+ group = settings[/\Agroup\s+(\S+)/, 1]
115
+ first = settings.split.first
116
+ group ||= first unless %w[group authentication-mode privacy-mode acl local engineid].include?(first)
117
+ user[:group] = group if group
118
+ modes = { auth: snmp_secret(settings, "authentication-mode", algorithms: %w[md5 sha sha2-224 sha2-256 sha2-384 sha2-512]),
119
+ priv: snmp_secret(settings, "privacy-mode", algorithms: %w[des des56 3des aes128 aes192 aes256]) }
120
+ snmp_credentials("snmp:#{match[1]}", modes, entry).each { |key, value| user[key] ||= value }
121
+ acl = settings[/\bacl\s+(?:name\s+)?(\S+)/, 1]
122
+ user[:acl] = acl if acl
123
+ end
124
+ @facts.snmp(communities: communities, users: users.values, groups: groups,
125
+ port: listener_port(snmp_port_command, default: 161, scope: "snmp.acl"))
126
+ end
127
+
128
+ # Comware、VRP 必须明确授予写视图;引号或未知语法保持未解析。
129
+ def snmp_group_write(settings)
130
+ syntax = /\A(?:(?:authentication|privacy|noauthentication)(?:\s+|\z))?(?:(?:read-view|(?<write>write-view)|notify-view)\s+[^\s"]+\s*|acl(?:\s+ipv6)?(?:\s+name)?\s+[^\s"]+\s*|acl-ipv[46]\s+[^\s"]+\s*)*\z/
131
+ match = syntax.match(settings)
132
+ return unless match
133
+
134
+ !match[:write].nil?
135
+ end
136
+
137
+ # 解析实际生效的认证方法及外部服务器引用。
138
+ def authentication
139
+ schemes = sections(tacacs_section).to_h do |section|
140
+ server_types = matching(tacacs_server, section.children).group_by { |entry| entry.text.split[1] }
141
+ .transform_values { |entries| values(tacacs_server, entries) }
142
+ servers = server_types.values.flatten.uniq
143
+ keys = section.children.filter_map do |entry|
144
+ match = tacacs_key.match(entry.text)
145
+ next unless match
146
+ kind, format, secret = match.captures
147
+ credential("tacacs:#{section.text.split.last}", "tacacs", format, secret, entry)
148
+ kind
149
+ end
150
+ [ section.text.split.last, { servers: servers, server_types: server_types, key_types: keys.uniq, line: section.line } ]
151
+ end
152
+ record("tacacs.schemes", schemes)
153
+ record("tacacs.servers", schemes.values.flat_map { |entry| entry[:servers] }.uniq)
154
+ domains = authentication_domains
155
+ methods = domains.flat_map { |entry| entry.children }
156
+ %w[authentication authorization accounting].each do |kind|
157
+ relevant = matching(/\A#{kind}\s+login\b/, methods)
158
+ relevant = matching(/\A#{kind}\s+default\b/, methods) if relevant.empty?
159
+ refs = relevant.filter_map { |entry| entry.text[/\A#{kind}\s+(?:login|default)\s+hwtacacs-scheme\s+(\S+)/, 1] }
160
+ refs.concat(vrp_tacacs_references(kind, domains))
161
+ key = kind == "authentication" ? "login" : kind
162
+ if refs.any? && refs.any? { |name| !schemes.key?(name) || schemes[name][:server_types].fetch(kind, []).empty? }
163
+ unknown("tacacs.#{key}", relevant, code: :tacacs_scheme_unresolved)
164
+ else
165
+ used = refs.any?
166
+ if used && key == "login"
167
+ unknown("tacacs.login", code: :vty_authentication_unknown) if @facts.vtys.empty? || @facts.vtys.any? { |vty| vty[:authentication].nil? }
168
+ used &&= @facts.vtys.any? && @facts.vtys.all? { |vty| %w[scheme aaa].include?(vty[:authentication]) }
169
+ end
170
+ record("tacacs.#{key}", used, relevant) if @facts.complete? || used
171
+ end
172
+ end
173
+ record("tacacs.keys_present", schemes.values.all? { |entry| tacacs_keys_complete?(entry[:key_types]) }) unless schemes.empty?
174
+ accounts
175
+ sections(/\A(?:line|user-interface)\b/).each do |section|
176
+ matching(/\A(?:set authentication )?password\b/, section.children).each do |entry|
177
+ match = /\A(?:set authentication )?password\s+(simple|cipher|hash|irreversible-cipher)\s+(.+)\z/.match(entry.text)
178
+ credential(section.text, "line", match ? match[1] : "unknown", match&.[](2), entry)
179
+ end
180
+ end
181
+ matching(/\Asuper password\b/).each do |entry|
182
+ match = /\Asuper password(?: level \d+)?\s+(simple|cipher|hash)\s+(.+)\z/.match(entry.text)
183
+ credential("super", "enable", match ? match[1] : "unknown", match&.[](2), entry)
184
+ end
185
+ end
186
+
187
+ # 解析认证域引用及其服务器组。
188
+ def authentication_domains
189
+ name = last(/\Adomain default enable\s+(\S+)\z/)
190
+ unless name
191
+ %w[login authorization accounting].each { |kind| unknown("tacacs.#{kind}", code: :authentication_domain_unknown) }
192
+ return []
193
+ end
194
+ sections(/\Adomain #{Regexp.escape(name)}\z/)
195
+ end
196
+ # 解析 VRP 认证域引用,避免将 VRP 语法应用到 Comware。
197
+ def vrp_tacacs_references(_kind, _domains) = []
198
+ # 返回当前平台修改 SNMP 监听端口的命令模式。
199
+ def snmp_port_command = "snmp-agent port"
200
+
201
+ # 提取本地账号角色及凭据存储属性。
202
+ def accounts
203
+ sections(/\Alocal-user\s+/).each do |section|
204
+ next if section.text.start_with?("local-user password-")
205
+ name = section.text.split[1]
206
+ passwords = matching(/\Apassword\s+/, section.children)
207
+ passwords.each do |entry|
208
+ match = /\Apassword\s+(simple|cipher|hash|irreversible-cipher)\s+(.+)\z/.match(entry.text)
209
+ credential(name, "local_user", match ? match[1] : "unknown", match ? match[2] : nil, entry)
210
+ end
211
+ credential(name, "local_user", "unknown", nil, section) if passwords.empty? && @facts.complete?
212
+ end
213
+ end
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Baseline
4
+ module Parsers
5
+ # 解析 H3C Comware 的 SSH、域认证及三类 TACACS 密钥角色。
6
+ class H3c < Comware
7
+ private
8
+
9
+ # 匹配当前平台显式启用 SSH 的命令。
10
+ def ssh_enable = /\Assh server enable\z/
11
+ # 匹配当前平台显式禁用 SSH 的命令。
12
+ def ssh_disable = /\A(?:undo ssh server enable|ssh server disable)\z/
13
+ # 匹配当前平台 TACACS 配置块的根语句。
14
+ def tacacs_section = /\Ahwtacacs scheme\s+/
15
+ # 匹配认证、授权或计费服务器声明。
16
+ def tacacs_server = /\A(?:primary|secondary) (?:authentication|authorization|accounting)\s+(\S+)/
17
+ # 匹配已配置 TACACS 密钥的存储格式。
18
+ def tacacs_key = /\Akey (authentication|authorization|accounting) (simple|cipher) (.+)\z/
19
+ # 要求具备当前平台 TACACS 方案使用的全部密钥角色。
20
+ def tacacs_keys_complete?(types) = (%w[authentication authorization accounting] - types).empty?
21
+ end
22
+ end
23
+ end