csrd-utils 0.3.14__tar.gz → 0.3.16__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.
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/PKG-INFO +8 -1
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/README.md +7 -0
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/pyproject.toml +1 -1
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/__init__.py +2 -1
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/__main__.py +103 -5
- csrd_utils-0.3.16/src/csrd_utils/audit.py +192 -0
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/augmentor.py +0 -2
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/cluster.py +34 -18
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/doctor.py +0 -2
- csrd_utils-0.3.16/src/csrd_utils/features/caching/conftest.fragment.py +22 -0
- csrd_utils-0.3.16/src/csrd_utils/features/caching/docker-compose.fragment.yaml +10 -0
- csrd_utils-0.3.16/src/csrd_utils/features/caching/manifest.yaml +22 -0
- csrd_utils-0.3.16/src/csrd_utils/features/caching/requirements.fragment +1 -0
- csrd_utils-0.3.16/src/csrd_utils/features/caching/settings.fragment.py +4 -0
- csrd_utils-0.3.16/src/csrd_utils/features/caching/src/app/cache.py +36 -0
- csrd_utils-0.3.16/src/csrd_utils/features/caching/tests/test_cache.py +39 -0
- csrd_utils-0.3.16/src/csrd_utils/features/logging/conftest.fragment.py +10 -0
- csrd_utils-0.3.16/src/csrd_utils/features/logging/manifest.yaml +18 -0
- csrd_utils-0.3.16/src/csrd_utils/features/logging/requirements.fragment +1 -0
- csrd_utils-0.3.16/src/csrd_utils/features/logging/settings.fragment.py +3 -0
- csrd_utils-0.3.16/src/csrd_utils/features/logging/src/app/middleware/logging.py +73 -0
- csrd_utils-0.3.16/src/csrd_utils/features/logging/tests/test_logging_middleware.py +41 -0
- csrd_utils-0.3.16/src/csrd_utils/features/metrics/conftest.fragment.py +7 -0
- csrd_utils-0.3.16/src/csrd_utils/features/metrics/docker-compose.fragment.yaml +12 -0
- csrd_utils-0.3.16/src/csrd_utils/features/metrics/manifest.yaml +21 -0
- csrd_utils-0.3.16/src/csrd_utils/features/metrics/requirements.fragment +1 -0
- csrd_utils-0.3.16/src/csrd_utils/features/metrics/settings.fragment.py +3 -0
- csrd_utils-0.3.16/src/csrd_utils/features/metrics/src/app/middleware/metrics.py +72 -0
- csrd_utils-0.3.16/src/csrd_utils/features/metrics/tests/test_metrics_middleware.py +79 -0
- csrd_utils-0.3.16/src/csrd_utils/features/tracing/conftest.fragment.py +9 -0
- csrd_utils-0.3.16/src/csrd_utils/features/tracing/manifest.yaml +21 -0
- csrd_utils-0.3.16/src/csrd_utils/features/tracing/requirements.fragment +3 -0
- csrd_utils-0.3.16/src/csrd_utils/features/tracing/settings.fragment.py +4 -0
- csrd_utils-0.3.16/src/csrd_utils/features/tracing/src/app/middleware/tracing.py +61 -0
- csrd_utils-0.3.16/src/csrd_utils/features/tracing/tests/test_tracing_middleware.py +51 -0
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/features/workers/src/app/workers/tasks.py +0 -1
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/features/workers/tests/test_workers.py +0 -1
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/fragments.py +0 -2
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/generator.py +0 -2
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/resources.py +0 -2
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/templates/cookiecutter-service/README.md +1 -1
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/.env.example +14 -8
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/README.md +7 -0
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/docker-compose.yml +18 -13
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/__init__.py +4 -1
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/settings.py +42 -5
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/views/unversioned/__init__.py +0 -4
- csrd_utils-0.3.16/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/views/unversioned/items_view.py +217 -0
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/conftest.py +7 -7
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/test_items.py +24 -1
- csrd_utils-0.3.14/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/views/unversioned/items_view.py +0 -70
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/.gitignore +0 -0
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/features/workers/conftest.fragment.py +0 -0
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/features/workers/docker-compose.fragment.yaml +0 -0
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/features/workers/manifest.yaml +0 -0
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/features/workers/requirements.fragment +0 -0
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/features/workers/settings.fragment.py +0 -0
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/features/workers/src/app/workers/__init__.py +0 -0
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/features/workers/src/app/workers/celery_app.py +0 -0
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/templates/cookiecutter-service/cookiecutter.json +0 -0
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/templates/cookiecutter-service/hooks/post_gen_project.py +0 -0
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/.dockerignore +0 -0
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/Dockerfile +0 -0
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/requirements.txt +0 -0
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/cache.py +0 -0
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/delegates/__init__.py +0 -0
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/delegates/upstreams.py +0 -0
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/dependencies/__init__.py +0 -0
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/dependencies/delegates.py +0 -0
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/dependencies/item_repository.py +0 -0
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/dependencies/worker_broker.py +0 -0
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/middleware/__init__.py +0 -0
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/middleware/logging.py +0 -0
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/repositories/__init__.py +0 -0
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/repositories/item_repository.py +0 -0
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/views/__init__.py +0 -0
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/views/unversioned/health_view.py +0 -0
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/workers/__init__.py +0 -0
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/workers/tasks.py +0 -0
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/__init__.py +0 -0
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/acceptance/test_logging_middleware.py +0 -0
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/unit/test_cache.py +0 -0
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/unit/test_database_settings.py +0 -0
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/unit/test_delegates.py +0 -0
- {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/unit/test_worker_broker.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: csrd-utils
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.16
|
|
4
4
|
Summary: CLI utilities for csrd service generation and feature augmentation
|
|
5
5
|
Project-URL: Repository, https://github.com/csrd-api/fastapi-common
|
|
6
6
|
Project-URL: Documentation, https://github.com/csrd-api/fastapi-common/tree/main/packages/utils
|
|
@@ -38,6 +38,8 @@ csrd feature plan workers --service .
|
|
|
38
38
|
csrd feature plan workers --service . --json
|
|
39
39
|
csrd feature add workers --service .
|
|
40
40
|
csrd doctor --service .
|
|
41
|
+
csrd audit # if cwd is workspace, audits all services
|
|
42
|
+
csrd audit --service . # explicitly audit one service path
|
|
41
43
|
```
|
|
42
44
|
|
|
43
45
|
`feature plan --json` is useful for CI or tooling wrappers.
|
|
@@ -70,6 +72,9 @@ csrd new service --name inventory --output ./local-dev --database postgres --wor
|
|
|
70
72
|
# 1) Verify service compatibility
|
|
71
73
|
csrd doctor --service ./local-dev/inventory
|
|
72
74
|
|
|
75
|
+
# 1b) Audit for weak/insecure defaults
|
|
76
|
+
csrd audit --service ./local-dev/inventory
|
|
77
|
+
|
|
73
78
|
# 2) Inspect available bundled features
|
|
74
79
|
csrd feature list
|
|
75
80
|
|
|
@@ -120,6 +125,7 @@ csrd new service --help
|
|
|
120
125
|
csrd new cluster --help
|
|
121
126
|
csrd feature --help
|
|
122
127
|
csrd doctor --help
|
|
128
|
+
csrd audit --help
|
|
123
129
|
csrd feature list
|
|
124
130
|
```
|
|
125
131
|
|
|
@@ -130,6 +136,7 @@ mkdir -p /tmp/csrd-smoke
|
|
|
130
136
|
csrd new workspace --name ws --output /tmp/csrd-smoke --generate none
|
|
131
137
|
csrd new service --name demo-svc --output /tmp/csrd-smoke/ws --no-interactive
|
|
132
138
|
csrd doctor --service /tmp/csrd-smoke/ws/demo-svc
|
|
139
|
+
csrd audit --service /tmp/csrd-smoke/ws/demo-svc
|
|
133
140
|
csrd feature plan workers --service /tmp/csrd-smoke/ws/demo-svc
|
|
134
141
|
csrd feature add workers --service /tmp/csrd-smoke/ws/demo-svc
|
|
135
142
|
```
|
|
@@ -25,6 +25,8 @@ csrd feature plan workers --service .
|
|
|
25
25
|
csrd feature plan workers --service . --json
|
|
26
26
|
csrd feature add workers --service .
|
|
27
27
|
csrd doctor --service .
|
|
28
|
+
csrd audit # if cwd is workspace, audits all services
|
|
29
|
+
csrd audit --service . # explicitly audit one service path
|
|
28
30
|
```
|
|
29
31
|
|
|
30
32
|
`feature plan --json` is useful for CI or tooling wrappers.
|
|
@@ -57,6 +59,9 @@ csrd new service --name inventory --output ./local-dev --database postgres --wor
|
|
|
57
59
|
# 1) Verify service compatibility
|
|
58
60
|
csrd doctor --service ./local-dev/inventory
|
|
59
61
|
|
|
62
|
+
# 1b) Audit for weak/insecure defaults
|
|
63
|
+
csrd audit --service ./local-dev/inventory
|
|
64
|
+
|
|
60
65
|
# 2) Inspect available bundled features
|
|
61
66
|
csrd feature list
|
|
62
67
|
|
|
@@ -107,6 +112,7 @@ csrd new service --help
|
|
|
107
112
|
csrd new cluster --help
|
|
108
113
|
csrd feature --help
|
|
109
114
|
csrd doctor --help
|
|
115
|
+
csrd audit --help
|
|
110
116
|
csrd feature list
|
|
111
117
|
```
|
|
112
118
|
|
|
@@ -117,6 +123,7 @@ mkdir -p /tmp/csrd-smoke
|
|
|
117
123
|
csrd new workspace --name ws --output /tmp/csrd-smoke --generate none
|
|
118
124
|
csrd new service --name demo-svc --output /tmp/csrd-smoke/ws --no-interactive
|
|
119
125
|
csrd doctor --service /tmp/csrd-smoke/ws/demo-svc
|
|
126
|
+
csrd audit --service /tmp/csrd-smoke/ws/demo-svc
|
|
120
127
|
csrd feature plan workers --service /tmp/csrd-smoke/ws/demo-svc
|
|
121
128
|
csrd feature add workers --service /tmp/csrd-smoke/ws/demo-svc
|
|
122
129
|
```
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"""csrd-utils: template and feature tooling for csrd services."""
|
|
2
2
|
|
|
3
|
+
from .audit import run_audit
|
|
3
4
|
from .augmentor import ServiceAugmentor
|
|
4
5
|
from .doctor import run_doctor
|
|
5
6
|
from .generator import generate_service
|
|
6
7
|
|
|
7
|
-
__all__ = ["ServiceAugmentor", "generate_service", "run_doctor"]
|
|
8
|
+
__all__ = ["ServiceAugmentor", "generate_service", "run_audit", "run_doctor"]
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
"""Command-line interface for csrd-utils."""
|
|
2
2
|
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
3
|
import argparse
|
|
6
4
|
import json
|
|
7
5
|
from contextlib import suppress
|
|
@@ -12,6 +10,7 @@ from urllib.parse import urlparse
|
|
|
12
10
|
|
|
13
11
|
import yaml # type: ignore[import-untyped]
|
|
14
12
|
|
|
13
|
+
from .audit import run_audit
|
|
15
14
|
from .augmentor import ServiceAugmentor
|
|
16
15
|
from .cluster import (
|
|
17
16
|
PRESETS,
|
|
@@ -423,10 +422,9 @@ def _service_config_to_cluster_service(config: GenerateServiceArgs) -> ServiceSp
|
|
|
423
422
|
has_database = config["has_database"]
|
|
424
423
|
has_workers = config["has_workers"]
|
|
425
424
|
has_delegates = config["has_delegates"]
|
|
426
|
-
|
|
427
|
-
if selected_roles > 1:
|
|
425
|
+
if has_workers and (has_database or has_delegates):
|
|
428
426
|
raise ValueError(
|
|
429
|
-
"Workspace cluster mapping
|
|
427
|
+
"Workspace cluster mapping does not support combining workers with database or delegates in one service."
|
|
430
428
|
)
|
|
431
429
|
|
|
432
430
|
service: ServiceSpec = {
|
|
@@ -439,6 +437,10 @@ def _service_config_to_cluster_service(config: GenerateServiceArgs) -> ServiceSp
|
|
|
439
437
|
if has_database:
|
|
440
438
|
service["type"] = "db"
|
|
441
439
|
service["database"] = config["database"]
|
|
440
|
+
if has_delegates:
|
|
441
|
+
targets = _delegate_targets_from_upstreams(config["upstreams"])
|
|
442
|
+
service["delegates"] = targets
|
|
443
|
+
service["requires"] = list(targets)
|
|
442
444
|
elif has_workers:
|
|
443
445
|
service["type"] = "worker"
|
|
444
446
|
service["broker"] = config["broker"]
|
|
@@ -604,6 +606,15 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
604
606
|
doctor.add_argument("--service", type=Path, default=Path.cwd(), help="Service root path")
|
|
605
607
|
doctor.add_argument("--json", action="store_true", help="Print doctor report as JSON")
|
|
606
608
|
|
|
609
|
+
audit = sub.add_parser("audit", help="Audit weak or insecure service defaults")
|
|
610
|
+
audit.add_argument(
|
|
611
|
+
"--service",
|
|
612
|
+
type=Path,
|
|
613
|
+
default=None,
|
|
614
|
+
help="Optional single-service path; if omitted and cwd is workspace, all workspace services are audited",
|
|
615
|
+
)
|
|
616
|
+
audit.add_argument("--json", action="store_true", help="Print audit report as JSON")
|
|
617
|
+
|
|
607
618
|
new = sub.add_parser("new", help="Generate new assets")
|
|
608
619
|
new_sub = new.add_subparsers(dest="new_command", required=True)
|
|
609
620
|
service = new_sub.add_parser("service", help="Generate a service from bundled template")
|
|
@@ -706,6 +717,90 @@ def _run_doctor(service: Path, as_json: bool) -> int:
|
|
|
706
717
|
return 0 if report.ok else 1
|
|
707
718
|
|
|
708
719
|
|
|
720
|
+
def _run_audit(service: Path | None, as_json: bool) -> int:
|
|
721
|
+
target = service.resolve() if service is not None else Path.cwd().resolve()
|
|
722
|
+
|
|
723
|
+
if _is_workspace(target):
|
|
724
|
+
service_roots = _workspace_service_roots(target)
|
|
725
|
+
service_reports: list[dict[str, object]] = []
|
|
726
|
+
has_high = False
|
|
727
|
+
for service_root in service_roots:
|
|
728
|
+
report = run_audit(service_root)
|
|
729
|
+
has_high = has_high or not report.ok
|
|
730
|
+
service_reports.append(
|
|
731
|
+
{
|
|
732
|
+
"service": service_root.name,
|
|
733
|
+
"path": str(service_root),
|
|
734
|
+
**report.to_payload(),
|
|
735
|
+
}
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
payload = {
|
|
739
|
+
"ok": not has_high,
|
|
740
|
+
"mode": "workspace",
|
|
741
|
+
"workspace": str(target),
|
|
742
|
+
"services": service_reports,
|
|
743
|
+
"summary": {
|
|
744
|
+
"services_scanned": len(service_reports),
|
|
745
|
+
"services_with_high": sum(
|
|
746
|
+
1
|
|
747
|
+
for service_report in service_reports
|
|
748
|
+
if isinstance(service_report.get("ok"), bool) and not service_report["ok"]
|
|
749
|
+
),
|
|
750
|
+
},
|
|
751
|
+
}
|
|
752
|
+
else:
|
|
753
|
+
report = run_audit(target)
|
|
754
|
+
payload = {
|
|
755
|
+
"mode": "service",
|
|
756
|
+
"service": str(target),
|
|
757
|
+
**report.to_payload(),
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
if as_json:
|
|
761
|
+
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
762
|
+
else:
|
|
763
|
+
if payload["ok"]:
|
|
764
|
+
print("Audit passed (no high-severity findings).")
|
|
765
|
+
else:
|
|
766
|
+
print("Audit failed (high-severity findings detected).")
|
|
767
|
+
|
|
768
|
+
if payload.get("mode") == "workspace":
|
|
769
|
+
print(f"Workspace: {payload['workspace']}")
|
|
770
|
+
services = payload.get("services", [])
|
|
771
|
+
if isinstance(services, list):
|
|
772
|
+
for service_report in services:
|
|
773
|
+
if not isinstance(service_report, dict):
|
|
774
|
+
continue
|
|
775
|
+
state = "OK" if service_report.get("ok") else "FAIL"
|
|
776
|
+
print(f"- {service_report.get('service', '<unknown>')}: {state}")
|
|
777
|
+
findings = service_report.get("findings", [])
|
|
778
|
+
if isinstance(findings, list):
|
|
779
|
+
for finding in findings:
|
|
780
|
+
if not isinstance(finding, dict):
|
|
781
|
+
continue
|
|
782
|
+
print(
|
|
783
|
+
f" {str(finding.get('severity', '')).upper()}: "
|
|
784
|
+
f"[{finding.get('rule')}] {finding.get('file')} :: {finding.get('message')}"
|
|
785
|
+
)
|
|
786
|
+
print(f" evidence: {finding.get('evidence')}")
|
|
787
|
+
print(f" fix: {finding.get('remediation')}")
|
|
788
|
+
else:
|
|
789
|
+
findings = payload.get("findings", [])
|
|
790
|
+
if isinstance(findings, list):
|
|
791
|
+
for finding in findings:
|
|
792
|
+
if not isinstance(finding, dict):
|
|
793
|
+
continue
|
|
794
|
+
print(
|
|
795
|
+
f"{str(finding.get('severity', '')).upper()}: "
|
|
796
|
+
f"[{finding.get('rule')}] {finding.get('file')} :: {finding.get('message')}"
|
|
797
|
+
)
|
|
798
|
+
print(f" evidence: {finding.get('evidence')}")
|
|
799
|
+
print(f" fix: {finding.get('remediation')}")
|
|
800
|
+
|
|
801
|
+
return 0 if bool(payload["ok"]) else 1
|
|
802
|
+
|
|
803
|
+
|
|
709
804
|
def main() -> int:
|
|
710
805
|
parser = _build_parser()
|
|
711
806
|
args = parser.parse_args()
|
|
@@ -788,6 +883,9 @@ def main() -> int:
|
|
|
788
883
|
if args.command == "doctor":
|
|
789
884
|
return _run_doctor(args.service, args.json)
|
|
790
885
|
|
|
886
|
+
if args.command == "audit":
|
|
887
|
+
return _run_audit(args.service, args.json)
|
|
888
|
+
|
|
791
889
|
if args.command != "feature" or args.feature_command not in {"add", "plan", "list"}:
|
|
792
890
|
parser.print_help()
|
|
793
891
|
return 0
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Security hardening audit checks for generated services."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import asdict, dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class AuditFinding:
|
|
10
|
+
"""A single hardening finding."""
|
|
11
|
+
|
|
12
|
+
rule: str
|
|
13
|
+
severity: str
|
|
14
|
+
message: str
|
|
15
|
+
file: str
|
|
16
|
+
evidence: str
|
|
17
|
+
remediation: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class AuditReport:
|
|
22
|
+
"""Structured audit output."""
|
|
23
|
+
|
|
24
|
+
ok: bool
|
|
25
|
+
findings: list[AuditFinding] = field(default_factory=list)
|
|
26
|
+
|
|
27
|
+
def to_payload(self) -> dict[str, object]:
|
|
28
|
+
return {
|
|
29
|
+
"ok": self.ok,
|
|
30
|
+
"findings": [asdict(finding) for finding in self.findings],
|
|
31
|
+
"summary": {
|
|
32
|
+
"high": sum(1 for finding in self.findings if finding.severity == "high"),
|
|
33
|
+
"medium": sum(1 for finding in self.findings if finding.severity == "medium"),
|
|
34
|
+
"low": sum(1 for finding in self.findings if finding.severity == "low"),
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_WEAK_PASSWORDS = r"(?:change_me|change_me_root|app|sandbox|password|root)"
|
|
40
|
+
_WEAK_IDENTIFIERS = r"(?:service_user|service_db|app|sandbox)"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _scan_file(path: Path, root: Path) -> list[AuditFinding]:
|
|
44
|
+
findings: list[AuditFinding] = []
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
text = path.read_text(encoding="utf-8")
|
|
48
|
+
except OSError:
|
|
49
|
+
return findings
|
|
50
|
+
|
|
51
|
+
rel = str(path.relative_to(root))
|
|
52
|
+
|
|
53
|
+
password_hits = re.findall(
|
|
54
|
+
rf"(?:DB_PASSWORD|POSTGRES_PASSWORD|MYSQL_PASSWORD|MARIADB_PASSWORD)\s*[:=]\s*['\"]?({_WEAK_PASSWORDS})['\"]?",
|
|
55
|
+
text,
|
|
56
|
+
flags=re.IGNORECASE,
|
|
57
|
+
)
|
|
58
|
+
if password_hits:
|
|
59
|
+
findings.append(
|
|
60
|
+
AuditFinding(
|
|
61
|
+
rule="weak-db-password-default",
|
|
62
|
+
severity="high",
|
|
63
|
+
message="Weak default database password is configured.",
|
|
64
|
+
file=rel,
|
|
65
|
+
evidence=password_hits[0],
|
|
66
|
+
remediation="Set a strong DB password via environment configuration.",
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
root_hits = re.findall(
|
|
71
|
+
rf"(?:MYSQL_ROOT_PASSWORD|MARIADB_ROOT_PASSWORD)\s*[:=]\s*['\"]?({_WEAK_PASSWORDS})['\"]?",
|
|
72
|
+
text,
|
|
73
|
+
flags=re.IGNORECASE,
|
|
74
|
+
)
|
|
75
|
+
if root_hits:
|
|
76
|
+
findings.append(
|
|
77
|
+
AuditFinding(
|
|
78
|
+
rule="weak-root-db-password-default",
|
|
79
|
+
severity="high",
|
|
80
|
+
message="Weak root database password is configured.",
|
|
81
|
+
file=rel,
|
|
82
|
+
evidence=root_hits[0],
|
|
83
|
+
remediation="Set a strong root password and avoid shared defaults.",
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
identifier_hits = re.findall(
|
|
88
|
+
rf"(?:DB_USER|POSTGRES_USER|MYSQL_USER|MARIADB_USER|DB_NAME|POSTGRES_DB|MYSQL_DATABASE|MARIADB_DATABASE)\s*[:=]\s*['\"]?({_WEAK_IDENTIFIERS})['\"]?",
|
|
89
|
+
text,
|
|
90
|
+
flags=re.IGNORECASE,
|
|
91
|
+
)
|
|
92
|
+
if identifier_hits:
|
|
93
|
+
findings.append(
|
|
94
|
+
AuditFinding(
|
|
95
|
+
rule="weak-db-identifier-default",
|
|
96
|
+
severity="medium",
|
|
97
|
+
message="Weak default DB user/database identifier is configured.",
|
|
98
|
+
file=rel,
|
|
99
|
+
evidence=identifier_hits[0],
|
|
100
|
+
remediation="Set DB_USER and DB_NAME to service-specific values per environment.",
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
if "include_actuator_endpoints=True" in text or "INCLUDE_ACTUATOR_ENDPOINTS=true" in text:
|
|
105
|
+
findings.append(
|
|
106
|
+
AuditFinding(
|
|
107
|
+
rule="actuator-endpoints-enabled",
|
|
108
|
+
severity="medium",
|
|
109
|
+
message="Actuator endpoints are enabled by default.",
|
|
110
|
+
file=rel,
|
|
111
|
+
evidence="include_actuator_endpoints=True",
|
|
112
|
+
remediation="Set INCLUDE_ACTUATOR_ENDPOINTS=false unless required on trusted networks.",
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
if 'allow_origins=["*"]' in text or "allow_origins=['*']" in text:
|
|
117
|
+
findings.append(
|
|
118
|
+
AuditFinding(
|
|
119
|
+
rule="cors-wildcard-origin",
|
|
120
|
+
severity="medium",
|
|
121
|
+
message="CORS wildcard origin is configured.",
|
|
122
|
+
file=rel,
|
|
123
|
+
evidence='allow_origins=["*"]',
|
|
124
|
+
remediation="Restrict CORS to known trusted origins.",
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
if "guest:guest" in text:
|
|
129
|
+
findings.append(
|
|
130
|
+
AuditFinding(
|
|
131
|
+
rule="rabbitmq-guest-default",
|
|
132
|
+
severity="medium",
|
|
133
|
+
message="RabbitMQ guest credentials are configured.",
|
|
134
|
+
file=rel,
|
|
135
|
+
evidence="guest:guest",
|
|
136
|
+
remediation="Set RABBITMQ_USER and RABBITMQ_PASSWORD explicitly.",
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
if "--reload" in text:
|
|
141
|
+
findings.append(
|
|
142
|
+
AuditFinding(
|
|
143
|
+
rule="dev-reload-usage",
|
|
144
|
+
severity="low",
|
|
145
|
+
message="Development reload mode appears in runnable commands.",
|
|
146
|
+
file=rel,
|
|
147
|
+
evidence="--reload",
|
|
148
|
+
remediation="Avoid reload mode in production run commands.",
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
return findings
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def run_audit(service_root: Path) -> AuditReport:
|
|
156
|
+
"""Scan a service path for insecure defaults and weak auto-configuration."""
|
|
157
|
+
root = service_root.resolve()
|
|
158
|
+
findings: list[AuditFinding] = []
|
|
159
|
+
|
|
160
|
+
if not root.exists() or not root.is_dir():
|
|
161
|
+
findings.append(
|
|
162
|
+
AuditFinding(
|
|
163
|
+
rule="invalid-service-path",
|
|
164
|
+
severity="high",
|
|
165
|
+
message="Service path does not exist or is not a directory.",
|
|
166
|
+
file=str(service_root),
|
|
167
|
+
evidence=str(service_root),
|
|
168
|
+
remediation="Provide a valid service root path.",
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
return AuditReport(ok=False, findings=findings)
|
|
172
|
+
|
|
173
|
+
candidates: list[Path] = []
|
|
174
|
+
include_names = {"docker-compose.yml", ".env", ".env.example", "README.md", "settings.py"}
|
|
175
|
+
|
|
176
|
+
for path in root.rglob("*"):
|
|
177
|
+
if not path.is_file():
|
|
178
|
+
continue
|
|
179
|
+
if path.name in include_names or path.suffix in {".py", ".yml", ".yaml", ".env", ".md"}:
|
|
180
|
+
candidates.append(path)
|
|
181
|
+
|
|
182
|
+
seen: set[tuple[str, str, str]] = set()
|
|
183
|
+
for path in candidates:
|
|
184
|
+
for finding in _scan_file(path, root):
|
|
185
|
+
key = (finding.rule, finding.file, finding.evidence)
|
|
186
|
+
if key in seen:
|
|
187
|
+
continue
|
|
188
|
+
seen.add(key)
|
|
189
|
+
findings.append(finding)
|
|
190
|
+
|
|
191
|
+
has_high = any(finding.severity == "high" for finding in findings)
|
|
192
|
+
return AuditReport(ok=not has_high, findings=findings)
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
"""Cluster scaffolding helpers for csrd-utils."""
|
|
2
2
|
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
3
|
from collections.abc import Iterable
|
|
6
4
|
from pathlib import Path
|
|
7
5
|
from typing import Any, TypedDict, cast
|
|
@@ -157,6 +155,12 @@ def normalize_cluster_spec(spec: ClusterSpec) -> ClusterSpec:
|
|
|
157
155
|
delegates = _split_csv(delegates)
|
|
158
156
|
normalized_service["delegates"] = list(delegates)
|
|
159
157
|
|
|
158
|
+
if service_type == "db" and "delegates" in service:
|
|
159
|
+
delegates = service.get("delegates", [])
|
|
160
|
+
if isinstance(delegates, str):
|
|
161
|
+
delegates = _split_csv(delegates)
|
|
162
|
+
normalized_service["delegates"] = list(delegates)
|
|
163
|
+
|
|
160
164
|
if service_type == "worker":
|
|
161
165
|
broker = str(service.get("broker", "")).strip() or "redis"
|
|
162
166
|
if broker not in {"redis", "rabbitmq"}:
|
|
@@ -240,19 +244,19 @@ def _service_environment(service: ServiceSpec, services: list[ServiceSpec]) -> d
|
|
|
240
244
|
if service["type"] == "db":
|
|
241
245
|
db = service.get("database")
|
|
242
246
|
if db == "sqlite":
|
|
243
|
-
env["DB_PATH"] = "./
|
|
247
|
+
env["DB_PATH"] = "./service.db"
|
|
244
248
|
elif db == "maria":
|
|
245
249
|
env["DB_HOST"] = "mariadb"
|
|
246
250
|
env["DB_PORT"] = "3306"
|
|
247
|
-
env["DB_USER"] = "
|
|
248
|
-
env["DB_PASSWORD"] = "
|
|
249
|
-
env["DB_NAME"] = "
|
|
251
|
+
env["DB_USER"] = "${DB_USER:-service_user}"
|
|
252
|
+
env["DB_PASSWORD"] = "${DB_PASSWORD:-change_me}"
|
|
253
|
+
env["DB_NAME"] = "${DB_NAME:-service_db}"
|
|
250
254
|
elif db == "postgres":
|
|
251
255
|
env["DB_HOST"] = "postgres"
|
|
252
256
|
env["DB_PORT"] = "5432"
|
|
253
|
-
env["DB_USER"] = "
|
|
254
|
-
env["DB_PASSWORD"] = "
|
|
255
|
-
env["DB_NAME"] = "
|
|
257
|
+
env["DB_USER"] = "${DB_USER:-service_user}"
|
|
258
|
+
env["DB_PASSWORD"] = "${DB_PASSWORD:-change_me}"
|
|
259
|
+
env["DB_NAME"] = "${DB_NAME:-service_db}"
|
|
256
260
|
|
|
257
261
|
if service["type"] == "worker":
|
|
258
262
|
broker = service.get("broker", "redis")
|
|
@@ -260,7 +264,9 @@ def _service_environment(service: ServiceSpec, services: list[ServiceSpec]) -> d
|
|
|
260
264
|
env["CELERY_BROKER_URL"] = "redis://redis:6379/0"
|
|
261
265
|
env["CELERY_RESULT_BACKEND"] = "redis://redis:6379/1"
|
|
262
266
|
else:
|
|
263
|
-
env["CELERY_BROKER_URL"] =
|
|
267
|
+
env["CELERY_BROKER_URL"] = (
|
|
268
|
+
"amqp://${RABBITMQ_USER:-service_rabbit}:${RABBITMQ_PASSWORD:-change_me}@rabbitmq:5672//"
|
|
269
|
+
)
|
|
264
270
|
env["CELERY_RESULT_BACKEND"] = "rpc://"
|
|
265
271
|
|
|
266
272
|
for target in service.get("delegates", []):
|
|
@@ -323,13 +329,13 @@ def render_compose(spec: ClusterSpec) -> dict[str, Any]:
|
|
|
323
329
|
compose["services"]["postgres"] = {
|
|
324
330
|
"image": "postgres:16",
|
|
325
331
|
"environment": {
|
|
326
|
-
"POSTGRES_DB": "
|
|
327
|
-
"POSTGRES_USER": "
|
|
328
|
-
"POSTGRES_PASSWORD": "
|
|
332
|
+
"POSTGRES_DB": "${DB_NAME:-service_db}",
|
|
333
|
+
"POSTGRES_USER": "${DB_USER:-service_user}",
|
|
334
|
+
"POSTGRES_PASSWORD": "${DB_PASSWORD:-change_me}",
|
|
329
335
|
},
|
|
330
336
|
"ports": ["5432:5432"],
|
|
331
337
|
"healthcheck": {
|
|
332
|
-
"test": ["CMD-SHELL", "pg_isready -U
|
|
338
|
+
"test": ["CMD-SHELL", "pg_isready -U ${DB_USER:-service_user}"],
|
|
333
339
|
"interval": "10s",
|
|
334
340
|
"timeout": "5s",
|
|
335
341
|
"retries": 5,
|
|
@@ -340,10 +346,10 @@ def render_compose(spec: ClusterSpec) -> dict[str, Any]:
|
|
|
340
346
|
compose["services"]["mariadb"] = {
|
|
341
347
|
"image": "mariadb:11",
|
|
342
348
|
"environment": {
|
|
343
|
-
"MYSQL_ROOT_PASSWORD": "
|
|
344
|
-
"MYSQL_DATABASE": "
|
|
345
|
-
"MYSQL_USER": "
|
|
346
|
-
"MYSQL_PASSWORD": "
|
|
349
|
+
"MYSQL_ROOT_PASSWORD": "${MYSQL_ROOT_PASSWORD:-change_me_root}",
|
|
350
|
+
"MYSQL_DATABASE": "${DB_NAME:-service_db}",
|
|
351
|
+
"MYSQL_USER": "${DB_USER:-service_user}",
|
|
352
|
+
"MYSQL_PASSWORD": "${DB_PASSWORD:-change_me}",
|
|
347
353
|
},
|
|
348
354
|
"ports": ["3306:3306"],
|
|
349
355
|
"healthcheck": {
|
|
@@ -369,6 +375,10 @@ def render_compose(spec: ClusterSpec) -> dict[str, Any]:
|
|
|
369
375
|
if "rabbitmq" in infra:
|
|
370
376
|
compose["services"]["rabbitmq"] = {
|
|
371
377
|
"image": "rabbitmq:3-management",
|
|
378
|
+
"environment": {
|
|
379
|
+
"RABBITMQ_DEFAULT_USER": "${RABBITMQ_USER:-service_rabbit}",
|
|
380
|
+
"RABBITMQ_DEFAULT_PASS": "${RABBITMQ_PASSWORD:-change_me}",
|
|
381
|
+
},
|
|
372
382
|
"ports": ["5672:5672", "15672:15672"],
|
|
373
383
|
"healthcheck": {
|
|
374
384
|
"test": ["CMD", "rabbitmq-diagnostics", "-q", "ping"],
|
|
@@ -385,6 +395,12 @@ def _build_env(spec: ClusterSpec) -> str:
|
|
|
385
395
|
lines = [
|
|
386
396
|
"# Cluster-level environment",
|
|
387
397
|
f"CLUSTER_NAME={spec['cluster']['name']}",
|
|
398
|
+
"DB_USER=service_user",
|
|
399
|
+
"DB_PASSWORD=change_me",
|
|
400
|
+
"DB_NAME=service_db",
|
|
401
|
+
"MYSQL_ROOT_PASSWORD=change_me_root",
|
|
402
|
+
"RABBITMQ_USER=service_rabbit",
|
|
403
|
+
"RABBITMQ_PASSWORD=change_me",
|
|
388
404
|
"",
|
|
389
405
|
"# Service URLs",
|
|
390
406
|
]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# [INSERT: fixtures]
|
|
2
|
+
@pytest.fixture # noqa: F821
|
|
3
|
+
def cache_client(monkeypatch):
|
|
4
|
+
"""Fake cache client that uses an in-memory dict."""
|
|
5
|
+
store: dict = {}
|
|
6
|
+
|
|
7
|
+
class FakeRedis:
|
|
8
|
+
def get(self, key):
|
|
9
|
+
return store.get(key)
|
|
10
|
+
|
|
11
|
+
def set(self, key, value, ex=None):
|
|
12
|
+
store[key] = value
|
|
13
|
+
|
|
14
|
+
def delete(self, key):
|
|
15
|
+
store.pop(key, None)
|
|
16
|
+
|
|
17
|
+
def ping(self):
|
|
18
|
+
return True
|
|
19
|
+
|
|
20
|
+
client = FakeRedis()
|
|
21
|
+
monkeypatch.setattr("src.app.cache.get_cache_client", lambda: client)
|
|
22
|
+
return client
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
name: caching
|
|
2
|
+
description: Adds Redis-backed caching with a shared cache client and settings
|
|
3
|
+
|
|
4
|
+
requires:
|
|
5
|
+
dependencies:
|
|
6
|
+
- redis
|
|
7
|
+
external_services:
|
|
8
|
+
- redis
|
|
9
|
+
settings:
|
|
10
|
+
- CACHE_HOST
|
|
11
|
+
- CACHE_PORT
|
|
12
|
+
- CACHE_TTL
|
|
13
|
+
|
|
14
|
+
modifies:
|
|
15
|
+
- requirements.txt
|
|
16
|
+
- docker-compose.yml
|
|
17
|
+
- src/app/settings.py
|
|
18
|
+
- tests/conftest.py
|
|
19
|
+
|
|
20
|
+
creates:
|
|
21
|
+
- src/app/cache.py
|
|
22
|
+
- tests/test_cache.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
redis>=5.0.0
|