iodef 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +6 -0
- data/README.md +37 -18
- data/SECURITY.md +1 -1
- data/data/enums.yml +1 -1
- data/data/types.yml +1 -1
- data/lib/iodef/binary_cbor.rb +285 -0
- data/lib/iodef/cbor.rb +16 -23
- data/lib/iodef/cbor_guard.rb +9 -110
- data/lib/iodef/version.rb +1 -1
- data/lib/iodef/xml/codec.rb +363 -0
- data/lib/iodef/xml/tree.rb +229 -0
- data/lib/iodef/xml.rb +24 -0
- data/sig/iodef.rbs +2 -0
- metadata +28 -4
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Iodef
|
|
4
|
+
# A pull reader bounds the tree before allocating descendants or expanding entities.
|
|
5
|
+
class XMLTree
|
|
6
|
+
XML_NS = "http://www.w3.org/XML/1998/namespace"
|
|
7
|
+
XMLNS_NS = "http://www.w3.org/2000/xmlns/"
|
|
8
|
+
Node = Data.define(:name, :uri, :local, :attributes, :namespaces, :parts, :lang)
|
|
9
|
+
Markup = Data.define(:kind, :value)
|
|
10
|
+
ENTITIES = { "amp" => "&", "lt" => "<", "gt" => ">", "quot" => '"', "apos" => "'" }.freeze
|
|
11
|
+
|
|
12
|
+
def initialize(limits)
|
|
13
|
+
@limits = limits
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def parse(source)
|
|
17
|
+
raise ParseError, "XML input must be a String" unless source.is_a?(String)
|
|
18
|
+
raise LimitError, "input exceeds max_input_size" if source.bytesize > @limits[:max_input_size]
|
|
19
|
+
|
|
20
|
+
source = source.dup.force_encoding(Encoding::UTF_8)
|
|
21
|
+
check_characters(source)
|
|
22
|
+
source.gsub!(/\r\n?/, "\n")
|
|
23
|
+
parser = REXML::Parsers::PullParser.new(source)
|
|
24
|
+
stack = []
|
|
25
|
+
root = nil
|
|
26
|
+
nodes = 0
|
|
27
|
+
loop do
|
|
28
|
+
event = parser.pull
|
|
29
|
+
nodes += 1
|
|
30
|
+
raise LimitError, "XML exceeds max_nodes" if nodes > @limits[:max_nodes]
|
|
31
|
+
|
|
32
|
+
case event.event_type
|
|
33
|
+
when :start_element
|
|
34
|
+
raise LimitError, "XML exceeds max_depth" if stack.size >= @limits[:max_depth]
|
|
35
|
+
raise ParseError, "XML must have exactly one root element" if stack.empty? && root
|
|
36
|
+
|
|
37
|
+
attributes = event[1]
|
|
38
|
+
raise LimitError, "XML attributes exceed max_collection_size" if attributes.size > @limits[:max_collection_size]
|
|
39
|
+
raise ParseError, "unescaped XML attribute markup" if attributes.values.any? { |value| value.include?("<") }
|
|
40
|
+
nodes += attributes.size
|
|
41
|
+
raise LimitError, "XML exceeds max_nodes" if nodes > @limits[:max_nodes]
|
|
42
|
+
|
|
43
|
+
namespaces = stack.empty? ? { "xml" => XML_NS } : stack.last.namespaces
|
|
44
|
+
if attributes.keys.any? { |name| name == "xmlns" || name.start_with?("xmlns:") }
|
|
45
|
+
nodes += namespaces.size
|
|
46
|
+
raise LimitError, "XML namespace contexts exceed max_nodes" if nodes > @limits[:max_nodes]
|
|
47
|
+
|
|
48
|
+
namespaces = namespaces.dup
|
|
49
|
+
end
|
|
50
|
+
attributes = attributes.transform_values { |value| decode_text(value.gsub(/[\t\r\n]/, " ")) }
|
|
51
|
+
attributes.each do |name, value|
|
|
52
|
+
next unless name == "xmlns" || name.start_with?("xmlns:")
|
|
53
|
+
|
|
54
|
+
prefix = name == "xmlns" ? "" : name.delete_prefix("xmlns:")
|
|
55
|
+
if prefix == "xmlns" || value == XMLNS_NS || (prefix == "xml") != (value == XML_NS) || (!prefix.empty? && value.empty?)
|
|
56
|
+
raise ParseError, "invalid XML namespace declaration"
|
|
57
|
+
end
|
|
58
|
+
namespaces[prefix] = value
|
|
59
|
+
end
|
|
60
|
+
expanded = {}
|
|
61
|
+
attributes.each_key do |name|
|
|
62
|
+
next if name == "xmlns" || name.start_with?("xmlns:")
|
|
63
|
+
|
|
64
|
+
pair = expand(name, namespaces, attribute: true)
|
|
65
|
+
raise ParseError, "duplicate expanded XML attribute" if expanded.key?(pair)
|
|
66
|
+
|
|
67
|
+
expanded[pair] = true
|
|
68
|
+
end
|
|
69
|
+
uri, local = expand(event[0], namespaces)
|
|
70
|
+
lang = attributes.fetch("xml:lang", stack.last&.lang)
|
|
71
|
+
node = Node.new(event[0], uri, local, attributes, namespaces, [], lang)
|
|
72
|
+
stack.empty? ? root = node : append(stack.last, node)
|
|
73
|
+
stack << node
|
|
74
|
+
when :end_element
|
|
75
|
+
raise ParseError, "unbalanced XML elements" if stack.empty? || stack.last.name != event[0]
|
|
76
|
+
|
|
77
|
+
stack.pop
|
|
78
|
+
when :text, :cdata
|
|
79
|
+
raise ParseError, "CDATA outside the root element" if event.event_type == :cdata && stack.empty?
|
|
80
|
+
raise ParseError, "invalid XML text" if event.event_type == :text && event[0].include?("]]>")
|
|
81
|
+
|
|
82
|
+
content = event.event_type == :text ? decode_text(event[0]) : event[0]
|
|
83
|
+
if stack.empty?
|
|
84
|
+
raise ParseError, "text outside the root element" unless content.strip.empty?
|
|
85
|
+
else
|
|
86
|
+
append(stack.last, content)
|
|
87
|
+
end
|
|
88
|
+
when :xmldecl
|
|
89
|
+
raise ParseError, "unsupported XML declaration" unless nodes == 1 && root.nil? && (event[0].nil? || event[0] == "1.0") &&
|
|
90
|
+
(event[1].nil? || %w[UTF-8 US-ASCII].include?(event[1].upcase))
|
|
91
|
+
when :comment
|
|
92
|
+
append(stack.last, Markup.new(:comment, event[0])) unless stack.empty?
|
|
93
|
+
when :processing_instruction
|
|
94
|
+
raise ParseError, "reserved XML processing instruction" if event[0].downcase == "xml"
|
|
95
|
+
|
|
96
|
+
append(stack.last, Markup.new(:instruction, [event[0], event[1]].join(" "))) unless stack.empty?
|
|
97
|
+
when :end_document
|
|
98
|
+
break
|
|
99
|
+
else
|
|
100
|
+
raise ParseError, "DTD and entity declarations are not supported"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
raise ParseError, "incomplete XML document" unless root && stack.empty?
|
|
104
|
+
|
|
105
|
+
root
|
|
106
|
+
rescue REXML::ParseException, EncodingError, ArgumentError, RangeError
|
|
107
|
+
raise ParseError, "invalid XML input"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def expand(name, namespaces, attribute: false)
|
|
111
|
+
names = name.split(":", -1)
|
|
112
|
+
raise ParseError, "invalid XML qualified name" if names.size > 2 || names.any?(&:empty?)
|
|
113
|
+
|
|
114
|
+
return [attribute ? "" : namespaces.fetch("", ""), name] if names.size == 1
|
|
115
|
+
|
|
116
|
+
uri = namespaces[names.first]
|
|
117
|
+
raise ParseError, "undeclared XML namespace prefix" unless uri
|
|
118
|
+
|
|
119
|
+
[uri, names.last]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def serialize(node)
|
|
123
|
+
writer = Writer.new(@limits)
|
|
124
|
+
write_node(writer, node)
|
|
125
|
+
writer.value
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def write_node(writer, node, parent_namespaces = {}, detached: true)
|
|
129
|
+
attrs = node.attributes.reject { |key, _| key == "xmlns" || key.start_with?("xmlns:") }
|
|
130
|
+
if detached
|
|
131
|
+
attrs["xmlns"] = "" unless node.namespaces.key?("")
|
|
132
|
+
attrs["xml:lang"] = node.lang || "" unless attrs.key?("xml:lang")
|
|
133
|
+
end
|
|
134
|
+
node.namespaces.each do |prefix, uri|
|
|
135
|
+
next if prefix == "xml" || parent_namespaces[prefix] == uri
|
|
136
|
+
|
|
137
|
+
attrs[prefix.empty? ? "xmlns" : "xmlns:#{prefix}"] = uri
|
|
138
|
+
end
|
|
139
|
+
writer.open(node.name, attrs)
|
|
140
|
+
node.parts.each do |part|
|
|
141
|
+
case part
|
|
142
|
+
when Node then write_node(writer, part, node.namespaces, detached: false)
|
|
143
|
+
when Markup
|
|
144
|
+
writer.append(part.kind == :comment ? "<!--#{part.value}-->" : "<?#{part.value}?>")
|
|
145
|
+
else writer.text(part)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
writer.close(node.name)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
def check_characters(value)
|
|
154
|
+
unless value.valid_encoding? && !value.match?(/[^\u0009\u000A\u000D\u0020-\uD7FF\uE000-\uFFFD\u{10000}-\u{10FFFF}]/)
|
|
155
|
+
raise ParseError, "XML must contain valid UTF-8 XML 1.0 characters"
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def decode_text(value)
|
|
160
|
+
result = value.gsub(/&([^&;]*);|&/) do
|
|
161
|
+
reference = Regexp.last_match(1)
|
|
162
|
+
raise ParseError, "invalid XML entity reference" unless reference
|
|
163
|
+
|
|
164
|
+
if ENTITIES.key?(reference)
|
|
165
|
+
ENTITIES.fetch(reference)
|
|
166
|
+
elsif reference.match?(/\A#(?:[0-9]+|x[0-9A-Fa-f]+)\z/)
|
|
167
|
+
number = reference.start_with?("#x") ? reference[2..].to_i(16) : reference[1..].to_i
|
|
168
|
+
raise ParseError, "invalid XML character reference" if number > 0x10ffff
|
|
169
|
+
|
|
170
|
+
[number].pack("U")
|
|
171
|
+
else
|
|
172
|
+
raise ParseError, "unsupported XML entity reference"
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
check_characters(result)
|
|
176
|
+
result
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def append(node, part)
|
|
180
|
+
raise LimitError, "XML content exceeds max_collection_size" if node.parts.size >= @limits[:max_collection_size]
|
|
181
|
+
|
|
182
|
+
node.parts << part
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
class Writer
|
|
186
|
+
attr_reader :value
|
|
187
|
+
|
|
188
|
+
def initialize(limits)
|
|
189
|
+
@maximum = limits[:max_input_size]
|
|
190
|
+
@value = +""
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def append(value)
|
|
194
|
+
raise LimitError, "output exceeds max_input_size" if @value.bytesize + value.bytesize > @maximum
|
|
195
|
+
|
|
196
|
+
@value << value
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def text(value, attribute: false)
|
|
200
|
+
if value.is_a?(Float)
|
|
201
|
+
value = format("%.17g", value)
|
|
202
|
+
value += ".0" unless value.match?(/[.eE]/)
|
|
203
|
+
else
|
|
204
|
+
value = value.to_s
|
|
205
|
+
end
|
|
206
|
+
unless Types.string?(value) && !value.match?(/[^\u0009\u000A\u000D\u0020-\uD7FF\uE000-\uFFFD\u{10000}-\u{10FFFF}]/)
|
|
207
|
+
raise ConversionError, "value cannot be represented in XML 1.0"
|
|
208
|
+
end
|
|
209
|
+
escapes = { "&" => "&", "<" => "<", ">" => ">", "\r" => " ", '"' => """, "\t" => "	", "\n" => " " }
|
|
210
|
+
append(value.gsub(attribute ? /[&<>\r"\t\n]/ : /[&<>\r]/) { |char| escapes.fetch(char) })
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def open(name, attributes = {})
|
|
214
|
+
append("<#{name}")
|
|
215
|
+
attributes.each do |key, value|
|
|
216
|
+
append(" #{key}=\"")
|
|
217
|
+
text(value, attribute: true)
|
|
218
|
+
# Attribute whitespace is emitted as references so XML normalization cannot alter it.
|
|
219
|
+
append('"')
|
|
220
|
+
end
|
|
221
|
+
append(">")
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def close(name)
|
|
225
|
+
append("</#{name}>")
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
data/lib/iodef/xml.rb
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../iodef"
|
|
4
|
+
require "rexml/parsers/pullparser"
|
|
5
|
+
require_relative "xml/tree"
|
|
6
|
+
require_relative "xml/codec"
|
|
7
|
+
|
|
8
|
+
module Iodef
|
|
9
|
+
class << self
|
|
10
|
+
def parse_xml(source, strict: true, validate: true, **limits)
|
|
11
|
+
options = self.limits(limits)
|
|
12
|
+
root = XMLTree.new(options).parse(source)
|
|
13
|
+
data = XMLCodec.new(options).decode(root)
|
|
14
|
+
Document.new(data, strict: strict && validate, **options)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class Document
|
|
19
|
+
def to_xml
|
|
20
|
+
validate!
|
|
21
|
+
XMLCodec.new(limits).encode(to_h)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
data/sig/iodef.rbs
CHANGED
|
@@ -8,6 +8,7 @@ module Iodef
|
|
|
8
8
|
|
|
9
9
|
def self.parse: (String source, ?strict: bool, ?validate: bool, **Integer limits) -> Document
|
|
10
10
|
def self.parse_cbor: (String source, ?strict: bool, ?validate: bool, **Integer limits) -> Document
|
|
11
|
+
def self.parse_xml: (String source, ?strict: bool, ?validate: bool, **Integer limits) -> Document
|
|
11
12
|
def self.validate: (untyped source, **Integer limits) -> Array[Violation]
|
|
12
13
|
def self.generate_json: (untyped value, ?untyped options) -> String
|
|
13
14
|
def self.pointer: (String parent, untyped key) -> String
|
|
@@ -62,6 +63,7 @@ module Iodef
|
|
|
62
63
|
def incidents: () -> Array[Incident]
|
|
63
64
|
def add_incident: (purpose: String, **untyped attributes) ?{ (Incident) -> untyped } -> Incident
|
|
64
65
|
def to_cbor: () -> String
|
|
66
|
+
def to_xml: () -> String
|
|
65
67
|
end
|
|
66
68
|
class Incident < Model
|
|
67
69
|
def contacts: () -> Array[Contact]
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: iodef
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yudai Takada
|
|
@@ -29,8 +29,28 @@ dependencies:
|
|
|
29
29
|
- - "<"
|
|
30
30
|
- !ruby/object:Gem::Version
|
|
31
31
|
version: '4'
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: rexml
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - ">="
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: 3.4.4
|
|
39
|
+
- - "<"
|
|
40
|
+
- !ruby/object:Gem::Version
|
|
41
|
+
version: '4'
|
|
42
|
+
type: :runtime
|
|
43
|
+
prerelease: false
|
|
44
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: 3.4.4
|
|
49
|
+
- - "<"
|
|
50
|
+
- !ruby/object:Gem::Version
|
|
51
|
+
version: '4'
|
|
52
|
+
description: RFC 8727 JSON incident reports, typed IODEF v2 models, validation, CBOR,
|
|
53
|
+
and RFC 7970 XML conversion.
|
|
34
54
|
email:
|
|
35
55
|
- t.yudai92@gmail.com
|
|
36
56
|
executables: []
|
|
@@ -45,6 +65,7 @@ files:
|
|
|
45
65
|
- data/mapkeys.yml
|
|
46
66
|
- data/types.yml
|
|
47
67
|
- lib/iodef.rb
|
|
68
|
+
- lib/iodef/binary_cbor.rb
|
|
48
69
|
- lib/iodef/cbor.rb
|
|
49
70
|
- lib/iodef/cbor_guard.rb
|
|
50
71
|
- lib/iodef/errors.rb
|
|
@@ -52,6 +73,9 @@ files:
|
|
|
52
73
|
- lib/iodef/types.rb
|
|
53
74
|
- lib/iodef/validator.rb
|
|
54
75
|
- lib/iodef/version.rb
|
|
76
|
+
- lib/iodef/xml.rb
|
|
77
|
+
- lib/iodef/xml/codec.rb
|
|
78
|
+
- lib/iodef/xml/tree.rb
|
|
55
79
|
- sig/iodef.rbs
|
|
56
80
|
homepage: https://github.com/ydah/iodef
|
|
57
81
|
licenses:
|
|
@@ -75,7 +99,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
75
99
|
- !ruby/object:Gem::Version
|
|
76
100
|
version: '0'
|
|
77
101
|
requirements: []
|
|
78
|
-
rubygems_version:
|
|
102
|
+
rubygems_version: 3.6.9
|
|
79
103
|
specification_version: 4
|
|
80
104
|
summary: Read, validate, and build IODEF incident reports
|
|
81
105
|
test_files: []
|