mirror of
https://github.com/FunnyWolf/agentic-soc-platform.git
synced 2026-08-22 13:12:56 +02:00
feat(custom): add managed variables
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+1
-1
Submodule asp-doc updated: 8835c768f7...c87ef70df2
@@ -7,6 +7,7 @@ from django.conf import settings
|
||||
from django.utils import timezone as django_timezone
|
||||
|
||||
from apps.settings.runtime_config import get_prompt_language
|
||||
from apps.settings.custom_variables import get_custom_variable
|
||||
|
||||
|
||||
class BasePlaybook:
|
||||
@@ -43,6 +44,9 @@ class BasePlaybook:
|
||||
raise FileNotFoundError(f"Custom playbook prompt not found: {path}")
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
def get_variable(self, key):
|
||||
return get_custom_variable(key)
|
||||
|
||||
|
||||
class BaseModule:
|
||||
NAME = ""
|
||||
@@ -53,6 +57,9 @@ class BaseModule:
|
||||
def run(self, message):
|
||||
raise NotImplementedError
|
||||
|
||||
def get_variable(self, key):
|
||||
return get_custom_variable(key)
|
||||
|
||||
|
||||
def parse_event_time(value, default=None):
|
||||
if not value:
|
||||
|
||||
@@ -38,6 +38,7 @@ RESOURCE_MODEL_TO_KEY = {
|
||||
"siemelkconfig": "siem-elk",
|
||||
"ldapconfig": "ldap",
|
||||
"runtimeconfig": "runtime",
|
||||
"customvariable": "custom-variables",
|
||||
}
|
||||
|
||||
RESOURCE_LABELS = {
|
||||
@@ -55,6 +56,7 @@ RESOURCE_LABELS = {
|
||||
"siemelkconfig": "ELK Settings",
|
||||
"ldapconfig": "LDAP Settings",
|
||||
"runtimeconfig": "Runtime Settings",
|
||||
"customvariable": "Custom Variable",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
from .models import CustomVariable
|
||||
|
||||
|
||||
def get_custom_variable(key):
|
||||
return (
|
||||
CustomVariable.objects.filter(key=key, enabled=True)
|
||||
.values_list("value", flat=True)
|
||||
.first()
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
# Generated by Django 6.0.7 on 2026-08-01 09:55
|
||||
|
||||
import django.core.validators
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('settings', '0004_runtimeconfig_dashboard_refresh_interval_seconds'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CustomVariable',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('key', models.CharField(max_length=128, unique=True, validators=[django.core.validators.RegexValidator(message='Key must start with an uppercase letter and contain only uppercase letters, numbers, and underscores.', regex='^[A-Z][A-Z0-9_]{0,127}$')])),
|
||||
('value', models.TextField()),
|
||||
('is_secret', models.BooleanField(default=False)),
|
||||
('description', models.TextField(blank=True, default='')),
|
||||
('enabled', models.BooleanField(default=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'setting_custom_variables',
|
||||
'ordering': ['key'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,5 +1,6 @@
|
||||
import uuid
|
||||
|
||||
from django.core.validators import RegexValidator
|
||||
from django.db import models
|
||||
|
||||
|
||||
@@ -156,3 +157,30 @@ class RuntimeConfig(models.Model):
|
||||
def get_current(cls):
|
||||
instance, _ = cls.objects.get_or_create(singleton_id=1)
|
||||
return instance
|
||||
|
||||
|
||||
class CustomVariable(models.Model):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
key = models.CharField(
|
||||
max_length=128,
|
||||
unique=True,
|
||||
validators=[
|
||||
RegexValidator(
|
||||
regex=r"^[A-Z][A-Z0-9_]{0,127}$",
|
||||
message="Key must start with an uppercase letter and contain only uppercase letters, numbers, and underscores.",
|
||||
)
|
||||
],
|
||||
)
|
||||
value = models.TextField()
|
||||
is_secret = models.BooleanField(default=False)
|
||||
description = models.TextField(blank=True, default="")
|
||||
enabled = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "setting_custom_variables"
|
||||
ordering = ["key"]
|
||||
|
||||
def __str__(self):
|
||||
return self.key
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from .models import (
|
||||
CustomVariable,
|
||||
LdapConfig,
|
||||
LLMProviderConfig,
|
||||
RuntimeConfig,
|
||||
@@ -11,6 +12,76 @@ from .models import (
|
||||
)
|
||||
|
||||
|
||||
MAX_CUSTOM_VARIABLE_VALUE_BYTES = 65_536
|
||||
|
||||
|
||||
class CustomVariableSerializer(serializers.ModelSerializer):
|
||||
value_configured = serializers.SerializerMethodField()
|
||||
confirm_secret_exposure = serializers.BooleanField(write_only=True, required=False, default=False)
|
||||
|
||||
class Meta:
|
||||
model = CustomVariable
|
||||
fields = (
|
||||
"id",
|
||||
"key",
|
||||
"value",
|
||||
"value_configured",
|
||||
"is_secret",
|
||||
"description",
|
||||
"enabled",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"confirm_secret_exposure",
|
||||
)
|
||||
read_only_fields = ("id", "value_configured", "created_at", "updated_at")
|
||||
extra_kwargs = {
|
||||
"value": {"required": False, "trim_whitespace": False, "allow_blank": False},
|
||||
"description": {"required": False, "allow_blank": True},
|
||||
}
|
||||
|
||||
def get_value_configured(self, obj):
|
||||
return bool(obj.value)
|
||||
|
||||
def validate_key(self, value):
|
||||
if self.instance is not None and value != self.instance.key:
|
||||
raise serializers.ValidationError("Key cannot be changed.")
|
||||
return value
|
||||
|
||||
def validate_value(self, value):
|
||||
if value == "":
|
||||
raise serializers.ValidationError("Value cannot be empty.")
|
||||
if len(value.encode("utf-8")) > MAX_CUSTOM_VARIABLE_VALUE_BYTES:
|
||||
raise serializers.ValidationError(
|
||||
f"Value cannot exceed {MAX_CUSTOM_VARIABLE_VALUE_BYTES:,} UTF-8 bytes."
|
||||
)
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
attrs = super().validate(attrs)
|
||||
confirmation = attrs.pop("confirm_secret_exposure", False)
|
||||
|
||||
if self.instance is None:
|
||||
if "value" not in attrs:
|
||||
raise serializers.ValidationError({"value": "Value is required."})
|
||||
return attrs
|
||||
|
||||
if not self.partial and "value" not in attrs and not self.instance.is_secret:
|
||||
raise serializers.ValidationError({"value": "Value is required."})
|
||||
|
||||
next_is_secret = attrs.get("is_secret", self.instance.is_secret)
|
||||
if self.instance.is_secret and not next_is_secret and not confirmation:
|
||||
raise serializers.ValidationError({
|
||||
"confirm_secret_exposure": "Confirm that this secret value may be exposed."
|
||||
})
|
||||
return attrs
|
||||
|
||||
def to_representation(self, instance):
|
||||
data = super().to_representation(instance)
|
||||
if instance.is_secret:
|
||||
data["value"] = ""
|
||||
return data
|
||||
|
||||
|
||||
class LLMProviderConfigSerializer(serializers.ModelSerializer):
|
||||
api_key_configured = serializers.SerializerMethodField()
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from .custom_views import (
|
||||
CustomModuleStreamMessagesView,
|
||||
)
|
||||
from .views import (
|
||||
CustomVariableViewSet,
|
||||
LLMProviderConfigViewSet,
|
||||
LdapConfigView,
|
||||
LdapTestView,
|
||||
@@ -29,6 +30,9 @@ router = DefaultRouter()
|
||||
router.register("llm-providers", LLMProviderConfigViewSet, basename="llm-provider")
|
||||
router.register("audit-logs", AdminAuditLogViewSet, basename="settings-audit-log")
|
||||
|
||||
custom_router = DefaultRouter()
|
||||
custom_router.register("variables", CustomVariableViewSet, basename="custom-variable")
|
||||
|
||||
urlpatterns = [
|
||||
path("settings/threat-intel/otx/", ThreatIntelAlienVaultOTXConfigView.as_view(), name="threat-intel-otx-config"),
|
||||
path("settings/threat-intel/otx/test/", ThreatIntelAlienVaultOTXTestView.as_view(), name="threat-intel-otx-test"),
|
||||
@@ -46,5 +50,6 @@ urlpatterns = [
|
||||
path("custom/modules/stream/message/", CustomModuleStreamMessageView.as_view(), name="custom-module-stream-message"),
|
||||
path("custom/playbooks/", CustomDefinitionsPlaybookView.as_view(), name="custom-definitions-playbooks"),
|
||||
path("custom/siem/", CustomDefinitionsSiemView.as_view(), name="custom-definitions-siem"),
|
||||
path("custom/", include(custom_router.urls)),
|
||||
path("settings/", include(router.urls)),
|
||||
]
|
||||
|
||||
@@ -15,6 +15,7 @@ from apps.audit.models import AuditLog
|
||||
from apps.common.advanced_filters import AdvancedFilterBackend
|
||||
from apps.common.operation_timeout import OperationTimeoutError, run_with_operation_timeout
|
||||
from .models import (
|
||||
CustomVariable,
|
||||
LdapConfig,
|
||||
LLMProviderConfig,
|
||||
RuntimeConfig,
|
||||
@@ -25,6 +26,7 @@ from .models import (
|
||||
)
|
||||
from .runtime_config import invalidate
|
||||
from .serializers import (
|
||||
CustomVariableSerializer,
|
||||
LLMProviderConfigSerializer,
|
||||
LdapConfigSerializer,
|
||||
SiemElkConfigSerializer,
|
||||
@@ -64,6 +66,7 @@ RUNTIME_AUDIT_FIELDS = (
|
||||
"stream_maxlen",
|
||||
"dashboard_refresh_interval_seconds",
|
||||
)
|
||||
CUSTOM_VARIABLE_AUDIT_FIELDS = ("key", "value", "is_secret", "description", "enabled")
|
||||
|
||||
|
||||
def _snapshot(instance, fields):
|
||||
@@ -202,6 +205,97 @@ class LLMProviderConfigViewSet(viewsets.ModelViewSet):
|
||||
return Response(result, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class CustomVariableViewSet(viewsets.ModelViewSet):
|
||||
queryset = CustomVariable.objects.all()
|
||||
serializer_class = CustomVariableSerializer
|
||||
permission_classes = [permissions.IsAuthenticated, IsAdmin]
|
||||
filter_backends = (DjangoFilterBackend, SearchFilter, OrderingFilter, AdvancedFilterBackend)
|
||||
search_fields = ("key", "description")
|
||||
filterset_fields = ("is_secret", "enabled")
|
||||
ordering_fields = ("key", "is_secret", "enabled", "created_at", "updated_at")
|
||||
advanced_filter_fields = {
|
||||
"key": "text",
|
||||
"description": "text",
|
||||
"is_secret": "select",
|
||||
"enabled": "select",
|
||||
"created_at": "date",
|
||||
"updated_at": "date",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _safe_changes(before, after):
|
||||
changes = {}
|
||||
for field in CUSTOM_VARIABLE_AUDIT_FIELDS:
|
||||
old_value = before.get(field) if before else None
|
||||
new_value = after.get(field) if after else None
|
||||
if old_value == new_value:
|
||||
continue
|
||||
if field == "value":
|
||||
changes[field] = {"from": "***", "to": "***"}
|
||||
else:
|
||||
changes[field] = {"from": old_value, "to": new_value}
|
||||
return changes
|
||||
|
||||
@transaction.atomic
|
||||
def perform_create(self, serializer):
|
||||
instance = serializer.save()
|
||||
after = _snapshot(instance, CUSTOM_VARIABLE_AUDIT_FIELDS)
|
||||
_write_audit(
|
||||
instance,
|
||||
"create",
|
||||
self.request.user,
|
||||
changes=self._safe_changes(None, after),
|
||||
metadata={"key": instance.key, "value_changed": True},
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
def perform_update(self, serializer):
|
||||
instance = self.get_object()
|
||||
before = _snapshot(instance, CUSTOM_VARIABLE_AUDIT_FIELDS)
|
||||
instance = serializer.save()
|
||||
after = _snapshot(instance, CUSTOM_VARIABLE_AUDIT_FIELDS)
|
||||
changes = self._safe_changes(before, after)
|
||||
if changes:
|
||||
_write_audit(
|
||||
instance,
|
||||
"update",
|
||||
self.request.user,
|
||||
changes=changes,
|
||||
metadata={"key": instance.key, "value_changed": before["value"] != after["value"]},
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
def perform_destroy(self, instance):
|
||||
before = _snapshot(instance, CUSTOM_VARIABLE_AUDIT_FIELDS)
|
||||
_write_audit(
|
||||
instance,
|
||||
"delete",
|
||||
self.request.user,
|
||||
changes=self._safe_changes(before, None),
|
||||
metadata={
|
||||
"key": instance.key,
|
||||
"description": instance.description,
|
||||
"is_secret": instance.is_secret,
|
||||
"enabled": instance.enabled,
|
||||
},
|
||||
)
|
||||
instance.delete()
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def reveal(self, request, pk=None):
|
||||
instance = self.get_object()
|
||||
if not instance.is_secret:
|
||||
return Response(
|
||||
{"detail": "Only secret variables can be revealed."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
_write_audit(instance, "reveal", request.user, metadata={"key": instance.key})
|
||||
response = Response({"value": instance.value})
|
||||
response["Cache-Control"] = "no-store"
|
||||
response["Pragma"] = "no-cache"
|
||||
return response
|
||||
|
||||
|
||||
def _otx_config_from_instance(instance, values):
|
||||
config = _snapshot(instance, OTX_AUDIT_FIELDS)
|
||||
config.update(values)
|
||||
|
||||
@@ -1,316 +0,0 @@
|
||||
# Custom Variables
|
||||
|
||||
Status: Confirmed
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
提供一个由 Admin 通过 UI 管理、由自定义 Playbook 和 Agentic Module 在后端运行时读取的字符串变量仓库。它类似受控的数据库版环境变量,但不会修改真实 `os.environ`,也不影响 Django 全局配置。
|
||||
|
||||
典型用途:
|
||||
|
||||
- 外部系统 base URL。
|
||||
- API token、username/password。
|
||||
- tenant/project ID。
|
||||
- 自定义 JSON 字符串。
|
||||
- Module 或 Playbook 的运行参数。
|
||||
|
||||
## 2. Scope
|
||||
|
||||
### Consumers
|
||||
|
||||
- `BasePlaybook.get_variable(key)`
|
||||
- `BaseModule.get_variable(key)`
|
||||
|
||||
### Not consumers
|
||||
|
||||
- Agent API。
|
||||
- CLI 或插件。
|
||||
- 前端运行时。
|
||||
- Django template。
|
||||
- 其他任意后端模块。
|
||||
- 系统 Runtime Settings。
|
||||
|
||||
### Excluded
|
||||
|
||||
- 写入真实进程环境变量。
|
||||
- 全局配置覆盖。
|
||||
- per-Playbook/per-Module scope。
|
||||
- 变量继承或 override。
|
||||
- value 类型系统。
|
||||
- `.env` 导入导出。
|
||||
- REQUIRED_VARIABLES 声明。
|
||||
- Secret 加密、版本历史或自动泄露防护。
|
||||
|
||||
## 3. Data model
|
||||
|
||||
建议模型 `CustomVariable`:
|
||||
|
||||
| Field | Type | Rules |
|
||||
| --- | --- | --- |
|
||||
| id | UUID | primary key |
|
||||
| key | CharField(128) | unique, immutable |
|
||||
| value | TextField | non-empty, max 65,536 UTF-8 bytes |
|
||||
| is_secret | Boolean | controls API masking only |
|
||||
| description | TextField | optional |
|
||||
| enabled | Boolean | default true |
|
||||
| created_at | DateTime | auto |
|
||||
| updated_at | DateTime | auto |
|
||||
|
||||
### Key validation
|
||||
|
||||
Regex:
|
||||
|
||||
```text
|
||||
[A-Z][A-Z0-9_]{0,127}
|
||||
```
|
||||
|
||||
- 全局唯一。
|
||||
- 大小写敏感,但合法输入只有大写。
|
||||
- 创建后不可改名。
|
||||
- 修改 key 的 PATCH/PUT 返回 validation error。
|
||||
|
||||
### Value validation
|
||||
|
||||
- 必须至少一个字符。
|
||||
- 以 UTF-8 bytes 计算,最多 65,536 bytes。
|
||||
- 可包含换行。
|
||||
- 不支持 binary。
|
||||
- 不做 trim;空格可能是有意值,但零长度禁止。
|
||||
- 更新 Secret 时省略 value 表示保留旧值。
|
||||
- 显式 `""` 始终拒绝。
|
||||
|
||||
## 4. Storage security
|
||||
|
||||
已确认:
|
||||
|
||||
- Secret 和非 Secret 均以 PostgreSQL 明文保存。
|
||||
- 数据库管理员、数据库泄露和未加密备份可以读取 Secret。
|
||||
- `is_secret` 只控制 API/UI 显示与审计,不提供 cryptographic protection。
|
||||
- 用户文档必须明确该限制。
|
||||
|
||||
不得在 AuditLog、普通 API response、异常文本或日志中复制 value。
|
||||
|
||||
## 5. Runtime API
|
||||
|
||||
Base class helper:
|
||||
|
||||
```python
|
||||
def get_variable(self, key):
|
||||
...
|
||||
```
|
||||
|
||||
返回:
|
||||
|
||||
- Enabled 且存在:原始 `str`。
|
||||
- 不存在:`None`。
|
||||
- Disabled:`None`。
|
||||
- 已删除:`None`。
|
||||
|
||||
行为:
|
||||
|
||||
- 每次调用都查询 PostgreSQL。
|
||||
- 不做 per-run、per-message 或 Worker 全局缓存。
|
||||
- Admin 在 Playbook/Module 运行中修改、Disabled 或删除变量,下一次读取立即看到变化。
|
||||
- 不抛 missing variable 异常。
|
||||
- 不支持 default 参数作为平台约定;自定义代码自行使用 `or` 或显式 None 处理。
|
||||
- 不返回 is_secret/description 等 metadata。
|
||||
- 不记录 read audit 或 usage relation。
|
||||
|
||||
建议共享 service 位于 `apps.settings.custom_variables` 或等价窄模块,BasePlaybook/BaseModule 只做代理,避免重复 ORM 代码。
|
||||
|
||||
## 6. Permissions
|
||||
|
||||
| Action | Admin | User | Viewer | Playbook/Module Worker |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| List metadata | Yes | No | No | No |
|
||||
| Read non-secret value via Admin API | Yes | No | No | N/A |
|
||||
| Reveal secret | Yes | No | No | N/A |
|
||||
| Create/update/delete | Yes | No | No | No |
|
||||
| get_variable runtime | No direct API | No direct API | No direct API | Yes |
|
||||
|
||||
Worker 读取不根据发起 Playbook 的用户角色过滤。User 发起的 Playbook 仍可读取所有 Custom Variables,因此 Admin 必须只安装可信自定义代码。
|
||||
|
||||
## 7. Admin API
|
||||
|
||||
建议资源:
|
||||
|
||||
`/api/settings/custom-variables/`
|
||||
|
||||
### List/retrieve default representation
|
||||
|
||||
非 Secret:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "...",
|
||||
"key": "EDR_BASE_URL",
|
||||
"value": "https://edr.internal.example",
|
||||
"is_secret": false,
|
||||
"description": "Production EDR API",
|
||||
"enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
Secret:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "...",
|
||||
"key": "EDR_API_TOKEN",
|
||||
"value": "",
|
||||
"value_configured": true,
|
||||
"is_secret": true,
|
||||
"description": "Production EDR token",
|
||||
"enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
### Reveal
|
||||
|
||||
`POST /api/settings/custom-variables/{id}/reveal/`
|
||||
|
||||
- 仅 active Admin session。
|
||||
- 不要求重新输入本地或 LDAP 密码。
|
||||
- 返回一次明文 value。
|
||||
- 每次请求写 AuditLog。
|
||||
- 不应支持 bulk reveal。
|
||||
- response 应使用 no-store cache headers。
|
||||
|
||||
### Update
|
||||
|
||||
- key 不可改。
|
||||
- Secret update 未包含 value:保留旧值。
|
||||
- Secret→non-secret:API 需要显式确认字段,例如 `confirm_secret_exposure=true`。
|
||||
- 没有确认返回 400。
|
||||
- non-secret→secret 正常允许。
|
||||
- 所有 changes 审计不包含 value。
|
||||
|
||||
### Delete
|
||||
|
||||
- 允许硬删除。
|
||||
- UI 必须二次确认。
|
||||
- API 不负责扫描 Python 代码引用。
|
||||
- 删除后运行时返回 None。
|
||||
- 删除 AuditLog 保存 key、description、is_secret、enabled,不保存 value。
|
||||
|
||||
## 8. Audit
|
||||
|
||||
写 AuditLog:
|
||||
|
||||
- create
|
||||
- update
|
||||
- enable/disable(作为 update)
|
||||
- reveal
|
||||
- delete
|
||||
|
||||
不写:
|
||||
|
||||
- get_variable runtime read。
|
||||
- Admin 普通 list/retrieve。
|
||||
|
||||
Secret 和非 Secret value 均不得进入 changes。可使用:
|
||||
|
||||
```json
|
||||
{
|
||||
"changes": {
|
||||
"value": {"from": "***", "to": "***"},
|
||||
"enabled": {"from": true, "to": false}
|
||||
},
|
||||
"metadata": {
|
||||
"key": "EDR_API_TOKEN",
|
||||
"value_changed": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
不保留旧 value,不支持 rollback。
|
||||
|
||||
## 9. Frontend
|
||||
|
||||
位置:System Settings 中新增 `Custom Variables`。
|
||||
|
||||
### List
|
||||
|
||||
- Key。
|
||||
- Description。
|
||||
- Secret 标记。
|
||||
- Enabled。
|
||||
- Value configured。
|
||||
- Updated time。
|
||||
- Edit/Delete actions。
|
||||
- Secret 默认不可见。
|
||||
|
||||
### Create/edit modal
|
||||
|
||||
- Key 创建时可编辑,编辑时只读。
|
||||
- Value 使用 multiline input;Secret 使用 password input。
|
||||
- is_secret switch。
|
||||
- enabled switch。
|
||||
- description。
|
||||
- value byte limit 提示。
|
||||
- 编辑 Secret 时 value 留空表示不修改,UI 必须明确说明。
|
||||
|
||||
### Reveal
|
||||
|
||||
- Secret 行显示 Reveal。
|
||||
- active Admin session 直接调用。
|
||||
- 明文只显示在临时 Modal。
|
||||
- Modal 关闭后从前端 state 清除。
|
||||
- 不复制到列表 state、URL、localStorage 或 console。
|
||||
|
||||
### Secret downgrade
|
||||
|
||||
从 Secret 改为普通变量时显示额外确认:
|
||||
|
||||
> This value will become visible in normal Admin API responses and UI.
|
||||
|
||||
## 10. Interaction with custom code
|
||||
|
||||
示例:
|
||||
|
||||
```python
|
||||
class Playbook(BasePlaybook):
|
||||
def run(self):
|
||||
base_url = self.get_variable("EDR_BASE_URL")
|
||||
token = self.get_variable("EDR_API_TOKEN")
|
||||
if not base_url or not token:
|
||||
raise ValueError("EDR custom variables are not configured.")
|
||||
```
|
||||
|
||||
自定义代码责任:
|
||||
|
||||
- 检查 None。
|
||||
- 解析 JSON/boolean/number。
|
||||
- 不把 Secret 写入 log、Stage summary、remark、Enrichment 或异常。
|
||||
- 为 HTTP 调用设置 timeout、TLS、proxy、重试和幂等。
|
||||
|
||||
平台不静态分析 key,也不展示“变量被哪些脚本使用”。
|
||||
|
||||
## 11. Migration and backup
|
||||
|
||||
- 新表 migration,无旧数据迁移。
|
||||
- v0.5.2 现有 LLM/SIEM/LDAP/TI Secret 不自动复制到 Custom Variables。
|
||||
- 备份/恢复自然包含明文 value。
|
||||
- Compose 文档需提醒保护数据库备份。
|
||||
|
||||
## 12. Acceptance criteria
|
||||
|
||||
1. 只有 Admin 能访问资源和 Reveal。
|
||||
2. key regex、唯一和不可改名规则生效。
|
||||
3. 空 value 和超过 65,536 bytes 被拒绝。
|
||||
4. Secret 默认 response 不含明文。
|
||||
5. Admin Reveal 返回明文并写不含 value 的审计。
|
||||
6. Secret→普通无确认被拒绝。
|
||||
7. Playbook 和 Module 每次读取当前数据库值。
|
||||
8. Disabled/missing/deleted 返回 None。
|
||||
9. Agent API、CLI 和普通用户没有读取端点。
|
||||
10. CRUD AuditLog 永不包含 old/new value。
|
||||
11. 删除无需引用检查且立即影响运行时读取。
|
||||
|
||||
## 13. Known tradeoffs
|
||||
|
||||
- PostgreSQL 和备份保存明文 Secret。
|
||||
- active Admin session 无需重新认证即可 Reveal。
|
||||
- User 发起的可信 Playbook 可以间接使用全部 Secret。
|
||||
- 平台无法阻止恶意或错误自定义代码泄露 Secret。
|
||||
- 每次 ORM 查询增加少量开销,这是为即时一致性接受的成本。
|
||||
@@ -10,7 +10,6 @@
|
||||
| [01-bulk-case-triage.md](01-bulk-case-triage.md) | Confirmed | Case 批量分诊、共享状态机、通知与审计 |
|
||||
| [02-case-merge.md](02-case-merge.md) | Confirmed | 多源案件合并、数据迁移、只读源案件与幂等 |
|
||||
| [03-playbook-execution.md](03-playbook-execution.md) | Confirmed | Playbook Run、结构化 Stage、取消、重试与 Worker 语义 |
|
||||
| [04-custom-variables.md](04-custom-variables.md) | Confirmed | Playbook/Module 可读取的 UI 管理变量 |
|
||||
| [05-worker-health.md](05-worker-health.md) | Confirmed | Redis 心跳、Worker 状态、积压指标与 Admin API |
|
||||
| [06-integration-health.md](06-integration-health.md) | Confirmed | 定时集成检查、当前状态持久化与安全错误 |
|
||||
| [07-sla-management.md](07-sla-management.md) | Confirmed | TTD/TTA/TTR 时限、Severity 策略、通知和 Dashboard 达标率 |
|
||||
|
||||
@@ -98,6 +98,14 @@ const llmProviderStatusTag = (v: unknown) => {
|
||||
const enabled = v === true || v === 'true'
|
||||
return choiceTag(enabled ? 'Enabled' : 'Disabled', enabled ? 'green' : 'default')
|
||||
}
|
||||
const customVariableSecretTag = (v: unknown) => {
|
||||
const secret = v === true || v === 'true'
|
||||
return choiceTag(secret ? 'Secret' : 'Plain', secret ? 'purple' : 'default')
|
||||
}
|
||||
const customVariableConfiguredTag = (v: unknown) => {
|
||||
const configured = v === true || v === 'true'
|
||||
return choiceTag(configured ? 'Configured' : 'Not configured', configured ? 'green' : 'red')
|
||||
}
|
||||
const llmTagColors: Record<string, string> = {
|
||||
fast: 'blue',
|
||||
powerful: 'purple',
|
||||
@@ -1011,6 +1019,36 @@ export const resourceConfigs: Record<string, ResourceConfig<RecordRow>> = {
|
||||
basicSections: [],
|
||||
tabs: [],
|
||||
},
|
||||
'custom-variables': {
|
||||
key: 'custom-variables',
|
||||
label: 'Custom Variables',
|
||||
icon: <ApiOutlined/>,
|
||||
endpoint: '/custom/variables/',
|
||||
rowKey: 'id',
|
||||
searchPlaceholder: 'Key, Description',
|
||||
filters: [
|
||||
{key: 'is_secret', label: 'Secret', valueType: 'select', options: [{label: 'Secret', value: 'true'}, {label: 'Plain', value: 'false'}], width: L132},
|
||||
{key: 'enabled', label: 'Enabled', valueType: 'select', options: [{label: 'Enabled', value: 'true'}, {label: 'Disabled', value: 'false'}], width: L132},
|
||||
],
|
||||
advancedFilters: [
|
||||
field('key', 'Key', 'text'),
|
||||
field('description', 'Description', 'text'),
|
||||
{key: 'is_secret', label: 'Secret', valueType: 'select', options: [{label: 'Secret', value: 'true'}, {label: 'Plain', value: 'false'}]},
|
||||
{key: 'enabled', label: 'Enabled', valueType: 'select', options: [{label: 'Enabled', value: 'true'}, {label: 'Disabled', value: 'false'}]},
|
||||
field('created_at', 'Created At', 'date'),
|
||||
field('updated_at', 'Updated At', 'date'),
|
||||
],
|
||||
columns: [
|
||||
column('key', 'Key', L240, {required: true, defaultVisible: true, openRecord: true, sorter: true}),
|
||||
column('description', 'Description', L360, {defaultVisible: true}),
|
||||
column('is_secret', 'Secret', L96, {defaultVisible: true, sorter: true, render: customVariableSecretTag}),
|
||||
column('enabled', 'Enabled', L96, {defaultVisible: true, sorter: true, render: llmProviderStatusTag}),
|
||||
column('value_configured', 'Value', L132, {defaultVisible: true, render: customVariableConfiguredTag}),
|
||||
column('updated_at', 'Updated At', L160, {defaultVisible: true, sorter: true, render: date('updated_at')}),
|
||||
],
|
||||
basicSections: [],
|
||||
tabs: [],
|
||||
},
|
||||
}
|
||||
|
||||
export function getResourceConfig(key: string): ResourceConfig<RecordRow> {
|
||||
|
||||
@@ -3,11 +3,12 @@ import {Alert, Button, Descriptions, Drawer, Empty, Flex, Input, InputNumber, Li
|
||||
import {message} from '../utils/appMessage'
|
||||
import {ReloadOutlined} from '@ant-design/icons'
|
||||
import type {ColumnsType} from 'antd/es/table'
|
||||
import {Boxes, BrainCircuit, DatabaseZap} from 'lucide-react'
|
||||
import {Boxes, BrainCircuit, DatabaseZap, KeyRound} from 'lucide-react'
|
||||
import client from '../api/client'
|
||||
import JsonViewer from '../components/JsonViewer'
|
||||
import IconTabLabel from '../components/IconTabLabel'
|
||||
import OverflowTags from '../components/OverflowTags'
|
||||
import CustomVariablesSettings from './CustomVariablesSettings'
|
||||
import {comfortableTagProps} from '../utils/tagStyles'
|
||||
|
||||
type SourceType = 'official' | 'custom'
|
||||
@@ -585,6 +586,7 @@ export default function CustomDefinitions() {
|
||||
{ key: 'modules', label: <IconTabLabel icon={Boxes}>Modules</IconTabLabel>, children: <ModulesTab /> },
|
||||
{ key: 'playbooks', label: <IconTabLabel icon={BrainCircuit}>Playbooks</IconTabLabel>, children: <PlaybooksTab /> },
|
||||
{ key: 'siem', label: <IconTabLabel icon={DatabaseZap}>SIEM YAML</IconTabLabel>, children: <SiemTab /> },
|
||||
{ key: 'variables', label: <IconTabLabel icon={KeyRound}>Variables</IconTabLabel>, children: <CustomVariablesSettings /> },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
import {useMemo, useState} from 'react'
|
||||
import {App as AntApp, Button, Form, Input, Modal, Popconfirm, Space, Switch, Typography} from 'antd'
|
||||
import {DeleteOutlined, EditOutlined, EyeOutlined, PlusOutlined} from '@ant-design/icons'
|
||||
import client from '../api/client'
|
||||
import DataTable from '../components/DataTable'
|
||||
import {getResourceConfig} from '../config/resources'
|
||||
import {message} from '../utils/appMessage'
|
||||
|
||||
type CustomVariable = Record<string, unknown> & {
|
||||
id: string
|
||||
key: string
|
||||
value: string
|
||||
value_configured: boolean
|
||||
is_secret: boolean
|
||||
description: string
|
||||
enabled: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface CustomVariableFormValues {
|
||||
key: string
|
||||
value?: string
|
||||
is_secret: boolean
|
||||
description?: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
interface RevealedValue {
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
|
||||
const MAX_VALUE_BYTES = 65_536
|
||||
|
||||
function apiErrorMessage(error: unknown, fallback: string) {
|
||||
const data = (error as { response?: { data?: unknown } }).response?.data
|
||||
if (typeof data === 'string') return data
|
||||
if (data && typeof data === 'object') {
|
||||
const detail = (data as { detail?: unknown }).detail
|
||||
if (typeof detail === 'string') return detail
|
||||
return JSON.stringify(data)
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
function initialValues(): CustomVariableFormValues {
|
||||
return {
|
||||
key: '',
|
||||
value: '',
|
||||
is_secret: false,
|
||||
description: '',
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
export default function CustomVariablesSettings() {
|
||||
const {modal} = AntApp.useApp()
|
||||
const config = useMemo(() => getResourceConfig('custom-variables'), [])
|
||||
const [form] = Form.useForm<CustomVariableFormValues>()
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<CustomVariable | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [revealingId, setRevealingId] = useState<string | null>(null)
|
||||
const [revealed, setRevealed] = useState<RevealedValue | null>(null)
|
||||
const [refreshKey, setRefreshKey] = useState(0)
|
||||
const isSecret = Form.useWatch('is_secret', form) ?? false
|
||||
|
||||
const refresh = () => setRefreshKey((value) => value + 1)
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
form.setFieldsValue(initialValues())
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = async (record: CustomVariable) => {
|
||||
try {
|
||||
const {data} = await client.get<CustomVariable>(`/custom/variables/${record.id}/`)
|
||||
setEditing(data)
|
||||
form.setFieldsValue({...initialValues(), ...data})
|
||||
setModalOpen(true)
|
||||
} catch (error: unknown) {
|
||||
message.error(apiErrorMessage(error, 'Failed to load custom variable'))
|
||||
}
|
||||
}
|
||||
|
||||
const closeEditor = () => {
|
||||
setModalOpen(false)
|
||||
setEditing(null)
|
||||
form.resetFields()
|
||||
}
|
||||
|
||||
const persist = async (values: CustomVariableFormValues, confirmSecretExposure: boolean) => {
|
||||
const payload: Record<string, unknown> = {
|
||||
is_secret: values.is_secret,
|
||||
description: values.description || '',
|
||||
enabled: values.enabled,
|
||||
}
|
||||
if (!editing) payload.key = values.key
|
||||
if (values.value !== '' && values.value !== undefined) payload.value = values.value
|
||||
if (confirmSecretExposure) payload.confirm_secret_exposure = true
|
||||
|
||||
if (editing) {
|
||||
await client.patch(`/custom/variables/${editing.id}/`, payload)
|
||||
message.success('Custom variable updated')
|
||||
} else {
|
||||
await client.post('/custom/variables/', payload)
|
||||
message.success('Custom variable created')
|
||||
}
|
||||
closeEditor()
|
||||
refresh()
|
||||
}
|
||||
|
||||
const saveVariable = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
if (editing?.is_secret && !values.is_secret) {
|
||||
modal.confirm({
|
||||
title: `Expose ${editing.key} as a non-secret variable?`,
|
||||
content: 'This value will become visible in normal Admin API responses and UI.',
|
||||
okText: 'Expose value',
|
||||
okButtonProps: {danger: true},
|
||||
onOk: async () => {
|
||||
try {
|
||||
await persist(values, true)
|
||||
} catch (error: unknown) {
|
||||
message.error(apiErrorMessage(error, 'Failed to save custom variable'))
|
||||
throw error
|
||||
}
|
||||
},
|
||||
})
|
||||
} else {
|
||||
await persist(values, false)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
message.error(apiErrorMessage(error, 'Failed to save custom variable'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const revealVariable = async (record: CustomVariable) => {
|
||||
setRevealingId(record.id)
|
||||
try {
|
||||
const {data} = await client.post<{value: string}>(`/custom/variables/${record.id}/reveal/`)
|
||||
setRevealed({key: record.key, value: data.value})
|
||||
} catch (error: unknown) {
|
||||
message.error(apiErrorMessage(error, 'Failed to reveal custom variable'))
|
||||
} finally {
|
||||
setRevealingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteVariable = async (record: CustomVariable) => {
|
||||
try {
|
||||
await client.delete(`/custom/variables/${record.id}/`)
|
||||
message.success('Custom variable deleted')
|
||||
refresh()
|
||||
} catch (error: unknown) {
|
||||
message.error(apiErrorMessage(error, 'Failed to delete custom variable'))
|
||||
}
|
||||
}
|
||||
|
||||
const validateValue = (_: unknown, value: string | undefined) => {
|
||||
if (!value && !editing?.is_secret) {
|
||||
return Promise.reject(new Error('Value is required.'))
|
||||
}
|
||||
if (value !== undefined && new TextEncoder().encode(value).length > MAX_VALUE_BYTES) {
|
||||
return Promise.reject(new Error(`Value cannot exceed ${MAX_VALUE_BYTES.toLocaleString()} UTF-8 bytes.`))
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{height: 'calc(100vh - 188px)', minHeight: 360}}>
|
||||
<DataTable
|
||||
key={refreshKey}
|
||||
endpoint={config.endpoint}
|
||||
tableKey={config.key}
|
||||
rowKey={config.rowKey}
|
||||
columns={config.columns}
|
||||
filters={config.filters}
|
||||
advancedFilters={config.advancedFilters}
|
||||
searchPlaceholder={config.searchPlaceholder}
|
||||
actions={<Button icon={<PlusOutlined />} onClick={openCreate} />}
|
||||
actionColumnWidth={136}
|
||||
rowActions={(record) => {
|
||||
const variable = record as CustomVariable
|
||||
return (
|
||||
<Space size={4} align="center" className="table-row-actions">
|
||||
{variable.is_secret ? (
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<EyeOutlined />}
|
||||
loading={revealingId === variable.id}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
revealVariable(variable)
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
openEdit(variable)
|
||||
}}
|
||||
/>
|
||||
<Popconfirm
|
||||
title={`Delete ${variable.key}?`}
|
||||
description="This variable will be permanently deleted."
|
||||
okText="Delete"
|
||||
okButtonProps={{danger: true}}
|
||||
onConfirm={(event) => {
|
||||
event?.stopPropagation()
|
||||
return deleteVariable(variable)
|
||||
}}
|
||||
onCancel={(event) => event?.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
/>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)
|
||||
}}
|
||||
onRowClick={(record) => openEdit(record as CustomVariable)}
|
||||
dense
|
||||
fillParent
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editing ? `Custom Variable: ${editing.key}` : 'Add Custom Variable'}
|
||||
open={modalOpen}
|
||||
onCancel={closeEditor}
|
||||
destroyOnHidden
|
||||
footer={(
|
||||
<Space>
|
||||
<Button onClick={closeEditor}>Cancel</Button>
|
||||
<Button type="primary" loading={saving} onClick={saveVariable}>Save</Button>
|
||||
</Space>
|
||||
)}
|
||||
>
|
||||
<Form form={form} layout="vertical" initialValues={initialValues()} style={{paddingTop: 8}}>
|
||||
<Form.Item
|
||||
name="key"
|
||||
label="Key"
|
||||
rules={[
|
||||
{required: true},
|
||||
{pattern: /^[A-Z][A-Z0-9_]{0,127}$/, message: 'Use uppercase letters, numbers, and underscores.'},
|
||||
]}
|
||||
>
|
||||
<Input disabled={editing !== null} placeholder="EDR_API_TOKEN" maxLength={128} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="value"
|
||||
label="Value"
|
||||
rules={[{validator: validateValue}]}
|
||||
extra={editing?.is_secret
|
||||
? 'Leave blank to keep the current value. Maximum 65,536 UTF-8 bytes.'
|
||||
: 'Maximum 65,536 UTF-8 bytes.'}
|
||||
>
|
||||
{isSecret
|
||||
? <Input.Password autoComplete="new-password" />
|
||||
: <Input.TextArea autoSize={{minRows: 3, maxRows: 10}} />}
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="Description">
|
||||
<Input.TextArea autoSize={{minRows: 2, maxRows: 5}} />
|
||||
</Form.Item>
|
||||
<Form.Item name="is_secret" label="Secret" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="enabled" label="Enabled" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={revealed ? `Secret: ${revealed.key}` : 'Secret'}
|
||||
open={revealed !== null}
|
||||
onCancel={() => setRevealed(null)}
|
||||
destroyOnHidden
|
||||
footer={<Button onClick={() => setRevealed(null)}>Close</Button>}
|
||||
>
|
||||
<Typography.Paragraph type="secondary">
|
||||
This value is shown only in this dialog.
|
||||
</Typography.Paragraph>
|
||||
<Input.TextArea value={revealed?.value || ''} readOnly autoSize={{minRows: 2, maxRows: 10}} />
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user