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.
- tracectrl_scanner-0.1.0/.gitignore +44 -0
- tracectrl_scanner-0.1.0/PKG-INFO +33 -0
- tracectrl_scanner-0.1.0/README.md +20 -0
- tracectrl_scanner-0.1.0/pyproject.toml +21 -0
- tracectrl_scanner-0.1.0/src/tracectrl_scanner/__init__.py +1 -0
- tracectrl_scanner-0.1.0/src/tracectrl_scanner/benchmark/__init__.py +0 -0
- tracectrl_scanner-0.1.0/src/tracectrl_scanner/benchmark/checks/__init__.py +13 -0
- tracectrl_scanner-0.1.0/src/tracectrl_scanner/benchmark/checks/compliance.py +72 -0
- tracectrl_scanner-0.1.0/src/tracectrl_scanner/benchmark/checks/credentials.py +94 -0
- tracectrl_scanner-0.1.0/src/tracectrl_scanner/benchmark/checks/filesystem.py +60 -0
- tracectrl_scanner-0.1.0/src/tracectrl_scanner/benchmark/checks/guardrails.py +57 -0
- tracectrl_scanner-0.1.0/src/tracectrl_scanner/benchmark/checks/ingress.py +58 -0
- tracectrl_scanner-0.1.0/src/tracectrl_scanner/benchmark/checks/lateral_movement.py +51 -0
- tracectrl_scanner-0.1.0/src/tracectrl_scanner/benchmark/checks/llm_providers.py +44 -0
- tracectrl_scanner-0.1.0/src/tracectrl_scanner/benchmark/checks/logging_checks.py +43 -0
- tracectrl_scanner-0.1.0/src/tracectrl_scanner/benchmark/checks/network.py +71 -0
- tracectrl_scanner-0.1.0/src/tracectrl_scanner/benchmark/checks/operational.py +48 -0
- tracectrl_scanner-0.1.0/src/tracectrl_scanner/benchmark/checks/performance.py +49 -0
- tracectrl_scanner-0.1.0/src/tracectrl_scanner/benchmark/checks/persistence.py +87 -0
- tracectrl_scanner-0.1.0/src/tracectrl_scanner/benchmark/checks/plugins.py +38 -0
- tracectrl_scanner-0.1.0/src/tracectrl_scanner/benchmark/checks/security_advanced.py +149 -0
- tracectrl_scanner-0.1.0/src/tracectrl_scanner/benchmark/checks/skills.py +219 -0
- tracectrl_scanner-0.1.0/src/tracectrl_scanner/benchmark/checks/tools.py +83 -0
- tracectrl_scanner-0.1.0/src/tracectrl_scanner/benchmark/models.py +32 -0
- tracectrl_scanner-0.1.0/src/tracectrl_scanner/benchmark/runner.py +19 -0
- tracectrl_scanner-0.1.0/src/tracectrl_scanner/discovery.py +80 -0
- tracectrl_scanner-0.1.0/src/tracectrl_scanner/fix.py +196 -0
- tracectrl_scanner-0.1.0/src/tracectrl_scanner/parser.py +60 -0
- tracectrl_scanner-0.1.0/src/tracectrl_scanner/session_reader.py +35 -0
- tracectrl_scanner-0.1.0/src/tracectrl_scanner/topology/__init__.py +0 -0
- tracectrl_scanner-0.1.0/src/tracectrl_scanner/topology/builder.py +622 -0
- tracectrl_scanner-0.1.0/src/tracectrl_scanner/topology/models.py +54 -0
- 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."""
|
|
File without changes
|
|
@@ -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
|