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.
- cyberplain-0.1.0/.gitignore +8 -0
- cyberplain-0.1.0/LICENSE +22 -0
- cyberplain-0.1.0/PKG-INFO +151 -0
- cyberplain-0.1.0/README.md +120 -0
- cyberplain-0.1.0/publish.py +124 -0
- cyberplain-0.1.0/pyproject.toml +50 -0
- cyberplain-0.1.0/src/cyberplain/__init__.py +8 -0
- cyberplain-0.1.0/src/cyberplain/__init__.py.bak +8 -0
- cyberplain-0.1.0/src/cyberplain/cli.py +153 -0
- cyberplain-0.1.0/src/cyberplain/dnscheck.py +34 -0
- cyberplain-0.1.0/src/cyberplain/files.py +32 -0
- cyberplain-0.1.0/src/cyberplain/hashing.py +54 -0
- cyberplain-0.1.0/src/cyberplain/httpcheck.py +53 -0
- cyberplain-0.1.0/src/cyberplain/iocs.py +40 -0
- cyberplain-0.1.0/src/cyberplain/models.py +32 -0
- cyberplain-0.1.0/src/cyberplain/passwords.py +50 -0
- cyberplain-0.1.0/src/cyberplain/pcap.py +66 -0
- cyberplain-0.1.0/src/cyberplain/ports.py +124 -0
- cyberplain-0.1.0/src/cyberplain/reporting.py +32 -0
- cyberplain-0.1.0/src/cyberplain/safety.py +25 -0
- cyberplain-0.1.0/src/cyberplain/tlscheck.py +38 -0
- cyberplain-0.1.0/tests/test_cli.py +7 -0
- cyberplain-0.1.0/tests/test_core.py +45 -0
cyberplain-0.1.0/LICENSE
ADDED
|
@@ -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,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
|