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}