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,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
|
+
}
|