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