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,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
|