Skip to content
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
4 changes: 2 additions & 2 deletions src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,11 +325,11 @@ export async function main(): Promise<void> {
if (
globalArgs.options.file &&
typeof globalArgs.options.file === 'string' &&
(globalArgs.options.file as string).match(/\.sln$/)
sln.isSolutionFile(globalArgs.options.file as string)
) {
if (globalArgs.options['project-name']) {
throw new UnsupportedOptionCombinationError([
'file=*.sln',
`file=*${sln.solutionExtension(globalArgs.options.file as string)}`,
'project-name',
]);
}
Expand Down
135 changes: 119 additions & 16 deletions src/lib/sln/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,31 +7,134 @@ import { FileFlagBadInputError } from '../errors';

const debug = Debug('snyk');

// The two .NET solution formats. `.slnx` is the XML replacement for the text
// `.sln`: the .NET SDK reads it from 9.0.200, and `dotnet new sln` creates it by
// default from .NET 10.
const SLN_EXTENSION = '.sln';
const SLNX_EXTENSION = '.slnx';

// Returns the solution extension `file` ends with, lower-cased, or '' when it is
// not a solution file. Matched on the suffix rather than with path.extname,
// which reports no extension at all for a name that is only an extension
// (`.slnx`) and would send it to the wrong parser. Lower-casing also keeps the
// message this feeds identical to the one cli-extension-os-flows produces.
export const solutionExtension = (file: string): string => {
const lowerCased = file.toLowerCase();

if (lowerCased.endsWith(SLNX_EXTENSION)) {
return SLNX_EXTENSION;
}
if (lowerCased.endsWith(SLN_EXTENSION)) {
return SLN_EXTENSION;
}

return '';
};

export const isSolutionFile = (file: string): boolean =>
solutionExtension(file) !== '';

// slnFile should exist.
// returns array of project paths (path/to/manifest.file)
export const parsePathsFromSln = (slnFile) => {
const contents = loadFile(path.resolve(slnFile));

// Each format reduces a project path to its folder its own way; they do not
// agree, and `.sln` has to keep behaving exactly as it always has.
const paths =
solutionExtension(slnFile) === SLNX_EXTENSION
? parseProjectPathsFromSlnx(contents).map(slnxProjectFolder)
: parseProjectPathsFromSln(contents).map(slnProjectFolder);

debug('extracted paths from solution file: ', paths);
return paths;
};

// The original text format: `Project(...) = "Name", "path\to\project.csproj", "{guid}"`.
function parseProjectPathsFromSln(contents: string): string[] {
// read project scopes from solution file
// [\s\S] is like ., but with newlines!
// *? means grab the shortest match
const projectScopes =
loadFile(path.resolve(slnFile)).match(/Project[\s\S]*?EndProject/g) || [];

const paths = projectScopes
.map((projectScope) => {
const secondArg = projectScope.split(',')[1];
// expected ` "path/to/manifest.file"`, clean it up
return secondArg && secondArg.trim().replace(/"/g, '');
const projectScopes = contents.match(/Project[\s\S]*?EndProject/g) || [];

return (
projectScopes
.map((projectScope) => {
const secondArg = projectScope.split(',')[1];
// expected ` "path/to/manifest.file"`, clean it up
return secondArg && secondArg.trim().replace(/"/g, '');
})
// drop falsey values
.filter(Boolean)
);
}

// `.slnx` is XML: a <Solution> of <Project Path="..." /> elements, which may be
// nested inside <Folder> elements to any depth. All we need out of it is the
// project paths, so we match the Project elements rather than pull an XML parser
// into the CLI's dependency tree. Comments are stripped first so a commented-out
// project isn't scanned.
function parseProjectPathsFromSlnx(contents: string): string[] {
const projectElements =
contents.replace(/<!--[\s\S]*?-->/g, '').match(/<Project\b[^>]*>/g) || [];

return projectElements
.map((element) => {
const attribute = element.match(/\sPath\s*=\s*("([^"]*)"|'([^']*)')/);
// one of the two capture groups holds the value; which one depends on the
// quote style the author used
return attribute && (attribute[2] ?? attribute[3]);
})
// drop falsey values
.filter(Boolean)
// convert path separators
.map((projectPath) => {
return path.dirname(projectPath.replace(/\\/g, path.sep));
});
.map(decodeXmlEntities);
}

debug('extracted paths from solution file: ', paths);
return paths;
};
function decodeXmlEntities(value: string): string {
return (
value
// Numeric character references first. `&#47;` is a path separator, so
// leaving these encoded doesn't just misname a folder, it collapses the
// path to the solution's own directory.
.replace(/&#x([0-9a-f]+);/gi, (_, hex) =>
String.fromCodePoint(parseInt(hex, 16)),
)
.replace(/&#(\d+);/g, (_, decimal) =>
String.fromCodePoint(parseInt(decimal, 10)),
)
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
// Ampersand last, so `&amp;#47;` decodes to the literal text `&#47;`
// rather than to a separator.
.replace(/&amp;/g, '&')
);
}

function toOsSeparators(projectPath: string): string {
return projectPath.replace(/\\/g, path.sep);
}

// Unchanged from before `.slnx` existed, deliberately. An ASP.NET Website
// project is written with a trailing separator (`..\WebSites\Site1\`) and has
// always resolved to the folder *above* it. That may well be wrong, but it
// decides which projects a `.sln` scan covers, so correcting it belongs in its
// own change with its own release note — not smuggled in with a new format.
function slnProjectFolder(projectPath: string): string {
return path.dirname(toOsSeparators(projectPath));
}

// A `.slnx` project path normally names the project file, whose folder holds the
// manifest. A path written with a trailing separator names a folder instead —
// that is how an ASP.NET Website project is recorded, which has no project file
// at all — so the folder itself is what gets scanned.
function slnxProjectFolder(projectPath: string): string {
const normalised = toOsSeparators(projectPath);

return /[\\/]$/.test(projectPath)
? normalised.slice(0, -1)
: path.dirname(normalised);
}

export const updateArgs = (args) => {
if (!args.options.file || typeof args.options.file !== 'string') {
Expand Down
3 changes: 3 additions & 0 deletions test/acceptance/workspaces/emptySolution.slnx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<Solution>
<Project Path="NoSuchFolder/NoSuchFile.csproj" />
</Solution>
6 changes: 6 additions & 0 deletions test/acceptance/workspaces/sln-example-app/mySolution.slnx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<Solution>
<Folder Name="/src/">
<Project Path="dotnet2_new_mvc_project/new_mvc_project.csproj" />
</Folder>
<Project Path="WebApplication2\WebApplication2.csproj" />
</Solution>
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<Solution>
<Project Path="dotnet2_new_mvc_project/new_mvc_project.csproj" />
<Project Path="WebApplication2/WebApplication2.csproj" />
</Solution>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio 2012
Project("{E24C65DC-7377-472B-9ABA-BC803B73C61A}") = "WebSite1", "..\..\WebSites\WebSite1\", "{26B9B59B-C5AC-49CE-9BD6-4C72940DAC89}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebApplication2", "WebApplication2\WebApplication2.csproj", "{9A79CF26-C7E8-47A3-B718-C5E654AA2701}"
EndProject
Global
EndGlobal
11 changes: 11 additions & 0 deletions test/acceptance/workspaces/slnxSolution.slnx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<Solution>
<Project Path="nuget-app\doesnt_matter.csproj">
<BuildType Solution="Debug|Any CPU" Project="Debug" />
</Project>
<Folder Name="/tests/">
<Project Path="../workspaces/nuget-app-2.1/doesnt_matter.csproj" />
</Folder>
<Project Path="NoSuchFolder/NoSuchFile.csproj" />
<!-- <Project Path="removed-app/doesnt_matter.csproj" /> -->
</Solution>
3 changes: 3 additions & 0 deletions test/fixtures/nuget-sln/Service.slnx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<Solution>
<Project Path="Service/Service.csproj" />
</Solution>
14 changes: 14 additions & 0 deletions test/jest/acceptance/cli-args.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,20 @@ describe.each(userJourneyWorkflows)(
expect(code).toEqual(2);
});

test('snyk test --file=file.slnx --project-name=NAME', async () => {
const { code, stdout } = await runSnykCLI(
`test --file=file.slnx --project-name=NAME`,
{
env,
},
);

expect(stdout).toContainText(
'The following option combination is not currently supported: file=*.slnx + project-name',
);
expect(code).toEqual(2);
});

test('snyk test --file=blah --scan-all-unmanaged', async () => {
const { code, stdout } = await runSnykCLI(
`test --file=blah --scan-all-unmanaged`,
Expand Down
20 changes: 20 additions & 0 deletions test/jest/acceptance/snyk-sbom/nuget-options.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,4 +147,24 @@ describe('snyk sbom: nuget options (mocked server only)', () => {
expect(bom.metadata.component.name).toEqual('Service');
expect(bom.components).toHaveLength(51);
});

test('`sbom --file` generates an SBOM for the NuGet project by using the file flag with a .slnx solution file', async () => {
const project = await createProjectFromFixture('nuget-sln');

const { code, stdout } = await runSnykCLI(
`sbom --org aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee --format cyclonedx1.4+json --file=Service.slnx`,
{
cwd: project.path(),
env,
},
);
let bom;

expect(code).toEqual(0);
expect(() => {
bom = JSON.parse(stdout);
}).not.toThrow();
expect(bom.metadata.component.name).toEqual('Service');
expect(bom.components).toHaveLength(51);
});
});
Loading