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 ADDED
@@ -0,0 +1,3 @@
1
+ """GhostPort: a tiny forensic CLI for local TCP ports and unexpected HTTP services."""
2
+
3
+ __version__ = "0.1.1"
ghostport/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from ghostport.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
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()
@@ -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
+ ![Python](https://img.shields.io/badge/Python-3.11%2B-BDA9F2?style=flat-square)
44
+ ![Platform](https://img.shields.io/badge/Platform-Windows%20%7C%20Linux%20%7C%20macOS-A9E8DC?style=flat-square)
45
+ ![License](https://img.shields.io/badge/License-MIT-B9DDF6?style=flat-square)
46
+ ![Version](https://img.shields.io/badge/Version-0.1.0-F4C7E8?style=flat-square)
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ghostport = ghostport.cli:main
@@ -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.