compliance_engine 0.4.0 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f5129c30257114df1399ae314ff6a0fb5d383de5bd4782b8b31dc060ea274870
4
- data.tar.gz: 8dfaa1d2feaf1a43ea6551851eac5a586f2d4e2b4148efcd14cad21c32c93cba
3
+ metadata.gz: 9f8203b0a0381216d9a1a91c318f0f162bde1d4835bc7731c954156371b6f9b8
4
+ data.tar.gz: 784264cbb1aba5772bbf97dafdd952c3b8823fa404236c372af87f1d6dad885b
5
5
  SHA512:
6
- metadata.gz: b4839e2648adfc6c1ea91d16c45a5494a6ad4da6521d816e16b9d0b3127b80d696d6f0cbb5249294cf6e0f21177809fbe9b8efd6c3665e3aae1bee6a02570134
7
- data.tar.gz: 68a0a04f93e3c007eec4287a92d6d19ace0ba37e9a739200474802ef28c6cb24b01257cec5e23f8b8e2cb8f29cf397641ac1e2fc5a1c117ca1db43f1932c2c3d
6
+ metadata.gz: 1026ff097b4e46f31b59b2a584e9d25b94baa986014564fa6a0196d70224306956fd0499d923fa19b0c1035d248c490a3ffaaa1bbcf8f55bd222e7726fb25157
7
+ data.tar.gz: 588997e4f32f95f7d1edef560a16a09399c59d1b7aba4677ca6cf0bf9d97f38568d9d14c9349a5e489288d16dffe0abc2abc0147cecc9beb5127701270d3594a
data/AGENTS.md CHANGED
@@ -91,6 +91,8 @@ A component can have multiple **fragments** (one per source file), which are dee
91
91
  | CE→Control overlap | Any of `check.ces`' CEs has a control that also appears in `profile.controls` |
92
92
  | Direct reference | `profile.checks[check_key]` is truthy |
93
93
 
94
+ An entry on the **profile side** with value `false` is treated as an **explicit exclusion** that overrides positive matches: `profile.checks[X] = false` hard-excludes check `X` from every route, and `profile.ces[X] = false` prevents CE `X` from being used as either a direct CE match or as a CE→Control bridge. On the **check side**, `false` is local-only (e.g. `check.controls[X] = false` simply means the check is not associated with control `X`); `check.ces` is an array, so no `false` handling applies.
95
+
94
96
  `check_mapping` can also be called with CE objects (in addition to profiles). Results are cached by `"#{object.class}:#{object.key}"`.
95
97
 
96
98
  ### Loading Pipeline
