tethr-engine 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.
- tethr_engine-0.1.0/LICENSE +21 -0
- tethr_engine-0.1.0/MANIFEST.in +2 -0
- tethr_engine-0.1.0/PKG-INFO +54 -0
- tethr_engine-0.1.0/README.md +31 -0
- tethr_engine-0.1.0/pyproject.toml +38 -0
- tethr_engine-0.1.0/setup.cfg +4 -0
- tethr_engine-0.1.0/tethr_engine/__init__.py +1 -0
- tethr_engine-0.1.0/tethr_engine/api_keys.py +86 -0
- tethr_engine-0.1.0/tethr_engine/audit.py +245 -0
- tethr_engine-0.1.0/tethr_engine/auth.py +180 -0
- tethr_engine-0.1.0/tethr_engine/cli.py +78 -0
- tethr_engine-0.1.0/tethr_engine/config.py +441 -0
- tethr_engine-0.1.0/tethr_engine/database.py +530 -0
- tethr_engine-0.1.0/tethr_engine/extractor.py +1488 -0
- tethr_engine-0.1.0/tethr_engine/identity.py +1244 -0
- tethr_engine-0.1.0/tethr_engine/mcp_server.py +244 -0
- tethr_engine-0.1.0/tethr_engine/memory.py +341 -0
- tethr_engine-0.1.0/tethr_engine/patterns.py +1755 -0
- tethr_engine-0.1.0/tethr_engine/personality.py +303 -0
- tethr_engine-0.1.0/tethr_engine/pots.py +640 -0
- tethr_engine-0.1.0/tethr_engine/pots_linux.py +991 -0
- tethr_engine-0.1.0/tethr_engine/server.py +515 -0
- tethr_engine-0.1.0/tethr_engine/static/app.js +611 -0
- tethr_engine-0.1.0/tethr_engine/static/index.html +131 -0
- tethr_engine-0.1.0/tethr_engine/static/style.css +824 -0
- tethr_engine-0.1.0/tethr_engine/utils.py +189 -0
- tethr_engine-0.1.0/tethr_engine.egg-info/PKG-INFO +54 -0
- tethr_engine-0.1.0/tethr_engine.egg-info/SOURCES.txt +30 -0
- tethr_engine-0.1.0/tethr_engine.egg-info/dependency_links.txt +1 -0
- tethr_engine-0.1.0/tethr_engine.egg-info/entry_points.txt +2 -0
- tethr_engine-0.1.0/tethr_engine.egg-info/requires.txt +12 -0
- tethr_engine-0.1.0/tethr_engine.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jeff Draper
|
|
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.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tethr-engine
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Standalone identity engine — REST API, memory, and pattern extraction
|
|
5
|
+
Author: Jeff Draper
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: fastapi
|
|
11
|
+
Requires-Dist: uvicorn[standard]
|
|
12
|
+
Requires-Dist: pydantic
|
|
13
|
+
Requires-Dist: groq
|
|
14
|
+
Requires-Dist: pyyaml
|
|
15
|
+
Requires-Dist: python-jose[cryptography]
|
|
16
|
+
Requires-Dist: bcrypt
|
|
17
|
+
Requires-Dist: requests
|
|
18
|
+
Requires-Dist: mcp
|
|
19
|
+
Requires-Dist: sentence-transformers
|
|
20
|
+
Requires-Dist: psutil>=5.9.0
|
|
21
|
+
Requires-Dist: watchdog
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# tethr-engine
|
|
25
|
+
|
|
26
|
+
Standalone identity and memory engine. Runs a local REST API server that tracks behavioral patterns, manages long-term memory, and provides an LLM-ready identity context layer.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
pip install tethr-engine
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
tethr start # start server on localhost:8001
|
|
38
|
+
tethr start --host 0.0.0.0 # network accessible
|
|
39
|
+
tethr start --reload # dev mode with auto-reload
|
|
40
|
+
tethr status # check health
|
|
41
|
+
tethr version # print version
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
On first run, `tethr start` will prompt for a Groq API key and create `config.yaml` in the working directory. `config.yaml` is never included in the package.
|
|
45
|
+
|
|
46
|
+
## MCP Server
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
python -m tethr_engine.mcp_server
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## License
|
|
53
|
+
|
|
54
|
+
MIT — Jeff Draper 2026
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# tethr-engine
|
|
2
|
+
|
|
3
|
+
Standalone identity and memory engine. Runs a local REST API server that tracks behavioral patterns, manages long-term memory, and provides an LLM-ready identity context layer.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
pip install tethr-engine
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
tethr start # start server on localhost:8001
|
|
15
|
+
tethr start --host 0.0.0.0 # network accessible
|
|
16
|
+
tethr start --reload # dev mode with auto-reload
|
|
17
|
+
tethr status # check health
|
|
18
|
+
tethr version # print version
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
On first run, `tethr start` will prompt for a Groq API key and create `config.yaml` in the working directory. `config.yaml` is never included in the package.
|
|
22
|
+
|
|
23
|
+
## MCP Server
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
python -m tethr_engine.mcp_server
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## License
|
|
30
|
+
|
|
31
|
+
MIT — Jeff Draper 2026
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tethr-engine"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Standalone identity engine — REST API, memory, and pattern extraction"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
authors = [{ name = "Jeff Draper" }]
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
|
|
14
|
+
dependencies = [
|
|
15
|
+
"fastapi",
|
|
16
|
+
"uvicorn[standard]",
|
|
17
|
+
"pydantic",
|
|
18
|
+
"groq",
|
|
19
|
+
"pyyaml",
|
|
20
|
+
"python-jose[cryptography]",
|
|
21
|
+
"bcrypt",
|
|
22
|
+
"requests",
|
|
23
|
+
"mcp",
|
|
24
|
+
# Optional but pulled in unconditionally so the package works out-of-box:
|
|
25
|
+
"sentence-transformers",
|
|
26
|
+
"psutil>=5.9.0",
|
|
27
|
+
"watchdog",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
tethr = "tethr_engine.cli:main"
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.packages.find]
|
|
34
|
+
where = ["."]
|
|
35
|
+
include = ["tethr_engine*"]
|
|
36
|
+
|
|
37
|
+
[tool.setuptools.package-data]
|
|
38
|
+
tethr_engine = ["static/*"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""tethr_engine — Standalone Familiar identity engine package."""
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""
|
|
2
|
+
api_keys.py — External API key management for network access mode.
|
|
3
|
+
|
|
4
|
+
Keys are prefixed 'fam_' and stored as SHA-256 hashes in the api_keys table.
|
|
5
|
+
The plaintext key is returned only once at creation time — store it immediately.
|
|
6
|
+
"""
|
|
7
|
+
import hashlib
|
|
8
|
+
import secrets
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
|
|
11
|
+
from tethr_engine.database import get_connection
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _hash_key(key: str) -> str:
|
|
15
|
+
return hashlib.sha256(key.encode()).hexdigest()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def create_api_key(name: str) -> dict:
|
|
19
|
+
"""
|
|
20
|
+
Generate a new API key, store hash in DB.
|
|
21
|
+
Returns {"id": int, "key": str, "name": str} — key is plaintext, shown once.
|
|
22
|
+
"""
|
|
23
|
+
key = "fam_" + secrets.token_urlsafe(32)
|
|
24
|
+
key_hash = _hash_key(key)
|
|
25
|
+
conn = get_connection()
|
|
26
|
+
try:
|
|
27
|
+
cursor = conn.execute(
|
|
28
|
+
"INSERT INTO api_keys (key_hash, name, created_at, active) VALUES (?, ?, ?, 1)",
|
|
29
|
+
(key_hash, name, datetime.now(timezone.utc).isoformat()),
|
|
30
|
+
)
|
|
31
|
+
conn.commit()
|
|
32
|
+
key_id = cursor.lastrowid
|
|
33
|
+
finally:
|
|
34
|
+
conn.close()
|
|
35
|
+
return {"id": key_id, "key": key, "name": name}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def verify_api_key(key: str) -> int | None:
|
|
39
|
+
"""
|
|
40
|
+
Validate a raw API key. Returns the api_key id if active, None otherwise.
|
|
41
|
+
Updates last_used on success.
|
|
42
|
+
"""
|
|
43
|
+
key_hash = _hash_key(key)
|
|
44
|
+
conn = get_connection()
|
|
45
|
+
try:
|
|
46
|
+
row = conn.execute(
|
|
47
|
+
"SELECT id FROM api_keys WHERE key_hash = ? AND active = 1",
|
|
48
|
+
(key_hash,),
|
|
49
|
+
).fetchone()
|
|
50
|
+
if row:
|
|
51
|
+
conn.execute(
|
|
52
|
+
"UPDATE api_keys SET last_used = ? WHERE id = ?",
|
|
53
|
+
(datetime.now(timezone.utc).isoformat(), row["id"]),
|
|
54
|
+
)
|
|
55
|
+
conn.commit()
|
|
56
|
+
return row["id"]
|
|
57
|
+
finally:
|
|
58
|
+
conn.close()
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def revoke_api_key(key_id: int) -> bool:
|
|
63
|
+
"""Deactivate an API key by id. Returns True if found and deactivated."""
|
|
64
|
+
conn = get_connection()
|
|
65
|
+
try:
|
|
66
|
+
cursor = conn.execute(
|
|
67
|
+
"UPDATE api_keys SET active = 0 WHERE id = ? AND active = 1",
|
|
68
|
+
(key_id,),
|
|
69
|
+
)
|
|
70
|
+
conn.commit()
|
|
71
|
+
return cursor.rowcount > 0
|
|
72
|
+
finally:
|
|
73
|
+
conn.close()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def list_api_keys() -> list:
|
|
77
|
+
"""Return all active API keys (key_hash excluded)."""
|
|
78
|
+
conn = get_connection()
|
|
79
|
+
try:
|
|
80
|
+
rows = conn.execute(
|
|
81
|
+
"SELECT id, name, created_at, last_used "
|
|
82
|
+
"FROM api_keys WHERE active = 1 ORDER BY created_at DESC"
|
|
83
|
+
).fetchall()
|
|
84
|
+
return [dict(r) for r in rows]
|
|
85
|
+
finally:
|
|
86
|
+
conn.close()
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""
|
|
2
|
+
audit.py — Audit logging, input sanitization middleware, and injection detection.
|
|
3
|
+
|
|
4
|
+
Audit log: immutable record of security events.
|
|
5
|
+
Sanitization middleware: strips null bytes and control characters, then scans
|
|
6
|
+
all string fields for prompt injection patterns before routing.
|
|
7
|
+
detect_injection(): flags instruction override, role hijack, system prompt
|
|
8
|
+
extraction, jailbreak, and identity replacement attempts.
|
|
9
|
+
"""
|
|
10
|
+
import logging
|
|
11
|
+
import re
|
|
12
|
+
import sqlite3
|
|
13
|
+
|
|
14
|
+
from tethr_engine.database import get_connection
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ── audit log ─────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
def log_audit(
|
|
22
|
+
action: str,
|
|
23
|
+
user_id: str = None,
|
|
24
|
+
details: str = None,
|
|
25
|
+
ip_address: str = None,
|
|
26
|
+
):
|
|
27
|
+
"""Write a security event to the audit_log table."""
|
|
28
|
+
conn = get_connection()
|
|
29
|
+
try:
|
|
30
|
+
conn.execute(
|
|
31
|
+
"INSERT INTO audit_log (user_id, action, details, ip_address) VALUES (?, ?, ?, ?)",
|
|
32
|
+
(user_id, action, details, ip_address)
|
|
33
|
+
)
|
|
34
|
+
conn.commit()
|
|
35
|
+
except Exception as e:
|
|
36
|
+
print(f"[audit] WARN — failed to write audit event '{action}': {e}")
|
|
37
|
+
finally:
|
|
38
|
+
conn.close()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ── prompt injection detection ────────────────────────────
|
|
42
|
+
# Patterns are ordered within each category by specificity.
|
|
43
|
+
# confidence: 3 = high certainty (exact known phrase), 2 = likely, 1 = possible.
|
|
44
|
+
|
|
45
|
+
_INJECTION_PATTERNS: list[tuple[str, int, re.Pattern]] = []
|
|
46
|
+
|
|
47
|
+
def _build_injection_patterns():
|
|
48
|
+
"""Compile injection detection patterns once at import time."""
|
|
49
|
+
raw = [
|
|
50
|
+
# (threat_type, confidence, pattern_string)
|
|
51
|
+
# Instruction override
|
|
52
|
+
("instruction_override", 3, r"ignore\s+previous\s+instructions?"),
|
|
53
|
+
("instruction_override", 3, r"ignore\s+all\s+(previous\s+)?instructions?"),
|
|
54
|
+
("instruction_override", 3, r"forget\s+your\s+instructions?"),
|
|
55
|
+
("instruction_override", 3, r"new\s+instructions?[\s:]+"),
|
|
56
|
+
("instruction_override", 2, r"\bdisregard\b"),
|
|
57
|
+
# Role hijack
|
|
58
|
+
("role_hijack", 3, r"you\s+are\s+now\s+\w"),
|
|
59
|
+
("role_hijack", 3, r"you\s+have\s+been\s+reprogrammed"),
|
|
60
|
+
("role_hijack", 3, r"your\s+new\s+name\s+is\b"),
|
|
61
|
+
("role_hijack", 2, r"\bpretend\s+you\s+are\b"),
|
|
62
|
+
("role_hijack", 2, r"\bact\s+as\s+(an?\s+)?\w"),
|
|
63
|
+
# System prompt extraction
|
|
64
|
+
("system_prompt_extraction", 3, r"repeat\s+your\s+instructions?"),
|
|
65
|
+
("system_prompt_extraction", 3, r"show\s+(me\s+)?your\s+system\s+prompt"),
|
|
66
|
+
("system_prompt_extraction", 3, r"print\s+your\s+(system\s+)?prompt"),
|
|
67
|
+
("system_prompt_extraction", 2, r"what\s+are\s+your\s+instructions?"),
|
|
68
|
+
("system_prompt_extraction", 2, r"reveal\s+your\s+(system\s+)?prompt"),
|
|
69
|
+
# Jailbreak
|
|
70
|
+
("jailbreak", 3, r"\bDAN\b"),
|
|
71
|
+
("jailbreak", 3, r"\bdeveloper\s+mode\b"),
|
|
72
|
+
("jailbreak", 3, r"\bunrestricted\s+mode\b"),
|
|
73
|
+
("jailbreak", 2, r"\bno\s+restrictions?\b"),
|
|
74
|
+
("jailbreak", 2, r"\bwithout\s+filters?\b"),
|
|
75
|
+
# Identity replacement
|
|
76
|
+
("identity_replacement", 3, r"your\s+real\s+name\s+is\b"),
|
|
77
|
+
("identity_replacement", 3, r"your\s+true\s+identity\b"),
|
|
78
|
+
("identity_replacement", 3, r"you\s+were\s+actually\b"),
|
|
79
|
+
]
|
|
80
|
+
for threat_type, confidence, pattern in raw:
|
|
81
|
+
_INJECTION_PATTERNS.append(
|
|
82
|
+
(threat_type, confidence, re.compile(pattern, re.IGNORECASE))
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
_build_injection_patterns()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def detect_injection(text: str) -> dict:
|
|
89
|
+
"""
|
|
90
|
+
Scan a string for prompt injection patterns.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
{
|
|
94
|
+
"detected": bool,
|
|
95
|
+
"threat_type": str | None, # first match wins
|
|
96
|
+
"confidence": int | None, # 1–3
|
|
97
|
+
"matched_pattern": str | None, # the matched substring
|
|
98
|
+
}
|
|
99
|
+
"""
|
|
100
|
+
for threat_type, confidence, pattern in _INJECTION_PATTERNS:
|
|
101
|
+
m = pattern.search(text)
|
|
102
|
+
if m:
|
|
103
|
+
return {
|
|
104
|
+
"detected": True,
|
|
105
|
+
"threat_type": threat_type,
|
|
106
|
+
"confidence": confidence,
|
|
107
|
+
"matched_pattern": m.group(0),
|
|
108
|
+
}
|
|
109
|
+
return {"detected": False, "threat_type": None, "confidence": None, "matched_pattern": None}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _collect_strings(value) -> list[str]:
|
|
113
|
+
"""Recursively collect all string values from a JSON-decoded structure."""
|
|
114
|
+
if isinstance(value, str):
|
|
115
|
+
return [value]
|
|
116
|
+
if isinstance(value, dict):
|
|
117
|
+
out = []
|
|
118
|
+
for v in value.values():
|
|
119
|
+
out.extend(_collect_strings(v))
|
|
120
|
+
return out
|
|
121
|
+
if isinstance(value, list):
|
|
122
|
+
out = []
|
|
123
|
+
for item in value:
|
|
124
|
+
out.extend(_collect_strings(item))
|
|
125
|
+
return out
|
|
126
|
+
return []
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ── input sanitization ────────────────────────────────────
|
|
130
|
+
# Strips: null bytes (\x00), ASCII control chars (0x01–0x1F except \t \n \r),
|
|
131
|
+
# DEL (0x7F), and Unicode zero-width / specials.
|
|
132
|
+
|
|
133
|
+
_STRIP_PATTERN = re.compile(
|
|
134
|
+
r"[\x00\x01-\x08\x0b\x0c\x0e-\x1f\x7f]"
|
|
135
|
+
r"|\u200b|\u200c|\u200d|\u200e|\u200f"
|
|
136
|
+
r"|[\ufff0-\uffff]"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _sanitize_str(value: str) -> tuple[str, bool]:
|
|
141
|
+
cleaned = _STRIP_PATTERN.sub("", value)
|
|
142
|
+
return cleaned, cleaned != value
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _sanitize_value(value):
|
|
146
|
+
"""Recursively sanitize strings within dicts, lists, and scalars."""
|
|
147
|
+
if isinstance(value, str):
|
|
148
|
+
return _sanitize_str(value)
|
|
149
|
+
if isinstance(value, dict):
|
|
150
|
+
result = {}
|
|
151
|
+
changed = False
|
|
152
|
+
for k, v in value.items():
|
|
153
|
+
new_v, c = _sanitize_value(v)
|
|
154
|
+
result[k] = new_v
|
|
155
|
+
changed = changed or c
|
|
156
|
+
return result, changed
|
|
157
|
+
if isinstance(value, list):
|
|
158
|
+
result = []
|
|
159
|
+
changed = False
|
|
160
|
+
for item in value:
|
|
161
|
+
new_item, c = _sanitize_value(item)
|
|
162
|
+
result.append(new_item)
|
|
163
|
+
changed = changed or c
|
|
164
|
+
return result, changed
|
|
165
|
+
return value, False
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ── FastAPI middleware ─────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
from fastapi import Request
|
|
171
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
172
|
+
import json
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class SanitizationMiddleware(BaseHTTPMiddleware):
|
|
176
|
+
"""
|
|
177
|
+
For every JSON request:
|
|
178
|
+
1. Strip control characters from all string values.
|
|
179
|
+
2. Scan all string values for prompt injection patterns.
|
|
180
|
+
3. Log both events to security_log (flag only — never block).
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
async def dispatch(self, request: Request, call_next):
|
|
184
|
+
content_type = request.headers.get("content-type", "")
|
|
185
|
+
|
|
186
|
+
if "application/json" in content_type:
|
|
187
|
+
try:
|
|
188
|
+
body_bytes = await request.body()
|
|
189
|
+
if body_bytes:
|
|
190
|
+
data = json.loads(body_bytes)
|
|
191
|
+
|
|
192
|
+
# 1. Sanitize control characters
|
|
193
|
+
cleaned, changed = _sanitize_value(data)
|
|
194
|
+
if changed:
|
|
195
|
+
self._log_security(request, "control_chars_stripped", None, None)
|
|
196
|
+
request._body = json.dumps(cleaned).encode()
|
|
197
|
+
data = cleaned # scan the cleaned version for injections
|
|
198
|
+
|
|
199
|
+
# 2. Injection detection — scan every string field
|
|
200
|
+
for text in _collect_strings(data):
|
|
201
|
+
result = detect_injection(text)
|
|
202
|
+
if result["detected"]:
|
|
203
|
+
self._log_security(
|
|
204
|
+
request,
|
|
205
|
+
"injection_detected",
|
|
206
|
+
result["threat_type"],
|
|
207
|
+
result["confidence"],
|
|
208
|
+
)
|
|
209
|
+
break # one log entry per request is enough
|
|
210
|
+
|
|
211
|
+
except (json.JSONDecodeError, Exception):
|
|
212
|
+
pass # malformed JSON — FastAPI validation handles it
|
|
213
|
+
|
|
214
|
+
return await call_next(request)
|
|
215
|
+
|
|
216
|
+
def _log_security(
|
|
217
|
+
self,
|
|
218
|
+
request: Request,
|
|
219
|
+
detail: str,
|
|
220
|
+
threat_type: str | None,
|
|
221
|
+
confidence: int | None,
|
|
222
|
+
):
|
|
223
|
+
ip = None
|
|
224
|
+
if request.client:
|
|
225
|
+
ip = request.client.host
|
|
226
|
+
forwarded = request.headers.get("X-Forwarded-For")
|
|
227
|
+
if forwarded:
|
|
228
|
+
ip = forwarded.split(",")[0].strip()
|
|
229
|
+
|
|
230
|
+
detail_str = detail
|
|
231
|
+
if threat_type:
|
|
232
|
+
detail_str += f" threat={threat_type} confidence={confidence}"
|
|
233
|
+
|
|
234
|
+
conn = get_connection()
|
|
235
|
+
try:
|
|
236
|
+
conn.execute(
|
|
237
|
+
"INSERT INTO security_log (ip_address, endpoint, detail, threat_type) "
|
|
238
|
+
"VALUES (?, ?, ?, ?)",
|
|
239
|
+
(ip, str(request.url.path), detail_str, threat_type)
|
|
240
|
+
)
|
|
241
|
+
conn.commit()
|
|
242
|
+
except sqlite3.Error as e:
|
|
243
|
+
logger.warning(f"DB error: {e}")
|
|
244
|
+
finally:
|
|
245
|
+
conn.close()
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""
|
|
2
|
+
auth.py — JWT creation/validation, password hashing, token management.
|
|
3
|
+
|
|
4
|
+
Access tokens: 30-day JWT stored in httpOnly cookie.
|
|
5
|
+
Refresh tokens: 7-day opaque token, SHA-256 hashed in `tokens` table.
|
|
6
|
+
Passwords: bcrypt-hashed, never logged.
|
|
7
|
+
"""
|
|
8
|
+
import hashlib
|
|
9
|
+
import logging
|
|
10
|
+
import re
|
|
11
|
+
import secrets
|
|
12
|
+
from datetime import datetime, timedelta, timezone
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
import bcrypt
|
|
16
|
+
from fastapi import Cookie, Depends, HTTPException, Request
|
|
17
|
+
from jose import JWTError, jwt
|
|
18
|
+
|
|
19
|
+
from tethr_engine.database import get_connection
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
# ── constants ─────────────────────────────────────────────
|
|
24
|
+
ALGORITHM = "HS256"
|
|
25
|
+
ACCESS_TOKEN_EXPIRE_MINUTES = 30 * 24 * 60 # 30 days
|
|
26
|
+
REFRESH_TOKEN_EXPIRE_DAYS = 7
|
|
27
|
+
|
|
28
|
+
# Lazy-loaded from config to avoid circular import at module load time
|
|
29
|
+
_SECRET_KEY: Optional[str] = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _get_secret() -> str:
|
|
33
|
+
global _SECRET_KEY
|
|
34
|
+
if _SECRET_KEY is None:
|
|
35
|
+
from tethr_engine.config import get_config
|
|
36
|
+
_SECRET_KEY = get_config().get("server_secret", "")
|
|
37
|
+
if not _SECRET_KEY:
|
|
38
|
+
raise RuntimeError(
|
|
39
|
+
"server_secret missing from config.yaml — "
|
|
40
|
+
"restart the server to auto-generate one"
|
|
41
|
+
)
|
|
42
|
+
return _SECRET_KEY
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ── password utilities ────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
def hash_password(password: str) -> str:
|
|
48
|
+
return bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12)).decode()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def verify_password(password: str, hashed: str) -> bool:
|
|
52
|
+
try:
|
|
53
|
+
return bcrypt.checkpw(password.encode(), hashed.encode())
|
|
54
|
+
except Exception as e:
|
|
55
|
+
logger.warning(f"Password verification error: {e}")
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def validate_password_strength(password: str):
|
|
60
|
+
"""Raises ValueError if password doesn't meet requirements."""
|
|
61
|
+
if len(password) < 8:
|
|
62
|
+
raise ValueError("Password must be at least 8 characters")
|
|
63
|
+
if not re.search(r"[A-Za-z]", password):
|
|
64
|
+
raise ValueError("Password must contain at least one letter")
|
|
65
|
+
if not re.search(r"\d", password):
|
|
66
|
+
raise ValueError("Password must contain at least one number")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ── token creation ────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
def create_access_token(user_id: str) -> str:
|
|
72
|
+
expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
|
73
|
+
payload = {"sub": user_id, "exp": expire, "type": "access"}
|
|
74
|
+
return jwt.encode(payload, _get_secret(), algorithm=ALGORITHM)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def create_refresh_token(user_id: str) -> str:
|
|
78
|
+
"""Creates opaque refresh token, stores SHA-256 hash in DB. Returns raw token."""
|
|
79
|
+
token = secrets.token_urlsafe(32)
|
|
80
|
+
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
|
81
|
+
expires_at = (datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)).isoformat()
|
|
82
|
+
|
|
83
|
+
conn = get_connection()
|
|
84
|
+
try:
|
|
85
|
+
conn.execute(
|
|
86
|
+
"INSERT INTO tokens (user_id, token_hash, expires_at) VALUES (?, ?, ?)",
|
|
87
|
+
(user_id, token_hash, expires_at)
|
|
88
|
+
)
|
|
89
|
+
conn.commit()
|
|
90
|
+
finally:
|
|
91
|
+
conn.close()
|
|
92
|
+
|
|
93
|
+
return token
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ── token verification ────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
def verify_access_token(token: str) -> str:
|
|
99
|
+
"""Returns user_id or raises 401."""
|
|
100
|
+
try:
|
|
101
|
+
payload = jwt.decode(token, _get_secret(), algorithms=[ALGORITHM])
|
|
102
|
+
if payload.get("type") != "access":
|
|
103
|
+
raise JWTError("wrong token type")
|
|
104
|
+
user_id = payload.get("sub")
|
|
105
|
+
if not user_id:
|
|
106
|
+
raise JWTError("missing sub")
|
|
107
|
+
return user_id
|
|
108
|
+
except JWTError:
|
|
109
|
+
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def verify_refresh_token(token: str) -> str:
|
|
113
|
+
"""Returns user_id or raises 401."""
|
|
114
|
+
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
|
115
|
+
conn = get_connection()
|
|
116
|
+
try:
|
|
117
|
+
row = conn.execute(
|
|
118
|
+
"SELECT user_id, expires_at, revoked FROM tokens WHERE token_hash = ?",
|
|
119
|
+
(token_hash,)
|
|
120
|
+
).fetchone()
|
|
121
|
+
finally:
|
|
122
|
+
conn.close()
|
|
123
|
+
|
|
124
|
+
if not row:
|
|
125
|
+
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
|
126
|
+
if row["revoked"]:
|
|
127
|
+
raise HTTPException(status_code=401, detail="Refresh token has been revoked")
|
|
128
|
+
|
|
129
|
+
expires = datetime.fromisoformat(row["expires_at"]).replace(tzinfo=timezone.utc)
|
|
130
|
+
if datetime.now(timezone.utc) > expires:
|
|
131
|
+
raise HTTPException(status_code=401, detail="Refresh token expired")
|
|
132
|
+
|
|
133
|
+
return row["user_id"]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def revoke_refresh_token(token: str):
|
|
137
|
+
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
|
138
|
+
conn = get_connection()
|
|
139
|
+
try:
|
|
140
|
+
conn.execute("UPDATE tokens SET revoked = 1 WHERE token_hash = ?", (token_hash,))
|
|
141
|
+
conn.commit()
|
|
142
|
+
finally:
|
|
143
|
+
conn.close()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ── FastAPI dependency ─────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
def get_current_user(request: Request) -> str:
|
|
149
|
+
"""
|
|
150
|
+
Dependency — accepts JWT from either:
|
|
151
|
+
1. Authorization: Bearer <token> (desktop / device clients)
|
|
152
|
+
2. httpOnly access_token cookie (web clients)
|
|
153
|
+
Raises 401 if missing or invalid.
|
|
154
|
+
"""
|
|
155
|
+
auth_header = request.headers.get("Authorization", "")
|
|
156
|
+
if auth_header.startswith("Bearer "):
|
|
157
|
+
return verify_access_token(auth_header[7:])
|
|
158
|
+
token = request.cookies.get("access_token")
|
|
159
|
+
if not token:
|
|
160
|
+
raise HTTPException(status_code=401, detail="Not authenticated")
|
|
161
|
+
return verify_access_token(token)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def get_current_admin(request: Request) -> str:
|
|
165
|
+
"""
|
|
166
|
+
Dependency — same as get_current_user but also checks admin flag.
|
|
167
|
+
Raises 403 if user is not an admin.
|
|
168
|
+
"""
|
|
169
|
+
user_id = get_current_user(request)
|
|
170
|
+
conn = get_connection()
|
|
171
|
+
try:
|
|
172
|
+
row = conn.execute(
|
|
173
|
+
"SELECT is_admin FROM users WHERE id = ?", (user_id,)
|
|
174
|
+
).fetchone()
|
|
175
|
+
finally:
|
|
176
|
+
conn.close()
|
|
177
|
+
|
|
178
|
+
if not row or not row["is_admin"]:
|
|
179
|
+
raise HTTPException(status_code=403, detail="Admin access required")
|
|
180
|
+
return user_id
|