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