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,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Baseline
|
|
8
|
+
# 按主机、型号、站点、角色或厂商选择批准值;缺少批准值时不回退到当前配置。
|
|
9
|
+
class Policy
|
|
10
|
+
SCOPES = Rule::CONTEXT_FIELDS
|
|
11
|
+
SELECTORS = Baseline.immutable(SCOPES.map { |scope| "by_#{scope}" } + [ "default" ])
|
|
12
|
+
Resolution = Data.define(:value, :reason, :path) do
|
|
13
|
+
# 冻结解析后的批准值、原因及策略选择路径。
|
|
14
|
+
def initialize(value:, reason:, path:)
|
|
15
|
+
super(value: Baseline.immutable(value), reason: Baseline.immutable(reason), path: Baseline.immutable(path))
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
attr_reader :rules, :sha256
|
|
19
|
+
|
|
20
|
+
# 加载 gem 内附带的默认策略。
|
|
21
|
+
def self.default = load(File.expand_path("../../policy.yml", __dir__))
|
|
22
|
+
|
|
23
|
+
# 读取并校验策略文档,禁止实例化任意 YAML 对象。
|
|
24
|
+
def self.load(path)
|
|
25
|
+
text = File.binread(path)
|
|
26
|
+
raise ArgumentError, "Baseline policy is too large" if text.bytesize > 1_048_576
|
|
27
|
+
stream = Psych.parse_stream(text)
|
|
28
|
+
raise ArgumentError, "One baseline YAML document is required" unless stream.children.one?
|
|
29
|
+
validate_yaml!(stream)
|
|
30
|
+
new(YAML.safe_load(text, permitted_classes: [], permitted_symbols: [], aliases: false))
|
|
31
|
+
rescue Psych::Exception
|
|
32
|
+
raise ArgumentError, "Invalid baseline YAML", cause: nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# 反序列化前拒绝不安全或嵌套过深的 YAML 结构。
|
|
36
|
+
def self.validate_yaml!(node, depth = 0)
|
|
37
|
+
raise ArgumentError, "Baseline YAML nesting is too deep" if depth > 32
|
|
38
|
+
if node.is_a?(Psych::Nodes::Alias)
|
|
39
|
+
raise ArgumentError, "Baseline YAML aliases are not allowed"
|
|
40
|
+
elsif node.is_a?(Psych::Nodes::Mapping)
|
|
41
|
+
keys = node.children.each_slice(2).map(&:first)
|
|
42
|
+
unless keys.all? { |key| key.is_a?(Psych::Nodes::Scalar) } && keys.map(&:value).uniq.size == keys.size
|
|
43
|
+
raise ArgumentError, "Duplicate or complex baseline YAML keys"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
Array(node.children).each { |child| validate_yaml!(child, depth + 1) }
|
|
47
|
+
end
|
|
48
|
+
private_class_method :validate_yaml!
|
|
49
|
+
|
|
50
|
+
# 在设备评估前校验并冻结策略定义。
|
|
51
|
+
def initialize(definition)
|
|
52
|
+
unless definition.is_a?(Hash) && (definition.keys - %w[version checks]).empty? &&
|
|
53
|
+
definition["version"].is_a?(Integer) && definition["version"] == 1 &&
|
|
54
|
+
definition["checks"].is_a?(Hash) && !definition["checks"].empty?
|
|
55
|
+
raise ArgumentError, "Invalid baseline policy"
|
|
56
|
+
end
|
|
57
|
+
@rules = definition["checks"].map { |id, options| Rule.new(id, options) }.freeze
|
|
58
|
+
rules.each { |rule| validate_selection!(rule, rule.expected) }
|
|
59
|
+
@sha256 = Digest::SHA256.hexdigest(JSON.generate(definition)).freeze
|
|
60
|
+
freeze
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# 解析上下文对应的策略值,并保留选择路径。
|
|
64
|
+
def resolve(rule, context)
|
|
65
|
+
resolution = resolve_with_source(rule, context)
|
|
66
|
+
[ resolution.value, resolution.reason ]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# 保留相对于 expected 的命中范围、默认分支或未解析选择器路径。
|
|
70
|
+
def resolve_with_source(rule, context)
|
|
71
|
+
value = rule.expected
|
|
72
|
+
path = []
|
|
73
|
+
while selector?(value)
|
|
74
|
+
selected = false
|
|
75
|
+
SCOPES.each do |scope|
|
|
76
|
+
selector = "by_#{scope}"
|
|
77
|
+
mapping = value[selector]
|
|
78
|
+
next unless mapping
|
|
79
|
+
return Resolution.new(value: nil, reason: :scope_unknown, path: path + [ selector ]) unless context.key?(scope)
|
|
80
|
+
next unless mapping.key?(context[scope])
|
|
81
|
+
path.concat([ selector, context[scope] ])
|
|
82
|
+
value, selected = mapping[context[scope]], true
|
|
83
|
+
break
|
|
84
|
+
end
|
|
85
|
+
unless selected
|
|
86
|
+
path << "default" if value.key?("default")
|
|
87
|
+
value = value["default"]
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
Resolution.new(value: value, reason: value.nil? ? :policy_missing : nil, path: path)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# 展示对象身份与状态,避免泄露原始回显或凭据。
|
|
94
|
+
def inspect = "#<#{self.class} rules=#{rules.size}>"
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
# 识别依赖上下文的策略选择器,避免当作普通值处理。
|
|
99
|
+
def selector?(value) = value.is_a?(Hash) && (value.keys & SELECTORS).any?
|
|
100
|
+
|
|
101
|
+
# 校验嵌套策略选择及其最终规则值。
|
|
102
|
+
def validate_selection!(rule, value, depth = 0)
|
|
103
|
+
raise ArgumentError, "Baseline scope nesting is too deep" if depth > 8
|
|
104
|
+
return if value.nil?
|
|
105
|
+
unless selector?(value)
|
|
106
|
+
rule.validate_expected!(value)
|
|
107
|
+
return
|
|
108
|
+
end
|
|
109
|
+
raise ArgumentError, "Invalid baseline selector" unless (value.keys - SELECTORS).empty?
|
|
110
|
+
value.each do |key, selection|
|
|
111
|
+
if key == "default"
|
|
112
|
+
validate_selection!(rule, selection, depth + 1)
|
|
113
|
+
else
|
|
114
|
+
unless selection.is_a?(Hash) && !selection.empty? && selection.keys.all? { |name| name.is_a?(String) && !name.strip.empty? }
|
|
115
|
+
raise ArgumentError, "Invalid baseline scope"
|
|
116
|
+
end
|
|
117
|
+
selection.each_value { |item| validate_selection!(rule, item, depth + 1) }
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "document"
|
|
4
|
+
|
|
5
|
+
module Baseline
|
|
6
|
+
# 多行引号内容始终作为数据处理,即使正文看起来像全局命令。
|
|
7
|
+
class QuotedDocument < Document
|
|
8
|
+
protected
|
|
9
|
+
|
|
10
|
+
# 区分配置结构行与引号、横幅中的文本内容。
|
|
11
|
+
def classify(line)
|
|
12
|
+
was_quoted = !@quote.nil?
|
|
13
|
+
# 注释和横幅具有独立语法,其中的单引号不能开启配置值。
|
|
14
|
+
unless was_quoted
|
|
15
|
+
kind = super
|
|
16
|
+
return kind unless kind == :entry && line.text.match?(/["']/)
|
|
17
|
+
end
|
|
18
|
+
escaped = false
|
|
19
|
+
line.text.each_char do |character|
|
|
20
|
+
if escaped
|
|
21
|
+
escaped = false
|
|
22
|
+
elsif character == "\\"
|
|
23
|
+
escaped = true
|
|
24
|
+
elsif @quote
|
|
25
|
+
@quote = nil if character == @quote
|
|
26
|
+
elsif character == '"' || character == "'"
|
|
27
|
+
@quote = character
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
if !was_quoted && @quote && multiline_fact?(line.text)
|
|
31
|
+
issues << { code: :multiline_fact_value, line: line.number }
|
|
32
|
+
end
|
|
33
|
+
return :ignored if was_quoted || @quote
|
|
34
|
+
|
|
35
|
+
kind
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# 由领域文档识别不能安全跨行的已建模语句。
|
|
39
|
+
def multiline_fact?(_text) = false
|
|
40
|
+
|
|
41
|
+
# 保留父级词法诊断,并报告未闭合的引号值。
|
|
42
|
+
def finish
|
|
43
|
+
super
|
|
44
|
+
issues << { code: :incomplete_quoted_value } if @quote
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Baseline
|
|
4
|
+
# 分别报告评估结果与输入覆盖率;未知项不计分,也不视为合规。
|
|
5
|
+
class Report
|
|
6
|
+
REASONS = {
|
|
7
|
+
input_incomplete: "配置不完整,需提供完整备份后重查", unsupported_platform: "尚未支持此平台的配置语法",
|
|
8
|
+
scope_mismatch: "不在策略适用范围内", scope_unknown: "缺少判断适用范围或选择批准值所需的上下文",
|
|
9
|
+
policy_missing: "未定义当前范围的批准值,请完善基线策略", fact_unavailable: "配置事实缺失或包含尚未支持的语法,请查看解析诊断",
|
|
10
|
+
fact_type_mismatch: "解析事实类型与检查要求不一致", unnecessary_service_enabled: "存在策略要求关闭的服务",
|
|
11
|
+
service_state_unknown: "配置未明确声明部分服务状态,不能推断厂商默认值", deviation: "实际配置偏离批准值",
|
|
12
|
+
unsupported_address_family: "地址格式超出当前 IPv4 检查能力", weak_password: "发现未满足强度要求的明文凭据",
|
|
13
|
+
password_strength_unobservable: "加密或散列凭据无法从备份验证原始口令强度"
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
Finding = Data.define(:id, :title, :severity, :status, :reason, :actual, :expected, :lines, :policy_path, :diagnostics, :details) do
|
|
17
|
+
# 冻结单条检查结果的策略路径、诊断和支持细节。
|
|
18
|
+
def initialize(policy_path: [], diagnostics: [], details: {}, **attributes)
|
|
19
|
+
super(**Baseline.immutable(attributes.merge(policy_path: policy_path, diagnostics: diagnostics, details: details)))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# 将稳定的结果原因代码转换为易读说明。
|
|
23
|
+
def explanation = reason ? Report::REASONS.fetch(reason, reason.to_s) : "满足基线要求"
|
|
24
|
+
# 返回公开的数据结构,并保留状态与诊断信息。
|
|
25
|
+
def to_h = super.merge(explanation: explanation)
|
|
26
|
+
|
|
27
|
+
# 生成紧凑的评估与策略路径元数据,不内嵌大量事实或诊断数组。
|
|
28
|
+
def summary
|
|
29
|
+
{ id: id, title: title, severity: severity, status: status, reason: reason, policy_path: policy_path }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
attr_reader :configuration, :policy_sha256, :findings, :context, :counts, :applicable
|
|
33
|
+
|
|
34
|
+
# 冻结报告证据与检查结果,便于复现评估。
|
|
35
|
+
def initialize(configuration:, policy_sha256:, findings:, context:)
|
|
36
|
+
@configuration, @policy_sha256 = configuration, policy_sha256
|
|
37
|
+
@findings = findings.dup.freeze
|
|
38
|
+
@context = Baseline.immutable(context)
|
|
39
|
+
@counts = @findings.map(&:status).tally.freeze
|
|
40
|
+
@applicable = @findings.reject { |finding| finding.status == :not_applicable }.freeze
|
|
41
|
+
freeze
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# 判断所有已选事实是否都有完整证据支持。
|
|
45
|
+
def complete? = configuration.complete? && applicable.any? && counts.fetch(:pass, 0) + counts.fetch(:fail, 0) == applicable.size
|
|
46
|
+
# 仅在证据完整且检查通过时声明合规。
|
|
47
|
+
def compliant? = complete? && counts.fetch(:pass, 0) == applicable.size
|
|
48
|
+
|
|
49
|
+
# 推导汇总状态,避免将证据缺失当作成功。
|
|
50
|
+
def status
|
|
51
|
+
return :fail if counts.fetch(:fail, 0).positive?
|
|
52
|
+
return :pass if compliant?
|
|
53
|
+
:unknown
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# 独立衡量已评估证据的覆盖情况,不与设备是否通过混淆。
|
|
57
|
+
def coverage
|
|
58
|
+
return 0.0 if applicable.empty?
|
|
59
|
+
(100.0 * (counts.fetch(:pass, 0) + counts.fetch(:fail, 0)) / applicable.size).round(1)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# 计算适用规则中证据完整且检查通过的占比。
|
|
63
|
+
def score
|
|
64
|
+
return 0.0 if applicable.empty?
|
|
65
|
+
(100.0 * counts.fetch(:pass, 0) / applicable.size).round(1)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# 返回公开的数据结构,并保留状态与诊断信息。
|
|
69
|
+
def to_h
|
|
70
|
+
metadata.merge(configuration: configuration.to_h, findings: findings.map(&:to_h))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# 限制摘要大小;完整事实由 to_h 提供,避免 ACL 或账号数量膨胀元数据。
|
|
74
|
+
def summary
|
|
75
|
+
metadata.merge(configuration: { platform: configuration.platform, sha256: configuration.sha256, status: configuration.status,
|
|
76
|
+
issue_counts: configuration.issues.map { |issue| issue[:code] }.tally },
|
|
77
|
+
findings: findings.map(&:summary))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# 以有长度限制且经过转义的文本展示运维证据;完整结构仍保留在 JSON 中。
|
|
81
|
+
def to_text
|
|
82
|
+
output = [ "baseline: #{status}, coverage=#{coverage}%, score=#{score}, #{counts.map { |name, count| "#{name}=#{count}" }.join(', ')}",
|
|
83
|
+
"配置: #{display(configuration.platform)}, #{configuration.status}; SHA256: #{configuration.sha256}",
|
|
84
|
+
"策略 SHA256: #{policy_sha256}; 上下文: #{display(context)}" ]
|
|
85
|
+
findings.each do |finding|
|
|
86
|
+
output << " [#{finding.status}] #{finding.id} | #{finding.title} | #{finding.severity}"
|
|
87
|
+
output << " #{finding.explanation}#{finding.reason ? " (#{finding.reason})" : ''}"
|
|
88
|
+
output << " 策略分支: #{display(finding.policy_path)}" if finding.policy_path.any?
|
|
89
|
+
if %i[pass fail].include?(finding.status)
|
|
90
|
+
output << " 预期: #{display(finding.expected)}; 实际: #{display(finding.actual)}"
|
|
91
|
+
end
|
|
92
|
+
output << " 差异: #{display(finding.details)}" if finding.details.any?
|
|
93
|
+
if finding.lines.any?
|
|
94
|
+
output << " 行号: #{finding.lines.first(20).join(', ')}#{finding.lines.size > 20 ? "… (共 #{finding.lines.size} 行,详见 JSON 报告)" : ''}"
|
|
95
|
+
end
|
|
96
|
+
finding.diagnostics.first(10).each { |issue| output << " 解析诊断: #{display(issue)}" }
|
|
97
|
+
output << " 另有 #{finding.diagnostics.size - 10} 条诊断,详见 JSON 报告" if finding.diagnostics.size > 10
|
|
98
|
+
end
|
|
99
|
+
output.join("\n")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# 展示对象身份与状态,避免泄露原始回显或凭据。
|
|
103
|
+
def inspect = "#<#{self.class} status=#{status} coverage=#{coverage} findings=#{findings.size}>"
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
# 提供来源与策略身份,便于复现并追溯报告。
|
|
108
|
+
def metadata
|
|
109
|
+
{ status: status, complete: complete?, compliant: compliant?, score: score, coverage: coverage, counts: counts,
|
|
110
|
+
policy_sha256: policy_sha256, context: context }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# 格式化报告值,不改变序列化后的评估契约。
|
|
114
|
+
def display(value)
|
|
115
|
+
text = JSON.generate(value)
|
|
116
|
+
text.length > 500 ? "#{text[0, 500]}… (详见 JSON 报告)" : text
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Baseline
|
|
4
|
+
# 规则声明事实、比较方式和批准值类型;厂商语法仅由解析器负责。
|
|
5
|
+
class Rule
|
|
6
|
+
# 校验与规则目录共用具名字段,避免从名称或元组位置猜测类型。
|
|
7
|
+
Definition = Data.define(:title, :fact, :operator, :value_type, :allowed_values) do
|
|
8
|
+
# 冻结规则目录条目的事实、比较方式及批准值类型。
|
|
9
|
+
def initialize(title:, fact:, operator:, value_type:, allowed_values: nil)
|
|
10
|
+
super(title: Baseline.immutable(title), fact: Baseline.immutable(fact), operator: Baseline.immutable(operator),
|
|
11
|
+
value_type: Baseline.immutable(value_type), allowed_values: Baseline.immutable(allowed_values))
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
DEFINITIONS = {
|
|
16
|
+
"snmp.enabled" => Definition.new(title: "SNMP 配置", fact: "snmp.enabled", operator: :equals, value_type: :boolean),
|
|
17
|
+
"snmp.versions" => Definition.new(title: "SNMP 允许版本", fact: "snmp.versions", operator: :subset, value_type: :enum_set, allowed_values: %w[v1 v2c v3]),
|
|
18
|
+
"snmp.read_only" => Definition.new(title: "SNMP 禁止写权限", fact: "snmp.write_enabled", operator: :equals, value_type: :boolean),
|
|
19
|
+
"snmp.auth_priv" => Definition.new(title: "SNMPv3 认证与加密", fact: "snmp.v3_auth_priv", operator: :equals, value_type: :boolean),
|
|
20
|
+
"snmp.acl_bound" => Definition.new(title: "SNMP 来源 ACL 引用", fact: "snmp.acl.bound", operator: :equals, value_type: :boolean),
|
|
21
|
+
"snmp.acl_restricted" => Definition.new(title: "SNMP 来源 ACL 限制", fact: "snmp.acl.restricted", operator: :equals, value_type: :boolean),
|
|
22
|
+
"snmp.sources" => Definition.new(title: "SNMP 批准来源", fact: "snmp.acl.sources", operator: :networks_within, value_type: :network_set),
|
|
23
|
+
"ntp.minimum_servers" => Definition.new(title: "NTP 服务器数量", fact: "ntp.servers", operator: :count_at_least, value_type: :positive_integer),
|
|
24
|
+
"ntp.servers" => Definition.new(title: "NTP 批准服务器", fact: "ntp.servers", operator: :set_equals, value_type: :string_set),
|
|
25
|
+
"ntp.authentication" => Definition.new(title: "NTP 认证开关", fact: "ntp.authentication", operator: :equals, value_type: :boolean),
|
|
26
|
+
"ntp.source" => Definition.new(title: "NTP 源接口", fact: "ntp.source", operator: :equals, value_type: :string),
|
|
27
|
+
"syslog.minimum_servers" => Definition.new(title: "Syslog 服务器数量", fact: "syslog.servers", operator: :count_at_least, value_type: :positive_integer),
|
|
28
|
+
"syslog.servers" => Definition.new(title: "Syslog 批准服务器", fact: "syslog.servers", operator: :set_equals, value_type: :string_set),
|
|
29
|
+
"syslog.source" => Definition.new(title: "Syslog 源接口", fact: "syslog.source", operator: :equals, value_type: :string),
|
|
30
|
+
"ssh.enabled" => Definition.new(title: "SSH 配置", fact: "ssh.enabled", operator: :equals, value_type: :boolean),
|
|
31
|
+
"ssh.versions" => Definition.new(title: "SSH 允许版本", fact: "ssh.versions", operator: :subset, value_type: :enum_set, allowed_values: %w[1 2]),
|
|
32
|
+
"ssh.vty_ssh_only" => Definition.new(title: "VTY 只允许 SSH", fact: "ssh.vty_ssh_only", operator: :equals, value_type: :boolean),
|
|
33
|
+
"ssh.vty_aaa" => Definition.new(title: "VTY 使用账户认证", fact: "ssh.vty_aaa", operator: :equals, value_type: :boolean),
|
|
34
|
+
"management.acl_bound" => Definition.new(title: "管理来源 ACL 引用", fact: "management.acl.bound", operator: :equals, value_type: :boolean),
|
|
35
|
+
"management.acl_restricted" => Definition.new(title: "管理来源 ACL 限制", fact: "management.acl.restricted", operator: :equals, value_type: :boolean),
|
|
36
|
+
"management.sources" => Definition.new(title: "管理批准来源", fact: "management.acl.sources", operator: :networks_within, value_type: :network_set),
|
|
37
|
+
"services.disabled" => Definition.new(title: "关闭非必要服务", fact: "services", operator: :all_disabled, value_type: :enum_set, allowed_values: %w[telnet http https ftp tftp finger]),
|
|
38
|
+
"credentials.storage" => Definition.new(title: "凭据存储方式", fact: "credentials.storage_safe", operator: :equals, value_type: :boolean),
|
|
39
|
+
"credentials.strength" => Definition.new(title: "可观察口令强度", fact: "credentials.records", operator: :password_strength, value_type: :password_strength),
|
|
40
|
+
"tacacs.minimum_servers" => Definition.new(title: "TACACS 服务器数量", fact: "tacacs.servers", operator: :count_at_least, value_type: :positive_integer),
|
|
41
|
+
"tacacs.servers" => Definition.new(title: "TACACS 批准服务器", fact: "tacacs.servers", operator: :set_equals, value_type: :string_set),
|
|
42
|
+
"tacacs.login" => Definition.new(title: "管理认证引用 TACACS", fact: "tacacs.login", operator: :equals, value_type: :boolean),
|
|
43
|
+
"tacacs.authorization" => Definition.new(title: "管理授权引用 TACACS", fact: "tacacs.authorization", operator: :equals, value_type: :boolean),
|
|
44
|
+
"tacacs.accounting" => Definition.new(title: "管理审计引用 TACACS", fact: "tacacs.accounting", operator: :equals, value_type: :boolean),
|
|
45
|
+
"tacacs.keys" => Definition.new(title: "TACACS 密钥配置", fact: "tacacs.keys_present", operator: :equals, value_type: :boolean)
|
|
46
|
+
}.freeze
|
|
47
|
+
CONTEXT_FIELDS = %w[host model site role vendor].freeze
|
|
48
|
+
# 适用条件采用标量相等或备选值匹配;集合事实不隐含成员比较语义。
|
|
49
|
+
APPLICABILITY_FACTS = DEFINITIONS.values.each_with_object({}) do |definition, facts|
|
|
50
|
+
facts[definition.fact] = definition.value_type if %i[boolean string].include?(definition.value_type)
|
|
51
|
+
end.merge("hostname" => :string, "snmp.community_present" => :boolean, "credentials.present" => :boolean).freeze
|
|
52
|
+
|
|
53
|
+
attr_reader :id, :definition, :title, :fact, :operator, :value_type, :allowed_values, :expected, :severity, :applies_to
|
|
54
|
+
|
|
55
|
+
# 校验规则定义,并冻结比较契约。
|
|
56
|
+
def initialize(id, options)
|
|
57
|
+
@definition = DEFINITIONS.fetch(id) { raise ArgumentError, "Unknown baseline rule" }
|
|
58
|
+
unless options.is_a?(Hash) && (options.keys - %w[enabled expected severity applies_to]).empty? &&
|
|
59
|
+
[ true, false ].include?(options.fetch("enabled", true)) &&
|
|
60
|
+
%w[low medium high critical].include?(options.fetch("severity", "medium"))
|
|
61
|
+
raise ArgumentError, "Invalid baseline rule options"
|
|
62
|
+
end
|
|
63
|
+
@id = Baseline.immutable(id)
|
|
64
|
+
@title, @fact, @operator = definition.title, definition.fact, definition.operator
|
|
65
|
+
@value_type, @allowed_values = definition.value_type, definition.allowed_values
|
|
66
|
+
@expected = Baseline.immutable(options["expected"])
|
|
67
|
+
@enabled, @severity = options.fetch("enabled", true), Baseline.immutable(options.fetch("severity", "medium"))
|
|
68
|
+
@applies_to = Baseline.immutable(options.fetch("applies_to", {}))
|
|
69
|
+
validate_applicability!
|
|
70
|
+
freeze
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# 判断策略规则是否已启用评估。
|
|
74
|
+
def enabled? = @enabled
|
|
75
|
+
|
|
76
|
+
# 将 nil 保留为批准值缺失;具体值则按规则目录声明的领域类型校验。
|
|
77
|
+
def validate_expected!(value)
|
|
78
|
+
valid = case value_type
|
|
79
|
+
when :boolean, :string then scalar?(value, value_type)
|
|
80
|
+
when :positive_integer then value.is_a?(Integer) && value.positive?
|
|
81
|
+
when :password_strength
|
|
82
|
+
value.is_a?(Hash) && value.size == 2 && (value.keys - %w[min_classes min_length]).empty? &&
|
|
83
|
+
value["min_length"].is_a?(Integer) && value["min_length"].between?(1, 1024) &&
|
|
84
|
+
value["min_classes"].is_a?(Integer) && value["min_classes"].between?(1, 4)
|
|
85
|
+
when :enum_set then string_set?(value) && !value.empty? && (value - allowed_values).empty?
|
|
86
|
+
when :string_set, :network_set then string_set?(value)
|
|
87
|
+
end
|
|
88
|
+
raise ArgumentError, "Invalid baseline expected value for #{id}" unless valid
|
|
89
|
+
value.each { |network| Acl.range(network) } if value_type == :network_set
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# 展示对象身份与状态,避免泄露原始回显或凭据。
|
|
93
|
+
def inspect = "#<#{self.class} id=#{id} enabled=#{enabled?}>"
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
# 拒绝拼错的事实名和空条件,避免将应失败的检查静默排除为不适用。
|
|
98
|
+
def validate_applicability!
|
|
99
|
+
unless applies_to.is_a?(Hash) && (applies_to.keys - CONTEXT_FIELDS - [ "facts" ]).empty?
|
|
100
|
+
raise ArgumentError, "Invalid baseline applicability for #{id}"
|
|
101
|
+
end
|
|
102
|
+
applies_to.each do |key, value|
|
|
103
|
+
if key == "facts"
|
|
104
|
+
unless value.is_a?(Hash) && !value.empty?
|
|
105
|
+
raise ArgumentError, "Invalid baseline applicability facts for #{id}"
|
|
106
|
+
end
|
|
107
|
+
value.each do |name, criterion|
|
|
108
|
+
type = APPLICABILITY_FACTS.fetch(name) { raise ArgumentError, "Unknown baseline applicability fact for #{id}" }
|
|
109
|
+
validate_criterion!(criterion, type)
|
|
110
|
+
end
|
|
111
|
+
else
|
|
112
|
+
validate_criterion!(value, :string)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# 按声明的值类型校验策略比较操作数。
|
|
118
|
+
def validate_criterion!(criterion, type)
|
|
119
|
+
values = criterion.is_a?(Array) ? criterion : [ criterion ]
|
|
120
|
+
unless !values.empty? && values.all? { |value| scalar?(value, type) }
|
|
121
|
+
raise ArgumentError, "Invalid baseline applicability value for #{id}"
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# 按声明类型校验标量策略值。
|
|
126
|
+
def scalar?(value, type)
|
|
127
|
+
case type
|
|
128
|
+
when :string then value.is_a?(String) && !value.strip.empty?
|
|
129
|
+
when :boolean then [ true, false ].include?(value)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# 要求策略值为内容有效且不重复的文本数组。
|
|
134
|
+
def string_set?(value)
|
|
135
|
+
value.is_a?(Array) && value.all? { |item| scalar?(item, :string) } && value.uniq == value
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
data/lib/baseline.rb
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "baseline/version"
|
|
4
|
+
|
|
5
|
+
require "ctx_parser/immutable"
|
|
6
|
+
|
|
7
|
+
module Baseline
|
|
8
|
+
# 复制并冻结可序列化的基线事实,避免调用方修改策略或报告状态。
|
|
9
|
+
def self.immutable(value)
|
|
10
|
+
CtxParser::Immutable.copy(value)
|
|
11
|
+
rescue ArgumentError
|
|
12
|
+
raise ArgumentError, "Baseline values must be plain data"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
require_relative "baseline/configuration"
|
|
17
|
+
require_relative "baseline/document"
|
|
18
|
+
require_relative "baseline/acl"
|
|
19
|
+
require_relative "baseline/credential"
|
|
20
|
+
require_relative "baseline/facts"
|
|
21
|
+
require_relative "baseline/parsers/base"
|
|
22
|
+
require_relative "baseline/parsers/comware"
|
|
23
|
+
require_relative "baseline/parsers/h3c"
|
|
24
|
+
require_relative "baseline/parsers/huawei"
|
|
25
|
+
require_relative "baseline/parsers/cisco"
|
|
26
|
+
require_relative "baseline/parsers/cisco_ios"
|
|
27
|
+
require_relative "baseline/parsers/cisco_nxos"
|
|
28
|
+
require_relative "baseline/parsers/hillstone"
|
|
29
|
+
require_relative "baseline/parsers/palo_alto"
|
|
30
|
+
require_relative "baseline/rule"
|
|
31
|
+
require_relative "baseline/policy"
|
|
32
|
+
require_relative "baseline/report"
|
|
33
|
+
require_relative "baseline/evaluator"
|
|
34
|
+
require_relative "baseline/audit"
|
data/policy.yml
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# 参考基线:组织批准的服务器、网段和源接口缺失时保持未知。
|
|
2
|
+
# expected 可使用 by_host > by_model > by_site > by_role > by_vendor > default。
|
|
3
|
+
# 此策略仅评估证据,不生成或执行设备配置。
|
|
4
|
+
version: 1
|
|
5
|
+
checks:
|
|
6
|
+
snmp.enabled: {expected: true}
|
|
7
|
+
snmp.versions: {expected: [v3], severity: high}
|
|
8
|
+
snmp.read_only:
|
|
9
|
+
expected: false
|
|
10
|
+
severity: high
|
|
11
|
+
applies_to: {facts: {snmp.enabled: true}}
|
|
12
|
+
snmp.auth_priv:
|
|
13
|
+
expected: true
|
|
14
|
+
severity: high
|
|
15
|
+
applies_to: {facts: {snmp.enabled: true}}
|
|
16
|
+
snmp.acl_bound: {expected: true, severity: high}
|
|
17
|
+
snmp.acl_restricted: {expected: true, severity: high}
|
|
18
|
+
snmp.sources: {expected: null, severity: high}
|
|
19
|
+
ntp.minimum_servers: {expected: 2}
|
|
20
|
+
ntp.servers: {expected: null}
|
|
21
|
+
ntp.authentication: {expected: true}
|
|
22
|
+
ntp.source: {expected: null}
|
|
23
|
+
syslog.minimum_servers: {expected: 2}
|
|
24
|
+
syslog.servers: {expected: null}
|
|
25
|
+
syslog.source: {expected: null}
|
|
26
|
+
ssh.enabled: {expected: true, severity: high}
|
|
27
|
+
ssh.versions: {expected: ['2'], severity: high}
|
|
28
|
+
ssh.vty_ssh_only: {expected: true, severity: high}
|
|
29
|
+
ssh.vty_aaa: {expected: true, severity: high}
|
|
30
|
+
management.acl_bound: {expected: true, severity: high}
|
|
31
|
+
management.acl_restricted: {expected: true, severity: high}
|
|
32
|
+
management.sources: {expected: null, severity: high}
|
|
33
|
+
services.disabled: {expected: [telnet, http, ftp, tftp], severity: high}
|
|
34
|
+
credentials.storage:
|
|
35
|
+
expected: true
|
|
36
|
+
severity: high
|
|
37
|
+
applies_to: {facts: {credentials.present: true}}
|
|
38
|
+
credentials.strength:
|
|
39
|
+
expected: {min_length: 12, min_classes: 3}
|
|
40
|
+
severity: high
|
|
41
|
+
applies_to: {facts: {credentials.present: true}}
|
|
42
|
+
tacacs.minimum_servers: {expected: 2}
|
|
43
|
+
tacacs.servers: {expected: null}
|
|
44
|
+
tacacs.login: {expected: true, severity: high}
|
|
45
|
+
tacacs.authorization: {expected: true}
|
|
46
|
+
tacacs.accounting: {expected: true}
|
|
47
|
+
tacacs.keys: {expected: true, severity: high}
|