@fjall/components-infrastructure 39.0.7 → 39.0.8

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.
@@ -0,0 +1,57 @@
1
+ /**
2
+ * How the secrets resolver names a failure without anything it carries: the
3
+ * class name and code of the error and of up to two causes, as
4
+ * `TypeError (cause: AggregateError ECONNREFUSED)`. Node's fetch keeps the
5
+ * bytes of an answer it could not parse as HTTP on its cause, and quotes a
6
+ * header value it refuses in its message (Node 20.19.2, 22.17.1, 24.18.1,
7
+ * 24.19.0 and 24.21.0, measured 2026-10-12), so no message is repeated.
8
+ *
9
+ * The rule is `describeErrorIdentity`'s in `@fjall/util` from 40.0.0, which
10
+ * this layer cannot load; 39.x's `@fjall/util` has no such function, so
11
+ * `lib/__tests__/secretsResolverErrorIdentity.test.ts` holds this layer to
12
+ * the rule's text alone.
13
+ */
14
+
15
+ /** The shape of an error's class name, such as `TypeError`. */
16
+ const ERROR_NAME = /^[A-Z][A-Za-z0-9]{0,58}(?:Error|Exception)$/;
17
+
18
+ /** The shape of a Node or undici error code, such as `ECONNREFUSED`. */
19
+ const ERROR_CODE = /^[A-Z][A-Z0-9_]{0,63}$/;
20
+
21
+ /** How many links of a cause chain a description names, the error's own first. */
22
+ const CAUSE_LINKS = 3;
23
+
24
+ function nameAndCode(link) {
25
+ if (!(link instanceof Error)) return "a value that is not an Error";
26
+ const name =
27
+ typeof link.name === "string" && ERROR_NAME.test(link.name)
28
+ ? link.name
29
+ : "Error";
30
+ const code =
31
+ "code" in link &&
32
+ typeof link.code === "string" &&
33
+ ERROR_CODE.test(link.code)
34
+ ? ` ${link.code}`
35
+ : "";
36
+ return `${name}${code}`;
37
+ }
38
+
39
+ /**
40
+ * `err` named by its class name and code, and its causes', never by a
41
+ * message. A name or code is repeated only in the shape Node and undici give
42
+ * one; a shape is not proof of origin, so this is for errors the runtime
43
+ * built, as a failed fetch or body read is.
44
+ */
45
+ export function describeErrorIdentity(err) {
46
+ const links = [];
47
+ const seen = new Set();
48
+ let link = err;
49
+ while (links.length < CAUSE_LINKS) {
50
+ links.push(nameAndCode(link));
51
+ if (!(link instanceof Error)) break;
52
+ seen.add(link);
53
+ if (link.cause === undefined || seen.has(link.cause)) break;
54
+ link = link.cause;
55
+ }
56
+ return links.reduceRight((inner, outer) => `${outer} (cause: ${inner})`);
57
+ }
@@ -3,28 +3,72 @@
3
3
  #
4
4
  # Invoked by AWS_LAMBDA_EXEC_WRAPPER before the Lambda runtime starts.
5
5
  # Calls the Node.js resolver to fetch secrets from the AWS Parameters and
6
- # Secrets Extension, then execs into the original runtime bootstrap.
6
+ # Secrets Extension, then execs into the original runtime bootstrap with
7
+ # each secret in its environment.
7
8
  #
8
- # The resolver outputs `export KEY='value'` lines which we eval to inject
9
- # secrets as environment variables visible to the handler.
9
+ # The resolver writes one `NAME=VALUE` line per secret, VALUE with each
10
+ # backslash written `\\` and each newline `\n` (encodeValue in
11
+ # resolve-secrets.mjs). Each VALUE is decoded with `printf '%b'` and handed
12
+ # to env(1) as an assignment: nothing the resolver writes is evaluated, so no
13
+ # value can run as code, and no shell variable is assigned from a secret's
14
+ # name, so none can clash with this script's variables or bash's own. The
15
+ # Lambda construct attaches this layer only to a function with secrets
16
+ # (addSecretsSupport in lib/resources/aws/compute/lambda.ts), so the resolver
17
+ # runs at every start and decides for itself what is configured. It runs
18
+ # the resolver without the function's NODE_OPTIONS, so its output holds the
19
+ # resolver's lines alone.
20
+ #
21
+ # Limit (2026-10-12): each value is in env(1)'s argument list for the moment
22
+ # between this script's exec and env's own. Every process of a Lambda
23
+ # function runs as one user, so nothing that cannot already read the
24
+ # function's environment can read it there, but a monitor that records each
25
+ # exec's arguments would record it.
10
26
 
27
+ # A SHELLOPTS in the function's environment can turn on tracing, which would
28
+ # write each command this script runs, secrets included, to the function's
29
+ # logs, or verbose mode, which would write the script itself.
30
+ set +o xtrace +o verbose
11
31
  set -euo pipefail
