Skip to content

Commit

Permalink
Find and fix vulnerable npm packages (#8)
Browse files Browse the repository at this point in the history
* Parse vulnerabilities into local csv file

* Detect and bump NPM dependency versions

* Some minor comments to document what's done

* Parse a single advisory file in tests due to ordering

* Additional tests to document current behavior

* Find all vulnerabilities, but only bump patch versions

* Minor polish to get rid of warnings

* Apply suggestions from code review

Co-authored-by: Shannon Pamperl <[email protected]>

* Apply suggestions from code review

* Apply suggestions from code review

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* Keep parsing advisories in rewrite-java-dependencies

---------

Co-authored-by: Shannon Pamperl <[email protected]>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
  • Loading branch information
3 people authored Aug 4, 2024
1 parent 11f46ea commit d52c381
Show file tree
Hide file tree
Showing 7 changed files with 4,730 additions and 8 deletions.
19 changes: 11 additions & 8 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,14 @@ plugins {
group = "org.openrewrite.recipe"
description = "Rewrite Node.js."

val rewriteVersion = if(project.hasProperty("releasing")) {
"latest.release"
} else {
"latest.integration"
}

val rewriteVersion = rewriteRecipe.rewriteVersion.get()
dependencies {
implementation("org.openrewrite:rewrite-json:${rewriteVersion}")
implementation("org.openrewrite:rewrite-core:${rewriteVersion}")
implementation(platform("org.openrewrite:rewrite-bom:$rewriteVersion"))
implementation("org.openrewrite:rewrite-json")
implementation("org.openrewrite:rewrite-core")

implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-csv")
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310")
}

configure<PublishingExtension> {
Expand All @@ -32,3 +31,7 @@ publishing {
}
}
}

license {
exclude("**/*.json")
}
6 changes: 6 additions & 0 deletions lombok.config
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# https://projectlombok.org/features/configuration
config.stopBubbling = true
lombok.addNullAnnotations = CUSTOM:org.openrewrite.internal.lang.NonNull:org.openrewrite.internal.lang.Nullable
lombok.copyableAnnotations += org.openrewrite.internal.lang.Nullable
lombok.copyableAnnotations += org.openrewrite.internal.lang.NonNull
lombok.anyConstructor.addConstructorProperties=true
169 changes: 169 additions & 0 deletions src/main/java/org/openrewrite/nodejs/DependencyVulnerabilityCheck.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
/*
* 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.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.Objects.requireNonNull;

@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 {
/**
* The name of the package as specified in the package.json.
*/
String name;

/**
* The resolved version actually in use, which may be different from the version specified in the package.json.
*/
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 ctx) {
NodeResolutionResult nodeResolutionResult = NodeResolutionResult.fromPackageLockJson(document);
// Find all vulnerable dependencies and add them to the accumulator
findVulnerabilities(nodeResolutionResult.getDependencies());
findVulnerabilities(nodeResolutionResult.getDevDependencies());
return document;
}

private void findVulnerabilities(Collection<Dependency> dependencies) {
for (Dependency dependency : dependencies) {
for (Vulnerability v : acc.getDb().getOrDefault(dependency.getName(), Collections.emptyList())) {
String resolvedVersion = dependency.getResolved() == null ? null : dependency.getResolved().getVersion();
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 fixWithPatchVersionUpdateOnly = new LatestPatch(null)
.isValid(nameVersion.getVersion(), vuln.getFixedVersion());
// Insert a row into the report for each vulnerability
report.insertRow(ctx, new VulnerabilityReport.Row(
vuln.getCve(),
nameVersion.getName(),
nameVersion.getVersion(),
vuln.getFixedVersion(),
fixWithPatchVersionUpdateOnly,
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);
Map<Accumulator.NameVersion, Set<Vulnerability>> vulnerabilities = acc.getVulnerabilities();
Set<Map.Entry<Accumulator.NameVersion, Set<Vulnerability>>> entries = vulnerabilities.entrySet();
for (Map.Entry<Accumulator.NameVersion, Set<Vulnerability>> entry : entries) {
Set<Vulnerability> vs = entry.getValue();
for (Vulnerability vulnerability : vs) {
boolean fixWithPatchVersionUpdateOnly = new LatestPatch(null)
.isValid(entry.getKey().getVersion(), vulnerability.getFixedVersion());
if (fixWithPatchVersionUpdateOnly) {
d = (Json.Document) new UpgradeDependencyVersion(
vulnerability.getPackageName(),
'^' + vulnerability.getFixedVersion())
.getVisitor().visitNonNull(d, ctx, requireNonNull(getCursor().getParent()));
}
}
}
return d;
}
});
}
}
48 changes: 48 additions & 0 deletions src/main/java/org/openrewrite/nodejs/Vulnerability.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/*
* 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.annotation.JsonPropertyOrder;
import lombok.EqualsAndHashCode;
import lombok.Value;

import java.time.ZonedDateTime;

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

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

public enum Severity {
LOW,
MODERATE,
HIGH,
CRITICAL
}
}
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;
}
}
Loading

0 comments on commit d52c381

Please sign in to comment.