Secure by default uplift (#511)
Co-authored-by: Alex Kenley <Alex.Kenley@threatvectorsecurity.com>
This commit is contained in:
@@ -7,6 +7,10 @@ from typing import Dict, List, Optional, Any
|
||||
|
||||
import httpx
|
||||
|
||||
from core.atomic_io import atomic_write_json
|
||||
from core.platform_compat import safe_chmod
|
||||
from src.secret_storage import decrypt, encrypt, is_encrypted
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
DATA_FILE = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "integrations.json")
|
||||
@@ -143,23 +147,69 @@ def _ensure_data_dir() -> None:
|
||||
os.makedirs(os.path.dirname(DATA_FILE), exist_ok=True)
|
||||
|
||||
|
||||
def _encrypt_integration_secrets(integrations: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Return storage-safe copies with API keys encrypted at rest."""
|
||||
safe: List[Dict[str, Any]] = []
|
||||
for item in integrations:
|
||||
copy = dict(item)
|
||||
api_key = copy.get("api_key", "")
|
||||
if api_key:
|
||||
copy["api_key"] = encrypt(str(api_key))
|
||||
safe.append(copy)
|
||||
return safe
|
||||
|
||||
|
||||
def _decrypt_integration_secrets(integrations: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Return runtime copies with API keys decrypted for callers."""
|
||||
decoded: List[Dict[str, Any]] = []
|
||||
for item in integrations:
|
||||
copy = dict(item)
|
||||
api_key = copy.get("api_key", "")
|
||||
if api_key:
|
||||
copy["api_key"] = decrypt(str(api_key))
|
||||
decoded.append(copy)
|
||||
return decoded
|
||||
|
||||
|
||||
def _has_plaintext_api_key(integrations: List[Dict[str, Any]]) -> bool:
|
||||
return any(
|
||||
bool(item.get("api_key")) and not is_encrypted(str(item.get("api_key")))
|
||||
for item in integrations
|
||||
)
|
||||
|
||||
|
||||
def mask_integration_secret(integration: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Return a copy safe for API responses."""
|
||||
safe = dict(integration)
|
||||
api_key = safe.get("api_key", "")
|
||||
if api_key:
|
||||
safe["api_key"] = f"{str(api_key)[:4]}****"
|
||||
return safe
|
||||
|
||||
|
||||
def load_integrations() -> List[Dict[str, Any]]:
|
||||
"""Load all integrations from disk."""
|
||||
"""Load all integrations from disk with secrets decrypted for runtime use."""
|
||||
if not os.path.exists(DATA_FILE):
|
||||
return []
|
||||
try:
|
||||
with open(DATA_FILE, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
integrations = json.load(f)
|
||||
if not isinstance(integrations, list):
|
||||
log.error("Invalid integrations file shape: expected a list")
|
||||
return []
|
||||
if _has_plaintext_api_key(integrations):
|
||||
save_integrations(_decrypt_integration_secrets(integrations))
|
||||
return _decrypt_integration_secrets(integrations)
|
||||
except (json.JSONDecodeError, IOError) as exc:
|
||||
log.error("Failed to load integrations: %s", exc)
|
||||
return []
|
||||
|
||||
|
||||
def save_integrations(integrations: List[Dict[str, Any]]) -> None:
|
||||
"""Persist integrations list to disk."""
|
||||
"""Persist integrations list to disk with API keys encrypted at rest."""
|
||||
_ensure_data_dir()
|
||||
with open(DATA_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(integrations, f, indent=2)
|
||||
atomic_write_json(DATA_FILE, _encrypt_integration_secrets(integrations), indent=2)
|
||||
safe_chmod(DATA_FILE, 0o600)
|
||||
|
||||
|
||||
def get_integration(integration_id: str) -> Optional[Dict[str, Any]]:
|
||||
|
||||
@@ -1058,56 +1058,53 @@ class TaskScheduler:
|
||||
except Exception as e:
|
||||
raw["notes_tasks"] = f"Error: {e}"
|
||||
|
||||
# Auto-discover API integrations (Miniflux RSS, etc.) from integrations.json
|
||||
# Auto-discover API integrations (Miniflux RSS, etc.).
|
||||
try:
|
||||
import httpx
|
||||
from pathlib import Path as _P
|
||||
integrations_file = _P("data/integrations.json")
|
||||
if integrations_file.exists():
|
||||
integrations = json.loads(integrations_file.read_text(encoding="utf-8"))
|
||||
for integ in integrations:
|
||||
if not integ.get("enabled"):
|
||||
continue
|
||||
preset = integ.get("preset", "")
|
||||
base_url = integ.get("base_url", "").rstrip("/")
|
||||
api_key = integ.get("api_key", "")
|
||||
if not base_url:
|
||||
continue
|
||||
from src.integrations import load_integrations
|
||||
for integ in load_integrations():
|
||||
if not integ.get("enabled"):
|
||||
continue
|
||||
preset = integ.get("preset", "")
|
||||
base_url = integ.get("base_url", "").rstrip("/")
|
||||
api_key = integ.get("api_key", "")
|
||||
if not base_url:
|
||||
continue
|
||||
|
||||
# Build auth headers
|
||||
headers = {}
|
||||
if integ.get("auth_type") == "header" and api_key:
|
||||
headers[integ.get("auth_header", "X-Auth-Token")] = api_key
|
||||
elif integ.get("auth_type") == "bearer" and api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
# Build auth headers
|
||||
headers = {}
|
||||
if integ.get("auth_type") == "header" and api_key:
|
||||
headers[integ.get("auth_header", "X-Auth-Token")] = api_key
|
||||
elif integ.get("auth_type") == "bearer" and api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
# Miniflux: fetch unread entries (cached 3 min across tasks)
|
||||
if preset == "miniflux":
|
||||
async def _fetch_miniflux(_base=base_url, _headers=dict(headers)):
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.get(
|
||||
f"{_base}/v1/entries",
|
||||
params={"status": "unread", "limit": 15, "order": "published_at", "direction": "desc"},
|
||||
headers=_headers,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
entries = resp.json().get("entries", []) or []
|
||||
if not entries:
|
||||
return None
|
||||
lines = []
|
||||
for e in entries[:15]:
|
||||
title = e.get("title", "?")
|
||||
feed = (e.get("feed") or {}).get("title", "?")
|
||||
url = e.get("url", "")
|
||||
lines.append(f"- [{feed}] {title} — {url}")
|
||||
return "\n".join(lines)
|
||||
try:
|
||||
val = await _cached(("miniflux_unread", base_url), 180, _fetch_miniflux)
|
||||
if val:
|
||||
raw["rss_miniflux_unread"] = val
|
||||
except Exception as e:
|
||||
logger.warning(f"Miniflux fetch failed: {e}")
|
||||
# Miniflux: fetch unread entries (cached 3 min across tasks)
|
||||
if preset == "miniflux":
|
||||
async def _fetch_miniflux(_base=base_url, _headers=dict(headers)):
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.get(
|
||||
f"{_base}/v1/entries",
|
||||
params={"status": "unread", "limit": 15, "order": "published_at", "direction": "desc"},
|
||||
headers=_headers,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
entries = resp.json().get("entries", []) or []
|
||||
if not entries:
|
||||
return None
|
||||
lines = []
|
||||
for e in entries[:15]:
|
||||
title = e.get("title", "?")
|
||||
feed = (e.get("feed") or {}).get("title", "?")
|
||||
url = e.get("url", "")
|
||||
lines.append(f"- [{feed}] {title} — {url}")
|
||||
return "\n".join(lines)
|
||||
try:
|
||||
val = await _cached(("miniflux_unread", base_url), 180, _fetch_miniflux)
|
||||
if val:
|
||||
raw["rss_miniflux_unread"] = val
|
||||
except Exception as e:
|
||||
logger.warning(f"Miniflux fetch failed: {e}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Integrations discovery failed: {e}")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user