Секреты — конфиденциальные данные, необходимые для работы приложения: пароли БД, API-ключи, сертификаты, токены.
Где НЕ хранить секреты
Место
Почему плохо
Исходный код
Попадёт в Git, видно всем разработчикам
.env в репозитории
Тот же риск, что и с кодом
Docker image
Секрет запечён в слой, видно через docker history
Логи
Случайное логирование секретов
Комментарии/README
Забытые секреты в документации
Переменные CI/CD без шифрования
Видно в логах pipeline
Иерархия хранения секретов
Уровень 1: Vault / AWS Secrets Manager (best)
└── Централизованное хранение, ротация, аудит
Уровень 2: Symfony Secrets (encrypted in repo)
└── Зашифрованные секреты в репозитории
Уровень 3: Environment variables (ok)
└── Через deployment tool, не в .env файле
Уровень 4: .env.local (dev only)
└── Только для локальной разработки, в .gitignore
Symfony Secrets
Symfony Secrets шифрует секреты с помощью libsodium и хранит зашифрованные значения прямо в репозитории.
<?php
declare(strict_types=1);
// Symfony Secrets workflow:
// 1. Generate keys: bin/console secrets:generate-keys
// 2. Set a secret: bin/console secrets:set DATABASE_URL
// 3. List secrets: bin/console secrets:list
// 4. Remove secret: bin/console secrets:remove DATABASE_URL
// Encrypted files stored in:
// config/secrets/prod/prod.DATABASE_URL.28a3bc.php (encrypted value)
// config/secrets/prod/prod.decrypt.private.php (private key - NOT in git)
// In services, secrets are injected as parameters:
namespace App\Service;
final readonly class PaymentGateway
{
public function __construct(
// Injected from Symfony Secrets or .env.
// #[\SensitiveParameter] redacts the value in stack traces and error
// reports — otherwise one uncaught exception prints the live key
#[\SensitiveParameter] private string $stripeSecretKey, // %env(STRIPE_SECRET_KEY)%
#[\SensitiveParameter] private string $stripeWebhookSecret, // %env(STRIPE_WEBHOOK_SECRET)%
) {}
public function createCharge(int $amount, string $currency): array
{
// Use the injected secret — never hardcode
return $this->callStripeApi('/charges', [
'amount' => $amount,
'currency' => $currency,
]);
}
/**
* @param array<string, scalar> $data
* @return array<string, mixed>
*/
private function callStripeApi(string $endpoint, array $data): array
{
$ch = curl_init("https://api.stripe.com/v1{$endpoint}");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($data),
CURLOPT_USERPWD => $this->stripeSecretKey . ':',
CURLOPT_TIMEOUT => 10,
]);
$response = curl_exec($ch);
$statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($response === false || $statusCode >= 400) {
// Surface the status only — the response body can echo back
// request data, and the message may reach a log or a user
throw new \RuntimeException("Payment API call failed: HTTP {$statusCode}");
}
return json_decode($response, true, 512, JSON_THROW_ON_ERROR);
}
}
package payment
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
// Secret hides a credential from every default formatting path: %v, %s and
// structured loggers all call String(), so an accidental log of the whole
// struct prints [REDACTED] instead of the live key.
type Secret string
func (Secret) String() string { return "[REDACTED]" }
// Gateway handles payment processing using injected secrets.
// Secrets come from environment variables or a vault, never hardcoded.
type Gateway struct {
secretKey Secret
webhookSecret Secret
client *http.Client
}
// NewGateway creates a payment gateway from environment config.
func NewGateway() (*Gateway, error) {
secretKey := os.Getenv("STRIPE_SECRET_KEY")
webhookSecret := os.Getenv("STRIPE_WEBHOOK_SECRET")
// Fail at startup rather than sending unauthenticated requests later.
if secretKey == "" || webhookSecret == "" {
return nil, errors.New("STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET must be set")
}
return &Gateway{
secretKey: Secret(secretKey),
webhookSecret: Secret(webhookSecret),
client: &http.Client{Timeout: 10 * time.Second},
}, nil
}
// CreateCharge calls the payment API.
func (g *Gateway) CreateCharge(ctx context.Context, amount int, currency string) (map[string]any, error) {
data := url.Values{
"amount": {strconv.Itoa(amount)},
"currency": {currency},
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
"https://api.stripe.com/v1/charges", strings.NewReader(data.Encode()))
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
req.SetBasicAuth(string(g.secretKey), "")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := g.client.Do(req)
if err != nil {
return nil, fmt.Errorf("payment API call failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= http.StatusBadRequest {
// Surface the status only -- the response body can echo request data.
return nil, fmt.Errorf("payment API returned HTTP %d", resp.StatusCode)
}
var result map[string]any
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
return result, nil
}
using System.Net.Http.Headers;
using System.Text;
using Microsoft.Extensions.Options;
namespace App.Payment;
// Secrets arrive through the options pattern, backed in development by
// `dotnet user-secrets` and in production by Key Vault / environment variables.
// The class itself never knows or cares which provider supplied them.
public sealed class StripeOptions
{
public const string SectionName = "Stripe";
public required string SecretKey { get; init; }
public required string WebhookSecret { get; init; }
}
public sealed class PaymentGateway
{
private readonly HttpClient _http;
public PaymentGateway(HttpClient http, IOptions<StripeOptions> options)
{
_http = http;
_http.BaseAddress = new Uri("https://api.stripe.com/v1/");
// Basic auth with the secret key as username, empty password.
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Basic",
Convert.ToBase64String(Encoding.ASCII.GetBytes($"{options.Value.SecretKey}:")));
}
public async Task<ChargeResponse> CreateChargeAsync(
int amount,
string currency,
CancellationToken ct = default)
{
using var content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["amount"] = amount.ToString(),
["currency"] = currency,
});
using HttpResponseMessage response = await _http.PostAsync("charges", content, ct);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<ChargeResponse>(ct)
?? throw new InvalidOperationException("Empty response from payment API");
}
}
// Registration — the secret is never written into source or appsettings.json:
// builder.Services.Configure<StripeOptions>(builder.Configuration.GetSection(StripeOptions.SectionName));
// builder.Services.AddHttpClient<PaymentGateway>();
import httpx
from pydantic import SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class StripeSettings(BaseSettings):
"""Secrets are read from the environment or a secrets file, never hardcoded.
SecretStr keeps the value out of reprs, logs and tracebacks.
"""
model_config = SettingsConfigDict(env_prefix="STRIPE_", secrets_dir="/run/secrets")
secret_key: SecretStr
webhook_secret: SecretStr
class PaymentGateway:
def __init__(self, settings: StripeSettings, client: httpx.AsyncClient) -> None:
self._client = client
self._settings = settings
async def create_charge(self, amount: int, currency: str) -> dict:
response = await self._client.post(
"https://api.stripe.com/v1/charges",
data={"amount": amount, "currency": currency},
# Basic auth with the secret key as username, empty password.
auth=(self._settings.secret_key.get_secret_value(), ""),
)
response.raise_for_status()
return response.json()
## Ротация секретов
Ротация — регулярная смена секретов для снижения риска компрометации.
Стратегии ротации
Стратегия
Описание
Downtime
Blue-Green
Два набора credentials, переключение
Нет
Graceful
Новый секрет + grace period для старого
Нет
Big Bang
Замена всех разом
Возможен
Automated
Vault/AWS автоматически ротирует
Нет
Поддержка ротации
<?php
declare(strict_types=1);
namespace App\Security;
/**
* Supports secret rotation by accepting multiple valid secrets.
* During rotation, both old and new secrets are valid.
*/
final readonly class RotatableApiKeyValidator
{
/**
* @param array<string> $validKeys Current + previous valid API keys
*/
public function __construct(
private array $validKeys,
) {}
/**
* Validate an API key. Accepts any key from the valid set.
* This allows zero-downtime rotation.
*/
public function validate(string $apiKey): ValidationResult
{
$result = new ValidationResult(valid: false, isLatest: false);
foreach ($this->validKeys as $index => $validKey) {
// hash_equals is constant-time, but returning on the first match
// is not: the loop deliberately runs to the end so the response
// time does not reveal which key in the set matched
if (hash_equals($validKey, $apiKey)) {
$result = new ValidationResult(
valid: true,
isLatest: $index === 0,
);
}
}
return $result;
}
}
final readonly class ValidationResult
{
public function __construct(
public bool $valid,
public bool $isLatest, // false = client should update their key
) {}
}
package security
import "crypto/subtle"
// RotatableAPIKeyValidator accepts multiple valid API keys for zero-downtime rotation.
type RotatableAPIKeyValidator struct {
validKeys []string // Current key first, then previous keys
}
// ValidationResult holds the API key validation outcome.
type KeyValidationResult struct {
Valid bool `json:"valid"`
IsLatest bool `json:"is_latest"` // false = client should update their key
}
// NewRotatableAPIKeyValidator creates a validator with ordered valid keys.
func NewRotatableAPIKeyValidator(keys []string) *RotatableAPIKeyValidator {
return &RotatableAPIKeyValidator{validKeys: keys}
}
// Validate checks if the provided API key matches any valid key.
func (v *RotatableAPIKeyValidator) Validate(apiKey string) KeyValidationResult {
result := KeyValidationResult{}
for i, valid := range v.validKeys {
// ConstantTimeCompare is constant-time, but returning on the first
// match is not: the loop deliberately runs to the end so the response
// time does not reveal which key in the set matched.
if subtle.ConstantTimeCompare([]byte(valid), []byte(apiKey)) == 1 {
result = KeyValidationResult{Valid: true, IsLatest: i == 0}
}
}
return result
}
using System.Security.Cryptography;
using System.Text;
namespace App.Security;
public readonly record struct KeyValidationResult(
bool Valid,
// false = the client is on an older key and should update it.
bool IsLatest);
// Accepting several keys at once is what makes rotation zero-downtime:
// the new key goes live while the previous one still works.
public sealed class RotatableApiKeyValidator(IReadOnlyList<string> validKeys)
{
public KeyValidationResult Validate(string apiKey)
{
byte[] provided = Encoding.UTF8.GetBytes(apiKey);
var result = new KeyValidationResult(false, false);
for (int i = 0; i < validKeys.Count; i++)
{
// FixedTimeEquals keeps the comparison constant-time; the loop
// deliberately runs to the end so timing does not leak which
// key matched.
if (CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(validKeys[i]),
provided))
{
result = new KeyValidationResult(true, i == 0);
}
}
return result;
}
}
import hmac
from dataclasses import dataclass
from collections.abc import Sequence
@dataclass(frozen=True, slots=True)
class KeyValidationResult:
valid: bool
# False = the client is on an older key and should update it.
is_latest: bool
class RotatableApiKeyValidator:
"""Accepting several keys at once is what makes rotation zero-downtime:
the new key goes live while the previous one still works.
"""
def __init__(self, valid_keys: Sequence[str]) -> None:
# Current key first, then previous keys.
self._valid_keys = tuple(valid_keys)
def validate(self, api_key: str) -> KeyValidationResult:
result = KeyValidationResult(valid=False, is_latest=False)
for index, valid_key in enumerate(self._valid_keys):
# compare_digest is constant-time; the loop deliberately runs to
# the end so timing does not leak which key matched.
if hmac.compare_digest(valid_key, api_key):
result = KeyValidationResult(valid=True, is_latest=index == 0)
return result
## HashiCorp Vault
Vault — система централизованного управления секретами с аудитом, ротацией и контролем доступа.
Клиент для Vault
<?php
declare(strict_types=1);
namespace App\Secrets;
final class VaultClient
{
public function __construct(
private readonly string $vaultUrl,
private readonly string $token,
) {}
/**
* Read a secret from Vault KV v2 engine.
*
* @return array<string, mixed>
*/
public function getSecret(string $path): array
{
$url = "{$this->vaultUrl}/v1/secret/data/{$path}";
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"X-Vault-Token: {$this->token}",
'Content-Type: application/json',
],
CURLOPT_TIMEOUT => 5,
]);
$response = curl_exec($ch);
$statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($response === false || $statusCode !== 200) {
// The status code is safe to surface; the response body is not
throw new \RuntimeException("Vault error: HTTP {$statusCode}");
}
$data = json_decode($response, true, 512, JSON_THROW_ON_ERROR);
return $data['data']['data'] ?? [];
}
/**
* Write a secret to Vault.
*/
public function putSecret(string $path, array $data): void
{
$url = "{$this->vaultUrl}/v1/secret/data/{$path}";
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_POSTFIELDS => json_encode(['data' => $data]),
CURLOPT_HTTPHEADER => [
"X-Vault-Token: {$this->token}",
'Content-Type: application/json',
],
CURLOPT_TIMEOUT => 5,
]);
$response = curl_exec($ch);
$statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($response === false || ($statusCode !== 200 && $statusCode !== 204)) {
throw new \RuntimeException("Vault write error: HTTP {$statusCode}");
}
}
}
package secrets
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
)
// VaultClient reads and writes secrets from HashiCorp Vault.
type VaultClient struct {
url string
token string
client *http.Client
}
// NewVaultClient creates a new Vault client.
func NewVaultClient(url, token string) *VaultClient {
return &VaultClient{
url: url,
token: token,
client: &http.Client{Timeout: 5 * time.Second},
}
}
// GetSecret reads a secret from Vault KV v2 engine.
func (c *VaultClient) GetSecret(ctx context.Context, path string) (map[string]any, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
fmt.Sprintf("%s/v1/secret/data/%s", c.url, path), nil)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
req.Header.Set("X-Vault-Token", c.token)
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("vault request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// The status code is safe to surface; the response body is not.
return nil, fmt.Errorf("vault error: HTTP %d", resp.StatusCode)
}
var result struct {
Data struct {
Data map[string]any `json:"data"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode vault response: %w", err)
}
return result.Data.Data, nil
}
// PutSecret writes a secret to the Vault KV v2 engine.
func (c *VaultClient) PutSecret(ctx context.Context, path string, data map[string]string) error {
body, err := json.Marshal(map[string]any{"data": data})
if err != nil {
return fmt.Errorf("encode secret: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
fmt.Sprintf("%s/v1/secret/data/%s", c.url, path), bytes.NewReader(body))
if err != nil {
return fmt.Errorf("build request: %w", err)
}
req.Header.Set("X-Vault-Token", c.token)
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("vault request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
return fmt.Errorf("vault write error: HTTP %d", resp.StatusCode)
}
return nil
}
using System.Net.Http.Json;
using System.Text.Json.Serialization;
namespace App.Secrets;
// Vault KV v2 wraps the payload twice: { "data": { "data": {...} } }.
file sealed record KvV2Envelope(
[property: JsonPropertyName("data")] KvV2Payload Data);
file sealed record KvV2Payload(
[property: JsonPropertyName("data")] Dictionary<string, string> Data);
public sealed class VaultClient
{
private readonly HttpClient _http;
public VaultClient(HttpClient http, string vaultUrl, string token)
{
_http = http;
_http.BaseAddress = new Uri(vaultUrl.TrimEnd('/') + "/");
_http.Timeout = TimeSpan.FromSeconds(5);
_http.DefaultRequestHeaders.Add("X-Vault-Token", token);
}
// Read a secret from the KV v2 engine.
public async Task<IReadOnlyDictionary<string, string>> GetSecretAsync(
string path,
CancellationToken ct = default)
{
using HttpResponseMessage response = await _http.GetAsync($"v1/secret/data/{path}", ct);
if (!response.IsSuccessStatusCode)
{
// The status code is safe to surface; the response body may not be.
throw new InvalidOperationException($"Vault error: HTTP {(int)response.StatusCode}");
}
KvV2Envelope? envelope = await response.Content.ReadFromJsonAsync<KvV2Envelope>(ct);
return envelope?.Data.Data ?? new Dictionary<string, string>();
}
// Write a secret to the KV v2 engine.
public async Task PutSecretAsync(
string path,
IReadOnlyDictionary<string, string> data,
CancellationToken ct = default)
{
using HttpResponseMessage response = await _http.PostAsJsonAsync(
$"v1/secret/data/{path}",
new { data },
ct);
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException($"Vault write error: HTTP {(int)response.StatusCode}");
}
}
}
import httpx
REQUEST_TIMEOUT_SECONDS = 5.0
class VaultError(RuntimeError):
"""Raised when Vault returns a non-success status."""
class VaultClient:
def __init__(self, vault_url: str, token: str) -> None:
self._client = httpx.AsyncClient(
base_url=vault_url.rstrip("/"),
headers={"X-Vault-Token": token},
timeout=REQUEST_TIMEOUT_SECONDS,
)
async def get_secret(self, path: str) -> dict[str, str]:
"""Read a secret from the KV v2 engine."""
response = await self._client.get(f"/v1/secret/data/{path}")
if response.status_code != httpx.codes.OK:
# The status code is safe to surface; the response body may not be.
raise VaultError(f"vault error: HTTP {response.status_code}")
# KV v2 wraps the payload twice: {"data": {"data": {...}}}.
return response.json().get("data", {}).get("data", {})
async def put_secret(self, path: str, data: dict[str, str]) -> None:
"""Write a secret to the KV v2 engine."""
response = await self._client.post(
f"/v1/secret/data/{path}",
json={"data": data},
)
if response.status_code not in (httpx.codes.OK, httpx.codes.NO_CONTENT):
raise VaultError(f"vault write error: HTTP {response.status_code}")
async def aclose(self) -> None:
await self._client.aclose()
### Кеширование секретов из Vault
<?php
declare(strict_types=1);
namespace App\Secrets;
final class CachedSecretProvider
{
/** @var array<string, array{value: string, expires_at: float}> */
private array $cache = [];
public function __construct(
private readonly VaultClient $vault,
private readonly int $cacheTtlSeconds = 300, // 5 minutes
) {}
/**
* Get secret with in-memory caching.
* Reduces Vault API calls while keeping secrets fresh.
*/
public function get(string $path, string $key): string
{
$cacheKey = "{$path}:{$key}";
if (isset($this->cache[$cacheKey])
&& $this->cache[$cacheKey]['expires_at'] > microtime(true)
) {
return $this->cache[$cacheKey]['value'];
}
$secrets = $this->vault->getSecret($path);
$value = $secrets[$key] ?? null;
// Validate the shape before caching. The message names the path and
// the key, never the value — this exception may reach a log
if (!is_string($value)) {
throw new \RuntimeException(
"Secret key '{$key}' at path '{$path}' is missing or not a string",
);
}
$this->cache[$cacheKey] = [
'value' => $value,
'expires_at' => microtime(true) + $this->cacheTtlSeconds,
];
return $value;
}
}
package secrets
import (
"context"
"fmt"
"sync"
"time"
)
// CachedSecretProvider caches secrets in memory to reduce Vault API calls.
type CachedSecretProvider struct {
vault *VaultClient
cacheTTL time.Duration
mu sync.RWMutex
cache map[string]cacheEntry
}
type cacheEntry struct {
value string
expiresAt time.Time
}
// NewCachedSecretProvider creates a provider with in-memory caching.
func NewCachedSecretProvider(vault *VaultClient, ttl time.Duration) *CachedSecretProvider {
return &CachedSecretProvider{
vault: vault,
cacheTTL: ttl,
cache: make(map[string]cacheEntry),
}
}
// Get retrieves a secret, using cache when available.
func (p *CachedSecretProvider) Get(ctx context.Context, path, key string) (string, error) {
cacheKey := path + ":" + key
p.mu.RLock()
if entry, ok := p.cache[cacheKey]; ok && entry.expiresAt.After(time.Now()) {
p.mu.RUnlock()
return entry.value, nil
}
p.mu.RUnlock()
secrets, err := p.vault.GetSecret(ctx, path)
if err != nil {
return "", err
}
val, ok := secrets[key]
if !ok {
return "", fmt.Errorf("secret key %q not found at path %q", key, path)
}
// Type-assert rather than format: %v on a nested map would splice
// unrelated secret material from the Vault payload into the result.
valStr, ok := val.(string)
if !ok {
return "", fmt.Errorf("secret key %q at path %q is not a string", key, path)
}
p.mu.Lock()
p.cache[cacheKey] = cacheEntry{value: valStr, expiresAt: time.Now().Add(p.cacheTTL)}
p.mu.Unlock()
return valStr, nil
}
using Microsoft.Extensions.Caching.Memory;
namespace App.Secrets;
// IMemoryCache handles expiry and thread safety, so no manual lock is needed.
// Cached secrets stay in process memory only — never persisted to disk.
public sealed class CachedSecretProvider(VaultClient vault, IMemoryCache cache)
{
private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(5);
// Retrieve a secret, reducing Vault API calls while keeping values fresh.
public async Task<string> GetAsync(string path, string key, CancellationToken ct = default)
{
string cacheKey = $"{path}:{key}";
if (cache.TryGetValue(cacheKey, out string? cached) && cached is not null)
{
return cached;
}
IReadOnlyDictionary<string, string> secrets = await vault.GetSecretAsync(path, ct);
if (!secrets.TryGetValue(key, out string? value))
{
throw new KeyNotFoundException($"Secret key '{key}' not found at path '{path}'");
}
cache.Set(cacheKey, value, CacheTtl);
return value;
}
}
import asyncio
import time
from dataclasses import dataclass
CACHE_TTL_SECONDS = 300 # 5 minutes
@dataclass(frozen=True, slots=True)
class _CacheEntry:
value: str
expires_at: float
class CachedSecretProvider:
"""Cached secrets stay in process memory only — never persisted to disk."""
def __init__(self, vault: VaultClient, ttl_seconds: int = CACHE_TTL_SECONDS) -> None:
self._vault = vault
self._ttl = ttl_seconds
self._cache: dict[str, _CacheEntry] = {}
self._lock = asyncio.Lock()
async def get(self, path: str, key: str) -> str:
"""Retrieve a secret, reducing Vault API calls while keeping values fresh."""
cache_key = f"{path}:{key}"
now = time.monotonic()
entry = self._cache.get(cache_key)
if entry is not None and entry.expires_at > now:
return entry.value
# The lock collapses a thundering herd of concurrent misses into one fetch.
async with self._lock:
entry = self._cache.get(cache_key)
if entry is not None and entry.expires_at > time.monotonic():
return entry.value
secrets = await self._vault.get_secret(path)
try:
value = secrets[key]
except KeyError as exc:
raise KeyError(f"secret key {key!r} not found at path {path!r}") from exc
self._cache[cache_key] = _CacheEntry(
value=value,
expires_at=time.monotonic() + self._ttl,
)
return value
## Аудит секретов
Что логировать
Зачем
Доступ к секрету
Кто и когда читал
Изменение секрета
Кто и когда менял
Неудачные попытки
Обнаружение атак
Ротация
Подтверждение процесса
Правило: Никогда не логируйте значения секретов. Логируйте только факт доступа (кто, когда, какой секрет, результат).