baseline-rb 0.2.0 → 0.3.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.
@@ -12,32 +12,32 @@ module Baseline
12
12
  private
13
13
 
14
14
  # 解析管理访问暴露检查使用的有序 ACL 定义。
15
- def access_lists
15
+ def parse_access_lists
16
16
  matching_blocks(/\Aip access-list\s+/).each do |section|
17
17
  name = section.text.split.last
18
18
  extended = section.text.start_with?("ip access-list extended ") || nxos?
19
- register_acl(name, section.children, source: section) { |entry, index| cisco_rule(entry, index, extended: extended) }
19
+ register_acl(name, section.children, source: section) { |entry, index| parse_acl_rule(entry, index, extended: extended) }
20
20
  end
21
21
  matching_blocks(/\Aaccess-list\s+/).group_by { |entry| entry.text.split[1] }.each do |name, entries|
22
22
  extended = name.to_i.between?(100, 199) || name.to_i.between?(2000, 2699)
23
- register_acl(name, entries) { |entry, index| cisco_rule(entry, index, extended: extended, prefix: /\Aaccess-list\s+\S+\s+/) }
23
+ register_acl(name, entries) { |entry, index| parse_acl_rule(entry, index, extended: extended, prefix: /\Aaccess-list\s+\S+\s+/) }
24
24
  end
25
25
  end
26
26
 
27
27
  # 解析 Cisco ACL 条目,保留规则顺序及地址语义。
28
- def cisco_rule(entry, index, extended:, prefix: /\A/)
28
+ def parse_acl_rule(entry, index, extended:, prefix: /\A/)
29
29
  text = entry.text.sub(prefix, "")
30
30
  match = /\A(?:(\d+)\s+)?(permit|deny)\s+(.+)\z/.match(text)
31
31
  return unless match
32
32
  parts = match[3].split
33
33
  protocol = extended ? parts.shift : "ip"
34
34
  return unless %w[ip tcp udp icmp].include?(protocol)
35
- network = acl_source_network(parts)
36
- return if extended && acl_source_network(parts) != "0.0.0.0/0"
35
+ network = parse_acl_source_network(parts)
36
+ return if extended && parse_acl_source_network(parts) != "0.0.0.0/0"
37
37
  if extended && parts.first == "eq"
38
38
  return unless %w[tcp udp].include?(protocol)
39
39
  parts.shift
40
- port = service_port(parts.shift)
40
+ port = parse_service_port(parts.shift)
41
41
  return if port.nil?
42
42
  end
43
43
  return unless (parts - %w[log log-input]).empty?
@@ -48,26 +48,29 @@ module Baseline
48
48
  end
49
49
 
50
50
  # 根据监听服务、认证方式与 ACL 绑定关系归纳管理访问事实。
51
- def management
52
- version = last_configured_value(/\Aip ssh version\s+([12])\z/)
53
- record("ssh.versions", [ version ]) if version
51
+ def parse_management
52
+ version = effective_configured_value(/\Aip ssh version\s+([12])\z/,
53
+ family: /\A(?:no )?ip ssh version\b/, fact: "ssh.versions", reset: /\Ano ip ssh version(?: [12])?\z/)
54
+ record_fact("ssh.versions", [ version ]) if version
54
55
  ssh = nxos? ? configured_state(/\Afeature ssh\z/, /\Ano feature ssh\z/) : version ? true : nil
55
- record("ssh.enabled", ssh, matching_blocks(/\A(?:ip ssh|(?:no )?feature ssh)/))
56
+ record_fact("ssh.enabled", ssh, matching_blocks(/\A(?:ip ssh|(?:no )?feature ssh)/))
56
57
  default_aaa = nxos? || configured_state(/\Aaaa new-model\z/, /\Ano aaa new-model\z/) == true
57
58
  vtys = matching_blocks(/\Aline vty(?:\s|\z)/).map do |section|
