Skip to content

Commit

Permalink
Detect and bump NPM dependency versions
Browse files Browse the repository at this point in the history
  • Loading branch information
timtebeek committed Aug 3, 2024
1 parent e34ae08 commit f3a1be2
Show file tree
Hide file tree
Showing 5 changed files with 340 additions and 5 deletions.
161 changes: 161 additions & 0 deletions src/main/java/org/openrewrite/nodejs/DependencyVulnerabilityCheck.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
/*
* Copyright 2021 the original author or authors.
* <p>
* 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
* <p>
* https://www.apache.org/licenses/LICENSE-2.0
* <p>
* 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 org.openrewrite.nodejs;

import com.fasterxml.jackson.databind.MappingIterator;
import com.fasterxml.jackson.dataformat.csv.CsvMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import lombok.EqualsAndHashCode;
import lombok.Value;
import org.openrewrite.*;
import org.openrewrite.json.JsonIsoVisitor;
import org.openrewrite.json.tree.Json;
import org.openrewrite.nodejs.github.Vulnerability;
import org.openrewrite.nodejs.search.IsPackageJson;
import org.openrewrite.nodejs.search.IsPackageLockJson;
import org.openrewrite.nodejs.table.VulnerabilityReport;
import org.openrewrite.semver.LatestPatch;

import java.io.IOException;
import java.io.InputStream;
import java.util.*;

import static java.util.stream.Collectors.toList;

@Value
@EqualsAndHashCode(callSuper = false)
public class DependencyVulnerabilityCheck extends ScanningRecipe<DependencyVulnerabilityCheck.Accumulator> {
transient VulnerabilityReport report = new VulnerabilityReport(this);

@Override
public String getDisplayName() {
return "Find and fix vulnerable npm dependencies";
}

@Override
public String getDescription() {
//language=markdown
return "This software composition analysis (SCA) tool detects and upgrades dependencies with publicly disclosed vulnerabilities. " +
"This recipe both generates a report of vulnerable dependencies and upgrades to newer versions with fixes. " +
"This recipe **only** upgrades to the latest **patch** version. If a minor or major upgrade is required to reach the fixed version, this recipe will not make any changes. " +
"Vulnerability information comes from the [GitHub Security Advisory Database](https://docs.github.com/en/code-security/security-advisories/global-security-advisories/about-the-github-advisory-database), " +
"which aggregates vulnerability data from several public databases, including the [National Vulnerability Database](https://nvd.nist.gov/) maintained by the United States government. " +
"Dependencies following [Semantic Versioning](https://semver.org/) will see their _patch_ version updated where applicable.";
}

@Value
public static class Accumulator {
Map<String, List<Vulnerability>> db;
Map<NameVersion, Set<Vulnerability>> vulnerabilities;

@Value
static class NameVersion {
String name;
String version;
}
}

@Override
public Accumulator getInitialValue(ExecutionContext ctx) {
CsvMapper csvMapper = new CsvMapper();
csvMapper.registerModule(new JavaTimeModule());
Map<String, List<Vulnerability>> db = new HashMap<>();

try (InputStream resourceAsStream = DependencyVulnerabilityCheck.class.getResourceAsStream("/advisories.csv");
MappingIterator<Vulnerability> vs = csvMapper.readerWithSchemaFor(Vulnerability.class).readValues(resourceAsStream)) {
while (vs.hasNextValue()) {
Vulnerability v = vs.nextValue();
db.computeIfAbsent(v.getPackageName(), g -> new ArrayList<>()).add(v);
}
} catch (IOException e) {
throw new RuntimeException(e);
}

return new Accumulator(db, new HashMap<>());
}

@Override
public TreeVisitor<?, ExecutionContext> getScanner(Accumulator acc) {
return Preconditions.check(new IsPackageLockJson<>(), new JsonIsoVisitor<ExecutionContext>() {
@Override
public Json.Document visitDocument(Json.Document document, ExecutionContext executionContext) {
NodeResolutionResult nodeResolutionResult = NodeResolutionResult.fromPackageLockJson(document);
findVulnerabilities(nodeResolutionResult.getDependencies());
findVulnerabilities(nodeResolutionResult.getDevDependencies());
// No need to visit further, we've already found all the vulnerabilities
return document;
}

private void findVulnerabilities(Collection<Dependency> dependencies) {
for (Dependency dependency : dependencies) {
List<Vulnerability> vs = acc.getDb().get(dependency.getName());
if (vs != null) {
for (Vulnerability v : vs) {
String resolvedVersion = dependency.getResolved() == null ? null : dependency.getResolved().getVersion();
boolean isLessThanFixed = new LatestPatch(null)
.isValid(resolvedVersion, v.getFixedVersion());
if (isLessThanFixed) {
acc.getVulnerabilities()
.computeIfAbsent(new Accumulator.NameVersion(dependency.getName(), resolvedVersion), nv -> new HashSet<>())
.add(v);
}
}
}
}
}
});
}

@Override
public Collection<SourceFile> generate(Accumulator acc, ExecutionContext ctx) {
for (Map.Entry<Accumulator.NameVersion, Set<Vulnerability>> vulnerabilitiesByPackage : acc.getVulnerabilities().entrySet()) {
Accumulator.NameVersion nameVersion = vulnerabilitiesByPackage.getKey();
for (Vulnerability vuln : vulnerabilitiesByPackage.getValue()) {
boolean fixWithVersionUpdateOnly = new LatestPatch(null)
.isValid(nameVersion.getVersion(), vuln.getFixedVersion());
report.insertRow(ctx, new VulnerabilityReport.Row(
vuln.getCve(),
nameVersion.getName(),
nameVersion.getVersion(),
vuln.getFixedVersion(),
fixWithVersionUpdateOnly,
vuln.getSummary(),
vuln.getSeverity().toString(),
vuln.getCwes()
));
}
}
return Collections.emptyList();
}

@Override
public TreeVisitor<?, ExecutionContext> getVisitor(Accumulator acc) {
return Preconditions.check(new IsPackageJson<>(), new JsonIsoVisitor<ExecutionContext>() {
@Override
public Json.Document visitDocument(Json.Document document, ExecutionContext ctx) {
Json.Document d = super.visitDocument(document, ctx);
for (Vulnerability vulnerability : acc.getVulnerabilities().values().stream()
.flatMap(Collection::stream).collect(toList())) {
d = (Json.Document) new UpgradeDependencyVersion(
vulnerability.getPackageName(),
'^' + vulnerability.getFixedVersion())
.getVisitor().visitNonNull(d, ctx, getCursor());
}
return d;
}
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
import com.fasterxml.jackson.dataformat.csv.CsvMapper;
import com.fasterxml.jackson.dataformat.csv.CsvSchema;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.openrewrite.nodejs.Vulnerability;
import org.openrewrite.nodejs.github.advisories.Advisory;
import org.openrewrite.nodejs.github.advisories.Affected;
import org.openrewrite.nodejs.github.advisories.Range;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,29 +13,31 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.openrewrite.nodejs;
package org.openrewrite.nodejs.github;

import com.fasterxml.jackson.annotation.JsonPropertyOrder;
import lombok.EqualsAndHashCode;
import lombok.Value;

import java.time.ZonedDateTime;

@Value
@EqualsAndHashCode(onlyExplicitlyIncluded = false)
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@JsonPropertyOrder({"cve", "published", "summary", "packageName", "introducedVersion", "fixedVersion", "severity", "cwes"})
public class Vulnerability {
@EqualsAndHashCode.Include
String cve;

ZonedDateTime published;
String summary;
String groupArtifact;
String packageName;
String introducedVersion;
String fixedVersion;
Severity severity;
/**
* Common Weakness Enumerations are semicolon separated.
*/
String CWEs;
String cwes;

