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