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.
@@ -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
+ # 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)
88
120
  record_configured_state("ntp.authentication", /\Antp authenticate\z/, /\Ano ntp authenticate\z/)
89
- record_configured_value("ntp.source", /\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_configured_value("syslog.source", /\Alogging source-interface\s+(\S+)\z/)
94
- snmp = matching_blocks(/\Asnmp-server\b/)
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)
98
- communities = {}
99
- matching_blocks(/\Asnmp-server community\b/).each do |entry|
148
+ enabled = nxos? || snmp_entries.any? if enabled.nil? && (@fact_builder.complete? || snmp_entries.any?)
149
+ record_fact("snmp.enabled", enabled)
150
+ communities, community_acls = {}, {}
151
+ configured_community_entries.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,32 @@ 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
+ unless nxos?
166
+ access = (community_acls[match[1]] ||= { value: acl, conflicted: false })
167
+ access[:conflicted] ||= access[:value] != acl
168
+ if access[:conflicted]
169
+ community.delete(:acl)
170
+ record_unknown_fact("snmp.acl", [ entry ], code: :conflicting_community_acl)
171
+ end
172
+ end
173
+ record_credential("snmp-community", "snmp", "plain", match[1], entry) unless @fact_builder.credential_recorded?(community[:line])
114
174
  end
115
175
  versions = communities.any? ? %w[v1 v2c] : []
116
- groups = snmp_groups(matching_blocks(/\Asnmp-server group\s+.+?\s+v3\b/)) do |entry|
176
+ unsupported_groups = matching_blocks(/\Asnmp-server group\b/).reject do |entry|
177
+ /\Asnmp-server group\s+\S+\s+v(?:1|2c|3)(?:\s|\z)/.match?(entry.text)
178
+ end
179
+ record_unknown_snmp_access(unsupported_groups, code: :unsupported_snmp_group) unless unsupported_groups.empty?
180
+ groups = configured_snmp_groups(matching_blocks(/\Asnmp-server group\s+\S+\s+v3(?:\s|\z)/)) do |entry|
117
181
  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)
182
+ write = name.include?('"') ? nil : snmp_group_write_state(settings.to_s)
119
183
  [ name, { acl: entry.text[/\baccess\s+(\S+)/, 1], line: entry.line, write: write } ]
120
184
  end
121
- record("snmp.groups", groups)
185
+ record_fact("snmp.groups", groups)
122
186
  users = {}
123
187
  matching_blocks(/\Asnmp-server user\b/).each do |entry|
124
188
  match = /\Asnmp-server user\s+(\S+)\s+(\S+)(.*)\z/.match(entry.text)
125
189
  unless match
126
- unknown_snmp_access([ entry ])
190
+ record_unknown_snmp_access([ entry ])
127
191
  next
128
192
  end
129
193
  if nxos? && match[2] == "use-ipv4acl"
@@ -133,11 +197,11 @@ module Baseline
133
197
  end
134
198
  settings = match[3]
135
199
  if /\bv[12]c?\b/.match?(settings)
136
- unknown_snmp_access([ entry ], code: :unsupported_snmp_user)
200
+ record_unknown_snmp_access([ entry ], code: :unsupported_snmp_user)
137
201
  next
138
202
  end
139
203
  unless nxos? || /\bv3\b/.match?(settings)
140
- unknown_snmp_access([ entry ])
204
+ record_unknown_snmp_access([ entry ])
141
205
  next
142
206
  end
143
207
  versions |= [ "v3" ]
@@ -145,33 +209,51 @@ module Baseline
145
209
  if nxos?
146
210
  (user[:roles] ||= []) << match[2]
147
211
  user[:roles].uniq!
148
- user[:write] = Facts.write_permission(user[:roles].map { |role| SNMP_ROLE_WRITE[role] })
212
+ user[:write] = FactBuilder.write_permission_state(user[:roles].map { |role| SNMP_ROLE_WRITE[role] })
149
213
  next if settings.strip.empty?
150
214
  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")