12
32
 
13
- # Only run resolver if secrets are configured
14
- if [ -n "${SSM_SECRET_NAMES:-}" ] || env | grep -q '_SECRET_ARN='; then
15
- RESOLVER_OUTPUT=$(/var/lang/bin/node /opt/bin/resolve-secrets.mjs)
16
- if [ -n "$RESOLVER_OUTPUT" ]; then
17
- # Validate each line matches `export NAME='...'` before eval to prevent
18
- # accidental code execution if the resolver ever emits unexpected output
19
- while IFS= read -r line; do
20
- if [[ "$line" =~ ^export\ [a-zA-Z_][a-zA-Z0-9_]*= ]]; then
21
- eval "$line"
22
- else
23
- echo "[fjall-resolver] Unexpected output from resolver: ${line:0:80}" >&2
24
- exit 1
25
- fi
26
- done <<< "$RESOLVER_OUTPUT"
27
- fi
33
+ if [ "$#" -eq 0 ]; then
34
+ # env(1) given no command lists its environment, every secret with it.
35
+ echo "[fjall-resolver] No runtime command was given to start" >&2
36
+ exit 64
28
37
  fi
29
38
 
30
- exec "$@"
39
+ # Appends one NAME=VALUE word to `assignments` for each line the resolver
40
+ # writes. Its variables are local: a variable of the function's environment
41
+ # that shares a name with one of them reaches the runtime as configured, and
42
+ # none of them reaches it when the function's SHELLOPTS includes allexport.
43
+ collect_assignments() {
44
+ local RESOLVER_OUTPUT line line_number name value
45
+ # The resolver runs without the function's NODE_OPTIONS: a module that
46
+ # option preloads would run in the resolver's process too, and anything it
47
+ # printed would join the resolver's output.
48
+ RESOLVER_OUTPUT=$(/usr/bin/env -u NODE_OPTIONS /var/lang/bin/node /opt/bin/resolve-secrets.mjs)
49
+ [ -n "$RESOLVER_OUTPUT" ] || return 0
50
+ line_number=0
51
+ while IFS= read -r line; do
52
+ line_number=$((line_number + 1))
53
+ name=${line%%=*}
54
+ if [[ "$line" != *=* || ! "$name" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then
55
+ # The line can hold a secret, so only its number is named.
56
+ echo "[fjall-resolver] Line ${line_number} of the resolver's output is not NAME=VALUE" >&2
57
+ exit 1
58
+ fi
59
+ # The dot keeps printf's result from being empty, which bash 3.2's
60
+ # printf -v takes as an order to unset the variable; it comes off after.
61
+ printf -v value '%b.' "${line#*=}"
62
+ assignments+=("$name=${value%.}")
63
+ done <<< "$RESOLVER_OUTPUT"
64
+ }
65
+
66
+ # bash exports no array, so a variable of the function's environment named
67
+ # `assignments` is handed to the runtime as the first assignment instead.
68
+ assignments=(${assignments+"assignments=$assignments"})
69
+ collect_assignments
70
+ # allexport exports a function as it does a variable.
71
+ unset -f collect_assignments
72
+
73
+ # The guarded expansion keeps an empty list legal under `set -u` before bash 4.4.
74
+ exec /usr/bin/env ${assignments[@]+"${assignments[@]}"} "$@"
@@ -1,25 +1,38 @@
1
1
  /**
2
- * Fjall Secrets Resolver — runs before Lambda handler via AWS_LAMBDA_EXEC_WRAPPER.
2
+ * Fjall Secrets Resolver — runs before the Lambda handler via
3
+ * AWS_LAMBDA_EXEC_WRAPPER.
3
4
  *
4
- * Resolves secrets from two sources using the AWS Parameters and Secrets Extension
5
- * HTTP cache at localhost:2773:
5
+ * Resolves secrets from two sources using the AWS Parameters and Secrets
6
+ * Extension HTTP cache at localhost:2773:
6
7
  *
7
8
  * 1. SSM Parameter Store (user-managed secrets via `fjall secrets set`)
8
- * Reads SSM_SECRETS_PATH + SSM_SECRET_NAMES, fetches each parameter,
9
- * exports as environment variables.
9
+ * Reads SSM_SECRETS_PATH + SSM_SECRET_NAMES and fetches each parameter.
10
10
  *
11
11
  * 2. Secrets Manager (CDK-managed secrets, e.g. database credentials)
12
- * Scans for *_SECRET_ARN env vars, fetches each secret,
13
- * optionally extracts a JSON field via the matching *_SECRET_FIELD var,
14
- * exports as the prefix (e.g. DATABASE_PASSWORD_SECRET_ARN → DATABASE_PASSWORD).
12
+ * Scans for *_SECRET_ARN env vars, fetches each secret, optionally
13
+ * extracts a JSON field via the matching *_SECRET_FIELD var, and names it
14
+ * by the prefix (e.g. DATABASE_PASSWORD_SECRET_ARN → DATABASE_PASSWORD).
15
15
  *
16
- * Outputs `export KEY='value'` lines to stdout. The bash wrapper evals this output
17
- * and then execs into the Lambda runtime.
16
+ * Writes one `NAME=VALUE` line per secret to stdout, VALUE as `encodeValue`
17
+ * writes it. The bash wrapper beside this file decodes each VALUE with
18
+ * `printf '%b'` and execs into the Lambda runtime with it in the
19
+ * environment, never evaluating it.
20
+ *
21
+ * Every line it writes to stderr, which Lambda sends to the function's
22
+ * CloudWatch Logs, is identifier-only: fixed text; what the function's
23
+ * configuration holds, a parameter path or a secret's name, ARN and field;
24
+ * the position of a name it refuses; an HTTP status; a length or a count; and
25
+ * an error's class name and code (`describeErrorIdentity`, in
26
+ * `error-identity.mjs` beside this file). It
27
+ * never repeats the extension's answer, an error's message or a name it
28
+ * refuses: it runs alone in its layer and cannot load `@fjall/util`'s mask.
18
29
  *
19
30
  * The Extension may not be ready immediately during INIT phase, so all HTTP
20
31
  * calls use a retry loop with exponential backoff.
21
32
  */
22
33
 
34
+ import { describeErrorIdentity } from "./error-identity.mjs";
35
+
23
36
  const EXTENSION_PORT =
24
37
  process.env.PARAMETERS_SECRETS_EXTENSION_HTTP_PORT || "2773";
25
38
  const EXTENSION_URL = `http://localhost:${EXTENSION_PORT}`;
@@ -28,6 +41,36 @@ const INITIAL_DELAY_MS = 100;
28
41
  /** Per-request timeout — generous for localhost; total budget bounded by Lambda INIT timeout */
29
42
  const REQUEST_TIMEOUT_MS = 2000;
30
43
 
44
+ /** A failure in this script's own words, so its message may be written. */
45
+ class ResolverFailure extends Error {
46
+ constructor(message, { retriable = false } = {}) {
47
+ super(message);
48
+ this.retriable = retriable;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * What a function's operator checks when the extension refuses a read. Its
54
+ * refusal can be for a parameter or secret that does not exist, a role that
55
+ * may not read or decrypt it, or throttling, and the body that would say
56
+ * which is not repeated.
57
+ */
58
+ const REFUSAL_CURE =
59
+ "Check that it exists, that the function's role may read it and decrypt it with its key, and that Systems Manager or Secrets Manager was not throttling the function's requests.";
60
+
61
+ /**
62
+ * An answer the extension refused with, named by its status and its body's
63
+ * length. The body is AWS's error, which can name the parameter or secret
64
+ * and the function's role in words this repository does not control.
65
+ */
66
+ async function describeRefusal(response) {
67
+ const body = await response.text().then(
68
+ (text) => `a body of ${text.length} characters, not repeated here`,
69
+ () => "a body that could not be read"
70
+ );
71
+ return `Extension returned ${response.status} with ${body}`;
72
+ }
73
+
31
74
  /**
32
75
  * Fetch from the Extension HTTP API with retry and exponential backoff.
33
76
  * The Extension starts during INIT phase 1 (Extension init) and the wrapper
@@ -36,7 +79,7 @@ const REQUEST_TIMEOUT_MS = 2000;
36
79
  */
37
80
  async function fetchWithRetry(path) {
38
81
  let delay = INITIAL_DELAY_MS;
39
- let lastError;
82
+ let lastFailure;
40
83
 
41
84
  for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
42
85
  try {
@@ -53,26 +96,24 @@ async function fetchWithRetry(path) {
53
96
  } catch {
54
97
  // The body carries the parameter's value or the secret string, and
55
98
  // V8's message quotes a window of it, so only its length is named.
56
- throw new Error(
57
- `Extension returned a body that is not valid JSON (${body.length} characters)`
99
+ throw new ResolverFailure(
100
+ `Extension returned a body that is not valid JSON (${body.length} characters)`,
101
+ { retriable: true }
58
102
  );
59
103
  }
60
104
  }
61
- // 4xx errors (e.g. secret not found) — don't retry
105
+ // 4xx errors (e.g. secret not found) — don't retry; 5xx — retriable
62
106
  if (response.status >= 400 && response.status < 500) {
63
- const body = await response.text().catch(() => "");
64
- const err = new Error(`Extension returned ${response.status}: ${body}`);
65
- err.nonRetriable = true;
66
- throw err;
107
+ throw new ResolverFailure(
108
+ `${await describeRefusal(response)}. ${REFUSAL_CURE}`
109
+ );
67
110
  }
68
- // 5xx — retriable
69
- const body = await response.text().catch(() => "");
70
- lastError = new Error(`Extension returned ${response.status}: ${body}`);
111
+ throw new ResolverFailure(await describeRefusal(response), {
112
+ retriable: true
113
+ });
71
114
  } catch (err) {
72
- if (err.nonRetriable) {
73
- throw err;
74
- }
75
- lastError = err;
115
+ if (err instanceof ResolverFailure && !err.retriable) throw err;
116
+ lastFailure = describeFailure(err);
76
117
  }
77
118
 
78
119
  // Retry with backoff (both 5xx and network errors)
@@ -82,28 +123,42 @@ async function fetchWithRetry(path) {
82
123
  }
83
124
  }
84
125
 
85
- throw new Error(
86
- `Failed to reach Extension after ${MAX_RETRIES} attempts: ${lastError?.message}`
126
+ throw new ResolverFailure(
127
+ `Failed to reach Extension after ${MAX_RETRIES} attempts: ${lastFailure}`
87
128
  );
88
129
  }
89
130
 
90
131
  /**
91
- * Escape a value for safe inclusion in a shell `export KEY='value'` statement.
92
- * Single-quotes are the safest quoting mechanism — only embedded single-quotes
93
- * need escaping via the close-reopen pattern: ' → '\''
132
+ * A value as the wrapper's `printf '%b'` reads it back: each backslash
133
+ * written `\\` and each newline `\n`, so every value is one line and no
134
+ * escape `%b` knows can begin anywhere else. As through 39.0.6, the
135
+ * wrapper's command substitution drops a NUL, and an unpaired surrogate
136
+ * reaches the function as U+FFFD; 40.0.0 refuses both.
94
137
  */
95
- function shellEscape(value) {
96
- return "'" + value.replace(/'/g, "'\\''") + "'";
138
+ function encodeValue(value) {
139
+ return value.replace(/\\/g, "\\\\").replace(/\n/g, "\\n");
140
+ }
141
+
142
+ /** What a failure to resolve one secret says: this script's words, or the error's identity. */
143
+ function describeFailure(err) {
144
+ return err instanceof ResolverFailure
145
+ ? err.message
146
+ : describeErrorIdentity(err);
97
147
  }
98
148
 
99
149
  // POSIX env var names: letters, digits, underscores; must not start with a digit
100
150
  const VALID_ENV_NAME = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
101
151
 
102
- function assertValidEnvName(name, source) {
152
+ /**
153
+ * Stops the resolver when `name` cannot name an environment variable,
154
+ * naming it by `where` alone: the name is the operator's text, and an entry
155
+ * written by mistake can hold a value (`DB_PASSWORD=<value>`).
156
+ */
157
+ function assertValidEnvName(name, where) {
103
158
  if (!VALID_ENV_NAME.test(name)) {
104
159
  process.stderr.write(
105
- `[fjall-resolver] Invalid env var name '${name}' from ${source}. ` +
106
- `Names must match [a-zA-Z_][a-zA-Z0-9_]* (no dots or hyphens).\n`
160
+ `[fjall-resolver] ${where} is not a valid environment variable name, and is not repeated here. ` +
161
+ `Names must match [a-zA-Z_][a-zA-Z0-9_]* (no dots, hyphens or '=').\n`
107
162
  );
108
163
  process.exit(1);
109
164
  }
@@ -119,11 +174,13 @@ async function resolveSsmSecrets() {
119
174
 
120
175
  if (!basePath || !secretNames) return [];
121
176
 
122
- const names = secretNames.split(",").filter(Boolean);
123
- const exports = [];
177
+ const lines = [];
124
178
 
125
- for (const name of names) {
126
- assertValidEnvName(name, "SSM_SECRET_NAMES");
179
+ // Positions count the empty entries a doubled comma leaves, so each names
180
+ // the entry an operator reading SSM_SECRET_NAMES counts to.
181
+ for (const [index, name] of secretNames.split(",").entries()) {
182
+ if (name === "") continue;
183
+ assertValidEnvName(name, `Entry ${index + 1} of SSM_SECRET_NAMES`);
127
184
  const paramPath = `${basePath}/${name}`;
128
185
  const encodedPath = encodeURIComponent(paramPath);
129
186
 
@@ -132,34 +189,37 @@ async function resolveSsmSecrets() {
132
189
  `/systemsmanager/parameters/get?name=${encodedPath}&withDecryption=true`
133
190
  );
134
191
  const value = data?.Parameter?.Value;
135
- if (value !== undefined && value !== null) {
136
- exports.push(`export ${name}=${shellEscape(value)}`);
137
- }
192
+ // As through 39.0.6; 40.0.0 stops the function instead.
193
+ if (value === undefined || value === null) continue;
194
+ lines.push(`${name}=${encodeValue(value)}`);
138
195
  } catch (err) {
139
- const msg = err instanceof Error ? err.message : String(err);
140
196
  process.stderr.write(
141
- `[fjall-resolver] Failed to resolve SSM parameter ${paramPath}: ${msg}\n`
197
+ `[fjall-resolver] Failed to resolve SSM parameter ${paramPath}: ${describeFailure(err)}\n`
142
198
  );
143
199
  process.exit(1);
144
200
  }
145
201
  }
146
202
 
147
- return exports;
203
+ return lines;
148
204
  }
149
205
 
150
206
  /**
151
207
  * Resolve Secrets Manager secrets.
152
208
  * Scans for *_SECRET_ARN env vars, fetches each secret from SM,
153
- * optionally extracts a JSON field, and exports as the prefix.
209
+ * optionally extracts a JSON field, and writes it as NAME=VALUE under the
210
+ * prefix.
154
211
  */
155
212
  async function resolveSecretsManagerSecrets() {
156
- const exports = [];
213
+ const lines = [];
157
214
 
158
215
  for (const [key, arn] of Object.entries(process.env)) {
159
216
  if (!key.endsWith("_SECRET_ARN") || !arn) continue;
160
217
 
161
218
  const prefix = key.slice(0, -"_SECRET_ARN".length);
162
- assertValidEnvName(prefix, "Secrets Manager");
219
+ assertValidEnvName(
220
+ prefix,
221
+ "The prefix of an environment variable ending _SECRET_ARN"
222
+ );
163
223
  const fieldKey = `${prefix}_SECRET_FIELD`;
164
224
  const field = process.env[fieldKey];
165
225
 
@@ -169,54 +229,57 @@ async function resolveSecretsManagerSecrets() {
169
229
  const data = await fetchWithRetry(
170
230
  `/secretsmanager/get?secretId=${encodedArn}`
171
231
  );
232
+ const secretString = data?.SecretString;
233
+ // As through 39.0.6; 40.0.0 stops the function instead.
234
+ if (secretString === undefined || secretString === null) continue;
172
235
 
173
236
  let value;
174
- if (field && data?.SecretString) {
237
+ if (field && secretString) {
175
238
  let parsed;
176
239
  try {
177
- parsed = JSON.parse(data.SecretString);
240
+ parsed = JSON.parse(secretString);
178
241
  } catch {
179
- throw new Error(
242
+ throw new ResolverFailure(
180
243
  `Secret is not valid JSON but field '${field}' was requested. Store the secret as a JSON object.`
181
244
  );
182
245
  }
183
246
  value = parsed[field];
184
247
  if (value === undefined) {
185
- throw new Error(
248
+ throw new ResolverFailure(
186
249
  `Field '${field}' not found in secret (${Object.keys(parsed).length} fields present).`
187
250
  );
188
251
  }
252
+ // As through 39.0.6; 40.0.0 stops the function instead.
253
+ if (value === null) continue;
189
254
  } else {
190
- value = data?.SecretString;
255
+ value = secretString;
191
256
  }
192
257
 
193
- if (value !== undefined && value !== null) {
194
- exports.push(`export ${prefix}=${shellEscape(String(value))}`);
195
- }
258
+ lines.push(`${prefix}=${encodeValue(String(value))}`);
196
259
  } catch (err) {
197
- const msg = err instanceof Error ? err.message : String(err);
198
260
  process.stderr.write(
199
- `[fjall-resolver] Failed to resolve SM secret ${prefix} (${arn}): ${msg}\n`
261
+ `[fjall-resolver] Failed to resolve SM secret ${prefix} (${arn}): ${describeFailure(err)}\n`
200
262
  );
201
263
  process.exit(1);
202
264
  }
203
265
  }
204
266
 
205
- return exports;
267
+ return lines;
206
268
  }
207
269
 
208
270
  async function main() {
209
- const ssmExports = await resolveSsmSecrets();
210
- const smExports = await resolveSecretsManagerSecrets();
211
- const allExports = [...ssmExports, ...smExports];
271
+ const ssmLines = await resolveSsmSecrets();
272
+ const smLines = await resolveSecretsManagerSecrets();
273
+ const allLines = [...ssmLines, ...smLines];
212
274
 
213
- if (allExports.length > 0) {
214
- process.stdout.write(allExports.join("\n") + "\n");
275
+ if (allLines.length > 0) {
276
+ process.stdout.write(allLines.join("\n") + "\n");
215
277
  }
216
278
  }
217
279
 
218
280
  main().catch((err) => {
219
- const msg = err instanceof Error ? err.message : String(err);
220
- process.stderr.write(`[fjall-resolver] Fatal error: ${msg}\n`);
281
+ process.stderr.write(
282
+ `[fjall-resolver] Fatal error: ${describeFailure(err)}\n`
283
+ );
221
284
  process.exit(1);
222
285
  });
@@ -87,11 +87,16 @@ export interface AgentSecretsEnvHookOptions {
87
87
  * `set -o allexport` (every assignment auto-exports) + `nounset` — pinned in
88
88
  * `lib/__tests__/fixtures/buildkite/upstream-hooks-v6.68.1/`. Hence: every
89
89
  * fallible command is guarded, every env read uses `${VAR:-}`, every temp
90
- * variable is unset after use, and the script never exits. `private_ssh_key`
91
- * loads into an ephemeral ssh-agent (stdin → agent memory, never disk or
92
- * argv); the upstream pre-exit hook kills whatever `SSH_AGENT_PID` is
93
- * exported, so our agent's lifecycle is owned upstream. Every fetched value
94
- * is registered with the agent redactor (stdin form) before use — the
95
- * upstream helper cannot see values we fetch at eval time.
90
+ * variable is unset after use, and the script never exits. A per-pipeline
91
+ * secret name the hook's shell already holds (other than as a plain exported
92
+ * string) is refused before the assignment, so a value bash would read as
93
+ * arithmetic is never evaluated; the refusing helper keeps its one temp
94
+ * `local` and is `unset -f` at the end, so under `allexport` neither it nor
95
+ * its temp reaches the job. `private_ssh_key` loads into an ephemeral
96
+ * ssh-agent (stdin → agent memory, never disk or argv); the upstream pre-exit
97
+ * hook kills whatever `SSH_AGENT_PID` is exported, so our agent's lifecycle is
98
+ * owned upstream. Every fetched value is registered with the agent redactor
99
+ * (stdin form) before use — the upstream helper cannot see values we fetch at
100
+ * eval time.
96
101
  */
97
102
  export declare function buildAgentSecretsEnvHookScript(options: AgentSecretsEnvHookOptions): string;
@@ -327,12 +327,17 @@ export const FJALL_API_KEY_SECRET_NAME = "fjall-api-key";
327
327
  * `set -o allexport` (every assignment auto-exports) + `nounset` — pinned in
328
328
  * `lib/__tests__/fixtures/buildkite/upstream-hooks-v6.68.1/`. Hence: every
329
329
  * fallible command is guarded, every env read uses `${VAR:-}`, every temp
330
- * variable is unset after use, and the script never exits. `private_ssh_key`
331
- * loads into an ephemeral ssh-agent (stdin → agent memory, never disk or
332
- * argv); the upstream pre-exit hook kills whatever `SSH_AGENT_PID` is
333
- * exported, so our agent's lifecycle is owned upstream. Every fetched value
334
- * is registered with the agent redactor (stdin form) before use — the
335
- * upstream helper cannot see values we fetch at eval time.
330
+ * variable is unset after use, and the script never exits. A per-pipeline
331
+ * secret name the hook's shell already holds (other than as a plain exported
332
+ * string) is refused before the assignment, so a value bash would read as
333
+ * arithmetic is never evaluated; the refusing helper keeps its one temp
334
+ * `local` and is `unset -f` at the end, so under `allexport` neither it nor
335
+ * its temp reaches the job. `private_ssh_key` loads into an ephemeral
336
+ * ssh-agent (stdin → agent memory, never disk or argv); the upstream pre-exit
337
+ * hook kills whatever `SSH_AGENT_PID` is exported, so our agent's lifecycle is
338
+ * owned upstream. Every fetched value is registered with the agent redactor
339
+ * (stdin form) before use — the upstream helper cannot see values we fetch at
340
+ * eval time.
336
341
  */
337
342
  export function buildAgentSecretsEnvHookScript(options) {
338
343
  const { fjallApiKeyParameterName, pipelineSecretsPrefix, region } = options;
@@ -341,23 +346,40 @@ export function buildAgentSecretsEnvHookScript(options) {
341
346
  lines.push(`FJALL_API_KEY="$(aws ssm get-parameter --name '${fjallApiKeyParameterName}' --with-decryption --query Parameter.Value --output text --region '${region}' 2>/dev/null)" || true`, 'if [ -n "${FJALL_API_KEY:-}" ]; then', ` printf '%s' "$FJALL_API_KEY" | buildkite-agent redactor add >/dev/null 2>&1 || true`, " export FJALL_API_KEY", "else", " unset FJALL_API_KEY", ` echo 'fjall-secrets: could not fetch ${fjallApiKeyParameterName}' >&2`, "fi");
342
347
  }
343
348
  if (pipelineSecretsPrefix !== undefined) {
344
- lines.push('if [ -n "${BUILDKITE_PIPELINE_SLUG:-}" ]; then', ` _fjall_names="$(aws ssm get-parameters-by-path --path "${pipelineSecretsPrefix}/\${BUILDKITE_PIPELINE_SLUG:-}" --query 'Parameters[].Name' --output text --region '${region}' 2>/dev/null)" || true`, " for _fjall_param in ${_fjall_names:-}; do", ' _fjall_name="${_fjall_param##*/}"', ` _fjall_value="$(aws ssm get-parameter --name "$_fjall_param" --with-decryption --query Parameter.Value --output text --region '${region}' 2>/dev/null)" || true`, ' if [ -z "${_fjall_value:-}" ]; then', ' echo "fjall-secrets: could not fetch $_fjall_param" >&2', " unset _fjall_value", " continue", " fi", ` printf '%s' "$_fjall_value" | buildkite-agent redactor add >/dev/null 2>&1 || true`, ` if [ "$_fjall_name" = 'private_ssh_key' ]; then`, ` _fjall_agent_out="$(ssh-agent -s 2>/dev/null)" || true`, ' if [ -n "${_fjall_agent_out:-}" ]; then', ' eval "$_fjall_agent_out" >/dev/null 2>&1 || true', ` printf '%s\\n' "$_fjall_value" | ssh-add - >/dev/null 2>&1 || echo 'fjall-secrets: ssh-add failed for private_ssh_key' >&2`, " else", ` echo 'fjall-secrets: ssh-agent unavailable - private_ssh_key not loaded' >&2`, " fi", " unset _fjall_agent_out", ` elif printf '%s' "$_fjall_name" | grep -Eq '${JOB_ENV_NAME_ALLOW_REGEX}' && ! printf '%s' "$_fjall_name" | grep -Eq '${JOB_ENV_NAME_DENY_REGEX}'; then`, ` export "$_fjall_name=$_fjall_value" 2>/dev/null || echo "fjall-secrets: could not export $_fjall_name" >&2`, " else", ' echo "fjall-secrets: skipping non-conforming secret name $_fjall_name" >&2', " fi", " unset _fjall_value", " done", " unset _fjall_names", " unset _fjall_param", " unset _fjall_name", "fi");
349
+ lines.push('if [ -n "${BUILDKITE_PIPELINE_SLUG:-}" ]; then',
350
+ // Bash evaluates an assignment to a name it holds specially (RANDOM,
351
+ // OPTIND, SECONDS, …) as arithmetic, so a value shaped PATH[$(cmd)]
352
+ // would run cmd on assignment (FJL-1368); refuse such a name before the
353
+ // export. Gotchas: probe with `|| return 1` first, never as the tail of
354
+ // a $() (that fires the upstream ERR trap on bash 3.2.57); `local` so
355
+ // allexport does not carry the temp into the job; only a plain exported
356
+ // string (-x) is an ordinary env var that defers to the deny regex.
357
+ " _fjall_is_reserved_name() {", ' declare -p "$1" >/dev/null 2>&1 || return 1', " local _fjall_decl", ' _fjall_decl="$(declare -p "$1" 2>/dev/null)"', ' _fjall_decl="${_fjall_decl#declare }"', ' case "${_fjall_decl%% *}" in', " -x) return 1 ;;", " *) return 0 ;;", " esac", " }", ` _fjall_names="$(aws ssm get-parameters-by-path --path "${pipelineSecretsPrefix}/\${BUILDKITE_PIPELINE_SLUG:-}" --query 'Parameters[].Name' --output text --region '${region}' 2>/dev/null)" || true`, " for _fjall_param in ${_fjall_names:-}; do", ' _fjall_name="${_fjall_param##*/}"', ` _fjall_value="$(aws ssm get-parameter --name "$_fjall_param" --with-decryption --query Parameter.Value --output text --region '${region}' 2>/dev/null)" || true`, ' if [ -z "${_fjall_value:-}" ]; then', ' echo "fjall-secrets: could not fetch $_fjall_param" >&2', " unset _fjall_value", " continue", " fi", ` printf '%s' "$_fjall_value" | buildkite-agent redactor add >/dev/null 2>&1 || true`, ` if [ "$_fjall_name" = 'private_ssh_key' ]; then`, ` _fjall_agent_out="$(ssh-agent -s 2>/dev/null)" || true`, ' if [ -n "${_fjall_agent_out:-}" ]; then', ' eval "$_fjall_agent_out" >/dev/null 2>&1 || true', ` printf '%s\\n' "$_fjall_value" | ssh-add - >/dev/null 2>&1 || echo 'fjall-secrets: ssh-add failed for private_ssh_key' >&2`, " else", ` echo 'fjall-secrets: ssh-agent unavailable - private_ssh_key not loaded' >&2`, " fi", " unset _fjall_agent_out", ` elif printf '%s' "$_fjall_name" | grep -Eq '${JOB_ENV_NAME_ALLOW_REGEX}' && ! printf '%s' "$_fjall_name" | grep -Eq '${JOB_ENV_NAME_DENY_REGEX}'; then`, ` if _fjall_is_reserved_name "$_fjall_name"; then`, ` echo "fjall-secrets: skipping shell-reserved secret name $_fjall_name" >&2`, " else", ` export "$_fjall_name=$_fjall_value" 2>/dev/null || echo "fjall-secrets: could not export $_fjall_name" >&2`, " fi", " else", ' echo "fjall-secrets: skipping non-conforming secret name $_fjall_name" >&2', " fi", " unset _fjall_value", " done", " unset _fjall_names", " unset _fjall_param", " unset _fjall_name", " unset -f _fjall_is_reserved_name", "fi");
345
358
  }
346
359
  lines.push("");
347
360
  return lines.join("\n");
348
361
  }
349
362
  /**
350
363
  * Job-env eligibility for per-pipeline secret names: the allow shape is a
351
- * portable shell identifier; the deny list removes the bash-readonly names
352
- * (`export UID=…` fails "readonly variable" — under the upstream ERR trap
353
- * that would exit-53 EVERY job on the pipeline until the parameter is
354
- * deleted), the ssh-agent lifecycle vars the D-B3 flow owns, the
355
- * loader-injection vector vars, and the `BUILDKITE_`/`AWS_` prefixes.
356
- * `private_ssh_key` is handled before this filter; denied/non-conforming
357
- * names are skipped with an identifier-only warning.
364
+ * portable shell identifier; the deny list removes the names Fjall forbids as
365
+ * job env vars even though bash would let them be exported — the shell-hijack
366
+ * vars (`PATH`, `HOME`, `IFS`, `CDPATH`, `BASH_ENV`, `GLOBIGNORE`, the loader
367
+ * names), the read-only names (`export UID=…` fails "readonly variable" under
368
+ * the upstream ERR trap that would exit-53 EVERY job on the pipeline), the
369
+ * ssh-agent lifecycle vars the D-B3 flow owns, and the `BUILDKITE_`/`AWS_`
370
+ * prefixes. `ENV` is deliberately NOT listed: it is a name an operator gives
371
+ * an environment selector, and only an interactive POSIX `sh` reads it (bash
372
+ * 5.2.15 as `sh`, dash and busybox ash, measured 2026-10-13). Nor are the
373
+ * arithmetic/special-on-assignment names (`RANDOM`, `SRANDOM`, `OPTIND`,
374
+ * `HISTCMD`, `SECONDS`, …): that set grows and shifts with the bash version,
375
+ * and `_fjall_is_reserved_name` refuses it structurally — any name the hook's
376
+ * own shell already holds, unless it holds it as a plain exported string — so
377
+ * no list has to track bash here. `private_ssh_key` is handled before this
378
+ * filter; denied, reserved and non-conforming names are each skipped with an
379
+ * identifier-only warning.
358
380
  */
359
381
  const JOB_ENV_NAME_ALLOW_REGEX = "^[A-Za-z_][A-Za-z0-9_]*$";
360
- const JOB_ENV_NAME_DENY_REGEX = "^(PATH|HOME|IFS|LD_PRELOAD|LD_LIBRARY_PATH|BASH_ENV|SSH_AUTH_SOCK|SSH_AGENT_PID|UID|EUID|PPID|SHELLOPTS|BASHOPTS|BASH_VERSINFO)$|^(BUILDKITE_|AWS_)";
382
+ const JOB_ENV_NAME_DENY_REGEX = "^(PATH|HOME|IFS|CDPATH|BASH_ENV|GLOBIGNORE|LD_PRELOAD|LD_LIBRARY_PATH|SSH_AUTH_SOCK|SSH_AGENT_PID|UID|EUID|PPID|SHELLOPTS|BASHOPTS|BASH_VERSINFO)$|^(BUILDKITE_|AWS_)";
361
383
  function deriveCpuArchitecture(instanceTypeIdentifier) {
362
384
  const architecture = new InstanceType(instanceTypeIdentifier).architecture;
363
385
  return architecture === InstanceArchitecture.ARM_64 ? "arm64" : "amd64";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fjall/components-infrastructure",
3
- "version": "39.0.7",
3
+ "version": "39.0.8",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/fjall-tech/fjall.git",
@@ -81,8 +81,8 @@
81
81
  },
82
82
  "dependencies": {
83
83
  "@aws-sdk/client-organizations": "^3.1098.0",
84
- "@fjall/generator": "^39.0.7",
85
- "@fjall/util": "^39.0.7",
84
+ "@fjall/generator": "^39.0.8",
85
+ "@fjall/util": "^39.0.8",
86
86
  "constructs": "^10.7.2",
87
87
  "zod": "^4.4.3"
88
88
  },