Skip to content
Open

update #3203

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
228 changes: 228 additions & 0 deletions app/api/v2/logging_middleware.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
import json
from pathlib import Path
from aiohttp import web
from aiohttp_security import api as aiosec_api

ROOT_DIR = Path(__file__).resolve().parents[3]
ALLOWED_JSON_PATH = ROOT_DIR / "plugins" / "rbac" / "state" / "allowed.json"

_ALLOWED_CACHE = {"ids": set(), "mtime": None}



def _load_allowed_from_json(path: Path) -> set:
try:
data = json.loads(path.read_text() or "{}")
# Prefer new format: per-user mapping
if isinstance(data.get("users"), dict):
return set(map(str, (data["users"].get("student") or [])))
# Legacy keys
if "allowed_student_abilities" in data:
return set(map(str, data.get("allowed_student_abilities", [])))
if "allowed_abilities" in data:
return set(map(str, data.get("allowed_abilities", [])))
return set()
except FileNotFoundError:
return set()
except Exception as e:
print(f"[RBAC] read error {path}: {e}")
return set()

def _get_allowed_cached() -> set:
try:
mtime = ALLOWED_JSON_PATH.stat().st_mtime
except FileNotFoundError:
if _ALLOWED_CACHE["mtime"] is not None:
_ALLOWED_CACHE["ids"], _ALLOWED_CACHE["mtime"] = set(), None
return _ALLOWED_CACHE["ids"]
if _ALLOWED_CACHE["mtime"] != mtime:
_ALLOWED_CACHE["ids"] = _load_allowed_from_json(ALLOWED_JSON_PATH)
_ALLOWED_CACHE["mtime"] = mtime
return _ALLOWED_CACHE["ids"]

def _load_plugin_blocks_from_json(path: Path) -> dict:
"""Return mapping username -> set(blocked plugin names) from JSON file."""
try:
data = json.loads(path.read_text() or "{}")
out = {}
if isinstance(data.get("plugin_blocks"), dict):
for u, names in data["plugin_blocks"].items():
out[str(u)] = set(map(str, names or []))
return out
except Exception:
return {}


def _get_allowed_for_user(request, username: str) -> set:
"""Resolve allowed abilities for the given username using RBAC plugin state.

Order of precedence:
1) Live per-user map published by RBAC plugin: request.app['rbac_allowed_map'][username]
2) Legacy single set in app for back-compat: request.app['rbac_allowed'] (only for 'student')
3) JSON file on disk: plugins/rbac/state/allowed.json (users[username] or legacy keys)
"""
# 1) Live per-user map
try:
live_map = request.app.get("rbac_allowed_map")
if isinstance(live_map, dict):
s = live_map.get(username)
if isinstance(s, set):
return s
if s:
return set(s)
except Exception:
pass

# 2) Legacy single set (student only)
if username == "student":
try:
live = request.app.get("rbac_allowed")
if isinstance(live, set):
return live
if live:
return set(live)
except Exception:
pass

# 3) JSON fallback
try:
data = json.loads(ALLOWED_JSON_PATH.read_text() or "{}")
if isinstance(data.get("users"), dict):
ids = data["users"].get(username)
if ids:
return set(map(str, ids))
if username == "student":
if "allowed_student_abilities" in data:
return set(map(str, data.get("allowed_student_abilities", [])))
if "allowed_abilities" in data:
return set(map(str, data.get("allowed_abilities", [])))
except Exception:
pass
return set()


def _get_blocked_plugins_for_user(request, username: str) -> set:
"""Resolve blocked plugin names for the given username."""
try:
m = request.app.get("rbac_plugin_blocks")
if isinstance(m, dict):
s = m.get(username)
if isinstance(s, set):
return s
if s:
return set(s)
except Exception:
pass
# fallback to JSON
return _load_plugin_blocks_from_json(ALLOWED_JSON_PATH).get(username, set())