215
+ modes = { auth: parse_snmp_secret(settings, "auth", algorithms: %w[md5 sha sha-224 sha-256 sha-384 sha-512]),
216
+ priv: parse_snmp_secret(settings, "priv", algorithms: %w[des 3des aes aes-128 aes-192 aes-256], optional_algorithm: nxos?, aes_size: !nxos?) }
217
+ security = record_snmp_credentials("snmp:#{match[1]}", modes, entry, format: /\bencrypted\b/.match?(settings) ? "cipher" : "unknown")
154
218
  user.merge!(security).merge!(group: match[2])
155
219
  acl = settings[/\b(?:access|use-ipv4acl)\s+(\S+)/, 1]
156
220
  user[:acl] = acl if acl
157
221
  end
158
222
  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,
223
+ record_unknown_listener_port("snmp.acl", port_overrides) unless port_overrides.empty?
224
+ @fact_builder.record_snmp_access(communities: communities.values, users: users.values, groups: nxos? ? nil : groups,
161
225
  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) }
226
+ record_unverified_snmp_user_sync(users, enabled: enabled) if nxos?
227
+ record_unknown_fact("snmp.v3_auth_priv", code: :snmp_user_unresolved) if users.values.any? { |user| !user.key?(:auth) }
164
228
  versions |= configured_values(/\Asnmp-server group\s+\S+\s+(v1|v2c|v3)\b/)
165
229
  legacy_groups = matching_blocks(/\Asnmp-server group\s+\S+\s+v(?:1|2c)\b/)
166
230
  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)
231
+ @fact_builder.record_unknown_snmp_permission(lines: legacy_groups.map(&:line), code: :unsupported_snmp_group)
232
+ record_unknown_fact("snmp.acl", legacy_groups, code: :unsupported_snmp_group)
169
233
  end
170
- record("snmp.versions", versions) unless versions.empty?
234
+ record_fact("snmp.versions", versions) unless versions.empty?
235
+ end
236
+
237
+ # IOS 的 no 声明撤销整项团体字;先归约有效来源,避免留下访问或凭据事实。
238
+ def configured_community_entries
239
+ return matching_blocks(/\Asnmp-server community\b/) if nxos?
240
+
241
+ active = {}
242
+ matching_blocks(/\A(?:no )?snmp-server community\b/).each do |entry|
243
+ if (match = /\Ano snmp-server community\s+(\S+)\z/.match(entry.text))
244
+ analyze(entry) { active.delete(match[1]) }
245
+ elsif entry.text.start_with?("no ")
246
+ record_unknown_snmp_access([ entry ])
247
+ else
248
+ name = entry.text.split[2]
249
+ (active[name] ||= []) << entry
250
+ end
251
+ end
252
+ active.values.flatten.sort_by(&:line)
171
253
  end
172
254
 
173
255
  # NX-OS 通常会同步 CLI 与 SNMP 账号;缺少证据时不能构造虚拟用户。
174
- def nxos_snmp_coverage(users, enabled:)
256
+ def record_unverified_snmp_user_sync(users, enabled:)
175
257
  return if enabled == false
176
258
  sync_disabled = configured_state(/\Asnmp-server disable snmp-aaa sync\z/, /\Ano snmp-server disable snmp-aaa sync\z/)
177
259
  return if sync_disabled == true
@@ -181,12 +263,12 @@ module Baseline
181
263
  covered = users[name]&.fetch(:roles, []) || []
182
264
  next if roles.any? && (roles - covered).empty?
183
265
 
184
- @facts.unknown_snmp_permission(lines: entries.map(&:line), code: :snmp_user_sync_unconfirmed)
266
+ @fact_builder.record_unknown_snmp_permission(lines: entries.map(&:line), code: :snmp_user_sync_unconfirmed)
185
267
  end
186
268
  end
187
269
 
188
270
  # IOS 默认不授予写视图;不熟悉的组配置项不能证明只读权限。
189
- def snmp_group_write(settings)
271
+ def snmp_group_write_state(settings)
190
272
  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
273
  match = syntax.match(settings)
192
274
  return unless match
@@ -195,25 +277,32 @@ module Baseline
195
277
  end
196
278
 
