iodef 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.
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Iodef
4
+ # Bounds allocations and rejects duplicate keys before the native decoder builds objects.
5
+ class CBORGuard
6
+ def initialize(bytes, limits)
7
+ @bytes = bytes
8
+ @limits = limits
9
+ @offset = 0
10
+ @nodes = 0
11
+ @preserved_tags = []
12
+ end
13
+
14
+ def validate!
15
+ item(0)
16
+ raise ParseError, "trailing CBOR input" unless @offset == @bytes.bytesize
17
+
18
+ # Preserve URI spelling and avoid backend-specific bignum conversion.
19
+ @preserved_tags.reverse_each.with_object(@bytes.dup) do |(offset, size, tag), bytes|
20
+ bytes[offset, size] = [0xd8, tag].pack("C*")
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def byte
27
+ result = @bytes.getbyte(@offset)
28
+ raise ParseError, "truncated CBOR input" unless result
29
+
30
+ @offset += 1
31
+ result
32
+ end
33
+
34
+ def take(length)
35
+ raise ParseError, "truncated CBOR input" if length > @bytes.bytesize - @offset
36
+
37
+ start = @offset
38
+ @offset += length
39
+ @bytes.byteslice(start, length)
40
+ end
41
+
42
+ def argument(info)
43
+ return info if info < 24
44
+ return nil if info == 31
45
+
46
+ width = { 24 => 1, 25 => 2, 26 => 4, 27 => 8 }[info]
47
+ raise ParseError, "reserved CBOR additional information" unless width
48
+
49
+ take(width).unpack1({ 1 => "C", 2 => "n", 4 => "N", 8 => "Q>" }.fetch(width))
50
+ end
51
+
52
+ def item(depth)
53
+ @nodes += 1
54
+ raise LimitError, "CBOR exceeds max_nodes" if @nodes > @limits[:max_nodes]
55
+ raise LimitError, "CBOR exceeds max_depth" if depth > @limits[:max_depth]
56
+
57
+ start = @offset
58
+ initial = byte
59
+ major = initial >> 5
60
+ info = initial & 31
61
+ length = argument(info)
62
+ case major
63
+ when 0, 1
64
+ raise ParseError, "indefinite CBOR integer" unless length
65
+ when 2, 3 then string(major, length, depth)
66
+ when 4, 5 then collection(major, length, depth)
67
+ when 6
68
+ raise ParseError, "indefinite CBOR tag" unless length
69
+ raise ConversionError, "unsupported CBOR tag" unless [0, 2, 3, 22, 23, 32].include?(length)
70
+
71
+ @preserved_tags << [start, @offset - start, { 2 => 253, 3 => 254, 32 => 255 }.fetch(length)] if [2, 3, 32].include?(length)
72
+ item(depth + 1)
73
+ when 7
74
+ raise ParseError, "unexpected CBOR break" if info == 31
75
+ end
76
+ end
77
+
78
+ def string(major, length, depth)
79
+ if length
80
+ take(length)
81
+ raise LimitError, "binary exceeds max_binary_size" if major == 2 && length > @limits[:max_binary_size]
82
+
83
+ length
84
+ else
85
+ total = 0
86
+ until @bytes.getbyte(@offset) == 255
87
+ initial = @bytes.getbyte(@offset)
88
+ raise ParseError, "invalid indefinite CBOR string chunk" unless initial && (initial >> 5) == major && (initial & 31) < 28
89
+
90
+ total += item(depth + 1)
91
+ raise LimitError, "binary exceeds max_binary_size" if major == 2 && total > @limits[:max_binary_size]
92
+ end
93
+ byte
94
+ total
95
+ end
96
+ end
97
+
98
+ def collection(major, length, depth)
99
+ raise LimitError, "CBOR exceeds max_collection_size" if length && length > @limits[:max_collection_size]
100
+
101
+ count = 0
102
+ keys = {}
103
+ until length ? count == length : @bytes.getbyte(@offset) == 255
104
+ count += 1
105
+ raise LimitError, "CBOR exceeds max_collection_size" if count > @limits[:max_collection_size]
106
+
107
+ if major == 5
108
+ start = @offset
109
+ initial = @bytes.getbyte(start)
110
+ raise ConversionError, "CBOR map keys must be text or integers" unless initial && [0, 1, 3].include?(initial >> 5)
111
+
112
+ item(depth + 1)
113
+ key = CBOR.decode(@bytes.byteslice(start, @offset - start))
114
+ raise ParseError, "duplicate CBOR map key" if keys.key?(key)
115
+
116
+ keys[key] = true
117
+ end
118
+ item(depth + 1)
119
+ end
120
+ byte unless length
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Iodef
4
+ Violation = Data.define(:pointer, :rule, :message)
5
+
6
+ class Error < StandardError; end
7
+ class ParseError < Error; end
8
+ class ConversionError < Error; end
9
+
10
+ class ValidationError < Error
11
+ attr_reader :violations, :pointer
12
+
13
+ def initialize(message = "invalid IODEF document", pointer: "", violations: [])
14
+ @violations = violations.freeze
15
+ @pointer = violations.first&.pointer || pointer
16
+ super("#{message} at #{@pointer.empty? ? "/" : @pointer}")
17
+ end
18
+ end
19
+
20
+ class LimitError < ValidationError; end
21
+ end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Iodef
4
+ # Mutable typed views share their document; exported hashes are independent copies.
5
+ class Model
6
+ attr_reader :limits, :warnings
7
+
8
+ def self.schema_type
9
+ name.split("::").last
10
+ end
11
+
12
+ def initialize(data = {}, strict: true, **limits)
13
+ @limits = Iodef.limits(limits)
14
+ @data = Iodef.copy(data, @limits)
15
+ raise ValidationError, "model must be an object" unless @data.is_a?(Hash)
16
+
17
+ @warnings = Validator.new(**@limits).validate(@data, type: self.class.schema_type)
18
+ validate! if strict && !@warnings.empty?
19
+ end
20
+
21
+ def self.view(data, limits)
22
+ allocate.tap do |model|
23
+ model.instance_variable_set(:@data, data)
24
+ model.instance_variable_set(:@limits, limits)
25
+ model.instance_variable_set(:@warnings, [].freeze)
26
+ end
27
+ end
28
+
29
+ def [](key)
30
+ @data[key.to_s]
31
+ end
32
+
33
+ def []=(key, value)
34
+ @data[key.to_s] = Iodef.copy(value, limits)
35
+ end
36
+
37
+ def delete(key)
38
+ @data.delete(key.to_s)
39
+ end
40
+
41
+ def to_h
42
+ Iodef.copy(@data, limits)
43
+ end
44
+
45
+ def validate!
46
+ @warnings = Validator.new(**limits).validate(@data, type: self.class.schema_type)
47
+ raise ValidationError.new(violations: @warnings) unless @warnings.empty?
48
+
49
+ self
50
+ end
51
+
52
+ def to_json(options = nil)
53
+ validate!
54
+ json = Iodef.generate_json(@data, options)
55
+ raise LimitError, "output exceeds max_input_size" if json.bytesize > limits[:max_input_size]
56
+
57
+ json
58
+ end
59
+
60
+ def description
61
+ MultilingualText.new(@data.fetch("Description", []), lang: @data["lang"])
62
+ end
63
+
64
+ def description=(value)
65
+ self["Description"] = value
66
+ end
67
+
68
+ def self.accessors
69
+ SCHEMA.fetch(schema_type).fetch("fields").each do |field, descriptor|
70
+ method = field.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2').gsub(/([a-z\d])([A-Z])/, '\1_\2').tr("-", "_").downcase
71
+ next if method == "description"
72
+
73
+ define_method(method) { read_field(field, descriptor.delete_suffix("!")) }
74
+ define_method("#{method}=") { |value| self[field] = value }
75
+ end
76
+ end
77
+
78
+ private
79
+
80
+ def read_field(field, descriptor)
81
+ value = @data[field]
82
+ return value unless value
83
+
84
+ if descriptor.end_with?("[]") && value.is_a?(Array)
85
+ value.map { |item| wrap(item, descriptor.delete_suffix("[]")) }
86
+ else
87
+ wrap(value, descriptor)
88
+ end
89
+ end
90
+
91
+ def wrap(value, type)
92
+ klass = TYPED[type]
93
+ klass && value.is_a?(Hash) ? klass.view(value, limits) : value
94
+ end
95
+ end
96
+
97
+ class Document < Model
98
+ def self.build(lang: nil, **limits)
99
+ data = { "version" => "2.0", "Incident" => [] }
100
+ data["lang"] = lang if lang
101
+ document = new(data, strict: false, **limits)
102
+ yield document
103
+ document.validate!
104
+ end
105
+
106
+ def incidents
107
+ read_field("Incident", "Incident[]") || []
108
+ end
109
+
110
+ def add_incident(purpose:, **attributes)
111
+ value = { "purpose" => purpose }.merge(attributes.transform_keys(&:to_s))
112
+ incident = Incident.view(Iodef.copy(value, limits), limits)
113
+ yield incident if block_given?
114
+ @data["Incident"] ||= []
115
+ @data["Incident"] << incident.instance_variable_get(:@data)
116
+ incident
117
+ end
118
+ end
119
+
120
+ class Incident < Model
121
+ def contacts
122
+ read_field("Contact", "Contact[]") || []
123
+ end
124
+
125
+ def assessments
126
+ read_field("Assessment", "Assessment[]") || []
127
+ end
128
+
129
+ def indicators
130
+ read_field("Indicator", "Indicator[]") || []
131
+ end
132
+
133
+ def add_contact(role:, type:, **attributes)
134
+ value = { "role" => role, "type" => type }.merge(attributes.transform_keys(&:to_s))
135
+ contact = Contact.view(Iodef.copy(value, limits), limits)
136
+ yield contact if block_given?
137
+ @data["Contact"] ||= []
138
+ @data["Contact"] << contact.instance_variable_get(:@data)
139
+ contact
140
+ end
141
+ end
142
+
143
+ class Contact < Model; end
144
+ class Assessment < Model; end
145
+ class EventData < Model; end
146
+ class Indicator < Model; end
147
+
148
+ TYPED = [Document, Incident, Contact, Assessment, EventData, Indicator].to_h { |klass| [klass.schema_type, klass] }.freeze
149
+ TYPED.each_value(&:accessors)
150
+
151
+ class MultilingualText
152
+ include Enumerable
153
+
154
+ def initialize(values, lang: nil)
155
+ @values = values.is_a?(Array) ? values : [values]
156
+ @lang = lang
157
+ end
158
+
159
+ def each(&)
160
+ @values.each(&)
161
+ end
162
+
163
+ # Exact matches win; a language prefix and then an unlabelled value are fallbacks.
164
+ def text(language = nil)
165
+ values = @values.map { |value| value.is_a?(String) ? { "value" => value, "lang" => @lang } : value }
166
+ language = language&.downcase
167
+ selected = values.find { |value| value["lang"]&.downcase == language } if language
168
+ selected ||= values.find { |value| value["lang"].to_s.downcase.split("-").first == language.split("-").first } if language
169
+ selected ||= values.find { |value| value["lang"].nil? || value["lang"].empty? }
170
+ (selected || values.first)&.fetch("value", nil)
171
+ end
172
+
173
+ def to_a
174
+ Iodef.copy(@values)
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Iodef
4
+ DEFAULT_LIMITS = { max_input_size: 16 * 1024 * 1024, max_depth: 32, max_collection_size: 10_000,
5
+ max_nodes: 200_000, max_binary_size: 1024 * 1024, max_violations: 1000 }.freeze
6
+
7
+ class << self
8
+ def limits(options)
9
+ unknown = options.keys - DEFAULT_LIMITS.keys
10
+ raise ArgumentError, "unknown limits: #{unknown.join(", ")}" unless unknown.empty?
11
+
12
+ DEFAULT_LIMITS.merge(options).tap do |result|
13
+ result.each_value { |value| raise ArgumentError, "limits must be positive integers" unless value.is_a?(Integer) && value.positive? }
14
+ end.freeze
15
+ end
16
+
17
+ def pointer(parent, key)
18
+ "#{parent}/#{key.to_s.gsub("~", "~0").gsub("/", "~1")}"
19
+ end
20
+
21
+ def freeze_tree(value)
22
+ case value
23
+ when Hash then value.each do |key, item|
24
+ freeze_tree(key)
25
+ freeze_tree(item)
26
+ end
27
+ when Array then value.each { |item| freeze_tree(item) }
28
+ end
29
+ value.freeze
30
+ end
31
+
32
+ def copy(value, options = DEFAULT_LIMITS, depth: 0, path: "", ancestors: [], budget: [0, 0])
33
+ budget[0] += 1
34
+ raise LimitError.new("value exceeds max_nodes", pointer: path) if budget[0] > options[:max_nodes]
35
+
36
+ value = value.to_h if value.is_a?(Model)
37
+ case value
38
+ when Hash, Array
39
+ raise LimitError.new("value exceeds max_depth", pointer: path) if depth >= options[:max_depth]
40
+ raise LimitError.new("collection exceeds max_collection_size", pointer: path) if value.size > options[:max_collection_size]
41
+ raise ValidationError.new("cyclic input", pointer: path) if ancestors.include?(value.object_id)
42
+
43
+ nested = { depth: depth + 1, ancestors: ancestors + [value.object_id], budget: budget }
44
+ if value.is_a?(Array)
45
+ value.each_with_index.map { |item, index| copy(item, options, path: pointer(path, index), **nested) }
46
+ else
47
+ value.each_with_object({}) do |(key, item), result|
48
+ unless (key.is_a?(String) || key.is_a?(Symbol)) && Types.string?(key.to_s)
49
+ raise ValidationError.new("object keys must be UTF-8 strings", pointer: path)
50
+ end
51
+
52
+ key = key.to_s
53
+ budget[1] += key.bytesize
54
+ raise LimitError.new("value exceeds max_input_size", pointer: path) if budget[1] > options[:max_input_size]
55
+
56
+ location = pointer(path, key)
57
+ raise ValidationError.new("duplicate object key", pointer: location) if result.key?(key)
58
+
59
+ result[key.dup.force_encoding(Encoding::UTF_8)] = copy(item, options, path: location, **nested)
60
+ end
61
+ end
62
+ when String
63
+ budget[1] += value.bytesize
64
+ raise LimitError.new("value exceeds max_input_size", pointer: path) if budget[1] > options[:max_input_size]
65
+ raise ValidationError.new("invalid UTF-8 string", pointer: path) unless Types.string?(value)
66
+
67
+ value.dup.force_encoding(Encoding::UTF_8)
68
+ when Float
69
+ raise ValidationError.new("non-finite number", pointer: path) unless value.finite?
70
+
71
+ value
72
+ when Integer, TrueClass, FalseClass, NilClass then value
73
+ else raise ValidationError.new("non-JSON value", pointer: path)
74
+ end
75
+ end
76
+
77
+ def generate_json(value, options = nil)
78
+ value = precise_floats(value) if RUBY_ENGINE == "truffleruby"
79
+ JSON.generate(value, options)
80
+ end
81
+
82
+ # TruffleRuby's Float#to_s may discard digits needed for a lossless JSON round trip.
83
+ def precise_floats(value)
84
+ case value
85
+ when Hash then value.transform_values { |item| precise_floats(item) }
86
+ when Array then value.map { |item| precise_floats(item) }
87
+ when Float
88
+ number = format("%.17g", value)
89
+ number += ".0" unless number.match?(/[.eE]/)
90
+ JSON::Fragment.new(number)
91
+ else value
92
+ end
93
+ end
94
+ private :precise_floats
95
+ end
96
+
97
+ schema = YAML.safe_load_file(File.expand_path("../../data/types.yml", __dir__))
98
+ SCHEMA = freeze_tree(schema.fetch("types").transform_values do |definition|
99
+ fields = definition.fetch("groups", []).each_with_object({}) { |group, result| result.merge!(schema.fetch("groups").fetch(group)) }
100
+ definition.merge("fields" => fields.merge(definition.fetch("fields")))
101
+ end)
102
+ ENUMS = freeze_tree(YAML.safe_load_file(File.expand_path("../../data/enums.yml", __dir__)))
103
+ map = YAML.safe_load_file(File.expand_path("../../data/mapkeys.yml", __dir__))
104
+ MAPKEYS = freeze_tree(map.fetch("names").split.each_with_index.to_h { |name, index| [name, index + map.fetch("first")] })
105
+ KEYNAMES = MAPKEYS.invert.freeze
106
+
107
+ module Types
108
+ module_function
109
+
110
+ def string?(value)
111
+ value.is_a?(String) && value.valid_encoding? && (value.encoding == Encoding::UTF_8 || value.ascii_only?)
112
+ end
113
+
114
+ def datetime?(value)
115
+ return false unless string?(value)
116
+
117
+ match = /\A(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(?:\.\d+)?([Zz]|[+-]\d{2}:\d{2})\z/.match(value)
118
+ return false unless match
119
+
120
+ year, month, day, hour, minute, second = match.captures.first(6).map(&:to_i)
121
+ zone = match[7]
122
+ Date.valid_date?(year, month, day, Date::GREGORIAN) && hour < 24 && minute < 60 && second <= 60 &&
123
+ (zone.match?(/\A[zZ]\z/) || (zone[1, 2].to_i < 24 && zone[4, 2].to_i < 60))
124
+ end
125
+
126
+ def uri?(value)
127
+ string?(value) && value.ascii_only? && !value.match?(/%(?![0-9a-fA-F]{2})/) && !URI.parse(value).scheme.nil?
128
+ rescue URI::InvalidURIError
129
+ false
130
+ end
131
+
132
+ def binary?(value)
133
+ string?(value) && value.match?(%r{\A(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?\z}) &&
134
+ [value.unpack1("m0")].pack("m0") == value
135
+ rescue ArgumentError
136
+ false
137
+ end
138
+
139
+ def portlist?(value)
140
+ return false unless string?(value) && value.match?(/\A\d+(?:-\d+)?(?:,\d+(?:-\d+)?)*\z/)
141
+
142
+ value.split(",").all? do |range|
143
+ ports = range.split("-").map(&:to_i)
144
+ ports.all? { |port| port <= 65_535 } && ports.first <= ports.last
145
+ end
146
+ end
147
+
148
+ def valid?(type, value)
149
+ return ENUMS.fetch(type).include?(value) if ENUMS.key?(type)
150
+
151
+ case type
152
+ when "string" then string?(value)
153
+ when "integer" then value.is_a?(Integer)
154
+ when "number" then value.is_a?(Numeric) && (!value.is_a?(Float) || value.finite?)
155
+ when "positive" then valid?("number", value) && value.positive?
156
+ when "id" then string?(value) && value.match?(/\A[A-Za-z_][A-Za-z0-9_.-]*\z/)
157
+ when "language" then string?(value) && (value.empty? || value.match?(/\A[A-Za-z]{1,8}(?:-[A-Za-z0-9]{1,8})*\z/))
158
+ when "timezone" then string?(value) && value.match?(/\A(?:Z|[+-](?:0\d|1[0-4]):[0-5]\d)\z/)
159
+ when "version" then %w[2.0 2.00].include?(value)
160
+ when "datetime" then datetime?(value)
161
+ when "uri" then uri?(value)
162
+ when "binary" then binary?(value)
163
+ when "portlist" then portlist?(value)
164
+ else false
165
+ end
166
+ end
167
+ end
168
+ end