jscanner

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README | LICENSE

commit f6c3d70d2f412a6569ffc7a180582df01cf654a8
parent f78c37edd54d7ccea53f8d3101832c479aeb1a1c
Author: Matthias Jaros <jaros@mailbox.org>
Date:   Wed,  1 Jul 2026 13:43:38 +0200

Added banner recognition for basic discovery of services

Diffstat:
Msrc/main/java/com/it_jaros/jscanner/App.java | 16++++++++++++++--
Msrc/main/java/com/it_jaros/jscanner/PortResult.java | 29++++++++++++++++++++++++++++-
Msrc/main/java/com/it_jaros/jscanner/PortState.java | 5+++--
Msrc/main/java/com/it_jaros/jscanner/ScanResult.java | 3++-
Msrc/main/java/com/it_jaros/jscanner/Scanner.java | 55+++++++++++++++++++++++++++++++++++++------------------
Asrc/main/java/com/it_jaros/jscanner/ServiceDetector.java | 46++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/java/com/it_jaros/jscanner/ServiceType.java | 5+++++
7 files changed, 135 insertions(+), 24 deletions(-)

diff --git a/src/main/java/com/it_jaros/jscanner/App.java b/src/main/java/com/it_jaros/jscanner/App.java @@ -31,10 +31,10 @@ public class App { System.out.println(result.host()); if (!result.openPorts().isEmpty()) { - System.out.printf("\t (%d) open:\t%s%n", result.openPorts().cardinality(), map(result.openPorts())); + System.out.printf("\t (%d) open:\t%s%n", result.openPorts().cardinality(), map(result.openPorts(), result.bannerRecognition())); } if (showFilteredPorts && !result.filteredPorts().isEmpty()) { - System.out.printf("\t (%d) filtered:\t%s%n", result.filteredPorts().cardinality(), map(result.filteredPorts())); + System.out.printf("\t (%d) filtered:\t%s%n", result.filteredPorts().cardinality(), map(result.filteredPorts(), null)); } System.out.println(); } @@ -42,4 +42,16 @@ public class App { private static String map(BitSet ports) { return ports.stream().mapToObj(String::valueOf).collect(Collectors.joining(",")); } + + private static String map(BitSet ports, java.util.HashMap<Integer, ServiceType> bannerRecognition) { + return ports.stream().mapToObj(port -> { + ServiceType serviceType = bannerRecognition.getOrDefault(port, ServiceType.UNKNOWN); + + if (serviceType == ServiceType.UNKNOWN) { + return String.valueOf(port); + } + + return port + "/" + serviceType.name().toLowerCase(); + }).collect(Collectors.joining(",")); + } } diff --git a/src/main/java/com/it_jaros/jscanner/PortResult.java b/src/main/java/com/it_jaros/jscanner/PortResult.java @@ -1,4 +1,31 @@ package com.it_jaros.jscanner; -public record PortResult(int port, PortState state) { +public class PortResult { + private int port; + private PortState state; + private String banner; + + public int getPort() { + return port; + } + + public void setPort(int port) { + this.port = port; + } + + public PortState getState() { + return state; + } + + public void setState(PortState state) { + this.state = state; + } + + public String getBanner() { + return banner; + } + + public void setBanner(String banner) { + this.banner = banner; + } } diff --git a/src/main/java/com/it_jaros/jscanner/PortState.java b/src/main/java/com/it_jaros/jscanner/PortState.java @@ -1,7 +1,8 @@ package com.it_jaros.jscanner; public enum PortState { - OPEN, CLOSED, - FILTERED + FILTERED, + OPEN, + UNKNOWN } diff --git a/src/main/java/com/it_jaros/jscanner/ScanResult.java b/src/main/java/com/it_jaros/jscanner/ScanResult.java @@ -2,4 +2,5 @@ package com.it_jaros.jscanner; import java.util.BitSet; -public record ScanResult(String host, BitSet openPorts, BitSet filteredPorts) {} +public record ScanResult(String host, BitSet openPorts, BitSet filteredPorts, + java.util.HashMap<Integer, ServiceType> bannerRecognition) {} diff --git a/src/main/java/com/it_jaros/jscanner/Scanner.java b/src/main/java/com/it_jaros/jscanner/Scanner.java @@ -3,12 +3,15 @@ package com.it_jaros.jscanner; import java.io.BufferedReader; import java.io.FileNotFoundException; import java.io.IOException; +import java.io.InputStream; import java.net.*; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.util.ArrayList; import java.util.BitSet; +import java.util.HashMap; import java.util.List; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; @@ -108,6 +111,7 @@ public class Scanner { Semaphore maxWorkers = new Semaphore(maxWorkersPerHost); BitSet openPorts = new BitSet(PortRange.MAX_PORT); BitSet filteredPorts = new BitSet(PortRange.MAX_PORT); + HashMap<Integer, ServiceType> bannerRecognition = new HashMap<Integer, ServiceType>(); Progress progress = new Progress(host, portRange.getTotal(), new AtomicInteger(), new AtomicInteger(), new AtomicInteger()); progressBar.submit(progress); @@ -115,7 +119,7 @@ public class Scanner { if(!disableHostCheck && !checkHostOnline(host)) { // Visually show that this host is basically done progress.done().set(progress.total()); - return new ScanResult(host, openPorts, filteredPorts); + return new ScanResult(host, openPorts, filteredPorts, bannerRecognition); } try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) { @@ -126,13 +130,7 @@ public class Scanner { futures.add(executor.submit(() -> { try { waitForSlot(portSlotFactory); - PortState state = getPortState(host, currentPort); - if (state.equals(PortState.OPEN)) { - progress.open().incrementAndGet(); - } else if (state.equals(PortState.FILTERED)) { - progress.filtered().incrementAndGet(); - } - return new PortResult(currentPort, state); + return checkPort(host, currentPort); } finally { maxWorkers.release(); progress.done().incrementAndGet(); @@ -146,10 +144,11 @@ public class Scanner { // bitset is not thread-safe, so it is set // outside the other virtual threads that update progress PortResult portResult = f.get(); - if (portResult.state().equals(PortState.OPEN)) { - openPorts.set(portResult.port()); - } else if (portResult.state().equals(PortState.FILTERED)) { - filteredPorts.set(portResult.port()); + if (portResult.getState().equals(PortState.OPEN)) { + openPorts.set(portResult.getPort()); + bannerRecognition.put(portResult.getPort(), ServiceDetector.detect(portResult.getBanner())); + } else if (portResult.getState().equals(PortState.FILTERED)) { + filteredPorts.set(portResult.getPort()); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); @@ -163,7 +162,7 @@ public class Scanner { } } - return new ScanResult(host, openPorts, filteredPorts); + return new ScanResult(host, openPorts, filteredPorts, bannerRecognition); } private boolean checkHostOnline(String host) { @@ -185,18 +184,22 @@ public class Scanner { } } - private PortState getPortState(String host, int port) { + private PortResult checkPort(String host, int port) { socketLimit.acquireUninterruptibly(); counter.inc(); + PortResult result = new PortResult(); + result.setPort(port); + result.setState(PortState.UNKNOWN); try (Socket socket = new Socket()) { socket.connect(new InetSocketAddress(host, port), timeoutInMillis); - return PortState.OPEN; + result.setBanner(getBanner(socket)); + result.setState(PortState.OPEN); } catch (NoRouteToHostException ignored) { // this can be safely ignored because the port is closed if a host is unreachable } catch (SocketTimeoutException ignored) { - return PortState.FILTERED; + result.setState(PortState.FILTERED); } catch (ConnectException ignored) { - return PortState.CLOSED; + result.setState(PortState.CLOSED); } catch (IOException ignored) { // Will happen a lot when scanning for open ports, so not needed } finally { @@ -204,6 +207,22 @@ public class Scanner { socketLimit.release(); } - return PortState.CLOSED; + return result; + } + + private String getBanner(Socket socket) { + int READ_BUFFER_SIZE = 1024; + byte[] buffer = new byte[READ_BUFFER_SIZE]; + try(InputStream input = socket.getInputStream()) { + socket.setSoTimeout(timeoutInMillis); + int bytesRead = input.read(buffer); + if (bytesRead <= 0) { + return null; + } + return new String(buffer,0, bytesRead, StandardCharsets.UTF_8).trim(); + } catch (IOException e) { + // we ignore this failure + } + return null; } } diff --git a/src/main/java/com/it_jaros/jscanner/ServiceDetector.java b/src/main/java/com/it_jaros/jscanner/ServiceDetector.java @@ -0,0 +1,45 @@ +package com.it_jaros.jscanner; + +import java.util.List; +import java.util.regex.Pattern; + +public class ServiceDetector { + private static final List<ServicePattern> patterns = List.of( + new ServicePattern(ServiceType.SSH, Pattern.compile("(?i)^SSH-")), + + new ServicePattern(ServiceType.HTTP, Pattern.compile("(?i)^HTTP/")), + new ServicePattern(ServiceType.HTTP, Pattern.compile("(?i)\\bServer:\\s*")), + + new ServicePattern(ServiceType.FTP, Pattern.compile("(?i)^220.*\\bFTP\\b")), + + new ServicePattern(ServiceType.SMTP, Pattern.compile("(?i)^220.*\\bSMTP\\b")), + new ServicePattern(ServiceType.SMTP, Pattern.compile("(?i)^220.*\\bESMTP\\b")), + + new ServicePattern(ServiceType.IMAP, Pattern.compile("(?i)^\\* OK.*\\bIMAP\\b")), + + new ServicePattern(ServiceType.POP3, Pattern.compile("(?i)^\\+OK.*\\bPOP3\\b")) + ); + + public static ServiceType detect(String banner) { + if (banner == null || banner.isBlank()) { + return ServiceType.UNKNOWN; + } + + for (ServicePattern servicePattern : patterns) { + if (servicePattern.matches(banner)) { + return servicePattern.serviceType(); + } + } + + return ServiceType.UNKNOWN; + } + + private record ServicePattern( + ServiceType serviceType, + Pattern pattern + ) { + boolean matches(String banner) { + return pattern.matcher(banner).find(); + } + } +} +\ No newline at end of file diff --git a/src/main/java/com/it_jaros/jscanner/ServiceType.java b/src/main/java/com/it_jaros/jscanner/ServiceType.java @@ -0,0 +1,5 @@ +package com.it_jaros.jscanner; + +public enum ServiceType { + HTTP, FTP, SMTP, IMAP, POP3, UNKNOWN, SSH +}