Build a Network Security Scanner in Go
Build a concurrent TCP port scanner in Go that generates JSON audit reports.
Introduction
30 Second Summary
Medical clinics store some of the most sensitive data imaginable, but many don't know which doors on their network are left wide open. An unmonitored open port is like an unlocked entrance that attackers can walk right through.
In this project, you will build a command-line network security scanner in Go that scans a target host for open ports, identifies running services through banner grabbing, and generates a structured JSON audit report.
What You'll Build
A Go CLI tool that uses concurrent goroutines to scan TCP ports, grab service banners for fingerprinting, and output structured JSON audit reports for medical clinic compliance.
By the end of this project, you'll have:
- A working TCP port scanner that uses Go's goroutines and channels to check multiple ports concurrently.
- A service fingerprinting system that identifies what software is running on each open port via banner grabbing.
- A JSON audit report generator that documents scan findings for medical clinic compliance records.
- Secret Mission: Add risk-level classification that flags dangerous protocols like Telnet and FTP as high-risk findings in your audit report.
Are there any prerequisites?
Familiarity with networking concepts like TCP/IP, ports, and protocols is helpful. No prior Go experience is required.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
Set Up Your Go Environment
Medical clinics store some of the most sensitive data on the planet. Open ports and exposed services on a clinic's network are invitations for attackers to steal patient records.
In this project, you will build a command-line network security scanner in Go that scans a target host for open TCP ports, identifies running services through banner grabbing, and generates structured JSON audit reports. Before you can start building, you need Go installed, VS Code configured with the Go extension, and a project folder ready.
In this step, get ready to:
- Install Go on your machine.
- Set up VS Code with the Go extension.
- Create and initialize your project folder.
Install Go
Go is the programming language for this entire project. It compiles to a single binary with zero external dependencies, which makes it ideal for a security tool you might deploy across clinic machines.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- Open the integrated terminal by pressing Ctrl+` (backtick key, usually above Tab).
- Check if Go is already installed by running this command:
go version
✔️ I see version 1.26 or higher
Go is ready. Move on to the next substep.
ⓧ I see an older version
You need Go 1.26 or higher for this project. The installer will replace the older version automatically.
- Open https://go.dev/dl/ in your browser.
- Download the featured installer for your operating system. On Windows, this is go1.26.4.windows-amd64.msi. On macOS, download the .pkg file that matches your chip (ARM64 for Apple Silicon, AMD64 for Intel).
- Run the installer and accept all default settings. The installer automatically updates your PATH.
- Back in VS Code, close your current terminal and open a new one by pressing Ctrl+`.
- Verify the update by running:
go version
You should see go version go1.26.4 followed by your operating system and architecture.
Still seeing the old version?
You must open a new terminal after installing. The old terminal session still has the previous PATH cached.
If the version is still wrong, try closing VS Code entirely and reopening it.
Help me troubleshoot my Go installation
ⓧ Command not found
Go is not installed yet. Let's install it now.
- Open https://go.dev/dl/ in your browser.
- Download the featured installer for your operating system. On Windows, this is go1.26.4.windows-amd64.msi. On macOS, download the .pkg file that matches your chip (ARM64 for Apple Silicon, AMD64 for Intel).
- Run the installer and accept all default settings. The installer automatically adds Go to your system PATH.
- Back in VS Code, close your current terminal and open a new one by pressing Ctrl+`.
- Verify the installation by running:
go version
You should see go version go1.26.4 followed by your operating system and architecture (for example, go version go1.26.4 windows/amd64).
Still seeing command not found?
Make sure you opened a new terminal after installing. The old terminal session does not pick up PATH changes.
On Windows, try closing VS Code completely and reopening it. The MSI installer sets the PATH system-wide, but VS Code needs a restart to see it.
Help me troubleshoot my Go installation
Install the Go extension for VS Code
The Go extension adds language support to VS Code, including code completion, error checking, and automatic formatting. This makes writing Go much smoother.
- In VS Code, open the Extensions panel by pressing Ctrl+Shift+X (Windows) or Cmd+Shift+X (macOS).
- Type Go in the search bar.
- Find the extension called Go by Go Team at Google (extension ID: golang.Go).
- Click Install.
When VS Code asks to install Go tools
After installing the extension, VS Code will display a notification asking you to install additional Go tools like gopls (the Go language server). Click Install All when you see this prompt.
These tools power features like auto-completion, inline error highlighting, and code formatting. If the notification disappears before you click it, open the Command Palette (Ctrl+Shift+P or Cmd+Shift+P) and search for Go: Install/Update Tools.
🙋♀️ Don't see the Go extension?
Make sure you are searching for Go (not "Golang" or "go lang"). The correct extension has the publisher name Go Team at Google underneath the title.
Help me find the right Go extension in VS Code
Create the project directory and initialize the Go module
Every Go project starts with a module. A module is Go's way of organizing your code and tracking dependencies.
- Switch back to your terminal in VS Code. Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir clinic-scanner
cd clinic-scanner
- Confirm the folder was created by running:
ls
The output should be empty because the folder is brand new. That is expected.
- Now open this folder in VS Code. Click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the clinic-scanner folder, and click Select Folder (Windows) or Open (macOS).
- Open a new terminal in this folder by pressing Ctrl+` (backtick).
- Initialize the Go module by running this command:
go mod init clinic-scanner
What does this command do?
The go mod init command creates a go.mod file in your project folder. This file declares the module name (clinic-scanner) and the Go version your project uses.
Think of it as the project's identity card. Go uses it to manage imports and dependencies.
- Confirm go.mod was created by running:
ls
You should see go.mod in the output.
- Now create your main source file. In the VS Code Explorer sidebar on the left, hover over the CLINIC-SCANNER heading.
- Click the New File icon (a page with a plus sign).
- Type main.go and press Enter.
Your Explorer sidebar should now show two files: go.mod and main.go.
Don't see go.mod in the sidebar?
Make sure you opened the clinic-scanner folder itself in VS Code, not its parent folder (your Desktop). The sidebar heading should read CLINIC-SCANNER.
If you ran go mod init clinic-scanner while your terminal was in a different directory, the go.mod file was created in the wrong location. Run pwd in your terminal to check your current directory.
Help me set up my Go project folder
✔️ Awesome, I've got everything!
Great. Double check that both go.mod and main.go are visible in the Explorer sidebar before moving on.
ⓧ I'd like to double check the full code
Click on go.mod in the Explorer sidebar. It should look like this:
module clinic-scanner
go 1.26.4
Your main.go file should be empty. You will start adding code to it in the next step.
Your environment is ready. Next up, you will build a basic TCP port scanner that checks individual ports on a target host.
Build a Basic TCP Port Scanner
Your Go environment is ready and your project folder is set up. Now it is time to build the core of your security scanner.
A network security scanner works by attempting to connect to specific TCP ports on a target machine. If the connection succeeds, the port is open and something is listening. If it fails, the port is closed or filtered. In this step, you will build a scanner that checks 18 common ports on a target host and identifies what service is running on each open port.
In this step, get ready to:
- Define the data structures that hold your scan results.
- Map common medical clinic network ports to their service names.
- Build the scanning logic and run the scanner against your own machine.
Set up the package and define scan results
Every Go program starts with a package declaration and its imports. Your scanner also needs a data structure to hold the results for each port it checks.
- Open main.go in VS Code and add the following code:
package main
import (
"flag"
"fmt"
"net"
"time"
)
// ScanResult holds the findings for a single port
type ScanResult struct {
Port int `json:"port"`
State string `json:"state"`
Service string `json:"service"`
Banner string `json:"banner,omitempty"`
}
What does this code do?
The import block pulls in four packages from Go's standard library. flag handles command-line arguments. fmt handles formatted printing. net provides networking functions for TCP connections. time provides duration types for connection timeouts.
The ScanResult struct stores what your scanner discovers about each port: its number, whether it is open or closed, the service name, and any banner text the service sends back. The backtick tags (like `json:"port"`) tell Go how to name each field when you export results to JSON later.
You won't see a change yet. The next chunk adds the port and service definitions your scanner needs.
Define the ports and services to scan
Medical clinic networks commonly run services like remote desktop access, databases for electronic health records, web-based patient portals, and file sharing. Your scanner needs to know which ports to check and what service each port typically hosts.
- In main.go, add the following code below the ScanResult struct:
// Common ports found on medical clinic networks
var commonPorts = []int{
22, 23, 25, 53, 80, 110, 135, 139,
443, 445, 993, 995, 1433, 3306,
3389, 5432, 8080, 8443,
}
// Maps port numbers to their known service names
var serviceMap = map[int]string{
22: "SSH",
23: "Telnet",
25: "SMTP",
53: "DNS",
80: "HTTP",
110: "POP3",
135: "MSRPC",
139: "NetBIOS",
443: "HTTPS",
445: "SMB",
993: "IMAPS",
995: "POP3S",
1433: "MSSQL",
3306: "MySQL",
3389: "RDP",
5432: "PostgreSQL",
8080: "HTTP-Proxy",
8443: "HTTPS-Alt",
}
// identifyService looks up the service name for a given port
func identifyService(port int, banner string) string {
if name, ok := serviceMap[port]; ok {
return name
}
return "Unknown"
}
What does this code do?
commonPorts is a slice of 18 port numbers commonly found on clinic networks. These cover remote access (SSH on 22, RDP on 3389), databases (MSSQL on 1433, MySQL on 3306, PostgreSQL on 5432), web services (HTTP on 80, HTTPS on 443), and file sharing (SMB on 445).
serviceMap pairs each port number with a human-readable service name. The identifyService function looks up a port in this map and returns its name. If the port is not in the map, it returns "Unknown".
You won't see a change yet. The next chunk adds the scanning logic and the CLI entry point that ties everything together.
Write the scanning logic and run the scanner
Your data structures are ready. Now you need a function that actually attempts a TCP connection to each port, and a main function that accepts a target host from the command line and kicks off the scan.
Only scan networks you own
Scanning networks you do not own or have explicit written authorization to scan is illegal in many jurisdictions. This project scans 127.0.0.1 (your own machine) by default. Only change the target to an IP address you are authorized to scan.
- In main.go, add the following code below the identifyService function:
// scanPort attempts a TCP connection to determine if a port is open
func scanPort(target string, port int, timeout time.Duration) ScanResult {
// Build the target address as host:port
address := fmt.Sprintf("%s:%d", target, port)
// Try to connect within the timeout window
conn, err := net.DialTimeout("tcp", address, timeout)
if err != nil {
// Connection failed: port is closed or filtered
return ScanResult{Port: port, State: "closed"}
}
defer conn.Close()
// Look up the service running on this port
service := identifyService(port, "")
return ScanResult{
Port: port,
State: "open",
Service: service,
}
}
func main() {
// Parse the -target flag from the command line
target := flag.String("target", "127.0.0.1", "Target host to scan")
flag.Parse()
fmt.Println("=== Clinic Network Security Scanner ===")
fmt.Printf("Target: %s\n", *target)
fmt.Printf("Ports: %d common ports\n\n", len(commonPorts))
// Scan each port one at a time
for _, port := range commonPorts {
result := scanPort(*target, port, 2*time.Second)
if result.State == "open" {
fmt.Printf("[OPEN] Port %-5d | Service: %s\n", result.Port, result.Service)
}
}
}
What does this code do?
scanPort uses net.DialTimeout to try opening a TCP connection to the target address. It waits up to 2 seconds for a response. If the connection succeeds, the port is open. If it times out or is refused, the port is closed.
The main function uses flag.String to accept a -target flag from the command line (defaulting to 127.0.0.1). It then loops through every port in commonPorts, scans each one sequentially, and prints any open ports to the console.
- Save main.go.
- Run your scanner by entering this command in the terminal:
go run main.go -target 127.0.0.1
You should see a header that says === Clinic Network Security Scanner === followed by the target and port count. If any ports are open on your machine, they appear as [OPEN] lines with their service name. The exact open ports vary depending on what software is running on your computer.
What if no ports show as open?
That is completely normal. If your machine has no services listening on the 18 ports the scanner checks, you will only see the header and nothing else. The scanner is working correctly. It just means those ports are closed on your machine.
🙋♀️ Seeing an error instead of scan results?
- Check that you saved main.go before running the command. Unsaved changes are not picked up by go run.
- If you see a syntax error with a line number, compare that line in your code against the code blocks above. A missing comma, brace, or quote is the most common cause.
- Make sure your terminal is open inside the clinic-scanner folder. Run ls (macOS/Linux) or dir (Windows) to confirm you see main.go and go.mod listed.
Help me debug a Go compile error in my port scanner
✔️ Awesome, I've got everything!
Great. Double check you have saved main.go before moving on.
ⓧ I'd like to double check the full code
Here is the complete main.go file. Compare your code against this and fix any differences.
Your file should contain the three chunks you built above, in order: the package declaration with imports and ScanResult struct, then commonPorts, serviceMap, and identifyService, then scanPort and main. Scroll up and confirm each block is present and saved.
Your scanner works, but it checks each port one at a time. With 18 ports and a 2-second timeout per port, a scan against an unresponsive host could take over 30 seconds. Next up, you will use Go's goroutines and channels to scan all ports at the same time.
Add Concurrent Scanning with Goroutines
In Step 2, you built a scanner that checks each port one at a time. That works, but with a 2-second timeout per port and 18 ports to check, the worst-case scan time is 36 seconds.
Go has a built-in concurrency model using lightweight threads called goroutines. In this step, you'll refactor your scanner to check all ports simultaneously, cutting scan time from seconds down to milliseconds.
In this step, get ready to:
- Add a -workers flag to control how many ports scan at the same time.
- Refactor the sequential loop into concurrent goroutines with channels.
- Measure scan duration to confirm the speed improvement.
Add the sync import and workers flag
Before writing the concurrent code, you need Go's sync package for coordinating goroutines. You also need a -workers flag so the user can control how many ports scan at once.
- In main.go, find the import block at the top of the file:
import (
"flag"
"fmt"
"net"
"time"
)
- Add "sync" between "net" and "time", so the import block looks like this:
import (
"flag"
"fmt"
"net"
"sync"
"time"
)
- Next, scroll down to the main function. Find these lines at the top of the function:
target := flag.String("target", "127.0.0.1", "Target host to scan")
flag.Parse()
fmt.Println("=== Clinic Network Security Scanner ===")
fmt.Printf("Target: %s\n", *target)
fmt.Printf("Ports: %d common ports\n\n", len(commonPorts))
- Replace those lines with the following code, which adds the workers flag and updates the header output:
target := flag.String("target", "127.0.0.1", "Target host to scan")
workers := flag.Int("workers", 50, "Number of concurrent workers")
flag.Parse()
fmt.Println("=== Clinic Network Security Scanner ===")
fmt.Printf("Target: %s\n", *target)
fmt.Printf("Ports: %d common ports\n", len(commonPorts))
fmt.Printf("Workers: %d\n\n", *workers)
What changed here?
You added a new workers flag using flag.Int. This lets the user pass -workers 20 to limit how many ports scan simultaneously. The default is 50.
The header output now prints the worker count so the user knows what concurrency level is active.
Replace the sequential loop with concurrent goroutines
Your current scanner calls scanPort one port at a time. With goroutines, you can launch all 18 scans at once and collect the results through a channel.
What are goroutines and channels?
A goroutine is a lightweight thread managed by Go's runtime. You launch one by putting go before a function call. Go can run thousands of goroutines simultaneously because they use far less memory than operating system threads.
A channel is a typed pipe that lets goroutines send data back to the main function safely. One goroutine sends a result into the channel, and another goroutine reads it out.
- Find the sequential for loop at the bottom of your main function:
for _, port := range commonPorts {
result := scanPort(*target, port, 2*time.Second)
if result.State == "open" {
fmt.Printf("[OPEN] Port %-5d | Service: %s\n", result.Port, result.Service)
}
}
- Delete that entire for loop. Replace it with the following code that sets up the concurrency infrastructure and launches goroutines:
// Record the scan start time
startTime := time.Now()
// Buffered channel to collect results from all goroutines
resultChan := make(chan ScanResult, len(commonPorts))
// Semaphore channel limits concurrent connections
semaphore := make(chan struct{}, *workers)
var wg sync.WaitGroup
// Launch a separate goroutine for each port
for _, port := range commonPorts {
wg.Add(1)
go func(p int) {
defer wg.Done()
// Acquire a semaphore slot before scanning
semaphore <- struct{}{}
defer func() { <-semaphore }()
// Send the result back through the channel
resultChan <- scanPort(*target, p, 2*time.Second)
}(port)
}
What does this code do?
- resultChan is a buffered channel with room for one result per port. Each goroutine sends its ScanResult into this channel when it finishes.
- semaphore is a buffered channel that acts as a rate limiter. Only 50 goroutines (the default worker count) can hold a slot at once. This prevents your scanner from overwhelming the target network, which is critical in a medical environment where disrupting services could affect patient care.
- wg.Add(1) increments the WaitGroup counter before each goroutine launches. defer wg.Done() decrements it when the goroutine finishes.
- go func(p int) launches an anonymous function as a goroutine. The port number is passed as parameter p to avoid a common Go closure bug where all goroutines would share the same loop variable.
You won't see a change yet. The next chunk collects the results and prints the scan duration.
- Directly below the goroutine loop you just added, paste this code to collect results and print the scan duration:
// Close the results channel once all goroutines finish
go func() {
wg.Wait()
close(resultChan)
}()
// Read results as they arrive
for result := range resultChan {
if result.State == "open" {
fmt.Printf("[OPEN] Port %-5d | Service: %s\n", result.Port, result.Service)
}
}
// Calculate and display total scan time
duration := time.Since(startTime)
fmt.Printf("\nScan completed in %s\n", duration)
What does this code do?
- The go func() block launches a separate goroutine that waits for all scans to finish (wg.Wait()), then closes the channel. Closing the channel signals to the range loop below that no more results are coming.
- for result := range resultChan reads each result from the channel as it arrives. It automatically stops when the channel is closed.
- time.Since(startTime) calculates how long the entire scan took. This lets you see the speed improvement over the sequential version.
💡 Why use a semaphore instead of unlimited goroutines?
Without the semaphore, all 18 goroutines would try to open TCP connections at the exact same moment. On a larger port list, hundreds of simultaneous connections could trigger intrusion detection systems or overwhelm a clinic's network equipment.
The buffered channel acts as a ticket system. Each goroutine must grab a ticket (send into the semaphore) before scanning and return it (receive from the semaphore) when done. The channel's capacity is the maximum number of simultaneous scans.
- Save main.go.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete main.go file as it should look at this point.
Your file should contain all the chunks from Step 2 plus the new sync import, the workers flag, the goroutine launch loop with the semaphore, and the result-collection block with time.Since. Scroll up and confirm each code block you added in this step is present and saved.
Run the concurrent scanner
Time to see the speed difference. The sequential scanner waited for each port to finish before moving to the next. The concurrent scanner checks them all at once.
- Run the scanner against your local machine by running this command:
go run main.go -target 127.0.0.1
- Check the output. You should see any open ports listed, followed by a Scan completed in line showing the total duration. The scan should complete in well under a second.
What should I see?
Your output will look something like this (the exact open ports depend on what services are running on your machine):
=== Clinic Network Security Scanner ===
Target: 127.0.0.1
Ports: 18 common ports
Workers: 50
[OPEN] Port 135 | Service: MSRPC
[OPEN] Port 445 | Service: SMB
Scan completed in 2.0123456s
The key difference from Step 2 is the total duration. The sequential version could take up to 36 seconds. The concurrent version finishes in roughly 2 seconds (one timeout cycle) because all ports are checked at the same time.
🙋♀️ Seeing an error when you run the scanner?
- If you see undefined: sync, make sure you added "sync" to your import block.
- If you see a syntax error near go func, check that the goroutine call ends with }(port). The (port) at the end passes the loop variable into the goroutine.
- If the scanner hangs and never finishes, make sure the go func() block that calls wg.Wait() and close(resultChan) is placed after the goroutine launch loop, not inside it.
Still stuck? Help me debug my concurrent goroutine scanner in Go. I'm getting an error when running go run main.go -target 127.0.0.1.
Your scanner now checks all ports concurrently and finishes in a fraction of the time. Next up, you'll add service fingerprinting to identify exactly what software is running on each open port.
Add Service Fingerprinting via Banner Grabbing
Your scanner can now sweep through all 18 ports in parallel using goroutines. Discovering open ports is useful, but knowing a port is open doesn't tell you what software is actually listening on it.
In this step, you'll add service fingerprinting through a technique called banner grabbing. When a service accepts a TCP connection, it often sends back a greeting message called a banner. Your scanner will read these banners to identify exactly what software is running on each open port.
In this step, get ready to:
- Write a grabBanner function that reads service banners from open ports.
- Update identifyService to detect services from banner content.
- Run the scanner and observe service names and banner data alongside open ports.
Implement the banner grabbing function
Some services announce themselves the moment you connect. An SSH server might send back SSH-2.0-OpenSSH_8.9. An HTTP server waits for a request first, then responds with a header. Your grabBanner function will handle both cases.
- In main.go, find the import block at the top of the file:
import (
"flag"
"fmt"
"net"
"sync"
"time"
)
- Add "bufio" at the top and "strings" after "net", so the import block looks like this:
import (
"bufio"
"flag"
"fmt"
"net"
"strings"
"sync"
"time"
)
- Add the grabBanner function below the serviceMap variable by copying this code:
func grabBanner(conn net.Conn, port int) string {
// Set a 3-second deadline so the read doesn't hang forever
conn.SetReadDeadline(time.Now().Add(3 * time.Second))
// HTTP services need a request before they'll respond
httpPorts := map[int]bool{80: true, 8080: true, 8443: true, 443: true}
if httpPorts[port] {
fmt.Fprintf(conn, "HEAD / HTTP/1.0\r\nHost: target\r\n\r\n")
}
// Try reading the first line of the response
reader := bufio.NewReader(conn)
banner, err := reader.ReadString('\n')
if err != nil && banner == "" {
// Fall back to raw byte read if line-based read fails
buf := make([]byte, 1024)
n, readErr := conn.Read(buf)
if readErr != nil {
return ""
}
banner = string(buf[:n])
}
// Clean up and truncate long banners
banner = strings.TrimSpace(banner)
if len(banner) > 200 {
banner = banner[:200]
}
return banner
}
What does this code do?
conn.SetReadDeadline gives each read attempt a 3-second window. Without this, the scanner would hang forever on ports that accept connections but never send data.
Services like SSH and FTP send a banner immediately when you connect. HTTP servers wait for a request first, so the function sends a minimal HEAD request to HTTP ports before reading.
The function tries ReadString to grab a line of text. If that fails (some services send binary data or no newline), it falls back to a raw 1024-byte read. The result is trimmed and capped at 200 characters.
- Save main.go.
- You won't see a change yet. The next update to scanPort will call this function.
Seeing a compile error?
Make sure you added both "bufio" and "strings" to your import block. Go will not compile if an imported package is missing or if an imported package is unused. If your Go extension shows a red underline on the imports, the function that uses them may not be saved yet.
help me fix a Go compile error in my grabBanner function
Update service identification with banner data
Your identifyService function currently looks up services by port number only. A port number gives you a guess, but the banner gives you proof. Checking the banner text first produces more accurate results.
- In main.go, find the existing identifyService function:
func identifyService(port int, banner string) string {
if name, ok := serviceMap[port]; ok {
return name
}
return "Unknown"
}
- Replace the entire function with this updated version that checks banner content first:
func identifyService(port int, banner string) string {
// Check the banner text first for a more accurate identification
if banner != "" {
bannerLower := strings.ToLower(banner)
switch {
case strings.Contains(bannerLower, "ssh"):
return "SSH"
case strings.Contains(bannerLower, "http"):
return "HTTP"
case strings.Contains(bannerLower, "ftp"):
return "FTP"
case strings.Contains(bannerLower, "smtp"):
return "SMTP"
case strings.Contains(bannerLower, "mysql"):
return "MySQL"
case strings.Contains(bannerLower, "postgresql"):
return "PostgreSQL"
}
}
// Fall back to the port-to-service map if no banner match
if name, ok := serviceMap[port]; ok {
return name
}
return "Unknown"
}
What changed in this function?
The function signature stays the same. The new logic adds a banner-first check before falling back to the port-based lookup.
strings.ToLower normalizes the banner to lowercase so a case-insensitive match catches variations like SSH-2.0 or ssh-2.0. The switch block checks for six common protocol keywords. If no keyword matches, the original port-to-service map takes over.
- Save main.go.
- You won't see a change yet. The next substep wires grabBanner into scanPort so both functions work together.
Connect banner grabbing to the scanner
Both functions are written but nothing calls them yet. You need to update scanPort to grab banners from open ports, then update the console output to display them.
- In main.go, scroll to the scanPort function and find these lines after defer conn.Close():
service := identifyService(port, "")
return ScanResult{
Port: port,
State: "open",
Service: service,
}
- Replace those lines with this updated version that calls grabBanner and passes the banner to identifyService:
// Get the initial service name from the port number
service := identifyService(port, "")
// Attempt to read a banner from the open port
banner := grabBanner(conn, port)
if banner != "" {
// Re-identify using the banner for a more accurate result
service = identifyService(port, banner)
}
return ScanResult{
Port: port,
State: "open",
Service: service,
Banner: banner,
}
What does this change do?
The scanner now grabs a banner immediately after confirming a port is open. If the banner is not empty, it re-runs identifyService with the banner text. This gives you two chances to identify the service: once by port number, then again with the actual banner data.
The Banner field is now populated in the ScanResult struct, so the banner data flows through the channel into the results loop.
- Now find the fmt.Printf line inside the for result := range resultChan loop in the main function:
fmt.Printf("[OPEN] Port %-5d | Service: %s\n", result.Port, result.Service)
- Replace it with this updated line that also prints the banner:
fmt.Printf("[OPEN] Port %-5d | Service: %-12s | Banner: %s\n",
result.Port, result.Service, result.Banner)
- Save main.go.
- Run your updated scanner by running this command in the terminal:
go run main.go -target 127.0.0.1
You should see open ports listed with a Service column and a Banner column. Some banners may be empty because not every service sends data on connection.
What if my banners are empty?
Empty banners are normal. Many services on 127.0.0.1 (your own machine) won't send banner text, especially Windows system services like MSRPC on port 135. The important thing is that the scanner attempts to grab banners and records whatever it finds.
If you scan a machine running an SSH or HTTP server, you'll see richer banner data.
🙋♀️ Scanner not running or showing errors?
If you see an error about an undefined function, check that grabBanner is defined outside of any other function. It should be its own standalone function, not nested inside scanPort or main.
If you see an error about unused imports, make sure your grabBanner and identifyService functions are saved. Go refuses to compile if an import is declared but not referenced anywhere.
help me debug my Go network scanner after adding banner grabbing
✔️ Awesome, I've got everything!
Great. Double check you've saved main.go before moving on.
ⓧ I'd like to double check the full code
Here is the full main.go as it should look at the end of this step:
Your file should contain all prior chunks plus the new bufio and strings imports, the grabBanner function, the updated identifyService with banner-based detection, and the updated scanPort that calls grabBanner. Scroll up and confirm each code block from this step is present and saved.
Your scanner can now see what software is running behind each open port. Next up, you'll turn all of this data into a structured JSON audit report that documents your findings for compliance records.
Generate a JSON Audit Report
Your scanner can discover open ports and identify running services through banner grabbing. That data is powerful, but right now it only lives in the terminal output.
A medical clinic compliance audit needs a permanent, structured record. In this step, you will generate a JSON report file that documents every finding with timestamps, service names, and banner data.
In this step, get ready to:
- Define a report struct to hold scan metadata and results.
- Marshal scan findings into formatted JSON and write them to a file.
- Print a scan summary to the console with the report file path.
Define the ScanReport struct
Your scan results need a container that holds both the findings and the metadata about the scan itself. A Go struct defines the exact shape of your JSON report, and JSON struct tags control how each field name appears in the output file.
- In main.go, find the import block at the top of the file:
import (
"bufio"
"flag"
"fmt"
"net"
"strings"
"sync"
"time"
)
- Add "encoding/json" and "os" to the list so the import block looks like this:
import (
"bufio"
"encoding/json"
"flag"
"fmt"
"net"
"os"
"strings"
"sync"
"time"
)
Why two new imports?
encoding/json provides json.MarshalIndent, which converts your Go structs into formatted JSON text.
os provides os.WriteFile, which writes the JSON output to a file on disk.
Now you need a struct that defines the shape of the report itself.
- In main.go, find the closing brace of the ScanResult struct (the } right before var commonPorts).
- Add the ScanReport struct directly below it by copying this code:
type ScanReport struct {
Target string `json:"target"`
ScanTime string `json:"scan_time"`
Duration string `json:"duration"`
TotalPorts int `json:"total_ports"`
OpenPorts int `json:"open_ports"`
Results []ScanResult `json:"results"`
}
What does this struct do?
ScanReport is the top-level container for your audit report. It captures metadata about the scan (target, timestamp, duration) alongside the actual findings.
- Target records which host was scanned.
- ScanTime and Duration record when the scan ran and how long it took.
- Results holds a slice of ScanResult structs for only the open ports.
- Save main.go.
You will not see a change yet. The next substep uses this struct to build the actual report.
Collect results and build the JSON report
The scanner currently prints open ports to the console but does not store them anywhere. You need a slice to collect results during the scan so you can pass them into your report struct afterwards.
- In the main function, find this line:
startTime := time.Now()
- Add this line directly below it to create an empty slice that will hold your results:
results := make([]ScanResult, 0)
Now you need to append each open port's result into this slice as the scan runs.
- Scroll down to the for result := range resultChan loop. Inside the if result.State == "open" block, find the fmt.Printf line:
fmt.Printf("[OPEN] Port %-5d | Service: %-12s | Banner: %s\n",
result.Port, result.Service, result.Banner)
- Add this line directly below the fmt.Printf call, still inside the if block:
results = append(results, result)
Now every open port is stored in the results slice for the report. Next, replace the old summary output with the report-building logic.
- Scroll to the bottom of the main function. Find these two lines:
duration := time.Since(startTime)
fmt.Printf("\nScan completed in %s\n", duration)
- Delete the fmt.Printf line but keep the duration line. Then add the following report-building code directly below duration := time.Since(startTime):
report := ScanReport{
Target: *target,
ScanTime: startTime.Format(time.RFC3339),
Duration: duration.String(),
TotalPorts: len(commonPorts),
OpenPorts: len(results),
Results: results,
}
// Convert the report struct to formatted JSON
reportJSON, err := json.MarshalIndent(report, "", " ")
if err != nil {
fmt.Printf("Error creating report: %v\n", err)
return
}
What does this code do?
- ScanReport{...} populates the struct with scan metadata. startTime.Format(time.RFC3339) produces a standardized timestamp like 2026-06-25T14:30:00Z.
- json.MarshalIndent converts the Go struct into a human-readable JSON string with two-space indentation. The empty first string means no prefix on each line.
- If marshaling fails for any reason, the error handler prints the problem and exits early with return.
- Save main.go.
You will not see a change yet. The JSON data is built in memory but not written anywhere. The next substep writes it to a file.
Seeing a compile error?
- Make sure you added "encoding/json" to the import block in the first substep. Without it, Go does not recognize json.MarshalIndent.
- Check that results is declared with results := make([]ScanResult, 0) before the goroutine loop. If it is missing, the compiler will report an undefined variable.
Help me debug a compile error in my Go network scanner
Write the report file and print a summary
The JSON report is ready in memory. Now you need to write it to a file on disk so the clinic has a permanent audit record. You will also print a quick summary to the console so the operator knows what happened.
- In main.go, add this code directly below the json.MarshalIndent error-handling block you just added:
// Build the report filename with target and timestamp
timestamp := startTime.Format("20060102_150405")
filename := fmt.Sprintf("scan_%s_%s.json", *target, timestamp)
err = os.WriteFile(filename, reportJSON, 0644)
if err != nil {
fmt.Printf("Error writing report: %v\n", err)
return
}
// Print a summary to the console
fmt.Println("\n=== Scan Complete ===")
fmt.Printf("Duration: %s\n", duration)
fmt.Printf("Ports scanned: %d\n", len(commonPorts))
fmt.Printf("Open ports: %d\n", len(results))
fmt.Printf("Report saved: %s\n", filename)
What does this code do?
- startTime.Format("20060102_150405") generates a compact timestamp for the filename. Go uses a reference date (January 2, 2006) as its format template instead of symbols like %Y%m%d.
- fmt.Sprintf("scan_%s_%s.json", ...) builds a filename like scan_127.0.0.1_20260625_143000.json. Each scan produces a unique file.
- os.WriteFile writes the JSON bytes to disk with 0644 permissions (readable by everyone, writable by the owner).
- The summary block prints key metrics so the operator can quickly see results without opening the file.
- Save main.go.
- Run the scanner against localhost by running this command in your terminal:
go run main.go -target 127.0.0.1
You should see the familiar open port output followed by a new summary block showing the duration, ports scanned, open ports found, and a line like Report saved: scan_127.0.0.1_<timestamp>.json.
- Look in the Explorer sidebar in VS Code. You should see a new .json file in your clinic-scanner folder.
- Click the JSON file to open it. You should see structured data with fields like target, scan_time, duration, and a results array containing each open port's details.
No JSON file appearing?
- Make sure you added "os" to your import block. Without it, os.WriteFile is undefined and the program will not compile.
- Check your terminal output for an error message. If you see something about permissions, make sure your terminal is running inside the clinic-scanner folder where you have write access.
- If the Explorer sidebar does not refresh automatically, right-click the folder and select Refresh Explorer.
Help me figure out why my JSON report file is not being created
✔️ Awesome, I've got everything!
Great. Double check you have saved main.go before moving on.
ⓧ I'd like to double check the full code
Your file should contain all prior chunks plus the new encoding/json and os imports, the ScanReport struct, the results slice, the json.MarshalIndent report block, and the os.WriteFile + summary output. Scroll up and confirm each code block from this step is present and saved.
Secret mission
Add Risk-Level Classification
Your scanner finds open ports and identifies services, but not all open ports carry the same risk. Telnet and FTP transmit credentials in plaintext, making them far more dangerous than encrypted services like SSH or HTTPS. Can you add risk-level classification that flags the most dangerous findings in your clinic's audit reports?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources, pause them to come back later, or delete them entirely.
Resources you used:
- clinic-scanner project folder on your Desktop (including main.go, go.mod, and any generated JSON scan reports).
- Go 1.26.4 installation.
- VS Code Go extension (golang.Go).
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with your scanner.
- Your clinic-scanner folder remains on your Desktop.
- Go and the VS Code Go extension stay installed for future projects.
Pause - I'll come back to this later
Close running processes to free up system memory. Your project files stay on disk for when you come back.
- Close VS Code to free up memory.
- Your clinic-scanner folder, Go installation, and VS Code Go extension all remain untouched.
Delete - I don't want to use this again
Remove all project resources and start fresh.
- Delete the clinic-scanner folder from your Desktop.
macOS
- Open a terminal and run:
rm -rf ~/Desktop/clinic-scanner
Windows
- Open PowerShell and run:
Remove-Item -Recurse -Force ~\Desktop\clinic-scanner
To uninstall Go from your Windows machine:
- Open Settings.
- Select Apps.
- Search for Go in the installed apps list.
- Click Uninstall to remove Go from your machine.
To uninstall the VS Code Go extension:
- Open the Extensions panel in VS Code by pressing Ctrl+Shift+X.
- Find the Go extension by Go Team at Google and click Uninstall.
Nice Work!
Nice Work!
Nice work! You just built a production-ready network security scanner in Go, using nothing but the standard library and zero external dependencies.
You've learned how to:
- Built a concurrent network scanner using Go's goroutines and channels to scan multiple ports simultaneously, running dramatically faster than sequential scanning.
- Implemented TCP banner grabbing to fingerprint services running on open ports, identifying what software is exposed on your network.
- Generated structured JSON audit reports with timestamps, service details, and scan metadata suitable for medical clinic compliance documentation.
- Secret Mission: Added risk-level classification that flags dangerous protocols like Telnet and FTP, helping prioritize remediation for your clinic's network security.
Ready to quiz yourself?