licensed 2.9.1 → 2.12.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -15,6 +15,25 @@ module Licensed
15
15
 
16
16
  protected
17
17
 
18
+ # Run the command for all enumerated dependencies found in a dependency source,
19
+ # recording results in a report.
20
+ # Enumerating dependencies in the source is skipped if a :sources option
21
+ # is provided and the evaluated `source.class.type` is not in the :sources values
22
+ #
23
+ # app - The application configuration for the source
24
+ # source - A dependency source enumerator
25
+ #
26
+ # Returns whether the command succeeded for the dependency source enumerator
27
+ def run_source(app, source)
28
+ super do |report|
29
+ next if Array(options[:sources]).empty?
30
+ next if options[:sources].include?(source.class.type)
31
+
32
+ report.warnings << "skipped source"
33
+ :skip
34
+ end
35
+ end
36
+
18
37
  # Verifies that a cached record exists, is up to date and
19
38
  # has license data that complies with the licensed configuration.
20
39
  #
@@ -108,7 +108,12 @@ module Licensed
108
108
  def detect_cache_path(options, inherited_options)
109
109
  return options["cache_path"] unless options["cache_path"].to_s.empty?
110
110
 
111
- cache_path = inherited_options["cache_path"] || DEFAULT_CACHE_PATH
111
+ # if cache_path and shared_cache are both set in inherited_options,
112
+ # don't append the app name to the cache path
113
+ cache_path = inherited_options["cache_path"]
114
+ return cache_path if cache_path && inherited_options["shared_cache"] == true
115
+
116
+ cache_path ||= DEFAULT_CACHE_PATH
112
117
  File.join(cache_path, self["name"])
113
118
  end
114
119
 
@@ -167,7 +172,12 @@ module Licensed
167
172
  # will handle configurations that don't have these explicitly set
168
173
  dir_name = File.basename(path)
169
174
  config["name"] = "#{config["name"]}-#{dir_name}" if config["name"]
170
- config["cache_path"] = File.join(config["cache_path"], dir_name) if config["cache_path"]
175
+
176
+ # if a cache_path is set and is not marked as shared, append the app name
177
+ # to the end of the cache path to make a unique cache path for the app
178
+ if config["cache_path"] && config["shared_cache"] != true
179
+ config["cache_path"] = File.join(config["cache_path"], dir_name)
180
+ end
171
181
 
172
182
  config
173
183
  end
@@ -74,7 +74,7 @@ module Licensed
74
74
  def license_contents
75
75
  files = matched_files.reject { |f| f == package_file }
76
76
  .group_by(&:content)
77
- .map { |content, files| { "sources" => license_content_sources(files), "text" => content } }
77
+ .map { |content, sources| { "sources" => license_content_sources(sources), "text" => content } }
78
78
 
79
79
  files << generated_license_contents if files.empty?
80
80
  files.compact
@@ -7,5 +7,6 @@ module Licensed
7
7
  require "licensed/reporters/list_reporter"
8
8
  require "licensed/reporters/json_reporter"
9
9
  require "licensed/reporters/yaml_reporter"
10
+ require "licensed/reporters/notices_reporter"
10
11
  end
11
12
  end
@@ -27,32 +27,32 @@ module Licensed
27
27
  shell.info " #{source.class.type}"
28
28
  result = yield report
29
29
 
30
- warning_reports = report.all_reports.select { |report| report.warnings.any? }.to_a
30
+ warning_reports = report.all_reports.select { |r| r.warnings.any? }.to_a
31
31
  if warning_reports.any?
32
32
  shell.newline
33
33
  shell.warn " * Warnings:"
34
- warning_reports.each do |report|
35
- display_metadata = report.map { |k, v| "#{k}: #{v}" }.join(", ")
34
+ warning_reports.each do |r|
35
+ display_metadata = r.map { |k, v| "#{k}: #{v}" }.join(", ")
36
36
 
37
- shell.warn " * #{report.name}"
37
+ shell.warn " * #{r.name}"
38
38
  shell.warn " #{display_metadata}" unless display_metadata.empty?
39
- report.warnings.each do |warning|
39
+ r.warnings.each do |warning|
40
40
  shell.warn " - #{warning}"
41
41
  end
42
42
  shell.newline
43
43
  end
44
44
  end
45
45
 
46
- errored_reports = report.all_reports.select { |report| report.errors.any? }.to_a
46
+ errored_reports = report.all_reports.select { |r| r.errors.any? }.to_a
47
47
  if errored_reports.any?
48
48
  shell.newline
49
49
  shell.error " * Errors:"
