tracectrl-scanner 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.
Files changed (33) hide show
  1. tracectrl_scanner-0.1.0/.gitignore +44 -0
  2. tracectrl_scanner-0.1.0/PKG-INFO +33 -0
  3. tracectrl_scanner-0.1.0/README.md +20 -0
  4. tracectrl_scanner-0.1.0/pyproject.toml +21 -0
  5. tracectrl_scanner-0.1.0/src/tracectrl_scanner/__init__.py +1 -0
  6. tracectrl_scanner-0.1.0/src/tracectrl_scanner/benchmark/__init__.py +0 -0
  7. tracectrl_scanner-0.1.0/src/tracectrl_scanner/benchmark/checks/__init__.py +13 -0
  8. tracectrl_scanner-0.1.0/src/tracectrl_scanner/benchmark/checks/compliance.py +72 -0
  9. tracectrl_scanner-0.1.0/src/tracectrl_scanner/benchmark/checks/credentials.py +94 -0
  10. tracectrl_scanner-0.1.0/src/tracectrl_scanner/benchmark/checks/filesystem.py +60 -0
  11. tracectrl_scanner-0.1.0/src/tracectrl_scanner/benchmark/checks/guardrails.py +57 -0
  12. tracectrl_scanner-0.1.0/src/tracectrl_scanner/benchmark/checks/ingress.py +58 -0
  13. tracectrl_scanner-0.1.0/src/tracectrl_scanner/benchmark/checks/lateral_movement.py +51 -0
  14. tracectrl_scanner-0.1.0/src/tracectrl_scanner/benchmark/checks/llm_providers.py +44 -0
  15. tracectrl_scanner-0.1.0/src/tracectrl_scanner/benchmark/checks/logging_checks.py +43 -0
  16. tracectrl_scanner-0.1.0/src/tracectrl_scanner/benchmark/checks/network.py +71 -0
  17. tracectrl_scanner-0.1.0/src/tracectrl_scanner/benchmark/checks/operational.py +48 -0
  18. tracectrl_scanner-0.1.0/src/tracectrl_scanner/benchmark/checks/performance.py +49 -0
  19. tracectrl_scanner-0.1.0/src/tracectrl_scanner/benchmark/checks/persistence.py +87 -0
  20. tracectrl_scanner-0.1.0/src/tracectrl_scanner/benchmark/checks/plugins.py +38 -0
  21. tracectrl_scanner-0.1.0/src/tracectrl_scanner/benchmark/checks/security_advanced.py +149 -0
  22. tracectrl_scanner-0.1.0/src/tracectrl_scanner/benchmark/checks/skills.py +219 -0
  23. tracectrl_scanner-0.1.0/src/tracectrl_scanner/benchmark/checks/tools.py +83 -0
  24. tracectrl_scanner-0.1.0/src/tracectrl_scanner/benchmark/models.py +32 -0
  25. tracectrl_scanner-0.1.0/src/tracectrl_scanner/benchmark/runner.py +19 -0
  26. tracectrl_scanner-0.1.0/src/tracectrl_scanner/discovery.py +80 -0
  27. tracectrl_scanner-0.1.0/src/tracectrl_scanner/fix.py +196 -0
  28. tracectrl_scanner-0.1.0/src/tracectrl_scanner/parser.py +60 -0
  29. tracectrl_scanner-0.1.0/src/tracectrl_scanner/session_reader.py +35 -0
  30. tracectrl_scanner-0.1.0/src/tracectrl_scanner/topology/__init__.py +0 -0
  31. tracectrl_scanner-0.1.0/src/tracectrl_scanner/topology/builder.py +622 -0
  32. tracectrl_scanner-0.1.0/src/tracectrl_scanner/topology/models.py +54 -0
  33. tracectrl_scanner-0.1.0/src/tracectrl_scanner/topology/risk.py +177 -0
