cybora 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cybora/__init__.py +2 -0
- cybora/__main__.py +4 -0
- cybora/cli.py +134 -0
- cybora/models.py +33 -0
- cybora/modules/__init__.py +0 -0
- cybora/modules/dependencies.py +57 -0
- cybora/modules/network.py +43 -0
- cybora/modules/repo.py +130 -0
- cybora/modules/system.py +46 -0
- cybora/modules/web.py +153 -0
- cybora/reporting.py +60 -0
- cybora/risk.py +22 -0
- cybora/runner.py +14 -0
- cybora-0.1.0.dist-info/METADATA +849 -0
- cybora-0.1.0.dist-info/RECORD +19 -0
- cybora-0.1.0.dist-info/WHEEL +5 -0
- cybora-0.1.0.dist-info/entry_points.txt +2 -0
- cybora-0.1.0.dist-info/licenses/LICENSE +21 -0
- cybora-0.1.0.dist-info/top_level.txt +1 -0
cybora/__init__.py
ADDED
cybora/__main__.py
ADDED
cybora/cli.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .modules import dependencies, network, system
|
|
11
|
+
from .modules import repo as repo_module
|
|
12
|
+
from .modules import web as web_module
|
|
13
|
+
from .reporting import print_result, write_json
|
|
14
|
+
from .runner import build_result
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(
|
|
17
|
+
name="cybora",
|
|
18
|
+
help="Free modular defensive cybersecurity assessment platform.",
|
|
19
|
+
no_args_is_help=True,
|
|
20
|
+
)
|
|
21
|
+
console = Console()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.callback()
|
|
25
|
+
def main(
|
|
26
|
+
version: bool | None = typer.Option(
|
|
27
|
+
None, "--version", callback=lambda value: _version_callback(value), is_eager=True
|
|
28
|
+
),
|
|
29
|
+
):
|
|
30
|
+
"""Cybora CLI entry point."""
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _version_callback(value: bool):
|
|
35
|
+
if value:
|
|
36
|
+
typer.echo(f"cybora {__version__}")
|
|
37
|
+
raise typer.Exit()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@app.command()
|
|
41
|
+
def demo():
|
|
42
|
+
"""Run a safe local demo using an in-memory sample."""
|
|
43
|
+
sample = [
|
|
44
|
+
{
|
|
45
|
+
"module": "demo",
|
|
46
|
+
"title": "Sample medium-risk finding",
|
|
47
|
+
"severity": "medium",
|
|
48
|
+
"description": "This is a demonstration finding.",
|
|
49
|
+
"target": "demo://local",
|
|
50
|
+
"remediation": "Replace the demo with a real authorized assessment.",
|
|
51
|
+
}
|
|
52
|
+
]
|
|
53
|
+
console.print_json(json.dumps({"tool": "cybora", "version": __version__, "findings": sample}))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@app.command()
|
|
57
|
+
def web(target: str):
|
|
58
|
+
"""Run a non-destructive website baseline assessment."""
|
|
59
|
+
result = build_result(target, web_module.scan(target))
|
|
60
|
+
print_result(result)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@app.command()
|
|
64
|
+
def repo(target: str = typer.Argument(".", help="Repository directory.")):
|
|
65
|
+
"""Scan a local repository for common accidental secrets and risky patterns."""
|
|
66
|
+
result = build_result(target, repo_module.scan(target))
|
|
67
|
+
print_result(result)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@app.command("deps")
|
|
71
|
+
def deps_command(target: str = typer.Argument(..., help="Dependency file.")):
|
|
72
|
+
"""Assess dependency pinning and source hygiene."""
|
|
73
|
+
result = build_result(target, dependencies.scan(target))
|
|
74
|
+
print_result(result)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@app.command("system")
|
|
78
|
+
def system_assess():
|
|
79
|
+
"""Assess the local system without making privileged changes."""
|
|
80
|
+
result = build_result("local-system", system.scan())
|
|
81
|
+
print_result(result)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@app.command("network")
|
|
85
|
+
def network_assess():
|
|
86
|
+
"""Check common service ports on loopback only."""
|
|
87
|
+
result = build_result("127.0.0.1", network.scan())
|
|
88
|
+
print_result(result)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@app.command()
|
|
92
|
+
def assess(
|
|
93
|
+
web_target: str | None = typer.Option(None, "--web", help="Authorized HTTP(S) target."),
|
|
94
|
+
repo_target: str | None = typer.Option(None, "--repo", help="Local repository."),
|
|
95
|
+
deps_target: str | None = typer.Option(None, "--deps", help="Dependency file."),
|
|
96
|
+
system_check: bool = typer.Option(False, "--system", help="Include local system assessment."),
|
|
97
|
+
network_check: bool = typer.Option(
|
|
98
|
+
False, "--network", help="Include loopback network assessment."
|
|
99
|
+
),
|
|
100
|
+
output: str | None = typer.Option(None, "--output", help="Write JSON report to this path."),
|
|
101
|
+
):
|
|
102
|
+
"""Run multiple defensive assessments and combine findings into one report."""
|
|
103
|
+
findings = []
|
|
104
|
+
targets = []
|
|
105
|
+
|
|
106
|
+
if web_target:
|
|
107
|
+
findings.extend(web_module.scan(web_target))
|
|
108
|
+
targets.append(f"web:{web_target}")
|
|
109
|
+
if repo_target:
|
|
110
|
+
findings.extend(repo_module.scan(repo_target))
|
|
111
|
+
targets.append(f"repo:{repo_target}")
|
|
112
|
+
if deps_target:
|
|
113
|
+
findings.extend(dependencies.scan(deps_target))
|
|
114
|
+
targets.append(f"deps:{deps_target}")
|
|
115
|
+
if system_check:
|
|
116
|
+
findings.extend(system.scan())
|
|
117
|
+
targets.append("system:local")
|
|
118
|
+
if network_check:
|
|
119
|
+
findings.extend(network.scan())
|
|
120
|
+
targets.append("network:loopback")
|
|
121
|
+
|
|
122
|
+
if not targets:
|
|
123
|
+
raise typer.BadParameter("Choose at least one assessment option.")
|
|
124
|
+
|
|
125
|
+
result = build_result(" | ".join(targets), findings)
|
|
126
|
+
print_result(result)
|
|
127
|
+
|
|
128
|
+
if output:
|
|
129
|
+
write_json(result, output)
|
|
130
|
+
console.print(f"\n[green]JSON report written to {Path(output).resolve()}[/green]")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
if __name__ == "__main__":
|
|
134
|
+
app()
|
cybora/models.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Severity(StrEnum):
|
|
10
|
+
CRITICAL = "critical"
|
|
11
|
+
HIGH = "high"
|
|
12
|
+
MEDIUM = "medium"
|
|
13
|
+
LOW = "low"
|
|
14
|
+
INFO = "info"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Finding(BaseModel):
|
|
18
|
+
module: str
|
|
19
|
+
title: str
|
|
20
|
+
severity: Severity
|
|
21
|
+
description: str
|
|
22
|
+
target: str
|
|
23
|
+
evidence: str | None = None
|
|
24
|
+
remediation: str | None = None
|
|
25
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AssessmentResult(BaseModel):
|
|
29
|
+
tool: str = "cybora"
|
|
30
|
+
version: str
|
|
31
|
+
target: str
|
|
32
|
+
findings: list[Finding] = Field(default_factory=list)
|
|
33
|
+
risk_score: int = 0
|
|
File without changes
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from ..models import Finding, Severity
|
|
7
|
+
|
|
8
|
+
PINNED = re.compile(r"^[A-Za-z0-9_.-]+\s*(==|===|~=)\s*[^#\s]+$")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def scan(path: str) -> list[Finding]:
|
|
12
|
+
file = Path(path).resolve()
|
|
13
|
+
if not file.exists():
|
|
14
|
+
return [
|
|
15
|
+
Finding(
|
|
16
|
+
module="deps",
|
|
17
|
+
title="Dependency file does not exist",
|
|
18
|
+
severity=Severity.HIGH,
|
|
19
|
+
description="The supplied dependency file was not found.",
|
|
20
|
+
target=str(file),
|
|
21
|
+
remediation="Provide an existing requirements or dependency file.",
|
|
22
|
+
)
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
findings = []
|
|
26
|
+
for line_no, raw in enumerate(
|
|
27
|
+
file.read_text(encoding="utf-8", errors="ignore").splitlines(), 1
|
|
28
|
+
):
|
|
29
|
+
line = raw.strip()
|
|
30
|
+
if not line or line.startswith("#") or line.startswith("-"):
|
|
31
|
+
continue
|
|
32
|
+
if line.startswith(("git+", "http://", "https://")):
|
|
33
|
+
findings.append(
|
|
34
|
+
Finding(
|
|
35
|
+
module="deps",
|
|
36
|
+
title="Direct URL dependency",
|
|
37
|
+
severity=Severity.MEDIUM,
|
|
38
|
+
description="A dependency is sourced directly from a URL instead of a normal package index.",
|
|
39
|
+
target=f"{file.name}:{line_no}",
|
|
40
|
+
evidence=line[:160],
|
|
41
|
+
remediation="Prefer a trusted package release or a reviewed immutable source reference.",
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
elif not PINNED.match(line):
|
|
45
|
+
package = re.split(r"[<>=!~\s]", line, maxsplit=1)[0]
|
|
46
|
+
findings.append(
|
|
47
|
+
Finding(
|
|
48
|
+
module="deps",
|
|
49
|
+
title="Unpinned dependency",
|
|
50
|
+
severity=Severity.LOW,
|
|
51
|
+
description="The dependency does not use an exact or compatible version constraint.",
|
|
52
|
+
target=f"{file.name}:{line_no}",
|
|
53
|
+
evidence=package,
|
|
54
|
+
remediation="Pin or constrain production dependencies and maintain a reviewed update process.",
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
return findings
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import socket
|
|
4
|
+
|
|
5
|
+
from ..models import Finding, Severity
|
|
6
|
+
|
|
7
|
+
COMMON_LOCAL_PORTS = {
|
|
8
|
+
22: "SSH",
|
|
9
|
+
80: "HTTP",
|
|
10
|
+
443: "HTTPS",
|
|
11
|
+
3000: "Node development server",
|
|
12
|
+
5000: "Development server",
|
|
13
|
+
8000: "Python development server",
|
|
14
|
+
8080: "HTTP development/proxy",
|
|
15
|
+
8443: "HTTPS development server",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def scan(host: str = "127.0.0.1") -> list[Finding]:
|
|
20
|
+
findings = []
|
|
21
|
+
for port, label in COMMON_LOCAL_PORTS.items():
|
|
22
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
23
|
+
sock.settimeout(0.15)
|
|
24
|
+
try:
|
|
25
|
+
is_open = sock.connect_ex((host, port)) == 0
|
|
26
|
+
except OSError:
|
|
27
|
+
is_open = False
|
|
28
|
+
finally:
|
|
29
|
+
sock.close()
|
|
30
|
+
|
|
31
|
+
if is_open:
|
|
32
|
+
severity = Severity.LOW if port in {3000, 5000, 8000, 8080, 8443} else Severity.INFO
|
|
33
|
+
findings.append(
|
|
34
|
+
Finding(
|
|
35
|
+
module="network",
|
|
36
|
+
title=f"Local service reachable: {label}",
|
|
37
|
+
severity=severity,
|
|
38
|
+
description="A common service port accepted a TCP connection on the loopback interface.",
|
|
39
|
+
target=f"{host}:{port}",
|
|
40
|
+
remediation="Confirm the service is intentional and appropriately protected. Do not expose development services to untrusted networks.",
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
return findings
|
cybora/modules/repo.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from ..models import Finding, Severity
|
|
7
|
+
|
|
8
|
+
SECRET_PATTERNS = [
|
|
9
|
+
("AWS access key", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
|
|
10
|
+
(
|
|
11
|
+
"Generic API key assignment",
|
|
12
|
+
re.compile(r"(?i)\b(api[_-]?key|secret[_-]?key)\s*[:=]\s*['\"][^'\"]{12,}['\"]"),
|
|
13
|
+
),
|
|
14
|
+
("Private key marker", re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----")),
|
|
15
|
+
(
|
|
16
|
+
"Generic password assignment",
|
|
17
|
+
re.compile(r"(?i)\b(password|passwd|pwd)\s*[:=]\s*['\"][^'\"]{8,}['\"]"),
|
|
18
|
+
),
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
RISKY_NAMES = {".env", "id_rsa", "id_ed25519", "credentials.json", "secrets.json"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def scan(path: str) -> list[Finding]:
|
|
25
|
+
root = Path(path).resolve()
|
|
26
|
+
findings: list[Finding] = []
|
|
27
|
+
|
|
28
|
+
if not root.exists():
|
|
29
|
+
return [
|
|
30
|
+
Finding(
|
|
31
|
+
module="repo",
|
|
32
|
+
title="Repository path does not exist",
|
|
33
|
+
severity=Severity.HIGH,
|
|
34
|
+
description="The requested repository path could not be found.",
|
|
35
|
+
target=str(root),
|
|
36
|
+
remediation="Check the path and run the assessment again.",
|
|
37
|
+
)
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
for file in _files(root):
|
|
41
|
+
rel = str(file.relative_to(root))
|
|
42
|
+
if file.name in RISKY_NAMES:
|
|
43
|
+
findings.append(
|
|
44
|
+
Finding(
|
|
45
|
+
module="repo",
|
|
46
|
+
title="Sensitive-looking file present",
|
|
47
|
+
severity=Severity.MEDIUM,
|
|
48
|
+
description="A file commonly used for credentials or secrets exists in the repository.",
|
|
49
|
+
target=rel,
|
|
50
|
+
remediation="Verify it contains no real secrets and keep credential files out of version control.",
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
if file.stat().st_size > 25 * 1024 * 1024:
|
|
55
|
+
findings.append(
|
|
56
|
+
Finding(
|
|
57
|
+
module="repo",
|
|
58
|
+
title="Very large file",
|
|
59
|
+
severity=Severity.LOW,
|
|
60
|
+
description="Large files can increase repository exposure and supply-chain review burden.",
|
|
61
|
+
target=rel,
|
|
62
|
+
evidence=f"{file.stat().st_size} bytes",
|
|
63
|
+
remediation="Use an appropriate artifact store or Git LFS when justified.",
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
if file.suffix.lower() not in {
|
|
68
|
+
".py",
|
|
69
|
+
".js",
|
|
70
|
+
".ts",
|
|
71
|
+
".json",
|
|
72
|
+
".yaml",
|
|
73
|
+
".yml",
|
|
74
|
+
".toml",
|
|
75
|
+
".ini",
|
|
76
|
+
".env",
|
|
77
|
+
".txt",
|
|
78
|
+
".md",
|
|
79
|
+
}:
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
text = file.read_text(encoding="utf-8", errors="ignore")
|
|
84
|
+
except OSError:
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
for label, pattern in SECRET_PATTERNS:
|
|
88
|
+
if pattern.search(text):
|
|
89
|
+
findings.append(
|
|
90
|
+
Finding(
|
|
91
|
+
module="repo",
|
|
92
|
+
title=f"Possible {label}",
|
|
93
|
+
severity=Severity.HIGH,
|
|
94
|
+
description="A heuristic pattern resembling a credential or private key was found.",
|
|
95
|
+
target=rel,
|
|
96
|
+
remediation="Inspect the exact line, remove real secrets, rotate exposed credentials, and use a secret manager.",
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
if re.search(r"(?m)^\s*eval\s*\(", text):
|
|
101
|
+
findings.append(
|
|
102
|
+
Finding(
|
|
103
|
+
module="repo",
|
|
104
|
+
title="Dynamic eval usage",
|
|
105
|
+
severity=Severity.MEDIUM,
|
|
106
|
+
description="Dynamic evaluation can increase code-injection risk depending on data flow.",
|
|
107
|
+
target=rel,
|
|
108
|
+
remediation="Avoid eval where possible and validate untrusted input before any dynamic execution.",
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
return _dedupe(findings)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _files(root: Path):
|
|
116
|
+
ignored = {".git", ".venv", "__pycache__", "node_modules", ".pytest_cache"}
|
|
117
|
+
for p in root.rglob("*"):
|
|
118
|
+
if p.is_file() and not any(part in ignored for part in p.parts):
|
|
119
|
+
yield p
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _dedupe(findings: list[Finding]) -> list[Finding]:
|
|
123
|
+
seen = set()
|
|
124
|
+
result = []
|
|
125
|
+
for f in findings:
|
|
126
|
+
key = (f.module, f.title, f.target)
|
|
127
|
+
if key not in seen:
|
|
128
|
+
seen.add(key)
|
|
129
|
+
result.append(f)
|
|
130
|
+
return result
|
cybora/modules/system.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import platform
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from ..models import Finding, Severity
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def scan() -> list[Finding]:
|
|
12
|
+
findings = [
|
|
13
|
+
Finding(
|
|
14
|
+
module="system",
|
|
15
|
+
title="Runtime inventory",
|
|
16
|
+
severity=Severity.INFO,
|
|
17
|
+
description="Basic local runtime information was collected for the report.",
|
|
18
|
+
target=platform.node() or "localhost",
|
|
19
|
+
evidence=f"OS={platform.platform()} | Python={sys.version.split()[0]}",
|
|
20
|
+
remediation="Keep the operating system and runtime on supported security-maintained versions.",
|
|
21
|
+
)
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
if os.name == "nt":
|
|
25
|
+
findings.append(
|
|
26
|
+
Finding(
|
|
27
|
+
module="system",
|
|
28
|
+
title="Windows local security review reminder",
|
|
29
|
+
severity=Severity.INFO,
|
|
30
|
+
description="The foundation release does not attempt privileged Windows security configuration changes.",
|
|
31
|
+
target=platform.node() or "localhost",
|
|
32
|
+
remediation="Review Windows Update, Defender, firewall, disk encryption, account protection, and least privilege using your organization's baseline.",
|
|
33
|
+
)
|
|
34
|
+
)
|
|
35
|
+
else:
|
|
36
|
+
findings.append(
|
|
37
|
+
Finding(
|
|
38
|
+
module="system",
|
|
39
|
+
title="Local permissions review reminder",
|
|
40
|
+
severity=Severity.INFO,
|
|
41
|
+
description="The foundation release avoids privileged host changes.",
|
|
42
|
+
target=str(Path.home()),
|
|
43
|
+
remediation="Review account privileges, updates, firewall configuration, SSH access, and sensitive file permissions.",
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
return findings
|
cybora/modules/web.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from urllib.parse import urlparse
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from ..models import Finding, Severity
|
|
8
|
+
|
|
9
|
+
SECURITY_HEADERS = {
|
|
10
|
+
"strict-transport-security": (
|
|
11
|
+
Severity.MEDIUM,
|
|
12
|
+
"Missing HSTS",
|
|
13
|
+
"Consider Strict-Transport-Security for HTTPS deployments after validating deployment behavior.",
|
|
14
|
+
),
|
|
15
|
+
"content-security-policy": (
|
|
16
|
+
Severity.MEDIUM,
|
|
17
|
+
"Missing Content-Security-Policy",
|
|
18
|
+
"Deploy a carefully tested Content-Security-Policy appropriate to the application.",
|
|
19
|
+
),
|
|
20
|
+
"x-content-type-options": (
|
|
21
|
+
Severity.LOW,
|
|
22
|
+
"Missing X-Content-Type-Options",
|
|
23
|
+
"Set X-Content-Type-Options: nosniff where appropriate.",
|
|
24
|
+
),
|
|
25
|
+
"referrer-policy": (
|
|
26
|
+
Severity.LOW,
|
|
27
|
+
"Missing Referrer-Policy",
|
|
28
|
+
"Set a deliberate Referrer-Policy suitable for your application.",
|
|
29
|
+
),
|
|
30
|
+
"permissions-policy": (
|
|
31
|
+
Severity.INFO,
|
|
32
|
+
"Missing Permissions-Policy",
|
|
33
|
+
"Review whether browser feature permissions should be restricted.",
|
|
34
|
+
),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def scan(url: str) -> list[Finding]:
|
|
39
|
+
parsed = urlparse(url)
|
|
40
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
41
|
+
return [
|
|
42
|
+
Finding(
|
|
43
|
+
module="web",
|
|
44
|
+
title="Invalid web target",
|
|
45
|
+
severity=Severity.HIGH,
|
|
46
|
+
description="The supplied value is not a valid HTTP(S) URL.",
|
|
47
|
+
target=url,
|
|
48
|
+
remediation="Use a URL such as https://example.com.",
|
|
49
|
+
)
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
findings = []
|
|
53
|
+
try:
|
|
54
|
+
with httpx.Client(
|
|
55
|
+
follow_redirects=True,
|
|
56
|
+
timeout=8.0,
|
|
57
|
+
verify=True,
|
|
58
|
+
headers={"User-Agent": "Cybora/0.1 defensive-assessment"},
|
|
59
|
+
) as client:
|
|
60
|
+
response = client.get(url)
|
|
61
|
+
except httpx.HTTPError as exc:
|
|
62
|
+
return [
|
|
63
|
+
Finding(
|
|
64
|
+
module="web",
|
|
65
|
+
title="Web target could not be reached",
|
|
66
|
+
severity=Severity.HIGH,
|
|
67
|
+
description="The HTTP client could not complete a connection.",
|
|
68
|
+
target=url,
|
|
69
|
+
evidence=str(exc)[:300],
|
|
70
|
+
remediation="Verify the URL, connectivity, DNS, and authorization to assess the target.",
|
|
71
|
+
)
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
final_url = str(response.url)
|
|
75
|
+
if parsed.scheme == "http" and urlparse(final_url).scheme != "https":
|
|
76
|
+
findings.append(
|
|
77
|
+
Finding(
|
|
78
|
+
module="web",
|
|
79
|
+
title="HTTP target did not end on HTTPS",
|
|
80
|
+
severity=Severity.HIGH,
|
|
81
|
+
description="The requested HTTP URL did not redirect to HTTPS.",
|
|
82
|
+
target=url,
|
|
83
|
+
remediation="Prefer HTTPS and configure a deliberate HTTP-to-HTTPS redirect.",
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
headers = {k.lower(): v for k, v in response.headers.items()}
|
|
88
|
+
for header, (severity, title, remediation) in SECURITY_HEADERS.items():
|
|
89
|
+
if header not in headers:
|
|
90
|
+
findings.append(
|
|
91
|
+
Finding(
|
|
92
|
+
module="web",
|
|
93
|
+
title=title,
|
|
94
|
+
severity=severity,
|
|
95
|
+
description=f"The response did not include the {header} header.",
|
|
96
|
+
target=final_url,
|
|
97
|
+
remediation=remediation,
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
server = headers.get("server")
|
|
102
|
+
if server:
|
|
103
|
+
findings.append(
|
|
104
|
+
Finding(
|
|
105
|
+
module="web",
|
|
106
|
+
title="Server header disclosed",
|
|
107
|
+
severity=Severity.LOW,
|
|
108
|
+
description="The HTTP response discloses a server identification header.",
|
|
109
|
+
target=final_url,
|
|
110
|
+
evidence=server[:120],
|
|
111
|
+
remediation="Consider whether detailed server identification is necessary.",
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
set_cookie = headers.get("set-cookie", "")
|
|
116
|
+
if set_cookie:
|
|
117
|
+
lower = set_cookie.lower()
|
|
118
|
+
if "secure" not in lower and urlparse(final_url).scheme == "https":
|
|
119
|
+
findings.append(
|
|
120
|
+
Finding(
|
|
121
|
+
module="web",
|
|
122
|
+
title="Cookie without Secure attribute observed",
|
|
123
|
+
severity=Severity.MEDIUM,
|
|
124
|
+
description="A Set-Cookie response did not visibly include the Secure attribute.",
|
|
125
|
+
target=final_url,
|
|
126
|
+
remediation="Use Secure on cookies carrying sensitive session state over HTTPS.",
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
if "httponly" not in lower:
|
|
130
|
+
findings.append(
|
|
131
|
+
Finding(
|
|
132
|
+
module="web",
|
|
133
|
+
title="Cookie without HttpOnly attribute observed",
|
|
134
|
+
severity=Severity.LOW,
|
|
135
|
+
description="A Set-Cookie response did not visibly include HttpOnly.",
|
|
136
|
+
target=final_url,
|
|
137
|
+
remediation="Use HttpOnly for cookies that do not need JavaScript access.",
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
findings.append(
|
|
142
|
+
Finding(
|
|
143
|
+
module="web",
|
|
144
|
+
title="HTTP response observed",
|
|
145
|
+
severity=Severity.INFO,
|
|
146
|
+
description="A non-destructive HTTP request completed.",
|
|
147
|
+
target=final_url,
|
|
148
|
+
evidence=f"status={response.status_code}; content-type={headers.get('content-type', 'unknown')}",
|
|
149
|
+
remediation="Use the findings above as a baseline; application security requires deeper authorized testing.",
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
return findings
|
cybora/reporting.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
from .models import AssessmentResult
|
|
10
|
+
|
|
11
|
+
console = Console()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def print_result(result: AssessmentResult) -> None:
|
|
15
|
+
console.print("\n[bold]CYBORA SECURITY ASSESSMENT[/bold]\n")
|
|
16
|
+
console.print(f"Target: {result.target}")
|
|
17
|
+
console.print(f"Findings: {len(result.findings)}")
|
|
18
|
+
console.print(f"Risk score: {result.risk_score}/100\n")
|
|
19
|
+
|
|
20
|
+
if not result.findings:
|
|
21
|
+
console.print("[green]No findings in the selected checks.[/green]")
|
|
22
|
+
return
|
|
23
|
+
|
|
24
|
+
table = Table(show_header=True, header_style="bold")
|
|
25
|
+
table.add_column("Severity")
|
|
26
|
+
table.add_column("Module")
|
|
27
|
+
table.add_column("Finding")
|
|
28
|
+
table.add_column("Target")
|
|
29
|
+
|
|
30
|
+
for finding in sorted(result.findings, key=lambda x: _rank(x.severity.value)):
|
|
31
|
+
table.add_row(
|
|
32
|
+
finding.severity.value.upper(),
|
|
33
|
+
finding.module,
|
|
34
|
+
finding.title,
|
|
35
|
+
finding.target,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
console.print(table)
|
|
39
|
+
console.print("\n[bold]Priority guidance[/bold]")
|
|
40
|
+
for i, finding in enumerate(
|
|
41
|
+
sorted(result.findings, key=lambda x: _rank(x.severity.value))[:5], start=1
|
|
42
|
+
):
|
|
43
|
+
console.print(f"{i}. {finding.title}: {finding.remediation or 'Review manually.'}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def write_json(result: AssessmentResult, path: str | Path) -> None:
|
|
47
|
+
Path(path).write_text(
|
|
48
|
+
json.dumps(result.model_dump(mode="json"), indent=2),
|
|
49
|
+
encoding="utf-8",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _rank(severity: str) -> int:
|
|
54
|
+
return {
|
|
55
|
+
"critical": 0,
|
|
56
|
+
"high": 1,
|
|
57
|
+
"medium": 2,
|
|
58
|
+
"low": 3,
|
|
59
|
+
"info": 4,
|
|
60
|
+
}.get(severity, 5)
|