50
- errored_reports.each do |report|
51
- display_metadata = report.map { |k, v| "#{k}: #{v}" }.join(", ")
50
+ errored_reports.each do |r|
51
+ display_metadata = r.map { |k, v| "#{k}: #{v}" }.join(", ")
52
52
 
53
- shell.error " * #{report.name}"
53
+ shell.error " * #{r.name}"
54
54
  shell.error " #{display_metadata}" unless display_metadata.empty?
55
- report.errors.each do |error|
55
+ r.errors.each do |error|
56
56
  shell.error " - #{error}"
57
57
  end
58
58
  shell.newline
@@ -28,16 +28,32 @@ module Licensed
28
28
  shell.info " #{source.class.type}"
29
29
  result = yield report
30
30
 
31
- errored_reports = report.all_reports.select { |report| report.errors.any? }.to_a
31
+ warning_reports = report.all_reports.select { |r| r.warnings.any? }.to_a
32
+ if warning_reports.any?
33
+ shell.newline
34
+ shell.warn " * Warnings:"
35
+ warning_reports.each do |r|
36
+ display_metadata = r.map { |k, v| "#{k}: #{v}" }.join(", ")
37
+
38
+ shell.warn " * #{r.name}"
39
+ shell.warn " #{display_metadata}" unless display_metadata.empty?
40
+ r.warnings.each do |warning|
41
+ shell.warn " - #{warning}"
42
+ end
43
+ shell.newline
44
+ end
45
+ end
46
+
47
+ errored_reports = report.all_reports.select { |r| r.errors.any? }.to_a
32
48
  if errored_reports.any?
33
49
  shell.newline
34
50
  shell.error " * Errors:"
35
- errored_reports.each do |report|
36
- display_metadata = report.map { |k, v| "#{k}: #{v}" }.join(", ")
51
+ errored_reports.each do |r|
52
+ display_metadata = r.map { |k, v| "#{k}: #{v}" }.join(", ")
37
53
 
38
- shell.error " * #{report.name}"
54
+ shell.error " * #{r.name}"
39
55
  shell.error " #{display_metadata}" unless display_metadata.empty?
40
- report.errors.each do |error|
56
+ r.errors.each do |error|
41
57
  shell.error " - #{error}"
42
58
  end
43
59
  shell.newline
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Licensed
4
+ module Reporters
5
+ class NoticesReporter < Reporter
6
+ TEXT_SEPARATOR = "\n\n#{("-" * 5)}\n\n".freeze
7
+ LICENSE_SEPARATOR = "\n#{("*" * 5)}\n".freeze
8
+
9
+ # Reports on an application configuration in a notices command run
10
+ #
11
+ # app - An application configuration
12
+ #
13
+ # Returns the result of the yielded method
14
+ # Note - must be called from inside the `report_run` scope
15
+ def report_app(app)
16
+ super do |report|
17
+ filename = app["shared_cache"] ? "NOTICE.#{app["name"]}" : "NOTICE"
18
+ path = app.cache_path.join(filename)
19
+ shell.info "Writing notices for #{app["name"]} to #{path}"
20
+
21
+ result = yield report
22
+
23
+ File.open(path, "w") do |file|
24
+ file << "THIRD PARTY NOTICES\n"
25
+ file << LICENSE_SEPARATOR
26
+ file << report.all_reports
27
+ .map { |r| notices(r) }
28
+ .compact
29
+ .join(LICENSE_SEPARATOR)
30
+ end
31
+
32
+ result
33
+ end
34
+ end
35
+
36
+
37
+ # Reports on a dependency source enumerator in a notices command run.
38
+ # Shows warnings encountered during the run.
39
+ #
40
+ # source - A dependency source enumerator
41
+ #
42
+ # Returns the result of the yielded method
43
+ # Note - must be called from inside the `report_run` scope
44
+ def report_source(source)
45
+ super do |report|
46
+ result = yield report
47
+
48
+ report.warnings.each do |warning|
49
+ shell.warn "* #{report.name}: #{warning}"
50
+ end
51
+
52
+ result
53
+ end
54
+ end
55
+
56
+ # Reports on a dependency in a notices command run.
57
+ #
58
+ # dependency - An application dependency
59
+ #
60
+ # Returns the result of the yielded method
61
+ # Note - must be called from inside the `report_run` scope
62
+ def report_dependency(dependency)
63
+ super do |report|
64
+ result = yield report
65
+ report.warnings.each do |warning|
66
+ shell.warn "* #{report.name}: #{warning}"
67
+ end
68
+ result
69
+ end
70
+ end
71
+
72
+ # Returns notices information for a dependency report
73
+ def notices(report)
74
+ return unless report.target.is_a?(Licensed::Dependency)
75
+
76
+ cached_record = report["cached_record"]
77
+ return unless cached_record
78
+
79
+ texts = cached_record.licenses.map(&:text)
80
+ cached_record.notices.each do |notice|
81
+ case notice
82
+ when Hash
83
+ texts << notice["text"]
84
+ when String
85
+ texts << notice
86
+ else
87
+ shell.warn "* unable to parse notices for #{report.target.name}"
88
+ end
89
+ end
90
+
91
+ <<~NOTICE
92
+ #{cached_record["name"]}@#{cached_record["version"]}
93
+
94
+ #{texts.map(&:strip).reject(&:empty?).compact.join(TEXT_SEPARATOR)}
95
+ NOTICE
96
+ end
97
+ end
98
+ end
99
+ end
@@ -47,6 +47,9 @@ module Licensed
47
47
 
