aspera-cli 4.27.5 → 4.28.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
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +160 -4
- data/CONTRIBUTING.md +4 -2
- data/README.md +1 -1
- data/bin/asession +1 -1
- data/docs/README.md +1086 -331
- data/docs/test-mcp-with-ai.md +5 -3
- data/lib/aspera/agent/direct.rb +15 -4
- data/lib/aspera/agent/factory.rb +2 -1
- data/lib/aspera/agent/node.rb +1 -1
- data/lib/aspera/api/cos_node.rb +1 -1
- data/lib/aspera/api/faspex.rb +1 -1
- data/lib/aspera/api/node.rb +6 -2
- data/lib/aspera/ascp/installation.rb +47 -11
- data/lib/aspera/ascp/management.rb +89 -43
- data/lib/aspera/cli/ascp_actions.rb +5 -19
- data/lib/aspera/cli/bootstrapper.rb +1 -2
- data/lib/aspera/cli/command_registry.rb +43 -3
- data/lib/aspera/cli/command_spec.rb +3 -0
- data/lib/aspera/cli/completion/ascli.bash +39 -5
- data/lib/aspera/cli/completion/ascli.fish +16 -8
- data/lib/aspera/cli/completion/ascli.ps1 +41 -0
- data/lib/aspera/cli/completion/ascli.zsh +20 -4
- data/lib/aspera/cli/completion_actions.rb +360 -0
- data/lib/aspera/cli/error.rb +42 -0
- data/lib/aspera/cli/extended_value.rb +30 -5
- data/lib/aspera/cli/formatter.rb +118 -56
- data/lib/aspera/cli/hints.rb +0 -1
- data/lib/aspera/cli/http.rb +6 -5
- data/lib/aspera/cli/info.rb +10 -4
- data/lib/aspera/cli/mailer.rb +1 -1
- data/lib/aspera/cli/mcp/http_servlet.rb +156 -0
- data/lib/aspera/cli/mcp/secrets.rb +143 -0
- data/lib/aspera/cli/mcp/tool.rb +231 -0
- data/lib/aspera/cli/option_value.rb +9 -1
- data/lib/aspera/cli/options.schema.yaml +390 -39
- data/lib/aspera/cli/parser.rb +14 -5
- data/lib/aspera/cli/plugins/aoc.rb +8 -11
- data/lib/aspera/cli/plugins/base.rb +36 -15
- data/lib/aspera/cli/plugins/config.rb +15 -44
- data/lib/aspera/cli/plugins/console.rb +3 -1
- data/lib/aspera/cli/plugins/factory.rb +1 -1
- data/lib/aspera/cli/plugins/faspex5.rb +9 -19
- data/lib/aspera/cli/plugins/faspio.rb +36 -5
- data/lib/aspera/cli/plugins/httpgw.rb +1 -0
- data/lib/aspera/cli/plugins/mcp.rb +177 -118
- data/lib/aspera/cli/plugins/node.rb +14 -8
- data/lib/aspera/cli/plugins/orchestrator.rb +351 -38
- data/lib/aspera/cli/plugins/server.rb +3 -1
- data/lib/aspera/cli/plugins/shares.rb +205 -35
- data/lib/aspera/cli/preset_actions.rb +15 -7
- data/lib/aspera/cli/preset_manager.rb +24 -2
- data/lib/aspera/cli/prompt.rb +3 -1
- data/lib/aspera/cli/result.rb +17 -4
- data/lib/aspera/cli/runner.rb +133 -43
- data/lib/aspera/cli/security.rb +134 -0
- data/lib/aspera/cli/table_fitter.rb +116 -0
- data/lib/aspera/cli/terminal_formatter.rb +23 -34
- data/lib/aspera/cli/transfer_agent.rb +4 -1
- data/lib/aspera/cli/vault_manager.rb +6 -5
- data/lib/aspera/cli/version.rb +1 -1
- data/lib/aspera/cli/web_secret.html.erb +61 -0
- data/lib/aspera/cli/web_secret.rb +220 -0
- data/lib/aspera/cli/wizard.rb +19 -9
- data/lib/aspera/command_line_builder.rb +26 -0
- data/lib/aspera/data_repository.rb +38 -24
- data/lib/aspera/duration.rb +42 -0
- data/lib/aspera/environment.rb +88 -24
- data/lib/aspera/exec_spec.rb +6 -5
- data/lib/aspera/faspex_gw.rb +98 -32
- data/lib/aspera/faspex_postproc.rb +187 -44
- data/lib/aspera/formatter_interface.rb +0 -1
- data/lib/aspera/id_generator.rb +2 -1
- data/lib/aspera/json_rpc/client.rb +51 -32
- data/lib/aspera/json_rpc/version.rb +1 -0
- data/lib/aspera/keychain/base.rb +5 -0
- data/lib/aspera/keychain/encrypted_hash.rb +6 -2
- data/lib/aspera/keychain/factory.rb +2 -3
- data/lib/aspera/keychain/hashicorp_vault.rb +7 -4
- data/lib/aspera/keychain/macos_security.rb +119 -13
- data/lib/aspera/keychain/one_password_api.rb +8 -3
- data/lib/aspera/keychain/one_password_base.rb +14 -9
- data/lib/aspera/keychain/one_password_cli.rb +4 -2
- data/lib/aspera/log.rb +3 -6
- data/lib/aspera/markdown.rb +152 -115
- data/lib/aspera/mime.rb +6 -0
- data/lib/aspera/nagios.rb +3 -1
- data/lib/aspera/node_emulator.rb +98 -101
- data/lib/aspera/oauth/{base.rb → client/base.rb} +5 -4
- data/lib/aspera/oauth/{boot.rb → client/boot.rb} +3 -2
- data/lib/aspera/oauth/{factory.rb → client/factory.rb} +7 -0
- data/lib/aspera/oauth/{generic.rb → client/generic.rb} +1 -1
- data/lib/aspera/oauth/{json_credentials.rb → client/json_credentials.rb} +1 -1
- data/lib/aspera/oauth/{jwt.rb → client/jwt.rb} +1 -1
- data/lib/aspera/oauth/{url_json.rb → client/url_json.rb} +1 -1
- data/lib/aspera/oauth/{web.rb → client/web.rb} +2 -2
- data/lib/aspera/oauth/server/authorize.html.erb +34 -0
- data/lib/aspera/oauth/server/pkce.rb +426 -0
- data/lib/aspera/orchestrator_workflow_checker.rb +561 -0
- data/lib/aspera/preview/terminal.rb +3 -2
- data/lib/aspera/rainbow.rb +27 -1
- data/lib/aspera/rest/aspera_errors.rb +51 -59
- data/lib/aspera/rest/client.rb +21 -14
- data/lib/aspera/rest/error_analyzer.rb +9 -0
- data/lib/aspera/rest/list.rb +6 -6
- data/lib/aspera/rest/util.rb +7 -1
- data/lib/aspera/rest.rb +0 -1
- data/lib/aspera/schema/IBM Aspera Faspex API-5.0-enhanced.yaml +0 -2
- data/lib/aspera/schema/IBM Aspera Orchestrator API-v1-enhanced.yaml +4001 -0
- data/lib/aspera/schema/IBM Aspera faspio Gateway API-1.0.0-enhanced.yaml +1097 -0
- data/lib/aspera/schema/IBM Aspera on Cloud Automation API-1.0.5-enhanced.yaml +35 -35
- data/lib/aspera/schema/IBM_Aspera_Shares.yaml +819 -7
- data/lib/aspera/schema/documentation.rb +50 -78
- data/lib/aspera/schema/reader.rb +39 -11
- data/lib/aspera/schema/registry.rb +8 -2
- data/lib/aspera/secret_hider.rb +94 -15
- data/lib/aspera/secret_strength.rb +114 -0
- data/lib/aspera/servlet_helpers.rb +111 -0
- data/lib/aspera/sync/args.schema.yaml +3 -1
- data/lib/aspera/sync/conf.schema.yaml +16 -6
- data/lib/aspera/sync/operations.rb +21 -1
- data/lib/aspera/temp_file_manager.rb +79 -20
- data/lib/aspera/transfer/parameters.rb +5 -0
- data/lib/aspera/transfer/spec.rb +52 -12
- data/lib/aspera/transfer/spec.schema.yaml +8 -5
- data/lib/aspera/web_auth/index.html.erb +16 -0
- data/lib/aspera/web_auth/server.rb +18 -10
- data/lib/aspera/web_page.html.erb +262 -0
- data/lib/aspera/web_page.rb +61 -0
- data/lib/aspera/web_server_simple.rb +76 -10
- data.tar.gz.sig +0 -0
- metadata +47 -32
- metadata.gz.sig +0 -0
- data/lib/aspera/cli/mcp_tool.rb +0 -189
- data/lib/aspera/oauth.rb +0 -9
- data/lib/aspera/schema/IBM Aspera Orchestrator API-v1.yaml +0 -1784
- data/lib/aspera/schema/IBM Aspera faspio Gateway API-1.0.0.yaml +0 -249
- data/lib/aspera/web_auth/index.html +0 -147
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'aspera/agent/factory'
|
|
4
|
-
require 'aspera/markdown'
|
|
5
|
-
|
|
6
3
|
module Aspera
|
|
7
4
|
module Schema
|
|
8
|
-
# Generate documentation from
|
|
5
|
+
# Generate documentation from a JSON Schema: in terminal and in manual.
|
|
6
|
+
# Information from `x-` extensions is provided by the caller (`annotate`).
|
|
9
7
|
class Documentation
|
|
10
8
|
# Map JSON Schema type names to user-friendly display names
|
|
11
9
|
JSON_TYPE_TO_DOC = {
|
|
@@ -16,19 +14,21 @@ module Aspera
|
|
|
16
14
|
'array' => 'Array',
|
|
17
15
|
'object' => 'Hash'
|
|
18
16
|
}.freeze
|
|
19
|
-
|
|
20
|
-
# @param
|
|
21
|
-
# @param
|
|
22
|
-
# @param agent_columns [Boolean] `true`: add separate columns for each transfer agent compatibility
|
|
17
|
+
COLUMNS = %w[name type description].freeze
|
|
18
|
+
# @param formatter [FormatterInterface] Formatter with methods: markdown_text, special_format
|
|
19
|
+
# @param schema [Reader]
|
|
23
20
|
# @param code_highlight [Boolean] `true`: format name and type as code
|
|
24
|
-
|
|
21
|
+
# @param annotate [Proc, nil] Called with the schema of a property (`Hash`) and the formatter.
|
|
22
|
+
# Returns lines added to the description (`Array<String>`), or `nil` to hide the property.
|
|
23
|
+
# @param extra_columns [Hash{String => Proc}] Columns inserted before the description: name => Proc
|
|
24
|
+
# called with the schema of a property (`Hash`) and the formatter, returns the value of the cell.
|
|
25
|
+
def initialize(formatter, schema, code_highlight: false, annotate: nil, extra_columns: {})
|
|
25
26
|
@formatter = formatter
|
|
26
27
|
@schema = schema
|
|
27
|
-
@include_option = include_option
|
|
28
|
-
@agent_columns = agent_columns
|
|
29
28
|
@code_highlight = code_highlight
|
|
30
|
-
@
|
|
31
|
-
@
|
|
29
|
+
@annotate = annotate
|
|
30
|
+
@extra_columns = extra_columns
|
|
31
|
+
@columns = COLUMNS.dup.insert(-2, *extra_columns.keys).freeze
|
|
32
32
|
# Sections: each entry is {header: row_or_nil, rows: []}
|
|
33
33
|
# A flat schema produces a single section with no header.
|
|
34
34
|
@sections = [{header: nil, rows: []}]
|
|
@@ -50,7 +50,7 @@ module Aspera
|
|
|
50
50
|
[@columns] + rows.map { |row| @columns.map { |field| row[field] } }
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
-
# Generate a documentation table from a JSON schema
|
|
53
|
+
# Generate a documentation table from a JSON schema
|
|
54
54
|
#
|
|
55
55
|
# Recursively processes a JSON schema to create a formatted table for manual documentation.
|
|
56
56
|
# Handles nested objects, arrays, and extracts metadata (descriptions, types, enums, deprecations).
|
|
@@ -60,88 +60,60 @@ module Aspera
|
|
|
60
60
|
def build(schema = nil)
|
|
61
61
|
code = @code_highlight ? ->(c) { "`#{c}`" } : ->(c) { c }
|
|
62
62
|
schema ||= @schema
|
|
63
|
-
render_title = ->(title) { title.gsub(Markdown::FORMATS) { @formatter.markdown_text(Regexp.last_match) } }
|
|
64
63
|
on_variant = ->(variant_reader, discriminant_property, discriminant_value) do
|
|
65
64
|
title = variant_reader.current['title'] || variant_reader.current['description']
|
|
66
65
|
header =
|
|
67
66
|
if discriminant_property && discriminant_value
|
|
68
|
-
desc =
|
|
69
|
-
desc += ": #{
|
|
70
|
-
|
|
71
|
-
'name' =>
|
|
67
|
+
desc = @formatter.markdown_text("`#{discriminant_value}`")
|
|
68
|
+
desc += ": #{@formatter.markdown_text(title)}" if title
|
|
69
|
+
{
|
|
70
|
+
'name' => @formatter.markdown_text("**#{discriminant_property}**"),
|
|
72
71
|
'type' => code.call(JSON_TYPE_TO_DOC['string']),
|
|
73
72
|
'description' => desc
|
|
74
|
-
}
|
|
73
|
+
}
|
|
75
74
|
elsif title
|
|
76
|
-
|
|
75
|
+
{'name' => @formatter.markdown_text("**#{title}**")}
|
|
77
76
|
end
|
|
78
77
|
@sections.push({header: header, rows: []})
|
|
79
78
|
end
|
|
80
|
-
schema.each_property(on_variant: on_variant) do |property_schema, _name, property_full_name|
|
|
79
|
+
schema.each_property(on_variant: on_variant) do |property_schema, _name, property_full_name, required|
|
|
81
80
|
node = property_schema.current
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
end
|
|
91
|
-
item = {
|
|
92
|
-
'name' => code.call(property_full_name),
|
|
93
|
-
'type' => code.call(item_type),
|
|
94
|
-
'description' => []
|
|
95
|
-
}
|
|
81
|
+
annotations = @annotate ? @annotate.call(node, @formatter) : []
|
|
82
|
+
next if annotations.nil?
|
|
83
|
+
item = {'name' => code.call(property_full_name)}
|
|
84
|
+
item_type = type_text(node)
|
|
85
|
+
item['type'] = code.call(item_type) unless item_type.nil?
|
|
86
|
+
@extra_columns.each { |column, value| item[column] = value.call(node, @formatter) }
|
|
87
|
+
description = []
|
|
88
|
+
description.push("DEPRECATED: #{node['x-deprecation']}") if node.key?('x-deprecation')
|
|
96
89
|
# Render Markdown formatting and split lines
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
agents.push(sym) if node['x-agents'].nil? || node['x-agents'].include?(sym.to_s)
|
|
106
|
-
end
|
|
107
|
-
Aspera.assert(agents.include?(:direct)) { "#{property_full_name}: x-cli-option requires agent direct (or nil)" } if node['x-cli-option']
|
|
108
|
-
if @agent_columns
|
|
109
|
-
Agent::Factory::ALL.each do |sym, names|
|
|
110
|
-
item[names[:short].to_s] = @formatter.tick(agents.include?(sym))
|
|
111
|
-
end
|
|
112
|
-
else
|
|
113
|
-
item['description'].push("(#{agents.map { |i| Agent::Factory::ALL[i][:short].to_s.upcase }.sort.join(', ')})") unless agents.length.eql?(Agent::Factory::ALL.length)
|
|
114
|
-
end
|
|
115
|
-
# Only keep lines that are usable in supported agents
|
|
116
|
-
next if agents.empty?
|
|
117
|
-
item['description'].push("Allowed values: #{node['enum'].map { |v| value_text(v) }.join(', ')}.") if node.key?('enum')
|
|
118
|
-
item['description'].push("Default: #{value_text(node['default'])}.") if node.key?('default')
|
|
119
|
-
item['description'].push("Example: #{value_text(node['example'])}.") if node.key?('example')
|
|
120
|
-
if @include_option
|
|
121
|
-
envvar_prefix = ''
|
|
122
|
-
cli_option =
|
|
123
|
-
if node.key?('x-cli-envvar')
|
|
124
|
-
envvar_prefix = 'env:'
|
|
125
|
-
node['x-cli-envvar']
|
|
126
|
-
elsif node['x-cli-switch']
|
|
127
|
-
false_part = node.key?('x-cli-false') ? " / #{node['x-cli-false']}" : ''
|
|
128
|
-
"#{node['x-cli-option']}#{false_part}"
|
|
129
|
-
elsif node['x-cli-option']
|
|
130
|
-
arg_type = node.key?('enum') ? '{enum}' : "{#{[node['type']].flatten.join('|')}}"
|
|
131
|
-
conversion_tag = node.key?('x-cli-convert') ? 'conversion' : nil
|
|
132
|
-
sep = node['x-cli-option'].start_with?('--') ? '=' : ' '
|
|
133
|
-
"#{node['x-cli-option']}#{sep}#{"(#{conversion_tag})" if conversion_tag}#{arg_type}"
|
|
134
|
-
end
|
|
135
|
-
short = node.key?('x-cli-short') ? "(#{node['x-cli-short']})" : nil
|
|
136
|
-
item['description'].push("(#{'special:' if node['x-cli-special']}#{envvar_prefix}#{@formatter.markdown_text("`#{cli_option}`")})#{short}") if cli_option
|
|
137
|
-
end
|
|
138
|
-
@sections.last[:rows].push(@formatter.check_row(item))
|
|
90
|
+
description.concat(@formatter.markdown_text(node['description']).split("\n")) if node.key?('description')
|
|
91
|
+
description.push('Required.') if required
|
|
92
|
+
description.push("Allowed values: #{node['enum'].map { |v| value_text(v) }.join(', ')}.") if node.key?('enum')
|
|
93
|
+
description.push("Default: #{value_text(node['default'])}.") if node.key?('default')
|
|
94
|
+
description.push("Example: #{value_text(node['example'])}.") if node.key?('example')
|
|
95
|
+
description.concat(annotations)
|
|
96
|
+
item['description'] = description unless description.empty?
|
|
97
|
+
@sections.last[:rows].push(item)
|
|
139
98
|
end
|
|
140
99
|
self
|
|
141
100
|
end
|
|
142
101
|
|
|
143
102
|
private
|
|
144
103
|
|
|
104
|
+
# @param node [Hash] Schema of a property
|
|
105
|
+
# @return [String, nil] Readable type, from `type`, or else from the types of `oneOf` alternatives, `nil` if none
|
|
106
|
+
def type_text(node)
|
|
107
|
+
if node['type'].nil?
|
|
108
|
+
types = Array(node['oneOf']).filter_map { |variant| type_text(variant) }.uniq
|
|
109
|
+
return types.empty? ? nil : types.join(', ')
|
|
110
|
+
end
|
|
111
|
+
return node['type'].map { |t| JSON_TYPE_TO_DOC.fetch(t, t) }.join(', ') if node['type'].is_a?(Array)
|
|
112
|
+
result = JSON_TYPE_TO_DOC.fetch(node['type'], node['type'])
|
|
113
|
+
result = "#{result}[#{type_text(node['items'])}]" if node['type'].eql?('array') && node.dig('items', 'type').is_a?(String)
|
|
114
|
+
result
|
|
115
|
+
end
|
|
116
|
+
|
|
145
117
|
# Format a schema value (enum, default, example) as code
|
|
146
118
|
# @param value [Object] value from schema, `nil` for JSON `null`
|
|
147
119
|
# @return [String]
|
data/lib/aspera/schema/reader.rb
CHANGED
|
@@ -5,6 +5,10 @@ module Aspera
|
|
|
5
5
|
module Schema
|
|
6
6
|
# JSON schema reader
|
|
7
7
|
class Reader
|
|
8
|
+
# Keys of a schema node that provide properties
|
|
9
|
+
PROPERTY_SOURCES = %w[properties allOf oneOf].freeze
|
|
10
|
+
private_constant :PROPERTY_SOURCES
|
|
11
|
+
|
|
8
12
|
attr_reader :current
|
|
9
13
|
|
|
10
14
|
class << self
|
|
@@ -72,14 +76,17 @@ module Aspera
|
|
|
72
76
|
# Recursively traverse schema properties with a block.
|
|
73
77
|
# If the current node has `oneOf`, each variant is traversed in turn and
|
|
74
78
|
# `on_variant` is called (if given) before each variant's properties.
|
|
75
|
-
# @param prefix
|
|
76
|
-
# @param on_variant
|
|
77
|
-
# @
|
|
79
|
+
# @param prefix [String] Prefix for property names (e.g., 'parent.child.')
|
|
80
|
+
# @param on_variant [Proc, nil] Called with the variant Reader before its properties
|
|
81
|
+
# @param parent_required [Array<String>] Names required by the enclosing `oneOf` or `allOf` node
|
|
82
|
+
# @yield [property_schema, name, full_name, required] Yields property info to block
|
|
78
83
|
# @yieldparam property_schema [Reader] Schema reader for this property
|
|
79
84
|
# @yieldparam name [String] Property name
|
|
80
85
|
# @yieldparam full_name [String] Full property name with prefix
|
|
86
|
+
# @yieldparam required [Boolean] `true` if listed in `required` of its object, or of the `oneOf` or `allOf` node that includes it
|
|
81
87
|
# @return [nil]
|
|
82
|
-
def each_property(prefix = '', on_variant: nil, &block)
|
|
88
|
+
def each_property(prefix = '', on_variant: nil, parent_required: [], &block)
|
|
89
|
+
required_names = parent_required + Array(@current['required'])
|
|
83
90
|
if @current.key?('oneOf')
|
|
84
91
|
# Build reverse map: $ref -> discriminant value, from discriminator.mapping if present
|
|
85
92
|
discriminant_by_ref = {}
|
|
@@ -94,16 +101,24 @@ module Aspera
|
|
|
94
101
|
variant_reader = ref ? resolve_ref(ref) : Reader.new(@root, variant_node)
|
|
95
102
|
discriminant_value = ref ? discriminant_by_ref[ref] : nil
|
|
96
103
|
on_variant&.call(variant_reader, discriminant_property, discriminant_value)
|
|
97
|
-
variant_reader.each_property(prefix, on_variant: on_variant, &block)
|
|
104
|
+
variant_reader.each_property(prefix, on_variant: on_variant, parent_required: required_names, &block)
|
|
98
105
|
end
|
|
99
106
|
return
|
|
100
107
|
end
|
|
101
108
|
if @current.key?('allOf')
|
|
102
|
-
# Merge all branches: each branch contributes its properties (no variants)
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
109
|
+
# Merge all branches: each branch contributes its properties (no variants).
|
|
110
|
+
# A property is listed once, with its first declaration: a later one may be empty,
|
|
111
|
+
# only to allow the property under `additionalProperties: false`.
|
|
112
|
+
# A property is required if any branch requires it.
|
|
113
|
+
branches = @current['allOf'].map { |branch_node| branch_reader(branch_node) }
|
|
114
|
+
required_names += branches.flat_map { |branch| Array(branch.current['required']) }
|
|
115
|
+
listed = Set.new
|
|
116
|
+
branches.each do |branch|
|
|
117
|
+
# A branch may only constrain properties of other branches, e.g. with `required`
|
|
118
|
+
next unless PROPERTY_SOURCES.any? { |key| branch.current.key?(key) }
|
|
119
|
+
branch.each_property(prefix, on_variant: on_variant, parent_required: required_names) do |property_schema, name, full_name, required|
|
|
120
|
+
yield(property_schema, name, full_name, required) if listed.add?(full_name)
|
|
121
|
+
end
|
|
107
122
|
end
|
|
108
123
|
return
|
|
109
124
|
end
|
|
@@ -114,7 +129,7 @@ module Aspera
|
|
|
114
129
|
node = property_schema.current
|
|
115
130
|
|
|
116
131
|
# Yield current property to block
|
|
117
|
-
yield(property_schema, name, property_full_name)
|
|
132
|
+
yield(property_schema, name, property_full_name, required_names.include?(name))
|
|
118
133
|
|
|
119
134
|
# Recursively process nested structures (`type` is a list for a nullable value, e.g. `[array, 'null']`)
|
|
120
135
|
types = Array(node['type'])
|
|
@@ -150,9 +165,22 @@ module Aspera
|
|
|
150
165
|
|
|
151
166
|
private
|
|
152
167
|
|
|
168
|
+
# @param node [Hash] Branch of `allOf` or `oneOf`
|
|
169
|
+
# @return [Reader] Reader on the branch, with `$ref` resolved
|
|
170
|
+
def branch_reader(node)
|
|
171
|
+
node['$ref'] ? resolve_ref(node['$ref']) : Reader.new(@root, node)
|
|
172
|
+
end
|
|
173
|
+
|
|
153
174
|
# Recursively collect rows from a schema node, passing each property's own
|
|
154
175
|
# parent `required` array so that only direct-parent membership is checked.
|
|
176
|
+
# Branches of `allOf` are merged: a property is listed once, with its first declaration.
|
|
155
177
|
def collect_rows(rows, reader, prefix)
|
|
178
|
+
reader.current['allOf']&.each do |branch_node|
|
|
179
|
+
branch_rows = []
|
|
180
|
+
collect_rows(branch_rows, branch_reader(branch_node), prefix)
|
|
181
|
+
listed = rows.to_set { |row| row['name'] }
|
|
182
|
+
rows.concat(branch_rows.reject { |row| listed.include?(row['name']) })
|
|
183
|
+
end
|
|
156
184
|
return unless reader.current.key?('properties')
|
|
157
185
|
parent_required = Set.new(Array(reader.current['required']))
|
|
158
186
|
props_reader = reader.dig('properties')
|
|
@@ -62,11 +62,11 @@ module Aspera
|
|
|
62
62
|
aoc: 'aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml',
|
|
63
63
|
automation: 'aspera/schema/IBM Aspera on Cloud Automation API-1.0.5-enhanced.yaml',
|
|
64
64
|
faspex: 'aspera/schema/IBM Aspera Faspex API-5.0-enhanced.yaml',
|
|
65
|
-
faspio: 'aspera/schema/IBM Aspera faspio Gateway API-1.0.0.yaml',
|
|
65
|
+
faspio: 'aspera/schema/IBM Aspera faspio Gateway API-1.0.0-enhanced.yaml',
|
|
66
66
|
console: 'aspera/schema/IBM Aspera Console-enhanced.yaml',
|
|
67
67
|
node: 'aspera/schema/IBM Aspera Node API-4.4.6.yaml',
|
|
68
68
|
shares: 'aspera/schema/IBM_Aspera_Shares.yaml',
|
|
69
|
-
orchestrator: 'aspera/schema/IBM Aspera Orchestrator API-v1.yaml',
|
|
69
|
+
orchestrator: 'aspera/schema/IBM Aspera Orchestrator API-v1-enhanced.yaml',
|
|
70
70
|
async_tables: 'aspera/schema/async_tables.yaml'
|
|
71
71
|
}
|
|
72
72
|
|
|
@@ -88,6 +88,9 @@ module Aspera
|
|
|
88
88
|
SHARES = 'shares+/api/v1'
|
|
89
89
|
ASYNC_TABLES = 'async_tables'
|
|
90
90
|
LOG_OPTIONS = "#{OPTIONS}:components.schemas.LogOptions"
|
|
91
|
+
UI_OPTIONS = "#{OPTIONS}:components.schemas.UiOptions"
|
|
92
|
+
SECURITY_OPTIONS = "#{OPTIONS}:components.schemas.SecurityOptions"
|
|
93
|
+
TEMP_OPTIONS = "#{OPTIONS}:components.schemas.TempOptions"
|
|
91
94
|
DIRECT_AGENT_OPTIONS = "#{OPTIONS}:components.schemas.DirectAgentOptions"
|
|
92
95
|
NODE_AGENT_OPTIONS = "#{OPTIONS}:components.schemas.NodeAgentOptions"
|
|
93
96
|
NODE_EMULATOR_OPTIONS = "#{OPTIONS}:components.schemas.NodeEmulatorOptions"
|
|
@@ -101,6 +104,9 @@ module Aspera
|
|
|
101
104
|
IMAGE_OPTIONS = "#{OPTIONS}:components.schemas.ImageOptions"
|
|
102
105
|
OUT_OPTIONS = "#{OPTIONS}:components.schemas.OutOptions"
|
|
103
106
|
PACKAGE_FOLDER_OPTIONS = "#{OPTIONS}:components.schemas.PackageFolderOptions"
|
|
107
|
+
WEB_SERVER_OPTIONS = "#{OPTIONS}:components.schemas.WebServerOptions"
|
|
108
|
+
GATEWAY_OPTIONS = "#{OPTIONS}:components.schemas.Faspex4GatewayOptions"
|
|
109
|
+
POSTPROC_OPTIONS = "#{OPTIONS}:components.schemas.Faspex4PostProcOptions"
|
|
104
110
|
|
|
105
111
|
REQ_BODY = '.requestBody.content.application/json.schema'
|
|
106
112
|
# Suffix appended to a dotted path to signal query-param extraction in reader()
|
data/lib/aspera/secret_hider.rb
CHANGED
|
@@ -4,17 +4,23 @@
|
|
|
4
4
|
require 'logger'
|
|
5
5
|
require 'set'
|
|
6
6
|
require 'singleton'
|
|
7
|
+
require 'json'
|
|
8
|
+
require 'uri'
|
|
7
9
|
|
|
8
10
|
module Aspera
|
|
9
11
|
# remove secret from logs and output
|
|
10
12
|
#
|
|
13
|
+
# Secrets are identified:
|
|
14
|
+
# - by key name, in structures (`secret?`, `hidden_copy`) and in strings with known patterns (`hide_secrets_in_string`)
|
|
15
|
+
# - by value, when declared where they come from (`add_secret_values`, `add_secrets_in`): option values, tokens, ...
|
|
16
|
+
#
|
|
11
17
|
# @!method self.instance
|
|
12
18
|
# Returns the singleton instance of SecretHider
|
|
13
19
|
# @return [SecretHider] the singleton instance
|
|
14
20
|
class SecretHider
|
|
15
21
|
include Singleton
|
|
16
22
|
|
|
17
|
-
# display string for hidden secrets
|
|
23
|
+
# display string for hidden secrets (see `Environment::UNICODE_TO_ASCII`)
|
|
18
24
|
HIDDEN_PASSWORD = '🔑'
|
|
19
25
|
# env vars for ascp with secrets
|
|
20
26
|
ASCP_ENV_SECRETS = %w[ASPERA_SCP_PASS ASPERA_SCP_KEY ASPERA_SCP_FILEPASS ASPERA_PROXY_PASS ASPERA_SCP_TOKEN].freeze
|
|
@@ -26,6 +32,9 @@ module Aspera
|
|
|
26
32
|
KEY_FALSE_POSITIVES = [/^access_key$/, /^fallback_private_key$/, /public_key$/, /^token_type$/].freeze
|
|
27
33
|
# min length of hidden secrets, use `+` or `{n,}`
|
|
28
34
|
SECRET_LENGTH = '{5,}'
|
|
35
|
+
# min length of a declared secret value to be hidden inside a string: shorter ones are hidden only when they are the whole string,
|
|
36
|
+
# else common words would be hidden (e.g. password `aspera` in URLs), or optionally as a word (`short_in_text`)
|
|
37
|
+
VALUE_IN_STRING_LENGTH = 8
|
|
29
38
|
# regex that define named captures :begin and :end
|
|
30
39
|
REGEX_LOG_REPLACES = [
|
|
31
40
|
# private key values (place first)
|
|
@@ -38,12 +47,16 @@ module Aspera
|
|
|
38
47
|
/(?<begin>(?:(?<quote>["'])|:)[^"':=]*(?:#{ALL_SECRETS.join('|')})[^"':=]*\k<quote>? *(?:=>|:) *")(?:[^"\\]|\\.)+(?<end>")/i,
|
|
39
48
|
# rendered Ruby 3.4+ symbol key: `k: "v"`
|
|
40
49
|
/(?<begin>\b\w*(?:#{ALL_SECRETS.join('|')})\w*: *")(?:[^"\\]|\\.)+(?<end>")/i,
|
|
50
|
+
# rendered Ruby Struct or object: `k="v"`, `@k="v"`
|
|
51
|
+
/(?<begin>@?\b\w*(?:#{ALL_SECRETS.join('|')})\w*=")(?:[^"\\]|\\.)+(?<end>")/i,
|
|
52
|
+
# query parameter of URL: `?token=v` or `&client_secret=v`
|
|
53
|
+
/(?<begin>[?&][^=&\s"]*(?:#{KEY_SECRETS.join('|')})[^=&\s"]*=)[^&\s"#]+(?<end>)/i,
|
|
41
54
|
# logged data
|
|
42
55
|
/(?<begin>(?:#{NON_ENV_SECRETS.join('|')})[ =:]+)[^ "\n]#{SECRET_LENGTH}(?<end>$)/i,
|
|
43
56
|
# cred in http dump
|
|
44
57
|
/(?<begin>(?:#{HTTP_SECRETS.join('|')}): )[^\\]+(?<end>\\)/i
|
|
45
58
|
].freeze
|
|
46
|
-
private_constant :HIDDEN_PASSWORD, :ASCP_ENV_SECRETS, :KEY_SECRETS, :HTTP_SECRETS, :ALL_SECRETS, :NON_ENV_SECRETS, :KEY_FALSE_POSITIVES, :SECRET_LENGTH, :REGEX_LOG_REPLACES
|
|
59
|
+
private_constant :HIDDEN_PASSWORD, :ASCP_ENV_SECRETS, :KEY_SECRETS, :HTTP_SECRETS, :ALL_SECRETS, :NON_ENV_SECRETS, :KEY_FALSE_POSITIVES, :SECRET_LENGTH, :VALUE_IN_STRING_LENGTH, :REGEX_LOG_REPLACES
|
|
47
60
|
attr_accessor :log_secrets
|
|
48
61
|
|
|
49
62
|
# @return [Proc] new log formatter that hides secrets
|
|
@@ -60,11 +73,13 @@ module Aspera
|
|
|
60
73
|
|
|
61
74
|
# Replace secrets in a string with the hidden password placeholder
|
|
62
75
|
# @param value [String] Input string possibly containing secrets
|
|
63
|
-
# @param all [Boolean] `false`: only private keys, `true`: all known secret patterns
|
|
76
|
+
# @param all [Boolean] `false`: only declared values and private keys, `true`: also all known secret patterns
|
|
64
77
|
# @return [String] String with secrets replaced by placeholder
|
|
65
78
|
def hide_secrets_in_string(value, all: false)
|
|
79
|
+
marker = hidden_marker
|
|
80
|
+
value = hide_values_in_string(value, marker)
|
|
66
81
|
(all ? REGEX_LOG_REPLACES : REGEX_LOG_REPLACES.first(1)).each do |reg_ex|
|
|
67
|
-
value = value.gsub(reg_ex) { "#{Regexp.last_match(:begin)}#{
|
|
82
|
+
value = value.gsub(reg_ex) { "#{Regexp.last_match(:begin)}#{marker}#{Regexp.last_match(:end)}" }
|
|
68
83
|
end
|
|
69
84
|
return value
|
|
70
85
|
end
|
|
@@ -84,21 +99,57 @@ module Aspera
|
|
|
84
99
|
ALL_SECRETS.any? { |kw| keyword.include?(kw.downcase) }
|
|
85
100
|
end
|
|
86
101
|
|
|
87
|
-
# Hides
|
|
88
|
-
|
|
102
|
+
# Hides secrets in a structure: by key name, and declared values, also in key names (e.g. a secret given as name of a parameter).
|
|
103
|
+
# Containers are copied, not modified: the structure may be shared (e.g. configuration, transfer spec).
|
|
104
|
+
# @param obj [Object] Hash, Array, Struct, String, or other value (returned as is)
|
|
105
|
+
# @param short_in_text [Boolean] `true`: a declared value shorter than 8 characters is also hidden inside a text, as a word
|
|
106
|
+
# (not next to a letter or digit), e.g. a short password in an error message of `mcp server`
|
|
107
|
+
# @return [Object] Copy of obj with secrets replaced by placeholder
|
|
108
|
+
def hidden_copy(obj, short_in_text: false)
|
|
89
109
|
case obj
|
|
90
|
-
when Array
|
|
91
|
-
obj.each { |i| deep_remove_secret(i) }
|
|
92
110
|
when Hash
|
|
93
|
-
obj.
|
|
94
|
-
|
|
95
|
-
obj[k] = HIDDEN_PASSWORD
|
|
96
|
-
elsif v.is_a?(Hash) || v.is_a?(Array)
|
|
97
|
-
deep_remove_secret(v)
|
|
98
|
-
end
|
|
111
|
+
obj.to_h do |k, v|
|
|
112
|
+
[k.is_a?(String) ? hidden_copy(k, short_in_text: short_in_text) : k, secret?(k, v) ? hidden_marker : hidden_copy(v, short_in_text: short_in_text)]
|
|
99
113
|
end
|
|
114
|
+
when Array then obj.map { |i| hidden_copy(i, short_in_text: short_in_text) }
|
|
115
|
+
when Struct then obj.dup.tap { |s| s.each_pair { |k, v| s[k] = secret?(k, v) ? hidden_marker : hidden_copy(v, short_in_text: short_in_text) } }
|
|
116
|
+
when String then hide_values_in_string(obj, hidden_marker, short_in_text: short_in_text)
|
|
117
|
+
else obj
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# @param obj [Object] Hash, Array, Struct, String, or other value
|
|
122
|
+
# @return [Boolean] `true` if obj contains a secret: by key name, or declared value
|
|
123
|
+
def contains_secret?(obj)
|
|
124
|
+
!hidden_copy(obj).eql?(obj)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Declare secret values: hidden wherever they appear (logs, results), also when escaped in JSON, Ruby or URL
|
|
128
|
+
# @param values [Array<Object>] Secret values: only non-empty Strings are retained
|
|
129
|
+
def add_secret_values(*values)
|
|
130
|
+
forms = values.select { |v| v.is_a?(String) && !v.empty? }.flat_map do |v|
|
|
131
|
+
[v, (JSON.generate(v)[1..-2] rescue nil), v.inspect[1..-2], URI.encode_www_form_component(v)]
|
|
132
|
+
end.compact
|
|
133
|
+
return if forms.empty?
|
|
134
|
+
@mutex.synchronize do
|
|
135
|
+
values = @values | forms
|
|
136
|
+
next if values.size.eql?(@values.size)
|
|
137
|
+
long, short = values.partition { |v| v.length >= VALUE_IN_STRING_LENGTH }
|
|
138
|
+
# Longest first, so that a secret is not partly hidden by another one that it contains
|
|
139
|
+
@values_regex = long.empty? ? nil : Regexp.union(long.sort_by { |v| -v.length })
|
|
140
|
+
@short_values_regex = short.empty? ? nil : /(?<![[:alnum:]])#{Regexp.union(short.sort_by { |v| -v.length })}(?![[:alnum:]])/
|
|
141
|
+
# Replaced, not modified, for readers in other threads
|
|
142
|
+
@values = values.freeze
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Declare the secret values found by key name in a structure
|
|
147
|
+
# @param obj [Object] Hash or Array, e.g. a transfer spec
|
|
148
|
+
def add_secrets_in(obj)
|
|
149
|
+
case obj
|
|
150
|
+
when Array then obj.each { |i| add_secrets_in(i) }
|
|
151
|
+
when Hash then obj.each { |k, v| secret?(k, v) ? add_secret_values(v) : add_secrets_in(v) }
|
|
100
152
|
end
|
|
101
|
-
return obj
|
|
102
153
|
end
|
|
103
154
|
|
|
104
155
|
# Declare additional exact key names whose values are secrets
|
|
@@ -112,6 +163,34 @@ module Aspera
|
|
|
112
163
|
def initialize
|
|
113
164
|
@log_secrets = false
|
|
114
165
|
@additional_keys = Set.new
|
|
166
|
+
# Declared secret values, and their escaped forms
|
|
167
|
+
@values = Set.new.freeze
|
|
168
|
+
# Matches declared values long enough to be hidden inside a string
|
|
169
|
+
@values_regex = nil
|
|
170
|
+
# Matches shorter declared values as a word
|
|
171
|
+
@short_values_regex = nil
|
|
172
|
+
@mutex = Mutex.new
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# @param value [String] Input string
|
|
176
|
+
# @param marker [String] Placeholder
|
|
177
|
+
# @param short_in_text [Boolean] `true`: also hide short values as a word
|
|
178
|
+
# @return [String] value with declared secret values replaced by placeholder
|
|
179
|
+
def hide_values_in_string(value, marker, short_in_text: false)
|
|
180
|
+
return marker if @values.include?(value)
|
|
181
|
+
long_regex = @values_regex
|
|
182
|
+
short_regex = @short_values_regex if short_in_text
|
|
183
|
+
# Long values first, as they may contain short ones
|
|
184
|
+
value = value.gsub(long_regex, marker) unless long_regex.nil?
|
|
185
|
+
value = value.gsub(short_regex, marker) unless short_regex.nil?
|
|
186
|
+
value
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# @return [String] Placeholder for hidden secrets, in ASCII when Unicode is not supported
|
|
190
|
+
def hidden_marker
|
|
191
|
+
# Required here, as `aspera/environment` requires `aspera/log`, which requires this file
|
|
192
|
+
require 'aspera/environment'
|
|
193
|
+
Environment.char(HIDDEN_PASSWORD)
|
|
115
194
|
end
|
|
116
195
|
end
|
|
117
196
|
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'aspera/log'
|
|
4
|
+
require 'aspera/assert'
|
|
5
|
+
|
|
6
|
+
module Aspera
|
|
7
|
+
# Estimate of the strength of a secret (e.g. a token), in bits: log2 of the number of guesses to find it
|
|
8
|
+
#
|
|
9
|
+
# Uses gem `zxcvbn` if installed (dictionaries, l33t, dates...), else a built-in estimate, the lowest of:
|
|
10
|
+
# - alphabet: length x log2(size of character classes used)
|
|
11
|
+
# - Shannon: empirical entropy of characters (catches repeats)
|
|
12
|
+
# - patterns: repeats, sequences and keyboard rows cost 1 bit per character, words and years 2 bits per character
|
|
13
|
+
# Both under-estimate random secrets: a random secret of 32 bytes (e.g. `openssl rand -hex 32`) is far above any usual minimum.
|
|
14
|
+
module SecretStrength
|
|
15
|
+
# Default minimum strength of tokens, in bits: random tokens of 32 bytes are far above
|
|
16
|
+
DEFAULT_MIN_BITS = 80
|
|
17
|
+
# Classes of characters, with their size
|
|
18
|
+
CHARACTER_CLASSES = [[/[a-z]/, 26], [/[A-Z]/, 26], [/\d/, 10], [/[^a-zA-Z\d]/, 33]].freeze
|
|
19
|
+
# Adjacent characters in those rows form a sequence
|
|
20
|
+
KEYBOARD_ROWS = %w[1234567890 qwertyuiop asdfghjkl zxcvbnm azertyuiop qsdfghjklm wxcvbn].freeze
|
|
21
|
+
# Words (3+ letters, optionally capitalized) and years
|
|
22
|
+
LOW_ENTROPY_REGEXES = [/[A-Z]?[a-z]{3,}/, /(?:19|20)\d\d/].freeze
|
|
23
|
+
# Entropy of English text is 1 to 1.5 bit per letter: keep a margin
|
|
24
|
+
LOW_ENTROPY_BITS = 2.0
|
|
25
|
+
# zxcvbn is slow on long secrets: the beginning is enough to assess strength
|
|
26
|
+
ZXCVBN_MAX_LENGTH = 100
|
|
27
|
+
private_constant :CHARACTER_CLASSES, :KEYBOARD_ROWS, :LOW_ENTROPY_REGEXES, :LOW_ENTROPY_BITS, :ZXCVBN_MAX_LENGTH
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
# @param secret [String] Secret to assess
|
|
31
|
+
# @return [Float] Estimated strength in bits
|
|
32
|
+
def bits(secret)
|
|
33
|
+
secret = secret.to_s
|
|
34
|
+
return 0.0 if secret.empty?
|
|
35
|
+
zxcvbn_bits(secret) || estimate_bits(secret)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Refuse an empty or weak secret: an empty one would often accept requests without secret
|
|
39
|
+
# @param secret [String] Secret to check
|
|
40
|
+
# @param name [String] Name of the parameter holding the secret, its minimum is in parameter `<name>_min_bits`
|
|
41
|
+
# @param min_bits [Integer] Minimum estimated strength in bits, `0` checks only that the secret is not empty
|
|
42
|
+
# @param type [Exception] Exception to raise
|
|
43
|
+
# @raise [Exception] if the secret is empty or weaker than `min_bits`
|
|
44
|
+
def assert_strong(secret, name:, min_bits: DEFAULT_MIN_BITS, type: ParameterError)
|
|
45
|
+
Aspera.assert(!secret.to_s.empty?, type: type) { "#{name} must not be empty" }
|
|
46
|
+
strength = bits(secret)
|
|
47
|
+
Aspera.assert(strength >= min_bits, type: type) do
|
|
48
|
+
"#{name} is too weak: about #{strength.round} bits, minimum #{min_bits} (#{name}_min_bits). Generate one with: openssl rand -hex 32"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Built-in estimate, without dictionary
|
|
53
|
+
# @param secret [String] Secret to assess
|
|
54
|
+
# @return [Float] Estimated strength in bits
|
|
55
|
+
def estimate_bits(secret)
|
|
56
|
+
return 0.0 if secret.empty?
|
|
57
|
+
bits_per_char = Math.log2(CHARACTER_CLASSES.sum { |regex, size| secret.match?(regex) ? size : 0 })
|
|
58
|
+
[secret.size * bits_per_char, shannon_bits(secret), pattern_bits(secret, bits_per_char)].min
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
# @return [Float, nil] Strength estimated by gem `zxcvbn`, or `nil` if not installed
|
|
64
|
+
def zxcvbn_bits(secret)
|
|
65
|
+
return unless zxcvbn?
|
|
66
|
+
Math.log2(Zxcvbn.test(secret[0, ZXCVBN_MAX_LENGTH]).guesses)
|
|
67
|
+
rescue StandardError => e
|
|
68
|
+
Log.log.debug { "zxcvbn failed: #{e.message}" }
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @return [Boolean] `true` if gem `zxcvbn` (or `zxcvbn-ruby`) is installed
|
|
73
|
+
def zxcvbn?
|
|
74
|
+
return @zxcvbn unless @zxcvbn.nil?
|
|
75
|
+
@zxcvbn =
|
|
76
|
+
begin
|
|
77
|
+
require 'zxcvbn'
|
|
78
|
+
true
|
|
79
|
+
rescue LoadError
|
|
80
|
+
false
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Empirical Shannon entropy of characters of the secret
|
|
85
|
+
def shannon_bits(secret)
|
|
86
|
+
secret.chars.tally.values.sum do |count|
|
|
87
|
+
probability = count.fdiv(secret.size)
|
|
88
|
+
-probability * Math.log2(probability)
|
|
89
|
+
end * secret.size
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Sum of bits of each character, depending on patterns
|
|
93
|
+
def pattern_bits(secret, bits_per_char)
|
|
94
|
+
# Bits of characters in words and years
|
|
95
|
+
low = Array.new(secret.size)
|
|
96
|
+
LOW_ENTROPY_REGEXES.each do |regex|
|
|
97
|
+
secret.scan(regex) { low.fill(LOW_ENTROPY_BITS, Regexp.last_match.begin(0)...Regexp.last_match.end(0)) }
|
|
98
|
+
end
|
|
99
|
+
secret.chars.each_with_index.sum do |char, index|
|
|
100
|
+
next 1.0 if index.positive? && sequence?(secret[index - 1], char)
|
|
101
|
+
low[index] || bits_per_char
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# @return [Boolean] `true` if `char` repeats `previous`, or follows it in alphabet, digits or keyboard row
|
|
106
|
+
def sequence?(previous, char)
|
|
107
|
+
previous = previous.downcase
|
|
108
|
+
char = char.downcase
|
|
109
|
+
return true if (char.ord - previous.ord).abs <= 1
|
|
110
|
+
KEYBOARD_ROWS.any? { |row| (one = row.index(previous)) && (two = row.index(char)) && (one - two).abs.eql?(1) }
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|