hammerkit 1.22.0 → 1.23.0

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/build.schema.json CHANGED
@@ -204,7 +204,10 @@
204
204
  "properties": {
205
205
  "from": {
206
206
  "type": "string",
207
- "pattern": "^(env|file):.+$"
207
+ "pattern": "^[A-Za-z][A-Za-z0-9_-]*:.+$"
208
+ },
209
+ "account": {
210
+ "type": "string"
208
211
  },
209
212
  "env": {
210
213
  "type": "string",
@@ -221,9 +224,12 @@
221
224
  "from"
222
225
  ],
223
226
  "additionalProperties": false,
224
- "description": "secret from env:NAME or file:path, injected as env or a read-only file"
227
+ "description": "secret from env:NAME, file:path or <provider>:<ref>, injected as env or a read-only file"
225
228
  }
226
229
  },
230
+ "account": {
231
+ "type": "string"
232
+ },
227
233
  "resources": {
228
234
  "type": "object",
229
235
  "properties": {
@@ -319,6 +325,9 @@
319
325
  "secrets": {
320
326
  "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/secrets"
321
327
  },
328
+ "account": {
329
+ "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/account"
330
+ },
322
331
  "resources": {
323
332
  "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/resources"
324
333
  },
@@ -446,6 +455,9 @@
446
455
  "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/secrets/items"
447
456
  }
448
457
  },
458
+ "account": {
459
+ "type": "string"
460
+ },
449
461
  "resources": {
450
462
  "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/resources",
451
463
  "description": "cpu and memory requests and limits"
@@ -748,6 +760,74 @@
748
760
  "additionalProperties": false
749
761
  }
750
762
  },
763
+ "secretProviders": {
764
+ "type": "object",
765
+ "additionalProperties": {
766
+ "type": "object",
767
+ "properties": {
768
+ "command": {
769
+ "type": "array",
770
+ "items": {
771
+ "type": "string"
772
+ },
773
+ "minItems": 1,
774
+ "description": "command and arguments, no shell; {{ref}} is replaced by the secret reference"
775
+ },
776
+ "env": {
777
+ "type": "object",
778
+ "additionalProperties": {
779
+ "type": "string"
780
+ },
781
+ "description": "static environment of the command, next to PATH and the account env"
782
+ },
783
+ "timeout": {
784
+ "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/timeout",
785
+ "description": "how long the command may run, 30s when omitted"
786
+ }
787
+ },
788
+ "required": [
789
+ "command"
790
+ ],
791
+ "additionalProperties": false,
792
+ "description": "secret provider, a command that prints the secret to stdout"
793
+ }
794
+ },
795
+ "secretAccounts": {
796
+ "type": "object",
797
+ "additionalProperties": {
798
+ "type": "object",
799
+ "properties": {
800
+ "default": {
801
+ "type": "boolean",
802
+ "description": "the account for secrets and tasks that name none"
803
+ },
804
+ "providers": {
805
+ "type": "object",
806
+ "additionalProperties": {
807
+ "type": "object",
808
+ "properties": {
809
+ "env": {
810
+ "type": "object",
811
+ "additionalProperties": {
812
+ "type": "string"
813
+ }
814
+ }
815
+ },
816
+ "required": [
817
+ "env"
818
+ ],
819
+ "additionalProperties": false
820
+ },
821
+ "description": "per provider, the environment that makes its command act as this account"
822
+ }
823
+ },
824
+ "required": [
825
+ "providers"
826
+ ],
827
+ "additionalProperties": false,
828
+ "description": "service account the secret providers are read as"
829
+ }
830
+ },
751
831
  "labels": {
752
832
  "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/labels"
753
833
  }
package/dist/cli.js CHANGED
@@ -21,6 +21,7 @@ const work_graph_serializer_1 = require("./planner/work-graph-serializer");
21
21
  const dry_run_1 = require("./executer/dry-run");
22
22
  const cache_sync_1 = require("./cache/cache-sync");
23
23
  const cache_inventory_1 = require("./cache/cache-inventory");
24
+ const prefetch_secrets_1 = require("./secrets/prefetch-secrets");
24
25
  const isCliTask = (val) => val.type === 'task';
25
26
  exports.isCliTask = isCliTask;
26
27
  const isCliService = (val) => val.type === 'service';
@@ -30,6 +31,11 @@ class Cli {
30
31
  this.workTree = workTree;
31
32
  this.environment = environment;
32
33
  }
