@nage-api/core 1.0.0-beta.2

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 (81) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +141 -0
  3. package/dist/bootstrap/bootstrap.d.ts +48 -0
  4. package/dist/bootstrap/bootstrap.js +255 -0
  5. package/dist/bootstrap/drain.d.ts +48 -0
  6. package/dist/bootstrap/drain.js +113 -0
  7. package/dist/bootstrap/lifecycle.d.ts +30 -0
  8. package/dist/bootstrap/lifecycle.js +64 -0
  9. package/dist/bootstrap/process-guards.d.ts +42 -0
  10. package/dist/bootstrap/process-guards.js +103 -0
  11. package/dist/bootstrap/query-parser.d.ts +34 -0
  12. package/dist/bootstrap/query-parser.js +37 -0
  13. package/dist/bootstrap/shutdown.d.ts +55 -0
  14. package/dist/bootstrap/shutdown.js +182 -0
  15. package/dist/constants.d.ts +32 -0
  16. package/dist/constants.js +48 -0
  17. package/dist/context/active-context.d.ts +23 -0
  18. package/dist/context/active-context.js +34 -0
  19. package/dist/context/request-context.middleware.d.ts +31 -0
  20. package/dist/context/request-context.middleware.js +95 -0
  21. package/dist/context/request-context.service.d.ts +29 -0
  22. package/dist/context/request-context.service.js +67 -0
  23. package/dist/decorators/owner.decorator.d.ts +18 -0
  24. package/dist/decorators/owner.decorator.js +31 -0
  25. package/dist/decorators/public.decorator.d.ts +13 -0
  26. package/dist/decorators/public.decorator.js +23 -0
  27. package/dist/decorators/version.decorators.d.ts +34 -0
  28. package/dist/decorators/version.decorators.js +40 -0
  29. package/dist/errors/catalog.d.ts +149 -0
  30. package/dist/errors/catalog.js +289 -0
  31. package/dist/errors/index.d.ts +3 -0
  32. package/dist/errors/index.js +22 -0
  33. package/dist/errors/nage.error.d.ts +43 -0
  34. package/dist/errors/nage.error.js +45 -0
  35. package/dist/guards/api-version.guard.d.ts +20 -0
  36. package/dist/guards/api-version.guard.js +73 -0
  37. package/dist/http/all-exceptions.filter.d.ts +25 -0
  38. package/dist/http/all-exceptions.filter.js +256 -0
  39. package/dist/http/envelope.d.ts +25 -0
  40. package/dist/http/envelope.js +44 -0
  41. package/dist/http/no-envelope.decorator.d.ts +11 -0
  42. package/dist/http/no-envelope.decorator.js +16 -0
  43. package/dist/http/request-timeout.decorators.d.ts +23 -0
  44. package/dist/http/request-timeout.decorators.js +29 -0
  45. package/dist/http/request-timeout.interceptor.d.ts +28 -0
  46. package/dist/http/request-timeout.interceptor.js +75 -0
  47. package/dist/http/response.interceptor.d.ts +19 -0
  48. package/dist/http/response.interceptor.js +73 -0
  49. package/dist/index.d.ts +38 -0
  50. package/dist/index.js +135 -0
  51. package/dist/job/job.factory.d.ts +29 -0
  52. package/dist/job/job.factory.js +50 -0
  53. package/dist/logging/json.logger.d.ts +23 -0
  54. package/dist/logging/json.logger.js +136 -0
  55. package/dist/logging/nest-logger.adapter.d.ts +20 -0
  56. package/dist/logging/nest-logger.adapter.js +46 -0
  57. package/dist/module/core.module.d.ts +40 -0
  58. package/dist/module/core.module.js +112 -0
  59. package/dist/security/audit.d.ts +42 -0
  60. package/dist/security/audit.js +399 -0
  61. package/dist/security/index.d.ts +15 -0
  62. package/dist/security/index.js +50 -0
  63. package/dist/security/legacy-scan.d.ts +24 -0
  64. package/dist/security/legacy-scan.js +98 -0
  65. package/dist/security/random.d.ts +40 -0
  66. package/dist/security/random.js +87 -0
  67. package/dist/security/rate-limit.decorators.d.ts +24 -0
  68. package/dist/security/rate-limit.decorators.js +25 -0
  69. package/dist/security/rate-limit.guard.d.ts +44 -0
  70. package/dist/security/rate-limit.guard.js +130 -0
  71. package/dist/security/rate-limit.store.d.ts +30 -0
  72. package/dist/security/rate-limit.store.js +63 -0
  73. package/dist/security/redaction.d.ts +54 -0
  74. package/dist/security/redaction.js +146 -0
  75. package/dist/security/tls.d.ts +29 -0
  76. package/dist/security/tls.js +48 -0
  77. package/dist/tokens.d.ts +60 -0
  78. package/dist/tokens.js +89 -0
  79. package/dist/version.d.ts +5 -0
  80. package/dist/version.js +8 -0
  81. package/package.json +77 -0
