| | | 1 | | import pc from 'picocolors'; |
| | | 2 | | import { EnvironmentVariable } from '../../domain/EnvironmentVariable.js'; |
| | | 3 | | import { |
| | | 4 | | ExpiredCredentialsError, |
| | | 5 | | type SecretFetchFailure, |
| | | 6 | | SsoSessionExpiredError, |
| | | 7 | | } from '../../domain/errors/DomainErrors.js'; |
| | | 8 | | import type { ILogger } from '../../domain/ports/ILogger.js'; |
| | | 9 | | import type { ISecretMasker } from '../../domain/ports/ISecretMasker.js'; |
| | | 10 | | import type { ISecretProvider } from '../../domain/ports/ISecretProvider.js'; |
| | | 11 | | import { describeError } from '../../infrastructure/describeError.js'; |
| | | 12 | | |
| | | 13 | | export type ResolvedSecret = { |
| | | 14 | | status: 'resolved'; |
| | | 15 | | envVar: string; |
| | | 16 | | value: string; |
| | | 17 | | masked: string; |
| | | 18 | | }; |
| | | 19 | | |
| | | 20 | | export type UnresolvedSecret = { |
| | | 21 | | status: 'warning'; |
| | | 22 | | envVar: string; |
| | | 23 | | path: string; |
| | | 24 | | reason: 'not-found' | 'empty'; |
| | | 25 | | }; |
| | | 26 | | |
| | | 27 | | export type FailedSecret = { |
| | | 28 | | status: 'error'; |
| | | 29 | | envVar: string; |
| | | 30 | | /** Already masked. */ |
| | | 31 | | path: string; |
| | | 32 | | reason: string; |
| | | 33 | | }; |
| | | 34 | | |
| | | 35 | | export type SecretOutcome = ResolvedSecret | UnresolvedSecret | FailedSecret; |
| | | 36 | | |
| | | 37 | | export type SecretResolution = { |
| | | 38 | | /** One outcome per mapping, in map-file order. */ |
| | | 39 | | outcomes: SecretOutcome[]; |
| | | 40 | | resolved: ResolvedSecret[]; |
| | | 41 | | warnings: UnresolvedSecret[]; |
| | | 42 | | errors: FailedSecret[]; |
| | | 43 | | }; |
| | | 44 | | |
| | 13 | 45 | | const LABEL_WIDTH = 20; |
| | 13 | 46 | | const RULE = pc.yellow('\u2501'.repeat(60)); |
| | | 47 | | |
| | | 48 | | /** |
| | | 49 | | * Resolves the secrets declared in a map file and presents the outcome. |
| | | 50 | | * |
| | | 51 | | * Shared by every mode that reads secrets (pull and exec) so the outcomes, |
| | | 52 | | * masking, failure reasons and the per-secret block stay identical. Each mode |
| | | 53 | | * decides what to do with missing or empty secrets: pull skips them, exec |
| | | 54 | | * refuses to start the child. |
| | | 55 | | */ |
| | | 56 | | export class SecretResolver { |
| | | 57 | | constructor( |
| | 166 | 58 | | private readonly secretProvider: ISecretProvider, |
| | 166 | 59 | | private readonly logger: ILogger, |
| | 166 | 60 | | private readonly secretMasker: ISecretMasker, |
| | | 61 | | ) {} |
| | | 62 | | |
| | | 63 | | /** |
| | | 64 | | * Fetches every mapped secret concurrently. Every resolved value is |
| | | 65 | | * registered with the secret masker as soon as it is known. Expired |
| | | 66 | | * credentials and SSO sessions propagate so callers can recover; any other |
| | | 67 | | * provider failure becomes an error outcome with a masked path. |
| | | 68 | | */ |
| | | 69 | | async resolve(mappings: Record<string, string>): Promise<SecretResolution> { |
| | 90 | 70 | | const outcomes = await Promise.all( |
| | | 71 | | Object.entries(mappings).map(([envVar, secretName]) => |
| | 156 | 72 | | this.resolveOne(envVar, secretName), |
| | | 73 | | ), |
| | | 74 | | ); |
| | | 75 | | |
| | 80 | 76 | | return { |
| | | 77 | | outcomes, |
| | | 78 | | resolved: outcomes.filter( |
| | 142 | 79 | | (outcome): outcome is ResolvedSecret => outcome.status === 'resolved', |
| | | 80 | | ), |
| | | 81 | | warnings: outcomes.filter( |
| | 142 | 82 | | (outcome): outcome is UnresolvedSecret => outcome.status === 'warning', |
| | | 83 | | ), |
| | | 84 | | errors: outcomes.filter( |
| | 142 | 85 | | (outcome): outcome is FailedSecret => outcome.status === 'error', |
| | | 86 | | ), |
| | | 87 | | }; |
| | | 88 | | } |
| | | 89 | | |
| | | 90 | | /** |
| | | 91 | | * Builds the failure entry used by `SecretsFetchError` for an outcome that |
| | | 92 | | * did not resolve. Missing and empty secrets use the reasons `not found` |
| | | 93 | | * and `empty`. |
| | | 94 | | */ |
| | | 95 | | static toFailure( |
| | | 96 | | outcome: UnresolvedSecret | FailedSecret, |
| | | 97 | | ): SecretFetchFailure { |
| | 11 | 98 | | if (outcome.status === 'error') { |
| | 4 | 99 | | return { |
| | | 100 | | envVar: outcome.envVar, |
| | | 101 | | path: outcome.path, |
| | | 102 | | reason: outcome.reason, |
| | | 103 | | }; |
| | | 104 | | } |
| | 7 | 105 | | return { |
| | | 106 | | envVar: outcome.envVar, |
| | | 107 | | path: EnvironmentVariable.maskSecretPath(outcome.path), |
| | | 108 | | reason: outcome.reason === 'not-found' ? 'not found' : 'empty', |
| | | 109 | | }; |
| | | 110 | | } |
| | | 111 | | |
| | | 112 | | /** |
| | | 113 | | * Logs the per-secret block: resolved secrets with masked values, then the |
| | | 114 | | * skipped ones as warnings. |
| | | 115 | | */ |
| | | 116 | | logSecretsSection( |
| | | 117 | | resolved: ResolvedSecret[], |
| | | 118 | | warnings: UnresolvedSecret[], |
| | | 119 | | ): void { |
| | 22 | 120 | | if (resolved.length === 0 && warnings.length === 0) { |
| | 2 | 121 | | return; |
| | | 122 | | } |
| | | 123 | | |
| | 20 | 124 | | this.logger.info(`\n${pc.bold(pc.yellow('\u{1FA99} RESOLVING SECRETS'))}`); |
| | 20 | 125 | | this.logger.info(RULE); |
| | 20 | 126 | | for (const outcome of resolved) { |
| | 16 | 127 | | this.logger.info( |
| | | 128 | | ` ${pc.green('\u2713 ')}${pc.bold(pad(outcome.envVar))}${pc.dim( |
| | | 129 | | '\u2192 ', |
| | | 130 | | )}${pc.dim(outcome.masked)}`, |
| | | 131 | | ); |
| | | 132 | | } |
| | 20 | 133 | | this.logSkippedWarnings(warnings); |
| | | 134 | | } |
| | | 135 | | |
| | | 136 | | private logSkippedWarnings(warnings: UnresolvedSecret[]): void { |
| | 20 | 137 | | for (const outcome of warnings) { |
| | 6 | 138 | | const maskedPath = EnvironmentVariable.maskSecretPath(outcome.path); |
| | 6 | 139 | | if (outcome.reason === 'not-found') { |
| | 2 | 140 | | this.logger.warn( |
| | | 141 | | ` ${pc.red('\u2717 ')}${pc.bold( |
| | | 142 | | pc.red(pad(outcome.envVar)), |
| | | 143 | | )} ${pc.red(`secret not found (path: ${maskedPath}) \u2014 skipped`)}`, |
| | | 144 | | ); |
| | 2 | 145 | | continue; |
| | | 146 | | } |
| | 4 | 147 | | this.logger.warn( |
| | | 148 | | ` ${pc.yellow('\u26A0 ')}${pc.bold(pad(outcome.envVar))} ${pc.dim( |
| | | 149 | | `no value found (path: ${maskedPath}) \u2014 skipped`, |
| | | 150 | | )}`, |
| | | 151 | | ); |
| | | 152 | | } |
| | | 153 | | } |
| | | 154 | | |
| | | 155 | | private async resolveOne( |
| | | 156 | | envVar: string, |
| | | 157 | | secretName: string, |
| | | 158 | | ): Promise<SecretOutcome> { |
| | 156 | 159 | | try { |
| | 156 | 160 | | const value = await this.secretProvider.getSecret(secretName); |
| | 140 | 161 | | if (value === undefined) { |
| | 7 | 162 | | return { |
| | | 163 | | status: 'warning', |
| | | 164 | | envVar, |
| | | 165 | | path: secretName, |
| | | 166 | | reason: 'not-found', |
| | | 167 | | }; |
| | | 168 | | } |
| | 133 | 169 | | if (value === '') { |
| | 6 | 170 | | return { status: 'warning', envVar, path: secretName, reason: 'empty' }; |
| | | 171 | | } |
| | | 172 | | |
| | 127 | 173 | | this.secretMasker.mask(value); |
| | 127 | 174 | | const masked = EnvironmentVariable.secret(envVar, value).maskedValue; |
| | | 175 | | |
| | 127 | 176 | | return { status: 'resolved', envVar, value, masked }; |
| | | 177 | | } catch (error) { |
| | 16 | 178 | | if ( |
| | | 179 | | error instanceof ExpiredCredentialsError || |
| | | 180 | | error instanceof SsoSessionExpiredError |
| | | 181 | | ) { |
| | 12 | 182 | | throw error; |
| | | 183 | | } |
| | 4 | 184 | | const maskedPath = EnvironmentVariable.maskSecretPath(secretName); |
| | 4 | 185 | | const reason = describeErrorReason(error, maskedPath); |
| | 4 | 186 | | return { status: 'error', envVar, path: maskedPath, reason }; |
| | | 187 | | } |
| | | 188 | | } |
| | | 189 | | } |
| | | 190 | | |
| | | 191 | | function describeErrorReason(error: unknown, maskedPath: string): string { |
| | 4 | 192 | | const message = describeError(error); |
| | 4 | 193 | | const duplicatedPrefix = `${maskedPath}: `; |
| | 4 | 194 | | return message.startsWith(duplicatedPrefix) |
| | | 195 | | ? message.slice(duplicatedPrefix.length) |
| | | 196 | | : message; |
| | | 197 | | } |
| | | 198 | | |
| | | 199 | | function pad(name: string): string { |
| | 22 | 200 | | return name.padEnd(LABEL_WIDTH); |
| | | 201 | | } |