001/*
002 * SPDX-FileCopyrightText: none
003 * SPDX-License-Identifier: CC0-1.0
004 */
005
006package gov.nist.secauto.metaschema.modules.sarif;
007
008import gov.nist.secauto.metaschema.core.datatype.markup.MarkupLine;
009import gov.nist.secauto.metaschema.core.datatype.markup.MarkupMultiline;
010import gov.nist.secauto.metaschema.core.model.IAttributable;
011import gov.nist.secauto.metaschema.core.model.IResourceLocation;
012import gov.nist.secauto.metaschema.core.model.constraint.ConstraintValidationFinding;
013import gov.nist.secauto.metaschema.core.model.constraint.IConstraint;
014import gov.nist.secauto.metaschema.core.model.constraint.IConstraint.Level;
015import gov.nist.secauto.metaschema.core.model.validation.IValidationFinding;
016import gov.nist.secauto.metaschema.core.model.validation.JsonSchemaContentValidator.JsonValidationFinding;
017import gov.nist.secauto.metaschema.core.model.validation.XmlSchemaContentValidator.XmlValidationFinding;
018import gov.nist.secauto.metaschema.core.util.CollectionUtil;
019import gov.nist.secauto.metaschema.core.util.IVersionInfo;
020import gov.nist.secauto.metaschema.core.util.ObjectUtils;
021import gov.nist.secauto.metaschema.core.util.UriUtils;
022import gov.nist.secauto.metaschema.databind.IBindingContext;
023import gov.nist.secauto.metaschema.databind.io.Format;
024import gov.nist.secauto.metaschema.databind.io.SerializationFeature;
025
026import org.schemastore.json.sarif.x210.Artifact;
027import org.schemastore.json.sarif.x210.ArtifactLocation;
028import org.schemastore.json.sarif.x210.Location;
029import org.schemastore.json.sarif.x210.LogicalLocation;
030import org.schemastore.json.sarif.x210.Message;
031import org.schemastore.json.sarif.x210.MultiformatMessageString;
032import org.schemastore.json.sarif.x210.PhysicalLocation;
033import org.schemastore.json.sarif.x210.Region;
034import org.schemastore.json.sarif.x210.ReportingDescriptor;
035import org.schemastore.json.sarif.x210.Result;
036import org.schemastore.json.sarif.x210.Run;
037import org.schemastore.json.sarif.x210.Sarif;
038import org.schemastore.json.sarif.x210.SarifModule;
039import org.schemastore.json.sarif.x210.Tool;
040import org.schemastore.json.sarif.x210.ToolComponent;
041
042import java.io.IOException;
043import java.io.StringWriter;
044import java.math.BigInteger;
045import java.net.URI;
046import java.net.URISyntaxException;
047import java.nio.file.Path;
048import java.nio.file.StandardOpenOption;
049import java.util.Collection;
050import java.util.LinkedHashMap;
051import java.util.LinkedList;
052import java.util.List;
053import java.util.Map;
054import java.util.Set;
055import java.util.UUID;
056import java.util.concurrent.atomic.AtomicInteger;
057
058import edu.umd.cs.findbugs.annotations.NonNull;
059import edu.umd.cs.findbugs.annotations.Nullable;
060
061/**
062 * Supports building a Static Analysis Results Interchange Format (SARIF)
063 * document based on a set of validation findings.
064 */
065@SuppressWarnings("PMD.CouplingBetweenObjects")
066public final class SarifValidationHandler {
067  private enum Kind {
068    NOT_APPLICABLE("notApplicable"),
069    PASS("pass"),
070    FAIL("fail"),
071    REVIEW("review"),
072    OPEN("open"),
073    INFORMATIONAL("informational");
074
075    @NonNull
076    private final String label;
077
078    Kind(@NonNull String label) {
079      this.label = label;
080    }
081
082    @NonNull
083    public String getLabel() {
084      return label;
085    }
086  }
087
088  private enum SeverityLevel {
089    NONE("none"),
090    NOTE("note"),
091    WARNING("warning"),
092    ERROR("error");
093
094    @NonNull
095    private final String label;
096
097    SeverityLevel(@NonNull String label) {
098      this.label = label;
099    }
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}