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