58
- authentication = last_configured_value(/\Alogin authentication\s+(\S+)\z/, section.children)
59
- local = section.children.any? { |entry| entry.text == "login local" }
60
- { name: section.text, protocols: last_configured_value(/\Atransport input\s+(.+)\z/, section.children)&.split,
61
- authentication: local ? "local" : (default_aaa ? "aaa" : nil),
62
- authentication_list: authentication || (default_aaa ? "default" : nil),
63
- acl: last_configured_value(/\Aaccess-class\s+(\S+)\s+in(?:\s+vrf-also)?\z/, section.children), line: section.line }
59
+ login = configured_vty_login(section, default_aaa: default_aaa)
60
+ acl_entries = section.children.reject { |entry| /\A(?:no )?access-class\s+\S+\s+out(?:\s|\z)/.match?(entry.text) }
61
+ { name: section.text, protocols: effective_configured_value(/\Atransport input\s+((?:all|none|ssh|telnet)(?:\s+(?:ssh|telnet))*)\z/, entries: section.children,
62
+ family: /\A(?:no )?transport input\b/, fact: "ssh.vty_ssh_only", reset: /\Ano transport input(?:\s+\S+)*\z/)&.split,
63
+ authentication: login[:authentication], authentication_list: login[:authentication_list],
64
+ acl: effective_configured_value(/\Aaccess-class\s+(\S+)\s+in(?:\s+vrf-also)?\z/, entries: acl_entries,
65
+ family: /\A(?:no )?access-class\b/, fact: "management.acl",
66
+ reset: /\Ano access-class(?:\s+\S+\s+in(?:\s+vrf-also)?)?\z/), line: section.line }
64
67
  end
65
68
  rotary = matching_blocks(/\A(?:no )?ip ssh port(?:\s|\z)/)
66
69
  rotary.concat(matching_blocks(/\Aline vty(?:\s|\z)/).flat_map { |section| matching_blocks(/\A(?:no )?rotary(?:\s|\z)/, section.children) })
67
- unknown_listener_port("management.acl", rotary) unless rotary.empty?
70
+ record_unknown_listener_port("management.acl", rotary) unless rotary.empty?
68
71
  telnet_ports = matching_blocks(/\A(?:no )?(?:ip )?telnet(?: server)? port(?:\s|\z)/)
