baseline-rb 0.2.1 → 0.3.1

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.
@@ -9,42 +9,42 @@ module Baseline
9
9
  private
10
10
 
11
11
  # 解析管理访问暴露检查使用的有序 ACL 定义。
12
- def access_lists
12
+ def parse_access_lists
13
13
  matching_blocks(/\Aacl\s+/).each do |section|
14
14
  # IPv6 ACL 不能误建为名为 ipv6 的 IPv4 ACL,也不能覆盖其别名。
15
15
  if /\Aacl\s+ipv6\b/.match?(section.text)
16
- unknown("ipv6.acls", [ section ], code: :unsupported_acl_family)
16
+ record_unknown_fact("ipv6.acls", [ section ], code: :unsupported_acl_family)
17
17
  next
18
18
  end
19
19
  match = /\Aacl\s+(?:number\s+|basic\s+|advanced\s+)?(?:name\s+)?(\S+)(?:\s+name\s+(\S+))?(?:\s+.*)?\z/.match(section.text)
20
20
  next unless match
21
21
  register_acl(match[2] || match[1], section.children, source: section, aliases: [ match[1] ], supported: !section.text.include?("match-order auto")) do |entry, index|
22
- comware_rule(entry, index)
22
+ parse_acl_rule(entry, index)
23
23
  end
24
24
  end
25
25
  end
26
26
 
27
27
  # 解析单条有序 Comware ACL 规则及来源限制。
28
- def comware_rule(entry, _index)
28
+ def parse_acl_rule(entry, _index)
29
29
  match = /\Arule\s+(\d+)\s+(permit|deny)(?:\s+(.*))?\z/.match(entry.text)
30
30
  return unless match
31
31
  parts = match[3].to_s.split
32
32
  protocol = %w[ip tcp udp icmp].include?(parts.first) ? parts.shift : "ip"
33
33
  network = if parts.first == "source"
34
34
  parts.shift
35
- acl_source_network(parts)
35
+ parse_acl_source_network(parts)
36
36
  else
37
37
  "0.0.0.0/0"
38
38
  end
39
39
  if parts.first == "destination"
40
40
  parts.shift
41
- return unless acl_source_network(parts) == "0.0.0.0/0"
41
+ return unless parse_acl_source_network(parts) == "0.0.0.0/0"
42
42
  end
43
43
  if parts.first == "destination-port"
44
44
  return unless %w[tcp udp].include?(protocol)
45
45
  parts.shift
46
46
  return unless parts.shift == "eq"
47
- port = service_port(parts.shift)
47
+ port = parse_service_port(parts.shift)
48
48
  return if port.nil?
49
49
  end
50
50
  return unless (parts - %w[logging counting]).empty?
@@ -55,60 +55,99 @@ module Baseline
55
55
  end
56
56
 
57
57
  # 根据监听服务、认证方式与 ACL 绑定关系归纳管理访问事实。
58
- def management
59
- record("ssh.enabled", configured_state(ssh_enable, ssh_disable), matching_blocks(/\A(?:undo )?(?:ssh|stelnet)/))
58
+ def parse_management
59
+ record_fact("ssh.enabled", configured_state(ssh_enable_pattern, ssh_disable_pattern), matching_blocks(/\A(?:undo )?(?:ssh|stelnet)/))
60
60
  compatible = configured_state(/\Assh server compatible-ssh1x enable\z/, /\A(?:undo ssh server compatible-ssh1x enable|ssh server compatible-ssh1x disable)\z/)