@@ -0,0 +1,112 @@
1
+ "use strict";
2
+ /**
3
+ * `NageCoreModule.forRoot(config)` — the one module every application imports
4
+ * (PLAN.md §7.3).
5
+ *
6
+ * Registering the interceptor, filter and guard as providers (rather than
7
+ * `app.useGlobal*`) keeps them inside the DI container, so they can inject the
8
+ * context and the logger, and so tests get the same wiring as production.
9
+ */
10
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
11
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
12
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
13
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
14
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
15
+ };
16
+ var NageCoreModule_1;
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.NageCoreModule = void 0;
19
+ exports.buildValidationPipe = buildValidationPipe;
20
+ const common_1 = require("@nestjs/common");
21
+ const core_1 = require("@nestjs/core");
22
+ const lifecycle_js_1 = require("../bootstrap/lifecycle.js");
23
+ const request_context_middleware_js_1 = require("../context/request-context.middleware.js");
24
+ const request_context_service_js_1 = require("../context/request-context.service.js");
25
+ const api_version_guard_js_1 = require("../guards/api-version.guard.js");
26
+ const all_exceptions_filter_js_1 = require("../http/all-exceptions.filter.js");
27
+ const request_timeout_interceptor_js_1 = require("../http/request-timeout.interceptor.js");
28
+ const response_interceptor_js_1 = require("../http/response.interceptor.js");
29
+ const json_logger_js_1 = require("../logging/json.logger.js");
30
+ const rate_limit_guard_js_1 = require("../security/rate-limit.guard.js");
31
+ const rate_limit_store_js_1 = require("../security/rate-limit.store.js");
32
+ const tokens_js_1 = require("../tokens.js");
33
+ /**
34
+ * Validation defaults (§16.2). `forbidNonWhitelisted` means an unexpected
35
+ * property is an error rather than something silently dropped — a mass-assignment
36
+ * guard, not a style preference.
37
+ */
38
+ function buildValidationPipe(config) {
39
+ return new common_1.ValidationPipe({
40
+ whitelist: config.validation?.whitelist ?? true,
41
+ forbidNonWhitelisted: config.validation?.forbidNonWhitelisted ?? true,
42
+ transform: config.validation?.transform ?? true,
43
+ transformOptions: { enableImplicitConversion: false },
44
+ });
45
+ }
46
+ let NageCoreModule = NageCoreModule_1 = class NageCoreModule {
47
+ static forRoot(config, options = {}) {
48
+ const providers = [
49
+ { provide: tokens_js_1.NAGE_CONFIG, useValue: config },
50
+ // The process's phase, not this module's: `installShutdown` flips it to
51
+ // `draining` on a signal and `/health/ready` reads it (§21). Published as a
52
+ // provider so a controller can inject it rather than import a singleton.
53
+ { provide: tokens_js_1.NAGE_LIFECYCLE, useValue: (0, lifecycle_js_1.processLifecycle)() },
54
+ request_context_service_js_1.RequestContextService,
55
+ { provide: tokens_js_1.NAGE_REQUEST_CONTEXT, useExisting: request_context_service_js_1.RequestContextService },
56
+ { provide: tokens_js_1.NAGE_LOGGER, useClass: json_logger_js_1.JsonLogger },
57
+ request_context_middleware_js_1.RequestContextMiddleware,
58
+ { provide: core_1.APP_INTERCEPTOR, useClass: response_interceptor_js_1.ResponseInterceptor },
59
+ // Registered after the envelope interceptor, which makes it the inner one:
60
+ // the timeout has to be closer to the handler than anything that maps its
61
+ // result, and the error it raises travels out to the exception filter like
62
+ // any other. On by default, and `http.requestTimeoutMs: 0` is the only way
63
+ // to have no ceiling at all (§21).
64
+ { provide: core_1.APP_INTERCEPTOR, useClass: request_timeout_interceptor_js_1.RequestTimeoutInterceptor },
65
+ { provide: core_1.APP_FILTER, useClass: all_exceptions_filter_js_1.AllExceptionsFilter },
66
+ { provide: core_1.APP_GUARD, useClass: api_version_guard_js_1.ApiVersionGuard },
67
+ ];
68
+ // Throttling is global and opt-out (§12): the legacy framework registered
69
+ // no guard at all, so only one endpoint was ever limited. The in-process
70
+ // store is the default, and it counts per process — `options.rateLimitStore`
71
+ // is how a multi-instance deployment makes the limit mean what it says.
72
+ const rateLimitEnabled = config.security?.rateLimit?.enabled !== false;
73
+ if (rateLimitEnabled) {
74
+ providers.push({
75
+ provide: tokens_js_1.NAGE_RATE_LIMIT_STORE,
76
+ useValue: options.rateLimitStore ?? new rate_limit_store_js_1.MemoryRateLimitStore(),
77
+ }, { provide: core_1.APP_GUARD, useClass: rate_limit_guard_js_1.RateLimitGuard });
78
+ }
79
+ // Validation needs class-validator/class-transformer, which are optional
80
+ // peers: an app that has no DTOs should not have to install them.
81
+ if (config.validation?.enabled !== false) {
82
+ providers.push({ provide: core_1.APP_PIPE, useValue: buildValidationPipe(config) });
83
+ }
84
+ return {
85
+ module: NageCoreModule_1,
86
+ providers,
87
+ // Only export what was actually provided: exporting the store token while
88
+ // throttling is off makes the whole application fail to boot.
89
+ exports: [
90
+ tokens_js_1.NAGE_CONFIG,
91
+ tokens_js_1.NAGE_LIFECYCLE,
92
+ tokens_js_1.NAGE_LOGGER,
93
+ tokens_js_1.NAGE_REQUEST_CONTEXT,
94
+ request_context_service_js_1.RequestContextService,
95
+ ...(rateLimitEnabled ? [tokens_js_1.NAGE_RATE_LIMIT_STORE] : []),
96
+ ],
97
+ };
98
+ }
99
+ configure(consumer) {
100
+ // Every route, including ones added by feature packages. The middleware is
101
+ // idempotent, so an app that also installs it in `bootstrap()` is fine.
102
+ consumer
103
+ .apply(request_context_middleware_js_1.RequestContextMiddleware)
104
+ .forRoutes({ path: '*path', method: common_1.RequestMethod.ALL });
105
+ }
106
+ };
107
+ exports.NageCoreModule = NageCoreModule;
108
+ exports.NageCoreModule = NageCoreModule = NageCoreModule_1 = __decorate([
109
+ (0, common_1.Global)(),
110
+ (0, common_1.Module)({})
111
+ ], NageCoreModule);
112
+ //# sourceMappingURL=core.module.js.map
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The security audit behind `nage doctor` (PLAN.md §12, §24 Phase 4).
3
+ *
4
+ * Pure functions over a configuration and an environment: no filesystem, no CLI,
5
+ * no Nest. Phase 6's `nage doctor` renders these findings, `assertSecureConfig`
6
+ * enforces them at boot, and tests assert them directly — one implementation,
7
+ * three consumers.
8
+ *
9
+ * The reference list is §12's table: every insecure default the legacy framework
10
+ * shipped with should be findable here.
11
+ */
12
+ import type { NageConfig, SecurityFinding, SecuritySeverity } from '@nage-api/contracts';
13
+ export interface SecurityAuditInput {
14
+ readonly config: NageConfig;
15
+ /**
16
+ * Environment values, used only to judge secret **strength**. Values are never
17
+ * copied into a finding.
18
+ */
19
+ readonly env?: Record<string, string | undefined>;
20
+ }
21
+ /** Order used when sorting a report; worst first. */
22
+ export declare const SEVERITY_ORDER: readonly SecuritySeverity[];
23
+ /**
24
+ * Audit a configuration against the security baseline.
25
+ *
26
+ * @returns findings sorted worst-first; an empty array means nothing to fix.
27
+ */
28
+ export declare function auditSecurity(input: SecurityAuditInput): readonly SecurityFinding[];
29
+ /** Sort worst-first, then by location, so reports are stable across runs. */
30
+ export declare function sortFindings(findings: readonly SecurityFinding[]): SecurityFinding[];
31
+ /** `15m`, `24h`, `30d` → milliseconds. Unparseable input counts as zero. */
32
+ export declare function parseDurationMs(duration: string): number;
33
+ /**
34
+ * Boot-time gate (§21): refuse to start with a critical finding.
35
+ *
36
+ * On by default in production and staging. Development reports and continues —
37
+ * a baseline that blocks local work is a baseline people switch off.
38
+ */
39
+ export declare function assertSecureConfig(input: SecurityAuditInput): readonly SecurityFinding[];
40
+ /** Render findings for a terminal — used by `nage doctor` (Phase 6). */
41
+ export declare function formatFindings(findings: readonly SecurityFinding[]): string;
42
+ //# sourceMappingURL=audit.d.ts.map
@@ -0,0 +1,399 @@
1
+ "use strict";
2
+ /**
3
+ * The security audit behind `nage doctor` (PLAN.md §12, §24 Phase 4).
4
+ *
5
+ * Pure functions over a configuration and an environment: no filesystem, no CLI,
6
+ * no Nest. Phase 6's `nage doctor` renders these findings, `assertSecureConfig`
7
+ * enforces them at boot, and tests assert them directly — one implementation,
8
+ * three consumers.
9
+ *
10
+ * The reference list is §12's table: every insecure default the legacy framework
11
+ * shipped with should be findable here.
12
+ */
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.SEVERITY_ORDER = void 0;
15
+ exports.auditSecurity = auditSecurity;
16
+ exports.sortFindings = sortFindings;
17
+ exports.parseDurationMs = parseDurationMs;
18
+ exports.assertSecureConfig = assertSecureConfig;
19
+ exports.formatFindings = formatFindings;
20
+ const catalog_js_1 = require("../errors/catalog.js");
21
+ /** Order used when sorting a report; worst first. */
22
+ exports.SEVERITY_ORDER = [
23
+ 'critical',
24
+ 'high',
25
+ 'medium',
26
+ 'low',
27
+ 'info',
28
+ ];
29
+ /** Secrets shorter than this are rejected outright. */
30
+ const MINIMUM_SECRET_LENGTH = 16;
31
+ /** Values that appear in every default config and every breach list. */
32
+ const KNOWN_WEAK_SECRETS = new Set([
33
+ 'changeme',
34
+ 'change-me',
35
+ 'secret',
36
+ 'password',
37
+ 'passw0rd',
38
+ '123456',
39
+ '12345678',
40
+ 'admin',
41
+ 'test',
42
+ 'dev',
43
+ 'nage',
44
+ 'jwt-secret',
45
+ 'your-secret-key',
46
+ ]);
47
+ const SECRET_VARIABLE_PATTERN = /(SECRET|PASSWORD|PRIVATE_KEY|_KEY|TOKEN|CREDENTIAL|PEPPER)/i;
48
+ /** Variables that name a file or an identifier rather than a credential. */
49
+ const SECRET_VARIABLE_EXCEPTIONS = /(KEY_PATH|KEY_FILE|KEY_ID|PUBLIC_KEY|_URL|_URI)$/i;
50
+ /**
51
+ * A finding's severity depends on where it is found: an unverified TLS mode is a
52
+ * critical problem in production and a note in development.
53
+ */
54
+ function inProduction(environment) {
55
+ return environment === 'production' || environment === 'staging';
56
+ }
57
+ function escalate(environment, productionSeverity, otherwise) {
58
+ return inProduction(environment) ? productionSeverity : otherwise;
59
+ }
60
+ /**
61
+ * Audit a configuration against the security baseline.
62
+ *
63
+ * @returns findings sorted worst-first; an empty array means nothing to fix.
64
+ */
65
+ function auditSecurity(input) {
66
+ const { config } = input;
67
+ const environment = config.app.environment;
68
+ const findings = [];
69
+ // ── CORS (§12: `enableCors()` with no arguments allowed every origin) ─────
70
+ const cors = config.http?.cors;
71
+ if (cors?.origins === '*') {
72
+ findings.push({
73
+ code: 'SEC_CORS_WILDCARD',
74
+ severity: escalate(environment, 'critical', 'low'),
75
+ location: 'http.cors.origins',
76
+ message: 'Every origin is allowed to call this API.',
77
+ remediation: 'Replace "*" with an explicit list of the origins that need access.',
78
+ });
79
+ if (cors.credentials === true) {
80
+ findings.push({
81
+ code: 'SEC_CORS_WILDCARD_WITH_CREDENTIALS',
82
+ severity: 'critical',
83
+ location: 'http.cors.credentials',
84
+ message: 'Credentialed requests are accepted from any origin.',
85
+ remediation: 'Set an explicit origin allow-list, or turn credentials off.',
86
+ });
87
+ }
88
+ }
89
+ // ── Secure headers ────────────────────────────────────────────────────────
90
+ if (config.http?.helmet?.enabled === false) {
91
+ findings.push({
92
+ code: 'SEC_HELMET_DISABLED',
93
+ severity: escalate(environment, 'high', 'medium'),
94
+ location: 'http.helmet.enabled',
95
+ message: 'Security headers are not sent (no CSP, no nosniff, no frame protection).',
96
+ remediation: 'Remove `helmet.enabled: false`; disable individual headers instead if needed.',
97
+ });
98
+ }
99
+ else if (config.http?.helmet?.contentSecurityPolicy === false) {
100
+ findings.push({
101
+ code: 'SEC_CSP_DISABLED',
102
+ severity: 'medium',
103
+ location: 'http.helmet.contentSecurityPolicy',
104
+ message: 'Content-Security-Policy is disabled for the whole application.',
105
+ remediation: 'Scope the exemption to the routes that need it, such as Swagger UI.',
106
+ });
107
+ }
108
+ if (inProduction(environment) && config.http?.helmet?.hsts === false) {
109
+ findings.push({
110
+ code: 'SEC_HSTS_DISABLED',
111
+ severity: 'high',
112
+ location: 'http.helmet.hsts',
113
+ message: 'HSTS is switched off in a deployed environment.',
114
+ remediation: 'Leave HSTS on so browsers refuse to downgrade to plain HTTP.',
115
+ });
116
+ }
117
+ // ── TLS to the database (§12: `rejectUnauthorized: false`) ────────────────
118
+ const ssl = config.database?.ssl;
119
+ if (ssl === 'no-verify') {
120
+ findings.push({
121
+ code: 'SEC_TLS_NO_VERIFY',
122
+ severity: escalate(environment, 'critical', 'medium'),
123
+ location: 'database.ssl',
124
+ message: 'The database certificate is not verified; the connection is interceptable.',
125
+ remediation: 'Use "verify-full" and install the server CA where the app can read it.',
126
+ });
127
+ }
128
+ else if (ssl === 'disable' && config.database?.enabled === true) {
129
+ findings.push({
130
+ code: 'SEC_TLS_DISABLED',
131
+ severity: escalate(environment, 'critical', 'low'),
132
+ location: 'database.ssl',
133
+ message: 'The database connection is not encrypted.',
134
+ remediation: 'Set database.ssl to "verify-full" outside local development.',
135
+ });
136
+ }
137
+ // ── Input validation ──────────────────────────────────────────────────────
138
+ if (config.validation?.enabled === false) {
139
+ findings.push({
140
+ code: 'SEC_VALIDATION_DISABLED',
141
+ severity: 'high',
142
+ location: 'validation.enabled',
143
+ message: 'Request bodies are not validated.',
144
+ remediation: 'Re-enable validation; exempt individual routes instead if necessary.',
145
+ });
146
+ }
147
+ else if (config.validation?.forbidNonWhitelisted === false) {
148
+ findings.push({
149
+ code: 'SEC_VALIDATION_PERMISSIVE',
150
+ severity: 'medium',
151
+ location: 'validation.forbidNonWhitelisted',
152
+ message: 'Unexpected body properties are dropped silently instead of rejected.',
153
+ remediation: 'Set forbidNonWhitelisted: true so mass-assignment attempts fail loudly.',
154
+ });
155
+ }
156
+ // ── Throttling (§12: the legacy throttler had no global guard) ────────────
157
+ if (config.security?.rateLimit?.enabled === false) {
158
+ findings.push({
159
+ code: 'SEC_RATE_LIMIT_DISABLED',
160
+ severity: escalate(environment, 'high', 'low'),
161
+ location: 'security.rateLimit.enabled',
162
+ message: 'No global request limit; brute-force and scraping are unimpeded.',
163
+ remediation: 'Leave the global limit on and exempt specific routes with @SkipRateLimit().',
164
+ });
165
+ }
166
+ // ── Query limits (§12: `limit: -1` was unbounded) ─────────────────────────
167
+ const maxQueryLimit = config.database?.maxQueryLimit;
168
+ if (maxQueryLimit !== undefined && (maxQueryLimit <= 0 || maxQueryLimit > 1000)) {
169
+ findings.push({
170
+ code: 'SEC_UNBOUNDED_QUERY_LIMIT',
171
+ severity: maxQueryLimit <= 0 ? 'high' : 'medium',
172
+ location: 'database.maxQueryLimit',
173
+ message: maxQueryLimit <= 0
174
+ ? 'Clients can request an unbounded number of rows.'
175
+ : `A single request can ask for ${maxQueryLimit} rows.`,
176
+ remediation: 'Set a bounded maxQueryLimit (100 is the default) and use cursors for exports.',
177
+ });
178
+ }
179
+ // ── Deployment behaviour ──────────────────────────────────────────────────
180
+ if (inProduction(environment) && config.database?.migrations?.runOnBoot === true) {
181
+ findings.push({
182
+ code: 'SEC_MIGRATIONS_ON_BOOT',
183
+ severity: 'medium',
184
+ location: 'database.migrations.runOnBoot',
185
+ message: 'Every instance runs migrations at start-up, concurrently.',
186
+ remediation: 'Run `nage db migrate` as a deploy step instead.',
187
+ });
188
+ }
189
+ const level = config.logging?.level;
190
+ if (inProduction(environment) && (level === 'trace' || level === 'debug')) {
191
+ findings.push({
192
+ code: 'SEC_DEBUG_LOGGING',
193
+ severity: 'medium',
194
+ location: 'logging.level',
195
+ message: `Log level "${level}" in a deployed environment writes request detail to disk.`,
196
+ remediation: 'Use "info" or higher in production.',
197
+ });
198
+ }
199
+ if (config.shutdown?.enabled === false) {
200
+ findings.push({
201
+ code: 'SEC_SHUTDOWN_HOOKS_DISABLED',
202
+ severity: 'low',
203
+ location: 'shutdown.enabled',
204
+ message: 'Shutdown hooks are off; connections are dropped rather than drained on SIGTERM.',
205
+ remediation: 'Leave shutdown hooks enabled so deploys do not sever in-flight requests.',
206
+ });
207
+ }
208
+ if (config.http?.requestTimeoutMs === 0) {
209
+ findings.push({
210
+ code: 'SEC_NO_REQUEST_TIMEOUT',
211
+ severity: escalate(environment, 'medium', 'low'),
212
+ location: 'http.requestTimeoutMs',
213
+ // Availability is part of the threat model: a handler with no ceiling
214
+ // holds a connection, a rate-limit slot and (during a deploy) the drain,
215
+ // which is a resource-exhaustion vector a slow upstream can trigger by
216
+ // accident and an attacker on purpose.
217
+ message: 'No request timeout: a slow or wedged handler holds its connection indefinitely.',
218
+ remediation: 'Leave http.requestTimeoutMs at its default and use @RequestTimeout on the few routes that need longer.',
219
+ });
220
+ }
221
+ findings.push(...auditAuth(config, environment));
222
+ findings.push(...auditSecrets(input.env));
223
+ return sortFindings(findings);
224
+ }
225
+ /** Auth-specific checks; skipped entirely when auth is not enabled. */
226
+ function auditAuth(config, environment) {
227
+ const auth = config.auth;
228
+ if (auth?.enabled !== true)
229
+ return [];
230
+ const findings = [];
231
+ if (auth.jwt?.algorithm === 'HS256') {
232
+ findings.push({
233
+ code: 'SEC_WEAK_JWT_ALGORITHM',
234
+ severity: escalate(environment, 'high', 'low'),
235
+ location: 'auth.jwt.algorithm',
236
+ message: 'HS256 is symmetric: every service that verifies a token can also mint one.',
237
+ remediation: 'Use RS256 (or ES256) so only the issuer holds the signing key.',
238
+ });
239
+ }
240
+ const accessTtl = auth.jwt?.accessTtl;
241
+ if (accessTtl !== undefined && parseDurationMs(accessTtl) > 60 * 60 * 1000) {
242
+ findings.push({
243
+ code: 'SEC_LONG_ACCESS_TTL',
244
+ severity: 'medium',
245
+ location: 'auth.jwt.accessTtl',
246
+ message: `Access tokens live for ${accessTtl}; a stolen one stays valid that long.`,
247
+ remediation: 'Keep access tokens short (15m) and rely on refresh rotation.',
248
+ });
249
+ }
250
+ if (auth.refresh?.rotate === false) {
251
+ findings.push({
252
+ code: 'SEC_REFRESH_ROTATION_DISABLED',
253
+ severity: 'high',
254
+ location: 'auth.refresh.rotate',
255
+ message: 'Refresh tokens are reusable, so a captured one works until it expires.',
256
+ remediation: 'Rotate refresh tokens on every use.',
257
+ });
258
+ }
259
+ else if (auth.refresh?.reuseDetection === false) {
260
+ findings.push({
261
+ code: 'SEC_REUSE_DETECTION_DISABLED',
262
+ severity: 'medium',
263
+ location: 'auth.refresh.reuseDetection',
264
+ message: 'A replayed refresh token is not detected, so theft goes unnoticed.',
265
+ remediation: 'Enable reuse detection to revoke the session family on replay.',
266
+ });
267
+ }
268
+ const minLength = auth.password?.minLength;
269
+ if (minLength !== undefined && minLength < 12) {
270
+ findings.push({
271
+ code: 'SEC_WEAK_PASSWORD_POLICY',
272
+ severity: 'medium',
273
+ location: 'auth.password.minLength',
274
+ message: `Passwords as short as ${minLength} characters are accepted.`,
275
+ remediation: 'Require at least 12 characters.',
276
+ });
277
+ }
278
+ return findings;
279
+ }
280
+ /**
281
+ * Judge the strength of secret-shaped environment variables.
282
+ *
283
+ * Only the variable **name** ever reaches a finding: this report is written to
284
+ * CI logs and terminals.
285
+ */
286
+ function auditSecrets(env) {
287
+ if (env === undefined)
288
+ return [];
289
+ const findings = [];
290
+ for (const [name, value] of Object.entries(env)) {
291
+ if (!SECRET_VARIABLE_PATTERN.test(name) || SECRET_VARIABLE_EXCEPTIONS.test(name))
292
+ continue;
293
+ if (value === undefined || value === '') {
294
+ findings.push({
295
+ code: 'SEC_MISSING_SECRET',
296
+ severity: 'high',
297
+ location: name,
298
+ message: `${name} is declared but empty.`,
299
+ remediation: `Set ${name}, or remove it if the feature that needs it is disabled.`,
300
+ });
301
+ continue;
302
+ }
303
+ // A PEM key is long by construction and not subject to the length rule.
304
+ if (value.includes('BEGIN') && value.includes('PRIVATE KEY'))
305
+ continue;
306
+ if (KNOWN_WEAK_SECRETS.has(value.toLowerCase())) {
307
+ findings.push({
308
+ code: 'SEC_WEAK_SECRET',
309
+ severity: 'critical',
310
+ location: name,
311
+ message: `${name} is set to a well-known placeholder value.`,
312
+ remediation: `Generate a random value, e.g. \`openssl rand -base64 32\`.`,
313
+ });
314
+ }
315
+ else if (value.length < MINIMUM_SECRET_LENGTH) {
316
+ findings.push({
317
+ code: 'SEC_WEAK_SECRET',
318
+ severity: 'high',
319
+ location: name,
320
+ message: `${name} is shorter than ${MINIMUM_SECRET_LENGTH} characters.`,
321
+ remediation: `Generate a random value, e.g. \`openssl rand -base64 32\`.`,
322
+ });
323
+ }
324
+ }
325
+ return findings;
326
+ }
327
+ /** Sort worst-first, then by location, so reports are stable across runs. */
328
+ function sortFindings(findings) {
329
+ return [...findings].sort((left, right) => {
330
+ const bySeverity = exports.SEVERITY_ORDER.indexOf(left.severity) - exports.SEVERITY_ORDER.indexOf(right.severity);
331
+ return bySeverity !== 0 ? bySeverity : left.location.localeCompare(right.location);
332
+ });
333
+ }
334
+ /** `15m`, `24h`, `30d` → milliseconds. Unparseable input counts as zero. */
335
+ function parseDurationMs(duration) {
336
+ const match = /^(\d+)(ms|s|m|h|d)$/.exec(duration);
337
+ if (match === null)
338
+ return 0;
339
+ const amount = Number(match[1]);
340
+ switch (match[2]) {
341
+ case 'ms':
342
+ return amount;
343
+ case 's':
344
+ return amount * 1000;
345
+ case 'm':
346
+ return amount * 60_000;
347
+ case 'h':
348
+ return amount * 3_600_000;
349
+ case 'd':
350
+ return amount * 86_400_000;
351
+ default:
352
+ return 0;
353
+ }
354
+ }
355
+ /**
356
+ * Boot-time gate (§21): refuse to start with a critical finding.
357
+ *
358
+ * On by default in production and staging. Development reports and continues —
359
+ * a baseline that blocks local work is a baseline people switch off.
360
+ */
361
+ function assertSecureConfig(input) {
362
+ const findings = auditSecurity(input);
363
+ const environment = input.config.app.environment;
364
+ const enforce = input.config.security?.assertOnBoot ?? inProduction(environment);
365
+ const blocking = findings.filter((finding) => finding.severity === 'critical');
366
+ if (enforce && blocking.length > 0) {
367
+ throw new catalog_js_1.ConfigurationError({
368
+ detail: `Refusing to start: ${blocking.length} critical security ${blocking.length === 1 ? 'problem' : 'problems'} in the configuration (${blocking.map((finding) => finding.code).join(', ')})`,
369
+ meta: {
370
+ findings: blocking.map((finding) => `${finding.code} at ${finding.location}`),
371
+ environment,
372
+ },
373
+ });
374
+ }
375
+ return findings;
376
+ }
377
+ /** Render findings for a terminal — used by `nage doctor` (Phase 6). */
378
+ function formatFindings(findings) {
379
+ if (findings.length === 0)
380
+ return '✔ No security findings.';
381
+ const marks = {
382
+ critical: '✖',
383
+ high: '✖',
384
+ medium: '▲',
385
+ low: '▲',
386
+ info: 'ℹ',
387
+ };
388
+ return [
389
+ `Security findings (${findings.length}):`,
390
+ '',
391
+ ...findings.flatMap((finding) => [
392
+ ` ${marks[finding.severity]} [${finding.severity}] ${finding.code} — ${finding.location}`,
393
+ ` ${finding.message}`,
394
+ ` → ${finding.remediation}`,
395
+ ]),
396
+ '',
397
+ ].join('\n');
398
+ }
399
+ //# sourceMappingURL=audit.js.map
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The security baseline (PLAN.md §12, Phase 4).
3
+ *
4
+ * Secure defaults that apply everywhere, plus the audit that reports when a
5
+ * configuration departs from them.
6
+ */
7
+ export { randomHex, randomId, randomInt, randomOtp, randomString, randomToken, secureCompare, ALPHANUMERIC_ALPHABET, UNAMBIGUOUS_ALPHABET, } from './random.js';
8
+ export { redact, redactHeaders, redactUrl, redactionSet, REDACTED, REDACTED_HEADERS, REDACTED_QUERY_PARAMS, } from './redaction.js';
9
+ export { assertTlsAllowed, resolveTlsOptions, DEFAULT_TLS_MODE, type TlsOptions } from './tls.js';
10
+ export { MemoryRateLimitStore, type MemoryRateLimitStoreOptions } from './rate-limit.store.js';
11
+ export { RateLimit, SkipRateLimit, type RateLimitPolicy } from './rate-limit.decorators.js';
12
+ export { RateLimitGuard, clientIp, DEFAULT_RATE_LIMIT } from './rate-limit.guard.js';
13
+ export { assertSecureConfig, auditSecurity, formatFindings, parseDurationMs, sortFindings, SEVERITY_ORDER, type SecurityAuditInput, } from './audit.js';
14
+ export { scanForLegacyPatterns, type SourceFile } from './legacy-scan.js';
15
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,50 @@
1
+ "use strict";
2
+ /**
3
+ * The security baseline (PLAN.md §12, Phase 4).
4
+ *
5
+ * Secure defaults that apply everywhere, plus the audit that reports when a
6
+ * configuration departs from them.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.scanForLegacyPatterns = exports.SEVERITY_ORDER = exports.sortFindings = exports.parseDurationMs = exports.formatFindings = exports.auditSecurity = exports.assertSecureConfig = exports.DEFAULT_RATE_LIMIT = exports.clientIp = exports.RateLimitGuard = exports.SkipRateLimit = exports.RateLimit = exports.MemoryRateLimitStore = exports.DEFAULT_TLS_MODE = exports.resolveTlsOptions = exports.assertTlsAllowed = exports.REDACTED_QUERY_PARAMS = exports.REDACTED_HEADERS = exports.REDACTED = exports.redactionSet = exports.redactUrl = exports.redactHeaders = exports.redact = exports.UNAMBIGUOUS_ALPHABET = exports.ALPHANUMERIC_ALPHABET = exports.secureCompare = exports.randomToken = exports.randomString = exports.randomOtp = exports.randomInt = exports.randomId = exports.randomHex = void 0;
10
+ var random_js_1 = require("./random.js");
11
+ Object.defineProperty(exports, "randomHex", { enumerable: true, get: function () { return random_js_1.randomHex; } });
12
+ Object.defineProperty(exports, "randomId", { enumerable: true, get: function () { return random_js_1.randomId; } });
13
+ Object.defineProperty(exports, "randomInt", { enumerable: true, get: function () { return random_js_1.randomInt; } });
14
+ Object.defineProperty(exports, "randomOtp", { enumerable: true, get: function () { return random_js_1.randomOtp; } });
15
+ Object.defineProperty(exports, "randomString", { enumerable: true, get: function () { return random_js_1.randomString; } });
16
+ Object.defineProperty(exports, "randomToken", { enumerable: true, get: function () { return random_js_1.randomToken; } });
17
+ Object.defineProperty(exports, "secureCompare", { enumerable: true, get: function () { return random_js_1.secureCompare; } });
18
+ Object.defineProperty(exports, "ALPHANUMERIC_ALPHABET", { enumerable: true, get: function () { return random_js_1.ALPHANUMERIC_ALPHABET; } });
19
+ Object.defineProperty(exports, "UNAMBIGUOUS_ALPHABET", { enumerable: true, get: function () { return random_js_1.UNAMBIGUOUS_ALPHABET; } });
20
+ var redaction_js_1 = require("./redaction.js");
21
+ Object.defineProperty(exports, "redact", { enumerable: true, get: function () { return redaction_js_1.redact; } });
22
+ Object.defineProperty(exports, "redactHeaders", { enumerable: true, get: function () { return redaction_js_1.redactHeaders; } });
23
+ Object.defineProperty(exports, "redactUrl", { enumerable: true, get: function () { return redaction_js_1.redactUrl; } });
24
+ Object.defineProperty(exports, "redactionSet", { enumerable: true, get: function () { return redaction_js_1.redactionSet; } });
25
+ Object.defineProperty(exports, "REDACTED", { enumerable: true, get: function () { return redaction_js_1.REDACTED; } });
26
+ Object.defineProperty(exports, "REDACTED_HEADERS", { enumerable: true, get: function () { return redaction_js_1.REDACTED_HEADERS; } });
27
+ Object.defineProperty(exports, "REDACTED_QUERY_PARAMS", { enumerable: true, get: function () { return redaction_js_1.REDACTED_QUERY_PARAMS; } });
28
+ var tls_js_1 = require("./tls.js");
29
+ Object.defineProperty(exports, "assertTlsAllowed", { enumerable: true, get: function () { return tls_js_1.assertTlsAllowed; } });
30
+ Object.defineProperty(exports, "resolveTlsOptions", { enumerable: true, get: function () { return tls_js_1.resolveTlsOptions; } });
31
+ Object.defineProperty(exports, "DEFAULT_TLS_MODE", { enumerable: true, get: function () { return tls_js_1.DEFAULT_TLS_MODE; } });
32
+ var rate_limit_store_js_1 = require("./rate-limit.store.js");
33
+ Object.defineProperty(exports, "MemoryRateLimitStore", { enumerable: true, get: function () { return rate_limit_store_js_1.MemoryRateLimitStore; } });
34
+ var rate_limit_decorators_js_1 = require("./rate-limit.decorators.js");
35
+ Object.defineProperty(exports, "RateLimit", { enumerable: true, get: function () { return rate_limit_decorators_js_1.RateLimit; } });
36
+ Object.defineProperty(exports, "SkipRateLimit", { enumerable: true, get: function () { return rate_limit_decorators_js_1.SkipRateLimit; } });
37
+ var rate_limit_guard_js_1 = require("./rate-limit.guard.js");
38
+ Object.defineProperty(exports, "RateLimitGuard", { enumerable: true, get: function () { return rate_limit_guard_js_1.RateLimitGuard; } });
39
+ Object.defineProperty(exports, "clientIp", { enumerable: true, get: function () { return rate_limit_guard_js_1.clientIp; } });
40
+ Object.defineProperty(exports, "DEFAULT_RATE_LIMIT", { enumerable: true, get: function () { return rate_limit_guard_js_1.DEFAULT_RATE_LIMIT; } });
41
+ var audit_js_1 = require("./audit.js");
42
+ Object.defineProperty(exports, "assertSecureConfig", { enumerable: true, get: function () { return audit_js_1.assertSecureConfig; } });
43
+ Object.defineProperty(exports, "auditSecurity", { enumerable: true, get: function () { return audit_js_1.auditSecurity; } });
44
+ Object.defineProperty(exports, "formatFindings", { enumerable: true, get: function () { return audit_js_1.formatFindings; } });
45
+ Object.defineProperty(exports, "parseDurationMs", { enumerable: true, get: function () { return audit_js_1.parseDurationMs; } });
46
+ Object.defineProperty(exports, "sortFindings", { enumerable: true, get: function () { return audit_js_1.sortFindings; } });
47
+ Object.defineProperty(exports, "SEVERITY_ORDER", { enumerable: true, get: function () { return audit_js_1.SEVERITY_ORDER; } });
48
+ var legacy_scan_js_1 = require("./legacy-scan.js");
49
+ Object.defineProperty(exports, "scanForLegacyPatterns", { enumerable: true, get: function () { return legacy_scan_js_1.scanForLegacyPatterns; } });
50
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Source scanner for the insecure patterns the legacy framework shipped
3
+ * (PLAN.md §12, §23.3 — `nage doctor --legacy`).
4
+ *
5
+ * The config audit covers a `@nage-api` application. This covers the repository it
6
+ * is being migrated from, where the same problems live as literals in source:
7
+ * `rejectUnauthorized: false`, `Math.random()` for tokens, `synchronize: true`,
8
+ * an argument-less `enableCors()`, uuid v1, and shipped default credentials.
9
+ *
10
+ * It takes file contents rather than paths so it stays testable and free of any
11
+ * filesystem dependency; the CLI supplies the files.
12
+ */
13
+ import type { SecurityFinding } from '@nage-api/contracts';
14
+ export interface SourceFile {
15
+ readonly path: string;
16
+ readonly content: string;
17
+ }
18
+ /**
19
+ * Scan files for legacy insecure patterns.
20
+ *
21
+ * @returns one finding per match, located as `path:line`.
22
+ */
23
+ export declare function scanForLegacyPatterns(files: readonly SourceFile[]): readonly SecurityFinding[];
24
+ //# sourceMappingURL=legacy-scan.d.ts.map