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,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ Trapline.configure do |config|
4
+ # Where rules and their test files live (`semgrep --test` layout).
5
+ config.rules_path = Rails.root.join("security", "semgrep").to_s
6
+
7
+ # Engine binary. nil = look for `opengrep`, then `semgrep`, on PATH.
8
+ # config.engine_binary = "/usr/local/bin/opengrep"
9
+
10
+ # Who can open the editor. Receives the Rack env; return true to allow access.
11
+ # Example with Devise/Warden:
12
+ # config.authorize = ->(env) { env["warden"]&.user&.admin? }
13
+ # Or HTTP Basic:
14
+ # config.authorize = [ENV.fetch("TRAPLINE_USER"), ENV.fetch("TRAPLINE_PASSWORD")]
15
+ config.authorize = ->(_env) { Rails.env.development? }
16
+
17
+ # Read-only outside development (the editor still validates and runs rules).
18
+ config.read_only = !Rails.env.development?
19
+
20
+ # NVD/OSV lookups by the CVE generator.
21
+ # config.allow_network = true
22
+ # config.nvd_api_key = ENV["NVD_API_KEY"]
23
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :trapline do
4
+ desc "Runs all semgrep/opengrep rules against their test files"
5
+ task test: :environment do
6
+ require "trapline/cli"
7
+ code = Trapline::CLI.start(["test"])
8
+ exit(code) unless code.zero?
9
+ end
10
+
11
+ desc "Validates the structure of all rules"
12
+ task validate: :environment do
13
+ require "trapline/cli"
14
+ files = Dir.glob(File.join(Trapline.config.rules_path, "**", "*.{yaml,yml}"))
15
+ abort "no rules in #{Trapline.config.rules_path}" if files.empty?
16
+ code = Trapline::CLI.start(["validate", *files])
17
+ exit(code) unless code.zero?
18
+ end
19
+ end
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "json"
5
+
6
+ module Trapline
7
+ class CLI
8
+ USAGE = <<~TXT
9
+ Usage: trapline <command> [options]
10
+
11
+ Commands:
12
+ server Opens the web editor (default: http://127.0.0.1:9292)
13
+ validate FILE... Validates rule files (--engine also uses opengrep/semgrep)
14
+ test Runs saved rules against their test files
15
+ run RULE TARGET Runs a rule against a source file
16
+ cve ID Generates rule drafts from a CVE/GHSA
17
+ templates Lists the available templates
18
+ version Shows the version and the detected engine
19
+
20
+ Global options:
21
+ -r, --rules DIR Rules directory (default: ./rules or $TRAPLINE_RULES_PATH)
22
+ --engine-bin B Engine binary (opengrep, semgrep or a path)
23
+ TXT
24
+
25
+ TEST_LABELS = { "pass" => "ok ", "fail" => "FAIL ", "error" => "ERROR", "skipped" => "no test",
26
+ "no-annotations" => "no annotations" }.freeze
27
+
28
+ def self.start(argv = ARGV, out: $stdout, err: $stderr)
29
+ new(out: out, err: err).run(argv.dup)
30
+ end
31
+
32
+ def initialize(out:, err:)
33
+ @out = out
34
+ @err = err
35
+ end
36
+
37
+ def run(argv)
38
+ leading_globals(argv)
39
+ command = argv.shift
40
+ case command
41
+ when "server", "s" then server(argv)
42
+ when "validate", "v" then validate(argv)
43
+ when "test", "t" then test(argv)
44
+ when "run" then run_rule(argv)
45
+ when "cve", "new" then cve(argv)
46
+ when "templates" then list_templates
47
+ when "version", "-v", "--version" then version
48
+ when nil, "help", "-h", "--help" then @out.puts(USAGE) || 0
49
+ else
50
+ @err.puts "unknown command: #{command}\n\n#{USAGE}"
51
+ 1
52
+ end
53
+ rescue OptionParser::ParseError, Error => e
54
+ @err.puts "error: #{e.message}"
55
+ 1
56
+ end
57
+
58
+ private
59
+
60
+ def leading_globals(argv)
61
+ leading = []
62
+ while (arg = argv.first) && arg.start_with?("-r", "--rules", "--engine-bin")
63
+ leading << argv.shift
64
+ leading << argv.shift if %w[-r --rules --engine-bin].include?(arg)
65
+ end
66
+ OptionParser.new { |o| global(o) }.parse!(leading) if leading.any?
67
+ end
68
+
69
+ def global(opts)
70
+ opts.on("-r", "--rules DIR") { |d| Trapline.config.rules_path = File.expand_path(d) }
71
+ opts.on("--engine-bin BIN") do |b|
72
+ Trapline.config.engine_binary = b
73
+ Trapline.engine = nil
74
+ end
75
+ end
76
+
77
+ def server(argv)
78
+ host = "127.0.0.1"
79
+ port = 9292
80
+ OptionParser.new do |o|
81
+ global(o)
82
+ o.on("-p", "--port PORT", Integer) { |p| port = p }
83
+ o.on("-b", "--host HOST") { |h| host = h }
84
+ o.on("--read-only") { Trapline.config.read_only = true }
85
+ o.on("--basic-auth USER:PASS") { |v| Trapline.config.authorize = v.split(":", 2) }
86
+ o.on("--offline") { Trapline.config.allow_network = false }
87
+ end.parse!(argv)
88
+
89
+ require_relative "web/server"
90
+ @err.puts "warning: no engine found (install opengrep or semgrep); structural validation only" unless Trapline.engine
91
+ @out.puts "Rules in #{File.expand_path(Trapline.config.rules_path)}"
92
+ Web::Server.start(Web::App.new, host: host, port: port, logger: @out)
93
+ 0
94
+ end
95
+
96
+ def validate(argv)
97
+ use_engine = false
98
+ json = false
99
+ OptionParser.new do |o|
100
+ global(o)
101
+ o.on("--engine") { use_engine = true }
102
+ o.on("--json") { json = true }
103
+ end.parse!(argv)
104
+ raise Error, "provide at least one file" if argv.empty?
105
+
106
+ engine = use_engine ? (Trapline.engine || raise(Error, "no engine found")) : nil
107
+ results = argv.to_h { |f| [f, Validator.new(engine: engine).validate(File.read(f))] }
108
+ if json
109
+ @out.puts JSON.pretty_generate(results.transform_values(&:to_h))
110
+ else
111
+ results.each do |file, r|
112
+ @out.puts "#{r.valid? ? 'ok ' : 'ERROR'} #{file}"
113
+ r.issues.reject { |i| i.level == "info" }.each { |i| @out.puts " #{i.level}: #{i.path}: #{i.message}" }
114
+ end
115
+ end
116
+ results.values.all?(&:valid?) ? 0 : 1
117
+ end
118
+
119
+ def test(argv)
120
+ OptionParser.new { |o| global(o) }.parse!(argv)
121
+ Trapline.config.rules_path = File.expand_path(argv.first) if argv.first
122
+ results = Suite.new.run
123
+ @out.puts "no rules in #{Trapline.store.root}" if results.empty?
124
+ results.each do |r|
125
+ @out.puts "#{TEST_LABELS[r.status]} #{r.slug}#{" (#{r.passed}/#{r.total})" if r.total.positive?}"
126
+ (r.errors + r.failures).each { |m| @out.puts " #{m}" }
127
+ end
128
+ failed = results.count { |r| %w[fail error].include?(r.status) }
129
+ @out.puts "\n#{results.size} rules, #{failed} with problems"
130
+ failed.zero? ? 0 : 1
131
+ end
132
+
133
+ def run_rule(argv)
134
+ json = false
135
+ OptionParser.new do |o|
136
+ global(o)
137
+ o.on("--json") { json = true }
138
+ end.parse!(argv)
139
+ rule_file, target = argv
140
+ raise Error, "usage: trapline run RULE.yaml TARGET" unless rule_file && target
141
+
142
+ engine = Trapline.engine || raise(Error, "no engine found")
143
+ lang = Languages.from_extension(File.extname(target))&.key
144
+ res = engine.run(File.read(rule_file), code: File.read(target), language: lang)
145
+ if json
146
+ @out.puts JSON.pretty_generate(res.to_h)
147
+ else
148
+ res.errors.each { |e| @err.puts "error: #{e}" }
149
+ res.findings.each { |f| @out.puts "#{target}:#{f.line}: [#{f.rule_id}] #{f.code.strip}" }
150
+ if (a = res.annotations)
151
+ @out.puts "annotations: #{a[:passed]}/#{a[:total]} ok"
152
+ a[:results].select { |r| r[:status] == "fail" }.each { |r| @out.puts " #{r[:reason]}" }
153
+ end
154
+ end
155
+ res.ok? && res.annotations_passed? ? 0 : 1
156
+ end
157
+
158
+ def cve(argv)
159
+ language = nil
160
+ save = false
161
+ pick = nil
162
+ from_json = nil
163
+ OptionParser.new do |o|
164
+ global(o)
165
+ o.on("-l", "--lang LANG") { |l| language = l }
166
+ o.on("--save", "Saves the first candidate (or --pick) to the rules directory") { save = true }
167
+ o.on("--pick N", Integer) { |n| pick = n }
168
+ o.on("--osv-json FILE", "Uses a saved OSV JSON instead of the network") { |f| from_json = f }
169
+ end.parse!(argv)
170
+
171
+ gen = Generator.new
172
+ result = if from_json
173
+ gen.analyze(Cve::Client.parse_osv(JSON.parse(File.read(from_json))), language: language)
174
+ else
175
+ raise Error, "usage: trapline cve CVE-YYYY-NNNN [--lang ruby]" if argv.empty?
176
+
177
+ gen.from_cve(argv.first, language: language)
178
+ end
179
+
180
+ rec = result[:record]
181
+ @out.puts "#{rec[:id]} #{rec[:severity]} #{rec[:cwes].join(', ')}"
182
+ @out.puts " #{rec[:summary] || rec[:description].to_s[0, 200]}"
183
+ result[:notes].each { |n| @out.puts " - #{n}" }
184
+ @out.puts
185
+ result[:candidates].each_with_index { |c, i| @out.puts " [#{i + 1}] #{c[:title]} — #{c[:language]}" }
186
+
187
+ index = (pick || 1) - 1
188
+ chosen = (result[:candidates][index] unless index.negative?) || raise(Error, "no such candidate: #{pick}")
189
+ if save
190
+ store = Trapline.store
191
+ slug = store.suggest_slug(Rule.parse(chosen[:yaml]).id)
192
+ store.write(slug, chosen[:yaml], test_code: chosen[:test_code], language: chosen[:language])
193
+ @out.puts "\nSaved to #{File.join(store.root, slug)}.yaml"
194
+ else
195
+ @out.puts "\n#{chosen[:yaml]}"
196
+ end
197
+ 0
198
+ end
199
+
200
+ def list_templates
201
+ Templates.all.each { |t| @out.puts "#{t.cwe.ljust(9)} #{t.title.ljust(45)} #{t.languages.keys.join(', ')}" }
202
+ 0
203
+ end
204
+
205
+ def version
206
+ e = Trapline.engine
207
+ @out.puts "trapline #{VERSION}"
208
+ @out.puts e ? "engine: #{e.name} #{e.version} (#{e.binary})" : "engine: not found (install opengrep or semgrep)"
209
+ 0
210
+ end
211
+ end
212
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trapline
4
+ class Configuration
5
+ attr_accessor :rules_path, :engine_binary, :engine_timeout, :cve_timeout, :nvd_api_key,
6
+ :allow_network, :authorize, :read_only
7
+
8
+ def initialize
9
+ @rules_path = ENV.fetch("TRAPLINE_RULES_PATH", File.join(Dir.pwd, "rules"))
10
+ @engine_binary = ENV["TRAPLINE_ENGINE"]
11
+ @engine_timeout = Integer(ENV.fetch("TRAPLINE_ENGINE_TIMEOUT", "60"))
12
+ @cve_timeout = Float(ENV.fetch("TRAPLINE_CVE_TIMEOUT", "30"))
13
+ @nvd_api_key = ENV["NVD_API_KEY"]
14
+ @allow_network = ENV["TRAPLINE_OFFLINE"] != "1"
15
+ @authorize = nil
16
+ @read_only = false
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module Trapline
8
+ module Cve
9
+ class Client
10
+ NVD_URL = "https://services.nvd.nist.gov/rest/json/cves/2.0"
11
+ OSV_URL = "https://api.osv.dev/v1/vulns/"
12
+ CVE_ID = /\ACVE-\d{4}-\d{4,}\z/
13
+ ECOSYSTEM_ALIAS = /\A(?:GO|RUSTSEC)-\d{4}-\d+\z/
14
+ GHSA_ID = /\AGHSA(-[23456789cfghjmpqrvwx]{4}){3}\z/i
15
+
16
+ attr_reader :errors
17
+
18
+ # timeout: per request. total_timeout: for the whole #fetch; requests
19
+ # that would start after it are skipped.
20
+ def initialize(nvd_api_key: Trapline.config.nvd_api_key, http: nil, timeout: 15,
21
+ total_timeout: Trapline.config.cve_timeout, clock: nil)
22
+ @nvd_api_key = nvd_api_key
23
+ @http = http || method(:http_get)
24
+ @timeout = timeout
25
+ @total_timeout = total_timeout
26
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
27
+ @errors = []
28
+ end
29
+
30
+ def timed_out?
31
+ @timed_out || false
32
+ end
33
+
34
+ def fetch(id)
35
+ id = normalize_id(id)
36
+ @errors = []
37
+ @deadline = @total_timeout && @clock.call + @total_timeout
38
+
39
+ record = Record.new(id: id)
40
+ record.merge!(fetch_nvd(id)) if id.match?(CVE_ID)
41
+ osv = fetch_osv(id)
42
+ record.merge!(osv)
43
+ follow_osv_links(record, osv) if osv
44
+
45
+ cve_alias = record.aliases.grep(CVE_ID).first
46
+ if id.match?(GHSA_ID) && cve_alias
47
+ record.promote_cve!
48
+ record.merge!(fetch_nvd(cve_alias))
49
+ end
50
+
51
+ @timed_out = time_left&.<=(0) || false
52
+ if record.sources.empty?
53
+ within = @timed_out ? " within #{format('%g', @total_timeout)} s" : ""
54
+ raise NetworkError, "could not fetch #{id}#{within}: #{errors.uniq.join('; ')}"
55
+ end
56
+
57
+ record.aliases -= [record.id]
58
+ record
59
+ end
60
+
61
+ def fetch_nvd(id)
62
+ headers = {}
63
+ headers["apiKey"] = @nvd_api_key if @nvd_api_key
64
+ body = request("#{NVD_URL}?cveId=#{URI.encode_www_form_component(id)}", headers)
65
+ self.class.parse_nvd(JSON.parse(body))
66
+ rescue StandardError => e
67
+ @errors << "NVD: #{e.message}"
68
+ nil
69
+ end
70
+
71
+ def fetch_osv(id)
72
+ body = request("#{OSV_URL}#{URI.encode_www_form_component(id)}", {})
73
+ self.class.parse_osv(JSON.parse(body))
74
+ rescue StandardError => e
75
+ @errors << "OSV: #{e.message}"
76
+ nil
77
+ end
78
+
79
+ def self.parse_nvd(json)
80
+ cve = json.dig("vulnerabilities", 0, "cve") || json["cve"] || json
81
+ return nil unless cve.is_a?(Hash) && cve["id"]
82
+
83
+ metrics = cve["metrics"] || {}
84
+ cvss = %w[cvssMetricV40 cvssMetricV31 cvssMetricV30 cvssMetricV2].lazy.filter_map { |k| Array(metrics[k]).first }.first
85
+ cvss_data = cvss&.dig("cvssData") || {}
86
+ cwes = Array(cve["weaknesses"]).flat_map { |w| Array(w["description"]).map { |d| d["value"] } }
87
+ Record.new(
88
+ id: cve["id"], description: Array(cve["descriptions"]).find { |d| d["lang"] == "en" }&.dig("value"),
89
+ cwes: cwes.grep(/\ACWE-\d+\z/).uniq,
90
+ severity: cvss_data["baseSeverity"] || cvss&.dig("baseSeverity"),
91
+ cvss_score: cvss_data["baseScore"], cvss_vector: cvss_data["vectorString"],
92
+ published: cve["published"], references: Array(cve["references"]).filter_map { |r| r["url"] },
93
+ products: nvd_products(cve), sources: ["nvd"]
94
+ )
95
+ end
96
+
97
+ def self.nvd_products(cve)
98
+ Array(cve["configurations"]).flat_map { |c| Array(c["nodes"]) }.flat_map { |n| Array(n["cpeMatch"]) }
99
+ .select { |m| m["vulnerable"] }
100
+ .filter_map { |m| cpe_product(m["criteria"])&.merge("versions" => [cpe_range(m)].compact) }
101
+ .group_by { |p| p.values_at("vendor", "product") }
102
+ .map do |(vendor, product), matches|
103
+ { "vendor" => vendor, "product" => product, "versions" => matches.flat_map { |m| m["versions"] }.uniq }
104
+ end
105
+ end
106
+
107
+ def self.parse_osv(json)
108
+ return nil unless json.is_a?(Hash) && json["id"]
109
+
110
+ packages = Array(json["affected"]).filter_map do |a|
111
+ pkg = a["package"] || {}
112
+ next if pkg["name"].to_s.empty?
113
+
114
+ events = Array(a["ranges"]).flat_map { |r| Array(r["events"]) }
115
+ fixed = events.filter_map { |e| e["fixed"] }.uniq
116
+ Package.new(ecosystem: pkg["ecosystem"].to_s.split(":").first, name: pkg["name"],
117
+ introduced: events.filter_map { |e| e["introduced"] }.find { |v| v != "0" },
118
+ fixed: fixed.empty? ? nil : fixed.join(", "),
119
+ last_affected: events.filter_map { |e| e["last_affected"] }.first,
120
+ ranges: event_ranges(events))
121
+ end
122
+ sev = json.dig("database_specific", "severity")&.upcase
123
+ Record.new(
124
+ id: json["id"], aliases: Array(json["aliases"]), summary: json["summary"],
125
+ description: json["details"], cwes: Array(json.dig("database_specific", "cwe_ids")),
126
+ severity: sev == "MODERATE" ? "MEDIUM" : sev, published: json["published"],
127
+ references: Array(json["references"]).filter_map { |r| r["url"] },
128
+ packages: packages, related: Array(json["related"]), sources: ["osv"]
129
+ )
130
+ end
131
+
132
+ def self.event_ranges(events)
133
+ events.each_with_object([]) do |e, ranges|
134
+ if e["introduced"]
135
+ ranges << { "introduced" => e["introduced"] }
136
+ elsif ranges.any? && (e["fixed"] || e["last_affected"])
137
+ ranges.last.merge!(e.slice("fixed", "last_affected"))
138
+ end
139
+ end
140
+ end
141
+
142
+ def self.cpe_range(match)
143
+ version = match["criteria"].to_s.split(":")[5]
144
+ return { "exact" => version } unless ["*", "-", nil, ""].include?(version)
145
+
146
+ { "start_including" => match["versionStartIncluding"], "start_excluding" => match["versionStartExcluding"],
147
+ "end_including" => match["versionEndIncluding"], "end_excluding" => match["versionEndExcluding"] }.compact
148
+ .then { |r| r.empty? ? nil : r }
149
+ end
150
+
151
+ def self.cpe_product(cpe)
152
+ parts = cpe.to_s.split(":")
153
+ return nil if parts.size < 5
154
+
155
+ { "vendor" => parts[3], "product" => parts[4] }
156
+ end
157
+
158
+ private
159
+
160
+ def normalize_id(id)
161
+ id = id.to_s.strip.upcase
162
+ id = "GHSA-#{id[5..].downcase}" if id.match?(GHSA_ID)
163
+ return id if id.match?(CVE_ID) || id.match?(GHSA_ID)
164
+
165
+ raise Error, "invalid identifier: #{id} (use CVE-YYYY-NNNN or GHSA-xxxx-xxxx-xxxx)"
166
+ end
167
+
168
+ # The OSV record of a CVE often has no packages: they are in the GHSA it
169
+ # links to. Go and RustSec advisories may list packages the GHSA lacks.
170
+ def follow_osv_links(record, osv)
171
+ linked = osv.aliases + osv.related
172
+ linked.grep(GHSA_ID).first(2).each { |ghsa| record.merge!(fetch_osv(ghsa)) } if osv.packages.empty?
173
+ linked.grep(ECOSYSTEM_ALIAS).uniq.first(2).each { |alias_id| record.merge!(fetch_osv(alias_id)) }
174
+ end
175
+
176
+ def time_left
177
+ @deadline && (@deadline - @clock.call)
178
+ end
179
+
180
+ def request(url, headers)
181
+ left = time_left
182
+ raise Error, "time limit reached, request skipped" if left && left <= 0
183
+
184
+ @request_timeout = left ? [@timeout, left].min : @timeout
185
+ @http.call(url, headers)
186
+ end
187
+
188
+ def http_get(url, headers)
189
+ uri = URI(url)
190
+ req = Net::HTTP::Get.new(uri)
191
+ req["Accept"] = "application/json"
192
+ req["User-Agent"] = "trapline/#{VERSION}"
193
+ headers.each { |k, v| req[k] = v }
194
+ t = @request_timeout
195
+ res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
196
+ open_timeout: t, read_timeout: t, write_timeout: t) { |h| h.request(req) }
197
+ raise Error, "HTTP #{res.code}" unless res.is_a?(Net::HTTPSuccess)
198
+
199
+ res.body
200
+ end
201
+ end
202
+ end
203
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trapline
4
+ module Cve
5
+ ECOSYSTEM_LANGUAGES = {
6
+ "npm" => "javascript", "pypi" => "python", "rubygems" => "ruby", "maven" => "java",
7
+ "go" => "go", "packagist" => "php", "nuget" => "csharp", "crates.io" => "rust",
8
+ "hex" => "elixir", "pub" => "dart", "swifturl" => "swift", "cocoapods" => "swift"
9
+ }.freeze
10
+
11
+ PRODUCT_LANGUAGES = {
12
+ "php:php" => "php", "python:python" => "python", "python:cpython" => "python",
13
+ "nodejs:node.js" => "javascript", "ruby-lang:ruby" => "ruby", "golang:go" => "go",
14
+ "oracle:jdk" => "java", "oracle:jre" => "java", "oracle:openjdk" => "java",
15
+ "microsoft:.net" => "csharp", "microsoft:.net_core" => "csharp", "microsoft:asp.net_core" => "csharp",
16
+ "rust-lang:rust" => "rust",
17
+ "rubyonrails:rails" => "ruby", "rubyonrails:ruby_on_rails" => "ruby", "djangoproject:django" => "python",
18
+ "palletsprojects:flask" => "python", "expressjs:express" => "javascript", "vmware:spring_framework" => "java",
19
+ "laravel:framework" => "php", "wordpress:wordpress" => "php", "drupal:drupal" => "php",
20
+ "symfony:symfony" => "php"
21
+ }.freeze
22
+
23
+ RUNTIME_PACKAGES = {
24
+ "go/stdlib" => { "vendor" => "golang", "product" => "go" },
25
+ "go/toolchain" => { "vendor" => "golang", "product" => "go" }
26
+ }.freeze
27
+
28
+ def self.product_key(product)
29
+ "#{product['vendor']}:#{product['product']}"
30
+ end
31
+
32
+ Package = Struct.new(:ecosystem, :name, :introduced, :fixed, :last_affected, :ranges, keyword_init: true) do
33
+ def language
34
+ ECOSYSTEM_LANGUAGES[ecosystem.to_s.downcase]
35
+ end
36
+
37
+ def runtime_product
38
+ RUNTIME_PACKAGES["#{ecosystem.to_s.downcase}/#{name}"]
39
+ end
40
+
41
+ # OSV ranges in the NVD CPE shape. Pre-release markers are dropped: Go
42
+ # writes 1.22.0 as "1.22.0-0".
43
+ def cpe_ranges
44
+ Array(ranges).filter_map do |r|
45
+ release = ->(v) { v&.sub(/-.*\z/, "") }
46
+ range = { "start_including" => (release.(r["introduced"]) unless [nil, "", "0"].include?(r["introduced"])),
47
+ "end_excluding" => release.(r["fixed"]), "end_including" => release.(r["last_affected"]) }.compact
48
+ range unless range.empty?
49
+ end
50
+ end
51
+
52
+ def to_h
53
+ super.except(:ranges).compact.merge(language: language).compact
54
+ end
55
+ end
56
+
57
+ class Record
58
+ attr_accessor :id, :aliases, :summary, :description, :cwes, :severity, :cvss_score,
59
+ :cvss_vector, :published, :references, :packages, :products, :sources, :related
60
+
61
+ def initialize(**attrs)
62
+ @aliases = []
63
+ @cwes = []
64
+ @references = []
65
+ @packages = []
66
+ @products = []
67
+ @sources = []
68
+ @related = []
69
+ attrs.each { |k, v| public_send("#{k}=", v) }
70
+ end
71
+
72
+ def self.from_h(h)
73
+ h = h.transform_keys(&:to_s)
74
+ new(
75
+ id: h["id"].to_s.strip.upcase,
76
+ summary: h["summary"], description: h["description"],
77
+ cwes: Array(h["cwes"] || h["cwe"]).flat_map { |c| c.to_s.split(/[,\s]+/) }.filter_map { |c| normalize_cwe(c) }.uniq,
78
+ severity: h["severity"]&.to_s&.upcase, cvss_score: h["cvss_score"],
79
+ references: Array(h["references"]).flat_map { |r| r.to_s.split(/\s+/) }.reject(&:empty?),
80
+ packages: Array(h["packages"]).map { |p| p.is_a?(Package) ? p : Package.new(**p.transform_keys(&:to_sym).slice(*Package.members)) },
81
+ products: Array(h["products"]).map { |p| p.transform_keys(&:to_s) },
82
+ sources: ["manual"]
83
+ )
84
+ end
85
+
86
+ def self.normalize_cwe(value)
87
+ n = value.to_s[/\d+/]
88
+ "CWE-#{n}" if n
89
+ end
90
+
91
+ def title
92
+ summary.to_s.strip.empty? ? description.to_s.split(/(?<=\.)\s/).first.to_s : summary
93
+ end
94
+
95
+ def languages
96
+ (packages.filter_map(&:language) + products.filter_map { |p| PRODUCT_LANGUAGES[Cve.product_key(p)] }).uniq
97
+ end
98
+
99
+ # NVD products plus the runtimes OSV lists as packages (Go's "stdlib").
100
+ # NVD's version ranges win when it has them.
101
+ def runtime_products
102
+ with_versions = products.select { |p| Array(p["versions"]).any? }.map { |p| Cve.product_key(p) }
103
+ from_packages = packages.filter_map { |p| p.runtime_product&.merge("versions" => p.cpe_ranges) }
104
+ .reject { |p| with_versions.include?(Cve.product_key(p)) }
105
+ .group_by { |p| Cve.product_key(p) }
106
+ .transform_values { |ps| ps.first.merge("versions" => ps.flat_map { |p| p["versions"] }.uniq) }
107
+ products.reject { |p| from_packages.key?(Cve.product_key(p)) } + from_packages.values
108
+ end
109
+
110
+ # "vendor/product" names. Known runtimes/frameworks come first: the rest
111
+ # are usually appliances that embed them.
112
+ def product_names
113
+ runtimes, others = products.partition { |p| PRODUCT_LANGUAGES.key?(Cve.product_key(p)) }
114
+ (runtimes.any? ? runtimes : others).map { |p| p["vendor"] == p["product"] ? p["product"] : "#{p['vendor']}/#{p['product']}" }
115
+ end
116
+
117
+ def merge!(other)
118
+ return self unless other
119
+
120
+ self.id = other.id if id.to_s.empty?
121
+ self.summary ||= other.summary
122
+ self.description ||= other.description
123
+ self.severity ||= other.severity
124
+ self.cvss_score ||= other.cvss_score
125
+ self.cvss_vector ||= other.cvss_vector
126
+ self.published ||= other.published
127
+ self.aliases = (aliases + other.aliases + [other.id]).compact.uniq - [id]
128
+ self.cwes = (cwes + other.cwes).uniq
129
+ self.references = (references + other.references).uniq
130
+ self.products = (products + other.products).uniq
131
+ self.sources = (sources + other.sources).uniq
132
+ other.packages.each { |p| packages << p if packages.none? { |q| [q.ecosystem, q.name] == [p.ecosystem, p.name] } }
133
+ self
134
+ end
135
+
136
+ def promote_cve!
137
+ cve = aliases.grep(/\ACVE-\d{4}-\d+\z/).first
138
+ return self unless id.to_s.start_with?("GHSA-") && cve
139
+
140
+ self.aliases = (aliases - [cve] + [id]).uniq
141
+ self.id = cve
142
+ self
143
+ end
144
+
145
+ def rule_severity
146
+ sev = severity.to_s.upcase
147
+ %w[CRITICAL HIGH LOW].include?(sev) ? sev : "MEDIUM"
148
+ end
149
+
150
+ def to_h
151
+ {
152
+ id: id, aliases: aliases, summary: summary, description: description, cwes: cwes,
153
+ severity: severity, cvss_score: cvss_score, cvss_vector: cvss_vector, published: published,
154
+ references: references, packages: packages.map(&:to_h), products: products,
155
+ languages: languages, sources: sources
156
+ }
157
+ end
158
+ end
159
+ end
160
+ end