61
- record("ssh.versions", compatible.nil? ? nil : compatible ? [ "1", "2" ] : [ "2" ])
62
- ssh_acl = last_configured_value(/\Assh server acl(?: ipv4)?\s+(?:name\s+)?(\S+)\z/)
61
+ record_fact("ssh.versions", compatible.nil? ? nil : compatible ? [ "1", "2" ] : [ "2" ])
62
+ ssh_acl_entries = matching_blocks(/\A(?:undo )?ssh server acl\b/).reject { |entry| /\A(?:undo )?ssh server acl ipv6\b/.match?(entry.text) }
63
+ ssh_acl = effective_configured_value(/\Assh server acl(?: ipv4)?\s+(?!name\z|ipv4\z)(?:name\s+)?(\S+)\z/, entries: ssh_acl_entries,
64
+ family: /\A(?:undo )?ssh server acl\b/, fact: "management.acl", reset: /\Aundo ssh server acl(?: ipv4)?(?: (?:name )?\S+)?\z/)
63
65
  defaults = matching_blocks(/\Aline class vty\z/).flat_map(&:children)
64
66
  vtys = matching_blocks(/\A(?:line|user-interface) vty\b/).map do |section|
65
67
  entries = defaults + section.children
66
- { name: section.text, protocols: last_configured_value(/\Aprotocol inbound\s+(.+)\z/, entries)&.split,
67
- authentication: last_configured_value(/\Aauthentication-mode\s+(\S+)\z/, entries),
68
- acl: last_configured_value(/\Aacl\s+(?:name\s+)?(\S+)(?:\s+inbound)?\z/, entries), line: section.line }
68
+ acl_entries = entries.reject { |entry| /\A(?:undo )?acl\s+ipv6\b|\A(?:undo )?acl\s+(?:name\s+)?\S+\s+outbound(?:\s|\z)/.match?(entry.text) }
69
+ { name: section.text, protocols: effective_configured_value(/\Aprotocol inbound\s+((?:all|ssh|stelnet|telnet)(?:\s+(?:ssh|stelnet|telnet))*)\z/, entries: entries,
70
+ family: /\A(?:undo )?protocol inbound\b/, fact: "ssh.vty_ssh_only", reset: /\Aundo protocol inbound(?:\s+\S+)*\z/)&.split,
71
+ authentication: effective_configured_value(/\Aauthentication-mode\s+(scheme|aaa|password|none)\z/, entries: entries,
72
+ family: /\A(?:undo )?authentication-mode\b/, fact: "ssh.vty_aaa", reset: /\Aundo authentication-mode(?: \S+)?\z/),
73
+ acl: effective_configured_value(/\Aacl\s+(?!name\z)(?:name\s+)?(\S+)(?:\s+inbound)?\z/, entries: acl_entries,
74
+ family: /\A(?:undo )?acl\b/, fact: "management.acl", reset: /\Aundo acl(?:\s+(?:name\s+)?\S+(?:\s+inbound)?)?\z/), line: section.line }
69
75
  end
