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.
Files changed (85) hide show
  1. {csrd_utils-0.3.14 → csrd_utils-0.3.16}/PKG-INFO +8 -1
  2. {csrd_utils-0.3.14 → csrd_utils-0.3.16}/README.md +7 -0
  3. {csrd_utils-0.3.14 → csrd_utils-0.3.16}/pyproject.toml +1 -1
  4. {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/__init__.py +2 -1
  5. {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/__main__.py +103 -5
  6. csrd_utils-0.3.16/src/csrd_utils/audit.py +192 -0
  7. {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/augmentor.py +0 -2
  8. {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/cluster.py +34 -18
  9. {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/doctor.py +0 -2
  10. csrd_utils-0.3.16/src/csrd_utils/features/caching/conftest.fragment.py +22 -0
  11. csrd_utils-0.3.16/src/csrd_utils/features/caching/docker-compose.fragment.yaml +10 -0
  12. csrd_utils-0.3.16/src/csrd_utils/features/caching/manifest.yaml +22 -0
  13. csrd_utils-0.3.16/src/csrd_utils/features/caching/requirements.fragment +1 -0
  14. csrd_utils-0.3.16/src/csrd_utils/features/caching/settings.fragment.py +4 -0
  15. csrd_utils-0.3.16/src/csrd_utils/features/caching/src/app/cache.py +36 -0
  16. csrd_utils-0.3.16/src/csrd_utils/features/caching/tests/test_cache.py +39 -0
  17. csrd_utils-0.3.16/src/csrd_utils/features/logging/conftest.fragment.py +10 -0
  18. csrd_utils-0.3.16/src/csrd_utils/features/logging/manifest.yaml +18 -0
  19. csrd_utils-0.3.16/src/csrd_utils/features/logging/requirements.fragment +1 -0
  20. csrd_utils-0.3.16/src/csrd_utils/features/logging/settings.fragment.py +3 -0
  21. csrd_utils-0.3.16/src/csrd_utils/features/logging/src/app/middleware/logging.py +73 -0
  22. csrd_utils-0.3.16/src/csrd_utils/features/logging/tests/test_logging_middleware.py +41 -0
  23. csrd_utils-0.3.16/src/csrd_utils/features/metrics/conftest.fragment.py +7 -0
  24. csrd_utils-0.3.16/src/csrd_utils/features/metrics/docker-compose.fragment.yaml +12 -0
  25. csrd_utils-0.3.16/src/csrd_utils/features/metrics/manifest.yaml +21 -0
  26. csrd_utils-0.3.16/src/csrd_utils/features/metrics/requirements.fragment +1 -0
  27. csrd_utils-0.3.16/src/csrd_utils/features/metrics/settings.fragment.py +3 -0
  28. csrd_utils-0.3.16/src/csrd_utils/features/metrics/src/app/middleware/metrics.py +72 -0
  29. csrd_utils-0.3.16/src/csrd_utils/features/metrics/tests/test_metrics_middleware.py +79 -0
  30. csrd_utils-0.3.16/src/csrd_utils/features/tracing/conftest.fragment.py +9 -0
  31. csrd_utils-0.3.16/src/csrd_utils/features/tracing/manifest.yaml +21 -0
  32. csrd_utils-0.3.16/src/csrd_utils/features/tracing/requirements.fragment +3 -0
  33. csrd_utils-0.3.16/src/csrd_utils/features/tracing/settings.fragment.py +4 -0
  34. csrd_utils-0.3.16/src/csrd_utils/features/tracing/src/app/middleware/tracing.py +61 -0
  35. csrd_utils-0.3.16/src/csrd_utils/features/tracing/tests/test_tracing_middleware.py +51 -0
  36. {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/features/workers/src/app/workers/tasks.py +0 -1
  37. {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/features/workers/tests/test_workers.py +0 -1
  38. {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/fragments.py +0 -2
  39. {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/generator.py +0 -2
  40. {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/resources.py +0 -2
  41. {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/templates/cookiecutter-service/README.md +1 -1
  42. {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/.env.example +14 -8
  43. {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/README.md +7 -0
  44. {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/docker-compose.yml +18 -13
  45. {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
  46. {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
  47. {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
  48. csrd_utils-0.3.16/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/views/unversioned/items_view.py +217 -0
  49. {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/conftest.py +7 -7
  50. {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
  51. csrd_utils-0.3.14/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/views/unversioned/items_view.py +0 -70
  52. {csrd_utils-0.3.14 → csrd_utils-0.3.16}/.gitignore +0 -0
  53. {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/features/workers/conftest.fragment.py +0 -0
  54. {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/features/workers/docker-compose.fragment.yaml +0 -0
  55. {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/features/workers/manifest.yaml +0 -0
  56. {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/features/workers/requirements.fragment +0 -0
  57. {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/features/workers/settings.fragment.py +0 -0
  58. {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/features/workers/src/app/workers/__init__.py +0 -0
  59. {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/features/workers/src/app/workers/celery_app.py +0 -0
  60. {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/templates/cookiecutter-service/cookiecutter.json +0 -0
  61. {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/templates/cookiecutter-service/hooks/post_gen_project.py +0 -0
  62. {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/.dockerignore +0 -0
  63. {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/Dockerfile +0 -0
  64. {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/requirements.txt +0 -0
  65. {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
  66. {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
  67. {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
  68. {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
  69. {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
  70. {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
  71. {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
  72. {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
  73. {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
  74. {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
  75. {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
  76. {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
  77. {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
  78. {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
  79. {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
  80. {csrd_utils-0.3.14 → csrd_utils-0.3.16}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/__init__.py +0 -0
  81. {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
  82. {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
  83. {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
  84. {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
  85. {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.14
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,6 +1,6 @@
1
1
  [project]
2
2
  name = "csrd-utils"
3
- version = "0.3.14"
3
+ version = "0.3.16"
4
4
  description = "CLI utilities for csrd service generation and feature augmentation"
5
5
  license = { text = "MIT" }
6
6
  requires-python = ">=3.12"
@@ -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
- selected_roles = sum(1 for enabled in (has_database, has_workers, has_delegates) if enabled)
427
- if selected_roles > 1:
425
+ if has_workers and (has_database or has_delegates):
428
426
  raise ValueError(
429
- "Workspace cluster mapping supports one core role per generated service (database or workers or delegates)."
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
  """Core augmentor logic for adding features to existing services."""
2
2
 
3
- from __future__ import annotations
4
-
5
3
  import shutil
6
4
  import tempfile
7
5
  from pathlib import Path
@@ -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"] = "./sandbox.db"
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"] = "sandbox"
248
- env["DB_PASSWORD"] = "sandbox"
249
- env["DB_NAME"] = "sandbox"
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"] = "sandbox"
254
- env["DB_PASSWORD"] = "sandbox"
255
- env["DB_NAME"] = "sandbox"
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"] = "amqp://guest:guest@rabbitmq:5672//"
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": "sandbox",
327
- "POSTGRES_USER": "sandbox",
328
- "POSTGRES_PASSWORD": "sandbox",
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 sandbox"],
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": "root",
344
- "MYSQL_DATABASE": "sandbox",
345
- "MYSQL_USER": "sandbox",
346
- "MYSQL_PASSWORD": "sandbox",
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
  ]
@@ -1,7 +1,5 @@
1
1
  """Preflight checks for services targeted by csrd-utils feature augmentation."""
2
2
 
3
- from __future__ import annotations
4
-
5
3
  from dataclasses import dataclass, field
6
4
  from pathlib import Path
7
5
 
@@ -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,10 @@
1
+ services:
2
+ redis:
3
+ image: redis:7-alpine
4
+ ports:
5
+ - "6379:6379"
6
+ healthcheck:
7
+ test: ["CMD", "redis-cli", "ping"]
8
+ interval: 5s
9
+ timeout: 3s
10
+ retries: 5
@@ -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,4 @@
1
+ # [INSERT: fields]
2
+ cache_host: str = "localhost"
3
+ cache_port: int = 6379
4
+ cache_ttl: int = 300 # seconds