From f3a1be23f250d826ab05628f63cb4d3ece2a2067 Mon Sep 17 00:00:00 2001 From: Tim te Beek Date: Sun, 4 Aug 2024 00:01:50 +0200 Subject: [PATCH] Detect and bump NPM dependency versions --- .../nodejs/DependencyVulnerabilityCheck.java | 161 ++++++++++++++++++ .../nodejs/github/ParseAdvisories.java | 1 - .../nodejs/{ => github}/Vulnerability.java | 10 +- .../nodejs/table/VulnerabilityReport.java | 70 ++++++++ .../DependencyVulnerabilityCheckTest.java | 103 +++++++++++ 5 files changed, 340 insertions(+), 5 deletions(-) create mode 100644 src/main/java/org/openrewrite/nodejs/DependencyVulnerabilityCheck.java rename src/main/java/org/openrewrite/nodejs/{ => github}/Vulnerability.java (77%) create mode 100644 src/main/java/org/openrewrite/nodejs/table/VulnerabilityReport.java create mode 100644 src/test/java/org/openrewrite/nodejs/DependencyVulnerabilityCheckTest.java diff --git a/src/main/java/org/openrewrite/nodejs/DependencyVulnerabilityCheck.java b/src/main/java/org/openrewrite/nodejs/DependencyVulnerabilityCheck.java new file mode 100644 index 0000000..b9e3986 --- /dev/null +++ b/src/main/java/org/openrewrite/nodejs/DependencyVulnerabilityCheck.java @@ -0,0 +1,161 @@ +/* + * Copyright 2021 the original author or authors. + *

+ * 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 + *

+ * https://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 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 { + 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> db; + Map> vulnerabilities; + + @Value + static class NameVersion { + String name; + String version; + } + } + + @Override + public Accumulator getInitialValue(ExecutionContext ctx) { + CsvMapper csvMapper = new CsvMapper(); + csvMapper.registerModule(new JavaTimeModule()); + Map> db = new HashMap<>(); + + try (InputStream resourceAsStream = DependencyVulnerabilityCheck.class.getResourceAsStream("/advisories.csv"); + MappingIterator 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 getScanner(Accumulator acc) { + return Preconditions.check(new IsPackageLockJson<>(), new JsonIsoVisitor() { + @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 dependencies) { + for (Dependency dependency : dependencies) { + List 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 generate(Accumulator acc, ExecutionContext ctx) { + for (Map.Entry> 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 getVisitor(Accumulator acc) { + return Preconditions.check(new IsPackageJson<>(), new JsonIsoVisitor() { + @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; + } + }); + } +} diff --git a/src/main/java/org/openrewrite/nodejs/github/ParseAdvisories.java b/src/main/java/org/openrewrite/nodejs/github/ParseAdvisories.java index 24cabd3..f88a6e9 100644 --- a/src/main/java/org/openrewrite/nodejs/github/ParseAdvisories.java +++ b/src/main/java/org/openrewrite/nodejs/github/ParseAdvisories.java @@ -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; diff --git a/src/main/java/org/openrewrite/nodejs/Vulnerability.java b/src/main/java/org/openrewrite/nodejs/github/Vulnerability.java similarity index 77% rename from src/main/java/org/openrewrite/nodejs/Vulnerability.java rename to src/main/java/org/openrewrite/nodejs/github/Vulnerability.java index b1824dd..aba0230 100644 --- a/src/main/java/org/openrewrite/nodejs/Vulnerability.java +++ b/src/main/java/org/openrewrite/nodejs/github/Vulnerability.java @@ -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, diff --git a/src/main/java/org/openrewrite/nodejs/table/VulnerabilityReport.java b/src/main/java/org/openrewrite/nodejs/table/VulnerabilityReport.java new file mode 100644 index 0000000..d1c51ef --- /dev/null +++ b/src/main/java/org/openrewrite/nodejs/table/VulnerabilityReport.java @@ -0,0 +1,70 @@ +/* + * Copyright 2024 the original author or authors. + *

+ * 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 + *

+ * https://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 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 { + + 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; + } +} diff --git a/src/test/java/org/openrewrite/nodejs/DependencyVulnerabilityCheckTest.java b/src/test/java/org/openrewrite/nodejs/DependencyVulnerabilityCheckTest.java new file mode 100644 index 0000000..f57f270 --- /dev/null +++ b/src/test/java/org/openrewrite/nodejs/DependencyVulnerabilityCheckTest.java @@ -0,0 +1,103 @@ +/* + * Copyright 2024 the original author or authors. + *

+ * 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 + *

+ * https://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 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") + ) + ); + } +} \ No newline at end of file