public enum Severity {
LOW,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
* Copyright 2024 the original author or authors.
* <p>
* 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
* <p>
* https://www.apache.org/licenses/LICENSE-2.0
* <p>
* 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 org.openrewrite.nodejs.table;

import com.fasterxml.jackson.annotation.JsonIgnoreType;
import lombok.Value;
import org.openrewrite.Column;
import org.openrewrite.DataTable;
import org.openrewrite.Recipe;

@JsonIgnoreType
public class VulnerabilityReport extends DataTable<VulnerabilityReport.Row> {

public VulnerabilityReport(Recipe recipe) {
super(recipe,
"Vulnerability report",
"A vulnerability report that includes detailed information about the affected artifact and the corresponding CVEs.");
}

@Value
public static class Row {
@Column(displayName = "CVE",
description = "The CVE number.")
String cve;

@Column(displayName = "Package name",
description = "The package name.")
String packageName;

@Column(displayName = "Version",
description = "The resolved version.")
String version;

@Column(displayName = "Fixed in version",
description = "The minimum version that is no longer vulnerable.")
String fixedVersion;

@Column(displayName = "Fixable with version update only",
//language=markdown
description = "Whether the vulnerability is likely to be fixed by increasing the dependency version only, " +
"with no code modifications required. This is a heuristic which assumes that the dependency " +
"is accurately versioned according to [semver](https://semver.org/).")
boolean fixWithVersionUpdateOnly;

@Column(displayName = "Summary",
description = "The summary of the CVE.")
String summary;

@Column(displayName = "Base score",
description = "The calculated base score.")
String severity;

@Column(displayName = "CWEs",
description = "Common Weakness Enumeration (CWE) identifiers; semicolon separated.")
String CWEs;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/*
* Copyright 2024 the original author or authors.
* <p>
* 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
* <p>
* https://www.apache.org/licenses/LICENSE-2.0
* <p>
* 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 org.openrewrite.nodejs;

import org.junit.jupiter.api.Test;
import org.openrewrite.DocumentExample;
import org.openrewrite.nodejs.table.VulnerabilityReport;
import org.openrewrite.test.RecipeSpec;
import org.openrewrite.test.RewriteTest;

import static org.assertj.core.api.Assertions.assertThat;
import static org.openrewrite.json.Assertions.json;

class DependencyVulnerabilityCheckTest implements RewriteTest {

@Override
public void defaults(RecipeSpec spec) {
spec.recipe(new DependencyVulnerabilityCheck());
}

@Test
@DocumentExample
void shouldUpgradePatchVersion(){
rewriteRun(
spec -> spec.dataTable(VulnerabilityReport.Row.class, rows ->
//CVE-2010-2273,2019-09-11T23:02:57Z,"Cross-Site Scripting in dojo",dojo,1.10.0,1.10.10,MODERATE,CWE-79
assertThat(rows).singleElement().satisfies(row -> {
assertThat(row.getCve()).isEqualTo("CVE-2010-2273");
assertThat(row.getSummary()).isEqualTo("Cross-Site Scripting in dojo");
assertThat(row.getPackageName()).isEqualTo("dojo");
assertThat(row.getVersion()).isEqualTo("1.10.5");
assertThat(row.getFixedVersion()).isEqualTo("1.10.10");
assertThat(row.getSeverity()).isEqualTo("MODERATE");
assertThat(row.getCWEs()).isEqualTo("CWE-79");
})),
json(
//language=json
"""
{
"name": "example",
"version": "1.0.0",
"dependencies": {
"dojo": "^1.10.0"
}
}
""",
//language=json
"""
{
"name": "example",
"version": "1.0.0",
"dependencies": {
"dojo": "^1.10.10"
}
}
""",
spec -> spec.path("package.json")
),
json(
//language=json
"""
{
"name": "example",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "example",
"version": "1.0.0",
"dependencies": {
"dojo": "^1.10.0"
}
},
"node_modules/dojo": {
"version": "1.10.5",
"resolved": "https://something",
"integrity": "c29tZXRoaW5n",
"engines": {
"node": ">=18"
}
}
}
}
""",
spec -> spec.path("package-lock.json")
)
);
}
}

0 comments on commit f3a1be2

Please sign in to comment.