Files
sencho/backend/src/__tests__/secret-classification.test.ts
T
Anso 57a0856ffc feat(stacks): per-stack environment inventory and secret-safe guardrails (#1397)
* feat(stacks): per-stack environment inventory and secret-safe guardrails

Add an Environment tab to Stack Anatomy that derives a per-stack inventory
of environment variables from the compose files and env files. Each variable
shows its source, whether Compose interpolates it or injects it into a
container, and a status (present, missing, unused, duplicate, or shell-only),
plus likely-secret classification. The inventory works from variable names
only: a value is never read, returned, or logged, and a likely secret shows
presence only. A copy env checklist action exports names and status without
values.

Surface a missing required env_file as a Compose Doctor preflight finding,
and add an opt-in node setting that refuses a deploy or update when a
required ${VAR:?...} variable is unset or empty, before any backup, pull, or
up runs. Default off.

The Environment tab is capability-gated so it hides on older remote nodes.

* fix(stacks): harden env-file reader against a stat-then-open race

Open the env-file handle first and fstat the open handle instead of
stat-ing the path before opening, removing the check-then-use window in
readEnvFileKeys. Use a secure mkdtemp directory for the out-of-base test
path instead of a predictable name in the temp root.

* fix(stacks): resolve nested env_file paths per compose file, reconcile inline keys per service

Resolve each env_file relative to the directory of the compose file that
declared it, so a nested multi-file Git override (infra/prod.yml referencing
./prod.env) lands next to that file instead of the stack root. The root
compose file is unaffected, since its directory is the stack directory.

Reconcile inline environment provenance per service, so a key an override
removed from one service's effective env is not labeled compose-inline just
because another service injects the same name from a different source.
2026-06-20 11:58:42 -04:00

36 lines
1.3 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { isLikelySecretKey } from '../helpers/secretClassification';
describe('isLikelySecretKey', () => {
it('flags keys whose segments are known secret words', () => {
for (const k of [
'DB_PASSWORD', 'API_KEY', 'PRIVATE_KEY', 'CLIENT_SECRET', 'WEBHOOK_SECRET',
'GITHUB_TOKEN', 'APP_PASS', 'JWT_SECRET', 'AUTH_TOKEN', 'REDIS_PASSWORD',
'SECRET_KEY_BASE', 'MAIL_PASSPHRASE',
]) {
expect(isLikelySecretKey(k), k).toBe(true);
}
});
it('flags connection-string keys whose segments are innocuous', () => {
for (const k of ['DATABASE_URL', 'REDIS_URL', 'MONGO_URI', 'MONGODB_URI', 'AMQP_URL', 'DSN']) {
expect(isLikelySecretKey(k), k).toBe(true);
}
});
it('does not flag innocuous keys that merely contain a secret word as a substring', () => {
for (const k of [
'KEYCLOAK_URL', 'APP_PORT', 'NODE_ENV', 'LOG_LEVEL', 'PUBLIC_URL',
'COMPASS_HOST', 'BYPASS_CACHE', 'TZ', 'SERVER_NAME', 'AUTHORS_FILE',
]) {
expect(isLikelySecretKey(k), k).toBe(false);
}
});
it('is case-insensitive and trims, and rejects empty', () => {
expect(isLikelySecretKey(' db_password ')).toBe(true);
expect(isLikelySecretKey('Api_Key')).toBe(true);
expect(isLikelySecretKey('')).toBe(false);
});
});