69
- unknown_listener_port("management.acl", telnet_ports) unless telnet_ports.empty?
70
- @facts.management(vtys: vtys,
72
+ record_unknown_listener_port("management.acl", telnet_ports) unless telnet_ports.empty?
73
+ @fact_builder.record_management_access(vtys: vtys,
71
74
  ssh_port: rotary.empty? ? listener_port("ssh port", default: 22, scope: "management.acl", negate: "no") : nil,
72
75
  telnet_port: telnet_ports.empty? ? 23 : nil)
73
76
  telnet = if nxos?
@@ -75,31 +78,80 @@ module Baseline
75
78
  elsif vtys.any? && vtys.all? { |entry| entry[:protocols] }
76
79
  vtys.any? { |entry| (entry[:protocols] & %w[all telnet]).any? }
77
80
  end
78
- record("services", { "telnet" => telnet, "http" => configured_state(/\Aip http server\z/, /\Ano ip http server\z/),
81
+ record_fact("services", { "telnet" => telnet, "http" => configured_state(/\Aip http server\z/, /\Ano ip http server\z/),
79
82
  "https" => configured_state(/\Aip http secure-server\z/, /\Ano ip http secure-server\z/),
80
83
  "ftp" => configured_state(/\A(?:feature ftp-server|ftp-server enable)\z/, /\Ano (?:feature ftp-server|ftp-server enable)\z/),
81
84
  "tftp" => configured_state(/\A(?:feature tftp-server|tftp-server\s+.+)\z/, /\Ano (?:feature tftp-server|tftp-server\s+.+)\z/),
82
85
  "finger" => configured_state(/\A(?:ip finger|service finger)\z/, /\Ano (?:ip finger|service finger)\z/) })
83
86
  end
84
87
 
85
- # 采集明确启用或禁用的管理服务。
86
- def services
87
- record("ntp.servers", configured_values(/\Antp server(?: vrf \S+)?\s+(\S+)/), matching_blocks(/\Antp server/))
88
- record("ntp.authentication", configured_state(/\Antp authenticate\z/, /\Ano ntp authenticate\z/))
89
- record("ntp.source", last_configured_value(/\Antp source(?:-interface)?\s+(\S+)\z/))
90
- hosts = configured_values(nxos? ? /\Alogging server\s+(\S+)/ : /\Alogging host(?: ipv6)?\s+(\S+)/)
91
- hosts |= configured_values(/\Alogging\s+(\d+(?:\.\d+){3})\z/) unless nxos?
92
- record("syslog.servers", hosts, matching_blocks(/\Alogging (?:host|server)/))
93
- record("syslog.source", last_configured_value(/\Alogging source-interface\s+(\S+)\z/))
94
- snmp = matching_blocks(/\Asnmp-server\b/)
88
+ # VTY 登录方式属于同一声明族,不能让先前的本地登录覆盖后续列表或撤销。
89
+ def configured_vty_login(section, default_aaa:)
90
+ entry = matching_blocks(/\A(?:no )?login\b/, section.children).last
91
+ fallback = { authentication: default_aaa ? "aaa" : nil, authentication_list: default_aaa ? "default" : nil }
92
+ return fallback unless entry
93
+
94
+ case entry.text
95
+ when "login local"
96
+ analyze(entry) { { authentication: "local", authentication_list: nil } }
97
+ when "login", "no login"
98
+ analyze(entry) { { authentication: "none", authentication_list: nil } }
99
+ when /\Alogin authentication\s+(\S+)\z/
100
+ analyze(entry) { fallback.merge(authentication_list: entry.text.split.last) }
101
+ else
102
+ record_unknown_fact("ssh.vty_aaa", [ entry ])
103
+ record_unknown_fact("tacacs.login", [ entry ])
104
+ { authentication: nil, authentication_list: nil }
105
+ end
106
+ end
107
+
108
+ # 提取时间服务器、认证要求与源接口。
109
+ def parse_ntp
110
+ ntp_entries = matching_blocks(/\A(?:no )?ntp server(?:\s|\z)/)
111
+ ntp_pattern = /\Antp server\s+(?:vrf\s+\S+\s+)?(?:(?:ip|ipv6)\s+)?((?!(?:vrf|ip|ipv6)(?:\s|\z))\S+)/
112
+ servers = configured_server_addresses("ntp.servers", ntp_pattern, ntp_entries, negate: "no") do |text, match|
113
+ options = parse_server_options(match.post_match, flags: %w[prefer normal-sync burst iburst],
114
+ integers: { "version" => 1..4, "key" => 1..65_535, "minpoll" => 4..17, "maxpoll" => 4..17 },
115
+ names: [ "use-vrf" ], interfaces: [ "source" ])
116
+ vrf = text[/\Antp server\s+vrf\s+(\S+)/, 1]
117
+ [ match[1], vrf || options["use-vrf"] ] if options && !(vrf && options.key?("use-vrf"))
118
+ end
119
+ record_fact("ntp.servers", servers, ntp_entries)
120
+ record_configured_state("ntp.authentication", /\Antp authenticate\z/, /\Ano ntp authenticate\z/)
121
+ source_entries = matching_blocks(/\A(?:no )?ntp source(?:-interface)?\b/)
122
+ source = effective_configured_value(/\Antp source(?:-interface)?\s+(\S+)\z/, entries: source_entries,
123
+ family: /\A/, fact: "ntp.source", reset: /\Ano ntp source(?:-interface)?(?:\s+\S+)?\z/)
124
+ record_fact("ntp.source", source, source_entries.last(1))
125
+ end
126
+
127
+ # 提取日志目标与源接口,厂商命令不进入事实归约层。
128
+ def parse_syslog
129
+ log_entries = matching_blocks(nxos? ? /\A(?:no )?logging server(?:\s|\z)/ : /\A(?:no )?logging host(?:\s|\z)/)
130
+ log_pattern = nxos? ? /\Alogging server\s+(\S+)/ : /\Alogging host\s+(?:ipv6\s+)?((?!ipv6(?:\s|\z))\S+)/
131
+ unless nxos?
132
+ log_entries += matching_blocks(/\A(?:no )?logging\s+\d+(?:\.\d+){3}\z/)
133
+ log_entries.sort_by!(&:index)
134
+ log_pattern = /\Alogging\s+(?:host\s+(?:ipv6\s+)?)?((?!(?:host|ipv6)(?:\s|\z))\S+)/
135
+ end
136
+ hosts = configured_server_addresses("syslog.servers", log_pattern, log_entries, negate: "no")
137
+ record_fact("syslog.servers", hosts, log_entries)
138
+ source_entries = matching_blocks(/\A(?:no )?logging source-interface\b/)
139
+ source = effective_configured_value(/\Alogging source-interface\s+(\S+)\z/, entries: source_entries,
140
+ family: /\A/, fact: "syslog.source", reset: /\Ano logging source-interface(?:\s+\S+)?\z/)
141
+ record_fact("syslog.source", source, source_entries.last(1))
142
+ end
143
+
144
+ # 提取轮询权限、用户安全属性及来源限制。
145
+ def parse_snmp
146
+ snmp_entries = matching_blocks(/\Asnmp-server\b/)
95
147
  enabled = nxos? ? configured_state(/\Asnmp-server protocol enable\z/, /\Ano snmp-server protocol enable\z/) : nil
96
- enabled = nxos? || snmp.any? if enabled.nil? && (@facts.complete? || snmp.any?)
97
- record("snmp.enabled", enabled)
148
+ enabled = nxos? || snmp_entries.any? if enabled.nil? && (@fact_builder.complete? || snmp_entries.any?)
149
+ record_fact("snmp.enabled", enabled)
98
150
  communities = {}
99
151
  matching_blocks(/\Asnmp-server community\b/).each do |entry|
100
152
  match = /\Asnmp-server community\s+(\S+)(?:\s+(.*))?\z/.match(entry.text)
101
153
  unless match
102
- unknown_snmp_access([ entry ])
154
+ record_unknown_snmp_access([ entry ])
103
155
  next
104
156
  end
105
157
  settings = match[2].to_s
@@ -110,20 +162,24 @@ module Baseline
110
162
  community[:write] = SNMP_ROLE_WRITE[role] if role
111
163
  acl = nxos? ? settings[/\buse-ipv4acl\s+(\S+)/, 1] : settings[/\A(?:ro|rw)\s+(\S+)\z/i, 1]
112
164
  community[:acl] = acl if acl
113
- record_credential("snmp-community", "snmp", "plain", match[1], entry) unless @facts.credential_recorded?(community[:line])
165
+ record_credential("snmp-community", "snmp", "plain", match[1], entry) unless @fact_builder.credential_recorded?(community[:line])
114
166
  end
115
167
  versions = communities.any? ? %w[v1 v2c] : []
116
- groups = snmp_groups(matching_blocks(/\Asnmp-server group\s+.+?\s+v3\b/)) do |entry|
168
+ unsupported_groups = matching_blocks(/\Asnmp-server group\b/).reject do |entry|
169
+ /\Asnmp-server group\s+\S+\s+v(?:1|2c|3)(?:\s|\z)/.match?(entry.text)
170
+ end
171
+ record_unknown_snmp_access(unsupported_groups, code: :unsupported_snmp_group) unless unsupported_groups.empty?
172
+ groups = configured_snmp_groups(matching_blocks(/\Asnmp-server group\s+\S+\s+v3(?:\s|\z)/)) do |entry|
117
173
  name, settings = entry.text.match(/\Asnmp-server group\s+(.+?)\s+v3(?:\s+(.*))?\z/).captures
118
- write = name.include?('"') ? nil : snmp_group_write(settings.to_s)
174
+ write = name.include?('"') ? nil : snmp_group_write_state(settings.to_s)
119
175
  [ name, { acl: entry.text[/\baccess\s+(\S+)/, 1], line: entry.line, write: write } ]
120
176
  end
121
- record("snmp.groups", groups)
177
+ record_fact("snmp.groups", groups)
122
178
  users = {}
123
179
  matching_blocks(/\Asnmp-server user\b/).each do |entry|
124
180
  match = /\Asnmp-server user\s+(\S+)\s+(\S+)(.*)\z/.match(entry.text)
125
181
  unless match
126
- unknown_snmp_access([ entry ])
182
+ record_unknown_snmp_access([ entry ])
127
183
  next
128
184
  end
129
185
  if nxos? && match[2] == "use-ipv4acl"
@@ -133,11 +189,11 @@ module Baseline
133
189
  end
134
190
  settings = match[3]
135
191
  if /\bv[12]c?\b/.match?(settings)
136
- unknown_snmp_access([ entry ], code: :unsupported_snmp_user)
192
+ record_unknown_snmp_access([ entry ], code: :unsupported_snmp_user)
137
193
  next
138
194
  end
139
195
  unless nxos? || /\bv3\b/.match?(settings)
140
- unknown_snmp_access([ entry ])
196
+ record_unknown_snmp_access([ entry ])
141
197
  next
142
198
  end
143
199
  versions |= [ "v3" ]
@@ -145,33 +201,33 @@ module Baseline
145
201
  if nxos?
146
202
  (user[:roles] ||= []) << match[2]
147
203
  user[:roles].uniq!
148
- user[:write] = Facts.write_permission(user[:roles].map { |role| SNMP_ROLE_WRITE[role] })
204
+ user[:write] = FactBuilder.write_permission_state(user[:roles].map { |role| SNMP_ROLE_WRITE[role] })
149
205
  next if settings.strip.empty?
150
206
  end
151
- modes = { auth: snmp_secret(settings, "auth", algorithms: %w[md5 sha sha-224 sha-256 sha-384 sha-512]),
152
- priv: snmp_secret(settings, "priv", algorithms: %w[des 3des aes aes-128 aes-192 aes-256], optional_algorithm: nxos?, aes_size: !nxos?) }
153
- security = snmp_credentials("snmp:#{match[1]}", modes, entry, format: /\bencrypted\b/.match?(settings) ? "cipher" : "unknown")
207
+ modes = { auth: parse_snmp_secret(settings, "auth", algorithms: %w[md5 sha sha-224 sha-256 sha-384 sha-512]),
208
+ priv: parse_snmp_secret(settings, "priv", algorithms: %w[des 3des aes aes-128 aes-192 aes-256], optional_algorithm: nxos?, aes_size: !nxos?) }
209
+ security = record_snmp_credentials("snmp:#{match[1]}", modes, entry, format: /\bencrypted\b/.match?(settings) ? "cipher" : "unknown")
154
210
  user.merge!(security).merge!(group: match[2])
155
211
  acl = settings[/\b(?:access|use-ipv4acl)\s+(\S+)/, 1]
156
212
  user[:acl] = acl if acl
157
213
  end
158
214
  port_overrides = matching_blocks(/\A(?:no )?snmp-server (?:listen-port|udp-port|port)(?:\s|\z)/)
159
- unknown_listener_port("snmp.acl", port_overrides) unless port_overrides.empty?
160
- @facts.snmp(communities: communities.values, users: users.values, groups: nxos? ? nil : groups,
215
+ record_unknown_listener_port("snmp.acl", port_overrides) unless port_overrides.empty?
216
+ @fact_builder.record_snmp_access(communities: communities.values, users: users.values, groups: nxos? ? nil : groups,
161
217
  port: port_overrides.empty? ? 161 : nil)
162
- nxos_snmp_coverage(users, enabled: enabled) if nxos?
163
- unknown("snmp.v3_auth_priv", code: :snmp_user_unresolved) if users.values.any? { |user| !user.key?(:auth) }
218
+ record_unverified_snmp_user_sync(users, enabled: enabled) if nxos?
219
+ record_unknown_fact("snmp.v3_auth_priv", code: :snmp_user_unresolved) if users.values.any? { |user| !user.key?(:auth) }
164
220
  versions |= configured_values(/\Asnmp-server group\s+\S+\s+(v1|v2c|v3)\b/)
165
221
  legacy_groups = matching_blocks(/\Asnmp-server group\s+\S+\s+v(?:1|2c)\b/)
166
222
  if legacy_groups.any?
167
- @facts.unknown_snmp_permission(lines: legacy_groups.map(&:line), code: :unsupported_snmp_group)
168
- unknown("snmp.acl", legacy_groups, code: :unsupported_snmp_group)
223
+ @fact_builder.record_unknown_snmp_permission(lines: legacy_groups.map(&:line), code: :unsupported_snmp_group)
224
+ record_unknown_fact("snmp.acl", legacy_groups, code: :unsupported_snmp_group)
169
225
  end
170
- record("snmp.versions", versions) unless versions.empty?
226
+ record_fact("snmp.versions", versions) unless versions.empty?
171
227
  end
172
228
 
173
229
  # NX-OS 通常会同步 CLI 与 SNMP 账号;缺少证据时不能构造虚拟用户。
174
- def nxos_snmp_coverage(users, enabled:)
230
+ def record_unverified_snmp_user_sync(users, enabled:)
175
231
  return if enabled == false
176
232
  sync_disabled = configured_state(/\Asnmp-server disable snmp-aaa sync\z/, /\Ano snmp-server disable snmp-aaa sync\z/)
177
233
  return if sync_disabled == true
@@ -181,12 +237,12 @@ module Baseline
181
237
  covered = users[name]&.fetch(:roles, []) || []
182
238
  next if roles.any? && (roles - covered).empty?
183
239
 
184
- @facts.unknown_snmp_permission(lines: entries.map(&:line), code: :snmp_user_sync_unconfirmed)
240
+ @fact_builder.record_unknown_snmp_permission(lines: entries.map(&:line), code: :snmp_user_sync_unconfirmed)
185
241
  end
186
242
  end
187
243
 
188
244
  # IOS 默认不授予写视图;不熟悉的组配置项不能证明只读权限。
189
- def snmp_group_write(settings)
245
+ def snmp_group_write_state(settings)
190
246
  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/
191
247
  match = syntax.match(settings)
192
248
  return unless match
@@ -195,25 +251,32 @@ module Baseline
195
251
  end
196
252
 
197
253
  # 解析实际生效的认证方法及外部服务器引用。
198
- def authentication
254
+ def parse_authentication
199
255
  servers, aliases, keyed = [], {}, []
200
256
  global_key = matching_blocks(/\Atacacs-server key\s+/)
201
- global_key.each { |entry| parse_key("tacacs", entry.text.sub(/\Atacacs-server key\s+/, ""), entry) }
202
- matching_blocks(/\Atacacs-server host\s+/).each do |entry|
203
- host = entry.text.split[2]
204
- servers << host
257
+ global_key.each { |entry| record_tacacs_key("tacacs", entry.text.sub(/\Atacacs-server key\s+/, ""), entry) }
258
+ legacy_hosts = matching_blocks(/\A(?:no )?tacacs-server host(?:\s|\z)/)
259
+ hosts = configured_server_addresses("tacacs", /\Atacacs-server host\s+(\S+)/, legacy_hosts, negate: "no")
260
+ servers |= hosts
261
+ active_hosts = {}
262
+ legacy_hosts.each do |entry|
263
+ host = entry.text[/\A(?:no )?tacacs-server host\s+(\S+)/, 1]
264
+ next unless server_address?(host)
265
+ entry.text.start_with?("no ") ? active_hosts.delete(host) : active_hosts[host] = entry
266
+ end
267
+ active_hosts.each do |host, entry|
205
268
  key = entry.text[/\bkey\s+(.+)\z/, 1]
206
269
  if key
207
- parse_key("tacacs:#{host}", key, entry)
270
+ record_tacacs_key("tacacs:#{host}", key, entry)
208
271
  keyed << host
209
272
  end
210
273
  end
211
274
  matching_blocks(/\Atacacs server\s+/).each do |section|
212
- hosts = configured_values(/\Aaddress ipv[46]\s+(\S+)/, section.children)
275
+ hosts = configured_server_addresses("tacacs", /\Aaddress ipv[46]\s+(\S+)/, matching_blocks(/\A(?:no )?address ipv[46](?:\s|\z)/, section.children), negate: "no")
213
276
  aliases[section.text.split.last] = hosts
214
277
  servers |= hosts
215
278
  matching_blocks(/\Akey\s+/, section.children).each do |entry|
216
- parse_key("tacacs:#{section.text.split.last}", entry.text.delete_prefix("key "), entry)
279
+ record_tacacs_key("tacacs:#{section.text.split.last}", entry.text.delete_prefix("key "), entry)
217
280
  keyed |= hosts
218
281
  end
219
282
  end
@@ -221,57 +284,68 @@ module Baseline
221
284
  members = configured_values(/\Aserver(?: name)?\s+(\S+)/, section.children)
222
285
  [ section.text.split.last, members.flat_map { |member| aliases.fetch(member, [ member ]) } ]
223
286
  end
224
- record("tacacs.servers", servers.uniq)
225
- record("tacacs.groups", groups)
226
- tacacs_login(servers, groups)
227
- patterns = { "authorization" => /\Aaaa authorization (?:exec|commands(?: \d+)?) default\s+(.+)\z/,
228
- "accounting" => /\Aaaa accounting (?:(?:exec|commands(?: \d+)?) )?default\s+(.+)\z/ }
229
- patterns.each do |kind, pattern|
230
- references = configured_values(pattern).flat_map { |methods| methods.scan(/\bgroup\s+(\S+)/).flatten }
231
- selected = references.flat_map { |name| name == "tacacs+" ? servers : groups.fetch(name, []) }.uniq
232
- unresolved = references.any? { |name| name != "radius" && name != "tacacs+" && !groups.key?(name) } || (selected - servers).any?
233
- if unresolved
234
- unknown("tacacs.#{kind}", matching_blocks(pattern), code: :tacacs_group_unresolved)
235
- else
236
- used = selected.any?
237
- record("tacacs.#{kind}", used, matching_blocks(pattern)) if @facts.complete? || used
287
+ record_fact("tacacs.servers", servers.uniq)
288
+ record_fact("tacacs.groups", groups)
289
+ record_tacacs_login_usage(servers, groups)
290
+ %w[authorization accounting].each { |kind| record_tacacs_action_usage(kind, servers, groups) }
291
+ record_fact("tacacs.keys_present", global_key.any? || (servers - keyed).empty?) unless servers.empty?
292
+ parse_local_accounts
293
+ end
294
+
295
+ # 授权和计费按动作及命令级别分别覆盖,只有全部最终声明都首选 TACACS 才确认使用。
296
+ def record_tacacs_action_usage(kind, servers, groups)
297
+ pattern = /\A(?:no )?aaa #{kind} (?:(exec|commands(?: \d+)?) )?default(?:\s|\z)/
298
+ entries = matching_blocks(pattern).group_by { |entry| entry.text[pattern, 1] }.values.map(&:last)
299
+ results = entries.map do |entry|
300
+ match = /\Aaaa #{kind} (?:(?:exec|commands(?: \d+)?) )?default\s+(.+)\z/.match(entry.text)
301
+ next :unsupported_authentication_method unless match
302
+
303
+ methods = match[1]
304
+ if kind == "accounting"
305
+ next analyze(entry) { false } if methods == "none"
306
+ methods = methods.sub(/\A(?:start-stop|stop-only)\s+/, "")
238
307
  end
308
+ analyze(entry) { tacacs_login_method_state(methods, servers, groups) }
309
+ end
310
+ if (reason = results.find { |value| value.is_a?(Symbol) })
311
+ record_unknown_fact("tacacs.#{kind}", entries, code: reason)
312
+ else
313
+ used = results.any? && results.all?(true)
314
+ record_fact("tacacs.#{kind}", used, entries) if @fact_builder.complete? || used
239
315
  end
240
- record("tacacs.keys_present", global_key.any? || (servers - keyed).empty?) unless servers.empty?
241
- accounts
242
316
  end
243
317
 
244
318
  # 解析各 VTY 实际选用的登录列表,包括命名列表。
245
- def tacacs_login(servers, groups)
319
+ def record_tacacs_login_usage(servers, groups)
246
320
  pattern = /\Aaaa authentication login\s+(\S+)\s+(.+)\z/
247
321
  lists = matching_blocks(pattern).each_with_object({}) do |entry, result|
248
322
  name, methods = analyze(entry) { entry.text.match(pattern).captures }
249
323
  result[name] = { methods: methods, entry: entry }
250
324
  end
251
- entries = @facts.vtys.filter_map { |vty| lists[vty[:authentication_list]]&.fetch(:entry) }.uniq
252
- results = @facts.vtys.map do |vty|
325
+ entries = @fact_builder.vtys.filter_map { |vty| lists[vty[:authentication_list]]&.fetch(:entry) }.uniq
326
+ results = @fact_builder.vtys.map do |vty|
253
327
  if vty[:authentication].nil?
254
328
  :vty_authentication_unknown
255
329
  elsif vty[:authentication] != "aaa"
256
330
  false
257
331
  elsif (list = lists[vty[:authentication_list]])
258
- tacacs_login_method(list[:methods], servers, groups)
332
+ tacacs_login_method_state(list[:methods], servers, groups)
259
333
  else
260
334
  :authentication_list_unresolved
261
335
  end
262
336
  end
263
337
  results << :vty_authentication_unknown if results.empty?
264
338
  if results.include?(false)
265
- record("tacacs.login", false, entries) if @facts.complete?
339
+ record_fact("tacacs.login", false, entries) if @fact_builder.complete?
266
340
  elsif (reason = results.find { |value| value.is_a?(Symbol) })
267
- unknown("tacacs.login", entries, code: reason)
341
+ record_unknown_fact("tacacs.login", entries, code: reason)
268
342
  else
269
- record("tacacs.login", true, entries)
343
+ record_fact("tacacs.login", true, entries)
270
344
  end
271
345
  end
272
346
 
273
347
  # 仅当首选认证方法引用可解析的 TACACS 组时,才确认使用 TACACS。
274
- def tacacs_login_method(methods, servers, groups)
348
+ def tacacs_login_method_state(methods, servers, groups)
275
349
  tokens = methods.split
276
350
  outcomes = []
277
351
  until tokens.empty?
@@ -296,13 +370,13 @@ module Baseline
296
370
  end
297
371
 
298
372
  # 提取已配置的凭据编码方式,不保留秘密值。
299
- def parse_key(owner, text, entry)
373
+ def record_tacacs_key(owner, text, entry)
300
374
  match = /\A(?:(\d+)\s+)?(\S+)/.match(text)
301
375
  record_credential(owner, "tacacs", match&.[](1) || "plain", match&.[](2), entry)
302
376
  end
303
377
 
304
378
  # 提取本地账号角色及凭据存储属性。
305
- def accounts
379
+ def parse_local_accounts
306
380
  matching_blocks(/\Ausername\s+/).group_by { |entry| entry.text.split[1] }.each do |name, entries|
307
381
  passwords = entries.filter_map do |entry|
308
382
  match = /\b(?:password|secret)\s+(?:(\d+)\s+)?(\S+)/.match(entry.text)