baseline-rb 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +20 -0
- data/README-zh.md +15 -1
- data/README.md +15 -1
- data/docs/architecture-zh.md +8 -8
- data/lib/baseline/acl.rb +44 -29
- data/lib/baseline/audit.rb +13 -4
- data/lib/baseline/{parsers/cisco.rb → cisco/parser.rb} +63 -61
- data/lib/baseline/{parsers/cisco_ios.rb → cisco_ios/parser.rb} +4 -2
- data/lib/baseline/{parsers/cisco_nxos.rb → cisco_nxos/parser.rb} +4 -2
- data/lib/baseline/{parsers/comware.rb → comware/parser.rb} +45 -43
- data/lib/baseline/configuration.rb +13 -6
- data/lib/baseline/credential.rb +3 -1
- data/lib/baseline/evaluator.rb +9 -5
- data/lib/baseline/facts.rb +18 -12
- data/lib/baseline/{parsers/h3c.rb → h3c/parser.rb} +4 -2
- data/lib/baseline/hillstone/document.rb +27 -0
- data/lib/baseline/{parsers/hillstone.rb → hillstone/parser.rb} +30 -28
- data/lib/baseline/{parsers/huawei.rb → huawei/parser.rb} +12 -10
- data/lib/baseline/palo_alto/document.rb +17 -0
- data/lib/baseline/{parsers/palo_alto.rb → palo_alto/parser.rb} +12 -10
- data/lib/baseline/parser.rb +260 -0
- data/lib/baseline/policy.rb +4 -2
- data/lib/baseline/report.rb +8 -3
- data/lib/baseline/rule.rb +13 -9
- data/lib/baseline/version.rb +1 -1
- data/lib/baseline.rb +0 -30
- data/policy.yml +1 -1
- metadata +16 -16
- data/lib/baseline/hillstone_document.rb +0 -25
- data/lib/baseline/palo_alto_document.rb +0 -15
- data/lib/baseline/parsers/base.rb +0 -256
|
@@ -1,30 +1,32 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../parser"
|
|
4
|
+
|
|
3
5
|
require "shellwords"
|
|
4
|
-
require_relative "
|
|
6
|
+
require_relative "document"
|
|
5
7
|
|
|
6
8
|
module Baseline
|
|
7
|
-
module
|
|
9
|
+
module Hillstone
|
|
8
10
|
# StoneOS 管理端点与原生主机允许列表不同于路由器 VTY。
|
|
9
|
-
class
|
|
11
|
+
class Parser < Baseline::Parser
|
|
10
12
|
MANAGEMENT_SERVICES = %w[ssh telnet http https ftp snmp].freeze
|
|
11
13
|
HOST_PROTOCOLS = %w[ssh telnet http https netconf any].freeze
|
|
12
14
|
|
|
13
15
|
private
|
|
14
16
|
|
|
15
17
|
# 选择适合当前平台语法的文档词法解析器。
|
|
16
|
-
def
|
|
18
|
+
def document_class = Document
|
|
17
19
|
|
|
18
|
-
#
|
|
19
|
-
def
|
|
20
|
+
# 初始化当前配置的词元缓存,供各领域解析阶段共享。
|
|
21
|
+
def prepare_configuration
|
|
20
22
|
@tokens = {}
|
|
21
23
|
end
|
|
22
24
|
|
|
23
25
|
# 已配置主机 ACL 本身不能证明管理监听服务已启用。
|
|
24
26
|
def management
|
|
25
|
-
interfaces =
|
|
27
|
+
interfaces = matching_blocks(/\Ainterface\s+/)
|
|
26
28
|
states = interfaces.map do |section|
|
|
27
|
-
|
|
29
|
+
matching_blocks(/\A(?:no )?manage\s+/, section.children).each_with_object({}) do |entry, values|
|
|
28
30
|
parts = tokens(entry)
|
|
29
31
|
negative = parts.first == "no"
|
|
30
32
|
parts = parts.drop(1) if negative
|
|
@@ -48,7 +50,7 @@ module Baseline
|
|
|
48
50
|
|
|
49
51
|
# 信任主机可使用 CIDR、掩码、区间或 any;跨协议按保守并集处理。
|
|
50
52
|
def management_sources
|
|
51
|
-
entries =
|
|
53
|
+
entries = matching_blocks(/\A(?:no )?admin host\b/)
|
|
52
54
|
sources = entries.flat_map do |entry|
|
|
53
55
|
parts = tokens(entry)
|
|
54
56
|
raise ArgumentError unless parts.take(2) == %w[admin host] && HOST_PROTOCOLS.include?(parts.last)
|
|
@@ -76,7 +78,7 @@ module Baseline
|
|
|
76
78
|
|
|
77
79
|
# 提取时间同步服务器及相关认证配置。
|
|
78
80
|
def ntp
|
|
79
|
-
entries =
|
|
81
|
+
entries = matching_blocks(/\Antp server\b/)
|
|
80
82
|
sources = []
|
|
81
83
|
servers = entries.filter_map do |entry|
|
|
82
84
|
parts = tokens(entry)
|
|
@@ -93,14 +95,14 @@ module Baseline
|
|
|
93
95
|
analyze(entry) { parts[2] }
|
|
94
96
|
end
|
|
95
97
|
record("ntp.servers", servers.uniq, entries)
|
|
96
|
-
record("ntp.authentication",
|
|
98
|
+
record("ntp.authentication", configured_state(/\Antp authentication\z/, /\Ano ntp authentication\z/))
|
|
97
99
|
record("ntp.source", sources.first) if sources.any? && sources.first && sources.uniq.one?
|
|
98
|
-
unknown("ntp",
|
|
100
|
+
unknown("ntp", matching_blocks(/\Ano ntp server\b/)) if matching_blocks(/\Ano ntp server\b/).any?
|
|
99
101
|
end
|
|
100
102
|
|
|
101
103
|
# 同一目标可供多个日志类别使用,统计时只计一次。
|
|
102
104
|
def syslog
|
|
103
|
-
entries =
|
|
105
|
+
entries = matching_blocks(/\Alogging syslog\b/)
|
|
104
106
|
sources = []
|
|
105
107
|
servers = entries.filter_map do |entry|
|
|
106
108
|
parts = tokens(entry)
|
|
@@ -123,13 +125,13 @@ module Baseline
|
|
|
123
125
|
end
|
|
124
126
|
record("syslog.servers", servers.uniq, entries)
|
|
125
127
|
record("syslog.source", sources.first) if sources.any? && sources.first && sources.uniq.one?
|
|
126
|
-
unknown("syslog",
|
|
128
|
+
unknown("syslog", matching_blocks(/\Ano logging syslog\b/)) if matching_blocks(/\Ano logging syslog\b/).any?
|
|
127
129
|
end
|
|
128
130
|
|
|
129
131
|
# 轮询主机权限独立于出站 Trap 接收端。
|
|
130
132
|
def snmp
|
|
131
|
-
record("snmp.enabled",
|
|
132
|
-
entries =
|
|
133
|
+
record("snmp.enabled", configured_state(/\Asnmp-server manager\z/, /\Ano snmp-server manager\z/))
|
|
134
|
+
entries = matching_blocks(/\Asnmp-server host\b/)
|
|
133
135
|
versions, sources, communities = [], [], []
|
|
134
136
|
entries.each do |entry|
|
|
135
137
|
parts = tokens(entry)
|
|
@@ -139,7 +141,7 @@ module Baseline
|
|
|
139
141
|
settings = parts.drop(offset + 1)
|
|
140
142
|
if %w[1 2c].include?(settings.first) && settings[1] == "community" && !settings[2].to_s.empty? &&
|
|
141
143
|
(settings.size == 3 || settings.size == 4 && %w[ro rw].include?(settings[3]))
|
|
142
|
-
|
|
144
|
+
record_credential("snmp-community", "snmp", secret_format(settings[2]), settings[2], entry)
|
|
143
145
|
versions << (settings.first == "1" ? "v1" : "v2c")
|
|
144
146
|
communities << { write: settings[3] == "rw", line: entry.line }
|
|
145
147
|
sources.concat(networks)
|
|
@@ -154,7 +156,7 @@ module Baseline
|
|
|
154
156
|
unknown_snmp_access([ entry ], code: :unsupported_snmp_host)
|
|
155
157
|
end
|
|
156
158
|
record("snmp.versions", versions.uniq, entries)
|
|
157
|
-
unsupported =
|
|
159
|
+
unsupported = matching_blocks(/\A(?:snmp-server (?:user|group)\b|no snmp-server (?:host|user|group)\b)/)
|
|
158
160
|
unknown_snmp_access(unsupported, code: :unsupported_snmp_security) if unsupported.any? || versions.include?("v3")
|
|
159
161
|
@facts.snmp(communities: communities, users: [])
|
|
160
162
|
@facts.direct_acl(fact: "snmp.acl", sources: sources, bound: true, lines: entries.map(&:line)) if entries.any?
|
|
@@ -162,13 +164,13 @@ module Baseline
|
|
|
162
164
|
|
|
163
165
|
# 分别记录 TACACS 定义与管理认证引用。
|
|
164
166
|
def authentication
|
|
165
|
-
definitions =
|
|
167
|
+
definitions = matching_blocks(/\Aaaa-server\s+/).filter_map do |section|
|
|
166
168
|
parts = tokens(section)
|
|
167
169
|
unless parts.size == 4 && parts[2..] == %w[type tacacs+]
|
|
168
170
|
unknown("tacacs", [ section ]) if parts.include?("tacacs+")
|
|
169
171
|
next
|
|
170
172
|
end
|
|
171
|
-
endpoints =
|
|
173
|
+
endpoints = matching_blocks(/\A(?:host|backup[12])\b/, section.children)
|
|
172
174
|
servers = endpoints.filter_map do |entry|
|
|
173
175
|
values = tokens(entry)
|
|
174
176
|
if server_address?(values[1]) && (values.size == 2 || values.size == 4 && values[2] == "vrouter")
|
|
@@ -178,7 +180,7 @@ module Baseline
|
|
|
178
180
|
nil
|
|
179
181
|
end
|
|
180
182
|
end
|
|
181
|
-
secrets =
|
|
183
|
+
secrets = matching_blocks(/\Asecret\b/, section.children)
|
|
182
184
|
keys = secrets.map { |entry| stored_secret("tacacs:#{parts[1]}", "tacacs", entry) }
|
|
183
185
|
[ parts[1], { servers: servers.uniq, keys_present: keys.any? && keys.all?, line: section.line,
|
|
184
186
|
declaration: section.children.empty? } ]
|
|
@@ -197,7 +199,7 @@ module Baseline
|
|
|
197
199
|
record("tacacs.servers", schemes.values.flat_map { |scheme| scheme[:servers] }.uniq)
|
|
198
200
|
record("tacacs.keys_present", schemes.values.all? { |scheme| scheme[:keys_present] }) if schemes.any?
|
|
199
201
|
{ "authentication-server" => "login", "authorization-mode" => "authorization" }.each do |command, fact|
|
|
200
|
-
entries =
|
|
202
|
+
entries = matching_blocks(/\Aadmin #{command}\b/)
|
|
201
203
|
next if entries.empty?
|
|
202
204
|
parts = tokens(entries.last)
|
|
203
205
|
name = parts[3] if parts.size == 4 && parts[2] == "aaa-server"
|
|
@@ -207,10 +209,10 @@ module Baseline
|
|
|
207
209
|
unknown("tacacs.#{fact}", entries, code: :tacacs_scheme_unresolved)
|
|
208
210
|
end
|
|
209
211
|
end
|
|
210
|
-
|
|
212
|
+
matching_blocks(/\Aadmin user\s+/).each do |section|
|
|
211
213
|
owner = tokens(section)[2]
|
|
212
|
-
passwords =
|
|
213
|
-
|
|
214
|
+
passwords = matching_blocks(/\Apassword\b/, section.children)
|
|
215
|
+
record_credential(owner, "local_user", "unknown", nil, section) if passwords.empty?
|
|
214
216
|
passwords.each { |entry| stored_secret(owner, "local_user", entry) }
|
|
215
217
|
end
|
|
216
218
|
end
|
|
@@ -224,11 +226,11 @@ module Baseline
|
|
|
224
226
|
valid = parts.size == 1 && !%w[cipher hash plain].include?(parts.first) || parts.size == 2 && %w[cipher hash plain].include?(parts.first)
|
|
225
227
|
unless valid && !parts.last.to_s.empty?
|
|
226
228
|
@facts.incomplete!(code: :invalid_credential_statement, lines: [ entry.line ])
|
|
227
|
-
|
|
229
|
+
record_credential(owner, kind, "unknown", nil, entry)
|
|
228
230
|
return false
|
|
229
231
|
end
|
|
230
232
|
format, secret = parts.size == 2 && %w[cipher hash plain].include?(parts.first) ? parts : [ secret_format(parts.first.to_s), parts.first ]
|
|
231
|
-
|
|
233
|
+
record_credential(owner, kind, format, secret, entry)
|
|
232
234
|
true
|
|
233
235
|
end
|
|
234
236
|
|
|
@@ -264,7 +266,7 @@ module Baseline
|
|
|
264
266
|
if parts.size == 3 && parts.first == "range"
|
|
265
267
|
first, last = parts.drop(1).map { |value| IPAddr.new(value) }
|
|
266
268
|
raise ArgumentError unless first.ipv4? && last.ipv4? && first.prefix == 32 && last.prefix == 32 && first.to_i <= last.to_i
|
|
267
|
-
Acl.
|
|
269
|
+
Acl.network_prefixes([ [ first.to_i, last.to_i ] ])
|
|
268
270
|
elsif parts.size == 1 || parts.size == 2
|
|
269
271
|
value = IPAddr.new(parts.join("/"))
|
|
270
272
|
raise ArgumentError unless value.ipv4?
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../comware/parser"
|
|
4
|
+
|
|
3
5
|
module Baseline
|
|
4
|
-
module
|
|
6
|
+
module Huawei
|
|
5
7
|
# 区分 Huawei VRP 的 AAA 嵌套、模板引用与 Comware 域语法。
|
|
6
|
-
class
|
|
8
|
+
class Parser < Comware::Parser
|
|
7
9
|
private
|
|
8
10
|
|
|
9
11
|
# 匹配当前平台显式启用 SSH 的命令。
|
|
@@ -21,10 +23,10 @@ module Baseline
|
|
|
21
23
|
# 返回当前平台修改 SNMP 监听端口的命令模式。
|
|
22
24
|
def snmp_port_command = "snmp-agent udp-port"
|
|
23
25
|
# 从实际所属配置作用域中选取 AAA 声明。
|
|
24
|
-
def aaa_entries =
|
|
26
|
+
def aaa_entries = matching_blocks(/\Aaaa\z/).flat_map(&:children)
|
|
25
27
|
# 解析认证域引用及其服务器组。
|
|
26
28
|
def authentication_domains
|
|
27
|
-
name =
|
|
29
|
+
name = last_configured_value(/\Adomain\s+(\S+)\s+admin\z/)
|
|
28
30
|
unless name
|
|
29
31
|
%w[login authorization accounting].each { |kind| unknown("tacacs.#{kind}", code: :authentication_domain_unknown) }
|
|
30
32
|
return []
|
|
@@ -35,26 +37,26 @@ module Baseline
|
|
|
35
37
|
# 解析 VRP 认证域引用,避免将 VRP 语法应用到 Comware。
|
|
36
38
|
def vrp_tacacs_references(kind, domains)
|
|
37
39
|
domains.filter_map do |domain|
|
|
38
|
-
template =
|
|
39
|
-
scheme =
|
|
40
|
+
template = last_configured_value(/\Ahwtacacs-server\s+(\S+)\z/, domain.children)
|
|
41
|
+
scheme = last_configured_value(/\A#{kind}-scheme\s+(\S+)\z/, domain.children)
|
|
40
42
|
next unless template && scheme
|
|
41
43
|
definition = aaa_entries.find { |entry| entry.text == "#{kind}-scheme #{scheme}" }
|
|
42
44
|
unless definition
|
|
43
45
|
unknown("tacacs.#{kind == 'authentication' ? 'login' : kind}", [ domain ], code: :tacacs_scheme_unresolved)
|
|
44
46
|
next
|
|
45
47
|
end
|
|
46
|
-
template if
|
|
48
|
+
template if last_configured_value(/\A#{kind}-mode\s+(.+)\z/, definition.children).to_s.split.first == "hwtacacs"
|
|
47
49
|
end
|
|
48
50
|
end
|
|
49
51
|
|
|
50
52
|
# 提取本地账号角色及凭据存储属性。
|
|
51
53
|
def accounts
|
|
52
|
-
|
|
54
|
+
matching_blocks(/\Alocal-user\s+\S+\s+password\b/, aaa_entries).each do |entry|
|
|
53
55
|
match = /\Alocal-user\s+(\S+)\s+password\s+(cipher|irreversible-cipher|simple)\s+(.+)\z/.match(entry.text)
|
|
54
56
|
if match
|
|
55
|
-
|
|
57
|
+
record_credential(match[1], "local_user", match[2], match[3], entry)
|
|
56
58
|
else
|
|
57
|
-
|
|
59
|
+
record_credential("local-user", "local_user", "unknown", nil, entry)
|
|
58
60
|
end
|
|
59
61
|
end
|
|
60
62
|
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../quoted_document"
|
|
4
|
+
|
|
5
|
+
module Baseline
|
|
6
|
+
module PaloAlto
|
|
7
|
+
# PAN-OS 的 set 导出可能包含引号包裹的多行证书与描述。
|
|
8
|
+
class Document < QuotedDocument
|
|
9
|
+
protected
|
|
10
|
+
|
|
11
|
+
# 已建模的多行值不能被误判为配置缺失;证书正文不参与语义分析。
|
|
12
|
+
def multiline_fact?(text)
|
|
13
|
+
/\Aset (?:deviceconfig system (?:hostname|service|permitted-ip|ntp-servers|snmp-setting|authentication-profile|route service)\b|shared (?:server-profile tacplus|authentication-profile|authentication-sequence|log-settings syslog)\b|mgt-config users\b|network profiles interface-management-profile\b)/.match?(text)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -1,24 +1,26 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../parser"
|
|
4
|
+
|
|
3
5
|
require "shellwords"
|
|
4
|
-
require_relative "
|
|
6
|
+
require_relative "document"
|
|
5
7
|
|
|
6
8
|
module Baseline
|
|
7
|
-
module
|
|
9
|
+
module PaloAlto
|
|
8
10
|
# 将 PAN-OS set 语法归纳为与配置块平台一致的事实。
|
|
9
|
-
class
|
|
11
|
+
class Parser < Baseline::Parser
|
|
10
12
|
MANAGEMENT_SERVICES = %w[ssh telnet http https].freeze
|
|
11
13
|
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
14
|
|
|
13
15
|
private
|
|
14
16
|
|
|
15
17
|
# 选择适合当前平台语法的文档词法解析器。
|
|
16
|
-
def
|
|
18
|
+
def document_class = Document
|
|
17
19
|
# 匹配当前平台显式声明的主机名。
|
|
18
20
|
def hostname_pattern = /\Aset deviceconfig system hostname\s+(\S+)\z/
|
|
19
21
|
|
|
20
22
|
# 仅对管理配置子集分词一次,不处理无关的安全规则。
|
|
21
|
-
def
|
|
23
|
+
def prepare_configuration
|
|
22
24
|
@statements = roots.filter_map do |entry|
|
|
23
25
|
next unless RELEVANT.match?(entry.text)
|
|
24
26
|
{ entry: entry, tokens: Shellwords.shellsplit(entry.text).drop(1) }
|
|
@@ -187,7 +189,7 @@ module Baseline
|
|
|
187
189
|
@facts.incomplete!(code: :invalid_set_statement, lines: [ row[:entry].line ])
|
|
188
190
|
next
|
|
189
191
|
end
|
|
190
|
-
|
|
192
|
+
record_credential("snmp-community", "snmp", secret_format(row[:tokens].last), row[:tokens].last, row[:entry])
|
|
191
193
|
{ write: false, line: row[:entry].line }
|
|
192
194
|
end
|
|
193
195
|
users_prefix = prefix + %w[v3 users]
|
|
@@ -202,7 +204,7 @@ module Baseline
|
|
|
202
204
|
next
|
|
203
205
|
end
|
|
204
206
|
user[key] = !row[:tokens].last.empty?
|
|
205
|
-
|
|
207
|
+
record_credential("snmp:#{name}", "snmp_user", secret_format(row[:tokens].last), row[:tokens].last, row[:entry])
|
|
206
208
|
end
|
|
207
209
|
user
|
|
208
210
|
end
|
|
@@ -227,7 +229,7 @@ module Baseline
|
|
|
227
229
|
keys = servers.values.map do |server|
|
|
228
230
|
secret = server.reverse.find { |row| row[:tokens][6] == "secret" }
|
|
229
231
|
value = secret && scalar(secret[:tokens].take(7))
|
|
230
|
-
|
|
232
|
+
record_credential("tacacs:#{name}", "tacacs", secret_format(value), value, secret[:entry]) if value
|
|
231
233
|
!value.to_s.empty?
|
|
232
234
|
end
|
|
233
235
|
[ name, { servers: addresses, keys_present: keys.any? && keys.all?, line: entries.first[:entry].line } ]
|
|
@@ -278,7 +280,7 @@ module Baseline
|
|
|
278
280
|
row = entries.reverse.find { |entry| entry[:tokens][3] == "phash" }
|
|
279
281
|
unless row
|
|
280
282
|
external_profile = scalar([ "mgt-config", "users", name, "authentication-profile" ])
|
|
281
|
-
|
|
283
|
+
record_credential(name, "local_user", "unknown", nil, entries.first[:entry]) unless external_profile && !external_profile.empty?
|
|
282
284
|
next
|
|
283
285
|
end
|
|
284
286
|
secret = scalar(row[:tokens].take(4))
|
|
@@ -287,7 +289,7 @@ module Baseline
|
|
|
287
289
|
else
|
|
288
290
|
"unknown"
|
|
289
291
|
end
|
|
290
|
-
|
|
292
|
+
record_credential(name, "local_user", format, secret, row[:entry])
|
|
291
293
|
end
|
|
292
294
|
end
|
|
293
295
|
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ctx_parser/configuration"
|
|
4
|
+
require "ctx_parser/input"
|
|
5
|
+
require_relative "document"
|
|
6
|
+
require_relative "facts"
|
|
7
|
+
require_relative "credential"
|
|
8
|
+
|
|
9
|
+
module Baseline
|
|
10
|
+
# 共享输入与提取边界;CtxParser 跟踪来源,Facts 归纳领域证据。
|
|
11
|
+
class Parser < CtxParser::Configuration
|
|
12
|
+
SERVICE_PORTS = { "ssh" => 22, "telnet" => 23, "snmp" => 161, "snmptrap" => 162 }.freeze
|
|
13
|
+
SNMP_OPTIONS = %w[auth priv authentication-mode privacy-mode acl access use-ipv4acl group local engineid engineID
|
|
14
|
+
encrypted localizedkey localizedV2key remote udp-port].freeze
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
# 解释当前命令回显,保留不支持或格式错误的证据。
|
|
19
|
+
def parse_output(text, complete:)
|
|
20
|
+
begin
|
|
21
|
+
text = CtxParser::Input.text(text, encoding: :utf8, max_bytes: 16 * 1024 * 1024)
|
|
22
|
+
rescue ArgumentError, EncodingError
|
|
23
|
+
return CtxParser::Result.new(status: :failed, issues: [ { code: :invalid_configuration } ])
|
|
24
|
+
end
|
|
25
|
+
read_configuration(text, document_class: document_class)
|
|
26
|
+
issues = document.issues.dup
|
|
27
|
+
if (!complete || issues.any?) && exclude_incomplete_final_line
|
|
28
|
+
issues << { code: :incomplete_final_line, line: cursor.lines.last.number }
|
|
29
|
+
end
|
|
30
|
+
@source_issues = issues
|
|
31
|
+
@facts = Facts.new(complete: complete, issues: issues)
|
|
32
|
+
if roots.empty?
|
|
33
|
+
return CtxParser::Result.new(status: :failed, issues: issues + [ { code: :empty_configuration } ])
|
|
34
|
+
end
|
|
35
|
+
prepare_configuration
|
|
36
|
+
if (hostname = matching_blocks(hostname_pattern).last)
|
|
37
|
+
name = analyze(hostname) { hostname.text[hostname_pattern, 1] }
|
|
38
|
+
record("hostname", name)
|
|
39
|
+
capture(hostname, name, [ "facts", "hostname" ])
|
|
40
|
+
end
|
|
41
|
+
access_lists
|
|
42
|
+
management
|
|
43
|
+
services
|
|
44
|
+
authentication
|
|
45
|
+
@facts.result
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# 在领域解析前准备当前配置的词元和引用索引。
|
|
49
|
+
def prepare_configuration; end
|
|
50
|
+
|
|
51
|
+
# 不使用命名 ACL 的平台由原生管理来源声明提供事实。
|
|
52
|
+
def access_lists; end
|
|
53
|
+
|
|
54
|
+
# 返回本次解析的根配置块。
|
|
55
|
+
def roots = @blocks
|
|
56
|
+
# 选择适合当前平台语法的文档词法解析器。
|
|
57
|
+
def document_class = Document
|
|
58
|
+
# 匹配当前平台显式声明的主机名。
|
|
59
|
+
def hostname_pattern = /\A(?:hostname|sysname)\s+(\S+)\z/
|
|
60
|
+
# 保留原始文本,但不将可能截断的词元纳入事实构建树。
|
|
61
|
+
def exclude_incomplete_final_line
|
|
62
|
+
line = cursor.lines.last
|
|
63
|
+
return false unless line && !line.raw.end_with?("\n", "\r") && cursor.state(line.index) != :ignored
|
|
64
|
+
|
|
65
|
+
entries, ancestors = roots, []
|
|
66
|
+
while (entry = entries.last)
|
|
67
|
+
if entry.index == line.index
|
|
68
|
+
retained = entries[0...-1].freeze
|
|
69
|
+
ancestors.reverse_each do |siblings, parent|
|
|
70
|
+
block = CtxParser::Block.new(cursor: cursor, header: parent.header, children: retained, last_index: parent.last_index)
|
|
71
|
+
retained = (siblings[0...-1] + [ block ]).freeze
|
|
72
|
+
end
|
|
73
|
+
@blocks = retained
|
|
74
|
+
cursor.mark(line.index, state: :unsupported)
|
|
75
|
+
return true
|
|
76
|
+
end
|
|
77
|
+
ancestors << [ entries, entry ]
|
|
78
|
+
entries = entry.children
|
|
79
|
+
end
|
|
80
|
+
false
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# 筛选匹配已支持命令模式的来源条目。
|
|
84
|
+
def matching_blocks(pattern, entries = roots) = entries.select { |entry| pattern.match?(entry.text) }
|
|
85
|
+
# 提取匹配值,并关联对应的来源条目。
|
|
86
|
+
def configured_values(pattern, entries = roots)
|
|
87
|
+
entries.filter_map do |entry|
|
|
88
|
+
value = entry.text[pattern, 1]
|
|
89
|
+
analyze(entry) { value } unless value.nil?
|
|
90
|
+
end.uniq
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# 按配置顺序选取最后一条匹配声明。
|
|
94
|
+
def last_configured_value(pattern, entries = roots)
|
|
95
|
+
entry = entries.reverse.find { |candidate| !candidate.text[pattern, 1].nil? }
|
|
96
|
+
return unless entry
|
|
97
|
+
|
|
98
|
+
analyze(entry) { entry.text[pattern, 1] }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# 保存归一化事实及其来源证据。
|
|
102
|
+
def record(path, value, entries = [])
|
|
103
|
+
return unless @facts.record(path, value, lines: entries.map(&:line))
|
|
104
|
+
|
|
105
|
+
# 来源块中尚未解释的命令不能标记为解析成功。
|
|
106
|
+
cursor.capture_many(entries.select { |entry| cursor.parsed?(entry.index) }, data: value, path: [ "facts", path ])
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# 记录事实不可用的原因,并保留对应的来源行。
|
|
110
|
+
def unknown(path, entries = [], code: :unsupported_syntax)
|
|
111
|
+
@facts.unknown(path, lines: entries.map(&:line), code: code)
|
|
112
|
+
entries.each { |entry| cursor.mark(entry.index, state: :unsupported) }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# 未解析的访问声明不能抹除其他已确认的写权限。
|
|
116
|
+
def unknown_snmp_access(entries, code: :unsupported_syntax)
|
|
117
|
+
@facts.unknown_snmp_permission(lines: entries.map(&:line), code: code)
|
|
118
|
+
%w[snmp.communities snmp.community_present snmp.users snmp.versions snmp.v3_auth_priv snmp.acl].each do |path|
|
|
119
|
+
unknown(path, entries, code: code)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# 按来源顺序确定最终生效的显式启用或禁用声明。
|
|
124
|
+
def configured_state(on, off, entries = roots)
|
|
125
|
+
entries.reduce(nil) do |value, entry|
|
|
126
|
+
if off.match?(entry.text)
|
|
127
|
+
analyze(entry) { false }
|
|
128
|
+
elsif on.match?(entry.text)
|
|
129
|
+
analyze(entry) { true }
|
|
130
|
+
else
|
|
131
|
+
value
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# 记录凭据属性,不保留凭据秘密值。
|
|
137
|
+
def record_credential(owner, kind, format, secret, entry)
|
|
138
|
+
value = analyze(entry) { Credential.new(owner: owner, kind: kind, format: format, secret: secret, line: entry.line) }
|
|
139
|
+
index = @facts.record_credential(value)
|
|
140
|
+
capture(entry, value.to_h, [ "facts", "credentials.records", index ])
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# 注册有序 ACL 规则与别名,同时保留不支持的语法。
|
|
144
|
+
def register_acl(name, entries, aliases: [], supported: true, source: nil, &block)
|
|
145
|
+
commands = entries.reject { |entry| /\A(?:\d+\s+)?(?:description|remark|step|statistics|hardware-count)\b/.match?(entry.text) }
|
|
146
|
+
parsed = commands.map.with_index do |entry, index|
|
|
147
|
+
value = analyze(entry) { block.call(entry, index) }
|
|
148
|
+
cursor.mark(entry.index, state: :unsupported) unless value
|
|
149
|
+
value
|
|
150
|
+
end
|
|
151
|
+
damaged = @source_issues.any? do |issue|
|
|
152
|
+
line = issue[:line]
|
|
153
|
+
next false unless line
|
|
154
|
+
|
|
155
|
+
source ? source.line_range.cover?(line) : damaged_numbered_acl?(cursor.lines.fetch(line - 1).text, name)
|
|
156
|
+
end
|
|
157
|
+
acl = Acl.new(name: name, line: entries.first&.line, rules: parsed.compact,
|
|
158
|
+
supported: supported && !damaged && parsed.none?(&:nil?))
|
|
159
|
+
if @facts.register_acl(acl, aliases: aliases, lines: entries.map(&:line)).any?
|
|
160
|
+
entries.each { |entry| cursor.mark(entry.index, state: :unsupported) }
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# 名称被分页截断时,所有匹配其前缀的编号 ACL 都不能声明完整。
|
|
165
|
+
def damaged_numbered_acl?(text, name)
|
|
166
|
+
prefix = text.split(/--\s*More\s*--|\e\[/i, 2).first
|
|
167
|
+
match = /\Aaccess-list(?:\s+(\S*))?(.*)\z/.match(prefix)
|
|
168
|
+
return false unless match
|
|
169
|
+
|
|
170
|
+
identity, suffix = match.captures
|
|
171
|
+
identity.to_s.empty? || (suffix.empty? ? name.start_with?(identity) : name == identity)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# 归一化 ACL 来源地址;不推测不支持语法的含义。
|
|
175
|
+
def acl_source_network(parts)
|
|
176
|
+
token = parts.shift
|
|
177
|
+
return "0.0.0.0/0" if token == "any"
|
|
178
|
+
token = parts.shift if token == "host"
|
|
179
|
+
wildcard = parts.shift if parts.first&.match?(/\A(?:\d+(?:\.\d+){3}|0)\z/)
|
|
180
|
+
Acl.source_network(token, wildcard)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# 显式端口名称仅属于配置语法,不代表组织批准值。
|
|
184
|
+
def service_port(token)
|
|
185
|
+
return SERVICE_PORTS[token] unless token&.match?(/\A\d+\z/)
|
|
186
|
+
value = token.to_i
|
|
187
|
+
value if value.between?(0, 65_535)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# 服务器目标只接受 IP 地址或 DNS 名称,不接受网段或空词元。
|
|
191
|
+
def server_address?(value)
|
|
192
|
+
return false unless value.is_a?(String) && !value.empty?
|
|
193
|
+
IPAddr.new(value)
|
|
194
|
+
!value.include?("/")
|
|
195
|
+
rescue IPAddr::Error
|
|
196
|
+
value.bytesize <= 253 && !/\A[\d.]+\z/.match?(value) && value.delete_suffix(".").split(".", -1).all? do |label|
|
|
197
|
+
/\A[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\z/i.match?(label)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# 配置的监听端口仅替代当前服务的默认端口。
|
|
202
|
+
def listener_port(command, default:, scope:, negate: "undo")
|
|
203
|
+
prefix = Regexp.escape(command)
|
|
204
|
+
entries = matching_blocks(/\A(?:#{negate} )?#{prefix}(?:\s|\z)/)
|
|
205
|
+
return default if entries.empty?
|
|
206
|
+
entry = entries.last
|
|
207
|
+
return analyze(entry) { default } if entry.text == "#{negate} #{command}"
|
|
208
|
+
token = entry.text[/\A#{prefix}\s+(\d+)\z/, 1]
|
|
209
|
+
port = service_port(token)
|
|
210
|
+
return analyze(entry) { port } if port && port.positive?
|
|
211
|
+
unknown_listener_port(scope, entries)
|
|
212
|
+
nil
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# 监听端口无法解析时,将依赖该端口的事实保留为未知。
|
|
216
|
+
def unknown_listener_port(scope, entries)
|
|
217
|
+
%w[sources restricted].each { |field| unknown("#{scope}.#{field}", entries, code: :unsupported_service_port) }
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# 区分模式未声明、格式无效及包含完整算法与秘密值的声明。
|
|
221
|
+
def snmp_secret(settings, keyword, algorithms:, optional_algorithm: false, aes_size: false)
|
|
222
|
+
tokens = settings.split
|
|
223
|
+
index = tokens.index(keyword)
|
|
224
|
+
return false unless index
|
|
225
|
+
return if tokens.count(keyword) > 1
|
|
226
|
+
parameters = tokens.drop(index + 1).take_while { |token| !SNMP_OPTIONS.include?(token) }
|
|
227
|
+
if algorithms.include?(parameters.first)
|
|
228
|
+
algorithm = parameters.shift
|
|
229
|
+
elsif !optional_algorithm || parameters.size != 1 || %w[aes des md5 sha].include?(parameters.first)
|
|
230
|
+
return
|
|
231
|
+
end
|
|
232
|
+
return if algorithm == "aes" && aes_size && !%w[128 192 256].include?(parameters.shift)
|
|
233
|
+
format = parameters.shift if %w[cipher simple].include?(parameters.first)
|
|
234
|
+
return unless parameters.size == 1
|
|
235
|
+
[ format, parameters.first ]
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# 提取 SNMP 凭据属性,不持久化秘密值。
|
|
239
|
+
def snmp_credentials(owner, modes, entry, format: nil)
|
|
240
|
+
modes.each_value do |value|
|
|
241
|
+
next if value == false
|
|
242
|
+
stored_format, secret = value || [ "unknown", nil ]
|
|
243
|
+
record_credential(owner, "snmp_user", stored_format || format || "unknown", secret, entry)
|
|
244
|
+
end
|
|
245
|
+
unknown("snmp.v3_auth_priv", [ entry ], code: :invalid_snmp_security) if modes.value?(nil)
|
|
246
|
+
modes.transform_values { |value| value ? true : value }
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# 同名组可出现在不同安全级别;后续只读声明不能消除已有写权限。
|
|
250
|
+
def snmp_groups(entries)
|
|
251
|
+
entries.map { |entry| yield(entry) }.group_by(&:first).transform_values do |definitions|
|
|
252
|
+
records = definitions.map(&:last)
|
|
253
|
+
acls = records.map { |record| record[:acl] }.uniq
|
|
254
|
+
{ acl: acls.one? ? acls.first : nil, acl_ambiguous: acls.size > 1,
|
|
255
|
+
write: Facts.write_permission(records.map { |record| record[:write] }),
|
|
256
|
+
line: records.first[:line], lines: records.map { |record| record[:line] } }
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
data/lib/baseline/policy.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "rule"
|
|
4
|
+
|
|
3
5
|
require "yaml"
|
|
4
6
|
require "digest"
|
|
5
7
|
require "json"
|
|
@@ -8,11 +10,11 @@ module Baseline
|
|
|
8
10
|
# 按主机、型号、站点、角色或厂商选择批准值;缺少批准值时不回退到当前配置。
|
|
9
11
|
class Policy
|
|
10
12
|
SCOPES = Rule::CONTEXT_FIELDS
|
|
11
|
-
SELECTORS =
|
|
13
|
+
SELECTORS = CtxParser::Immutable.copy(SCOPES.map { |scope| "by_#{scope}" } + [ "default" ])
|
|
12
14
|
Resolution = Data.define(:value, :reason, :path) do
|
|
13
15
|
# 冻结解析后的批准值、原因及策略选择路径。
|
|
14
16
|
def initialize(value:, reason:, path:)
|
|
15
|
-
super(value:
|
|
17
|
+
super(value: CtxParser::Immutable.copy(value), reason: CtxParser::Immutable.copy(reason), path: CtxParser::Immutable.copy(path))
|
|
16
18
|
end
|
|
17
19
|
end
|
|
18
20
|
attr_reader :rules, :sha256
|
data/lib/baseline/report.rb
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "configuration"
|
|
4
|
+
|
|
5
|
+
require "ctx_parser/immutable"
|
|
6
|
+
require "json"
|
|
7
|
+
|
|
3
8
|
module Baseline
|
|
4
9
|
# 分别报告评估结果与输入覆盖率;未知项不计分,也不视为合规。
|
|
5
10
|
class Report
|
|
@@ -16,7 +21,7 @@ module Baseline
|
|
|
16
21
|
Finding = Data.define(:id, :title, :severity, :status, :reason, :actual, :expected, :lines, :policy_path, :diagnostics, :details) do
|
|
17
22
|
# 冻结单条检查结果的策略路径、诊断和支持细节。
|
|
18
23
|
def initialize(policy_path: [], diagnostics: [], details: {}, **attributes)
|
|
19
|
-
super(**
|
|
24
|
+
super(**CtxParser::Immutable.copy(attributes.merge(policy_path: policy_path, diagnostics: diagnostics, details: details)))
|
|
20
25
|
end
|
|
21
26
|
|
|
22
27
|
# 将稳定的结果原因代码转换为易读说明。
|
|
@@ -35,7 +40,7 @@ module Baseline
|
|
|
35
40
|
def initialize(configuration:, policy_sha256:, findings:, context:)
|
|
36
41
|
@configuration, @policy_sha256 = configuration, policy_sha256
|
|
37
42
|
@findings = findings.dup.freeze
|
|
38
|
-
@context =
|
|
43
|
+
@context = CtxParser::Immutable.copy(context)
|
|
39
44
|
@counts = @findings.map(&:status).tally.freeze
|
|
40
45
|
@applicable = @findings.reject { |finding| finding.status == :not_applicable }.freeze
|
|
41
46
|
freeze
|
|
@@ -72,7 +77,7 @@ module Baseline
|
|
|
72
77
|
|
|
73
78
|
# 限制摘要大小;完整事实由 to_h 提供,避免 ACL 或账号数量膨胀元数据。
|
|
74
79
|
def summary
|
|
75
|
-
metadata.merge(configuration: { platform: configuration.platform, sha256: configuration.sha256, status: configuration.status,
|
|
80
|
+
metadata.merge(configuration: { platform: configuration.platform, sha256: configuration.sha256, status: configuration.status, input_complete: configuration.input_complete?,
|
|
76
81
|
issue_counts: configuration.issues.map { |issue| issue[:code] }.tally },
|
|
77
82
|
findings: findings.map(&:summary))
|
|
78
83
|
end
|