@checkstack/secrets-common 0.1.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/CHANGELOG.md +109 -0
- package/package.json +35 -0
- package/src/access.ts +26 -0
- package/src/backend-config.ts +83 -0
- package/src/env-mapping.test.ts +103 -0
- package/src/env-mapping.ts +81 -0
- package/src/hooks.ts +21 -0
- package/src/index.ts +62 -0
- package/src/internal-secrets.test.ts +20 -0
- package/src/internal-secrets.ts +25 -0
- package/src/mask-run-result.test.ts +40 -0
- package/src/mask-run-result.ts +50 -0
- package/src/masking.test.ts +156 -0
- package/src/masking.ts +111 -0
- package/src/metadata.ts +21 -0
- package/src/plugin-metadata.ts +9 -0
- package/src/routes.ts +8 -0
- package/src/rpc-contract.ts +108 -0
- package/src/secret-field.test.ts +89 -0
- package/src/secret-field.ts +87 -0
- package/src/test-secret-env.test.ts +61 -0
- package/src/test-secret-env.ts +54 -0
- package/tsconfig.json +11 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# @checkstack/secrets-common
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- b995afb: Fix the automation Run Script action's `secretEnv` (secret → env mapping) test wiring and tolerate bare secret names.
|
|
8
|
+
|
|
9
|
+
- `@checkstack/ui` `ScriptTestPanel` now accepts the script field's declared `secretEnv` and renders an optional per-secret test-override input. The `ScriptTestRenderer` callback (DynamicForm) receives the SIBLING `x-secret-env` mapping value, located by annotation (not by field name), so a testable script field forwards it to the panel. Previously the test path never sent `secretEnv`, so `buildTestSecretEnv` got `undefined` and `process.env.<env>` was undefined in an in-UI test. Now an override-less test injects `__SECRET_<NAME>__` placeholders, and any operator override is masked from the output. Real secret values are still NEVER resolved in the test path.
|
|
10
|
+
- `@checkstack/automation-frontend` forwards the action's `secretEnv` and the collected overrides to `testScript`.
|
|
11
|
+
- `@checkstack/secrets-common`: the `secretEnv` mapping VALUE now accepts EITHER a `${{ secrets.NAME }}` template OR a bare secret name, normalizing a bare name to the canonical `${{ secrets.NAME }}` template on parse. This is a forgiving / NARROWING input change (more inputs accepted; stored/output form is unchanged and still the template), not a breaking change. Existing data and YAML shorthand like `secretEnv: { secret: SECRET }` now pass config validation instead of failing with "Must contain a ${{ secrets.NAME }} reference". Partial inline interpolation (e.g. `u:${{ secrets.pw }}@host`) keeps working unchanged; values that are neither a secret reference nor a valid secret name are still rejected.
|
|
12
|
+
- `@checkstack/ui` `parseSecretName` tolerates a legacy bare secret name for display so the picker shows the same name for both the template and the bare form.
|
|
13
|
+
|
|
14
|
+
The healthcheck collector test panel was checked: its config has no `x-secret-env` field, so it needed no secret wiring (only the `onRun` signature change, which is backward compatible).
|
|
15
|
+
|
|
16
|
+
- 270ef29: Add the Secrets platform (Phase 1): a central, plugin-agnostic secret manager with a pluggable backend extension point, a cross-plugin resolver service, and a universal Jenkins-style masking layer.
|
|
17
|
+
|
|
18
|
+
- New packages: `secrets-common` (schemas, contract, `secrets.read`/`secrets.manage`, masking utils), `secrets-backend` (`SecretBackend` extension point, `secretResolverRef`/`secretAdminRef` services, run-scoped masking context, RPC router), `secrets-backend-local` (default AES-256-GCM backend, owns the `secrets` table promoted from gitops), `secrets-frontend` (admin Settings page).
|
|
19
|
+
- Resolution machinery (`resolveSecretsBySchema`, `SecretStore`, `${{ secrets.NAME }}` / `x-secret`) is promoted out of `gitops-backend` into `secrets-backend`. GitOps now resolves and manages secrets through the platform's service refs (single source of truth); its secret table is migrated without loss.
|
|
20
|
+
- Universal masking seam wired at the central script-output boundaries: automation `run_script` / `run_shell` artifacts and the in-UI test panel redact run-scoped secret values from `result`/`stdout`/`stderr`/`error` before persist/return. Phase 1 resolves no run-scoped secrets yet, so masking is a no-op until Phase 2; the seam guarantees the boundary exists.
|
|
21
|
+
- No endpoint returns a secret value to a browser: DTOs expose only name/metadata/`hasValue`.
|
|
22
|
+
|
|
23
|
+
BREAKING CHANGES: `gitops-backend` now depends on `secrets-backend` and resolves/manages secrets through it. The `secrets` table is owned by `secrets-backend-local`; the gitops `secrets` table is retained as a migration source but is no longer the source of truth.
|
|
24
|
+
|
|
25
|
+
- 270ef29: Secrets platform Phase 2: secret -> env-var mapping with central resolve, inject, and mask.
|
|
26
|
+
|
|
27
|
+
- Script consumers declare a least-privilege `secretEnv` allowlist
|
|
28
|
+
(`{ ENV_NAME: "${{ secrets.NAME }}" }`). The automation `run_script` /
|
|
29
|
+
`run_shell` actions resolve ONLY the declared secrets via
|
|
30
|
+
`secretResolverRef.resolveForRun`, inject them into the runner env for
|
|
31
|
+
that run (memory-only; the ESM runner gained a per-run `env` option), and
|
|
32
|
+
mask their values out of stdout/stderr/result/error via the run-scoped
|
|
33
|
+
masking context. A missing required secret fails the run clearly. No
|
|
34
|
+
ambient secret access.
|
|
35
|
+
- Test panel: `testScript` / `testCollectorScript` inject named
|
|
36
|
+
`__SECRET_<NAME>__` placeholders by default, or user-supplied per-secret
|
|
37
|
+
overrides; real production values are never resolved in the test path,
|
|
38
|
+
and overrides are masked out of the result.
|
|
39
|
+
- Healthcheck collectors carry the `secretEnv` field for authoring +
|
|
40
|
+
the test panel; runtime injection on satellites lands in Phase 3.
|
|
41
|
+
- Editor UX: a new `@checkstack/ui` `SecretEnvEditor` renders `x-secret-env`
|
|
42
|
+
record fields with `${{ secrets.* }}` name autocomplete (from
|
|
43
|
+
`listSecretNames`), wired into the automation action editor and the
|
|
44
|
+
healthcheck collector editor. New `withConfigMeta` helper +
|
|
45
|
+
`x-secret-env` config-meta key in `@checkstack/backend-api`.
|
|
46
|
+
|
|
47
|
+
- 270ef29: Secrets platform Phase 4: HashiCorp Vault backend + backend selection.
|
|
48
|
+
|
|
49
|
+
- New `@checkstack/secrets-backend-vault`: a read-through `SecretBackend`
|
|
50
|
+
against Vault. Token, AppRole, and OIDC/JWT auth (session cached to the
|
|
51
|
+
lease TTL, capped); KV v2 reads mapped via the backend's own
|
|
52
|
+
`secret_index` table (name → path/key); read-through value cache with a
|
|
53
|
+
capped TTL (rotated values re-read). `list()` returns metadata only,
|
|
54
|
+
never values. Minimal typed HTTP client (no extra dependency), injectable
|
|
55
|
+
fetch for testing.
|
|
56
|
+
- Backend selection: the active backend is persisted via `ConfigService`
|
|
57
|
+
and switchable in Settings → Secrets; switching re-routes resolution.
|
|
58
|
+
New `setBackendConfig` / `testBackend` RPCs (manage-gated, status-only)
|
|
59
|
+
and `getBackendConfig` now returns Vault connection metadata
|
|
60
|
+
(`hasCredential`, never the credential). `SecretBackend` gains optional
|
|
61
|
+
`test` / `configure` / `getConfigMeta`.
|
|
62
|
+
- The Vault auth credential is stored as an `x-secret` config field
|
|
63
|
+
(encrypted at rest with the AES-GCM master key, redacted on read) —
|
|
64
|
+
bootstrapping it WITHOUT putting it in Vault. It is write-only over the
|
|
65
|
+
API and never logged.
|
|
66
|
+
- Admin UI: backend selector + Vault connection form + "Test connection".
|
|
67
|
+
|
|
68
|
+
Satellite-direct-Vault (a satellite reading Vault itself) is deferred to a
|
|
69
|
+
follow-up; core-mediated delivery already routes through the Vault backend.
|
|
70
|
+
|
|
71
|
+
- 270ef29: Secrets platform Phase 5: internal-secret consolidation (registry token) + connection-credential leak hardening.
|
|
72
|
+
|
|
73
|
+
- New `internalSecretsRef`: platform-internal secrets (not user-managed
|
|
74
|
+
named secrets) stored under a reserved `__internal__:` prefix, ALWAYS on
|
|
75
|
+
the local (always-writable, AES-GCM) backend so internal writes never
|
|
76
|
+
break when Vault is the active backend. Excluded from the user-facing
|
|
77
|
+
Secrets list.
|
|
78
|
+
- The script-package registry auth token is consolidated onto
|
|
79
|
+
`internalSecretsRef`. The `authSecretRef` column now holds a stable
|
|
80
|
+
marker; a one-time, idempotent, parity-verified migration moves legacy
|
|
81
|
+
inline ciphertext into the platform and only rewrites the column once the
|
|
82
|
+
platform copy reads back identically (legacy value never dropped early).
|
|
83
|
+
Resolution stays backward-compatible with legacy ciphertext.
|
|
84
|
+
- Integration: `createConnection` / `updateConnection` now return the
|
|
85
|
+
redacted connection preview instead of echoing the submitted credential
|
|
86
|
+
fields back in the response (leak hardening). Non-breaking — the frontend
|
|
87
|
+
refetches the redacted list and ignores the returned preview.
|
|
88
|
+
|
|
89
|
+
NOTE: integration connection-credential STORAGE is intentionally NOT
|
|
90
|
+
migrated onto the secrets platform. Connection creds are co-mingled
|
|
91
|
+
secret/non-secret config stored per-provider via `ConfigService` (which
|
|
92
|
+
already uses the same AES-GCM crypto + per-field redaction); splitting them
|
|
93
|
+
out would require per-provider schema-walking and a lossy migration across
|
|
94
|
+
live integrations for no real gain. The `ConnectionStore` API + storage are
|
|
95
|
+
unchanged.
|
|
96
|
+
|
|
97
|
+
- b995afb: Hide secret write controls when the active backend is read-through.
|
|
98
|
+
|
|
99
|
+
When a read-through backend (e.g. Vault) was active, the Secrets admin page still showed the "Add a secret", Rotate, and Delete controls even though the backend correctly rejects writes (`set` / `delete` are intentionally unimplemented), so every attempt errored.
|
|
100
|
+
|
|
101
|
+
The active backend now reports a capability flag and the UI gates its write affordances on it instead of any hardcoded backend id, so other read-through backends are handled the same way.
|
|
102
|
+
|
|
103
|
+
Changes:
|
|
104
|
+
|
|
105
|
+
- `@checkstack/secrets-common`: add a `writable: boolean` field to `BackendConfigDto` (returned by `getBackendConfig`). It carries no sensitive data - a capability boolean only.
|
|
106
|
+
- `@checkstack/secrets-backend`: populate `writable` in the `getBackendConfig` handler by inspecting the resolved active backend (true only when it implements both `set` and `delete`; `false` for read-through backends or an unresolved active id). Exposes a small `isBackendWritable` helper.
|
|
107
|
+
- `@checkstack/secrets-frontend`: hide the create form, per-row Rotate / Delete buttons, and adjust the empty-state and helper text when the active backend is not writable, plus show a short "read-through" explainer. The local backend stays fully writable.
|
|
108
|
+
|
|
109
|
+
State & scale: `writable` is derived on read from the resolved active backend's capabilities (durable config selects the backend), so every pod computes the same answer; no new state is introduced.
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@checkstack/secrets-common",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Shared schemas, contract, and access rules for the Secrets platform",
|
|
5
|
+
"author": "Checkstack contributors",
|
|
6
|
+
"license": "Elastic-2.0",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "src/index.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": "./src/index.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"checkstack": {
|
|
15
|
+
"type": "common",
|
|
16
|
+
"pluginId": "secrets"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"pack": "bunx @checkstack/scripts plugin-pack",
|
|
20
|
+
"typecheck": "tsgo -b",
|
|
21
|
+
"lint": "bun run lint:code",
|
|
22
|
+
"lint:code": "eslint . --max-warnings 0"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@checkstack/common": "0.12.0",
|
|
26
|
+
"@orpc/contract": "^1.13.2",
|
|
27
|
+
"zod": "^4.2.1"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@checkstack/scripts": "0.3.4",
|
|
31
|
+
"@checkstack/tsconfig": "0.0.7",
|
|
32
|
+
"@types/bun": "^1.3.5",
|
|
33
|
+
"typescript": "^5.7.2"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/access.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { accessPair } from "@checkstack/common";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Access rules for the Secrets platform.
|
|
5
|
+
*
|
|
6
|
+
* `read` lets a user see secret NAMES + metadata (never values), e.g. for
|
|
7
|
+
* the `${{ secrets.* }}` editor autocomplete. `manage` lets a user create,
|
|
8
|
+
* rotate, and delete secrets and configure the active backend.
|
|
9
|
+
*/
|
|
10
|
+
export const secretsAccess = {
|
|
11
|
+
secret: accessPair("secret", {
|
|
12
|
+
read: {
|
|
13
|
+
description: "View secret names and metadata (never values)",
|
|
14
|
+
isDefault: true,
|
|
15
|
+
},
|
|
16
|
+
manage: {
|
|
17
|
+
description: "Create, rotate, delete secrets and configure backends",
|
|
18
|
+
},
|
|
19
|
+
}),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** All access rules for registration with the plugin system. */
|
|
23
|
+
export const secretsAccessRules = [
|
|
24
|
+
secretsAccess.secret.read,
|
|
25
|
+
secretsAccess.secret.manage,
|
|
26
|
+
];
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Vault authentication method. All three are supported:
|
|
5
|
+
* - `token`: a static Vault token (the `credential`).
|
|
6
|
+
* - `approle`: AppRole `role_id` (`role`) + `secret_id` (`credential`).
|
|
7
|
+
* - `oidc`: OIDC/JWT login — `role` is the Vault role, `credential` is the
|
|
8
|
+
* JWT/OIDC token presented to Vault's `jwt`/`oidc` auth mount.
|
|
9
|
+
*/
|
|
10
|
+
export const vaultAuthMethodSchema = z.enum(["token", "approle", "oidc"]);
|
|
11
|
+
export type VaultAuthMethod = z.infer<typeof vaultAuthMethodSchema>;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Non-secret Vault connection settings. The auth `credential` itself is
|
|
15
|
+
* NEVER part of this shape — it is write-only input / encrypted at rest and
|
|
16
|
+
* never returned to a browser. This is the metadata the admin UI shows.
|
|
17
|
+
*/
|
|
18
|
+
export const vaultConfigMetaSchema = z.object({
|
|
19
|
+
/** Vault address, e.g. "https://vault.example.com:8200". */
|
|
20
|
+
address: z.string().url(),
|
|
21
|
+
/** KV v2 mount path, e.g. "secret". */
|
|
22
|
+
mount: z.string().min(1).default("secret"),
|
|
23
|
+
authMethod: vaultAuthMethodSchema,
|
|
24
|
+
/** AppRole role_id or the OIDC/JWT Vault role name. Unused for `token`. */
|
|
25
|
+
role: z.string().optional(),
|
|
26
|
+
/** Auth mount path for approle/oidc (e.g. "approle", "jwt"). */
|
|
27
|
+
authMount: z.string().optional(),
|
|
28
|
+
/** Vault namespace (Enterprise), optional. */
|
|
29
|
+
namespace: z.string().optional(),
|
|
30
|
+
/** Whether an auth credential is currently stored (never the value). */
|
|
31
|
+
hasCredential: z.boolean(),
|
|
32
|
+
});
|
|
33
|
+
export type VaultConfigMeta = z.infer<typeof vaultConfigMetaSchema>;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Backend configuration as exposed to the browser: the active backend, the
|
|
37
|
+
* available backend ids, whether the active backend accepts writes, and
|
|
38
|
+
* (when configured) the Vault connection metadata. NEVER carries the Vault
|
|
39
|
+
* auth credential or any secret value.
|
|
40
|
+
*/
|
|
41
|
+
export const backendConfigDtoSchema = z.object({
|
|
42
|
+
activeBackend: z.string(),
|
|
43
|
+
availableBackends: z.array(z.string()),
|
|
44
|
+
/**
|
|
45
|
+
* Whether the active backend supports creating, rotating, and deleting
|
|
46
|
+
* secret values from this UI. Read-through backends (e.g. Vault) report
|
|
47
|
+
* `false`: secret values are managed in the external store, so the UI hides
|
|
48
|
+
* its write controls. The local backend reports `true`.
|
|
49
|
+
*/
|
|
50
|
+
writable: z.boolean(),
|
|
51
|
+
vault: vaultConfigMetaSchema.optional(),
|
|
52
|
+
});
|
|
53
|
+
export type BackendConfigDto = z.infer<typeof backendConfigDtoSchema>;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Input for `setBackendConfig`. The Vault `credential` is write-only: it is
|
|
57
|
+
* accepted here, stored encrypted, and never returned. Omit `credential` on
|
|
58
|
+
* update to keep the existing one.
|
|
59
|
+
*/
|
|
60
|
+
export const setBackendConfigInputSchema = z.object({
|
|
61
|
+
activeBackend: z.string().min(1),
|
|
62
|
+
vault: z
|
|
63
|
+
.object({
|
|
64
|
+
address: z.string().url(),
|
|
65
|
+
mount: z.string().min(1).default("secret"),
|
|
66
|
+
authMethod: vaultAuthMethodSchema,
|
|
67
|
+
role: z.string().optional(),
|
|
68
|
+
authMount: z.string().optional(),
|
|
69
|
+
namespace: z.string().optional(),
|
|
70
|
+
/** Write-only auth credential (token / secret_id / OIDC JWT). */
|
|
71
|
+
credential: z.string().optional(),
|
|
72
|
+
})
|
|
73
|
+
.optional(),
|
|
74
|
+
});
|
|
75
|
+
export type SetBackendConfigInput = z.infer<typeof setBackendConfigInputSchema>;
|
|
76
|
+
|
|
77
|
+
/** Result of `testBackend` — status only, never a secret value. */
|
|
78
|
+
export const testBackendResultSchema = z.object({
|
|
79
|
+
ok: z.boolean(),
|
|
80
|
+
/** Human-readable detail (auth failure reason, etc.). Never a value. */
|
|
81
|
+
message: z.string(),
|
|
82
|
+
});
|
|
83
|
+
export type TestBackendResult = z.infer<typeof testBackendResultSchema>;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import {
|
|
4
|
+
secretEnvValueSchema,
|
|
5
|
+
secretEnvMappingSchema,
|
|
6
|
+
normalizeSecretEnvValue,
|
|
7
|
+
toSecretTemplate,
|
|
8
|
+
} from "./env-mapping";
|
|
9
|
+
|
|
10
|
+
// The value schema is a plain union (NO transform — it must stay
|
|
11
|
+
// JSON-Schema-representable for the plugin config UI). It ACCEPTS both a
|
|
12
|
+
// canonical template and a bare secret name, returning the value unchanged;
|
|
13
|
+
// normalization to the canonical template is done separately by
|
|
14
|
+
// `normalizeSecretEnvValue` at the consumption boundary.
|
|
15
|
+
describe("secretEnvValueSchema", () => {
|
|
16
|
+
it("accepts a canonical template unchanged", () => {
|
|
17
|
+
expect(secretEnvValueSchema.parse("${{ secrets.jira_token }}")).toBe(
|
|
18
|
+
"${{ secrets.jira_token }}",
|
|
19
|
+
);
|
|
20
|
+
expect(secretEnvValueSchema.parse("${{secrets.x}}")).toBe("${{secrets.x}}");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("accepts a bare secret name unchanged (no transform on parse)", () => {
|
|
24
|
+
expect(secretEnvValueSchema.parse("SECRET")).toBe("SECRET");
|
|
25
|
+
expect(secretEnvValueSchema.parse("jira_token")).toBe("jira_token");
|
|
26
|
+
// Hyphens are allowed in secret names.
|
|
27
|
+
expect(secretEnvValueSchema.parse("my-secret")).toBe("my-secret");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("is representable in JSON Schema (no transform — guards plugin load)", () => {
|
|
31
|
+
// The plugin manager renders config schemas to JSON Schema at load time;
|
|
32
|
+
// a zod `.transform()` here throws "Transforms cannot be represented in
|
|
33
|
+
// JSON Schema" and crashes plugin load. Assert generation succeeds.
|
|
34
|
+
expect(() => z.toJSONSchema(secretEnvValueSchema)).not.toThrow();
|
|
35
|
+
expect(() => z.toJSONSchema(secretEnvMappingSchema)).not.toThrow();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("rejects an invalid secret name", () => {
|
|
39
|
+
expect(() => secretEnvValueSchema.parse("1secret")).toThrow();
|
|
40
|
+
expect(() => secretEnvValueSchema.parse("not a name")).toThrow();
|
|
41
|
+
expect(() => secretEnvValueSchema.parse("")).toThrow();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("accepts existing partial-template (inline) interpolation", () => {
|
|
45
|
+
expect(secretEnvValueSchema.parse("u:${{ secrets.pw }}@host")).toBe(
|
|
46
|
+
"u:${{ secrets.pw }}@host",
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("rejects a value with no secret reference and no valid bare name", () => {
|
|
51
|
+
expect(() => secretEnvValueSchema.parse("${{ env.FOO }}")).toThrow();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("normalizeSecretEnvValue", () => {
|
|
56
|
+
it("wraps a bare secret name in the canonical template", () => {
|
|
57
|
+
expect(normalizeSecretEnvValue("SECRET")).toBe("${{ secrets.SECRET }}");
|
|
58
|
+
expect(normalizeSecretEnvValue("my-secret")).toBe(
|
|
59
|
+
"${{ secrets.my-secret }}",
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("leaves a template (or any value containing one) unchanged", () => {
|
|
64
|
+
expect(normalizeSecretEnvValue("${{ secrets.jira_token }}")).toBe(
|
|
65
|
+
"${{ secrets.jira_token }}",
|
|
66
|
+
);
|
|
67
|
+
expect(normalizeSecretEnvValue("u:${{ secrets.pw }}@host")).toBe(
|
|
68
|
+
"u:${{ secrets.pw }}@host",
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("secretEnvMappingSchema", () => {
|
|
74
|
+
it("accepts a bare-name value (unchanged on parse)", () => {
|
|
75
|
+
expect(secretEnvMappingSchema.parse({ secret: "SECRET" })).toEqual({
|
|
76
|
+
secret: "SECRET",
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("accepts a mix of template and bare-name values unchanged", () => {
|
|
81
|
+
expect(
|
|
82
|
+
secretEnvMappingSchema.parse({
|
|
83
|
+
API_TOKEN: "${{ secrets.jira_token }}",
|
|
84
|
+
DB: "db_pass",
|
|
85
|
+
}),
|
|
86
|
+
).toEqual({
|
|
87
|
+
API_TOKEN: "${{ secrets.jira_token }}",
|
|
88
|
+
DB: "db_pass",
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("rejects an invalid env-var key", () => {
|
|
93
|
+
expect(() =>
|
|
94
|
+
secretEnvMappingSchema.parse({ "1BAD": "SECRET" }),
|
|
95
|
+
).toThrow();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("toSecretTemplate", () => {
|
|
100
|
+
it("wraps a name in the canonical template", () => {
|
|
101
|
+
expect(toSecretTemplate("api")).toBe("${{ secrets.api }}");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import {
|
|
3
|
+
SECRET_NAME_REGEX,
|
|
4
|
+
secretNameSchema,
|
|
5
|
+
secretTemplateSchema,
|
|
6
|
+
} from "./secret-field";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Valid POSIX-ish environment variable name: starts with a letter or
|
|
10
|
+
* underscore, then letters, digits, underscores. Upper-case is the
|
|
11
|
+
* convention but not enforced (shells are case-sensitive).
|
|
12
|
+
*/
|
|
13
|
+
export const ENV_NAME_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
14
|
+
|
|
15
|
+
export const envNameSchema = z
|
|
16
|
+
.string()
|
|
17
|
+
.min(1)
|
|
18
|
+
.max(128)
|
|
19
|
+
.regex(
|
|
20
|
+
ENV_NAME_REGEX,
|
|
21
|
+
"Environment variable names must start with a letter or underscore and contain only letters, digits, or underscores",
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Render a bare secret name as its canonical `${{ secrets.NAME }}` template.
|
|
26
|
+
* Shared with the UI serializer so both produce identical canonical output.
|
|
27
|
+
*/
|
|
28
|
+
export function toSecretTemplate(secretName: string): string {
|
|
29
|
+
return `\${{ secrets.${secretName} }}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A single secret→env mapping VALUE. The canonical (stored / serialized)
|
|
34
|
+
* form is the `${{ secrets.NAME }}` template, but a bare secret name (e.g.
|
|
35
|
+
* `"jira_token"`, as a YAML / GitOps shorthand or legacy data) is TOLERATED
|
|
36
|
+
* on input. Only a pure secret reference (a whole-value template) or a pure
|
|
37
|
+
* bare secret name is accepted — arbitrary interpolation or partial templates
|
|
38
|
+
* are not.
|
|
39
|
+
*
|
|
40
|
+
* IMPORTANT: this is a plain union with NO `.transform()`. This schema is
|
|
41
|
+
* embedded (via `secretEnvMappingSchema`) in plugin action/collector CONFIG
|
|
42
|
+
* schemas, which the plugin manager renders to JSON Schema for the UI — and a
|
|
43
|
+
* zod transform "cannot be represented in JSON Schema" (it throws at load
|
|
44
|
+
* time). Normalization of a bare name to the canonical template therefore
|
|
45
|
+
* happens at the CONSUMPTION boundary instead, via
|
|
46
|
+
* {@link normalizeSecretEnvValue} (called by the run resolver and the test
|
|
47
|
+
* placeholder builder), not on parse.
|
|
48
|
+
*/
|
|
49
|
+
export const secretEnvValueSchema = z.union([
|
|
50
|
+
secretTemplateSchema,
|
|
51
|
+
secretNameSchema,
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Normalize a single `secretEnv` mapping value to its canonical
|
|
56
|
+
* `${{ secrets.NAME }}` template form: a bare secret name is wrapped, an
|
|
57
|
+
* already-canonical template (or any value containing a `${{ … }}`) is
|
|
58
|
+
* returned unchanged. Apply this at every point that parses the value as a
|
|
59
|
+
* template, so a tolerated bare name resolves / placeholder-builds correctly.
|
|
60
|
+
*/
|
|
61
|
+
export function normalizeSecretEnvValue(value: string): string {
|
|
62
|
+
return SECRET_NAME_REGEX.test(value) ? toSecretTemplate(value) : value;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A consumer's explicit secret→env allowlist:
|
|
67
|
+
* `{ API_TOKEN: "${{ secrets.jira_token }}" }`.
|
|
68
|
+
*
|
|
69
|
+
* Each key is the env var the consumer's run sees; each value is a
|
|
70
|
+
* `${{ secrets.NAME }}` template resolved against the active backend (a bare
|
|
71
|
+
* secret name is tolerated on input and normalized to the template — see
|
|
72
|
+
* {@link secretEnvValueSchema}). This is the least-privilege contract: only
|
|
73
|
+
* the secrets named here are resolved and injected for the consumer's runs
|
|
74
|
+
* (no ambient access).
|
|
75
|
+
*/
|
|
76
|
+
export const secretEnvMappingSchema = z.record(
|
|
77
|
+
envNameSchema,
|
|
78
|
+
secretEnvValueSchema,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
export type SecretEnvMapping = z.infer<typeof secretEnvMappingSchema>;
|
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Hook id fired when a secret is created, rotated, or deleted. The actual
|
|
5
|
+
* `Hook` object is created in `@checkstack/secrets-backend` (via
|
|
6
|
+
* `createHook` from `@checkstack/backend-api`); only the id + payload
|
|
7
|
+
* shape live here so consumers can reference them without a backend dep.
|
|
8
|
+
*
|
|
9
|
+
* Consumers (e.g. gitops) subscribe to re-reconcile entities that
|
|
10
|
+
* reference the changed secret.
|
|
11
|
+
*/
|
|
12
|
+
export const SECRETS_CHANGED_HOOK_ID = "secrets.changed";
|
|
13
|
+
|
|
14
|
+
export const secretsChangedPayloadSchema = z.object({
|
|
15
|
+
/** Name of the secret that changed. */
|
|
16
|
+
name: z.string(),
|
|
17
|
+
/** What happened to it. */
|
|
18
|
+
change: z.enum(["created", "rotated", "deleted"]),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export type SecretsChangedPayload = z.infer<typeof secretsChangedPayloadSchema>;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export { pluginMetadata } from "./plugin-metadata";
|
|
2
|
+
export { secretsAccess, secretsAccessRules } from "./access";
|
|
3
|
+
export { secretsRoutes } from "./routes";
|
|
4
|
+
export {
|
|
5
|
+
SECRET_NAME_REGEX,
|
|
6
|
+
SECRET_TEMPLATE_REGEX,
|
|
7
|
+
secretNameSchema,
|
|
8
|
+
secretTemplateSchema,
|
|
9
|
+
collectSecretNames,
|
|
10
|
+
type SecretName,
|
|
11
|
+
} from "./secret-field";
|
|
12
|
+
export {
|
|
13
|
+
ENV_NAME_REGEX,
|
|
14
|
+
envNameSchema,
|
|
15
|
+
secretEnvMappingSchema,
|
|
16
|
+
secretEnvValueSchema,
|
|
17
|
+
normalizeSecretEnvValue,
|
|
18
|
+
toSecretTemplate,
|
|
19
|
+
type SecretEnvMapping,
|
|
20
|
+
} from "./env-mapping";
|
|
21
|
+
export { secretMetadataSchema, type SecretMetadata } from "./metadata";
|
|
22
|
+
export {
|
|
23
|
+
vaultAuthMethodSchema,
|
|
24
|
+
vaultConfigMetaSchema,
|
|
25
|
+
backendConfigDtoSchema,
|
|
26
|
+
setBackendConfigInputSchema,
|
|
27
|
+
testBackendResultSchema,
|
|
28
|
+
type VaultAuthMethod,
|
|
29
|
+
type VaultConfigMeta,
|
|
30
|
+
type BackendConfigDto,
|
|
31
|
+
type SetBackendConfigInput,
|
|
32
|
+
type TestBackendResult,
|
|
33
|
+
} from "./backend-config";
|
|
34
|
+
export {
|
|
35
|
+
maskSecrets,
|
|
36
|
+
maskSecretsDeep,
|
|
37
|
+
DEFAULT_MASK_TOKEN,
|
|
38
|
+
MIN_MASKABLE_LENGTH,
|
|
39
|
+
} from "./masking";
|
|
40
|
+
export {
|
|
41
|
+
maskScriptRunOutput,
|
|
42
|
+
type ScriptRunOutput,
|
|
43
|
+
} from "./mask-run-result";
|
|
44
|
+
export {
|
|
45
|
+
buildTestSecretEnv,
|
|
46
|
+
secretTestPlaceholder,
|
|
47
|
+
} from "./test-secret-env";
|
|
48
|
+
export {
|
|
49
|
+
INTERNAL_SECRET_PREFIX,
|
|
50
|
+
internalSecretName,
|
|
51
|
+
isInternalSecretName,
|
|
52
|
+
} from "./internal-secrets";
|
|
53
|
+
export {
|
|
54
|
+
SECRETS_CHANGED_HOOK_ID,
|
|
55
|
+
secretsChangedPayloadSchema,
|
|
56
|
+
type SecretsChangedPayload,
|
|
57
|
+
} from "./hooks";
|
|
58
|
+
export {
|
|
59
|
+
secretsContract,
|
|
60
|
+
SecretsApi,
|
|
61
|
+
type SecretsContract,
|
|
62
|
+
} from "./rpc-contract";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
INTERNAL_SECRET_PREFIX,
|
|
4
|
+
internalSecretName,
|
|
5
|
+
isInternalSecretName,
|
|
6
|
+
} from "./internal-secrets";
|
|
7
|
+
|
|
8
|
+
describe("internal secret names", () => {
|
|
9
|
+
it("builds a prefixed colon-joined name", () => {
|
|
10
|
+
expect(internalSecretName("script-packages", "registry-auth-token")).toBe(
|
|
11
|
+
`${INTERNAL_SECRET_PREFIX}script-packages:registry-auth-token`,
|
|
12
|
+
);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("recognizes internal names and rejects ordinary ones", () => {
|
|
16
|
+
expect(isInternalSecretName(internalSecretName("a", "b"))).toBe(true);
|
|
17
|
+
expect(isInternalSecretName("jira_token")).toBe(false);
|
|
18
|
+
expect(isInternalSecretName("")).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal (platform-managed) secrets.
|
|
3
|
+
*
|
|
4
|
+
* Some platform secrets are not user-managed named secrets — they back a
|
|
5
|
+
* specific feature (e.g. the script-package registry auth token, or a
|
|
6
|
+
* connection's credential fields). These are stored under a reserved name
|
|
7
|
+
* prefix so they are:
|
|
8
|
+
*
|
|
9
|
+
* - hidden from the user-facing Secrets UI (`listSecrets` / `listSecretNames`
|
|
10
|
+
* exclude them — they aren't `${{ secrets.NAME }}`-referenceable names);
|
|
11
|
+
* - always kept on the local (always-writable, AES-GCM) backend, never the
|
|
12
|
+
* active external backend (Vault is read-through and has no `set`), so
|
|
13
|
+
* writing them never breaks when Vault is the active backend.
|
|
14
|
+
*/
|
|
15
|
+
export const INTERNAL_SECRET_PREFIX = "__internal__:";
|
|
16
|
+
|
|
17
|
+
/** Build a reserved internal secret name from a namespace + key parts. */
|
|
18
|
+
export function internalSecretName(...parts: string[]): string {
|
|
19
|
+
return `${INTERNAL_SECRET_PREFIX}${parts.join(":")}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Whether a secret name is a reserved internal name (excluded from the UI). */
|
|
23
|
+
export function isInternalSecretName(name: string): boolean {
|
|
24
|
+
return name.startsWith(INTERNAL_SECRET_PREFIX);
|
|
25
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { maskScriptRunOutput } from "./mask-run-result";
|
|
3
|
+
|
|
4
|
+
describe("maskScriptRunOutput", () => {
|
|
5
|
+
it("redacts secret values from stdout, stderr, error, and result", () => {
|
|
6
|
+
const masked = maskScriptRunOutput({
|
|
7
|
+
output: {
|
|
8
|
+
result: { token: "gh_secretToken123", nested: ["gh_secretToken123"] },
|
|
9
|
+
stdout: "printing gh_secretToken123 to stdout",
|
|
10
|
+
stderr: "error referencing db-password-456",
|
|
11
|
+
error: "failed with db-password-456",
|
|
12
|
+
},
|
|
13
|
+
values: ["gh_secretToken123", "db-password-456"],
|
|
14
|
+
});
|
|
15
|
+
expect(masked.stdout).toBe("printing **** to stdout");
|
|
16
|
+
expect(masked.stderr).toBe("error referencing ****");
|
|
17
|
+
expect(masked.error).toBe("failed with ****");
|
|
18
|
+
expect(masked.result).toEqual({ token: "****", nested: ["****"] });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("is a no-op with an empty value set", () => {
|
|
22
|
+
const output = {
|
|
23
|
+
result: { token: "gh_secretToken123" },
|
|
24
|
+
stdout: "gh_secretToken123",
|
|
25
|
+
stderr: "",
|
|
26
|
+
};
|
|
27
|
+
const masked = maskScriptRunOutput({ output, values: [] });
|
|
28
|
+
expect(masked).toBe(output);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("leaves result/error absent when not provided", () => {
|
|
32
|
+
const masked = maskScriptRunOutput({
|
|
33
|
+
output: { stdout: "has topsecretvalue", stderr: "clean" },
|
|
34
|
+
values: ["topsecretvalue"],
|
|
35
|
+
});
|
|
36
|
+
expect(masked.stdout).toBe("has ****");
|
|
37
|
+
expect("result" in masked).toBe(false);
|
|
38
|
+
expect("error" in masked).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { maskSecrets, maskSecretsDeep } from "./masking";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The captured output of a script/shell run — the central shape produced
|
|
5
|
+
* by the script runners and surfaced to users (automation
|
|
6
|
+
* `run_script`/`run_shell` artifacts, the in-UI test panel, healthcheck
|
|
7
|
+
* collectors). Masking is applied to EVERY field that can carry a secret
|
|
8
|
+
* the run was given.
|
|
9
|
+
*/
|
|
10
|
+
export interface ScriptRunOutput {
|
|
11
|
+
result?: unknown;
|
|
12
|
+
stdout: string;
|
|
13
|
+
stderr: string;
|
|
14
|
+
error?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Redact every literal occurrence of the run's resolved secret `values`
|
|
19
|
+
* out of a script run's output before it is persisted or returned. This is
|
|
20
|
+
* the central, reusable leak-masking seam: callers pass the run-scoped,
|
|
21
|
+
* least-privilege value set (only that run's resolved secrets).
|
|
22
|
+
*
|
|
23
|
+
* `result` (an arbitrary JSON value) is masked deeply (keys + string
|
|
24
|
+
* leaves); `stdout`/`stderr`/`error` are masked as text. With an empty
|
|
25
|
+
* `values` set the output is returned unchanged (no resolved secrets to
|
|
26
|
+
* redact).
|
|
27
|
+
*/
|
|
28
|
+
export function maskScriptRunOutput<T extends ScriptRunOutput>({
|
|
29
|
+
output,
|
|
30
|
+
values,
|
|
31
|
+
}: {
|
|
32
|
+
output: T;
|
|
33
|
+
values: Iterable<string>;
|
|
34
|
+
}): T {
|
|
35
|
+
const valueArray = [...values];
|
|
36
|
+
if (valueArray.length === 0) {
|
|
37
|
+
return output;
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
...output,
|
|
41
|
+
...(output.result === undefined
|
|
42
|
+
? {}
|
|
43
|
+
: { result: maskSecretsDeep({ value: output.result, values: valueArray }) }),
|
|
44
|
+
stdout: maskSecrets({ text: output.stdout, values: valueArray }),
|
|
45
|
+
stderr: maskSecrets({ text: output.stderr, values: valueArray }),
|
|
46
|
+
...(output.error === undefined
|
|
47
|
+
? {}
|
|
48
|
+
: { error: maskSecrets({ text: output.error, values: valueArray }) }),
|
|
49
|
+
};
|
|
50
|
+
}
|