34
+ // Starts the run's secrets: the values that make up a task id are fetched
35
+ // before anything asks for an id, see prefetchSecrets.
36
+ async prepareSecrets(newRun = false) {
37
+ await (0, prefetch_secrets_1.prefetchSecrets)(this.workTree, this.environment, newRun);
38
+ }
33
39
  setup(type, options) {
34
40
  const logMode = options?.logMode ?? (ci_1.isCI ? 'live' : 'interactive');
35
41
  const workTree = (0, reset_work_tree_1.resetWorkTree)(this.workTree, type);
@@ -43,6 +49,9 @@ class Cli {
43
49
  return {
44
50
  state: processWorkTree,
45
51
  start: async () => {
52
+ if (type !== 'down') {
53
+ await this.prepareSecrets(true);
54
+ }
46
55
  (0, check_for_loop_1.checkForLoop)(workTree);
47
56
  const capacity = (0, plan_work_tasks_1.hasError)(workTree) ? null : await (0, resource_budget_1.getResourceCapacity)(workTree, this.environment);
48
57
  if (capacity && type !== 'down' && (options?.resourceCheck ?? true)) {
@@ -100,15 +109,19 @@ class Cli {
100
109
  return await run.start();
101
110
  }
102
111
  async clean(options) {
112
+ await this.prepareSecrets();
103
113
  await (0, event_cache_1.cleanCache)(this.workTree, this.environment, options);
104
114
  }
105
115
  async restore(path) {
116
+ await this.prepareSecrets();
106
117
  await (0, event_cache_1.restoreCache)(this.environment, path, this.workTree);
107
118
  }
108
119
  async package(options) {
120
+ await this.prepareSecrets();
109
121
  await (0, package_1.packageWorkTree)(this.workTree, this.environment, options);
110
122
  }
111
123
  async store(path) {
124
+ await this.prepareSecrets();
112
125
  await (0, event_cache_1.storeCache)(this.environment, path, this.workTree);
113
126
  }
114
127
  services() {
@@ -132,6 +145,7 @@ class Cli {
132
145
  return [...(0, plan_work_tasks_1.iterateWorkTasks)(this.workTree)].map((task) => (0, describe_task_1.describeTask)(task, this.environment.cwd));
133
146
  }
134
147
  async explain(options) {
148
+ await this.prepareSecrets();
135
149
  return (0, explain_1.explainWorkTree)(this.workTree, options?.cacheDefault ?? 'checksum', this.environment);
136
150
  }
137
151
  // Serialize the in-scope work graph for rendering (mermaid/dot). Pure read of
@@ -142,25 +156,30 @@ class Cli {
142
156
  // Ordered execution plan with predicted cache decisions, reusing the explain
143
157
  // engine — executes nothing, starts nothing, performs no cache push/pull.
144
158
  async dryRun(options) {
159
+ await this.prepareSecrets();
145
160
  return (0, dry_run_1.planDryRun)(this.workTree, options?.cacheDefault ?? 'checksum', this.environment);
146
161
  }
147
162
  // Move cache entries between the tasks' own caches and a named remote without
148
163
  // executing anything (`cache pull` / `cache push`).
149
164
  async syncCache(options) {
165
+ await this.prepareSecrets();
150
166
  return (0, cache_sync_1.syncCache)(this.workTree, options, this.environment);
151
167
  }
152
168
  // Entries of a named cache (default: the local `default` cache).
153
169
  async listCache(cacheName) {
170
+ await this.prepareSecrets();
154
171
  return (0, cache_inventory_1.listCache)(this.workTree, cacheName, this.environment);
155
172
  }
156
173
  retentionPolicy(cacheName, overrides) {
157
174
  return (0, cache_inventory_1.retentionPolicyOf)(this.workTree, cacheName, overrides);
158
175
  }
159
176
  async pruneCache(cacheName, policy, dryRun = false) {
177
+ await this.prepareSecrets();
160
178
  return (0, cache_inventory_1.pruneCache)(this.workTree, cacheName, policy, { dryRun }, this.environment);
161
179
  }
162
180
  // Apply declared retention to the local caches this build file uses.
163
181
  async autoPrune() {
182
+ await this.prepareSecrets();
164
183
  return (0, cache_inventory_1.autoPrune)(this.workTree, this.environment);
165
184
  }
166
185
  task(name) {
@@ -5,14 +5,17 @@ const work_item_status_1 = require("../planner/work-item-status");
5
5
  const get_file_context_1 = require("../file/get-file-context");
6
6
  const log_1 = require("../log");
7
7
  const empty_writable_1 = require("../utils/empty-writable");
8
+ const redact_1 = require("../utils/redact");
8
9
  function environmentMock(cwd) {
10
+ const secrets = (0, redact_1.createSecretRegistry)();
9
11
  return {
10
12
  cwd,
11
13
  file: (0, get_file_context_1.getFileContext)(cwd),
12
14
  console: (0, log_1.consoleContext)((0, empty_writable_1.emptyWritable)()),
13
15
  abortCtrl: new AbortController(),
14
16
  processEnvs: {},
15
- status: (0, work_item_status_1.statusConsole)((0, empty_writable_1.emptyWritable)()),
17
+ status: (0, work_item_status_1.statusConsole)((0, empty_writable_1.emptyWritable)(), secrets),
18
+ secrets,
16
19
  stdout: (0, empty_writable_1.emptyWritable)(),
17
20
  stderr: (0, empty_writable_1.emptyWritable)(),
18
21
  stdoutColumns: 80,
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
4
4
  const log_1 = require("./log");
5
5
  const get_file_context_1 = require("./file/get-file-context");
6
6
  const work_item_status_1 = require("./planner/work-item-status");
7
+ const redact_1 = require("./utils/redact");
7
8
  const empty_writable_1 = require("./utils/empty-writable");
8
9
  const run_program_1 = require("./run-program");
9
10
  const abort_on_signals_1 = require("./utils/abort-on-signals");
@@ -19,13 +20,15 @@ process.on('beforeExit', () => {
19
20
  process.exitCode = 1;
20
21
  }
21
22
  });
23
+ const secrets = (0, redact_1.createSecretRegistry)();
22
24
  (0, run_program_1.runProgram)({
23
25
  cwd: process.cwd(),
24
26
  abortCtrl,
25
27
  processEnvs: process.env,
26
28
  file: (0, get_file_context_1.getFileContext)(process.cwd()),
27
29
  console: (0, log_1.consoleContext)(process.stdout),
28
- status: (0, work_item_status_1.statusConsole)((0, empty_writable_1.emptyWritable)()),
30
+ status: (0, work_item_status_1.statusConsole)((0, empty_writable_1.emptyWritable)(), secrets),
31
+ secrets,
29
32
  stdout: process.stdout,
30
33
  stderr: process.stderr,
31
34
  stdoutColumns: process.stdout.columns,
@@ -26,7 +26,9 @@ function getServiceDefinitionHash(item) {
26
26
  src: service.src.map((s) => s.absolutePath),
27
27
  healthcheck: service.healthcheck?.cmd.cmd ?? null,
28
28
  // where secrets go, never their values: a rotated value needs a `down`
29
- secrets: service.secrets.map((s) => `${s.source.type}:${(0, work_secret_1.getSecretName)(s)}`),
29
+ secrets: service.secrets.map((s) => s.source.type === 'provider'
30
+ ? `provider:${(0, work_secret_1.getSecretName)(s)}:${(0, work_secret_1.describeSecretSource)(s)}`
31
+ : `${s.source.type}:${(0, work_secret_1.getSecretName)(s)}`),
30
32
  // the init task's id covers its image, commands, envs and mounts: a changed
31
33
  // init recreates the service, so it runs again
32
34
  init: service.init ? service.init.task.id() : null,
@@ -114,7 +114,7 @@ function parseService(workTree, service, environment, context) {
114
114
  caching,
115
115
  // set once the service is planned, see appendServiceInit
116
116
  init: null,
117
- secrets: (0, work_secret_1.parseWorkSecrets)(service.cwd, context.projectRoot, service.schema.secrets, envs, environment),
117
+ secrets: (0, work_secret_1.parseWorkSecrets)(service.cwd, context.projectRoot, service.schema.secrets, envs, environment, context.secrets, service.schema.account),
118
118
  resources: (0, work_resources_1.parseWorkResources)(service.schema.resources),
119
119
  };
120
120
  }
@@ -70,7 +70,7 @@ function parseTask(cwd, task, environment, context) {
70
70
  shell: task.schema.shell ? (0, template_value_1.templateValue)(task.schema.shell, envs) : '/bin/sh',
71
71
  continuous: task.schema.continuous ?? false,
72
72
  timeout: task.schema.timeout ? (0, units_1.parseDuration)(task.schema.timeout) : null,
73
- secrets: (0, work_secret_1.parseWorkSecrets)(cwd, context.projectRoot, task.schema.secrets, envs, environment),
73
+ secrets: (0, work_secret_1.parseWorkSecrets)(cwd, context.projectRoot, task.schema.secrets, envs, environment, context.secrets, task.schema.account),
74
74
  resources: (0, work_resources_1.parseWorkResources)(task.schema.resources),
75
75
  };
76
76
  if ((0, build_file_task_schema_1.isBuildFileContainerTaskSchema)(task.schema)) {
@@ -15,12 +15,13 @@ function logContext(type, item) {
15
15
  };
16
16
  }
17
17
  exports.logContext = logContext;
18
- function statusConsole(writable) {
18
+ // `secrets` is the registry of the run (its Environment) whose output this console shows.
19
+ function statusConsole(writable, secrets = (0, redact_1.createSecretRegistry)()) {
19
20
  const statusBuffer = new buffer_context_1.BufferContext(StatusBufferMax);
20
21
  const consoleBuffer = new buffer_context_1.BufferContext(ConsoleBufferMax);
21
22
  const emit = (0, emitter_1.emitter)();
22
23
  function addMessage(context, unredacted) {
23
- const message = { ...unredacted, message: (0, redact_1.redact)(unredacted.message) };
24
+ const message = { ...unredacted, message: secrets.redact(unredacted.message) };
24
25
  emit.emit(message);
25
26
  writable.write(JSON.stringify({ context, message }) + '\n');
26
27
  if (message.type === 'console') {
@@ -1,13 +1,24 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.getSecretDigest = exports.resolveSecretValue = exports.parseWorkSecrets = exports.getPortableSecretName = exports.getSecretName = void 0;
3
+ exports.getSecretDigest = exports.resolveSecretValue = exports.parseWorkSecrets = exports.getPortableSecretName = exports.getSecretName = exports.describeSecretSource = void 0;
4
4
  const crypto_1 = require("crypto");
5
5
  const os_1 = require("os");
6
6
  const fs_1 = require("fs");
7
7
  const normalize_path_1 = require("./utils/normalize-path");
8
8
  const template_value_1 = require("./utils/template-value");
9
9
  const portable_path_1 = require("./utils/portable-path");
10
- const redact_1 = require("../utils/redact");
10
+ const abort_1 = require("../executer/abort");
11
+ const provider_values_1 = require("../secrets/provider-values");
12
+ // Where the value comes from, for messages and for what makes a service
13
+ // different: never the value.
14
+ function describeSecretSource(secret) {
15
+ const source = secret.source;
16
+ if (source.type === 'provider') {
17
+ return `${source.provider}:${source.ref} as ${source.account}`;
18
+ }
19
+ return source.type === 'env' ? `env:${source.name}` : `file:${source.path}`;
20
+ }
21
+ exports.describeSecretSource = describeSecretSource;
11
22
  function getSecretName(secret) {
12
23
  return secret.target.type === 'env' ? secret.target.name : secret.target.path;
13
24
  }
@@ -18,15 +29,16 @@ function getPortableSecretName(projectRoot, secret) {
18
29
  return secret.target.type === 'env' ? secret.target.name : (0, portable_path_1.portablePath)(projectRoot, secret.target.path);
19
30
  }
20
31
  exports.getPortableSecretName = getPortableSecretName;
21
- function parseWorkSecrets(cwd, projectRoot, secrets, envs, environment) {
32
+ function parseWorkSecrets(cwd, projectRoot, secrets, envs, environment, catalog,
33
+ // the service account of the task or service the secrets belong to
34
+ account) {
22
35
  return (secrets ?? []).map((secret) => {
23
36
  const ref = secret.from.substring(secret.from.indexOf(':') + 1);
24
- const source = secret.from.startsWith('env:')
25
- ? { type: 'env', name: ref }
26
- : { type: 'file', path: (0, normalize_path_1.normalizePath)(cwd, (0, os_1.homedir)(), (0, template_value_1.templateValue)(ref, envs)) };
27
37
  const target = secret.env
28
38
  ? { type: 'env', name: secret.env }
29
39
  : { type: 'file', path: (0, normalize_path_1.normalizePath)(cwd, cwd, (0, template_value_1.templateValue)(secret.path ?? '', envs)) };
40
+ const name = target.type === 'env' ? target.name : secret.path;
41
+ const source = parseSecretSource(secret, ref, cwd, envs, catalog, account, `secret ${name}`);
30
42
  const workSecret = {
31
43
  source,
32
44
  target,
@@ -37,20 +49,65 @@ function parseWorkSecrets(cwd, projectRoot, secrets, envs, environment) {
37
49
  });
38
50
  }
39
51
  exports.parseWorkSecrets = parseWorkSecrets;
52
+ function parseSecretSource(secret, ref, cwd, envs, catalog, taskAccount, label) {
53
+ const kind = secret.from.substring(0, secret.from.indexOf(':'));
54
+ if (kind === 'env') {
55
+ return { type: 'env', name: ref };
56
+ }
57
+ if (kind === 'file') {
58
+ return { type: 'file', path: (0, normalize_path_1.normalizePath)(cwd, (0, os_1.homedir)(), (0, template_value_1.templateValue)(ref, envs)) };
59
+ }
60
+ const provider = catalog.providers[kind];
61
+ if (!provider) {
62
+ const declared = Object.keys(catalog.providers);
63
+ throw new Error(`${label}: unknown secret provider ${kind}, ${declared.length > 0 ? `declared: ${declared.join(', ')}` : 'none is declared'}`);
64
+ }
65
+ const providerRef = (0, template_value_1.templateValue)(ref, envs);
66
+ if (providerRef.length === 0 || providerRef.startsWith('-')) {
67
+ throw new Error(`${label}: a reference for ${kind} must not be empty or start with -`);
68
+ }
69
+ const accountName = secret.account ?? taskAccount ?? Object.values(catalog.accounts).find((a) => a.default)?.name;
70
+ if (!accountName) {
71
+ throw new Error(`${label}: ${kind}:${providerRef} needs an account, and none is named or marked default: true`);
72
+ }
73
+ const account = catalog.accounts[accountName];
74
+ if (!account) {
75
+ throw new Error(`${label}: unknown secret account ${accountName}, declared: ${Object.keys(catalog.accounts).join(', ') || 'none'}`);
76
+ }
77
+ const credentials = account.providers[kind];
78
+ if (!credentials) {
79
+ throw new Error(`${label}: account ${accountName} has no credentials for provider ${kind}`);
80
+ }
81
+ return {
82
+ type: 'provider',
83
+ provider: kind,
84
+ ref: providerRef,
85
+ account: accountName,
86
+ binding: { definition: provider, account: { name: accountName, env: credentials.env } },
87
+ };
88
+ }
40
89
  function readSecretValueSync(secret, environment) {
41
90
  if (secret.source.type === 'env') {
42
91
  const value = environment.processEnvs[secret.source.name];
43
92
  if (value === undefined || value === null) {
44
93
  throw new Error(`secret ${getSecretName(secret)}: environment variable ${secret.source.name} is not set`);
45
94
  }
46
- (0, redact_1.registerSecretValue)(value);
95
+ environment.secrets.register(value);
96
+ return value;
97
+ }
98
+ if (secret.source.type === 'provider') {
99
+ const value = (0, provider_values_1.peekProviderValue)(secret.source.binding, secret.source.ref, environment);
100
+ if (value === undefined) {
101
+ throw new Error(`secret ${getSecretName(secret)}: ${describeSecretSource(secret)} was not fetched before its task id was computed`);
102
+ }
103
+ environment.secrets.register(value);
47
104
  return value;
48
105
  }
49
106
  if (!(0, fs_1.existsSync)(secret.source.path)) {
50
107
  throw new Error(`secret ${getSecretName(secret)}: file ${secret.source.path} does not exist`);
51
108
  }
52
109
  const value = (0, fs_1.readFileSync)(secret.source.path, 'utf8');
53
- (0, redact_1.registerSecretValue)(value);
110
+ environment.secrets.register(value);
54
111
  return value;
55
112
  }
56
113
  // The value, read when the task or service starts (or when a cache-affecting
@@ -61,14 +118,17 @@ async function resolveSecretValue(secret, environment) {
61
118
  if (value === undefined || value === null) {
62
119
  throw new Error(`secret ${getSecretName(secret)}: environment variable ${secret.source.name} is not set`);
63
120
  }
64
- (0, redact_1.registerSecretValue)(value);
121
+ environment.secrets.register(value);
65
122
  return value;
66
123
  }
124
+ if (secret.source.type === 'provider') {
125
+ return resolveProviderSecret(secret, secret.source, environment);
126
+ }
67
127
  if (!(await environment.file.exists(secret.source.path))) {
68
128
  throw new Error(`secret ${getSecretName(secret)}: file ${secret.source.path} does not exist`);
69
129
  }
70
130
  const value = await environment.file.read(secret.source.path);
71
- (0, redact_1.registerSecretValue)(value);
131
+ environment.secrets.register(value);
72
132
  return value;
73
133
  }
74
134
  exports.resolveSecretValue = resolveSecretValue;
@@ -80,4 +140,21 @@ function getSecretDigest(projectRoot, cwd, secret, value) {
80
140
  return `sha256:${(0, crypto_1.createHash)('sha256').update(`${salt}\0${value}`).digest('hex')}`;
81
141
  }
82
142
  exports.getSecretDigest = getSecretDigest;
143
+ async function resolveProviderSecret(secret, source, environment) {
144
+ let value;
145
+ try {
146
+ value = await (0, provider_values_1.fetchProviderValue)(source.binding, source.ref, environment);
147
+ }
148
+ catch (error) {
149
+ if (error instanceof abort_1.AbortError) {
150
+ throw error;
151
+ }
152
+ throw new Error(`secret ${getSecretName(secret)} (${describeSecretSource(secret)}): ${error instanceof Error ? error.message : error}`);
153
+ }
154
+ if (value.length === 0) {
155
+ throw new Error(`secret ${getSecretName(secret)} (${describeSecretSource(secret)}): the provider returned no value`);
156
+ }
157
+ environment.secrets.register(value);
158
+ return value;
159
+ }
83
160
  //# sourceMappingURL=work-secret.js.map
@@ -34,6 +34,8 @@ exports.buildFileBaseTaskSchema = (0, zod_1.object)({
34
34
  // runs longer. Does not affect the cache key (ADR-0002).
35
35
  timeout: duration_schema_1.durationSchema.optional(),
36
36
  secrets: (0, zod_1.array)(build_file_secret_schema_1.buildFileSecretSchema).optional(),
37
+ // the service account its provider secrets are read as, unless a secret names one
38
+ account: (0, zod_1.string)().optional(),
37
39
  // CPU and memory limits of the container; a local task is not limited
38
40
  resources: resources_schema_1.resourcesSchema.optional(),
39
41
  });
@@ -36,6 +36,8 @@ exports.buildFileContainerServiceSchema = (0, zod_1.object)({
36
36
  // passed; the service counts as ready for dependents only after it succeeded
37
37
  init: (0, zod_1.string)().describe('task run once the service is healthy, before dependents start').optional(),
38
38
  secrets: (0, zod_1.array)(build_file_secret_schema_1.buildFileSecretSchema).optional(),
39
+ // the service account its provider secrets are read as, unless a secret names one
40
+ account: (0, zod_1.string)().optional(),
39
41
  resources: resources_schema_1.resourcesSchema.optional(),
40
42
  })
41
43
  .strict()
@@ -9,6 +9,7 @@ const build_file_task_schema_1 = require("./build-file-task-schema");
9
9
  const labels_schema_1 = require("./labels-schema");
10
10
  const cache_schema_1 = require("./cache-schema");
11
11
  const build_file_include_schema_1 = require("./build-file-include-schema");
12
+ const build_file_secret_provider_schema_1 = require("./build-file-secret-provider-schema");
12
13
  exports.buildFileSchema = (0, zod_1.object)({
13
14
  envs: envs_schema_1.envsSchema.optional(),
14
15
  tasks: (0, zod_1.record)(build_file_task_schema_1.buildFileTaskSchema).optional(),
@@ -17,6 +18,8 @@ exports.buildFileSchema = (0, zod_1.object)({
17
18
  includes: (0, zod_1.record)(build_file_include_schema_1.buildFileIncludeSchema).optional(),
18
19
  environments: (0, zod_1.record)(build_file_environment_schema_1.buildFileEnvironmentSchema).optional(),
19
20
  caches: (0, zod_1.record)(cache_schema_1.buildFileCacheSchema).optional(),
21
+ secretProviders: build_file_secret_provider_schema_1.buildFileSecretProvidersSchema.optional(),
22
+ secretAccounts: build_file_secret_provider_schema_1.buildFileSecretAccountsSchema.optional(),
20
23
  labels: labels_schema_1.labelsSchema.optional(),
21
24
  })
22
25
  .strict()
@@ -0,0 +1,36 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildFileSecretAccountsSchema = exports.buildFileSecretProvidersSchema = exports.buildFileSecretAccountSchema = exports.buildFileSecretProviderSchema = exports.RESERVED_SECRET_PROVIDERS = void 0;
4
+ const zod_1 = require("zod");
5
+ const duration_schema_1 = require("./duration-schema");
6
+ // `env` and `file` are the built-in sources of `from`, so a provider cannot take
7
+ // either name.
8
+ exports.RESERVED_SECRET_PROVIDERS = ['env', 'file'];
9
+ const nameSchema = (0, zod_1.string)()
10
+ .regex(/^[A-Za-z][A-Za-z0-9_-]*$/, 'a name starts with a letter and holds letters, digits, - and _')
11
+ .refine((name) => !exports.RESERVED_SECRET_PROVIDERS.includes(name), 'env and file are built-in secret sources');
12
+ // How to fetch a secret: a command that prints the value to stdout (ADR 0008).
13
+ // The command is an argv list, never run through a shell. `{{ref}}` is replaced
14
+ // by the text after `provider:` in a secret's `from`. It holds no credential:
15
+ // those come from the account the secret is read as.
16
+ exports.buildFileSecretProviderSchema = (0, zod_1.object)({
17
+ command: (0, zod_1.array)((0, zod_1.string)())
18
+ .min(1)
19
+ .describe('command and arguments, no shell; {{ref}} is replaced by the secret reference'),
20
+ env: (0, zod_1.record)((0, zod_1.string)()).optional().describe('static environment of the command, next to PATH and the account env'),
21
+ timeout: duration_schema_1.durationSchema.optional().describe('how long the command may run, 30s when omitted'),
22
+ })
23
+ .strict()
24
+ .describe('secret provider, a command that prints the secret to stdout');
25
+ // As whom a provider is read: the environment its command runs with. Values
26
+ // are `${HOST_VARIABLE}` references to a credential the machine running
27
+ // hammerkit holds, so a build file never carries one.
28
+ exports.buildFileSecretAccountSchema = (0, zod_1.object)({
29
+ default: (0, zod_1.boolean)().optional().describe('the account for secrets and tasks that name none'),
30
+ providers: (0, zod_1.record)(nameSchema, (0, zod_1.object)({ env: (0, zod_1.record)((0, zod_1.string)()) }).strict()).describe('per provider, the environment that makes its command act as this account'),
31
+ })
32
+ .strict()
33
+ .describe('service account the secret providers are read as');
34
+ exports.buildFileSecretProvidersSchema = (0, zod_1.record)(nameSchema, exports.buildFileSecretProviderSchema);
35
+ exports.buildFileSecretAccountsSchema = (0, zod_1.record)(nameSchema, exports.buildFileSecretAccountSchema);
36
+ //# sourceMappingURL=build-file-secret-provider-schema.js.map
@@ -3,11 +3,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.buildFileSecretSchema = void 0;
4
4
  const zod_1 = require("zod");
5
5
  // A credential handed to a task or service without putting it in the build
6
- // file, the cache or the logs: `from: env:NAME` or `from: file:path`, injected
6
+ // file, the cache or the logs: `from: env:NAME`, `from: file:path` or
7
+ // `from: <provider>:<ref>` (a secret provider, read as an account), injected
7
8
  // as an env variable (`env`) or a read-only file (`path`). `cache: true` makes
8
9
  // a salted digest of the value part of the task's cache key (ADR 0002).
9
10
  exports.buildFileSecretSchema = (0, zod_1.object)({
10
- from: (0, zod_1.string)().regex(/^(env|file):.+$/, 'from must be env:NAME or file:path'),
11
+ from: (0, zod_1.string)().regex(/^[A-Za-z][A-Za-z0-9_-]*:.+$/, 'from must be env:NAME, file:path or <provider>:<ref>'),
12
+ // the service account a provider secret is read as, else the task's or service's, else the default
13
+ account: (0, zod_1.string)().optional(),
11
14
  env: (0, zod_1.string)()
12
15
  .regex(/^[A-Za-z_][A-Za-z0-9_]*$/)
13
16
  .optional(),
@@ -18,5 +21,5 @@ exports.buildFileSecretSchema = (0, zod_1.object)({
18
21
  .refine((secret) => (secret.env === undefined) !== (secret.path === undefined), {
19
22
  message: 'a secret needs exactly one of env or path',
20
23
  })
21
- .describe('secret from env:NAME or file:path, injected as env or a read-only file');
24
+ .describe('secret from env:NAME, file:path or <provider>:<ref>, injected as env or a read-only file');
22
25
  //# sourceMappingURL=build-file-secret-schema.js.map
@@ -9,6 +9,7 @@ const label_values_1 = require("../executer/label-values");
9
9
  const merge_environment_variables_1 = require("../environment/merge-environment-variables");
10
10
  const resolve_cache_1 = require("../cache/resolve-cache");
11
11
  const project_root_1 = require("../planner/project-root");
12
+ const secret_catalog_1 = require("../secrets/secret-catalog");
12
13
  function combinePath(namePrefix, name) {
13
14
  return namePrefix.length > 0 ? `${namePrefix}:${name}` : name;
14
15
  }
@@ -21,6 +22,7 @@ async function parseReferences(ctx, mainScope, environment) {
21
22
  envFiles: {},
22
23
  environments: {},
23
24
  caches: {},
25
+ secrets: (0, secret_catalog_1.emptySecretCatalog)(),
24
26
  projectRoot: await (0, project_root_1.findProjectRoot)(mainScope.cwd, environment),
25
27
  };
26
28
  for (const file of Object.values(ctx.files)) {
@@ -90,6 +92,7 @@ async function parseReferences(ctx, mainScope, environment) {
90
92
  resolveInit(reference, service);
91
93
  }
92
94
  reference.caches = (0, resolve_cache_1.withBuiltinCaches)(declaredCaches);
95
+ reference.secrets = (0, secret_catalog_1.collectSecretCatalog)(Object.values(ctx.files), environment, reference);
93
96
  return reference;
94
97
  }
95
98
  exports.parseReferences = parseReferences;
@@ -0,0 +1,108 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createCommandSecretProvider = void 0;
4
+ const child_process_1 = require("child_process");
5
+ const abort_1 = require("../executer/abort");
6
+ const STDERR_LIMIT = 500;
7
+ // Runs the provider's command and takes its stdout, verbatim, as the value: a
8
+ // key file needs its trailing newline. No shell, so a reference is one argument
9
+ // and never syntax. The command sees PATH, the provider's own `env` and the
10
+ // account's `env` and nothing else of this process: a personal login (`gcloud
11
+ // auth login`, the 1Password app) is never picked up, and a missing credential
12
+ // fails instead of falling back to one.
13
+ function createCommandSecretProvider(binding) {
14
+ const { definition, account } = binding;
15
+ return {
16
+ async resolve(ref, environment) {
17
+ const [command, ...args] = definition.command.map((part) => expand(part, environment, `provider ${definition.name}`).replace(/\{\{ref\}\}/g, () => ref));
18
+ const env = {
19
+ ...minimalEnvironment(environment),
20
+ ...expandAll(definition.env, environment, `provider ${definition.name}`),
21
+ ...expandAll(account.env, environment, `account ${account.name}, provider ${definition.name}`),
22
+ };
23
+ return run(command, args, env, definition.timeoutMs, environment);
24
+ },
25
+ };
26
+ }
27
+ exports.createCommandSecretProvider = createCommandSecretProvider;
28
+ function minimalEnvironment(environment) {
29
+ const names = process.platform === 'win32' ? ['PATH', 'Path', 'PATHEXT', 'SystemRoot'] : ['PATH'];
30
+ const result = {};
31
+ for (const name of names) {
32
+ const value = environment.processEnvs[name];
33
+ if (value !== undefined && value !== null) {
34
+ result[name] = value;
35
+ }
36
+ }
37
+ return result;
38
+ }
39
+ function expandAll(values, environment, owner) {
40
+ return Object.fromEntries(Object.entries(values).map(([key, value]) => [key, expand(value, environment, owner)]));
41
+ }
42
+ // `${NAME}` from the process environment; one that is not set stops the secret
43
+ // before the command runs.
44
+ function expand(value, environment, owner) {
45
+ return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_match, name) => {
46
+ const resolved = environment.processEnvs[name];
47
+ if (resolved === undefined || resolved === null || resolved === '') {
48
+ throw new Error(`${owner}: ${name} is not set`);
49
+ }
50
+ return resolved;
51
+ });
52
+ }
53
+ function run(command, args, env, timeoutMs, environment) {
54
+ return new Promise((resolve, reject) => {
55
+ const signal = environment.abortCtrl.signal;
56
+ if (signal.aborted) {
57
+ reject(new abort_1.AbortError());
58
+ return;
59
+ }
60
+ const child = (0, child_process_1.spawn)(command, args, { env, shell: false, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
61
+ const stdout = [];
62
+ const stderr = [];
63
+ let failure = null;
64
+ const finish = (error, value) => {
65
+ clearTimeout(timer);
66
+ signal.removeEventListener('abort', onAbort);
67
+ if (error) {
68
+ reject(error);
69
+ }
70
+ else {
71
+ resolve(value ?? '');
72
+ }
73
+ };
74
+ const stop = (error) => {
75
+ failure = failure ?? error;
76
+ child.kill('SIGKILL');
77
+ };
78
+ const onAbort = () => stop(new abort_1.AbortError());
79
+ const timer = setTimeout(() => stop(new Error(`${command} did not finish within ${timeoutMs}ms`)), timeoutMs);
80
+ signal.addEventListener('abort', onAbort);
81
+ child.stdout.on('data', (chunk) => stdout.push(chunk));
82
+ child.stderr.on('data', (chunk) => stderr.push(chunk));
83
+ child.on('error', (error) => {
84
+ finish(error.code === 'ENOENT' ? new Error(`${command} was not found on PATH`) : error);
85
+ });
86
+ child.on('close', (code) => {
87
+ if (failure) {
88
+ finish(failure);
89
+ }
90
+ else if (code !== 0) {
91
+ finish(new Error(`${command} exited with code ${code}${describeStderr(stderr, environment)}`));
92
+ }
93
+ else {
94
+ finish(null, Buffer.concat(stdout).toString('utf8'));
95
+ }
96
+ });
97
+ });
98
+ }
99
+ // What the CLI said, as the first lines of a failure; its own output may hold a
100
+ // value it was asked for, so it is masked like all output.
101
+ function describeStderr(chunks, environment) {
102
+ const text = environment.secrets.redact(Buffer.concat(chunks).toString('utf8').trim());
103
+ if (text.length === 0) {
104
+ return '';
105
+ }
106
+ return `: ${text.length > STDERR_LIMIT ? `${text.substring(0, STDERR_LIMIT)}…` : text}`;
107
+ }
108
+ //# sourceMappingURL=command-secret-provider.js.map
@@ -0,0 +1,27 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.prefetchSecrets = void 0;
4
+ const plan_work_tasks_1 = require("../planner/utils/plan-work-tasks");
5
+ const work_secret_1 = require("../planner/work-secret");
6
+ const provider_values_1 = require("./provider-values");
7
+ // Fetches the values a task id needs; a run drops the ones of the previous run
8
+ // first (`newRun`), any other command reuses what the run already fetched. A task id is computed synchronously and holds the
9
+ // digest of each `cache: true` secret, so those values must be in memory before
10
+ // anything asks for an id. Secrets that don't affect the id are read when their
11
+ // task starts, and a plan without provider secrets calls no provider.
12
+ async function prefetchSecrets(workTree, environment, newRun = false) {
13
+ if (newRun) {
14
+ (0, provider_values_1.beginSecretRun)(environment);
15
+ }
16
+ const fetches = [];
17
+ for (const task of (0, plan_work_tasks_1.iterateWorkTasks)(workTree)) {
18
+ for (const secret of task.data.secrets) {
19
+ if (secret.cache && secret.source.type === 'provider') {
20
+ fetches.push((0, work_secret_1.resolveSecretValue)(secret, environment));
21
+ }
22
+ }
23
+ }
24
+ await Promise.all(fetches);
25
+ }
26
+ exports.prefetchSecrets = prefetchSecrets;
27
+ //# sourceMappingURL=prefetch-secrets.js.map
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.peekProviderValue = exports.fetchProviderValue = exports.beginSecretRun = void 0;
4
+ const secret_provider_1 = require("./secret-provider");
5
+ // The values fetched in the current run, by provider, account and reference.
6
+ // Concurrent requests share one fetch: a secret manager is rate limited (a
7
+ // 1Password service account per token) and several tasks read the same value.
8
+ // Held in memory only, never written anywhere, and dropped when the next run
9
+ // begins so a rotated value is picked up by it.
10
+ const runs = new WeakMap();
11
+ function beginSecretRun(environment) {
12
+ runs.set(environment, new Map());
13
+ }
14
+ exports.beginSecretRun = beginSecretRun;
15
+ function keyOf(binding, ref) {
16
+ return [binding.definition.name, binding.account.name, ref].join('\0');
17
+ }
18
+ function fetchProviderValue(binding, ref, environment) {
19
+ let values = runs.get(environment);
20
+ if (!values) {
21
+ values = new Map();
22
+ runs.set(environment, values);
23
+ }
24
+ const key = keyOf(binding, ref);
25
+ let fetched = values.get(key);
26
+ if (!fetched) {
27
+ environment.status
28
+ .context({ type: 'cli', name: 'hammerkit' })
29
+ .write('debug', `secret ${ref} via ${binding.definition.name}/${binding.account.name}`);
30
+ const entry = { promise: (0, secret_provider_1.createSecretProvider)(binding).resolve(ref, environment) };
31
+ entry.promise = entry.promise.then((value) => {
32
+ entry.value = value;
33
+ return value;
34
+ });
35
+ fetched = entry;
36
+ values.set(key, fetched);
37
+ }
38
+ return fetched.promise;
39
+ }
40
+ exports.fetchProviderValue = fetchProviderValue;
41
+ // The value of a fetch that has finished in this run, for the sync part of
42
+ // planning (a task id); undefined when it was not fetched, see prefetchSecrets.
43
+ function peekProviderValue(binding, ref, environment) {
44
+ return runs.get(environment)?.get(keyOf(binding, ref))?.value;
45
+ }
46
+ exports.peekProviderValue = peekProviderValue;
47
+ //# sourceMappingURL=provider-values.js.map
@@ -0,0 +1,78 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.collectSecretCatalog = exports.emptySecretCatalog = void 0;
4
+ const merge_environment_variables_1 = require("../environment/merge-environment-variables");
5
+ const replace_env_variables_1 = require("../environment/replace-env-variables");
6
+ const units_1 = require("../utils/units");
7
+ const DEFAULT_TIMEOUT = '30s';
8
+ function emptySecretCatalog() {
9
+ return { providers: {}, accounts: {} };
10
+ }
11
+ exports.emptySecretCatalog = emptySecretCatalog;
12
+ const FULL_COMMIT = /^[0-9a-f]{40}$/i;
13
+ // Providers and accounts are global names, like caches, collected from every
14
+ // loaded build file. A name declared twice is an error rather than a silent
15
+ // override: which definition runs with a service account's credential must not
16
+ // depend on include order.
17
+ function collectSecretCatalog(files, environment, context) {
18
+ const catalog = emptySecretCatalog();
19
+ for (const file of files) {
20
+ const providers = Object.entries(file.schema.secretProviders ?? {});
21
+ const accounts = Object.entries(file.schema.secretAccounts ?? {});
22
+ if (providers.length === 0 && accounts.length === 0) {
23
+ continue;
24
+ }
25
+ requirePinnedSource(file);
26
+ const envs = (0, replace_env_variables_1.buildEnvironmentVariables)((0, merge_environment_variables_1.mergeEnvironmentVariables)(file.schema.envs, null), environment, context).variables;
27
+ for (const [name, provider] of providers) {
28
+ if (catalog.providers[name]) {
29
+ throw new Error(`secret provider ${name} is declared in ${catalog.providers[name].declaredIn} and in ${file.fileName}`);
30
+ }
31
+ catalog.providers[name] = {
32
+ name,
33
+ type: 'command',
34
+ command: provider.command.map((part) => applyEnvs(part, envs)),
35
+ env: provider.env ?? {},
36
+ timeoutMs: (0, units_1.parseDuration)(provider.timeout ?? DEFAULT_TIMEOUT),
37
+ declaredIn: file.fileName,
38
+ };
39
+ }
40
+ for (const [name, account] of accounts) {
41
+ if (catalog.accounts[name]) {
42
+ throw new Error(`secret account ${name} is declared in ${catalog.accounts[name].declaredIn} and in ${file.fileName}`);
43
+ }
44
+ catalog.accounts[name] = {
45
+ name,
46
+ default: account.default ?? false,
47
+ providers: account.providers,
48
+ declaredIn: file.fileName,
49
+ };
50
+ }
51
+ }
52
+ const defaults = Object.values(catalog.accounts).filter((account) => account.default);
53
+ if (defaults.length > 1) {
54
+ throw new Error(`more than one default secret account: ${defaults.map((a) => a.name).join(', ')}`);
55
+ }
56
+ for (const account of Object.values(catalog.accounts)) {
57
+ for (const provider of Object.keys(account.providers)) {
58
+ if (!catalog.providers[provider]) {
59
+ throw new Error(`secret account ${account.name} (${account.declaredIn}) names provider ${provider}, which is not declared`);
60
+ }
61
+ }
62
+ }
63
+ return catalog;
64
+ }
65
+ exports.collectSecretCatalog = collectSecretCatalog;
66
+ // A tag or branch can be moved, and a moved file runs with the credentials of a
67
+ // service account: providers and accounts from a git include are pinned.
68
+ function requirePinnedSource(file) {
69
+ if (file.remote && !FULL_COMMIT.test(file.remote.ref ?? '')) {
70
+ throw new Error(`${file.fileName} declares secret providers or accounts, so its git include ${file.remote.git} needs ref to be a full commit SHA`);
71
+ }
72
+ }
73
+ // `${NAME}` that the build file's own `envs` define; any other reference stays
74
+ // for the host environment.
75
+ function applyEnvs(value, envs) {
76
+ return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (match, name) => name in envs && !envs[name].includes('$') ? envs[name] : match);
77
+ }
78
+ //# sourceMappingURL=secret-catalog.js.map
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createSecretProvider = exports.registerSecretProvider = void 0;
4
+ const command_secret_provider_1 = require("./command-secret-provider");
5
+ const factories = new Map();
6
+ function registerSecretProvider(type, factory) {
7
+ factories.set(type, factory);
8
+ }
9
+ exports.registerSecretProvider = registerSecretProvider;
10
+ function createSecretProvider(binding) {
11
+ const factory = factories.get(binding.definition.type);
12
+ if (!factory) {
13
+ throw new Error(`unknown secret provider type: ${binding.definition.type}`);
14
+ }
15
+ return factory(binding);
16
+ }
17
+ exports.createSecretProvider = createSecretProvider;
18
+ registerSecretProvider('command', command_secret_provider_1.createCommandSecretProvider);
19
+ //# sourceMappingURL=secret-provider.js.map
@@ -1,29 +1,85 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.redact = exports.registerSecretValue = void 0;
4
- // Secret values hammerkit has read, masked in everything it logs, including the
5
- // output it streams from a task or service. Logs are per line, so each line of
6
- // a multi-line value (a key file) is masked on its own. Values shorter than
7
- // MIN_LENGTH are left alone: masking them would mangle unrelated output.
3
+ exports.getEncodedForms = exports.createSecretRegistry = void 0;
4
+ // Secret values a run has read, masked in everything it logs, including the
5
+ // output it streams from a task or service. The registry belongs to one run
6
+ // (its Environment): a process that serves several runs must not mask one
7
+ // run's values in another's output, and must not keep them after the run.
8
+ //
9
+ // Logs are per line, so each line of a multi-line value (a key file) is masked
10
+ // on its own. Values shorter than MIN_LENGTH are left alone: masking them would
11
+ // mangle unrelated output. The usual encodings of a value (base64, hex, URL,
12
+ // JSON) are masked too, since a task or tool often prints those instead; an
13
+ // encoding must be MIN_ENCODED_LENGTH long to be matched, for the same reason.
8
14
  const MIN_LENGTH = 4;
15
+ const MIN_ENCODED_LENGTH = 8;
9
16
  const MASK = '***';
10
- const values = new Set();
11
- function registerSecretValue(value) {
12
- for (const candidate of [value, ...value.split(/\r?\n/)]) {
13
- const trimmed = candidate.trim();
14
- if (trimmed.length >= MIN_LENGTH) {
15
- values.add(trimmed);
17
+ function createSecretRegistry() {
18
+ const values = new Set();
19
+ // longest first, so a value containing another is masked as a whole
20
+ let sorted = null;
21
+ function add(candidate, minLength) {
22
+ if (candidate.length >= minLength && !values.has(candidate)) {
23
+ values.add(candidate);
24
+ sorted = null;
16
25
  }
17
26
  }
27
+ return {
28
+ register(value) {
29
+ // the whole value, then each line of it; encodings are taken of each as it was written
30
+ for (const base of new Set([value, ...value.split(/\r?\n/)])) {
31
+ if (base.trim().length < MIN_LENGTH) {
32
+ continue;
33
+ }
34
+ add(base.trim(), MIN_LENGTH);
35
+ for (const form of getEncodedForms(base)) {
36
+ add(form.value, form.minLength);
37
+ }
38
+ }
39
+ },
40
+ redact(message) {
41
+ if (values.size === 0) {
42
+ return message;
43
+ }
44
+ sorted = sorted ?? [...values].sort((a, b) => b.length - a.length);
45
+ return sorted.reduce((result, value) => result.split(value).join(MASK), message);
46
+ },
47
+ };
18
48
  }
19
- exports.registerSecretValue = registerSecretValue;
20
- function redact(message) {
21
- if (values.size === 0) {
22
- return message;
49
+ exports.createSecretRegistry = createSecretRegistry;
50
+ // The encodings of a value that are likely to show up in output. Base64 is
51
+ // matched wherever it starts inside a longer base64 string: the value begins
52
+ // at byte offset 0, 1 or 2 of a 3-byte group, and the characters that mix in
53
+ // bytes before or after it are dropped, since they depend on unknown data.
54
+ function getEncodedForms(value) {
55
+ const bytes = Buffer.from(value, 'utf8');
56
+ const forms = [];
57
+ // the value on its own, as a task or tool usually prints it
58
+ const whole = bytes.toString('base64');
59
+ forms.push({ value: whole, minLength: MIN_ENCODED_LENGTH });
60
+ forms.push({ value: whole.replace(/\+/g, '-').replace(/\//g, '_'), minLength: MIN_ENCODED_LENGTH });
61
+ for (let offset = 0; offset < 3; offset++) {
62
+ const encoded = Buffer.concat([Buffer.alloc(offset), bytes])
63
+ .toString('base64')
64
+ .replace(/=+$/, '');
65
+ const leading = [0, 2, 3][offset];
66
+ const trailing = (bytes.length + offset) % 3 === 0 ? 0 : 1;
67
+ const form = encoded.substring(leading, encoded.length - trailing);
68
+ forms.push({ value: form, minLength: MIN_ENCODED_LENGTH });
69
+ forms.push({ value: form.replace(/\+/g, '-').replace(/\//g, '_'), minLength: MIN_ENCODED_LENGTH });
23
70
  }
24
- // longest first, so a value containing another is masked as a whole
25
- const sorted = [...values].sort((a, b) => b.length - a.length);
26
- return sorted.reduce((result, value) => result.split(value).join(MASK), message);
71
+ const hex = bytes.toString('hex');
72
+ forms.push({ value: hex, minLength: MIN_ENCODED_LENGTH });
73
+ forms.push({ value: hex.toUpperCase(), minLength: MIN_ENCODED_LENGTH });
74
+ const url = encodeURIComponent(value);
75
+ if (url !== value) {
76
+ forms.push({ value: url, minLength: MIN_LENGTH });
77
+ }
78
+ const json = JSON.stringify(value).slice(1, -1);
79
+ if (json !== value) {
80
+ forms.push({ value: json, minLength: MIN_LENGTH });
81
+ }
82
+ return forms;
27
83
  }
28
- exports.redact = redact;
84
+ exports.getEncodedForms = getEncodedForms;
29
85
  //# sourceMappingURL=redact.js.map
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "hammerkit",
3
3
  "description": "Containerized build tool with incremental caching that runs the same build file locally, in Docker and on Kubernetes.",
4
- "version": "1.22.0",
4
+ "version": "1.23.0",
5
5
  "keywords": [
6
6
  "build",
7
7
  "build-tool",