def _extract_plugin_name(path: str) -> str | None:
"""Return plugin name if the path targets a plugin UI or plugin API.

Matches:
- /plugin/<name>[/*]
- /plugins/<name>[/*]
- /api/<name>[/*] (plugin REST endpoints)
"""
if not path:
return None
for prefix in ("/plugin/", "/plugins/", "/api/"):
if path.startswith(prefix):
parts = path.split('/')
# ['', 'plugin(s)|api', '<name>', ...]
if len(parts) > 2 and parts[2]:
return parts[2]
return None

@web.middleware
async def log_all_requests(request, handler):
"""Dynamic RBAC enforcement in v2 API layer.

- Blocks plugin GUIs based on per-user blocklist (from RBAC plugin state).
- Filters abilities/adversaries lists based on per-user allowed ability IDs.
- Admin-like users (admin, red) bypass enforcement.
"""
user = None
try:
try:
user = await aiosec_api.authorized_userid(request)
except Exception:
user = None

path = request.path

# Admin bypass
is_admin = user and user.lower() in {"admin", "red"}

# Dynamic plugin blocking for GUI and plugin APIs
plugin_name = _extract_plugin_name(path)
if plugin_name and user and not is_admin:
blocked = _get_blocked_plugins_for_user(request, user)
if plugin_name in blocked:
# Return HTML for GUI routes, JSON for APIs
if path.startswith('/plugin/') or path.startswith('/plugins/'):
print(f"[RBAC] BLOCK GUI user={user} plugin={plugin_name} path={path}")
return web.Response(text=f"Plugin '{plugin_name}' is not available for your role.", status=403, content_type='text/html')
if path.startswith('/api/'):
print(f"[RBAC] BLOCK API user={user} plugin={plugin_name} path={path}")
return web.json_response({"error": "Forbidden for your role"}, status=403)
parts = path.split('/')
plugin_name = parts[2] if len(parts) > 2 else ''
blocked = _get_blocked_plugins_for_user(request, user)
if plugin_name and plugin_name in blocked:
if path.startswith('/plugin/') or path.startswith('/plugins/'):
return web.Response(text=f"Plugin '{plugin_name}' is not available for your role.", status=403, content_type='text/html')
return web.json_response({"error": "Forbidden for your role"}, status=403)
Comment on lines +170 to +176
Copy link

Copilot AI Sep 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate plugin blocking logic. This code block (lines 170-176) duplicates the same logic from lines 159-169. The duplicate block should be removed as it's unreachable and creates confusion.

Suggested change
parts = path.split('/')
plugin_name = parts[2] if len(parts) > 2 else ''
blocked = _get_blocked_plugins_for_user(request, user)
if plugin_name and plugin_name in blocked:
if path.startswith('/plugin/') or path.startswith('/plugins/'):
return web.Response(text=f"Plugin '{plugin_name}' is not available for your role.", status=403, content_type='text/html')
return web.json_response({"error": "Forbidden for your role"}, status=403)

Copilot uses AI. Check for mistakes.

# Let downstream handlers run and generate their response
resp = await handler(request)

# Apply read filters for non-admin users
if not is_admin and user:
if path.startswith('/api/v2/abilities'):
try:
body_text = resp.text or (resp.body.decode('utf-8', 'ignore') if getattr(resp, 'body', None) else '')
if body_text:
data = json.loads(body_text)
allowed = _get_allowed_for_user(request, user)

if isinstance(data, dict) and 'abilities' in data:
items = [a for a in (data.get('abilities') or []) if isinstance(a, dict) and (a.get('ability_id') or a.get('id')) in allowed]
return web.json_response({'total': len(items), 'abilities': items}, status=200)

if isinstance(data, list):
limited = [a for a in data if isinstance(a, dict) and (a.get('ability_id') or a.get('id')) in allowed]
return web.json_response(limited, status=200)
except Exception:
return web.json_response({'total': 0, 'abilities': []}, status=200)

if path.startswith('/api/v2/adversaries'):
try:
body_text = resp.text or (resp.body.decode('utf-8', 'ignore') if getattr(resp, 'body', None) else '')
if body_text:
data = json.loads(body_text)
allowed = _get_allowed_for_user(request, user)

def adv_ability_ids(disp):
ids = []
if isinstance(disp, dict):
if disp.get('atomic_ordering'): ids += disp['atomic_ordering']
if disp.get('phases'):
for chunk in (disp['phases'] or {}).values():
ids += chunk or []
return [x for x in ids if x]

