svc-infra 0.1.589__py3-none-any.whl → 0.1.706__py3-none-any.whl
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.
Potentially problematic release.
This version of svc-infra might be problematic. Click here for more details.
- svc_infra/__init__.py +58 -2
- svc_infra/apf_payments/README.md +732 -0
- svc_infra/apf_payments/models.py +133 -42
- svc_infra/apf_payments/provider/__init__.py +4 -0
- svc_infra/apf_payments/provider/aiydan.py +871 -0
- svc_infra/apf_payments/provider/base.py +30 -9
- svc_infra/apf_payments/provider/stripe.py +156 -62
- svc_infra/apf_payments/schemas.py +19 -10
- svc_infra/apf_payments/service.py +211 -68
- svc_infra/apf_payments/settings.py +27 -3
- svc_infra/api/__init__.py +61 -0
- svc_infra/api/fastapi/__init__.py +15 -0
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +245 -0
- svc_infra/api/fastapi/apf_payments/router.py +145 -46
- svc_infra/api/fastapi/apf_payments/setup.py +26 -8
- svc_infra/api/fastapi/auth/__init__.py +65 -0
- svc_infra/api/fastapi/auth/_cookies.py +6 -2
- svc_infra/api/fastapi/auth/add.py +27 -14
- svc_infra/api/fastapi/auth/gaurd.py +104 -13
- svc_infra/api/fastapi/auth/mfa/models.py +3 -1
- svc_infra/api/fastapi/auth/mfa/pre_auth.py +10 -6
- svc_infra/api/fastapi/auth/mfa/router.py +15 -8
- svc_infra/api/fastapi/auth/mfa/security.py +1 -2
- svc_infra/api/fastapi/auth/mfa/utils.py +2 -1
- svc_infra/api/fastapi/auth/mfa/verify.py +9 -2
- svc_infra/api/fastapi/auth/policy.py +0 -1
- svc_infra/api/fastapi/auth/providers.py +3 -1
- svc_infra/api/fastapi/auth/routers/apikey_router.py +6 -6
- svc_infra/api/fastapi/auth/routers/oauth_router.py +214 -75
- svc_infra/api/fastapi/auth/routers/session_router.py +67 -0
- svc_infra/api/fastapi/auth/security.py +31 -10
- svc_infra/api/fastapi/auth/sender.py +8 -1
- svc_infra/api/fastapi/auth/settings.py +2 -0
- svc_infra/api/fastapi/auth/state.py +3 -1
- svc_infra/api/fastapi/auth/ws_security.py +275 -0
- svc_infra/api/fastapi/billing/router.py +73 -0
- svc_infra/api/fastapi/billing/setup.py +19 -0
- svc_infra/api/fastapi/cache/add.py +9 -5
- svc_infra/api/fastapi/db/__init__.py +5 -1
- svc_infra/api/fastapi/db/http.py +3 -1
- svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
- svc_infra/api/fastapi/db/nosql/mongo/add.py +47 -32
- svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +30 -11
- svc_infra/api/fastapi/db/sql/__init__.py +5 -1
- svc_infra/api/fastapi/db/sql/add.py +71 -26
- svc_infra/api/fastapi/db/sql/crud_router.py +210 -22
- svc_infra/api/fastapi/db/sql/health.py +3 -1
- svc_infra/api/fastapi/db/sql/session.py +18 -0
- svc_infra/api/fastapi/db/sql/users.py +29 -5
- svc_infra/api/fastapi/dependencies/ratelimit.py +130 -0
- svc_infra/api/fastapi/docs/add.py +173 -0
- svc_infra/api/fastapi/docs/landing.py +4 -2
- svc_infra/api/fastapi/docs/scoped.py +62 -15
- svc_infra/api/fastapi/dual/__init__.py +12 -2
- svc_infra/api/fastapi/dual/dualize.py +1 -1
- svc_infra/api/fastapi/dual/protected.py +126 -4
- svc_infra/api/fastapi/dual/public.py +25 -0
- svc_infra/api/fastapi/dual/router.py +40 -13
- svc_infra/api/fastapi/dx.py +33 -2
- svc_infra/api/fastapi/ease.py +10 -2
- svc_infra/api/fastapi/http/concurrency.py +2 -1
- svc_infra/api/fastapi/http/conditional.py +3 -1
- svc_infra/api/fastapi/middleware/debug.py +4 -1
- svc_infra/api/fastapi/middleware/errors/catchall.py +6 -2
- svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -1
- svc_infra/api/fastapi/middleware/errors/handlers.py +54 -8
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +104 -0
- svc_infra/api/fastapi/middleware/idempotency.py +197 -70
- svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
- svc_infra/api/fastapi/middleware/optimistic_lock.py +42 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +143 -31
- svc_infra/api/fastapi/middleware/ratelimit_store.py +111 -0
- svc_infra/api/fastapi/middleware/request_id.py +27 -11
- svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
- svc_infra/api/fastapi/middleware/timeout.py +177 -0
- svc_infra/api/fastapi/openapi/apply.py +5 -3
- svc_infra/api/fastapi/openapi/conventions.py +9 -2
- svc_infra/api/fastapi/openapi/mutators.py +165 -20
- svc_infra/api/fastapi/openapi/pipeline.py +1 -1
- svc_infra/api/fastapi/openapi/security.py +3 -1
- svc_infra/api/fastapi/ops/add.py +75 -0
- svc_infra/api/fastapi/pagination.py +47 -20
- svc_infra/api/fastapi/routers/__init__.py +43 -15
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +188 -56
- svc_infra/api/fastapi/tenancy/add.py +19 -0
- svc_infra/api/fastapi/tenancy/context.py +112 -0
- svc_infra/api/fastapi/versioned.py +101 -0
- svc_infra/app/README.md +5 -5
- svc_infra/app/__init__.py +3 -1
- svc_infra/app/env.py +69 -1
- svc_infra/app/logging/add.py +9 -2
- svc_infra/app/logging/formats.py +12 -5
- svc_infra/billing/__init__.py +23 -0
- svc_infra/billing/async_service.py +147 -0
- svc_infra/billing/jobs.py +241 -0
- svc_infra/billing/models.py +177 -0
- svc_infra/billing/quotas.py +103 -0
- svc_infra/billing/schemas.py +36 -0
- svc_infra/billing/service.py +123 -0
- svc_infra/bundled_docs/README.md +5 -0
- svc_infra/bundled_docs/__init__.py +1 -0
- svc_infra/bundled_docs/getting-started.md +6 -0
- svc_infra/cache/__init__.py +9 -0
- svc_infra/cache/add.py +170 -0
- svc_infra/cache/backend.py +7 -6
- svc_infra/cache/decorators.py +81 -15
- svc_infra/cache/demo.py +2 -2
- svc_infra/cache/keys.py +24 -4
- svc_infra/cache/recache.py +26 -14
- svc_infra/cache/resources.py +14 -5
- svc_infra/cache/tags.py +19 -44
- svc_infra/cache/utils.py +3 -1
- svc_infra/cli/__init__.py +52 -8
- svc_infra/cli/__main__.py +4 -0
- svc_infra/cli/cmds/__init__.py +39 -2
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +7 -4
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +7 -5
- svc_infra/cli/cmds/db/ops_cmds.py +270 -0
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +103 -18
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +88 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
- svc_infra/cli/cmds/docs/docs_cmds.py +142 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +116 -0
- svc_infra/cli/cmds/health/__init__.py +179 -0
- svc_infra/cli/cmds/health/health_cmds.py +8 -0
- svc_infra/cli/cmds/help.py +4 -0
- svc_infra/cli/cmds/jobs/__init__.py +1 -0
- svc_infra/cli/cmds/jobs/jobs_cmds.py +47 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +36 -15
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +112 -0
- svc_infra/cli/foundation/runner.py +6 -2
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +58 -0
- svc_infra/data/erasure.py +45 -0
- svc_infra/data/fixtures.py +42 -0
- svc_infra/data/retention.py +61 -0
- svc_infra/db/__init__.py +15 -0
- svc_infra/db/crud_schema.py +9 -9
- svc_infra/db/inbox.py +67 -0
- svc_infra/db/nosql/__init__.py +3 -0
- svc_infra/db/nosql/core.py +30 -9
- svc_infra/db/nosql/indexes.py +3 -1
- svc_infra/db/nosql/management.py +1 -1
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/nosql/mongo/client.py +19 -2
- svc_infra/db/nosql/mongo/settings.py +6 -2
- svc_infra/db/nosql/repository.py +35 -15
- svc_infra/db/nosql/resource.py +20 -3
- svc_infra/db/nosql/scaffold.py +9 -3
- svc_infra/db/nosql/service.py +3 -1
- svc_infra/db/nosql/types.py +6 -2
- svc_infra/db/ops.py +384 -0
- svc_infra/db/outbox.py +108 -0
- svc_infra/db/sql/apikey.py +37 -9
- svc_infra/db/sql/authref.py +9 -3
- svc_infra/db/sql/constants.py +12 -8
- svc_infra/db/sql/core.py +2 -2
- svc_infra/db/sql/management.py +11 -8
- svc_infra/db/sql/repository.py +99 -26
- svc_infra/db/sql/resource.py +5 -0
- svc_infra/db/sql/scaffold.py +6 -2
- svc_infra/db/sql/service.py +15 -5
- svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
- svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
- svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
- svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
- svc_infra/db/sql/tenant.py +88 -0
- svc_infra/db/sql/uniq_hooks.py +9 -3
- svc_infra/db/sql/utils.py +138 -51
- svc_infra/db/sql/versioning.py +14 -0
- svc_infra/deploy/__init__.py +538 -0
- svc_infra/documents/__init__.py +100 -0
- svc_infra/documents/add.py +264 -0
- svc_infra/documents/ease.py +233 -0
- svc_infra/documents/models.py +114 -0
- svc_infra/documents/storage.py +264 -0
- svc_infra/dx/add.py +65 -0
- svc_infra/dx/changelog.py +74 -0
- svc_infra/dx/checks.py +68 -0
- svc_infra/exceptions.py +141 -0
- svc_infra/health/__init__.py +864 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +105 -0
- svc_infra/jobs/builtins/outbox_processor.py +40 -0
- svc_infra/jobs/builtins/webhook_delivery.py +95 -0
- svc_infra/jobs/easy.py +33 -0
- svc_infra/jobs/loader.py +50 -0
- svc_infra/jobs/queue.py +116 -0
- svc_infra/jobs/redis_queue.py +256 -0
- svc_infra/jobs/runner.py +79 -0
- svc_infra/jobs/scheduler.py +53 -0
- svc_infra/jobs/worker.py +40 -0
- svc_infra/loaders/__init__.py +186 -0
- svc_infra/loaders/base.py +142 -0
- svc_infra/loaders/github.py +311 -0
- svc_infra/loaders/models.py +147 -0
- svc_infra/loaders/url.py +235 -0
- svc_infra/logging/__init__.py +374 -0
- svc_infra/mcp/svc_infra_mcp.py +91 -33
- svc_infra/obs/README.md +2 -0
- svc_infra/obs/add.py +65 -9
- svc_infra/obs/cloud_dash.py +2 -1
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/obs/metrics/__init__.py +52 -0
- svc_infra/obs/metrics/asgi.py +13 -7
- svc_infra/obs/metrics/http.py +9 -5
- svc_infra/obs/metrics/sqlalchemy.py +13 -9
- svc_infra/obs/metrics.py +53 -0
- svc_infra/obs/settings.py +6 -2
- svc_infra/security/add.py +217 -0
- svc_infra/security/audit.py +212 -0
- svc_infra/security/audit_service.py +74 -0
- svc_infra/security/headers.py +52 -0
- svc_infra/security/hibp.py +101 -0
- svc_infra/security/jwt_rotation.py +105 -0
- svc_infra/security/lockout.py +102 -0
- svc_infra/security/models.py +287 -0
- svc_infra/security/oauth_models.py +73 -0
- svc_infra/security/org_invites.py +130 -0
- svc_infra/security/passwords.py +79 -0
- svc_infra/security/permissions.py +171 -0
- svc_infra/security/session.py +98 -0
- svc_infra/security/signed_cookies.py +100 -0
- svc_infra/storage/__init__.py +93 -0
- svc_infra/storage/add.py +253 -0
- svc_infra/storage/backends/__init__.py +11 -0
- svc_infra/storage/backends/local.py +339 -0
- svc_infra/storage/backends/memory.py +216 -0
- svc_infra/storage/backends/s3.py +353 -0
- svc_infra/storage/base.py +239 -0
- svc_infra/storage/easy.py +185 -0
- svc_infra/storage/settings.py +195 -0
- svc_infra/testing/__init__.py +685 -0
- svc_infra/utils.py +7 -3
- svc_infra/webhooks/__init__.py +69 -0
- svc_infra/webhooks/add.py +339 -0
- svc_infra/webhooks/encryption.py +115 -0
- svc_infra/webhooks/fastapi.py +39 -0
- svc_infra/webhooks/router.py +55 -0
- svc_infra/webhooks/service.py +70 -0
- svc_infra/webhooks/signing.py +34 -0
- svc_infra/websocket/__init__.py +79 -0
- svc_infra/websocket/add.py +140 -0
- svc_infra/websocket/client.py +282 -0
- svc_infra/websocket/config.py +69 -0
- svc_infra/websocket/easy.py +76 -0
- svc_infra/websocket/exceptions.py +61 -0
- svc_infra/websocket/manager.py +344 -0
- svc_infra/websocket/models.py +49 -0
- svc_infra-0.1.706.dist-info/LICENSE +21 -0
- svc_infra-0.1.706.dist-info/METADATA +356 -0
- svc_infra-0.1.706.dist-info/RECORD +357 -0
- svc_infra-0.1.589.dist-info/METADATA +0 -79
- svc_infra-0.1.589.dist-info/RECORD +0 -234
- {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from typing import Iterable, Literal, cast
|
|
6
|
+
|
|
7
|
+
from fastapi import FastAPI
|
|
8
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
9
|
+
from starlette.middleware.sessions import SessionMiddleware
|
|
10
|
+
|
|
11
|
+
from svc_infra.app.env import require_secret
|
|
12
|
+
from svc_infra.security.headers import SECURE_DEFAULTS, SecurityHeadersMiddleware
|
|
13
|
+
|
|
14
|
+
DEFAULT_SESSION_SECRET = "dev-only-session-secret-not-for-production"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _parse_bool(value: str | None) -> bool | None:
|
|
18
|
+
if value is None:
|
|
19
|
+
return None
|
|
20
|
+
lowered = value.strip().lower()
|
|
21
|
+
if lowered in {"1", "true", "yes", "on"}:
|
|
22
|
+
return True
|
|
23
|
+
if lowered in {"0", "false", "no", "off"}:
|
|
24
|
+
return False
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _normalize_origins(value: Iterable[str] | str | None) -> list[str]:
|
|
29
|
+
if value is None:
|
|
30
|
+
return []
|
|
31
|
+
if isinstance(value, str):
|
|
32
|
+
parts = [p.strip() for p in value.split(",")]
|
|
33
|
+
else:
|
|
34
|
+
parts = [str(v).strip() for v in value]
|
|
35
|
+
return [p for p in parts if p]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _resolve_cors_origins(
|
|
39
|
+
provided: Iterable[str] | str | None,
|
|
40
|
+
env: Mapping[str, str],
|
|
41
|
+
) -> list[str]:
|
|
42
|
+
if provided is not None:
|
|
43
|
+
return _normalize_origins(provided)
|
|
44
|
+
return _normalize_origins(env.get("CORS_ALLOW_ORIGINS"))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _resolve_allow_credentials(
|
|
48
|
+
allow_credentials: bool,
|
|
49
|
+
env: Mapping[str, str],
|
|
50
|
+
) -> bool:
|
|
51
|
+
env_value = _parse_bool(env.get("CORS_ALLOW_CREDENTIALS"))
|
|
52
|
+
if env_value is None:
|
|
53
|
+
return allow_credentials
|
|
54
|
+
# Allow explicit overrides via function arguments.
|
|
55
|
+
if allow_credentials is not True:
|
|
56
|
+
return allow_credentials
|
|
57
|
+
return env_value
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _configure_cors(
|
|
61
|
+
app: FastAPI,
|
|
62
|
+
*,
|
|
63
|
+
cors_origins: Iterable[str] | str | None,
|
|
64
|
+
allow_credentials: bool,
|
|
65
|
+
env: Mapping[str, str],
|
|
66
|
+
) -> None:
|
|
67
|
+
origins = _resolve_cors_origins(cors_origins, env)
|
|
68
|
+
if not origins:
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
allow_methods = _normalize_origins(env.get("CORS_ALLOW_METHODS")) or ["*"]
|
|
72
|
+
allow_headers = _normalize_origins(env.get("CORS_ALLOW_HEADERS")) or ["*"]
|
|
73
|
+
|
|
74
|
+
credentials = _resolve_allow_credentials(allow_credentials, env)
|
|
75
|
+
|
|
76
|
+
wildcard_origins = "*" in origins
|
|
77
|
+
|
|
78
|
+
cors_kwargs: dict[str, object] = {
|
|
79
|
+
"allow_credentials": credentials,
|
|
80
|
+
"allow_methods": allow_methods,
|
|
81
|
+
"allow_headers": allow_headers,
|
|
82
|
+
"allow_origins": ["*"] if wildcard_origins else origins,
|
|
83
|
+
}
|
|
84
|
+
origin_regex = env.get("CORS_ALLOW_ORIGIN_REGEX")
|
|
85
|
+
if wildcard_origins:
|
|
86
|
+
cors_kwargs["allow_origin_regex"] = origin_regex or ".*"
|
|
87
|
+
else:
|
|
88
|
+
if origin_regex:
|
|
89
|
+
cors_kwargs["allow_origin_regex"] = origin_regex
|
|
90
|
+
|
|
91
|
+
app.add_middleware(CORSMiddleware, **cors_kwargs) # type: ignore[arg-type] # CORSMiddleware accepts these kwargs
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _configure_security_headers(
|
|
95
|
+
app: FastAPI,
|
|
96
|
+
*,
|
|
97
|
+
overrides: dict[str, str] | None,
|
|
98
|
+
enable_hsts_preload: bool | None,
|
|
99
|
+
) -> None:
|
|
100
|
+
merged_overrides = dict(overrides or {})
|
|
101
|
+
if enable_hsts_preload is not None:
|
|
102
|
+
current = merged_overrides.get(
|
|
103
|
+
"Strict-Transport-Security",
|
|
104
|
+
SECURE_DEFAULTS["Strict-Transport-Security"],
|
|
105
|
+
)
|
|
106
|
+
directives = [p.strip() for p in current.split(";") if p.strip()]
|
|
107
|
+
directives = [d for d in directives if d.lower() != "preload"]
|
|
108
|
+
if enable_hsts_preload:
|
|
109
|
+
directives.append("preload")
|
|
110
|
+
merged_overrides["Strict-Transport-Security"] = "; ".join(directives)
|
|
111
|
+
|
|
112
|
+
app.add_middleware(SecurityHeadersMiddleware, overrides=merged_overrides)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _should_add_session_middleware(app: FastAPI) -> bool:
|
|
116
|
+
return not any(m.cls is SessionMiddleware for m in app.user_middleware)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _configure_session_middleware(
|
|
120
|
+
app: FastAPI,
|
|
121
|
+
*,
|
|
122
|
+
env: Mapping[str, str],
|
|
123
|
+
install: bool,
|
|
124
|
+
secret_key: str | None,
|
|
125
|
+
session_cookie: str,
|
|
126
|
+
max_age: int,
|
|
127
|
+
same_site: str,
|
|
128
|
+
https_only: bool | None,
|
|
129
|
+
) -> None:
|
|
130
|
+
if not install or not _should_add_session_middleware(app):
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
# Use require_secret to ensure secrets are set in production
|
|
134
|
+
secret = require_secret(
|
|
135
|
+
secret_key or env.get("SESSION_SECRET"),
|
|
136
|
+
"SESSION_SECRET",
|
|
137
|
+
dev_default=DEFAULT_SESSION_SECRET,
|
|
138
|
+
)
|
|
139
|
+
https_env = _parse_bool(env.get("SESSION_COOKIE_SECURE"))
|
|
140
|
+
effective_https_only = (
|
|
141
|
+
https_only
|
|
142
|
+
if https_only is not None
|
|
143
|
+
else (https_env if https_env is not None else False)
|
|
144
|
+
)
|
|
145
|
+
same_site_env = env.get("SESSION_COOKIE_SAMESITE")
|
|
146
|
+
same_site_raw = same_site_env.strip() if same_site_env else same_site
|
|
147
|
+
# Validate and narrow to expected Literal type
|
|
148
|
+
same_site_value: Literal["lax", "strict", "none"] = (
|
|
149
|
+
"lax"
|
|
150
|
+
if same_site_raw not in ("lax", "strict", "none")
|
|
151
|
+
else cast(Literal["lax", "strict", "none"], same_site_raw)
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
max_age_env = env.get("SESSION_COOKIE_MAX_AGE_SECONDS")
|
|
155
|
+
try:
|
|
156
|
+
max_age_value = int(max_age_env) if max_age_env is not None else max_age
|
|
157
|
+
except ValueError:
|
|
158
|
+
max_age_value = max_age
|
|
159
|
+
|
|
160
|
+
session_cookie_env = env.get("SESSION_COOKIE_NAME")
|
|
161
|
+
session_cookie_value = (
|
|
162
|
+
session_cookie_env.strip() if session_cookie_env else session_cookie
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
app.add_middleware(
|
|
166
|
+
SessionMiddleware,
|
|
167
|
+
secret_key=secret,
|
|
168
|
+
session_cookie=session_cookie_value,
|
|
169
|
+
max_age=max_age_value,
|
|
170
|
+
same_site=same_site_value,
|
|
171
|
+
https_only=effective_https_only,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def add_security(
|
|
176
|
+
app: FastAPI,
|
|
177
|
+
*,
|
|
178
|
+
cors_origins: Iterable[str] | str | None = None,
|
|
179
|
+
headers_overrides: dict[str, str] | None = None,
|
|
180
|
+
allow_credentials: bool = True,
|
|
181
|
+
env: Mapping[str, str] = os.environ,
|
|
182
|
+
enable_hsts_preload: bool | None = None,
|
|
183
|
+
install_session_middleware: bool = False,
|
|
184
|
+
session_secret_key: str | None = None,
|
|
185
|
+
session_cookie_name: str = "svc_session",
|
|
186
|
+
session_cookie_max_age_seconds: int = 4 * 3600,
|
|
187
|
+
session_cookie_samesite: str = "lax",
|
|
188
|
+
session_cookie_https_only: bool | None = None,
|
|
189
|
+
) -> None:
|
|
190
|
+
"""Install security middlewares with svc-infra defaults."""
|
|
191
|
+
|
|
192
|
+
_configure_security_headers(
|
|
193
|
+
app,
|
|
194
|
+
overrides=headers_overrides,
|
|
195
|
+
enable_hsts_preload=enable_hsts_preload,
|
|
196
|
+
)
|
|
197
|
+
_configure_cors(
|
|
198
|
+
app,
|
|
199
|
+
cors_origins=cors_origins,
|
|
200
|
+
allow_credentials=allow_credentials,
|
|
201
|
+
env=env,
|
|
202
|
+
)
|
|
203
|
+
_configure_session_middleware(
|
|
204
|
+
app,
|
|
205
|
+
env=env,
|
|
206
|
+
install=install_session_middleware,
|
|
207
|
+
secret_key=session_secret_key,
|
|
208
|
+
session_cookie=session_cookie_name,
|
|
209
|
+
max_age=session_cookie_max_age_seconds,
|
|
210
|
+
same_site=session_cookie_samesite,
|
|
211
|
+
https_only=session_cookie_https_only,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
__all__ = [
|
|
216
|
+
"add_security",
|
|
217
|
+
]
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Audit log append & chain verification utilities.
|
|
2
|
+
|
|
3
|
+
Provides helpers to append a new AuditLog entry maintaining a hash-chain
|
|
4
|
+
integrity model and to verify an existing sequence for tampering.
|
|
5
|
+
|
|
6
|
+
Design notes:
|
|
7
|
+
- Each event stores prev_hash (previous event's hash or 64 zeros for genesis).
|
|
8
|
+
- Hash = sha256(prev_hash + canonical_json_payload).
|
|
9
|
+
- Verification recomputes expected hash for each event and compares.
|
|
10
|
+
- If a middle event is altered, that event and all subsequent events will
|
|
11
|
+
fail verification (because their prev_hash links break transitively).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from typing import Any, List, Optional, Protocol, Sequence, Tuple
|
|
19
|
+
|
|
20
|
+
try: # SQLAlchemy may not be present in minimal test context
|
|
21
|
+
from sqlalchemy import select
|
|
22
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
23
|
+
except Exception: # pragma: no cover
|
|
24
|
+
AsyncSession = Any # type: ignore[misc,assignment]
|
|
25
|
+
select = None # type: ignore[assignment]
|
|
26
|
+
|
|
27
|
+
from svc_infra.security.models import AuditLog, compute_audit_hash
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class AuditEvent:
|
|
32
|
+
ts: datetime
|
|
33
|
+
actor_id: Any
|
|
34
|
+
tenant_id: str | None
|
|
35
|
+
event_type: str
|
|
36
|
+
resource_ref: str | None
|
|
37
|
+
metadata: dict
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AuditLogStore(Protocol):
|
|
41
|
+
"""Minimal interface for storing audit events.
|
|
42
|
+
|
|
43
|
+
This is intentionally small so applications can swap in a SQL-backed store.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def append(
|
|
47
|
+
self,
|
|
48
|
+
*,
|
|
49
|
+
actor_id: Any = None,
|
|
50
|
+
tenant_id: str | None = None,
|
|
51
|
+
event_type: str,
|
|
52
|
+
resource_ref: str | None = None,
|
|
53
|
+
metadata: dict | None = None,
|
|
54
|
+
ts: datetime | None = None,
|
|
55
|
+
) -> AuditEvent:
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
def list(
|
|
59
|
+
self,
|
|
60
|
+
*,
|
|
61
|
+
tenant_id: str | None = None,
|
|
62
|
+
limit: int | None = None,
|
|
63
|
+
) -> list[AuditEvent]:
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class InMemoryAuditLogStore:
|
|
68
|
+
"""In-memory audit event store (useful for tests and prototypes)."""
|
|
69
|
+
|
|
70
|
+
def __init__(self):
|
|
71
|
+
self._events: list[AuditEvent] = []
|
|
72
|
+
|
|
73
|
+
def append(
|
|
74
|
+
self,
|
|
75
|
+
*,
|
|
76
|
+
actor_id: Any = None,
|
|
77
|
+
tenant_id: str | None = None,
|
|
78
|
+
event_type: str,
|
|
79
|
+
resource_ref: str | None = None,
|
|
80
|
+
metadata: dict | None = None,
|
|
81
|
+
ts: datetime | None = None,
|
|
82
|
+
) -> AuditEvent:
|
|
83
|
+
event = AuditEvent(
|
|
84
|
+
ts=ts or datetime.now(timezone.utc),
|
|
85
|
+
actor_id=actor_id,
|
|
86
|
+
tenant_id=tenant_id,
|
|
87
|
+
event_type=event_type,
|
|
88
|
+
resource_ref=resource_ref,
|
|
89
|
+
metadata=dict(metadata or {}),
|
|
90
|
+
)
|
|
91
|
+
self._events.append(event)
|
|
92
|
+
return event
|
|
93
|
+
|
|
94
|
+
def list(
|
|
95
|
+
self, *, tenant_id: str | None = None, limit: int | None = None
|
|
96
|
+
) -> list[AuditEvent]:
|
|
97
|
+
out = [e for e in self._events if tenant_id is None or e.tenant_id == tenant_id]
|
|
98
|
+
if limit is not None:
|
|
99
|
+
return out[-int(limit) :]
|
|
100
|
+
return out
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
async def append_audit_event(
|
|
104
|
+
db: Any,
|
|
105
|
+
*,
|
|
106
|
+
actor_id=None,
|
|
107
|
+
tenant_id: Optional[str] = None,
|
|
108
|
+
event_type: str,
|
|
109
|
+
resource_ref: Optional[str] = None,
|
|
110
|
+
metadata: dict | None = None,
|
|
111
|
+
ts: Optional[datetime] = None,
|
|
112
|
+
prev_event: Optional[AuditLog] = None,
|
|
113
|
+
) -> AuditLog:
|
|
114
|
+
"""Append an audit event returning the persisted row.
|
|
115
|
+
|
|
116
|
+
If prev_event is not supplied, it attempts to fetch the latest event for
|
|
117
|
+
the tenant (or global chain when tenant_id is None).
|
|
118
|
+
"""
|
|
119
|
+
metadata = metadata or {}
|
|
120
|
+
ts = ts or datetime.now(timezone.utc)
|
|
121
|
+
|
|
122
|
+
prev_hash: Optional[str] = None
|
|
123
|
+
if prev_event is not None:
|
|
124
|
+
prev_hash = prev_event.hash
|
|
125
|
+
elif select is not None and hasattr(
|
|
126
|
+
db, "execute"
|
|
127
|
+
): # attempt DB lookup for previous event
|
|
128
|
+
try:
|
|
129
|
+
stmt = (
|
|
130
|
+
select(AuditLog)
|
|
131
|
+
.where(AuditLog.tenant_id == tenant_id)
|
|
132
|
+
.order_by(AuditLog.id.desc())
|
|
133
|
+
.limit(1)
|
|
134
|
+
)
|
|
135
|
+
result = await db.execute(stmt)
|
|
136
|
+
prev = result.scalars().first()
|
|
137
|
+
if prev:
|
|
138
|
+
prev_hash = prev.hash
|
|
139
|
+
except Exception: # pragma: no cover - defensive for minimal fakes
|
|
140
|
+
pass
|
|
141
|
+
|
|
142
|
+
new_hash = compute_audit_hash(
|
|
143
|
+
prev_hash,
|
|
144
|
+
ts=ts,
|
|
145
|
+
actor_id=actor_id,
|
|
146
|
+
tenant_id=tenant_id,
|
|
147
|
+
event_type=event_type,
|
|
148
|
+
resource_ref=resource_ref,
|
|
149
|
+
metadata=metadata,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
row = AuditLog(
|
|
153
|
+
ts=ts,
|
|
154
|
+
actor_id=actor_id,
|
|
155
|
+
tenant_id=tenant_id,
|
|
156
|
+
event_type=event_type,
|
|
157
|
+
resource_ref=resource_ref,
|
|
158
|
+
event_metadata=metadata,
|
|
159
|
+
prev_hash=prev_hash or "0" * 64,
|
|
160
|
+
hash=new_hash,
|
|
161
|
+
)
|
|
162
|
+
if hasattr(db, "add"):
|
|
163
|
+
try:
|
|
164
|
+
db.add(row)
|
|
165
|
+
except Exception: # pragma: no cover - minimal shim safety
|
|
166
|
+
pass
|
|
167
|
+
if hasattr(db, "flush"):
|
|
168
|
+
try:
|
|
169
|
+
await db.flush()
|
|
170
|
+
except Exception: # pragma: no cover
|
|
171
|
+
pass
|
|
172
|
+
return row
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def verify_audit_chain(events: Sequence[AuditLog]) -> Tuple[bool, List[int]]:
|
|
176
|
+
"""Verify a sequence of audit events.
|
|
177
|
+
|
|
178
|
+
Returns (ok, broken_indices). If any event's hash doesn't match the recomputed
|
|
179
|
+
expected hash (based on previous event), its index is recorded. All events are
|
|
180
|
+
checked so callers can analyze extent of tampering.
|
|
181
|
+
"""
|
|
182
|
+
broken: List[int] = []
|
|
183
|
+
prev_hash = "0" * 64
|
|
184
|
+
for idx, ev in enumerate(events):
|
|
185
|
+
expected = compute_audit_hash(
|
|
186
|
+
prev_hash if ev.prev_hash == prev_hash else ev.prev_hash,
|
|
187
|
+
ts=ev.ts,
|
|
188
|
+
actor_id=ev.actor_id,
|
|
189
|
+
tenant_id=ev.tenant_id,
|
|
190
|
+
event_type=ev.event_type,
|
|
191
|
+
resource_ref=ev.resource_ref,
|
|
192
|
+
metadata=ev.event_metadata,
|
|
193
|
+
)
|
|
194
|
+
# prev_hash stored should equal previous event hash (or zeros for genesis)
|
|
195
|
+
if (idx == 0 and ev.prev_hash != "0" * 64) or (
|
|
196
|
+
idx > 0 and ev.prev_hash != events[idx - 1].hash
|
|
197
|
+
):
|
|
198
|
+
broken.append(idx)
|
|
199
|
+
if ev.hash != expected:
|
|
200
|
+
broken.append(idx)
|
|
201
|
+
prev_hash = ev.hash
|
|
202
|
+
ok = not broken
|
|
203
|
+
return ok, sorted(set(broken))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
__all__ = [
|
|
207
|
+
"append_audit_event",
|
|
208
|
+
"verify_audit_chain",
|
|
209
|
+
"AuditEvent",
|
|
210
|
+
"AuditLogStore",
|
|
211
|
+
"InMemoryAuditLogStore",
|
|
212
|
+
]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, List, Optional, Sequence, Tuple
|
|
4
|
+
|
|
5
|
+
try: # optional SQLAlchemy import for environments without SA
|
|
6
|
+
from sqlalchemy import select
|
|
7
|
+
except Exception: # pragma: no cover
|
|
8
|
+
select = None # type: ignore[assignment]
|
|
9
|
+
|
|
10
|
+
from .audit import append_audit_event, verify_audit_chain
|
|
11
|
+
from .models import AuditLog
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def append_event(
|
|
15
|
+
db: Any,
|
|
16
|
+
*,
|
|
17
|
+
actor_id=None,
|
|
18
|
+
tenant_id: Optional[str] = None,
|
|
19
|
+
event_type: str,
|
|
20
|
+
resource_ref: Optional[str] = None,
|
|
21
|
+
metadata: dict | None = None,
|
|
22
|
+
prev_event: Optional[AuditLog] = None,
|
|
23
|
+
) -> AuditLog:
|
|
24
|
+
"""Append an AuditLog event using the shared append utility.
|
|
25
|
+
|
|
26
|
+
If prev_event is not provided, attempts to look up the last event for the tenant.
|
|
27
|
+
"""
|
|
28
|
+
return await append_audit_event(
|
|
29
|
+
db,
|
|
30
|
+
actor_id=actor_id,
|
|
31
|
+
tenant_id=tenant_id,
|
|
32
|
+
event_type=event_type,
|
|
33
|
+
resource_ref=resource_ref,
|
|
34
|
+
metadata=metadata,
|
|
35
|
+
prev_event=prev_event,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def verify_chain_for_tenant(
|
|
40
|
+
db: Any, *, tenant_id: Optional[str] = None
|
|
41
|
+
) -> Tuple[bool, List[int]]:
|
|
42
|
+
"""Fetch all AuditLog events for a tenant and verify hash-chain integrity.
|
|
43
|
+
|
|
44
|
+
Falls back to inspecting an in-memory 'added' list when SQLAlchemy is not available,
|
|
45
|
+
to simplify unit tests with fake DBs.
|
|
46
|
+
"""
|
|
47
|
+
events: Sequence[AuditLog] = []
|
|
48
|
+
if select is not None and hasattr(db, "execute"):
|
|
49
|
+
try:
|
|
50
|
+
stmt = select(AuditLog)
|
|
51
|
+
if tenant_id is not None:
|
|
52
|
+
stmt = stmt.where(AuditLog.tenant_id == tenant_id)
|
|
53
|
+
stmt = stmt.order_by(AuditLog.id.asc())
|
|
54
|
+
result = await db.execute(stmt)
|
|
55
|
+
events = list(result.scalars().all())
|
|
56
|
+
except Exception: # pragma: no cover
|
|
57
|
+
events = []
|
|
58
|
+
elif hasattr(db, "added"):
|
|
59
|
+
try:
|
|
60
|
+
pool = getattr(db, "added")
|
|
61
|
+
# Preserve insertion order for in-memory fake DBs where primary keys may be None
|
|
62
|
+
events = [
|
|
63
|
+
e
|
|
64
|
+
for e in pool
|
|
65
|
+
if isinstance(e, AuditLog)
|
|
66
|
+
and (tenant_id is None or e.tenant_id == tenant_id)
|
|
67
|
+
]
|
|
68
|
+
except Exception: # pragma: no cover
|
|
69
|
+
events = []
|
|
70
|
+
|
|
71
|
+
return verify_audit_chain(list(events))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
__all__ = ["append_event", "verify_chain_for_tenant"]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
SECURE_DEFAULTS = {
|
|
4
|
+
"Strict-Transport-Security": "max-age=63072000; includeSubDomains; preload",
|
|
5
|
+
"X-Content-Type-Options": "nosniff",
|
|
6
|
+
"X-Frame-Options": "DENY",
|
|
7
|
+
"Referrer-Policy": "strict-origin-when-cross-origin",
|
|
8
|
+
"X-XSS-Protection": "0",
|
|
9
|
+
# CSP with practical defaults - allows inline styles/scripts and data URIs for images
|
|
10
|
+
# Also allows cdn.jsdelivr.net for FastAPI docs (Swagger UI, ReDoc)
|
|
11
|
+
# Still secure: blocks arbitrary external scripts, prevents framing, restricts form actions
|
|
12
|
+
# Override via headers_overrides in add_security() for stricter or custom policies
|
|
13
|
+
"Content-Security-Policy": (
|
|
14
|
+
"default-src 'self'; "
|
|
15
|
+
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
|
|
16
|
+
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
|
|
17
|
+
"img-src 'self' data: https:; "
|
|
18
|
+
"connect-src 'self'; "
|
|
19
|
+
"font-src 'self' https://cdn.jsdelivr.net; "
|
|
20
|
+
"frame-ancestors 'none'; "
|
|
21
|
+
"base-uri 'self'; "
|
|
22
|
+
"form-action 'self'"
|
|
23
|
+
),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SecurityHeadersMiddleware:
|
|
28
|
+
def __init__(self, app, overrides: dict[str, str] | None = None):
|
|
29
|
+
self.app = app
|
|
30
|
+
self.overrides = overrides or {}
|
|
31
|
+
|
|
32
|
+
async def __call__(self, scope, receive, send):
|
|
33
|
+
if scope.get("type") != "http":
|
|
34
|
+
await self.app(scope, receive, send)
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
async def _send(message):
|
|
38
|
+
if message.get("type") == "http.response.start":
|
|
39
|
+
headers = message.setdefault("headers", [])
|
|
40
|
+
existing = {k.decode(): v.decode() for k, v in headers}
|
|
41
|
+
merged = {**SECURE_DEFAULTS, **existing, **self.overrides}
|
|
42
|
+
# rebuild headers list
|
|
43
|
+
new_headers = []
|
|
44
|
+
for k, v in merged.items():
|
|
45
|
+
new_headers.append((k.encode(), v.encode()))
|
|
46
|
+
message["headers"] = new_headers
|
|
47
|
+
await send(message)
|
|
48
|
+
|
|
49
|
+
await self.app(scope, receive, _send)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
__all__ = ["SecurityHeadersMiddleware", "SECURE_DEFAULTS"]
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import logging
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Dict, Optional
|
|
8
|
+
|
|
9
|
+
from svc_infra.http import new_httpx_client
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def sha1_hex(data: str) -> str:
|
|
15
|
+
return hashlib.sha1(data.encode("utf-8")).hexdigest().upper()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class CacheEntry:
|
|
20
|
+
body: str
|
|
21
|
+
expires_at: float
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class HIBPClient:
|
|
25
|
+
"""Minimal HaveIBeenPwned range API client with simple in-memory cache.
|
|
26
|
+
|
|
27
|
+
- Uses k-anonymity range query: send first 5 chars of SHA1 hash, receive suffix list.
|
|
28
|
+
- Caches prefix responses for TTL to avoid repeated network calls.
|
|
29
|
+
- Synchronous implementation to allow use in sync validators.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
*,
|
|
35
|
+
base_url: str = "https://api.pwnedpasswords.com",
|
|
36
|
+
ttl_seconds: int = 3600,
|
|
37
|
+
timeout: float = 5.0,
|
|
38
|
+
user_agent: str = "svc-infra/hibp",
|
|
39
|
+
) -> None:
|
|
40
|
+
self.base_url = base_url.rstrip("/")
|
|
41
|
+
self.ttl_seconds = ttl_seconds
|
|
42
|
+
self.timeout = timeout
|
|
43
|
+
self.user_agent = user_agent
|
|
44
|
+
self._cache: Dict[str, CacheEntry] = {}
|
|
45
|
+
# Use central factory for consistent defaults; retain explicit timeout override
|
|
46
|
+
self._http = new_httpx_client(
|
|
47
|
+
timeout_seconds=self.timeout,
|
|
48
|
+
headers={"User-Agent": self.user_agent},
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def _get_cached(self, prefix: str) -> Optional[str]:
|
|
52
|
+
now = time.time()
|
|
53
|
+
ent = self._cache.get(prefix)
|
|
54
|
+
if ent and ent.expires_at > now:
|
|
55
|
+
return ent.body
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
def _set_cache(self, prefix: str, body: str) -> None:
|
|
59
|
+
self._cache[prefix] = CacheEntry(
|
|
60
|
+
body=body, expires_at=time.time() + self.ttl_seconds
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def range_query(self, prefix: str) -> str:
|
|
64
|
+
cached = self._get_cached(prefix)
|
|
65
|
+
if cached is not None:
|
|
66
|
+
return cached
|
|
67
|
+
url = f"{self.base_url}/range/{prefix}"
|
|
68
|
+
resp = self._http.get(url)
|
|
69
|
+
resp.raise_for_status()
|
|
70
|
+
body = resp.text
|
|
71
|
+
self._set_cache(prefix, body)
|
|
72
|
+
return body
|
|
73
|
+
|
|
74
|
+
def is_breached(self, password: str) -> bool:
|
|
75
|
+
full = sha1_hex(password)
|
|
76
|
+
prefix, suffix = full[:5], full[5:]
|
|
77
|
+
try:
|
|
78
|
+
body = self.range_query(prefix)
|
|
79
|
+
except Exception as e:
|
|
80
|
+
# Fail-open: if HIBP unavailable, do not block users.
|
|
81
|
+
logger.warning("HIBP password check failed (fail-open): %s", e)
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
for line in body.splitlines():
|
|
85
|
+
# Lines formatted as "SUFFIX:COUNT"
|
|
86
|
+
if not line:
|
|
87
|
+
continue
|
|
88
|
+
parts = line.split(":")
|
|
89
|
+
if len(parts) != 2:
|
|
90
|
+
continue
|
|
91
|
+
sfx = parts[0].strip().upper()
|
|
92
|
+
if sfx == suffix:
|
|
93
|
+
# Count > 0 implies breached
|
|
94
|
+
try:
|
|
95
|
+
return int(parts[1].strip()) > 0
|
|
96
|
+
except ValueError:
|
|
97
|
+
return True
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
__all__ = ["HIBPClient", "sha1_hex"]
|