View Javadoc
1   /*
2    * This file is part of dependency-check-core.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    *
8    *     http://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS,
12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13   * See the License for the specific language governing permissions and
14   * limitations under the License.
15   *
16   * Copyright (c) 2017 Steve Springett. All Rights Reserved.
17   */
18  package org.owasp.dependencycheck.analyzer;
19  
20  import com.github.packageurl.MalformedPackageURLException;
21  import com.github.packageurl.PackageURL;
22  import com.github.packageurl.PackageURL.StandardTypes;
23  import com.github.packageurl.PackageURLBuilder;
24  import org.semver4j.Semver;
25  import org.semver4j.SemverException;
26  import org.owasp.dependencycheck.Engine;
27  import org.owasp.dependencycheck.data.nodeaudit.Advisory;
28  import org.owasp.dependencycheck.data.nodeaudit.NodeAuditSearch;
29  import org.owasp.dependencycheck.dependency.Confidence;
30  import org.owasp.dependencycheck.dependency.Dependency;
31  import org.owasp.dependencycheck.dependency.Vulnerability;
32  import org.owasp.dependencycheck.dependency.VulnerableSoftware;
33  import org.owasp.dependencycheck.dependency.VulnerableSoftwareBuilder;
34  import org.owasp.dependencycheck.exception.InitializationException;
35  import org.owasp.dependencycheck.utils.InvalidSettingException;
36  import org.owasp.dependencycheck.utils.Settings;
37  import org.slf4j.Logger;
38  import org.slf4j.LoggerFactory;
39  import java.io.File;
40  import java.io.IOException;
41  import java.net.MalformedURLException;
42  import java.net.URL;
43  import java.util.Collection;
44  import java.util.List;
45  import java.util.Map;
46  import javax.annotation.concurrent.ThreadSafe;
47  import jakarta.json.Json;
48  import jakarta.json.JsonArray;
49  import jakarta.json.JsonObject;
50  import jakarta.json.JsonObjectBuilder;
51  import jakarta.json.JsonString;
52  import jakarta.json.JsonValue;
53  import jakarta.json.JsonValue.ValueType;
54  import org.apache.commons.collections4.MultiValuedMap;
55  import org.apache.commons.lang3.StringUtils;
56  import org.owasp.dependencycheck.analyzer.exception.AnalysisException;
57  import org.owasp.dependencycheck.analyzer.exception.UnexpectedAnalysisException;
58  import org.owasp.dependencycheck.data.nvd.ecosystem.Ecosystem;
59  import org.owasp.dependencycheck.dependency.EvidenceType;
60  import org.owasp.dependencycheck.dependency.naming.GenericIdentifier;
61  import org.owasp.dependencycheck.dependency.naming.Identifier;
62  import org.owasp.dependencycheck.dependency.naming.PurlIdentifier;
63  import org.owasp.dependencycheck.utils.Checksum;
64  import us.springett.parsers.cpe.exceptions.CpeValidationException;
65  import us.springett.parsers.cpe.values.Part;
66  
67  /**
68   * An abstract NPM analyzer that contains common methods for concrete
69   * implementations.
70   *
71   * @author Steve Springett
72   */
73  @ThreadSafe
74  public abstract class AbstractNpmAnalyzer extends AbstractFileTypeAnalyzer {
75  
76      /**
77       * The logger.
78       */
79      private static final Logger LOGGER = LoggerFactory.getLogger(AbstractNpmAnalyzer.class);
80  
81      /**
82       * A descriptor for the type of dependencies processed or added by this
83       * analyzer.
84       */
85      public static final String NPM_DEPENDENCY_ECOSYSTEM = Ecosystem.NODEJS;
86      /**
87       * The file name to scan.
88       */
89      private static final String PACKAGE_JSON = "package.json";
90  
91      /**
92       * The Node Audit Searcher.
93       */
94      private NodeAuditSearch searcher;
95  
96      /**
97       * Determines if the file can be analyzed by the analyzer.
98       *
99       * @param pathname the path to the file
100      * @return true if the file can be analyzed by the given analyzer; otherwise
101      * false
102      */
103     @Override
104     public boolean accept(File pathname) {
105         boolean accept = super.accept(pathname);
106         if (accept) {
107             try {
108                 accept = shouldProcess(pathname);
109             } catch (AnalysisException ex) {
110                 throw new UnexpectedAnalysisException(ex.getMessage(), ex.getCause());
111             }
112         }
113         return accept;
114     }
115 
116     /**
117      * Determines if the path contains "/node_modules/" or "/bower_components/"
118      * (i.e. it is a child module). This analyzer does not scan child modules.
119      *
120      * @param pathname the path to test
121      * @return <code>true</code> if the path does not contain "/node_modules/"
122      * or "/bower_components/"
123      * @throws AnalysisException thrown if the canonical path cannot be obtained
124      * from the given file
125      */
126     public static boolean shouldProcess(File pathname) throws AnalysisException {
127         try {
128             // Do not scan the node_modules (or bower_components) directory
129             final String canonicalPath = pathname.getCanonicalPath();
130             if (canonicalPath.contains(File.separator + "node_modules" + File.separator)
131                     || canonicalPath.contains(File.separator + "bower_components" + File.separator)) {
132                 LOGGER.debug("Skipping analysis of node/bower module: {}", canonicalPath);
133                 return false;
134             }
135         } catch (IOException ex) {
136             throw new AnalysisException("Unable to process dependency", ex);
137         }
138         return true;
139     }
140 
141     /**
142      * Construct a dependency object.
143      *
144      * @param dependency the parent dependency
145      * @param name the name of the dependency to create
146      * @param version the version of the dependency to create
147      * @param scope the scope of the dependency being created
148      * @return the generated dependency
149      */
150     protected Dependency createDependency(Dependency dependency, String name, String version, String scope) {
151         final Dependency nodeModule = new Dependency(new File(dependency.getActualFile() + "?" + name), true);
152         nodeModule.setEcosystem(NPM_DEPENDENCY_ECOSYSTEM);
153         //this is virtual - the sha1 is purely for the hyperlink in the final html report
154         nodeModule.setSha1sum(Checksum.getSHA1Checksum(String.format("%s:%s", name, version)));
155         nodeModule.setSha256sum(Checksum.getSHA256Checksum(String.format("%s:%s", name, version)));
156         nodeModule.setMd5sum(Checksum.getMD5Checksum(String.format("%s:%s", name, version)));
157         nodeModule.addEvidence(EvidenceType.PRODUCT, "package.json", "name", name, Confidence.HIGHEST);
158         nodeModule.addEvidence(EvidenceType.VENDOR, "package.json", "name", name, Confidence.HIGH);
159         if (!StringUtils.isBlank(version)) {
160             nodeModule.addEvidence(EvidenceType.VERSION, "package.json", "version", version, Confidence.HIGHEST);
161             nodeModule.setVersion(version);
162         }
163         if (dependency.getName() != null) {
164             nodeModule.addProjectReference(dependency.getName() + ": " + scope);
165         } else {
166             nodeModule.addProjectReference(dependency.getDisplayFileName() + ": " + scope);
167         }
168         nodeModule.setName(name);
169 
170         //TODO  - we can likely create a valid CPE as a low confidence guess using cpe:2.3:a:[name]_project:[name]:[version]
171         //(and add a targetSw of npm/node)
172         Identifier id;
173         try {
174             final PackageURL purl = PackageURLBuilder.aPackageURL().withType(StandardTypes.NPM)
175                     .withName(name).withVersion(version).build();
176             id = new PurlIdentifier(purl, Confidence.HIGHEST);
177         } catch (MalformedPackageURLException ex) {
178             LOGGER.debug("Unable to generate Purl - using a generic identifier instead " + ex.getMessage());
179             id = new GenericIdentifier(String.format("npm:%s@%s", dependency.getName(), version), Confidence.HIGHEST);
180         }
181         nodeModule.addSoftwareIdentifier(id);
182         return nodeModule;
183     }
184 
185     /**
186      * Processes a part of package.json (as defined by JsonArray) and update the
187      * specified dependency with relevant info.
188      *
189      * @param engine the dependency-check engine
190      * @param dependency the Dependency to update
191      * @param jsonArray the jsonArray to parse
192      * @param depType the dependency type
193      */
194     protected void processPackage(Engine engine, Dependency dependency, JsonArray jsonArray, String depType) {
195         final JsonObjectBuilder builder = Json.createObjectBuilder();
196         jsonArray.getValuesAs(JsonString.class).forEach((str) -> builder.add(str.toString(), ""));
197         final JsonObject jsonObject = builder.build();
198         processPackage(engine, dependency, jsonObject, depType);
199     }
200 
201     /**
202      * Processes a part of package.json (as defined by JsonObject) and update
203      * the specified dependency with relevant info.
204      *
205      * @param engine the dependency-check engine
206      * @param dependency the Dependency to update
207      * @param jsonObject the jsonObject to parse
208      * @param depType the dependency type
209      */
210     protected void processPackage(Engine engine, Dependency dependency, JsonObject jsonObject, String depType) {
211         for (int i = 0; i < jsonObject.size(); i++) {
212             jsonObject.forEach((name, value) -> {
213                 String version = "";
214                 if (value != null && value.getValueType() == ValueType.STRING) {
215                     version = ((JsonString) value).getString();
216                 }
217                 final Dependency existing = findDependency(engine, name, version);
218                 if (existing == null) {
219                     final Dependency nodeModule = createDependency(dependency, name, version, depType);
220                     engine.addDependency(nodeModule);
221                 } else {
222                     existing.addProjectReference(dependency.getName() + ": " + depType);
223                 }
224             });
225         }
226     }
227 
228     /**
229      * Adds information to an evidence collection from the node json
230      * configuration.
231      *
232      * @param dep the dependency to add the evidence
233      * @param t the type of evidence to add
234      * @param json information from node.js
235      * @return the actual string set into evidence
236      * @param key the key to obtain the data from the json information
237      */
238     private static String addToEvidence(Dependency dep, EvidenceType t, JsonObject json, String key) {
239         String evidenceStr = null;
240         if (json.containsKey(key)) {
241             final JsonValue value = json.get(key);
242             if (value instanceof JsonString) {
243                 evidenceStr = ((JsonString) value).getString();
244                 dep.addEvidence(t, PACKAGE_JSON, key, evidenceStr, Confidence.HIGHEST);
245             } else if (value instanceof JsonObject) {
246                 final JsonObject jsonObject = (JsonObject) value;
247                 for (final Map.Entry<String, JsonValue> entry : jsonObject.entrySet()) {
248                     final String property = entry.getKey();
249                     final JsonValue subValue = entry.getValue();
250                     if (subValue instanceof JsonString) {
251                         evidenceStr = ((JsonString) subValue).getString();
252                         dep.addEvidence(t, PACKAGE_JSON,
253                                 String.format("%s.%s", key, property),
254                                 evidenceStr,
255                                 Confidence.HIGHEST);
256                     } else {
257                         LOGGER.warn("JSON sub-value not string as expected: {}", subValue);
258                     }
259                 }
260             } else if (value instanceof JsonArray) {
261                 final JsonArray jsonArray = (JsonArray) value;
262                 jsonArray.forEach(entry -> {
263                     if (entry instanceof JsonObject) {
264                         ((JsonObject) entry).keySet().forEach(item -> {
265                             final JsonValue v = ((JsonObject) entry).get(item);
266                             if (v instanceof JsonString) {
267                                 final String eStr = ((JsonString) v).getString();
268                                 dep.addEvidence(t, PACKAGE_JSON,
269                                         String.format("%s.%s", key, item),
270                                         eStr,
271                                         Confidence.HIGHEST);
272                             }
273                         });
274                     }
275                 });
276             } else {
277                 LOGGER.warn("JSON value not string or JSON object as expected: {}", value);
278             }
279         }
280         return evidenceStr;
281     }
282 
283     /**
284      * Locates the dependency from the list of dependencies that have been
285      * scanned by the engine.
286      *
287      * @param engine the dependency-check engine
288      * @param name the name of the dependency to find
289      * @param version the version of the dependency to find
290      * @return the identified dependency; otherwise null
291      */
292     protected Dependency findDependency(Engine engine, String name, String version) {
293         for (Dependency d : engine.getDependencies()) {
294             if (NPM_DEPENDENCY_ECOSYSTEM.equals(d.getEcosystem()) && name.equals(d.getName()) && version != null && d.getVersion() != null) {
295                 final String dependencyVersion = d.getVersion();
296                 if (DependencyBundlingAnalyzer.npmVersionsMatch(version, dependencyVersion)) {
297                     return d;
298                 }
299             }
300         }
301         return null;
302     }
303 
304     /**
305      * Collects evidence from the given JSON for the associated dependency.
306      *
307      * @param json the JSON that contains the evidence to collect
308      * @param dependency the dependency to add the evidence too
309      */
310     public void gatherEvidence(final JsonObject json, Dependency dependency) {
311         String displayName = null;
312         if (json.containsKey("name")) {
313             final Object value = json.get("name");
314             if (value instanceof JsonString) {
315                 final String valueString = ((JsonString) value).getString();
316                 displayName = valueString;
317                 dependency.setName(valueString);
318                 dependency.setPackagePath(valueString);
319                 dependency.addEvidence(EvidenceType.PRODUCT, PACKAGE_JSON, "name", valueString, Confidence.HIGHEST);
320                 dependency.addEvidence(EvidenceType.VENDOR, PACKAGE_JSON, "name", valueString, Confidence.HIGHEST);
321                 dependency.addEvidence(EvidenceType.VENDOR, PACKAGE_JSON, "name", valueString + "_project", Confidence.HIGHEST);
322             } else {
323                 LOGGER.warn("JSON value not string as expected: {}", value);
324             }
325         }
326         //TODO - if we start doing CPE analysis on node - we need to exclude description as it creates too many FP
327         final String desc = addToEvidence(dependency, EvidenceType.VENDOR, json, "description");
328         dependency.setDescription(desc);
329         String vendor = addToEvidence(dependency, EvidenceType.VENDOR, json, "author");
330         if (vendor == null) {
331             vendor = addToEvidence(dependency, EvidenceType.VENDOR, json, "maintainers");
332         } else {
333             addToEvidence(dependency, EvidenceType.VENDOR, json, "maintainers");
334         }
335         addToEvidence(dependency, EvidenceType.VENDOR, json, "homepage");
336         addToEvidence(dependency, EvidenceType.VENDOR, json, "bugs");
337 
338         final String version = addToEvidence(dependency, EvidenceType.VERSION, json, "version");
339         if (version != null) {
340             displayName = String.format("%s:%s", displayName, version);
341             dependency.setVersion(version);
342             dependency.setPackagePath(displayName);
343             Identifier id;
344             try {
345                 final PackageURL purl = PackageURLBuilder.aPackageURL()
346                         .withType(StandardTypes.NPM).withName(dependency.getName()).withVersion(version).build();
347                 id = new PurlIdentifier(purl, Confidence.HIGHEST);
348             } catch (MalformedPackageURLException ex) {
349                 LOGGER.debug("Unable to generate Purl - using a generic identifier instead " + ex.getMessage());
350                 id = new GenericIdentifier(String.format("npm:%s:%s", dependency.getName(), version), Confidence.HIGHEST);
351             }
352             dependency.addSoftwareIdentifier(id);
353         }
354         if (displayName != null) {
355             dependency.setDisplayFileName(displayName);
356             dependency.setPackagePath(displayName);
357         } else {
358             LOGGER.warn("Unable to determine package name or version for {}", dependency.getActualFilePath());
359             if (vendor != null && !vendor.isEmpty()) {
360                 dependency.setDisplayFileName(String.format("%s package.json", vendor));
361             }
362         }
363         // Adds the license if defined in package.json
364         if (json.containsKey("license")) {
365             final Object value = json.get("license");
366             if (value instanceof JsonString) {
367                 dependency.setLicense(json.getString("license"));
368             } else if (value instanceof JsonArray) {
369                 final JsonArray array = (JsonArray) value;
370                 final StringBuilder sb = new StringBuilder();
371                 boolean addComma = false;
372                 for (int x = 0; x < array.size(); x++) {
373                     if (!array.isNull(x)) {
374                         if (addComma) {
375                             sb.append(", ");
376                         } else {
377                             addComma = true;
378                         }
379                         if (ValueType.STRING == array.get(x).getValueType()) {
380                             sb.append(array.getString(x));
381                         } else {
382                             final JsonObject lo = array.getJsonObject(x);
383                             if (lo.containsKey("type") && !lo.isNull("type")
384                                     && lo.containsKey("url") && !lo.isNull("url")) {
385                                 final String license = String.format("%s (%s)", lo.getString("type"), lo.getString("url"));
386                                 sb.append(license);
387                             } else if (lo.containsKey("type") && !lo.isNull("type")) {
388                                 sb.append(lo.getString("type"));
389                             } else if (lo.containsKey("url") && !lo.isNull("url")) {
390                                 sb.append(lo.getString("url"));
391                             }
392                         }
393                     }
394                 }
395                 dependency.setLicense(sb.toString());
396             } else if (value instanceof JsonObject) {
397                 final JsonObject object = (JsonObject) value;
398                 if (object.containsKey("type") && !object.isNull("type")) {
399                     dependency.setLicense(object.getString("type"));
400                 }
401             }
402         }
403     }
404 
405     /**
406      * Initializes the analyzer once before any analysis is performed.
407      *
408      * @param engine a reference to the dependency-check engine
409      * @throws InitializationException if there's an error during initialization
410      */
411     @Override
412     protected void prepareFileTypeAnalyzer(Engine engine) throws InitializationException {
413         if (!isEnabled() || !getFilesMatched()) {
414             this.setEnabled(false);
415             return;
416         }
417         if (searcher == null) {
418             LOGGER.debug("Initializing {}", getName());
419             try {
420                 searcher = new NodeAuditSearch(getSettings());
421             } catch (MalformedURLException ex) {
422                 setEnabled(false);
423                 throw new InitializationException("The configured URL to NPM Audit API is malformed", ex);
424             }
425             try {
426                 final Settings settings = engine.getSettings();
427                 final boolean nodeEnabled = settings.getBoolean(Settings.KEYS.ANALYZER_NODE_PACKAGE_ENABLED);
428                 if (!nodeEnabled) {
429                     LOGGER.warn("The Node Package Analyzer has been disabled; the resulting report will only "
430                             + "contain the known vulnerable dependency - not a bill of materials for the node project.");
431                 }
432             } catch (InvalidSettingException ex) {
433                 throw new InitializationException("Unable to read configuration settings", ex);
434             }
435         }
436     }
437 
438     /**
439      * Processes the advisories creating the appropriate dependency objects and
440      * adding the resulting vulnerabilities.
441      *
442      * @param advisories a collection of advisories from npm
443      * @param engine a reference to the analysis engine
444      * @param dependency a reference to the package-lock.json dependency
445      * @param dependencyMap a collection of module/version pairs obtained from
446      * the package-lock file - used in case the advisories do not include a
447      * version number
448      * @throws CpeValidationException thrown when a CPE cannot be created
449      */
450     protected void processResults(final List<Advisory> advisories, Engine engine,
451             Dependency dependency, MultiValuedMap<String, String> dependencyMap)
452             throws CpeValidationException {
453         for (Advisory advisory : advisories) {
454             //Create a new vulnerability out of the advisory returned by nsp.
455             final Vulnerability vuln = new Vulnerability();
456             vuln.setDescription(advisory.getOverview());
457             vuln.setName(String.valueOf(advisory.getGhsaId()));
458             vuln.setUnscoredSeverity(advisory.getSeverity());
459             vuln.setCvssV3(advisory.getCvssV3());
460             vuln.setSource(Vulnerability.Source.NPM);
461             for (String cwe : advisory.getCwes()) {
462                 vuln.addCwe(cwe);
463             }
464             if (advisory.getReferences() != null) {
465                 final String[] references = advisory.getReferences().split("\\n");
466                 for (String reference : references) {
467                     if (reference.length() > 3) {
468                         String url = reference.substring(2);
469                         try {
470                             new URL(url);
471                         } catch (MalformedURLException ignored) {
472                             // reference is not a format-valid URL, so null it to make the reference be used as plaintext
473                             url = null;
474                         }
475                         vuln.addReference("NPM Advisory reference: ", url == null ? reference : url, url);
476                     }
477                 }
478             }
479 
480             //Create a single vulnerable software object - these do not use CPEs unlike the NVD.
481             final VulnerableSoftwareBuilder builder = new VulnerableSoftwareBuilder();
482             builder.part(Part.APPLICATION).product(advisory.getModuleName().replace(" ", "_"))
483                     .version(advisory.getVulnerableVersions().replace(" ", ""));
484             final VulnerableSoftware vs = builder.build();
485             vuln.addVulnerableSoftware(vs);
486 
487             String version = advisory.getVersion();
488             if (version == null && dependencyMap.containsKey(advisory.getModuleName())) {
489                 version = determineVersionFromMap(advisory.getVulnerableVersions(), dependencyMap.get(advisory.getModuleName()));
490             }
491             final Dependency existing = findDependency(engine, advisory.getModuleName(), version);
492             if (existing == null) {
493                 final Dependency nodeModule = createDependency(dependency, advisory.getModuleName(), version, "transitive");
494                 nodeModule.addVulnerability(vuln);
495                 engine.addDependency(nodeModule);
496             } else {
497                 replaceOrAddVulnerability(existing, vuln);
498             }
499         }
500     }
501 
502     /**
503      * Evaluates if the vulnerability is already present; if it is the
504      * vulnerability is not added.
505      *
506      * @param dependency a reference to the dependency being analyzed
507      * @param vuln the vulnerability to add
508      */
509     protected void replaceOrAddVulnerability(Dependency dependency, Vulnerability vuln) {
510         final boolean found = vuln.getSource() == Vulnerability.Source.NPM
511                 && dependency.getVulnerabilities().stream().anyMatch(existing -> {
512                     return existing.getReferences().stream().anyMatch(ref -> {
513                         return ref.getName() != null
514                                 && ref.getName().equals("https://nodesecurity.io/advisories/" + vuln.getName());
515                     });
516                 });
517         if (!found) {
518             dependency.addVulnerability(vuln);
519         }
520     }
521 
522     /**
523      * Returns the node audit search utility.
524      *
525      * @return the node audit search utility
526      */
527     protected NodeAuditSearch getSearcher() {
528         return searcher;
529     }
530 
531     /**
532      * Give an NPM version range and a collection of versions, this method
533      * attempts to select a specific version from the collection that is in the
534      * range.
535      *
536      * @param versionRange the version range to evaluate
537      * @param availableVersions the collection of possible versions to select
538      * @return the selected range from the versionRange
539      */
540     public static String determineVersionFromMap(String versionRange, Collection<String> availableVersions) {
541         if (availableVersions.size() == 1) {
542             return availableVersions.iterator().next();
543         }
544         for (String v : availableVersions) {
545             try {
546                 final Semver version = new Semver(v);
547                 if (version.satisfies(versionRange)) {
548                     return v;
549                 }
550             } catch (SemverException ex) {
551                 LOGGER.debug("invalid semver: " + v);
552             }
553         }
554         return availableVersions.iterator().next();
555     }
556 }