ghostport-cli 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ghostport/__init__.py +3 -0
- ghostport/__main__.py +4 -0
- ghostport/cli.py +111 -0
- ghostport/http_probe.py +133 -0
- ghostport/inspector.py +83 -0
- ghostport/models.py +154 -0
- ghostport/process.py +96 -0
- ghostport/py.typed +0 -0
- ghostport/redact.py +79 -0
- ghostport/render.py +137 -0
- ghostport/tcp.py +69 -0
- ghostport_cli-0.1.1.dist-info/METADATA +358 -0
- ghostport_cli-0.1.1.dist-info/RECORD +16 -0
- ghostport_cli-0.1.1.dist-info/WHEEL +4 -0
- ghostport_cli-0.1.1.dist-info/entry_points.txt +2 -0
- ghostport_cli-0.1.1.dist-info/licenses/LICENSE +21 -0
ghostport/__init__.py
ADDED
ghostport/__main__.py
ADDED
ghostport/cli.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Typer command-line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Annotated, NoReturn
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from ghostport import __version__
|
|
12
|
+
from ghostport.inspector import inspect_port
|
|
13
|
+
from ghostport.models import EXIT_USAGE, error_envelope
|
|
14
|
+
from ghostport.render import make_console, render
|
|
15
|
+
|
|
16
|
+
MIN_TIMEOUT = 0.05
|
|
17
|
+
MAX_TIMEOUT = 30.0
|
|
18
|
+
|
|
19
|
+
app = typer.Typer(
|
|
20
|
+
name="ghostport",
|
|
21
|
+
help="A tiny forensic tool for local TCP ports. Localhost only, read-only, no telemetry.",
|
|
22
|
+
no_args_is_help=True,
|
|
23
|
+
add_completion=False,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _version(value: bool) -> None:
|
|
28
|
+
if value:
|
|
29
|
+
typer.echo(f"ghostport {__version__}")
|
|
30
|
+
raise typer.Exit()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@app.callback()
|
|
34
|
+
def _root(
|
|
35
|
+
version: Annotated[
|
|
36
|
+
bool,
|
|
37
|
+
typer.Option("--version", callback=_version, is_eager=True, help="Show version and exit."),
|
|
38
|
+
] = False,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""GhostPort."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _fail(code: str, message: str, as_json: bool) -> NoReturn:
|
|
44
|
+
if as_json:
|
|
45
|
+
typer.echo(json.dumps(error_envelope(code, message), indent=2))
|
|
46
|
+
else:
|
|
47
|
+
make_console(stderr=True).print(
|
|
48
|
+
f"[gp.bad]error:[/] {message}", markup=True, highlight=False
|
|
49
|
+
)
|
|
50
|
+
raise typer.Exit(EXIT_USAGE)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _parse_port(raw: str, as_json: bool) -> int:
|
|
54
|
+
try:
|
|
55
|
+
port = int(raw.strip(), 10)
|
|
56
|
+
except ValueError:
|
|
57
|
+
_fail("invalid_port", f"invalid port {raw!r}: expected an integer from 1 to 65535", as_json)
|
|
58
|
+
if not 1 <= port <= 65535:
|
|
59
|
+
_fail("invalid_port", f"invalid port {port}: must be between 1 and 65535", as_json)
|
|
60
|
+
return port
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@app.command("inspect")
|
|
64
|
+
def inspect_command(
|
|
65
|
+
port: Annotated[str, typer.Argument(metavar="PORT", help="TCP port on localhost (1-65535).")],
|
|
66
|
+
http: Annotated[bool, typer.Option("--http", help="Send one bounded HTTP GET.")] = False,
|
|
67
|
+
expect_json: Annotated[
|
|
68
|
+
bool, typer.Option("--expect-json", help="Check that Content-Type is a JSON media type.")
|
|
69
|
+
] = False,
|
|
70
|
+
json_output: Annotated[
|
|
71
|
+
bool, typer.Option("--json", help="Emit stable machine-readable JSON.")
|
|
72
|
+
] = False,
|
|
73
|
+
verbose: Annotated[
|
|
74
|
+
bool, typer.Option("--verbose", "-v", help="Add diagnostics (sensitive values redacted).")
|
|
75
|
+
] = False,
|
|
76
|
+
timeout: Annotated[
|
|
77
|
+
float, typer.Option("--timeout", "-t", help="Per-operation timeout in seconds.")
|
|
78
|
+
] = 3.0,
|
|
79
|
+
path: Annotated[str, typer.Option("--path", help="Request path for --http.")] = "/",
|
|
80
|
+
) -> None:
|
|
81
|
+
"""Inspect a localhost TCP port: connectivity, owning process, optional HTTP probe."""
|
|
82
|
+
number = _parse_port(port, json_output)
|
|
83
|
+
if not MIN_TIMEOUT <= timeout <= MAX_TIMEOUT:
|
|
84
|
+
_fail(
|
|
85
|
+
"invalid_timeout",
|
|
86
|
+
f"timeout must be between {MIN_TIMEOUT:g} and {MAX_TIMEOUT:g} seconds",
|
|
87
|
+
json_output,
|
|
88
|
+
)
|
|
89
|
+
if expect_json and not http:
|
|
90
|
+
_fail("usage", "--expect-json requires --http", json_output)
|
|
91
|
+
if not path.startswith("/") or any(c in path for c in " \r\n\t"):
|
|
92
|
+
_fail("invalid_path", "--path must start with '/' and contain no whitespace", json_output)
|
|
93
|
+
|
|
94
|
+
result = inspect_port(
|
|
95
|
+
number, timeout=timeout, http=http, expect_json=expect_json, path=path, verbose=verbose
|
|
96
|
+
)
|
|
97
|
+
if json_output:
|
|
98
|
+
typer.echo(json.dumps(result.to_dict(verbose), indent=2))
|
|
99
|
+
else:
|
|
100
|
+
render(result, make_console(), verbose)
|
|
101
|
+
raise typer.Exit(result.exit_code)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def main() -> None:
|
|
105
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
106
|
+
sys.stdout.reconfigure(errors="replace")
|
|
107
|
+
app()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
if __name__ == "__main__":
|
|
111
|
+
main()
|
ghostport/http_probe.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Bounded HTTP probe: headers only, no redirects, no body download, no proxies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from ghostport import __version__
|
|
10
|
+
from ghostport.models import HttpResult, JsonCheck
|
|
11
|
+
from ghostport.redact import redact_header, redact_url, truncate
|
|
12
|
+
|
|
13
|
+
_JSON_MEDIA_TYPES = {"application/json", "text/json"}
|
|
14
|
+
MAX_HEADERS = 50
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def media_type(content_type: str | None) -> str | None:
|
|
18
|
+
if not content_type:
|
|
19
|
+
return None
|
|
20
|
+
return content_type.split(";", 1)[0].strip().lower() or None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def is_json_media_type(mt: str | None) -> bool:
|
|
24
|
+
return bool(mt) and (mt in _JSON_MEDIA_TYPES or mt.endswith("+json")) # type: ignore[union-attr]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def check_json(content_type: str | None) -> JsonCheck:
|
|
28
|
+
mt = media_type(content_type)
|
|
29
|
+
if mt is None:
|
|
30
|
+
return JsonCheck(
|
|
31
|
+
matched=False,
|
|
32
|
+
media_type=None,
|
|
33
|
+
message="no Content-Type header; cannot confirm a JSON response",
|
|
34
|
+
)
|
|
35
|
+
if is_json_media_type(mt):
|
|
36
|
+
return JsonCheck(
|
|
37
|
+
matched=True,
|
|
38
|
+
media_type=mt,
|
|
39
|
+
message=f"Content-Type '{mt}' is a JSON media type (body was not inspected)",
|
|
40
|
+
)
|
|
41
|
+
return JsonCheck(
|
|
42
|
+
matched=False,
|
|
43
|
+
media_type=mt,
|
|
44
|
+
message=(
|
|
45
|
+
f"Content-Type '{mt}' is not a JSON media type; "
|
|
46
|
+
"this alone does not identify the service"
|
|
47
|
+
),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _url_for(address: str, port: int, path: str) -> str:
|
|
52
|
+
host = f"[{address}]" if ":" in address else address
|
|
53
|
+
return f"http://{host}:{port}{path}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def probe_http(
|
|
57
|
+
address: str,
|
|
58
|
+
port: int,
|
|
59
|
+
timeout: float,
|
|
60
|
+
*,
|
|
61
|
+
path: str = "/",
|
|
62
|
+
expect_json: bool = False,
|
|
63
|
+
verbose: bool = False,
|
|
64
|
+
) -> HttpResult:
|
|
65
|
+
"""Issue one streamed GET and record response metadata.
|
|
66
|
+
|
|
67
|
+
The body is never read: leaving the stream context closes the connection.
|
|
68
|
+
``trust_env=False`` keeps environment proxies and netrc out of local diagnostics.
|
|
69
|
+
"""
|
|
70
|
+
url = _url_for(address, port, path)
|
|
71
|
+
shown_url = redact_url(url)
|
|
72
|
+
started = time.perf_counter()
|
|
73
|
+
try:
|
|
74
|
+
with (
|
|
75
|
+
httpx.Client(
|
|
76
|
+
timeout=httpx.Timeout(timeout),
|
|
77
|
+
follow_redirects=False,
|
|
78
|
+
trust_env=False,
|
|
79
|
+
headers={"User-Agent": f"ghostport/{__version__}", "Accept": "*/*"},
|
|
80
|
+
) as client,
|
|
81
|
+
client.stream("GET", url) as response,
|
|
82
|
+
):
|
|
83
|
+
elapsed = round((time.perf_counter() - started) * 1000, 2)
|
|
84
|
+
content_type = response.headers.get("content-type")
|
|
85
|
+
server = response.headers.get("server")
|
|
86
|
+
headers = (
|
|
87
|
+
tuple(
|
|
88
|
+
(name, redact_header(name, value))
|
|
89
|
+
for name, value in list(response.headers.items())[:MAX_HEADERS]
|
|
90
|
+
)
|
|
91
|
+
if verbose
|
|
92
|
+
else ()
|
|
93
|
+
)
|
|
94
|
+
return HttpResult(
|
|
95
|
+
status="ok",
|
|
96
|
+
url=shown_url,
|
|
97
|
+
status_code=response.status_code,
|
|
98
|
+
content_type=truncate(content_type) if content_type else None,
|
|
99
|
+
server=truncate(server) if server else None,
|
|
100
|
+
response_time_ms=elapsed,
|
|
101
|
+
json_check=check_json(content_type) if expect_json else None,
|
|
102
|
+
headers=headers,
|
|
103
|
+
)
|
|
104
|
+
except httpx.TimeoutException:
|
|
105
|
+
return _failure("timeout", shown_url, f"no HTTP response within {timeout:g}s", expect_json)
|
|
106
|
+
except httpx.ConnectError:
|
|
107
|
+
return _failure("error", shown_url, "connection failed before HTTP began", expect_json)
|
|
108
|
+
except (httpx.RemoteProtocolError, httpx.ReadError, httpx.WriteError):
|
|
109
|
+
return _failure(
|
|
110
|
+
"error",
|
|
111
|
+
shown_url,
|
|
112
|
+
"port is open but did not return a valid HTTP response "
|
|
113
|
+
"(it may speak another protocol, such as TLS or raw TCP, or reset the connection)",
|
|
114
|
+
expect_json,
|
|
115
|
+
)
|
|
116
|
+
except httpx.HTTPError as exc:
|
|
117
|
+
return _failure(
|
|
118
|
+
"error", shown_url, f"HTTP probe failed ({type(exc).__name__})", expect_json
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _failure(status: str, url: str, message: str, expect_json: bool) -> HttpResult:
|
|
123
|
+
check = (
|
|
124
|
+
JsonCheck(matched=None, media_type=None, message="not evaluated: no HTTP response")
|
|
125
|
+
if expect_json
|
|
126
|
+
else None
|
|
127
|
+
)
|
|
128
|
+
return HttpResult(
|
|
129
|
+
status=status, # type: ignore[arg-type]
|
|
130
|
+
url=url,
|
|
131
|
+
error=message,
|
|
132
|
+
json_check=check,
|
|
133
|
+
)
|
ghostport/inspector.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Orchestrates the independent TCP, process, and HTTP checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import platform
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
import psutil
|
|
11
|
+
|
|
12
|
+
from ghostport import __version__
|
|
13
|
+
from ghostport.http_probe import probe_http
|
|
14
|
+
from ghostport.models import (
|
|
15
|
+
EXIT_CONNECTION_FAILED,
|
|
16
|
+
EXIT_HTTP_CHECK_FAILED,
|
|
17
|
+
EXIT_OK,
|
|
18
|
+
HttpResult,
|
|
19
|
+
InspectionResult,
|
|
20
|
+
)
|
|
21
|
+
from ghostport.process import identify_process
|
|
22
|
+
from ghostport.tcp import LOOPBACK_ADDRESSES, probe_tcp
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _diagnostics(timeout: float, path: str) -> dict[str, Any]:
|
|
26
|
+
return {
|
|
27
|
+
"ghostport": __version__,
|
|
28
|
+
"python": platform.python_version(),
|
|
29
|
+
"platform": sys.platform,
|
|
30
|
+
"psutil": psutil.__version__,
|
|
31
|
+
"httpx": httpx.__version__,
|
|
32
|
+
"timeout_seconds": timeout,
|
|
33
|
+
"loopback_addresses": list(LOOPBACK_ADDRESSES),
|
|
34
|
+
"http": {
|
|
35
|
+
"method": "GET",
|
|
36
|
+
"path": path,
|
|
37
|
+
"follow_redirects": False,
|
|
38
|
+
"trust_env": False,
|
|
39
|
+
"reads_body": False,
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def inspect_port(
|
|
45
|
+
port: int,
|
|
46
|
+
*,
|
|
47
|
+
timeout: float = 3.0,
|
|
48
|
+
http: bool = False,
|
|
49
|
+
expect_json: bool = False,
|
|
50
|
+
path: str = "/",
|
|
51
|
+
verbose: bool = False,
|
|
52
|
+
) -> InspectionResult:
|
|
53
|
+
tcp = probe_tcp(port, timeout)
|
|
54
|
+
# Process lookup is independent: its failure never changes the TCP verdict.
|
|
55
|
+
process = identify_process(port, verbose=verbose)
|
|
56
|
+
|
|
57
|
+
http_result: HttpResult | None = None
|
|
58
|
+
if http:
|
|
59
|
+
if tcp.connected and tcp.address:
|
|
60
|
+
http_result = probe_http(
|
|
61
|
+
tcp.address, port, timeout, path=path, expect_json=expect_json, verbose=verbose
|
|
62
|
+
)
|
|
63
|
+
else:
|
|
64
|
+
http_result = HttpResult(status="skipped", error="TCP port is not open")
|
|
65
|
+
|
|
66
|
+
if not tcp.connected:
|
|
67
|
+
code = EXIT_CONNECTION_FAILED
|
|
68
|
+
elif http_result is not None and (
|
|
69
|
+
http_result.status != "ok"
|
|
70
|
+
or (http_result.json_check is not None and http_result.json_check.matched is not True)
|
|
71
|
+
):
|
|
72
|
+
code = EXIT_HTTP_CHECK_FAILED
|
|
73
|
+
else:
|
|
74
|
+
code = EXIT_OK
|
|
75
|
+
|
|
76
|
+
return InspectionResult(
|
|
77
|
+
port=port,
|
|
78
|
+
tcp=tcp,
|
|
79
|
+
process=process,
|
|
80
|
+
http=http_result,
|
|
81
|
+
exit_code=code,
|
|
82
|
+
diagnostics=_diagnostics(timeout, path) if verbose else None,
|
|
83
|
+
)
|
ghostport/models.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Result types and the stable JSON schema (schema_version 1)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
from ghostport import __version__
|
|
9
|
+
|
|
10
|
+
SCHEMA_VERSION = 1
|
|
11
|
+
TOOL_NAME = "ghostport"
|
|
12
|
+
|
|
13
|
+
EXIT_OK = 0
|
|
14
|
+
EXIT_CONNECTION_FAILED = 1
|
|
15
|
+
EXIT_USAGE = 2
|
|
16
|
+
EXIT_HTTP_CHECK_FAILED = 3
|
|
17
|
+
|
|
18
|
+
TcpStatus = Literal["open", "closed", "timeout", "error"]
|
|
19
|
+
ProcessStatus = Literal["identified", "partial", "unavailable", "not_found"]
|
|
20
|
+
HttpStatus = Literal["ok", "error", "timeout", "skipped"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class TcpResult:
|
|
25
|
+
status: TcpStatus
|
|
26
|
+
address: str | None = None
|
|
27
|
+
latency_ms: float | None = None
|
|
28
|
+
error: str | None = None
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def connected(self) -> bool:
|
|
32
|
+
return self.status == "open"
|
|
33
|
+
|
|
34
|
+
def to_dict(self) -> dict[str, Any]:
|
|
35
|
+
return {
|
|
36
|
+
"status": self.status,
|
|
37
|
+
"connected": self.connected,
|
|
38
|
+
"address": self.address,
|
|
39
|
+
"latency_ms": self.latency_ms,
|
|
40
|
+
"error": self.error,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class Listener:
|
|
46
|
+
pid: int | None
|
|
47
|
+
name: str | None = None
|
|
48
|
+
exe: str | None = None
|
|
49
|
+
cmdline: tuple[str, ...] | None = None
|
|
50
|
+
|
|
51
|
+
def to_dict(self, verbose: bool) -> dict[str, Any]:
|
|
52
|
+
data: dict[str, Any] = {"pid": self.pid, "name": self.name}
|
|
53
|
+
if verbose:
|
|
54
|
+
data["exe"] = self.exe
|
|
55
|
+
data["cmdline"] = list(self.cmdline) if self.cmdline is not None else None
|
|
56
|
+
return data
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class ProcessResult:
|
|
61
|
+
status: ProcessStatus
|
|
62
|
+
pid: int | None = None
|
|
63
|
+
name: str | None = None
|
|
64
|
+
reason: str | None = None
|
|
65
|
+
listeners: tuple[Listener, ...] = ()
|
|
66
|
+
|
|
67
|
+
def to_dict(self, verbose: bool) -> dict[str, Any]:
|
|
68
|
+
return {
|
|
69
|
+
"status": self.status,
|
|
70
|
+
"pid": self.pid,
|
|
71
|
+
"name": self.name,
|
|
72
|
+
"reason": self.reason,
|
|
73
|
+
"listeners": [item.to_dict(verbose) for item in self.listeners],
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class JsonCheck:
|
|
79
|
+
matched: bool | None
|
|
80
|
+
media_type: str | None
|
|
81
|
+
message: str
|
|
82
|
+
|
|
83
|
+
def to_dict(self) -> dict[str, Any]:
|
|
84
|
+
return {
|
|
85
|
+
"expected": True,
|
|
86
|
+
"matched": self.matched,
|
|
87
|
+
"media_type": self.media_type,
|
|
88
|
+
"message": self.message,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True)
|
|
93
|
+
class HttpResult:
|
|
94
|
+
status: HttpStatus
|
|
95
|
+
url: str | None = None
|
|
96
|
+
status_code: int | None = None
|
|
97
|
+
content_type: str | None = None
|
|
98
|
+
server: str | None = None
|
|
99
|
+
response_time_ms: float | None = None
|
|
100
|
+
error: str | None = None
|
|
101
|
+
json_check: JsonCheck | None = None
|
|
102
|
+
headers: tuple[tuple[str, str], ...] = field(default=())
|
|
103
|
+
|
|
104
|
+
def to_dict(self, verbose: bool) -> dict[str, Any]:
|
|
105
|
+
data: dict[str, Any] = {
|
|
106
|
+
"status": self.status,
|
|
107
|
+
"url": self.url,
|
|
108
|
+
"status_code": self.status_code,
|
|
109
|
+
"content_type": self.content_type,
|
|
110
|
+
"server": self.server,
|
|
111
|
+
"response_time_ms": self.response_time_ms,
|
|
112
|
+
"error": self.error,
|
|
113
|
+
"json_check": self.json_check.to_dict() if self.json_check else None,
|
|
114
|
+
}
|
|
115
|
+
if verbose:
|
|
116
|
+
data["headers"] = [{"name": n, "value": v} for n, v in self.headers]
|
|
117
|
+
return data
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True)
|
|
121
|
+
class InspectionResult:
|
|
122
|
+
port: int
|
|
123
|
+
tcp: TcpResult
|
|
124
|
+
process: ProcessResult
|
|
125
|
+
http: HttpResult | None
|
|
126
|
+
exit_code: int
|
|
127
|
+
diagnostics: dict[str, Any] | None = None
|
|
128
|
+
|
|
129
|
+
def to_dict(self, verbose: bool = False) -> dict[str, Any]:
|
|
130
|
+
data: dict[str, Any] = {
|
|
131
|
+
"schema_version": SCHEMA_VERSION,
|
|
132
|
+
"tool": TOOL_NAME,
|
|
133
|
+
"version": __version__,
|
|
134
|
+
"ok": self.exit_code == EXIT_OK,
|
|
135
|
+
"exit_code": self.exit_code,
|
|
136
|
+
"target": {"host": "localhost", "port": self.port},
|
|
137
|
+
"tcp": self.tcp.to_dict(),
|
|
138
|
+
"process": self.process.to_dict(verbose),
|
|
139
|
+
"http": self.http.to_dict(verbose) if self.http else None,
|
|
140
|
+
}
|
|
141
|
+
if verbose:
|
|
142
|
+
data["diagnostics"] = self.diagnostics
|
|
143
|
+
return data
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def error_envelope(code: str, message: str, exit_code: int = EXIT_USAGE) -> dict[str, Any]:
|
|
147
|
+
return {
|
|
148
|
+
"schema_version": SCHEMA_VERSION,
|
|
149
|
+
"tool": TOOL_NAME,
|
|
150
|
+
"version": __version__,
|
|
151
|
+
"ok": False,
|
|
152
|
+
"exit_code": exit_code,
|
|
153
|
+
"error": {"code": code, "message": message},
|
|
154
|
+
}
|
ghostport/process.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Best-effort listener identification. Never raises; never needs elevation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ipaddress
|
|
6
|
+
|
|
7
|
+
import psutil
|
|
8
|
+
|
|
9
|
+
from ghostport.models import Listener, ProcessResult
|
|
10
|
+
from ghostport.redact import redact_cmdline, redact_path
|
|
11
|
+
|
|
12
|
+
_WILDCARDS = {"", "0.0.0.0", "::"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _is_local_bind(ip: str) -> bool:
|
|
16
|
+
if ip in _WILDCARDS:
|
|
17
|
+
return True
|
|
18
|
+
try:
|
|
19
|
+
addr = ipaddress.ip_address(ip.split("%", 1)[0])
|
|
20
|
+
except ValueError:
|
|
21
|
+
return False
|
|
22
|
+
mapped = getattr(addr, "ipv4_mapped", None)
|
|
23
|
+
return (mapped or addr).is_loopback
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _describe(pid: int, verbose: bool) -> Listener:
|
|
27
|
+
name: str | None = None
|
|
28
|
+
exe: str | None = None
|
|
29
|
+
cmdline: tuple[str, ...] | None = None
|
|
30
|
+
try:
|
|
31
|
+
proc = psutil.Process(pid)
|
|
32
|
+
try:
|
|
33
|
+
name = proc.name()
|
|
34
|
+
except (psutil.Error, OSError):
|
|
35
|
+
name = None
|
|
36
|
+
if verbose:
|
|
37
|
+
try:
|
|
38
|
+
exe = redact_path(proc.exe()) or None
|
|
39
|
+
except (psutil.Error, OSError):
|
|
40
|
+
exe = None
|
|
41
|
+
try:
|
|
42
|
+
cmdline = redact_cmdline(proc.cmdline()) or None
|
|
43
|
+
except (psutil.Error, OSError):
|
|
44
|
+
cmdline = None
|
|
45
|
+
except (psutil.Error, OSError):
|
|
46
|
+
pass
|
|
47
|
+
return Listener(pid=pid, name=name, exe=exe, cmdline=cmdline)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def identify_process(port: int, verbose: bool = False) -> ProcessResult:
|
|
51
|
+
try:
|
|
52
|
+
connections = psutil.net_connections(kind="tcp")
|
|
53
|
+
except psutil.AccessDenied:
|
|
54
|
+
return ProcessResult(
|
|
55
|
+
"unavailable", reason="permission denied reading the system socket table"
|
|
56
|
+
)
|
|
57
|
+
except (psutil.Error, OSError, NotImplementedError) as exc:
|
|
58
|
+
return ProcessResult(
|
|
59
|
+
"unavailable", reason=f"could not read the socket table ({type(exc).__name__})"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
pids: list[int | None] = []
|
|
63
|
+
for conn in connections:
|
|
64
|
+
laddr = conn.laddr
|
|
65
|
+
if (
|
|
66
|
+
conn.status == psutil.CONN_LISTEN
|
|
67
|
+
and laddr
|
|
68
|
+
and laddr.port == port
|
|
69
|
+
and _is_local_bind(laddr.ip)
|
|
70
|
+
and conn.pid not in pids
|
|
71
|
+
):
|
|
72
|
+
pids.append(conn.pid)
|
|
73
|
+
|
|
74
|
+
if not pids:
|
|
75
|
+
return ProcessResult(
|
|
76
|
+
"not_found",
|
|
77
|
+
reason="no listening socket found for this port (it may belong to a process "
|
|
78
|
+
"this account cannot see, or have closed)",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
visible = [pid for pid in pids if pid is not None]
|
|
82
|
+
if not visible:
|
|
83
|
+
return ProcessResult(
|
|
84
|
+
"unavailable", reason="a listener exists but its owning PID is hidden by permissions"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
listeners = tuple(_describe(pid, verbose) for pid in visible)
|
|
88
|
+
primary = next((item for item in listeners if item.name), listeners[0])
|
|
89
|
+
if primary.name:
|
|
90
|
+
return ProcessResult("identified", pid=primary.pid, name=primary.name, listeners=listeners)
|
|
91
|
+
return ProcessResult(
|
|
92
|
+
"partial",
|
|
93
|
+
pid=primary.pid,
|
|
94
|
+
reason="process name is not readable with current permissions",
|
|
95
|
+
listeners=listeners,
|
|
96
|
+
)
|
ghostport/py.typed
ADDED
|
File without changes
|
ghostport/redact.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Redaction helpers so verbose output never leaks credentials."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
REDACTED = "***"
|
|
10
|
+
|
|
11
|
+
_SENSITIVE_NAME = re.compile(
|
|
12
|
+
r"auth|token|secret|passw|pwd|key|cookie|session|credential|bearer|signature|sig$",
|
|
13
|
+
re.IGNORECASE,
|
|
14
|
+
)
|
|
15
|
+
_QUERY_PAIR = re.compile(r"([?&;])([^=&;\s]+)=([^&;\s]*)")
|
|
16
|
+
_USERINFO = re.compile(r"(://)[^/@\s]+@")
|
|
17
|
+
|
|
18
|
+
MAX_VALUE_LEN = 200
|
|
19
|
+
MAX_ARGS = 20
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def is_sensitive_name(name: str) -> bool:
|
|
23
|
+
return bool(_SENSITIVE_NAME.search(name))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def truncate(value: str, limit: int = MAX_VALUE_LEN) -> str:
|
|
27
|
+
return value if len(value) <= limit else value[: limit - 3] + "..."
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def redact_text(value: str) -> str:
|
|
31
|
+
"""Mask URL credentials and sensitive query values inside free text."""
|
|
32
|
+
|
|
33
|
+
def _query(match: re.Match[str]) -> str:
|
|
34
|
+
sep, key, val = match.groups()
|
|
35
|
+
return f"{sep}{key}={REDACTED if is_sensitive_name(key) else val}"
|
|
36
|
+
|
|
37
|
+
return _QUERY_PAIR.sub(_query, _USERINFO.sub(r"\1" + REDACTED + "@", value))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def redact_header(name: str, value: str) -> str:
|
|
41
|
+
if is_sensitive_name(name):
|
|
42
|
+
return REDACTED
|
|
43
|
+
return truncate(redact_text(value))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def redact_url(url: str) -> str:
|
|
47
|
+
return redact_text(url)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def redact_path(path: str) -> str:
|
|
51
|
+
home = str(Path.home())
|
|
52
|
+
if home and path.lower().startswith(home.lower()):
|
|
53
|
+
return "~" + path[len(home) :]
|
|
54
|
+
return path
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def redact_cmdline(args: Iterable[str]) -> tuple[str, ...]:
|
|
58
|
+
"""Mask values of sensitive flags (`--token=x`, `--token x`, `KEY=x`) and URL userinfo."""
|
|
59
|
+
out: list[str] = []
|
|
60
|
+
mask_next = False
|
|
61
|
+
for raw in args:
|
|
62
|
+
if len(out) >= MAX_ARGS:
|
|
63
|
+
out.append("...")
|
|
64
|
+
break
|
|
65
|
+
if mask_next:
|
|
66
|
+
out.append(REDACTED)
|
|
67
|
+
mask_next = False
|
|
68
|
+
continue
|
|
69
|
+
if "=" in raw:
|
|
70
|
+
key, _, _val = raw.partition("=")
|
|
71
|
+
if is_sensitive_name(key):
|
|
72
|
+
out.append(f"{key}={REDACTED}")
|
|
73
|
+
continue
|
|
74
|
+
elif raw.startswith("-") and is_sensitive_name(raw):
|
|
75
|
+
out.append(raw)
|
|
76
|
+
mask_next = True
|
|
77
|
+
continue
|
|
78
|
+
out.append(truncate(redact_text(raw)))
|
|
79
|
+
return tuple(out)
|
ghostport/render.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Rich terminal rendering (pastel cybercore palette)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich import box
|
|
6
|
+
from rich.console import Console, Group, RenderableType
|
|
7
|
+
from rich.panel import Panel
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
from rich.theme import Theme
|
|
11
|
+
|
|
12
|
+
from ghostport import __version__
|
|
13
|
+
from ghostport.models import HttpResult, InspectionResult, ProcessResult, TcpResult
|
|
14
|
+
|
|
15
|
+
THEME = Theme(
|
|
16
|
+
{
|
|
17
|
+
"gp.title": "bold #c9b6ff",
|
|
18
|
+
"gp.label": "bold #a9d8ff",
|
|
19
|
+
"gp.ok": "#a8ffd8",
|
|
20
|
+
"gp.warn": "#ffe3a8",
|
|
21
|
+
"gp.bad": "#ff9fc2",
|
|
22
|
+
"gp.dim": "#9aa5c4",
|
|
23
|
+
}
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
_MARK = {"ok": ("[+]", "gp.ok"), "warn": ("[~]", "gp.warn"), "bad": ("[x]", "gp.bad")}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def make_console(**kwargs: object) -> Console:
|
|
30
|
+
return Console(theme=THEME, highlight=False, **kwargs) # type: ignore[arg-type]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _line(level: str, *parts: tuple[str, str | None]) -> Text:
|
|
34
|
+
mark, style = _MARK[level]
|
|
35
|
+
text = Text()
|
|
36
|
+
text.append(mark + " ", style=style)
|
|
37
|
+
for value, part_style in parts:
|
|
38
|
+
text.append(value, style=part_style)
|
|
39
|
+
return text
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _tcp_line(tcp: TcpResult) -> Text:
|
|
43
|
+
if tcp.connected:
|
|
44
|
+
return _line("ok", (f"open on {tcp.address}", None), (f" {tcp.latency_ms} ms", "gp.dim"))
|
|
45
|
+
level = "warn" if tcp.status == "timeout" else "bad"
|
|
46
|
+
return _line(level, (tcp.status, None), (f" {tcp.error or ''}", "gp.dim"))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _process_line(proc: ProcessResult) -> Text:
|
|
50
|
+
if proc.status == "identified":
|
|
51
|
+
return _line("ok", (f"{proc.name}", "bold"), (f" pid {proc.pid}", "gp.dim"))
|
|
52
|
+
if proc.status == "partial":
|
|
53
|
+
return _line(
|
|
54
|
+
"warn", (f"pid {proc.pid}", None), (f" name unavailable: {proc.reason}", "gp.dim")
|
|
55
|
+
)
|
|
56
|
+
label = "no listener found" if proc.status == "not_found" else "unavailable"
|
|
57
|
+
return _line("warn", (label, None), (f" {proc.reason}", "gp.dim"))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _http_line(http: HttpResult) -> Text:
|
|
61
|
+
if http.status == "ok":
|
|
62
|
+
text = _line("ok", (f"{http.status_code}", "bold"))
|
|
63
|
+
text.append(f" {http.content_type or 'no content-type'}", style=None)
|
|
64
|
+
text.append(f" {http.response_time_ms} ms", style="gp.dim")
|
|
65
|
+
text.append(f" server: {http.server or 'not sent'}", style="gp.dim")
|
|
66
|
+
return text
|
|
67
|
+
level = "warn" if http.status in ("timeout", "skipped") else "bad"
|
|
68
|
+
return _line(level, (http.status, None), (f" {http.error or ''}", "gp.dim"))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _verdict(result: InspectionResult) -> Text:
|
|
72
|
+
if not result.tcp.connected:
|
|
73
|
+
return Text("Nothing accepted a localhost connection on this port.", style="gp.bad")
|
|
74
|
+
if result.exit_code == 0:
|
|
75
|
+
return Text(
|
|
76
|
+
"Port is accepting connections. These checks do not conclusively identify the service.",
|
|
77
|
+
style="gp.ok",
|
|
78
|
+
)
|
|
79
|
+
return Text(
|
|
80
|
+
"Port is open, but the HTTP check did not pass. This does not identify the service.",
|
|
81
|
+
style="gp.warn",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def render(result: InspectionResult, console: Console, verbose: bool = False) -> None:
|
|
86
|
+
grid = Table.grid(padding=(0, 2))
|
|
87
|
+
grid.add_column(style="gp.label", no_wrap=True)
|
|
88
|
+
grid.add_column()
|
|
89
|
+
grid.add_row("TCP", _tcp_line(result.tcp))
|
|
90
|
+
grid.add_row("PROCESS", _process_line(result.process))
|
|
91
|
+
if result.http is not None:
|
|
92
|
+
grid.add_row("HTTP", _http_line(result.http))
|
|
93
|
+
if result.http.json_check is not None:
|
|
94
|
+
check = result.http.json_check
|
|
95
|
+
level = "ok" if check.matched else ("bad" if check.matched is False else "warn")
|
|
96
|
+
grid.add_row("JSON", _line(level, (check.message, None)))
|
|
97
|
+
|
|
98
|
+
body: list[RenderableType] = [grid, Text(), _verdict(result)]
|
|
99
|
+
if verbose:
|
|
100
|
+
body.append(Text())
|
|
101
|
+
body.append(_verbose_table(result))
|
|
102
|
+
|
|
103
|
+
title = Text.assemble(
|
|
104
|
+
("ghostport", "gp.title"),
|
|
105
|
+
(f" v{__version__}", "gp.dim"),
|
|
106
|
+
(f" localhost:{result.port}", None),
|
|
107
|
+
)
|
|
108
|
+
console.print(
|
|
109
|
+
Panel(
|
|
110
|
+
Group(*body),
|
|
111
|
+
title=title,
|
|
112
|
+
title_align="left",
|
|
113
|
+
box=box.ROUNDED,
|
|
114
|
+
border_style="#c9b6ff",
|
|
115
|
+
padding=(1, 2),
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _verbose_table(result: InspectionResult) -> Table:
|
|
121
|
+
table = Table.grid(padding=(0, 2))
|
|
122
|
+
table.add_column(style="gp.dim", no_wrap=True)
|
|
123
|
+
table.add_column(overflow="fold")
|
|
124
|
+
for listener in result.process.listeners:
|
|
125
|
+
table.add_row("listener", f"pid={listener.pid} name={listener.name}")
|
|
126
|
+
if listener.exe:
|
|
127
|
+
table.add_row(" exe", listener.exe)
|
|
128
|
+
if listener.cmdline:
|
|
129
|
+
table.add_row(" cmdline", " ".join(listener.cmdline))
|
|
130
|
+
if result.http and result.http.url:
|
|
131
|
+
table.add_row("url", result.http.url)
|
|
132
|
+
if result.http:
|
|
133
|
+
for name, value in result.http.headers:
|
|
134
|
+
table.add_row(f" {name}", value)
|
|
135
|
+
for key, value in (result.diagnostics or {}).items():
|
|
136
|
+
table.add_row(key, str(value))
|
|
137
|
+
return table
|
ghostport/tcp.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Localhost-only TCP connectivity probe."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import socket
|
|
6
|
+
import time
|
|
7
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
from ghostport.models import TcpResult
|
|
11
|
+
|
|
12
|
+
LOOPBACK_ADDRESSES: tuple[str, ...] = ("127.0.0.1", "::1")
|
|
13
|
+
|
|
14
|
+
Outcome = tuple[Literal["open", "refused", "timeout", "error"], str, float, str | None]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _family(address: str) -> socket.AddressFamily:
|
|
18
|
+
return socket.AF_INET6 if ":" in address else socket.AF_INET
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _attempt(address: str, port: int, timeout: float) -> Outcome:
|
|
22
|
+
sock: socket.socket | None = None
|
|
23
|
+
started = time.perf_counter()
|
|
24
|
+
try:
|
|
25
|
+
sock = socket.socket(_family(address), socket.SOCK_STREAM)
|
|
26
|
+
sock.settimeout(timeout)
|
|
27
|
+
sock.connect((address, port))
|
|
28
|
+
except ConnectionRefusedError:
|
|
29
|
+
return ("refused", address, 0.0, None)
|
|
30
|
+
except TimeoutError:
|
|
31
|
+
return ("timeout", address, 0.0, None)
|
|
32
|
+
except OSError as exc:
|
|
33
|
+
# e.g. IPv6 loopback unavailable; only matters if nothing else answered.
|
|
34
|
+
return ("error", address, 0.0, f"{address}: {exc.strerror or type(exc).__name__}")
|
|
35
|
+
finally:
|
|
36
|
+
if sock is not None:
|
|
37
|
+
sock.close()
|
|
38
|
+
return ("open", address, round((time.perf_counter() - started) * 1000, 2), None)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def probe_tcp(
|
|
42
|
+
port: int,
|
|
43
|
+
timeout: float,
|
|
44
|
+
addresses: tuple[str, ...] = LOOPBACK_ADDRESSES,
|
|
45
|
+
) -> TcpResult:
|
|
46
|
+
"""Probe loopback addresses concurrently; the first successful connect wins.
|
|
47
|
+
|
|
48
|
+
Only loopback addresses are ever contacted. Attempts run in parallel because
|
|
49
|
+
Windows can take about two seconds to report a refused loopback connection.
|
|
50
|
+
"""
|
|
51
|
+
outcomes: list[Outcome] = []
|
|
52
|
+
pool = ThreadPoolExecutor(max_workers=len(addresses))
|
|
53
|
+
try:
|
|
54
|
+
futures = [pool.submit(_attempt, addr, port, timeout) for addr in addresses]
|
|
55
|
+
for future in as_completed(futures):
|
|
56
|
+
outcome = future.result()
|
|
57
|
+
if outcome[0] == "open":
|
|
58
|
+
return TcpResult("open", address=outcome[1], latency_ms=outcome[2])
|
|
59
|
+
outcomes.append(outcome)
|
|
60
|
+
finally:
|
|
61
|
+
pool.shutdown(wait=False, cancel_futures=True)
|
|
62
|
+
|
|
63
|
+
kinds = {o[0] for o in outcomes}
|
|
64
|
+
if "timeout" in kinds:
|
|
65
|
+
return TcpResult("timeout", error=f"no response within {timeout:g}s")
|
|
66
|
+
if "refused" in kinds:
|
|
67
|
+
return TcpResult("closed", error="connection refused")
|
|
68
|
+
errors = [o[3] for o in outcomes if o[3]]
|
|
69
|
+
return TcpResult("error", error=errors[0] if errors else "unable to connect")
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: ghostport-cli
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: A tiny forensic CLI for diagnosing local TCP ports and unexpected HTTP services.
|
|
5
|
+
Project-URL: Homepage, https://github.com/CyberStudiosTM/GhostPort
|
|
6
|
+
Project-URL: Issues, https://github.com/CyberStudiosTM/GhostPort/issues
|
|
7
|
+
Author: CyberStudios
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: cli,developer-tools,diagnostics,http,localhost,ports
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: MacOS
|
|
15
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
16
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: System :: Networking :: Monitoring
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.11
|
|
24
|
+
Requires-Dist: httpx>=0.27
|
|
25
|
+
Requires-Dist: psutil>=5.9
|
|
26
|
+
Requires-Dist: rich>=13.7
|
|
27
|
+
Requires-Dist: typer>=0.12
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# 👻 GhostPort
|
|
34
|
+
|
|
35
|
+
<div align="center">
|
|
36
|
+
|
|
37
|
+
**A little ghost in your localhost.**
|
|
38
|
+
|
|
39
|
+
*Find out what's listening, who's behind it, and whether it's answering the way you expected.*
|
|
40
|
+
|
|
41
|
+
**A CyberStudios™ developer utility**
|
|
42
|
+
|
|
43
|
+

|
|
44
|
+

|
|
45
|
+

|
|
46
|
+

|
|
47
|
+
|
|
48
|
+
[Getting Started](#-getting-started) · [Commands](#-commands) · [How It Works](#-how-it-works) · [Safety](#-safety--privacy) · [Contributing](CONTRIBUTING.md)
|
|
49
|
+
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## ✦ Welcome to GhostPort
|
|
55
|
+
|
|
56
|
+
Somewhere inside your computer, a service is listening.
|
|
57
|
+
|
|
58
|
+
Maybe it's your development server. Maybe it's an MCP endpoint. Maybe something else has occupied the port you expected to use.
|
|
59
|
+
|
|
60
|
+
**GhostPort helps you investigate.**
|
|
61
|
+
|
|
62
|
+
It's a lightweight, local-first command-line utility that inspects TCP ports on your own machine, identifies listening processes when possible, and checks how local HTTP services respond.
|
|
63
|
+
|
|
64
|
+
No dashboards. No cloud account. No background daemon.
|
|
65
|
+
|
|
66
|
+
Just you, your terminal, and a little ghost with questions.
|
|
67
|
+
|
|
68
|
+
### What can it do?
|
|
69
|
+
|
|
70
|
+
- **Inspect local ports.** Check whether a TCP port accepts connections.
|
|
71
|
+
- **Identify processes.** Find the associated PID and process name when the operating system permits.
|
|
72
|
+
- **Probe HTTP services.** Inspect status codes, content types, server headers, and response times.
|
|
73
|
+
- **Check JSON expectations.** Find out when a service isn't returning the expected JSON media type.
|
|
74
|
+
- **Generate structured reports.** Use machine-readable JSON output in scripts and developer workflows.
|
|
75
|
+
- **Keep diagnostics private.** No telemetry, external requests, or process termination.
|
|
76
|
+
|
|
77
|
+
GhostPort is an independent CyberStudios™ project. It does not require CyberCore or any other CyberStudios software.
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## 🌱 Getting Started
|
|
82
|
+
|
|
83
|
+
**Requirements:** Python 3.11 or newer.
|
|
84
|
+
|
|
85
|
+
### Install from PyPI
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
python -m pip install ghostport-cli
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
The PyPI package is named `ghostport-cli`; the command and import name are `ghostport`.
|
|
92
|
+
|
|
93
|
+
### Install from GitHub
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
python -m pip install git+https://github.com/CyberStudiosTM/GhostPort.git
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Install for development
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
git clone https://github.com/CyberStudiosTM/GhostPort.git
|
|
103
|
+
cd GhostPort
|
|
104
|
+
python -m pip install -e ".[dev]"
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Your first inspection
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
ghostport inspect 8080
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Check the HTTP response:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
ghostport inspect 8080 --http
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Expect a JSON response:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
ghostport inspect 8080 --http --expect-json
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Export a report:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
ghostport inspect 8080 --json
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
> **Note:** A successful connection doesn't prove which service owns a port. GhostPort reports the evidence it can observe without inventing an identity.
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## 🖥️ Commands
|
|
136
|
+
|
|
137
|
+
```text
|
|
138
|
+
ghostport inspect PORT [--http] [--expect-json] [--json] [--verbose]
|
|
139
|
+
[--timeout SECONDS] [--path /PATH]
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
| Option | Description |
|
|
143
|
+
|---|---|
|
|
144
|
+
| `--http` | Send one bounded HTTP GET request and inspect response headers. |
|
|
145
|
+
| `--expect-json` | Validate that the HTTP content type is a supported JSON media type. Requires `--http`. |
|
|
146
|
+
| `--json` | Return structured, machine-readable output. |
|
|
147
|
+
| `--verbose`, `-v` | Display additional diagnostics with sensitive values redacted. |
|
|
148
|
+
| `--timeout`, `-t` | Set the per-operation timeout from 0.05 to 30 seconds. Default: 3 seconds. |
|
|
149
|
+
| `--path` | Specify the HTTP request path. Default: `/`. |
|
|
150
|
+
|
|
151
|
+
### Example investigations
|
|
152
|
+
|
|
153
|
+
**Is my development server listening?**
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
ghostport inspect 3000
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
**What's responding on my API port?**
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
ghostport inspect 8000 --http
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
**Is this endpoint returning JSON?**
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
ghostport inspect 8000 --http --expect-json
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
**I need a report for another tool.**
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
ghostport inspect 8000 --http --json
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
**Show additional diagnostics.**
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
ghostport inspect 5432 --verbose
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## ✧ Example Output
|
|
186
|
+
|
|
187
|
+
This example comes from a local GhostPort test against a Python HTTP server.
|
|
188
|
+
|
|
189
|
+
```text
|
|
190
|
+
$ ghostport inspect 8765 --http --expect-json
|
|
191
|
+
|
|
192
|
+
ghostport v0.1.0 · localhost:8765
|
|
193
|
+
|
|
194
|
+
TCP [+] open on 127.0.0.1 · 1.11 ms
|
|
195
|
+
PROCESS [+] python.exe · pid 17724
|
|
196
|
+
HTTP [+] 200 · text/html; charset=utf-8
|
|
197
|
+
403.44 ms · SimpleHTTP/0.6 Python/3.11.9
|
|
198
|
+
JSON [x] Content-Type is not a JSON media type
|
|
199
|
+
|
|
200
|
+
Port is open, but the HTTP check did not pass.
|
|
201
|
+
This does not identify the service.
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
The actual terminal interface uses rounded panels and a pastel color palette. GhostPort also respects `NO_COLOR`.
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## 🔎 How It Works
|
|
209
|
+
|
|
210
|
+
GhostPort separates its diagnostics into three independent stages.
|
|
211
|
+
|
|
212
|
+
### 01 / TCP inspection
|
|
213
|
+
|
|
214
|
+
Attempts a connection to the requested port using IPv4 and IPv6 loopback addresses.
|
|
215
|
+
|
|
216
|
+
Reports whether the connection succeeds, fails, or times out.
|
|
217
|
+
|
|
218
|
+
### 02 / Process inspection
|
|
219
|
+
|
|
220
|
+
Attempts to identify the listening process using available operating-system information.
|
|
221
|
+
|
|
222
|
+
Process identification may be unavailable due to permissions or platform limitations. That does not change the TCP result.
|
|
223
|
+
|
|
224
|
+
### 03 / HTTP inspection
|
|
225
|
+
|
|
226
|
+
When requested, sends a single HTTP GET request and inspects the response headers.
|
|
227
|
+
|
|
228
|
+
GhostPort does not read or display the response body, follow redirects, or infer a service's identity from its content type.
|
|
229
|
+
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
## 📡 JSON Reports
|
|
233
|
+
|
|
234
|
+
GhostPort provides a stable, versioned JSON report format.
|
|
235
|
+
|
|
236
|
+
```json
|
|
237
|
+
{
|
|
238
|
+
"schema_version": 1,
|
|
239
|
+
"tool": "ghostport",
|
|
240
|
+
"version": "0.1.0",
|
|
241
|
+
"ok": true,
|
|
242
|
+
"exit_code": 0,
|
|
243
|
+
"target": {
|
|
244
|
+
"host": "localhost",
|
|
245
|
+
"port": 8765
|
|
246
|
+
},
|
|
247
|
+
"tcp": {
|
|
248
|
+
"status": "open",
|
|
249
|
+
"connected": true,
|
|
250
|
+
"address": "127.0.0.1",
|
|
251
|
+
"latency_ms": 2.7,
|
|
252
|
+
"error": null
|
|
253
|
+
},
|
|
254
|
+
"process": {
|
|
255
|
+
"status": "identified",
|
|
256
|
+
"pid": 17724,
|
|
257
|
+
"name": "python.exe",
|
|
258
|
+
"reason": null,
|
|
259
|
+
"listeners": []
|
|
260
|
+
},
|
|
261
|
+
"http": null
|
|
262
|
+
}
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
The `tcp`, `process`, and `http` sections remain independent. Unavailable values are represented as `null`.
|
|
266
|
+
|
|
267
|
+
Supported status values:
|
|
268
|
+
|
|
269
|
+
- TCP: `open`, `closed`, `timeout`, `error`
|
|
270
|
+
- Process: `identified`, `partial`, `unavailable`, `not_found`
|
|
271
|
+
- HTTP: `ok`, `error`, `timeout`, `skipped`
|
|
272
|
+
|
|
273
|
+
When `--http` is enabled, the HTTP section includes the URL, status code, content type, server, response time, error information, and optional JSON validation result.
|
|
274
|
+
|
|
275
|
+
Verbose output may additionally include redacted diagnostics, listener details, and HTTP headers.
|
|
276
|
+
|
|
277
|
+
Invalid input returns a JSON error object containing the schema version, tool name, version, failure status, exit code, and structured error.
|
|
278
|
+
|
|
279
|
+
Additive schema changes preserve `schema_version: 1`. Breaking changes require a new schema version.
|
|
280
|
+
|
|
281
|
+
---
|
|
282
|
+
|
|
283
|
+
## 🚦 Exit Codes
|
|
284
|
+
|
|
285
|
+
| Code | Meaning |
|
|
286
|
+
|---|---|
|
|
287
|
+
| `0` | TCP connection succeeded and requested HTTP checks passed. |
|
|
288
|
+
| `1` | TCP connection failed, timed out, or encountered an error. |
|
|
289
|
+
| `2` | Invalid arguments or unsupported option combinations. |
|
|
290
|
+
| `3` | TCP succeeded, but the requested HTTP or JSON check failed. |
|
|
291
|
+
|
|
292
|
+
HTTP status codes such as `404` or `500` still count as valid HTTP responses for exit-code purposes.
|
|
293
|
+
|
|
294
|
+
Failure to identify the process does not change the TCP result.
|
|
295
|
+
|
|
296
|
+
---
|
|
297
|
+
|
|
298
|
+
## 🔐 Safety & Privacy
|
|
299
|
+
|
|
300
|
+
GhostPort is designed for local diagnostics.
|
|
301
|
+
|
|
302
|
+
- Connects only to `127.0.0.1` and `::1`.
|
|
303
|
+
- Does not offer arbitrary host scanning.
|
|
304
|
+
- Never kills, signals, or modifies processes.
|
|
305
|
+
- Does not require administrator privileges.
|
|
306
|
+
- Does not send telemetry or contact external services.
|
|
307
|
+
- Does not follow HTTP redirects.
|
|
308
|
+
- Does not read or display HTTP response bodies.
|
|
309
|
+
- Disables environment proxy inheritance for HTTP probes.
|
|
310
|
+
- Redacts sensitive headers, credentials, query values, and command-line arguments in supported verbose diagnostics.
|
|
311
|
+
|
|
312
|
+
For vulnerability reporting, see [SECURITY.md](SECURITY.md).
|
|
313
|
+
|
|
314
|
+
---
|
|
315
|
+
|
|
316
|
+
## ☁️ Known Limitations
|
|
317
|
+
|
|
318
|
+
GhostPort is intentionally small and transparent about its capabilities.
|
|
319
|
+
|
|
320
|
+
- Process identification depends on OS permissions.
|
|
321
|
+
- macOS socket inspection may require elevated permissions; GhostPort continues when process information is unavailable.
|
|
322
|
+
- Some Windows system processes may expose a PID without a process name.
|
|
323
|
+
- Closed loopback ports may take approximately two seconds to report on Windows.
|
|
324
|
+
- The default timeout is three seconds and applies per network operation, not as a total execution deadline.
|
|
325
|
+
- A JSON content type does not conclusively identify a service.
|
|
326
|
+
- TLS-only services are not supported by the plain HTTP probe.
|
|
327
|
+
- Only IPv4 and IPv6 loopback addresses are inspected.
|
|
328
|
+
|
|
329
|
+
---
|
|
330
|
+
|
|
331
|
+
## 🛠️ Development
|
|
332
|
+
|
|
333
|
+
```bash
|
|
334
|
+
python -m pip install -e ".[dev]"
|
|
335
|
+
ruff check .
|
|
336
|
+
ruff format --check .
|
|
337
|
+
python -m pytest
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
GitHub Actions runs the test and lint checks on Windows, Ubuntu, and macOS using Python 3.11, 3.12, and 3.13.
|
|
341
|
+
|
|
342
|
+
Want to help improve GhostPort? Read [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
343
|
+
|
|
344
|
+
---
|
|
345
|
+
|
|
346
|
+
## ✿ Made in Cyberspace
|
|
347
|
+
|
|
348
|
+
GhostPort is part of **CyberStudios™**, a creative software ecosystem exploring development tools, game systems, digital environments, and interactive experiences.
|
|
349
|
+
|
|
350
|
+
Small tools deserve thoughtful engineering, too.
|
|
351
|
+
|
|
352
|
+
**Developed under CyberStudios™.**
|
|
353
|
+
|
|
354
|
+
Licensed under the [MIT License](LICENSE).
|
|
355
|
+
|
|
356
|
+
[Repository](https://github.com/CyberStudiosTM/GhostPort) · [Issues](https://github.com/CyberStudiosTM/GhostPort/issues) · [Security](SECURITY.md)
|
|
357
|
+
|
|
358
|
+
*End of transmission. The port is still listening.* 👻
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
ghostport/__init__.py,sha256=68ov8FwMJh6NoR0KdWLHjujyM3RLTzgTx89KOlOd45Q,110
|
|
2
|
+
ghostport/__main__.py,sha256=8SYNfmgNObGkv3PdRC8JxKG90S48AxXr1g4Ri0g9Zk8,70
|
|
3
|
+
ghostport/cli.py,sha256=-FeLDz0YqfBI3Ux8M3vQTxmKWeuFcHzV-rZh0mrE4K8,3549
|
|
4
|
+
ghostport/http_probe.py,sha256=-AGL4-xQBI7eBhccjOb24XSdrTkSD-pbSoVjqjy1LTw,4465
|
|
5
|
+
ghostport/inspector.py,sha256=QBrUGAZNpqsfmumZ5q1J7U_qyGxO2qVAqX6Wb9xZHPY,2291
|
|
6
|
+
ghostport/models.py,sha256=BboU8DyC1o9c4EVx7yP-abzpXSM5D0IxfeXtj5ulXwc,4398
|
|
7
|
+
ghostport/process.py,sha256=NC6Kau8SjcBXDPBoHsCbcXICRpkFpeiiTg7XHv6ndzM,3023
|
|
8
|
+
ghostport/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
ghostport/redact.py,sha256=erbsAeNZKWi1qhieAZHJLrVrsspvf_vjmT4111QsAh0,2236
|
|
10
|
+
ghostport/render.py,sha256=voygW6gwHOS7xdbYuPiHBLB8DxeznxAm86ZenWPHVlo,4895
|
|
11
|
+
ghostport/tcp.py,sha256=T5kNE9yUrNTDwvzwd2uZDxGwHfo8Srg8644_IhpmBu8,2525
|
|
12
|
+
ghostport_cli-0.1.1.dist-info/METADATA,sha256=5huQT5KlzkKA-NhgoYn0j6yy0t9laHbvybcIdmMZV5A,10745
|
|
13
|
+
ghostport_cli-0.1.1.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
14
|
+
ghostport_cli-0.1.1.dist-info/entry_points.txt,sha256=32CZvhl2CfwkosJ6EquO5o2SWb03p4CHetjhrkWHOPc,49
|
|
15
|
+
ghostport_cli-0.1.1.dist-info/licenses/LICENSE,sha256=Q7X2NS-UVrfPWkwSsN6Y9qPWFEP_DUbtmgI3nHeNSNQ,1069
|
|
16
|
+
ghostport_cli-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 CyberStudios
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|