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 solution filter file (#853) #920

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
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
59 changes: 48 additions & 11 deletions CycloneDX.Tests/SolutionFileServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,14 @@
// Copyright (c) OWASP Foundation. All Rights Reserved.

using System.Collections.Generic;
using System.Threading.Tasks;
using Xunit;
using System.IO.Abstractions.TestingHelpers;
using System.Threading.Tasks;
using CycloneDX.Interfaces;
using XFS = System.IO.Abstractions.TestingHelpers.MockUnixSupport;
using Moq;
using CycloneDX.Services;
using CycloneDX.Models;
using System.IO;
using CycloneDX.Services;
using Moq;
using Xunit;
using XFS = System.IO.Abstractions.TestingHelpers.MockUnixSupport;

namespace CycloneDX.Tests
{
Expand All @@ -47,8 +46,8 @@ public async Task GetSolutionProjectReferences_ReturnsProjectThatExists()
var solutionFileService = new SolutionFileService(mockFileSystem, mockProjectFileService.Object);

var projects = await solutionFileService.GetSolutionProjectReferencesAsync(XFS.Path(@"c:\SolutionPath\SolutionFile.sln")).ConfigureAwait(true);
Assert.Collection(projects,

Assert.Collection(projects,
item => Assert.Equal(XFS.Path(@"c:\SolutionPath\Project\Project.csproj"), item));
}

Expand Down Expand Up @@ -77,7 +76,7 @@ public async Task GetSolutionProjectReferences_ReturnsListOfProjects()
var projects = await solutionFileService.GetSolutionProjectReferencesAsync(XFS.Path(@"c:\SolutionPath\SolutionFile.sln")).ConfigureAwait(true);
var sortedProjects = new List<string>(projects);
sortedProjects.Sort();

Assert.Collection(sortedProjects,
item => Assert.Equal(XFS.Path(@"c:\SolutionPath\Project1\Project1.csproj"), item),
item => Assert.Equal(XFS.Path(@"c:\SolutionPath\Project2\Project2.csproj"), item),
Expand Down Expand Up @@ -112,7 +111,7 @@ public async Task GetSolutionProjectReferences_ReturnsListOfProjectsIncludingFSh
var projects = await solutionFileService.GetSolutionProjectReferencesAsync(XFS.Path(@"c:\SolutionPath\SolutionFile.sln")).ConfigureAwait(true);
var sortedProjects = new List<string>(projects);
sortedProjects.Sort();

Assert.Collection(sortedProjects,
item => Assert.Equal(XFS.Path(@"c:\SolutionPath\Project1\Project1.csproj"), item),
item => Assert.Equal(XFS.Path(@"c:\SolutionPath\Project2\Project2.fsproj"), item),
Expand Down Expand Up @@ -147,10 +146,48 @@ public async Task GetSolutionProjectReferences_ReturnsListOfProjects_IncludingSe
var projects = await solutionFileService.GetSolutionProjectReferencesAsync(XFS.Path(@"c:\SolutionPath\SolutionFile.sln")).ConfigureAwait(true);
var sortedProjects = new List<string>(projects);
sortedProjects.Sort();

Assert.Collection(sortedProjects,
item => Assert.Equal(XFS.Path(@"c:\SolutionPath\Project1\Project1.csproj"), item),
item => Assert.Equal(XFS.Path(@"c:\SolutionPath\Project2\Project2.csproj"), item));
}

[Fact]
public async Task GetSolutionFilterProjectReferences_ReturnsListOfProjects()
{
var mockFileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
{
{ XFS.Path(@"c:\SolutionPath\SolutionFile.slnf"), new MockFileData(@"
{
""solution"": {
""path"": ""SolutionFile.sln"",
""projects"": [
""Project1\\Project1.csproj"",
""Project2\\Project2.csproj"",
""Project3\\Project3.csproj""
]
}
}")},
{ XFS.Path(@"c:\SolutionPath\Project1\Project1.csproj"), Helpers.GetEmptyProjectFile() },
{ XFS.Path(@"c:\SolutionPath\Project2\Project2.csproj"), Helpers.GetEmptyProjectFile() },
{ XFS.Path(@"c:\SolutionPath\Project3\Project3.csproj"), Helpers.GetEmptyProjectFile() },
});
var mockProjectFileService = new Mock<IProjectFileService>();
mockProjectFileService
.SetupSequence(s => s.RecursivelyGetProjectReferencesAsync(It.IsAny<string>()))
.ReturnsAsync(new HashSet<DotnetDependency>())
.ReturnsAsync(new HashSet<DotnetDependency>())
.ReturnsAsync(new HashSet<DotnetDependency>());
var solutionFileService = new SolutionFileService(mockFileSystem, mockProjectFileService.Object);

var projects = await solutionFileService.GetSolutionProjectReferencesAsync(XFS.Path(@"c:\SolutionPath\SolutionFile.slnf")).ConfigureAwait(true);
var sortedProjects = new List<string>(projects);
sortedProjects.Sort();

Assert.Collection(sortedProjects,
item => Assert.Equal(XFS.Path(@"c:\SolutionPath\Project1\Project1.csproj"), item),
item => Assert.Equal(XFS.Path(@"c:\SolutionPath\Project2\Project2.csproj"), item),
item => Assert.Equal(XFS.Path(@"c:\SolutionPath\Project3\Project3.csproj"), item));
}
}
}
19 changes: 19 additions & 0 deletions CycloneDX/Models/SolutionFilterFileModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using System.Text.Json.Serialization;

namespace CycloneDX.Models
{
public class SolutionFilterFileModel
{
[JsonPropertyName("solution")]
public SolutionFilterSolution Solution { get; set; }
}

public class SolutionFilterSolution
{
[JsonPropertyName("path")]
public string Path { get; init; }

[JsonPropertyName("projects")]
public string[] Projects { get; init; }
}
}
10 changes: 4 additions & 6 deletions CycloneDX/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,7 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) OWASP Foundation. All Rights Reserved.

using System;
using System.CommandLine;
using System.IO;
using System.Threading.Tasks;
using CycloneDX.Models;

Expand All @@ -29,7 +27,7 @@ public static Task<int> Main(string[] args)
{


var SolutionOrProjectFile = new Argument<string>("path", description: "The path to a .sln, .csproj, .fsproj, .vbproj, .xsproj, or packages.config file or the path to a directory which will be recursively analyzed for packages.config files.");
var SolutionOrProjectFile = new Argument<string>("path", description: "The path to a .sln, .slnf, .csproj, .fsproj, .vbproj, .xsproj, or packages.config file or the path to a directory which will be recursively analyzed for packages.config files.");
var framework = new Option<string>(new[] { "--framework", "-tfm" }, "The target framework to use. If not defined, all will be aggregated.");
var runtime = new Option<string>(new[] { "--runtime", "-rt" }, "The runtime to use. If not defined, all will be aggregated.");
var outputDirectory = new Option<string>(new[] { "--output", "-o" }, description: "The directory to write the BOM");
Expand Down Expand Up @@ -59,8 +57,8 @@ public static Task<int> Main(string[] args)
//Deprecated args
var disableGithubLicenses = new Option<bool>(new[] { "--disable-github-licenses", "-dgl" }, "(Deprecated, this is the default setting now");
var outputFilenameDeprecated = new Option<string>(new[] { "-f" }, "(Deprecated use -fn instead) Optionally provide a filename for the BOM (default: bom.xml or bom.json).");
var excludeDevDeprecated = new Option<bool>(new[] {"-d" }, "(Deprecated use -ed instead) Exclude development dependencies from the BOM.");
var scanProjectDeprecated = new Option<bool>(new[] {"-r" }, "(Deprecated use -rs instead) To be used with a single project file, it will recursively scan project references of the supplied project file.");
var excludeDevDeprecated = new Option<bool>(new[] { "-d" }, "(Deprecated use -ed instead) Exclude development dependencies from the BOM.");
var scanProjectDeprecated = new Option<bool>(new[] { "-r" }, "(Deprecated use -rs instead) To be used with a single project file, it will recursively scan project references of the supplied project file.");
var outputDirectoryDeprecated = new Option<string>(new[] { "--out", }, description: "(Deprecated use -output instead) The directory to write the BOM");


Expand Down Expand Up @@ -131,7 +129,7 @@ public static Task<int> Main(string[] args)
setVersion = context.ParseResult.GetValueForOption(setVersion),
setType = context.ParseResult.GetValueForOption(setType),
includeProjectReferences = context.ParseResult.GetValueForOption(includeProjectReferences)
};
};

Runner runner = new Runner();
var taskStatus = await runner.HandleCommandAsync(options);
Expand Down
33 changes: 18 additions & 15 deletions CycloneDX/Runner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@
using System.Net.Http;
using System.Reflection;
using System.Threading.Tasks;
using CycloneDX.Models;
using CycloneDX.Interfaces;
using CycloneDX.Models;
using CycloneDX.Services;
using static CycloneDX.Models.Component;

Expand Down Expand Up @@ -71,13 +71,13 @@ public async Task<int> HandleCommandAsync(RunOptions options)
string outputFilename = options.outputFilename;
bool json = options.json;
bool excludeDev = options.excludeDev;
bool excludetestprojects = options.excludeTestProjects;
bool excludetestprojects = options.excludeTestProjects;
bool scanProjectReferences = options.scanProjectReferences;
bool noSerialNumber = options.noSerialNumber;
string githubUsername = options.githubUsername;
string githubT = options.githubT;
string githubBT = options.githubBT;
bool disablePackageRestore = options.disablePackageRestore;
string githubBT = options.githubBT;
bool disablePackageRestore = options.disablePackageRestore;
int dotnetCommandTimeout = options.dotnetCommandTimeout;
string baseIntermediateOutputPath = options.baseIntermediateOutputPath;
string importMetadataPath = options.importMetadataPath;
Expand Down Expand Up @@ -151,6 +151,8 @@ public async Task<int> HandleCommandAsync(RunOptions options)
&&
(SolutionOrProjectFile.ToLowerInvariant().EndsWith(".sln", StringComparison.OrdinalIgnoreCase)
||
SolutionOrProjectFile.ToLowerInvariant().EndsWith(".slnf", StringComparison.OrdinalIgnoreCase)
||
fileSystem.Directory.Exists(fullSolutionOrProjectFilePath)
||
this.fileSystem.Path.GetFileName(SolutionOrProjectFile).ToLowerInvariant().Equals("packages.config", StringComparison.OrdinalIgnoreCase)))
Expand All @@ -162,7 +164,8 @@ public async Task<int> HandleCommandAsync(RunOptions options)

try
{
if (SolutionOrProjectFile.ToLowerInvariant().EndsWith(".sln", StringComparison.OrdinalIgnoreCase))
if (SolutionOrProjectFile.ToLowerInvariant().EndsWith(".sln", StringComparison.OrdinalIgnoreCase) ||
SolutionOrProjectFile.ToLowerInvariant().EndsWith(".slnf", StringComparison.OrdinalIgnoreCase))
{
if (!fileSystem.File.Exists(SolutionOrProjectFile))
{
Expand All @@ -174,20 +177,20 @@ public async Task<int> HandleCommandAsync(RunOptions options)
}
else if (Utils.IsSupportedProjectType(SolutionOrProjectFile) && scanProjectReferences)
{
if(!fileSystem.File.Exists(SolutionOrProjectFile))
if (!fileSystem.File.Exists(SolutionOrProjectFile))
{
Console.Error.WriteLine($"No file found at path {SolutionOrProjectFile}");
return (int)ExitCode.InvalidOptions;
return (int)ExitCode.InvalidOptions;
}
packages = await projectFileService.RecursivelyGetProjectDotnetDependencysAsync(fullSolutionOrProjectFilePath, baseIntermediateOutputPath, excludetestprojects, framework, runtime).ConfigureAwait(false);
topLevelComponent.Name = fileSystem.Path.GetFileNameWithoutExtension(SolutionOrProjectFile);
}
else if (Utils.IsSupportedProjectType(SolutionOrProjectFile))
{
if(!fileSystem.File.Exists(SolutionOrProjectFile))
{
if (!fileSystem.File.Exists(SolutionOrProjectFile))
{
Console.Error.WriteLine($"No file found at path {SolutionOrProjectFile}");
return (int)ExitCode.InvalidOptions;
return (int)ExitCode.InvalidOptions;
}
packages = await projectFileService.GetProjectDotnetDependencysAsync(fullSolutionOrProjectFilePath, baseIntermediateOutputPath, excludetestprojects, framework, runtime).ConfigureAwait(false);
topLevelComponent.Name = fileSystem.Path.GetFileNameWithoutExtension(SolutionOrProjectFile);
Expand All @@ -209,7 +212,7 @@ public async Task<int> HandleCommandAsync(RunOptions options)
}
else
{
Console.Error.WriteLine($"Only .sln, .csproj, .fsproj, .vbproj, .xsproj, and packages.config files are supported");
Console.Error.WriteLine($"Only .sln, .slnf, .csproj, .fsproj, .vbproj, .xsproj, and packages.config files are supported");
return (int)ExitCode.InvalidOptions;
}
}
Expand All @@ -226,7 +229,7 @@ public async Task<int> HandleCommandAsync(RunOptions options)
topLevelComponent.Name = setName;
}


if (excludeDev)
{
foreach (var item in packages.Where(p => p.IsDevDependency))
Expand All @@ -239,7 +242,7 @@ public async Task<int> HandleCommandAsync(RunOptions options)
}




// get all the components and dependency graph from the NuGet packages
var components = new HashSet<Component>();
Expand Down Expand Up @@ -423,7 +426,7 @@ public async Task<int> HandleCommandAsync(RunOptions options)
Console.WriteLine("Writing to: " + bomFilePath);
this.fileSystem.File.WriteAllText(bomFilePath, bomContents);

return 0;
return 0;
}


Expand Down Expand Up @@ -465,7 +468,7 @@ private static void SetMetadataComponentIfNecessary(Bom bom, Component topLevelC
{
bom.Metadata.Timestamp = DateTime.UtcNow;
}

}

internal static Bom ReadMetaDataFromFile(Bom bom, string templatePath)
Expand Down
78 changes: 54 additions & 24 deletions CycloneDX/Services/SolutionFileService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@

using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.IO.Abstractions;
using System.Linq;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using CycloneDX.Interfaces;
using CycloneDX.Models;

Expand All @@ -44,31 +45,17 @@ public SolutionFileService(IFileSystem fileSystem, IProjectFileService projectFi
/// <returns></returns>
public async Task<HashSet<string>> GetSolutionProjectReferencesAsync(string solutionFilePath)
{
var solutionFolder = _fileSystem.Path.GetDirectoryName(solutionFilePath);
var projects = new HashSet<string>();
using (var reader = _fileSystem.File.OpenText(solutionFilePath))
HashSet<string> projects;
if (solutionFilePath.ToLowerInvariant().EndsWith(".sln", StringComparison.OrdinalIgnoreCase))
{
string line;

while ((line = await reader.ReadLineAsync().ConfigureAwait(false)) != null)
{
if (!line.StartsWith("Project", StringComparison.OrdinalIgnoreCase))
{
continue;
}
var regex = new Regex("(.*) = \"(.*?)\", \"(.*?)\"");
var match = regex.Match(line);
if (match.Success)
{
var relativeProjectPath = match.Groups[3].Value.Replace('\\', _fileSystem.Path.DirectorySeparatorChar);
var projectFile = _fileSystem.Path.GetFullPath(_fileSystem.Path.Combine(solutionFolder, relativeProjectPath));
if (Utils.IsSupportedProjectType(projectFile)) projects.Add(projectFile);
}
}
projects = await GetProjectsForSolution(solutionFilePath).ConfigureAwait(false);
}
else
{
projects = await GetProjectsForSolutionFilter(solutionFilePath).ConfigureAwait(false);
}

var projectList = new List<string>(projects);
foreach (var project in projectList)
foreach (var project in projects.ToArray())
{
var projectReferences = await _projectFileService.RecursivelyGetProjectReferencesAsync(project).ConfigureAwait(false);
projects.UnionWith(projectReferences.Select(dep => dep.Path));
Expand All @@ -77,6 +64,49 @@ public async Task<HashSet<string>> GetSolutionProjectReferencesAsync(string solu
return projects;
}

private async Task<HashSet<string>> GetProjectsForSolutionFilter(string mainFile)
{
await using var stream = _fileSystem.File.OpenRead(mainFile);
var solutionFilterFile = await JsonSerializer.DeserializeAsync<SolutionFilterFileModel>(stream).ConfigureAwait(false);
Console.WriteLine(solutionFilterFile.Solution.Projects);

var solutionFolder = _fileSystem.Path.GetDirectoryName(mainFile);
var projects = new HashSet<string>();
foreach (string relativeProjectPath in solutionFilterFile.Solution.Projects)
{
var projectFile = _fileSystem.Path.GetFullPath(_fileSystem.Path.Combine(solutionFolder, relativeProjectPath));
if (Utils.IsSupportedProjectType(projectFile)) projects.Add(projectFile);
}

return projects;
}

private async Task<HashSet<string>> GetProjectsForSolution(string mainFile)
{
var solutionFolder = _fileSystem.Path.GetDirectoryName(mainFile);
var projects = new HashSet<string>();
using var reader = _fileSystem.File.OpenText(mainFile);
string line;

while ((line = await reader.ReadLineAsync().ConfigureAwait(false)) != null)
{
if (!line.StartsWith("Project", StringComparison.OrdinalIgnoreCase))
{
continue;
}
var regex = new Regex("(.*) = \"(.*?)\", \"(.*?)\"");
var match = regex.Match(line);
if (match.Success)
{
var relativeProjectPath = match.Groups[3].Value.Replace('\\', _fileSystem.Path.DirectorySeparatorChar);
var projectFile = _fileSystem.Path.GetFullPath(_fileSystem.Path.Combine(solutionFolder, relativeProjectPath));
if (Utils.IsSupportedProjectType(projectFile)) projects.Add(projectFile);
}
}

return projects;
}

/// <summary>
/// Analyzes a single Solution file for NuGet package references in referenced project files
/// </summary>
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ Usage:
CycloneDX <path> [options]

Arguments:
<path> The path to a .sln, .csproj, .fsproj, .vbproj, .xsproj, or packages.config file or the path to a directory which will be recursively analyzed for packages.config files.
<path> The path to a .sln, .slnf, .csproj, .fsproj, .vbproj, .xsproj, or packages.config file or the path to a directory which will be recursively analyzed for packages.config files.

Options:
-tfm, --framework <framework> The target framework to use. If not defined, all will be aggregated.
Expand Down