48
48
  def initialize(shell = Licensed::UI::Shell.new)
49
49
  @shell = shell
50
+ @run_report = nil
51
+ @app_report = nil
52
+ @source_report = nil
50
53
  end
51
54
 
52
55
  # Generate a report for a licensed command execution
@@ -15,20 +15,37 @@ module Licensed
15
15
  result = yield report
16
16
 
17
17
  all_reports = report.all_reports
18
- errored_reports = all_reports.select { |report| report.errors.any? }.to_a
19
18
 
20
- dependency_count = all_reports.select { |report| report.target.is_a?(Licensed::Dependency) }.size
21
- error_count = errored_reports.sum { |report| report.errors.size }
19
+ warning_reports = all_reports.select { |r| r.warnings.any? }.to_a
20
+ if warning_reports.any?
21
+ shell.newline
22
+ shell.warn "Warnings:"
23
+ warning_reports.each do |r|
24
+ display_metadata = r.map { |k, v| "#{k}: #{v}" }.join(", ")
25
+
26
+ shell.warn "* #{r.name}"
27
+ shell.warn " #{display_metadata}" unless display_metadata.empty?
28
+ r.warnings.each do |warning|
29
+ shell.warn " - #{warning}"
30
+ end
31
+ shell.newline
32
+ end
33
+ end
34
+
35
+ errored_reports = all_reports.select { |r| r.errors.any? }.to_a
36
+
37
+ dependency_count = all_reports.select { |r| r.target.is_a?(Licensed::Dependency) }.size
38
+ error_count = errored_reports.sum { |r| r.errors.size }
22
39
 
23
40
  if error_count > 0
24
41
  shell.newline
25
42
  shell.error "Errors:"
26
- errored_reports.each do |report|
27
- display_metadata = report.map { |k, v| "#{k}: #{v}" }.join(", ")
43
+ errored_reports.each do |r|
44
+ display_metadata = r.map { |k, v| "#{k}: #{v}" }.join(", ")
28
45
 
29
- shell.error "* #{report.name}"
46
+ shell.error "* #{r.name}"
30
47
  shell.error " #{display_metadata}" unless display_metadata.empty?
31
- report.errors.each do |error|
48
+ r.errors.each do |error|
32
49
  shell.error " - #{error}"
33
50
  end
34
51
  shell.newline
@@ -11,6 +11,7 @@ module Licensed
11
11
  require "licensed/sources/go"
12
12
  require "licensed/sources/manifest"
13
13
  require "licensed/sources/npm"
14
+ require "licensed/sources/nuget"
14
15
  require "licensed/sources/pip"
15
16
  require "licensed/sources/pipenv"
16
17
  require "licensed/sources/gradle"
@@ -111,11 +111,11 @@ module Licensed
111
111
  info = package_info_command(id).strip
112
112
  return missing_package(id) if info.empty?
113
113
 
114
- info.lines.each_with_object({}) do |line, info|
114
+ info.lines.each_with_object({}) do |line, hsh|
115
115
  key, value = line.split(":", 2).map(&:strip)
116
116
  next unless key && value
117
117
 
118
- info[key] = value
118
+ hsh[key] = value
119
119
  end
120
120
  end
121
121
 
@@ -120,12 +120,12 @@ module Licensed
120
120
  return go_mod["Version"] if go_mod
121
121
 
122
122
  package_directory = package["Dir"]
123
- return unless package_directory
123
+ return unless package_directory && File.exist?(package_directory)
124
124
 
125
125
  # find most recent git SHA for a package, or nil if SHA is
126
126
  # not available
127
127
  Dir.chdir package_directory do
