From 45cace41b288f4b0c0bf5e84eb0f335fabcfdd6c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C3=81d=C3=A1m=20Balogh?=
Date: Wed, 13 Apr 2022 19:48:50 +0200
Subject: [PATCH 1/5] CFG Visualizer (implemented as a test detector)
Sometimes, during development it is an advantage if the CFG can be seen visually. This patch adds a detector
that generates the CFG in DOT format so it can be viewed (or converted to a graphical format) using GraphViz.
---
spotbugs/etc/findbugs.xml | 2 +
spotbugs/etc/messages.xml | 7 +
.../edu/umd/cs/findbugs/detect/ViewCFG.java | 162 ++++++++++++++++++
3 files changed, 171 insertions(+)
create mode 100644 spotbugs/src/main/java/edu/umd/cs/findbugs/detect/ViewCFG.java
diff --git a/spotbugs/etc/findbugs.xml b/spotbugs/etc/findbugs.xml
index 9f3afa6676a..1007dbd6e50 100644
--- a/spotbugs/etc/findbugs.xml
+++ b/spotbugs/etc/findbugs.xml
@@ -623,6 +623,8 @@
reports="IO_APPENDING_TO_OBJECT_OUTPUT_STREAM"/>
+
diff --git a/spotbugs/etc/messages.xml b/spotbugs/etc/messages.xml
index 5f9f5b77713..48fe3ead07e 100644
--- a/spotbugs/etc/messages.xml
+++ b/spotbugs/etc/messages.xml
@@ -1586,6 +1586,13 @@ factory pattern to create these objects.
]]>
+
+
+ Generate DOT files from the CFGs.
+ ]]>
+
+
bi = cfg.blockIterator(); bi.hasNext();) {
+ BasicBlock block = bi.next();
+ if (block == cfg.getEntry()) {
+ out.println(" Node" + block.getLabel() + " [shape=record label=\"{" + block.getLabel() +
+ " (ENTRY) }\"];");
+ continue;
+ }
+
+ if (block == cfg.getExit()) {
+ out.println(" Node" + block.getLabel() + " [shape=record label=\"{" + block.getLabel() +
+ " (EXIT) }\"];");
+ continue;
+ }
+
+ out.print(" Node" + block.getLabel() + " [shape=record label=\"{" + block.getLabel());
+ if (block.getFirstInstruction() != null) {
+ out.print("|");
+ }
+ for (Iterator ii = block.instructionIterator(); ii.hasNext();) {
+ InstructionHandle ins = ii.next();
+ String insStr = ins.toString(false).replaceAll(" ->", "").replaceAll(" (\\d+)$", " #$1");
+ out.print(insStr + "\\l");
+ }
+ out.println("}\"];");
+ }
+
+ for (Iterator ei = cfg.edgeIterator(); ei.hasNext();) {
+ Edge edge = ei.next();
+ BasicBlock src = edge.getSource();
+ BasicBlock tgt = edge.getTarget();
+ switch (edge.getType()) {
+ default:
+ out.println(" Node" + src.getLabel() + " -> Node" + tgt.getLabel() + ";");
+ break;
+ case IFCMP_EDGE:
+ out.println(" Node" + src.getLabel() + " -> Node" + tgt.getLabel() +
+ " [shape=plaintext label=\" True branch\"];");
+ break;
+ case HANDLED_EXCEPTION_EDGE:
+ out.println(" Node" + src.getLabel() + " -> Node" + tgt.getLabel() +
+ " [shape=plaintext label=\" Handled exception for #" +
+ edge.getSource().getExceptionThrower().getPosition() + "\"];");
+ break;
+ case UNHANDLED_EXCEPTION_EDGE:
+ out.println(" Node" + src.getLabel() + " -> Node" + tgt.getLabel() +
+ " [shape=plaintext label=\" Unhandled exception for #" +
+ edge.getSource().getExceptionThrower().getPosition() + "\"];");
+ break;
+ case RETURN_EDGE:
+ out.println(" Node" + src.getLabel() + " -> Node" + tgt.getLabel() +
+ " [shape=plaintext label=\" Return\"];");
+ break;
+ case START_EDGE:
+ out.println(" Node" + src.getLabel() + " -> Node" + tgt.getLabel() +
+ " [shape=plaintext label=\" Start\"];");
+ break;
+ case EXIT_EDGE:
+ out.println(" Node" + src.getLabel() + " -> Exit" + tgt.getLabel() +
+ " [shape=plaintext label=\" Exit\"];");
+ break;
+ case SWITCH_EDGE:
+ out.println(" Node" + src.getLabel() + " -> Node" + tgt.getLabel() +
+ " [shape=plaintext label=\" Switch case (non-default)\"];");
+ break;
+ case SWITCH_DEFAULT_EDGE:
+ out.println(" Node" + src.getLabel() + " -> Node" + tgt.getLabel() +
+ " [shape=plaintext label=\" Switch case (default)\"];");
+ break;
+ case JSR_EDGE:
+ out.println(" Node" + src.getLabel() + " -> Node" + tgt.getLabel() +
+ " [shape=plaintext label=\" JSR statement\"];");
+ break;
+ case RET_EDGE:
+ out.println(" Node" + src.getLabel() + " -> Node" + tgt.getLabel() +
+ " [shape=plaintext label=\" RET statement\"];");
+ break;
+ case GOTO_EDGE:
+ out.println(" Node" + src.getLabel() + " -> Node" + tgt.getLabel() +
+ " [shape=plaintext label=\" GOTO statement\"];");
+ break;
+ }
+ }
+ out.println("}");
+ } catch (IOException e) {
+ bugReporter.logError("Could not create file for method " + method.getName(), e);
+ }
+ }
+
+ @Override
+ public void report() {
+ if (tempDir != null) {
+ System.out.println("CFGs generated into directory: " + tempDir + ". Please do not forget to delete it.");
+ }
+ }
+}
From 3ff87be10583c27f6e1e652cf2f41e7ba7099ae9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C3=81d=C3=A1m=20Balogh?=
Date: Thu, 14 Apr 2022 17:41:22 +0200
Subject: [PATCH 2/5] Fix for filename generation
---
.../edu/umd/cs/findbugs/detect/ViewCFG.java | 30 ++++++++++++++-----
1 file changed, 22 insertions(+), 8 deletions(-)
diff --git a/spotbugs/src/main/java/edu/umd/cs/findbugs/detect/ViewCFG.java b/spotbugs/src/main/java/edu/umd/cs/findbugs/detect/ViewCFG.java
index 836f64842eb..3c61020437e 100644
--- a/spotbugs/src/main/java/edu/umd/cs/findbugs/detect/ViewCFG.java
+++ b/spotbugs/src/main/java/edu/umd/cs/findbugs/detect/ViewCFG.java
@@ -1,8 +1,8 @@
package edu.umd.cs.findbugs.detect;
-import java.io.FileOutputStream;
import java.io.IOException;
import java.io.PrintStream;
+import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
@@ -22,6 +22,7 @@
import static edu.umd.cs.findbugs.ba.EdgeTypes.*;
public class ViewCFG implements Detector {
+
private final BugReporter bugReporter;
private Path tempDir;
@@ -39,18 +40,18 @@ public void visitClassContext(ClassContext classContext) {
if (tempDir == null) {
return;
}
-
+ //
JavaClass cls = classContext.getJavaClass();
String classDirName = (cls.getPackageName() != "") ? (cls.getPackageName() + "." + cls.getClassName()) : cls.getClassName();
Path classDir;
-
+ //
try {
classDir = Files.createDirectory(Paths.get(tempDir.toString(), classDirName));
} catch (IOException e) {
bugReporter.logError("Could not create directory for class " + cls.getClassName(), e);
return;
}
-
+ //
for (Method method : cls.getMethods()) {
try {
analyzeMethod(classContext, method, classDir);
@@ -61,8 +62,9 @@ public void visitClassContext(ClassContext classContext) {
}
private void analyzeMethod(ClassContext classContext, Method method, Path classDir) throws CFGBuilderException {
- try (PrintStream out = new PrintStream(new FileOutputStream(
- Files.createFile(Paths.get(classDir.toString(), method.getName() + ".dot")).toFile()))) {
+ Path methodFile = getMethodFile(classDir, method.getName());
+
+ try (PrintStream out = new PrintStream(Files.createFile(methodFile).toFile(), Charset.defaultCharset().name())) {
CFG cfg = classContext.getCFG(method);
out.println("digraph " + method.getName() + " {");
for (Iterator bi = cfg.blockIterator(); bi.hasNext();) {
@@ -72,13 +74,13 @@ private void analyzeMethod(ClassContext classContext, Method method, Path classD
" (ENTRY) }\"];");
continue;
}
-
+ //
if (block == cfg.getExit()) {
out.println(" Node" + block.getLabel() + " [shape=record label=\"{" + block.getLabel() +
" (EXIT) }\"];");
continue;
}
-
+ //
out.print(" Node" + block.getLabel() + " [shape=record label=\"{" + block.getLabel());
if (block.getFirstInstruction() != null) {
out.print("|");
@@ -153,6 +155,18 @@ private void analyzeMethod(ClassContext classContext, Method method, Path classD
}
}
+ private Path getMethodFile(Path classDir, String methodName) {
+ String methodFileName = methodName;
+ Path methodFile;
+ int index = 0;
+
+ do {
+ methodFile = Paths.get(classDir.toString(), methodFileName + ".dot");
+ methodFileName = methodName + ++index;
+ } while (Files.exists(methodFile));
+ return methodFile;
+ }
+
@Override
public void report() {
if (tempDir != null) {
From 4c05f4038d392694783c92b224173f61bcf95474 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C3=81d=C3=A1m=20Balogh?=
Date: Tue, 19 Apr 2022 15:23:42 +0200
Subject: [PATCH 3/5] Update according to the comments of @KengoTODA.
---
.../edu/umd/cs/findbugs/detect/ViewCFG.java | 197 ++++++++++--------
1 file changed, 106 insertions(+), 91 deletions(-)
diff --git a/spotbugs/src/main/java/edu/umd/cs/findbugs/detect/ViewCFG.java b/spotbugs/src/main/java/edu/umd/cs/findbugs/detect/ViewCFG.java
index 3c61020437e..bcbe1cdd210 100644
--- a/spotbugs/src/main/java/edu/umd/cs/findbugs/detect/ViewCFG.java
+++ b/spotbugs/src/main/java/edu/umd/cs/findbugs/detect/ViewCFG.java
@@ -7,6 +7,7 @@
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Iterator;
+import java.util.regex.Pattern;
import org.apache.bcel.classfile.JavaClass;
import org.apache.bcel.classfile.Method;
@@ -23,6 +24,10 @@
public class ViewCFG implements Detector {
+ private final Pattern SPACE_ARROW = Pattern.compile(" ->");
+ private final Pattern NUMBER_SUFFIX = Pattern.compile(" (\\d+)$");
+ private final Pattern SPECIAL_METHOD = Pattern.compile("<(\\w+)>");
+
private final BugReporter bugReporter;
private Path tempDir;
@@ -40,18 +45,18 @@ public void visitClassContext(ClassContext classContext) {
if (tempDir == null) {
return;
}
- //
+
JavaClass cls = classContext.getJavaClass();
- String classDirName = (cls.getPackageName() != "") ? (cls.getPackageName() + "." + cls.getClassName()) : cls.getClassName();
+ String classDirName = (!cls.getPackageName().isEmpty()) ? (cls.getPackageName() + "." + cls.getClassName()) : cls.getClassName();
Path classDir;
- //
+
try {
classDir = Files.createDirectory(Paths.get(tempDir.toString(), classDirName));
} catch (IOException e) {
bugReporter.logError("Could not create directory for class " + cls.getClassName(), e);
return;
}
- //
+
for (Method method : cls.getMethods()) {
try {
analyzeMethod(classContext, method, classDir);
@@ -63,100 +68,110 @@ public void visitClassContext(ClassContext classContext) {
private void analyzeMethod(ClassContext classContext, Method method, Path classDir) throws CFGBuilderException {
Path methodFile = getMethodFile(classDir, method.getName());
+ PrintStream out;
- try (PrintStream out = new PrintStream(Files.createFile(methodFile).toFile(), Charset.defaultCharset().name())) {
- CFG cfg = classContext.getCFG(method);
- out.println("digraph " + method.getName() + " {");
- for (Iterator bi = cfg.blockIterator(); bi.hasNext();) {
- BasicBlock block = bi.next();
- if (block == cfg.getEntry()) {
- out.println(" Node" + block.getLabel() + " [shape=record label=\"{" + block.getLabel() +
- " (ENTRY) }\"];");
- continue;
- }
- //
- if (block == cfg.getExit()) {
- out.println(" Node" + block.getLabel() + " [shape=record label=\"{" + block.getLabel() +
- " (EXIT) }\"];");
- continue;
- }
- //
- out.print(" Node" + block.getLabel() + " [shape=record label=\"{" + block.getLabel());
- if (block.getFirstInstruction() != null) {
- out.print("|");
- }
- for (Iterator ii = block.instructionIterator(); ii.hasNext();) {
- InstructionHandle ins = ii.next();
- String insStr = ins.toString(false).replaceAll(" ->", "").replaceAll(" (\\d+)$", " #$1");
- out.print(insStr + "\\l");
- }
- out.println("}\"];");
+ try {
+ out = new PrintStream(Files.createFile(methodFile).toFile(), Charset.defaultCharset().name());
+ } catch (IOException e) {
+ bugReporter.logError("Could not create file for method " + method.getName(), e);
+ return;
+ }
+
+ CFG cfg = classContext.getCFG(method);
+ out.println("digraph " + method.getName() + " {");
+ for (Iterator bi = cfg.blockIterator(); bi.hasNext();) {
+ BasicBlock block = bi.next();
+ if (block == cfg.getEntry()) {
+ out.println(" Node" + block.getLabel() + " [shape=record label=\"{" + block.getLabel() +
+ " (ENTRY) }\"];");
+ continue;
}
- for (Iterator ei = cfg.edgeIterator(); ei.hasNext();) {
- Edge edge = ei.next();
- BasicBlock src = edge.getSource();
- BasicBlock tgt = edge.getTarget();
- switch (edge.getType()) {
- default:
- out.println(" Node" + src.getLabel() + " -> Node" + tgt.getLabel() + ";");
- break;
- case IFCMP_EDGE:
- out.println(" Node" + src.getLabel() + " -> Node" + tgt.getLabel() +
- " [shape=plaintext label=\" True branch\"];");
- break;
- case HANDLED_EXCEPTION_EDGE:
- out.println(" Node" + src.getLabel() + " -> Node" + tgt.getLabel() +
- " [shape=plaintext label=\" Handled exception for #" +
- edge.getSource().getExceptionThrower().getPosition() + "\"];");
- break;
- case UNHANDLED_EXCEPTION_EDGE:
- out.println(" Node" + src.getLabel() + " -> Node" + tgt.getLabel() +
- " [shape=plaintext label=\" Unhandled exception for #" +
- edge.getSource().getExceptionThrower().getPosition() + "\"];");
- break;
- case RETURN_EDGE:
- out.println(" Node" + src.getLabel() + " -> Node" + tgt.getLabel() +
- " [shape=plaintext label=\" Return\"];");
- break;
- case START_EDGE:
- out.println(" Node" + src.getLabel() + " -> Node" + tgt.getLabel() +
- " [shape=plaintext label=\" Start\"];");
- break;
- case EXIT_EDGE:
- out.println(" Node" + src.getLabel() + " -> Exit" + tgt.getLabel() +
- " [shape=plaintext label=\" Exit\"];");
- break;
- case SWITCH_EDGE:
- out.println(" Node" + src.getLabel() + " -> Node" + tgt.getLabel() +
- " [shape=plaintext label=\" Switch case (non-default)\"];");
- break;
- case SWITCH_DEFAULT_EDGE:
- out.println(" Node" + src.getLabel() + " -> Node" + tgt.getLabel() +
- " [shape=plaintext label=\" Switch case (default)\"];");
- break;
- case JSR_EDGE:
- out.println(" Node" + src.getLabel() + " -> Node" + tgt.getLabel() +
- " [shape=plaintext label=\" JSR statement\"];");
- break;
- case RET_EDGE:
- out.println(" Node" + src.getLabel() + " -> Node" + tgt.getLabel() +
- " [shape=plaintext label=\" RET statement\"];");
- break;
- case GOTO_EDGE:
- out.println(" Node" + src.getLabel() + " -> Node" + tgt.getLabel() +
- " [shape=plaintext label=\" GOTO statement\"];");
- break;
- }
+ if (block == cfg.getExit()) {
+ out.println(" Node" + block.getLabel() + " [shape=record label=\"{" + block.getLabel() +
+ " (EXIT) }\"];");
+ continue;
}
- out.println("}");
- } catch (IOException e) {
- bugReporter.logError("Could not create file for method " + method.getName(), e);
+
+ out.print(" Node" + block.getLabel() + " [shape=record label=\"{" + block.getLabel());
+ if (block.getFirstInstruction() != null) {
+ out.print("|");
+ }
+ for (Iterator ii = block.instructionIterator(); ii.hasNext();) {
+ InstructionHandle ins = ii.next();
+ String insStr = NUMBER_SUFFIX.matcher(
+ SPACE_ARROW.matcher(ins.toString(false)).replaceAll(""))
+ .replaceAll(" #$1");
+ out.print(insStr + "\\l");
+ }
+ out.println("}\"];");
}
- }
+
+ for (Iterator ei = cfg.edgeIterator(); ei.hasNext();) {
+ Edge edge = ei.next();
+ BasicBlock src = edge.getSource();
+ BasicBlock tgt = edge.getTarget();
+ switch (edge.getType()) {
+ default:
+ out.println(" Node" + src.getLabel() + " -> Node" + tgt.getLabel() + ";");
+ break;
+ case IFCMP_EDGE:
+ out.println(" Node" + src.getLabel() + " -> Node" + tgt.getLabel() +
+ " [shape=plaintext label=\" True branch\"];");
+ break;
+ case HANDLED_EXCEPTION_EDGE:
+ out.println(" Node" + src.getLabel() + " -> Node" + tgt.getLabel() +
+ " [shape=plaintext label=\" Handled exception for #" +
+ edge.getSource().getExceptionThrower().getPosition() + "\"];");
+ break;
+ case UNHANDLED_EXCEPTION_EDGE:
+ out.println(" Node" + src.getLabel() + " -> Node" + tgt.getLabel() +
+ " [shape=plaintext label=\" Unhandled exception for #" +
+ edge.getSource().getExceptionThrower().getPosition() + "\"];");
+ break;
+ case RETURN_EDGE:
+ out.println(" Node" + src.getLabel() + " -> Node" + tgt.getLabel() +
+ " [shape=plaintext label=\" Return\"];");
+ break;
+ case START_EDGE:
+ out.println(" Node" + src.getLabel() + " -> Node" + tgt.getLabel() +
+ " [shape=plaintext label=\" Start\"];");
+ break;
+ case EXIT_EDGE:
+ out.println(" Node" + src.getLabel() + " -> Exit" + tgt.getLabel() +
+ " [shape=plaintext label=\" Exit\"];");
+ break;
+ case SWITCH_EDGE:
+ out.println(" Node" + src.getLabel() + " -> Node" + tgt.getLabel() +
+ " [shape=plaintext label=\" Switch case (non-default)\"];");
+ break;
+ case SWITCH_DEFAULT_EDGE:
+ out.println(" Node" + src.getLabel() + " -> Node" + tgt.getLabel() +
+ " [shape=plaintext label=\" Switch case (default)\"];");
+ break;
+ case JSR_EDGE:
+ out.println(" Node" + src.getLabel() + " -> Node" + tgt.getLabel() +
+ " [shape=plaintext label=\" JSR statement\"];");
+ break;
+ case RET_EDGE:
+ out.println(" Node" + src.getLabel() + " -> Node" + tgt.getLabel() +
+ " [shape=plaintext label=\" RET statement\"];");
+ break;
+ case GOTO_EDGE:
+ out.println(" Node" + src.getLabel() + " -> Node" + tgt.getLabel() +
+ " [shape=plaintext label=\" GOTO statement\"];");
+ break;
+ }
+ }
+ out.println("}");
+ out.close();
+ if (out.checkError()) {
+ bugReporter.logError("Error writing to file " + methodFile.toString());
+ }
+}
private Path getMethodFile(Path classDir, String methodName) {
- String methodFileName = methodName;
+ String methodFileName = SPECIAL_METHOD.matcher(methodName).replaceAll("____$1");
Path methodFile;
int index = 0;
From 1e026d93c482b3bd5aa9b4d6ae0e9efe7350c58f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C3=81d=C3=A1m=20Balogh?=
Date: Tue, 19 Apr 2022 15:38:31 +0200
Subject: [PATCH 4/5] Spotless error fixed.
---
.../src/main/java/edu/umd/cs/findbugs/detect/ViewCFG.java | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/spotbugs/src/main/java/edu/umd/cs/findbugs/detect/ViewCFG.java b/spotbugs/src/main/java/edu/umd/cs/findbugs/detect/ViewCFG.java
index bcbe1cdd210..f8052e57b57 100644
--- a/spotbugs/src/main/java/edu/umd/cs/findbugs/detect/ViewCFG.java
+++ b/spotbugs/src/main/java/edu/umd/cs/findbugs/detect/ViewCFG.java
@@ -100,7 +100,7 @@ private void analyzeMethod(ClassContext classContext, Method method, Path classD
for (Iterator ii = block.instructionIterator(); ii.hasNext();) {
InstructionHandle ins = ii.next();
String insStr = NUMBER_SUFFIX.matcher(
- SPACE_ARROW.matcher(ins.toString(false)).replaceAll(""))
+ SPACE_ARROW.matcher(ins.toString(false)).replaceAll(""))
.replaceAll(" #$1");
out.print(insStr + "\\l");
}
@@ -168,7 +168,7 @@ private void analyzeMethod(ClassContext classContext, Method method, Path classD
if (out.checkError()) {
bugReporter.logError("Error writing to file " + methodFile.toString());
}
-}
+ }
private Path getMethodFile(Path classDir, String methodName) {
String methodFileName = SPECIAL_METHOD.matcher(methodName).replaceAll("____$1");
From d73544a480c18ae31598e50982d06da28bce143e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C3=81d=C3=A1m=20Balogh?=
Date: Wed, 20 Apr 2022 14:21:02 +0200
Subject: [PATCH 5/5] CHANGELOG updated
---
CHANGELOG.md | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e2c6618c11c..89e573f5924 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -22,9 +22,8 @@ Currently the versioning policy of this project follows [Semantic Versioning v2.
* `THROWS_METHOD_THROWS_CLAUSE_BASIC_EXCEPTION` is reported when a method has Exception in its throws clause and
* `THROWS_METHOD_THROWS_CLAUSE_THROWABLE` is reported when a method has Throwable in its throws clause (See [SEI CERT ERR07-J](https://wiki.sei.cmu.edu/confluence/display/java/ERR07-J.+Do+not+throw+RuntimeException%2C+Exception%2C+or+Throwable))
* New rule `PERM_SUPER_NOT_CALLED_IN_GETPERMISSIONS` to warn for custom class loaders who do not call their superclasses' `getPermissions()` in their `getPermissions()` method. This rule based on the SEI CERT rule *SEC07-J Call the superclass's getPermissions() method when writing a custom class loader*. ([#SEC07-J](https://wiki.sei.cmu.edu/confluence/display/java/SEC07-J.+Call+the+superclass%27s+getPermissions%28%29+method+when+writing+a+custom+class+loader))
-
-### Added
* New rule `USC_POTENTIAL_SECURITY_CHECK_BASED_ON_UNTRUSTED_SOURCE` to detect cases where a non-final method of a non-final class is called from public methods of public classes and then the same method is called on the same object inside a doPrivileged block. Since the called method may have been overridden to behave differently on the first and second invocations this is a possible security check based on an unreliable source. This rule is based on *SEC02-J. Do not base security checks on untrusted sources*. ([#SEC02-J](https://wiki.sei.cmu.edu/confluence/display/java/SEC02-J.+Do+not+base+security+checks+on+untrusted+sources))
+* New test detector `ViewCFG` to visualize the control-flow graph for `SpotBugs` developers
## 4.6.0 - 2022-03-08
### Fixed