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