@@ -0,0 +1,44 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ *.egg
6
+ dist/
7
+ build/
8
+ .venv/
9
+ venv/
10
+ .pytest_cache/
11
+ .ruff_cache/
12
+
13
+ # Node
14
+ node_modules/
15
+ ui/dist/
16
+
17
+ # Environment
18
+ .env
19
+
20
+ # IDE
21
+ .vscode/
22
+ .idea/
23
+ *.swp
24
+ *.swo
25
+ .DS_Store
26
+
27
+ # Docker
28
+ clickhouse-data/
29
+
30
+ # Internal planning docs
31
+ docs/launch-plan.md
32
+ docs/meetings-actionables-for-launch.md
33
+ docs/licensing-study.md
34
+ docs/
35
+
36
+ # Agent skills (installed via `skills add`)
37
+ .agents/
38
+ .claude/
39
+ .kiro/
40
+ skills/
41
+ skills-lock.json
42
+
43
+ videos/
44
+ .mcp.json
@@ -0,0 +1,33 @@
1
+ Metadata-Version: 2.4
2
+ Name: tracectrl-scanner
3
+ Version: 0.1.0
4
+ Summary: Static security scanner for OpenClaw installations
5
+ Author: CloudsineAI
6
+ License-Expression: Apache-2.0
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: networkx>=3.0
9
+ Requires-Dist: pydantic>=2.0
10
+ Requires-Dist: pyjson5>=1.6
11
+ Requires-Dist: rich>=13.0
12
+ Description-Content-Type: text/markdown
13
+
14
+ # tracectrl-scanner
15
+
16
+ Static security scanner for OpenClaw installations. Runs 30+ checks across Security, Lateral Movement, Performance, and Compliance categories and produces structured findings with remediation guidance.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ pip install tracectrl-scanner
22
+ ```
23
+
24
+ ## Usage
25
+
26
+ ```bash
27
+ tracectrl scan /path/to/openclaw/workspace
28
+ ```
29
+
30
+ ## Links
31
+
32
+ - [Docs](https://tracectrl.io/docs)
33
+ - [GitHub](https://github.com/tracectrl/tracectrl)
@@ -0,0 +1,20 @@
1
+ # tracectrl-scanner
2
+
3
+ Static security scanner for OpenClaw installations. Runs 30+ checks across Security, Lateral Movement, Performance, and Compliance categories and produces structured findings with remediation guidance.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install tracectrl-scanner
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```bash
14
+ tracectrl scan /path/to/openclaw/workspace
15
+ ```
16
+
17
+ ## Links
18
+
19
+ - [Docs](https://tracectrl.io/docs)
20
+ - [GitHub](https://github.com/tracectrl/tracectrl)
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "tracectrl-scanner"
7
+ version = "0.1.0"
8
+ description = "Static security scanner for OpenClaw installations"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "Apache-2.0"
12
+ authors = [{ name = "CloudsineAI" }]
13
+ dependencies = [
14
+ "pyjson5>=1.6",
15
+ "networkx>=3.0",
16
+ "pydantic>=2.0",
17
+ "rich>=13.0",
18
+ ]
19
+
20
+ [tool.hatch.build.targets.wheel]
21
+ sources = ["src"]
@@ -0,0 +1 @@
1
+ """TraceCtrl Scanner — static security analysis for OpenClaw installations."""
@@ -0,0 +1,13 @@
1
+ from . import ( # noqa: F401
2
+ network, credentials, tools, ingress, guardrails,
3
+ filesystem, persistence, lateral_movement, plugins,
4
+ llm_providers, logging_checks, security_advanced,
5
+ operational, performance, compliance, skills,
6
+ )
7
+
8
+ __all__ = [
9
+ "network", "credentials", "tools", "ingress", "guardrails",
10
+ "filesystem", "persistence", "lateral_movement", "plugins",
11
+ "llm_providers", "logging_checks", "security_advanced",
12
+ "operational", "performance", "compliance", "skills",
13
+ ]
@@ -0,0 +1,72 @@
1
+ """Compliance and data governance checks — retention, session scope, redaction."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+ from ..models import CheckResult, Severity, Profile, AssessmentType
6
+
7
+
8
+ def run(config: dict[str, Any], root: Path) -> list[CheckResult]:
9
+ results: list[CheckResult] = []
10
+
11
+ session_cfg = config.get("session", {})
12
+ if not isinstance(session_cfg, dict):
13
+ session_cfg = {}
14
+
15
+ # OC-COMP-001: Session data retention policy set
16
+ maintenance = session_cfg.get("maintenance", {})
17
+ if not isinstance(maintenance, dict):
18
+ maintenance = {}
19
+ prune_after = maintenance.get("pruneAfter")
20
+ max_entries = maintenance.get("maxEntries")
21
+ has_retention = bool(prune_after) or bool(max_entries)
22
+ results.append(CheckResult(
23
+ check_id="OC-COMP-001",
24
+ section="Compliance",
25
+ title="Session data retention policy is configured",
26
+ severity=Severity.MEDIUM,
27
+ profile=Profile.L1,
28
+ assessment_type=AssessmentType.AUTOMATED,
29
+ passed=has_retention,
30
+ finding="Neither session.maintenance.pruneAfter nor maxEntries is set" if not has_retention else None,
31
+ remediation="Set session.maintenance.pruneAfter (e.g. \"30d\") and/or maxEntries to enforce data retention limits.",
32
+ config_path="session.maintenance",
33
+ rationale="Without a retention policy, conversation data accumulates indefinitely, increasing privacy risk and storage costs.",
34
+ ))
35
+
36
+ # OC-COMP-002: Session scope isolates per-user data
37
+ dm_scope = session_cfg.get("dmScope", "main")
38
+ is_isolated = dm_scope != "main"
39
+ results.append(CheckResult(
40
+ check_id="OC-COMP-002",
41
+ section="Compliance",
42
+ title="Session scope isolates per-user conversations",
43
+ severity=Severity.MEDIUM,
44
+ profile=Profile.L1,
45
+ assessment_type=AssessmentType.AUTOMATED,
46
+ passed=is_isolated,
47
+ finding=f"session.dmScope is \"{dm_scope}\" — all DM context is shared across senders" if not is_isolated else None,
48
+ remediation="Set session.dmScope to \"per-peer\" or \"per-channel-peer\" to isolate conversations between different users.",
49
+ config_path="session.dmScope",
50
+ rationale="With dmScope set to \"main\", all users share the same conversation context. User A's messages and data are visible to the agent when responding to User B.",
51
+ ))
52
+
53
+ # OC-COMP-003: Sensitive data redaction enabled in logs
54
+ logging_cfg = config.get("logging", {})
55
+ if not isinstance(logging_cfg, dict):
56
+ logging_cfg = {}
57
+ redact = logging_cfg.get("redactSensitive", "tools")
58
+ results.append(CheckResult(
59
+ check_id="OC-COMP-003",
60
+ section="Compliance",
61
+ title="Sensitive data redaction is enabled in logs",
62
+ severity=Severity.MEDIUM,
63
+ profile=Profile.L1,
64
+ assessment_type=AssessmentType.AUTOMATED,
65
+ passed=redact != "off",
66
+ finding="logging.redactSensitive is \"off\"" if redact == "off" else None,
67
+ remediation="Set logging.redactSensitive to \"tools\" (default) to redact tokens and sensitive data from log output.",
68
+ config_path="logging.redactSensitive",
69
+ rationale="Disabling log redaction exposes API keys, tokens, and user data in log files, which may be captured by log aggregation systems.",
70
+ ))
71
+
72
+ return results
@@ -0,0 +1,94 @@
1
+ from pathlib import Path
2
+ from typing import Any
3
+ import re
4
+ from ..models import CheckResult, Severity, Profile, AssessmentType
5
+
6
+ _SENSITIVE_KEY_NAMES = {"apikey", "api_key", "token", "secret", "password", "key"}
7
+ _SAFE_PREFIXES = ("http://", "https://", "~/", "/")
8
+
9
+
10
+ def _is_plaintext_key(key_name: str, value: str) -> bool:
11
+ """Check if a string value under a sensitive key name looks like a plaintext secret."""
12
+ if not isinstance(value, str) or not value:
13
+ return False
14
+ # Only flag values under sensitive key names
15
+ if key_name.lower() not in _SENSITIVE_KEY_NAMES:
16
+ return False
17
+ # Env var references are OK
18
+ if re.match(r"^\$\{.+\}$", value):
19
+ return False
20
+ # Safe prefixes (URLs, paths) are not secrets
21
+ if value.startswith(_SAFE_PREFIXES):
22
+ return False
23
+ # Known key prefixes are always flagged
24
+ if value.startswith("sk-") or value.startswith("key-"):
25
+ return True
26
+ # Non-empty value under a sensitive key name
27
+ return True
28
+
29
+
30
+ def _scan_for_plaintext_keys(obj: Any, path: str = "") -> list[str]:
31
+ """Recursively scan a dict for plaintext API key values."""
32
+ findings: list[str] = []
33
+ if isinstance(obj, dict):
34
+ for k, v in obj.items():
35
+ current_path = f"{path}.{k}" if path else k
36
+ if isinstance(v, str) and _is_plaintext_key(k, v):
37
+ findings.append(current_path)
38
+ elif isinstance(v, (dict, list)):
39
+ findings.extend(_scan_for_plaintext_keys(v, current_path))
40
+ elif isinstance(obj, list):
41
+ for i, item in enumerate(obj):
42
+ findings.extend(_scan_for_plaintext_keys(item, f"{path}[{i}]"))
43
+ return findings
44
+
45
+
46
+ def run(config: dict[str, Any], root: Path) -> list[CheckResult]:
47
+ results: list[CheckResult] = []
48
+
49
+ # OC-CRED-001: No plaintext API keys anywhere in config
50
+ plaintext_paths = _scan_for_plaintext_keys(config)
51
+ results.append(CheckResult(
52
+ check_id="OC-CRED-001",
53
+ section="Credentials",
54
+ title="No plaintext API keys in configuration",
55
+ severity=Severity.HIGH,
56
+ profile=Profile.L1,
57
+ assessment_type=AssessmentType.AUTOMATED,
58
+ passed=len(plaintext_paths) == 0,
59
+ finding=f"Plaintext keys found at: {', '.join(plaintext_paths)}" if plaintext_paths else None,
60
+ remediation="Replace plaintext API keys with environment variable references using ${VAR_NAME} syntax.",
61
+ config_path="<entire config>",
62
+ rationale="Plaintext credentials in config files can be leaked via version control, file sharing, or log output.",
63
+ ))
64
+
65
+ # OC-CRED-002: .env file should be in .gitignore
66
+ env_file = root / ".env"
67
+ gitignore = root / ".gitignore"
68
+ if not env_file.exists():
69
+ passed = True
70
+ finding = None
71
+ else:
72
+ if gitignore.exists():
73
+ lines = [line.strip() for line in gitignore.read_text().splitlines()]
74
+ passed = ".env" in lines or "/.env" in lines
75
+ finding = ".env file exists but is not listed in .gitignore" if not passed else None
76
+ else:
77
+ passed = False
78
+ finding = ".env file exists but no .gitignore file found"
79
+
80
+ results.append(CheckResult(
81
+ check_id="OC-CRED-002",
82
+ section="Credentials",
83
+ title=".env file is protected by .gitignore",
84
+ severity=Severity.HIGH,
85
+ profile=Profile.L1,
86
+ assessment_type=AssessmentType.AUTOMATED,
87
+ passed=passed,
88
+ finding=finding,
89
+ remediation="Add .env to your .gitignore file to prevent accidental commits of secrets.",
90
+ config_path=".env",
91
+ rationale="An unprotected .env file can be accidentally committed, exposing secrets to anyone with repository access.",
92
+ ))
93
+
94
+ return results
@@ -0,0 +1,60 @@
1
+ import os
2
+ from pathlib import Path
3
+ from typing import Any
4
+ from ..models import CheckResult, Severity, Profile, AssessmentType
5
+
6
+
7
+ def run(config: dict[str, Any], root: Path) -> list[CheckResult]:
8
+ results: list[CheckResult] = []
9
+
10
+ # OC-FS-001: openclaw.json should not be world-readable
11
+ config_file = root / "openclaw.json"
12
+ if config_file.exists():
13
+ mode = os.stat(config_file).st_mode
14
+ world_readable = bool(mode & 0o004)
15
+ passed = not world_readable
16
+ finding = f"openclaw.json has mode {oct(mode)} — world-readable bit is set" if world_readable else None
17
+ else:
18
+ passed = True
19
+ finding = None
20
+
21
+ results.append(CheckResult(
22
+ check_id="OC-FS-001",
23
+ section="Filesystem",
24
+ title="openclaw.json is not world-readable",
25
+ severity=Severity.HIGH,
26
+ profile=Profile.L1,
27
+ assessment_type=AssessmentType.AUTOMATED,
28
+ passed=passed,
29
+ finding=finding,
30
+ remediation="Restrict file permissions: chmod 600 openclaw.json",
31
+ config_path="openclaw.json",
32
+ rationale="A world-readable config file exposes API keys and agent configuration to any local user or container sidecar.",
33
+ ))
34
+
35
+ # OC-FS-002: Credentials directory permissions
36
+ creds_dir = root / "credentials"
37
+ if creds_dir.exists() and creds_dir.is_dir():
38
+ mode = os.stat(creds_dir).st_mode
39
+ world_readable = bool(mode & 0o004)
40
+ passed = not world_readable
41
+ finding = f"credentials/ directory has mode {oct(mode)} — world-readable bit is set" if world_readable else None
42
+ else:
43
+ passed = True
44
+ finding = None
45
+
46
+ results.append(CheckResult(
47
+ check_id="OC-FS-002",
48
+ section="Filesystem",
49
+ title="Credentials directory has restrictive permissions",
50
+ severity=Severity.HIGH,
51
+ profile=Profile.L1,
52
+ assessment_type=AssessmentType.AUTOMATED,
53
+ passed=passed,
54
+ finding=finding,
55
+ remediation="Restrict directory permissions: chmod 700 credentials/",
56
+ config_path="credentials/",
57
+ rationale="A world-readable credentials directory allows any local user to read stored secrets, tokens, and service account keys.",
58
+ ))
59
+
60
+ return results
@@ -0,0 +1,57 @@
1
+ from pathlib import Path
2
+ from typing import Any
3
+ from ..models import CheckResult, Severity, Profile, AssessmentType
4
+
5
+
6
+ def run(config: dict[str, Any], root: Path) -> list[CheckResult]:
7
+ results: list[CheckResult] = []
8
+
9
+ # OC-GUARD-001: SOUL.md must exist for each agent
10
+ agents_dir = root / "agents"
11
+ if not agents_dir.exists():
12
+ # No agents directory — nothing to check, pass
13
+ passed = True
14
+ finding = None
15
+ else:
16
+ missing: list[str] = []
17
+ for agent_dir in agents_dir.iterdir():
18
+ if agent_dir.is_dir():
19
+ soul_path = agent_dir / "agent" / "SOUL.md"
20
+ if not soul_path.exists():
21
+ missing.append(agent_dir.name)
22
+ passed = len(missing) == 0
23
+ finding = f"Agents missing SOUL.md: {', '.join(missing)}" if missing else None
24
+
25
+ results.append(CheckResult(
26
+ check_id="OC-GUARD-001",
27
+ section="Guardrails",
28
+ title="SOUL.md exists for each agent",
29
+ severity=Severity.MEDIUM,
30
+ profile=Profile.L1,
31
+ assessment_type=AssessmentType.AUTOMATED,
32
+ passed=passed,
33
+ finding=finding,
34
+ remediation="Create a SOUL.md file at agents/<agent_id>/agent/SOUL.md for each agent to define behavioral guardrails.",
35
+ config_path="agents/<agent_id>/agent/SOUL.md",
36
+ rationale="Without a SOUL.md, the agent has no explicit behavioral boundaries, making it susceptible to prompt injection that overrides its purpose.",
37
+ ))
38
+
39
+ # OC-GUARD-002: Content filter should be enabled
40
+ agents_filter = config.get("agents", {}).get("defaults", {}).get("contentFilter", {}).get("enabled", False)
41
+ tools_filter = config.get("tools", {}).get("contentFilter", {}).get("enabled", False)
42
+ filter_enabled = agents_filter is True or tools_filter is True
43
+ results.append(CheckResult(
44
+ check_id="OC-GUARD-002",
45
+ section="Guardrails",
46
+ title="Content filter is enabled",
47
+ severity=Severity.MEDIUM,
48
+ profile=Profile.L2,
49
+ assessment_type=AssessmentType.AUTOMATED,
50
+ passed=filter_enabled,
51
+ finding="Content filter is not enabled in agents.defaults.contentFilter or tools.contentFilter" if not filter_enabled else None,
52
+ remediation="Enable content filtering by setting agents.defaults.contentFilter.enabled or tools.contentFilter.enabled to true.",
53
+ config_path="agents.defaults.contentFilter.enabled",
54
+ rationale="Without content filtering, the agent can generate or relay harmful, toxic, or policy-violating content to end users.",
55
+ ))
56
+
57
+ return results
@@ -0,0 +1,58 @@
1
+ from pathlib import Path
2
+ from typing import Any
3
+ from ..models import CheckResult, Severity, Profile, AssessmentType
4
+
5
+
6
+ def run(config: dict[str, Any], root: Path) -> list[CheckResult]:
7
+ results: list[CheckResult] = []
8
+
9
+ # OC-ING-001: Enabled channels must not have dmPolicy "open"
10
+ channels = config.get("channels", {})
11
+ open_channels: list[str] = []
12
+ for name, channel_cfg in channels.items():
13
+ if isinstance(channel_cfg, dict) and channel_cfg.get("enabled", False):
14
+ if channel_cfg.get("dmPolicy") == "open":
15
+ open_channels.append(name)
16
+
17
+ results.append(CheckResult(
18
+ check_id="OC-ING-001",
19
+ section="Ingress",
20
+ title="No enabled channels have open DM policy",
21
+ severity=Severity.HIGH,
22
+ profile=Profile.L1,
23
+ assessment_type=AssessmentType.AUTOMATED,
24
+ passed=len(open_channels) == 0,
25
+ finding=f"Channels with open dmPolicy: {', '.join(open_channels)}" if open_channels else None,
26
+ remediation='Set dmPolicy to "pairing" (requires one-time approval) or "allowlist" (explicit sender list).',
27
+ config_path="channels.<name>.dmPolicy",
28
+ rationale="An open DM policy lets any user message the agent, enabling prompt injection and social engineering attacks from unknown senders.",
29
+ ))
30
+
31
+ # OC-ING-002: Webhook auth token should be set (only if webhook is configured)
32
+ webhook = config.get("webhook")
33
+ if webhook is None or not isinstance(webhook, dict):
34
+ # Webhook section not configured — check is not applicable
35
+ passed = True
36
+ finding = None
37
+ else:
38
+ auth_token = webhook.get("auth", {}).get("token", "")
39
+ webhook_secret = webhook.get("secret", "")
40
+ has_auth = bool(auth_token) or bool(webhook_secret)
41
+ passed = has_auth
42
+ finding = "Neither webhook.auth.token nor webhook.secret is set" if not has_auth else None
43
+
44
+ results.append(CheckResult(
45
+ check_id="OC-ING-002",
46
+ section="Ingress",
47
+ title="Webhook authentication is configured",
48
+ severity=Severity.HIGH,
49
+ profile=Profile.L2,
50
+ assessment_type=AssessmentType.AUTOMATED,
51
+ passed=passed,
52
+ finding=finding,
53
+ remediation="Configure webhook.auth.token or webhook.secret to authenticate incoming webhook requests.",
54
+ config_path="webhook.auth.token",
55
+ rationale="Unauthenticated webhooks let any external party trigger agent actions by sending crafted payloads to the endpoint.",
56
+ ))
57
+
58
+ return results
@@ -0,0 +1,51 @@
1
+ from pathlib import Path
2
+ from typing import Any
3
+ from ..models import CheckResult, Severity, Profile, AssessmentType
4
+
5
+
6
+ def run(config: dict[str, Any], root: Path) -> list[CheckResult]:
7
+ results: list[CheckResult] = []
8
+
9
+ # OC-LAT-001: subagents spawning has restrictions
10
+ # Correct path: agents.defaults.subagents (not top-level subagents)
11
+ agents_cfg = config.get("agents", {})
12
+ defaults = agents_cfg.get("defaults", {}) if isinstance(agents_cfg, dict) else {}
13
+ subagents = defaults.get("subagents", {}) if isinstance(defaults, dict) else {}
14
+ if not isinstance(subagents, dict):
15
+ subagents = {}
16
+
17
+ # Check if sub-agents are unrestricted
18
+ allow_agents = subagents.get("allowAgents")
19
+ max_depth = subagents.get("maxSpawnDepth")
20
+ require_id = subagents.get("requireAgentId", False)
21
+
22
+ # Fail if: allowAgents is an open list or True, with no depth limit and no requireAgentId
23
+ if isinstance(allow_agents, list) and len(allow_agents) > 0:
24
+ # Explicit allowlist — this is restricted
25
+ passed = True
26
+ finding = None
27
+ elif allow_agents is True or allow_agents is None:
28
+ # Unrestricted or default — check for other controls
29
+ has_depth = isinstance(max_depth, int) and max_depth > 0
30
+ has_require = require_id is True
31
+ passed = has_depth or has_require
32
+ finding = "Sub-agent spawning is unrestricted — no allowAgents list, maxSpawnDepth, or requireAgentId" if not passed else None
33
+ else:
34
+ passed = True
35
+ finding = None
36
+
37
+ results.append(CheckResult(
38
+ check_id="OC-LAT-001",
39
+ section="Lateral Movement",
40
+ title="Sub-agent spawning has restrictions",
41
+ severity=Severity.HIGH,
42
+ profile=Profile.L1,
43
+ assessment_type=AssessmentType.AUTOMATED,
44
+ passed=passed,
45
+ finding=finding,
46
+ remediation="Configure agents.defaults.subagents.allowAgents with a list of permitted agent IDs, set maxSpawnDepth, or enable requireAgentId.",
47
+ config_path="agents.defaults.subagents",
48
+ rationale="Unrestricted sub-agent spawning lets a compromised agent create new agents with elevated privileges, enabling lateral movement across the system.",
49
+ ))
50
+
51
+ return results
@@ -0,0 +1,44 @@
1
+ from pathlib import Path
2
+ from typing import Any
3
+ from ..models import CheckResult, Severity, Profile, AssessmentType
4
+
5
+
6
+ def run(config: dict[str, Any], root: Path) -> list[CheckResult]:
7
+ results: list[CheckResult] = []
8
+
9
+ # OC-LLM-001: LLM provider configuration hygiene
10
+ # Credential scanning is handled by OC-CRED-001; this check focuses on
11
+ # provider-specific configuration issues (missing provider field, HTTP endpoints).
12
+ providers = config.get("models", {}).get("providers", {})
13
+
14
+ issues: list[str] = []
15
+ for name, provider_cfg in providers.items():
16
+ if not isinstance(provider_cfg, dict):
17
+ continue
18
+ # Check for missing provider type
19
+ if not provider_cfg.get("provider"):
20
+ issues.append(f"models.providers.{name}: missing 'provider' field")
21
+ # Check for HTTP (non-TLS) endpoint
22
+ endpoint = provider_cfg.get("endpoint", "") or provider_cfg.get("baseUrl", "")
23
+ if isinstance(endpoint, str) and endpoint.startswith("http://"):
24
+ issues.append(f"models.providers.{name}: endpoint uses HTTP instead of HTTPS")
25
+
26
+ passed = len(issues) == 0
27
+ results.append(CheckResult(
28
+ check_id="OC-LLM-001",
29
+ section="LLM Providers",
30
+ title="LLM provider configuration is well-formed and secure",
31
+ severity=Severity.HIGH,
32
+ profile=Profile.L1,
33
+ assessment_type=AssessmentType.AUTOMATED,
34
+ passed=passed,
35
+ finding="; ".join(issues) if issues else None,
36
+ remediation=(
37
+ "Ensure each provider has a 'provider' field and uses HTTPS endpoints. "
38
+ "Credential scanning is handled by OC-CRED-001."
39
+ ),
40
+ config_path="models.providers",
41
+ rationale="Missing provider fields cause runtime errors; HTTP endpoints transmit API keys and prompts in cleartext, enabling interception.",
42
+ ))
43
+
44
+ return results
@@ -0,0 +1,43 @@
1
+ from pathlib import Path
2
+ from typing import Any
3
+ from ..models import CheckResult, Severity, Profile, AssessmentType
4
+
5
+
6
+ def run(config: dict[str, Any], root: Path) -> list[CheckResult]:
7
+ results: list[CheckResult] = []
8
+
9
+ # OC-LOG-001: Audit logging should be enabled
10
+ logging_cfg = config.get("logging", {})
11
+ audit_enabled = logging_cfg.get("audit", False)
12
+ results.append(CheckResult(
13
+ check_id="OC-LOG-001",
14
+ section="Logging",
15
+ title="Audit logging is enabled",
16
+ severity=Severity.MEDIUM,
17
+ profile=Profile.L1,
18
+ assessment_type=AssessmentType.AUTOMATED,
19
+ passed=audit_enabled is True,
20
+ finding="logging.audit is not enabled" if audit_enabled is not True else None,
21
+ remediation="Enable audit logging by setting logging.audit to true.",
22
+ config_path="logging.audit",
23
+ rationale="Without audit logs, there is no forensic trail to detect or investigate agent misuse, prompt injection, or data exfiltration.",
24
+ ))
25
+
26
+ # OC-LOG-002: Log level should not be "debug" in production
27
+ log_level = logging_cfg.get("level", "info")
28
+ is_debug = str(log_level).lower() == "debug"
29
+ results.append(CheckResult(
30
+ check_id="OC-LOG-002",
31
+ section="Logging",
32
+ title="Log level is not set to debug",
33
+ severity=Severity.MEDIUM,
34
+ profile=Profile.L2,
35
+ assessment_type=AssessmentType.AUTOMATED,
36
+ passed=not is_debug,
37
+ finding="logging.level is set to \"debug\" — may expose sensitive data in production" if is_debug else None,
38
+ remediation="Set logging.level to \"info\" or \"warn\" for production deployments.",
39
+ config_path="logging.level",
40
+ rationale="Debug-level logs often include full prompts, API keys, and user data, which can be captured by log aggregation systems.",
41
+ ))
42
+
43
+ return results