baseline-rb 0.1.0 → 0.2.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.
data/lib/baseline/rule.rb CHANGED
@@ -1,5 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "acl"
4
+
5
+ require "ctx_parser/immutable"
6
+
3
7
  module Baseline
4
8
  # 规则声明事实、比较方式和批准值类型;厂商语法仅由解析器负责。
5
9
  class Rule
@@ -7,15 +11,15 @@ module Baseline
7
11
  Definition = Data.define(:title, :fact, :operator, :value_type, :allowed_values) do
8
12
  # 冻结规则目录条目的事实、比较方式及批准值类型。
9
13
  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))
14
+ super(title: CtxParser::Immutable.copy(title), fact: CtxParser::Immutable.copy(fact), operator: CtxParser::Immutable.copy(operator),
15
+ value_type: CtxParser::Immutable.copy(value_type), allowed_values: CtxParser::Immutable.copy(allowed_values))
12
16
  end
13
17
  end
14
18
 
15
19
  DEFINITIONS = {
16
20
  "snmp.enabled" => Definition.new(title: "SNMP 配置", fact: "snmp.enabled", operator: :equals, value_type: :boolean),
17
21
  "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),
22
+ "snmp.write_enabled" => Definition.new(title: "SNMP 写权限", fact: "snmp.write_enabled", operator: :equals, value_type: :boolean),
19
23
  "snmp.auth_priv" => Definition.new(title: "SNMPv3 认证与加密", fact: "snmp.v3_auth_priv", operator: :equals, value_type: :boolean),
20
24
  "snmp.acl_bound" => Definition.new(title: "SNMP 来源 ACL 引用", fact: "snmp.acl.bound", operator: :equals, value_type: :boolean),
21
25
  "snmp.acl_restricted" => Definition.new(title: "SNMP 来源 ACL 限制", fact: "snmp.acl.restricted", operator: :equals, value_type: :boolean),
@@ -35,7 +39,7 @@ module Baseline
35
39
  "management.acl_restricted" => Definition.new(title: "管理来源 ACL 限制", fact: "management.acl.restricted", operator: :equals, value_type: :boolean),
36
40
  "management.sources" => Definition.new(title: "管理批准来源", fact: "management.acl.sources", operator: :networks_within, value_type: :network_set),
37
41
  "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),
42
+ "credentials.storage" => Definition.new(title: "凭据安全存储", fact: "credentials.storage_safe", operator: :equals, value_type: :boolean),
39
43
  "credentials.strength" => Definition.new(title: "可观察口令强度", fact: "credentials.records", operator: :password_strength, value_type: :password_strength),
40
44
  "tacacs.minimum_servers" => Definition.new(title: "TACACS 服务器数量", fact: "tacacs.servers", operator: :count_at_least, value_type: :positive_integer),
41
45
  "tacacs.servers" => Definition.new(title: "TACACS 批准服务器", fact: "tacacs.servers", operator: :set_equals, value_type: :string_set),
@@ -60,12 +64,12 @@ module Baseline
60
64
  %w[low medium high critical].include?(options.fetch("severity", "medium"))
61
65
  raise ArgumentError, "Invalid baseline rule options"
62
66
  end
63
- @id = Baseline.immutable(id)
67
+ @id = CtxParser::Immutable.copy(id)
64
68
  @title, @fact, @operator = definition.title, definition.fact, definition.operator
65
69
  @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", {}))
70
+ @expected = CtxParser::Immutable.copy(options["expected"])
71
+ @enabled, @severity = options.fetch("enabled", true), CtxParser::Immutable.copy(options.fetch("severity", "medium"))
72
+ @applies_to = CtxParser::Immutable.copy(options.fetch("applies_to", {}))
69
73
  validate_applicability!
70
74
  freeze
71
75
  end
@@ -86,7 +90,7 @@ module Baseline
86
90
  when :string_set, :network_set then string_set?(value)
87
91
  end
88
92
  raise ArgumentError, "Invalid baseline expected value for #{id}" unless valid
89
- value.each { |network| Acl.range(network) } if value_type == :network_set
93
+ value.each { |network| Acl.address_range(network) } if value_type == :network_set
90
94
  end
91
95
 
92
96
  # 展示对象身份与状态,避免泄露原始回显或凭据。
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Baseline
4
4
  # 公开包版本,供 RubyGems 元数据和运行时调用方共用。
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.0"
6
6
  end
data/lib/baseline.rb CHANGED
@@ -1,34 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
 
3
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
4
  require_relative "baseline/audit"
data/policy.yml CHANGED
@@ -5,7 +5,7 @@ version: 1
5
5
  checks:
6
6
  snmp.enabled: {expected: true}
7
7
  snmp.versions: {expected: [v3], severity: high}
8
- snmp.read_only:
8
+ snmp.write_enabled:
9
9
  expected: false
10
10
  severity: high
11
11
  applies_to: {facts: {snmp.enabled: true}}
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: baseline-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gatework contributors
@@ -13,16 +13,16 @@ dependencies:
13
13
  name: ctx_parser
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
- - - ">="
16
+ - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '0'
18
+ version: 0.2.0
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
- - - ">="
23
+ - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: '0'
25
+ version: 0.2.0
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: digest
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -109,22 +109,22 @@ files:
109
109
  - lib/baseline.rb
110
110
  - lib/baseline/acl.rb
111
111
  - lib/baseline/audit.rb
112
+ - lib/baseline/cisco/parser.rb
113
+ - lib/baseline/cisco_ios/parser.rb
114
+ - lib/baseline/cisco_nxos/parser.rb
115
+ - lib/baseline/comware/parser.rb
112
116
  - lib/baseline/configuration.rb
113
117
  - lib/baseline/credential.rb
114
118
  - lib/baseline/document.rb
115
119
  - lib/baseline/evaluator.rb
116
120
  - lib/baseline/facts.rb
117
- - lib/baseline/hillstone_document.rb
118
- - lib/baseline/palo_alto_document.rb
119
- - lib/baseline/parsers/base.rb
120
- - lib/baseline/parsers/cisco.rb
121
- - lib/baseline/parsers/cisco_ios.rb
122
- - lib/baseline/parsers/cisco_nxos.rb
123
- - lib/baseline/parsers/comware.rb
124
- - lib/baseline/parsers/h3c.rb
125
- - lib/baseline/parsers/hillstone.rb
126
- - lib/baseline/parsers/huawei.rb
127
- - lib/baseline/parsers/palo_alto.rb
121
+ - lib/baseline/h3c/parser.rb
122
+ - lib/baseline/hillstone/document.rb
123
+ - lib/baseline/hillstone/parser.rb
124
+ - lib/baseline/huawei/parser.rb
125
+ - lib/baseline/palo_alto/document.rb
126
+ - lib/baseline/palo_alto/parser.rb
127
+ - lib/baseline/parser.rb
128
128
  - lib/baseline/policy.rb
129
129
  - lib/baseline/quoted_document.rb
130
130
  - lib/baseline/report.rb
@@ -1,25 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "quoted_document"
4
-
5
- module Baseline
6
- # StoneOS 导出内容可能包含命令回显、显式退出及引号包裹的界面状态。
7
- class HillstoneDocument < QuotedDocument
8
- protected
9
-
10
- # 区分配置结构行与引号、横幅中的文本内容。
11
- def classify(line)
12
- unless @quote
13
- return :ignored if line.text.match?(/\A(?:show (?:configuration|config)|Running configuration|Version\b|[\w.():-]+#\s*\z)/)
14
- return :ignored if line.text == "exit"
15
- return :boundary if line.text == "End"
16
- end
17
- super
18
- end
19
-
20
- # 识别配置值可能跨行延续的语句。
21
- def multiline_fact?(text)
22
- text.match?(/\A(?:hostname|admin host|snmp-server|ntp|logging syslog|password|secret|host|backup[12])\b/)
23
- end
24
- end
25
- end
@@ -1,15 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "quoted_document"
4
-
5
- module Baseline
6
- # PAN-OS 的 set 导出可能包含引号包裹的多行证书与描述。
7
- class PaloAltoDocument < QuotedDocument
8
- protected
9
-
10
- # 已建模的多行值不能被误判为配置缺失;证书正文不参与语义分析。
11
- def multiline_fact?(text)
12
- /\Aset (?:deviceconfig system (?:hostname|service|permitted-ip|ntp-servers|snmp-setting|authentication-profile|route service)\b|shared (?:server-profile tacplus|authentication-profile|authentication-sequence|log-settings syslog)\b|mgt-config users\b|network profiles interface-management-profile\b)/.match?(text)
13
- end
14
- end
15
- end
@@ -1,256 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "ctx_parser/configuration_parser"
4
- require "ctx_parser/input"
5
- require_relative "../document"
6
-
7
- module Baseline
8
- module Parsers
9
- # 共享输入与提取边界;CtxParser 跟踪来源,Facts 归纳领域证据。
10
- class Base < CtxParser::ConfigurationParser
11
- SERVICE_PORTS = { "ssh" => 22, "telnet" => 23, "snmp" => 161, "snmptrap" => 162 }.freeze
12
- SNMP_OPTIONS = %w[auth priv authentication-mode privacy-mode acl access use-ipv4acl group local engineid engineID
13
- encrypted localizedkey localizedV2key remote udp-port].freeze
14
-
15
- private
16
-
17
- # 解释当前命令回显,保留不支持或格式错误的证据。
18
- def parse_output(text, complete:)
19
- begin
20
- text = CtxParser::Input.text(text, encoding: :utf8, max_bytes: 16 * 1024 * 1024)
21
- rescue ArgumentError, EncodingError
22
- return CtxParser::Result.new(status: :failed, issues: [ { code: :invalid_configuration } ])
23
- end
24
- read_configuration(text, document_class: configuration_document_class)
25
- issues = document.issues.dup
26
- if (!complete || issues.any?) && exclude_incomplete_final_line
27
- issues << { code: :incomplete_final_line, line: cursor.lines.last.number }
28
- end
29
- @source_issues = issues
30
- @facts = Facts.new(complete: complete, issues: issues)
31
- if roots.empty?
32
- return CtxParser::Result.new(status: :failed, issues: issues + [ { code: :empty_configuration } ])
33
- end
34
- if (hostname = matching(hostname_pattern).last)
35
- name = analyze(hostname) { hostname.text[hostname_pattern, 1] }
36
- record("hostname", name)
37
- capture(hostname, name, [ "facts", "hostname" ])
38
- end
39
- access_lists
40
- management
41
- services
42
- authentication
43
- @facts.result
44
- end
45
-
46
- # 返回本次解析的根配置块。
47
- def roots = @blocks
48
- # 选择适合当前平台语法的文档词法解析器。
49
- def configuration_document_class = Document
50
- # 匹配当前平台显式声明的主机名。
51
- def hostname_pattern = /\A(?:hostname|sysname)\s+(\S+)\z/
52
- # 选择符合目标命令语法的根配置块。
53
- def sections(pattern) = roots.select { |entry| pattern.match?(entry.text) }
54
-
55
- # 保留原始文本,但不将可能截断的词元纳入事实构建树。
56
- def exclude_incomplete_final_line
57
- line = cursor.lines.last
58
- return false unless line && !line.raw.end_with?("\n", "\r") && cursor.state(line.index) != :ignored
59
-
60
- entries, ancestors = roots, []
61
- while (entry = entries.last)
62
- if entry.index == line.index
63
- retained = entries[0...-1].freeze
64
- ancestors.reverse_each do |siblings, parent|
65
- block = CtxParser::Block.new(cursor: cursor, header: parent.header, children: retained, last_index: parent.last_index)
66
- retained = (siblings[0...-1] + [ block ]).freeze
67
- end
68
- @blocks = retained
69
- cursor.mark(line.index, state: :unsupported)
70
- return true
71
- end
72
- ancestors << [ entries, entry ]
73
- entries = entry.children
74
- end
75
- false
76
- end
77
-
78
- # 筛选匹配已支持命令模式的来源条目。
79
- def matching(pattern, entries = roots) = entries.select { |entry| pattern.match?(entry.text) }
80
- # 提取匹配值,并关联对应的来源条目。
81
- def values(pattern, entries = roots)
82
- entries.filter_map do |entry|
83
- value = entry.text[pattern, 1]
84
- analyze(entry) { value } unless value.nil?
85
- end.uniq
86
- end
87
-
88
- # 按配置顺序选取最后一条匹配声明。
89
- def last(pattern, entries = roots)
90
- entry = entries.reverse.find { |candidate| !candidate.text[pattern, 1].nil? }
91
- return unless entry
92
-
93
- analyze(entry) { entry.text[pattern, 1] }
94
- end
95
-
96
- # 保存归一化事实及其来源证据。
97
- def record(path, value, entries = [])
98
- return unless @facts.record(path, value, lines: entries.map(&:line))
99
-
100
- # 来源块中尚未解释的命令不能标记为解析成功。
101
- cursor.capture_many(entries.select { |entry| cursor.parsed?(entry.index) }, data: value, path: [ "facts", path ])
102
- end
103
-
104
- # 记录事实不可用的原因,并保留对应的来源行。
105
- def unknown(path, entries = [], code: :unsupported_syntax)
106
- @facts.unknown(path, lines: entries.map(&:line), code: code)
107
- entries.each { |entry| cursor.mark(entry.index, state: :unsupported) }
108
- end
109
-
110
- # 未解析的访问声明不能抹除其他已确认的写权限。
111
- def unknown_snmp_access(entries, code: :unsupported_syntax)
112
- @facts.unknown_snmp_permission(lines: entries.map(&:line), code: code)
113
- %w[snmp.communities snmp.community_present snmp.users snmp.versions snmp.v3_auth_priv snmp.acl].each do |path|
114
- unknown(path, entries, code: code)
115
- end
116
- end
117
-
118
- # 按来源顺序确定最终生效的显式启用或禁用声明。
119
- def state(on, off, entries = roots)
120
- entries.reduce(nil) do |value, entry|
121
- if off.match?(entry.text)
122
- analyze(entry) { false }
123
- elsif on.match?(entry.text)
124
- analyze(entry) { true }
125
- else
126
- value
127
- end
128
- end
129
- end
130
-
131
- # 记录凭据属性,不保留凭据秘密值。
132
- def credential(owner, kind, format, secret, entry)
133
- value = analyze(entry) { Credential.new(owner: owner, kind: kind, format: format, secret: secret, line: entry.line) }
134
- index = @facts.credential(value)
135
- capture(entry, value.to_h, [ "facts", "credentials.records", index ])
136
- end
137
-
138
- # 注册有序 ACL 规则与别名,同时保留不支持的语法。
139
- def register_acl(name, entries, aliases: [], supported: true, source: nil, &block)
140
- commands = entries.reject { |entry| /\A(?:\d+\s+)?(?:description|remark|step|statistics|hardware-count)\b/.match?(entry.text) }
141
- parsed = commands.map.with_index do |entry, index|
142
- value = analyze(entry) { block.call(entry, index) }
143
- cursor.mark(entry.index, state: :unsupported) unless value
144
- value
145
- end
146
- damaged = @source_issues.any? do |issue|
147
- line = issue[:line]
148
- next false unless line
149
-
150
- source ? source.line_range.cover?(line) : damaged_numbered_acl?(cursor.lines.fetch(line - 1).text, name)
151
- end
152
- acl = Acl.new(name: name, line: entries.first&.line, rules: parsed.compact,
153
- supported: supported && !damaged && parsed.none?(&:nil?))
154
- if @facts.register_acl(acl, aliases: aliases, lines: entries.map(&:line)).any?
155
- entries.each { |entry| cursor.mark(entry.index, state: :unsupported) }
156
- end
157
- end
158
-
159
- # 名称被分页截断时,所有匹配其前缀的编号 ACL 都不能声明完整。
160
- def damaged_numbered_acl?(text, name)
161
- prefix = text.split(/--\s*More\s*--|\e\[/i, 2).first
162
- match = /\Aaccess-list(?:\s+(\S*))?(.*)\z/.match(prefix)
163
- return false unless match
164
-
165
- identity, suffix = match.captures
166
- identity.to_s.empty? || (suffix.empty? ? name.start_with?(identity) : name == identity)
167
- end
168
-
169
- # 归一化 ACL 来源地址;不推测不支持语法的含义。
170
- def source(parts)
171
- token = parts.shift
172
- return "0.0.0.0/0" if token == "any"
173
- token = parts.shift if token == "host"
174
- wildcard = parts.shift if parts.first&.match?(/\A(?:\d+(?:\.\d+){3}|0)\z/)
175
- Acl.source(token, wildcard)
176
- end
177
-
178
- # 显式端口名称仅属于配置语法,不代表组织批准值。
179
- def service_port(token)
180
- return SERVICE_PORTS[token] unless token&.match?(/\A\d+\z/)
181
- value = token.to_i
182
- value if value.between?(0, 65_535)
183
- end
184
-
185
- # 服务器目标只接受 IP 地址或 DNS 名称,不接受网段或空词元。
186
- def server_address?(value)
187
- return false unless value.is_a?(String) && !value.empty?
188
- IPAddr.new(value)
189
- !value.include?("/")
190
- rescue IPAddr::Error
191
- value.bytesize <= 253 && !/\A[\d.]+\z/.match?(value) && value.delete_suffix(".").split(".", -1).all? do |label|
192
- /\A[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\z/i.match?(label)
193
- end
194
- end
195
-
196
- # 配置的监听端口仅替代当前服务的默认端口。
197
- def listener_port(command, default:, scope:, negate: "undo")
198
- prefix = Regexp.escape(command)
199
- entries = matching(/\A(?:#{negate} )?#{prefix}(?:\s|\z)/)
200
- return default if entries.empty?
201
- entry = entries.last
202
- return analyze(entry) { default } if entry.text == "#{negate} #{command}"
203
- token = entry.text[/\A#{prefix}\s+(\d+)\z/, 1]
204
- port = service_port(token)
205
- return analyze(entry) { port } if port && port.positive?
206
- unknown_listener_port(scope, entries)
207
- nil
208
- end
209
-
210
- # 监听端口无法解析时,将依赖该端口的事实保留为未知。
211
- def unknown_listener_port(scope, entries)
212
- %w[sources restricted].each { |field| unknown("#{scope}.#{field}", entries, code: :unsupported_service_port) }
213
- end
214
-
215
- # 区分模式未声明、格式无效及包含完整算法与秘密值的声明。
216
- def snmp_secret(settings, keyword, algorithms:, optional_algorithm: false, aes_size: false)
217
- tokens = settings.split
218
- index = tokens.index(keyword)
219
- return false unless index
220
- return if tokens.count(keyword) > 1
221
- parameters = tokens.drop(index + 1).take_while { |token| !SNMP_OPTIONS.include?(token) }
222
- if algorithms.include?(parameters.first)
223
- algorithm = parameters.shift
224
- elsif !optional_algorithm || parameters.size != 1 || %w[aes des md5 sha].include?(parameters.first)
225
- return
226
- end
227
- return if algorithm == "aes" && aes_size && !%w[128 192 256].include?(parameters.shift)
228
- format = parameters.shift if %w[cipher simple].include?(parameters.first)
229
- return unless parameters.size == 1
230
- [ format, parameters.first ]
231
- end
232
-
233
- # 提取 SNMP 凭据属性,不持久化秘密值。
234
- def snmp_credentials(owner, modes, entry, format: nil)
235
- modes.each_value do |value|
236
- next if value == false
237
- stored_format, secret = value || [ "unknown", nil ]
238
- credential(owner, "snmp_user", stored_format || format || "unknown", secret, entry)
239
- end
240
- unknown("snmp.v3_auth_priv", [ entry ], code: :invalid_snmp_security) if modes.value?(nil)
241
- modes.transform_values { |value| value ? true : value }
242
- end
243
-
244
- # 同名组可出现在不同安全级别;后续只读声明不能消除已有写权限。
245
- def snmp_groups(entries)
246
- entries.map { |entry| yield(entry) }.group_by(&:first).transform_values do |definitions|
247
- records = definitions.map(&:last)
248
- acls = records.map { |record| record[:acl] }.uniq
249
- { acl: acls.one? ? acls.first : nil, acl_ambiguous: acls.size > 1,
250
- write: Facts.write_permission(records.map { |record| record[:write] }),
251
- line: records.first[:line], lines: records.map { |record| record[:line] } }
252
- end
253
- end
254
- end
255
- end
256
- end