Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## 0.5.0 - 2026-07-22

### Added

- Add insecure-http scanner detecting websites served over plaintext HTTP

### Fixed

- Correct the documented default for the accessible-rdp scanner

## 0.4.5 - 2026-07-22

### Updated
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

![Release Version](https://img.shields.io/github/v/release/thomaslaurenson/prongs?style=flat&logo=github) ![Release downloads](https://img.shields.io/github/downloads/thomaslaurenson/prongs/total?label=downloads&logo=github)

![Go Version](https://img.shields.io/github/go-mod/go-version/thomaslaurenson/prongs?logo=go) ![Code Coverage](https://img.shields.io/badge/Coverage-95.7%25-blue?logo=go)
![Go Version](https://img.shields.io/github/go-mod/go-version/thomaslaurenson/prongs?logo=go) ![Code Coverage](https://img.shields.io/badge/Coverage-96.2%25-blue?logo=go)

Fast, custom security scanner.

Expand Down Expand Up @@ -54,15 +54,19 @@ Targets are CIDR ranges or single IPs, supplied via `--target` (repeatable and/o
| Name | Description | Default |
|---|---|---|
| `password-ssh` | Detects SSH servers accepting password authentication | yes |
| `accessible-rdp` | Detects RDP services accepting unauthenticated connections | yes |
| `accessible-rdp` | Detects RDP services accepting unauthenticated connections | no |
| `accessible-db` | Detects databases accepting unauthenticated connections | yes |
| `insecure-http` | Detects websites served over plaintext HTTP without an HTTPS redirect | yes |

### Examples

```bash
# Run one scanner against a single network
prongs scan --scanner password-ssh --target 192.168.0.0/24

# Detect websites served over plaintext HTTP
prongs scan --scanner insecure-http --target 192.168.0.0/24

# Run all default scanners against multiple networks
prongs scan --all --target 192.168.0.0/24 --target 10.0.0.0/24

Expand Down
95 changes: 95 additions & 0 deletions internal/scanner/insecure_http.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package scanner

import (
"net"
"net/http"
"net/url"
"strconv"
"time"

"github.com/thomaslaurenson/prongs/internal/config"
)

// httpPort is the single plaintext HTTP port probed. Kept as a named constant so
// a later expansion to other plaintext ports (8080, 8000) is a small change.
const httpPort = 80

// InsecureHTTP detects a website served over plaintext HTTP on port 80.
//
// It sends GET / to port 80 without following redirects and classifies the first
// response:
//
// - TCP closed, or the reply is not parseable HTTP: not a finding (nothing is
// listening, or a non-HTTP service occupies the port).
// - 3xx redirect to an absolute https:// URL: not a finding. Redirecting
// cleartext to HTTPS is the recommended configuration.
// - 3xx redirect to http://, a relative path, or a scheme-relative //host:
// a finding. The server redirects but keeps the client on plaintext.
// - 2xx: a finding. The site serves content over plaintext HTTP.
// - 401: a finding. A cleartext auth prompt carries credentials in the clear.
// - any other 4xx or 5xx: not a finding. An error stub is not proof the site is
// served over HTTP, and flagging it only adds noise.
//
// Scanning is by IP, so the request carries Host: <ip>. Name-based virtual hosts
// may therefore serve a default site rather than their real redirect behaviour;
// this is an inherent limitation of IP-based scanning.
type InsecureHTTP struct{}

func (s *InsecureHTTP) Name() string { return "insecure-http" }
func (s *InsecureHTTP) DefaultEnabled() bool { return true }

func (s *InsecureHTTP) Run(ip net.IP) (Result, bool) {
rawURL := "http://" + net.JoinHostPort(ip.String(), strconv.Itoa(httpPort)) + "/"
return s.probe(ip, rawURL)
}

// probe issues GET rawURL and, on a plaintext finding, returns a Result for ip.
func (s *InsecureHTTP) probe(ip net.IP, rawURL string) (Result, bool) {
client := &http.Client{
Timeout: time.Duration(config.DefaultTimeout) * time.Second,
// Do not follow redirects: we classify the first response ourselves so an
// https redirect (clean) is distinguished from one that stays on plaintext.
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
Transport: &http.Transport{DisableKeepAlives: true},
}

resp, err := client.Get(rawURL)
if err != nil {
return Result{}, false
}
defer resp.Body.Close()

if !servedOverPlaintext(resp) {
return Result{}, false
}

return Result{
Timestamp: time.Now().UTC(),
IP: ip,
ScanType: s.Name(),
Port: httpPort,
}, true
}

// servedOverPlaintext reports whether resp indicates the site is served over
// plaintext HTTP. See the InsecureHTTP doc comment for the full rule table.
func servedOverPlaintext(resp *http.Response) bool {
switch {
case resp.StatusCode >= 300 && resp.StatusCode < 400:
// Clean only when the redirect target is an absolute https URL. A relative,
// scheme-relative, http, missing, or unparseable Location keeps plaintext.
u, err := url.Parse(resp.Header.Get("Location"))
if err != nil {
return true
}
return u.Scheme != "https"
case resp.StatusCode >= 200 && resp.StatusCode < 300:
return true
case resp.StatusCode == http.StatusUnauthorized:
return true
default:
return false
}
}
155 changes: 155 additions & 0 deletions internal/scanner/insecure_http_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package scanner

import (
"net"
"net/http"
"net/http/httptest"
"testing"
)

func TestServedOverPlaintext(t *testing.T) {
t.Parallel()
tests := []struct {
name string
status int
location string
want bool
}{
{name: "200 serves content", status: 200, want: true},
{name: "204 no content is still plaintext", status: 204, want: true},
{name: "301 to https is clean", status: 301, location: "https://example.com/", want: false},
{name: "302 to https is clean", status: 302, location: "https://example.com/", want: false},
{name: "308 to uppercase https is clean", status: 308, location: "HTTPS://example.com/", want: false},
{name: "301 to http stays plaintext", status: 301, location: "http://example.com/", want: true},
{name: "302 relative path stays plaintext", status: 302, location: "/login", want: true},
{name: "302 scheme-relative stays plaintext", status: 302, location: "//example.com/", want: true},
{name: "302 missing location", status: 302, location: "", want: true},
{name: "302 unparseable location", status: 302, location: "http://a:b", want: true},
{name: "401 cleartext auth prompt", status: 401, want: true},
{name: "403 forbidden ignored", status: 403, want: false},
{name: "404 not found ignored", status: 404, want: false},
{name: "500 error ignored", status: 500, want: false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
resp := &http.Response{StatusCode: tc.status, Header: make(http.Header)}
if tc.location != "" {
resp.Header.Set("Location", tc.location)
}
if got := servedOverPlaintext(resp); got != tc.want {
t.Errorf("servedOverPlaintext(status=%d, location=%q) = %v, want %v",
tc.status, tc.location, got, tc.want)
}
})
}
}

func TestInsecureHTTPProbe(t *testing.T) {
t.Parallel()

// A TLS server used only as an https redirect target. The scanner must not
// follow the redirect, so this server is never actually contacted.
tlsServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer tlsServer.Close()

tests := []struct {
name string
handler http.HandlerFunc
want bool
}{
{
name: "serves content over http",
handler: func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) },
want: true,
},
{
name: "redirects to https",
handler: func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, tlsServer.URL, http.StatusMovedPermanently)
},
want: false,
},
{
name: "redirects to another http url",
handler: func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "http://example.com/", http.StatusFound)
},
want: true,
},
{
name: "cleartext auth prompt",
handler: func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("WWW-Authenticate", `Basic realm="x"`)
w.WriteHeader(http.StatusUnauthorized)
},
want: true,
},
{
name: "not found is ignored",
handler: func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) },
want: false,
},
}

s := &InsecureHTTP{}
ip := net.ParseIP("192.0.2.10")
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
server := httptest.NewServer(tc.handler)
defer server.Close()

res, found := s.probe(ip, server.URL+"/")
if found != tc.want {
t.Fatalf("probe found = %v, want %v", found, tc.want)
}
if found {
if res.ScanType != "insecure-http" {
t.Errorf("ScanType = %q, want insecure-http", res.ScanType)
}
if res.Port != httpPort {
t.Errorf("Port = %d, want %d", res.Port, httpPort)
}
if !res.IP.Equal(ip) {
t.Errorf("IP = %v, want %v", res.IP, ip)
}
}
})
}
}

func TestInsecureHTTPProbeConnectionRefused(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
url := server.URL + "/"
server.Close() // close so the connection is refused

s := &InsecureHTTP{}
if _, found := s.probe(net.ParseIP("192.0.2.10"), url); found {
t.Errorf("probe to closed server = found, want not found")
}
}

func TestInsecureHTTPRunNoServer(t *testing.T) {
t.Parallel()
// Nothing is expected on loopback port 80 in the test environment, so Run
// exercises the request path and returns no finding.
s := &InsecureHTTP{}
if _, found := s.Run(net.ParseIP("127.0.0.1")); found {
t.Errorf("Run against loopback:80 = found, want not found")
}
}

func TestInsecureHTTPMetadata(t *testing.T) {
t.Parallel()
s := &InsecureHTTP{}
if got := s.Name(); got != "insecure-http" {
t.Errorf("Name() = %q, want insecure-http", got)
}
if !s.DefaultEnabled() {
t.Errorf("DefaultEnabled() = false, want true")
}
}
1 change: 1 addition & 0 deletions internal/scanner/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ var All = []Scanner{
&PasswordSSH{},
&AccessibleRDP{},
&AccessibleDB{},
&InsecureHTTP{},
}

// ByName maps each scanner name to its implementation for O(1) lookup.
Expand Down
3 changes: 1 addition & 2 deletions internal/scanner/scanner.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,7 @@ type Result struct {

// Scanner is implemented by every scan module.
type Scanner interface {
// Name returns the scanner identifier used in -s flags and output.
// Must match the Python version exactly, e.g. "password-ssh".
// Name returns the scanner identifier used in --scanner flags and output.
Name() string

// DefaultEnabled returns false for scanners excluded from --all.
Expand Down