baseline-rb 0.1.0 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 53a9b192c6ad23a797b8079d847ec57b6decd1ea74bea3d0219658977dc52b53
4
- data.tar.gz: b0a2a43555dec3c0567944ceef523efec372425f72fff529005f9e967b731ebf
3
+ metadata.gz: 2386adb307215040a989dccfcba29d403bd31f28d400cbb6ba9f34215c13e7bb
4
+ data.tar.gz: 975bc0b9e0136528a8af240d21fe623068ec334c3f444746ce84dcbc25e097e6
5
5
  SHA512:
6
- metadata.gz: f78aab76915e2594de7bc858f773611746946a5ad3ecfdc4c5a994f9dec86d8953e8ec1eae1c53adbd01925902d484bb2236a20a3ba92ca603f01da8088d1e93
7
- data.tar.gz: d1dabf597eb164f6bf720a65cf7d0086171e98c23d29d94c364445af5513403fc2cf983650123e0737124eebf7687ef889472ff2fc9ea6f6791d70cc912b2aff
6
+ metadata.gz: 81a940f8b6b82df076c2c1f541ee6b5f7e95154095ca3ccffc2b70580d129388d5ec40c6c6f9a926c30f813c31d5cc39f669f2c7f487dda94718954707e5f5f9
7
+ data.tar.gz: cc66c2181c786de9341533ae65caf8214d0e8d28d40628c7b22b9161e3189174144a38cb58f8599dd2b0848cc511b3ebb31a5634e807b957ef074201eee1624e
data/CHANGELOG.md CHANGED
@@ -2,6 +2,30 @@
2
2
 
3
3
  Changes to the public contract are recorded here. Versions follow Semantic Versioning.
4
4
 
5
+ ## 0.2.1 (2026-10-17)
6
+
7
+ - Reject duplicate normalized context keys before policy selection; preserve source lines and cursor captures for scalar NTP and Syslog configuration facts.
8
+
9
+ ## 0.2.0 (2026-10-17)
10
+
11
+ ### Changed
12
+
13
+ - Organize vendor parsers as `Baseline::<Vendor>::Parser`, with Hillstone and Palo Alto `Document` classes beside their parsers. Shared parsing uses `Baseline::Parser`, `Comware::Parser`, and `Cisco::Parser`; platform entry points load independently.
14
+ - Rename extraction helpers and ACL/credential APIs to describe their purpose: `permitted_source_networks`, `source_network`, `address_range`, `network_prefixes`, `networks_within?`, and `record_credential`. Remove redundant immutable-data and range-subtraction wrappers.
15
+ - Replace `snmp.read_only` with `snmp.write_enabled`. Set `expected: false` to prohibit writes. Old class names, methods, and rule keys have no compatibility aliases; update custom callers and policies.
16
+ - Require `ctx_parser ~> 0.2.0` for the shared configuration API.
17
+
18
+ ### Fixed
19
+
20
+ - Keep out-of-policy source findings unknown for incomplete ordered ACL input: an unseen earlier deny can change the permitted sources. Report caller input completeness separately from parser status.
21
+ - Copy ACL names and management bindings without freezing or borrowing caller-owned strings.
22
+ - Preserve NTP source and source-interface command recognition across the shared Cisco and Comware parser families.
23
+
24
+ ### Performance and verification
25
+
26
+ - Compare IPv4 network unions and rejected sources with sorted scans, and index credential source lines to avoid quadratic lookups.
27
+ - Add regression coverage, independent platform loading checks, and synthetic benchmarks. Verify installed default policy and compliance behavior outside the checkout.
28
+
5
29
  ## 0.1.0 (2026-10-16)
6
30
 
7
31
  ### Added
data/README-zh.md CHANGED
@@ -21,7 +21,7 @@ gem "baseline-rb", require: "baseline"
21
21
 
22
22
  然后执行 `bundle install`;也可直接执行 `gem install baseline-rb`。Ruby 加载入口为 `baseline`,公开命名空间为 `Baseline`;gem 包名与 Ruby 命名空间分别维护。
23
23
 
