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 parsing of MSBuild SDK dependencies (NuGet) #2849

Merged
merged 10 commits into from
Nov 16, 2021
63 changes: 61 additions & 2 deletions nuget/lib/dependabot/nuget/file_parser/project_file_parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class ProjectFileParser
"ItemGroup > Dependency, "\
"ItemGroup > DevelopmentDependency"

PROJECT_SDK_REGEX = %r{^([^/]+)/(\d+(?:[.]\d+(?:[.]\d+)?)?(?:[+-].*)?)$}.freeze
PROPERTY_REGEX = /\$\((?<property>.*?)\)/.freeze
ITEM_REGEX = /\@\((?<property>.*?)\)/.freeze

Expand All @@ -32,16 +33,19 @@ def dependency_set(project_file:)

doc = Nokogiri::XML(project_file.content)
doc.remove_namespaces!
# Look for regular package references
doc.css(DEPENDENCY_SELECTOR).each do |dependency_node|
name = dependency_name(dependency_node, project_file)
req = dependency_requirement(dependency_node, project_file)
version = dependency_version(dependency_node, project_file)
prop_name = req_property_name(dependency_node)

dependency =
build_dependency(name, req, version, prop_name, project_file)
dependency = build_dependency(name, req, version, prop_name, project_file)
dependency_set << dependency if dependency
end
# Look for SDK references; see:
# https://docs.microsoft.com/en-us/visualstudio/msbuild/how-to-use-project-sdk
add_sdk_references(doc, dependency_set, project_file)

dependency_set
end
Expand All @@ -50,6 +54,61 @@ def dependency_set(project_file:)

attr_reader :dependency_files

def add_sdk_references(doc, dependency_set, project_file)
# These come in 3 flavours:
# - <Project Sdk="Name/Version">
# - <Sdk Name="Name" Version="Version" />
# - <Import Project="..." Sdk="Name" Version="Version" />
# None of these support the use of properties, nor do they allow child
# elements instead of attributes.
add_sdk_refs_from_project(doc, dependency_set, project_file)
add_sdk_refs_from_sdk_tags(doc, dependency_set, project_file)
add_sdk_refs_from_import_tags(doc, dependency_set, project_file)
end

def add_sdk_ref_from_project(sdk_references, dependency_set, project_file)
sdk_references.split(";")&.each do |sdk_reference|
m = sdk_reference.match(PROJECT_SDK_REGEX)
if m
dependency = build_dependency(m[1], m[2], m[2], nil, project_file)
dependency_set << dependency if dependency
end
end
end

def add_sdk_refs_from_import_tags(doc, dependency_set, project_file)
doc.xpath("/Project/Import").each do |import_node|
next unless import_node.attribute("Sdk") && import_node.attribute("Version")

name = import_node.attribute("Sdk")&.value&.strip
version = import_node.attribute("Version")&.value&.strip

dependency = build_dependency(name, version, version, nil, project_file)
dependency_set << dependency if dependency
end
end

def add_sdk_refs_from_project(doc, dependency_set, project_file)
doc.xpath("/Project").each do |project_node|
sdk_references = project_node.attribute("Sdk")&.value&.strip
next unless sdk_references

add_sdk_ref_from_project(sdk_references, dependency_set, project_file)
end
end

def add_sdk_refs_from_sdk_tags(doc, dependency_set, project_file)
doc.xpath("/Project/Sdk").each do |sdk_node|
next unless sdk_node.attribute("Version")

name = sdk_node.attribute("Name")&.value&.strip
version = sdk_node.attribute("Version")&.value&.strip

dependency = build_dependency(name, version, version, nil, project_file)
dependency_set << dependency if dependency
end
end