128
- contents_version *contents_version_arguments
128
+ contents_version(*contents_version_arguments)
129
129
  end
130
130
  end
131
131
 
@@ -48,6 +48,7 @@ module Licensed
48
48
  # package name to it's metadata
49
49
  def recursive_dependencies(dependencies, result = {})
50
50
  dependencies.each do |name, dependency|
51
+ next if dependency["peerMissing"]
51
52
  next if yarn_lock_present && dependency["missing"]
52
53
  (result[name] ||= []) << dependency
53
54
  recursive_dependencies(dependency["dependencies"] || {}, result)
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+ require "json"
3
+ require "reverse_markdown"
4
+
5
+ module Licensed
6
+ module Sources
7
+ # Only supports ProjectReference (project.assets.json) style restore used in .NET Core.
8
+ # Does not currently support packages.config style restore.
9
+ class NuGet < Source
10
+ def self.type
11
+ "nuget"
12
+ end
13
+
14
+ class NuGetDependency < Licensed::Dependency
15
+ LICENSE_FILE_REGEX = /<license\s*type\s*=\s*\"\s*file\s*\"\s*>\s*(.*)\s*<\/license>/ix.freeze
16
+ LICENSE_URL_REGEX = /<licenseUrl>\s*(.*)\s*<\/licenseUrl>/ix.freeze
17
+ PROJECT_URL_REGEX = /<projectUrl>\s*(.*)\s*<\/projectUrl>/ix.freeze
18
+ PROJECT_DESC_REGEX = /<description>\s*(.*)\s*<\/description>/ix.freeze
19
+
20
+ # Returns the metadata that represents this dependency. This metadata
21
+ # is written to YAML in the dependencys cached text file
22
+ def license_metadata
23
+ super.tap do |record_metadata|
24
+ record_metadata["homepage"] = project_url if project_url
25
+ record_metadata["summary"] = description if description
26
+ end
27
+ end
28
+
29
+ def nuspec_path
30
+ name = @metadata["name"]
31
+ File.join(self.path, "#{name.downcase}.nuspec")
32
+ end
33
+
34
+ def nuspec_contents
35
+ return @nuspec_contents if defined?(@nuspec_contents)
36
+ @nuspec_contents = begin
37
+ return unless nuspec_path && File.exist?(nuspec_path)
38
+ File.read(nuspec_path)
39
+ end
40
+ end
41
+
42
+ def project_url
43
+ return @project_url if defined?(@project_url)
44
+ @project_url = begin
45
+ return unless nuspec_contents
46
+ match = nuspec_contents.match PROJECT_URL_REGEX
47
+ match[1] if match && match[1]
48
+ end
49
+ end
50
+
51
+ def description
52
+ return @description if defined?(@description)
53
+ @description = begin
54
+ return unless nuspec_contents
55
+ match = nuspec_contents.match PROJECT_DESC_REGEX
56
+ match[1] if match && match[1]
57
+ end
58
+ end
59
+
60
+ def project_files
61
+ @nuget_project_files ||= begin
62
+ files = super().flatten.compact
63
+
64
+ # Only include the local file if it's a file licensee didn't already detect
65
+ nuspec_license_filename = File.basename(nuspec_local_license_file.filename) if nuspec_local_license_file
66
+ if nuspec_license_filename && files.none? { |file| File.basename(file.filename) == nuspec_license_filename }
67
+ files.push(nuspec_local_license_file)
68
+ end
69
+
70
+ # Only download licenseUrl if no recognized license was found locally
71
+ if files.none? { |file| file.license && file.license.key != "other" }
72
+ files.push(nuspec_remote_license_file)
73
+ end
74
+
75
+ files.compact
76
+ end
77
+ end
78
+
79
+ # Look for a <license type="file"> element in the nuspec that points to an
80
+ # on-disk license file (which licensee may not find due to a non-standard filename)
81
+ def nuspec_local_license_file
82
+ return @nuspec_local_license_file if defined?(@nuspec_local_license_file)
83
+ return unless nuspec_contents
84
+
85
+ match = nuspec_contents.match LICENSE_FILE_REGEX
86
+ return unless match && match[1]
87
+
88
+ license_path = File.join(File.dirname(nuspec_path), match[1])
89
+ return unless File.exist?(license_path)
90
+
91
+ license_data = File.read(license_path)
92
+ @nuspec_local_license_file = Licensee::ProjectFiles::LicenseFile.new(license_data, license_path)
93
+ end
94
+
95
+ # Look for a <licenseUrl> element in the nuspec that either is known to contain a license identifier
96
+ # in the URL, or points to license text on the internet that can be downloaded.
97
+ def nuspec_remote_license_file
98
+ return @nuspec_remote_license_file if defined?(@nuspec_remote_license_file)
99
+ return unless nuspec_contents
100
+
101
+ match = nuspec_contents.match LICENSE_URL_REGEX
102
+ return unless match && match[1]
103
+
104
+ # Attempt to fetch the license content
105
+ license_content = self.class.retrieve_license(match[1])
106
+ @nuspec_remote_license_file = Licensee::ProjectFiles::LicenseFile.new(license_content, { uri: match[1] }) if license_content
107
+ end
108
+
109
+ class << self
110
+ def strip_html(html)
111
+ return unless html
112
+
113
+ return html unless html.downcase.include?("<html")
114
+ ReverseMarkdown.convert(html, unknown_tags: :bypass)
115
+ end
116
+
117
+ def ignored_url?(url)
118
+ # Many Microsoft packages that now use <license> use this for <licenseUrl>
119
+ # No need to fetch this page - it just contains NuGet documentation
120
+ url == "https://aka.ms/deprecateLicenseUrl"
121
+ end
122
+
123
+ def text_content_url(url)
124
+ # Convert github file URLs to raw URLs
125
+ return url unless match = url.match(/https?:\/\/(?:www\.)?github.com\/([^\/]+)\/([^\/]+)\/blob\/(.*)/i)
126
+ "https://github.com/#{match[1]}/#{match[2]}/raw/#{match[3]}"
127
+ end
128
+
129
+ def retrieve_license(url)
130
+ return unless url
131
+ return if ignored_url?(url)
132
+
133
+ # Transform URLs that are known to return HTML but have a corresponding text-based URL
134
+ text_url = text_content_url(url)
135
+
136
+ raw_content = fetch_content(text_url)
137
+ strip_html(raw_content)
138
+ end
139
+
140
+ def fetch_content(url, redirect_limit = 5)
141
+ url = URI.parse(url) if url.instance_of? String
142
+ return @response_by_url[url] if (@response_by_url ||= {}).key?(url)
143
+ return if redirect_limit == 0
144
+
145
+ begin
146
+ response = Net::HTTP.get_response(url)
147
+ case response
148
+ when Net::HTTPSuccess then
149
+ @response_by_url[url] = response.body
150
+ when Net::HTTPRedirection then
151
+ redirect_url = URI.parse(response["location"])
152
+ if redirect_url.relative?
153
+ redirect_url = url + redirect_url
154
+ end
155
+ # The redirect might be to a URL that requires transformation, i.e. a github file
156
+ redirect_url = text_content_url(redirect_url.to_s)
157
+ @response_by_url[url] = fetch_content(redirect_url, redirect_limit - 1)
158
+ end
159
+ rescue
160
+ # Host might no longer exist or some other error, ignore
161
+ end
162
+ end
163
+ end
164
+ end
165
+
166
+ def project_assets_file_path
167
+ File.join(config.pwd, "project.assets.json")
168
+ end
169
+
170
+ def project_assets_file
171
+ return @project_assets_file if defined?(@project_assets_file)
172
+ @project_assets_file = File.read(project_assets_file_path)
173
+ end
174
+
175
+ def enabled?
176
+ File.exist?(project_assets_file_path)
177
+ end
178
+
179
+ # Inspect project.assets.json files for package references.
180
+ # Ideally we'd use `dotnet list package` instead, but its output isn't
181
+ # easily machine readable and doesn't contain everything we need.
182
+ def enumerate_dependencies
183
+ json = JSON.parse(project_assets_file)
184
+ nuget_packages_dir = json["project"]["restore"]["packagesPath"]
185
+ json["targets"].each_with_object({}) do |(_, target), dependencies|
186
+ target.each do |reference_key, reference|
187
+ # Ignore project references
188
+ next unless reference["type"] == "package"
189
+ package_id_parts = reference_key.partition("/")
190
+ name = package_id_parts[0]
191
+ version = package_id_parts[-1]
192
+ id = "#{name}-#{version}"
193
+
194
+ # Already know this package from another target
195
+ next if dependencies.key?(id)
196
+
197
+ path = File.join(nuget_packages_dir, json["libraries"][reference_key]["path"])
198
+ dependencies[id] = NuGetDependency.new(
199
+ name: id,
200
+ version: version,
201
+ path: path,
202
+ metadata: {
203
+ "type" => NuGet.type,
204
+ "name" => name
205
+ }
206
+ )
207
+ end
208
+ end.values
209
+ end
210
+ end
211
+ end
212
+ end