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,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
|
data/lib/trapline/cli.rb
ADDED
|
@@ -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
|