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,243 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "tmpdir"
|
|
6
|
+
|
|
7
|
+
module Trapline
|
|
8
|
+
class Engine
|
|
9
|
+
CANDIDATES = %w[opengrep semgrep].freeze
|
|
10
|
+
QUIET_ENV = { "SEMGREP_SEND_METRICS" => "off", "SEMGREP_ENABLE_VERSION_CHECK" => "0",
|
|
11
|
+
"OPENGREP_SEND_METRICS" => "off" }.freeze
|
|
12
|
+
|
|
13
|
+
Finding = Struct.new(:rule_id, :line, :end_line, :col, :end_col, :message, :severity, :code,
|
|
14
|
+
:metavars, keyword_init: true) do
|
|
15
|
+
def to_h
|
|
16
|
+
super.compact
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
RunResult = Struct.new(:findings, :errors, :annotations, :duration, :engine, keyword_init: true) do
|
|
21
|
+
def ok?
|
|
22
|
+
errors.empty?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def annotations_passed?
|
|
26
|
+
annotations.nil? || annotations[:failed].zero?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def to_h
|
|
30
|
+
{ findings: findings.map(&:to_h), errors: errors, annotations: annotations,
|
|
31
|
+
duration: duration, engine: engine }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
attr_reader :binary, :timeout
|
|
36
|
+
|
|
37
|
+
def self.detect(preferred = nil)
|
|
38
|
+
[preferred, *CANDIDATES].compact.uniq.each do |bin|
|
|
39
|
+
path = executable?(bin) ? bin : which(bin)
|
|
40
|
+
return new(path) if path
|
|
41
|
+
end
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.which(cmd)
|
|
46
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).map { |dir| File.join(dir, cmd) }.find { |f| executable?(f) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.executable?(path)
|
|
50
|
+
File.executable?(path) && !File.directory?(path)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def initialize(binary, timeout: Trapline.config.engine_timeout)
|
|
54
|
+
@binary = binary
|
|
55
|
+
@timeout = timeout
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def name
|
|
59
|
+
File.basename(binary).include?("opengrep") ? "opengrep" : "semgrep"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def version
|
|
63
|
+
@version ||= capture([binary, "--version"]).first.to_s.strip.lines.last.to_s.strip
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def run(yaml, code:, language: nil)
|
|
67
|
+
rules = begin
|
|
68
|
+
RuleFile.parse(yaml).rules
|
|
69
|
+
rescue ParseError
|
|
70
|
+
[]
|
|
71
|
+
end
|
|
72
|
+
language ||= rules.first&.primary_language
|
|
73
|
+
rule_ids = rules.filter_map { |r| r.id&.to_s }
|
|
74
|
+
ext = Languages.extension_for(language)
|
|
75
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
76
|
+
|
|
77
|
+
json, errors = scan(yaml, ext == "dockerfile" ? "Dockerfile" : "target.#{ext}", code)
|
|
78
|
+
findings = Array(json&.dig("results")).map { |r| build_finding(r, code, rule_ids) }
|
|
79
|
+
RunResult.new(findings: findings, errors: errors, annotations: Annotations.evaluate(code, findings, rule_ids: rule_ids),
|
|
80
|
+
duration: (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).round(3),
|
|
81
|
+
engine: "#{name} #{version}")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def validate(yaml)
|
|
85
|
+
scan(yaml, "empty.txt", "").last
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def scan(yaml, target_name, code)
|
|
91
|
+
Dir.mktmpdir("trapline") do |dir|
|
|
92
|
+
rule_path = File.join(dir, "rule.yaml")
|
|
93
|
+
target = File.join(dir, target_name)
|
|
94
|
+
File.write(rule_path, yaml)
|
|
95
|
+
File.write(target, code)
|
|
96
|
+
|
|
97
|
+
cmd = [binary, "scan", "--config", rule_path, "--json", "--disable-version-check", "--no-git-ignore"]
|
|
98
|
+
cmd << "--metrics=off" if name == "semgrep"
|
|
99
|
+
out, err, status = capture(cmd << target, chdir: dir)
|
|
100
|
+
json = parse_json(out)
|
|
101
|
+
[json, errors_from(json, err, status, dir)]
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def parse_json(text)
|
|
106
|
+
JSON.parse(text.to_s)
|
|
107
|
+
rescue JSON::ParserError
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def errors_from(json, err, status, dir)
|
|
112
|
+
if json
|
|
113
|
+
Array(json["errors"]).reject { |e| e["level"] == "warn" }.map { |e| format_error(e, dir) }.uniq
|
|
114
|
+
elsif status.nil?
|
|
115
|
+
["the engine exceeded the #{timeout}s timeout"]
|
|
116
|
+
else
|
|
117
|
+
detail = err.to_s.lines.map(&:strip).reject(&:empty?).last(3).join(" ")
|
|
118
|
+
["failed to run #{name} (exit #{status.exitstatus}): #{strip_tmpdir(detail, dir)}"]
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def format_error(e, dir)
|
|
123
|
+
msg = strip_tmpdir((e["long_msg"] || e["message"] || e["short_msg"] || e).to_s, dir).strip
|
|
124
|
+
line = e.dig("spans", 0, "start", "line")
|
|
125
|
+
line ? "#{e['type']}: #{msg} (rule line #{line})" : [e["type"], msg].compact.join(": ")
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# The engine prints the temp dir as given or resolved (macOS /var ->
|
|
129
|
+
# /private/var), and also in dotted form inside rule ids.
|
|
130
|
+
def strip_tmpdir(text, dir)
|
|
131
|
+
paths = [File.realpath(dir), dir].uniq
|
|
132
|
+
prefixes = paths.map { |p| "#{p}/" } + paths.map { |p| "#{p.delete_prefix('/').tr('/', '.')}." }
|
|
133
|
+
prefixes.sort_by { |p| -p.length }.reduce(text) { |t, p| t.gsub(p, "") }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# check_id comes prefixed with the dotted config path, and rule ids may
|
|
137
|
+
# contain dots themselves, so match the declared ids.
|
|
138
|
+
def rule_id_for(check_id, rule_ids)
|
|
139
|
+
rule_ids.select { |id| check_id == id || check_id.end_with?(".#{id}") }.max_by(&:length) ||
|
|
140
|
+
check_id.split(".").last
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def build_finding(r, code, rule_ids)
|
|
144
|
+
start = r["start"] || {}
|
|
145
|
+
finish = r["end"] || {}
|
|
146
|
+
snippet = code.lines[(start["line"].to_i - 1)..(finish["line"].to_i - 1)].to_a.join.chomp
|
|
147
|
+
metavars = (r.dig("extra", "metavars") || {}).transform_values { |v| v["abstract_content"] }
|
|
148
|
+
Finding.new(rule_id: rule_id_for(r["check_id"].to_s, rule_ids), line: start["line"], end_line: finish["line"],
|
|
149
|
+
col: start["col"], end_col: finish["col"], message: r.dig("extra", "message"),
|
|
150
|
+
severity: r.dig("extra", "severity"), code: snippet, metavars: metavars.empty? ? nil : metavars)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def capture(cmd, chdir: Dir.pwd)
|
|
154
|
+
Open3.popen3(QUIET_ENV, *cmd, chdir: chdir, pgroup: true) do |stdin, stdout, stderr, wait|
|
|
155
|
+
stdin.close
|
|
156
|
+
out_reader = Thread.new { stdout.read }
|
|
157
|
+
err_reader = Thread.new { stderr.read }
|
|
158
|
+
unless wait.join(timeout)
|
|
159
|
+
begin
|
|
160
|
+
Process.kill("KILL", -wait.pid)
|
|
161
|
+
rescue Errno::ESRCH
|
|
162
|
+
nil
|
|
163
|
+
end
|
|
164
|
+
return [nil, "timeout", nil]
|
|
165
|
+
end
|
|
166
|
+
[out_reader.value, err_reader.value, wait.value]
|
|
167
|
+
end
|
|
168
|
+
rescue Errno::ENOENT => e
|
|
169
|
+
[nil, e.message, Struct.new(:exitstatus).new(127)]
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
module Annotations
|
|
174
|
+
PATTERN = %r{(?:#|//|/\*|<!--|--)\s*(ruleid|ok|todoruleid|todook)\s*:\s*([A-Za-z0-9._\-, ]+)}
|
|
175
|
+
|
|
176
|
+
module_function
|
|
177
|
+
|
|
178
|
+
def parse(code)
|
|
179
|
+
lines = code.lines
|
|
180
|
+
lines.each_with_index.filter_map do |line, idx|
|
|
181
|
+
m = line.match(PATTERN) or next
|
|
182
|
+
target = own_line?(line, m) ? next_code_line(lines, idx) : idx + 1
|
|
183
|
+
{ kind: m[1], rule_ids: m[2].split(",").map(&:strip).reject(&:empty?), annotation_line: idx + 1, line: target }
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def own_line?(line, match)
|
|
188
|
+
line[0...match.begin(0)].strip.empty?
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Stacked annotations all point to the first line after them.
|
|
192
|
+
def next_code_line(lines, idx)
|
|
193
|
+
j = idx + 1
|
|
194
|
+
j += 1 while j < lines.size && (m = lines[j].match(PATTERN)) && own_line?(lines[j], m)
|
|
195
|
+
j + 1
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Like `--test`, every id in a ruleid/ok/todoruleid annotation must be a
|
|
199
|
+
# rule of the file (todook is exempt).
|
|
200
|
+
ID_CHECKED = %w[ruleid ok todoruleid].freeze
|
|
201
|
+
|
|
202
|
+
def evaluate(code, findings, rule_ids: [])
|
|
203
|
+
anns = parse(code)
|
|
204
|
+
return nil if anns.empty?
|
|
205
|
+
|
|
206
|
+
hit = Hash.new { |h, k| h[k] = [] }
|
|
207
|
+
findings.each { |f| hit[f.line] << f.rule_id }
|
|
208
|
+
lines = code.lines
|
|
209
|
+
results = anns.map do |a|
|
|
210
|
+
unknown = ID_CHECKED.include?(a[:kind]) && rule_ids.any? ? a[:rule_ids] - rule_ids : []
|
|
211
|
+
next check(a, lines[a[:line] - 1], hit[a[:line]]) if unknown.empty?
|
|
212
|
+
|
|
213
|
+
a.merge(line: a[:annotation_line], status: "fail",
|
|
214
|
+
reason: "`#{a[:kind]}` names a rule that is not in this file: #{unknown.join(', ')}")
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
covered = anns.select { |a| %w[ruleid todoruleid ok].include?(a[:kind]) }.map { |a| a[:line] }
|
|
218
|
+
findings.reject { |f| covered.include?(f.line) }.each do |f|
|
|
219
|
+
results << { kind: "unexpected", rule_ids: [f.rule_id], line: f.line, status: "fail",
|
|
220
|
+
reason: "unexpected match on line #{f.line} (the line is not marked as expected)" }
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
{ total: results.size, passed: results.count { |r| r[:status] == "pass" },
|
|
224
|
+
failed: results.count { |r| r[:status] == "fail" }, results: results }
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def check(ann, target, hit_ids)
|
|
228
|
+
# A blank or missing target would make `ok:` pass without testing anything.
|
|
229
|
+
if target.nil? || target.strip.empty?
|
|
230
|
+
return ann.merge(line: ann[:annotation_line], status: "fail",
|
|
231
|
+
reason: "`#{ann[:kind]}` annotation has no code below it (line #{ann[:line]} is " \
|
|
232
|
+
"#{target.nil? ? 'past the end of the file' : 'blank'})")
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
found = ann[:rule_ids].any? { |id| hit_ids.include?(id) }
|
|
236
|
+
case ann[:kind]
|
|
237
|
+
when "ruleid" then found ? ann.merge(status: "pass") : ann.merge(status: "fail", reason: "false negative: expected a finding on line #{ann[:line]}")
|
|
238
|
+
when "ok" then found ? ann.merge(status: "fail", reason: "false positive on line #{ann[:line]}") : ann.merge(status: "pass")
|
|
239
|
+
else ann.merge(status: "todo")
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trapline
|
|
4
|
+
class Generator
|
|
5
|
+
# Too broad to pick a template from: the description may say more.
|
|
6
|
+
GENERIC_CWES = %w[CWE-20 CWE-74 CWE-284 CWE-664 CWE-693 CWE-707].freeze
|
|
7
|
+
# Bugs in native code, which no rule over application code can find.
|
|
8
|
+
MEMORY_CWES = %w[CWE-119 CWE-120 CWE-121 CWE-122 CWE-125 CWE-131 CWE-190 CWE-191 CWE-401 CWE-415
|
|
9
|
+
CWE-416 CWE-457 CWE-476 CWE-680 CWE-787 CWE-824 CWE-908 CWE-1325].freeze
|
|
10
|
+
|
|
11
|
+
Candidate = Struct.new(:key, :kind, :title, :language, :languages, :yaml, :test_code, :notes, keyword_init: true)
|
|
12
|
+
|
|
13
|
+
def initialize(client: nil, offline: !Trapline.config.allow_network)
|
|
14
|
+
@client = client || Cve::Client.new
|
|
15
|
+
@offline = offline
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def from_cve(id, language: nil)
|
|
19
|
+
raise NetworkError, "network access disabled (TRAPLINE_OFFLINE=1); enter the CVE data manually" if @offline
|
|
20
|
+
|
|
21
|
+
result = analyze(@client.fetch(id), language: language)
|
|
22
|
+
if @client.respond_to?(:timed_out?) && @client.timed_out?
|
|
23
|
+
result[:notes].unshift("The lookup hit its time limit (cve_timeout), so some requests were skipped or cut short: " \
|
|
24
|
+
"the data may be incomplete. Try again or fill in the missing fields.")
|
|
25
|
+
end
|
|
26
|
+
result
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def analyze(record, language: nil)
|
|
30
|
+
record = Cve::Record.from_h(record) if record.is_a?(Hash)
|
|
31
|
+
record.promote_cve!
|
|
32
|
+
language = Languages.find(language)&.key if language
|
|
33
|
+
fallback = record.languages.first
|
|
34
|
+
|
|
35
|
+
templates, how = matching_templates(record)
|
|
36
|
+
notes = [how, memory_note(record), *language_notes(record, language, fallback)].compact
|
|
37
|
+
candidates = templates.filter_map do |tpl|
|
|
38
|
+
lang = pick_language(tpl, language, record) or next
|
|
39
|
+
wanted = language || fallback
|
|
40
|
+
if wanted && lang != wanted
|
|
41
|
+
notes << "Template #{tpl.cwe} has no #{lang_name(wanted)} version yet; using #{lang_name(lang)}. " \
|
|
42
|
+
"Use the taint skeleton for #{lang_name(wanted)}."
|
|
43
|
+
end
|
|
44
|
+
from_template(record, tpl, lang)
|
|
45
|
+
end
|
|
46
|
+
candidates += record.runtime_products.filter_map { |product| from_runtime(record, product) }
|
|
47
|
+
candidates += record.packages.filter_map do |pkg|
|
|
48
|
+
next if pkg.language.nil? || pkg.runtime_product || (language && pkg.language != language)
|
|
49
|
+
|
|
50
|
+
from_dependency(record, pkg)
|
|
51
|
+
end
|
|
52
|
+
candidates << starter(record, language || fallback || "ruby", templates.first)
|
|
53
|
+
|
|
54
|
+
{ record: record.to_h, candidates: candidates.uniq(&:key).map(&:to_h), notes: notes }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def memory_note(record)
|
|
60
|
+
memory = record.cwes & MEMORY_CWES
|
|
61
|
+
return if memory.empty?
|
|
62
|
+
|
|
63
|
+
where = record.product_names.first(3).join(", ")
|
|
64
|
+
image = record.runtime_products.any? { |p| Runtime.image_rule(p, "x") }
|
|
65
|
+
"#{memory.join(', ')}: memory-safety flaw in native code. " \
|
|
66
|
+
"A rule over application code cannot detect the bug itself; check the installed versions#{" (#{where})" unless where.empty?} and upgrade. " \
|
|
67
|
+
"#{'The container image draft flags affected runtime versions. ' if image}" \
|
|
68
|
+
"Use the skeleton only to flag code paths that reach the vulnerable feature."
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def language_notes(record, language, fallback)
|
|
72
|
+
return [] if language
|
|
73
|
+
return ["Could not detect the language (no affected packages or known products); using Ruby. Pick the language and generate again."] unless fallback
|
|
74
|
+
return [] if record.languages.size < 2
|
|
75
|
+
|
|
76
|
+
["Affects several languages (#{record.languages.join(', ')}); drafts that need one use #{fallback}. " \
|
|
77
|
+
"Pick another in Language to generate them for it."]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def matching_templates(record)
|
|
81
|
+
ranked = Templates.detect([record.summary, record.description].join(" "))
|
|
82
|
+
by_cwe = record.cwes.filter_map { |c| Templates.for_cwe(c) }.uniq
|
|
83
|
+
by_cwe = by_cwe.sort_by { |t| [ranked.index(t) || ranked.size, by_cwe.index(t)] }
|
|
84
|
+
return [by_cwe, "CWE reported by the source: #{record.cwes.join(', ')}."] if by_cwe.any?
|
|
85
|
+
|
|
86
|
+
# "remote code execution" in a use-after-free advisory is the impact, not
|
|
87
|
+
# a code injection: a specific CWE from the source beats a keyword guess.
|
|
88
|
+
if (record.cwes - GENERIC_CWES).any?
|
|
89
|
+
return [[], "There is no template for #{record.cwes.join(', ')} yet; no class guessed from the description " \
|
|
90
|
+
"because the source already names the weakness."]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
detected = ranked.first(2)
|
|
94
|
+
if detected.any?
|
|
95
|
+
reported = record.cwes.empty? ? "CWE (not reported)" : record.cwes.join(", ")
|
|
96
|
+
return [detected, "No template for #{reported}; class detected from the description: #{detected.map(&:cwe).join(', ')}."]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
[[], record.cwes.empty? ? "The source reported no CWE and the description did not point to a known class." : "There is no template for #{record.cwes.join(', ')} yet."]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def pick_language(tpl, preferred, record)
|
|
103
|
+
[preferred, *record.languages, "ruby", *tpl.languages.keys].compact.find { |l| tpl.supports?(l) }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def from_template(record, tpl, lang)
|
|
107
|
+
id = rule_id(record, slugify(tpl.title), lang: lang)
|
|
108
|
+
rule = Rule.new(base(record, id, lang, message: "#{record.id}: #{truncate(record.title, 180)}\n\n#{tpl.message.strip}",
|
|
109
|
+
cwe: tpl.label, owasp: tpl.owasp, confidence: "MEDIUM").merge(tpl.body(lang)))
|
|
110
|
+
notes = [
|
|
111
|
+
"Generic #{tpl.title} template for #{lang_name(lang)}. Adjust sources/sinks to the affected component's APIs.",
|
|
112
|
+
"The test code uses `ruleid:`/`ok:` annotations; add examples from the real vulnerable and fixed code."
|
|
113
|
+
]
|
|
114
|
+
notes << "Templates also available in: #{(tpl.languages.keys - [lang]).join(', ')}." if tpl.languages.size > 1
|
|
115
|
+
Candidate.new(key: "cwe:#{tpl.cwe}:#{lang}", kind: "cwe", title: "#{tpl.title} (#{tpl.cwe})",
|
|
116
|
+
language: lang, languages: tpl.languages.keys, yaml: rule.to_yaml,
|
|
117
|
+
test_code: tpl.test_code(lang, id), notes: notes)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def from_runtime(record, product)
|
|
121
|
+
runtime = Runtime.runtime_for(product) or return nil
|
|
122
|
+
name = runtime[:name]
|
|
123
|
+
id = rule_id(record, "vulnerable-#{name}-image", lang: "regex", scope: name)
|
|
124
|
+
rt = Runtime.image_rule(product, id) or return nil
|
|
125
|
+
|
|
126
|
+
image = rt[:images].first
|
|
127
|
+
rule = Rule.new(base(record, id, "regex",
|
|
128
|
+
message: "This image reference uses a #{name} version affected by #{record.id}: " \
|
|
129
|
+
"#{short_title(record)}.#{upgrade_hint(rt[:fixed], 'No fixed version reported yet.')}",
|
|
130
|
+
cwe: all_cwes(record), confidence: "HIGH").merge(rt[:body]))
|
|
131
|
+
rule.metadata["affected"] = [{ "image" => rt[:images].join(", "), "fixed" => rt[:fixed] }.reject { |_, v| v.empty? }]
|
|
132
|
+
notes = [
|
|
133
|
+
"Flags references to #{rt[:images].map { |i| "`#{i}:<tag>`" }.join(' and ')} whose version is in the ranges reported by NVD: " \
|
|
134
|
+
"Dockerfile `FROM`, `image:` in Compose, Kubernetes and CI files, Helm `repository:` + `tag:`, `docker run`. " \
|
|
135
|
+
"It runs on every file; to narrow it, add `paths: include:` (e.g. Dockerfile, *.yml, *.yaml).",
|
|
136
|
+
"Floating tags (#{floating_example(image, rt[:fixed])}, #{image}:latest) are only flagged when the whole line is affected: they move to the latest patch on rebuild. " \
|
|
137
|
+
"Tags set through variables (`${PHP_VERSION}`, Helm templates) are not resolved.",
|
|
138
|
+
"Covers only the official image. Built images, other base images and OS packages need an image scanner: " \
|
|
139
|
+
"Trivy (`trivy image`), Grype or osv-scanner (`osv-scanner scan image`)."
|
|
140
|
+
]
|
|
141
|
+
if %w[dotnet aspnet].include?(name)
|
|
142
|
+
notes << "The SDK image (mcr.microsoft.com/dotnet/sdk) is not covered: its tags use SDK version numbers (8.0.404), not the runtime's."
|
|
143
|
+
end
|
|
144
|
+
Candidate.new(key: "runtime:#{name}", kind: "runtime", title: "Vulnerable #{name} version in container images",
|
|
145
|
+
language: "regex", languages: ["regex"], yaml: rule.to_yaml, test_code: rt[:test], notes: notes)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def floating_example(image, fixed)
|
|
149
|
+
v = fixed.first
|
|
150
|
+
v ? "#{image}:#{v.split('.').first(2).join('.')}" : "#{image}:<major>"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def from_dependency(record, pkg)
|
|
154
|
+
pkg_slug = slugify(pkg.name.split(%r{[:/]}).last)
|
|
155
|
+
id = rule_id(record, "#{pkg_slug}-usage", lang: pkg.language, scope: pkg_slug)
|
|
156
|
+
dep = Templates.dependency(pkg, id) or return nil
|
|
157
|
+
|
|
158
|
+
rule = Rule.new(base(record, id, dep[:language],
|
|
159
|
+
message: "This code uses #{pkg.name}, affected by #{record.id}: " \
|
|
160
|
+
"#{short_title(record)}.#{upgrade_hint(Array(pkg.fixed))}",
|
|
161
|
+
cwe: all_cwes(record), confidence: "LOW").merge(dep[:body]))
|
|
162
|
+
rule.metadata["affected"] = [{ "package" => pkg.name, "ecosystem" => pkg.ecosystem,
|
|
163
|
+
"introduced" => pkg.introduced, "fixed" => pkg.fixed }.compact]
|
|
164
|
+
notes = [
|
|
165
|
+
"Finds imports/declarations of #{pkg.name} (#{pkg.ecosystem}). The rule does not check the installed version — pair it with an SCA tool (bundler-audit, npm audit, osv-scanner).",
|
|
166
|
+
"Refine it with the specific vulnerable API (e.g. the method named in the advisory) to reduce noise."
|
|
167
|
+
]
|
|
168
|
+
Candidate.new(key: "dependency:#{pkg.ecosystem}:#{pkg.name}", kind: "dependency",
|
|
169
|
+
title: "Usage of package #{pkg.name} (#{pkg.ecosystem})", language: dep[:language],
|
|
170
|
+
languages: [dep[:language]], yaml: rule.to_yaml, test_code: dep[:test], notes: notes)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def starter(record, lang, tpl)
|
|
174
|
+
id = rule_id(record, "custom", lang: lang)
|
|
175
|
+
skeleton = Templates.starter("taint", language: lang, id: id)
|
|
176
|
+
rule = Rule.parse(skeleton[:yaml])
|
|
177
|
+
rule.merge_fields("message" => "#{record.id}: #{truncate(record.title, 180)}")
|
|
178
|
+
rule.data.merge!(base(record, id, lang, message: rule.message, cwe: all_cwes(record),
|
|
179
|
+
owasp: tpl&.owasp, confidence: "LOW").slice("severity", "metadata"))
|
|
180
|
+
Candidate.new(key: "starter:#{lang}", kind: "starter", title: "Taint skeleton (write from scratch)",
|
|
181
|
+
language: lang, languages: [lang], yaml: rule.to_yaml, test_code: skeleton[:test_code],
|
|
182
|
+
notes: ["Skeleton with the CVE metadata filled in. Define sources, sanitizers and sinks based on the advisory."])
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def base(record, id, lang, message:, cwe: nil, owasp: nil, confidence: "MEDIUM")
|
|
186
|
+
ids = [record.id, *record.aliases]
|
|
187
|
+
nvd = ["https://nvd.nist.gov/vuln/detail/#{record.id}"] if record.id.to_s.start_with?("CVE-")
|
|
188
|
+
meta = {
|
|
189
|
+
"cve" => ids.grep(/\ACVE-/).uniq, "ghsa" => ids.grep(/\AGHSA-/).uniq,
|
|
190
|
+
"cwe" => Array(cwe).compact, "owasp" => Array(owasp),
|
|
191
|
+
"category" => "security", "subcategory" => ["vuln"], "confidence" => confidence,
|
|
192
|
+
"likelihood" => "MEDIUM", "impact" => impact(record), "cvss_score" => record.cvss_score&.to_s,
|
|
193
|
+
"references" => (Array(nvd) + record.references.first(4)).uniq, "source" => "trapline #{VERSION}"
|
|
194
|
+
}.reject { |_, v| v.nil? || (v.respond_to?(:empty?) && v.empty?) }
|
|
195
|
+
{ "id" => id, "message" => message, "severity" => record.rule_severity, "languages" => [lang], "metadata" => meta }
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def all_cwes(record)
|
|
199
|
+
record.cwes.map { |c| Templates.cwe_name(c) }
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def impact(record)
|
|
203
|
+
{ "CRITICAL" => "HIGH", "HIGH" => "HIGH", "LOW" => "LOW" }.fetch(record.severity.to_s, "MEDIUM")
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def upgrade_hint(fixed, none = nil)
|
|
207
|
+
return " #{none}".rstrip if fixed.empty?
|
|
208
|
+
|
|
209
|
+
" Upgrade to #{fixed.join(', ')} or later."
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# <language>.<framework, package or "lang">.security.<cve>-<suffix>
|
|
213
|
+
def rule_id(record, suffix, lang:, scope: nil)
|
|
214
|
+
name = [record.id.to_s.downcase, suffix].reject(&:empty?).join("-")
|
|
215
|
+
scope = slugify(scope)
|
|
216
|
+
[slugify(lang), scope.empty? ? "lang" : scope, "security", name].join(".")
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def lang_name(key)
|
|
220
|
+
Languages.find(key)&.name || key
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def short_title(record)
|
|
224
|
+
truncate(record.title, 160).delete_suffix(".")
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def slugify(text)
|
|
228
|
+
text.to_s.downcase.gsub(/\(.*?\)/, "").gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-\z/, "")
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def truncate(text, len)
|
|
232
|
+
t = text.to_s.gsub(/\s+/, " ").strip
|
|
233
|
+
t.length > len ? "#{t[0, len - 1].rstrip}…" : t
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trapline
|
|
4
|
+
module Languages
|
|
5
|
+
Lang = Struct.new(:key, :name, :extension, :comment, :aliases, keyword_init: true)
|
|
6
|
+
|
|
7
|
+
ALL = [
|
|
8
|
+
Lang.new(key: "ruby", name: "Ruby", extension: "rb", comment: "#", aliases: %w[rb]),
|
|
9
|
+
Lang.new(key: "python", name: "Python", extension: "py", comment: "#", aliases: %w[py python3]),
|
|
10
|
+
Lang.new(key: "javascript", name: "JavaScript", extension: "js", comment: "//", aliases: %w[js]),
|
|
11
|
+
Lang.new(key: "typescript", name: "TypeScript", extension: "ts", comment: "//", aliases: %w[ts]),
|
|
12
|
+
Lang.new(key: "java", name: "Java", extension: "java", comment: "//", aliases: []),
|
|
13
|
+
Lang.new(key: "go", name: "Go", extension: "go", comment: "//", aliases: %w[golang]),
|
|
14
|
+
Lang.new(key: "php", name: "PHP", extension: "php", comment: "//", aliases: []),
|
|
15
|
+
Lang.new(key: "csharp", name: "C#", extension: "cs", comment: "//", aliases: %w[cs c#]),
|
|
16
|
+
Lang.new(key: "kotlin", name: "Kotlin", extension: "kt", comment: "//", aliases: %w[kt]),
|
|
17
|
+
Lang.new(key: "scala", name: "Scala", extension: "scala", comment: "//", aliases: []),
|
|
18
|
+
Lang.new(key: "rust", name: "Rust", extension: "rs", comment: "//", aliases: []),
|
|
19
|
+
Lang.new(key: "c", name: "C", extension: "c", comment: "//", aliases: []),
|
|
20
|
+
Lang.new(key: "cpp", name: "C++", extension: "cpp", comment: "//", aliases: %w[c++]),
|
|
21
|
+
Lang.new(key: "swift", name: "Swift", extension: "swift", comment: "//", aliases: []),
|
|
22
|
+
Lang.new(key: "bash", name: "Bash", extension: "sh", comment: "#", aliases: %w[sh]),
|
|
23
|
+
Lang.new(key: "yaml", name: "YAML", extension: "yaml", comment: "#", aliases: %w[yml]),
|
|
24
|
+
Lang.new(key: "json", name: "JSON", extension: "json", comment: "//", aliases: []),
|
|
25
|
+
Lang.new(key: "dockerfile", name: "Dockerfile", extension: "dockerfile", comment: "#", aliases: %w[docker]),
|
|
26
|
+
Lang.new(key: "terraform", name: "Terraform", extension: "tf", comment: "#", aliases: %w[hcl tf]),
|
|
27
|
+
Lang.new(key: "generic", name: "Generic", extension: "txt", comment: "#", aliases: []),
|
|
28
|
+
Lang.new(key: "regex", name: "Regex", extension: "txt", comment: "#", aliases: %w[none])
|
|
29
|
+
].freeze
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
def all
|
|
34
|
+
ALL
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def keys
|
|
38
|
+
ALL.flat_map { |l| [l.key, *l.aliases] }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def find(name)
|
|
42
|
+
return nil if name.nil?
|
|
43
|
+
|
|
44
|
+
n = name.to_s.downcase
|
|
45
|
+
ALL.find { |l| l.key == n || l.aliases.include?(n) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def known?(name)
|
|
49
|
+
!find(name).nil?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def extension_for(name)
|
|
53
|
+
find(name)&.extension || "txt"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def comment_for(name)
|
|
57
|
+
find(name)&.comment || "#"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def from_extension(ext)
|
|
61
|
+
e = ext.to_s.delete_prefix(".").downcase
|
|
62
|
+
ALL.find { |l| l.extension == e }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trapline
|
|
4
|
+
class Railtie < ::Rails::Railtie
|
|
5
|
+
config.trapline = ActiveSupport::OrderedOptions.new
|
|
6
|
+
|
|
7
|
+
initializer "trapline.defaults", before: :load_config_initializers do |app|
|
|
8
|
+
Trapline.config.rules_path = app.root.join("security", "semgrep").to_s unless ENV["TRAPLINE_RULES_PATH"]
|
|
9
|
+
Trapline.config.read_only = !Rails.env.development?
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
initializer "trapline.config", after: :load_config_initializers do |app|
|
|
13
|
+
app.config.trapline.each do |key, value|
|
|
14
|
+
Trapline.config.public_send("#{key}=", value) if Trapline.config.respond_to?("#{key}=")
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
rake_tasks do
|
|
19
|
+
load File.expand_path("../tasks/trapline.rake", __dir__)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
generators do
|
|
23
|
+
require_relative "../generators/trapline/install_generator"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|