Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for configuring reviewed and ignored dependencies at specific versions #629

Merged
merged 6 commits into from
Feb 22, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/configuration/ignoring_dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,16 @@ ignored:
go:
- github.com/me/my-repo/**/*
```

## Ignoring dependencies at specific versions

Ignore a dependency at specific versions by appending `@<version>` to the end of the dependency's name in an `ignore` list. If a dependency is configured to be ignored at a specific version, licensed will not ignore non-matching versions of the dependency.

The version value can be one of:

1. `"*"` - match any version value
1. any version string, or version range string, that can be parsed by `Gem::Requirement`
- a semantic version - `dependency@1.2.3`
- a gem requirement range - `dependency@~> 1.0.0` or `dependency@< 3.0`
- see the [Rubygems version guides](https://guides.rubygems.org/patterns/#pessimistic-version-constraint) for more details about specifying gem version requirements
1. a value that can't be parsed by `Gem::Requirement`, which will only match dependencies with the same version string
13 changes: 13 additions & 0 deletions docs/configuration/reviewing_dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,16 @@ reviewed:
bundler:
- gem-using-unallowed-license
```

## Reviewing dependencies at specific versions

Review a dependency at specific versions by appending `@<version>` to the end of the dependency's name in an `reviewed` list. If a dependency is configured to be reviewed at a specific version, licensed will not recognize non-matching versions of the dependency as being manually reviewed and accepted.

The version value can be one of:

1. `"*"` - match any version value
1. any version string, or version range string, that can be parsed by `Gem::Requirement`
- a semantic version - `dependency@1.2.3`
- a gem requirement range - `dependency@~> 1.0.0` or `dependency@< 3.0`
- see the [Rubygems version guides](https://guides.rubygems.org/patterns/#pessimistic-version-constraint) for more details about specifying gem version requirements
1. a value that can't be parsed by `Gem::Requirement`, which will only match dependencies with the same version string
2 changes: 2 additions & 0 deletions docs/sources/pnpm.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

The npm source will detect dependencies when `pnpm-lock.yaml` is found at an apps `source_path`. It uses `pnpm licenses list` to enumerate dependencies and metadata.

All dependencies enumerated by the pnpm source include the dependency version in the dependency's name identifier. All [reviewed](../configuration/reviewing_dependencies.md) or [ignored](../configuration/ignoring_dependencies.md) dependencies must include a version signifier in the configured dependency name.

**NOTE** [pnpm licenses list](https://pnpm.io/cli/licenses) is an experimental CLI command and subject to change. If changes to pnpm result in unexpected or broken behavior in licensed please open an [issue](https://github.com/github/licensed/issues/new).

## Including development dependencies
Expand Down
6 changes: 3 additions & 3 deletions lib/licensed/commands/cache.rb
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ def evaluate_dependency(app, source, dependency, report)
report["version"] = dependency.version

if save_dependency_record?(dependency, cached_record)
update_dependency_from_cached_record(app, dependency, cached_record)
update_dependency_from_cached_record(app, source, dependency, cached_record)

dependency.record.save(filename)
report["cached"] = true
Expand Down Expand Up @@ -123,14 +123,14 @@ def save_dependency_record?(dependency, cached_record)
# Update dependency metadata from the cached record, to support:
# 1. continuity between cache runs to cut down on churn
# 2. notifying users when changed content needs to be reviewed
def update_dependency_from_cached_record(app, dependency, cached_record)
def update_dependency_from_cached_record(app, source, dependency, cached_record)
return if cached_record.nil?
return if options[:force]

if dependency.record.matches?(cached_record)
# use the cached license value if the license text wasn't updated
dependency.record["license"] = cached_record["license"]
elsif app.reviewed?(dependency.record)
elsif app.reviewed?(dependency.record, require_version: source.class.require_matched_dependency_version)
# if the license text changed and the dependency is set as reviewed
# force a re-review of the dependency
dependency.record["review_changed_license"] = true
Expand Down
9 changes: 5 additions & 4 deletions lib/licensed/commands/status.rb
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ def evaluate_dependency(app, source, dependency, report)
report.errors << "missing license text" if record.licenses.empty?
if record["review_changed_license"]
report.errors << "license text has changed and needs re-review. if the new text is ok, remove the `review_changed_license` flag from the cached record"
elsif license_needs_review?(app, record)
elsif license_needs_review?(app, source, record)
report.errors << needs_review_error_message(app, record)
end
end
Expand All @@ -70,9 +70,10 @@ def evaluate_dependency(app, source, dependency, report)

# Returns true if a cached record needs further review based on the
# record's license(s) and the app's configuration
def license_needs_review?(app, record)
def license_needs_review?(app, source, record)
# review is not needed if the record is set as reviewed
return false if app.reviewed?(record, match_version: data_source == "configuration")
require_version = data_source == "configuration" || source.class.require_matched_dependency_version
return false if app.reviewed?(record, require_version: require_version)

# review is not needed if the top level license is allowed
return false if app.allowed?(record["license"])
Expand All @@ -99,7 +100,7 @@ def needs_review_error_message(app, record)
error = "dependency needs review"

# look for an unversioned reviewed list match
if app.reviewed?(record, match_version: false)
if app.reviewed?(record, require_version: false)
error += ", unversioned 'reviewed' match found: #{record["name"]}"
end

Expand Down
54 changes: 42 additions & 12 deletions lib/licensed/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ class AppConfiguration < Hash

DEFAULT_CACHE_PATH = ".licenses".freeze

ANY_VERSION_REQUIREMENT = "*".freeze

# Returns the root for a configuration in following order of precedence:
# 1. explicitly configured "root" property
# 2. a found git repository root
Expand Down Expand Up @@ -73,8 +75,8 @@ def enabled?(source_type)
end

# Is the given dependency reviewed?
def reviewed?(dependency, match_version: false)
any_list_pattern_matched? self["reviewed"][dependency["type"]], dependency, match_version: match_version
def reviewed?(dependency, require_version: false)
any_list_pattern_matched? self["reviewed"][dependency["type"]], dependency, require_version: require_version
end

# Find all reviewed dependencies that match the provided dependency's name
Expand All @@ -83,8 +85,8 @@ def reviewed_versions(dependency)
end

# Is the given dependency ignored?
def ignored?(dependency)
any_list_pattern_matched? self["ignored"][dependency["type"]], dependency
def ignored?(dependency, require_version: false)
any_list_pattern_matched? self["ignored"][dependency["type"]], dependency, require_version: require_version
end

# Is the license of the dependency allowed?
Expand All @@ -93,8 +95,10 @@ def allowed?(license)
end

# Ignore a dependency
def ignore(dependency)
(self["ignored"][dependency["type"]] ||= []) << dependency["name"]
def ignore(dependency, at_version: false)
id = dependency["name"]
id += "@#{dependency["version"]}" if at_version && dependency["version"]
(self["ignored"][dependency["type"]] ||= []) << id
end

# Set a dependency as reviewed
Expand All @@ -117,15 +121,41 @@ def additional_terms_for_dependency(dependency)

private

def any_list_pattern_matched?(list, dependency, match_version: false)
def any_list_pattern_matched?(list, dependency, require_version: false)
Array(list).any? do |pattern|
if match_version
at_version = "@#{dependency["version"]}"
pattern, pattern_version = pattern.rpartition(at_version).values_at(0, 1)
next false if pattern == "" || pattern_version == ""
# parse a name and version requirement value from the pattern
name, requirement = pattern.rpartition("@").values_at(0, 2).map(&:strip)

if name == ""
# if name == "", then the pattern doesn't contain a valid version value.
# treat the entire pattern as the dependency name with no version.
name = pattern
requirement = nil
elsif !requirement.to_s.empty?
# check if the version requirement is a valid Gem::Requirement
# for range matching
requirements = requirement.split(",").map(&:strip)
if requirements.all? { |r| Gem::Requirement::PATTERN.match?(r) }
requirement = Gem::Requirement.new(requirements)
end
end

File.fnmatch?(pattern, dependency["name"], File::FNM_PATHNAME | File::FNM_CASEFOLD)
# the pattern's name must match the dependency's name
next false unless File.fnmatch?(name, dependency["name"], File::FNM_PATHNAME | File::FNM_CASEFOLD)

# if there is no version requirement configured or if the dependency doesn't have a version
# specified, return a value based on whether a version match is required
next !require_version if requirement.nil? || dependency["version"].to_s.empty?

case requirement
when String
# string match the requirement against "*" or the dependency's version
[ANY_VERSION_REQUIREMENT, dependency["version"]].any? { |r| requirement == r }
when Gem::Requirement
# if the version was parsed as a gem requirement, check whether the version requirement
# matches the dependency's version
Gem::Version.correct?(dependency["version"]) && requirement.satisfied_by?(Gem::Version.new(dependency["version"]))
end
end
end

Expand Down
21 changes: 13 additions & 8 deletions lib/licensed/dependency.rb
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,17 @@ def notice_contents
.select { |notice| notice["text"].length > 0 } # files with content only
end

# Returns a hash of basic metadata about the dependency - name, version, type, etc
def metadata
{
# can be overriden by values in @metadata
"name" => name,
"version" => version
}.merge(
@metadata
)
end

private

def read_file_with_encoding_check(file_path)
Expand Down Expand Up @@ -135,14 +146,8 @@ def normalize_source_path(path)
# Returns the metadata that represents this dependency. This metadata
# is written to YAML in the dependencys cached text file
def license_metadata
{
# can be overriden by values in @metadata
"name" => name,
"version" => version
}.merge(
@metadata
).merge({
# overrides all other values
metadata.merge({
# overrides all metadata values
"license" => license_key
})
end
Expand Down
6 changes: 6 additions & 0 deletions lib/licensed/sources/pnpm.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@
module Licensed
module Sources
class PNPM < Source
# The PNPM source requires matching reviewed or ignored dependencies
# on both name and version
def self.require_matched_dependency_version
true
end

# Returns true when pnpm is installed and a pnpm-lock.yaml file is found,
# otherwise false
def enabled?
Expand Down
8 changes: 7 additions & 1 deletion lib/licensed/sources/source.rb
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ def type_and_version
.downcase
.split("::")
end

# Returns true if the source requires matching reviewed and ignored dependencies'
# versions as well as their name
def require_matched_dependency_version
false
end
end

# all sources have a configuration
Expand Down Expand Up @@ -81,7 +87,7 @@ def enumerate_dependencies

# Returns whether a dependency is ignored in the configuration.
def ignored?(dependency)
config.ignored?("type" => self.class.type, "name" => dependency.name)
config.ignored?(dependency.metadata, require_version: self.class.require_matched_dependency_version)
end

private
Expand Down
4 changes: 2 additions & 2 deletions test/commands/cache_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
config.apps.each do |app|
FileUtils.mkdir_p app.cache_path.join("test")
File.write app.cache_path.join("test/dependency.#{Licensed::DependencyRecord::EXTENSION}"), ""
app.ignore "type" => "test", "name" => "dependency"
app.ignore({ "type" => "test", "name" => "dependency" })
end

run_command
Expand Down Expand Up @@ -206,7 +206,7 @@
config.apps.each do |app|
FileUtils.mkdir_p app.cache_path.join("test")
File.write app.cache_path.join("test/dependency.#{Licensed::DependencyRecord::EXTENSION}"), ""
app.ignore "type" => "test", "name" => "dependency"
app.ignore({ "type" => "test", "name" => "dependency" })
end

run_command
Expand Down
2 changes: 1 addition & 1 deletion test/commands/list_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
count = dependencies.size

config.apps.each do |app|
app.ignore("type" => "test", "name" => "dependency")
app.ignore({ "type" => "test", "name" => "dependency" })
end

run_command
Expand Down
Loading