data/CHANGELOG.md CHANGED
@@ -1,3 +1,18 @@
1
+ ### 0.6.0 / 2026-05-05
2
+ * Honor explicit `false` in profile `checks` and `ces` mappings as exclusions that override positive matches (#117)
3
+ * Coerce non-String `zipfile.name` to `-` for buffer-opened zips (#121)
4
+ * Replace `Zip::File` handoff with `open_environment_zip_bytes` (#124)
5
+
6
+ ### 0.5.0 / 2026-04-29
7
+ * Add JSON Schema for SCE data, exposed via `ComplianceEngine.schema` (#49)
8
+ * Add `ComplianceEngine::Tolerance` constants for enforcement tolerance levels (#107)
9
+ * Drop stale file data on environment rescan (#108)
10
+ * Handle malformed compliance data gracefully instead of raising (#111)
11
+ * Make dotfile loading opt-in (defaults to off) (#112)
12
+ * Accept `::Zip::File` objects directly in `EnvironmentLoader::Zip` (#114)
13
+ * Support `compliance_markup`-style parameter knockout prefixes
14
+ * Split `version.rb`: separate gem `VERSION` from data format validator (#104)
15
+
1
16
  ### 0.4.0 / 2026-04-20
2
17
  * Route Puppet Hiera backend log messages through Puppet's logging system (#96)
3
18
  * Fix JRuby compatibility: convert internal requires to require_relative so the library loads correctly inside Puppet Server / OpenVox Server when installed as a Puppet module rather than a standalone gem
@@ -19,7 +19,7 @@ Gem::Specification.new do |spec|
19
19
  spec.metadata['bug_tracker_uri'] = 'https://github.com/simp/rubygem-simp-compliance_engine/issues'
20
20
 
21
21
  # Specify which files should be added to the gem when it is released.
22
- spec.files = Dir.glob(['*.gemspec', '*.md', 'LICENSE', 'exe/*', 'lib/**/*.rb']).reject { |f| f.start_with?('lib/puppet/') }
22
+ spec.files = Dir.glob(['*.gemspec', '*.md', 'LICENSE', 'exe/*', 'lib/**/*.rb', 'lib/**/*.json']).reject { |f| f.start_with?('lib/puppet/') }
23
23
  spec.bindir = 'exe'
24
24
  spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
25
25
  spec.require_paths = ['lib']
@@ -12,7 +12,21 @@ class ComplianceEngine::Collection
12
12
  @collection ||= {}
13
13
  hash_key = key
14
14
  data.files.each do |file|
15
- data.get(file)[hash_key]&.each do |k, v|
15
+ file_data = data.get(file)
16
+ unless file_data.is_a?(Hash)
17
+ ComplianceEngine.log.debug "Skipping #{file}: expected Hash content, got #{file_data.class}"
18
+ next
19
+ end
20
+
21
+ entries = file_data[hash_key]
22
+ next if entries.nil?
23
+
24
+ unless entries.is_a?(Hash)
25
+ ComplianceEngine.log.error "Expected '#{hash_key}' in #{file} to be a Hash, got #{entries.class}"
26
+ next
27
+ end
28
+
29
+ entries.each do |k, v|
16
30
  @collection[k] ||= collected.new(k, data: self)
17
31
  @collection[k].add(file, v)
18
32
  end
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'set'
3
4
  require_relative '../compliance_engine'
4
- require_relative 'version'
5
+ require_relative 'data_version'
5
6
  require_relative 'component'
6
7
  require_relative 'ce'
7
8
  require_relative 'check'
@@ -124,13 +125,29 @@ class ComplianceEngine::Data
124
125
  nil
125
126
  end
126
127
 
127
- # Scan a Puppet environment from a zip file
128
- # @param path [String] The Puppet environment archive file
128
+ # Scan a Puppet environment from a zip file on disk
129
+ # @param path [String] filesystem path to the zip archive
130
+ # @param name [String, nil] stable string identifier used as the modulepath
131
+ # and cache-key prefix; defaults to the full path string passed as +path+.
129
132
  # @return [NilClass]
130
- def open_environment_zip(path)
133
+ def open_environment_zip(path, name: nil)
131
134
  require 'compliance_engine/environment_loader/zip'
132
135
 
133
- environment = ComplianceEngine::EnvironmentLoader::Zip.new(path)
136
+ environment = ComplianceEngine::EnvironmentLoader::Zip.new(path, name: name)
137
+ self.modulepath = environment.modulepath
138
+ open(environment)
139
+ end
140
+
141
+ # Scan a Puppet environment from a raw zip byte string
142
+ #
143
+ # @param bytes [String] raw binary zip data (e.g. from File.binread or an HTTP body)
144
+ # @param name [String, nil] stable string identifier used as the modulepath
145
+ # and cache-key prefix; defaults to "-" when no filename is available.
146
+ # @return [NilClass]
147
+ def open_environment_zip_bytes(bytes, name: nil)
148
+ require 'compliance_engine/environment_loader/zip_bytes'
149
+
150
+ environment = ComplianceEngine::EnvironmentLoader::ZipBytes.new(bytes, name: name)
134
151
  self.modulepath = environment.modulepath
135
152
  open(environment)
136
153
  end
@@ -161,9 +178,17 @@ class ComplianceEngine::Data
161
178
 
162
179
  if path.is_a?(ComplianceEngine::ModuleLoader)
163
180
  modules[path.name] = path.version unless path.name.nil?
164
- path.files.each do |file_loader|
165
- update(file_loader)
166
- end
181
+ new_keys = path.files.to_set(&:key)
182
+ module_root = if path.zipfile_path
183
+ ::File.join(path.zipfile_path, '.', path.path.sub(%r{^/+}, ''))
184
+ else
185
+ path.path
186
+ end
187
+ module_prefix = ::File.join(module_root, '')
188
+ stale_keys = data.keys.select { |k| (k == module_root || k.start_with?(module_prefix)) && !new_keys.include?(k) }
189
+ stale_keys.each { |k| data.delete(k) }
190
+ path.files.each { |file_loader| update(file_loader) }
191
+ reset_collection if path.files.empty? && !stale_keys.empty?
167
192
  next
168
193
  end
169
194
 
@@ -221,7 +246,7 @@ class ComplianceEngine::Data
221
246
  loader.add_observer(self, :update)
222
247
  data[key] = {
223
248
  loader: loader,
224
- version: ComplianceEngine::Version.new(loader.data['version']),
249
+ version: ComplianceEngine::DataVersion.new(loader.data['version']),
225
250
  content: loader.data,
226
251
  }
227
252
  else
@@ -237,7 +262,7 @@ class ComplianceEngine::Data
237
262
  data[filename.key][:loader] = filename
238
263
  data[filename.key][:loader].add_observer(self, :update)
239
264
  end
240
- data[filename.key][:version] = ComplianceEngine::Version.new(filename.data['version'])
265
+ data[filename.key][:version] = ComplianceEngine::DataVersion.new(filename.data['version'])
241
266
  data[filename.key][:content] = filename.data
242
267
  end
243
268
 
@@ -306,7 +331,8 @@ class ComplianceEngine::Data
306
331
  v.to_a.each do |component|
307
332
  next unless component.key?('confine')
308
333
 
309
- @confines = DeepMerge.deep_merge!(component['confine'], @confines)
334
+ confine = component['confine'].transform_values { |val| val.is_a?(Array) ? val.dup : Array(val) }
335
+ @confines = DeepMerge.deep_merge!(confine, @confines, knockout_prefix: '--')
310
336
  end
311
337
  end
312
338
  end
@@ -348,10 +374,24 @@ class ComplianceEngine::Data
348
374
 
349
375
  valid_profiles.reverse_each do |profile|
350
376
  check_mapping(profile).each_value do |check|
351
- parameters = DeepMerge.deep_merge!(check.hiera, parameters)
377
+ hiera_data = check.hiera
378
+ next if hiera_data.nil?
379
+
380
+ parameters = DeepMerge.deep_merge!(Marshal.load(Marshal.dump(hiera_data)), parameters)
352
381
  end
353
382
  end
354
383
 
384
+ # deep_merge does not support hash-key knockout via knockout_prefix.
385
+ # Handle parameter-name knockout explicitly: any key starting with '--'
386
+ # signals that the matching key without the prefix should be suppressed
387
+ # (mirrors compliance_markup behavior).
388
+ parameters.each_key do |key|
389
+ next unless key.start_with?('--')
390
+
391
+ parameters.delete(key.delete_prefix('--'))
392
+ parameters.delete(key)
393
+ end
394
+
355
395
  @hiera[cache_key] = parameters
356
396
  end
357
397
 
@@ -415,6 +455,9 @@ class ComplianceEngine::Data
415
455
  cache_key = [check.key, "#{profile_or_ce.class}:#{profile_or_ce.key}"].to_s
416
456
  return @mapping[cache_key] if @mapping.key?(cache_key)
417
457
 
458
+ # An explicit `false` on the profile side hard-excludes the check, regardless of any other route.
459
+ return @mapping[cache_key] = false if profile_or_ce.is_a?(ComplianceEngine::Profile) && profile_or_ce.checks&.dig(check.key) == false
460
+
418
461
  # Correlate based on controls
419
462
  controls = check.controls&.select { |_, v| v }&.map { |k, _| k }
420
463
 
@@ -433,9 +476,10 @@ class ComplianceEngine::Data
433
476
  # Correlate based on CEs
434
477
  return @mapping[cache_key] = true if correlate(check.ces, profile_or_ce.ces)
435
478
 
436
- # Correlate based on CEs and controls
437
- return @mapping[cache_key] = true if profile_or_ce.ces&.any? { |k, _| correlate(controls, ces[k]&.controls) }
438
- return @mapping[cache_key] = true if check.ces&.any? { |ce| ces[ce]&.controls&.any? { |k, v| v && profile_or_ce.controls&.dig(k) } }
479
+ # Correlate based on CEs and controls. A CE explicitly set to `false` in the profile
480
+ # cannot be used as a bridge; absence (nil) still allows the bridge.
481
+ return @mapping[cache_key] = true if profile_or_ce.ces&.any? { |k, v| v && correlate(controls, ces[k]&.controls) }
482
+ return @mapping[cache_key] = true if check.ces&.any? { |ce| profile_or_ce.ces&.dig(ce) != false && ces[ce]&.controls&.any? { |k, v| v && profile_or_ce.controls&.dig(k) } }
439
483
 
440
484
  @mapping[cache_key] = false
441
485
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../compliance_engine'
4
+
5
+ # Validates the version field found in compliance data files.
6
+ # Currently only version 2.0.0 of the data format is supported.
7
+ class ComplianceEngine::DataVersion
8
+ # @param version [String] the version string from a compliance data file
9
+ # @raise [ComplianceEngine::Error] if version is nil or not '2.0.0'
10
+ def initialize(version)
11
+ raise ComplianceEngine::Error, 'Missing version' if version.nil?
12
+ raise ComplianceEngine::Error, "Unsupported version '#{version}'" unless version == '2.0.0'
13
+
14
+ @version = version
15
+ end
16
+
17
+ # @return [String]
18
+ def to_s
19
+ @version
20
+ end
21
+ end
@@ -7,18 +7,21 @@ require 'zip/filesystem'
7
7
  # Load compliance engine data from a zip file containing a Puppet environment
8
8
  class ComplianceEngine::EnvironmentLoader::Zip < ComplianceEngine::EnvironmentLoader
9
9
  # Initialize a ComplianceEngine::EnvironmentLoader::Zip object from a zip
10
- # file and an optional root directory.
10
+ # file path and an optional root directory.
11
11
  #
12
- # @param path [String] the path to the zip file containing the Puppet environment
12
+ # @param input [String] filesystem path to a zip file
13
13
  # @param root [String] a directory within the zip file to use as the root of the environment
14
- def initialize(path, root: '/'.dup)
15
- @modulepath = path
14
+ # @param load_dotfiles [Boolean] whether to load dotfiles; defaults to true to
15
+ # preserve the historical zip-loader behaviour of including all files
16
+ # @param name [String, nil] identifier used for modulepath and downstream
17
+ # cache keys; defaults to the full path string passed as +input+.
18
+ def initialize(input, root: '/'.dup, load_dotfiles: true, name: nil)
19
+ raise ArgumentError, "input must be a String path, got #{input.class}" unless input.is_a?(String)
16
20
 
17
- ::Zip::File.open(path) do |zipfile|
18
- dir = zipfile.dir
19
- file = zipfile.file
20
-
21
- super(root, fileclass: file, dirclass: dir, zipfile_path: path)
22
- end
21
+ zipfile = ::Zip::File.open(input)
22
+ @modulepath = name || input.to_s
23
+ super(root, fileclass: zipfile.file, dirclass: zipfile.dir, zipfile_path: @modulepath, load_dotfiles: load_dotfiles)
24
+ ensure
25
+ zipfile&.close
23
26
  end
24
27
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../compliance_engine'
4
+ require_relative '../environment_loader'
5
+ # zip/filesystem must be required before any Zip::File instance is opened so
6
+ # that the per-instance dir/file accessors are wired up at open time.
7
+ require 'zip/filesystem'
8
+ require 'zip'
9
+
10
+ # Load compliance engine data from a raw zip byte string containing a Puppet environment
11
+ class ComplianceEngine::EnvironmentLoader::ZipBytes < ComplianceEngine::EnvironmentLoader
12
+ # @param bytes [String] raw binary zip data (e.g. from File.binread or an HTTP body)
13
+ # @param root [String] a directory within the zip file to use as the root of the environment
14
+ # @param load_dotfiles [Boolean] whether to load dotfiles; defaults to true to
15
+ # preserve the historical zip-loader behaviour of including all files
16
+ # @param name [String, nil] identifier used for modulepath and downstream
17
+ # cache keys; defaults to "-" when no filename is available.
18
+ def initialize(bytes, root: '/'.dup, load_dotfiles: true, name: nil)
19
+ raise ArgumentError, "bytes must be a String, got #{bytes.class}" unless bytes.is_a?(String)
20
+
21
+ zipfile = ::Zip::File.open_buffer(bytes)
22
+ @modulepath = name || '-'
23
+ super(root, fileclass: zipfile.file, dirclass: zipfile.dir, zipfile_path: @modulepath, load_dotfiles: load_dotfiles)
24
+ ensure
25
+ zipfile&.close
26
+ end
27
+ end
@@ -11,7 +11,9 @@ class ComplianceEngine::EnvironmentLoader
11
11
  # @param fileclass [File] the class to use for file operations (default: `File`)
12
12
  # @param dirclass [Dir] the class to use for directory operations (default: `Dir`)
13
13
  # @param zipfile_path [String, nil] the path to the zip file if loading from a zip archive
14
- def initialize(*paths, fileclass: File, dirclass: Dir, zipfile_path: nil)
14
+ # @param load_dotfiles [Boolean] whether to load dotfiles; passed through to
15
+ # each ModuleLoader (default: false)
16
+ def initialize(*paths, fileclass: File, dirclass: Dir, zipfile_path: nil, load_dotfiles: false)
15
17
  raise ArgumentError, 'No paths specified' if paths.empty?
16
18
 
17
19
  @modulepath ||= paths
@@ -26,7 +28,7 @@ class ComplianceEngine::EnvironmentLoader
26
28
  []
27
29
  end
28
30
  modules.flatten!
29
- @modules = modules.map { |path| ComplianceEngine::ModuleLoader.new(path, fileclass: fileclass, dirclass: dirclass, zipfile_path: @zipfile_path) }
31
+ @modules = modules.map { |path| ComplianceEngine::ModuleLoader.new(path, fileclass: fileclass, dirclass: dirclass, zipfile_path: @zipfile_path, load_dotfiles: load_dotfiles) }
30
32
  end
31
33
 
32
34
  attr_reader :modulepath, :modules, :zipfile_path
@@ -12,12 +12,18 @@ class ComplianceEngine::ModuleLoader
12
12
  # @param fileclass [File] the class to use for file operations (default: `File`)
13
13
  # @param dirclass [Dir] the class to use for directory operations (default: `Dir`)
14
14
  # @param zipfile_path [String, nil] the path to the zip file if loading from a zip archive
15
- def initialize(path, fileclass: File, dirclass: Dir, zipfile_path: nil)
15
+ # @param load_dotfiles [Boolean] whether to load files whose relative path contains
16
+ # a component (directory or filename) beginning with '.'. Defaults to false so that
17
+ # dotfiles are skipped during normal module scanning, matching the behavior of
18
+ # Ruby's Dir.glob on real filesystems. Set to true only when the caller explicitly
19
+ # needs dotfile support (e.g. zip-based environment loading).
20
+ def initialize(path, fileclass: File, dirclass: Dir, zipfile_path: nil, load_dotfiles: false)
16
21
  raise ComplianceEngine::Error, "#{path} is not a directory" unless fileclass.directory?(path)
17
22
 
18
23
  @name = nil
19
24
  @version = nil
20
25
  @files = []
26
+ @path = path.to_s
21
27
  @zipfile_path = zipfile_path
22
28
 
23
29
  # Read the Puppet module's metadata.json
@@ -34,30 +40,42 @@ class ComplianceEngine::ModuleLoader
34
40
 
35
41
  # In this directory, we want to look for all yaml and json files
36
42
  # under SIMP/compliance_profiles and simp/compliance_profiles.
37
- globs = ['SIMP/compliance_profiles', 'simp/compliance_profiles']
38
- .select { |dir| fileclass.directory?(File.join(path, dir)) }
39
- .map { |dir|
40
- ['yaml', 'json'].map { |type| File.join(path, dir, '**', "*.#{type}") }
41
- }.flatten
42
- # Using .each here to make mocking with rspec easier.
43
- globs.each do |glob|
44
- dirclass.glob(glob).sort.each do |file|
45
- key = if @zipfile_path
46
- File.join(@zipfile_path, '.', file.to_s)
47
- else
48
- file.to_s
49
- end
50
- loader = if File.extname(file.to_s) == '.json'
51
- ComplianceEngine::DataLoader::Json.new(file.to_s, fileclass: fileclass, key: key)
52
- else
53
- ComplianceEngine::DataLoader::Yaml.new(file.to_s, fileclass: fileclass, key: key)
54
- end
55
- @files << loader
56
- rescue StandardError => e
57
- ComplianceEngine.log.warn "Could not load #{file}: #{e.message}"
43
+ # The loops are structured this way (rather than building a flat globs
44
+ # array first) so that each glob result can be checked against its
45
+ # base directory for dotfile filtering.
46
+ ['SIMP/compliance_profiles', 'simp/compliance_profiles'].each do |dir|
47
+ base = File.join(path, dir)
48
+ next unless fileclass.directory?(base)
49
+
50
+ # Using .each here to make mocking with rspec easier.
51
+ ['yaml', 'json'].each do |type|
52
+ dirclass.glob(File.join(base, '**', "*.#{type}")).sort.each do |file|
53
+ unless load_dotfiles
54
+ # Skip any file whose path (relative to the compliance_profiles
55
+ # base) contains a component beginning with '.', e.g. hidden
56
+ # files (.profile.yaml) or files inside hidden directories
57
+ # (.hidden/profile.yaml).
58
+ relative = file.to_s.delete_prefix("#{base}/")
59
+ next if relative.split('/').any? { |part| part.start_with?('.') }
60
+ end
61
+
62
+ key = if @zipfile_path
63
+ File.join(@zipfile_path, '.', file.to_s)
64
+ else
65
+ file.to_s
66
+ end
67
+ loader = if File.extname(file.to_s) == '.json'
68
+ ComplianceEngine::DataLoader::Json.new(file.to_s, fileclass: fileclass, key: key)
69
+ else
70
+ ComplianceEngine::DataLoader::Yaml.new(file.to_s, fileclass: fileclass, key: key)
71
+ end
72
+ @files << loader
73
+ rescue StandardError => e
74
+ ComplianceEngine.log.warn "Could not load #{file}: #{e.message}"
75
+ end
58
76
  end
59
77
  end
60
78
  end
61
79
 
62
- attr_reader :name, :version, :files, :zipfile_path
80
+ attr_reader :name, :version, :files, :path, :zipfile_path
63
81
  end
@@ -0,0 +1,517 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://simp-project.com/docs/sce/schema.json",
4
+ "type": "object",
5
+ "default": {},
6
+ "title": "Compliance Engine data",
7
+ "description": "Data for the Sicura Compliance Engine",
8
+ "required": [
9
+ "version"
10
+ ],
11
+ "additionalProperties": false,
12
+ "$defs": {
13
+ "confine": {
14
+ "type": "object",
15
+ "title": "Conditions that confine this entry to specific environments",
16
+ "description": "Keys are dot-notation Puppet fact paths (e.g. os.release.major) or the special keys module_name and module_version. Values may be a single scalar (exact match; strings may be prefixed with ! to negate) or an array of scalars (any match).",
17
+ "patternProperties": {
18
+ "^\\w+(?:[.\\-]\\w+)*$": {
19
+ "oneOf": [
20
+ {
21
+ "type": "string"
22
+ },
23
+ {
24
+ "type": "boolean"
25
+ },
26
+ {
27
+ "type": "integer"
28
+ },
29
+ {
30
+ "type": "array",
31
+ "items": {
32
+ "type": ["string", "boolean", "integer"]
33
+ }
34
+ }
35
+ ]
36
+ }
37
+ },
38
+ "examples": [
39
+ {
40
+ "os.release.major": ["8"],
41
+ "os.name": "RedHat"
42
+ },
43
+ {
44
+ "ipv6_enabled": true
45
+ },
46
+ {
47
+ "module_name": "simp-compliance_engine",
48
+ "module_version": ">=1.0.0 <2.0.0"
49
+ }
50
+ ]
51
+ },
52
+ "controlsMap": {
53
+ "type": "object",
54
+ "title": "References to top-level controls entries. Enforced when value is true; explicitly excluded when value is false.",
55
+ "patternProperties": {
56
+ "^\\w(?:[\\w.-]*[\\w-])?(?::\\w(?:[\\w.-]*[\\w-])?)*$": {
57
+ "type": "boolean",
58
+ "default": false
59
+ }
60
+ },
61
+ "additionalProperties": false,
62
+ "examples": [
63
+ {
64
+ "nist_800_53:rev4:AU-2": true
65
+ }
66
+ ]
67
+ },
68
+ "identifiers": {
69
+ "title": "External framework identifiers",
70
+ "description": "Preferred form: an object whose keys are framework names (e.g. nist_800_53:rev4, disa_stig) and whose values are arrays of identifier strings or numbers within that framework. A plain array of identifier strings is also accepted.",
71
+ "oneOf": [
72
+ {
73
+ "type": "object",
74
+ "patternProperties": {
75
+ "^\\w(?:[\\w.-]*[\\w-])?(?::\\w(?:[\\w.-]*[\\w-])?)*$": {
76
+ "type": "array",
77
+ "items": {
78
+ "type": ["string", "number"]
79
+ }
80
+ }
81
+ },
82
+ "examples": [
83
+ {
84
+ "nist_800_53:rev4": ["AU-2", "AU-3"],
85
+ "disa_stig": ["RHEL-07-021820", "CCI-000293"]
86
+ }
87
+ ]
88
+ },
89
+ {
90
+ "type": "array",
91
+ "items": {
92
+ "type": ["string", "number"]
93
+ },
94
+ "examples": [
95
+ ["ID1.1", "ID1.2"]
96
+ ]
97
+ }
98
+ ]
99
+ }
100
+ },
101
+ "properties": {
102
+ "version": {
103
+ "type": "string",
104
+ "const": "2.0.0",
105
+ "title": "The SCE data format version"
106
+ },
107
+ "profiles": {
108
+ "type": "object",
109
+ "default": {},
110
+ "title": "Collection of compliance profiles",
111
+ "description": "Each profile is a named checklist of Checks, CEs, and Controls to enforce.",
112
+ "patternProperties": {
113
+ "^\\w(?:[\\w.-]*[\\w-])?(?::\\w(?:[\\w.-]*[\\w-])?)*$": {
114
+ "type": "object",
115
+ "default": {},
116
+ "title": "A compliance profile",
117
+ "properties": {
118
+ "title": {
119
+ "type": "string",
120
+ "title": "Short description of the profile",
121
+ "examples": [
122
+ "Level 1 - Server"
123
+ ]
124
+ },
125
+ "description": {
126
+ "type": "string",
127
+ "title": "Longer description of the profile",
128
+ "examples": [
129
+ "Items in this profile intend to: be practical and prudent; provide a clear security benefit; and not inhibit the utility of the technology beyond acceptable means. This profile is intended for servers."
130
+ ]
131
+ },
132
+ "ces": {
133
+ "type": "object",
134
+ "default": {},
135
+ "title": "CEs to include in this profile. Enforced when value is true; explicitly excluded when value is false (overrides any positive mapping route).",
136
+ "patternProperties": {
137
+ "^\\w(?:[\\w.-]*[\\w-])?(?::\\w(?:[\\w.-]*[\\w-])?)*$": {
138
+ "type": "boolean",
139
+ "default": false
140
+ }
141
+ },
142
+ "examples": [
143
+ {
144
+ "oval:simp.cis.el8.1.6.2_Ensure_ASLR_is_enabled:def:1": true
145
+ }
146
+ ]
147
+ },
148
+ "controls": {
149
+ "$ref": "#/$defs/controlsMap"
150
+ },
151
+ "checks": {
152
+ "type": "object",
153
+ "default": {},
154
+ "title": "Checks to include in this profile. Enforced when value is true; hard-excluded when value is false (overrides any positive mapping route).",
155
+ "patternProperties": {
156
+ "^\\w(?:[\\w.-]*[\\w-])?(?::\\w(?:[\\w.-]*[\\w-])?)*$": {
157
+ "type": "boolean",
158
+ "default": false
159
+ }
160
+ },
161
+ "examples": [
162
+ {
163
+ "widget_spinner_audit_logging": true
164
+ }
165
+ ]
166
+ },
167
+ "confine": {
168
+ "$ref": "#/$defs/confine"
169
+ }
170
+ },
171
+ "examples": [
172
+ {
173
+ "title": "Level 1 - Server",
174
+ "description": "Items in this profile intend to: be practical and prudent; provide a clear security benefit; and not inhibit the utility of the technology beyond acceptable means. This profile is intended for servers.",
175
+ "controls": {
176
+ "cis:el8:l1": true
177
+ }
178
+ }
179
+ ]
180
+ }
181
+ },
182
+ "examples": [
183
+ {
184
+ "cis:el8:l1:server": {
185
+ "title": "CIS Level 1 - Server",
186
+ "controls": {
187
+ "cis:el8:l1": true
188
+ }
189
+ }
190
+ }
191
+ ]
192
+ },
193
+ "ce": {
194
+ "type": "object",
195
+ "default": {},
196
+ "title": "Collection of Compliance Elements (CEs)",
197
+ "description": "Each CE names a single compliance capability and bridges profiles to checks via a shared vocabulary.",
198
+ "patternProperties": {
199
+ "^\\w(?:[\\w.-]*[\\w-])?(?::\\w(?:[\\w.-]*[\\w-])?)*$": {
200
+ "type": "object",
201
+ "default": {},
202
+ "title": "A Compliance Element",
203
+ "properties": {
204
+ "title": {
205
+ "type": "string",
206
+ "title": "Short title of the compliance element",
207
+ "examples": [
208
+ "Ensure address space layout randomization (ASLR) is enabled"
209
+ ]
210
+ },
211
+ "description": {
212
+ "type": "string",
213
+ "title": "Detailed description of the compliance element",
214
+ "examples": [
215
+ "Address space layout randomization (ASLR) is an exploit mitigation technique which randomly arranges the address space of key data areas of a process."
216
+ ]
217
+ },
218
+ "controls": {
219
+ "$ref": "#/$defs/controlsMap"
220
+ },
221
+ "identifiers": {
222
+ "$ref": "#/$defs/identifiers"
223
+ },
224
+ "oval-ids": {
225
+ "type": "array",
226
+ "default": [],
227
+ "title": "OVAL identifiers associated with this CE",
228
+ "items": {
229
+ "type": "string"
230
+ },
231
+ "examples": [
232
+ [
233
+ "xccdf_org.cisecurity.benchmarks_rule_1.6.2_Ensure_ASLR_is_enabled"
234
+ ]
235
+ ]
236
+ },
237
+ "imported_data": {
238
+ "type": "object",
239
+ "default": {},
240
+ "title": "Data imported from external benchmark sources",
241
+ "properties": {
242
+ "fixtext": {
243
+ "type": "string",
244
+ "title": "Remediation instructions from the source benchmark"
245
+ }
246
+ },
247
+ "additionalProperties": true
248
+ },
249
+ "confine": {
250
+ "$ref": "#/$defs/confine"
251
+ }
252
+ },
253
+ "examples": [
254
+ {
255
+ "controls": {
256
+ "cis:el8:v1.0.0.1": true
257
+ },
258
+ "identifiers": {
259
+ "cis": ["1.6.2"],
260
+ "nist_800_53:rev4": ["SC-39"]
261
+ },
262
+ "title": "Ensure address space layout randomization (ASLR) is enabled",
263
+ "description": "Address space layout randomization (ASLR) is an exploit mitigation technique which randomly arranges the address space of key data areas of a process.",
264
+ "confine": {
265
+ "os.release.major": ["8"],
266
+ "os.name": ["RedHat"]
267
+ }
268
+ }
269
+ ]
270
+ }
271
+ }
272
+ },
273
+ "checks": {
274
+ "type": "object",
275
+ "default": {},
276
+ "title": "Collection of compliance checks",
277
+ "description": "Each check is a verifiable assertion about a system setting. Currently only type puppet-class-parameter is supported; it carries a parameter and value that become Hiera data.",
278
+ "patternProperties": {
279
+ "^\\w(?:[\\w.-]*[\\w-])?(?::\\w(?:[\\w.-]*[\\w-])?)*$": {
280
+ "type": "object",
281
+ "default": {},
282
+ "title": "A compliance check",
283
+ "properties": {
284
+ "type": {
285
+ "type": "string",
286
+ "const": "puppet-class-parameter",
287
+ "title": "The type of check"
288
+ },
289
+ "settings": {
290
+ "type": "object",
291
+ "title": "Settings for the check",
292
+ "required": ["parameter", "value"],
293
+ "properties": {
294
+ "parameter": {
295
+ "type": "string",
296
+ "title": "The fully-qualified Puppet class parameter name. Prefix with -- to knock out an existing parameter.",
297
+ "examples": [
298
+ "simp::sysctl::kernel__randomize_va_space",
299
+ "--simp::sysctl::kernel__randomize_va_space"
300
+ ]
301
+ },
302
+ "value": {
303
+ "title": "The value to enforce for the parameter",
304
+ "type": ["boolean", "integer", "number", "string", "array", "object", "null"],
305
+ "examples": [
306
+ 2,
307
+ true,
308
+ "weekly",
309
+ ["item1", "item2"]
310
+ ]
311
+ }
312
+ },
313
+ "examples": [
314
+ {
315
+ "parameter": "simp::sysctl::kernel__randomize_va_space",
316
+ "value": 2
317
+ }
318
+ ]
319
+ },
320
+ "ces": {
321
+ "type": "array",
322
+ "default": [],
323
+ "title": "CEs that this check satisfies",
324
+ "items": {
325
+ "type": "string"
326
+ },
327
+ "examples": [
328
+ [
329
+ "oval:simp.cis.el8.1.6.2_Ensure_ASLR_is_enabled:def:1"
330
+ ]
331
+ ]
332
+ },
333
+ "controls": {
334
+ "$ref": "#/$defs/controlsMap"
335
+ },
336
+ "identifiers": {
337
+ "$ref": "#/$defs/identifiers"
338
+ },
339
+ "confine": {
340
+ "$ref": "#/$defs/confine"
341
+ },
342
+ "remediation": {
343
+ "type": "object",
344
+ "title": "Remediation metadata used to filter checks by enforcement tolerance",
345
+ "properties": {
346
+ "risk": {
347
+ "type": "array",
348
+ "title": "Risk entries describing the potential impact of enforcing this check",
349
+ "items": {
350
+ "type": "object",
351
+ "properties": {
352
+ "level": {
353
+ "type": "integer",
354
+ "minimum": 1,
355
+ "maximum": 100,
356
+ "title": "Numeric risk level (1-100). Checks are skipped when this value is >= the enforcement_tolerance setting."
357
+ },
358
+ "reason": {
359
+ "type": "string",
360
+ "title": "Human-readable explanation of the risk"
361
+ }
362
+ }
363
+ },
364
+ "examples": [
365
+ [
366
+ {
367
+ "level": 41,
368
+ "reason": "Depending on the system being scanned, aide could cause system sluggishness during the period it performs its scan."
369
+ }
370
+ ]
371
+ ]
372
+ },
373
+ "disabled": {
374
+ "type": "array",
375
+ "title": "Entries indicating this check is disabled. Disabled checks are always skipped when enforcement_tolerance is set.",
376
+ "items": {
377
+ "type": "object",
378
+ "properties": {
379
+ "reason": {
380
+ "type": "string",
381
+ "title": "Human-readable explanation of why this check is disabled"
382
+ }
383
+ }
384
+ },
385
+ "examples": [
386
+ [
387
+ {
388
+ "reason": "This check is not applicable to containerized environments."
389
+ }
390
+ ]
391
+ ]
392
+ }
393
+ }
394
+ }
395
+ },
396
+ "if": {
397
+ "required": ["type"]
398
+ },
399
+ "then": {
400
+ "required": ["settings"]
401
+ },
402
+ "examples": [
403
+ {
404
+ "type": "puppet-class-parameter",
405
+ "settings": {
406
+ "parameter": "simp::sysctl::kernel__randomize_va_space",
407
+ "value": 2
408
+ },
409
+ "controls": {
410
+ "nist_800_53:rev4:SC-39": true
411
+ },
412
+ "confine": {
413
+ "os.family": "RedHat",
414
+ "os.release.major": ["7", "8", "9"]
415
+ }
416
+ }
417
+ ]
418
+ }
419
+ }
420
+ },
421
+ "controls": {
422
+ "type": "object",
423
+ "default": {},
424
+ "title": "Collection of compliance controls",
425
+ "description": "Controls are cross-reference labels from external frameworks (e.g. nist_800_53:rev4:AU-2, disa_stig). Profiles and checks annotate themselves with controls to express alignment. Control entries carry optional metadata; an empty object {} is valid.",
426
+ "patternProperties": {
427
+ "^\\w(?:[\\w.-]*[\\w-])?(?::\\w(?:[\\w.-]*[\\w-])?)*$": {
428
+ "type": "object",
429
+ "default": {},
430
+ "title": "A compliance control",
431
+ "properties": {
432
+ "title": {
433
+ "type": "string",
434
+ "title": "Short title of the control"
435
+ },
436
+ "description": {
437
+ "type": "string",
438
+ "title": "Description of the control"
439
+ },
440
+ "identifiers": {
441
+ "$ref": "#/$defs/identifiers"
442
+ },
443
+ "confine": {
444
+ "$ref": "#/$defs/confine"
445
+ }
446
+ },
447
+ "examples": [
448
+ {},
449
+ {
450
+ "title": "Audit Events",
451
+ "identifiers": {
452
+ "nist_800_53:rev4": ["AU-2"]
453
+ }
454
+ }
455
+ ]
456
+ }
457
+ },
458
+ "examples": [
459
+ {
460
+ "nist_800_53:rev4": {},
461
+ "disa_stig": {},
462
+ "nist_800_53:rev4:AU-2": {
463
+ "title": "Audit Events"
464
+ }
465
+ }
466
+ ]
467
+ }
468
+ },
469
+ "examples": [
470
+ {
471
+ "version": "2.0.0",
472
+ "profiles": {
473
+ "cis:el8:l1:server": {
474
+ "title": "CIS Level 1 - Server",
475
+ "controls": {
476
+ "cis:el8:l1": true
477
+ }
478
+ }
479
+ },
480
+ "ce": {
481
+ "oval:simp.cis.el8.1.6.2_Ensure_ASLR_is_enabled:def:1": {
482
+ "controls": {
483
+ "cis:el8:l1": true
484
+ },
485
+ "identifiers": {
486
+ "cis": ["1.6.2"],
487
+ "nist_800_53:rev4": ["SC-39"]
488
+ },
489
+ "title": "Ensure address space layout randomization (ASLR) is enabled",
490
+ "description": "ASLR is an exploit mitigation technique which randomly arranges the address space of key data areas of a process.",
491
+ "confine": {
492
+ "os.release.major": ["8"],
493
+ "os.name": ["RedHat"]
494
+ }
495
+ }
496
+ },
497
+ "checks": {
498
+ "oval:com.puppet.forge.simp.cis.simp.sysctl.kernel__randomize_va_space": {
499
+ "type": "puppet-class-parameter",
500
+ "settings": {
501
+ "parameter": "simp::sysctl::kernel__randomize_va_space",
502
+ "value": 2
503
+ },
504
+ "controls": {
505
+ "cis:el8:l1": true
506
+ },
507
+ "confine": {
508
+ "os.family": "RedHat"
509
+ }
510
+ }
511
+ },
512
+ "controls": {
513
+ "cis:el8:l1": {}
514
+ }
515
+ }
516
+ ]
517
+ }
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ComplianceEngine
4
+ # Named constants for enforcement tolerance levels.
5
+ #
6
+ # The enforcement tolerance controls which remediation risk levels are
7
+ # enforced. A check whose risk level is greater than or equal to the
8
+ # tolerance value is filtered out. For example, setting
9
+ # +enforcement_tolerance+ to +ComplianceEngine::Tolerance::MODERATE+
10
+ # enforces checks with risk levels below 60 while skipping anything
11
+ # rated MODERATE (60) or higher.
12
+ module Tolerance
13
+ # Enforce only checks with no meaningful risk (risk < 20).
14
+ NONE = 20
15
+
16
+ # Enforce checks below low-risk remediations; skip SAFE (40) and above (risk < 40).
17
+ SAFE = 40
18
+
19
+ # Enforce checks below moderate-risk remediations; skip MODERATE (60) and above (risk < 60).
20
+ MODERATE = 60
21
+
22
+ # Enforce checks below remediations that affect access; skip ACCESS (80) and above (risk < 80).
23
+ ACCESS = 80
24
+
25
+ # Enforce all checks, including those that may cause breaking changes (risk < 100).
26
+ BREAKING = 100
27
+ end
28
+ end
@@ -1,25 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ComplianceEngine
4
- VERSION = '0.4.0'
5
-
6
- # Handle supported compliance data versions
7
- class Version
8
- # Verify that the version is supported
9
- #
10
- # @param version [String] The version to verify
11
- def initialize(version)
12
- raise 'Missing version' if version.nil?
13
- raise "Unsupported version '#{version}'" unless version == '2.0.0'
14
-
15
- @version = version
16
- end
17
-
18
- # Convert the version to a string
19
- #
20
- # @return [String]
21
- def to_s
22
- @version
23
- end
24
- end
4
+ VERSION = '0.6.0'
25
5
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'compliance_engine/version'
4
+ require_relative 'compliance_engine/tolerance'
4
5
  require_relative 'compliance_engine/data'
5
6
  require 'logger'
6
7
 
@@ -42,6 +43,44 @@ module ComplianceEngine
42
43
  @log = value
43
44
  end
44
45
 
46
+ # Return the path to the bundled JSON schema for SCE data files
47
+ #
48
+ # @return [String] absolute path to sce-schema.json
49
+ def self.schema_path
50
+ File.expand_path(File.join(__dir__, 'compliance_engine', 'sce-schema.json'))
51
+ end
52
+
53
+ # Return the parsed JSON schema for SCE data files
54
+ #
55
+ # @return [Hash] the parsed JSON schema
56
+ def self.schema
57
+ require 'json'
58
+ @schema ||= begin
59
+ path = schema_path
60
+ deep_freeze(JSON.parse(File.read(path, encoding: 'UTF-8')))
61
+ rescue Errno::ENOENT, JSON::ParserError => e
62
+ raise Error, "Failed to load schema from #{path}: #{e.class}: #{e.message}"
63
+ end
64
+ end
65
+
66
+ # Recursively freeze a Hash, Array, and all nested objects.
67
+ #
68
+ # @param obj [Object] the object to freeze
69
+ # @return [Object] the frozen object
70
+ def self.deep_freeze(obj)
71
+ case obj
72
+ when Hash
73
+ obj.each do |k, v|
74
+ deep_freeze(k)
75
+ deep_freeze(v)
76
+ end
77
+ when Array
78
+ obj.each { |v| deep_freeze(v) }
79
+ end
80
+ obj.freeze
81
+ end
82
+ private_class_method :deep_freeze
83
+
45
84
  # Install a PuppetLogger unless a logger has already been explicitly configured.
46
85
  # Extracted so the behaviour can be unit-tested without reloading enforcement.rb.
47
86
  #
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: compliance_engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Steven Pritchard
@@ -142,12 +142,16 @@ files:
142
142
  - lib/compliance_engine/data_loader/file.rb
143
143
  - lib/compliance_engine/data_loader/json.rb
144
144
  - lib/compliance_engine/data_loader/yaml.rb
145
+ - lib/compliance_engine/data_version.rb
145
146
  - lib/compliance_engine/environment_loader.rb
146
147
  - lib/compliance_engine/environment_loader/zip.rb
148
+ - lib/compliance_engine/environment_loader/zip_bytes.rb
147
149
  - lib/compliance_engine/module_loader.rb
148
150
  - lib/compliance_engine/profile.rb
149
151
  - lib/compliance_engine/profiles.rb
150
152
  - lib/compliance_engine/puppet_logger.rb
153
+ - lib/compliance_engine/sce-schema.json
154
+ - lib/compliance_engine/tolerance.rb
151
155
  - lib/compliance_engine/version.rb
152
156
  homepage: https://simp-project.com/docs/sce/
153
157
  licenses:
@@ -155,7 +159,7 @@ licenses:
155
159
  metadata:
156
160
  homepage_uri: https://simp-project.com/docs/sce/
157
161
  source_code_uri: https://github.com/simp/rubygem-simp-compliance_engine
158
- changelog_uri: https://github.com/simp/rubygem-simp-compliance_engine/releases/tag/0.4.0
162
+ changelog_uri: https://github.com/simp/rubygem-simp-compliance_engine/releases/tag/0.6.0
159
163
  bug_tracker_uri: https://github.com/simp/rubygem-simp-compliance_engine/issues
160
164
  rdoc_options: []
161
165
  require_paths: