Skip to content
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
2 changes: 2 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ jobs:
uses: actions/setup-go@v5
with:
go-version: '1.20.1'
- name: Install pnpm
run: npm install -g pnpm
- name: get Python location
id: python-location
run: |
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,7 @@ There are 2 approaches for customizing <em>Exhort Java API</em>. Using <em>Envir
```java
System.setProperty("EXHORT_MVN_PATH", "/path/to/custom/mvn");
System.setProperty("EXHORT_NPM_PATH", "/path/to/custom/npm");
System.setProperty("EXHORT_PNPM_PATH", "/path/to/custom/pnpm");
System.setProperty("EXHORT_GO_PATH", "/path/to/custom/go");
System.setProperty("EXHORT_GRADLE_PATH", "/path/to/custom/gradle");
//python - python3, pip3 take precedence if python version > 3 installed
Expand Down Expand Up @@ -373,6 +374,11 @@ following keys for setting custom paths for the said executables.
<td>EXHORT_NPM_PATH</td>
</tr>
<tr>
<td><a href="https://pnpm.io//">pnPM</a></td>
<td><em>pnpm</em></td>
<td>EXHORT_PNPM_PATH</td>
</tr>
<tr>
<td><a href="https://go.dev/blog/using-go-modules/">Go Modules</a></td>
<td><em>go</em></td>
<td>EXHORT_GO_PATH</td>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ public GoModulesProvider(Path manifest) {

@Override
public Content provideStack() throws IOException {
// check for custom npm executable
// check for custom executable
Sbom sbom = getDependenciesSbom(manifest, true);
return new Content(
sbom.getAsJsonString().getBytes(StandardCharsets.UTF_8), Api.CYCLONEDX_MEDIA_TYPE);
Expand Down
240 changes: 36 additions & 204 deletions src/main/java/com/redhat/exhort/providers/JavaScriptNpmProvider.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,229 +15,61 @@
*/
package com.redhat.exhort.providers;

import static com.redhat.exhort.impl.ExhortApi.debugLoggingIsNeeded;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.packageurl.MalformedPackageURLException;
import com.github.packageurl.PackageURL;
import com.redhat.exhort.Api;
import com.redhat.exhort.Provider;
import com.redhat.exhort.sbom.Sbom;
import com.redhat.exhort.sbom.SbomFactory;
import com.redhat.exhort.tools.Ecosystem;
import com.redhat.exhort.tools.Ecosystem.Type;
import com.redhat.exhort.tools.Operations;
import com.redhat.exhort.utils.Environment;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;

/**
* Concrete implementation of the {@link Provider} used for converting dependency trees for npm
* projects (package.json) into a SBOM content for Stack analysis or Component analysis.
* Concrete implementation of the {@link JavaScriptProvider} used for converting dependency trees
* for npm projects (package.json) into a SBOM content for Stack analysis or Component analysis.
*/
public final class JavaScriptNpmProvider extends Provider {
public final class JavaScriptNpmProvider extends JavaScriptProvider {

private static final String PROP_PATH = "PATH";
private System.Logger log = System.getLogger(this.getClass().getName());
public static final String LOCK_FILE = "package-lock.json";
public static final String CMD_NAME = "npm";
public static final String ENV_NODE_HOME = "NODE_HOME";

public JavaScriptNpmProvider(Path manifest) {
super(Type.NPM, manifest);
super(manifest, Ecosystem.Type.NPM, CMD_NAME);
}

@Override
public Content provideStack() throws IOException {
// check for custom npm executable
Sbom sbom = getDependencySbom(manifest, true, false);
return new Content(
sbom.getAsJsonString().getBytes(StandardCharsets.UTF_8), Api.CYCLONEDX_MEDIA_TYPE);
protected final String lockFileName() {
return LOCK_FILE;
}

@Override
public Content provideComponent() throws IOException {
return new Content(
getDependencySbom(manifest, false, false)
.getAsJsonString()
.getBytes(StandardCharsets.UTF_8),
Api.CYCLONEDX_MEDIA_TYPE);
}

private PackageURL getRoot(JsonNode jsonDependenciesNpm) throws MalformedPackageURLException {
return toPurl(
jsonDependenciesNpm.get("name").asText(), jsonDependenciesNpm.get("version").asText());
}

private PackageURL toPurl(String name, String version) throws MalformedPackageURLException {
String[] parts = name.split("/");
if (parts.length == 2) {
return new PackageURL(Ecosystem.Type.NPM.getType(), parts[0], parts[1], version, null, null);
}
return new PackageURL(Ecosystem.Type.NPM.getType(), null, parts[0], version, null, null);
}

private void addDependenciesOf(Sbom sbom, PackageURL from, JsonNode dependencies)
throws MalformedPackageURLException {
Iterator<Entry<String, JsonNode>> fields = dependencies.fields();
while (fields.hasNext()) {
Entry<String, JsonNode> e = fields.next();
String name = e.getKey();
JsonNode versionNode = e.getValue().get("version");
if (versionNode == null) {
continue; // ignore optional dependencies
}
String version = versionNode.asText();
PackageURL purl = toPurl(name, version);
sbom.addDependency(from, purl);
JsonNode transitiveDeps = e.getValue().findValue("dependencies");
if (transitiveDeps != null) {
addDependenciesOf(sbom, purl, transitiveDeps);
}
}
}

private Sbom getDependencySbom(
Path manifestPath, boolean includeTransitive, boolean deletePackageLock) throws IOException {
var npmListResult = buildNpmDependencyTree(manifestPath, includeTransitive, deletePackageLock);
var sbom = buildSbom(npmListResult);
sbom.filterIgnoredDeps(getIgnoredDeps(manifestPath));
return sbom;
}

private JsonNode buildNpmDependencyTree(
Path manifestPath, boolean includeTransitive, boolean deletePackageLock)
throws JsonMappingException, JsonProcessingException {
var npm = Operations.getCustomPathOrElse("npm");
var npmEnvs = getNpmExecEnv();
// clean command used to clean build target
Path manifestDir = null;
try {
// MacOS requires resolving to the CanonicalPath to avoid problems with /var being a symlink
// of /private/var
manifestDir = Path.of(manifestPath.getParent().toFile().getCanonicalPath());
} catch (IOException e) {
throw new RuntimeException(
String.format(
"Unable to resolve manifest directory %s, got %s",
manifestPath.getParent(), e.getMessage()));
}
Path packageLockJson = manifestDir.resolve("package-lock.json");
var createPackageLock =
new String[] {npm, "i", "--package-lock-only", "--prefix", manifestDir.toString()};
// execute the clean command
Operations.runProcess(createPackageLock, npmEnvs);
String[] npmAllDeps;
Path workDir = null;
if (!manifestPath.getParent().toString().trim().contains(" ")) {

npmAllDeps =
new String[] {
npm,
"ls",
includeTransitive ? "--all" : "--depth=0",
"--omit=dev",
"--package-lock-only",
"--json",
"--prefix",
manifestDir.toString()
};
} else {
npmAllDeps =
new String[] {
npm,
"ls",
includeTransitive ? "--all" : "--depth=0",
"--omit=dev",
"--package-lock-only",
"--json"
};
workDir = manifestPath.getParent();
}
// execute the clean command
String npmOutput;
if (npmEnvs != null) {
npmOutput =
Operations.runProcessGetOutput(
workDir,
npmAllDeps,
npmEnvs.entrySet().stream()
.map(e -> e.getKey() + "=" + e.getValue())
.toArray(String[]::new));
} else {
npmOutput = Operations.runProcessGetOutput(workDir, npmAllDeps);
}
if (debugLoggingIsNeeded()) {
log.log(
System.Logger.Level.INFO,
String.format(
"Npm Listed Install Pacakges in Json : %s %s", System.lineSeparator(), npmOutput));
}
if (!includeTransitive) {
if (deletePackageLock) {
try {
Files.delete(packageLockJson);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
return objectMapper.readTree(npmOutput);
}

private Sbom buildSbom(JsonNode npmListResult) {
Sbom sbom = SbomFactory.newInstance();
try {
PackageURL root = getRoot(npmListResult);
sbom.addRoot(root);
JsonNode dependencies = npmListResult.get("dependencies");
addDependenciesOf(sbom, root, dependencies);
} catch (MalformedPackageURLException e) {
throw new IllegalArgumentException("Unable to parse NPM Json", e);
}
return sbom;
protected String pathEnv() {
return ENV_NODE_HOME;
}

private List<String> getIgnoredDeps(Path manifestPath) throws IOException {
var ignored = new ArrayList<String>();
var root = new ObjectMapper().readTree(Files.newInputStream(manifestPath));
var ignoredNode = root.withArray("exhortignore");
if (ignoredNode == null) {
return ignored;
}
for (JsonNode n : ignoredNode) {
ignored.add(n.asText());
}
return ignored;
}

Map<String, String> getNpmExecEnv() {
String nodeHome = Environment.get("NODE_HOME");
if (nodeHome != null && !nodeHome.isBlank()) {
String path = Environment.get(PROP_PATH);
if (path != null) {
return Collections.singletonMap(PROP_PATH, path + File.pathSeparator + nodeHome);
} else {
return Collections.singletonMap(PROP_PATH, nodeHome);
}
}
return null;
@Override
protected String[] updateLockFileCmd(Path manifestDir) {
return new String[] {
packageManager(), "i", "--package-lock-only", "--prefix", manifestDir.toString()
};
}

@Override
public void validateLockFile(Path lockFileDir) {
if (!Files.isRegularFile(lockFileDir.resolve("package-lock.json"))) {
throw new IllegalStateException(
"Lock file does not exist or is not supported. Execute 'npm install' to generate it.");
protected String[] listDepsCmd(boolean includeTransitive, Path manifestDir) {
if (manifestDir != null) {
return new String[] {
packageManager(),
"ls",
includeTransitive ? "--all" : "--depth=0",
"--omit=dev",
"--package-lock-only",
"--json",
"--prefix",
manifestDir.toString()
};
}
return new String[] {
packageManager(),
"ls",
includeTransitive ? "--all" : "--depth=0",
"--omit=dev",
"--package-lock-only",
"--json"
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/*
* Copyright © 2023 Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.redhat.exhort.providers;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.redhat.exhort.tools.Ecosystem;
import java.nio.file.Path;

/**
* Concrete implementation of the {@link JavaScriptProvider} used for converting dependency trees
* for pnpm projects (package.json) into a SBOM content for Stack analysis or Component analysis.
*/
public final class JavaScriptPnpmProvider extends JavaScriptProvider {

public static final String LOCK_FILE = "pnpm-lock.yaml";
public static final String CMD_NAME = "pnpm";
public static final String ENV_PNPM_HOME = "PNPM_HOME";

public JavaScriptPnpmProvider(Path manifest) {
super(manifest, Ecosystem.Type.PNPM, CMD_NAME);
}

@Override
protected final String lockFileName() {
return LOCK_FILE;
}

@Override
protected String pathEnv() {
return ENV_PNPM_HOME;
}

@Override
protected String[] updateLockFileCmd(Path manifestDir) {
return new String[] {
packageManager(), "install", "--frozen-lockfile", "--dir", manifestDir.toString()
};
}

@Override
protected String[] listDepsCmd(boolean includeTransitive, Path manifestDir) {
if (manifestDir != null) {
return new String[] {
packageManager(),
"ls",
"--dir",
manifestDir.toString(),
includeTransitive ? "--depth=Infinity" : "--depth=0",
"--prod",
"--json"
};
}
return new String[] {
packageManager(), "list", includeTransitive ? "--depth=-1" : "--depth=0", "--prod", "--json"
};
}

@Override
protected JsonNode buildDependencyTree(Path manifestPath, boolean includeTransitive)
throws JsonMappingException, JsonProcessingException {
var depTree = super.buildDependencyTree(manifestPath, includeTransitive);
return depTree.get(0);
}
}
Loading
Loading