permittable 0.14.0 → 0.15.1
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 +31 -0
- data/README.md +10 -8
- data/lib/permittable/audit.rb +64 -7
- data/lib/permittable/authored_values.rb +23 -11
- data/lib/permittable/column_guard.rb +133 -12
- data/lib/permittable/contract.rb +4 -1
- data/lib/permittable/json_schema.rb +1 -1
- data/lib/permittable/open_api.rb +48 -15
- data/lib/permittable/rspec.rb +154 -23
- data/lib/permittable/tasks/audit.rake +3 -2
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +156 -29
- metadata +2 -2
data/lib/permittable/open_api.rb
CHANGED
|
@@ -197,19 +197,46 @@ module Permittable
|
|
|
197
197
|
# required field (missing).
|
|
198
198
|
def request_body_for(controller, action)
|
|
199
199
|
rule = controller.permit_rule_for(action)
|
|
200
|
-
rule && rule_request_body(rule)
|
|
200
|
+
rule && rule_request_body(rule, controller)
|
|
201
201
|
end
|
|
202
202
|
|
|
203
|
-
def rule_request_body(rule)
|
|
203
|
+
def rule_request_body(rule, controller)
|
|
204
204
|
media_type = body_media_type(rule)
|
|
205
205
|
schema = JsonSchema.rule(rule)
|
|
206
|
-
|
|
206
|
+
exempt = exempt_keys(controller)
|
|
207
|
+
schema = form_schema(schema, rule, exempt) if media_type == "multipart/form-data"
|
|
208
|
+
allow_form_keys!(schema, exempt) if media_type != "multipart/form-data" && rule[:unknown] == :error && !rule[:root]
|
|
207
209
|
{
|
|
208
210
|
"required" => !!(rule[:root] || rule[:fields].any? { |f| f[:required] }),
|
|
209
211
|
"content" => { media_type => { "schema" => schema } }
|
|
210
212
|
}
|
|
211
213
|
end
|
|
212
214
|
|
|
215
|
+
# The top-level keys the runtime exempts under `unknown: :error`: the
|
|
216
|
+
# constant list it subtracts (UNCHECKED_TOP_LEVEL_KEYS) plus the CSRF
|
|
217
|
+
# parameter the controller renamed, which it reads off the controller too
|
|
218
|
+
# (permittable_configured_csrf_key). Per-instance overrides and an image
|
|
219
|
+
# submit's x/y pair cannot be known here.
|
|
220
|
+
def exempt_keys(controller)
|
|
221
|
+
token = controller.request_forgery_protection_token if controller.respond_to?(:request_forgery_protection_token)
|
|
222
|
+
(Permittable::UNCHECKED_TOP_LEVEL_KEYS + [token&.to_s]).compact.uniq
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Under `unknown: :error` the runtime still exempts Rails' routing and form
|
|
226
|
+
# keys (`exempt`, see exempt_keys) at the top of a rootless contract, so a
|
|
227
|
+
# JSON body carrying `authenticity_token`, `format` or `_method` (a form's
|
|
228
|
+
# fields sent as JSON, a record echoed back) is accepted, and the body
|
|
229
|
+
# schema must not forbid it. They
|
|
230
|
+
# stay in as free, never-required properties, as the multipart body lists
|
|
231
|
+
# them (see form_schema). A name the contract declares keeps its own
|
|
232
|
+
# schema. A `root:` rule needs none of this: the keys sit outside the root,
|
|
233
|
+
# in a wrapper that is permissive already. The plain JsonSchema.rule and
|
|
234
|
+
# Contract#json_schema describe the contract, not an HTTP body, and a
|
|
235
|
+
# standalone Contract exempts no form key, so they do not list them.
|
|
236
|
+
def allow_form_keys!(schema, exempt)
|
|
237
|
+
exempt.each { |key| schema["properties"][key] ||= {} }
|
|
238
|
+
end
|
|
239
|
+
|
|
213
240
|
# A multipart body is not one JSON object: each part is a form field, and
|
|
214
241
|
# Rails reads the part NAMES. `user[avatar]` is the avatar of the `user`
|
|
215
242
|
# hash and a repeated `docs[]` is an array, so the schema's properties
|
|
@@ -228,15 +255,15 @@ module Permittable
|
|
|
228
255
|
# nullable container's null (`user[docs]=""`), the keys of a :json
|
|
229
256
|
# field (`user[extra][any]`), and, for a `root:` rule, any part outside
|
|
230
257
|
# the root, which the runtime never reads; a rootless rule instead gets
|
|
231
|
-
# the
|
|
232
|
-
def form_schema(json_schema, rule)
|
|
258
|
+
# the keys (`exempt`, see exempt_keys) the runtime exempts at the top level.
|
|
259
|
+
def form_schema(json_schema, rule, exempt)
|
|
233
260
|
root = rule[:root] && rule[:root].to_s
|
|
234
261
|
form = { properties: {}, patterns: {}, required: [], groups: {}, strict: rule[:unknown] == :error }
|
|
235
262
|
flatten_form(json_schema, [], true, form, skip_group: root)
|
|
236
263
|
if form[:strict] && root
|
|
237
264
|
form[:patterns]["^(?!#{form_escape(root)}(?:\\[|$))"] = {}
|
|
238
265
|
elsif form[:strict]
|
|
239
|
-
form[:patterns]["^(?:#{
|
|
266
|
+
form[:patterns]["^(?:#{exempt.map { |key| form_escape(key) }.join('|')})$"] = {}
|
|
240
267
|
end
|
|
241
268
|
schema = { "type" => "object", "properties" => form[:properties] }
|
|
242
269
|
schema["patternProperties"] = form[:patterns] unless form[:patterns].empty?
|
|
@@ -386,7 +413,7 @@ module Permittable
|
|
|
386
413
|
# dup: the stored desc is frozen, and a caller editing the generated
|
|
387
414
|
# document in place must neither raise nor reach the contract.
|
|
388
415
|
operation["description"] = rule[:desc].dup if rule[:desc]
|
|
389
|
-
operation["requestBody"] = rule_request_body(rule)
|
|
416
|
+
operation["requestBody"] = rule_request_body(rule, controller)
|
|
390
417
|
operation["responses"] = responses_for(rule)
|
|
391
418
|
# The docs must not promise a 422 the server doesn't yet send. Only
|
|
392
419
|
# the rule's own declaration is contract data — the app-wide
|
|
@@ -573,7 +600,7 @@ module Permittable
|
|
|
573
600
|
else
|
|
574
601
|
free.each do |path, verb, template, defaults|
|
|
575
602
|
holder = (paths[path] ||= {})
|
|
576
|
-
placed = with_path_parameters(operation, path, controller.permit_rule_for(action), template || path, defaults)
|
|
603
|
+
placed = with_path_parameters(operation, path, controller.permit_rule_for(action), controller, [template || path, defaults])
|
|
577
604
|
holder[verb] = template ? placed.merge("x-permittable-path" => template) : placed
|
|
578
605
|
slots << OperationSlot.new(holder, verb, verb, operation["operationId"], owner, path)
|
|
579
606
|
end
|
|
@@ -692,6 +719,9 @@ module Permittable
|
|
|
692
719
|
# otherwise: a rootless contract field named like one is that parameter's
|
|
693
720
|
# schema (path_fields), and leaves the body schema.
|
|
694
721
|
#
|
|
722
|
+
# `route` is `[own, defaults]`; `controller` supplies the keys the body
|
|
723
|
+
# exempts (see exempt_keys).
|
|
724
|
+
#
|
|
695
725
|
# `own` is the route's own spelling of the template, which differs
|
|
696
726
|
# from `path` when collapse_shapes placed the route under another
|
|
697
727
|
# route's spelling: the same shape, so the names pair up by position.
|
|
@@ -702,7 +732,8 @@ module Permittable
|
|
|
702
732
|
# not carry them and they are no path parameter. A name the path
|
|
703
733
|
# templates is that parameter instead: `get "o(/:id)", defaults:
|
|
704
734
|
# { id: 5 }` is a parameter on `/o/{id}` and a default only on `/o`.
|
|
705
|
-
def with_path_parameters(operation, path, rule,
|
|
735
|
+
def with_path_parameters(operation, path, rule, controller, route)
|
|
736
|
+
own, defaults = route
|
|
706
737
|
# A name the path templates twice (a mount and an engine route both
|
|
707
738
|
# name `:id`) is declared once: OpenAPI forbids two parameters with
|
|
708
739
|
# one name and location.
|
|
@@ -721,7 +752,7 @@ module Permittable
|
|
|
721
752
|
operation.each_with_object({}) do |(key, value), out|
|
|
722
753
|
if key == "requestBody"
|
|
723
754
|
out["parameters"] = parameters unless parameters.empty?
|
|
724
|
-
value = body_without(rule, fields.values + default_fields(rule, supplied), own_names + supplied)
|
|
755
|
+
value = body_without(rule, controller, fields.values + default_fields(rule, supplied), own_names + supplied)
|
|
725
756
|
end
|
|
726
757
|
out[key] = value unless value.nil?
|
|
727
758
|
end
|
|
@@ -782,11 +813,11 @@ module Permittable
|
|
|
782
813
|
# nothing in the body can make the request wrong. A name the route's
|
|
783
814
|
# `defaults:` fill is treated the same. A rooted contract
|
|
784
815
|
# needs none of this: its wrapper object is permissive already.
|
|
785
|
-
def body_without(rule, moved, templated)
|
|
816
|
+
def body_without(rule, controller, moved, templated)
|
|
786
817
|
rest = rule[:fields] - moved
|
|
787
818
|
return if rest.empty? && !moved.empty?
|
|
788
819
|
|
|
789
|
-
body = rule_request_body(moved.empty? ? rule : rule.merge(fields: rest))
|
|
820
|
+
body = rule_request_body(moved.empty? ? rule : rule.merge(fields: rest), controller)
|
|
790
821
|
return body unless rule[:unknown] == :error && !rule[:root]
|
|
791
822
|
|
|
792
823
|
properties = body["content"].values.first["schema"]["properties"]
|
|
@@ -1013,13 +1044,15 @@ module Permittable
|
|
|
1013
1044
|
# The names a route fills from its `defaults:` (`scope`, `resources` and
|
|
1014
1045
|
# `mount` take them too), as strings. The router puts them into
|
|
1015
1046
|
# path_parameters beside the URL's own, so the client cannot set them;
|
|
1016
|
-
# `controller
|
|
1017
|
-
#
|
|
1047
|
+
# `controller` and `action` are the router's own keys and are left
|
|
1048
|
+
# out, as the runtime leaves them out of `unknown:`. `format` is left in:
|
|
1049
|
+
# a `defaults: { format: ... }` lands in path_parameters like any other
|
|
1050
|
+
# default, so a contract field of that name is filled by it. A
|
|
1018
1051
|
# hand-built route that cannot say fills nothing.
|
|
1019
1052
|
def route_defaults(route)
|
|
1020
1053
|
return [] unless route.respond_to?(:defaults)
|
|
1021
1054
|
|
|
1022
|
-
route.defaults.keys.map(&:to_s) - %w[controller action
|
|
1055
|
+
route.defaults.keys.map(&:to_s) - %w[controller action]
|
|
1023
1056
|
end
|
|
1024
1057
|
|
|
1025
1058
|
# The Prefix of each variant of a mount's path under each of `prefixes`,
|
data/lib/permittable/rspec.rb
CHANGED
|
@@ -469,6 +469,8 @@ module Permittable
|
|
|
469
469
|
end
|
|
470
470
|
|
|
471
471
|
def cast_json_default(field, expected)
|
|
472
|
+
# Absent on a nullable field is the nil a contract stores for it.
|
|
473
|
+
return nil if field[:nullable] && Coercion.absent_value?(expected)
|
|
472
474
|
return expected unless expected.is_a?(Hash)
|
|
473
475
|
|
|
474
476
|
status, value = AuthoredValues.read_json(field, expected)
|
|
@@ -634,7 +636,7 @@ module Permittable
|
|
|
634
636
|
# `not_to ...returning(x)` would pass both on a rejection and on an
|
|
635
637
|
# acceptance returning anything else.
|
|
636
638
|
def does_not_match?(subject) # rubocop:disable Naming/PredicatePrefix -- the RSpec protocol name
|
|
637
|
-
raise ArgumentError, negated_qualifier_message if @returning || !@expected_violations.empty?
|
|
639
|
+
raise ArgumentError, negated_qualifier_message(subject) if @returning || !@expected_violations.empty?
|
|
638
640
|
|
|
639
641
|
rule = locate(subject)
|
|
640
642
|
return false unless rule
|
|
@@ -649,9 +651,12 @@ module Permittable
|
|
|
649
651
|
if @expect_accepted
|
|
650
652
|
return "expected #{subject_name} to #{description}, but it rejected them: #{summary(@violations)}" unless @violations.empty?
|
|
651
653
|
|
|
652
|
-
"expected #{subject_name} to #{description}, but it accepted them but returned
|
|
654
|
+
"expected #{subject_name} to #{description}, but it accepted them but returned " \
|
|
655
|
+
"#{printable(@result.to_h)}#{differs_note}"
|
|
653
656
|
else
|
|
654
|
-
|
|
657
|
+
if @violations.empty?
|
|
658
|
+
return "expected #{subject_name} to #{description}, but it accepted them, returning #{printable(@result.to_h)}"
|
|
659
|
+
end
|
|
655
660
|
|
|
656
661
|
"expected #{subject_name} to #{description}, but the violations were: #{summary(@violations)}"
|
|
657
662
|
end
|
|
@@ -668,7 +673,7 @@ module Permittable
|
|
|
668
673
|
label = @expect_accepted ? "accept those params" : "reject those params"
|
|
669
674
|
label += action_label
|
|
670
675
|
label += " with #{summary(@expected_violations)}" unless @expected_violations.empty?
|
|
671
|
-
label += " returning #{@returning
|
|
676
|
+
label += " returning #{printable(@returning)}" if @returning
|
|
672
677
|
label
|
|
673
678
|
end
|
|
674
679
|
|
|
@@ -682,15 +687,27 @@ module Permittable
|
|
|
682
687
|
# first so a matcher object reused across examples (or re-chained
|
|
683
688
|
# with another for_action) never reports a previous run's problem.
|
|
684
689
|
def locate(subject)
|
|
685
|
-
@problem = @violations = @result = nil
|
|
690
|
+
@problem = @violations = @result = @root = nil
|
|
686
691
|
@subject = resolve_subject(subject)
|
|
687
|
-
resolve_rule
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
692
|
+
rule = resolve_rule
|
|
693
|
+
@root = rule[:root] if rule
|
|
694
|
+
rule
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
def negated_qualifier_message(subject = nil)
|
|
698
|
+
# Raised before the rule is located, so the root a scoped filter
|
|
699
|
+
# needs is looked up here, best effort: a subject or an ambiguity
|
|
700
|
+
# that locate would refuse (a BasicObject answers no respond_to?) is not
|
|
701
|
+
# this message's to report, and leaves the filter unscoped (the
|
|
702
|
+
# shallow one) rather than masking the qualifier error.
|
|
703
|
+
begin
|
|
704
|
+
locate(subject) if subject
|
|
705
|
+
rescue StandardError
|
|
706
|
+
@root = nil
|
|
707
|
+
end
|
|
691
708
|
call = @expect_accepted ? "accept_params" : "reject_params"
|
|
692
709
|
qualifiers = []
|
|
693
|
-
qualifiers << "returning #{@returning
|
|
710
|
+
qualifiers << "returning #{printable(@returning)}" if @returning
|
|
694
711
|
qualifiers << "with_violation #{summary(@expected_violations)}" unless @expected_violations.empty?
|
|
695
712
|
"#{LABEL}: `not_to #{call}(...)` cannot take qualifiers (here: #{qualifiers.join(', ')}) — " \
|
|
696
713
|
"negating one is ambiguous, since it would pass both when the params are " \
|
|
@@ -788,36 +805,40 @@ module Permittable
|
|
|
788
805
|
# payload belongs to the spec. The copy stringifies Symbol keys, which
|
|
789
806
|
# the normalizer's HashWithIndifferentAccess does anyway but only after
|
|
790
807
|
# it has read a Symbol :tempfile key as an upload, a key a request
|
|
791
|
-
# (String keys only) never has
|
|
792
|
-
# so it is normalized like the
|
|
808
|
+
# (String keys only) never has, and Symbol values (see copy_payload).
|
|
809
|
+
# A Parameters payload is unwrapped first so it is normalized like the
|
|
810
|
+
# Hash it wraps. Without actionpack's request code only the normalizer
|
|
811
|
+
# is skipped; the copy (and its Symbol stringifying) still happens.
|
|
793
812
|
def request_params
|
|
813
|
+
payload = copy_payload(@params.respond_to?(:to_unsafe_h) ? @params.to_unsafe_h : @params)
|
|
794
814
|
utils = request_utils
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
payload = @params.respond_to?(:to_unsafe_h) ? @params.to_unsafe_h : @params
|
|
798
|
-
utils.normalize_encode_params(copy_payload(payload))
|
|
815
|
+
utils ? utils.normalize_encode_params(payload) : payload
|
|
799
816
|
end
|
|
800
817
|
|
|
801
818
|
# Rails main moved the class from ActionDispatch::Request::Utils to
|
|
802
|
-
# ActionDispatch::Http::Utils. nil
|
|
803
|
-
# loaded
|
|
804
|
-
#
|
|
805
|
-
#
|
|
819
|
+
# ActionDispatch::Http::Utils. nil when actionpack's request code is not
|
|
820
|
+
# loaded (a non-Rails stand-in controller, or a bare ActionDispatch module
|
|
821
|
+
# that something else namespaced): there is nothing to normalize with, and
|
|
822
|
+
# nothing was renamed. Once ActionDispatch::Request exists, a rename this
|
|
823
|
+
# does not know raises instead of silently skipping the normalization,
|
|
824
|
+
# which would bring the false greens back.
|
|
806
825
|
def request_utils
|
|
807
|
-
return unless defined?(ActionDispatch)
|
|
808
|
-
|
|
809
826
|
if defined?(ActionDispatch::Http::Utils) then ActionDispatch::Http::Utils
|
|
810
827
|
elsif defined?(ActionDispatch::Request::Utils) then ActionDispatch::Request::Utils
|
|
811
|
-
|
|
828
|
+
elsif defined?(ActionDispatch::Request)
|
|
812
829
|
raise "Permittable's matchers cannot find Rails' parameter normalizer: neither " \
|
|
813
830
|
"ActionDispatch::Http::Utils nor ActionDispatch::Request::Utils is defined"
|
|
814
831
|
end
|
|
815
832
|
end
|
|
816
833
|
|
|
834
|
+
# Symbol keys become Strings, and so do Symbol values: a request carries
|
|
835
|
+
# every scalar as a String (or a JSON number, boolean or null), never a
|
|
836
|
+
# Symbol, so `status: :active` is the "active" the contract would read.
|
|
817
837
|
def copy_payload(value)
|
|
818
838
|
case value
|
|
819
839
|
when Hash then value.to_h { |key, item| [key.is_a?(Symbol) ? key.to_s : key, copy_payload(item)] }
|
|
820
840
|
when Array then value.map { |item| copy_payload(item) }
|
|
841
|
+
when Symbol then value.to_s
|
|
821
842
|
else value
|
|
822
843
|
end
|
|
823
844
|
end
|
|
@@ -874,6 +895,116 @@ module Permittable
|
|
|
874
895
|
raise ArgumentError, "#{LABEL}: the subject of accept_params/reject_params must include Permittable (got #{subject.inspect})"
|
|
875
896
|
end
|
|
876
897
|
|
|
898
|
+
# A hash echoed into a failure message, which lands in CI logs, goes out
|
|
899
|
+
# redacted the way a request log is: by the names `sensitive:` fields
|
|
900
|
+
# registered, and by the app's config.filter_parameters when a Rails
|
|
901
|
+
# application is booted. Only the printed copy is filtered; the matching
|
|
902
|
+
# itself reads the real values.
|
|
903
|
+
def printable(hash)
|
|
904
|
+
redacted(hash).inspect
|
|
905
|
+
end
|
|
906
|
+
|
|
907
|
+
# The redacted structure itself, which differs_note walks beside the
|
|
908
|
+
# real one. A rule with a root: has its hash put back under that root
|
|
909
|
+
# for the app's filter, as a request logs it, so a scoped filter
|
|
910
|
+
# ("payment.number", /\Apayment\.code\z/) matches; the registry pass
|
|
911
|
+
# runs on the hash alone, as it does at runtime.
|
|
912
|
+
def redacted(hash)
|
|
913
|
+
inner = redact_sensitive(hash)
|
|
914
|
+
filter = parameter_filter
|
|
915
|
+
return inner unless filter
|
|
916
|
+
return filter.filter(inner) unless @root
|
|
917
|
+
|
|
918
|
+
key = @root.to_s
|
|
919
|
+
filter.filter(key => inner)[key]
|
|
920
|
+
end
|
|
921
|
+
|
|
922
|
+
# The registry's names, by whatever it can answer: #include?, else its
|
|
923
|
+
# #names matched the way the registry matches (case-insensitive
|
|
924
|
+
# substring). One with neither is left to its #to_proc in
|
|
925
|
+
# parameter_filter. A masked value is replaced whole, a Hash included.
|
|
926
|
+
# An ActionController::Parameters is unwrapped, so its nested values
|
|
927
|
+
# are reached too.
|
|
928
|
+
def redact_sensitive(value)
|
|
929
|
+
value = value.to_unsafe_h if value.respond_to?(:to_unsafe_h)
|
|
930
|
+
return value.map { |item| redact_sensitive(item) } if value.is_a?(Array)
|
|
931
|
+
return value unless value.is_a?(Hash)
|
|
932
|
+
|
|
933
|
+
registry = Permittable.filter_parameter_registry
|
|
934
|
+
value.to_h do |key, item|
|
|
935
|
+
[key, sensitive_name?(registry, key) ? Permittable::FilterParameterRegistry::FILTERED : redact_sensitive(item)]
|
|
936
|
+
end
|
|
937
|
+
end
|
|
938
|
+
|
|
939
|
+
def sensitive_name?(registry, key)
|
|
940
|
+
if registry.respond_to?(:include?)
|
|
941
|
+
registry.include?(key)
|
|
942
|
+
elsif registry.respond_to?(:names)
|
|
943
|
+
name = key.to_s.downcase
|
|
944
|
+
registry.names.any? { |sensitive| name.include?(sensitive.to_s.downcase) }
|
|
945
|
+
else
|
|
946
|
+
false
|
|
947
|
+
end
|
|
948
|
+
end
|
|
949
|
+
|
|
950
|
+
# Rails' ParameterFilter over the app's config.filter_parameters, plus
|
|
951
|
+
# the registry's own #to_proc when it can answer neither #include? nor
|
|
952
|
+
# #names (a proc masks only Strings, but it is all such a registry
|
|
953
|
+
# offers). ActiveSupport::ParameterFilter is not loaded by
|
|
954
|
+
# `require "active_support"` before 7.0, and a Rails 6.1 app without
|
|
955
|
+
# ActionDispatch::Request has not loaded it either; it has been
|
|
956
|
+
# requirable since 6.0.
|
|
957
|
+
def parameter_filter
|
|
958
|
+
filters = []
|
|
959
|
+
app = defined?(Rails) && Rails.respond_to?(:application) && Rails.application
|
|
960
|
+
filters.concat(Array(app.config.filter_parameters)) if app
|
|
961
|
+
registry = Permittable.filter_parameter_registry
|
|
962
|
+
filters << registry.to_proc unless registry.respond_to?(:include?) || registry.respond_to?(:names)
|
|
963
|
+
return if filters.empty?
|
|
964
|
+
|
|
965
|
+
begin
|
|
966
|
+
require "active_support/parameter_filter"
|
|
967
|
+
rescue LoadError
|
|
968
|
+
return
|
|
969
|
+
end
|
|
970
|
+
ActiveSupport::ParameterFilter.new(filters)
|
|
971
|
+
end
|
|
972
|
+
|
|
973
|
+
# The registry matches by substring, like Rails (`pin` also redacts
|
|
974
|
+
# `shipping`), so an expected and an actual hash that differ only under
|
|
975
|
+
# redacted names print the same. Say where they differ, by name only.
|
|
976
|
+
def differs_note
|
|
977
|
+
return "" unless @returning
|
|
978
|
+
|
|
979
|
+
expected = ActiveSupport::HashWithIndifferentAccess.new(@returning).to_h
|
|
980
|
+
actual = @result.to_h
|
|
981
|
+
return "" if expected == actual || printable(expected) != printable(actual)
|
|
982
|
+
|
|
983
|
+
# A filter on the root itself masks the whole hash, leaving the root as the one path.
|
|
984
|
+
paths = differing_paths(expected, actual, redacted(expected), redacted(actual)).map { |path| path || @root.to_s }
|
|
985
|
+
paths.empty? ? "" : "; differs at: #{paths.join(', ')}"
|
|
986
|
+
end
|
|
987
|
+
|
|
988
|
+
# Walks the real hashes beside their redacted copies and stops at the
|
|
989
|
+
# redaction boundary: a node the copy shows as masked is named by its
|
|
990
|
+
# path alone when the real values differ, and not entered, since a key
|
|
991
|
+
# or an index below it is as secret as its value.
|
|
992
|
+
def differing_paths(expected, actual, expected_masked, actual_masked, prefix = nil)
|
|
993
|
+
filtered = Permittable::FilterParameterRegistry::FILTERED
|
|
994
|
+
masked = expected_masked == filtered || actual_masked == filtered
|
|
995
|
+
if !masked && expected.is_a?(Hash) && actual.is_a?(Hash)
|
|
996
|
+
(expected.keys | actual.keys).flat_map do |key|
|
|
997
|
+
differing_paths(expected[key], actual[key], expected_masked[key], actual_masked[key], [prefix, key].compact.join("."))
|
|
998
|
+
end
|
|
999
|
+
elsif !masked && expected.is_a?(Array) && actual.is_a?(Array) && expected.length == actual.length
|
|
1000
|
+
expected.each_index.flat_map do |i|
|
|
1001
|
+
differing_paths(expected[i], actual[i], expected_masked[i], actual_masked[i], "#{prefix}[#{i}]")
|
|
1002
|
+
end
|
|
1003
|
+
else
|
|
1004
|
+
expected == actual ? [] : [prefix]
|
|
1005
|
+
end
|
|
1006
|
+
end
|
|
1007
|
+
|
|
877
1008
|
def summary(violations)
|
|
878
1009
|
violations.map { |v| v[:code] ? "#{v[:param]} (#{v[:code]})" : v[:param] }.join(", ")
|
|
879
1010
|
end
|
|
@@ -13,9 +13,10 @@ namespace :permittable do
|
|
|
13
13
|
|
|
14
14
|
# Every controller, not just the ones including Permittable — a controller
|
|
15
15
|
# that never included it is unguarded, which is exactly the finding. Bare
|
|
16
|
-
# ActionController::Metal ones included
|
|
17
|
-
|
|
16
|
+
# ActionController::Metal ones included, and autoloaded framework ones
|
|
17
|
+
# (Rails::HealthController) the routes name; see Audit.rails_controllers.
|
|
18
18
|
routes = Permittable::OpenAPI.rails_routes(Rails.application)
|
|
19
|
+
controllers = Permittable::Audit.rails_controllers(routes)
|
|
19
20
|
|
|
20
21
|
entries = Permittable::Audit.entries(controllers: controllers, routes: routes)
|
|
21
22
|
stale = Permittable::Audit.stale(controllers: controllers, routes: routes)
|
data/lib/permittable/version.rb
CHANGED