keeper_secrets_manager 17.0.4 → 17.2.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/CHANGELOG.md +100 -16
- data/Gemfile +6 -3
- data/README.md +75 -270
- data/Rakefile +1 -1
- data/bin/console +47 -0
- data/keeper_secrets_manager.gemspec +36 -0
- data/lib/keeper_secrets_manager/cache.rb +139 -0
- data/lib/keeper_secrets_manager/config_keys.rb +4 -2
- data/lib/keeper_secrets_manager/core.rb +993 -452
- data/lib/keeper_secrets_manager/crypto.rb +101 -116
- data/lib/keeper_secrets_manager/dto/payload.rb +7 -6
- data/lib/keeper_secrets_manager/dto.rb +374 -38
- data/lib/keeper_secrets_manager/errors.rb +13 -2
- data/lib/keeper_secrets_manager/field_types.rb +3 -3
- data/lib/keeper_secrets_manager/folder_manager.rb +25 -29
- data/lib/keeper_secrets_manager/keeper_globals.rb +11 -17
- data/lib/keeper_secrets_manager/notation.rb +202 -93
- data/lib/keeper_secrets_manager/notation_enhancements.rb +24 -24
- data/lib/keeper_secrets_manager/storage.rb +38 -38
- data/lib/keeper_secrets_manager/totp.rb +27 -27
- data/lib/keeper_secrets_manager/utils.rb +85 -18
- data/lib/keeper_secrets_manager/version.rb +2 -2
- data/lib/keeper_secrets_manager.rb +11 -3
- metadata +39 -22
- data/DEVELOPER_SETUP.md +0 -0
- data/MANUAL_TESTING_GUIDE.md +0 -332
- data/RUBY_SDK_COMPLETE_DOCUMENTATION.md +0 -354
- data/RUBY_SDK_COMPREHENSIVE_SUMMARY.md +0 -192
- data/examples/01_quick_start.rb +0 -45
- data/examples/02_authentication.rb +0 -82
- data/examples/03_retrieve_secrets.rb +0 -81
- data/examples/04_create_update_delete.rb +0 -104
- data/examples/05_field_types.rb +0 -135
- data/examples/06_files.rb +0 -137
- data/examples/07_folders.rb +0 -145
- data/examples/08_notation.rb +0 -103
- data/examples/09_totp.rb +0 -100
- data/examples/README.md +0 -89
|
@@ -3,72 +3,70 @@ module KeeperSecretsManager
|
|
|
3
3
|
def initialize(folders)
|
|
4
4
|
@folders = folders
|
|
5
5
|
end
|
|
6
|
-
|
|
6
|
+
|
|
7
7
|
# Build a hierarchical tree structure from flat folder list
|
|
8
8
|
def build_folder_tree
|
|
9
9
|
# Create a hash for quick lookup
|
|
10
10
|
folder_map = {}
|
|
11
11
|
@folders.each { |f| folder_map[f.uid] = f }
|
|
12
|
-
|
|
12
|
+
|
|
13
13
|
# Find root folders (no parent) and build tree
|
|
14
14
|
root_folders = []
|
|
15
15
|
@folders.each do |folder|
|
|
16
|
-
if folder.parent_uid.nil? || folder.parent_uid.empty?
|
|
17
|
-
root_folders << build_node(folder, folder_map)
|
|
18
|
-
end
|
|
16
|
+
root_folders << build_node(folder, folder_map) if folder.parent_uid.nil? || folder.parent_uid.empty?
|
|
19
17
|
end
|
|
20
|
-
|
|
18
|
+
|
|
21
19
|
root_folders
|
|
22
20
|
end
|
|
23
|
-
|
|
21
|
+
|
|
24
22
|
# Get folder path from root to given folder
|
|
25
23
|
def get_folder_path(folder_uid)
|
|
26
24
|
folder = @folders.find { |f| f.uid == folder_uid }
|
|
27
25
|
return nil unless folder
|
|
28
|
-
|
|
26
|
+
|
|
29
27
|
path = []
|
|
30
28
|
current = folder
|
|
31
|
-
|
|
29
|
+
|
|
32
30
|
# Walk up the tree
|
|
33
31
|
while current
|
|
34
32
|
path.unshift(current.name)
|
|
35
33
|
current = @folders.find { |f| f.uid == current.parent_uid }
|
|
36
34
|
end
|
|
37
|
-
|
|
35
|
+
|
|
38
36
|
path.join('/')
|
|
39
37
|
end
|
|
40
|
-
|
|
38
|
+
|
|
41
39
|
# Get all ancestors of a folder (parent, grandparent, etc.)
|
|
42
40
|
def get_ancestors(folder_uid)
|
|
43
41
|
ancestors = []
|
|
44
42
|
folder = @folders.find { |f| f.uid == folder_uid }
|
|
45
43
|
return ancestors unless folder
|
|
46
|
-
|
|
44
|
+
|
|
47
45
|
current_parent_uid = folder.parent_uid
|
|
48
46
|
while current_parent_uid && !current_parent_uid.empty?
|
|
49
47
|
parent = @folders.find { |f| f.uid == current_parent_uid }
|
|
50
48
|
break unless parent
|
|
51
|
-
|
|
49
|
+
|
|
52
50
|
ancestors << parent
|
|
53
51
|
current_parent_uid = parent.parent_uid
|
|
54
52
|
end
|
|
55
|
-
|
|
53
|
+
|
|
56
54
|
ancestors
|
|
57
55
|
end
|
|
58
|
-
|
|
56
|
+
|
|
59
57
|
# Get all descendants of a folder (children, grandchildren, etc.)
|
|
60
58
|
def get_descendants(folder_uid)
|
|
61
59
|
descendants = []
|
|
62
60
|
children = @folders.select { |f| f.parent_uid == folder_uid }
|
|
63
|
-
|
|
61
|
+
|
|
64
62
|
children.each do |child|
|
|
65
63
|
descendants << child
|
|
66
64
|
descendants.concat(get_descendants(child.uid))
|
|
67
65
|
end
|
|
68
|
-
|
|
66
|
+
|
|
69
67
|
descendants
|
|
70
68
|
end
|
|
71
|
-
|
|
69
|
+
|
|
72
70
|
# Find folder by name (optionally within a parent)
|
|
73
71
|
def find_folder_by_name(name, parent_uid: nil)
|
|
74
72
|
if parent_uid
|
|
@@ -77,11 +75,11 @@ module KeeperSecretsManager
|
|
|
77
75
|
@folders.find { |f| f.name == name }
|
|
78
76
|
end
|
|
79
77
|
end
|
|
80
|
-
|
|
78
|
+
|
|
81
79
|
# Print folder tree to console
|
|
82
80
|
def print_tree(folders = nil, indent = 0)
|
|
83
81
|
folders ||= build_folder_tree
|
|
84
|
-
|
|
82
|
+
|
|
85
83
|
folders.each do |node|
|
|
86
84
|
puts "#{' ' * indent}├── #{node[:folder].name} (#{node[:folder].uid})"
|
|
87
85
|
if node[:folder].records && !node[:folder].records.empty?
|
|
@@ -92,23 +90,21 @@ module KeeperSecretsManager
|
|
|
92
90
|
print_tree(node[:children], indent + 4) if node[:children]
|
|
93
91
|
end
|
|
94
92
|
end
|
|
95
|
-
|
|
93
|
+
|
|
96
94
|
private
|
|
97
|
-
|
|
95
|
+
|
|
98
96
|
def build_node(folder, folder_map)
|
|
99
|
-
node = {
|
|
97
|
+
node = {
|
|
100
98
|
folder: folder,
|
|
101
99
|
children: []
|
|
102
100
|
}
|
|
103
|
-
|
|
101
|
+
|
|
104
102
|
# Find children
|
|
105
103
|
@folders.each do |f|
|
|
106
|
-
if f.parent_uid == folder.uid
|
|
107
|
-
node[:children] << build_node(f, folder_map)
|
|
108
|
-
end
|
|
104
|
+
node[:children] << build_node(f, folder_map) if f.parent_uid == folder.uid
|
|
109
105
|
end
|
|
110
|
-
|
|
106
|
+
|
|
111
107
|
node
|
|
112
108
|
end
|
|
113
109
|
end
|
|
114
|
-
end
|
|
110
|
+
end
|
|
@@ -2,20 +2,12 @@ require_relative 'version'
|
|
|
2
2
|
|
|
3
3
|
module KeeperSecretsManager
|
|
4
4
|
module KeeperGlobals
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
# **TODO: Register 'mb' (Ruby) client with Keeper before production use**
|
|
8
|
-
# **Currently using 'mr' which is already registered (likely Rust SDK)**
|
|
9
|
-
CLIENT_VERSION_PREFIX = 'mr'.freeze # Should be 'mb' for Ruby, but using 'mr' temporarily
|
|
10
|
-
|
|
11
|
-
# Get client version
|
|
5
|
+
CLIENT_VERSION_PREFIX = 'mb'.freeze
|
|
6
|
+
|
|
12
7
|
def self.client_version
|
|
13
|
-
#
|
|
14
|
-
# Java: mj17.0.0, Python: mp16.x.x, JavaScript: ms16.x.x, Go: mg16.x.x
|
|
15
|
-
# Ruby should be: mb17.0.0 (but not registered yet)
|
|
16
|
-
"#{CLIENT_VERSION_PREFIX}17.0.0"
|
|
8
|
+
"#{CLIENT_VERSION_PREFIX}#{KeeperSecretsManager::VERSION}"
|
|
17
9
|
end
|
|
18
|
-
|
|
10
|
+
|
|
19
11
|
# Keeper public keys by ID
|
|
20
12
|
KEEPER_PUBLIC_KEYS = {
|
|
21
13
|
'1' => 'BK9w6TZFxE6nFNbMfIpULCup2a8xc6w2tUTABjxny7yFmxW0dAEojwC6j6zb5nTlmb1dAx8nwo3qF7RPYGmloRM',
|
|
@@ -34,7 +26,8 @@ module KeeperSecretsManager
|
|
|
34
26
|
'14' => 'BJFF8j-dH7pDEw_U347w2CBM6xYM8Dk5fPPAktjib-opOqzvvbsER-WDHM4ONCSBf9O_obAHzCyygxmtpktDuiE',
|
|
35
27
|
'15' => 'BDKyWBvLbyZ-jMueORl3JwJnnEpCiZdN7yUvT0vOyjwpPBCDf6zfL4RWzvSkhAAFnwOni_1tQSl8dfXHbXqXsQ8',
|
|
36
28
|
'16' => 'BDXyZZnrl0tc2jdC5I61JjwkjK2kr7uet9tZjt8StTiJTAQQmnVOYBgbtP08PWDbecxnHghx3kJ8QXq1XE68y8c',
|
|
37
|
-
'17' => 'BFX68cb97m9_sweGdOVavFM3j5ot6gveg6xT4BtGahfGhKib-zdZyO9pwvv1cBda9ahkSzo1BQ4NVXp9qRyqVGU'
|
|
29
|
+
'17' => 'BFX68cb97m9_sweGdOVavFM3j5ot6gveg6xT4BtGahfGhKib-zdZyO9pwvv1cBda9ahkSzo1BQ4NVXp9qRyqVGU',
|
|
30
|
+
'18' => 'BNhngQqTT1bPKxGuB6FhbPTAeNVFl8PKGGSGo5W06xWIReutm6ix6JPivqnbvkydY-1uDQTr-5e6t70G01Bb5JA'
|
|
38
31
|
}.freeze
|
|
39
32
|
|
|
40
33
|
# Keeper servers by region
|
|
@@ -44,16 +37,17 @@ module KeeperSecretsManager
|
|
|
44
37
|
'AU' => 'keepersecurity.com.au',
|
|
45
38
|
'GOV' => 'govcloud.keepersecurity.us',
|
|
46
39
|
'JP' => 'keepersecurity.jp',
|
|
47
|
-
'CA' => 'keepersecurity.ca'
|
|
40
|
+
'CA' => 'keepersecurity.ca',
|
|
41
|
+
'IL5' => 'il5.keepersecurity.us'
|
|
48
42
|
}.freeze
|
|
49
43
|
|
|
50
44
|
# Default server (US)
|
|
51
45
|
DEFAULT_SERVER = KEEPER_SERVERS['US'].freeze
|
|
52
|
-
|
|
46
|
+
|
|
53
47
|
# Default public key ID
|
|
54
48
|
DEFAULT_KEY_ID = '7'.freeze
|
|
55
|
-
|
|
49
|
+
|
|
56
50
|
# Logger name
|
|
57
51
|
LOGGER_NAME = 'keeper_secrets_manager'.freeze
|
|
58
52
|
end
|
|
59
|
-
end
|
|
53
|
+
end
|
|
@@ -11,42 +11,93 @@ module KeeperSecretsManager
|
|
|
11
11
|
@secrets_manager = secrets_manager
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
+
def get_notation_results(notation)
|
|
15
|
+
return [] if notation.nil? || !notation.is_a?(String) || notation.empty?
|
|
16
|
+
|
|
17
|
+
parsed = parse_notation(notation)
|
|
18
|
+
raise NotationError, "Invalid notation: #{notation}" if parsed.length < 3
|
|
19
|
+
|
|
20
|
+
record_token = parsed[1].text&.first
|
|
21
|
+
selector = parsed[2].text&.first
|
|
22
|
+
raise NotationError, 'Invalid notation: missing record' unless record_token
|
|
23
|
+
raise NotationError, 'Invalid notation: missing selector' unless selector
|
|
24
|
+
|
|
25
|
+
records = @secrets_manager.get_secrets([record_token])
|
|
26
|
+
if records.empty?
|
|
27
|
+
all = @secrets_manager.get_secrets
|
|
28
|
+
records = all.select { |r| r.title == record_token }
|
|
29
|
+
end
|
|
30
|
+
records = records.uniq { |r| r.uid } if records.size > 1
|
|
31
|
+
raise NotationError, "Multiple records match '#{record_token}'" if records.size > 1
|
|
32
|
+
raise NotationError, "No records match '#{record_token}'" if records.empty?
|
|
33
|
+
|
|
34
|
+
record = records.first
|
|
35
|
+
parameter = parsed[2].parameter&.first
|
|
36
|
+
index1 = parsed[2].index1&.first
|
|
37
|
+
index2 = parsed[2].index2&.first
|
|
38
|
+
|
|
39
|
+
case selector.downcase
|
|
40
|
+
when 'type'
|
|
41
|
+
record.type ? [record.type] : []
|
|
42
|
+
when 'title'
|
|
43
|
+
record.title ? [record.title] : []
|
|
44
|
+
when 'notes'
|
|
45
|
+
(record.notes && !record.notes.empty?) ? [record.notes] : []
|
|
46
|
+
when 'file'
|
|
47
|
+
notation_results_file(record, parameter, record_token)
|
|
48
|
+
when 'field', 'custom_field'
|
|
49
|
+
notation_results_field(record, parameter, index1, index2, parsed[2])
|
|
50
|
+
else
|
|
51
|
+
raise NotationError, "Invalid selector: #{selector}"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
14
55
|
# Parse notation and return value
|
|
15
56
|
def parse(notation)
|
|
16
|
-
return nil if notation.nil?
|
|
17
|
-
|
|
57
|
+
return nil if notation.nil?
|
|
58
|
+
raise NotationError, 'Invalid notation format: must be a string' unless notation.is_a?(String)
|
|
59
|
+
return nil if notation.empty?
|
|
60
|
+
|
|
18
61
|
# Parse notation URI
|
|
19
|
-
|
|
20
|
-
|
|
62
|
+
begin
|
|
63
|
+
parsed = parse_notation(notation)
|
|
64
|
+
rescue StandardError => e
|
|
65
|
+
raise NotationError, "Invalid notation format: #{e.message}"
|
|
66
|
+
end
|
|
67
|
+
|
|
21
68
|
# Validate we have minimum required sections
|
|
22
69
|
raise NotationError, "Invalid notation: #{notation}" if parsed.length < 3
|
|
23
|
-
|
|
70
|
+
|
|
24
71
|
# Extract components
|
|
25
72
|
record_token = parsed[1].text&.first
|
|
26
73
|
selector = parsed[2].text&.first
|
|
27
|
-
|
|
28
|
-
raise NotationError,
|
|
29
|
-
raise NotationError,
|
|
30
|
-
|
|
74
|
+
|
|
75
|
+
raise NotationError, 'Invalid notation: missing record' unless record_token
|
|
76
|
+
raise NotationError, 'Invalid notation: missing selector' unless selector
|
|
77
|
+
|
|
31
78
|
# Get record
|
|
32
79
|
records = @secrets_manager.get_secrets([record_token])
|
|
33
|
-
|
|
80
|
+
|
|
34
81
|
# If not found by UID, try by title
|
|
35
82
|
if records.empty?
|
|
36
83
|
all_records = @secrets_manager.get_secrets
|
|
37
84
|
records = all_records.select { |r| r.title == record_token }
|
|
38
85
|
end
|
|
39
|
-
|
|
86
|
+
|
|
87
|
+
# Remove duplicate UIDs - shortcuts/linked records both shared to same KSM App
|
|
88
|
+
records = records.uniq { |r| r.uid } if records.size > 1
|
|
89
|
+
|
|
90
|
+
# Now check for genuine ambiguity (different records with same title)
|
|
40
91
|
raise NotationError, "Multiple records match '#{record_token}'" if records.size > 1
|
|
41
92
|
raise NotationError, "No records match '#{record_token}'" if records.empty?
|
|
42
|
-
|
|
93
|
+
|
|
43
94
|
record = records.first
|
|
44
|
-
|
|
95
|
+
|
|
45
96
|
# Extract parameters
|
|
46
97
|
parameter = parsed[2].parameter&.first
|
|
47
98
|
index1 = parsed[2].index1&.first
|
|
48
99
|
index2 = parsed[2].index2&.first
|
|
49
|
-
|
|
100
|
+
|
|
50
101
|
# Process selector
|
|
51
102
|
case selector.downcase
|
|
52
103
|
when 'type'
|
|
@@ -66,72 +117,135 @@ module KeeperSecretsManager
|
|
|
66
117
|
|
|
67
118
|
private
|
|
68
119
|
|
|
120
|
+
def value_to_string(v)
|
|
121
|
+
v.is_a?(String) ? v : v.to_json
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Unlike handle_field_selector: no first-element shortcut when no index is given.
|
|
125
|
+
def notation_results_field(record, parameter, index1, index2, parsed_section)
|
|
126
|
+
raise NotationError, 'Missing required parameter for field' unless parameter
|
|
127
|
+
|
|
128
|
+
field = record.get_field(parameter)
|
|
129
|
+
raise NotationError, "Field '#{parameter}' not found" unless field
|
|
130
|
+
|
|
131
|
+
values = field['value'] || []
|
|
132
|
+
idx = parse_index(index1)
|
|
133
|
+
|
|
134
|
+
if idx == -1 && index1 && !index1.empty?
|
|
135
|
+
# index1 is a property name (e.g. [hostName])
|
|
136
|
+
if values.first.is_a?(Hash)
|
|
137
|
+
raise NotationError, "Property '#{index1}' not found" unless values.first.key?(index1)
|
|
138
|
+
|
|
139
|
+
return [value_to_string(values.first[index1])]
|
|
140
|
+
else
|
|
141
|
+
raise NotationError, 'Cannot extract property from non-object value'
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
raise NotationError, "Field index out of bounds: #{idx} >= #{values.size}" if idx != -1 && idx >= values.size
|
|
146
|
+
|
|
147
|
+
selected = idx >= 0 ? [values[idx]] : values
|
|
148
|
+
|
|
149
|
+
if index2 && !index2.empty? && index2 != '[]'
|
|
150
|
+
selected.filter_map do |v|
|
|
151
|
+
next nil unless v.is_a?(Hash) && v.key?(index2)
|
|
152
|
+
|
|
153
|
+
value_to_string(v[index2])
|
|
154
|
+
end
|
|
155
|
+
else
|
|
156
|
+
selected.map { |v| value_to_string(v) }
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def notation_results_file(record, parameter, record_token)
|
|
161
|
+
file = handle_file_selector(record, parameter, record_token)
|
|
162
|
+
|
|
163
|
+
file_hash = if file.is_a?(KeeperSecretsManager::Dto::KeeperFile)
|
|
164
|
+
{
|
|
165
|
+
'fileUid' => file.uid,
|
|
166
|
+
'url' => file.url,
|
|
167
|
+
'fileKey' => file.file_key,
|
|
168
|
+
'name' => file.name
|
|
169
|
+
}
|
|
170
|
+
else
|
|
171
|
+
file
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
content = @secrets_manager.download_file(file_hash)
|
|
175
|
+
[KeeperSecretsManager::Utils.bytes_to_url_safe_str(content['data'])]
|
|
176
|
+
end
|
|
177
|
+
|
|
69
178
|
# Handle file selector
|
|
70
179
|
def handle_file_selector(record, parameter, record_token)
|
|
71
|
-
raise NotationError,
|
|
72
|
-
|
|
73
|
-
|
|
180
|
+
raise NotationError, 'Missing required parameter: filename or file UID' unless parameter
|
|
181
|
+
|
|
182
|
+
if record.files.nil? || record.files.empty?
|
|
183
|
+
raise NotationError,
|
|
184
|
+
"Record #{record_token} has no file attachments"
|
|
185
|
+
end
|
|
186
|
+
|
|
74
187
|
# Find matching file
|
|
75
188
|
files = record.files.select do |f|
|
|
76
189
|
parameter == f.name || parameter == f.title || parameter == f.uid
|
|
77
190
|
end
|
|
78
|
-
|
|
191
|
+
|
|
79
192
|
raise NotationError, "No files match '#{parameter}'" if files.empty?
|
|
80
193
|
raise NotationError, "Multiple files match '#{parameter}'" if files.size > 1
|
|
81
|
-
|
|
194
|
+
|
|
82
195
|
# Return file object (downloading would be handled by the caller)
|
|
83
196
|
files.first
|
|
84
197
|
end
|
|
85
198
|
|
|
86
199
|
# Handle field selector
|
|
87
200
|
def handle_field_selector(record, selector, parameter, index1, index2, parsed_section)
|
|
88
|
-
raise NotationError,
|
|
89
|
-
|
|
90
|
-
# Get field
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
201
|
+
raise NotationError, 'Missing required parameter for field' unless parameter
|
|
202
|
+
|
|
203
|
+
# Get field (works for both standard and custom fields)
|
|
204
|
+
field = record.get_field(parameter)
|
|
205
|
+
|
|
94
206
|
raise NotationError, "Field '#{parameter}' not found" unless field
|
|
95
|
-
|
|
207
|
+
|
|
96
208
|
# Get field values
|
|
97
209
|
values = field['value'] || []
|
|
98
|
-
|
|
210
|
+
|
|
99
211
|
# Handle index1
|
|
100
212
|
idx = parse_index(index1)
|
|
101
|
-
|
|
102
|
-
#
|
|
103
|
-
if idx == -1 &&
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
213
|
+
|
|
214
|
+
# If index1 is not a valid number but has a value, treat it as a property name
|
|
215
|
+
if idx == -1 && index1 && !index1.empty?
|
|
216
|
+
# index1 is a property name (e.g., [hostName])
|
|
217
|
+
if values.first.is_a?(Hash)
|
|
218
|
+
property = index1
|
|
219
|
+
if values.first.key?(property)
|
|
220
|
+
return values.first[property]
|
|
221
|
+
else
|
|
222
|
+
raise NotationError, "Property '#{property}' not found"
|
|
223
|
+
end
|
|
224
|
+
else
|
|
225
|
+
raise NotationError, 'Cannot extract property from non-object value'
|
|
226
|
+
end
|
|
111
227
|
end
|
|
112
|
-
|
|
228
|
+
|
|
229
|
+
raise NotationError, "Field index out of bounds: #{idx} >= #{values.size}" if idx >= values.size
|
|
230
|
+
|
|
113
231
|
# Apply index1
|
|
114
232
|
values = [values[idx]] if idx >= 0
|
|
115
|
-
|
|
233
|
+
|
|
116
234
|
# Handle legacy compatibility
|
|
117
|
-
if parsed_section.index1.nil? && parsed_section.index2.nil?
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
if parsed_section.index1 && parsed_section.index1[1] == '[]' &&
|
|
235
|
+
return values.first if parsed_section.index1.nil? && parsed_section.index2.nil?
|
|
236
|
+
|
|
237
|
+
if parsed_section.index1 && parsed_section.index1[1] == '[]' &&
|
|
122
238
|
(index2.nil? || index2.empty?)
|
|
123
239
|
return values
|
|
124
240
|
end
|
|
125
|
-
|
|
126
|
-
if index1.to_s.empty? && !index2.to_s.empty?
|
|
127
|
-
|
|
128
|
-
end
|
|
129
|
-
|
|
241
|
+
|
|
242
|
+
return values.first[index2] if index1.to_s.empty? && !index2.to_s.empty? && values.first.is_a?(Hash)
|
|
243
|
+
|
|
130
244
|
# Handle index2 (property access)
|
|
131
|
-
full_obj_value = parsed_section.index2.nil? ||
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
245
|
+
full_obj_value = parsed_section.index2.nil? ||
|
|
246
|
+
parsed_section.index2[1] == '' ||
|
|
247
|
+
parsed_section.index2[1] == '[]'
|
|
248
|
+
|
|
135
249
|
if full_obj_value
|
|
136
250
|
idx >= 0 ? values.first : values
|
|
137
251
|
elsif values.first.is_a?(Hash)
|
|
@@ -142,14 +256,14 @@ module KeeperSecretsManager
|
|
|
142
256
|
raise NotationError, "Property '#{obj_property}' not found"
|
|
143
257
|
end
|
|
144
258
|
else
|
|
145
|
-
raise NotationError,
|
|
259
|
+
raise NotationError, 'Cannot extract property from non-object value'
|
|
146
260
|
end
|
|
147
261
|
end
|
|
148
262
|
|
|
149
263
|
# Parse index value
|
|
150
264
|
def parse_index(index_str)
|
|
151
265
|
return -1 if index_str.nil? || index_str.empty?
|
|
152
|
-
|
|
266
|
+
|
|
153
267
|
begin
|
|
154
268
|
Integer(index_str)
|
|
155
269
|
rescue ArgumentError
|
|
@@ -164,23 +278,23 @@ module KeeperSecretsManager
|
|
|
164
278
|
begin
|
|
165
279
|
decoded = Base64.urlsafe_decode64(notation)
|
|
166
280
|
notation = decoded.force_encoding('UTF-8')
|
|
167
|
-
rescue
|
|
168
|
-
raise NotationError,
|
|
281
|
+
rescue StandardError
|
|
282
|
+
raise NotationError, 'Invalid notation format'
|
|
169
283
|
end
|
|
170
284
|
end
|
|
171
|
-
|
|
285
|
+
|
|
172
286
|
# Parse sections
|
|
173
287
|
prefix = parse_section(notation, 'prefix', 0)
|
|
174
288
|
pos = prefix.present? ? prefix.end_pos + 1 : 0
|
|
175
|
-
|
|
289
|
+
|
|
176
290
|
record = parse_section(notation, 'record', pos)
|
|
177
291
|
pos = record.present? ? record.end_pos + 1 : notation.length
|
|
178
|
-
|
|
292
|
+
|
|
179
293
|
selector = parse_section(notation, 'selector', pos)
|
|
180
294
|
pos = selector.present? ? selector.end_pos + 1 : notation.length
|
|
181
|
-
|
|
295
|
+
|
|
182
296
|
footer = parse_section(notation, 'footer', pos)
|
|
183
|
-
|
|
297
|
+
|
|
184
298
|
[prefix, record, selector, footer]
|
|
185
299
|
end
|
|
186
300
|
|
|
@@ -188,7 +302,7 @@ module KeeperSecretsManager
|
|
|
188
302
|
def parse_section(notation, section_name, pos)
|
|
189
303
|
result = NotationSection.new(section_name)
|
|
190
304
|
result.start_pos = pos
|
|
191
|
-
|
|
305
|
+
|
|
192
306
|
case section_name.downcase
|
|
193
307
|
when 'prefix'
|
|
194
308
|
# Check for keeper:// prefix
|
|
@@ -199,7 +313,7 @@ module KeeperSecretsManager
|
|
|
199
313
|
result.end_pos = prefix.length - 1
|
|
200
314
|
result.text = [notation[0...prefix.length], notation[0...prefix.length]]
|
|
201
315
|
end
|
|
202
|
-
|
|
316
|
+
|
|
203
317
|
when 'footer'
|
|
204
318
|
# Footer is anything after the last section
|
|
205
319
|
if pos < notation.length
|
|
@@ -208,7 +322,7 @@ module KeeperSecretsManager
|
|
|
208
322
|
result.end_pos = notation.length - 1
|
|
209
323
|
result.text = [notation[pos..], notation[pos..]]
|
|
210
324
|
end
|
|
211
|
-
|
|
325
|
+
|
|
212
326
|
when 'record'
|
|
213
327
|
# Record is required - parse until '/' with escaping
|
|
214
328
|
if pos < notation.length
|
|
@@ -220,7 +334,7 @@ module KeeperSecretsManager
|
|
|
220
334
|
result.text = parsed
|
|
221
335
|
end
|
|
222
336
|
end
|
|
223
|
-
|
|
337
|
+
|
|
224
338
|
when 'selector'
|
|
225
339
|
# Selector is required
|
|
226
340
|
if pos < notation.length
|
|
@@ -230,7 +344,7 @@ module KeeperSecretsManager
|
|
|
230
344
|
result.start_pos = pos
|
|
231
345
|
result.end_pos = pos + parsed[1].length - 1
|
|
232
346
|
result.text = parsed
|
|
233
|
-
|
|
347
|
+
|
|
234
348
|
# Check for long selectors that have parameters
|
|
235
349
|
if %w[field custom_field file].include?(parsed[0].downcase)
|
|
236
350
|
# Parse parameter (field type/label or filename)
|
|
@@ -240,13 +354,13 @@ module KeeperSecretsManager
|
|
|
240
354
|
plen = param_parsed[1].length
|
|
241
355
|
plen -= 1 if param_parsed[1].end_with?('[') && !param_parsed[1].end_with?('\\[')
|
|
242
356
|
result.end_pos += plen
|
|
243
|
-
|
|
357
|
+
|
|
244
358
|
# Parse index1 [N] or []
|
|
245
359
|
index1_parsed = parse_subsection(notation, result.end_pos + 1, '[]', true)
|
|
246
360
|
if index1_parsed
|
|
247
361
|
result.index1 = index1_parsed
|
|
248
362
|
result.end_pos += index1_parsed[1].length
|
|
249
|
-
|
|
363
|
+
|
|
250
364
|
# Parse index2 [property]
|
|
251
365
|
index2_parsed = parse_subsection(notation, result.end_pos + 1, '[]', true)
|
|
252
366
|
if index2_parsed
|
|
@@ -258,80 +372,75 @@ module KeeperSecretsManager
|
|
|
258
372
|
end
|
|
259
373
|
end
|
|
260
374
|
end
|
|
261
|
-
|
|
375
|
+
|
|
262
376
|
else
|
|
263
377
|
raise NotationError, "Unknown section: #{section_name}"
|
|
264
378
|
end
|
|
265
|
-
|
|
379
|
+
|
|
266
380
|
result
|
|
267
381
|
end
|
|
268
382
|
|
|
269
383
|
# Parse subsection with delimiters and escaping
|
|
270
384
|
def parse_subsection(text, pos, delimiters, escaped = false)
|
|
271
385
|
return nil if text.nil? || text.empty? || pos < 0 || pos >= text.length
|
|
272
|
-
|
|
273
|
-
if delimiters.nil? || delimiters.length > 2
|
|
274
|
-
|
|
275
|
-
end
|
|
276
|
-
|
|
386
|
+
|
|
387
|
+
raise NotationError, 'Internal error: incorrect delimiters' if delimiters.nil? || delimiters.length > 2
|
|
388
|
+
|
|
277
389
|
token = ''
|
|
278
390
|
raw = ''
|
|
279
|
-
|
|
391
|
+
|
|
280
392
|
while pos < text.length
|
|
281
393
|
if escaped && text[pos] == ESCAPE_CHAR
|
|
282
394
|
# Handle escape sequence
|
|
283
395
|
if pos + 1 >= text.length || !ESCAPE_CHARS.include?(text[pos + 1])
|
|
284
396
|
raise NotationError, "Incorrect escape sequence at position #{pos}"
|
|
285
397
|
end
|
|
286
|
-
|
|
398
|
+
|
|
287
399
|
token += text[pos + 1]
|
|
288
400
|
raw += text[pos, 2]
|
|
289
401
|
pos += 2
|
|
290
402
|
else
|
|
291
403
|
raw += text[pos]
|
|
292
|
-
|
|
404
|
+
|
|
293
405
|
if delimiters.length == 1
|
|
294
406
|
# Single delimiter
|
|
295
407
|
break if text[pos] == delimiters[0]
|
|
408
|
+
|
|
296
409
|
token += text[pos]
|
|
297
410
|
else
|
|
298
411
|
# Two delimiters (for brackets)
|
|
299
|
-
if raw[0] != delimiters[0]
|
|
300
|
-
|
|
301
|
-
end
|
|
302
|
-
|
|
412
|
+
raise NotationError, "Index sections must start with '['" if raw[0] != delimiters[0]
|
|
413
|
+
|
|
303
414
|
if raw.length > 1 && text[pos] == delimiters[0]
|
|
304
415
|
raise NotationError, "Index sections do not allow extra '[' inside"
|
|
305
416
|
end
|
|
306
|
-
|
|
417
|
+
|
|
307
418
|
if !delimiters.include?(text[pos])
|
|
308
419
|
token += text[pos]
|
|
309
420
|
elsif text[pos] == delimiters[1]
|
|
310
421
|
break
|
|
311
422
|
end
|
|
312
423
|
end
|
|
313
|
-
|
|
424
|
+
|
|
314
425
|
pos += 1
|
|
315
426
|
end
|
|
316
427
|
end
|
|
317
|
-
|
|
428
|
+
|
|
318
429
|
# Validate brackets are properly closed
|
|
319
430
|
if delimiters.length == 2
|
|
320
431
|
if raw.length < 2 || raw[0] != delimiters[0] || raw[-1] != delimiters[1]
|
|
321
432
|
raise NotationError, "Index sections must be enclosed in '[' and ']'"
|
|
322
433
|
end
|
|
323
|
-
|
|
324
|
-
if escaped && raw[-2] == ESCAPE_CHAR
|
|
325
|
-
raise NotationError, "Index sections must be enclosed in '[' and ']'"
|
|
326
|
-
end
|
|
434
|
+
|
|
435
|
+
raise NotationError, "Index sections must be enclosed in '[' and ']'" if escaped && raw[-2] == ESCAPE_CHAR
|
|
327
436
|
end
|
|
328
|
-
|
|
437
|
+
|
|
329
438
|
[token, raw]
|
|
330
439
|
end
|
|
331
440
|
|
|
332
441
|
# Notation section data class
|
|
333
442
|
class NotationSection
|
|
334
|
-
attr_accessor :section, :present, :start_pos, :end_pos,
|
|
443
|
+
attr_accessor :section, :present, :start_pos, :end_pos,
|
|
335
444
|
:text, :parameter, :index1, :index2
|
|
336
445
|
|
|
337
446
|
def initialize(section_name)
|
|
@@ -351,4 +460,4 @@ module KeeperSecretsManager
|
|
|
351
460
|
end
|
|
352
461
|
end
|
|
353
462
|
end
|
|
354
|
-
end
|
|
463
|
+
end
|