197
279
  # 解析实际生效的认证方法及外部服务器引用。
198
- def authentication
280
+ def parse_authentication
199
281
  servers, aliases, keyed = [], {}, []
200
282
  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
283
+ global_key.each { |entry| record_tacacs_key("tacacs", entry.text.sub(/\Atacacs-server key\s+/, ""), entry) }
284
+ legacy_hosts = matching_blocks(/\A(?:no )?tacacs-server host(?:\s|\z)/)
285
+ hosts = configured_server_addresses("tacacs", /\Atacacs-server host\s+(\S+)/, legacy_hosts, negate: "no")
286
+ servers |= hosts
287
+ active_hosts = {}
288
+ legacy_hosts.each do |entry|
289
+ host = entry.text[/\A(?:no )?tacacs-server host\s+(\S+)/, 1]
290
+ next unless server_address?(host)
291
+ entry.text.start_with?("no ") ? active_hosts.delete(host) : active_hosts[host] = entry
292
+ end
293
+ active_hosts.each do |host, entry|
205
294
  key = entry.text[/\bkey\s+(.+)\z/, 1]
206
295
  if key
207
- parse_key("tacacs:#{host}", key, entry)
296
+ record_tacacs_key("tacacs:#{host}", key, entry)
208
297
  keyed << host
209
298
  end
210
299
  end
211
300
  matching_blocks(/\Atacacs server\s+/).each do |section|
212
- hosts = configured_values(/\Aaddress ipv[46]\s+(\S+)/, section.children)
301
+ hosts = configured_server_addresses("tacacs", /\Aaddress ipv[46]\s+(\S+)/, matching_blocks(/\A(?:no )?address ipv[46](?:\s|\z)/, section.children), negate: "no")
213
302
  aliases[section.text.split.last] = hosts
214
303
  servers |= hosts
215
304
  matching_blocks(/\Akey\s+/, section.children).each do |entry|
216
- parse_key("tacacs:#{section.text.split.last}", entry.text.delete_prefix("key "), entry)
305
+ record_tacacs_key("tacacs:#{section.text.split.last}", entry.text.delete_prefix("key "), entry)
217
306
  keyed |= hosts
218
307
  end
219
308
  end
@@ -221,57 +310,72 @@ module Baseline
221
310
  members = configured_values(/\Aserver(?: name)?\s+(\S+)/, section.children)
222
311
  [ section.text.split.last, members.flat_map { |member| aliases.fetch(member, [ member ]) } ]
223
312
  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
313
+ record_fact("tacacs.servers", servers.uniq)
314
+ record_fact("tacacs.groups", groups)
315
+ record_tacacs_login_usage(servers, groups)
316
+ %w[authorization accounting].each { |kind| record_tacacs_action_usage(kind, servers, groups) }
317
+ record_fact("tacacs.keys_present", global_key.any? || (servers - keyed).empty?) unless servers.empty?
318
+ parse_local_accounts
319
+ end
320
+
321
+ # 授权和计费按动作及命令级别分别覆盖,只有全部最终声明都首选 TACACS 才确认使用。
322
+ def record_tacacs_action_usage(kind, servers, groups)
323
+ pattern = /\A(?:no )?aaa #{kind} (?:(exec|commands(?: \d+)?) )?default(?:\s|\z)/
324
+ entries = matching_blocks(pattern).group_by { |entry| entry.text[pattern, 1] }.values.map(&:last)
325
+ results = entries.map do |entry|
326
+ match = /\Aaaa #{kind} (?:(?:exec|commands(?: \d+)?) )?default\s+(.+)\z/.match(entry.text)
327
+ next :unsupported_authentication_method unless match
328
+
329
+ methods = match[1]
330
+ if kind == "accounting"
331
+ next analyze(entry) { false } if methods == "none"
332
+ methods = methods.sub(/\A(?:start-stop|stop-only)\s+/, "")
238
333
  end
334
+ analyze(entry) { tacacs_login_method_state(methods, servers, groups) }
335
+ end
336
+ if (reason = results.find { |value| value.is_a?(Symbol) })
337
+ record_unknown_fact("tacacs.#{kind}", entries, code: reason)
338
+ else
339
+ used = results.any? && results.all?(true)
340
+ record_fact("tacacs.#{kind}", used, entries) if @fact_builder.complete? || used
239
341
  end
240
- record("tacacs.keys_present", global_key.any? || (servers - keyed).empty?) unless servers.empty?
241
- accounts
242
342
  end
243
343
 
244
344
  # 解析各 VTY 实际选用的登录列表,包括命名列表。
245
- def tacacs_login(servers, groups)
345
+ def record_tacacs_login_usage(servers, groups)
246
346
  pattern = /\Aaaa authentication login\s+(\S+)\s+(.+)\z/
247
- lists = matching_blocks(pattern).each_with_object({}) do |entry, result|
248
- name, methods = analyze(entry) { entry.text.match(pattern).captures }
347
+ family = /\A(?:no )?aaa authentication login\s+(\S+)(?:\s|\z)/
348
+ lists = matching_blocks(family).each_with_object({}) do |entry, result|
349
+ name = entry.text[family, 1]
350
+ match = pattern.match(entry.text)
351
+ # 最后同名声明决定列表证据;撤销或损坏不能复用先前的方法。
352
+ methods = match ? analyze(entry) { match[2] } : nil
249
353
  result[name] = { methods: methods, entry: entry }
250
354
  end
251
- entries = @facts.vtys.filter_map { |vty| lists[vty[:authentication_list]]&.fetch(:entry) }.uniq
252
- results = @facts.vtys.map do |vty|
355
+ entries = @fact_builder.vtys.filter_map { |vty| lists[vty[:authentication_list]]&.fetch(:entry) }.uniq
356
+ results = @fact_builder.vtys.map do |vty|
253
357
  if vty[:authentication].nil?
254
358
  :vty_authentication_unknown
255
359
  elsif vty[:authentication] != "aaa"
256
360
  false
257
361
  elsif (list = lists[vty[:authentication_list]])
258
- tacacs_login_method(list[:methods], servers, groups)
362
+ list[:methods] ? tacacs_login_method_state(list[:methods], servers, groups) : :authentication_list_unresolved
259
363
  else
260
364
  :authentication_list_unresolved
261
365
  end
262
366
  end
263
367
  results << :vty_authentication_unknown if results.empty?
264
368
  if results.include?(false)
265
- record("tacacs.login", false, entries) if @facts.complete?
369
+ record_fact("tacacs.login", false, entries) if @fact_builder.complete?
266
370
  elsif (reason = results.find { |value| value.is_a?(Symbol) })
267
- unknown("tacacs.login", entries, code: reason)
371
+ record_unknown_fact("tacacs.login", entries, code: reason)
268
372
  else
269
- record("tacacs.login", true, entries)
373
+ record_fact("tacacs.login", true, entries)
270
374
  end
271
375
  end
272
376
 
273
377
  # 仅当首选认证方法引用可解析的 TACACS 组时,才确认使用 TACACS。
274
- def tacacs_login_method(methods, servers, groups)
378
+ def tacacs_login_method_state(methods, servers, groups)
275
379
  tokens = methods.split
276
380
  outcomes = []
277
381
  until tokens.empty?
@@ -296,13 +400,13 @@ module Baseline
296
400
  end
297
401
 
298
402
  # 提取已配置的凭据编码方式,不保留秘密值。
299
- def parse_key(owner, text, entry)
403
+ def record_tacacs_key(owner, text, entry)
300
404
  match = /\A(?:(\d+)\s+)?(\S+)/.match(text)
301
405
  record_credential(owner, "tacacs", match&.[](1) || "plain", match&.[](2), entry)
302
406
  end
303
407
 
304
408
  # 提取本地账号角色及凭据存储属性。
305
- def accounts
409
+ def parse_local_accounts
306
410
  matching_blocks(/\Ausername\s+/).group_by { |entry| entry.text.split[1] }.each do |name, entries|
307
411
  passwords = entries.filter_map do |entry|
308
412
  match = /\b(?:password|secret)\s+(?:(\d+)\s+)?(\S+)/.match(entry.text)