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
data/lib/baseline/acl.rb
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ipaddr"
|
|
4
|
+
|
|
5
|
+
module Baseline
|
|
6
|
+
# 按首次匹配规则分析 IPv4 来源 ACL;不支持的语法保持未知。
|
|
7
|
+
class Acl
|
|
8
|
+
attr_reader :name, :rules, :line, :supported
|
|
9
|
+
|
|
10
|
+
# 冻结有序 ACL 规则及其来源、支持状态。
|
|
11
|
+
def initialize(name:, rules:, line:, supported: true)
|
|
12
|
+
@name, @rules, @line, @supported = name.freeze, Baseline.immutable(rules), line, supported
|
|
13
|
+
freeze
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# 按 ACL 顺序匹配语义计算允许访问的来源范围。
|
|
17
|
+
def sources(protocol:, port: nil)
|
|
18
|
+
return unless supported && rules.any?
|
|
19
|
+
selected = rules.sort_by { |rule| rule.fetch(:sequence) }.select do |rule|
|
|
20
|
+
next unless [ "ip", protocol ].include?(rule.fetch(:protocol))
|
|
21
|
+
if rule[:destination_port]
|
|
22
|
+
return if port.nil?
|
|
23
|
+
next unless rule[:destination_port] == port
|
|
24
|
+
end
|
|
25
|
+
true
|
|
26
|
+
end
|
|
27
|
+
self.class.networks(permitted_ranges(selected))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# 返回公开的数据结构,并保留状态与诊断信息。
|
|
31
|
+
def to_h = { name: name, rules: rules, line: line, supported: supported }
|
|
32
|
+
# 展示对象身份与状态,避免泄露原始回显或凭据。
|
|
33
|
+
def inspect = "#<#{self.class} name=#{name} rules=#{rules.size} supported=#{supported}>"
|
|
34
|
+
|
|
35
|
+
# 归一化 ACL 来源地址;不推测不支持语法的含义。
|
|
36
|
+
def self.source(address, wildcard = nil)
|
|
37
|
+
return "0.0.0.0/0" if address == "any"
|
|
38
|
+
ip = IPAddr.new(address)
|
|
39
|
+
raise ArgumentError, "IPv4 ACL required" unless ip.ipv4?
|
|
40
|
+
if wildcard
|
|
41
|
+
mask = IPAddr.new(wildcard == "0" ? "0.0.0.0" : wildcard)
|
|
42
|
+
value = mask.to_i
|
|
43
|
+
raise ArgumentError, "Noncontiguous wildcard" unless mask.ipv4? && (value & (value + 1)).zero?
|
|
44
|
+
prefix = 32 - value.bit_length
|
|
45
|
+
ip = ip.mask(prefix)
|
|
46
|
+
end
|
|
47
|
+
"#{ip}/#{ip.prefix}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# 将网络前缀转换为包含首尾地址的整数区间。
|
|
51
|
+
def self.range(cidr)
|
|
52
|
+
ip = IPAddr.new(cidr)
|
|
53
|
+
raise ArgumentError, "IPv4 ACL required" unless ip.ipv4?
|
|
54
|
+
[ ip.to_range.first.to_i, ip.to_range.last.to_i ]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# 通过地址区间相减,检查实际来源是否包含于策略批准的网段。
|
|
58
|
+
def self.subtract(ranges, removed)
|
|
59
|
+
removed.reduce(ranges) do |remaining, (left, right)|
|
|
60
|
+
remaining.flat_map do |first, last|
|
|
61
|
+
if right < first || left > last
|
|
62
|
+
[ [ first, last ] ]
|
|
63
|
+
else
|
|
64
|
+
[ ([ first, left - 1 ] if left > first), ([ right + 1, last ] if right < last) ].compact
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# 判断所有实际来源范围是否均在允许范围内。
|
|
71
|
+
def self.within?(actual, expected)
|
|
72
|
+
subtract(actual.map { |cidr| range(cidr) }, expected.map { |cidr| range(cidr) }).empty?
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# 将地址区间转换为数量最少的网络前缀。
|
|
76
|
+
def self.networks(ranges)
|
|
77
|
+
merged = ranges.sort.each_with_object([]) do |(first, last), result|
|
|
78
|
+
if result.any? && first <= result.last.last + 1
|
|
79
|
+
result.last[1] = [ result.last.last, last ].max
|
|
80
|
+
else
|
|
81
|
+
result << [ first, last ]
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
merged.flat_map do |first, last|
|
|
85
|
+
networks = []
|
|
86
|
+
while first <= last
|
|
87
|
+
aligned = first.zero? ? 32 : (first & -first).bit_length - 1
|
|
88
|
+
bits = [ aligned, (last - first + 1).bit_length - 1 ].min
|
|
89
|
+
networks << "#{IPAddr.new(first, Socket::AF_INET)}/#{32 - bits}"
|
|
90
|
+
first += 1 << bits
|
|
91
|
+
end
|
|
92
|
+
networks
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
# 按地址边界扫描一次;每个区间由最先匹配的生效规则决定。
|
|
99
|
+
def permitted_ranges(selected)
|
|
100
|
+
boundaries = Hash.new { |entries, address| entries[address] = [] }
|
|
101
|
+
selected.each_with_index do |rule, priority|
|
|
102
|
+
first, last = self.class.range(rule.fetch(:source))
|
|
103
|
+
boundaries[first] << [ priority, true ]
|
|
104
|
+
# 将闭区间终点转为下一地址的失效事件,统一处理相邻和重叠网段。
|
|
105
|
+
boundaries[last + 1] << [ priority, false ]
|
|
106
|
+
end
|
|
107
|
+
active, priorities, permitted = [], [], []
|
|
108
|
+
boundaries.keys.sort.each_cons(2) do |first, following|
|
|
109
|
+
boundaries.fetch(first).each do |priority, enabled|
|
|
110
|
+
active[priority] = enabled
|
|
111
|
+
push_priority(priorities, priority) if enabled
|
|
112
|
+
end
|
|
113
|
+
# 堆顶始终是配置顺序最靠前的生效规则,保持 ACL 首次匹配语义。
|
|
114
|
+
pop_priority(priorities) while priorities.any? && !active[priorities.first]
|
|
115
|
+
if priorities.any? && selected.fetch(priorities.first).fetch(:action) == "permit"
|
|
116
|
+
permitted << [ first, following - 1 ]
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
permitted
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# 使用二叉最小堆维护规则优先级,避免反复排序生效规则。
|
|
123
|
+
def push_priority(heap, priority)
|
|
124
|
+
index = heap.size
|
|
125
|
+
heap << priority
|
|
126
|
+
while index.positive?
|
|
127
|
+
parent = (index - 1) / 2
|
|
128
|
+
break if heap[parent] <= priority
|
|
129
|
+
heap[index] = heap[parent]
|
|
130
|
+
index = parent
|
|
131
|
+
end
|
|
132
|
+
heap[index] = priority
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# 移除已失效的堆顶规则;其他失效规则在升至堆顶时再清理。
|
|
136
|
+
def pop_priority(heap)
|
|
137
|
+
last = heap.pop
|
|
138
|
+
return if heap.empty?
|
|
139
|
+
|
|
140
|
+
index = 0
|
|
141
|
+
while (child = index * 2 + 1) < heap.size
|
|
142
|
+
child += 1 if child + 1 < heap.size && heap[child + 1] < heap[child]
|
|
143
|
+
break if last <= heap[child]
|
|
144
|
+
heap[index] = heap[child]
|
|
145
|
+
index = child
|
|
146
|
+
end
|
|
147
|
+
heap[index] = last
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Baseline
|
|
6
|
+
# 仅分析传入文本,不读取文件、访问数据库、连接设备或执行修复。
|
|
7
|
+
class Audit
|
|
8
|
+
PARSERS = { "h3c" => Parsers::H3c, "huawei" => Parsers::Huawei,
|
|
9
|
+
"cisco_ios" => Parsers::CiscoIos, "cisco_nxos" => Parsers::CiscoNxos,
|
|
10
|
+
"hillstone" => Parsers::Hillstone, "palo_alto" => Parsers::PaloAlto }.freeze
|
|
11
|
+
ALIASES = { "h3c_wireless" => "h3c", "comware" => "h3c", "huawei_wireless" => "huawei", "vrp" => "huawei",
|
|
12
|
+
"ios" => "cisco_ios", "iosxe" => "cisco_ios", "ios_xe" => "cisco_ios", "cisco_ios_xe" => "cisco_ios",
|
|
13
|
+
"nxos" => "cisco_nxos", "nx_os" => "cisco_nxos", "stoneos" => "hillstone", "stone_os" => "hillstone",
|
|
14
|
+
"paloalto" => "palo_alto", "panos" => "palo_alto", "pan_os" => "palo_alto" }.freeze
|
|
15
|
+
|
|
16
|
+
# 绑定配置解析与基线评估使用的策略。
|
|
17
|
+
def initialize(policy:)
|
|
18
|
+
@evaluator = Evaluator.new(policy: policy)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# 解析配置证据,并评估适用的基线规则。
|
|
22
|
+
def call(configuration:, platform:, complete: false, context: {})
|
|
23
|
+
unless configuration.is_a?(String) && (platform.is_a?(String) || platform.is_a?(Symbol)) && [ true, false ].include?(complete)
|
|
24
|
+
raise ArgumentError, "Configuration text, platform and boolean completeness are required"
|
|
25
|
+
end
|
|
26
|
+
name = platform.to_s.strip.downcase.tr("-", "_")
|
|
27
|
+
name = ALIASES.fetch(name, name)
|
|
28
|
+
parser = PARSERS[name]
|
|
29
|
+
result = parser ? parser.parse(configuration, complete: complete) :
|
|
30
|
+
CtxParser::Result.new(status: :unsupported, issues: [ { code: :unsupported_platform } ])
|
|
31
|
+
facts = Configuration.new(platform: name, sha256: Digest::SHA256.hexdigest(configuration), result: result)
|
|
32
|
+
@evaluator.call(configuration: facts, context: context)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# 展示对象身份与状态,避免泄露原始回显或凭据。
|
|
36
|
+
def inspect = "#<#{self.class}>"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Baseline
|
|
4
|
+
# 在归一化配置中区分输入完整性、已知事实与局部不支持的语法。
|
|
5
|
+
class Configuration
|
|
6
|
+
PARTIAL_SCOPE_FACTS = { "snmp.enabled" => true, "snmp.community_present" => true, "credentials.present" => true,
|
|
7
|
+
"snmp.write_enabled" => true, "credentials.storage_safe" => false }.freeze
|
|
8
|
+
attr_reader :platform, :sha256, :status, :facts, :sources, :issues
|
|
9
|
+
|
|
10
|
+
# 冻结配置事实及其平台、来源身份。
|
|
11
|
+
def initialize(platform:, sha256:, result:)
|
|
12
|
+
@platform, @sha256, @status = platform.dup.freeze, sha256.dup.freeze, result.status
|
|
13
|
+
record = result.records.first || {}
|
|
14
|
+
@facts = Baseline.immutable(record.fetch("facts", {}))
|
|
15
|
+
@sources = Baseline.immutable(record.fetch("sources", {}))
|
|
16
|
+
@issues = Baseline.immutable(result.issues)
|
|
17
|
+
freeze
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# 判断配置采集与解析是否均已完成。
|
|
21
|
+
def complete? = status == :complete
|
|
22
|
+
# 读取已知值,同时保留事实缺失或未知的区别。
|
|
23
|
+
def value(path) = facts[path]
|
|
24
|
+
|
|
25
|
+
# 区分明确已知的事实与缺失、不支持的配置证据。
|
|
26
|
+
def known?(path)
|
|
27
|
+
facts.key?(path) && issues.none? do |issue|
|
|
28
|
+
scope = issue[:fact]
|
|
29
|
+
scope && (path == scope || path.start_with?("#{scope}."))
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# 局部输入可证明存在或明确违规,但不能证明缺失或所有成员均满足条件。
|
|
34
|
+
def scope_known?(path)
|
|
35
|
+
return false unless known?(path)
|
|
36
|
+
return true if complete?
|
|
37
|
+
return false unless status == :partial
|
|
38
|
+
|
|
39
|
+
path == "hostname" || PARTIAL_SCOPE_FACTS.key?(path) && PARTIAL_SCOPE_FACTS[path] == value(path)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# 返回公开的数据结构,并保留状态与诊断信息。
|
|
43
|
+
def to_h
|
|
44
|
+
{ platform: platform, sha256: sha256, status: status, facts: facts, sources: sources, issues: issues }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# 展示对象身份与状态,避免泄露原始回显或凭据。
|
|
48
|
+
def inspect = "#<#{self.class} platform=#{platform} status=#{status} facts=#{facts.size}>"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Baseline
|
|
4
|
+
# 仅在构造时分析凭据,不保留明文、密文或可枚举的摘要。
|
|
5
|
+
class Credential
|
|
6
|
+
COMMON = %w[admin administrator password passwd public private cisco huawei h3c root guest test 123456 12345678 123456789 1234567890 qwerty welcome changeme].freeze
|
|
7
|
+
attr_reader :attributes
|
|
8
|
+
|
|
9
|
+
# 提取可安全报告的凭据属性,随后丢弃传入的秘密值。
|
|
10
|
+
def initialize(owner:, kind:, format:, secret:, line:)
|
|
11
|
+
storage = case format.to_s
|
|
12
|
+
when "plain", "simple", "0" then "plaintext"
|
|
13
|
+
when "4", "5", "7" then "legacy"
|
|
14
|
+
when "6", "8", "9", "hash", "cipher", "irreversible-cipher" then "protected"
|
|
15
|
+
else "unknown"
|
|
16
|
+
end
|
|
17
|
+
values = { owner: owner, kind: kind, storage: storage, format: format.to_s, present: !secret.to_s.empty?, line: line }
|
|
18
|
+
if storage == "plaintext"
|
|
19
|
+
text = secret.to_s
|
|
20
|
+
values.merge!(length: text.length, character_classes: [ /[a-z]/, /[A-Z]/, /\d/, /[^a-zA-Z\d]/ ].count { |pattern| pattern.match?(text) },
|
|
21
|
+
common: COMMON.include?(text.downcase) || text.downcase == owner.to_s.downcase || /\A(.)\1+\z/.match?(text))
|
|
22
|
+
end
|
|
23
|
+
@attributes = Baseline.immutable(values)
|
|
24
|
+
freeze
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# 返回公开的数据结构,并保留状态与诊断信息。
|
|
28
|
+
def to_h = attributes
|
|
29
|
+
# 展示对象身份与状态,避免泄露原始回显或凭据。
|
|
30
|
+
def inspect = "#<#{self.class} kind=#{attributes[:kind]} storage=#{attributes[:storage]}>"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ctx_parser/document"
|
|
4
|
+
require "ctx_parser/banner"
|
|
5
|
+
|
|
6
|
+
module Baseline
|
|
7
|
+
# 复用游标与配置树,识别厂商注释、横幅及不完整回显。
|
|
8
|
+
class Document < CtxParser::Document
|
|
9
|
+
protected
|
|
10
|
+
|
|
11
|
+
# 区分配置结构行与引号、横幅中的文本内容。
|
|
12
|
+
def classify(line)
|
|
13
|
+
text = line.text
|
|
14
|
+
return :ignored if text.empty?
|
|
15
|
+
@banner ||= CtxParser::Banner.new
|
|
16
|
+
return :ignored if @banner.consume(text)
|
|
17
|
+
if /--\s*More\s*--|\e\[|\A(?:%\s*(?:Invalid|Incomplete|Ambiguous)|Error:)/i.match?(text)
|
|
18
|
+
issues << { code: :incomplete_configuration, line: line.number }
|
|
19
|
+
return :ignored
|
|
20
|
+
end
|
|
21
|
+
return :boundary if text.start_with?("!", "#") || %w[end return].include?(text)
|
|
22
|
+
return :ignored if /\A(?:Building configuration|Current configuration)/i.match?(text)
|
|
23
|
+
|
|
24
|
+
:entry
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# 报告配置文档结束时仍未闭合的横幅。
|
|
28
|
+
def finish
|
|
29
|
+
issues << { code: :incomplete_banner } if @banner&.open?
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Baseline
|
|
4
|
+
# 根据策略评估归一化事实;厂商语法差异由解析器负责。
|
|
5
|
+
class Evaluator
|
|
6
|
+
# 绑定归一化证据评估使用的策略。
|
|
7
|
+
def initialize(policy:)
|
|
8
|
+
raise ArgumentError, "A baseline policy is required" unless policy.is_a?(Policy)
|
|
9
|
+
@policy = policy
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# 评估适用基线规则,分别报告合规结果与证据覆盖率。
|
|
13
|
+
def call(configuration:, context: {})
|
|
14
|
+
raise ArgumentError, "Invalid baseline context" unless context.is_a?(Hash)
|
|
15
|
+
context = context.transform_keys(&:to_s).merge("vendor" => configuration.platform)
|
|
16
|
+
unless (context.keys - Policy::SCOPES).empty? && context.values.all? { |value| value.is_a?(String) && !value.strip.empty? }
|
|
17
|
+
raise ArgumentError, "Invalid baseline context"
|
|
18
|
+
end
|
|
19
|
+
findings = @policy.rules.select(&:enabled?).map { |rule| evaluate(rule, configuration, context) }
|
|
20
|
+
Report.new(configuration: configuration, policy_sha256: @policy.sha256, findings: findings, context: context)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
# 使用已知事实和解析后的策略值评估单条适用规则。
|
|
26
|
+
def evaluate(rule, configuration, context)
|
|
27
|
+
attributes = { id: rule.id, title: rule.title, severity: rule.severity, actual: nil, expected: nil,
|
|
28
|
+
lines: configuration.sources.fetch(rule.fact, []), policy_path: [], details: {}, diagnostics: diagnostics(rule, configuration) }
|
|
29
|
+
applicable = applicability(rule, configuration, context)
|
|
30
|
+
return finding(attributes, :not_applicable, :scope_mismatch) if applicable == false
|
|
31
|
+
unless configuration.complete? || configuration.status == :partial
|
|
32
|
+
return finding(attributes, :unknown, configuration.status == :unsupported ? :unsupported_platform : :input_incomplete)
|
|
33
|
+
end
|
|
34
|
+
return finding(attributes, :unknown, :scope_unknown) if applicable.nil?
|
|
35
|
+
resolution = @policy.resolve_with_source(rule, context)
|
|
36
|
+
attributes[:policy_path] = resolution.path
|
|
37
|
+
return finding(attributes, :unknown, resolution.reason) if resolution.reason
|
|
38
|
+
expected = attributes[:expected] = resolution.value
|
|
39
|
+
unless configuration.known?(rule.fact)
|
|
40
|
+
return finding(attributes, :unknown, configuration.complete? ? :fact_unavailable : :input_incomplete)
|
|
41
|
+
end
|
|
42
|
+
actual = configuration.value(rule.fact)
|
|
43
|
+
attributes[:actual] = actual
|
|
44
|
+
status, reason = compare(rule.operator, actual, expected)
|
|
45
|
+
# 不完整输入不能证明通过;仅保留补充记录也无法推翻的明确违规。
|
|
46
|
+
if !configuration.complete? && (status == :pass || status == :fail && !partial_failure?(rule, actual, expected))
|
|
47
|
+
status, reason = :unknown, :input_incomplete
|
|
48
|
+
end
|
|
49
|
+
if status == :fail
|
|
50
|
+
attributes[:details] = differences(rule.operator, actual, expected)
|
|
51
|
+
# 尚未采集的配置可能补齐缺项,因此不将缺项作为已确认偏差展示。
|
|
52
|
+
attributes[:details].delete(:missing) unless configuration.complete?
|
|
53
|
+
end
|
|
54
|
+
finding(attributes, status, reason)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# 采集尚未完成时,仅报告后续缺失记录也无法推翻的明确违规。
|
|
58
|
+
def partial_failure?(rule, actual, expected)
|
|
59
|
+
case rule.operator
|
|
60
|
+
when :all_disabled, :subset, :networks_within, :password_strength then true
|
|
61
|
+
when :set_equals then (actual - expected).any?
|
|
62
|
+
when :equals
|
|
63
|
+
rule.fact == "snmp.write_enabled" && actual == true && expected == false ||
|
|
64
|
+
rule.fact == "credentials.storage_safe" && actual == false && expected == true
|
|
65
|
+
else false
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# 按事实路径关联诊断,说明未知原因,同时避免泄露配置文本或凭据。
|
|
70
|
+
def diagnostics(rule, configuration)
|
|
71
|
+
scopes = [ rule.fact, *rule.applies_to.fetch("facts", {}).keys ]
|
|
72
|
+
configuration.issues.select do |issue|
|
|
73
|
+
fact = issue[:fact]
|
|
74
|
+
!fact || scopes.any? { |scope| scope == fact || scope.start_with?("#{fact}.") || fact.start_with?("#{scope}.") }
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# 说明事实与策略的偏差,不改变比较结果。
|
|
79
|
+
def differences(operator, actual, expected)
|
|
80
|
+
case operator
|
|
81
|
+
when :set_equals then { missing: expected - actual, unexpected: actual - expected }
|
|
82
|
+
when :subset then { unexpected: actual - expected }
|
|
83
|
+
when :count_at_least then { minimum: expected, actual_count: actual.size }
|
|
84
|
+
when :networks_within then { outside_approved: actual.reject { |network| Acl.within?([ network ], expected) } }
|
|
85
|
+
when :all_disabled then { enabled: expected.select { |name| actual[name] == true } }
|
|
86
|
+
else {}
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# 构造包含稳定状态和原因代码的规则检查结果。
|
|
91
|
+
def finding(attributes, status, reason)
|
|
92
|
+
Report::Finding.new(**attributes, status: status, reason: reason)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# 根据平台与设备上下文判断规则是否适用。
|
|
96
|
+
def applicability(rule, configuration, context)
|
|
97
|
+
unknown = false
|
|
98
|
+
rule.applies_to.each do |key, expected|
|
|
99
|
+
values = key == "facts" ? expected : { key => expected }
|
|
100
|
+
values.each do |name, criterion|
|
|
101
|
+
known = key == "facts" ? configuration.scope_known?(name) : context.key?(name)
|
|
102
|
+
value = key == "facts" ? configuration.value(name) : context[name]
|
|
103
|
+
if !known
|
|
104
|
+
unknown = true
|
|
105
|
+
elsif !(criterion.is_a?(Array) ? criterion.include?(value) : criterion == value)
|
|
106
|
+
return false
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
unknown ? nil : true
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# 按声明的比较运算符处理归一化策略值。
|
|
114
|
+
def compare(operator, actual, expected)
|
|
115
|
+
case operator
|
|
116
|
+
when :password_strength then return password_strength(actual, expected)
|
|
117
|
+
when :all_disabled
|
|
118
|
+
return [ :unknown, :fact_type_mismatch ] unless actual.is_a?(Hash)
|
|
119
|
+
return [ :fail, :unnecessary_service_enabled ] if expected.any? { |name| actual[name] == true }
|
|
120
|
+
return [ :unknown, :service_state_unknown ] unless expected.all? { |name| actual[name] == false }
|
|
121
|
+
passed = true
|
|
122
|
+
when :equals
|
|
123
|
+
return [ :unknown, :fact_type_mismatch ] unless actual.class == expected.class || [ true, false ].include?(actual) && [ true, false ].include?(expected)
|
|
124
|
+
passed = actual == expected
|
|
125
|
+
else
|
|
126
|
+
return [ :unknown, :fact_type_mismatch ] unless actual.is_a?(Array) && actual.all? { |item| item.is_a?(String) }
|
|
127
|
+
passed = case operator
|
|
128
|
+
when :subset then (actual - expected).empty?
|
|
129
|
+
when :set_equals then actual.sort == expected.sort
|
|
130
|
+
when :count_at_least then actual.size >= expected
|
|
131
|
+
when :networks_within then Acl.within?(actual, expected)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
[ passed ? :pass : :fail, passed ? nil : :deviation ]
|
|
135
|
+
rescue ArgumentError, IPAddr::InvalidAddressError
|
|
136
|
+
[ :unknown, :unsupported_address_family ]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# 检查可度量的凭据属性,不保存秘密值。
|
|
140
|
+
def password_strength(actual, expected)
|
|
141
|
+
return [ :unknown, :fact_type_mismatch ] unless actual.is_a?(Array) && actual.all? { |item| item.is_a?(Hash) }
|
|
142
|
+
visible = actual.select { |item| item[:storage] == "plaintext" }
|
|
143
|
+
weak = visible.any? do |item|
|
|
144
|
+
item[:common] || item.fetch(:length) < expected.fetch("min_length") || item.fetch(:character_classes) < expected.fetch("min_classes")
|
|
145
|
+
end
|
|
146
|
+
return [ :fail, :weak_password ] if weak
|
|
147
|
+
return [ :unknown, :password_strength_unobservable ] if actual.empty? || visible.size != actual.size
|
|
148
|
+
[ :pass, nil ]
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|