def build_dependency(name, req, version, prop_name, project_file)
return unless name

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,17 @@ class ProjectFileDeclarationFinder
<DevelopmentDependency [^>]*?/>|
<DevelopmentDependency [^>]*?[^/]>.*?</DevelopmentDependency>
}mx.freeze
SDK_IMPORT_REGEX =
/ <Import [^>]*?Sdk="[^"]*?"[^>]*?Version="[^"]*?"[^>]*?>
| <Import [^>]*?Version="[^"]*?"[^>]*?Sdk="[^"]*?"[^>]*?>
/mx.freeze
SDK_PROJECT_REGEX =
/ <Project [^>]*?Sdk="[^"]*?"[^>]*?>
/mx.freeze
SDK_SDK_REGEX =
/ <Sdk [^>]*?Name="[^"]*?"[^>]*?Version="[^"]*?"[^>]*?>
| <Sdk [^>]*?Version="[^"]*?"[^>]*?Name="[^"]*?"[^>]*?>
/mx.freeze

attr_reader :dependency_name, :declaring_requirement,
:dependency_files
Expand All @@ -33,6 +44,7 @@ def initialize(dependency_name:, dependency_files:,

def declaration_strings
@declaration_strings ||= fetch_declaration_strings
@declaration_strings += fetch_sdk_strings
end

def declaration_nodes
Expand Down Expand Up @@ -72,6 +84,10 @@ def fetch_declaration_strings
# rubocop:enable Metrics/PerceivedComplexity
# rubocop:enable Metrics/CyclomaticComplexity

def fetch_sdk_strings
sdk_project_strings + sdk_sdk_strings + sdk_import_strings
end

# rubocop:disable Metrics/PerceivedComplexity
def get_node_version_value(node)
attribute = "Version"
Expand All @@ -95,6 +111,71 @@ def declaring_file

raise "No file found with name #{filename}!"
end

def sdk_import_strings
sdk_strings(SDK_IMPORT_REGEX, "Import", "Sdk", "Version")
end

def parse_element(string, name)
xml = string
xml += "</#{name}>" unless string.end_with?("/>")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: could this be abused by an XML injection attack?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not know. It has been a long time since I created the PR, but I think I copied what was already there for other processing (like PackageReference).

node = Nokogiri::XML(xml)
node.remove_namespaces!
node.at_xpath("/#{name}")
end

def get_attribute_value_nocase(element, name)
value = element.attribute(name)&.value ||
element.attribute(name.downcase)&.value ||
element.attribute(name.upcase)&.value
value&.strip
end

def desired_sdk_reference?(sdk_reference, dep_name, dep_version)
parts = sdk_reference.split("/")
parts.length == 2 && parts[0]&.downcase == dep_name && parts[1] == dep_version
end

def sdk_project_strings
dep_name = dependency_name&.downcase
dep_version = declaring_requirement.fetch(:requirement)
strings = []
declaring_file.content.scan(SDK_PROJECT_REGEX).each do |string|
element = parse_element(string, "Project")
next unless element

sdk_references = get_attribute_value_nocase(element, "Sdk")
next unless sdk_references&.include?("/")

sdk_references.split(";").each do |sdk_reference|
strings << sdk_reference if desired_sdk_reference?(sdk_reference, dep_name, dep_version)
end
end
strings.uniq
end

def sdk_sdk_strings
sdk_strings(SDK_SDK_REGEX, "Sdk", "Name", "Version")
end

def sdk_strings(regex, element_name, name_attribute, version_attribute)
dep_name = dependency_name&.downcase
dep_version = declaring_requirement.fetch(:requirement)
strings = []
declaring_file.content.scan(regex).each do |string|
element = parse_element(string, element_name)
next unless element

node_name = get_attribute_value_nocase(element, name_attribute)&.downcase
next unless node_name == dep_name

node_version = get_attribute_value_nocase(element, version_attribute)
next unless node_version == dep_version

strings << string
end
strings
end
end
end
end
Expand Down
116 changes: 116 additions & 0 deletions nuget/spec/dependabot/nuget/file_parser/project_file_parser_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,122 @@
expect(dependencies.count).to eq(0)
end
end

context "with a versioned sdk reference" do
context "specified in the Project tag" do
let(:file_body) { fixture("csproj", "sdk_reference_via_project.csproj") }

its(:length) { is_expected.to eq(2) }

describe "the first dependency" do
subject(:dependency) { dependencies.first }

it "has the right details" do
expect(dependency).to be_a(Dependabot::Dependency)
expect(dependency.name).to eq("Awesome.Sdk")
expect(dependency.version).to eq("1.2.3")
expect(dependency.requirements).to eq([{
requirement: "1.2.3",
file: "my.csproj",
groups: [],
source: nil
}])
end
end

describe "the second dependency" do
subject(:dependency) { dependencies[1] }

it "has the right details" do
expect(dependency).to be_a(Dependabot::Dependency)
expect(dependency.name).to eq("Prototype.Sdk")
expect(dependency.version).to eq("0.1.0-beta")
expect(dependency.requirements).to eq([{
requirement: "0.1.0-beta",
file: "my.csproj",
groups: [],
source: nil
}])
end
end
end

context "specified via an Sdk tag" do
let(:file_body) { fixture("csproj", "sdk_reference_via_sdk.csproj") }

its(:length) { is_expected.to eq(2) }

describe "the first dependency" do
subject(:dependency) { dependencies.first }

it "has the right details" do
expect(dependency).to be_a(Dependabot::Dependency)
expect(dependency.name).to eq("Awesome.Sdk")
expect(dependency.version).to eq("1.2.3")
expect(dependency.requirements).to eq([{
requirement: "1.2.3",
file: "my.csproj",
groups: [],
source: nil
}])
end
end

describe "the second dependency" do
subject(:dependency) { dependencies[1] }

it "has the right details" do
expect(dependency).to be_a(Dependabot::Dependency)
expect(dependency.name).to eq("Prototype.Sdk")
expect(dependency.version).to eq("0.1.0-beta")
expect(dependency.requirements).to eq([{
requirement: "0.1.0-beta",
file: "my.csproj",
groups: [],
source: nil
}])
end
end
end

context "specified via an Import tag" do
let(:file_body) { fixture("csproj", "sdk_reference_via_import.csproj") }

its(:length) { is_expected.to eq(2) }

describe "the first dependency" do
subject(:dependency) { dependencies.first }

it "has the right details" do
expect(dependency).to be_a(Dependabot::Dependency)
expect(dependency.name).to eq("Awesome.Sdk")
expect(dependency.version).to eq("1.2.3")
expect(dependency.requirements).to eq([{
requirement: "1.2.3",
file: "my.csproj",
groups: [],
source: nil
}])
end
end

describe "the second dependency" do
subject(:dependency) { dependencies[1] }

it "has the right details" do
expect(dependency).to be_a(Dependabot::Dependency)
expect(dependency.name).to eq("Prototype.Sdk")
expect(dependency.version).to eq("0.1.0-beta")
expect(dependency.requirements).to eq([{
requirement: "0.1.0-beta",
file: "my.csproj",
groups: [],
source: nil
}])
end
end
end
end
end
end
end
43 changes: 43 additions & 0 deletions nuget/spec/dependabot/nuget/file_updater_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,49 @@
)
end
end

context "with MSBuild SDKs" do
let(:csproj_body) do
fixture("csproj", "sdk_references_of_all_kinds.csproj")
end
let(:dependency_name) { "Foo.Bar" }
let(:version) { "1.2.3" }
let(:previous_version) { "1.1.1" }
let(:requirements) do
[{
requirement: "1.2.3",
file: "my.csproj",
groups: [],
source: nil
}]
end
let(:previous_requirements) do
[{
requirement: "1.1.1",
file: "my.csproj",
groups: [],
source: nil
}]
end

it "updates the project correctly" do
content = updated_csproj_file.content
# Sdk attribute on Project (front, middle, back)
expect(content).to include(%(Sdk="Foo.Bar/1.2.3;))
expect(content).to include(%(X;Foo.Bar/1.2.3;Y))
expect(content).to include(%(Y;Foo.Bar/1.2.3">))
# Sdk tag (name/version and version/name)
expect(content).to include(%(<Sdk Version="1.2.3" Name="Foo.Bar"))
expect(content).to include(%(<Sdk Name="Foo.Bar" Version="1.2.3"))
# Import tag (name/version and version/name)
expect(content).to include(
%(<Import Project="X" Version="1.2.3" Sdk="Foo.Bar")
)
expect(content).to include(
%(<Import Sdk="Foo.Bar" Project="Y" Version="1.2.3")
)
end
end
end

context "with a packages.config file" do
Expand Down
11 changes: 11 additions & 0 deletions nuget/spec/fixtures/csproj/sdk_reference_via_import.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<Project>

<Import Project="Awesome.props" Sdk="Awesome.Sdk" Version="1.2.3" />

<PropertyGroup>
<Description>Very simple project using a custom SDK.</Description>
</PropertyGroup>

<Import Project="Prototype.targets" Sdk="Prototype.Sdk" Version="0.1.0-beta" />

</Project>
7 changes: 7 additions & 0 deletions nuget/spec/fixtures/csproj/sdk_reference_via_project.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk;Awesome.Sdk/1.2.3;Prototype.Sdk/0.1.0-beta">

<PropertyGroup>
<Description>Very simple project using a custom SDK.</Description>
</PropertyGroup>

</Project>
Loading