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