if isinstance(data, dict) and 'adversaries' in data:
items = [adv for adv in (data.get('adversaries') or []) if set(adv_ability_ids(adv)).issubset(allowed)]
return web.json_response({'total': len(items), 'adversaries': items}, status=200)
except Exception:
return web.json_response({'total': 0, 'adversaries': []}, status=200)

return resp

except web.HTTPException:
raise
except Exception as e:
print(f"[LOGGER ERR] {request.method} {request.path}: {e}")
raise
2 changes: 1 addition & 1 deletion app/service/auth_svc.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ def decorate(cls):


def check_authorization(func):
"""Authorization Decorator
"""Authorization Decorator
This requires that the calling class have `self.auth_svc` set to the authentication service.
"""
async def process(func, *args, **params):
Expand Down
6 changes: 6 additions & 0 deletions caldera/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 5 additions & 4 deletions conf/agents.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
bootstrap_abilities:
- 43b3754c-def4-4699-a673-1d85648fda6a
deployments:
- 2f34977d-9558-4c12-abad-349716777c6b
- 1837b43e-4fff-46b2-a604-a602f7540469
- 0ab383be-b819-41bf-91b9-1bd4404d83bf
- 356d1722-7784-40c4-822b-0cf864b0b36d
implant_name: splunkd
sleep_max: 60
sleep_min: 30
untrusted_timer: 90
watchdog: 0
deployments:
- 2f34977d-9558-4c12-abad-349716777c6b #54ndc47
- 356d1722-7784-40c4-822b-0cf864b0b36d #Manx
- 0ab383be-b819-41bf-91b9-1bd4404d83bf #Ragdoll
23 changes: 12 additions & 11 deletions conf/default.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,33 +3,31 @@ api_key_blue: BLUEADMIN123
api_key_red: ADMIN123
app.contact.dns.domain: mycaldera.caldera
app.contact.dns.socket: 0.0.0.0:8853
app.contact.ftp.host: 0.0.0.0
app.contact.ftp.port: 2222
app.contact.ftp.pword: caldera
app.contact.ftp.server.dir: ftp_dir
app.contact.ftp.user: caldera_user
app.contact.gist: API_KEY
app.contact.html: /weather
app.contact.http: http://0.0.0.0:8888
app.contact.slack.api_key: SLACK_TOKEN
app.contact.slack.bot_id: SLACK_BOT_ID
app.contact.slack.channel_id: SLACK_CHANNEL_ID
app.contact.tcp: 0.0.0.0:7010
app.contact.tunnel.ssh.host_key_file: REPLACE_WITH_KEY_FILE_PATH
app.contact.tunnel.ssh.host_key_passphrase: REPLACE_WITH_KEY_FILE_PASSPHRASE
app.contact.tunnel.ssh.socket: 0.0.0.0:8022
app.contact.tunnel.ssh.user_name: sandcat
app.contact.tunnel.ssh.user_password: s4ndc4t!
app.contact.ftp.host: 0.0.0.0
app.contact.ftp.port: 2222
app.contact.ftp.pword: caldera
app.contact.ftp.server.dir: ftp_dir
app.contact.ftp.user: caldera_user
app.contact.tcp: 0.0.0.0:7010
app.contact.udp: 0.0.0.0:7011
app.contact.websocket: 0.0.0.0:7012
objects.planners.default: atomic
auth.login.handler.module: default
crypt_salt: REPLACE_WITH_RANDOM_VALUE
encryption_key: ADMIN123
exfil_dir: /tmp/caldera
reachable_host_traits:
- remote.host.fqdn
- remote.host.ip
host: 0.0.0.0
objects.planners.default: atomic
plugins:
- access
- atomic
Expand All @@ -42,8 +40,10 @@ plugins:
- stockpile
- training
port: 8888
reachable_host_traits:
- remote.host.fqdn
- remote.host.ip
reports_dir: /tmp
auth.login.handler.module: default
requirements:
go:
command: go version
Expand All @@ -60,3 +60,4 @@ users:
red:
admin: admin
red: admin

Loading
Loading