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,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Trapline
6
+ class Rule
7
+ SEVERITIES = %w[CRITICAL HIGH MEDIUM LOW INFO ERROR WARNING INVENTORY EXPERIMENT].freeze
8
+ MODES = %w[search taint join extract].freeze
9
+ PATTERN_KEYS = %w[pattern patterns pattern-either pattern-regex match taint].freeze
10
+
11
+ KEY_ORDER = %w[id message severity languages mode options pattern patterns pattern-either
12
+ pattern-regex match pattern-sources pattern-propagators pattern-sanitizers
13
+ pattern-sinks paths fix fix-regex metadata].freeze
14
+ LIST_METADATA = %w[cwe cve references owasp].freeze
15
+
16
+ attr_reader :data
17
+
18
+ def initialize(data = {})
19
+ @data = stringify(data)
20
+ end
21
+
22
+ def self.parse(yaml)
23
+ RuleFile.parse(yaml).rules.first || raise(ParseError, "no rule found")
24
+ end
25
+
26
+ %w[id message severity languages mode fix].each do |key|
27
+ define_method(key) { @data[key] }
28
+ define_method("#{key}=") { |v| @data[key] = v }
29
+ end
30
+
31
+ def metadata
32
+ @data["metadata"] ||= {}
33
+ end
34
+
35
+ def taint?
36
+ @data["mode"] == "taint"
37
+ end
38
+
39
+ def primary_language
40
+ Array(languages).first
41
+ end
42
+
43
+ def [](key)
44
+ @data[key.to_s]
45
+ end
46
+
47
+ def []=(key, value)
48
+ @data[key.to_s] = value
49
+ end
50
+
51
+ def merge_fields(fields)
52
+ fields = stringify(fields)
53
+ %w[id message severity fix].each do |k|
54
+ next unless fields.key?(k)
55
+
56
+ v = fields[k].to_s
57
+ v.strip.empty? ? @data.delete(k) : @data[k] = v
58
+ end
59
+ @data["languages"] = split_list(fields["languages"]) if fields.key?("languages")
60
+
61
+ (fields["metadata"] || {}).each do |k, v|
62
+ value = LIST_METADATA.include?(k) ? split_list(v) : v
63
+ value.nil? || (value.respond_to?(:empty?) && value.empty?) ? metadata.delete(k) : metadata[k] = value
64
+ end
65
+ @data.delete("metadata") if metadata.empty?
66
+ self
67
+ end
68
+
69
+ def to_h
70
+ known = KEY_ORDER.select { |k| @data.key?(k) }.to_h { |k| [k, @data[k]] }
71
+ known.merge(@data.except(*KEY_ORDER))
72
+ end
73
+
74
+ def to_yaml
75
+ RuleFile.new([self]).to_yaml
76
+ end
77
+
78
+ private
79
+
80
+ def split_list(value)
81
+ case value
82
+ when Array then value.map { |v| v.to_s.strip }.reject(&:empty?)
83
+ when nil then []
84
+ else value.to_s.split(/[,\n]/).map(&:strip).reject(&:empty?)
85
+ end
86
+ end
87
+
88
+ def stringify(obj)
89
+ case obj
90
+ when Hash then obj.to_h { |k, v| [k.to_s, stringify(v)] }
91
+ when Array then obj.map { |v| stringify(v) }
92
+ else obj
93
+ end
94
+ end
95
+ end
96
+
97
+ class RuleFile
98
+ attr_reader :rules
99
+
100
+ def initialize(rules = [])
101
+ @rules = rules
102
+ end
103
+
104
+ def self.parse(yaml)
105
+ doc = begin
106
+ YAML.safe_load(yaml.to_s, aliases: false)
107
+ rescue Psych::SyntaxError => e
108
+ raise ParseError.new("invalid YAML: #{e.problem} (line #{e.line}, column #{e.column})",
109
+ line: e.line, column: e.column)
110
+ end
111
+
112
+ list = case doc
113
+ when Hash
114
+ if doc.key?("rules") then doc["rules"]
115
+ elsif doc.key?("id") then [doc]
116
+ else raise ParseError, "the document must have a `rules:` key"
117
+ end
118
+ when Array then doc
119
+ when nil then raise ParseError, "empty document"
120
+ else raise ParseError, "the document must be a map with `rules:`"
121
+ end
122
+ raise ParseError, "`rules` must be a list" unless list.is_a?(Array)
123
+
124
+ new(list.map { |r| r.is_a?(Hash) ? Rule.new(r) : raise(ParseError, "each item in `rules` must be a map") })
125
+ end
126
+
127
+ def to_yaml
128
+ Yaml.dump({ "rules" => rules.map(&:to_h) })
129
+ end
130
+ end
131
+
132
+ module Yaml
133
+ module_function
134
+
135
+ def dump(obj)
136
+ ast = Psych::Visitors::YAMLTree.create
137
+ ast << obj
138
+ ast.tree.each do |node|
139
+ next unless node.is_a?(Psych::Nodes::Scalar) && node.value.is_a?(String)
140
+
141
+ if node.value.include?("\n") && node.value.lines.none? { |l| l.end_with?(" \n") }
142
+ node.style = Psych::Nodes::Scalar::LITERAL
143
+ node.plain = false
144
+ node.quoted = true
145
+ elsif node.style == Psych::Nodes::Scalar::DOUBLE_QUOTED && plain_safe?(node.value)
146
+ # Psych quotes anything that starts with a symbol ($X, (...)); semgrep
147
+ # patterns are more readable without quotes when YAML allows it.
148
+ node.style = Psych::Nodes::Scalar::PLAIN
149
+ node.plain = true
150
+ node.quoted = false
151
+ end
152
+ end
153
+ ast.tree.yaml(nil, line_width: -1).sub(/\A---\s*\n/, "")
154
+ end
155
+
156
+ def plain_safe?(value)
157
+ return false if value.empty? || value.include?("\n") || value != value.strip
158
+ return false if value.match?(/\A[-?:,\[\]{}#&*!|>'"%@`]/) || value.include?(": ") || value.include?(" #")
159
+
160
+ YAML.safe_load("k: #{value}") == { "k" => value }
161
+ rescue Psych::Exception
162
+ false
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trapline
4
+ module Runtime
5
+ DOTNET = { name: "dotnet", images: %w[mcr.microsoft.com/dotnet/runtime mcr.microsoft.com/dotnet/aspnet] }.freeze
6
+ RUNTIMES = {
7
+ "php:php" => { name: "php", images: %w[php] },
8
+ "python:python" => { name: "python", images: %w[python] },
9
+ "python:cpython" => { name: "python", images: %w[python] },
10
+ "nodejs:node.js" => { name: "node", images: %w[node] },
11
+ "ruby-lang:ruby" => { name: "ruby", images: %w[ruby] },
12
+ "golang:go" => { name: "golang", images: %w[golang] },
13
+ "microsoft:.net" => DOTNET,
14
+ "microsoft:.net_core" => DOTNET,
15
+ "microsoft:asp.net_core" => { name: "aspnet", images: %w[mcr.microsoft.com/dotnet/aspnet] }
16
+ }.freeze
17
+
18
+ Interval = Struct.new(:low, :high) # [low, high); high nil = unbounded
19
+
20
+ module_function
21
+
22
+ def runtime_for(product)
23
+ RUNTIMES[Cve.product_key(product)]
24
+ end
25
+
26
+ # CPE ranges as [major, minor, patch] intervals. Non-numeric versions
27
+ # (8.2.0rc1, 2.7.x) are skipped.
28
+ def intervals(ranges)
29
+ Array(ranges).filter_map do |r|
30
+ if r["exact"]
31
+ v = parse(r["exact"]) or next
32
+ next Interval.new(v, bump(v))
33
+ end
34
+
35
+ low = bound(r["start_including"], r["start_excluding"]) || ([0, 0, 0] unless r["start_including"] || r["start_excluding"])
36
+ high = bound(r["end_excluding"], r["end_including"])
37
+ next if low.nil? || ((r["end_excluding"] || r["end_including"]) && high.nil?)
38
+ next if high && (low <=> high) >= 0
39
+
40
+ Interval.new(low, high)
41
+ end
42
+ end
43
+
44
+ # Half-open bound: `as_is` is used directly, `bumped` is moved one patch up.
45
+ def bound(as_is, bumped)
46
+ as_is ? parse(as_is) : bumped && bump(parse(bumped))
47
+ end
48
+
49
+ def parse(version)
50
+ return nil unless version.to_s.match?(/\A\d+(\.\d+){0,2}\z/)
51
+
52
+ parts = version.split(".").map(&:to_i)
53
+ parts + [0] * (3 - parts.size)
54
+ end
55
+
56
+ def bump(v)
57
+ v && [v[0], v[1], v[2] + 1]
58
+ end
59
+
60
+ def format(v)
61
+ v.join(".")
62
+ end
63
+
64
+ # Partial tags (`8`, `8.1`) only match when every version under them is
65
+ # affected: `php:7.4` is flagged, `php:8.2` is not when 8.2.35 fixed it.
66
+ def version_regex(intervals)
67
+ intervals.map { |i| range_regex(i.low, i.high) }.uniq.join("|")
68
+ end
69
+
70
+ def range_regex(low, high)
71
+ n = low.size
72
+ a = low[0]
73
+ d = high&.first
74
+ return int_range(a, d && d - 1) if n == 1
75
+ return "#{a}\\.(?:#{range_regex(low.drop(1), high.drop(1))})" if d == a
76
+
77
+ alts = []
78
+ if low.drop(1).all?(&:zero?)
79
+ first_full = a
80
+ else
81
+ alts << "#{a}\\.(?:#{range_regex(low.drop(1), nil)})"
82
+ first_full = a + 1
83
+ end
84
+ last_full = d && d - 1
85
+ if last_full.nil? || first_full <= last_full
86
+ alts << "(?:#{int_range(first_full, last_full)})(?:\\.\\d+){0,#{n - 1}}"
87
+ end
88
+ alts << "#{d}\\.(?:#{range_regex([0] * (n - 1), high.drop(1))})" if d && !high.drop(1).all?(&:zero?)
89
+ alts.join("|")
90
+ end
91
+
92
+ def int_range(lo, hi)
93
+ return (lo..hi).to_a.join("|") if hi
94
+
95
+ digits = lo.to_s.size
96
+ [*(lo..(10**digits - 1)), "\\d{#{digits + 1},}"].join("|")
97
+ end
98
+
99
+ def image_rule(product, rule_id)
100
+ rt = runtime_for(product) or return nil
101
+ ivs = intervals(product["versions"])
102
+ return nil if ivs.empty?
103
+
104
+ fixed = ivs.filter_map { |i| i.high && format(i.high) }.uniq
105
+ { name: rt[:name], images: rt[:images], body: { "pattern-regex" => image_regex(rt[:images], ivs) },
106
+ test: test_code(rt[:images].first, ivs, rule_id), fixed: fixed }
107
+ end
108
+
109
+ # Matches `<image>:<tag>` as a whole token (not `myorg/php:8.1` nor
110
+ # `php:8.1.2x`) and Helm's `repository: <image>` followed within a few
111
+ # lines by `tag: <tag>`. The finding starts at the image name.
112
+ def image_regex(images, ivs)
113
+ name = images.map { |i| i.include?("/") ? Regexp.escape(i) : "(?:docker\\.io/)?(?:library/)?#{Regexp.escape(i)}" }
114
+ .join("|")
115
+ tag = "(?:#{version_regex(ivs)})(?:-[\\w.-]+)?(?![\\w.-])"
116
+ ref = "(?<![\\w./@-])(?:#{name}):#{tag}"
117
+ helm = "repository:[ \\t]*[\"']?(?:#{name})[\"']?[ \\t]*\\r?\\n" \
118
+ "(?:(?![ \\t]*(?:repository|tag):)[^\\n]*\\n){0,3}[ \\t]*tag:[ \\t]*[\"']?#{tag}"
119
+ "#{ref}|#{helm}"
120
+ end
121
+
122
+ def test_code(image, ivs, rule_id)
123
+ lines = []
124
+ ivs.first(4).each do |i|
125
+ lines << "# ruleid: #{rule_id}" << "FROM #{image}:#{format(vulnerable(i))}-alpine"
126
+ lines << "# ok: #{rule_id}" << "FROM #{image}:#{format(i.high)}-alpine" if i.high
127
+ end
128
+ i = ivs.first
129
+ v = format(vulnerable(i))
130
+ lines << "" << "# docker-compose.yml, Kubernetes, CI" << "services:" << " app:"
131
+ lines << " # ruleid: #{rule_id}" << " image: #{image}:#{v}"
132
+ lines << " # ok: #{rule_id}" << " image: myorg/#{image.split('/').last}:#{v}"
133
+ lines << "containers:" << " # ruleid: #{rule_id}" << " - image: \"#{image}:#{v}\""
134
+ lines << " # ok: #{rule_id}" << " - image: \"#{image}:#{format(i.high)}\"" if i.high
135
+ lines << "" << "# Helm values.yaml" << "image:" << " # ruleid: #{rule_id}" << " repository: #{image}"
136
+ lines << " pullPolicy: IfNotPresent" << " tag: \"#{v}\""
137
+ if i.high
138
+ lines << "sidecar:" << " # ok: #{rule_id}" << " repository: #{image}" << " tag: \"#{format(i.high)}\""
139
+ end
140
+ "#{lines.join("\n")}\n"
141
+ end
142
+
143
+ def vulnerable(interval)
144
+ high = interval.high
145
+ high && high[2].positive? ? [*high[0, 2], high[2] - 1] : interval.low
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Trapline
6
+ class Store
7
+ SLUG = %r{\A[A-Za-z0-9][A-Za-z0-9._\-/]*\z}
8
+ RULE_EXT = %w[.yaml .yml].freeze
9
+
10
+ attr_reader :root
11
+
12
+ def initialize(root = Trapline.config.rules_path)
13
+ @root = File.expand_path(root.to_s)
14
+ end
15
+
16
+ def list
17
+ Dir.glob(File.join(root, "**", "*.{yaml,yml}")).sort.map { |path| summary(slug_for(path), path) }
18
+ end
19
+
20
+ def exists?(slug)
21
+ !rule_path(slug).nil?
22
+ end
23
+
24
+ def read(slug)
25
+ path = rule_path(slug) || raise(NotFound, "rule not found: #{slug}")
26
+ test = test_path(slug)
27
+ yaml = File.read(path)
28
+ { slug: slug, yaml: yaml, test_code: test && File.read(test), test_language: test && test_language(test, yaml),
29
+ updated_at: File.mtime(path).utc.iso8601 }
30
+ end
31
+
32
+ def write(slug, yaml, test_code: nil, language: nil)
33
+ check_slug!(slug)
34
+ path = rule_path(slug) || File.join(root, "#{slug}.yaml")
35
+ FileUtils.mkdir_p(File.dirname(path))
36
+ File.write(path, yaml.end_with?("\n") ? yaml : "#{yaml}\n")
37
+
38
+ unless test_code.to_s.strip.empty?
39
+ ext = Languages.extension_for(language || safe_language(yaml))
40
+ test_file = File.join(File.dirname(path), "#{File.basename(slug)}.#{ext}")
41
+ (test_paths(slug) - [test_file]).each { |p| File.delete(p) }
42
+ File.write(test_file, test_code)
43
+ end
44
+ read(slug)
45
+ end
46
+
47
+ def delete(slug)
48
+ path = rule_path(slug) || raise(NotFound, "rule not found: #{slug}")
49
+ File.delete(path)
50
+ test_paths(slug).each { |p| File.delete(p) }
51
+ true
52
+ end
53
+
54
+ # Registry-style ids are named after their last segment, like the
55
+ # semgrep/opengrep rules repos.
56
+ def suggest_slug(id)
57
+ name = id.to_s.split(".").reject(&:empty?).last.to_s
58
+ base = name.downcase.gsub(%r{[^a-z0-9_/-]+}, "-").gsub(%r{\A[-/]+|[-/]+\z}, "")
59
+ base = "rule" if base.empty?
60
+ (1..).lazy.map { |n| n == 1 ? base : "#{base}-#{n}" }.find { |slug| !exists?(slug) }
61
+ end
62
+
63
+ private
64
+
65
+ def summary(slug, path)
66
+ rule = RuleFile.parse(File.read(path)).rules.first
67
+ meta = rule&.data&.dig("metadata") || {}
68
+ { slug: slug, id: rule&.id, message: rule&.message.to_s.lines.first.to_s.strip,
69
+ severity: rule&.severity, languages: Array(rule&.languages),
70
+ cve: Array(meta["cve"]), cwe: Array(meta["cwe"]), has_test: !test_path(slug).nil?,
71
+ updated_at: File.mtime(path).utc.iso8601 }
72
+ rescue ParseError => e
73
+ { slug: slug, id: nil, error: e.message, updated_at: File.mtime(path).utc.iso8601 }
74
+ end
75
+
76
+ def slug_for(path)
77
+ path.delete_prefix("#{root}/").sub(/\.ya?ml\z/, "")
78
+ end
79
+
80
+ def check_slug!(slug)
81
+ raise InvalidSlug, "invalid name: #{slug.inspect}" unless slug.to_s.match?(SLUG) && !slug.include?("..")
82
+ end
83
+
84
+ def rule_path(slug)
85
+ check_slug!(slug)
86
+ RULE_EXT.map { |e| File.join(root, "#{slug}#{e}") }.find { |p| File.file?(p) }
87
+ end
88
+
89
+ def test_paths(slug)
90
+ check_slug!(slug)
91
+ Dir.glob(File.join(root, "#{slug}.*")).reject { |p| RULE_EXT.include?(File.extname(p)) || p.end_with?(".fixed") }
92
+ end
93
+
94
+ def test_path(slug)
95
+ test_paths(slug).first
96
+ end
97
+
98
+ # generic and regex share .txt: the rule's own language wins.
99
+ def test_language(test, yaml)
100
+ ext = File.extname(test).delete_prefix(".").downcase
101
+ own = Languages.find(safe_language(yaml))
102
+ return own.key if own&.extension == ext
103
+
104
+ Languages.from_extension(ext)&.key
105
+ end
106
+
107
+ def safe_language(yaml)
108
+ RuleFile.parse(yaml).rules.first&.primary_language
109
+ rescue ParseError
110
+ nil
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trapline
4
+ class Suite
5
+ Result = Struct.new(:slug, :status, :passed, :total, :errors, :failures, keyword_init: true)
6
+
7
+ def initialize(store: Trapline.store, engine: Trapline.engine)
8
+ @store = store
9
+ @engine = engine
10
+ end
11
+
12
+ def run
13
+ raise Error, "no engine found: install opengrep or semgrep" unless @engine
14
+
15
+ @store.list.map { |entry| run_one(entry) }
16
+ end
17
+
18
+ private
19
+
20
+ def run_one(entry)
21
+ slug = entry[:slug]
22
+ return result(slug, "error", errors: [entry[:error]]) if entry[:error]
23
+
24
+ rule = @store.read(slug)
25
+ validation = Validator.new.validate(rule[:yaml])
26
+ return result(slug, "error", errors: validation.errors.map { |i| "#{i.path}: #{i.message}" }) unless validation.valid?
27
+ return result(slug, "skipped") unless rule[:test_code]
28
+
29
+ res = @engine.run(rule[:yaml], code: rule[:test_code], language: rule[:test_language])
30
+ ann = res.annotations || { passed: 0, total: 0, results: [] }
31
+ failures = ann[:results].select { |r| r[:status] == "fail" }.map { |r| r[:reason] }
32
+ status = if res.errors.any? then "error"
33
+ elsif ann[:total].zero? then "no-annotations"
34
+ elsif failures.any? then "fail"
35
+ else "pass"
36
+ end
37
+ result(slug, status, passed: ann[:passed], total: ann[:total], errors: res.errors, failures: failures)
38
+ end
39
+
40
+ def result(slug, status, passed: 0, total: 0, errors: [], failures: [])
41
+ Result.new(slug: slug, status: status, passed: passed, total: total, errors: errors, failures: failures)
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,167 @@
1
+ cwe: CWE-22
2
+ name: "Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')"
3
+ title: Path traversal
4
+ owasp: ["A01:2021 - Broken Access Control"]
5
+ keywords: ["path traversal", "directory traversal", "../", "arbitrary file read", "arbitrary files"]
6
+ message: >-
7
+ Untrusted input is used to build a file path, which can allow reading or
8
+ writing files outside the intended directory. Normalize the path and check
9
+ that it stays inside an allowed base directory.
10
+ languages:
11
+ ruby:
12
+ rule: |
13
+ mode: taint
14
+ pattern-sources:
15
+ - pattern: params
16
+ - pattern: cookies
17
+ pattern-sanitizers:
18
+ - pattern: File.basename(...)
19
+ pattern-sinks:
20
+ - patterns:
21
+ - pattern-either:
22
+ - pattern: File.read($P, ...)
23
+ - pattern: File.open($P, ...)
24
+ - pattern: File.readlines($P, ...)
25
+ - pattern: File.write($P, ...)
26
+ - pattern: IO.read($P, ...)
27
+ - pattern: send_file($P, ...)
28
+ - pattern: Rails.root.join(..., $P, ...)
29
+ - focus-metavariable: $P
30
+ test: |
31
+ class FilesController < ApplicationController
32
+ def show
33
+ # ruleid: __RULE_ID__
34
+ send_file Rails.root.join("uploads", params[:name])
35
+ # ruleid: __RULE_ID__
36
+ data = File.read("/var/data/#{params[:name]}")
37
+ # ok: __RULE_ID__
38
+ data = File.read("/var/data/#{File.basename(params[:name])}")
39
+ end
40
+ end
41
+ python:
42
+ rule: |
43
+ mode: taint
44
+ pattern-sources:
45
+ - pattern: flask.request.$ANY
46
+ - patterns:
47
+ - pattern-inside: |
48
+ def $VIEW(request, ...):
49
+ ...
50
+ - pattern: request.$ANY
51
+ pattern-sanitizers:
52
+ - pattern: os.path.basename(...)
53
+ - pattern: werkzeug.utils.secure_filename(...)
54
+ pattern-sinks:
55
+ - pattern: open(...)
56
+ - pattern: flask.send_file(...)
57
+ - pattern: os.remove(...)
58
+ - pattern: shutil.rmtree(...)
59
+ test: |
60
+ import os
61
+ from flask import request, send_file
62
+ from werkzeug.utils import secure_filename
63
+
64
+ def download():
65
+ name = request.args.get("file")
66
+ # ruleid: __RULE_ID__
67
+ return send_file(os.path.join("/srv/files", name))
68
+
69
+ def download_safe():
70
+ name = secure_filename(request.args.get("file"))
71
+ # ok: __RULE_ID__
72
+ return send_file(os.path.join("/srv/files", name))
73
+ javascript:
74
+ rule: |
75
+ mode: taint
76
+ pattern-sources:
77
+ - pattern: $REQ.query
78
+ - pattern: $REQ.body
79
+ - pattern: $REQ.params
80
+ pattern-sanitizers:
81
+ - pattern: path.basename(...)
82
+ pattern-sinks:
83
+ - patterns:
84
+ - pattern-either:
85
+ - pattern: fs.$FN($P, ...)
86
+ - pattern: $RES.sendFile($P, ...)
87
+ - pattern: $RES.download($P, ...)
88
+ - focus-metavariable: $P
89
+ test: |
90
+ const fs = require("fs");
91
+ const path = require("path");
92
+
93
+ app.get("/file", (req, res) => {
94
+ // ruleid: __RULE_ID__
95
+ fs.readFile("/srv/files/" + req.query.name, (e, d) => res.send(d));
96
+ // ok: __RULE_ID__
97
+ fs.readFile(path.join("/srv/files", path.basename(req.query.name)), (e, d) => res.send(d));
98
+ });
99
+ java:
100
+ rule: |
101
+ mode: taint
102
+ pattern-sources:
103
+ - pattern: (javax.servlet.http.HttpServletRequest $REQ).$GET(...)
104
+ - pattern: (jakarta.servlet.http.HttpServletRequest $REQ).$GET(...)
105
+ - patterns:
106
+ - pattern-inside: |
107
+ $RET $METHOD(..., @RequestParam $T $P, ...) { ... }
108
+ - pattern: $P
109
+ pattern-sanitizers:
110
+ - pattern: FilenameUtils.getName(...)
111
+ - pattern: (java.nio.file.Path $P).getFileName()
112
+ pattern-sinks:
113
+ - pattern: new File(...)
114
+ - pattern: new FileInputStream(...)
115
+ - pattern: new FileOutputStream(...)
116
+ - pattern: new FileReader(...)
117
+ - pattern: Paths.get(...)
118
+ - pattern: Path.of(...)
119
+ test: |
120
+ import javax.servlet.http.HttpServletRequest;
121
+ import java.io.File;
122
+ import java.io.FileInputStream;
123
+ import org.apache.commons.io.FilenameUtils;
124
+
125
+ public class DownloadServlet {
126
+ public void doGet(HttpServletRequest request) throws Exception {
127
+ String name = request.getParameter("file");
128
+ // ruleid: __RULE_ID__
129
+ FileInputStream in = new FileInputStream("/srv/files/" + name);
130
+ // ok: __RULE_ID__
131
+ File safe = new File("/srv/files/" + FilenameUtils.getName(name));
132
+ }
133
+ }
134
+ go:
135
+ rule: |
136
+ mode: taint
137
+ pattern-sources:
138
+ - pattern: '($R : *http.Request).URL.Query().Get(...)'
139
+ - pattern: '($R : *http.Request).FormValue(...)'
140
+ pattern-sanitizers:
141
+ - pattern: filepath.Base(...)
142
+ pattern-sinks:
143
+ - pattern: os.Open(...)
144
+ - pattern: os.OpenFile(...)
145
+ - pattern: os.ReadFile(...)
146
+ - pattern: ioutil.ReadFile(...)
147
+ - pattern: os.Remove(...)
148
+ - patterns:
149
+ - pattern: http.ServeFile($W, $REQ, $P)
150
+ - focus-metavariable: $P
151
+ test: |
152
+ package main
153
+
154
+ import (
155
+ "net/http"
156
+ "os"
157
+ "path/filepath"
158
+ )
159
+
160
+ func download(w http.ResponseWriter, r *http.Request) {
161
+ name := r.URL.Query().Get("file")
162
+ // ruleid: __RULE_ID__
163
+ data, _ := os.ReadFile("/srv/files/" + name)
164
+ // ok: __RULE_ID__
165
+ safe, _ := os.ReadFile(filepath.Join("/srv/files", filepath.Base(name)))
166
+ w.Write(append(data, safe...))
167
+ }