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,278 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+ require_relative "../hillstone_document"
5
+
6
+ module Baseline
7
+ module Parsers
8
+ # StoneOS 管理端点与原生主机允许列表不同于路由器 VTY。
9
+ class Hillstone < Base
10
+ MANAGEMENT_SERVICES = %w[ssh telnet http https ftp snmp].freeze
11
+ HOST_PROTOCOLS = %w[ssh telnet http https netconf any].freeze
12
+
13
+ private
14
+
15
+ # 选择适合当前平台语法的文档词法解析器。
16
+ def configuration_document_class = HillstoneDocument
17
+
18
+ # 解析管理访问暴露检查使用的有序 ACL 定义。
19
+ def access_lists
20
+ @tokens = {}
21
+ end
22
+
23
+ # 已配置主机 ACL 本身不能证明管理监听服务已启用。
24
+ def management
25
+ interfaces = sections(/\Ainterface\s+/)
26
+ states = interfaces.map do |section|
27
+ matching(/\A(?:no )?manage\s+/, section.children).each_with_object({}) do |entry, values|
28
+ parts = tokens(entry)
29
+ negative = parts.first == "no"
30
+ parts = parts.drop(1) if negative
31
+ next unless MANAGEMENT_SERVICES.include?(parts[1])
32
+ if parts.size == 2
33
+ values[parts[1]] = analyze(entry) { !negative }
34
+ else
35
+ values[parts[1]] = nil
36
+ end
37
+ end
38
+ end
39
+ services = MANAGEMENT_SERVICES.to_h do |name|
40
+ values = states.map { |state| state[name] }
41
+ enabled = values.include?(true) ? true : values.any? && values.all?(false) ? false : nil
42
+ [ name, enabled ]
43
+ end
44
+ record("ssh.enabled", services["ssh"])
45
+ record("services", services.slice("telnet", "http", "https", "ftp"))
46
+ management_sources
47
+ end
48
+
49
+ # 信任主机可使用 CIDR、掩码、区间或 any;跨协议按保守并集处理。
50
+ def management_sources
51
+ entries = matching(/\A(?:no )?admin host\b/)
52
+ sources = entries.flat_map do |entry|
53
+ parts = tokens(entry)
54
+ raise ArgumentError unless parts.take(2) == %w[admin host] && HOST_PROTOCOLS.include?(parts.last)
55
+ analyze(entry) { host_networks(parts[2...-1]) }
56
+ rescue ArgumentError, IPAddr::Error
57
+ unknown("management.acl", [ entry ], code: :unsupported_trusted_host)
58
+ nil
59
+ end
60
+ if entries.empty?
61
+ return unless @facts.complete?
62
+ # 未显式配置受信任主机时,不能据此证明来源访问受限。
63
+ @facts.direct_acl(fact: "management.acl", sources: nil, bound: false)
64
+ else
65
+ @facts.direct_acl(fact: "management.acl", sources: sources.include?(nil) ? nil : sources,
66
+ bound: true, lines: entries.map(&:line))
67
+ end
68
+ end
69
+
70
+ # 采集明确启用或禁用的管理服务。
71
+ def services
72
+ ntp
73
+ syslog
74
+ snmp
75
+ end
76
+
77
+ # 提取时间同步服务器及相关认证配置。
78
+ def ntp
79
+ entries = matching(/\Antp server\b/)
80
+ sources = []
81
+ servers = entries.filter_map do |entry|
82
+ parts = tokens(entry)
83
+ unless server_address?(parts[2])
84
+ unknown("ntp", [ entry ])
85
+ next
86
+ end
87
+ options = pairs(parts.drop(3), flags: [ "prefer" ], keys: %w[key source vrouter])
88
+ unless options && (!options["key"] || /\A[1-9]\d*\z/.match?(options["key"]))
89
+ unknown("ntp", [ entry ])
90
+ next
91
+ end
92
+ sources << options["source"]
93
+ analyze(entry) { parts[2] }
94
+ end
95
+ record("ntp.servers", servers.uniq, entries)
96
+ record("ntp.authentication", state(/\Antp authentication\z/, /\Ano ntp authentication\z/))
97
+ record("ntp.source", sources.first) if sources.any? && sources.first && sources.uniq.one?
98
+ unknown("ntp", matching(/\Ano ntp server\b/)) if matching(/\Ano ntp server\b/).any?
99
+ end
100
+
101
+ # 同一目标可供多个日志类别使用,统计时只计一次。
102
+ def syslog
103
+ entries = matching(/\Alogging syslog\b/)
104
+ sources = []
105
+ servers = entries.filter_map do |entry|
106
+ parts = tokens(entry)
107
+ options = parts.drop(3)
108
+ source = nil
109
+ if %w[vrouter source-interface].include?(options.first)
110
+ source = options[1] if options.first == "source-interface"
111
+ options = options.drop(2)
112
+ end
113
+ valid = server_address?(parts[2]) && %w[tcp udp secure-tcp].include?(options[0]) && service_port(options[1])&.positive?
114
+ tail = options.drop(2)
115
+ tail = tail.drop(1) if options[0] == "secure-tcp" && tail.first == "server-cert-check-disable"
116
+ valid &&= tail.empty? || tail.first == "type" && tail.size >= 2
117
+ unless valid
118
+ unknown("syslog", [ entry ])
119
+ next
120
+ end
121
+ sources << source
122
+ analyze(entry) { parts[2] }
123
+ end
124
+ record("syslog.servers", servers.uniq, entries)
125
+ record("syslog.source", sources.first) if sources.any? && sources.first && sources.uniq.one?
126
+ unknown("syslog", matching(/\Ano logging syslog\b/)) if matching(/\Ano logging syslog\b/).any?
127
+ end
128
+
129
+ # 轮询主机权限独立于出站 Trap 接收端。
130
+ def snmp
131
+ record("snmp.enabled", state(/\Asnmp-server manager\z/, /\Ano snmp-server manager\z/))
132
+ entries = matching(/\Asnmp-server host\b/)
133
+ versions, sources, communities = [], [], []
134
+ entries.each do |entry|
135
+ parts = tokens(entry)
136
+ offset = parts.index("version")
137
+ raise ArgumentError unless offset && offset > 2
138
+ networks = host_networks(parts[2...offset])
139
+ settings = parts.drop(offset + 1)
140
+ if %w[1 2c].include?(settings.first) && settings[1] == "community" && !settings[2].to_s.empty? &&
141
+ (settings.size == 3 || settings.size == 4 && %w[ro rw].include?(settings[3]))
142
+ credential("snmp-community", "snmp", secret_format(settings[2]), settings[2], entry)
143
+ versions << (settings.first == "1" ? "v1" : "v2c")
144
+ communities << { write: settings[3] == "rw", line: entry.line }
145
+ sources.concat(networks)
146
+ elsif settings == [ "3" ]
147
+ versions << "v3"
148
+ sources.concat(networks)
149
+ analyze(entry) { true }
150
+ else
151
+ raise ArgumentError
152
+ end
153
+ rescue ArgumentError, IPAddr::Error
154
+ unknown_snmp_access([ entry ], code: :unsupported_snmp_host)
155
+ end
156
+ record("snmp.versions", versions.uniq, entries)
157
+ unsupported = matching(/\A(?:snmp-server (?:user|group)\b|no snmp-server (?:host|user|group)\b)/)
158
+ unknown_snmp_access(unsupported, code: :unsupported_snmp_security) if unsupported.any? || versions.include?("v3")
159
+ @facts.snmp(communities: communities, users: [])
160
+ @facts.direct_acl(fact: "snmp.acl", sources: sources, bound: true, lines: entries.map(&:line)) if entries.any?
161
+ end
162
+
163
+ # 分别记录 TACACS 定义与管理认证引用。
164
+ def authentication
165
+ definitions = sections(/\Aaaa-server\s+/).filter_map do |section|
166
+ parts = tokens(section)
167
+ unless parts.size == 4 && parts[2..] == %w[type tacacs+]
168
+ unknown("tacacs", [ section ]) if parts.include?("tacacs+")
169
+ next
170
+ end
171
+ endpoints = matching(/\A(?:host|backup[12])\b/, section.children)
172
+ servers = endpoints.filter_map do |entry|
173
+ values = tokens(entry)
174
+ if server_address?(values[1]) && (values.size == 2 || values.size == 4 && values[2] == "vrouter")
175
+ analyze(entry) { values[1] }
176
+ else
177
+ unknown("tacacs", [ entry ])
178
+ nil
179
+ end
180
+ end
181
+ secrets = matching(/\Asecret\b/, section.children)
182
+ keys = secrets.map { |entry| stored_secret("tacacs:#{parts[1]}", "tacacs", entry) }
183
+ [ parts[1], { servers: servers.uniq, keys_present: keys.any? && keys.all?, line: section.line,
184
+ declaration: section.children.empty? } ]
185
+ end
186
+ schemes = definitions.group_by(&:first).to_h do |name, entries|
187
+ # StoneOS 会先导出空的前向声明,再导出包含内容的服务器块。
188
+ populated = entries.map(&:last).reject { |entry| entry[:declaration] }
189
+ selected = populated.last || entries.last.last
190
+ @facts.unknown("tacacs", lines: populated.map { |entry| entry[:line] }, code: :duplicate_tacacs_scheme) if populated.size > 1
191
+ if selected[:servers].empty?
192
+ @facts.unknown("tacacs.servers", lines: [ selected[:line] ], code: :tacacs_server_unresolved)
193
+ end
194
+ [ name, selected.except(:declaration) ]
195
+ end
196
+ record("tacacs.schemes", schemes)
197
+ record("tacacs.servers", schemes.values.flat_map { |scheme| scheme[:servers] }.uniq)
198
+ record("tacacs.keys_present", schemes.values.all? { |scheme| scheme[:keys_present] }) if schemes.any?
199
+ { "authentication-server" => "login", "authorization-mode" => "authorization" }.each do |command, fact|
200
+ entries = matching(/\Aadmin #{command}\b/)
201
+ next if entries.empty?
202
+ parts = tokens(entries.last)
203
+ name = parts[3] if parts.size == 4 && parts[2] == "aaa-server"
204
+ if name && schemes[name] && schemes[name][:servers].any?
205
+ record("tacacs.#{fact}", true, entries)
206
+ else
207
+ unknown("tacacs.#{fact}", entries, code: :tacacs_scheme_unresolved)
208
+ end
209
+ end
210
+ sections(/\Aadmin user\s+/).each do |section|
211
+ owner = tokens(section)[2]
212
+ passwords = matching(/\Apassword\b/, section.children)
213
+ credential(owner, "local_user", "unknown", nil, section) if passwords.empty?
214
+ passwords.each { |entry| stored_secret(owner, "local_user", entry) }
215
+ end
216
+ end
217
+
218
+ # 编码格式的存储语义未经确认时保持未知,不能猜测为明文。
219
+ def secret_format(value) = value.start_with?("$") ? "unknown" : "plain"
220
+
221
+ # 仅记录已配置秘密值的存储属性。
222
+ def stored_secret(owner, kind, entry)
223
+ parts = tokens(entry).drop(1)
224
+ valid = parts.size == 1 && !%w[cipher hash plain].include?(parts.first) || parts.size == 2 && %w[cipher hash plain].include?(parts.first)
225
+ unless valid && !parts.last.to_s.empty?
226
+ @facts.incomplete!(code: :invalid_credential_statement, lines: [ entry.line ])
227
+ credential(owner, kind, "unknown", nil, entry)
228
+ return false
229
+ end
230
+ format, secret = parts.size == 2 && %w[cipher hash plain].include?(parts.first) ? parts : [ secret_format(parts.first.to_s), parts.first ]
231
+ credential(owner, kind, format, secret, entry)
232
+ true
233
+ end
234
+
235
+ # 切分含引号的命令值,不将其作为 Ruby 代码解释。
236
+ def tokens(entry)
237
+ @tokens[entry.index] ||= Shellwords.shellsplit(entry.text)
238
+ rescue ArgumentError
239
+ @facts.incomplete!(code: :invalid_quoted_statement, lines: [ entry.line ])
240
+ cursor.mark(entry.index, state: :unsupported)
241
+ @tokens[entry.index] = []
242
+ end
243
+
244
+ # 校验可选参数,避免接受任意尾随命令片段。
245
+ def pairs(parts, flags:, keys:)
246
+ values = {}
247
+ until parts.empty?
248
+ key = parts.shift
249
+ return if values.key?(key)
250
+ if flags.include?(key)
251
+ values[key] = true
252
+ elsif keys.include?(key) && parts.any?
253
+ values[key] = parts.shift
254
+ else
255
+ return
256
+ end
257
+ end
258
+ values
259
+ end
260
+
261
+ # 将命名主机或网络对象归一化为地址区间。
262
+ def host_networks(parts)
263
+ return [ "0.0.0.0/0" ] if parts == [ "any" ]
264
+ if parts.size == 3 && parts.first == "range"
265
+ first, last = parts.drop(1).map { |value| IPAddr.new(value) }
266
+ raise ArgumentError unless first.ipv4? && last.ipv4? && first.prefix == 32 && last.prefix == 32 && first.to_i <= last.to_i
267
+ Acl.networks([ [ first.to_i, last.to_i ] ])
268
+ elsif parts.size == 1 || parts.size == 2
269
+ value = IPAddr.new(parts.join("/"))
270
+ raise ArgumentError unless value.ipv4?
271
+ [ "#{value}/#{value.prefix}" ]
272
+ else
273
+ raise ArgumentError
274
+ end
275
+ end
276
+ end
277
+ end
278
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Baseline
4
+ module Parsers
5
+ # 区分 Huawei VRP 的 AAA 嵌套、模板引用与 Comware 域语法。
6
+ class Huawei < Comware
7
+ private
8
+
9
+ # 匹配当前平台显式启用 SSH 的命令。
10
+ def ssh_enable = /\Astelnet server enable\z/
11
+ # 匹配当前平台显式禁用 SSH 的命令。
12
+ def ssh_disable = /\A(?:undo stelnet server enable|stelnet server disable)\z/
13
+ # 匹配当前平台 TACACS 配置块的根语句。
14
+ def tacacs_section = /\Ahwtacacs-server template\s+/
15
+ # 匹配认证、授权或计费服务器声明。
16
+ def tacacs_server = /\Ahwtacacs-server (?:authentication|authorization|accounting)\s+(\S+)/
17
+ # 匹配已配置 TACACS 密钥的存储格式。
18
+ def tacacs_key = /\Ahwtacacs-server (shared)-key (cipher|simple) (.+)\z/
19
+ # 要求具备当前平台 TACACS 方案使用的全部密钥角色。
20
+ def tacacs_keys_complete?(types) = types.include?("shared")
21
+ # 返回当前平台修改 SNMP 监听端口的命令模式。
22
+ def snmp_port_command = "snmp-agent udp-port"
23
+ # 从实际所属配置作用域中选取 AAA 声明。
24
+ def aaa_entries = sections(/\Aaaa\z/).flat_map(&:children)
25
+ # 解析认证域引用及其服务器组。
26
+ def authentication_domains
27
+ name = last(/\Adomain\s+(\S+)\s+admin\z/)
28
+ unless name
29
+ %w[login authorization accounting].each { |kind| unknown("tacacs.#{kind}", code: :authentication_domain_unknown) }
30
+ return []
31
+ end
32
+ aaa_entries.select { |entry| entry.text == "domain #{name}" }
33
+ end
34
+
35
+ # 解析 VRP 认证域引用,避免将 VRP 语法应用到 Comware。
36
+ def vrp_tacacs_references(kind, domains)
37
+ domains.filter_map do |domain|
38
+ template = last(/\Ahwtacacs-server\s+(\S+)\z/, domain.children)
39
+ scheme = last(/\A#{kind}-scheme\s+(\S+)\z/, domain.children)
40
+ next unless template && scheme
41
+ definition = aaa_entries.find { |entry| entry.text == "#{kind}-scheme #{scheme}" }
42
+ unless definition
43
+ unknown("tacacs.#{kind == 'authentication' ? 'login' : kind}", [ domain ], code: :tacacs_scheme_unresolved)
44
+ next
45
+ end
46
+ template if last(/\A#{kind}-mode\s+(.+)\z/, definition.children).to_s.split.first == "hwtacacs"
47
+ end
48
+ end
49
+
50
+ # 提取本地账号角色及凭据存储属性。
51
+ def accounts
52
+ matching(/\Alocal-user\s+\S+\s+password\b/, aaa_entries).each do |entry|
53
+ match = /\Alocal-user\s+(\S+)\s+password\s+(cipher|irreversible-cipher|simple)\s+(.+)\z/.match(entry.text)
54
+ if match
55
+ credential(match[1], "local_user", match[2], match[3], entry)
56
+ else
57
+ credential("local-user", "local_user", "unknown", nil, entry)
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,335 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+ require_relative "../palo_alto_document"
5
+
6
+ module Baseline
7
+ module Parsers
8
+ # 将 PAN-OS set 语法归纳为与配置块平台一致的事实。
9
+ class PaloAlto < Base
10
+ MANAGEMENT_SERVICES = %w[ssh telnet http https].freeze
11
+ RELEVANT = /\Aset (?:deviceconfig system |network (?:profiles interface-management-profile |interface )|shared (?:server-profile tacplus |authentication-profile |authentication-sequence |log-settings syslog )|mgt-config users )/
12
+
13
+ private
14
+
15
+ # 选择适合当前平台语法的文档词法解析器。
16
+ def configuration_document_class = PaloAltoDocument
17
+ # 匹配当前平台显式声明的主机名。
18
+ def hostname_pattern = /\Aset deviceconfig system hostname\s+(\S+)\z/
19
+
20
+ # 仅对管理配置子集分词一次,不处理无关的安全规则。
21
+ def access_lists
22
+ @statements = roots.filter_map do |entry|
23
+ next unless RELEVANT.match?(entry.text)
24
+ { entry: entry, tokens: Shellwords.shellsplit(entry.text).drop(1) }
25
+ rescue ArgumentError
26
+ @facts.incomplete!(code: :invalid_set_statement, lines: [ entry.line ])
27
+ nil
28
+ end
29
+ @interface_profiles = rows("network", "profiles", "interface-management-profile").group_by { |row| row[:tokens][3] }
30
+ @unresolved_profile_bindings = false
31
+ @profile_bindings = rows("network", "interface").select do |row|
32
+ index = row[:tokens].index("interface-management-profile")
33
+ next false unless index
34
+ valid = row[:tokens].size == index + 2 && !row[:tokens].last.empty?
35
+ unless valid
36
+ @unresolved_profile_bindings = true
37
+ @facts.incomplete!(code: :invalid_management_profile_binding, lines: [ row[:entry].line ])
38
+ end
39
+ valid
40
+ end
41
+ @active_profiles = @profile_bindings.map { |row| row[:tokens].last }.uniq
42
+ end
43
+
44
+ # 覆盖所有已配置的管理端点,包括数据接口。
45
+ def management
46
+ services = (MANAGEMENT_SERVICES + [ "snmp" ]).to_h { |name| [ name, service_state(name) ] }
47
+ record("ssh.enabled", services["ssh"], service_entries([ "ssh" ]))
48
+ record("services", services.slice("telnet", "http", "https"), service_entries(%w[telnet http https]))
49
+ record("snmp.enabled", services["snmp"], service_entries([ "snmp" ]))
50
+ permitted_sources("management.acl", MANAGEMENT_SERVICES)
51
+ permitted_sources("snmp.acl", [ "snmp" ])
52
+ end
53
+
54
+ # MGT 服务标志缺失时保持未知;明确启用的端点可证明服务已启用。
55
+ def service_state(name)
56
+ disabled = boolean(%w[deviceconfig system service] + [ "disable-#{name}" ])
57
+ global = disabled.nil? ? nil : !disabled
58
+ values = @active_profiles.map { |profile| profile_service(profile, name) }
59
+ return true if global == true || values.include?(true)
60
+ return false if global == false && values.all?(false)
61
+
62
+ nil
63
+ end
64
+
65
+ # 保留参与跨端点服务事实归纳的显式声明。
66
+ def service_entries(names)
67
+ names.flat_map do |name|
68
+ rows(*(%w[deviceconfig system service] + [ "disable-#{name}" ])) + @active_profiles.flat_map do |profile|
69
+ rows("network", "profiles", "interface-management-profile", profile, name)
70
+ end
71
+ end.map { |row| row[:entry] }
72
+ end
73
+
74
+ # 仅解释命名接口配置文件中显式声明的服务。
75
+ def profile_service(profile, name)
76
+ return unless @interface_profiles.key?(profile)
77
+ prefix = [ "network", "profiles", "interface-management-profile", profile, name ]
78
+ value = boolean(prefix)
79
+ return value unless value.nil?
80
+ return if rows(*prefix).any?
81
+ return false if value.nil? && @facts.complete?
82
+
83
+ nil
84
+ end
85
+
86
+ # 端点的原生允许列表适用于该端点所有已启用服务。
87
+ def permitted_sources(fact, services)
88
+ global = services.map { |name| boolean(%w[deviceconfig system service] + [ "disable-#{name}" ]) }
89
+ prefixes = global.all?(true) ? [] : [ %w[deviceconfig system permitted-ip] ]
90
+ unresolved = @unresolved_profile_bindings
91
+ @active_profiles.each do |profile|
92
+ enabled = services.map { |name| profile_service(profile, name) }
93
+ unresolved ||= enabled.include?(nil)
94
+ prefixes << [ "network", "profiles", "interface-management-profile", profile, "permitted-ip" ] if enabled.include?(true)
95
+ end
96
+ entries = []
97
+ sources = prefixes.flat_map do |prefix|
98
+ matches = rows(*prefix)
99
+ entries.concat(matches.map { |row| row[:entry] })
100
+ matches.empty? ? [ "0.0.0.0/0" ] : matches.map do |row|
101
+ tokens = row[:tokens].drop(prefix.size)
102
+ unless tokens.size == 1 || tokens.size == 3 && tokens[1] == "description"
103
+ unresolved = true
104
+ next
105
+ end
106
+ network = IPAddr.new(tokens.first)
107
+ unless network.ipv4?
108
+ unresolved = true
109
+ next
110
+ end
111
+ analyze(row[:entry]) { "#{network}/#{network.prefix}" }
112
+ rescue IPAddr::Error, ArgumentError
113
+ unresolved = true
114
+ nil
115
+ end
116
+ end.compact
117
+ bound = !sources.include?("0.0.0.0/0")
118
+ @facts.direct_acl(fact: fact, sources: unresolved ? nil : sources, bound: bound, lines: entries.map(&:line))
119
+ unknown("#{fact}.bound", code: :management_profile_unresolved) if unresolved
120
+ cursor.capture_many(entries.select { |entry| cursor.parsed?(entry.index) }, data: sources, path: [ "facts", "#{fact}.sources" ]) unless unresolved
121
+ end
122
+
123
+ # 采集明确启用或禁用的管理服务。
124
+ def services
125
+ ntp
126
+ syslog
127
+ snmp
128
+ end
129
+
130
+ # 认证要求必须覆盖全部已配置服务器,不能由任意一台满足来证明。
131
+ def ntp
132
+ entries = rows("deviceconfig", "system", "ntp-servers")
133
+ servers = entries.group_by { |row| row[:tokens][3] }.filter_map do |name, definitions|
134
+ prefix = [ "deviceconfig", "system", "ntp-servers", name ]
135
+ address = endpoint(prefix + [ "ntp-server-address" ])
136
+ unless address && %w[primary-ntp-server secondary-ntp-server].include?(name)
137
+ unknown("ntp.servers", definitions.map { |row| row[:entry] }, code: :ntp_server_unresolved)
138
+ unknown("ntp.authentication", definitions.map { |row| row[:entry] }, code: :ntp_server_unresolved)
139
+ next
140
+ end
141
+ { address: address, authenticated: ntp_authentication(prefix) }
142
+ end
143
+ record("ntp.servers", servers.map { |server| server[:address] }.uniq, entries.map { |row| row[:entry] })
144
+ if servers.any? && servers.none? { |server| server[:authenticated].nil? }
145
+ record("ntp.authentication", servers.all? { |server| server[:authenticated] })
146
+ end
147
+ record("ntp.source", scalar(%w[deviceconfig system route service ntp source interface]))
148
+ end
149
+
150
+ # 仅完整的认证声明才能证明所有服务器均满足认证要求。
151
+ def ntp_authentication(prefix)
152
+ entries = rows(*(prefix + [ "authentication-type" ]))
153
+ options = entries.map { |row| row[:tokens].drop(prefix.size + 1) }.uniq
154
+ return if options.empty?
155
+ return false if options == [ [ "none" ] ]
156
+ return true if options == [ [ "autokey" ] ]
157
+ key = options.find { |parts| parts.take(2) == %w[symmetric-key algorithm] }
158
+ id = options.find { |parts| parts.take(2) == %w[symmetric-key key-id] }
159
+ if options.size == 2 && key && id && key.size == 5 && %w[md5 sha1 sha256 sha512].include?(key[2]) &&
160
+ key[3] == "authentication-key" && !key[4].empty? && id.size == 3 && /\A\d+\z/.match?(id[2]) && id[2].to_i.between?(1, 65_534)
161
+ return true
162
+ end
163
+ unknown("ntp.authentication", entries.map { |row| row[:entry] }, code: :unsupported_ntp_authentication)
164
+ nil
165
+ end
166
+
167
+ # 配置的采集端地址与运行时日志转发成功是两类事实。
168
+ def syslog
169
+ entries = rows("shared", "log-settings", "syslog").select { |row| row[:tokens][4] == "server" }
170
+ servers = entries.group_by { |row| [ row[:tokens][3], row[:tokens][5] ] }.values.filter_map do |definitions|
171
+ address = definitions.reverse.find { |row| row[:tokens][6] == "server" }
172
+ value = address && endpoint(address[:tokens].take(7))
173
+ unknown("syslog.servers", definitions.map { |row| row[:entry] }, code: :syslog_server_unresolved) unless value
174
+ value
175
+ end
176
+ record("syslog.servers", servers.uniq, entries.map { |row| row[:entry] })
177
+ record("syslog.source", scalar(%w[deviceconfig system route service syslog source interface]))
178
+ end
179
+
180
+ # 轮询用户、团体字与出站 SNMP Trap 配置相互独立。
181
+ def snmp
182
+ prefix = %w[deviceconfig system snmp-setting access-setting version]
183
+ versions = rows(*prefix).map { |row| row[:tokens][prefix.size] }.compact.uniq
184
+ record("snmp.versions", versions)
185
+ communities = rows(*(prefix + %w[v2c snmp-community-string])).filter_map do |row|
186
+ unless row[:tokens].size == prefix.size + 3 && !row[:tokens].last.empty?
187
+ @facts.incomplete!(code: :invalid_set_statement, lines: [ row[:entry].line ])
188
+ next
189
+ end
190
+ credential("snmp-community", "snmp", secret_format(row[:tokens].last), row[:tokens].last, row[:entry])
191
+ { write: false, line: row[:entry].line }
192
+ end
193
+ users_prefix = prefix + %w[v3 users]
194
+ users = rows(*users_prefix).group_by { |row| row[:tokens][users_prefix.size] }.filter_map do |name, entries|
195
+ next unless name
196
+ user = { name: name, auth: false, priv: false, write: false, line: entries.first[:entry].line }
197
+ { "authpwd" => :auth, "privpwd" => :priv }.each do |field, key|
198
+ row = entries.reverse.find { |entry| entry[:tokens][users_prefix.size + 1] == field }
199
+ next unless row
200
+ unless row[:tokens].size == users_prefix.size + 3 && !row[:tokens].last.empty?
201
+ @facts.incomplete!(code: :invalid_set_statement, lines: [ row[:entry].line ])
202
+ next
203
+ end
204
+ user[key] = !row[:tokens].last.empty?
205
+ credential("snmp:#{name}", "snmp_user", secret_format(row[:tokens].last), row[:tokens].last, row[:entry])
206
+ end
207
+ user
208
+ end
209
+ @facts.snmp(communities: communities, users: users)
210
+ if (versions - %w[v2c v3]).any? || versions.size > 1
211
+ unknown_snmp_access(rows(*prefix).map { |row| row[:entry] }, code: :unsupported_snmp_version)
212
+ end
213
+ end
214
+
215
+ # 将选中的登录配置或有序认证序列解析为 TACACS 服务器。
216
+ def authentication
217
+ server_rows = rows("shared", "server-profile", "tacplus")
218
+ schemes = server_rows.group_by { |row| row[:tokens][3] }.to_h do |name, entries|
219
+ servers = entries.select { |row| row[:tokens][4] == "server" }.group_by { |row| row[:tokens][5] }
220
+ addresses = servers.values.filter_map do |server|
221
+ address = server.reverse.find { |row| row[:tokens][6] == "address" }
222
+ value = address && endpoint(address[:tokens].take(7))
223
+ unknown("tacacs.servers", server.map { |row| row[:entry] }, code: :tacacs_server_unresolved) unless value
224
+ value
225
+ end
226
+ unknown("tacacs.servers", entries.map { |row| row[:entry] }, code: :tacacs_server_unresolved) if servers.empty?
227
+ keys = servers.values.map do |server|
228
+ secret = server.reverse.find { |row| row[:tokens][6] == "secret" }
229
+ value = secret && scalar(secret[:tokens].take(7))
230
+ credential("tacacs:#{name}", "tacacs", secret_format(value), value, secret[:entry]) if value
231
+ !value.to_s.empty?
232
+ end
233
+ [ name, { servers: addresses, keys_present: keys.any? && keys.all?, line: entries.first[:entry].line } ]
234
+ end
235
+ record("tacacs.schemes", schemes)
236
+ record("tacacs.servers", schemes.values.flat_map { |scheme| scheme[:servers] }.uniq)
237
+ record("tacacs.keys_present", schemes.values.all? { |scheme| scheme[:keys_present] }) if schemes.any?
238
+ selected = scalar(%w[deviceconfig system authentication-profile])
239
+ login = selected ? tacacs_profile(selected, schemes) : false
240
+ login.nil? ? unknown("tacacs.login", code: :tacacs_scheme_unresolved) : record("tacacs.login", login)
241
+ # PAN-OS 角色映射与命令计费不能套用路由器式 AAA 开关语义。
242
+ %w[authorization accounting].each { |kind| unknown("tacacs.#{kind}", code: :platform_semantics_unresolved) }
243
+ accounts
244
+ end
245
+
246
+ # 认证序列具有顺序;后备 TACACS 不能视为首选 TACACS 登录。
247
+ def tacacs_profile(name, schemes)
248
+ prefix = [ "shared", "authentication-profile", name, "method" ]
249
+ methods = rows(*prefix)
250
+ if methods.empty?
251
+ sequence = rows("shared", "authentication-sequence", name, "authentication-profiles").last
252
+ return unless sequence
253
+ names = sequence[:tokens].drop(4).reject { |token| %w[[ ]].include?(token) }
254
+ return if names.empty? || names.first == name
255
+ return tacacs_profile_definition(names.first, schemes)
256
+ end
257
+ tacacs_profile_definition(name, schemes)
258
+ end
259
+
260
+ # 选中的 TACACS 配置必须至少解析出一台具有地址的服务器。
261
+ def tacacs_profile_definition(name, schemes)
262
+ prefix = [ "shared", "authentication-profile", name, "method" ]
263
+ methods = rows(*prefix)
264
+ return if methods.empty?
265
+ kinds = methods.map { |row| row[:tokens][4] }.uniq
266
+ return unless kinds.one?
267
+ return false if %w[none local-database radius ldap kerberos saml-idp cloud].include?(kinds.first)
268
+ return unless kinds.first == "tacplus"
269
+ target = scalar(prefix + %w[tacplus server-profile])
270
+ return unless target && schemes[target] && schemes[target][:servers].any?
271
+
272
+ true
273
+ end
274
+
275
+ # 仅存储凭据元数据,导出的加密值不进入报告。
276
+ def accounts
277
+ rows("mgt-config", "users").group_by { |row| row[:tokens][2] }.each do |name, entries|
278
+ row = entries.reverse.find { |entry| entry[:tokens][3] == "phash" }
279
+ unless row
280
+ external_profile = scalar([ "mgt-config", "users", name, "authentication-profile" ])
281
+ credential(name, "local_user", "unknown", nil, entries.first[:entry]) unless external_profile && !external_profile.empty?
282
+ next
283
+ end
284
+ secret = scalar(row[:tokens].take(4))
285
+ format = if /\A\$(?:1|5|6)\$[^$]+\$[^$]+\z/.match?(secret.to_s)
286
+ secret.start_with?("$1$") ? "5" : "hash"
287
+ else
288
+ "unknown"
289
+ end
290
+ credential(name, "local_user", format, secret, row[:entry])
291
+ end
292
+ end
293
+
294
+ # 识别 PAN-OS 带标记的加密 set 值;其余情况保留明文风险。
295
+ def secret_format(value) = value.start_with?("-AQ", "-AR") ? "cipher" : "plain"
296
+
297
+ # 按词元前缀选择归一化后的 PAN-OS set 命令行。
298
+ def rows(*prefix)
299
+ @statements.select { |row| row[:tokens].take(prefix.size) == prefix }
300
+ end
301
+
302
+ # 地址仅接受 IP 或 DNS 名称,不接受不完整转义或任意文本。
303
+ def endpoint(prefix)
304
+ value = scalar(prefix)
305
+ return if value.nil?
306
+ return value if server_address?(value)
307
+
308
+ @facts.incomplete!(code: :invalid_server_address, lines: rows(*prefix).map { |row| row[:entry].line })
309
+ nil
310
+ end
311
+
312
+ # 显式标志格式错误时,不能用平台默认值替代。
313
+ def boolean(prefix)
314
+ value = scalar(prefix)
315
+ return if value.nil?
316
+ return value == "yes" if %w[yes no].include?(value)
317
+
318
+ @facts.incomplete!(code: :invalid_set_statement, lines: rows(*prefix).map { |row| row[:entry].line })
319
+ nil
320
+ end
321
+
322
+ # 严格校验词元数量,避免尾随内容使错误配置被误判为已知事实。
323
+ def scalar(prefix)
324
+ row = rows(*prefix).last
325
+ return unless row
326
+ unless row[:tokens].size == prefix.size + 1
327
+ @facts.incomplete!(code: :invalid_set_statement, lines: [ row[:entry].line ])
328
+ return
329
+ end
330
+
331
+ analyze(row[:entry]) { row[:tokens].last }
332
+ end
333
+ end
334
+ end
335
+ end