70
- @facts.management(vtys: vtys, ssh_acl: ssh_acl,
76
+ @fact_builder.record_management_access(vtys: vtys, ssh_acl: ssh_acl,
71
77
  ssh_port: listener_port("ssh server port", default: 22, scope: "management.acl"),
72
78
  telnet_port: listener_port("telnet server port", default: 23, scope: "management.acl"))
73
79
  definitions = { "telnet" => "telnet server", "http" => "ip http", "https" => "ip https", "ftp" => "ftp server", "tftp" => "tftp server" }
74
- record("services", definitions.to_h do |name, command|
80
+ record_fact("services", definitions.to_h do |name, command|
75
81
  [ name, configured_state(/\A#{command} enable\z/, /\A(?:undo #{command} enable|#{command} disable)\z/) ]
76
82
  end)
77
83
  end
78
84
 
79
- # 采集明确启用或禁用的管理服务。
80
- def services
81
- record("ntp.servers", configured_values(/\Antp-service unicast-server\s+(\S+)/), matching_blocks(/\Antp-service unicast-server/))
85
+ # 提取时间服务器、认证要求与源接口。
86
+ def parse_ntp
87
+ ntp_entries = matching_blocks(/\A(?:undo )?ntp-service (?:ipv6 )?unicast-server(?:\s|\z)/)
88
+ ntp_pattern = /\Antp-service (?:ipv6 )?unicast-server\s+(?:vpn-instance\s+\S+\s+)?((?!(?:vpn-instance|ip|ipv6)(?:\s|\z))\S+)/
89
+ servers = configured_server_addresses("ntp.servers", ntp_pattern, ntp_entries, negate: "undo") do |text, match|
90
+ options = parse_server_options(match.post_match, flags: [ "priority" ],
91
+ integers: { "version" => 1..4, "authentication-keyid" => 1..4_294_967_295, "authkey" => 1..4_294_967_295 },
92
+ names: [ "vpn-instance" ], interfaces: [ "source-interface" ])
93
+ vpn = text[/\Antp-service (?:ipv6 )?unicast-server\s+vpn-instance\s+(\S+)/, 1]
94
+ if options && !(vpn && options.key?("vpn-instance")) && !(options.key?("authkey") && options.key?("authentication-keyid"))
95
+ [ match[1], vpn || options["vpn-instance"] ]
96
+ end
97
+ end
98
+ record_fact("ntp.servers", servers, ntp_entries)
82
99
  record_configured_state("ntp.authentication", /\Antp-service authentication enable\z/, /\Aundo ntp-service authentication enable\z/)
83
- record_configured_value("ntp.source", /\Antp-service source(?:-interface)?\s+(\S+)\z/)
84
- # 从日志服务器中排除 source 子命令,并保留评估所需的归纳事实。
85
- hosts = configured_values(/\Ainfo-center loghost\s+(\S+)/) - [ "source" ]
86
- record("syslog.servers", hosts, matching_blocks(/\Ainfo-center loghost/))
87
- record_configured_value("syslog.source", /\Ainfo-center loghost source\s+(\S+)\z/)
100
+ source_entries = matching_blocks(/\A(?:undo )?ntp-service source(?:-interface)?\b/)
101
+ source = effective_configured_value(/\Antp-service source(?:-interface)?\s+(\S+)\z/, entries: source_entries,
102
+ family: /\A/, fact: "ntp.source", reset: /\Aundo ntp-service source(?:-interface)?(?:\s+\S+)?\z/)
103
+ record_fact("ntp.source", source, source_entries.last(1))
104
+ end
105
+
106
+ # 提取日志目标与源接口,厂商命令不进入事实归约层。
107
+ def parse_syslog
108
+ log_entries = matching_blocks(/\A(?:undo )?info-center loghost(?:\s|\z)/).reject { |entry| /\A(?:undo )?info-center loghost source(?:\s|\z)/.match?(entry.text) }
109
+ hosts = configured_server_addresses("syslog.servers", /\Ainfo-center loghost\s+(?:vpn-instance\s+\S+\s+)?(?:ipv6\s+)?((?!(?:vpn-instance|ipv6)(?:\s|\z))\S+)/, log_entries, negate: "undo") do |text, match|
110
+ [ match[1], text[/\Ainfo-center loghost\s+vpn-instance\s+(\S+)/, 1] ]
111
+ end
112
+ record_fact("syslog.servers", hosts, log_entries)
113
+ source_entries = matching_blocks(/\A(?:undo )?info-center loghost source\b/)
114
+ source = effective_configured_value(/\Ainfo-center loghost source\s+(\S+)\z/, entries: source_entries,
115
+ family: /\A/, fact: "syslog.source", reset: /\Aundo info-center loghost source(?:\s+\S+)?\z/)
116
+ record_fact("syslog.source", source, source_entries.last(1))
117
+ end
118
+
119
+ # 提取轮询权限、用户安全属性及来源限制。
120
+ def parse_snmp
88
121
  enabled = configured_state(/\Asnmp-agent(?:\s|\z)/, /\Aundo snmp-agent\z/)
89
- record("snmp.enabled", enabled.nil? && @facts.complete? ? false : enabled)
122
+ record_fact("snmp.enabled", enabled.nil? && @fact_builder.complete? ? false : enabled)
90
123
  versions = last_configured_value(/\Asnmp-agent sys-info version\s+(.+)\z/)&.split
91
124
  versions = %w[v1 v2c v3] if versions == [ "all" ]
92
- record("snmp.versions", versions)
125
+ record_fact("snmp.versions", versions)
93
126
  communities = matching_blocks(/\Asnmp-agent community\b/).filter_map do |entry|
94
127
  match = /\Asnmp-agent community (read|write)\s+(?:(simple|cipher)\s+)?(\S+)(.*)\z/.match(entry.text)
95
128
  unless match
96
- unknown_snmp_access([ entry ])
129
+ record_unknown_snmp_access([ entry ])
97
130
  next
98
131
  end
99
132
  record_credential("snmp-community", "snmp", match[2] || "plain", match[3], entry)
100
133
  { write: match[1] == "write", acl: match[4][/\bacl\s+(?:name\s+)?(\S+)/, 1], line: entry.line }
101
134
  end
102
- groups = snmp_groups(matching_blocks(/\Asnmp-agent group v3\s+/)) do |entry|
135
+ group_entries = matching_blocks(/\Asnmp-agent group\b/).select do |entry|
136
+ next true if /\Asnmp-agent group v3\s+\S+(?:\s|\z)/.match?(entry.text)
137
+
138
+ record_unknown_snmp_access([ entry ], code: :unsupported_snmp_group)
139
+ false
140
+ end
141
+ groups = configured_snmp_groups(group_entries) do |entry|
103
142
  settings = entry.text.sub(/\Asnmp-agent group v3\s+\S+\s*/, "")
104
- [ entry.text.split[3], { acl: entry.text[/\bacl\s+(?:name\s+)?(\S+)/, 1], line: entry.line, write: snmp_group_write(settings) } ]
143
+ [ entry.text.split[3], { acl: entry.text[/\bacl\s+(?:name\s+)?(\S+)/, 1], line: entry.line, write: snmp_group_write_state(settings) } ]
105
144
  end
106
- record("snmp.groups", groups)
145
+ record_fact("snmp.groups", groups)
107
146
  users = {}
108
147
  matching_blocks(/\Asnmp-agent usm-user\b/).each do |entry|
109
148
  match = /\Asnmp-agent usm-user v3\s+(\S+)(?:\s+(.*))?\z/.match(entry.text)
110
149
  unless match
111
- unknown_snmp_access([ entry ], code: :unsupported_snmp_user)
150
+ record_unknown_snmp_access([ entry ], code: :unsupported_snmp_user)
112
151
  next
113
152
  end
114
153
  settings = match[2].to_s
@@ -117,18 +156,18 @@ module Baseline
117
156
  first = settings.split.first
118
157
  group ||= first unless %w[group authentication-mode privacy-mode acl local engineid].include?(first)
119
158
  user[:group] = group if group
120
- modes = { auth: snmp_secret(settings, "authentication-mode", algorithms: %w[md5 sha sha2-224 sha2-256 sha2-384 sha2-512]),
121
- priv: snmp_secret(settings, "privacy-mode", algorithms: %w[des des56 3des aes128 aes192 aes256]) }
122
- snmp_credentials("snmp:#{match[1]}", modes, entry).each { |key, value| user[key] ||= value }
159
+ modes = { auth: parse_snmp_secret(settings, "authentication-mode", algorithms: %w[md5 sha sha2-224 sha2-256 sha2-384 sha2-512]),
160
+ priv: parse_snmp_secret(settings, "privacy-mode", algorithms: %w[des des56 3des aes128 aes192 aes256]) }
161
+ record_snmp_credentials("snmp:#{match[1]}", modes, entry).each { |key, value| user[key] ||= value }
123
162
  acl = settings[/\bacl\s+(?:name\s+)?(\S+)/, 1]
124
163
  user[:acl] = acl if acl
125
164
  end
126
- @facts.snmp(communities: communities, users: users.values, groups: groups,
165
+ @fact_builder.record_snmp_access(communities: communities, users: users.values, groups: groups,
127
166
  port: listener_port(snmp_port_command, default: 161, scope: "snmp.acl"))
128
167
  end
129
168
 
130
169
  # Comware、VRP 必须明确授予写视图;引号或未知语法保持未解析。
131
- def snmp_group_write(settings)
170
+ def snmp_group_write_state(settings)
132
171
  syntax = /\A(?:(?:authentication|privacy|noauthentication)(?:\s+|\z))?(?:(?:read-view|(?<write>write-view)|notify-view)\s+[^\s"]+\s*|acl(?:\s+ipv6)?(?:\s+name)?\s+[^\s"]+\s*|acl-ipv[46]\s+[^\s"]+\s*)*\z/
133
172
  match = syntax.match(settings)
134
173
  return unless match
@@ -137,13 +176,13 @@ module Baseline
137
176
  end
138
177
 
139
178
  # 解析实际生效的认证方法及外部服务器引用。
140
- def authentication
141
- schemes = matching_blocks(tacacs_section).to_h do |section|
142
- server_types = matching_blocks(tacacs_server, section.children).group_by { |entry| entry.text.split[1] }
143
- .transform_values { |entries| configured_values(tacacs_server, entries) }
179
+ def parse_authentication
180
+ schemes = matching_blocks(tacacs_section_pattern).to_h do |section|
181
+ server_types = matching_blocks(tacacs_server_family_pattern, section.children).group_by { |entry| entry.text.delete_prefix("undo ").split[1] }
182
+ .transform_values { |entries| configured_server_addresses("tacacs", tacacs_server_pattern, entries, negate: "undo") }
144
183
  servers = server_types.values.flatten.uniq
145
184
  keys = section.children.filter_map do |entry|
146
- match = tacacs_key.match(entry.text)
185
+ match = tacacs_key_pattern.match(entry.text)
147
186
  next unless match
148
187
  kind, format, secret = match.captures
149
188
  record_credential("tacacs:#{section.text.split.last}", "tacacs", format, secret, entry)
@@ -151,29 +190,34 @@ module Baseline
151
190
  end
152
191
  [ section.text.split.last, { servers: servers, server_types: server_types, key_types: keys.uniq, line: section.line } ]
153
192
  end
154
- record("tacacs.schemes", schemes)
155
- record("tacacs.servers", schemes.values.flat_map { |entry| entry[:servers] }.uniq)
193
+ record_fact("tacacs.schemes", schemes)
194
+ record_fact("tacacs.servers", schemes.values.flat_map { |entry| entry[:servers] }.uniq)
156
195
  domains = authentication_domains
157
196
  methods = domains.flat_map { |entry| entry.children }
158
197
  %w[authentication authorization accounting].each do |kind|
159
- relevant = matching_blocks(/\A#{kind}\s+login\b/, methods)
160
- relevant = matching_blocks(/\A#{kind}\s+default\b/, methods) if relevant.empty?
198
+ relevant = matching_blocks(/\A(?:undo )?#{kind}\s+login\b/, methods)
199
+ relevant = matching_blocks(/\A(?:undo )?#{kind}\s+default\b/, methods) if relevant.empty?
200
+ relevant = relevant.last(1)
201
+ if relevant.any? && !/\A#{kind}\s+(?:login|default)\s+(?:(?:hwtacacs-scheme|radius-scheme)\s+\S+(?:\s+(?:local|none))?|local|none)\z/.match?(relevant.first.text)
202
+ record_unknown_fact("tacacs.#{kind == 'authentication' ? 'login' : kind}", relevant)
203
+ next
204
+ end
161
205
  refs = relevant.filter_map { |entry| entry.text[/\A#{kind}\s+(?:login|default)\s+hwtacacs-scheme\s+(\S+)/, 1] }
162
- refs.concat(vrp_tacacs_references(kind, domains))
206
+ refs.concat(domain_tacacs_references(kind, domains))
163
207
  key = kind == "authentication" ? "login" : kind
164
208
  if refs.any? && refs.any? { |name| !schemes.key?(name) || schemes[name][:server_types].fetch(kind, []).empty? }
165
- unknown("tacacs.#{key}", relevant, code: :tacacs_scheme_unresolved)
209
+ record_unknown_fact("tacacs.#{key}", relevant, code: :tacacs_scheme_unresolved)
166
210
  else
167
211
  used = refs.any?
168
212
  if used && key == "login"
169
- unknown("tacacs.login", code: :vty_authentication_unknown) if @facts.vtys.empty? || @facts.vtys.any? { |vty| vty[:authentication].nil? }
170
- used &&= @facts.vtys.any? && @facts.vtys.all? { |vty| %w[scheme aaa].include?(vty[:authentication]) }
213
+ record_unknown_fact("tacacs.login", code: :vty_authentication_unknown) if @fact_builder.vtys.empty? || @fact_builder.vtys.any? { |vty| vty[:authentication].nil? }
214
+ used &&= @fact_builder.vtys.any? && @fact_builder.vtys.all? { |vty| %w[scheme aaa].include?(vty[:authentication]) }
171
215
  end
172
- record("tacacs.#{key}", used, relevant) if @facts.complete? || used
216
+ record_fact("tacacs.#{key}", used, relevant) if @fact_builder.complete? || used
173
217
  end
174
218
  end
175
- record("tacacs.keys_present", schemes.values.all? { |entry| tacacs_keys_complete?(entry[:key_types]) }) unless schemes.empty?
176
- accounts
219
+ record_fact("tacacs.keys_present", schemes.values.all? { |entry| tacacs_keys_complete?(entry[:key_types]) }) unless schemes.empty?
220
+ parse_local_accounts
177
221
  matching_blocks(/\A(?:line|user-interface)\b/).each do |section|
178
222
  matching_blocks(/\A(?:set authentication )?password\b/, section.children).each do |entry|
179
223
  match = /\A(?:set authentication )?password\s+(simple|cipher|hash|irreversible-cipher)\s+(.+)\z/.match(entry.text)
@@ -190,18 +234,18 @@ module Baseline
190
234
  def authentication_domains
191
235
  name = last_configured_value(/\Adomain default enable\s+(\S+)\z/)
192
236
  unless name
193
- %w[login authorization accounting].each { |kind| unknown("tacacs.#{kind}", code: :authentication_domain_unknown) }
237
+ %w[login authorization accounting].each { |kind| record_unknown_fact("tacacs.#{kind}", code: :authentication_domain_unknown) }
194
238
  return []
195
239
  end
196
240
  matching_blocks(/\Adomain #{Regexp.escape(name)}\z/)
197
241
  end
198
242
  # 解析 VRP 认证域引用,避免将 VRP 语法应用到 Comware。
199
- def vrp_tacacs_references(_kind, _domains) = []
243
+ def domain_tacacs_references(_kind, _domains) = []
200
244
  # 返回当前平台修改 SNMP 监听端口的命令模式。
201
245
  def snmp_port_command = "snmp-agent port"
202
246
 
203
247
  # 提取本地账号角色及凭据存储属性。
204
- def accounts
248
+ def parse_local_accounts
205
249
  matching_blocks(/\Alocal-user\s+/).each do |section|
206
250
  next if section.text.start_with?("local-user password-")
207
251
  name = section.text.split[1]
@@ -210,7 +254,7 @@ module Baseline
210
254
  match = /\Apassword\s+(simple|cipher|hash|irreversible-cipher)\s+(.+)\z/.match(entry.text)
211
255
  record_credential(name, "local_user", match ? match[1] : "unknown", match ? match[2] : nil, entry)
212
256
  end
213
- record_credential(name, "local_user", "unknown", nil, section) if passwords.empty? && @facts.complete?
257
+ record_credential(name, "local_user", "unknown", nil, section) if passwords.empty? && @fact_builder.complete?
214
258
  end
215
259
  end
216
260
  end
@@ -38,22 +38,24 @@ module Baseline
38
38
  return finding(attributes, :unknown, configuration.status == :unsupported ? :unsupported_platform : :input_incomplete)
39
39
  end
40
40
  return finding(attributes, :unknown, :scope_unknown) if applicable.nil?
41
- resolution = @policy.resolve_with_source(rule, context)
41
+ resolution = @policy.resolve_with_path(rule, context)
42
42
  attributes[:policy_path] = resolution.path
43
43
  return finding(attributes, :unknown, resolution.reason) if resolution.reason
44
44
  expected = attributes[:expected] = resolution.value
45
- unless configuration.known?(rule.fact)
46
- return finding(attributes, :unknown, configuration.complete? ? :fact_unavailable : :input_incomplete)
45
+ unless configuration.fact_known?(rule.fact)
46
+ unavailable = attributes[:diagnostics].any? { |issue| issue[:code] == :unsupported_capability && issue[:fact] == rule.fact }
47
+ reason = !configuration.complete? ? :input_incomplete : unavailable ? :unsupported_capability : :fact_unavailable
48
+ return finding(attributes, :unknown, reason)
47
49
  end
48
- actual = configuration.value(rule.fact)
50
+ actual = configuration.fact(rule.fact)
49
51
  attributes[:actual] = actual
50
- status, reason = compare(rule.operator, actual, expected)
52
+ status, reason, details = compare(rule.operator, actual, expected)
51
53
  # 不完整输入不能证明通过;仅保留补充记录也无法推翻的明确违规。
52
54
  if !configuration.complete? && (status == :pass || status == :fail && !conclusive_violation?(rule, actual, expected, configuration))
53
55
  status, reason = :unknown, :input_incomplete
54
56
  end
55
57
  if status == :fail
56
- attributes[:details] = differences(rule.operator, actual, expected)
58
+ attributes[:details] = details || differences(rule.operator, actual, expected)
57
59
  # 尚未采集的配置可能补齐缺项,因此不将缺项作为已确认偏差展示。
58
60
  attributes[:details].delete(:missing) unless configuration.complete?
59
61
  end
@@ -88,7 +90,6 @@ module Baseline
88
90
  when :set_equals then { missing: expected - actual, unexpected: actual - expected }
89
91
  when :subset then { unexpected: actual - expected }
90
92
  when :count_at_least then { minimum: expected, actual_count: actual.size }
91
- when :networks_within then { outside_approved: Acl.unapproved_networks(actual, expected) }
92
93
  when :all_disabled then { enabled: expected.select { |name| actual[name] == true } }
93
94
  else {}
94
95
  end
@@ -105,8 +106,8 @@ module Baseline
105
106
  rule.applies_to.each do |key, expected|
106
107
  values = key == "facts" ? expected : { key => expected }
107
108
  values.each do |name, criterion|
108
- known = key == "facts" ? configuration.scope_known?(name) : context.key?(name)
109
- value = key == "facts" ? configuration.value(name) : context[name]
109
+ known = key == "facts" ? configuration.applicability_fact_known?(name) : context.key?(name)
110
+ value = key == "facts" ? configuration.fact(name) : context[name]
110
111
  if !known
111
112
  unknown = true
112
113
  elsif !(criterion.is_a?(Array) ? criterion.include?(value) : criterion == value)
@@ -135,24 +136,38 @@ module Baseline
135
136
  when :subset then (actual - expected).empty?
136
137
  when :set_equals then actual.sort == expected.sort
137
138
  when :count_at_least then actual.size >= expected
138
- when :networks_within then Acl.networks_within?(actual, expected)
139
+ when :networks_within
140
+ outside = Acl.unapproved_networks(actual, expected)
141
+ details = { outside_approved: outside }
142
+ outside.empty?
139
143
  end
140
144
  end
141
- [ passed ? :pass : :fail, passed ? nil : :deviation ]
145
+ [ passed ? :pass : :fail, passed ? nil : :deviation, details ]
142
146
  rescue ArgumentError, IPAddr::InvalidAddressError
143
147
  [ :unknown, :unsupported_address_family ]
144
148
  end
145
149
 
146
150
  # 检查可度量的凭据属性,不保存秘密值。
147
151
  def password_strength(actual, expected)
148
- return [ :unknown, :fact_type_mismatch ] unless actual.is_a?(Array) && actual.all? { |item| item.is_a?(Hash) }
149
- visible = actual.select { |item| item[:storage] == "plaintext" }
152
+ return [ :unknown, :fact_type_mismatch ] unless actual.is_a?(Array)
153
+ valid = actual.select { |item| valid_strength_attributes?(item) }
154
+ visible = valid.select { |item| item[:storage] == "plaintext" }
150
155
  weak = visible.any? do |item|
151
156
  item[:common] || item.fetch(:length) < expected.fetch("min_length") || item.fetch(:character_classes) < expected.fetch("min_classes")
152
157
  end
153
158
  return [ :fail, :weak_password ] if weak
159
+ return [ :unknown, :fact_type_mismatch ] if valid.size != actual.size
154
160
  return [ :unknown, :password_strength_unobservable ] if actual.empty? || visible.size != actual.size
155
161
  [ :pass, nil ]
156
162
  end
163
+
164
+ # 公开事实入口可接受外部解析结果;损坏属性不能冒充可比较的口令证据。
165
+ def valid_strength_attributes?(item)
166
+ return false unless item.is_a?(Hash) && %w[plaintext legacy protected unknown].include?(item[:storage])
167
+ return true unless item[:storage] == "plaintext"
168
+
169
+ [ true, false ].include?(item[:common]) && item[:length].is_a?(Integer) && item[:length] >= 0 &&
170
+ item[:character_classes].is_a?(Integer) && item[:character_classes].between?(0, 4)
171
+ end
157
172
  end
158
173
  end
@@ -4,7 +4,7 @@ require "ctx_parser/immutable"
4
4
 
5
5
  module Baseline
6
6
  # 在归一化配置中区分输入完整性、已知事实与局部不支持的语法。
7
- class Configuration
7
+ class Evidence
8
8
  PARTIAL_SCOPE_FACTS = { "snmp.enabled" => true, "snmp.community_present" => true, "credentials.present" => true,
9
9
  "snmp.write_enabled" => true, "credentials.storage_safe" => false }.freeze
10
10
  attr_reader :platform, :sha256, :status, :facts, :sources, :issues
@@ -27,10 +27,10 @@ module Baseline
27
27
  # 判断配置采集与解析是否均已完成。
28
28
  def complete? = input_complete? && status == :complete
29
29
  # 读取已知值,同时保留事实缺失或未知的区别。
30
- def value(path) = facts[path]
30
+ def fact(path) = facts[path]
31
31
 
32
32
  # 区分明确已知的事实与缺失、不支持的配置证据。
33
- def known?(path)
33
+ def fact_known?(path)
34
34
  facts.key?(path) && issues.none? do |issue|
35
35
  scope = issue[:fact]
36
36
  scope && (path == scope || path.start_with?("#{scope}."))
@@ -38,12 +38,12 @@ module Baseline
38
38
  end
39
39
 
40
40
  # 局部输入可证明存在或明确违规,但不能证明缺失或所有成员均满足条件。
41
- def scope_known?(path)
42
- return false unless known?(path)
41
+ def applicability_fact_known?(path)
42
+ return false unless fact_known?(path)
43
43
  return true if complete?
44
44
  return false unless status == :partial
45
45
 
46
- path == "hostname" || PARTIAL_SCOPE_FACTS.key?(path) && PARTIAL_SCOPE_FACTS[path] == value(path)
46
+ path == "hostname" || PARTIAL_SCOPE_FACTS.key?(path) && PARTIAL_SCOPE_FACTS[path] == fact(path)
47
47
  end
48
48
 
49
49
  # 返回公开的数据结构,并保留状态与诊断信息。