24
- 模块运行依赖:[`ctx_parser`](https://github.com/gatework/ctx_parser)。 gemspec 的运行依赖不添加版本约束;应用应提交自己的 `Gemfile.lock`,保持部署可复现。使用本库不需要启动 Rails 或配置兄弟模块的本地路径。
24
+ 模块运行依赖:[`ctx_parser`](https://github.com/gatework/ctx_parser)。 gemspec 要求 `ctx_parser ~> 0.2.0`,以使用共享配置解析 API;应用应提交自己的 `Gemfile.lock`,保持部署可复现。使用本库不需要启动 Rails 或配置兄弟模块的本地路径。
25
25
 
26
26
  发布产物和校验值见 [GitHub Releases](https://github.com/gatework/baseline-rb/releases),已发布版本以 [RubyGems](https://rubygems.org/gems/baseline-rb) 为准。
27
27
 
@@ -65,8 +65,22 @@ puts report.to_text
65
65
 
66
66
  详细厂商语义、历史验证记录和 Motor 专用集成说明保留在[架构参考](docs/architecture-zh.md)。其中宿主集成代码需要 Motor,不属于独立 gem API。适配层位于 [Motor 仓库](https://github.com/gatework/motor/tree/main/backend/lib) 的 `backend/lib/baseline`、`backend/lib/vxlan` 和 `backend/lib/wireless`。
67
67
 
68
+ ## 领域术语
69
+
70
+ `Facts` 汇总配置证据,`Policy` 提供批准值,`Evaluator` 生成合规判定。`Acl#permitted_source_networks` 按有序规则、协议和目标端口计算允许的 IPv4 来源网段:`nil` 表示证据未解决,`[]` 表示没有允许来源。`Facts#record_credential` 记录安全的凭据属性。部分证据可以证明确定违规,但不能证明合规。
71
+
72
+ 厂商代码按命名空间组织:`Baseline::Hillstone::Parser` 和 `Baseline::Hillstone::Document` 同放在 `baseline/hillstone/`;Palo Alto 使用相同布局。六个平台解析器复用 `Baseline::Parser`、`Comware::Parser` 或 `Cisco::Parser`。单个平台可用 `require "baseline/hillstone/parser"` 独立加载;完整评估入口仍为 `require "baseline"`。
73
+
74
+ 解析辅助方法直接描述提取意图:`matching_blocks`、`configured_values`、`last_configured_value`、`configured_state` 和 `acl_source_network`。SNMP 写权限规则统一为 `snmp.write_enabled`,禁止写入时配置 `expected: false`。自定义策略需迁移旧键 `snmp.read_only`;旧类名与规则键不保留兼容别名。
75
+
76
+ 上下文允许字符串或符号键,但归一化后重复的键会抛出 `ArgumentError`,避免依赖插入顺序选择设备策略。
77
+
78
+ `complete: true` 表示调用方提供了完整快照。报告通过 `input_complete` 保留这项声明,与解析状态分别记录。不完整的有序 ACL 可能缺少更早的 deny,因此不能仅根据已见 permit 判断来源越界,此时保持 `unknown`。
79
+
68
80
  ## 开发与测试
69
81
 
82
+ 共享类按实际职责命名,平台信息放在命名空间中。直接对应设备命令的解析器保留文件名和类名。
83
+
70
84
  ```sh
71
85
  bundle install
72
86
  bundle exec rake
@@ -76,6 +90,8 @@ bundle exec rake build
76
90
 
77
91
  `bundle exec rake` 执行 RSpec、RuboCop 及离线示例。测试放在 `spec/`,沿用项目 RSpec 约定;样例采用合成或脱敏证据。新增解析格式时补充成功、不完整、不支持及非法输入回归。离线测试不代表所有设备版本均通过实机验证。
78
92
 
93
+ 运行 `bundle exec ruby -Ilib script/benchmark.rb` 可测量合成 ACL 覆盖比较、凭据来源行查询及越界来源报告;脚本报告三次运行的中位数,请在同一机器和 Ruby 版本下比较不同修订。
94
+
79
95
  使用声明的最低 Ruby 版本检查兼容性。方法用途统一使用简明中文注释;复杂实现还需说明业务规则、实现思路及关键分支。保持公开返回值与错误契约。真实回显和凭据不可进入示例、fixture、日志或发布包。
80
96
 
81
97
  ## 发布
data/README.md CHANGED
@@ -21,7 +21,7 @@ gem "baseline-rb", require: "baseline"
21
21
 
22
22
  Then run `bundle install`, or install directly with `gem install baseline-rb`. The require path is `baseline` and the public namespace is `Baseline`; package names and Ruby namespaces are intentionally separate.
23
23
 
24
- Internal runtime dependencies: [`ctx_parser`](https://github.com/gatework/ctx_parser). The gemspec declares runtime dependencies without version constraints. Applications should commit their own `Gemfile.lock` to make deployments reproducible. No Rails boot or sibling checkout paths are required.
24
+ Internal runtime dependencies: [`ctx_parser`](https://github.com/gatework/ctx_parser). The gemspec requires `ctx_parser ~> 0.2.0` for the shared configuration API. Applications should commit their own `Gemfile.lock` to make deployments reproducible. No Rails boot or sibling checkout paths are required.
25
25
 
26
26
  Release artifacts and checksums are available from [GitHub Releases](https://github.com/gatework/baseline-rb/releases). Check [RubyGems](https://rubygems.org/gems/baseline-rb) for published versions.
27
27
 
@@ -65,8 +65,22 @@ The library performs no device access, backup collection, remediation, or databa
65
65
 
66
66
  Detailed vendor behavior, historical validation notes, and Motor-only integration are preserved in [the architecture reference](docs/architecture-zh.md). Host integration examples in that reference require Motor; they are not standalone gem APIs. Motor adapters live under `backend/lib/baseline`, `backend/lib/vxlan`, and `backend/lib/wireless` in the [Motor repository](https://github.com/gatework/motor/tree/main/backend/lib).
67
67
 
68
+ ## Domain vocabulary
69
+
70
+ `Facts` assembles configuration evidence; `Policy` supplies approved values; `Evaluator` produces compliance findings. `Acl#permitted_source_networks` returns the IPv4 networks permitted by ordered rules for a protocol and destination port: `nil` means unresolved evidence, while `[]` means no permitted sources. `Facts#record_credential` records safe credential attributes. Partial evidence may establish a conclusive violation, but cannot establish compliance.
71
+
72
+ Vendor-specific code lives in a vendor namespace: `Baseline::Hillstone::Parser` and `Baseline::Hillstone::Document` reside in `baseline/hillstone/`; Palo Alto uses the same layout. The six platform parsers inherit shared behavior from `Baseline::Parser`, `Comware::Parser`, or `Cisco::Parser`. Load only one platform with `require "baseline/hillstone/parser"`, or load the assessment API with `require "baseline"`.
73
+
74
+ Parser helpers describe the evidence they extract: `matching_blocks`, `configured_values`, `last_configured_value`, `configured_state`, and `acl_source_network`. SNMP write permission uses the policy key `snmp.write_enabled` (`expected: false` to prohibit writes). Update custom policies that used `snmp.read_only`; old class names and rule keys have no compatibility aliases.
75
+
76
+ Context accepts string or symbol keys, but rejects duplicate keys after normalization with `ArgumentError` rather than selecting a device policy by insertion order.
77
+
78
+ `complete: true` asserts that the caller supplied the entire snapshot. Reports retain that assertion as `input_complete`, separately from parser status. A partial ordered ACL cannot prove an out-of-policy source: an unseen earlier deny may change its meaning, so the finding stays `unknown`.
79
+
68
80
  ## Development and tests
69
81
 
82
+ Name shared classes by their responsibility and use the namespace for platform context. Preserve filenames and class names that directly match device commands.
83
+
70
84
  ```sh
71
85
  bundle install
72
86
  bundle exec rake
@@ -76,6 +90,8 @@ bundle exec rake build
76
90
 
77
91
  `bundle exec rake` runs RSpec, RuboCop, and the offline examples. Tests live in `spec/`, following the existing project's RSpec convention; fixtures use synthetic or sanitized evidence. Add regression cases for successful, incomplete, unsupported, and malformed inputs when extending a parser. Offline tests do not certify all device versions or replace live integration checks.
78
92
 
93
+ Run `bundle exec ruby -Ilib script/benchmark.rb` for synthetic network-coverage, credential-lookup, and rejected-source report timings. It reports the median of three runs at two input sizes; compare revisions on the same runtime and machine.
94
+
79
95
  Use the declared minimum Ruby version for compatibility checks. Document source methods and non-obvious business logic in concise Chinese, preserve public return/error contracts, and never add raw device captures or credentials to examples, fixtures, logs, or release archives.
80
96
 
81
97
  ## Release
@@ -27,11 +27,11 @@ flowchart LR
27
27
  | 对象 | 职责 |
28
28
  | --- | --- |
29
29
  | `Audit` | 纯文本入口,选择厂商解析器并组织判定 |
30
- | `Parsers::Base < CtxParser::ConfigurationParser` | 输入边界、语法提取;通过公共 `analyze` / `capture` 管理逐行状态和来源 |
31
- | `Parsers::Comware` / `Parsers::Cisco` | 分别共享 Comware/VRP 与 IOS/NX-OS 的语法族 |
32
- | `Parsers::H3c` / `Huawei` / `CiscoIos` / `CiscoNxos` / `Hillstone` / `PaloAlto` | 厂商差异,不把厂商分支散落到规则求值中 |
30
+ | `Parser < CtxParser::Configuration` | 输入边界、语法提取;通过公共 `analyze` / `capture` 管理逐行状态和来源 |
31
+ | `Comware::Parser` / `Cisco::Parser` | 分别共享 Comware/VRP 与 IOS/NX-OS 的语法族 |
32
+ | `H3c::Parser` / `Huawei::Parser` / `CiscoIos::Parser` / `CiscoNxos::Parser` / `Hillstone::Parser` / `PaloAlto::Parser` | 厂商差异,不把厂商分支散落到规则求值中 |
33
33
  | `Document < CtxParser::Document` | 使用公共游标和缩进树,隔离 banner、接口子配置及不完整输入 |
34
- | `QuotedDocument` / `HillstoneDocument` / `PaloAltoDocument` | 隔离跨行字符串、证书和 UI 数据,保留厂商命令边界;注释和 banner 不参与字符串扫描 |
34
+ | `QuotedDocument` / `Hillstone::Document` / `PaloAlto::Document` | 隔离跨行字符串、证书和 UI 数据,保留厂商命令边界;注释和 banner 不参与字符串扫描 |
35
35
  | `Facts` | 每次解析独享的事实集合,归约 VTY、SNMP 组引用、命名 ACL 与原生来源列表、凭据;输出独立的结果快照 |
36
36
  | `Configuration` | 不可变事实、输入指纹、完整性和局部解析诊断 |
37
37
  | `Acl` | IPv4 地址区间与有序规则,按首次匹配计算允许来源 |
@@ -43,9 +43,9 @@ flowchart LR
43
43
 
44
44
  独立 Ruby 调用只需加载 `baseline`;`Check` 由 Rails 自动加载。持久化使用 `baseline_assessments` 表;纯文本分析不依赖数据库。没有新增外部依赖、HTTP 接口或后台任务。
45
45
 
46
- 厂商解析器把命令转换为普通 Ruby 值及 `Acl` / `Credential` 对象;`Facts` 不读取命令文本,也不操作游标。语法解释成功后由 `ConfigurationParser#analyze` 标记当前行,`capture` 关联脱敏数据与来源;未解释的子命令保留原状态。事实中的行号用于判定报告,游标中的捕获用于显式溯源,两者不将原文带入持久化结果。
46
+ 厂商解析器把命令转换为普通 Ruby 值及 `Acl` / `Credential` 对象;`Facts` 不读取命令文本,也不操作游标。语法解释成功后由 `Configuration#analyze` 标记当前行,`capture` 关联脱敏数据与来源;未解释的子命令保留原状态。事实中的行号用于判定报告,游标中的捕获用于显式溯源,两者不将原文带入持久化结果。
47
47
 
48
- 新增厂商时,有相同语法族才继承 `Comware` 或 `Cisco`;防火墙直接继承 `Base`,独立解释本机管理语法。新增跨厂商归约逻辑放入 `Facts` 或已有领域对象,批准值仍声明在策略 YAML 中。不要在厂商类中重复维护事实字典、诊断列表或引用解析状态。`Facts#direct_acl` 对原生允许来源统一归一化,区分 `nil`(未确认)、`[]`(明确没有允许来源)与 `0.0.0.0/0`(全网);`incomplete!` 使破损声明无法证明合规的缺省状态。
48
+ 新增厂商时,有相同语法族才继承 `Comware::Parser` 或 `Cisco::Parser`;防火墙直接继承 `Baseline::Parser`,独立解释本机管理语法。新增跨厂商归约逻辑放入 `Facts` 或已有领域对象,批准值仍声明在策略 YAML 中。不要在厂商类中重复维护事实字典、诊断列表或引用解析状态。`Facts#direct_acl` 对原生允许来源统一归一化,区分 `nil`(未确认)、`[]`(明确没有允许来源)与 `0.0.0.0/0`(全网);`incomplete!` 使破损声明无法证明合规的缺省状态。
49
49
 
50
50
  ## 事实与检查
51
51
 
@@ -66,7 +66,7 @@ flowchart LR
66
66
 
67
67
  `complete: true` 是调用方对**完整配置快照**的声明,不代表每个事实都可识别。分页残留、错误回显、未闭合 banner、无 hostname/sysname、非法编码会阻止合规结论。接口或 banner 内看似合法的全局命令不会变成全局事实。
68
68
 
69
- 部分配置可以保留已确认且不依赖未见记录的违规:禁用服务开启、意外集合成员、越界管理来源、弱明文凭据、SNMP 写授权及不安全凭据存储。
69
+ 部分配置可以保留已确认且不依赖未见记录的违规:禁用服务开启、意外集合成员、完整输入中可确认的越界管理来源、弱明文凭据、SNMP 写授权及不安全凭据存储。
70
70
  缺少批准成员、数量不足、其他标量比较仍保持 unknown;部分证据不会产生 pass,报告仍不完整。
71
71
  明确的上下文不匹配先返回 not_applicable;部分集合的“全部满足/不存在”不能用来判定策略适用范围。
72
72
  部分输入或分页输入的无终止末行,以及内联分页/错误污染行不进入事实,避免截断密码/地址被当作有效配置;
@@ -104,7 +104,7 @@ PAN-OS 管理 profile 未指定 permitted IP 时没有来源限制,因此必
104
104
 
105
105
  ### SNMP 写权限证据
106
106
 
107
- `snmp.read_only` 检查的是配置授予的写权限:任一已知写授权会得到 `fail`;所有授权均明确只读才会通过;缺失组引用、未知角色或不能完整解释的组语法保持 `unknown`。一个授权未知不会抹掉另一个已确认的写授权,也不会阻断 SSH 等无关检查。写视图声明不等于已经成功执行 SNMP SET,MIB 对象可写性、网络可达性和运行时认证需要另行验证。
107
+ `snmp.write_enabled` 检查的是配置授予的写权限:任一已知写授权会得到 `fail`;所有授权均明确只读才会通过;缺失组引用、未知角色或不能完整解释的组语法保持 `unknown`。一个授权未知不会抹掉另一个已确认的写授权,也不会阻断 SSH 等无关检查。写视图声明不等于已经成功执行 SNMP SET,MIB 对象可写性、网络可达性和运行时认证需要另行验证。
108
108
 
109
109
  默认策略在 `snmp.enabled: true` 时执行写权限检查,因此纯 SNMPv3 配置也纳入检查,不能仅以 community 是否存在决定规则范围。
110
110
 
data/lib/baseline/acl.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "ctx_parser/immutable"
4
+
3
5
  require "ipaddr"
4
6
 
5
7
  module Baseline
@@ -9,12 +11,12 @@ module Baseline
9
11
 
10
12
  # 冻结有序 ACL 规则及其来源、支持状态。
11
13
  def initialize(name:, rules:, line:, supported: true)
12
- @name, @rules, @line, @supported = name.freeze, Baseline.immutable(rules), line, supported
14
+ @name, @rules, @line, @supported = CtxParser::Immutable.copy(name), CtxParser::Immutable.copy(rules), line, supported
13
15
  freeze
14
16
  end
15
17
 
16
18
  # 按 ACL 顺序匹配语义计算允许访问的来源范围。
17
- def sources(protocol:, port: nil)
19
+ def permitted_source_networks(protocol:, port: nil)
18
20
  return unless supported && rules.any?
19
21
  selected = rules.sort_by { |rule| rule.fetch(:sequence) }.select do |rule|
20
22
  next unless [ "ip", protocol ].include?(rule.fetch(:protocol))
@@ -24,7 +26,7 @@ module Baseline
24
26
  end
25
27
  true
26
28
  end
27
- self.class.networks(permitted_ranges(selected))
29
+ self.class.network_prefixes(permitted_ranges(selected))
28
30
  end
29
31
 
30
32
  # 返回公开的数据结构,并保留状态与诊断信息。
@@ -33,7 +35,7 @@ module Baseline
33
35
  def inspect = "#<#{self.class} name=#{name} rules=#{rules.size} supported=#{supported}>"
34
36
 
35
37
  # 归一化 ACL 来源地址;不推测不支持语法的含义。
36
- def self.source(address, wildcard = nil)
38
+ def self.source_network(address, wildcard = nil)
37
39
  return "0.0.0.0/0" if address == "any"
38
40
  ip = IPAddr.new(address)
39
41
  raise ArgumentError, "IPv4 ACL required" unless ip.ipv4?
@@ -48,40 +50,41 @@ module Baseline
48
50
  end
49
51
 
50
52
  # 将网络前缀转换为包含首尾地址的整数区间。
51
- def self.range(cidr)
53
+ def self.address_range(cidr)
52
54
  ip = IPAddr.new(cidr)
53
55
  raise ArgumentError, "IPv4 ACL required" unless ip.ipv4?
54
56
  [ ip.to_range.first.to_i, ip.to_range.last.to_i ]
55
57
  end
56
58
 
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
59
+ # 判断所有实际来源范围是否均在允许范围内。
60
+ def self.networks_within?(actual, expected)
61
+ each_network_coverage(actual, expected) { |_index, covered| return false unless covered }
62
+ true
68
63
  end
69
64
 
70
- # 判断所有实际来源范围是否均在允许范围内。
71
- def self.within?(actual, expected)
72
- subtract(actual.map { |cidr| range(cidr) }, expected.map { |cidr| range(cidr) }).empty?
65
+ # 批量返回未被批准范围完整覆盖的原始网段,保持报告的输入顺序。
66
+ def self.unapproved_networks(actual, expected)
67
+ indexes = []
68
+ each_network_coverage(actual, expected) { |index, covered| indexes << index unless covered }
69
+ indexes.sort.map { |index| actual.fetch(index) }
73
70
  end
74
71
 
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
72
+ # 批准区间只解析、合并一次;实际网段按起点顺序与其线性比较。
73
+ def self.each_network_coverage(actual, expected)
74
+ approved = merge_ranges(expected.map { |cidr| address_range(cidr) })
75
+ networks = actual.each_with_index.map { |cidr, index| [ address_range(cidr), index ] }.sort_by(&:first)
76
+ approved_index = 0
77
+ networks.each do |(first, last), source_index|
78
+ approved_index += 1 while approved_index < approved.size && approved[approved_index].last < first
79
+ candidate = approved[approved_index]
80
+ yield source_index, candidate && candidate.first <= first && candidate.last >= last
83
81
  end
84
- merged.flat_map do |first, last|
82
+ end
83
+ private_class_method :each_network_coverage
84
+
85
+ # 将地址区间转换为数量最少的网络前缀。
86
+ def self.network_prefixes(ranges)
87
+ merge_ranges(ranges).flat_map do |first, last|
85
88
  networks = []
86
89
  while first <= last
87
90
  aligned = first.zero? ? 32 : (first & -first).bit_length - 1
@@ -93,13 +96,25 @@ module Baseline
93
96
  end
94
97
  end
95
98
 
99
+ # 合并相邻或重叠区间,使覆盖比较只需顺序扫描,不反复切割整个集合。
100
+ def self.merge_ranges(ranges)
101
+ ranges.sort.each_with_object([]) do |(first, last), result|
102
+ if result.any? && first <= result.last.last + 1
103
+ result.last[1] = [ result.last.last, last ].max
104
+ else
105
+ result << [ first, last ]
106
+ end
107
+ end
108
+ end
109
+ private_class_method :merge_ranges
110
+
96
111
  private
97
112
 
98
113
  # 按地址边界扫描一次;每个区间由最先匹配的生效规则决定。
99
114
  def permitted_ranges(selected)
100
115
  boundaries = Hash.new { |entries, address| entries[address] = [] }
101
116
  selected.each_with_index do |rule, priority|
102
- first, last = self.class.range(rule.fetch(:source))
117
+ first, last = self.class.address_range(rule.fetch(:source))
103
118
  boundaries[first] << [ priority, true ]
104
119
  # 将闭区间终点转为下一地址的失效事件,统一处理相邻和重叠网段。
105
120
  boundaries[last + 1] << [ priority, false ]
@@ -1,13 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "configuration"
4
+ require_relative "evaluator"
5
+ require_relative "h3c/parser"
6
+ require_relative "huawei/parser"
7
+ require_relative "cisco_ios/parser"
8
+ require_relative "cisco_nxos/parser"
9
+ require_relative "hillstone/parser"
10
+ require_relative "palo_alto/parser"
11
+
3
12
  require "digest"
4
13
 
5
14
  module Baseline
6
15
  # 仅分析传入文本,不读取文件、访问数据库、连接设备或执行修复。
7
16
  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
17
+ PARSERS = { "h3c" => H3c::Parser, "huawei" => Huawei::Parser,
18
+ "cisco_ios" => CiscoIos::Parser, "cisco_nxos" => CiscoNxos::Parser,
19
+ "hillstone" => Hillstone::Parser, "palo_alto" => PaloAlto::Parser }.freeze
11
20
  ALIASES = { "h3c_wireless" => "h3c", "comware" => "h3c", "huawei_wireless" => "huawei", "vrp" => "huawei",
12
21
  "ios" => "cisco_ios", "iosxe" => "cisco_ios", "ios_xe" => "cisco_ios", "cisco_ios_xe" => "cisco_ios",
13
22
  "nxos" => "cisco_nxos", "nx_os" => "cisco_nxos", "stoneos" => "hillstone", "stone_os" => "hillstone",
@@ -28,7 +37,7 @@ module Baseline
28
37
  parser = PARSERS[name]
29
38
  result = parser ? parser.parse(configuration, complete: complete) :
30
39
  CtxParser::Result.new(status: :unsupported, issues: [ { code: :unsupported_platform } ])
31
- facts = Configuration.new(platform: name, sha256: Digest::SHA256.hexdigest(configuration), result: result)
40
+ facts = Configuration.new(platform: name, sha256: Digest::SHA256.hexdigest(configuration), result: result, input_complete: complete)
32
41
  @evaluator.call(configuration: facts, context: context)
33
42
  end
34
43
 
@@ -1,9 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../parser"
4
+
3
5
  module Baseline
4
- module Parsers
6
+ module Cisco
5
7
  # 复用 Cisco 管理服务、IPv4 ACL、SNMP 和 AAA 的公共语法。
6
- class Cisco < Base
8
+ class Parser < Baseline::Parser
7
9
  SNMP_ROLE_WRITE = { "network-admin" => true, "vdc-admin" => true,
8
10
  "network-operator" => false, "vdc-operator" => false }.freeze
9
11
 
@@ -11,12 +13,12 @@ module Baseline
11
13
 
12
14
  # 解析管理访问暴露检查使用的有序 ACL 定义。
13
15
  def access_lists
14
- sections(/\Aip access-list\s+/).each do |section|
16
+ matching_blocks(/\Aip access-list\s+/).each do |section|
15
17
  name = section.text.split.last
16
18
  extended = section.text.start_with?("ip access-list extended ") || nxos?
17
19
  register_acl(name, section.children, source: section) { |entry, index| cisco_rule(entry, index, extended: extended) }
18
20
  end
19
- matching(/\Aaccess-list\s+/).group_by { |entry| entry.text.split[1] }.each do |name, entries|
21
+ matching_blocks(/\Aaccess-list\s+/).group_by { |entry| entry.text.split[1] }.each do |name, entries|
20
22
  extended = name.to_i.between?(100, 199) || name.to_i.between?(2000, 2699)
21
23
  register_acl(name, entries) { |entry, index| cisco_rule(entry, index, extended: extended, prefix: /\Aaccess-list\s+\S+\s+/) }
22
24
  end
@@ -30,8 +32,8 @@ module Baseline
30
32
  parts = match[3].split
31
33
  protocol = extended ? parts.shift : "ip"
32
34
  return unless %w[ip tcp udp icmp].include?(protocol)
33
- network = source(parts)
34
- return if extended && source(parts) != "0.0.0.0/0"
35
+ network = acl_source_network(parts)
36
+ return if extended && acl_source_network(parts) != "0.0.0.0/0"
35
37
  if extended && parts.first == "eq"
36
38
  return unless %w[tcp udp].include?(protocol)
37
39
  parts.shift
@@ -47,54 +49,54 @@ module Baseline
47
49
 
48
50
  # 根据监听服务、认证方式与 ACL 绑定关系归纳管理访问事实。
49
51
  def management
50
- version = last(/\Aip ssh version\s+([12])\z/)
52
+ version = last_configured_value(/\Aip ssh version\s+([12])\z/)
51
53
  record("ssh.versions", [ version ]) if version
52
- ssh = nxos? ? state(/\Afeature ssh\z/, /\Ano feature ssh\z/) : version ? true : nil
53
- record("ssh.enabled", ssh, matching(/\A(?:ip ssh|(?:no )?feature ssh)/))
54
- default_aaa = nxos? || state(/\Aaaa new-model\z/, /\Ano aaa new-model\z/) == true
55
- vtys = sections(/\Aline vty(?:\s|\z)/).map do |section|
56
- authentication = last(/\Alogin authentication\s+(\S+)\z/, section.children)
54
+ ssh = nxos? ? configured_state(/\Afeature ssh\z/, /\Ano feature ssh\z/) : version ? true : nil
55
+ record("ssh.enabled", ssh, matching_blocks(/\A(?:ip ssh|(?:no )?feature ssh)/))
56
+ default_aaa = nxos? || configured_state(/\Aaaa new-model\z/, /\Ano aaa new-model\z/) == true
57
+ vtys = matching_blocks(/\Aline vty(?:\s|\z)/).map do |section|
58
+ authentication = last_configured_value(/\Alogin authentication\s+(\S+)\z/, section.children)
57
59
  local = section.children.any? { |entry| entry.text == "login local" }
58
- { name: section.text, protocols: last(/\Atransport input\s+(.+)\z/, section.children)&.split,
60
+ { name: section.text, protocols: last_configured_value(/\Atransport input\s+(.+)\z/, section.children)&.split,
59
61
  authentication: local ? "local" : (default_aaa ? "aaa" : nil),
60
62
  authentication_list: authentication || (default_aaa ? "default" : nil),
61
- acl: last(/\Aaccess-class\s+(\S+)\s+in(?:\s+vrf-also)?\z/, section.children), line: section.line }
63
+ acl: last_configured_value(/\Aaccess-class\s+(\S+)\s+in(?:\s+vrf-also)?\z/, section.children), line: section.line }
62
64
  end
63
- rotary = matching(/\A(?:no )?ip ssh port(?:\s|\z)/)
64
- rotary.concat(sections(/\Aline vty(?:\s|\z)/).flat_map { |section| matching(/\A(?:no )?rotary(?:\s|\z)/, section.children) })
65
+ rotary = matching_blocks(/\A(?:no )?ip ssh port(?:\s|\z)/)
66
+ rotary.concat(matching_blocks(/\Aline vty(?:\s|\z)/).flat_map { |section| matching_blocks(/\A(?:no )?rotary(?:\s|\z)/, section.children) })
65
67
  unknown_listener_port("management.acl", rotary) unless rotary.empty?
66
- telnet_ports = matching(/\A(?:no )?(?:ip )?telnet(?: server)? port(?:\s|\z)/)
68
+ telnet_ports = matching_blocks(/\A(?:no )?(?:ip )?telnet(?: server)? port(?:\s|\z)/)
67
69
  unknown_listener_port("management.acl", telnet_ports) unless telnet_ports.empty?
68
70
  @facts.management(vtys: vtys,
69
71
  ssh_port: rotary.empty? ? listener_port("ssh port", default: 22, scope: "management.acl", negate: "no") : nil,
70
72
  telnet_port: telnet_ports.empty? ? 23 : nil)
71
73
  telnet = if nxos?
72
- state(/\Afeature telnet\z/, /\Ano feature telnet\z/)
74
+ configured_state(/\Afeature telnet\z/, /\Ano feature telnet\z/)
73
75
  elsif vtys.any? && vtys.all? { |entry| entry[:protocols] }
74
76
  vtys.any? { |entry| (entry[:protocols] & %w[all telnet]).any? }
75
77
  end
76
- record("services", { "telnet" => telnet, "http" => state(/\Aip http server\z/, /\Ano ip http server\z/),
77
- "https" => state(/\Aip http secure-server\z/, /\Ano ip http secure-server\z/),
78
- "ftp" => state(/\A(?:feature ftp-server|ftp-server enable)\z/, /\Ano (?:feature ftp-server|ftp-server enable)\z/),
79
- "tftp" => state(/\A(?:feature tftp-server|tftp-server\s+.+)\z/, /\Ano (?:feature tftp-server|tftp-server\s+.+)\z/),
80
- "finger" => state(/\A(?:ip finger|service finger)\z/, /\Ano (?:ip finger|service finger)\z/) })
78
+ record("services", { "telnet" => telnet, "http" => configured_state(/\Aip http server\z/, /\Ano ip http server\z/),
79
+ "https" => configured_state(/\Aip http secure-server\z/, /\Ano ip http secure-server\z/),
80
+ "ftp" => configured_state(/\A(?:feature ftp-server|ftp-server enable)\z/, /\Ano (?:feature ftp-server|ftp-server enable)\z/),
81
+ "tftp" => configured_state(/\A(?:feature tftp-server|tftp-server\s+.+)\z/, /\Ano (?:feature tftp-server|tftp-server\s+.+)\z/),
82
+ "finger" => configured_state(/\A(?:ip finger|service finger)\z/, /\Ano (?:ip finger|service finger)\z/) })
81
83
  end
82
84
 
83
85
  # 采集明确启用或禁用的管理服务。
84
86
  def services
85
- record("ntp.servers", values(/\Antp server(?: vrf \S+)?\s+(\S+)/), matching(/\Antp server/))
86
- record("ntp.authentication", state(/\Antp authenticate\z/, /\Ano ntp authenticate\z/))
87
- record("ntp.source", last(/\Antp source(?:-interface)?\s+(\S+)\z/))
88
- hosts = values(nxos? ? /\Alogging server\s+(\S+)/ : /\Alogging host(?: ipv6)?\s+(\S+)/)
89
- hosts |= values(/\Alogging\s+(\d+(?:\.\d+){3})\z/) unless nxos?
90
- record("syslog.servers", hosts, matching(/\Alogging (?:host|server)/))
91
- record("syslog.source", last(/\Alogging source-interface\s+(\S+)\z/))
92
- snmp = matching(/\Asnmp-server\b/)
93
- enabled = nxos? ? state(/\Asnmp-server protocol enable\z/, /\Ano snmp-server protocol enable\z/) : nil
87
+ record("ntp.servers", configured_values(/\Antp server(?: vrf \S+)?\s+(\S+)/), matching_blocks(/\Antp server/))
88
+ record_configured_state("ntp.authentication", /\Antp authenticate\z/, /\Ano ntp authenticate\z/)
89
+ record_configured_value("ntp.source", /\Antp source(?:-interface)?\s+(\S+)\z/)
90
+ hosts = configured_values(nxos? ? /\Alogging server\s+(\S+)/ : /\Alogging host(?: ipv6)?\s+(\S+)/)
91
+ hosts |= configured_values(/\Alogging\s+(\d+(?:\.\d+){3})\z/) unless nxos?
92
+ record("syslog.servers", hosts, matching_blocks(/\Alogging (?:host|server)/))
93
+ record_configured_value("syslog.source", /\Alogging source-interface\s+(\S+)\z/)
94
+ snmp = matching_blocks(/\Asnmp-server\b/)
95
+ enabled = nxos? ? configured_state(/\Asnmp-server protocol enable\z/, /\Ano snmp-server protocol enable\z/) : nil
94
96
  enabled = nxos? || snmp.any? if enabled.nil? && (@facts.complete? || snmp.any?)
95
97
  record("snmp.enabled", enabled)
96
98
  communities = {}
97
- matching(/\Asnmp-server community\b/).each do |entry|
99
+ matching_blocks(/\Asnmp-server community\b/).each do |entry|
98
100
  match = /\Asnmp-server community\s+(\S+)(?:\s+(.*))?\z/.match(entry.text)
99
101
  unless match
100
102
  unknown_snmp_access([ entry ])
@@ -108,17 +110,17 @@ module Baseline
108
110
  community[:write] = SNMP_ROLE_WRITE[role] if role
109
111
  acl = nxos? ? settings[/\buse-ipv4acl\s+(\S+)/, 1] : settings[/\A(?:ro|rw)\s+(\S+)\z/i, 1]
110
112
  community[:acl] = acl if acl
111
- credential("snmp-community", "snmp", "plain", match[1], entry) unless @facts.credential_recorded?(community[:line])
113
+ record_credential("snmp-community", "snmp", "plain", match[1], entry) unless @facts.credential_recorded?(community[:line])
112
114
  end
113
115
  versions = communities.any? ? %w[v1 v2c] : []
114
- groups = snmp_groups(matching(/\Asnmp-server group\s+.+?\s+v3\b/)) do |entry|
116
+ groups = snmp_groups(matching_blocks(/\Asnmp-server group\s+.+?\s+v3\b/)) do |entry|
115
117
  name, settings = entry.text.match(/\Asnmp-server group\s+(.+?)\s+v3(?:\s+(.*))?\z/).captures
116
118
  write = name.include?('"') ? nil : snmp_group_write(settings.to_s)
117
119
  [ name, { acl: entry.text[/\baccess\s+(\S+)/, 1], line: entry.line, write: write } ]
118
120
  end
119
121
  record("snmp.groups", groups)
120
122
  users = {}
121
- matching(/\Asnmp-server user\b/).each do |entry|
123
+ matching_blocks(/\Asnmp-server user\b/).each do |entry|
122
124
  match = /\Asnmp-server user\s+(\S+)\s+(\S+)(.*)\z/.match(entry.text)
123
125
  unless match
124
126
  unknown_snmp_access([ entry ])
@@ -153,14 +155,14 @@ module Baseline
153
155
  acl = settings[/\b(?:access|use-ipv4acl)\s+(\S+)/, 1]
154
156
  user[:acl] = acl if acl
155
157
  end
156
- port_overrides = matching(/\A(?:no )?snmp-server (?:listen-port|udp-port|port)(?:\s|\z)/)
158
+ port_overrides = matching_blocks(/\A(?:no )?snmp-server (?:listen-port|udp-port|port)(?:\s|\z)/)
157
159
  unknown_listener_port("snmp.acl", port_overrides) unless port_overrides.empty?
158
160
  @facts.snmp(communities: communities.values, users: users.values, groups: nxos? ? nil : groups,
159
161
  port: port_overrides.empty? ? 161 : nil)
160
162
  nxos_snmp_coverage(users, enabled: enabled) if nxos?
161
163
  unknown("snmp.v3_auth_priv", code: :snmp_user_unresolved) if users.values.any? { |user| !user.key?(:auth) }
162
- versions |= values(/\Asnmp-server group\s+\S+\s+(v1|v2c|v3)\b/)
163
- legacy_groups = matching(/\Asnmp-server group\s+\S+\s+v(?:1|2c)\b/)
164
+ versions |= configured_values(/\Asnmp-server group\s+\S+\s+(v1|v2c|v3)\b/)
165
+ legacy_groups = matching_blocks(/\Asnmp-server group\s+\S+\s+v(?:1|2c)\b/)
164
166
  if legacy_groups.any?
165
167
  @facts.unknown_snmp_permission(lines: legacy_groups.map(&:line), code: :unsupported_snmp_group)
166
168
  unknown("snmp.acl", legacy_groups, code: :unsupported_snmp_group)
@@ -171,10 +173,10 @@ module Baseline
171
173
  # NX-OS 通常会同步 CLI 与 SNMP 账号;缺少证据时不能构造虚拟用户。
172
174
  def nxos_snmp_coverage(users, enabled:)
173
175
  return if enabled == false
174
- sync_disabled = state(/\Asnmp-server disable snmp-aaa sync\z/, /\Ano snmp-server disable snmp-aaa sync\z/)
176
+ sync_disabled = configured_state(/\Asnmp-server disable snmp-aaa sync\z/, /\Ano snmp-server disable snmp-aaa sync\z/)
175
177
  return if sync_disabled == true
176
178
 
177
- matching(/\Ausername\s+/).group_by { |entry| entry.text.split[1] }.each do |name, entries|
179
+ matching_blocks(/\Ausername\s+/).group_by { |entry| entry.text.split[1] }.each do |name, entries|
178
180
  roles = entries.filter_map { |entry| entry.text[/\brole\s+(\S+)\s*\z/, 1] }.uniq
179
181
  covered = users[name]&.fetch(:roles, []) || []
180
182
  next if roles.any? && (roles - covered).empty?
@@ -195,9 +197,9 @@ module Baseline
195
197
  # 解析实际生效的认证方法及外部服务器引用。
196
198
  def authentication
197
199
  servers, aliases, keyed = [], {}, []
198
- global_key = matching(/\Atacacs-server key\s+/)
200
+ global_key = matching_blocks(/\Atacacs-server key\s+/)
199
201
  global_key.each { |entry| parse_key("tacacs", entry.text.sub(/\Atacacs-server key\s+/, ""), entry) }
200
- matching(/\Atacacs-server host\s+/).each do |entry|
202
+ matching_blocks(/\Atacacs-server host\s+/).each do |entry|
201
203
  host = entry.text.split[2]
202
204
  servers << host
203
205
  key = entry.text[/\bkey\s+(.+)\z/, 1]
@@ -206,17 +208,17 @@ module Baseline
206
208
  keyed << host
207
209
  end
208
210
  end
209
- sections(/\Atacacs server\s+/).each do |section|
210
- hosts = values(/\Aaddress ipv[46]\s+(\S+)/, section.children)
211
+ matching_blocks(/\Atacacs server\s+/).each do |section|
212
+ hosts = configured_values(/\Aaddress ipv[46]\s+(\S+)/, section.children)
211
213
  aliases[section.text.split.last] = hosts
212
214
  servers |= hosts
213
- matching(/\Akey\s+/, section.children).each do |entry|
215
+ matching_blocks(/\Akey\s+/, section.children).each do |entry|
214
216
  parse_key("tacacs:#{section.text.split.last}", entry.text.delete_prefix("key "), entry)
215
217
  keyed |= hosts
216
218
  end
217
219
  end
218
- groups = sections(/\Aaaa group server tacacs\+\s+/).to_h do |section|
219
- members = values(/\Aserver(?: name)?\s+(\S+)/, section.children)
220
+ groups = matching_blocks(/\Aaaa group server tacacs\+\s+/).to_h do |section|
221
+ members = configured_values(/\Aserver(?: name)?\s+(\S+)/, section.children)
220
222
  [ section.text.split.last, members.flat_map { |member| aliases.fetch(member, [ member ]) } ]
221
223
  end
222
224
  record("tacacs.servers", servers.uniq)
@@ -225,14 +227,14 @@ module Baseline
225
227
  patterns = { "authorization" => /\Aaaa authorization (?:exec|commands(?: \d+)?) default\s+(.+)\z/,
226
228
  "accounting" => /\Aaaa accounting (?:(?:exec|commands(?: \d+)?) )?default\s+(.+)\z/ }
227
229
  patterns.each do |kind, pattern|
228
- references = values(pattern).flat_map { |methods| methods.scan(/\bgroup\s+(\S+)/).flatten }
230
+ references = configured_values(pattern).flat_map { |methods| methods.scan(/\bgroup\s+(\S+)/).flatten }
229
231
  selected = references.flat_map { |name| name == "tacacs+" ? servers : groups.fetch(name, []) }.uniq
230
232
  unresolved = references.any? { |name| name != "radius" && name != "tacacs+" && !groups.key?(name) } || (selected - servers).any?
231
233
  if unresolved
232
- unknown("tacacs.#{kind}", matching(pattern), code: :tacacs_group_unresolved)
234
+ unknown("tacacs.#{kind}", matching_blocks(pattern), code: :tacacs_group_unresolved)
233
235
  else
234
236
  used = selected.any?
235
- record("tacacs.#{kind}", used, matching(pattern)) if @facts.complete? || used
237
+ record("tacacs.#{kind}", used, matching_blocks(pattern)) if @facts.complete? || used
236
238
  end
237
239
  end
238
240
  record("tacacs.keys_present", global_key.any? || (servers - keyed).empty?) unless servers.empty?
@@ -242,7 +244,7 @@ module Baseline
242
244
  # 解析各 VTY 实际选用的登录列表,包括命名列表。
243
245
  def tacacs_login(servers, groups)
244
246
  pattern = /\Aaaa authentication login\s+(\S+)\s+(.+)\z/
245
- lists = matching(pattern).each_with_object({}) do |entry, result|
247
+ lists = matching_blocks(pattern).each_with_object({}) do |entry, result|
246
248
  name, methods = analyze(entry) { entry.text.match(pattern).captures }
247
249
  result[name] = { methods: methods, entry: entry }
248
250
  end
@@ -296,26 +298,26 @@ module Baseline
296
298
  # 提取已配置的凭据编码方式,不保留秘密值。
297
299
  def parse_key(owner, text, entry)
298
300
  match = /\A(?:(\d+)\s+)?(\S+)/.match(text)
299
- credential(owner, "tacacs", match&.[](1) || "plain", match&.[](2), entry)
301
+ record_credential(owner, "tacacs", match&.[](1) || "plain", match&.[](2), entry)
300
302
  end
301
303
 
302
304
  # 提取本地账号角色及凭据存储属性。
303
305
  def accounts
304
- matching(/\Ausername\s+/).group_by { |entry| entry.text.split[1] }.each do |name, entries|
306
+ matching_blocks(/\Ausername\s+/).group_by { |entry| entry.text.split[1] }.each do |name, entries|
305
307
  passwords = entries.filter_map do |entry|
306
308
  match = /\b(?:password|secret)\s+(?:(\d+)\s+)?(\S+)/.match(entry.text)
307
- credential(name, "local_user", match[1] || "plain", match[2], entry) if match
309
+ record_credential(name, "local_user", match[1] || "plain", match[2], entry) if match
308
310
  end
309
- credential(name, "local_user", "unknown", nil, entries.first) if passwords.empty?
311
+ record_credential(name, "local_user", "unknown", nil, entries.first) if passwords.empty?
310
312
  end
311
- matching(/\Aenable (?:password|secret)\s+/).each do |entry|
313
+ matching_blocks(/\Aenable (?:password|secret)\s+/).each do |entry|
312
314
  match = /\Aenable (?:password|secret)(?: level \d+)?\s+(?:(\d+)\s+)?(\S+)/.match(entry.text)
313
- credential("enable", "enable", match ? match[1] || "plain" : "unknown", match&.[](2), entry)
315
+ record_credential("enable", "enable", match ? match[1] || "plain" : "unknown", match&.[](2), entry)
314
316
  end
315
- sections(/\Aline\b/).each do |section|
316
- matching(/\Apassword\b/, section.children).each do |entry|
317
+ matching_blocks(/\Aline\b/).each do |section|
318
+ matching_blocks(/\Apassword\b/, section.children).each do |entry|
317
319
  match = /\Apassword\s+(?:(\d+)\s+)?(\S+)/.match(entry.text)
318
- credential(section.text, "line", match ? match[1] || "plain" : "unknown", match&.[](2), entry)
320
+ record_credential(section.text, "line", match ? match[1] || "plain" : "unknown", match&.[](2), entry)
319
321
  end
320
322
  end
321
323
  end