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