cyberplain 0.1.0__tar.gz

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.
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .pytest_cache/
4
+ .ruff_cache/
5
+ .venv/
6
+ dist/
7
+ build/
8
+ *.egg-info/
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 William J. Laurento II
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.
22
+
@@ -0,0 +1,151 @@
1
+ Metadata-Version: 2.5
2
+ Name: cyberplain
3
+ Version: 0.1.0
4
+ Summary: Plain-English defensive cybersecurity tools for Python and the command line
5
+ Project-URL: Homepage, https://github.com/fortnitecodedrop-cmyk/new
6
+ Project-URL: Repository, https://github.com/fortnitecodedrop-cmyk/new
7
+ Project-URL: Issues, https://github.com/fortnitecodedrop-cmyk/new/issues
8
+ Author: William J. Laurento II
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: cybersecurity,defensive-security,dns,pcap,port-scanner,security,tls
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: System Administrators
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Security
22
+ Requires-Python: >=3.10
23
+ Provides-Extra: dev
24
+ Requires-Dist: build>=1.2; extra == 'dev'
25
+ Requires-Dist: pytest>=8; extra == 'dev'
26
+ Requires-Dist: ruff>=0.6; extra == 'dev'
27
+ Requires-Dist: twine>=5; extra == 'dev'
28
+ Provides-Extra: packets
29
+ Requires-Dist: dpkt>=1.9.8; extra == 'packets'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # CyberPlain
33
+
34
+ CyberPlain is a defensive cybersecurity toolkit for Python and the command line. It uses normal English in its names, output, errors, and documentation so beginners can understand what a check did and experienced practitioners can automate it.
35
+
36
+ > Use active network checks only on systems you own or have explicit written permission to test. CyberPlain is designed for defense, education, inventory, and authorized assessment—not exploitation, evasion, credential theft, or disruption.
37
+
38
+ ## What is included
39
+
40
+ | Area | Command / API | What it does |
41
+ |---|---|---|
42
+ | Ports | `cyberplain ports` / `scan_ports()` | Bounded TCP connect scan with service names |
43
+ | Packets | `cyberplain pcap` / `summarize_pcap()` | Offline PCAP/PCAPNG traffic inventory |
44
+ | TLS | `cyberplain tls` / `inspect_tls()` | Certificate, cipher, version, and expiry explanation |
45
+ | Web | `cyberplain headers` / `audit_headers()` | Reviews common HTTP security headers |
46
+ | DNS | `cyberplain dns` | Forward and reverse address lookups |
47
+ | Files | `cyberplain file` | SHA-256, size, and entropy clues |
48
+ | Integrity | `cyberplain hash`, `manifest` | Hashing and directory change detection |
49
+ | Passwords | `cyberplain password` | Local strength feedback; never sends the password |
50
+ | Indicators | `cyberplain iocs`, `defang` | Extracts and safely formats URLs, domains, IPs, emails, and hashes |
51
+
52
+ ## Install
53
+
54
+ ```bash
55
+ pip install cyberplain
56
+ ```
57
+
58
+ For offline packet-capture analysis:
59
+
60
+ ```bash
61
+ pip install "cyberplain[packets]"
62
+ ```
63
+
64
+ ## Quick examples
65
+
66
+ ```bash
67
+ # You must explicitly confirm authorization for active port checks.
68
+ cyberplain ports 192.168.1.10 --ports 22,80,443,8000-8010 --authorized
69
+
70
+ cyberplain tls example.com
71
+ cyberplain headers https://example.com
72
+ cyberplain dns example.com
73
+ cyberplain file download.zip
74
+ cyberplain hash installer.exe
75
+ cyberplain password
76
+ cyberplain pcap capture.pcap
77
+ cyberplain iocs suspicious-email.txt
78
+ cyberplain defang https://example.com/path
79
+ cyberplain manifest ./important-files --create baseline.json
80
+ cyberplain manifest ./important-files --verify baseline.json
81
+ ```
82
+
83
+ Python API:
84
+
85
+ ```python
86
+ from cyberplain import assess_password, hash_file, scan_ports
87
+
88
+ print(hash_file("download.zip"))
89
+ print(assess_password("a long example passphrase"))
90
+
91
+ result = scan_ports(
92
+ "192.168.1.10",
93
+ "22,80,443",
94
+ authorized=True, # only after you confirm permission
95
+ )
96
+ print(result["results"])
97
+ ```
98
+
99
+ ## Design promises
100
+
101
+ - Safe defaults and bounded concurrency.
102
+ - No telemetry; checks run locally unless the feature obviously contacts the host you name.
103
+ - Structured dictionaries/JSON for automation.
104
+ - Plain-English context instead of unexplained security jargon.
105
+ - Honest wording: a missing header or high-entropy file is a clue, not automatic proof of a vulnerability or malware.
106
+
107
+ ## Development and PyPI publishing
108
+
109
+ ### One-command publishing
110
+
111
+ From PowerShell inside the extracted project folder:
112
+
113
+ ```powershell
114
+ py publish.py
115
+ ```
116
+
117
+ The script installs the official build/upload tools, deletes only the old local
118
+ `dist` directory, builds fresh packages, validates them, asks for a final typed
119
+ confirmation, and uploads to PyPI. Twine then prompts for the username
120
+ `__token__` and your complete `pypi-...` API token. The token is never stored by
121
+ this project.
122
+
123
+ To test the complete workflow against TestPyPI first:
124
+
125
+ ```powershell
126
+ py publish.py --test
127
+ ```
128
+
129
+ ### Manual publishing
130
+
131
+ ```bash
132
+ python -m venv .venv
133
+ source .venv/bin/activate # Windows: .venv\Scripts\activate
134
+ pip install -e ".[dev,packets]"
135
+ pytest
136
+ ruff check .
137
+ python -m build
138
+ twine check dist/*
139
+
140
+ # Test upload first
141
+ python -m twine upload --repository testpypi dist/*
142
+
143
+ # Final upload (requires your PyPI API token)
144
+ python -m twine upload dist/*
145
+ ```
146
+
147
+ Before publishing, replace the placeholder GitHub URLs in `pyproject.toml`, confirm that the distribution name is available on PyPI, and choose your author/maintainer metadata.
148
+
149
+ ## Scope and roadmap
150
+
151
+ No responsible package can literally cover every cybersecurity topic in one safe, maintainable release. CyberPlain 0.1 focuses on a strong defensive foundation. Good future additions include SARIF output, YARA rule scanning, OSV dependency checks, richer packet protocol summaries, certificate transparency lookup, and optional integrations with reputable threat-intelligence services.
@@ -0,0 +1,120 @@
1
+ # CyberPlain
2
+
3
+ CyberPlain is a defensive cybersecurity toolkit for Python and the command line. It uses normal English in its names, output, errors, and documentation so beginners can understand what a check did and experienced practitioners can automate it.
4
+
5
+ > Use active network checks only on systems you own or have explicit written permission to test. CyberPlain is designed for defense, education, inventory, and authorized assessment—not exploitation, evasion, credential theft, or disruption.
6
+
7
+ ## What is included
8
+
9
+ | Area | Command / API | What it does |
10
+ |---|---|---|
11
+ | Ports | `cyberplain ports` / `scan_ports()` | Bounded TCP connect scan with service names |
12
+ | Packets | `cyberplain pcap` / `summarize_pcap()` | Offline PCAP/PCAPNG traffic inventory |
13
+ | TLS | `cyberplain tls` / `inspect_tls()` | Certificate, cipher, version, and expiry explanation |
14
+ | Web | `cyberplain headers` / `audit_headers()` | Reviews common HTTP security headers |
15
+ | DNS | `cyberplain dns` | Forward and reverse address lookups |
16
+ | Files | `cyberplain file` | SHA-256, size, and entropy clues |
17
+ | Integrity | `cyberplain hash`, `manifest` | Hashing and directory change detection |
18
+ | Passwords | `cyberplain password` | Local strength feedback; never sends the password |
19
+ | Indicators | `cyberplain iocs`, `defang` | Extracts and safely formats URLs, domains, IPs, emails, and hashes |
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ pip install cyberplain
25
+ ```
26
+
27
+ For offline packet-capture analysis:
28
+
29
+ ```bash
30
+ pip install "cyberplain[packets]"
31
+ ```
32
+
33
+ ## Quick examples
34
+
35
+ ```bash
36
+ # You must explicitly confirm authorization for active port checks.
37
+ cyberplain ports 192.168.1.10 --ports 22,80,443,8000-8010 --authorized
38
+
39
+ cyberplain tls example.com
40
+ cyberplain headers https://example.com
41
+ cyberplain dns example.com
42
+ cyberplain file download.zip
43
+ cyberplain hash installer.exe
44
+ cyberplain password
45
+ cyberplain pcap capture.pcap
46
+ cyberplain iocs suspicious-email.txt
47
+ cyberplain defang https://example.com/path
48
+ cyberplain manifest ./important-files --create baseline.json
49
+ cyberplain manifest ./important-files --verify baseline.json
50
+ ```
51
+
52
+ Python API:
53
+
54
+ ```python
55
+ from cyberplain import assess_password, hash_file, scan_ports
56
+
57
+ print(hash_file("download.zip"))
58
+ print(assess_password("a long example passphrase"))
59
+
60
+ result = scan_ports(
61
+ "192.168.1.10",
62
+ "22,80,443",
63
+ authorized=True, # only after you confirm permission
64
+ )
65
+ print(result["results"])
66
+ ```
67
+
68
+ ## Design promises
69
+
70
+ - Safe defaults and bounded concurrency.
71
+ - No telemetry; checks run locally unless the feature obviously contacts the host you name.
72
+ - Structured dictionaries/JSON for automation.
73
+ - Plain-English context instead of unexplained security jargon.
74
+ - Honest wording: a missing header or high-entropy file is a clue, not automatic proof of a vulnerability or malware.
75
+
76
+ ## Development and PyPI publishing
77
+
78
+ ### One-command publishing
79
+
80
+ From PowerShell inside the extracted project folder:
81
+
82
+ ```powershell
83
+ py publish.py
84
+ ```
85
+
86
+ The script installs the official build/upload tools, deletes only the old local
87
+ `dist` directory, builds fresh packages, validates them, asks for a final typed
88
+ confirmation, and uploads to PyPI. Twine then prompts for the username
89
+ `__token__` and your complete `pypi-...` API token. The token is never stored by
90
+ this project.
91
+
92
+ To test the complete workflow against TestPyPI first:
93
+
94
+ ```powershell
95
+ py publish.py --test
96
+ ```
97
+
98
+ ### Manual publishing
99
+
100
+ ```bash
101
+ python -m venv .venv
102
+ source .venv/bin/activate # Windows: .venv\Scripts\activate
103
+ pip install -e ".[dev,packets]"
104
+ pytest
105
+ ruff check .
106
+ python -m build
107
+ twine check dist/*
108
+
109
+ # Test upload first
110
+ python -m twine upload --repository testpypi dist/*
111
+
112
+ # Final upload (requires your PyPI API token)
113
+ python -m twine upload dist/*
114
+ ```
115
+
116
+ Before publishing, replace the placeholder GitHub URLs in `pyproject.toml`, confirm that the distribution name is available on PyPI, and choose your author/maintainer metadata.
117
+
118
+ ## Scope and roadmap
119
+
120
+ No responsible package can literally cover every cybersecurity topic in one safe, maintainable release. CyberPlain 0.1 focuses on a strong defensive foundation. Good future additions include SARIF output, YARA rule scanning, OSV dependency checks, richer packet protocol summaries, certificate transparency lookup, and optional integrations with reputable threat-intelligence services.
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env python3
2
+ """Build and publish CyberPlain to PyPI or TestPyPI."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import os
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ import getpass
14
+
15
+ ROOT = Path(__file__).resolve().parent
16
+ DIST = ROOT / "dist"
17
+
18
+
19
+ def run(command: list[str], *, cwd: Path | None = None) -> None:
20
+ subprocess.run(command, cwd=cwd or ROOT, check=True)
21
+
22
+
23
+ def install_tools() -> None:
24
+ run([sys.executable, "-m", "pip", "install", "--upgrade", "pip", "build", "twine"])
25
+
26
+
27
+ def clean_dist() -> None:
28
+ if DIST.exists():
29
+ shutil.rmtree(DIST)
30
+
31
+
32
+ def build_package() -> None:
33
+ run([sys.executable, "-m", "build"])
34
+ run([sys.executable, "-m", "twine", "check", "dist/*"])
35
+
36
+
37
+ def confirm_publish(test_mode: bool, skip_confirm: bool) -> None:
38
+ if skip_confirm:
39
+ return
40
+ target = "TestPyPI" if test_mode else "PyPI"
41
+ response = input(f"Type 'PUBLISH' to upload to {target} and press Enter: ")
42
+ if response != "PUBLISH":
43
+ raise SystemExit("Publishing cancelled.")
44
+
45
+
46
+ def resolve_token(test_mode: bool, token: str | None) -> str:
47
+ if token:
48
+ return token
49
+
50
+ env_name = "TESTPYPI_TOKEN" if test_mode else "PYPI_TOKEN"
51
+ env_token = os.environ.get(env_name)
52
+ if env_token:
53
+ return env_token
54
+
55
+ return getpass.getpass(f"Enter your {('TestPyPI' if test_mode else 'PyPI')} API token: ")
56
+
57
+
58
+ def upload_package(test_mode: bool, token: str | None, *, dry_run: bool) -> None:
59
+ repo = "testpypi" if test_mode else "pypi"
60
+ resolved = resolve_token(test_mode, token)
61
+
62
+ if dry_run:
63
+ print(f"Dry run: python -m twine upload --repository {repo} --username __token__ --password <token> dist/*")
64
+ return
65
+
66
+ run([
67
+ sys.executable,
68
+ "-m",
69
+ "twine",
70
+ "upload",
71
+ f"--repository={repo}",
72
+ "--username",
73
+ "__token__",
74
+ "--password",
75
+ resolved,
76
+ "dist/*",
77
+ ])
78
+
79
+
80
+ def parse_args() -> argparse.Namespace:
81
+ parser = argparse.ArgumentParser(description="Build and publish the CyberPlain package.")
82
+ parser.add_argument(
83
+ "--test",
84
+ action="store_true",
85
+ help="Upload to TestPyPI instead of the main PyPI index.",
86
+ )
87
+ parser.add_argument(
88
+ "--token",
89
+ help="Pass your PyPI/TestPyPI API token directly without an interactive prompt.",
90
+ )
91
+ parser.add_argument(
92
+ "--skip-confirm",
93
+ action="store_true",
94
+ help="Skip the interactive confirmation prompt.",
95
+ )
96
+ parser.add_argument(
97
+ "--dry-run",
98
+ action="store_true",
99
+ help="Build and validate the package without uploading it.",
100
+ )
101
+ return parser.parse_args()
102
+
103
+
104
+ def main() -> None:
105
+ args = parse_args()
106
+ print("Installing packaging tools...")
107
+ install_tools()
108
+ print("Cleaning old build artifacts...")
109
+ clean_dist()
110
+ print("Building distribution artifacts...")
111
+ build_package()
112
+ print("Build and metadata checks passed.")
113
+ confirm_publish(args.test, args.skip_confirm)
114
+ print("Uploading package...")
115
+ upload_package(args.test, args.token, dry_run=args.dry_run)
116
+ if not args.dry_run:
117
+ print("Upload completed.")
118
+
119
+
120
+ if __name__ == "__main__":
121
+ try:
122
+ main()
123
+ except subprocess.CalledProcessError as exc:
124
+ raise SystemExit(f"Command failed with exit code {exc.returncode}.") from exc
@@ -0,0 +1,50 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "cyberplain"
7
+ version = "0.1.0"
8
+ description = "Plain-English defensive cybersecurity tools for Python and the command line"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = {text = "MIT"}
12
+ authors = [{name = "William J. Laurento II"}]
13
+ keywords = ["cybersecurity", "security", "port-scanner", "pcap", "tls", "dns", "defensive-security"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "Intended Audience :: System Administrators",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Topic :: Security",
25
+ ]
26
+ dependencies = []
27
+
28
+ [project.optional-dependencies]
29
+ packets = ["dpkt>=1.9.8"]
30
+ dev = ["build>=1.2", "pytest>=8", "ruff>=0.6", "twine>=5"]
31
+
32
+ [project.scripts]
33
+ cyberplain = "cyberplain.cli:main"
34
+
35
+ [project.urls]
36
+ Homepage = "https://github.com/fortnitecodedrop-cmyk/new"
37
+ Repository = "https://github.com/fortnitecodedrop-cmyk/new"
38
+ Issues = "https://github.com/fortnitecodedrop-cmyk/new/issues"
39
+
40
+ [tool.hatch.build.targets.wheel]
41
+ packages = ["src/cyberplain"]
42
+
43
+ [tool.pytest.ini_options]
44
+ testpaths = ["tests"]
45
+ pythonpath = ["src"]
46
+
47
+ [tool.ruff]
48
+ line-length = 100
49
+ target-version = "py310"
50
+
@@ -0,0 +1,8 @@
1
+ """CyberPlain: defensive cybersecurity in readable Python."""
2
+
3
+ from .hashing import hash_file, verify_file
4
+ from .passwords import assess_password
5
+ from .ports import scan_ports
6
+
7
+ __all__ = ["assess_password", "hash_file", "scan_ports", "verify_file"]
8
+ __version__ = "0.1.0"
@@ -0,0 +1,8 @@
1
+ """CyberPlain: defensive cybersecurity in readable Python."""
2
+
3
+ from .hashing import hash_file, verify_file
4
+ from .passwords import assess_password
5
+ from .ports import scan_ports
6
+
7
+ __all__ = ["assess_password", "hash_file", "scan_ports", "verify_file"]
8
+ __version__ = "0.1.0"
@@ -0,0 +1,153 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import getpass
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from .dnscheck import resolve_host, reverse_lookup
11
+ from .files import inspect_file
12
+ from .hashing import create_manifest, hash_file, verify_file, verify_manifest
13
+ from .httpcheck import audit_headers
14
+ from .iocs import defang, extract_iocs, refang
15
+ from .passwords import assess_password
16
+ from .pcap import summarize_pcap
17
+ from .ports import scan_ports
18
+ from .tlscheck import inspect_tls
19
+
20
+
21
+ def _emit(data: Any) -> None:
22
+ print(json.dumps(data, indent=2, default=str))
23
+
24
+
25
+ def _authorized(args: argparse.Namespace) -> bool:
26
+ if not args.authorized:
27
+ raise ValueError("Add --authorized to confirm that you have permission to test this host.")
28
+ return True
29
+
30
+
31
+ def build_parser() -> argparse.ArgumentParser:
32
+ parser = argparse.ArgumentParser(
33
+ prog="cyberplain", description="Defensive cybersecurity tools that explain their results."
34
+ )
35
+ sub = parser.add_subparsers(dest="command", required=True)
36
+
37
+ ports = sub.add_parser("ports", help="Check which TCP ports accept connections")
38
+ ports.add_argument("host")
39
+ ports.add_argument("--ports", default="22,80,443")
40
+ ports.add_argument("--timeout", type=float, default=0.5)
41
+ ports.add_argument("--show-closed", action="store_true")
42
+ ports.add_argument(
43
+ "--authorized",
44
+ action="store_true",
45
+ help="I own or have explicit permission to test this host",
46
+ )
47
+
48
+ tls = sub.add_parser("tls", help="Explain a site's TLS certificate")
49
+ tls.add_argument("host")
50
+ tls.add_argument("--port", type=int, default=443)
51
+
52
+ headers = sub.add_parser("headers", help="Review common HTTP security headers")
53
+ headers.add_argument("url")
54
+
55
+ dns = sub.add_parser("dns", help="Resolve a hostname or reverse-resolve an IP address")
56
+ dns.add_argument("value")
57
+ dns.add_argument("--reverse", action="store_true")
58
+
59
+ hashing = sub.add_parser("hash", help="Calculate a safe file fingerprint")
60
+ hashing.add_argument("path")
61
+ hashing.add_argument("--algorithm", default="sha256")
62
+ hashing.add_argument("--expected")
63
+
64
+ file_parser = sub.add_parser("file", help="Inspect file metadata, hash, and entropy")
65
+ file_parser.add_argument("path")
66
+
67
+ password = sub.add_parser("password", help="Assess a password locally without transmitting it")
68
+ password.add_argument(
69
+ "--value", help="Avoid this option on shared systems because shell history may retain it"
70
+ )
71
+
72
+ ioc = sub.add_parser("iocs", help="Extract indicators from a text file or stdin")
73
+ ioc.add_argument("path", nargs="?", help="Omit to read stdin")
74
+ transform = sub.add_parser("defang", help="Make a URL or domain safer to paste")
75
+ transform.add_argument("value")
76
+ transform.add_argument("--reverse", action="store_true")
77
+
78
+ pcap = sub.add_parser("pcap", help="Summarize an offline PCAP/PCAPNG file")
79
+ pcap.add_argument("path")
80
+ pcap.add_argument("--limit", type=int, default=100_000)
81
+
82
+ manifest = sub.add_parser("manifest", help="Create or verify a directory integrity manifest")
83
+ manifest.add_argument("directory")
84
+ manifest.add_argument("--create")
85
+ manifest.add_argument("--verify")
86
+ return parser
87
+
88
+
89
+ def run(args: argparse.Namespace) -> Any:
90
+ if args.command == "ports":
91
+ return scan_ports(
92
+ args.host,
93
+ args.ports,
94
+ authorized=_authorized(args),
95
+ timeout=args.timeout,
96
+ include_closed=args.show_closed,
97
+ )
98
+ if args.command == "tls":
99
+ return inspect_tls(args.host, args.port)
100
+ if args.command == "headers":
101
+ return audit_headers(args.url)
102
+ if args.command == "dns":
103
+ return reverse_lookup(args.value) if args.reverse else resolve_host(args.value)
104
+ if args.command == "hash":
105
+ return (
106
+ {"path": args.path, "matches": verify_file(args.path, args.expected, args.algorithm)}
107
+ if args.expected
108
+ else {
109
+ "path": args.path,
110
+ "algorithm": args.algorithm,
111
+ "digest": hash_file(args.path, args.algorithm),
112
+ }
113
+ )
114
+ if args.command == "file":
115
+ return inspect_file(args.path)
116
+ if args.command == "password":
117
+ return assess_password(
118
+ args.value
119
+ if args.value is not None
120
+ else getpass.getpass("Password (input stays local): ")
121
+ )
122
+ if args.command == "iocs":
123
+ text = (
124
+ Path(args.path).read_text(encoding="utf-8", errors="replace")
125
+ if args.path
126
+ else sys.stdin.read()
127
+ )
128
+ return extract_iocs(text)
129
+ if args.command == "defang":
130
+ return {"result": refang(args.value) if args.reverse else defang(args.value)}
131
+ if args.command == "pcap":
132
+ return summarize_pcap(args.path, args.limit)
133
+ if args.command == "manifest":
134
+ if bool(args.create) == bool(args.verify):
135
+ raise ValueError("Choose exactly one of --create FILE or --verify FILE.")
136
+ return (
137
+ create_manifest(args.directory, args.create)
138
+ if args.create
139
+ else verify_manifest(args.directory, args.verify)
140
+ )
141
+ raise ValueError("Unknown command")
142
+
143
+
144
+ def main() -> None:
145
+ try:
146
+ _emit(run(build_parser().parse_args()))
147
+ except (ValueError, OSError, RuntimeError, PermissionError) as error:
148
+ print(f"CyberPlain could not complete the check: {error}", file=sys.stderr)
149
+ raise SystemExit(2) from error
150
+
151
+
152
+ if __name__ == "__main__":
153
+ main()
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ import ipaddress
4
+ import socket
5
+
6
+
7
+ def resolve_host(host: str) -> dict[str, object]:
8
+ records = socket.getaddrinfo(host, None, proto=socket.IPPROTO_TCP)
9
+ addresses = sorted({record[4][0] for record in records})
10
+ explained = []
11
+ for value in addresses:
12
+ address = ipaddress.ip_address(value)
13
+ explained.append(
14
+ {
15
+ "address": value,
16
+ "version": address.version,
17
+ "scope": "public" if address.is_global else "private or special-use",
18
+ }
19
+ )
20
+ return {"host": host, "addresses": explained}
21
+
22
+
23
+ def reverse_lookup(address: str) -> dict[str, object]:
24
+ ipaddress.ip_address(address)
25
+ try:
26
+ hostname, aliases, addresses = socket.gethostbyaddr(address)
27
+ return {
28
+ "address": address,
29
+ "hostname": hostname,
30
+ "aliases": aliases,
31
+ "addresses": addresses,
32
+ }
33
+ except socket.herror:
34
+ return {"address": address, "hostname": None, "aliases": [], "addresses": []}
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from collections import Counter
5
+ from pathlib import Path
6
+
7
+ from .hashing import hash_file
8
+
9
+
10
+ def shannon_entropy(data: bytes) -> float:
11
+ if not data:
12
+ return 0.0
13
+ counts = Counter(data)
14
+ return -sum((count / len(data)) * math.log2(count / len(data)) for count in counts.values())
15
+
16
+
17
+ def inspect_file(path: str | Path, sample_bytes: int = 2_000_000) -> dict[str, object]:
18
+ target = Path(path)
19
+ with target.open("rb") as handle:
20
+ sample = handle.read(sample_bytes)
21
+ entropy = shannon_entropy(sample)
22
+ return {
23
+ "path": str(target),
24
+ "size_bytes": target.stat().st_size,
25
+ "sha256": hash_file(target),
26
+ "sample_entropy": round(entropy, 3),
27
+ "entropy_explanation": (
28
+ "High entropy can be normal for compressed or encrypted files; it is only a clue."
29
+ if entropy > 7.2
30
+ else "The sampled bytes do not have unusually high entropy."
31
+ ),
32
+ }
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import hmac
5
+ import json
6
+ from pathlib import Path
7
+
8
+ ALGORITHMS = {"sha256", "sha384", "sha512", "blake2b"}
9
+
10
+
11
+ def hash_file(path: str | Path, algorithm: str = "sha256", chunk_size: int = 1024 * 1024) -> str:
12
+ if algorithm not in ALGORITHMS:
13
+ raise ValueError(f"Choose one of: {', '.join(sorted(ALGORITHMS))}")
14
+ digest = hashlib.new(algorithm)
15
+ with Path(path).open("rb") as handle:
16
+ for chunk in iter(lambda: handle.read(chunk_size), b""):
17
+ digest.update(chunk)
18
+ return digest.hexdigest()
19
+
20
+
21
+ def verify_file(path: str | Path, expected: str, algorithm: str = "sha256") -> bool:
22
+ return hmac.compare_digest(hash_file(path, algorithm), expected.strip().lower())
23
+
24
+
25
+ def create_manifest(directory: str | Path, output: str | Path | None = None) -> dict[str, str]:
26
+ root = Path(directory).resolve()
27
+ manifest = {
28
+ str(path.relative_to(root)): hash_file(path)
29
+ for path in sorted(root.rglob("*"))
30
+ if path.is_file() and (output is None or path.resolve() != Path(output).resolve())
31
+ }
32
+ if output:
33
+ Path(output).write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
34
+ return manifest
35
+
36
+
37
+ def verify_manifest(
38
+ directory: str | Path, manifest: str | Path | dict[str, str]
39
+ ) -> dict[str, list[str]]:
40
+ root = Path(directory).resolve()
41
+ expected = (
42
+ json.loads(Path(manifest).read_text(encoding="utf-8"))
43
+ if not isinstance(manifest, dict)
44
+ else manifest
45
+ )
46
+ actual_paths = {str(path.relative_to(root)): path for path in root.rglob("*") if path.is_file()}
47
+ missing = sorted(set(expected) - set(actual_paths))
48
+ added = sorted(set(actual_paths) - set(expected))
49
+ changed = sorted(
50
+ name
51
+ for name in set(expected) & set(actual_paths)
52
+ if not verify_file(actual_paths[name], expected[name])
53
+ )
54
+ return {"changed": changed, "missing": missing, "added": added}
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ import urllib.error
4
+ import urllib.request
5
+ from urllib.parse import urlparse
6
+
7
+ IMPORTANT_HEADERS = {
8
+ "strict-transport-security": "Tells browsers to keep using HTTPS.",
9
+ "content-security-policy": "Limits where scripts, styles, and other content may load from.",
10
+ "x-content-type-options": "Stops browsers from guessing a file's content type.",
11
+ "referrer-policy": "Controls how much referral information leaves the site.",
12
+ "permissions-policy": "Limits browser features such as camera and location access.",
13
+ }
14
+
15
+
16
+ def audit_headers(url: str, timeout: float = 8.0) -> dict[str, object]:
17
+ parsed = urlparse(url)
18
+ if parsed.scheme not in {"http", "https"} or not parsed.hostname:
19
+ raise ValueError("Use a complete http:// or https:// URL.")
20
+ request = urllib.request.Request(
21
+ url, method="GET", headers={"User-Agent": "CyberPlain/0.1 defensive-audit"}
22
+ )
23
+ try:
24
+ with urllib.request.urlopen(request, timeout=timeout) as response:
25
+ headers = {key.lower(): value for key, value in response.headers.items()}
26
+ status = response.status
27
+ final_url = response.url
28
+ except urllib.error.HTTPError as error:
29
+ headers = {key.lower(): value for key, value in error.headers.items()}
30
+ status = error.code
31
+ final_url = error.url
32
+ present = {
33
+ name: {"value": headers[name], "purpose": purpose}
34
+ for name, purpose in IMPORTANT_HEADERS.items()
35
+ if name in headers
36
+ }
37
+ missing = {name: purpose for name, purpose in IMPORTANT_HEADERS.items() if name not in headers}
38
+ notes = []
39
+ if parsed.scheme != "https":
40
+ notes.append("The starting URL does not use HTTPS.")
41
+ if "server" in headers:
42
+ notes.append(
43
+ "The Server header may reveal implementation details; minimize it when practical."
44
+ )
45
+ return {
46
+ "url": url,
47
+ "final_url": final_url,
48
+ "status": status,
49
+ "present_security_headers": present,
50
+ "missing_security_headers": missing,
51
+ "notes": notes,
52
+ "warning": "Missing headers are review items, not automatic proof of a vulnerability.",
53
+ }
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ import ipaddress
4
+ import re
5
+ from urllib.parse import urlparse
6
+
7
+ URL_RE = re.compile(r"https?://[^\s<>\"']+", re.IGNORECASE)
8
+ EMAIL_RE = re.compile(r"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b", re.IGNORECASE)
9
+ IP_RE = re.compile(r"(?<![\w.])(?:\d{1,3}\.){3}\d{1,3}(?![\w.])")
10
+ HASH_RE = re.compile(r"\b(?:[a-fA-F0-9]{32}|[a-fA-F0-9]{40}|[a-fA-F0-9]{64})\b")
11
+
12
+
13
+ def extract_iocs(text: str) -> dict[str, list[str]]:
14
+ ips = []
15
+ for candidate in IP_RE.findall(text):
16
+ try:
17
+ ips.append(str(ipaddress.ip_address(candidate)))
18
+ except ValueError:
19
+ pass
20
+ urls = [item.rstrip(".,);]") for item in URL_RE.findall(text)]
21
+ domains = []
22
+ for url in urls:
23
+ host = urlparse(url).hostname
24
+ if host:
25
+ domains.append(host.casefold())
26
+ return {
27
+ "urls": sorted(set(urls)),
28
+ "domains": sorted(set(domains)),
29
+ "ip_addresses": sorted(set(ips)),
30
+ "email_addresses": sorted(set(EMAIL_RE.findall(text))),
31
+ "hashes": sorted({value.lower() for value in HASH_RE.findall(text)}),
32
+ }
33
+
34
+
35
+ def defang(value: str) -> str:
36
+ return value.replace("https://", "hxxps://").replace("http://", "hxxp://").replace(".", "[.]")
37
+
38
+
39
+ def refang(value: str) -> str:
40
+ return value.replace("hxxps://", "https://").replace("hxxp://", "http://").replace("[.]", ".")
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass, field
4
+ from typing import Any
5
+
6
+
7
+ @dataclass(slots=True)
8
+ class Finding:
9
+ title: str
10
+ severity: str
11
+ explanation: str
12
+ recommendation: str = ""
13
+ evidence: dict[str, Any] = field(default_factory=dict)
14
+
15
+ def to_dict(self) -> dict[str, Any]:
16
+ return asdict(self)
17
+
18
+
19
+ @dataclass(slots=True)
20
+ class CheckResult:
21
+ subject: str
22
+ summary: str
23
+ findings: list[Finding] = field(default_factory=list)
24
+ details: dict[str, Any] = field(default_factory=dict)
25
+
26
+ def to_dict(self) -> dict[str, Any]:
27
+ return {
28
+ "subject": self.subject,
29
+ "summary": self.summary,
30
+ "findings": [item.to_dict() for item in self.findings],
31
+ "details": self.details,
32
+ }
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import re
5
+
6
+ COMMON = {
7
+ "password",
8
+ "password1",
9
+ "123456",
10
+ "12345678",
11
+ "qwerty",
12
+ "letmein",
13
+ "admin",
14
+ "welcome",
15
+ "iloveyou",
16
+ }
17
+
18
+
19
+ def assess_password(password: str) -> dict[str, object]:
20
+ pools = [
21
+ (r"[a-z]", 26, "lowercase letter"),
22
+ (r"[A-Z]", 26, "uppercase letter"),
23
+ (r"[0-9]", 10, "number"),
24
+ (r"[^A-Za-z0-9]", 33, "symbol"),
25
+ ]
26
+ pool_size = sum(size for pattern, size, _ in pools if re.search(pattern, password))
27
+ entropy = len(password) * math.log2(pool_size) if pool_size else 0.0
28
+ warnings: list[str] = []
29
+ lower = password.casefold()
30
+ if len(password) < 14:
31
+ warnings.append(
32
+ "Use at least 14 characters; length usually helps more than forced substitutions."
33
+ )
34
+ if lower in COMMON:
35
+ warnings.append("This is an extremely common password.")
36
+ if re.search(r"(.)\1{2,}", password):
37
+ warnings.append("Avoid repeating the same character three or more times.")
38
+ if any(sequence in lower for sequence in ("1234", "abcd", "qwerty", "asdf")):
39
+ warnings.append("Avoid predictable keyboard or counting sequences.")
40
+ score = 0 if lower in COMMON else min(4, int(entropy // 22))
41
+ labels = ["very weak", "weak", "fair", "strong", "very strong"]
42
+ return {
43
+ "score": score,
44
+ "rating": labels[score],
45
+ "length": len(password),
46
+ "estimated_entropy_bits": round(entropy, 1),
47
+ "warnings": warnings,
48
+ "advice": "Prefer a password manager and a unique generated password or long passphrase.",
49
+ "note": "Entropy is only an estimate and does not prove resistance to real password cracking.",
50
+ }
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ import socket
4
+ from collections import Counter
5
+ from pathlib import Path
6
+
7
+
8
+ def summarize_pcap(path: str | Path, packet_limit: int = 100_000) -> dict[str, object]:
9
+ """Summarize a PCAP file without replaying or modifying any traffic."""
10
+ try:
11
+ import dpkt
12
+ except ImportError as error:
13
+ raise RuntimeError("Packet analysis needs: pip install 'cyberplain[packets]'") from error
14
+ protocols: Counter[str] = Counter()
15
+ endpoints: Counter[str] = Counter()
16
+ conversations: Counter[str] = Counter()
17
+ total_bytes = 0
18
+ first_time = last_time = None
19
+ count = 0
20
+ with Path(path).open("rb") as handle:
21
+ try:
22
+ reader = dpkt.pcap.Reader(handle)
23
+ except (ValueError, dpkt.dpkt.NeedData):
24
+ handle.seek(0)
25
+ reader = dpkt.pcapng.Reader(handle)
26
+ for timestamp, buffer in reader:
27
+ count += 1
28
+ if count > packet_limit:
29
+ break
30
+ first_time = timestamp if first_time is None else first_time
31
+ last_time = timestamp
32
+ total_bytes += len(buffer)
33
+ try:
34
+ ethernet = dpkt.ethernet.Ethernet(buffer)
35
+ ip = ethernet.data
36
+ if isinstance(ip, dpkt.ip.IP):
37
+ source, destination = socket.inet_ntoa(ip.src), socket.inet_ntoa(ip.dst)
38
+ elif isinstance(ip, dpkt.ip6.IP6):
39
+ source, destination = (
40
+ socket.inet_ntop(socket.AF_INET6, ip.src),
41
+ socket.inet_ntop(socket.AF_INET6, ip.dst),
42
+ )
43
+ else:
44
+ protocols[type(ip).__name__.upper()] += 1
45
+ continue
46
+ protocol = {6: "TCP", 17: "UDP", 1: "ICMP", 58: "ICMPv6"}.get(
47
+ ip.p, f"IP protocol {ip.p}"
48
+ )
49
+ protocols[protocol] += 1
50
+ endpoints.update((source, destination))
51
+ conversations[f"{source} -> {destination}"] += 1
52
+ except (dpkt.dpkt.NeedData, dpkt.dpkt.UnpackError, ValueError):
53
+ protocols["Malformed or unsupported"] += 1
54
+ return {
55
+ "file": str(path),
56
+ "packets_read": min(count, packet_limit),
57
+ "truncated": count > packet_limit,
58
+ "bytes_observed": total_bytes,
59
+ "duration_seconds": round(last_time - first_time, 3)
60
+ if first_time is not None and last_time is not None
61
+ else 0,
62
+ "protocols": dict(protocols.most_common()),
63
+ "top_endpoints": dict(endpoints.most_common(20)),
64
+ "top_conversations": dict(conversations.most_common(20)),
65
+ "plain_english": "This is a traffic inventory, not a verdict that any endpoint is malicious.",
66
+ }
@@ -0,0 +1,124 @@
1
+ from __future__ import annotations
2
+
3
+ import concurrent.futures
4
+ import socket
5
+ import time
6
+ from collections.abc import Iterable
7
+ from dataclasses import asdict, dataclass
8
+
9
+ from .safety import public_ip_warning, require_authorization
10
+
11
+ COMMON_PORTS = {
12
+ 20: "FTP data",
13
+ 21: "FTP",
14
+ 22: "SSH",
15
+ 23: "Telnet",
16
+ 25: "SMTP",
17
+ 53: "DNS",
18
+ 67: "DHCP server",
19
+ 68: "DHCP client",
20
+ 80: "HTTP",
21
+ 110: "POP3",
22
+ 123: "NTP",
23
+ 135: "Microsoft RPC",
24
+ 139: "NetBIOS",
25
+ 143: "IMAP",
26
+ 161: "SNMP",
27
+ 389: "LDAP",
28
+ 443: "HTTPS",
29
+ 445: "SMB",
30
+ 465: "SMTPS",
31
+ 587: "SMTP submission",
32
+ 636: "LDAPS",
33
+ 993: "IMAPS",
34
+ 995: "POP3S",
35
+ 1433: "Microsoft SQL Server",
36
+ 1521: "Oracle DB",
37
+ 2049: "NFS",
38
+ 2375: "Docker API (unencrypted)",
39
+ 3306: "MySQL",
40
+ 3389: "Remote Desktop",
41
+ 5432: "PostgreSQL",
42
+ 5900: "VNC",
43
+ 6379: "Redis",
44
+ 8080: "Alternate HTTP",
45
+ 8443: "Alternate HTTPS",
46
+ 9200: "Elasticsearch",
47
+ 27017: "MongoDB",
48
+ }
49
+
50
+
51
+ @dataclass(slots=True)
52
+ class PortResult:
53
+ port: int
54
+ state: str
55
+ service: str
56
+ response_ms: float | None
57
+
58
+ def to_dict(self) -> dict[str, object]:
59
+ return asdict(self)
60
+
61
+
62
+ def parse_ports(spec: str) -> list[int]:
63
+ """Parse ``22,80,443,8000-8010`` into a sorted list of ports."""
64
+ ports: set[int] = set()
65
+ for raw_part in spec.split(","):
66
+ part = raw_part.strip()
67
+ if not part:
68
+ continue
69
+ if "-" in part:
70
+ start_text, end_text = part.split("-", 1)
71
+ start, end = int(start_text), int(end_text)
72
+ if end < start:
73
+ raise ValueError(f"Invalid port range: {part}")
74
+ ports.update(range(start, end + 1))
75
+ else:
76
+ ports.add(int(part))
77
+ if not ports or any(port < 1 or port > 65535 for port in ports):
78
+ raise ValueError("Ports must be between 1 and 65535.")
79
+ if len(ports) > 1024:
80
+ raise ValueError("A single scan is limited to 1,024 ports.")
81
+ return sorted(ports)
82
+
83
+
84
+ def _check_port(address: str, port: int, timeout: float) -> PortResult:
85
+ started = time.perf_counter()
86
+ try:
87
+ with socket.create_connection((address, port), timeout=timeout):
88
+ elapsed = round((time.perf_counter() - started) * 1000, 2)
89
+ return PortResult(port, "open", COMMON_PORTS.get(port, "Unknown"), elapsed)
90
+ except (TimeoutError, ConnectionRefusedError, OSError):
91
+ return PortResult(port, "closed or filtered", COMMON_PORTS.get(port, "Unknown"), None)
92
+
93
+
94
+ def scan_ports(
95
+ host: str,
96
+ ports: Iterable[int] | str = (22, 80, 443),
97
+ *,
98
+ authorized: bool = False,
99
+ timeout: float = 0.5,
100
+ workers: int = 64,
101
+ include_closed: bool = False,
102
+ ) -> dict[str, object]:
103
+ """Run a bounded TCP connect scan against an authorized host."""
104
+ require_authorization(authorized)
105
+ port_list = parse_ports(ports) if isinstance(ports, str) else sorted(set(ports))
106
+ if not port_list or len(port_list) > 1024:
107
+ raise ValueError("Choose between 1 and 1,024 ports.")
108
+ if any(not isinstance(port, int) or port < 1 or port > 65535 for port in port_list):
109
+ raise ValueError("Ports must be integers between 1 and 65535.")
110
+ if not 0.05 <= timeout <= 10:
111
+ raise ValueError("Timeout must be between 0.05 and 10 seconds.")
112
+ workers = max(1, min(int(workers), 128))
113
+ address = socket.gethostbyname(host)
114
+ with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
115
+ results = list(executor.map(lambda p: _check_port(address, p, timeout), port_list))
116
+ visible = results if include_closed else [item for item in results if item.state == "open"]
117
+ return {
118
+ "host": host,
119
+ "resolved_address": address,
120
+ "ports_checked": len(port_list),
121
+ "open_count": sum(item.state == "open" for item in results),
122
+ "warning": public_ip_warning(address),
123
+ "results": [item.to_dict() for item in visible],
124
+ }
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+
8
+ def to_json(data: Any, output: str | Path | None = None) -> str:
9
+ rendered = json.dumps(data, indent=2, default=str) + "\n"
10
+ if output:
11
+ Path(output).write_text(rendered, encoding="utf-8")
12
+ return rendered
13
+
14
+
15
+ def to_markdown(data: dict[str, Any], title: str = "CyberPlain Report") -> str:
16
+ lines = [f"# {title}", ""]
17
+ for key, value in data.items():
18
+ heading = key.replace("_", " ").title()
19
+ if isinstance(value, (dict, list)):
20
+ lines.extend(
21
+ (
22
+ f"## {heading}",
23
+ "",
24
+ "```json",
25
+ json.dumps(value, indent=2, default=str),
26
+ "```",
27
+ "",
28
+ )
29
+ )
30
+ else:
31
+ lines.append(f"- **{heading}:** {value}")
32
+ return "\n".join(lines).rstrip() + "\n"
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ import ipaddress
4
+
5
+
6
+ class AuthorizationRequired(PermissionError):
7
+ """Raised when an active network check lacks explicit authorization."""
8
+
9
+
10
+ def require_authorization(authorized: bool) -> None:
11
+ if not authorized:
12
+ raise AuthorizationRequired(
13
+ "This active network check requires permission. Pass authorized=True only for "
14
+ "systems you own or are explicitly allowed to test."
15
+ )
16
+
17
+
18
+ def public_ip_warning(value: str) -> str | None:
19
+ try:
20
+ address = ipaddress.ip_address(value)
21
+ except ValueError:
22
+ return None
23
+ if address.is_global:
24
+ return "This is a public Internet address; confirm the exact testing scope first."
25
+ return None
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ import socket
4
+ import ssl
5
+ from datetime import datetime, timezone
6
+
7
+
8
+ def inspect_tls(host: str, port: int = 443, timeout: float = 5.0) -> dict[str, object]:
9
+ context = ssl.create_default_context()
10
+ with (
11
+ socket.create_connection((host, port), timeout=timeout) as raw,
12
+ context.wrap_socket(raw, server_hostname=host) as secure,
13
+ ):
14
+ certificate = secure.getpeercert()
15
+ cipher = secure.cipher()
16
+ expires_text = certificate.get("notAfter")
17
+ expires = (
18
+ datetime.strptime(expires_text, "%b %d %H:%M:%S %Y %Z").replace(tzinfo=timezone.utc)
19
+ if expires_text
20
+ else None
21
+ )
22
+ days = (expires - datetime.now(timezone.utc)).days if expires else None
23
+ return {
24
+ "host": host,
25
+ "port": port,
26
+ "tls_version": secure.version(),
27
+ "cipher": cipher[0] if cipher else None,
28
+ "certificate_subject": dict(item[0] for item in certificate.get("subject", [])),
29
+ "certificate_issuer": dict(item[0] for item in certificate.get("issuer", [])),
30
+ "expires_utc": expires.isoformat() if expires else None,
31
+ "days_until_expiry": days,
32
+ "subject_alt_names": [
33
+ value for kind, value in certificate.get("subjectAltName", []) if kind == "DNS"
34
+ ],
35
+ "plain_english": f"The certificate is trusted and expires in {days} days."
36
+ if days is not None
37
+ else "The certificate is trusted.",
38
+ }
@@ -0,0 +1,7 @@
1
+ from cyberplain.cli import build_parser, run
2
+
3
+
4
+ def test_cli_dns_parser():
5
+ args = build_parser().parse_args(["dns", "localhost"])
6
+ result = run(args)
7
+ assert result["host"] == "localhost"
@@ -0,0 +1,45 @@
1
+ from pathlib import Path
2
+
3
+ import pytest
4
+
5
+ from cyberplain.hashing import create_manifest, hash_file, verify_file, verify_manifest
6
+ from cyberplain.iocs import defang, extract_iocs, refang
7
+ from cyberplain.passwords import assess_password
8
+ from cyberplain.ports import parse_ports, scan_ports
9
+
10
+
11
+ def test_port_parser():
12
+ assert parse_ports("443,80,8000-8002") == [80, 443, 8000, 8001, 8002]
13
+
14
+
15
+ def test_scan_requires_authorization():
16
+ with pytest.raises(PermissionError):
17
+ scan_ports("127.0.0.1", "80")
18
+
19
+
20
+ def test_hash_and_verify(tmp_path: Path):
21
+ target = tmp_path / "hello.txt"
22
+ target.write_text("hello", encoding="utf-8")
23
+ digest = hash_file(target)
24
+ assert len(digest) == 64
25
+ assert verify_file(target, digest)
26
+
27
+
28
+ def test_manifest_detects_changes(tmp_path: Path):
29
+ target = tmp_path / "hello.txt"
30
+ target.write_text("first", encoding="utf-8")
31
+ manifest = create_manifest(tmp_path)
32
+ target.write_text("second", encoding="utf-8")
33
+ assert verify_manifest(tmp_path, manifest)["changed"] == ["hello.txt"]
34
+
35
+
36
+ def test_ioc_extraction_and_defang():
37
+ result = extract_iocs("Visit https://example.com/a from 192.0.2.8 or email a@example.com")
38
+ assert result["domains"] == ["example.com"]
39
+ assert result["ip_addresses"] == ["192.0.2.8"]
40
+ assert refang(defang("https://example.com")) == "https://example.com"
41
+
42
+
43
+ def test_password_feedback():
44
+ assert assess_password("password")["rating"] == "very weak"
45
+ assert assess_password("correct horse battery staple plus")["score"] >= 3