1   /*
2    * SPDX-FileCopyrightText: none
3    * SPDX-License-Identifier: CC0-1.0
4    */
5   
6   package gov.nist.secauto.metaschema.modules.sarif;
7   
8   import gov.nist.secauto.metaschema.core.datatype.markup.MarkupLine;
9   import gov.nist.secauto.metaschema.core.datatype.markup.MarkupMultiline;
10  import gov.nist.secauto.metaschema.core.model.IAttributable;
11  import gov.nist.secauto.metaschema.core.model.IResourceLocation;
12  import gov.nist.secauto.metaschema.core.model.MetaschemaException;
13  import gov.nist.secauto.metaschema.core.model.constraint.ConstraintValidationFinding;
14  import gov.nist.secauto.metaschema.core.model.constraint.IConstraint;
15  import gov.nist.secauto.metaschema.core.model.constraint.IConstraint.Level;
16  import gov.nist.secauto.metaschema.core.model.validation.IValidationFinding;
17  import gov.nist.secauto.metaschema.core.model.validation.JsonSchemaContentValidator.JsonValidationFinding;
18  import gov.nist.secauto.metaschema.core.model.validation.XmlSchemaContentValidator.XmlValidationFinding;
19  import gov.nist.secauto.metaschema.core.util.CollectionUtil;
20  import gov.nist.secauto.metaschema.core.util.IVersionInfo;
21  import gov.nist.secauto.metaschema.core.util.ObjectUtils;
22  import gov.nist.secauto.metaschema.core.util.UriUtils;
23  import gov.nist.secauto.metaschema.databind.IBindingContext;
24  import gov.nist.secauto.metaschema.databind.io.Format;
25  import gov.nist.secauto.metaschema.databind.io.SerializationFeature;
26  
27  import org.schemastore.json.sarif.x210.Artifact;
28  import org.schemastore.json.sarif.x210.ArtifactLocation;
29  import org.schemastore.json.sarif.x210.Location;
30  import org.schemastore.json.sarif.x210.LogicalLocation;
31  import org.schemastore.json.sarif.x210.Message;
32  import org.schemastore.json.sarif.x210.MultiformatMessageString;
33  import org.schemastore.json.sarif.x210.PhysicalLocation;
34  import org.schemastore.json.sarif.x210.Region;
35  import org.schemastore.json.sarif.x210.ReportingDescriptor;
36  import org.schemastore.json.sarif.x210.Result;
37  import org.schemastore.json.sarif.x210.Run;
38  import org.schemastore.json.sarif.x210.Sarif;
39  import org.schemastore.json.sarif.x210.SarifModule;
40  import org.schemastore.json.sarif.x210.Tool;
41  import org.schemastore.json.sarif.x210.ToolComponent;
42  
43  import java.io.IOException;
44  import java.io.StringWriter;
45  import java.math.BigInteger;
46  import java.net.URI;
47  import java.net.URISyntaxException;
48  import java.nio.file.Path;
49  import java.nio.file.StandardOpenOption;
50  import java.util.Collection;
51  import java.util.LinkedHashMap;
52  import java.util.LinkedList;
53  import java.util.List;
54  import java.util.Map;
55  import java.util.Set;
56  import java.util.UUID;
57  import java.util.concurrent.atomic.AtomicInteger;
58  
59  import edu.umd.cs.findbugs.annotations.NonNull;
60  import edu.umd.cs.findbugs.annotations.Nullable;
61  
62  /**
63   * Supports building a Static Analysis Results Interchange Format (SARIF)
64   * document based on a set of validation findings.
65   */
66  @SuppressWarnings("PMD.CouplingBetweenObjects")
67  public final class SarifValidationHandler {
68    private enum Kind {
69      NOT_APPLICABLE("notApplicable"),
70      PASS("pass"),
71      FAIL("fail"),
72      REVIEW("review"),
73      OPEN("open"),
74      INFORMATIONAL("informational");
75  
76      @NonNull
77      private final String label;
78  
79      Kind(@NonNull String label) {
80        this.label = label;
81      }
82  
83      @NonNull
84      public String getLabel() {
85        return label;
86      }
87    }
88  
89    private enum SeverityLevel {
90      NONE("none"),
91      NOTE("note"),
92      WARNING("warning"),
93      ERROR("error");
94  
95      @NonNull
96      private final String label;
97  
98      SeverityLevel(@NonNull String label) {
99        this.label = label;
100     }
101 
102     @NonNull
103     public String getLabel() {
104       return label;
105     }
106   }
107 
108   @NonNull
109   static final String SARIF_NS = "https://docs.oasis-open.org/sarif/sarif/v2.1.0";
110   @NonNull
111   public static final IAttributable.Key SARIF_HELP_URL_KEY
112       = IAttributable.key("help-url", SARIF_NS);
113   @NonNull
114   public static final IAttributable.Key SARIF_HELP_TEXT_KEY
115       = IAttributable.key("help-text", SARIF_NS);
116   @NonNull
117   public static final IAttributable.Key SARIF_HELP_MARKDOWN_KEY
118       = IAttributable.key("help-markdown", SARIF_NS);
119 
120   @NonNull
121   private final URI source;
122   @Nullable
123   private final IVersionInfo toolVersion;
124   private final AtomicInteger artifactIndex = new AtomicInteger(-1);
125   private final AtomicInteger ruleIndex = new AtomicInteger(-1);
126 
127   @SuppressWarnings("PMD.UseConcurrentHashMap")
128   @NonNull
129   private final Map<URI, ArtifactRecord> artifacts = new LinkedHashMap<>();
130   @NonNull
131   private final List<AbstractRuleRecord> rules = new LinkedList<>();
132   @SuppressWarnings("PMD.UseConcurrentHashMap")
133   @NonNull
134   private final Map<IConstraint, ConstraintRuleRecord> constraintRules = new LinkedHashMap<>();
135   @NonNull
136   private final List<IResult> results = new LinkedList<>();
137   @NonNull
138   private final SchemaRuleRecord schemaRule = new SchemaRuleRecord();
139   private boolean schemaValid = true;
140 
141   /**
142    * Construct a new validation handler.
143    *
144    * @param source
145    *          the URI of the content that was validated
146    * @param toolVersion
147    *          the version information for the tool producing the validation
148    *          results
149    */
150   public SarifValidationHandler(
151       @NonNull URI source,
152       @Nullable IVersionInfo toolVersion) {
153     if (!source.isAbsolute()) {
154       throw new IllegalArgumentException(String.format("The source URI '%s' is not absolute.", source.toASCIIString()));
155     }
156 
157     this.source = source;
158     this.toolVersion = toolVersion;
159   }
160 
161   @NonNull
162   private URI getSource() {
163     return source;
164   }
165 
166   private IVersionInfo getToolVersion() {
167     return toolVersion;
168   }
169 
170   /**
171    * Register a collection of validation finding.
172    *
173    * @param findings
174    *          the findings to register
175    */
176   public void addFindings(@NonNull Collection<? extends IValidationFinding> findings) {
177     for (IValidationFinding finding : findings) {
178       assert finding != null;
179       addFinding(finding);
180     }
181   }
182 
183   /**
184    * Register a validation finding.
185    *
186    * @param finding
187    *          the finding to register
188    */
189   public void addFinding(@NonNull IValidationFinding finding) {
190     if (finding instanceof JsonValidationFinding) {
191       addJsonValidationFinding((JsonValidationFinding) finding);
192     } else if (finding instanceof XmlValidationFinding) {
193       addXmlValidationFinding((XmlValidationFinding) finding);
194     } else if (finding instanceof ConstraintValidationFinding) {
195       addConstraintValidationFinding((ConstraintValidationFinding) finding);
196     } else {
197       throw new IllegalStateException();
198     }
199   }
200 
201   private ConstraintRuleRecord getRuleRecord(@NonNull IConstraint constraint) {
202     ConstraintRuleRecord retval = constraintRules.get(constraint);
203     if (retval == null) {
204       retval = new ConstraintRuleRecord(constraint);
205       constraintRules.put(constraint, retval);
206       rules.add(retval);
207     }
208     return retval;
209   }
210 
211   private ArtifactRecord getArtifactRecord(@NonNull URI artifactUri) {
212     ArtifactRecord retval = artifacts.get(artifactUri);
213     if (retval == null) {
214       retval = new ArtifactRecord(artifactUri);
215       artifacts.put(artifactUri, retval);
216     }
217     return retval;
218   }
219 
220   private void addJsonValidationFinding(@NonNull JsonValidationFinding finding) {
221     results.add(new SchemaResult(finding));
222     if (schemaValid && IValidationFinding.Kind.FAIL.equals(finding.getKind())) {
223       schemaValid = false;
224     }
225   }
226 
227   private void addXmlValidationFinding(@NonNull XmlValidationFinding finding) {
228     results.add(new SchemaResult(finding));
229     if (schemaValid && IValidationFinding.Kind.FAIL.equals(finding.getKind())) {
230       schemaValid = false;
231     }
232   }
233 
234   private void addConstraintValidationFinding(@NonNull ConstraintValidationFinding finding) {
235     results.add(new ConstraintResult(finding));
236   }
237 
238   /**
239    * Generate a SARIF document based on the collected findings.
240    *
241    * @param outputUri
242    *          the URI to use as the base for relative paths in the SARIF document
243    * @return the generated SARIF document
244    * @throws IOException
245    *           if an error occurred while generating the SARIF document
246    */
247   @NonNull
248   private Sarif generateSarif(@NonNull URI outputUri) throws IOException {
249     Sarif sarif = new Sarif();
250     sarif.setVersion("2.1.0");
251 
252     Run run = new Run();
253     sarif.addRun(run);
254 
255     Artifact artifact = new Artifact();
256     artifact.setLocation(getArtifactRecord(getSource()).generateArtifactLocation(outputUri));
257     run.addArtifact(artifact);
258 
259     for (IResult result : results) {
260       result.generateResults(outputUri).forEach(run::addResult);
261     }
262 
263     IVersionInfo toolVersion = getToolVersion();
264     if (!rules.isEmpty() || toolVersion != null) {
265       Tool tool = new Tool();
266       ToolComponent driver = new ToolComponent();
267 
268       if (toolVersion != null) {
269         driver.setName(toolVersion.getName());
270         driver.setVersion(toolVersion.getVersion());
271       }
272 
273       for (AbstractRuleRecord rule : rules) {
274         driver.addRule(rule.generate());
275       }
276 
277       tool.setDriver(driver);
278       run.setTool(tool);
279     }
280 
281     return sarif;
282   }
283 
284   /**
285    * Write the collection of findings to a string in SARIF format.
286    *
287    * @param bindingContext
288    *          the context used to access Metaschema module information based on
289    *          Java class bindings
290    * @return the SARIF document as a string
291    * @throws IOException
292    *           if an error occurred while generating the SARIF document
293    */
294   @NonNull
295   public String writeToString(@NonNull IBindingContext bindingContext) throws IOException {
296     registerSarifMetaschemaModule(bindingContext);
297     try (StringWriter writer = new StringWriter()) {
298       bindingContext.newSerializer(Format.JSON, Sarif.class)
299           .disableFeature(SerializationFeature.SERIALIZE_ROOT)
300           .serialize(generateSarif(getSource()), writer);
301       return ObjectUtils.notNull(writer.toString());
302     }
303   }
304 
305   /**
306    * Write the collection of findings to the provided output file.
307    *
308    * @param outputFile
309    *          the path to the output file to write to
310    * @param bindingContext
311    *          the context used to access Metaschema module information based on
312    *          Java class bindings
313    * @throws IOException
314    *           if an error occurred while writing the SARIF file
315    */
316   public void write(
317       @NonNull Path outputFile,
318       @NonNull IBindingContext bindingContext) throws IOException {
319 
320     URI output = ObjectUtils.notNull(outputFile.toUri());
321     Sarif sarif = generateSarif(output);
322 
323     registerSarifMetaschemaModule(bindingContext);
324     bindingContext.newSerializer(Format.JSON, Sarif.class)
325         .disableFeature(SerializationFeature.SERIALIZE_ROOT)
326         .serialize(
327             sarif,
328             outputFile,
329             StandardOpenOption.CREATE,
330             StandardOpenOption.WRITE,
331             StandardOpenOption.TRUNCATE_EXISTING);
332   }
333 
334   private static void registerSarifMetaschemaModule(@NonNull IBindingContext bindingContext) {
335     try {
336       bindingContext.registerModule(SarifModule.class);
337     } catch (MetaschemaException ex) {
338       throw new IllegalStateException("Unable to register the builtin SARIF module.", ex);
339     }
340   }
341 
342   private interface IResult {
343     @NonNull
344     IValidationFinding getFinding();
345 
346     @NonNull
347     List<Result> generateResults(@NonNull URI output) throws IOException;
348   }
349 
350   private abstract class AbstractResult<T extends IValidationFinding> implements IResult {
351     @NonNull
352     private final T finding;
353 
354     protected AbstractResult(@NonNull T finding) {
355       this.finding = finding;
356     }
357 
358     @Override
359     public T getFinding() {
360       return finding;
361     }
362 
363     @NonNull
364     protected Kind kind(@NonNull IValidationFinding finding) {
365       IValidationFinding.Kind kind = finding.getKind();
366 
367       Kind retval;
368       switch (kind) {
369       case FAIL:
370         retval = Kind.FAIL;
371         break;
372       case INFORMATIONAL:
373         retval = Kind.INFORMATIONAL;
374         break;
375       case NOT_APPLICABLE:
376         retval = Kind.NOT_APPLICABLE;
377         break;
378       case PASS:
379         retval = Kind.PASS;
380         break;
381       default:
382         throw new IllegalArgumentException(String.format("Invalid finding kind '%s'.", kind));
383       }
384       return retval;
385     }
386 
387     @NonNull
388     protected SeverityLevel level(@NonNull Level severity) {
389       SeverityLevel retval;
390       switch (severity) {
391       case CRITICAL:
392       case ERROR:
393         retval = SeverityLevel.ERROR;
394         break;
395       case INFORMATIONAL:
396       case DEBUG:
397         retval = SeverityLevel.NOTE;
398         break;
399       case WARNING:
400         retval = SeverityLevel.WARNING;
401         break;
402       case NONE:
403         retval = SeverityLevel.NONE;
404         break;
405       default:
406         throw new IllegalArgumentException(String.format("Invalid severity '%s'.", severity));
407       }
408       return retval;
409     }
410 
411     protected void message(@NonNull IValidationFinding finding, @NonNull Result result) {
412       String message = finding.getMessage();
413       if (message == null) {
414         message = "";
415       }
416 
417       Message msg = new Message();
418       msg.setText(message);
419       result.setMessage(msg);
420     }
421 
422     protected void location(@NonNull IValidationFinding finding, @NonNull Result result, @NonNull URI base)
423         throws IOException {
424       IResourceLocation location = finding.getLocation();
425       if (location != null) {
426         // region
427         Region region = new Region();
428 
429         if (location.getLine() > -1) {
430           region.setStartLine(BigInteger.valueOf(location.getLine()));
431           region.setEndLine(BigInteger.valueOf(location.getLine()));
432         }
433         if (location.getColumn() > -1) {
434           region.setStartColumn(BigInteger.valueOf(location.getColumn() + 1));
435           region.setEndColumn(BigInteger.valueOf(location.getColumn() + 1));
436         }
437         if (location.getByteOffset() > -1) {
438           region.setByteOffset(BigInteger.valueOf(location.getByteOffset()));
439           region.setByteLength(BigInteger.ZERO);
440         }
441         if (location.getCharOffset() > -1) {
442           region.setCharOffset(BigInteger.valueOf(location.getCharOffset()));
443           region.setCharLength(BigInteger.ZERO);
444         }
445 
446         PhysicalLocation physical = new PhysicalLocation();
447 
448         URI documentUri = finding.getDocumentUri();
449         if (documentUri != null) {
450           physical.setArtifactLocation(getArtifactRecord(documentUri).generateArtifactLocation(base));
451         }
452         physical.setRegion(region);
453 
454         LogicalLocation logical = new LogicalLocation();
455 
456         logical.setDecoratedName(finding.getPath());
457 
458         Location loc = new Location();
459         loc.setPhysicalLocation(physical);
460         loc.addLogicalLocation(logical);
461         result.addLocation(loc);
462       }
463     }
464   }
465 
466   private final class SchemaResult
467       extends AbstractResult<IValidationFinding> {
468 
469     protected SchemaResult(@NonNull IValidationFinding finding) {
470       super(finding);
471     }
472 
473     @Override
474     public List<Result> generateResults(@NonNull URI output) throws IOException {
475       IValidationFinding finding = getFinding();
476 
477       Result result = new Result();
478 
479       result.setRuleId(schemaRule.getId());
480       result.setRuleIndex(BigInteger.valueOf(schemaRule.getIndex()));
481       result.setGuid(schemaRule.getGuid());
482 
483       result.setKind(kind(finding).getLabel());
484       result.setLevel(level(finding.getSeverity()).getLabel());
485       message(finding, result);
486       location(finding, result, output);
487 
488       return CollectionUtil.singletonList(result);
489     }
490   }
491 
492   private final class ConstraintResult
493       extends AbstractResult<ConstraintValidationFinding> {
494 
495     protected ConstraintResult(@NonNull ConstraintValidationFinding finding) {
496       super(finding);
497     }
498 
499     @Override
500     public List<Result> generateResults(@NonNull URI output) throws IOException {
501       ConstraintValidationFinding finding = getFinding();
502 
503       List<Result> retval = new LinkedList<>();
504 
505       Kind kind = kind(finding);
506       SeverityLevel level = level(finding.getSeverity());
507 
508       for (IConstraint constraint : finding.getConstraints()) {
509         assert constraint != null;
510         ConstraintRuleRecord rule = getRuleRecord(constraint);
511 
512         @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
513         Result result = new Result();
514 
515         String id = constraint.getId();
516         if (id != null) {
517           result.setRuleId(id);
518         }
519         result.setRuleIndex(BigInteger.valueOf(rule.getIndex()));
520         result.setGuid(rule.getGuid());
521         result.setKind(kind.getLabel());
522         result.setLevel(level.getLabel());
523         message(finding, result);
524         location(finding, result, output);
525 
526         retval.add(result);
527       }
528       return retval;
529     }
530   }
531 
532   private abstract class AbstractRuleRecord {
533     private final int index;
534     @NonNull
535     private final UUID guid;
536 
537     private AbstractRuleRecord() {
538       this.index = ruleIndex.addAndGet(1);
539       this.guid = ObjectUtils.notNull(UUID.randomUUID());
540     }
541 
542     public int getIndex() {
543       return index;
544     }
545 
546     @NonNull
547     public UUID getGuid() {
548       return guid;
549     }
550 
551     @NonNull
552     protected abstract ReportingDescriptor generate();
553   }
554 
555   private final class SchemaRuleRecord
556       extends AbstractRuleRecord {
557 
558     @Override
559     protected ReportingDescriptor generate() {
560       ReportingDescriptor retval = new ReportingDescriptor();
561       retval.setId(getId());
562       retval.setGuid(getGuid());
563       return retval;
564     }
565 
566     public String getId() {
567       return "schema-valid";
568     }
569   }
570 
571   private final class ConstraintRuleRecord
572       extends AbstractRuleRecord {
573     @NonNull
574     private final IConstraint constraint;
575 
576     public ConstraintRuleRecord(@NonNull IConstraint constraint) {
577       this.constraint = constraint;
578     }
579 
580     @NonNull
581     public IConstraint getConstraint() {
582       return constraint;
583     }
584 
585     @Override
586     protected ReportingDescriptor generate() {
587       ReportingDescriptor retval = new ReportingDescriptor();
588       IConstraint constraint = getConstraint();
589 
590       UUID guid = getGuid();
591 
592       String id = constraint.getId();
593       if (id == null) {
594         retval.setId(guid.toString());
595       } else {
596         retval.setId(id);
597       }
598       retval.setGuid(guid);
599       String formalName = constraint.getFormalName();
600       if (formalName != null) {
601         MultiformatMessageString text = new MultiformatMessageString();
602         text.setText(formalName);
603         retval.setShortDescription(text);
604       }
605       MarkupLine description = constraint.getDescription();
606       if (description != null) {
607         MultiformatMessageString text = new MultiformatMessageString();
608         text.setText(description.toText());
609         text.setMarkdown(description.toMarkdown());
610         retval.setFullDescription(text);
611       }
612 
613       Set<String> helpUrls = constraint.getPropertyValues(SARIF_HELP_URL_KEY);
614       if (!helpUrls.isEmpty()) {
615         retval.setHelpUri(URI.create(helpUrls.stream().findFirst().get()));
616       }
617 
618       Set<String> helpText = constraint.getPropertyValues(SARIF_HELP_TEXT_KEY);
619       Set<String> helpMarkdown = constraint.getPropertyValues(SARIF_HELP_MARKDOWN_KEY);
620       // if there is help text or markdown, produce a message
621       if (!helpText.isEmpty() || !helpMarkdown.isEmpty()) {
622         MultiformatMessageString help = new MultiformatMessageString();
623 
624         MarkupMultiline markdown = helpMarkdown.stream().map(MarkupMultiline::fromMarkdown).findFirst().orElse(null);
625         if (markdown != null) {
626           // markdown is provided
627           help.setMarkdown(markdown.toMarkdown());
628         }
629 
630         String text = helpText.isEmpty()
631             ? ObjectUtils.requireNonNull(markdown).toText() // if text is empty, markdown must be provided
632             : helpText.stream().findFirst().get(); // use the provided text
633         help.setText(text);
634 
635         retval.setHelp(help);
636       }
637 
638       return retval;
639     }
640 
641   }
642 
643   private final class ArtifactRecord {
644     @NonNull
645     private final URI uri;
646     private final int index;
647 
648     public ArtifactRecord(@NonNull URI uri) {
649       this.uri = uri;
650       this.index = artifactIndex.addAndGet(1);
651     }
652 
653     @NonNull
654     public URI getUri() {
655       return uri;
656     }
657 
658     public int getIndex() {
659       return index;
660     }
661 
662     public ArtifactLocation generateArtifactLocation(@NonNull URI baseUri) throws IOException {
663       ArtifactLocation location = new ArtifactLocation();
664 
665       try {
666         location.setUri(UriUtils.relativize(baseUri, getUri(), true));
667       } catch (URISyntaxException ex) {
668         throw new IOException(ex);
669       }
670 
671       location.setIndex(BigInteger.valueOf(getIndex()));
672       return location;
673     }
674   }
675 }