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,131 @@
1
+ cwe: CWE-94
2
+ name: "Improper Control of Generation of Code ('Code Injection')"
3
+ title: Code injection
4
+ owasp: ["A03:2021 - Injection"]
5
+ # Only the vulnerability class: "remote code execution"/"arbitrary code" describe an
6
+ # impact shared by memory corruption, deserialization, command injection...
7
+ keywords: ["code injection", "eval", "template injection", "ssti"]
8
+ message: >-
9
+ Untrusted input is evaluated as code. Never pass user-controlled data to
10
+ eval-like functions; map inputs to an allowlist of operations instead.
11
+ languages:
12
+ ruby:
13
+ rule: |
14
+ mode: taint
15
+ pattern-sources:
16
+ - pattern: params
17
+ - pattern: cookies
18
+ pattern-sinks:
19
+ - pattern: eval(...)
20
+ - pattern: $O.instance_eval(...)
21
+ - pattern: $O.class_eval(...)
22
+ - pattern: $O.module_eval(...)
23
+ - pattern: $X.constantize
24
+ - pattern: $X.safe_constantize
25
+ - pattern: ERB.new(...)
26
+ - patterns:
27
+ - pattern-either:
28
+ - pattern: $O.send($M, ...)
29
+ - pattern: $O.public_send($M, ...)
30
+ - focus-metavariable: $M
31
+ test: |
32
+ class CalcController < ApplicationController
33
+ def run
34
+ # ruleid: __RULE_ID__
35
+ result = eval(params[:expr])
36
+ # ruleid: __RULE_ID__
37
+ klass = params[:type].constantize
38
+ # ruleid: __RULE_ID__
39
+ report.public_send(params[:format])
40
+ # ok: __RULE_ID__
41
+ klass = { "user" => User, "admin" => Admin }.fetch(params[:type])
42
+ end
43
+ end
44
+ python:
45
+ rule: |
46
+ mode: taint
47
+ pattern-sources:
48
+ - pattern: flask.request.$ANY
49
+ - patterns:
50
+ - pattern-inside: |
51
+ def $VIEW(request, ...):
52
+ ...
53
+ - pattern: request.$ANY
54
+ pattern-sinks:
55
+ - pattern: eval(...)
56
+ - pattern: exec(...)
57
+ - pattern: compile(...)
58
+ - pattern: flask.render_template_string(...)
59
+ - pattern: jinja2.Template(...)
60
+ - pattern: importlib.import_module(...)
61
+ test: |
62
+ import ast
63
+ from flask import request, render_template_string
64
+
65
+ def calc():
66
+ expr = request.args.get("expr")
67
+ # ruleid: __RULE_ID__
68
+ result = eval(expr)
69
+ # ruleid: __RULE_ID__
70
+ page = render_template_string("<p>" + expr + "</p>")
71
+ # ok: __RULE_ID__
72
+ value = ast.literal_eval(expr)
73
+ javascript:
74
+ rule: |
75
+ mode: taint
76
+ pattern-sources:
77
+ - pattern: $REQ.query
78
+ - pattern: $REQ.body
79
+ - pattern: $REQ.params
80
+ pattern-sinks:
81
+ - pattern: eval(...)
82
+ - pattern: Function(...)
83
+ - pattern: new Function(...)
84
+ - pattern: $VM.runInNewContext(...)
85
+ - pattern: $VM.runInThisContext(...)
86
+ - patterns:
87
+ - pattern-either:
88
+ - pattern: setTimeout($CODE, ...)
89
+ - pattern: setInterval($CODE, ...)
90
+ - focus-metavariable: $CODE
91
+ test: |
92
+ app.get("/calc", (req, res) => {
93
+ // ruleid: __RULE_ID__
94
+ const result = eval(req.query.expr);
95
+ // ruleid: __RULE_ID__
96
+ const fn = new Function("x", req.body.code);
97
+ // ok: __RULE_ID__
98
+ const n = JSON.parse(req.query.expr);
99
+ res.json({ result, n });
100
+ });
101
+ java:
102
+ rule: |
103
+ mode: taint
104
+ pattern-sources:
105
+ - pattern: (javax.servlet.http.HttpServletRequest $REQ).$GET(...)
106
+ - pattern: (jakarta.servlet.http.HttpServletRequest $REQ).$GET(...)
107
+ - patterns:
108
+ - pattern-inside: |
109
+ $RET $METHOD(..., @RequestParam $T $P, ...) { ... }
110
+ - pattern: $P
111
+ pattern-sinks:
112
+ - pattern: (javax.script.ScriptEngine $E).eval(...)
113
+ - pattern: (ScriptEngine $E).eval(...)
114
+ - pattern: (ExpressionParser $P).parseExpression(...)
115
+ - pattern: (SpelExpressionParser $P).parseExpression(...)
116
+ - pattern: Ognl.getValue(...)
117
+ test: |
118
+ import javax.servlet.http.HttpServletRequest;
119
+ import javax.script.ScriptEngine;
120
+ import javax.script.ScriptEngineManager;
121
+
122
+ public class CalcServlet {
123
+ public void doGet(HttpServletRequest request) throws Exception {
124
+ String expr = request.getParameter("expr");
125
+ ScriptEngine engine = new ScriptEngineManager().getEngineByName("js");
126
+ // ruleid: __RULE_ID__
127
+ Object result = engine.eval(expr);
128
+ // ok: __RULE_ID__
129
+ Object fixed = engine.eval("1 + 1");
130
+ }
131
+ }
@@ -0,0 +1,261 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Trapline
6
+ module Templates
7
+ DIR = File.expand_path("templates/cwe", __dir__)
8
+
9
+ CWE_ALIASES = {
10
+ "CWE-77" => "CWE-78", "CWE-88" => "CWE-78",
11
+ "CWE-80" => "CWE-79", "CWE-83" => "CWE-79", "CWE-87" => "CWE-79",
12
+ "CWE-23" => "CWE-22", "CWE-35" => "CWE-22", "CWE-36" => "CWE-22", "CWE-73" => "CWE-22",
13
+ "CWE-564" => "CWE-89", "CWE-943" => "CWE-89",
14
+ "CWE-95" => "CWE-94", "CWE-1336" => "CWE-94", "CWE-96" => "CWE-94",
15
+ "CWE-326" => "CWE-327", "CWE-328" => "CWE-327", "CWE-916" => "CWE-327",
16
+ "CWE-259" => "CWE-798", "CWE-321" => "CWE-798",
17
+ "CWE-776" => "CWE-611", "CWE-827" => "CWE-611",
18
+ "CWE-1188" => "CWE-917"
19
+ }.freeze
20
+
21
+ Template = Struct.new(:cwe, :name, :title, :owasp, :keywords, :message, :languages, keyword_init: true) do
22
+ def label
23
+ "#{cwe}: #{name}"
24
+ end
25
+
26
+ def supports?(lang)
27
+ languages.key?(lang.to_s)
28
+ end
29
+
30
+ def body(lang)
31
+ YAML.safe_load(languages.fetch(lang.to_s)["rule"])
32
+ end
33
+
34
+ def test_code(lang, rule_id)
35
+ languages.fetch(lang.to_s)["test"].gsub("__RULE_ID__", rule_id)
36
+ end
37
+
38
+ def summary
39
+ { cwe: cwe, name: name, title: title, languages: languages.keys }
40
+ end
41
+ end
42
+
43
+ STARTERS = {
44
+ "search" => {
45
+ title: "Simple search (pattern)",
46
+ body: { "pattern" => "dangerous_call(...)" },
47
+ test: {
48
+ "ruby" => "# ruleid: __RULE_ID__\ndangerous_call(1)\n# ok: __RULE_ID__\nsafe_call(1)\n",
49
+ "python" => "# ruleid: __RULE_ID__\ndangerous_call(1)\n# ok: __RULE_ID__\nsafe_call(1)\n",
50
+ "javascript" => "// ruleid: __RULE_ID__\ndangerous_call(1);\n// ok: __RULE_ID__\nsafe_call(1);\n"
51
+ }
52
+ },
53
+ "patterns" => {
54
+ title: "Combination (patterns + pattern-not)",
55
+ body: {
56
+ "patterns" => [
57
+ { "pattern" => "$OBJ.$METHOD($ARG)" },
58
+ { "pattern-not" => "$OBJ.$METHOD(\"...\")" },
59
+ { "metavariable-regex" => { "metavariable" => "$METHOD", "regex" => "^(eval|exec)$" } }
60
+ ]
61
+ },
62
+ test: {
63
+ "ruby" => "# ruleid: __RULE_ID__\nobj.eval(input)\n# ok: __RULE_ID__\nobj.eval(\"1 + 1\")\n",
64
+ "python" => "# ruleid: __RULE_ID__\nobj.eval(data)\n# ok: __RULE_ID__\nobj.eval(\"1 + 1\")\n",
65
+ "javascript" => "// ruleid: __RULE_ID__\nobj.eval(input);\n// ok: __RULE_ID__\nobj.eval(\"1 + 1\");\n"
66
+ }
67
+ },
68
+ "taint" => {
69
+ title: "Data flow (taint)",
70
+ body: {
71
+ "mode" => "taint",
72
+ "pattern-sources" => [{ "pattern" => "user_input(...)" }],
73
+ "pattern-sanitizers" => [{ "pattern" => "sanitize(...)" }],
74
+ "pattern-sinks" => [{ "pattern" => "dangerous_sink(...)" }]
75
+ },
76
+ test: {
77
+ "ruby" => "# ruleid: __RULE_ID__\ndangerous_sink(user_input(:q))\n# ok: __RULE_ID__\ndangerous_sink(sanitize(user_input(:q)))\n",
78
+ "python" => "# ruleid: __RULE_ID__\ndangerous_sink(user_input(\"q\"))\n# ok: __RULE_ID__\ndangerous_sink(sanitize(user_input(\"q\")))\n",
79
+ "javascript" => "// ruleid: __RULE_ID__\ndangerous_sink(user_input(\"q\"));\n// ok: __RULE_ID__\ndangerous_sink(sanitize(user_input(\"q\")));\n",
80
+ "java" => "class Example {\n void run() {\n // ruleid: __RULE_ID__\n dangerous_sink(user_input(\"q\"));\n // ok: __RULE_ID__\n dangerous_sink(sanitize(user_input(\"q\")));\n }\n}\n",
81
+ "go" => "package main\n\nfunc run() {\n\t// ruleid: __RULE_ID__\n\tdangerous_sink(user_input(\"q\"))\n\t// ok: __RULE_ID__\n\tdangerous_sink(sanitize(user_input(\"q\")))\n}\n",
82
+ "typescript" => "// ruleid: __RULE_ID__\ndangerous_sink(user_input(\"q\"));\n// ok: __RULE_ID__\ndangerous_sink(sanitize(user_input(\"q\")));\n",
83
+ "php" => "<?php\n// ruleid: __RULE_ID__\ndangerous_sink(user_input(\"q\"));\n// ok: __RULE_ID__\ndangerous_sink(sanitize(user_input(\"q\")));\n",
84
+ "csharp" => "class Example {\n void Run() {\n // ruleid: __RULE_ID__\n dangerous_sink(user_input(\"q\"));\n // ok: __RULE_ID__\n dangerous_sink(sanitize(user_input(\"q\")));\n }\n}\n",
85
+ "kotlin" => "fun run() {\n // ruleid: __RULE_ID__\n dangerous_sink(user_input(\"q\"))\n // ok: __RULE_ID__\n dangerous_sink(sanitize(user_input(\"q\")))\n}\n",
86
+ "scala" => "object Example {\n def run(): Unit = {\n // ruleid: __RULE_ID__\n dangerous_sink(user_input(\"q\"))\n // ok: __RULE_ID__\n dangerous_sink(sanitize(user_input(\"q\")))\n }\n}\n",
87
+ "rust" => "fn run() {\n // ruleid: __RULE_ID__\n dangerous_sink(user_input(\"q\"));\n // ok: __RULE_ID__\n dangerous_sink(sanitize(user_input(\"q\")));\n}\n",
88
+ "c" => "void run(void) {\n // ruleid: __RULE_ID__\n dangerous_sink(user_input(\"q\"));\n // ok: __RULE_ID__\n dangerous_sink(sanitize(user_input(\"q\")));\n}\n",
89
+ "cpp" => "void run() {\n // ruleid: __RULE_ID__\n dangerous_sink(user_input(\"q\"));\n // ok: __RULE_ID__\n dangerous_sink(sanitize(user_input(\"q\")));\n}\n",
90
+ "swift" => "func run() {\n // ruleid: __RULE_ID__\n dangerous_sink(user_input(\"q\"))\n // ok: __RULE_ID__\n dangerous_sink(sanitize(user_input(\"q\")))\n}\n"
91
+ }
92
+ },
93
+ "regex" => {
94
+ title: "Regular expression (pattern-regex)",
95
+ body: { "pattern-regex" => "(?i)todo:\\s*security" },
96
+ test: { "generic" => "# ruleid: __RULE_ID__\n# TODO: security review this\n" }
97
+ }
98
+ }.freeze
99
+
100
+ module_function
101
+
102
+ def all
103
+ @all ||= Dir.glob(File.join(DIR, "*.yml")).sort_by { |f| f[/\d+/].to_i }.map do |file|
104
+ data = YAML.safe_load(File.read(file))
105
+ Template.new(**data.transform_keys(&:to_sym).slice(*Template.members))
106
+ end
107
+ end
108
+
109
+ def for_cwe(cwe)
110
+ id = Cve::Record.normalize_cwe(cwe)
111
+ id = CWE_ALIASES.fetch(id, id)
112
+ all.find { |t| t.cwe == id }
113
+ end
114
+
115
+ def cwe_name(cwe)
116
+ all.find { |t| t.cwe == cwe }&.label || cwe
117
+ end
118
+
119
+ def detect(text)
120
+ text = text.to_s.downcase
121
+ return [] if text.empty?
122
+
123
+ all.map { |t| [t, Array(t.keywords).count { |k| keyword_regex(k).match?(text) }] }
124
+ .select { |_, score| score.positive? }
125
+ .sort_by { |_, score| -score }
126
+ .map(&:first)
127
+ end
128
+
129
+ # Whole words ("eval" must not hit "retrieval"), except at edges that are
130
+ # not word characters ("../").
131
+ def keyword_regex(keyword)
132
+ k = keyword.downcase
133
+ Regexp.new("#{'\\b' if k.match?(/\A\w/)}#{Regexp.escape(k)}#{'\\b' if k.match?(/\w\z/)}")
134
+ end
135
+
136
+ def starter(kind, language: "ruby", id: "my-rule")
137
+ spec = STARTERS.fetch(kind.to_s) { STARTERS["search"] }
138
+ lang = language.to_s
139
+ test = spec[:test][lang] || spec[:test]["generic"] || placeholder_test(lang)
140
+ langs = kind.to_s == "regex" ? ["regex"] : [lang]
141
+ rule = Rule.new({ "id" => id, "message" => "Describe the problem and how to fix it.", "severity" => "WARNING",
142
+ "languages" => langs, **spec[:body],
143
+ "metadata" => { "category" => "security", "confidence" => "MEDIUM" } })
144
+ { yaml: rule.to_yaml, test_code: test.gsub("__RULE_ID__", id), language: langs.first }
145
+ end
146
+
147
+ def placeholder_test(lang)
148
+ c = Languages.comment_for(lang)
149
+ "#{c} Write an example of vulnerable code and a fixed one here.\n" \
150
+ "#{c} Before the line that MUST produce a finding, add a comment with ruleid, a colon and the rule id;\n" \
151
+ "#{c} before the one that must NOT, use ok instead of ruleid.\n"
152
+ end
153
+
154
+ def dependency(package, rule_id)
155
+ lang = package.language
156
+ name = package.name.to_s
157
+ body, test = case lang
158
+ when "ruby" then ruby_dependency(name, rule_id)
159
+ when "python" then python_dependency(name, rule_id)
160
+ when "javascript" then js_dependency(name, rule_id)
161
+ when "java" then java_dependency(name, rule_id)
162
+ when "go" then go_dependency(name, rule_id)
163
+ end
164
+ return nil unless body
165
+
166
+ { body: body, test: test, language: lang }
167
+ end
168
+
169
+ def ruby_dependency(name, id)
170
+ paths = [name, name.tr("-", "/")].uniq
171
+ body = { "pattern-either" => paths.map { |p| { "pattern" => "require \"#{p}\"" } } +
172
+ [{ "pattern" => "gem \"#{name}\", ..." }] }
173
+ test = <<~RUBY
174
+ # ruleid: #{id}
175
+ gem "#{name}", "~> 1.0"
176
+ # ruleid: #{id}
177
+ require "#{paths.last}"
178
+ # ok: #{id}
179
+ require "json"
180
+ RUBY
181
+ [body, test]
182
+ end
183
+
184
+ PYTHON_IMPORTS = { "pyyaml" => "yaml", "pillow" => "PIL", "beautifulsoup4" => "bs4",
185
+ "scikit-learn" => "sklearn", "python-dateutil" => "dateutil",
186
+ "pyjwt" => "jwt", "pycryptodome" => "Crypto", "opencv-python" => "cv2" }.freeze
187
+
188
+ def python_dependency(name, id)
189
+ mod = PYTHON_IMPORTS.fetch(name.downcase) { name.downcase.tr("-", "_").tr(".", "_") }
190
+ body = { "pattern-either" => [
191
+ { "pattern" => "import #{mod}" },
192
+ { "pattern" => "import #{mod}.$SUB" },
193
+ { "pattern" => "from #{mod} import $X" },
194
+ { "pattern" => "from #{mod}.$SUB import $X" }
195
+ ] }
196
+ test = <<~PY
197
+ # ruleid: #{id}
198
+ import #{mod}
199
+ # ruleid: #{id}
200
+ from #{mod} import something
201
+ # ok: #{id}
202
+ import json
203
+ PY
204
+ [body, test]
205
+ end
206
+
207
+ def js_dependency(name, id)
208
+ re = "=~/^#{Regexp.escape(name).gsub('/', '\/')}(\\/.*)?$/"
209
+ body = { "pattern-either" => [
210
+ { "pattern" => "require(\"#{re}\")" },
211
+ { "pattern" => "import $X from \"#{re}\"" },
212
+ { "pattern" => "import * as $X from \"#{re}\"" },
213
+ { "pattern" => "import {...} from \"#{re}\"" },
214
+ { "pattern" => "import \"#{re}\"" }
215
+ ] }
216
+ test = <<~JS
217
+ // ruleid: #{id}
218
+ const lib = require("#{name}");
219
+ // ruleid: #{id}
220
+ import thing from "#{name}";
221
+ // ok: #{id}
222
+ const fs = require("fs");
223
+ JS
224
+ [body, test]
225
+ end
226
+
227
+ def java_dependency(name, id)
228
+ group = name.split(":").first.to_s
229
+ return nil if group.empty?
230
+
231
+ body = { "pattern-regex" => "(?m)^\\s*import\\s+(static\\s+)?#{Regexp.escape(group)}\\." }
232
+ test = <<~JAVA
233
+ // ruleid: #{id}
234
+ import #{group}.Example;
235
+ // ok: #{id}
236
+ import java.util.List;
237
+
238
+ public class App {}
239
+ JAVA
240
+ [body, test]
241
+ end
242
+
243
+ def go_dependency(name, id)
244
+ # A regex: `import "..."` patterns report the whole import block on its first line.
245
+ body = { "pattern-regex" => "(?m)^\\s*(import\\s+)?([\\w.]+\\s+)?\"#{Regexp.escape(name)}(/[^\"]*)?\"\\s*$" }
246
+ test = <<~GO
247
+ package main
248
+
249
+ import (
250
+ // ruleid: #{id}
251
+ "#{name}"
252
+ // ok: #{id}
253
+ "fmt"
254
+ )
255
+
256
+ func main() { fmt.Println(x) }
257
+ GO
258
+ [body, test]
259
+ end
260
+ end
261
+ end