seekrit-sdk 0.2.0 → 0.4.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.
- checksums.yaml +4 -4
- data/README.md +13 -0
- data/lib/seekrit/client.rb +6 -2
- data/lib/seekrit/crypto.rb +10 -2
- data/lib/seekrit/errors.rb +13 -0
- data/lib/seekrit/interpolate.rb +130 -0
- data/lib/seekrit/version.rb +1 -1
- data/lib/seekrit.rb +1 -0
- data/testdata/vectors.json +381 -12
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f230fc82fb27db0d54bfa68297258f0548223d055215a38f73c0e409c8bfa165
|
|
4
|
+
data.tar.gz: 2f04d0d66d69fccdb7505e59e54dc6eee087f9c16663bbb575c80048991d07d9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3ee87b6ed2a34832fcc53b6b9bba22bdb3c1c46b743dfb145a3a93f294e391bb13a73ecb903342ea5033e53403e75db25d120ecd6cf16df1f6c841b527396348
|
|
7
|
+
data.tar.gz: 8d8a7ce8d752735509690df6acac4fd9849c632072abf6d6e89661e81e46ae674e0dd93498a6f3c403f3e43cc7be91e0e36536c2b4914fae2c642aea9d3f77d2
|
data/README.md
CHANGED
|
@@ -82,6 +82,19 @@ Seekrit::Client.new(with: { "shared" => "dev" }).resolve
|
|
|
82
82
|
`#resolve` is **fail-closed**: any resolve or decrypt failure raises rather than
|
|
83
83
|
returning partial results.
|
|
84
84
|
|
|
85
|
+
## Secret references
|
|
86
|
+
|
|
87
|
+
A secret's value may reference another with `${OTHER_SECRET}`. References are
|
|
88
|
+
stored literally and expanded here, after the layers are merged — so a reference
|
|
89
|
+
picks up whichever layer won that name, and rotating the referenced secret
|
|
90
|
+
updates every value that uses it. `$${OTHER_SECRET}` is a literal; an unknown
|
|
91
|
+
name is left as written; a reference cycle raises. Full rules:
|
|
92
|
+
[seekrit.dev/docs/guides/references](https://seekrit.dev/docs/guides/references).
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
client = Seekrit::Client.new(interpolate: false) # get the stored text instead
|
|
96
|
+
```
|
|
97
|
+
|
|
85
98
|
## Zero-knowledge
|
|
86
99
|
|
|
87
100
|
`GET /v1/resolve` returns ciphertext plus a data-encryption key wrapped to your
|
data/lib/seekrit/client.rb
CHANGED
|
@@ -21,7 +21,10 @@ module Seekrit
|
|
|
21
21
|
# @param with [Hash] {group_slug => env_slug} overrides (the ?with= override).
|
|
22
22
|
# @param open_timeout [Numeric] connection timeout, seconds.
|
|
23
23
|
# @param read_timeout [Numeric] read timeout, seconds.
|
|
24
|
-
|
|
24
|
+
# @param interpolate [Boolean] expand ${OTHER_SECRET} references in resolved
|
|
25
|
+
# values (default true); false returns the stored text verbatim.
|
|
26
|
+
def initialize(token: nil, api_url: nil, with: {}, open_timeout: 10, read_timeout: 30,
|
|
27
|
+
interpolate: true)
|
|
25
28
|
@token = token || ENV["SEEKRIT_TOKEN"]
|
|
26
29
|
raise Error, "no service token: pass token: or set SEEKRIT_TOKEN" unless @token && !@token.empty?
|
|
27
30
|
|
|
@@ -30,12 +33,13 @@ module Seekrit
|
|
|
30
33
|
@with = with || {}
|
|
31
34
|
@open_timeout = open_timeout
|
|
32
35
|
@read_timeout = read_timeout
|
|
36
|
+
@interpolate = interpolate
|
|
33
37
|
end
|
|
34
38
|
|
|
35
39
|
# Fetch, decrypt, and merge; returns { "NAME" => value }. Fail-closed.
|
|
36
40
|
# @return [Hash{String=>String}]
|
|
37
41
|
def resolve
|
|
38
|
-
Crypto.materialize(fetch, @key)
|
|
42
|
+
Crypto.materialize(fetch, @key, interpolate: @interpolate)
|
|
39
43
|
end
|
|
40
44
|
|
|
41
45
|
# Resolve and return a single secret's value, or +default+ if absent.
|
data/lib/seekrit/crypto.rb
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
require "openssl"
|
|
4
4
|
require "base64"
|
|
5
5
|
|
|
6
|
+
require_relative "interpolate"
|
|
7
|
+
|
|
6
8
|
module Seekrit
|
|
7
9
|
# Zero-knowledge read path, byte-compatible with @seekrit/crypto and
|
|
8
10
|
# crates/seekrit-core. Three steps:
|
|
@@ -144,7 +146,11 @@ module Seekrit
|
|
|
144
146
|
# Decrypt every layer and merge by precedence. Layers arrive lowest
|
|
145
147
|
# precedence first (composed groups, then the app environment); later layers
|
|
146
148
|
# overwrite earlier ones on a name collision.
|
|
147
|
-
|
|
149
|
+
#
|
|
150
|
+
# ${OTHER_SECRET} references are then expanded over the merged result (so a
|
|
151
|
+
# reference sees whichever layer won the name). Pass interpolate: false to
|
|
152
|
+
# get the stored text instead.
|
|
153
|
+
def materialize(resolve_response, key, interpolate: true)
|
|
148
154
|
merged = {}
|
|
149
155
|
resolve_response["layers"].each do |layer|
|
|
150
156
|
dek = key.unwrap_dek(layer["wrappedDek"])
|
|
@@ -153,7 +159,9 @@ module Seekrit
|
|
|
153
159
|
merged[secret["name"]] = decrypt_secret(dek, secret["ciphertext"], secret_aad(env_id, secret["name"]))
|
|
154
160
|
end
|
|
155
161
|
end
|
|
156
|
-
merged
|
|
162
|
+
return merged unless interpolate
|
|
163
|
+
|
|
164
|
+
Interpolate.interpolate_secrets(merged).values
|
|
157
165
|
end
|
|
158
166
|
end
|
|
159
167
|
end
|
data/lib/seekrit/errors.rb
CHANGED
|
@@ -9,6 +9,19 @@ module Seekrit
|
|
|
9
9
|
# from "tampered data" from "mismatched context".
|
|
10
10
|
class CryptoError < Error; end
|
|
11
11
|
|
|
12
|
+
# A ${OTHER_SECRET} reference could not be expanded: a cycle, or an expansion
|
|
13
|
+
# that grows without bound. An *unknown* reference is not an error — it is left
|
|
14
|
+
# as literal text (see Seekrit::Interpolate.interpolate_secrets).
|
|
15
|
+
class ReferenceError < Error
|
|
16
|
+
# @return [String] "CYCLE" or "TOO_LARGE".
|
|
17
|
+
attr_reader :code
|
|
18
|
+
|
|
19
|
+
def initialize(code, message)
|
|
20
|
+
@code = code
|
|
21
|
+
super(message)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
12
25
|
# The resolve API returned a non-2xx response.
|
|
13
26
|
class ApiError < Error
|
|
14
27
|
# @return [Integer] HTTP status code.
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
|
|
5
|
+
module Seekrit
|
|
6
|
+
# Secret references: ${OTHER_SECRET} inside a secret value.
|
|
7
|
+
#
|
|
8
|
+
# Expansion happens here, in-process, after the layers are merged — the API
|
|
9
|
+
# only ever holds the ciphertext of the literal ${OTHER_SECRET} text. The rules
|
|
10
|
+
# are fixed by the shared golden fixture (testdata/vectors.json, key
|
|
11
|
+
# "interpolation"), which every seekrit client is tested against:
|
|
12
|
+
#
|
|
13
|
+
# * ${NAME} becomes NAME's value from the same merged set, recursively.
|
|
14
|
+
# * NAME must look like an environment variable ([A-Za-z_][A-Za-z0-9_]*);
|
|
15
|
+
# anything else (${FOO:-bar}, ${1}) is left exactly as written.
|
|
16
|
+
# * A name that is not in the set is left literal and listed in +unresolved+,
|
|
17
|
+
# so a stored value containing e.g. ${GITHUB_SHA} keeps working.
|
|
18
|
+
# * $${NAME} is an escape for the literal text ${NAME}.
|
|
19
|
+
# * A reference cycle raises ReferenceError.
|
|
20
|
+
module Interpolate
|
|
21
|
+
REFERENCE_NAME = /\A[A-Za-z_][A-Za-z0-9_]*\z/.freeze
|
|
22
|
+
|
|
23
|
+
# Cap on a single expanded value: nested references can multiply length.
|
|
24
|
+
MAX_EXPANDED_LENGTH = 1_048_576
|
|
25
|
+
|
|
26
|
+
# The outcome of expanding a variable set.
|
|
27
|
+
#
|
|
28
|
+
# @!attribute values
|
|
29
|
+
# @return [Hash{String=>String}] the set with every reference expanded
|
|
30
|
+
# @!attribute expanded
|
|
31
|
+
# @return [Array<String>] names whose value had a reference expanded
|
|
32
|
+
# @!attribute unresolved
|
|
33
|
+
# @return [Array<String>] referenced names absent from the set, sorted
|
|
34
|
+
Interpolation = Struct.new(:values, :expanded, :unresolved)
|
|
35
|
+
|
|
36
|
+
module_function
|
|
37
|
+
|
|
38
|
+
# Split a value into literal runs and references — the single tokenizer the
|
|
39
|
+
# rules above are expressed in terms of. Yields [reference, text]: for a
|
|
40
|
+
# literal, +reference+ is nil; for a reference, +text+ is its raw ${NAME}
|
|
41
|
+
# source (used verbatim when the name turns out to be absent).
|
|
42
|
+
def scan(text)
|
|
43
|
+
return enum_for(:scan, text) unless block_given?
|
|
44
|
+
|
|
45
|
+
i = 0
|
|
46
|
+
while i < text.length
|
|
47
|
+
dollar = text.index("$", i)
|
|
48
|
+
if dollar.nil?
|
|
49
|
+
yield [nil, text[i..]]
|
|
50
|
+
return
|
|
51
|
+
end
|
|
52
|
+
yield [nil, text[i...dollar]] if dollar > i
|
|
53
|
+
|
|
54
|
+
# `$${` — escape: emit a literal `${` and skip all three characters, so
|
|
55
|
+
# the `{NAME}` that follows can never be read as a reference.
|
|
56
|
+
if text[dollar + 1, 2] == "${"
|
|
57
|
+
yield [nil, "${"]
|
|
58
|
+
i = dollar + 3
|
|
59
|
+
next
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
close = text[dollar + 1] == "{" ? text.index("}", dollar + 2) : nil
|
|
63
|
+
reference = close ? text[(dollar + 2)...close] : nil
|
|
64
|
+
if reference && REFERENCE_NAME.match?(reference)
|
|
65
|
+
yield [reference, text[dollar..close]]
|
|
66
|
+
i = close + 1
|
|
67
|
+
next
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# A plain `$`, an unterminated `${`, or a non-name: literal. Advancing a
|
|
71
|
+
# single character lets a `${` later in the run still be recognized.
|
|
72
|
+
yield [nil, "$"]
|
|
73
|
+
i = dollar + 1
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Expand ${NAME} references throughout a merged variable set. The input hash
|
|
78
|
+
# is never mutated.
|
|
79
|
+
#
|
|
80
|
+
# @param values [Hash{String=>String}]
|
|
81
|
+
# @return [Interpolation]
|
|
82
|
+
# @raise [Seekrit::ReferenceError] on a reference cycle or unbounded growth
|
|
83
|
+
def interpolate_secrets(values)
|
|
84
|
+
state = { resolved: {}, unresolved: {}, stack: [] }
|
|
85
|
+
result = {}
|
|
86
|
+
expanded = []
|
|
87
|
+
values.each_key do |name|
|
|
88
|
+
value = resolve_reference(name, values, state)
|
|
89
|
+
result[name] = value
|
|
90
|
+
expanded << name if value != values[name]
|
|
91
|
+
end
|
|
92
|
+
Interpolation.new(result, expanded, state[:unresolved].keys.sort)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Expand one present name's value, memoized so each is expanded once.
|
|
96
|
+
# @api private
|
|
97
|
+
def resolve_reference(name, values, state)
|
|
98
|
+
cached = state[:resolved][name]
|
|
99
|
+
return cached if cached
|
|
100
|
+
|
|
101
|
+
stack = state[:stack]
|
|
102
|
+
at = stack.index(name)
|
|
103
|
+
unless at.nil?
|
|
104
|
+
chain = stack[at..] + [name]
|
|
105
|
+
raise ReferenceError.new("CYCLE", "secret reference cycle: #{chain.join(' -> ')}")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
stack.push(name)
|
|
109
|
+
out = +""
|
|
110
|
+
scan(values[name]) do |reference, text|
|
|
111
|
+
if reference.nil?
|
|
112
|
+
out << text
|
|
113
|
+
elsif values.key?(reference)
|
|
114
|
+
out << resolve_reference(reference, values, state)
|
|
115
|
+
else
|
|
116
|
+
state[:unresolved][reference] = true
|
|
117
|
+
out << text
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
stack.pop
|
|
121
|
+
|
|
122
|
+
if out.length > MAX_EXPANDED_LENGTH
|
|
123
|
+
raise ReferenceError.new("TOO_LARGE", "#{name} expands to more than #{MAX_EXPANDED_LENGTH} bytes")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
state[:resolved][name] = out
|
|
127
|
+
out
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
data/lib/seekrit/version.rb
CHANGED
data/lib/seekrit.rb
CHANGED
data/testdata/vectors.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"token": "
|
|
3
|
-
"tokenId": "
|
|
4
|
-
"publicKeyJwk": "{\"key_ops\":[],\"ext\":true,\"kty\":\"EC\",\"x\":\"
|
|
2
|
+
"token": "skt_6EYgmoh8LuiPPAbM0SaVtc_MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgra6e_i8D2dIWlz-43EVbrHz-g698-kxY22MCp95L7VGhRANCAATA4nkyQKLq4hD5YvzXLtNtKAK_g-H4LF260zk0bInSWMbDfx0vTTtOg0tRWoDYWbuIN0vb1YMZ5z13xH5aBsDx",
|
|
3
|
+
"tokenId": "skt_6EYgmoh8LuiPPAbM0SaVtc",
|
|
4
|
+
"publicKeyJwk": "{\"key_ops\":[],\"ext\":true,\"kty\":\"EC\",\"x\":\"wOJ5MkCi6uIQ-WL81y7TbSgCv4Ph-CxdutM5NGyJ0lg\",\"y\":\"xsN_HS9NO06DS1FagNhZu4g3S9vVgxnnPXfEfloGwPE\",\"crv\":\"P-256\"}",
|
|
5
5
|
"resolve": {
|
|
6
6
|
"scope": {
|
|
7
7
|
"orgId": "org_test",
|
|
@@ -17,19 +17,19 @@
|
|
|
17
17
|
"environmentId": "env_grouptest0000000001",
|
|
18
18
|
"slug": "production",
|
|
19
19
|
"groupSlug": "shared",
|
|
20
|
-
"wrappedDek": "wd1.
|
|
20
|
+
"wrappedDek": "wd1.BGAAVA2nxO1UzcRZA_7ubZ9nLAGSf_Fc_6y07AHlv4N_q8dhQ0z4dwMHqUUaKPkkSnH8rnWkjnitlKHRmuIvoz8.dJ0ALJ9Wejh6nYiKM3adOA.VFgZWwqtT_ly6gP4.RIcWNi6id_QO9NtmCU4iFUEX9U9fUlqn2UNgmkgunpU_eVnKgMf6q8ui1PxcEBiw",
|
|
21
21
|
"secrets": [
|
|
22
22
|
{
|
|
23
23
|
"name": "SHARED",
|
|
24
|
-
"ciphertext": "sc1.
|
|
24
|
+
"ciphertext": "sc1.oIjnQEJskdJ_nweV.MGETgRWPDkwhHm9e_jp567-b19Fn8DJohrU"
|
|
25
25
|
},
|
|
26
26
|
{
|
|
27
27
|
"name": "DATABASE_URL",
|
|
28
|
-
"ciphertext": "sc1
|
|
28
|
+
"ciphertext": "sc1.Fo69NaVLlUDFtGsk.tr8o0e7mMSB7DWkfZuv0eLOsNq7Fm-3dd2zOiPSXSHiwXbg"
|
|
29
29
|
},
|
|
30
30
|
{
|
|
31
31
|
"name": "UNICODE",
|
|
32
|
-
"ciphertext": "sc1.
|
|
32
|
+
"ciphertext": "sc1.hu_o1qPHzmeJrT0q.5jIWUj9fTvGXjyeSyhtOMdvlWRenXqNy9F2uYg3XVgui0CYmdA"
|
|
33
33
|
}
|
|
34
34
|
]
|
|
35
35
|
},
|
|
@@ -37,19 +37,31 @@
|
|
|
37
37
|
"source": "app",
|
|
38
38
|
"environmentId": "env_apptest0000000000001",
|
|
39
39
|
"slug": "production",
|
|
40
|
-
"wrappedDek": "wd1.
|
|
40
|
+
"wrappedDek": "wd1.BHVKzCsJECbpyULjVjFe-vw5mmKEJ26MZz02wc1RfzgozIRx9CrCFJJ0RkYWT_iuvAAG2rN7WNg5uNt0RQ6X14Q.Wfuvupy_B16u453L04UpSA.YPq62FodkXeezDd3.HgXwlx5gZRAnqmyk0WSz4HW9qVrb-DfEsvD76HKXkgigAShwBJ9g1qiVtSUgUgCo",
|
|
41
41
|
"secrets": [
|
|
42
42
|
{
|
|
43
43
|
"name": "SHARED",
|
|
44
|
-
"ciphertext": "sc1.
|
|
44
|
+
"ciphertext": "sc1.hVO-Xgq16bV52VDQ.gePrTLVlrjIINJCs_gomYWnwqS4sD1CP"
|
|
45
45
|
},
|
|
46
46
|
{
|
|
47
47
|
"name": "API_KEY",
|
|
48
|
-
"ciphertext": "sc1.
|
|
48
|
+
"ciphertext": "sc1.KpB-GlAGsWiNUN5p.PY5HnW1tOA-ZCBSSslnqFld4sPGWLujiW_j8gQ"
|
|
49
49
|
},
|
|
50
50
|
{
|
|
51
51
|
"name": "EMPTY",
|
|
52
|
-
"ciphertext": "sc1.
|
|
52
|
+
"ciphertext": "sc1.qR6EjHPQkz6mZI37.PKrdRmvhq-N5bqYwWaK5Dg"
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"name": "REFERENCING",
|
|
56
|
+
"ciphertext": "sc1.mc6E1roINLiI5H5U.jkSlIFcn3NxJLgStBEdpelOJ_aB9pouUIOu5YX7yIB0G_maOkBRA2UpFsFoXI1mRppEWwg"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"name": "ESCAPED_REF",
|
|
60
|
+
"ciphertext": "sc1.obgYCJstXaXFUJJg.tcI9_IOzvpSaXF96WPBowUKxph3dJ_7GvHs"
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"name": "DANGLING_REF",
|
|
64
|
+
"ciphertext": "sc1.VJCnlvqadm3F6lge.kONf1u13yEIWc8pBjSs_XQDR9kmGi0wOTH35QE_QM8qJEhLuDg"
|
|
53
65
|
}
|
|
54
66
|
]
|
|
55
67
|
}
|
|
@@ -60,6 +72,363 @@
|
|
|
60
72
|
"DATABASE_URL": "postgres://group/db",
|
|
61
73
|
"UNICODE": "héllo-🌍-\n-tab\tend",
|
|
62
74
|
"API_KEY": "sk-app-12345",
|
|
63
|
-
"EMPTY": ""
|
|
75
|
+
"EMPTY": "",
|
|
76
|
+
"REFERENCING": "url=postgres://group/db;shared=from-app",
|
|
77
|
+
"ESCAPED_REF": "${SHARED}",
|
|
78
|
+
"DANGLING_REF": "build-${NOT_A_SECRET}"
|
|
79
|
+
},
|
|
80
|
+
"interpolation": {
|
|
81
|
+
"cases": [
|
|
82
|
+
{
|
|
83
|
+
"name": "substitutes a reference",
|
|
84
|
+
"input": {
|
|
85
|
+
"HOST": "db.internal",
|
|
86
|
+
"URL": "postgres://app@${HOST}:5432/db"
|
|
87
|
+
},
|
|
88
|
+
"expected": {
|
|
89
|
+
"HOST": "db.internal",
|
|
90
|
+
"URL": "postgres://app@db.internal:5432/db"
|
|
91
|
+
},
|
|
92
|
+
"unresolved": []
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
"name": "expands recursively, repeats, and forward references",
|
|
96
|
+
"input": {
|
|
97
|
+
"ADDR": "${HOST}:${PORT}",
|
|
98
|
+
"HOST": "db.internal",
|
|
99
|
+
"PORT": "5432",
|
|
100
|
+
"PAIR": "${ADDR}|${ADDR}"
|
|
101
|
+
},
|
|
102
|
+
"expected": {
|
|
103
|
+
"ADDR": "db.internal:5432",
|
|
104
|
+
"HOST": "db.internal",
|
|
105
|
+
"PORT": "5432",
|
|
106
|
+
"PAIR": "db.internal:5432|db.internal:5432"
|
|
107
|
+
},
|
|
108
|
+
"unresolved": []
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
"name": "leaves unknown references literal and reports them",
|
|
112
|
+
"input": {
|
|
113
|
+
"TAG": "build-${GITHUB_SHA}",
|
|
114
|
+
"OTHER": "${NOPE}-${GITHUB_SHA}"
|
|
115
|
+
},
|
|
116
|
+
"expected": {
|
|
117
|
+
"TAG": "build-${GITHUB_SHA}",
|
|
118
|
+
"OTHER": "${NOPE}-${GITHUB_SHA}"
|
|
119
|
+
},
|
|
120
|
+
"unresolved": [
|
|
121
|
+
"GITHUB_SHA",
|
|
122
|
+
"NOPE"
|
|
123
|
+
]
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
"name": "expands around an unknown reference",
|
|
127
|
+
"input": {
|
|
128
|
+
"HOST": "db.internal",
|
|
129
|
+
"URL": "${HOST}/${MISSING}"
|
|
130
|
+
},
|
|
131
|
+
"expected": {
|
|
132
|
+
"HOST": "db.internal",
|
|
133
|
+
"URL": "db.internal/${MISSING}"
|
|
134
|
+
},
|
|
135
|
+
"unresolved": [
|
|
136
|
+
"MISSING"
|
|
137
|
+
]
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
"name": "$${NAME} escapes to a literal ${NAME}",
|
|
141
|
+
"input": {
|
|
142
|
+
"NAME": "expanded",
|
|
143
|
+
"TEMPLATE": "$${NAME} stays, ${NAME} goes"
|
|
144
|
+
},
|
|
145
|
+
"expected": {
|
|
146
|
+
"NAME": "expanded",
|
|
147
|
+
"TEMPLATE": "${NAME} stays, expanded goes"
|
|
148
|
+
},
|
|
149
|
+
"unresolved": []
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
"name": "leaves shell-style, non-name, and unterminated forms alone",
|
|
153
|
+
"input": {
|
|
154
|
+
"FOO": "value",
|
|
155
|
+
"DEFAULTED": "${FOO:-fallback}",
|
|
156
|
+
"POSITIONAL": "${1}",
|
|
157
|
+
"DOTTED": "${a.b}",
|
|
158
|
+
"EMPTY_REF": "${}",
|
|
159
|
+
"UNTERMINATED": "${FOO",
|
|
160
|
+
"PASSWORD": "p$$w0rd$"
|
|
161
|
+
},
|
|
162
|
+
"expected": {
|
|
163
|
+
"FOO": "value",
|
|
164
|
+
"DEFAULTED": "${FOO:-fallback}",
|
|
165
|
+
"POSITIONAL": "${1}",
|
|
166
|
+
"DOTTED": "${a.b}",
|
|
167
|
+
"EMPTY_REF": "${}",
|
|
168
|
+
"UNTERMINATED": "${FOO",
|
|
169
|
+
"PASSWORD": "p$$w0rd$"
|
|
170
|
+
},
|
|
171
|
+
"unresolved": []
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
"name": "recognizes a reference after an unterminated one",
|
|
175
|
+
"input": {
|
|
176
|
+
"A": "a",
|
|
177
|
+
"T": "${ ${A}"
|
|
178
|
+
},
|
|
179
|
+
"expected": {
|
|
180
|
+
"A": "a",
|
|
181
|
+
"T": "${ a"
|
|
182
|
+
},
|
|
183
|
+
"unresolved": []
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
"name": "handles empty values and multibyte text",
|
|
187
|
+
"input": {
|
|
188
|
+
"EMPTY": "",
|
|
189
|
+
"A": "héllo-🌍",
|
|
190
|
+
"T": "🌍${A}${EMPTY}🌍"
|
|
191
|
+
},
|
|
192
|
+
"expected": {
|
|
193
|
+
"EMPTY": "",
|
|
194
|
+
"A": "héllo-🌍",
|
|
195
|
+
"T": "🌍héllo-🌍🌍"
|
|
196
|
+
},
|
|
197
|
+
"unresolved": []
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
"name": "a diamond is not a cycle",
|
|
201
|
+
"input": {
|
|
202
|
+
"BASE": "b",
|
|
203
|
+
"LEFT": "${BASE}-l",
|
|
204
|
+
"RIGHT": "${BASE}-r",
|
|
205
|
+
"TOP": "${LEFT}+${RIGHT}"
|
|
206
|
+
},
|
|
207
|
+
"expected": {
|
|
208
|
+
"BASE": "b",
|
|
209
|
+
"LEFT": "b-l",
|
|
210
|
+
"RIGHT": "b-r",
|
|
211
|
+
"TOP": "b-l+b-r"
|
|
212
|
+
},
|
|
213
|
+
"unresolved": []
|
|
214
|
+
}
|
|
215
|
+
],
|
|
216
|
+
"cycles": [
|
|
217
|
+
{
|
|
218
|
+
"name": "self reference",
|
|
219
|
+
"input": {
|
|
220
|
+
"A": "x${A}"
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
"name": "direct cycle",
|
|
225
|
+
"input": {
|
|
226
|
+
"A": "${B}",
|
|
227
|
+
"B": "${A}"
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
"name": "indirect cycle",
|
|
232
|
+
"input": {
|
|
233
|
+
"A": "${B}",
|
|
234
|
+
"B": "${C}",
|
|
235
|
+
"C": "${A}"
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
]
|
|
239
|
+
},
|
|
240
|
+
"substitution": {
|
|
241
|
+
"note": "Placeholder substitution over plaintext alone — no crypto involved. Every implementation of the engine (apps/proxy/src/substitute.rs and the fetch/httpx shims in the language SDKs) runs these, so the rules can never drift apart. The lookup each harness must install: a name in `denied` returns Denied, otherwise a name present in `values` returns that Value, otherwise Unknown. A case has either `expected` + `names`, or `error`.",
|
|
242
|
+
"cases": [
|
|
243
|
+
{
|
|
244
|
+
"name": "substitutes a placeholder and reports the name",
|
|
245
|
+
"input": "Bearer {{seekrit:OPENAI_API_KEY}}",
|
|
246
|
+
"values": {
|
|
247
|
+
"OPENAI_API_KEY": "sk-live"
|
|
248
|
+
},
|
|
249
|
+
"denied": [],
|
|
250
|
+
"expected": "Bearer sk-live",
|
|
251
|
+
"names": [
|
|
252
|
+
"OPENAI_API_KEY"
|
|
253
|
+
]
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
"name": "passes text with no placeholder through untouched",
|
|
257
|
+
"input": "nothing to see here",
|
|
258
|
+
"values": {
|
|
259
|
+
"OPENAI_API_KEY": "sk-live"
|
|
260
|
+
},
|
|
261
|
+
"denied": [],
|
|
262
|
+
"expected": "nothing to see here",
|
|
263
|
+
"names": []
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
"name": "repeats one name as many times as it appears",
|
|
267
|
+
"input": "{{seekrit:OPENAI_API_KEY}}::{{seekrit:OPENAI_API_KEY}}",
|
|
268
|
+
"values": {
|
|
269
|
+
"OPENAI_API_KEY": "sk-live"
|
|
270
|
+
},
|
|
271
|
+
"denied": [],
|
|
272
|
+
"expected": "sk-live::sk-live",
|
|
273
|
+
"names": [
|
|
274
|
+
"OPENAI_API_KEY"
|
|
275
|
+
]
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
"name": "reports two injected names, sorted",
|
|
279
|
+
"input": "{\"a\":\"{{seekrit:STRIPE_KEY}}\",\"b\":\"{{seekrit:OPENAI_API_KEY}}\"}",
|
|
280
|
+
"values": {
|
|
281
|
+
"OPENAI_API_KEY": "sk-live",
|
|
282
|
+
"STRIPE_KEY": "sk_test_1"
|
|
283
|
+
},
|
|
284
|
+
"denied": [],
|
|
285
|
+
"expected": "{\"a\":\"sk_test_1\",\"b\":\"sk-live\"}",
|
|
286
|
+
"names": [
|
|
287
|
+
"OPENAI_API_KEY",
|
|
288
|
+
"STRIPE_KEY"
|
|
289
|
+
]
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
"name": "a denied name aborts the pass",
|
|
293
|
+
"input": "{{seekrit:SOME_OTHER}}",
|
|
294
|
+
"values": {
|
|
295
|
+
"OPENAI_API_KEY": "sk-live"
|
|
296
|
+
},
|
|
297
|
+
"denied": [
|
|
298
|
+
"SOME_OTHER"
|
|
299
|
+
],
|
|
300
|
+
"error": {
|
|
301
|
+
"code": "denied",
|
|
302
|
+
"name": "SOME_OTHER"
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
"name": "an allowed but unresolved name aborts the pass",
|
|
307
|
+
"input": "{{seekrit:MISSING}}",
|
|
308
|
+
"values": {
|
|
309
|
+
"OPENAI_API_KEY": "sk-live"
|
|
310
|
+
},
|
|
311
|
+
"denied": [],
|
|
312
|
+
"error": {
|
|
313
|
+
"code": "unresolved",
|
|
314
|
+
"name": "MISSING"
|
|
315
|
+
}
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
"name": "a denial wins even when a valid placeholder came first",
|
|
319
|
+
"input": "{{seekrit:OPENAI_API_KEY}} then {{seekrit:SOME_OTHER}}",
|
|
320
|
+
"values": {
|
|
321
|
+
"OPENAI_API_KEY": "sk-live"
|
|
322
|
+
},
|
|
323
|
+
"denied": [
|
|
324
|
+
"SOME_OTHER"
|
|
325
|
+
],
|
|
326
|
+
"error": {
|
|
327
|
+
"code": "denied",
|
|
328
|
+
"name": "SOME_OTHER"
|
|
329
|
+
}
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
"name": "an unterminated marker is literal",
|
|
333
|
+
"input": "value {{seekrit:OPENAI_API_KEY",
|
|
334
|
+
"values": {
|
|
335
|
+
"OPENAI_API_KEY": "sk-live"
|
|
336
|
+
},
|
|
337
|
+
"denied": [],
|
|
338
|
+
"expected": "value {{seekrit:OPENAI_API_KEY",
|
|
339
|
+
"names": []
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
"name": "an illegal character in the name makes the marker literal",
|
|
343
|
+
"input": "{{seekrit:bad-name}}",
|
|
344
|
+
"values": {
|
|
345
|
+
"OPENAI_API_KEY": "sk-live"
|
|
346
|
+
},
|
|
347
|
+
"denied": [],
|
|
348
|
+
"expected": "{{seekrit:bad-name}}",
|
|
349
|
+
"names": []
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
"name": "an empty name is not a placeholder",
|
|
353
|
+
"input": "{{seekrit:}}",
|
|
354
|
+
"values": {
|
|
355
|
+
"OPENAI_API_KEY": "sk-live"
|
|
356
|
+
},
|
|
357
|
+
"denied": [],
|
|
358
|
+
"expected": "{{seekrit:}}",
|
|
359
|
+
"names": []
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
"name": "whitespace inside the marker is not a placeholder",
|
|
363
|
+
"input": "{{ seekrit:OPENAI_API_KEY }}",
|
|
364
|
+
"values": {
|
|
365
|
+
"OPENAI_API_KEY": "sk-live"
|
|
366
|
+
},
|
|
367
|
+
"denied": [],
|
|
368
|
+
"expected": "{{ seekrit:OPENAI_API_KEY }}",
|
|
369
|
+
"names": []
|
|
370
|
+
},
|
|
371
|
+
{
|
|
372
|
+
"name": "nesting is not expanded recursively",
|
|
373
|
+
"input": "{{seekrit:{{seekrit:OPENAI_API_KEY}}}}",
|
|
374
|
+
"values": {
|
|
375
|
+
"OPENAI_API_KEY": "sk-live"
|
|
376
|
+
},
|
|
377
|
+
"denied": [],
|
|
378
|
+
"expected": "{{seekrit:sk-live}}",
|
|
379
|
+
"names": [
|
|
380
|
+
"OPENAI_API_KEY"
|
|
381
|
+
]
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
"name": "a substituted value is never rescanned",
|
|
385
|
+
"input": "{{seekrit:OPENAI_API_KEY}}",
|
|
386
|
+
"values": {
|
|
387
|
+
"OPENAI_API_KEY": "{{seekrit:STRIPE_KEY}}",
|
|
388
|
+
"STRIPE_KEY": "sk_test_1"
|
|
389
|
+
},
|
|
390
|
+
"denied": [],
|
|
391
|
+
"expected": "{{seekrit:STRIPE_KEY}}",
|
|
392
|
+
"names": [
|
|
393
|
+
"OPENAI_API_KEY"
|
|
394
|
+
]
|
|
395
|
+
},
|
|
396
|
+
{
|
|
397
|
+
"name": "extra closing braces stay literal",
|
|
398
|
+
"input": "{{seekrit:OPENAI_API_KEY}}}}",
|
|
399
|
+
"values": {
|
|
400
|
+
"OPENAI_API_KEY": "sk-live"
|
|
401
|
+
},
|
|
402
|
+
"denied": [],
|
|
403
|
+
"expected": "sk-live}}",
|
|
404
|
+
"names": [
|
|
405
|
+
"OPENAI_API_KEY"
|
|
406
|
+
]
|
|
407
|
+
},
|
|
408
|
+
{
|
|
409
|
+
"name": "multibyte text around a placeholder survives",
|
|
410
|
+
"input": "🌍 {{seekrit:OPENAI_API_KEY}} héllo",
|
|
411
|
+
"values": {
|
|
412
|
+
"OPENAI_API_KEY": "sk-live"
|
|
413
|
+
},
|
|
414
|
+
"denied": [],
|
|
415
|
+
"expected": "🌍 sk-live héllo",
|
|
416
|
+
"names": [
|
|
417
|
+
"OPENAI_API_KEY"
|
|
418
|
+
]
|
|
419
|
+
},
|
|
420
|
+
{
|
|
421
|
+
"name": "an empty value substitutes to nothing",
|
|
422
|
+
"input": "[{{seekrit:EMPTY}}]",
|
|
423
|
+
"values": {
|
|
424
|
+
"EMPTY": ""
|
|
425
|
+
},
|
|
426
|
+
"denied": [],
|
|
427
|
+
"expected": "[]",
|
|
428
|
+
"names": [
|
|
429
|
+
"EMPTY"
|
|
430
|
+
]
|
|
431
|
+
}
|
|
432
|
+
]
|
|
64
433
|
}
|
|
65
434
|
}
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: seekrit-sdk
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- seekrit
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-08-20 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: |
|
|
14
14
|
Read-path SDK for seekrit, the zero-knowledge secrets manager. Authenticate
|
|
@@ -28,6 +28,7 @@ files:
|
|
|
28
28
|
- lib/seekrit/client.rb
|
|
29
29
|
- lib/seekrit/crypto.rb
|
|
30
30
|
- lib/seekrit/errors.rb
|
|
31
|
+
- lib/seekrit/interpolate.rb
|
|
31
32
|
- lib/seekrit/railtie.rb
|
|
32
33
|
- lib/seekrit/sdk.rb
|
|
33
34
|
- lib/seekrit/version.rb
|