@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.
- package/dist/lib/layers/secrets-resolver/bin/error-identity.mjs +57 -0
- package/dist/lib/layers/secrets-resolver/bin/resolve-secrets +63 -19
- package/dist/lib/layers/secrets-resolver/bin/resolve-secrets.mjs +129 -66
- package/dist/lib/patterns/aws/buildkite/buildkite.d.ts +11 -6
- package/dist/lib/patterns/aws/buildkite/buildkite.js +37 -15
- package/package.json +3 -3
|
@@ -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
|
|
9
|
-
#
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
14
|
-
*
|
|
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
|
-
*
|
|
17
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
throw err;
|
|
107
|
+
throw new ResolverFailure(
|
|
108
|
+
`${await describeRefusal(response)}. ${REFUSAL_CURE}`
|
|
109
|
+
);
|
|
67
110
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
111
|
+
throw new ResolverFailure(await describeRefusal(response), {
|
|
112
|
+
retriable: true
|
|
113
|
+
});
|
|
71
114
|
} catch (err) {
|
|
72
|
-
if (err.
|
|
73
|
-
|
|
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
|
|
86
|
-
`Failed to reach Extension after ${MAX_RETRIES} attempts: ${
|
|
126
|
+
throw new ResolverFailure(
|
|
127
|
+
`Failed to reach Extension after ${MAX_RETRIES} attempts: ${lastFailure}`
|
|
87
128
|
);
|
|
88
129
|
}
|
|
89
130
|
|
|
90
131
|
/**
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
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
|
|
96
|
-
return "
|
|
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
|
-
|
|
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]
|
|
106
|
-
`Names must match [a-zA-Z_][a-zA-Z0-9_]* (no dots or
|
|
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
|
|
123
|
-
const exports = [];
|
|
177
|
+
const lines = [];
|
|
124
178
|
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
136
|
-
|
|
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}: ${
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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 &&
|
|
237
|
+
if (field && secretString) {
|
|
175
238
|
let parsed;
|
|
176
239
|
try {
|
|
177
|
-
parsed = JSON.parse(
|
|
240
|
+
parsed = JSON.parse(secretString);
|
|
178
241
|
} catch {
|
|
179
|
-
throw new
|
|
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
|
|
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 =
|
|
255
|
+
value = secretString;
|
|
191
256
|
}
|
|
192
257
|
|
|
193
|
-
|
|
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}): ${
|
|
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
|
|
267
|
+
return lines;
|
|
206
268
|
}
|
|
207
269
|
|
|
208
270
|
async function main() {
|
|
209
|
-
const
|
|
210
|
-
const
|
|
211
|
-
const
|
|
271
|
+
const ssmLines = await resolveSsmSecrets();
|
|
272
|
+
const smLines = await resolveSecretsManagerSecrets();
|
|
273
|
+
const allLines = [...ssmLines, ...smLines];
|
|
212
274
|
|
|
213
|
-
if (
|
|
214
|
-
process.stdout.write(
|
|
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
|
-
|
|
220
|
-
|
|
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.
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
* is
|
|
95
|
-
*
|
|
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.
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
*
|
|
334
|
-
* is
|
|
335
|
-
*
|
|
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',
|
|
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
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
*
|
|
357
|
-
*
|
|
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|
|
|
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.
|
|
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.
|
|
85
|
-
"@fjall/util": "^39.0.
|
|
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
|
},
|