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,230 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ctx_parser/result"
|
|
4
|
+
|
|
5
|
+
module Baseline
|
|
6
|
+
# 归并跨命令引用及未知状态,不依赖厂商语法或解析游标。
|
|
7
|
+
class Facts
|
|
8
|
+
attr_reader :vtys
|
|
9
|
+
|
|
10
|
+
# 初始化带来源信息的事实存储,并显式记录证据完整性。
|
|
11
|
+
def initialize(complete:, issues: [])
|
|
12
|
+
@confirmed_complete = complete
|
|
13
|
+
@complete = complete && issues.empty?
|
|
14
|
+
@values, @sources, @issues, @acls = {}, {}, issues.dup, {}
|
|
15
|
+
@vtys, @communities, @snmp_users, @credentials = [], [], [], []
|
|
16
|
+
@snmp_groups = {}
|
|
17
|
+
@snmp_permission_issues = []
|
|
18
|
+
@ssh_acl = nil
|
|
19
|
+
@management_ports = { "ssh" => 22, "stelnet" => 22, "telnet" => 23 }
|
|
20
|
+
@snmp_port = 161
|
|
21
|
+
@direct_acls = {}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# 判断所有已选事实是否都有完整证据支持。
|
|
25
|
+
def complete? = @complete
|
|
26
|
+
|
|
27
|
+
# 相关配置语句格式错误时,不能继续依据配置缺失推导结论。
|
|
28
|
+
def incomplete!(code:, lines: [])
|
|
29
|
+
@complete = false
|
|
30
|
+
@issues << { code: code, lines: lines.uniq }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# 防火墙可直接声明来源网段,不要求一定引用路由器式命名 ACL。
|
|
34
|
+
def direct_acl(fact:, sources:, bound:, lines: [])
|
|
35
|
+
unless %w[management.acl snmp.acl].include?(fact) && [ true, false ].include?(bound) &&
|
|
36
|
+
(sources.nil? || sources.is_a?(Array))
|
|
37
|
+
raise ArgumentError, "Direct access requires a known scope, binding state and source networks"
|
|
38
|
+
end
|
|
39
|
+
networks = sources && Acl.networks(sources.map { |source| Acl.range(source) })
|
|
40
|
+
@direct_acls[fact] = true
|
|
41
|
+
record("#{fact}.bound", bound, lines: lines)
|
|
42
|
+
if networks
|
|
43
|
+
record("#{fact}.sources", networks, lines: lines)
|
|
44
|
+
record("#{fact}.restricted", !networks.include?("0.0.0.0/0"), lines: lines)
|
|
45
|
+
else
|
|
46
|
+
%w[sources restricted].each { |key| unknown("#{fact}.#{key}", lines: lines, code: :acl_unresolved) }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# 将 false 保留为已知事实;输入不完整时,不接受 nil 或空集作为确定值。
|
|
51
|
+
def record(path, value, lines: [])
|
|
52
|
+
return false if value.nil? || (!complete? && value.respond_to?(:empty?) && value.empty?)
|
|
53
|
+
|
|
54
|
+
@values[path] = Baseline.immutable(value)
|
|
55
|
+
@sources[path] = lines.uniq unless lines.empty?
|
|
56
|
+
true
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# 记录事实不可用的原因,并保留对应的来源行。
|
|
60
|
+
def unknown(path, lines: [], code: :unsupported_syntax)
|
|
61
|
+
@issues << { code: code, fact: path, lines: lines.uniq }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# 将别名关联到同一 ACL;保留定义冲突,避免后续定义静默覆盖。
|
|
65
|
+
def register_acl(acl, aliases: [], lines: [])
|
|
66
|
+
keys = ([ acl.name ] + aliases).uniq
|
|
67
|
+
duplicates = keys.select { |key| @acls.key?(key) }
|
|
68
|
+
duplicates.each { |key| unknown("acls.#{key}", lines: lines, code: :duplicate_acl) }
|
|
69
|
+
keys.each { |key| @acls[key] = acl }
|
|
70
|
+
duplicates
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# 根据监听服务、认证方式与 ACL 绑定关系归纳管理访问事实。
|
|
74
|
+
def management(vtys:, ssh_acl: nil, ssh_port: 22, telnet_port: 23)
|
|
75
|
+
@vtys, @ssh_acl = Baseline.immutable(vtys), ssh_acl
|
|
76
|
+
@management_ports = { "ssh" => ssh_port, "stelnet" => ssh_port, "telnet" => telnet_port }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# 根据用户、组和团体字归纳 SNMP 暴露面及凭据事实。
|
|
80
|
+
def snmp(communities:, users:, groups: nil, port: 161)
|
|
81
|
+
@snmp_port = port
|
|
82
|
+
@communities = Baseline.immutable(communities)
|
|
83
|
+
@snmp_groups = Baseline.immutable(groups || {})
|
|
84
|
+
@snmp_users = users.map do |user|
|
|
85
|
+
if groups && user[:group]
|
|
86
|
+
if groups.key?(user[:group])
|
|
87
|
+
group = groups.fetch(user[:group])
|
|
88
|
+
user = user.merge(write: group[:write])
|
|
89
|
+
unless user[:acl]
|
|
90
|
+
user = user.merge(acl: group[:acl])
|
|
91
|
+
unknown("snmp.acl", code: :snmp_group_ambiguous) if group[:acl_ambiguous]
|
|
92
|
+
end
|
|
93
|
+
elsif !user[:acl]
|
|
94
|
+
unknown("snmp.acl", code: :snmp_group_unresolved)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
Baseline.immutable(user)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# 即使其他权限尚未解析,已确认的写权限仍属于违规证据。
|
|
102
|
+
def self.write_permission(permissions)
|
|
103
|
+
return true if permissions.include?(true)
|
|
104
|
+
return false if permissions.all?(false)
|
|
105
|
+
|
|
106
|
+
nil
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# 暂存不完整的访问证据,优先保留已确认的权限授予事实。
|
|
110
|
+
def unknown_snmp_permission(lines: [], code: :snmp_permission_unresolved)
|
|
111
|
+
@snmp_permission_issues << { code: code, fact: "snmp.write_enabled", lines: lines.uniq }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# 检查此来源行是否已经产生凭据事实。
|
|
115
|
+
def credential_recorded?(line) = @credentials.any? { |entry| entry[:line] == line }
|
|
116
|
+
|
|
117
|
+
# 记录凭据属性,不保留凭据秘密值。
|
|
118
|
+
def credential(credential)
|
|
119
|
+
index = @credentials.size
|
|
120
|
+
@credentials << credential.to_h
|
|
121
|
+
index
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# 汇总事实与诊断,并保留证据完整性状态。
|
|
125
|
+
def result
|
|
126
|
+
record("acls", @acls.values.uniq.map(&:to_h))
|
|
127
|
+
management_facts
|
|
128
|
+
snmp_facts
|
|
129
|
+
credential_facts
|
|
130
|
+
@issues << { code: :hostname_missing } unless @values.key?("hostname")
|
|
131
|
+
@issues << { code: :input_not_confirmed_complete } unless @confirmed_complete
|
|
132
|
+
status = complete? && @values.key?("hostname") ? :complete : :partial
|
|
133
|
+
CtxParser::Result.new(status: status,
|
|
134
|
+
records: Baseline.immutable([ { "facts" => @values, "sources" => @sources } ]), issues: Baseline.immutable(@issues.uniq))
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# 展示对象身份与状态,避免泄露原始回显或凭据。
|
|
138
|
+
def inspect = "#<#{self.class} complete=#{complete?} facts=#{@values.size}>"
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
# 归纳管理服务监听端点及其实际生效的访问限制。
|
|
143
|
+
def management_facts
|
|
144
|
+
record("ssh.vty", vtys)
|
|
145
|
+
if vtys.any? && vtys.all? { |entry| entry[:protocols] }
|
|
146
|
+
protocols = vtys.flat_map { |entry| entry[:protocols] }.uniq
|
|
147
|
+
record("ssh.vty_ssh_only", protocols.any? && (protocols - %w[ssh stelnet]).empty?)
|
|
148
|
+
end
|
|
149
|
+
if vtys.any? && vtys.all? { |entry| entry[:authentication] }
|
|
150
|
+
record("ssh.vty_aaa", vtys.all? { |entry| %w[scheme aaa local].include?(entry[:authentication]) })
|
|
151
|
+
end
|
|
152
|
+
# 全局 SSH ACL 仅覆盖明确限制为 SSH 的管理监听端点。
|
|
153
|
+
bindings = if @ssh_acl && @values["ssh.vty_ssh_only"] == true
|
|
154
|
+
[ { name: @ssh_acl, ports: management_ports([ "ssh" ]) } ]
|
|
155
|
+
else
|
|
156
|
+
vtys.map { |entry| { name: entry[:acl], ports: management_ports(entry[:protocols]) } }
|
|
157
|
+
end
|
|
158
|
+
acl_sources(bindings, protocol: "tcp", fact: "management.acl") unless @direct_acls["management.acl"]
|
|
159
|
+
if vtys.any?
|
|
160
|
+
%w[ssh.vty_ssh_only ssh.vty_aaa management.acl.bound].each { |path| @sources[path] = vtys.map { |entry| entry[:line] } }
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# 归纳 SNMP 监听、团体字及用户的安全属性。
|
|
165
|
+
def snmp_facts
|
|
166
|
+
record("snmp.communities", @communities)
|
|
167
|
+
record("snmp.community_present", @communities.any?) if complete? || @communities.any?
|
|
168
|
+
access = @communities + @snmp_users + @snmp_groups.values
|
|
169
|
+
permissions = access.map { |entry| entry[:write] }
|
|
170
|
+
permissions << nil unless @snmp_permission_issues.empty?
|
|
171
|
+
writable = self.class.write_permission(permissions)
|
|
172
|
+
record("snmp.write_enabled", writable, lines: access.flat_map { |entry| entry[:lines] || [ entry[:line] ] }.compact)
|
|
173
|
+
if writable.nil?
|
|
174
|
+
unknown("snmp.write_enabled", code: :snmp_permission_unresolved)
|
|
175
|
+
@issues.concat(@snmp_permission_issues)
|
|
176
|
+
end
|
|
177
|
+
record("snmp.users", @snmp_users)
|
|
178
|
+
record("snmp.v3_auth_priv", @snmp_users.all? { |entry| entry[:auth] && entry[:priv] }) if @snmp_users.any?
|
|
179
|
+
bindings = (@communities + @snmp_users).map { |entry| { name: entry[:acl], ports: @snmp_port ? [ @snmp_port ] : nil } }
|
|
180
|
+
acl_sources(bindings, protocol: "udp", fact: "snmp.acl") unless @direct_acls["snmp.acl"]
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# 汇总凭据存储方式及可度量的强度属性。
|
|
184
|
+
def credential_facts
|
|
185
|
+
record("credentials.records", @credentials)
|
|
186
|
+
record("credentials.present", @credentials.any?) if complete? || @credentials.any?
|
|
187
|
+
return if @credentials.empty?
|
|
188
|
+
|
|
189
|
+
storage = @credentials.map { |entry| entry[:storage] }
|
|
190
|
+
safe = storage.any? { |value| %w[plaintext legacy].include?(value) } ? false : storage.include?("unknown") ? nil : true
|
|
191
|
+
record("credentials.storage_safe", safe)
|
|
192
|
+
%w[credentials.records credentials.storage_safe].each { |path| @sources[path] = @credentials.map { |entry| entry[:line] }.uniq }
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# 确定所选管理协议使用的监听端口。
|
|
196
|
+
def management_ports(protocols)
|
|
197
|
+
return if !protocols || protocols.empty? || (protocols - @management_ports.keys).any?
|
|
198
|
+
ports = protocols.map { |name| @management_ports.fetch(name) }.uniq
|
|
199
|
+
ports unless ports.include?(nil)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# 解析已绑定 ACL 的来源范围,并保留不支持的证据。
|
|
203
|
+
def acl_sources(bindings, protocol:, fact:)
|
|
204
|
+
names = bindings.map { |binding| binding[:name] }
|
|
205
|
+
if names.empty? || names.any? { |name| name.nil? || name.empty? }
|
|
206
|
+
record("#{fact}.bound", false) if complete?
|
|
207
|
+
return
|
|
208
|
+
end
|
|
209
|
+
record("#{fact}.bound", true)
|
|
210
|
+
resolved = bindings.uniq.map do |binding|
|
|
211
|
+
name = binding[:name]
|
|
212
|
+
acl = @acls[name]
|
|
213
|
+
duplicate = @issues.any? { |issue| issue[:fact] == "acls.#{name}" }
|
|
214
|
+
next if !acl || duplicate
|
|
215
|
+
sources = (binding[:ports] || [ nil ]).map { |port| acl.sources(protocol: protocol, port: port) }
|
|
216
|
+
sources.flatten unless sources.any?(&:nil?)
|
|
217
|
+
end
|
|
218
|
+
if resolved.any?(&:nil?)
|
|
219
|
+
unknown("#{fact}.sources", code: :acl_unresolved)
|
|
220
|
+
unknown("#{fact}.restricted", code: :acl_unresolved)
|
|
221
|
+
return
|
|
222
|
+
end
|
|
223
|
+
sources = Acl.networks(resolved.flatten.uniq.map { |cidr| Acl.range(cidr) })
|
|
224
|
+
record("#{fact}.sources", sources)
|
|
225
|
+
record("#{fact}.restricted", !sources.include?("0.0.0.0/0"))
|
|
226
|
+
lines = names.uniq.flat_map { |name| @acls.fetch(name).rules.map { |rule| rule[:line] } }.compact.uniq
|
|
227
|
+
%w[sources restricted].each { |field| @sources["#{fact}.#{field}"] = lines }
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "quoted_document"
|
|
4
|
+
|
|
5
|
+
module Baseline
|
|
6
|
+
# StoneOS 导出内容可能包含命令回显、显式退出及引号包裹的界面状态。
|
|
7
|
+
class HillstoneDocument < QuotedDocument
|
|
8
|
+
protected
|
|
9
|
+
|
|
10
|
+
# 区分配置结构行与引号、横幅中的文本内容。
|
|
11
|
+
def classify(line)
|
|
12
|
+
unless @quote
|
|
13
|
+
return :ignored if line.text.match?(/\A(?:show (?:configuration|config)|Running configuration|Version\b|[\w.():-]+#\s*\z)/)
|
|
14
|
+
return :ignored if line.text == "exit"
|
|
15
|
+
return :boundary if line.text == "End"
|
|
16
|
+
end
|
|
17
|
+
super
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# 识别配置值可能跨行延续的语句。
|
|
21
|
+
def multiline_fact?(text)
|
|
22
|
+
text.match?(/\A(?:hostname|admin host|snmp-server|ntp|logging syslog|password|secret|host|backup[12])\b/)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "quoted_document"
|
|
4
|
+
|
|
5
|
+
module Baseline
|
|
6
|
+
# PAN-OS 的 set 导出可能包含引号包裹的多行证书与描述。
|
|
7
|
+
class PaloAltoDocument < QuotedDocument
|
|
8
|
+
protected
|
|
9
|
+
|
|
10
|
+
# 已建模的多行值不能被误判为配置缺失;证书正文不参与语义分析。
|
|
11
|
+
def multiline_fact?(text)
|
|
12
|
+
/\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)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ctx_parser/configuration_parser"
|
|
4
|
+
require "ctx_parser/input"
|
|
5
|
+
require_relative "../document"
|
|
6
|
+
|
|
7
|
+
module Baseline
|
|
8
|
+
module Parsers
|
|
9
|
+
# 共享输入与提取边界;CtxParser 跟踪来源,Facts 归纳领域证据。
|
|
10
|
+
class Base < CtxParser::ConfigurationParser
|
|
11
|
+
SERVICE_PORTS = { "ssh" => 22, "telnet" => 23, "snmp" => 161, "snmptrap" => 162 }.freeze
|
|
12
|
+
SNMP_OPTIONS = %w[auth priv authentication-mode privacy-mode acl access use-ipv4acl group local engineid engineID
|
|
13
|
+
encrypted localizedkey localizedV2key remote udp-port].freeze
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
# 解释当前命令回显,保留不支持或格式错误的证据。
|
|
18
|
+
def parse_output(text, complete:)
|
|
19
|
+
begin
|
|
20
|
+
text = CtxParser::Input.text(text, encoding: :utf8, max_bytes: 16 * 1024 * 1024)
|
|
21
|
+
rescue ArgumentError, EncodingError
|
|
22
|
+
return CtxParser::Result.new(status: :failed, issues: [ { code: :invalid_configuration } ])
|
|
23
|
+
end
|
|
24
|
+
read_configuration(text, document_class: configuration_document_class)
|
|
25
|
+
issues = document.issues.dup
|
|
26
|
+
if (!complete || issues.any?) && exclude_incomplete_final_line
|
|
27
|
+
issues << { code: :incomplete_final_line, line: cursor.lines.last.number }
|
|
28
|
+
end
|
|
29
|
+
@source_issues = issues
|
|
30
|
+
@facts = Facts.new(complete: complete, issues: issues)
|
|
31
|
+
if roots.empty?
|
|
32
|
+
return CtxParser::Result.new(status: :failed, issues: issues + [ { code: :empty_configuration } ])
|
|
33
|
+
end
|
|
34
|
+
if (hostname = matching(hostname_pattern).last)
|
|
35
|
+
name = analyze(hostname) { hostname.text[hostname_pattern, 1] }
|
|
36
|
+
record("hostname", name)
|
|
37
|
+
capture(hostname, name, [ "facts", "hostname" ])
|
|
38
|
+
end
|
|
39
|
+
access_lists
|
|
40
|
+
management
|
|
41
|
+
services
|
|
42
|
+
authentication
|
|
43
|
+
@facts.result
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# 返回本次解析的根配置块。
|
|
47
|
+
def roots = @blocks
|
|
48
|
+
# 选择适合当前平台语法的文档词法解析器。
|
|
49
|
+
def configuration_document_class = Document
|
|
50
|
+
# 匹配当前平台显式声明的主机名。
|
|
51
|
+
def hostname_pattern = /\A(?:hostname|sysname)\s+(\S+)\z/
|
|
52
|
+
# 选择符合目标命令语法的根配置块。
|
|
53
|
+
def sections(pattern) = roots.select { |entry| pattern.match?(entry.text) }
|
|
54
|
+
|
|
55
|
+
# 保留原始文本,但不将可能截断的词元纳入事实构建树。
|
|
56
|
+
def exclude_incomplete_final_line
|
|
57
|
+
line = cursor.lines.last
|
|
58
|
+
return false unless line && !line.raw.end_with?("\n", "\r") && cursor.state(line.index) != :ignored
|
|
59
|
+
|
|
60
|
+
entries, ancestors = roots, []
|
|
61
|
+
while (entry = entries.last)
|
|
62
|
+
if entry.index == line.index
|
|
63
|
+
retained = entries[0...-1].freeze
|
|
64
|
+
ancestors.reverse_each do |siblings, parent|
|
|
65
|
+
block = CtxParser::Block.new(cursor: cursor, header: parent.header, children: retained, last_index: parent.last_index)
|
|
66
|
+
retained = (siblings[0...-1] + [ block ]).freeze
|
|
67
|
+
end
|
|
68
|
+
@blocks = retained
|
|
69
|
+
cursor.mark(line.index, state: :unsupported)
|
|
70
|
+
return true
|
|
71
|
+
end
|
|
72
|
+
ancestors << [ entries, entry ]
|
|
73
|
+
entries = entry.children
|
|
74
|
+
end
|
|
75
|
+
false
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# 筛选匹配已支持命令模式的来源条目。
|
|
79
|
+
def matching(pattern, entries = roots) = entries.select { |entry| pattern.match?(entry.text) }
|
|
80
|
+
# 提取匹配值,并关联对应的来源条目。
|
|
81
|
+
def values(pattern, entries = roots)
|
|
82
|
+
entries.filter_map do |entry|
|
|
83
|
+
value = entry.text[pattern, 1]
|
|
84
|
+
analyze(entry) { value } unless value.nil?
|
|
85
|
+
end.uniq
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# 按配置顺序选取最后一条匹配声明。
|
|
89
|
+
def last(pattern, entries = roots)
|
|
90
|
+
entry = entries.reverse.find { |candidate| !candidate.text[pattern, 1].nil? }
|
|
91
|
+
return unless entry
|
|
92
|
+
|
|
93
|
+
analyze(entry) { entry.text[pattern, 1] }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# 保存归一化事实及其来源证据。
|
|
97
|
+
def record(path, value, entries = [])
|
|
98
|
+
return unless @facts.record(path, value, lines: entries.map(&:line))
|
|
99
|
+
|
|
100
|
+
# 来源块中尚未解释的命令不能标记为解析成功。
|
|
101
|
+
cursor.capture_many(entries.select { |entry| cursor.parsed?(entry.index) }, data: value, path: [ "facts", path ])
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# 记录事实不可用的原因,并保留对应的来源行。
|
|
105
|
+
def unknown(path, entries = [], code: :unsupported_syntax)
|
|
106
|
+
@facts.unknown(path, lines: entries.map(&:line), code: code)
|
|
107
|
+
entries.each { |entry| cursor.mark(entry.index, state: :unsupported) }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# 未解析的访问声明不能抹除其他已确认的写权限。
|
|
111
|
+
def unknown_snmp_access(entries, code: :unsupported_syntax)
|
|
112
|
+
@facts.unknown_snmp_permission(lines: entries.map(&:line), code: code)
|
|
113
|
+
%w[snmp.communities snmp.community_present snmp.users snmp.versions snmp.v3_auth_priv snmp.acl].each do |path|
|
|
114
|
+
unknown(path, entries, code: code)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# 按来源顺序确定最终生效的显式启用或禁用声明。
|
|
119
|
+
def state(on, off, entries = roots)
|
|
120
|
+
entries.reduce(nil) do |value, entry|
|
|
121
|
+
if off.match?(entry.text)
|
|
122
|
+
analyze(entry) { false }
|
|
123
|
+
elsif on.match?(entry.text)
|
|
124
|
+
analyze(entry) { true }
|
|
125
|
+
else
|
|
126
|
+
value
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# 记录凭据属性,不保留凭据秘密值。
|
|
132
|
+
def credential(owner, kind, format, secret, entry)
|
|
133
|
+
value = analyze(entry) { Credential.new(owner: owner, kind: kind, format: format, secret: secret, line: entry.line) }
|
|
134
|
+
index = @facts.credential(value)
|
|
135
|
+
capture(entry, value.to_h, [ "facts", "credentials.records", index ])
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# 注册有序 ACL 规则与别名,同时保留不支持的语法。
|
|
139
|
+
def register_acl(name, entries, aliases: [], supported: true, source: nil, &block)
|
|
140
|
+
commands = entries.reject { |entry| /\A(?:\d+\s+)?(?:description|remark|step|statistics|hardware-count)\b/.match?(entry.text) }
|
|
141
|
+
parsed = commands.map.with_index do |entry, index|
|
|
142
|
+
value = analyze(entry) { block.call(entry, index) }
|
|
143
|
+
cursor.mark(entry.index, state: :unsupported) unless value
|
|
144
|
+
value
|
|
145
|
+
end
|
|
146
|
+
damaged = @source_issues.any? do |issue|
|
|
147
|
+
line = issue[:line]
|
|
148
|
+
next false unless line
|
|
149
|
+
|
|
150
|
+
source ? source.line_range.cover?(line) : damaged_numbered_acl?(cursor.lines.fetch(line - 1).text, name)
|
|
151
|
+
end
|
|
152
|
+
acl = Acl.new(name: name, line: entries.first&.line, rules: parsed.compact,
|
|
153
|
+
supported: supported && !damaged && parsed.none?(&:nil?))
|
|
154
|
+
if @facts.register_acl(acl, aliases: aliases, lines: entries.map(&:line)).any?
|
|
155
|
+
entries.each { |entry| cursor.mark(entry.index, state: :unsupported) }
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# 名称被分页截断时,所有匹配其前缀的编号 ACL 都不能声明完整。
|
|
160
|
+
def damaged_numbered_acl?(text, name)
|
|
161
|
+
prefix = text.split(/--\s*More\s*--|\e\[/i, 2).first
|
|
162
|
+
match = /\Aaccess-list(?:\s+(\S*))?(.*)\z/.match(prefix)
|
|
163
|
+
return false unless match
|
|
164
|
+
|
|
165
|
+
identity, suffix = match.captures
|
|
166
|
+
identity.to_s.empty? || (suffix.empty? ? name.start_with?(identity) : name == identity)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# 归一化 ACL 来源地址;不推测不支持语法的含义。
|
|
170
|
+
def source(parts)
|
|
171
|
+
token = parts.shift
|
|
172
|
+
return "0.0.0.0/0" if token == "any"
|
|
173
|
+
token = parts.shift if token == "host"
|
|
174
|
+
wildcard = parts.shift if parts.first&.match?(/\A(?:\d+(?:\.\d+){3}|0)\z/)
|
|
175
|
+
Acl.source(token, wildcard)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# 显式端口名称仅属于配置语法,不代表组织批准值。
|
|
179
|
+
def service_port(token)
|
|
180
|
+
return SERVICE_PORTS[token] unless token&.match?(/\A\d+\z/)
|
|
181
|
+
value = token.to_i
|
|
182
|
+
value if value.between?(0, 65_535)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# 服务器目标只接受 IP 地址或 DNS 名称,不接受网段或空词元。
|
|
186
|
+
def server_address?(value)
|
|
187
|
+
return false unless value.is_a?(String) && !value.empty?
|
|
188
|
+
IPAddr.new(value)
|
|
189
|
+
!value.include?("/")
|
|
190
|
+
rescue IPAddr::Error
|
|
191
|
+
value.bytesize <= 253 && !/\A[\d.]+\z/.match?(value) && value.delete_suffix(".").split(".", -1).all? do |label|
|
|
192
|
+
/\A[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\z/i.match?(label)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# 配置的监听端口仅替代当前服务的默认端口。
|
|
197
|
+
def listener_port(command, default:, scope:, negate: "undo")
|
|
198
|
+
prefix = Regexp.escape(command)
|
|
199
|
+
entries = matching(/\A(?:#{negate} )?#{prefix}(?:\s|\z)/)
|
|
200
|
+
return default if entries.empty?
|
|
201
|
+
entry = entries.last
|
|
202
|
+
return analyze(entry) { default } if entry.text == "#{negate} #{command}"
|
|
203
|
+
token = entry.text[/\A#{prefix}\s+(\d+)\z/, 1]
|
|
204
|
+
port = service_port(token)
|
|
205
|
+
return analyze(entry) { port } if port && port.positive?
|
|
206
|
+
unknown_listener_port(scope, entries)
|
|
207
|
+
nil
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# 监听端口无法解析时,将依赖该端口的事实保留为未知。
|
|
211
|
+
def unknown_listener_port(scope, entries)
|
|
212
|
+
%w[sources restricted].each { |field| unknown("#{scope}.#{field}", entries, code: :unsupported_service_port) }
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# 区分模式未声明、格式无效及包含完整算法与秘密值的声明。
|
|
216
|
+
def snmp_secret(settings, keyword, algorithms:, optional_algorithm: false, aes_size: false)
|
|
217
|
+
tokens = settings.split
|
|
218
|
+
index = tokens.index(keyword)
|
|
219
|
+
return false unless index
|
|
220
|
+
return if tokens.count(keyword) > 1
|
|
221
|
+
parameters = tokens.drop(index + 1).take_while { |token| !SNMP_OPTIONS.include?(token) }
|
|
222
|
+
if algorithms.include?(parameters.first)
|
|
223
|
+
algorithm = parameters.shift
|
|
224
|
+
elsif !optional_algorithm || parameters.size != 1 || %w[aes des md5 sha].include?(parameters.first)
|
|
225
|
+
return
|
|
226
|
+
end
|
|
227
|
+
return if algorithm == "aes" && aes_size && !%w[128 192 256].include?(parameters.shift)
|
|
228
|
+
format = parameters.shift if %w[cipher simple].include?(parameters.first)
|
|
229
|
+
return unless parameters.size == 1
|
|
230
|
+
[ format, parameters.first ]
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# 提取 SNMP 凭据属性,不持久化秘密值。
|
|
234
|
+
def snmp_credentials(owner, modes, entry, format: nil)
|
|
235
|
+
modes.each_value do |value|
|
|
236
|
+
next if value == false
|
|
237
|
+
stored_format, secret = value || [ "unknown", nil ]
|
|
238
|
+
credential(owner, "snmp_user", stored_format || format || "unknown", secret, entry)
|
|
239
|
+
end
|
|
240
|
+
unknown("snmp.v3_auth_priv", [ entry ], code: :invalid_snmp_security) if modes.value?(nil)
|
|
241
|
+
modes.transform_values { |value| value ? true : value }
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# 同名组可出现在不同安全级别;后续只读声明不能消除已有写权限。
|
|
245
|
+
def snmp_groups(entries)
|
|
246
|
+
entries.map { |entry| yield(entry) }.group_by(&:first).transform_values do |definitions|
|
|
247
|
+
records = definitions.map(&:last)
|
|
248
|
+
acls = records.map { |record| record[:acl] }.uniq
|
|
249
|
+
{ acl: acls.one? ? acls.first : nil, acl_ambiguous: acls.size > 1,
|
|
250
|
+
write: Facts.write_permission(records.map { |record| record[:write] }),
|
|
251
|
+
line: records.first[:line], lines: records.map { |record| record[:line] } }
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|