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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +585 -0
  4. data/config.ru.example +21 -0
  5. data/exe/trapline +8 -0
  6. data/lib/generators/trapline/install_generator.rb +34 -0
  7. data/lib/generators/trapline/templates/initializer.rb +23 -0
  8. data/lib/tasks/trapline.rake +19 -0
  9. data/lib/trapline/cli.rb +212 -0
  10. data/lib/trapline/configuration.rb +19 -0
  11. data/lib/trapline/cve/client.rb +203 -0
  12. data/lib/trapline/cve/record.rb +160 -0
  13. data/lib/trapline/engine.rb +243 -0
  14. data/lib/trapline/generator.rb +236 -0
  15. data/lib/trapline/languages.rb +65 -0
  16. data/lib/trapline/railtie.rb +26 -0
  17. data/lib/trapline/rule.rb +165 -0
  18. data/lib/trapline/runtime.rb +148 -0
  19. data/lib/trapline/store.rb +113 -0
  20. data/lib/trapline/suite.rb +44 -0
  21. data/lib/trapline/templates/cwe/cwe-22.yml +167 -0
  22. data/lib/trapline/templates/cwe/cwe-327.yml +124 -0
  23. data/lib/trapline/templates/cwe/cwe-502.yml +123 -0
  24. data/lib/trapline/templates/cwe/cwe-601.yml +124 -0
  25. data/lib/trapline/templates/cwe/cwe-611.yml +87 -0
  26. data/lib/trapline/templates/cwe/cwe-78.yml +166 -0
  27. data/lib/trapline/templates/cwe/cwe-79.yml +164 -0
  28. data/lib/trapline/templates/cwe/cwe-798.yml +125 -0
  29. data/lib/trapline/templates/cwe/cwe-89.yml +189 -0
  30. data/lib/trapline/templates/cwe/cwe-917.yml +46 -0
  31. data/lib/trapline/templates/cwe/cwe-918.yml +166 -0
  32. data/lib/trapline/templates/cwe/cwe-94.yml +131 -0
  33. data/lib/trapline/templates.rb +261 -0
  34. data/lib/trapline/validator.rb +359 -0
  35. data/lib/trapline/version.rb +5 -0
  36. data/lib/trapline/web/app.rb +234 -0
  37. data/lib/trapline/web/assets/THIRD_PARTY_LICENSES.txt +214 -0
  38. data/lib/trapline/web/assets/app.css +399 -0
  39. data/lib/trapline/web/assets/app.js +804 -0
  40. data/lib/trapline/web/assets/codemirror.css +344 -0
  41. data/lib/trapline/web/assets/codemirror.js +13263 -0
  42. data/lib/trapline/web/assets/index.html +165 -0
  43. data/lib/trapline/web/assets/jetbrains-mono-latin-400-normal.woff2 +0 -0
  44. data/lib/trapline/web/assets/jetbrains-mono-latin-600-normal.woff2 +0 -0
  45. data/lib/trapline/web/assets/public-sans-latin-400-normal.woff2 +0 -0
  46. data/lib/trapline/web/assets/public-sans-latin-500-normal.woff2 +0 -0
  47. data/lib/trapline/web/assets/public-sans-latin-600-normal.woff2 +0 -0
  48. data/lib/trapline/web/assets/public-sans-latin-700-normal.woff2 +0 -0
  49. data/lib/trapline/web/server.rb +108 -0
  50. data/lib/trapline.rb +70 -0
  51. 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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trapline
4
+ VERSION = "0.1.0"
5
+ 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