moku6 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +131 -0
- data/exe/moku6 +6 -0
- data/lib/moku6/catalog.rb +23 -0
- data/lib/moku6/cli.rb +118 -0
- data/lib/moku6/config.rb +61 -0
- data/lib/moku6/differ.rb +74 -0
- data/lib/moku6/envelope_schema.rb +56 -0
- data/lib/moku6/event.rb +54 -0
- data/lib/moku6/generate.rb +95 -0
- data/lib/moku6/generators/base_generator.rb +31 -0
- data/lib/moku6/generators/bigquery_generator.rb +29 -0
- data/lib/moku6/generators/cloud_events_generator.rb +47 -0
- data/lib/moku6/generators/docs_generator.rb +49 -0
- data/lib/moku6/generators/event_catalog_generator.rb +57 -0
- data/lib/moku6/generators/example_generator.rb +66 -0
- data/lib/moku6/generators/json_schema_generator.rb +23 -0
- data/lib/moku6/generators/open_api_generator.rb +60 -0
- data/lib/moku6/generators/outbox_generator.rb +96 -0
- data/lib/moku6/generators/rails_generator.rb +84 -0
- data/lib/moku6/generators/ruby_generator.rb +27 -0
- data/lib/moku6/generators/typescript_generator.rb +59 -0
- data/lib/moku6/generators.rb +29 -0
- data/lib/moku6/initializer.rb +44 -0
- data/lib/moku6/linter.rb +60 -0
- data/lib/moku6/loader.rb +31 -0
- data/lib/moku6/offense.rb +13 -0
- data/lib/moku6/reporter.rb +71 -0
- data/lib/moku6/result.rb +23 -0
- data/lib/moku6/rules/action_naming_rule.rb +18 -0
- data/lib/moku6/rules/base_rule.rb +37 -0
- data/lib/moku6/rules/example_consistency_rule.rb +53 -0
- data/lib/moku6/rules/label_description_rule.rb +21 -0
- data/lib/moku6/rules/pii_field_name_heuristic_rule.rb +45 -0
- data/lib/moku6/rules/privacy_masking_rule.rb +21 -0
- data/lib/moku6/rules/retention_rule.rb +19 -0
- data/lib/moku6/rules/schema_rule.rb +31 -0
- data/lib/moku6/rules/uniqueness_rule.rb +22 -0
- data/lib/moku6/rules/visibility_rule.rb +27 -0
- data/lib/moku6/version.rb +6 -0
- data/lib/moku6.rb +55 -0
- data/schemas/audit-event.schema.json +85 -0
- data/sig/generated/moku6/catalog.rbs +22 -0
- data/sig/generated/moku6/config.rbs +43 -0
- data/sig/generated/moku6/differ.rbs +28 -0
- data/sig/generated/moku6/envelope_schema.rbs +19 -0
- data/sig/generated/moku6/event.rbs +51 -0
- data/sig/generated/moku6/generators/base_generator.rbs +22 -0
- data/sig/generated/moku6/generators/bigquery_generator.rbs +12 -0
- data/sig/generated/moku6/generators/cloud_events_generator.rbs +19 -0
- data/sig/generated/moku6/generators/docs_generator.rbs +18 -0
- data/sig/generated/moku6/generators/event_catalog_generator.rbs +16 -0
- data/sig/generated/moku6/generators/example_generator.rbs +23 -0
- data/sig/generated/moku6/generators/json_schema_generator.rbs +12 -0
- data/sig/generated/moku6/generators/open_api_generator.rbs +20 -0
- data/sig/generated/moku6/generators/outbox_generator.rbs +23 -0
- data/sig/generated/moku6/generators/rails_generator.rbs +23 -0
- data/sig/generated/moku6/generators/ruby_generator.rbs +15 -0
- data/sig/generated/moku6/generators/typescript_generator.rbs +20 -0
- data/sig/generated/moku6/generators.rbs +13 -0
- data/sig/generated/moku6/initializer.rbs +23 -0
- data/sig/generated/moku6/linter.rbs +31 -0
- data/sig/generated/moku6/loader.rbs +15 -0
- data/sig/generated/moku6/reporter.rbs +30 -0
- data/sig/generated/moku6/result.rbs +22 -0
- data/sig/generated/moku6/rules/action_naming_rule.rbs +10 -0
- data/sig/generated/moku6/rules/base_rule.rbs +23 -0
- data/sig/generated/moku6/rules/example_consistency_rule.rbs +18 -0
- data/sig/generated/moku6/rules/label_description_rule.rbs +15 -0
- data/sig/generated/moku6/rules/pii_field_name_heuristic_rule.rbs +21 -0
- data/sig/generated/moku6/rules/privacy_masking_rule.rbs +10 -0
- data/sig/generated/moku6/rules/retention_rule.rbs +10 -0
- data/sig/generated/moku6/rules/schema_rule.rbs +15 -0
- data/sig/generated/moku6/rules/uniqueness_rule.rbs +11 -0
- data/sig/generated/moku6/rules/visibility_rule.rbs +10 -0
- data/sig/generated/moku6/version.rbs +5 -0
- data/sig/generated/moku6.rbs +9 -0
- data/sig/manual/dependencies.rbs +13 -0
- data/sig/manual/offense.rbs +13 -0
- data/templates/init/.moku6.yml +18 -0
- data/templates/init/catalog/employee.updated.yaml +36 -0
- metadata +141 -0
data/lib/moku6/linter.rb
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Moku6
|
|
5
|
+
class Linter
|
|
6
|
+
EVENT_RULES = [
|
|
7
|
+
Rules::SchemaRule,
|
|
8
|
+
Rules::ActionNamingRule,
|
|
9
|
+
Rules::LabelDescriptionRule,
|
|
10
|
+
Rules::PrivacyMaskingRule,
|
|
11
|
+
Rules::VisibilityRule,
|
|
12
|
+
Rules::RetentionRule,
|
|
13
|
+
Rules::ExampleConsistencyRule,
|
|
14
|
+
Rules::PiiFieldNameHeuristicRule
|
|
15
|
+
].freeze #: Array[singleton(Rules::BaseRule)]
|
|
16
|
+
|
|
17
|
+
CATALOG_RULES = [
|
|
18
|
+
Rules::UniquenessRule
|
|
19
|
+
].freeze #: Array[singleton(Rules::BaseRule)]
|
|
20
|
+
|
|
21
|
+
# @rbs @catalog: Catalog
|
|
22
|
+
# @rbs @config: Config
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
# Extension points for consumers to register custom rules (design section 13).
|
|
26
|
+
|
|
27
|
+
#: (singleton(Rules::BaseRule) klass) -> Array[singleton(Rules::BaseRule)]
|
|
28
|
+
def register_event_rule(klass) = custom_event_rules << klass
|
|
29
|
+
|
|
30
|
+
#: (singleton(Rules::BaseRule) klass) -> Array[singleton(Rules::BaseRule)]
|
|
31
|
+
def register_catalog_rule(klass) = custom_catalog_rules << klass
|
|
32
|
+
|
|
33
|
+
#: () -> Array[singleton(Rules::BaseRule)]
|
|
34
|
+
def custom_event_rules = @custom_event_rules ||= []
|
|
35
|
+
|
|
36
|
+
#: () -> Array[singleton(Rules::BaseRule)]
|
|
37
|
+
def custom_catalog_rules = @custom_catalog_rules ||= []
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
#: (Catalog catalog, Config config) -> void
|
|
41
|
+
def initialize(catalog, config)
|
|
42
|
+
@catalog = catalog
|
|
43
|
+
@config = config
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
#: () -> Result
|
|
47
|
+
def run
|
|
48
|
+
offenses = []
|
|
49
|
+
(CATALOG_RULES + self.class.custom_catalog_rules).each do |rule_class|
|
|
50
|
+
offenses.concat(rule_class.new(@config).check_catalog(@catalog)) # steep:ignore NoMethod
|
|
51
|
+
end
|
|
52
|
+
@catalog.events.each do |event|
|
|
53
|
+
(EVENT_RULES + self.class.custom_event_rules).each do |rule_class|
|
|
54
|
+
offenses.concat(rule_class.new(@config).check(event))
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
Result.new(offenses)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
data/lib/moku6/loader.rb
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Moku6
|
|
5
|
+
class Loader
|
|
6
|
+
PERMITTED_CLASSES = [Symbol].freeze #: Array[Class]
|
|
7
|
+
|
|
8
|
+
# @rbs @catalog_dir: String
|
|
9
|
+
|
|
10
|
+
#: (String catalog_dir) -> void
|
|
11
|
+
def initialize(catalog_dir) = @catalog_dir = catalog_dir
|
|
12
|
+
|
|
13
|
+
#: () -> Catalog
|
|
14
|
+
def load
|
|
15
|
+
unless Dir.exist?(@catalog_dir)
|
|
16
|
+
raise UsageError, "catalog directory not found: #{@catalog_dir}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
paths = Dir.glob(File.join(@catalog_dir, "**", "*.{yml,yaml}")).sort
|
|
20
|
+
events = paths.map do |path|
|
|
21
|
+
data = begin
|
|
22
|
+
YAML.safe_load_file(path, permitted_classes: PERMITTED_CLASSES)
|
|
23
|
+
rescue Psych::SyntaxError => e
|
|
24
|
+
raise UsageError, "failed to parse YAML (#{path}): #{e.message}"
|
|
25
|
+
end
|
|
26
|
+
Event.new(data, source_path: path)
|
|
27
|
+
end
|
|
28
|
+
Catalog.new(events)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moku6
|
|
4
|
+
# keyword_init is required on Ruby 3.1 (required_ruby_version >= 3.1.0).
|
|
5
|
+
Offense = Struct.new(
|
|
6
|
+
:rule, # String rule id
|
|
7
|
+
:severity, # Symbol :error | :warning
|
|
8
|
+
:action, # String target action (nil if unknown)
|
|
9
|
+
:file, # String source file path
|
|
10
|
+
:message, # String human-readable message
|
|
11
|
+
keyword_init: true # standard:disable Style/RedundantStructKeywordInit
|
|
12
|
+
)
|
|
13
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Moku6
|
|
5
|
+
module Reporter
|
|
6
|
+
#: (String? format) -> untyped
|
|
7
|
+
def self.for(format)
|
|
8
|
+
case format
|
|
9
|
+
when "json" then Json
|
|
10
|
+
when "markdown" then Markdown
|
|
11
|
+
else Text
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
module Text
|
|
16
|
+
#: (Result result) -> void
|
|
17
|
+
def self.report(result)
|
|
18
|
+
result.offenses.each do |o|
|
|
19
|
+
tag = (o.severity == :error) ? "ERROR" : "WARN "
|
|
20
|
+
puts "#{tag} #{o.file} #{o.action} #{o.message}"
|
|
21
|
+
end
|
|
22
|
+
e = result.errors.size
|
|
23
|
+
w = result.warnings.size
|
|
24
|
+
puts "\n#{e} error#{"s" if e != 1}, #{w} warning#{"s" if w != 1}"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
module Json
|
|
29
|
+
#: (Result result) -> void
|
|
30
|
+
def self.report(result)
|
|
31
|
+
puts JSON.pretty_generate(
|
|
32
|
+
summary: {errors: result.errors.size, warnings: result.warnings.size},
|
|
33
|
+
offenses: result.offenses.map(&:to_h)
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Review-friendly Markdown report (e.g. for posting as a PR comment).
|
|
39
|
+
module Markdown
|
|
40
|
+
#: (Result result) -> void
|
|
41
|
+
def self.report(result) = puts render(result)
|
|
42
|
+
|
|
43
|
+
#: (Result result) -> String
|
|
44
|
+
def self.render(result)
|
|
45
|
+
errors = result.errors
|
|
46
|
+
warnings = result.warnings
|
|
47
|
+
out = ["# Audit catalog report", ""]
|
|
48
|
+
if result.empty?
|
|
49
|
+
out << "No issues detected. ✅"
|
|
50
|
+
return out.join("\n") + "\n"
|
|
51
|
+
end
|
|
52
|
+
out << "**#{errors.size} error#{"s" if errors.size != 1}, " \
|
|
53
|
+
"#{warnings.size} warning#{"s" if warnings.size != 1}** detected."
|
|
54
|
+
out.concat(section("❌ Errors", errors))
|
|
55
|
+
out.concat(section("⚠️ Warnings", warnings))
|
|
56
|
+
out.join("\n") + "\n"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
#: (String title, Array[Offense] offenses) -> Array[String]
|
|
60
|
+
def self.section(title, offenses)
|
|
61
|
+
return [] if offenses.empty?
|
|
62
|
+
|
|
63
|
+
lines = ["", "## #{title}", ""]
|
|
64
|
+
offenses.each do |o|
|
|
65
|
+
lines << "- `#{o.action}` — #{o.message} (`#{o.file}`)"
|
|
66
|
+
end
|
|
67
|
+
lines
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
data/lib/moku6/result.rb
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Moku6
|
|
5
|
+
class Result
|
|
6
|
+
attr_reader :offenses #: Array[Offense]
|
|
7
|
+
|
|
8
|
+
#: (Array[Offense] offenses) -> void
|
|
9
|
+
def initialize(offenses) = @offenses = offenses
|
|
10
|
+
|
|
11
|
+
#: () -> Array[Offense]
|
|
12
|
+
def errors = offenses.select { |o| o.severity == :error }
|
|
13
|
+
|
|
14
|
+
#: () -> Array[Offense]
|
|
15
|
+
def warnings = offenses.select { |o| o.severity == :warning }
|
|
16
|
+
|
|
17
|
+
#: () -> bool
|
|
18
|
+
def errors? = errors.any?
|
|
19
|
+
|
|
20
|
+
#: () -> bool
|
|
21
|
+
def empty? = offenses.empty?
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Moku6
|
|
5
|
+
module Rules
|
|
6
|
+
class ActionNamingRule < BaseRule
|
|
7
|
+
#: (Event event) -> Array[Offense]
|
|
8
|
+
def check(event)
|
|
9
|
+
pattern = Regexp.new(@config.naming_pattern.to_s)
|
|
10
|
+
return [] if event.action.to_s.match?(pattern)
|
|
11
|
+
|
|
12
|
+
[offense(event, :error,
|
|
13
|
+
"action '#{event.action}' violates the naming convention " \
|
|
14
|
+
"(expected: namespace.verb form, lowercase, dot-separated).")]
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Moku6
|
|
5
|
+
module Rules
|
|
6
|
+
class BaseRule
|
|
7
|
+
# @rbs @config: Config
|
|
8
|
+
|
|
9
|
+
#: (Config config) -> void
|
|
10
|
+
def initialize(config) = @config = config
|
|
11
|
+
|
|
12
|
+
#: (Event event) -> Array[Offense]
|
|
13
|
+
def check(_event) = raise NotImplementedError
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
#: (Event event, Symbol severity, String message, ?rule: String) -> Offense
|
|
18
|
+
def offense(event, severity, message, rule: rule_id)
|
|
19
|
+
Offense.new(
|
|
20
|
+
rule: rule,
|
|
21
|
+
severity: severity,
|
|
22
|
+
action: event.action,
|
|
23
|
+
file: event.source_path,
|
|
24
|
+
message: message
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
#: () -> String
|
|
29
|
+
def rule_id
|
|
30
|
+
self.class.name.split("::").last
|
|
31
|
+
.gsub(/Rule\z/, "")
|
|
32
|
+
.gsub(/([a-z])([A-Z])/, '\1_\2')
|
|
33
|
+
.downcase
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "senko"
|
|
5
|
+
|
|
6
|
+
module Moku6
|
|
7
|
+
module Rules
|
|
8
|
+
class ExampleConsistencyRule < BaseRule
|
|
9
|
+
#: (Event event) -> Array[Offense]
|
|
10
|
+
def check(event)
|
|
11
|
+
examples = collect_examples(event)
|
|
12
|
+
return [] if examples.empty?
|
|
13
|
+
|
|
14
|
+
schemer = Senko.compile(EnvelopeSchema.for(event))
|
|
15
|
+
examples.each_with_index.flat_map do |example, idx|
|
|
16
|
+
result = schemer.validate(example)
|
|
17
|
+
next [] if result.valid?
|
|
18
|
+
|
|
19
|
+
result.errors.map do |err|
|
|
20
|
+
h = err.to_h
|
|
21
|
+
location = h["instanceLocation"].to_s
|
|
22
|
+
pointer = location.empty? ? "(root)" : location
|
|
23
|
+
keyword = h["keywordLocation"].to_s.split("/").last
|
|
24
|
+
offense(event, :error,
|
|
25
|
+
"examples[#{idx}] does not match the definition #{pointer}: #{keyword}",
|
|
26
|
+
rule: "example_consistency")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
#: (Event event) -> Array[untyped]
|
|
34
|
+
def collect_examples(event)
|
|
35
|
+
inline = event.examples
|
|
36
|
+
return inline if inline.is_a?(Array) && !inline.empty?
|
|
37
|
+
|
|
38
|
+
path = external_path(event)
|
|
39
|
+
return [] unless path && File.exist?(path)
|
|
40
|
+
|
|
41
|
+
data = JSON.parse(File.read(path))
|
|
42
|
+
data.is_a?(Array) ? data : [data]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
#: (Event event) -> String?
|
|
46
|
+
def external_path(event)
|
|
47
|
+
return nil unless event.action
|
|
48
|
+
|
|
49
|
+
File.join(@config.examples_dir, "#{event.action}.json")
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Moku6
|
|
5
|
+
module Rules
|
|
6
|
+
class LabelDescriptionRule < BaseRule
|
|
7
|
+
#: (Event event) -> Array[Offense]
|
|
8
|
+
def check(event)
|
|
9
|
+
offenses = []
|
|
10
|
+
offenses << offense(event, :error, "label is missing.") if blank?(event.label)
|
|
11
|
+
offenses << offense(event, :error, "description is missing.") if blank?(event.description)
|
|
12
|
+
offenses
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
#: (untyped value) -> bool
|
|
18
|
+
def blank?(value) = value.nil? || value.to_s.strip.empty?
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Moku6
|
|
5
|
+
module Rules
|
|
6
|
+
# Warns when a field name looks like PII (email/phone/ssn, ...) but is not
|
|
7
|
+
# covered by privacy.masked_fields (design section 10.1, v0.2).
|
|
8
|
+
class PiiFieldNameHeuristicRule < BaseRule
|
|
9
|
+
PII_PATTERN = /
|
|
10
|
+
email | phone | tel | mobile | fax |
|
|
11
|
+
ssn | mynumber | my_number | passport | license |
|
|
12
|
+
credit_?card | card_?number | cvv |
|
|
13
|
+
address | postal | zip |
|
|
14
|
+
birth | dob | password | secret
|
|
15
|
+
/xi #: Regexp
|
|
16
|
+
|
|
17
|
+
#: (Event event) -> Array[Offense]
|
|
18
|
+
def check(event)
|
|
19
|
+
return [] unless @config.warn_pii_field_names?
|
|
20
|
+
|
|
21
|
+
masked = masked_field_tokens(event)
|
|
22
|
+
event.fields.filter_map do |name, _f|
|
|
23
|
+
next unless name.to_s.match?(PII_PATTERN)
|
|
24
|
+
next if masked.include?(name.to_s)
|
|
25
|
+
|
|
26
|
+
offense(event, :warning,
|
|
27
|
+
"field '#{name}' looks like personal data but is not listed in privacy.masked_fields.",
|
|
28
|
+
rule: "pii_field_name_heuristic")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# Tokens that count a field as masked: the full masked path and each of its
|
|
35
|
+
# dot-separated segments (e.g. "before.email" covers "email").
|
|
36
|
+
#: (Event event) -> Array[String]
|
|
37
|
+
def masked_field_tokens(event)
|
|
38
|
+
list = (event.privacy.is_a?(Hash) ? event.privacy["masked_fields"] : nil) #: Array[untyped]?
|
|
39
|
+
return [] unless list.is_a?(Array)
|
|
40
|
+
|
|
41
|
+
list.flat_map { |path| [path.to_s] + path.to_s.split(".") }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Moku6
|
|
5
|
+
module Rules
|
|
6
|
+
class PrivacyMaskingRule < BaseRule
|
|
7
|
+
#: (Event event) -> Array[Offense]
|
|
8
|
+
def check(event)
|
|
9
|
+
privacy = event.privacy
|
|
10
|
+
return [] unless privacy && privacy["contains_personal_data"]
|
|
11
|
+
|
|
12
|
+
masked = privacy["masked_fields"]
|
|
13
|
+
return [] if masked.is_a?(Array) && masked.any?
|
|
14
|
+
|
|
15
|
+
[offense(event, :error,
|
|
16
|
+
"contains personal data (contains_personal_data: true) but masked_fields is not set. " \
|
|
17
|
+
"List the fields to mask under privacy.masked_fields.")]
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Moku6
|
|
5
|
+
module Rules
|
|
6
|
+
class RetentionRule < BaseRule
|
|
7
|
+
#: (Event event) -> Array[Offense]
|
|
8
|
+
def check(event)
|
|
9
|
+
retention = event.retention
|
|
10
|
+
years = retention.is_a?(Hash) ? retention["years"] : nil
|
|
11
|
+
return [] if years.is_a?(Integer) && years >= 1
|
|
12
|
+
|
|
13
|
+
[offense(event, :error,
|
|
14
|
+
"retention.years must be a positive integer.",
|
|
15
|
+
rule: "retention_present")]
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "senko"
|
|
5
|
+
|
|
6
|
+
module Moku6
|
|
7
|
+
module Rules
|
|
8
|
+
class SchemaRule < BaseRule
|
|
9
|
+
SCHEMA_PATH = File.expand_path("../../../schemas/audit-event.schema.json", __dir__.to_s) #: String
|
|
10
|
+
|
|
11
|
+
#: () -> untyped
|
|
12
|
+
def self.schemer
|
|
13
|
+
@schemer ||= Senko.compile(JSON.parse(File.read(SCHEMA_PATH)))
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
#: (Event event) -> Array[Offense]
|
|
17
|
+
def check(event)
|
|
18
|
+
result = self.class.schemer.validate(event.to_h)
|
|
19
|
+
return [] if result.valid?
|
|
20
|
+
|
|
21
|
+
result.errors.map do |err|
|
|
22
|
+
h = err.to_h
|
|
23
|
+
location = h["instanceLocation"].to_s
|
|
24
|
+
pointer = location.empty? ? "(root)" : location
|
|
25
|
+
keyword = h["keywordLocation"].to_s.split("/").last
|
|
26
|
+
offense(event, :error, "schema violation #{pointer}: #{keyword} (#{h["error"] || "invalid"})")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Moku6
|
|
5
|
+
module Rules
|
|
6
|
+
class UniquenessRule < BaseRule
|
|
7
|
+
# catalog-level rule
|
|
8
|
+
#: (Catalog catalog) -> Array[Offense]
|
|
9
|
+
def check_catalog(catalog)
|
|
10
|
+
dupes = catalog.actions.compact.tally.select { |_, n| n > 1 }.keys
|
|
11
|
+
dupes.map do |action|
|
|
12
|
+
files = catalog.events.select { |e| e.action == action }.map(&:source_path)
|
|
13
|
+
Offense.new(
|
|
14
|
+
rule: "uniqueness", severity: :error, action: action,
|
|
15
|
+
file: files.join(", "),
|
|
16
|
+
message: "action '#{action}' is duplicated."
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Moku6
|
|
5
|
+
module Rules
|
|
6
|
+
class VisibilityRule < BaseRule
|
|
7
|
+
#: (Event event) -> Array[Offense]
|
|
8
|
+
def check(event)
|
|
9
|
+
visibility = event.visibility
|
|
10
|
+
unless visibility.is_a?(Hash) &&
|
|
11
|
+
visibility.key?("customer_visible") && visibility.key?("internal_only")
|
|
12
|
+
return [offense(event, :error,
|
|
13
|
+
"customer_visible / internal_only must be set explicitly.",
|
|
14
|
+
rule: "visibility_explicit")]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
if visibility["customer_visible"] && visibility["internal_only"]
|
|
18
|
+
return [offense(event, :warning,
|
|
19
|
+
"customer_visible and internal_only are contradictory.",
|
|
20
|
+
rule: "visibility_consistency")]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
[]
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
data/lib/moku6.rb
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "yaml"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
require_relative "moku6/version"
|
|
8
|
+
|
|
9
|
+
module Moku6
|
|
10
|
+
class Error < StandardError; end
|
|
11
|
+
|
|
12
|
+
class UsageError < Error; end # mapped to exit code 2
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
require_relative "moku6/config"
|
|
16
|
+
require_relative "moku6/event"
|
|
17
|
+
require_relative "moku6/catalog"
|
|
18
|
+
require_relative "moku6/loader"
|
|
19
|
+
require_relative "moku6/offense"
|
|
20
|
+
require_relative "moku6/result"
|
|
21
|
+
require_relative "moku6/reporter"
|
|
22
|
+
require_relative "moku6/envelope_schema"
|
|
23
|
+
|
|
24
|
+
# rules
|
|
25
|
+
require_relative "moku6/rules/base_rule"
|
|
26
|
+
require_relative "moku6/rules/schema_rule"
|
|
27
|
+
require_relative "moku6/rules/action_naming_rule"
|
|
28
|
+
require_relative "moku6/rules/label_description_rule"
|
|
29
|
+
require_relative "moku6/rules/uniqueness_rule"
|
|
30
|
+
require_relative "moku6/rules/privacy_masking_rule"
|
|
31
|
+
require_relative "moku6/rules/visibility_rule"
|
|
32
|
+
require_relative "moku6/rules/retention_rule"
|
|
33
|
+
require_relative "moku6/rules/example_consistency_rule"
|
|
34
|
+
require_relative "moku6/rules/pii_field_name_heuristic_rule"
|
|
35
|
+
require_relative "moku6/linter"
|
|
36
|
+
require_relative "moku6/differ"
|
|
37
|
+
|
|
38
|
+
# generators
|
|
39
|
+
require_relative "moku6/generators/base_generator"
|
|
40
|
+
require_relative "moku6/generators/docs_generator"
|
|
41
|
+
require_relative "moku6/generators/json_schema_generator"
|
|
42
|
+
require_relative "moku6/generators/typescript_generator"
|
|
43
|
+
require_relative "moku6/generators/ruby_generator"
|
|
44
|
+
require_relative "moku6/generators/bigquery_generator"
|
|
45
|
+
require_relative "moku6/generators/cloud_events_generator"
|
|
46
|
+
require_relative "moku6/generators/event_catalog_generator"
|
|
47
|
+
require_relative "moku6/generators/open_api_generator"
|
|
48
|
+
require_relative "moku6/generators/rails_generator"
|
|
49
|
+
require_relative "moku6/generators/outbox_generator"
|
|
50
|
+
require_relative "moku6/generators/example_generator"
|
|
51
|
+
require_relative "moku6/generators"
|
|
52
|
+
|
|
53
|
+
require_relative "moku6/initializer"
|
|
54
|
+
require_relative "moku6/generate"
|
|
55
|
+
require_relative "moku6/cli"
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://moku6/schemas/audit-event.schema.json",
|
|
4
|
+
"title": "Audit Event Definition",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["action", "label", "description", "category", "actor", "target", "privacy", "visibility", "retention"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"action": {
|
|
10
|
+
"type": "string",
|
|
11
|
+
"pattern": "^[a-z0-9_]+(\\.[a-z0-9_]+)+$"
|
|
12
|
+
},
|
|
13
|
+
"label": { "type": "string", "minLength": 1 },
|
|
14
|
+
"description": { "type": "string", "minLength": 1 },
|
|
15
|
+
"category": { "type": "string", "minLength": 1 },
|
|
16
|
+
"required": { "type": "boolean" },
|
|
17
|
+
"actor": {
|
|
18
|
+
"type": "object",
|
|
19
|
+
"additionalProperties": false,
|
|
20
|
+
"required": ["required"],
|
|
21
|
+
"properties": {
|
|
22
|
+
"required": { "type": "boolean" },
|
|
23
|
+
"type": { "type": "string" }
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"target": {
|
|
27
|
+
"type": "object",
|
|
28
|
+
"additionalProperties": false,
|
|
29
|
+
"required": ["type", "required"],
|
|
30
|
+
"properties": {
|
|
31
|
+
"type": { "type": "string" },
|
|
32
|
+
"required": { "type": "boolean" }
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"fields": {
|
|
36
|
+
"type": "object",
|
|
37
|
+
"additionalProperties": {
|
|
38
|
+
"type": "object",
|
|
39
|
+
"additionalProperties": false,
|
|
40
|
+
"required": ["type", "required"],
|
|
41
|
+
"properties": {
|
|
42
|
+
"type": {
|
|
43
|
+
"enum": ["string", "integer", "number", "boolean", "array", "object", "timestamp"]
|
|
44
|
+
},
|
|
45
|
+
"required": { "type": "boolean" },
|
|
46
|
+
"description": { "type": "string" },
|
|
47
|
+
"items": { "type": "object" }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"privacy": {
|
|
52
|
+
"type": "object",
|
|
53
|
+
"additionalProperties": false,
|
|
54
|
+
"required": ["contains_personal_data"],
|
|
55
|
+
"properties": {
|
|
56
|
+
"contains_personal_data": { "type": "boolean" },
|
|
57
|
+
"masked_fields": {
|
|
58
|
+
"type": "array",
|
|
59
|
+
"items": { "type": "string" }
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
"visibility": {
|
|
64
|
+
"type": "object",
|
|
65
|
+
"additionalProperties": false,
|
|
66
|
+
"required": ["customer_visible", "internal_only"],
|
|
67
|
+
"properties": {
|
|
68
|
+
"customer_visible": { "type": "boolean" },
|
|
69
|
+
"internal_only": { "type": "boolean" }
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
"retention": {
|
|
73
|
+
"type": "object",
|
|
74
|
+
"additionalProperties": false,
|
|
75
|
+
"required": ["years"],
|
|
76
|
+
"properties": {
|
|
77
|
+
"years": { "type": "integer", "minimum": 1 }
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
"examples": {
|
|
81
|
+
"type": "array",
|
|
82
|
+
"items": { "type": "object" }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Generated from lib/moku6/catalog.rb with RBS::Inline
|
|
2
|
+
|
|
3
|
+
module Moku6
|
|
4
|
+
class Catalog
|
|
5
|
+
attr_reader events: Array[Event]
|
|
6
|
+
|
|
7
|
+
# : (Array[Event] events) -> void
|
|
8
|
+
def initialize: (Array[Event] events) -> void
|
|
9
|
+
|
|
10
|
+
# : () -> Array[String?]
|
|
11
|
+
def actions: () -> Array[String?]
|
|
12
|
+
|
|
13
|
+
# : () -> Array[Event]
|
|
14
|
+
def sorted: () -> Array[Event]
|
|
15
|
+
|
|
16
|
+
# : (String? action) -> Event?
|
|
17
|
+
def find: (String? action) -> Event?
|
|
18
|
+
|
|
19
|
+
# : () -> bool
|
|
20
|
+
def empty?: () -> bool
|
|
21
|
+
end
|
|
22
|
+
end
|