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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +22 -0
- data/LICENSE +22 -0
- data/README-zh.md +91 -0
- data/README.md +91 -0
- data/docs/architecture-zh.md +225 -0
- data/docs/release.md +13 -0
- data/examples/offline.rb +17 -0
- data/lib/baseline/acl.rb +150 -0
- data/lib/baseline/audit.rb +38 -0
- data/lib/baseline/configuration.rb +50 -0
- data/lib/baseline/credential.rb +32 -0
- data/lib/baseline/document.rb +32 -0
- data/lib/baseline/evaluator.rb +151 -0
- data/lib/baseline/facts.rb +230 -0
- data/lib/baseline/hillstone_document.rb +25 -0
- data/lib/baseline/palo_alto_document.rb +15 -0
- data/lib/baseline/parsers/base.rb +256 -0
- data/lib/baseline/parsers/cisco.rb +324 -0
- data/lib/baseline/parsers/cisco_ios.rb +13 -0
- data/lib/baseline/parsers/cisco_nxos.rb +13 -0
- data/lib/baseline/parsers/comware.rb +216 -0
- data/lib/baseline/parsers/h3c.rb +23 -0
- data/lib/baseline/parsers/hillstone.rb +278 -0
- data/lib/baseline/parsers/huawei.rb +63 -0
- data/lib/baseline/parsers/palo_alto.rb +335 -0
- data/lib/baseline/policy.rb +122 -0
- data/lib/baseline/quoted_document.rb +47 -0
- data/lib/baseline/report.rb +119 -0
- data/lib/baseline/rule.rb +138 -0
- data/lib/baseline/version.rb +6 -0
- data/lib/baseline.rb +34 -0
- data/policy.yml +47 -0
- metadata +161 -0
|
@@ -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,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
|