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.esotericsoftware.minlog.Log;
21  import com.github.packageurl.MalformedPackageURLException;
22  import com.github.packageurl.PackageURLBuilder;
23  import com.google.common.annotations.VisibleForTesting;
24  import com.h3xstream.retirejs.repo.JsLibraryResult;
25  import com.h3xstream.retirejs.repo.ScannerFacade;
26  import com.h3xstream.retirejs.repo.VulnerabilitiesRepository;
27  import com.h3xstream.retirejs.repo.VulnerabilitiesRepositoryLoader;
28  import org.apache.commons.io.IOUtils;
29  import org.apache.commons.lang3.StringUtils;
30  import org.apache.commons.validator.routines.UrlValidator;
31  import org.json.JSONException;
32  import org.jspecify.annotations.NonNull;
33  import org.jspecify.annotations.Nullable;
34  import org.owasp.dependencycheck.Engine;
35  import org.owasp.dependencycheck.analyzer.exception.AnalysisException;
36  import org.owasp.dependencycheck.data.nvd.ecosystem.Ecosystem;
37  import org.owasp.dependencycheck.data.nvdcve.DatabaseException;
38  import org.owasp.dependencycheck.data.update.RetireJSDataSource;
39  import org.owasp.dependencycheck.data.update.exception.UpdateException;
40  import org.owasp.dependencycheck.dependency.Confidence;
41  import org.owasp.dependencycheck.dependency.Dependency;
42  import org.owasp.dependencycheck.dependency.EvidenceType;
43  import org.owasp.dependencycheck.dependency.Reference;
44  import org.owasp.dependencycheck.dependency.Vulnerability;
45  import org.owasp.dependencycheck.dependency.naming.GenericIdentifier;
46  import org.owasp.dependencycheck.dependency.naming.Identifier;
47  import org.owasp.dependencycheck.dependency.naming.PurlIdentifier;
48  import org.owasp.dependencycheck.exception.InitializationException;
49  import org.owasp.dependencycheck.exception.WriteLockException;
50  import org.owasp.dependencycheck.utils.FileFilterBuilder;
51  import org.owasp.dependencycheck.utils.Settings;
52  import org.owasp.dependencycheck.utils.WriteLock;
53  import org.owasp.dependencycheck.utils.search.FileContentSearch;
54  import org.slf4j.Logger;
55  import org.slf4j.LoggerFactory;
56  
57  import javax.annotation.concurrent.ThreadSafe;
58  import java.io.File;
59  import java.io.FileFilter;
60  import java.io.FileInputStream;
61  import java.io.IOException;
62  import java.io.InputStream;
63  import java.nio.file.Files;
64  import java.nio.file.StandardCopyOption;
65  import java.util.HashSet;
66  import java.util.LinkedHashMap;
67  import java.util.List;
68  import java.util.Map;
69  import java.util.Objects;
70  import java.util.Optional;
71  import java.util.OptionalInt;
72  import java.util.Set;
73  import java.util.stream.Collectors;
74  
75  import static org.owasp.dependencycheck.analyzer.RetireJsLibrary.KnownIdentifierTypes.CVE;
76  import static org.owasp.dependencycheck.analyzer.RetireJsLibrary.KnownIdentifierTypes.GITHUB_SECURITY_ADVISORY;
77  import static org.owasp.dependencycheck.analyzer.RetireJsLibrary.KnownIdentifierTypes.SECONDARY_NAME_TYPES;
78  import static org.owasp.dependencycheck.analyzer.RetireJsLibrary.KnownIdentifierTypes.SUMMARY;
79  import static org.owasp.dependencycheck.analyzer.RetireJsLibrary.KnownIdentifierTypes.singleEntry;
80  import static org.owasp.dependencycheck.analyzer.RetireJsLibrary.KnownIdentifierTypes.singleItem;
81  
82  /**
83   * The RetireJS analyzer uses the manually curated list of vulnerabilities from
84   * the RetireJS community along with the necessary information to assist in
85   * identifying vulnerable components. Vulnerabilities documented by the RetireJS
86   * community usually originate from other sources such as the NVD, GHSA,
87   * and various issue trackers.
88   *
89   * @author Steve Springett
90   */
91  @ThreadSafe
92  public class RetireJsAnalyzer extends AbstractFileTypeAnalyzer {
93  
94      /**
95       * A descriptor for the type of dependencies processed or added by this
96       * analyzer.
97       */
98      public static final String DEPENDENCY_ECOSYSTEM = Ecosystem.JAVASCRIPT;
99      /**
100      * The logger.
101      */
102     private static final Logger LOGGER = LoggerFactory.getLogger(RetireJsAnalyzer.class);
103     /**
104      * The name of the analyzer.
105      */
106     private static final String ANALYZER_NAME = "RetireJS Analyzer";
107     /**
108      * The phase that this analyzer is intended to run in.
109      */
110     private static final AnalysisPhase ANALYSIS_PHASE = AnalysisPhase.FINDING_ANALYSIS;
111     /**
112      * The set of file extensions supported by this analyzer.
113      */
114     private static final String[] EXTENSIONS = {"js"};
115     /**
116      * The file filter used to determine which files this analyzer supports.
117      */
118     private static final FileFilter FILTER = FileFilterBuilder.newInstance().addExtensions(EXTENSIONS).build();
119     /**
120      * An instance of the local VulnerabilitiesRepository
121      */
122     private VulnerabilitiesRepository jsRepository;
123     /**
124      * The list of filters used to exclude files by file content; the intent is
125      * that this could be used to filter out a companies custom files by filter
126      * on their own copyright statements.
127      */
128     private String[] filters = null;
129 
130     /**
131      * Returns the FileFilter.
132      *
133      * @return the FileFilter
134      */
135     @Override
136     protected FileFilter getFileFilter() {
137         return FILTER;
138     }
139 
140     /**
141      * Determines if the file can be analyzed by the analyzer.
142      *
143      * @param pathname the path to the file
144      * @return true if the file can be analyzed by the given analyzer; otherwise
145      * false
146      */
147     @Override
148     public boolean accept(File pathname) {
149         try {
150             final boolean accepted = super.accept(pathname);
151             if (accepted && !pathname.exists()) {
152                 //file may not yet have been extracted from an archive
153                 super.setFilesMatched(true);
154                 return true;
155             }
156             if (accepted && filters != null && FileContentSearch.contains(pathname, filters)) {
157                 return false;
158             }
159             return accepted;
160         } catch (IOException ex) {
161             LOGGER.warn("Error testing file {}", pathname, ex);
162         }
163         return false;
164     }
165 
166     /**
167      * Initializes the analyzer with the configured settings.
168      *
169      * @param settings the configured settings to use
170      */
171     @Override
172     public void initialize(Settings settings) {
173         super.initialize(settings);
174         if (this.isEnabled()) {
175             this.filters = settings.getArray(Settings.KEYS.ANALYZER_RETIREJS_FILTERS);
176         }
177     }
178 
179     /**
180      * {@inheritDoc}
181      *
182      * @param engine a reference to the dependency-check engine
183      * @throws InitializationException thrown if there is an exception during
184      * initialization
185      */
186     @Override
187     protected void prepareFileTypeAnalyzer(Engine engine) throws InitializationException {
188         // RetireJS outputs a bunch of repeated output like the following for
189         // vulnerable dependencies, with little context:
190         //
191         // INFO: Vulnerability found: jquery below 1.6.3
192         //
193         // This logging is suppressed because it isn't particularly useful, and
194         // it aligns with other analyzers that don't log such information.
195         Log.set(Log.LEVEL_WARN);
196 
197         File repoFile = tryRemoteFetchIfConfigured(engine);
198 
199         try (WriteLock ignored = new WriteLock(getSettings(), true, repoFile.getName() + ".lock")) {
200             final File temp = getSettings().getTempDirectory();
201             final File tempRepo = new File(temp, repoFile.getName());
202             LOGGER.debug("copying RetireJS repo {} to {}", repoFile.toPath(), tempRepo.toPath());
203             Files.copy(repoFile.toPath(), tempRepo.toPath(), StandardCopyOption.REPLACE_EXISTING);
204             repoFile = tempRepo;
205         } catch (WriteLockException | IOException ex) {
206             this.setEnabled(false);
207             throw new InitializationException("Failed to copy the RetireJS repo", ex);
208         }
209         try (FileInputStream in = new FileInputStream(repoFile)) {
210             this.jsRepository = new VulnerabilitiesRepositoryLoader().loadFromInputStream(in);
211         } catch (JSONException ex) {
212             this.setEnabled(false);
213             throw new InitializationException("Failed to initialize the RetireJS repo: `" + repoFile
214                     + "` appears to be malformed. Please delete the file or run the dependency-check purge "
215                     + "command and re-try running dependency-check.", ex);
216         } catch (IOException ex) {
217             this.setEnabled(false);
218             throw new InitializationException("Failed to initialize the RetireJS repo", ex);
219         }
220     }
221 
222     private File tryRemoteFetchIfConfigured(Engine engine) throws InitializationException {
223         RetireJSDataSource ds = new RetireJSDataSource();
224         try {
225             ds.update(engine);
226             return ds.validatedRepoFile();
227         } catch (UpdateException ex) {
228             this.setEnabled(false);
229             throw new InitializationException("Failed to initialize the RetireJS repo", ex);
230         }
231     }
232 
233     /**
234      * Returns the name of the analyzer.
235      *
236      * @return the name of the analyzer.
237      */
238     @Override
239     public String getName() {
240         return ANALYZER_NAME;
241     }
242 
243     /**
244      * Returns the phase that the analyzer is intended to run in.
245      *
246      * @return the phase that the analyzer is intended to run in.
247      */
248     @Override
249     public AnalysisPhase getAnalysisPhase() {
250         return ANALYSIS_PHASE;
251     }
252 
253     /**
254      * Returns the key used in the properties file to reference the analyzer's
255      * enabled property.
256      *
257      * @return the analyzer's enabled property setting key
258      */
259     @Override
260     protected String getAnalyzerEnabledSettingKey() {
261         return Settings.KEYS.ANALYZER_RETIREJS_ENABLED;
262     }
263 
264     /**
265      * Analyzes the specified JavaScript file.
266      *
267      * @param dependency the dependency to analyze.
268      * @param engine     the engine that is scanning the dependencies
269      * @throws AnalysisException is thrown if there is an error reading the file
270      */
271     @Override
272     public void analyzeDependency(Dependency dependency, Engine engine) throws AnalysisException {
273         if (dependency.isVirtual()) {
274             return;
275         }
276         try (InputStream fis = new FileInputStream(dependency.getActualFile())) {
277             final List<RetireJsLibrary> vulnerableLibraries = new ScannerFacade(jsRepository)
278                     .scanScript(dependency.getActualFile().getAbsolutePath(), IOUtils.toByteArray(fis), 0)
279                     .stream().map(RetireJsLibrary::adapt).collect(Collectors.toList());
280 
281             if (vulnerableLibraries.isEmpty() && getSettings().getBoolean(Settings.KEYS.ANALYZER_RETIREJS_FILTER_NON_VULNERABLE, false)) {
282                 engine.removeDependency(dependency);
283                 return;
284             }
285 
286             for (RetireJsLibrary lib : vulnerableLibraries) {
287                 dependency.setName(lib.libraryName());
288                 dependency.setVersion(lib.version());
289                 dependency.addSoftwareIdentifier(lib.identifier());
290                 dependency.addEvidence(EvidenceType.VERSION, "RetireJS", "version", lib.version(), Confidence.HIGH);
291                 dependency.addEvidence(EvidenceType.PRODUCT, "RetireJS", "name", lib.libraryName(), Confidence.HIGH);
292                 dependency.addEvidence(EvidenceType.VENDOR, "RetireJS", "name", lib.libraryName(), Confidence.HIGH);
293                 dependency.addVulnerabilities(lib.vulnerabilities(cve -> engine.getDatabase().getVulnerability(cve)));
294             }
295         } catch (StackOverflowError ex) {
296             final String msg = String.format("An error occurred trying to analyze %s. "
297                             + "To resolve this error please try increasing the Java stack size to "
298                             + "8mb and re-run dependency-check:%n%n"
299                             + "(win) : set JAVA_OPTS=\"-Xss8m\"%n"
300                             + "(*nix): export JAVA_OPTS=\"-Xss8m\"%n%n",
301                     dependency.getDisplayFileName());
302             throw new AnalysisException(msg, ex);
303         } catch (IOException | DatabaseException e) {
304             throw new AnalysisException(e);
305         }
306     }
307 
308     @Override
309     protected void closeAnalyzer() throws Exception {
310         Log.set(Log.LEVEL_INFO);
311     }
312 
313     @SuppressWarnings("SameParameterValue")
314     @VisibleForTesting
315     OptionalInt knownLibraryCountFor(String fileName) {
316         return jsRepository == null ? OptionalInt.empty() : OptionalInt.of(jsRepository.findByFilename(fileName).size());
317     }
318 }
319 
320 class RetireJsLibrary {
321     private static final Logger LOGGER = LoggerFactory.getLogger(RetireJsLibrary.class);
322 
323     private final JsLibraryResult result;
324 
325     private RetireJsLibrary(JsLibraryResult result) {
326         this.result = result;
327     }
328 
329     static RetireJsLibrary adapt(JsLibraryResult result) {
330         return new RetireJsLibrary(result);
331     }
332 
333     String libraryName() {
334         return result.getLibrary().getName();
335     }
336 
337     String version() {
338         return result.getDetectedVersion();
339     }
340 
341     Identifier identifier() {
342         try {
343             return new PurlIdentifier(
344                     PackageURLBuilder.aPackageURL()
345                             .withType("javascript")
346                             .withName(libraryName())
347                             .withVersion(version())
348                             .build(),
349                     Confidence.HIGHEST);
350         } catch (MalformedPackageURLException ex) {
351             LOGGER.debug("Unable to build package url for retireJS; using generic identifier", ex);
352             return new GenericIdentifier(String.format("javascript:%s@%s", libraryName(), version()), Confidence.HIGHEST);
353         }
354     }
355 
356     List<Vulnerability> vulnerabilities(KnownCveProvider knownCveProvider) {
357         List<Vulnerability> vulns = new RetireJsVulnerabilityIdentifiers(result.getVuln().getIdentifiers())
358                 .toVulnerabilities(knownCveProvider, result.getVuln().getSeverity());
359 
360         for (Vulnerability vuln : vulns) {
361             vuln.addReferences(infoReferences());
362         }
363         return vulns;
364     }
365 
366     private @NonNull Set<Reference> infoReferences() {
367         return result.getVuln().getInfo().stream()
368                 .map(info -> new Reference(info, "info", UrlValidator.getInstance().isValid(info) ? info : null))
369                 .collect(Collectors.toSet());
370     }
371 
372     @SuppressWarnings("OptionalUsedAsFieldOrParameterType")
373     private class RetireJsVulnerabilityIdentifiers {
374 
375         public static final int MAX_NAME_LENGTH = 100;
376 
377         // Preferred global identifiers
378         private final List<String> cveIds;
379         private final Optional<String> ghsaId;
380 
381         // Fallback identifiers that can be used as vuln names
382         private final Map<String, String> secondaryNameIds;
383         private final Optional<String> summary;
384 
385         RetireJsVulnerabilityIdentifiers(Map<String, List<String>> rawIdentifiers) {
386             // CVE identifiers can be a list
387             this.cveIds = Optional.ofNullable(rawIdentifiers.get(CVE)).orElse(List.of()).stream()
388                     .map(StringUtils::trimToNull)
389                     .filter(StringUtils::isNotEmpty)
390                     .collect(Collectors.toList());
391 
392             // Other identifiers are only supported by the underlying schema as single items, so we get the first
393             this.ghsaId = singleItem(rawIdentifiers.get(GITHUB_SECURITY_ADVISORY));
394             this.secondaryNameIds = SECONDARY_NAME_TYPES.stream()
395                     .flatMap(type -> singleEntry(type, rawIdentifiers.get(type)).stream())
396                     .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, (a, b) -> a, LinkedHashMap::new));
397 
398             // Summary is sometimes present; and can be a fallback vulnerability name as well as description
399             this.summary = singleItem(rawIdentifiers.get(SUMMARY));
400         }
401 
402         List<Vulnerability> toVulnerabilities(KnownCveProvider cveProvider, String severity) {
403             // Prefer CVEs; and see if we already know about them from the NVD.
404             // RetireJS can map multiple CVEs, so create 'N' vulns
405             List<Vulnerability> discoveredVulnerabilities = cveIds.stream()
406                     .map(cveId -> cveProvider.optional(cveId).orElseGet(() -> retireJsVulnFor(cveId)))
407                     .collect(Collectors.toList());
408 
409             // We try and index off CVEs that we can find existing from NVD; else create a single new one
410             // with the best canonical name we can determine from identifiers
411             if (discoveredVulnerabilities.isEmpty()) {
412                 discoveredVulnerabilities.add(retireJsVulnFor(vulnerabilityName()));
413             }
414 
415             // For vulnerabilities not referenced externally; populate description and references from identifiers
416             discoveredVulnerabilities.stream()
417                     .filter(vuln -> Vulnerability.Source.RETIREJS.equals(vuln.getSource()))
418                     .forEach(vuln -> {
419                         vuln.setUnscoredSeverity(severity);
420                         summary.ifPresent(vuln::setDescription);
421                         vuln.addReferences(references());
422                     });
423             return discoveredVulnerabilities;
424         }
425 
426         private Vulnerability retireJsVulnFor(String name) {
427             final Vulnerability vuln = new Vulnerability(name);
428             vuln.setSource(Vulnerability.Source.RETIREJS);
429             return vuln;
430         }
431 
432 
433         private @NonNull String vulnerabilityName() {
434             if (!cveIds.isEmpty()) {
435                 throw new IllegalStateException("vulnerability names for RetireJS vulnerabilities should be taken from the CVE ID");
436             }
437 
438             // Use the GHSA as a universal identifier if present; otherwise create a vuln name that is library
439             // contextual, as we don't know we have a globally unique ID.
440             return ghsaId
441                     .or(() -> secondaryNameIds.entrySet().stream().findFirst().map(e -> libraryContextualName(e.getKey(), e.getValue())))
442                     .or(() -> summary.filter(this::isSmallSingleLine))
443                     .orElseGet(() -> "Vulnerability in " + libraryName());
444         }
445 
446         private String libraryContextualName(String type, String id) {
447             return String.format("%s %s: %s", libraryName(), type, id);
448         }
449 
450         private boolean isSmallSingleLine(String value) {
451             return value.length() <= MAX_NAME_LENGTH && value.lines().limit(2).count() == 1;
452         }
453 
454         private Set<Reference> references() {
455             Set<Reference> references = new HashSet<>();
456             // RetireJS identifiers are never URLs
457             ghsaId.ifPresent(id -> references.add(new Reference(id, "ghsaId", null)));
458             secondaryNameIds.forEach((type, id) -> references.add(new Reference(id, type, null)));
459             return references;
460         }
461     }
462 
463     @FunctionalInterface
464     interface KnownCveProvider {
465         @Nullable Vulnerability lookup(String cve);
466 
467         default @NonNull Optional<Vulnerability> optional(String cve) {
468             return Optional.ofNullable(lookup(cve));
469         }
470     }
471 
472     /**
473      * Types of identifiers within the RetireJS repo. Note that there are some legacy/deprecated types which we do not
474      * attempt to handle (e.g osvdb, retid, tenable, gist, PR. blog)
475      * <br/>
476      * Resources:
477      *  - <a href="https://raw.githubusercontent.com/Retirejs/retire.js/master/repository/jsrepository.json">Latest raw data </a>
478      *  - <a href="https://github.com/RetireJS/retire.js/blob/700590ffc92f993dfe15af1b89e364b443bd9bfa/node/src/types.ts#L23-L37">TypeScript types for the identifiers</a>
479      *  - <a href="https://github.com/RetireJS/retire.js/blob/700590ffc92f993dfe15af1b89e364b443bd9bfa/node/src/repo.ts#L29-L57">Repo validation</a>
480      */
481     interface KnownIdentifierTypes {
482         String CVE = "CVE";
483         String GITHUB_SECURITY_ADVISORY = "githubID";
484         List<String> SECONDARY_NAME_TYPES = List.of("issue", "bug", "PR");
485         String SUMMARY = "summary";
486 
487         static @NonNull Optional<String> singleItem(@Nullable List<String> identifiers) {
488             return Optional.ofNullable(identifiers)
489                     .flatMap(s -> s.stream().map(StringUtils::trimToNull).filter(Objects::nonNull).findFirst());
490         }
491 
492         static @NonNull Optional<Map.Entry<String, String>> singleEntry(@NonNull String type, @Nullable List<String> identifiers) {
493             return singleItem(identifiers).map(id -> Map.entry(type, id));
494         }
495     }
496 }