trapline 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 +21 -0
- data/README.md +585 -0
- data/config.ru.example +21 -0
- data/exe/trapline +8 -0
- data/lib/generators/trapline/install_generator.rb +34 -0
- data/lib/generators/trapline/templates/initializer.rb +23 -0
- data/lib/tasks/trapline.rake +19 -0
- data/lib/trapline/cli.rb +212 -0
- data/lib/trapline/configuration.rb +19 -0
- data/lib/trapline/cve/client.rb +203 -0
- data/lib/trapline/cve/record.rb +160 -0
- data/lib/trapline/engine.rb +243 -0
- data/lib/trapline/generator.rb +236 -0
- data/lib/trapline/languages.rb +65 -0
- data/lib/trapline/railtie.rb +26 -0
- data/lib/trapline/rule.rb +165 -0
- data/lib/trapline/runtime.rb +148 -0
- data/lib/trapline/store.rb +113 -0
- data/lib/trapline/suite.rb +44 -0
- data/lib/trapline/templates/cwe/cwe-22.yml +167 -0
- data/lib/trapline/templates/cwe/cwe-327.yml +124 -0
- data/lib/trapline/templates/cwe/cwe-502.yml +123 -0
- data/lib/trapline/templates/cwe/cwe-601.yml +124 -0
- data/lib/trapline/templates/cwe/cwe-611.yml +87 -0
- data/lib/trapline/templates/cwe/cwe-78.yml +166 -0
- data/lib/trapline/templates/cwe/cwe-79.yml +164 -0
- data/lib/trapline/templates/cwe/cwe-798.yml +125 -0
- data/lib/trapline/templates/cwe/cwe-89.yml +189 -0
- data/lib/trapline/templates/cwe/cwe-917.yml +46 -0
- data/lib/trapline/templates/cwe/cwe-918.yml +166 -0
- data/lib/trapline/templates/cwe/cwe-94.yml +131 -0
- data/lib/trapline/templates.rb +261 -0
- data/lib/trapline/validator.rb +359 -0
- data/lib/trapline/version.rb +5 -0
- data/lib/trapline/web/app.rb +234 -0
- data/lib/trapline/web/assets/THIRD_PARTY_LICENSES.txt +214 -0
- data/lib/trapline/web/assets/app.css +399 -0
- data/lib/trapline/web/assets/app.js +804 -0
- data/lib/trapline/web/assets/codemirror.css +344 -0
- data/lib/trapline/web/assets/codemirror.js +13263 -0
- data/lib/trapline/web/assets/index.html +165 -0
- data/lib/trapline/web/assets/jetbrains-mono-latin-400-normal.woff2 +0 -0
- data/lib/trapline/web/assets/jetbrains-mono-latin-600-normal.woff2 +0 -0
- data/lib/trapline/web/assets/public-sans-latin-400-normal.woff2 +0 -0
- data/lib/trapline/web/assets/public-sans-latin-500-normal.woff2 +0 -0
- data/lib/trapline/web/assets/public-sans-latin-600-normal.woff2 +0 -0
- data/lib/trapline/web/assets/public-sans-latin-700-normal.woff2 +0 -0
- data/lib/trapline/web/server.rb +108 -0
- data/lib/trapline.rb +70 -0
- metadata +136 -0
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trapline
|
|
4
|
+
class Validator
|
|
5
|
+
Issue = Struct.new(:level, :path, :message, keyword_init: true) do
|
|
6
|
+
def to_h
|
|
7
|
+
{ level: level, path: path, message: message }
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
Result = Struct.new(:issues, :rules, keyword_init: true) do
|
|
12
|
+
def valid?
|
|
13
|
+
errors.empty?
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def errors
|
|
17
|
+
issues.select { |i| i.level == "error" }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def to_h
|
|
21
|
+
{ valid: valid?, issues: issues.map(&:to_h) }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
FORMULA_KEYS = %w[pattern pattern-not pattern-inside pattern-not-inside pattern-either patterns
|
|
26
|
+
pattern-regex pattern-not-regex metavariable-regex metavariable-pattern
|
|
27
|
+
metavariable-comparison metavariable-type metavariable-analysis metavariable-name
|
|
28
|
+
focus-metavariable].freeze
|
|
29
|
+
POSITIVE_KEYS = %w[pattern pattern-inside pattern-either patterns pattern-regex].freeze
|
|
30
|
+
EITHER_KEYS = %w[pattern patterns pattern-either pattern-regex pattern-inside].freeze
|
|
31
|
+
ID_FORMAT = /\A[A-Za-z0-9][A-Za-z0-9._-]*\z/
|
|
32
|
+
CVE_FORMAT = /\ACVE-\d{4}-\d{4,}\z/
|
|
33
|
+
CWE_FORMAT = /\ACWE-\d+/
|
|
34
|
+
|
|
35
|
+
def self.call(yaml_or_rules, engine: nil)
|
|
36
|
+
new(engine: engine).validate(yaml_or_rules)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def initialize(engine: nil)
|
|
40
|
+
@engine = engine
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def validate(input)
|
|
44
|
+
@issues = []
|
|
45
|
+
rules = case input
|
|
46
|
+
when Rule then [input]
|
|
47
|
+
when RuleFile then input.rules
|
|
48
|
+
when Array then input
|
|
49
|
+
else
|
|
50
|
+
begin
|
|
51
|
+
RuleFile.parse(input).rules
|
|
52
|
+
rescue ParseError => e
|
|
53
|
+
add("error", "", e.message)
|
|
54
|
+
return Result.new(issues: @issues, rules: [])
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
add("error", "rules", "the file contains no rules") if rules.empty?
|
|
59
|
+
rules.each_with_index { |rule, i| check_rule(rule, "rules[#{i}]") }
|
|
60
|
+
check_duplicates(rules)
|
|
61
|
+
|
|
62
|
+
if @engine && @issues.none? { |i| i.level == "error" }
|
|
63
|
+
@engine.validate(RuleFile.new(rules).to_yaml).each { |e| add("error", "engine", e) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
Result.new(issues: @issues, rules: rules)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def add(level, path, message)
|
|
72
|
+
@issues << Issue.new(level: level, path: path, message: message)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def check_rule(rule, path)
|
|
76
|
+
d = rule.data
|
|
77
|
+
mode = d["mode"] || "search"
|
|
78
|
+
|
|
79
|
+
if blank?(d["id"])
|
|
80
|
+
add("error", "#{path}.id", "`id` is required")
|
|
81
|
+
elsif d["id"].to_s !~ ID_FORMAT
|
|
82
|
+
add("error", "#{path}.id", "`id` may only contain letters, digits, '.', '_' or '-'")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
add("error", "#{path}.message", "`message` is required") if blank?(d["message"]) && mode != "extract"
|
|
86
|
+
|
|
87
|
+
if blank?(d["severity"])
|
|
88
|
+
add("error", "#{path}.severity", "`severity` is required")
|
|
89
|
+
elsif !Rule::SEVERITIES.include?(d["severity"].to_s)
|
|
90
|
+
add("error", "#{path}.severity", "invalid severity: #{d['severity']} (use #{Rule::SEVERITIES.first(4).join(', ')}...)")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
check_languages(d["languages"], "#{path}.languages")
|
|
94
|
+
|
|
95
|
+
unless Rule::MODES.include?(mode)
|
|
96
|
+
add("error", "#{path}.mode", "invalid mode: #{mode} (use #{Rule::MODES.join(', ')})")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
case mode
|
|
100
|
+
when "taint" then check_taint(d, path)
|
|
101
|
+
when "search" then check_search(d, path)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
check_metavariables(d, path)
|
|
105
|
+
check_scalars(d, path)
|
|
106
|
+
check_metadata(d["metadata"], "#{path}.metadata")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def check_languages(langs, path)
|
|
110
|
+
if !langs.is_a?(Array) || langs.empty?
|
|
111
|
+
add("error", path, "`languages` must be a non-empty list (e.g. [ruby])")
|
|
112
|
+
return
|
|
113
|
+
end
|
|
114
|
+
langs.reject { |l| Languages.known?(l) }.each { |l| add("warning", path, "language unknown to Trapline: #{l}") }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def check_search(d, path)
|
|
118
|
+
present = Rule::PATTERN_KEYS.select { |k| d.key?(k) } - ["taint"]
|
|
119
|
+
if present.empty?
|
|
120
|
+
add("error", path, "the rule needs one of these keys: pattern, patterns, pattern-either, pattern-regex or match")
|
|
121
|
+
elsif present.size > 1
|
|
122
|
+
add("error", path, "use only one top-level pattern key (found: #{present.join(', ')})")
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
check_value(d["pattern"], "#{path}.pattern") if d.key?("pattern")
|
|
126
|
+
check_value(d["pattern-regex"], "#{path}.pattern-regex", regex: true) if d.key?("pattern-regex")
|
|
127
|
+
check_patterns(d["patterns"], "#{path}.patterns") if d.key?("patterns")
|
|
128
|
+
check_either(d["pattern-either"], "#{path}.pattern-either") if d.key?("pattern-either")
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def check_taint(d, path)
|
|
132
|
+
return if d.key?("taint") # new taint syntax: left to the engine
|
|
133
|
+
|
|
134
|
+
%w[pattern-sources pattern-sinks].each do |key|
|
|
135
|
+
list = d[key]
|
|
136
|
+
if !list.is_a?(Array) || list.empty?
|
|
137
|
+
add("error", "#{path}.#{key}", "taint rules need `#{key}` with at least one item")
|
|
138
|
+
next
|
|
139
|
+
end
|
|
140
|
+
list.each_with_index { |item, i| check_taint_spec(item, "#{path}.#{key}[#{i}]") }
|
|
141
|
+
end
|
|
142
|
+
%w[pattern-sanitizers pattern-propagators].each do |key|
|
|
143
|
+
Array(d[key]).each_with_index { |item, i| check_taint_spec(item, "#{path}.#{key}[#{i}]") }
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def check_taint_spec(item, path)
|
|
148
|
+
unless item.is_a?(Hash)
|
|
149
|
+
add("error", path, "each item must be a map (e.g. `- pattern: ...`)")
|
|
150
|
+
return
|
|
151
|
+
end
|
|
152
|
+
formula = item.slice(*FORMULA_KEYS)
|
|
153
|
+
if formula.empty?
|
|
154
|
+
add("error", path, "item without a pattern (use pattern, patterns or pattern-either)")
|
|
155
|
+
elsif formula.size == 1
|
|
156
|
+
check_formula_item(formula, path)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def check_patterns(list, path)
|
|
161
|
+
unless list.is_a?(Array) && !list.empty?
|
|
162
|
+
add("error", path, "`patterns` must be a non-empty list")
|
|
163
|
+
return
|
|
164
|
+
end
|
|
165
|
+
list.each_with_index { |item, i| check_formula_item(item, "#{path}[#{i}]") }
|
|
166
|
+
|
|
167
|
+
keys = list.select { |i| i.is_a?(Hash) }.flat_map(&:keys)
|
|
168
|
+
if (keys & POSITIVE_KEYS).empty?
|
|
169
|
+
add("error", path, "`patterns` needs at least one positive term (pattern, pattern-inside, pattern-either, pattern-regex)")
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def check_either(list, path)
|
|
174
|
+
unless list.is_a?(Array) && !list.empty?
|
|
175
|
+
add("error", path, "`pattern-either` must be a non-empty list")
|
|
176
|
+
return
|
|
177
|
+
end
|
|
178
|
+
list.each_with_index do |item, i|
|
|
179
|
+
p = "#{path}[#{i}]"
|
|
180
|
+
unless item.is_a?(Hash) && item.size == 1
|
|
181
|
+
add("error", p, "each pattern-either item must have exactly one key")
|
|
182
|
+
next
|
|
183
|
+
end
|
|
184
|
+
key = item.keys.first
|
|
185
|
+
unless EITHER_KEYS.include?(key)
|
|
186
|
+
add("error", p, "`#{key}` is not allowed inside pattern-either (use #{EITHER_KEYS.join(', ')})")
|
|
187
|
+
next
|
|
188
|
+
end
|
|
189
|
+
check_formula_item(item, p)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def check_formula_item(item, path)
|
|
194
|
+
unless item.is_a?(Hash)
|
|
195
|
+
add("error", path, "each item must be a map (e.g. `- pattern: ...`)")
|
|
196
|
+
return
|
|
197
|
+
end
|
|
198
|
+
if item.size != 1
|
|
199
|
+
add("error", path, "each item must have exactly one key (found: #{item.keys.join(', ')}) — check the indentation")
|
|
200
|
+
return
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
key, value = item.first
|
|
204
|
+
case key
|
|
205
|
+
when "pattern", "pattern-not", "pattern-inside", "pattern-not-inside"
|
|
206
|
+
check_value(value, "#{path}.#{key}")
|
|
207
|
+
when "pattern-regex", "pattern-not-regex"
|
|
208
|
+
check_value(value, "#{path}.#{key}", regex: true)
|
|
209
|
+
when "patterns" then check_patterns(value, "#{path}.patterns")
|
|
210
|
+
when "pattern-either" then check_either(value, "#{path}.pattern-either")
|
|
211
|
+
when "focus-metavariable"
|
|
212
|
+
Array(value).each { |mv| check_mv_name(mv, "#{path}.focus-metavariable") }
|
|
213
|
+
when "metavariable-regex"
|
|
214
|
+
check_mv_hash(value, "#{path}.#{key}", required: %w[metavariable regex])
|
|
215
|
+
check_value(value["regex"], "#{path}.#{key}.regex", regex: true) if value.is_a?(Hash) && value["regex"]
|
|
216
|
+
when "metavariable-pattern"
|
|
217
|
+
check_mv_hash(value, "#{path}.#{key}", required: %w[metavariable])
|
|
218
|
+
if value.is_a?(Hash)
|
|
219
|
+
inner = value.slice("pattern", "patterns", "pattern-either", "pattern-regex")
|
|
220
|
+
add("error", "#{path}.#{key}", "metavariable-pattern needs pattern, patterns, pattern-either or pattern-regex") if inner.empty?
|
|
221
|
+
check_patterns(value["patterns"], "#{path}.#{key}.patterns") if value["patterns"]
|
|
222
|
+
check_either(value["pattern-either"], "#{path}.#{key}.pattern-either") if value["pattern-either"]
|
|
223
|
+
end
|
|
224
|
+
when "metavariable-comparison"
|
|
225
|
+
check_mv_hash(value, "#{path}.#{key}", required: %w[comparison])
|
|
226
|
+
when "metavariable-type", "metavariable-name"
|
|
227
|
+
check_mv_hash(value, "#{path}.#{key}", required: %w[metavariable])
|
|
228
|
+
when "metavariable-analysis"
|
|
229
|
+
check_mv_hash(value, "#{path}.#{key}", required: %w[metavariable analyzer])
|
|
230
|
+
else
|
|
231
|
+
add("error", path, "unknown operator: `#{key}`")
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def check_mv_hash(value, path, required:)
|
|
236
|
+
unless value.is_a?(Hash)
|
|
237
|
+
add("error", path, "expected a map with #{required.join(', ')}")
|
|
238
|
+
return
|
|
239
|
+
end
|
|
240
|
+
required.each { |k| add("error", "#{path}.#{k}", "`#{k}` is required") if blank?(value[k]) }
|
|
241
|
+
check_mv_name(value["metavariable"], "#{path}.metavariable") if value["metavariable"]
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def check_mv_name(name, path)
|
|
245
|
+
add("error", path, "metavariables must start with $ and be UPPERCASE (e.g. $X)") unless name.to_s =~ /\A\$[A-Z_][A-Z0-9_]*\z/
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def check_value(value, path, regex: false)
|
|
249
|
+
unless value.is_a?(String) && !value.strip.empty?
|
|
250
|
+
add("error", path, "the pattern must be a non-empty string")
|
|
251
|
+
return
|
|
252
|
+
end
|
|
253
|
+
if regex
|
|
254
|
+
begin
|
|
255
|
+
Regexp.new(value)
|
|
256
|
+
rescue RegexpError => e
|
|
257
|
+
add("warning", path, "regex may be invalid (#{e.message}); the engine uses PCRE")
|
|
258
|
+
end
|
|
259
|
+
else
|
|
260
|
+
check_balance(value, path)
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def check_balance(text, path)
|
|
265
|
+
stripped = text.gsub(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/, "")
|
|
266
|
+
pairs = { "(" => ")", "[" => "]", "{" => "}" }
|
|
267
|
+
stack = []
|
|
268
|
+
stripped.each_char do |c|
|
|
269
|
+
if pairs.key?(c) then stack << c
|
|
270
|
+
elsif pairs.value?(c)
|
|
271
|
+
return add("warning", path, "unbalanced parentheses/brackets in the pattern") if stack.pop != pairs.key(c)
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
add("warning", path, "unbalanced parentheses/brackets in the pattern") unless stack.empty?
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def check_metavariables(d, path)
|
|
278
|
+
text = collect_patterns(d).join("\n")
|
|
279
|
+
used = text.scan(/\$(?:\.\.\.)?([A-Z_][A-Z0-9_]*)/).flatten.map { |n| "$#{n}" }.uniq
|
|
280
|
+
referenced = []
|
|
281
|
+
walk(d) do |h|
|
|
282
|
+
referenced << h["metavariable"] if h["metavariable"]
|
|
283
|
+
referenced.concat(Array(h["focus-metavariable"]))
|
|
284
|
+
end
|
|
285
|
+
(referenced.uniq - used).each do |mv|
|
|
286
|
+
add("warning", path, "metavariable #{mv} is referenced but does not appear in any pattern")
|
|
287
|
+
end
|
|
288
|
+
msg = [d["message"], d["fix"]].compact.join(" ")
|
|
289
|
+
msg.scan(/\$([A-Z_][A-Z0-9_]*)/).flatten.map { |n| "$#{n}" }.uniq.each do |mv|
|
|
290
|
+
add("warning", "#{path}.message", "#{mv} is used in the message/fix but does not appear in the patterns") unless used.include?(mv)
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def collect_patterns(obj, out = [])
|
|
295
|
+
case obj
|
|
296
|
+
when Hash
|
|
297
|
+
obj.each do |k, v|
|
|
298
|
+
if %w[pattern pattern-inside pattern-not pattern-not-inside].include?(k) && v.is_a?(String)
|
|
299
|
+
out << v
|
|
300
|
+
elsif k == "pattern-regex" && v.is_a?(String)
|
|
301
|
+
out << v.scan(/\(\?P<([A-Z_][A-Z0-9_]*)>/).flatten.map { |n| "$#{n}" }.join(" ")
|
|
302
|
+
else
|
|
303
|
+
collect_patterns(v, out)
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
when Array then obj.each { |v| collect_patterns(v, out) }
|
|
307
|
+
end
|
|
308
|
+
out
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def walk(obj, &block)
|
|
312
|
+
case obj
|
|
313
|
+
when Hash
|
|
314
|
+
yield obj
|
|
315
|
+
obj.each_value { |v| walk(v, &block) }
|
|
316
|
+
when Array then obj.each { |v| walk(v, &block) }
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# semgrep (Python/ruamel) rejects floats anywhere in the rule.
|
|
321
|
+
def check_scalars(obj, path)
|
|
322
|
+
case obj
|
|
323
|
+
when Hash then obj.each { |k, v| check_scalars(v, "#{path}.#{k}") }
|
|
324
|
+
when Array then obj.each_with_index { |v, i| check_scalars(v, "#{path}[#{i}]") }
|
|
325
|
+
when Float then add("error", path, "semgrep does not accept decimal numbers (#{obj}); quote it: \"#{obj}\"")
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def check_metadata(meta, path)
|
|
330
|
+
if meta.nil?
|
|
331
|
+
add("info", path, "add `metadata` (cwe, references, confidence) to make triage easier")
|
|
332
|
+
return
|
|
333
|
+
end
|
|
334
|
+
unless meta.is_a?(Hash)
|
|
335
|
+
add("error", path, "`metadata` must be a map")
|
|
336
|
+
return
|
|
337
|
+
end
|
|
338
|
+
Array(meta["cve"]).each do |c|
|
|
339
|
+
add("warning", "#{path}.cve", "unexpected CVE format: #{c} (e.g. CVE-2021-44228)") unless c.to_s =~ CVE_FORMAT
|
|
340
|
+
end
|
|
341
|
+
Array(meta["cwe"]).each do |c|
|
|
342
|
+
add("warning", "#{path}.cwe", "unexpected CWE format: #{c} (e.g. \"CWE-89: SQL Injection\")") unless c.to_s =~ CWE_FORMAT
|
|
343
|
+
end
|
|
344
|
+
add("info", "#{path}.cwe", "specify the related CWE") if blank?(meta["cwe"])
|
|
345
|
+
add("info", "#{path}.references", "include references (advisory, CVE, documentation)") if blank?(meta["references"])
|
|
346
|
+
if meta["confidence"] && !%w[LOW MEDIUM HIGH].include?(meta["confidence"].to_s)
|
|
347
|
+
add("warning", "#{path}.confidence", "confidence must be LOW, MEDIUM or HIGH")
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def check_duplicates(rules)
|
|
352
|
+
rules.map(&:id).compact.tally.each { |id, n| add("error", "rules", "duplicate id: #{id}") if n > 1 }
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def blank?(v)
|
|
356
|
+
v.nil? || (v.respond_to?(:empty?) && v.empty?) || (v.is_a?(String) && v.strip.empty?)
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
end
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "openssl"
|
|
5
|
+
|
|
6
|
+
module Trapline
|
|
7
|
+
module Web
|
|
8
|
+
class App
|
|
9
|
+
ASSETS = File.expand_path("assets", __dir__)
|
|
10
|
+
MIME = { ".js" => "application/javascript", ".css" => "text/css", ".html" => "text/html",
|
|
11
|
+
".svg" => "image/svg+xml", ".woff2" => "font/woff2", ".txt" => "text/plain" }.freeze
|
|
12
|
+
MAX_BODY = 2 * 1024 * 1024
|
|
13
|
+
CSRF_HEADER = "HTTP_X_TRAPLINE"
|
|
14
|
+
|
|
15
|
+
class HttpError < StandardError
|
|
16
|
+
attr_reader :status
|
|
17
|
+
|
|
18
|
+
def initialize(status, message)
|
|
19
|
+
super(message)
|
|
20
|
+
@status = status
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(store: nil, engine: :auto, generator: nil, config: Trapline.config)
|
|
25
|
+
@config = config
|
|
26
|
+
@store = store
|
|
27
|
+
@engine = engine
|
|
28
|
+
@generator = generator
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def call(env)
|
|
32
|
+
authorize!(env)
|
|
33
|
+
method = env["REQUEST_METHOD"]
|
|
34
|
+
path = env["PATH_INFO"].to_s
|
|
35
|
+
path = "/" if path.empty?
|
|
36
|
+
|
|
37
|
+
if %w[POST PUT DELETE PATCH].include?(method) && env[CSRF_HEADER] != "1"
|
|
38
|
+
raise HttpError.new(403, "missing X-Trapline header (CSRF protection)")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
route(method, path, env)
|
|
42
|
+
rescue HttpError => e
|
|
43
|
+
json({ error: e.message }, e.status, e.status == 401 ? { "www-authenticate" => 'Basic realm="Trapline"' } : {})
|
|
44
|
+
rescue NotFound => e
|
|
45
|
+
json({ error: e.message }, 404)
|
|
46
|
+
rescue InvalidSlug, ParseError => e
|
|
47
|
+
json({ error: e.message }, 422)
|
|
48
|
+
rescue NetworkError => e
|
|
49
|
+
json({ error: e.message }, 502)
|
|
50
|
+
rescue Error => e
|
|
51
|
+
json({ error: e.message }, 400)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def store
|
|
57
|
+
@store ||= Store.new(@config.rules_path)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def engine
|
|
61
|
+
@engine = Trapline.engine if @engine == :auto
|
|
62
|
+
@engine
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def slug_route(path)
|
|
66
|
+
path[%r{\A/api/rules/(.+)\z}, 1]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def generator
|
|
70
|
+
@generator ||= Generator.new
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def route(method, path, env)
|
|
74
|
+
case [method, path]
|
|
75
|
+
in ["GET", "/"] then index(env)
|
|
76
|
+
in ["GET", %r{\A/assets/([\w.-]+)\z}] then asset(Regexp.last_match(1))
|
|
77
|
+
in ["GET", "/api/info"] then json(info)
|
|
78
|
+
in ["GET", "/api/templates"] then json(templates)
|
|
79
|
+
in ["POST", "/api/templates/new"]
|
|
80
|
+
b = body(env)
|
|
81
|
+
json(Templates.starter(b["kind"] || "search", language: b["language"] || "ruby",
|
|
82
|
+
id: b["id"].to_s.empty? ? "my-rule" : b["id"]))
|
|
83
|
+
in ["GET", "/api/rules"] then json(rules: store.list, path: store.root, read_only: @config.read_only)
|
|
84
|
+
in ["GET", %r{\A/api/rules/.+\z}] then json(store.read(slug_route(path)))
|
|
85
|
+
in ["PUT", %r{\A/api/rules/.+\z}] then save(slug_route(path), body(env))
|
|
86
|
+
in ["DELETE", %r{\A/api/rules/.+\z}] then destroy(slug_route(path))
|
|
87
|
+
in ["POST", "/api/validate"] then validate(body(env))
|
|
88
|
+
in ["POST", "/api/run"] then run(body(env))
|
|
89
|
+
in ["POST", "/api/merge"] then merge(body(env))
|
|
90
|
+
in ["POST", "/api/cve"]
|
|
91
|
+
b = body(env)
|
|
92
|
+
json(generator.from_cve(b["id"].to_s, language: presence(b["language"])))
|
|
93
|
+
in ["POST", "/api/cve/manual"]
|
|
94
|
+
b = body(env)
|
|
95
|
+
json(generator.analyze(Cve::Record.from_h(b["record"] || {}), language: presence(b["language"])))
|
|
96
|
+
else
|
|
97
|
+
raise HttpError.new(404, "route not found: #{method} #{path}")
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def index(env)
|
|
102
|
+
base = "#{env['SCRIPT_NAME']}/".squeeze("/")
|
|
103
|
+
html = File.read(File.join(ASSETS, "index.html")).sub("__BASE__", base)
|
|
104
|
+
[200, { "content-type" => "text/html; charset=utf-8", "cache-control" => "no-cache" }, [html]]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def asset(name)
|
|
108
|
+
file = File.join(ASSETS, name)
|
|
109
|
+
raise HttpError.new(404, "asset not found") unless File.file?(file)
|
|
110
|
+
|
|
111
|
+
type = MIME.fetch(File.extname(name), "application/octet-stream")
|
|
112
|
+
type += "; charset=utf-8" if type.start_with?("text/", "application/javascript")
|
|
113
|
+
cache = name.end_with?(".woff2") || name.start_with?("codemirror") ? "public, max-age=86400" : "no-cache"
|
|
114
|
+
[200, { "content-type" => type, "cache-control" => cache }, [File.binread(file)]]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def info
|
|
118
|
+
{
|
|
119
|
+
version: VERSION,
|
|
120
|
+
engine: engine && { name: engine.name, version: engine.version, path: engine.binary },
|
|
121
|
+
rules_path: store.root, read_only: @config.read_only, network: @config.allow_network,
|
|
122
|
+
languages: Languages.all.map { |l| { key: l.key, name: l.name, extension: l.extension, comment: l.comment } },
|
|
123
|
+
severities: Rule::SEVERITIES
|
|
124
|
+
}
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def templates
|
|
128
|
+
{ starters: Templates::STARTERS.map { |k, v| { kind: k, title: v[:title] } }, cwe: Templates.all.map(&:summary) }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def save(slug, b)
|
|
132
|
+
writable!
|
|
133
|
+
yaml = b["yaml"].to_s
|
|
134
|
+
result = Validator.new.validate(yaml)
|
|
135
|
+
if result.rules.empty?
|
|
136
|
+
return json({ error: "could not save: #{result.issues.first&.message}", issues: result.issues.map(&:to_h) }, 422)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
saved = store.write(slug, yaml, test_code: b["test_code"], language: presence(b["language"]))
|
|
140
|
+
json(saved.merge(issues: result.issues.map(&:to_h)))
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def destroy(slug)
|
|
144
|
+
writable!
|
|
145
|
+
store.delete(slug)
|
|
146
|
+
json(deleted: true)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def validate(b)
|
|
150
|
+
result = Validator.new(engine: (engine if b["engine"])).validate(b["yaml"].to_s)
|
|
151
|
+
rule = result.rules.first
|
|
152
|
+
json(result.to_h.merge(fields: rule && fields_for(rule), rule_count: result.rules.size))
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def run(b)
|
|
156
|
+
raise HttpError.new(503, "no engine found: install opengrep or semgrep and restart") unless engine
|
|
157
|
+
|
|
158
|
+
yaml = b["yaml"].to_s
|
|
159
|
+
check = Validator.new.validate(yaml)
|
|
160
|
+
unless check.valid?
|
|
161
|
+
return json(findings: [], errors: check.errors.map { |i| "#{i.path}: #{i.message}" }, annotations: nil)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
json(engine.run(yaml, code: b["code"].to_s, language: presence(b["language"])).to_h)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def merge(b)
|
|
168
|
+
file = RuleFile.parse(b["yaml"].to_s)
|
|
169
|
+
rule = file.rules.first || raise(ParseError, "no rule found")
|
|
170
|
+
rule.merge_fields(b["fields"] || {})
|
|
171
|
+
json(yaml: file.to_yaml, fields: fields_for(rule))
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def fields_for(rule)
|
|
175
|
+
meta = rule["metadata"].is_a?(Hash) ? rule["metadata"] : {}
|
|
176
|
+
{ id: rule.id, message: rule.message, severity: rule.severity, languages: Array(rule.languages),
|
|
177
|
+
mode: rule.mode || "search",
|
|
178
|
+
metadata: { cwe: Array(meta["cwe"]), cve: Array(meta["cve"]), references: Array(meta["references"]),
|
|
179
|
+
confidence: meta["confidence"], category: meta["category"] } }
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def authorize!(env)
|
|
183
|
+
auth = @config.authorize
|
|
184
|
+
return if auth.nil?
|
|
185
|
+
|
|
186
|
+
# Fails closed: anything but a callable or a [user, password] pair denies.
|
|
187
|
+
basic = auth.is_a?(Array) && auth.size == 2
|
|
188
|
+
ok = if auth.respond_to?(:call) then auth.call(env)
|
|
189
|
+
elsif basic then basic_auth?(env, *auth)
|
|
190
|
+
else false
|
|
191
|
+
end
|
|
192
|
+
raise HttpError.new(basic ? 401 : 403, "access denied") unless ok
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def basic_auth?(env, user, pass)
|
|
196
|
+
header = env["HTTP_AUTHORIZATION"].to_s
|
|
197
|
+
return false unless header.start_with?("Basic ")
|
|
198
|
+
|
|
199
|
+
given_user, given_pass = header.delete_prefix("Basic ").unpack1("m").split(":", 2)
|
|
200
|
+
OpenSSL.secure_compare(given_user.to_s, user.to_s) & OpenSSL.secure_compare(given_pass.to_s, pass.to_s)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def writable!
|
|
204
|
+
raise HttpError.new(403, "the editor is in read-only mode") if @config.read_only
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def body(env)
|
|
208
|
+
input = env["rack.input"]
|
|
209
|
+
return {} unless input
|
|
210
|
+
|
|
211
|
+
raw = input.read(MAX_BODY + 1).to_s
|
|
212
|
+
input.rewind if input.respond_to?(:rewind)
|
|
213
|
+
raise HttpError.new(413, "request too large") if raw.bytesize > MAX_BODY
|
|
214
|
+
return {} if raw.strip.empty?
|
|
215
|
+
|
|
216
|
+
data = JSON.parse(raw)
|
|
217
|
+
raise HttpError.new(400, "expected a JSON object") unless data.is_a?(Hash)
|
|
218
|
+
|
|
219
|
+
data
|
|
220
|
+
rescue JSON::ParserError
|
|
221
|
+
raise HttpError.new(400, "invalid JSON")
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def presence(v)
|
|
225
|
+
v.to_s unless v.to_s.strip.empty?
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def json(data, status = 200, headers = {})
|
|
229
|
+
[status, { "content-type" => "application/json; charset=utf-8", "cache-control" => "no-store" }.merge(headers),
|
|
230
|
+
[JSON.generate(data)]]
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|