standard_audit 0.13.3 → 0.14.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 77f38f95622eab13dc70d4376ad9694a3bbb03d3d54f5f2c43f63012e041ea23
4
- data.tar.gz: 5d68228d9da02a23808db8cc2588dc358df1dc7f7a82d86da4447d98038f6411
3
+ metadata.gz: 052265cb7a2b77f0bdbbec540a7603112a26ad20173828a0009a5aee72d68270
4
+ data.tar.gz: 3dd110875c9356705e363437c97ef35b27e8412eed4f54c523894d9dc8173ca2
5
5
  SHA512:
6
- metadata.gz: c5ca114c4894594f1e0de803d0d0e207bd308f887e130237af3d967a4ed33bc476ac72f28b1cff8b83af5f2d5c56e158542a917c560680c96e3f686ec339c6ec
7
- data.tar.gz: 991e7cbfd6c21c73f54bc1bc1ed64aa1df8faaa2a6d44dfdf75222ab79911eb462de275ca086d74ef72108db70bad6df1f8c9f65fab9ec7ea115c0bb5bb99f1e
6
+ metadata.gz: abba2f5dac90739c1a96b1470030f81bcc967261af6effab9a3561edc1ec57115e19695aa7cebebb716b20a406bdfca2200a89afda7d826acd7de9a5379d5206
7
+ data.tar.gz: 60985a94ecd78c1c47a9e66ffe7fb0df0ffd26928361c4a0b4ee1417a14eea31762aca75cb1e4805e117af3df668cd5e99f473ff3ddf05b6a920e48c35ef2f40
data/CHANGELOG.md CHANGED
@@ -7,6 +7,90 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.14.1] - 2026-10-22
11
+
12
+ ### Changed
13
+
14
+ - **`Checks::Retention` fails closed on `APP_ENVIRONMENT`.** Only `staging`,
15
+ `preview`, `development` and `test` are non-production; any other set value
16
+ is production, so a misspelt `APP_ENVIRONMENT` can no longer silence the
17
+ unbounded-retention advisory (before, only the exact value `production`
18
+ counted). Surrounding whitespace is ignored and matching is case-sensitive
19
+ (`Staging` is production). Unset or blank still falls back to
20
+ `Rails.env.production?`. The estate
21
+ convention shared with standard_id, standard_health and standard_singpass.
22
+
23
+ ## [0.14.0] - 2026-10-22
24
+
25
+ ### Added
26
+
27
+ - **`config.retention_days = :indefinite`** (or
28
+ `STANDARD_AUDIT_RETENTION_DAYS=indefinite`) records keeping audit rows
29
+ forever as a decision. Rows are kept exactly as with `nil`, but the production
30
+ advisory `StandardAudit::Checks::Retention` reports `ok` with
31
+ `retention: "indefinite"` instead of warning. Hosts that set a huge day count
32
+ to quiet the advisory (nutripod-web uses 36,500) should switch: a day count is
33
+ a window, and `CleanupJob` acts on it. `CleanupJob` does nothing for
34
+ `:indefinite`, and `standard_audit:cleanup`/`:archive` abort unless given days.
35
+ New readers: `Configuration#retention_window_days`, `#retention_indefinite?`
36
+ and `#retention_misconfigured?`; `CleanupJob` warns and deletes nothing for a
37
+ value that is neither a positive Integer nor `:indefinite` (e.g. `"90"`).
38
+ - **`standard_audit/rspec` fails loudly when configuration would be lost.**
39
+ A `before(:suite)` hook calls the new `StandardAudit.verify_baseline_configuration!`,
40
+ which raises `StandardAudit::BaselineConfigurationError`, naming each call
41
+ site, if any plain `StandardAudit.configure` (without `baseline: true`) ran
42
+ at boot. The per-example reset replays only the baseline block, so those
43
+ settings, `before_checksum` hooks included, disappeared from the second
44
+ example on, and the specs that would have noticed passed vacuously.
45
+ `configure` calls inside an example are unaffected. **Upgrading:** move any
46
+ boot-time plain `configure` into the single `configure(baseline: true)` block.
47
+ - **`StandardAudit::AuthFailure`** records authentication failures: requests
48
+ refused before any operation runs. Include
49
+ `StandardAudit::AuthFailure::Controller` for a private
50
+ `record_auth_failure(event_type, error_code, actor: nil, **extra)`, or call
51
+ `StandardAudit::AuthFailure.record(..., request:/path:)` outside a controller.
52
+ It writes allow-listed metadata (`error_code`, `path`, `method`, plus the
53
+ caller's extras), the request id/IP/user agent as columns (user agent
54
+ truncated to 1024 characters), drops a non-GlobalID actor, and never raises,
55
+ including for a request whose forwarded-IP headers trip Rails' spoofing
56
+ check (it falls back to Rack's `request.ip`, which ignores `Client-IP`).
57
+ `config.log_auth_failures = true` (default `false`) adds a structured warn
58
+ line whose extras pass through `MetadataFilter` like the row's; if the log
59
+ line fails, the row is still written. It replaces the hand-copied
60
+ `AuditAuthFailure` concerns in nutripod-web, luminality-web and sidekick-web.
61
+ - **`"standard_audit catalogue coverage"`** shared example
62
+ (`require "standard_audit/rspec/coverage"`): every catalogue action must be
63
+ asserted by a positive literal `have_audited("…")` somewhere in the suite,
64
+ apart from an explicit `pending:` list that may only shrink. It is a static
65
+ scan of the spec files, so it behaves the same under `parallel_rspec` and in
66
+ partial runs. Commented-out lines don't count. Predicates:
67
+ `StandardAudit::RSpec::Coverage.covered_actions` and `.report`.
68
+ It replaces the hand-synced `CATALOGUE_ACTIONS` copies in the
69
+ apps' `spec/support/audit_log_coverage.rb`.
70
+
71
+ ### Fixed
72
+
73
+ - A logger that raises no longer turns a swallowed audit failure into a raised
74
+ one. `report_write_error` and `report_error`'s own fallback both guard their
75
+ `Rails.logger` call, so `record(raise: false)`, the subscribers and
76
+ `AuthFailure.record` keep their never-raise guarantee.
77
+
78
+ ### Changed
79
+
80
+ - **`config.filter_nested_metadata` now defaults to `true`.** Redaction
81
+ descends into nested Hashes (and Hashes inside Arrays) unless a host turns it
82
+ off, so `metadata: { stripe: { client_secret: … } }` is no longer written
83
+ intact by default. Every rarebit-one consumer already set it to true, so for
84
+ them nothing changes. A host that relied on the old default will have nested
85
+ sensitive keys redacted from now on; to keep the old behaviour, set
86
+ `config.filter_nested_metadata = false` after a dry run
87
+ (`rake "standard_audit:sensitive_keys:dry_run[...]" NESTED=1`).
88
+ `raise_on_audit_write_error` keeps its `false` default: three of the five
89
+ consumers report and swallow failed audit writes on purpose.
90
+ - The README configuration reference and the install generator's commented
91
+ `scope_extractor` example show the record guard the 0.13.3 default has, so a
92
+ host copying them no longer reintroduces FUNDBRIGHT-WEB-41.
93
+
10
94
  ## [0.13.3] - 2026-10-21
11
95
 
12
96
  ### Fixed
data/README.md CHANGED
@@ -258,6 +258,43 @@ config.before_checksum { |log| log.actor_role = Current.audit_role_for(log.actor
258
258
  `metadata_builder` now applies to direct writes too, so no change is needed
259
259
  beyond deleting the caveat.
260
260
 
261
+ ## Authentication Failures
262
+
263
+ A request refused before any operation runs (an invalid or revoked token, a
264
+ missing scope, a locked account) never reaches the operation audit contract, so
265
+ the controller records it. `StandardAudit::AuthFailure` is that recorder:
266
+
267
+ ```ruby
268
+ class Api::BaseController < ActionController::API
269
+ include StandardAudit::AuthFailure::Controller
270
+
271
+ rescue_from InvalidToken do
272
+ record_auth_failure("auth.token_invalid", "AUTH_001")
273
+ render json: { error: "AUTH_001" }, status: :unauthorized
274
+ end
275
+ end
276
+
277
+ # Outside a controller, with or without a request:
278
+ StandardAudit::AuthFailure.record("auth.social_denied", "AUTH_009", request: request)
279
+ StandardAudit::AuthFailure.record("auth.social_denied", "AUTH_009", path: "/auth/callback", actor: account)
280
+ ```
281
+
282
+ - **Metadata is an allow-list:** `error_code`, `path`, `method` (with a
283
+ request), plus exactly the extra keywords you pass. Nothing comes from params,
284
+ headers or the exception. The rejected credential is in scope on this path, so
285
+ never pass it; `sensitive_keys` is only the backstop.
286
+ - **Request context goes in the columns:** with `request:`, the request id, IP
287
+ and user agent are written explicitly, and the user agent is truncated to
288
+ 1024 characters so an over-long header can't cost you the row.
289
+ - **The actor** is usually nil. A value that isn't GlobalID-able is dropped.
290
+ - **It never raises** (`raise: false`), so an audit failure can't turn a 401
291
+ into a 500. Failures go to `config.error_reporter`.
292
+ - **`config.log_auth_failures = true`** also emits one structured
293
+ `Rails.logger.warn` line per failure, for alerting off the log stream.
294
+
295
+ These are not operation actions, so leave them out of an `orphans_within:
296
+ :catalogue` check (or out of the catalogue).
297
+
261
298
  ## Model Concerns
262
299
 
263
300
  ### Auditable
@@ -488,6 +525,38 @@ each app's `spec/initializers/standard_audit_baseline_spec.rb` (or
488
525
  examples can go too once those overrides are deleted (they are the 0.12.0
489
526
  defaults).
490
527
 
528
+ ### `"standard_audit catalogue coverage"`
529
+
530
+ Checks that every catalogue action is asserted by a positive
531
+ `have_audited("…")` somewhere in the suite:
532
+
533
+ ```ruby
534
+ require "standard_audit/rspec/coverage"
535
+
536
+ RSpec.describe "Audit catalogue coverage" do
537
+ it_behaves_like "standard_audit catalogue coverage",
538
+ catalogue: -> { AuditCatalogue::OPERATION_ACTIONS }, # default: config.audit_catalogue
539
+ pending: %w[grant.pass_scheduled] # catalogued, no writer yet
540
+ end
541
+ ```
542
+
543
+ It fails on a catalogue action with no assertion, a `pending:` action that
544
+ isn't in the catalogue, and a `pending:` action that has gained an assertion
545
+ (so the list only shrinks).
546
+
547
+ It scans the spec files' text rather than tallying matches at runtime, so it
548
+ gives the same answer under `parallel_rspec`, in a single-file run and in the
549
+ full suite. Only literal, positive assertions count: `.to have_audited("x")`,
550
+ `.to(have_audited("x"))` and `.and have_audited("x")`. `not_to`, and names
551
+ built at runtime, don't. Their example still has to pass, so in a green suite an
552
+ assertion means the row was written.
553
+
554
+ **Replace your host code with** the shared example: the hand-copied
555
+ `CATALOGUE_ACTIONS` / `PENDING_FEATURE_ACTIONS` in
556
+ `spec/support/audit_log_coverage.rb`, the spec that kept them in sync with the
557
+ catalogue, and the `change(…).by(1)` + `expect_well_formed_log` pairs, which
558
+ `have_audited("x").by.on.once` replaces.
559
+
491
560
  ## Configuration Reference
492
561
 
493
562
  Use `configure(baseline: true)` in your initializer. It remembers the block so
@@ -508,7 +577,13 @@ StandardAudit.configure(baseline: true) do |config|
508
577
  # Defaults shown below.
509
578
  config.actor_extractor = ->(payload) { payload[:actor] }
510
579
  config.target_extractor = ->(payload) { payload[:target] }
511
- config.scope_extractor = ->(payload) { payload[:scope] } # default keeps it only if it responds to to_global_id
580
+ # Only a record can be a scope; the default drops anything else (e.g. the
581
+ # Symbol standard_id publishes as `scope:`) so it falls back to
582
+ # `current_scope_resolver`. Keep that guard in a custom extractor.
583
+ config.scope_extractor = ->(payload) {
584
+ scope = payload[:scope]
585
+ scope if scope.respond_to?(:to_global_id)
586
+ }
512
587
 
513
588
  # -- Current Attribute Resolvers --
514
589
  # Fallbacks used when payload values are nil.
@@ -598,7 +673,8 @@ StandardAudit.configure(baseline: true) do |config|
598
673
 
599
674
  # -- Retention (schedule StandardAudit::CleanupJob to enforce) --
600
675
  # Defaults from STANDARD_AUDIT_RETENTION_DAYS (see Retention below); set here
601
- # to override per app. Leave unset for infinite retention.
676
+ # to override per app. Leave unset for infinite retention, or set
677
+ # :indefinite to record keeping rows forever as a decision.
602
678
  config.retention_days = 90
603
679
 
604
680
  # -- Checksum cutover --
@@ -840,10 +916,19 @@ It defaults from the `STANDARD_AUDIT_RETENTION_DAYS` environment variable, so a
840
916
  deployment can opt into a retention window without a code change:
841
917
 
842
918
  ```bash
843
- STANDARD_AUDIT_RETENTION_DAYS=365 # keep 365 days
919
+ STANDARD_AUDIT_RETENTION_DAYS=365 # keep 365 days
920
+ STANDARD_AUDIT_RETENTION_DAYS=indefinite # keep forever, on purpose
844
921
  # unset / blank / 0 / negative / non-numeric => nil => infinite retention
845
922
  ```
846
923
 
924
+ `nil` and `:indefinite` both keep every row. The difference is intent: `nil`
925
+ means nobody has decided, so the production advisory below warns, while
926
+ `config.retention_days = :indefinite` records a deliberate keep-forever policy
927
+ and the advisory reports ok. Don't set a huge day count (say 36,500) to silence
928
+ the advisory; that is a window, so `CleanupJob` would one day act on it.
929
+ `CleanupJob` does nothing for either, and the cleanup and archive rake tasks
930
+ abort unless you pass days explicitly.
931
+
847
932
  Infinite retention (the default) is the compliance-safe behavior: nothing is
848
933
  ever auto-deleted. The `standard_audit:cleanup` and `standard_audit:archive`
849
934
  rake tasks respect this: with no days argument and a nil `retention_days` they
@@ -864,9 +949,13 @@ StandardHealth.configure do |c|
864
949
  end
865
950
  ```
866
951
 
867
- When `APP_ENVIRONMENT == "production"` (falling back to `Rails.env.production?`
868
- when that var is unset — so staging is not flagged) and `retention_days` is nil,
869
- the check returns `:warn`. That rolls `GET /health/ready` up to `:degraded`,
952
+ On a production deployment with `retention_days` nil, the check returns `:warn`.
953
+ "Production" follows the estate's `APP_ENVIRONMENT` convention: when the variable
954
+ is set (surrounding whitespace ignored, matched case-sensitively), only
955
+ `staging`, `preview`, `development` and `test` are non-production, so staging is
956
+ not flagged, and any other value, a typo included, counts as production. When it
957
+ is unset or blank, `Rails.env.production?` decides. With `:indefinite` it returns `:ok` with
958
+ `retention: "indefinite"`. That rolls `GET /health/ready` up to `:degraded`,
870
959
  which is **still HTTP 200** — it surfaces the advisory in the readiness JSON
871
960
  without failing the probe or blocking a deploy. The check is duck-typed and has
872
961
  no hard dependency on `standard_health`.
@@ -1138,7 +1227,7 @@ canonical-checksum cutover do not depend on key order; earlier rows may — see
1138
1227
  config.sensitive_keys += %i[medical_record_number] # extend the built-in defaults
1139
1228
  config.sensitive_key_patterns = [/secret/i] # catch a whole family
1140
1229
  config.sensitive_key_exceptions = %i[input_tokens] # ...minus the real ones
1141
- config.filter_nested_metadata = true # redact nested Hashes too
1230
+ config.filter_nested_metadata = true # the default since 0.14.0
1142
1231
  ```
1143
1232
 
1144
1233
  `sensitive_keys` matches **exactly** on the key name. There is deliberately no
@@ -1153,12 +1242,13 @@ bin/rails "standard_audit:sensitive_keys:dry_run[secret]" # NESTED=1 to model
1153
1242
  ```
1154
1243
 
1155
1244
  The dry run writes nothing. It reports per key what would be stripped, what
1156
- would be kept, and nested matches that survive because
1157
- `filter_nested_metadata` is off.
1245
+ would be kept, and nested matches that would survive if
1246
+ `filter_nested_metadata` were off.
1158
1247
 
1159
- Nested metadata is **not** redacted unless `filter_nested_metadata` is enabled
1160
- — `metadata: { stripe: { client_secret: ... } }` passes both write paths under
1161
- exact matching by default.
1248
+ Nested metadata is redacted by default (`filter_nested_metadata`, true since
1249
+ 0.14.0): `metadata: { stripe: { client_secret: ... } }` has `client_secret`
1250
+ stripped on both write paths. Before 0.14.0 the default was false and such
1251
+ values were written intact.
1162
1252
 
1163
1253
  ### Records in metadata
1164
1254
 
@@ -3,7 +3,15 @@ module StandardAudit
3
3
  queue_as { StandardAudit.config.queue_name }
4
4
 
5
5
  def perform
6
- days = StandardAudit.config.retention_days
6
+ config = StandardAudit.config
7
+ if config.retention_misconfigured?
8
+ Rails.logger.warn("[StandardAudit] CleanupJob skipped: retention_days is #{config.retention_days.inspect}, " \
9
+ "not a positive Integer or :indefinite, so no audit logs were deleted")
10
+ return
11
+ end
12
+
13
+ # nil and :indefinite both mean keep forever.
14
+ days = config.retention_window_days
7
15
  return unless days
8
16
 
9
17
  cutoff = days.days.ago
@@ -19,7 +19,10 @@ StandardAudit.configure(baseline: true) do |config|
19
19
  # config.target_extractor = ->(payload) { payload[:target] }
20
20
 
21
21
  # Scope extractor from notification payload
22
- # config.scope_extractor = ->(payload) { payload[:scope] }
22
+ # config.scope_extractor = ->(payload) {
23
+ # scope = payload[:scope]
24
+ # scope if scope.respond_to?(:to_global_id) # only a record can be a scope
25
+ # }
23
26
 
24
27
  # Multi-tenancy: include StandardAudit::AuditScope in your tenant model
25
28
  # to add a scoped_audit_logs association.
@@ -56,10 +59,10 @@ StandardAudit.configure(baseline: true) do |config|
56
59
  # can keep the handful of real audit keys it would otherwise swallow:
57
60
  # config.sensitive_key_exceptions = %i[input_tokens output_tokens]
58
61
  #
59
- # Descend into nested Hashes when redacting. Off by default because it
60
- # changes what gets written; `metadata: { stripe: { client_secret: ... } }`
61
- # is NOT redacted unless you turn this on. Run the dry run first.
62
- # config.filter_nested_metadata = true
62
+ # Redaction descends into nested Hashes by default (since 0.14.0), so
63
+ # `metadata: { stripe: { client_secret: ... } }` is redacted. Turning it off
64
+ # writes nested secrets intact; run the dry run first if you do.
65
+ # config.filter_nested_metadata = false
63
66
 
64
67
  # Run audit log creation in background job
65
68
  # config.async = false
@@ -71,7 +74,9 @@ StandardAudit.configure(baseline: true) do |config|
71
74
  # GDPR: metadata keys to strip on anonymization
72
75
  # config.anonymizable_metadata_keys = %i[email name ip_address]
73
76
 
74
- # Data retention (schedule StandardAudit::CleanupJob to enforce)
77
+ # Data retention (schedule StandardAudit::CleanupJob to enforce). nil keeps
78
+ # rows forever and the production health advisory warns; :indefinite keeps
79
+ # them forever as a recorded decision.
75
80
  # config.retention_days = nil
76
81
 
77
82
  # -- Write-time hooks -------------------------------------------------------
@@ -0,0 +1,136 @@
1
+ module StandardAudit
2
+ # Records an authentication failure: a request refused before any operation
3
+ # runs (an invalid or revoked token, a missing scope, a locked account). No
4
+ # operation executes, so the operation audit contract (`audits` / `audit!`,
5
+ # the catalogue) never sees these. The controller records them directly.
6
+ # Extracted from three host copies (nutripod-web, luminality-web,
7
+ # sidekick-web) of the same concern.
8
+ #
9
+ # In a controller, include the concern and call the private helper from your
10
+ # `rescue_from` handlers:
11
+ #
12
+ # class Api::BaseController < ActionController::API
13
+ # include StandardAudit::AuthFailure::Controller
14
+ # end
15
+ #
16
+ # record_auth_failure("auth.token_invalid", "AUTH_001")
17
+ # record_auth_failure("auth.token_revoked", "AUTH_003", actor: session)
18
+ #
19
+ # Outside a controller (a hook that has the request but no controller of
20
+ # yours), call the module directly:
21
+ #
22
+ # StandardAudit::AuthFailure.record("auth.social_denied", "AUTH_009", request: request)
23
+ # StandardAudit::AuthFailure.record("auth.social_denied", "AUTH_009", path: "/auth/callback")
24
+ #
25
+ # == What lands in the row
26
+ #
27
+ # Metadata is an allow-list: `error_code`, `path`, `method` (when a request
28
+ # is given), plus exactly the extra keywords the caller passes. Nothing is
29
+ # read from params, headers or the exception message. This is the one path
30
+ # where a live credential (the rejected token) is guaranteed to be in scope,
31
+ # and audit rows are append-only, so callers must never pass it.
32
+ # `sensitive_keys` redaction is the backstop, not the control.
33
+ #
34
+ # With a `request:`, the request id, IP and user agent go into the row's
35
+ # columns explicitly, rather than through the Current resolvers, and the user
36
+ # agent is truncated to USER_AGENT_LIMIT so an over-long header cannot make
37
+ # the insert fail and cost the row.
38
+ #
39
+ # The actor is usually nil (authentication failed). A value that is not
40
+ # GlobalID-able (a String, an id) is dropped rather than written.
41
+ #
42
+ # The write never raises (`raise: false`): the request is already being
43
+ # refused, and an audit failure must not turn a 401 into a 500. Failed writes
44
+ # go to `config.error_reporter` like any other non-raising record.
45
+ #
46
+ # With `config.log_auth_failures = true` each failure also emits one
47
+ # structured `Rails.logger.warn` line, for alerting off the log stream. Its
48
+ # extras pass through MetadataFilter like the row's.
49
+ module AuthFailure
50
+ # Hosts commonly cap `audit_logs.user_agent` at 1024 characters.
51
+ USER_AGENT_LIMIT = 1024
52
+
53
+ module_function
54
+
55
+ # @return [StandardAudit::AuditLog, nil] the row, or nil when the write
56
+ # failed or auditing is disabled.
57
+ def record(event_type, error_code, actor: nil, request: nil, path: nil, **extra_metadata)
58
+ audit_actor = actor.respond_to?(:to_global_id) ? actor : nil
59
+ path ||= request&.path
60
+ ip = request && client_ip(request)
61
+
62
+ safely_log(event_type, error_code, actor, audit_actor, path, ip, extra_metadata) if StandardAudit.config.log_auth_failures
63
+
64
+ metadata = {
65
+ "error_code" => error_code,
66
+ "path" => path,
67
+ "method" => request&.request_method,
68
+ **extra_metadata.transform_keys(&:to_s)
69
+ }.compact
70
+
71
+ context = {}
72
+ if request
73
+ context[:request_id] = request.request_id
74
+ context[:ip_address] = ip
75
+ context[:user_agent] = request.user_agent&.slice(0, USER_AGENT_LIMIT)
76
+ end
77
+
78
+ StandardAudit.record(event_type, actor: audit_actor, metadata: metadata, raise: false, **context.compact)
79
+ rescue => e
80
+ # `raise: false` only covers the write. Building the row's inputs and the
81
+ # log line happens first, and must not turn the refusal into a 500 either.
82
+ StandardAudit.report_write_error(e, event_type, source: "StandardAudit::AuthFailure")
83
+ nil
84
+ end
85
+
86
+ # `request.remote_ip` raises ActionDispatch::RemoteIp::IpSpoofAttackError
87
+ # for a request whose Client-IP and X-Forwarded-For disagree (with
88
+ # `check_ip_spoofing`, the default). A client controls both headers, so
89
+ # fall back to Rack's `request.ip`, which ignores Client-IP, rather than
90
+ # fail.
91
+ def client_ip(request)
92
+ request.remote_ip
93
+ rescue => e
94
+ raise unless e.class.name == "ActionDispatch::RemoteIp::IpSpoofAttackError"
95
+
96
+ request.ip
97
+ end
98
+ private_class_method :client_ip
99
+
100
+ # The log line is secondary: if it fails, report that and still write the
101
+ # row, which is the record that matters.
102
+ def safely_log(event_type, *args)
103
+ log(event_type, *args)
104
+ rescue => e
105
+ StandardAudit.report_write_error(e, event_type, source: "StandardAudit::AuthFailure log line")
106
+ end
107
+ private_class_method :safely_log
108
+
109
+ # The extras pass through MetadataFilter first, so the log line gets the
110
+ # same `sensitive_keys` backstop as the row.
111
+ def log(event_type, error_code, actor, audit_actor, path, ip, extra_metadata)
112
+ return unless defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
113
+
114
+ Rails.logger.warn({
115
+ event: event_type,
116
+ error_code: error_code,
117
+ path: path,
118
+ ip: ip,
119
+ actor: audit_actor ? audit_actor.to_global_id.to_s : actor&.to_s,
120
+ timestamp: Time.now.utc.iso8601,
121
+ **StandardAudit::MetadataFilter.call(extra_metadata).to_h.transform_keys(&:to_sym)
122
+ }.compact)
123
+ end
124
+ private_class_method :log
125
+
126
+ # The controller concern. `record_auth_failure` is private, so it never
127
+ # becomes an action.
128
+ module Controller
129
+ private
130
+
131
+ def record_auth_failure(event_type, error_code, actor: nil, **extra_metadata)
132
+ StandardAudit::AuthFailure.record(event_type, error_code, actor: actor, request: request, **extra_metadata)
133
+ end
134
+ end
135
+ end
136
+ end
@@ -19,10 +19,15 @@ module StandardAudit
19
19
  # the probe or blocking a deploy. Only a *critical* check failure returns
20
20
  # 503, and this check is never critical.
21
21
  #
22
- # "Production" is ENV["APP_ENVIRONMENT"] == "production" when that var is
23
- # set (so staging — which also runs RAILS_ENV=production — is not flagged);
22
+ # "Production" follows the estate's APP_ENVIRONMENT convention (shared
23
+ # with standard_id, standard_health and standard_singpass): when that var
24
+ # is set, only staging, preview, development and test are non-production
25
+ # (so staging, which also runs RAILS_ENV=production, is not flagged) and
26
+ # any other value is production, so a typo never silences the advisory;
24
27
  # otherwise it falls back to Rails.env.production?.
25
28
  class Retention
29
+ NON_PRODUCTION_APP_ENVIRONMENTS = %w[staging preview development test].freeze
30
+
26
31
  def initialize(name: :audit_retention, critical: false)
27
32
  @name = name
28
33
  @critical = critical
@@ -33,23 +38,26 @@ module StandardAudit
33
38
  return { status: :ok, detail: "retention advisory only runs on production deployments" }
34
39
  end
35
40
 
36
- days = StandardAudit.config.retention_days
41
+ config = StandardAudit.config
42
+ return { status: :ok, retention: "indefinite" } if config.retention_indefinite?
43
+
44
+ days = config.retention_window_days
37
45
  return { status: :ok, retention_days: days } if days
38
46
 
39
47
  {
40
48
  status: :warn,
41
49
  message: "audit_logs retention is unbounded on production. Set " \
42
50
  "STANDARD_AUDIT_RETENTION_DAYS (or config.retention_days) and schedule " \
43
- "StandardAudit::CleanupJob, or treat indefinite retention as a deliberate " \
44
- "compliance decision."
51
+ "StandardAudit::CleanupJob, or record indefinite retention as a deliberate " \
52
+ "compliance decision with config.retention_days = :indefinite."
45
53
  }
46
54
  end
47
55
 
48
56
  private
49
57
 
50
58
  def production?
51
- app_env = ENV["APP_ENVIRONMENT"].to_s
52
- return app_env == "production" unless app_env.empty?
59
+ app_env = ENV["APP_ENVIRONMENT"].to_s.strip
60
+ return !NON_PRODUCTION_APP_ENVIRONMENTS.include?(app_env) unless app_env.empty?
53
61
 
54
62
  defined?(Rails) && Rails.respond_to?(:env) && Rails.env.production?
55
63
  end
@@ -1,5 +1,8 @@
1
1
  module StandardAudit
2
2
  class Configuration
3
+ # `config.retention_days = :indefinite`: keep audit rows forever, on purpose.
4
+ INDEFINITE_RETENTION = :indefinite
5
+
3
6
  attr_accessor :async, :queue_name, :enabled,
4
7
  :actor_extractor, :target_extractor, :scope_extractor,
5
8
  :current_actor_resolver, :current_request_id_resolver,
@@ -7,7 +10,7 @@ module StandardAudit
7
10
  :current_session_id_resolver, :current_scope_resolver,
8
11
  :before_write,
9
12
  :sensitive_keys, :sensitive_key_patterns,
10
- :sensitive_key_exceptions, :filter_nested_metadata,
13
+ :sensitive_key_exceptions, :filter_nested_metadata, :log_auth_failures,
11
14
  :dereference_record_metadata,
12
15
  :metadata_builder, :before_checksum_hooks,
13
16
  :anonymizable_metadata_keys, :retention_days,
@@ -105,18 +108,27 @@ module StandardAudit
105
108
  # `sensitive_key_exceptions = %i[input_tokens output_tokens]`.
106
109
  @sensitive_key_exceptions = []
107
110
 
108
- # When true, redaction descends into nested Hashes (and Hashes inside
109
- # Arrays), so `metadata: { stripe: { client_secret: … } }` is caught.
110
- # Defaults to false: it changes what gets written, and audit rows are
111
- # append-only. Reserved keys are never descended into.
112
- @filter_nested_metadata = false
111
+ # When true (the default since 0.14.0), redaction descends into nested
112
+ # Hashes (and Hashes inside Arrays), so
113
+ # `metadata: { stripe: { client_secret: … } }` is caught. Reserved keys
114
+ # are never descended into. Before 0.14.0 it defaulted to false, which
115
+ # wrote nested secrets intact; every app in the estate had already
116
+ # turned it on. Set false only to keep pre-0.14.0 rows comparable, after
117
+ # a dry run (`rake standard_audit:sensitive_keys:dry_run`).
118
+ @filter_nested_metadata = true
119
+
120
+ # When true, StandardAudit::AuthFailure.record also emits one structured
121
+ # `Rails.logger.warn` line per failure (event, error_code, path, ip,
122
+ # actor, timestamp), for alerting off the log stream. The audit row is
123
+ # written either way.
124
+ @log_auth_failures = false
113
125
 
114
126
  # When true (the default), an ActiveRecord object appearing anywhere in
115
127
  # audit metadata is replaced by a reference —
116
128
  # `{ "gid" => …, "type" => …, "id" => … }` — instead of being serialised
117
129
  # with all of its attributes. See StandardAudit::RecordReference.
118
130
  #
119
- # Defaults to the SAFE behaviour, unlike `filter_nested_metadata`,
131
+ # Defaults to the SAFE behaviour, like `filter_nested_metadata`,
120
132
  # because the values this catches are ones no host asked to record:
121
133
  # `password_digest`, `token_digest`, `lookup_hash` and PKCE verifiers
122
134
  # arriving as a side effect of a payload carrying `account:` or
@@ -235,9 +247,31 @@ module StandardAudit
235
247
  # code change. Unset/blank/non-positive => nil (infinite retention, the
236
248
  # compliance-safe default that never auto-deletes). A host app can still
237
249
  # override with `config.retention_days = N` in its initializer.
250
+ #
251
+ # `:indefinite` (or STANDARD_AUDIT_RETENTION_DAYS=indefinite) also keeps
252
+ # rows forever, but records that as a decision: the production retention
253
+ # advisory (StandardAudit::Checks::Retention) only warns on nil, which
254
+ # means nobody chose.
238
255
  @retention_days = self.class.retention_days_from_env
239
256
  end
240
257
 
258
+ # The retention window as a positive Integer of days, or nil when rows are
259
+ # kept forever (`retention_days` nil or `:indefinite`). What cleanup reads.
260
+ def retention_window_days
261
+ retention_days.is_a?(Integer) && retention_days.positive? ? retention_days : nil
262
+ end
263
+
264
+ # True when the host chose to keep audit rows forever.
265
+ def retention_indefinite?
266
+ retention_days == INDEFINITE_RETENTION
267
+ end
268
+
269
+ # True for a `retention_days` that is neither a window nor keep-forever,
270
+ # e.g. a String "90" or 0. CleanupJob keeps every row then, and says so.
271
+ def retention_misconfigured?
272
+ !retention_days.nil? && !retention_indefinite? && retention_window_days.nil?
273
+ end
274
+
241
275
  # Reads `::Current.<name>` when a top-level `Current` exists and responds
242
276
  # to it; nil otherwise. The building block of the default resolvers.
243
277
  def self.current_attribute(name)
@@ -246,11 +280,13 @@ module StandardAudit
246
280
  ::Current.public_send(name)
247
281
  end
248
282
 
249
- # Parses STANDARD_AUDIT_RETENTION_DAYS into a positive Integer, or nil when
250
- # unset/blank/zero/negative/non-numeric (=> infinite retention).
283
+ # Parses STANDARD_AUDIT_RETENTION_DAYS into a positive Integer, `:indefinite`
284
+ # for the literal "indefinite", or nil when unset/blank/zero/negative/
285
+ # non-numeric (=> infinite retention, undecided).
251
286
  def self.retention_days_from_env
252
287
  raw = ENV["STANDARD_AUDIT_RETENTION_DAYS"]
253
288
  return nil if raw.nil? || raw.strip.empty?
289
+ return INDEFINITE_RETENTION if raw.strip.casecmp?(INDEFINITE_RETENTION.to_s)
254
290
 
255
291
  days = Integer(raw, exception: false)
256
292
  days&.positive? ? days : nil
@@ -14,8 +14,8 @@ module StandardAudit
14
14
  #
15
15
  # None of the three existing defences fired: `sensitive_keys` matches keys
16
16
  # exactly and the secrets are attributes *underneath* `account:`;
17
- # `filter_nested_metadata` is off by default so the filter never descended
18
- # to them; and `account:` does not look sensitive at the top level.
17
+ # `filter_nested_metadata` was off by default (until 0.14.0) so the filter
18
+ # never descended to them; and `account:` does not look sensitive at the top level.
19
19
  #
20
20
  # Key-based redaction is the wrong tool here — the leak is not "this key is
21
21
  # sensitive", it is "this value is an entire database row". So the value is
@@ -0,0 +1,133 @@
1
+ require "standard_audit"
2
+
3
+ # Catalogue coverage: every catalogue action is asserted by a positive
4
+ # `have_audited("…")` somewhere in the suite.
5
+ #
6
+ # require "standard_audit/rspec/coverage"
7
+ #
8
+ # RSpec.describe "Audit catalogue coverage" do
9
+ # it_behaves_like "standard_audit catalogue coverage",
10
+ # pending: %w[grant.pass_scheduled] # catalogued, not written yet
11
+ # end
12
+ #
13
+ # It is a STATIC scan of the spec files, not a runtime tally. Four of the five
14
+ # estate apps run their suites under parallel_rspec, where no single process
15
+ # sees every example, and a partial run (one file) would always look
16
+ # uncovered. A literal `expect { … }.to have_audited("order.created")` in a
17
+ # green suite means that example wrote the row, which is what coverage needs.
18
+ # Negated assertions (`not_to have_audited`) and commented-out lines do not
19
+ # count, and neither do action names built at runtime (interpolated strings,
20
+ # constants): list those under `pending:` with a comment, or assert them with
21
+ # a literal. The scan is line-based, so an assertion inside a heredoc or string
22
+ # would still count; don't keep one there.
23
+ #
24
+ # This replaces the hand-maintained `CATALOGUE_ACTIONS` copies in
25
+ # `spec/support/audit_log_coverage.rb` and the specs that kept them in sync
26
+ # with the real catalogue.
27
+ #
28
+ # Options (all optional):
29
+ #
30
+ # catalogue: the actions that must be covered, as an Array or a callable.
31
+ # Defaults to `config.audit_catalogue`. Pass a slice (e.g.
32
+ # `-> { AuditCatalogue::OPERATION_ACTIONS }`) to scope it.
33
+ # pending: catalogue actions allowed to have no assertion yet. Each must be
34
+ # in the catalogue, and one that gains an assertion must be
35
+ # removed, so the list only ever shrinks.
36
+ # specs: glob(s) of spec files to scan, relative to Rails.root (or the
37
+ # working directory). Defaults to "spec/**/*_spec.rb".
38
+ # root: the directory `specs:` is relative to. Defaults to Rails.root.
39
+ module StandardAudit
40
+ module RSpec
41
+ module Coverage
42
+ # A positive have_audited with a literal action: `.to have_audited("x")`,
43
+ # `.to(have_audited("x"))`, or a compound `.and have_audited("x")`.
44
+ ASSERTION = /(?:\.to|\.and)\s*\(?\s*have_audited\(\s*(["'])([^"'#\\]+)\1/
45
+ DEFAULT_SPECS = "spec/**/*_spec.rb".freeze
46
+
47
+ Report = Struct.new(:catalogue, :covered, :uncovered, :unknown_pending, :stale_pending, keyword_init: true) do
48
+ def ok?
49
+ uncovered.empty? && unknown_pending.empty? && stale_pending.empty?
50
+ end
51
+ end
52
+
53
+ module_function
54
+
55
+ # action => ["path:line", ...] for every positive literal assertion.
56
+ def covered_actions(specs: DEFAULT_SPECS, root: default_root)
57
+ files = Array(specs).flat_map { |glob| Dir.glob(File.expand_path(glob, root)) }.uniq.sort
58
+ files.each_with_object(Hash.new { |h, k| h[k] = [] }) do |file, covered|
59
+ File.foreach(file).with_index(1) do |line, number|
60
+ next if line.lstrip.start_with?("#")
61
+
62
+ line.scan(ASSERTION) { |_quote, action| covered[action] << "#{relative(file, root)}:#{number}" }
63
+ end
64
+ end
65
+ end
66
+
67
+ def report(catalogue: nil, pending: [], specs: DEFAULT_SPECS, root: default_root)
68
+ catalogue = resolve(catalogue || StandardAudit.config.audit_catalogue).map(&:to_s).uniq
69
+ pending = resolve(pending).map(&:to_s)
70
+ covered = covered_actions(specs: specs, root: root)
71
+
72
+ Report.new(
73
+ catalogue: catalogue,
74
+ covered: covered,
75
+ uncovered: catalogue - pending - covered.keys,
76
+ unknown_pending: pending - catalogue,
77
+ stale_pending: pending & covered.keys
78
+ )
79
+ end
80
+
81
+ def resolve(value)
82
+ value = value.call if value.respond_to?(:call)
83
+ Array(value)
84
+ end
85
+
86
+ def default_root
87
+ defined?(Rails) && Rails.respond_to?(:root) && Rails.root ? Rails.root.to_s : Dir.pwd
88
+ end
89
+
90
+ def relative(file, root)
91
+ file.delete_prefix("#{root}/")
92
+ end
93
+ end
94
+ end
95
+ end
96
+
97
+ RSpec.shared_examples "standard_audit catalogue coverage" do |options = {}|
98
+ coverage = StandardAudit::RSpec::Coverage
99
+
100
+ let(:coverage_report) do
101
+ coverage.report(
102
+ catalogue: options[:catalogue],
103
+ pending: options.fetch(:pending, []),
104
+ specs: options.fetch(:specs, coverage::DEFAULT_SPECS),
105
+ root: options.fetch(:root) { coverage.default_root }
106
+ )
107
+ end
108
+
109
+ it "has a catalogue to check" do
110
+ expect(coverage_report.catalogue).not_to be_empty,
111
+ "No catalogue actions to check. Set config.audit_catalogue or pass `catalogue:`; " \
112
+ "otherwise every other example here passes against an empty set."
113
+ end
114
+
115
+ it "asserts every catalogue action with have_audited" do
116
+ expect(coverage_report.uncovered).to be_empty,
117
+ "These catalogue actions have no `expect { … }.to have_audited(\"…\")` in the suite:\n " \
118
+ "#{coverage_report.uncovered.sort.join("\n ")}\n" \
119
+ "Add an example that writes each one, or list it under `pending:` with the reason."
120
+ end
121
+
122
+ it "lists only catalogue actions as pending" do
123
+ expect(coverage_report.unknown_pending).to be_empty,
124
+ "These `pending:` actions are not in the catalogue: #{coverage_report.unknown_pending.sort.join(", ")}"
125
+ end
126
+
127
+ it "has no pending action that is already covered" do
128
+ stale = coverage_report.stale_pending.sort
129
+ expect(stale).to be_empty,
130
+ "These `pending:` actions now have an assertion, so remove them from `pending:`:\n " \
131
+ "#{stale.map { |a| "#{a} (#{coverage_report.covered[a].first})" }.join("\n ")}"
132
+ end
133
+ end
@@ -42,7 +42,17 @@ require "standard_audit/rspec/baseline"
42
42
  #
43
43
  # Intentionally `before(:example)` rather than `after(:example)` so the
44
44
  # reset always runs even when a previous example aborted in an after hook.
45
+ #
46
+ # Before the first example, `before(:suite)` checks that no plain
47
+ # `StandardAudit.configure` ran at boot (StandardAudit.verify_baseline_configuration!)
48
+ # and raises StandardAudit::BaselineConfigurationError, naming the call
49
+ # sites, if one did. Such a block is applied once and then silently dropped by
50
+ # the first reset, which is exactly the failure described above.
45
51
  RSpec.configure do |config|
52
+ config.before(:suite) do
53
+ StandardAudit.verify_baseline_configuration!
54
+ end
55
+
46
56
  config.before(:example) do
47
57
  Thread.current[:standard_audit_batch] = nil
48
58
  StandardAudit.reset_configuration!
@@ -1,3 +1,3 @@
1
1
  module StandardAudit
2
- VERSION = "0.13.3"
2
+ VERSION = "0.14.1"
3
3
  end
@@ -1,6 +1,12 @@
1
1
  require "standard_audit/version"
2
2
 
3
3
  module StandardAudit
4
+ # Raised by `verify_baseline_configuration!` (see `configure`).
5
+ class BaselineConfigurationError < StandardError; end
6
+
7
+ # How many distinct plain `configure` call sites are remembered.
8
+ UNBASELINED_SITE_LIMIT = 20
9
+
4
10
  # From this instant (by each row's `created_at`) new rows get the canonical
5
11
  # checksum and are verified strictly with it; earlier rows are legacy. See
6
12
  # StandardAudit::Checksum and fundbright/delivery-ops#689. Every host must
@@ -22,6 +28,7 @@ require "standard_audit/operation"
22
28
  require "standard_audit/auditable"
23
29
  require "standard_audit/audit_scope"
24
30
  require "standard_audit/checks/retention"
31
+ require "standard_audit/auth_failure"
25
32
 
26
33
  module StandardAudit
27
34
  # Metadata keys owned internally by StandardAudit. Never filtered by
@@ -60,11 +67,49 @@ module StandardAudit
60
67
  def configure(baseline: false, &block)
61
68
  return config unless block
62
69
 
63
- @baseline_configuration = block if baseline
70
+ if baseline
71
+ @baseline_configuration = block
72
+ else
73
+ site = caller_locations(1, 1).first.to_s
74
+ # Bounded: only boot-time calls matter, but a suite that configures
75
+ # inside thousands of examples would otherwise grow this forever.
76
+ sites = unbaselined_configure_sites
77
+ sites << site unless sites.include?(site) || sites.size >= UNBASELINED_SITE_LIMIT
78
+ end
64
79
  block.call(config)
65
80
  config
66
81
  end
67
82
 
83
+ # Where a plain (non-baseline) `configure` block was applied, oldest first,
84
+ # each site once, at most UNBASELINED_SITE_LIMIT of them.
85
+ # `reset_configuration!` cannot replay these, so a plain block that runs at
86
+ # boot is lost after the rspec plugin's first per-example reset.
87
+ # `standard_audit/rspec` reads this when the suite starts (see
88
+ # `verify_baseline_configuration!`).
89
+ def unbaselined_configure_sites
90
+ @unbaselined_configure_sites ||= []
91
+ end
92
+
93
+ # Raises when configuration applied so far would not survive
94
+ # `reset_configuration!`: any plain `configure` block, whether or not a
95
+ # baseline exists too. `standard_audit/rspec` calls it from
96
+ # `before(:suite)`, after the app has booted and before the first reset, so
97
+ # the mistake fails the suite instead of quietly reverting to gem defaults
98
+ # (and dropping `before_checksum` hooks) from the second example on.
99
+ # Configuring inside an example is fine and isn't checked.
100
+ def verify_baseline_configuration!
101
+ sites = unbaselined_configure_sites
102
+ return true if sites.empty?
103
+
104
+ raise BaselineConfigurationError,
105
+ "StandardAudit.configure was called without `baseline: true` at:\n " \
106
+ "#{sites.uniq.join("\n ")}\n" \
107
+ "standard_audit/rspec resets the configuration before every example and " \
108
+ "replays only the `configure(baseline: true)` block, so these settings are " \
109
+ "lost after the first example. Move them into that one baseline block " \
110
+ "(config/initializers/standard_audit.rb)."
111
+ end
112
+
68
113
  def config
69
114
  @configuration ||= Configuration.new
70
115
  end
@@ -179,7 +224,12 @@ module StandardAudit
179
224
  # `report_error`. Used by the subscribers, which must never let an audit
180
225
  # failure break the instrumented code path, and by `record(raise: false)`.
181
226
  def report_write_error(error, event_type, **context)
182
- Rails.logger.error("[StandardAudit] Error creating audit log for #{event_type}: #{error.class}: #{error.message}")
227
+ begin
228
+ Rails.logger.error("[StandardAudit] Error creating audit log for #{event_type}: #{error.class}: #{error.message}")
229
+ rescue StandardError
230
+ # A broken logger must not turn a swallowed failure into a raised one;
231
+ # the reporter below still runs.
232
+ end
183
233
  report_error(error, { config.audit_error_context_key => event_type, **context })
184
234
  end
185
235
 
@@ -196,7 +246,11 @@ module StandardAudit
196
246
  nil
197
247
  rescue => report_failure
198
248
  message = "[StandardAudit] Error reporting audit failure: #{report_failure.class}: #{report_failure.message}"
199
- Rails.logger&.error(message) if defined?(Rails) && Rails.respond_to?(:logger)
249
+ begin
250
+ Rails.logger&.error(message) if defined?(Rails) && Rails.respond_to?(:logger)
251
+ rescue StandardError
252
+ # Nothing left to tell: the reporter and the logger both failed.
253
+ end
200
254
  nil
201
255
  end
202
256
 
@@ -222,6 +276,7 @@ module StandardAudit
222
276
  # specs and for a host that needs a genuinely pristine configuration.
223
277
  def clear_baseline_configuration!
224
278
  @baseline_configuration = nil
279
+ @unbaselined_configure_sites = nil
225
280
  end
226
281
 
227
282
  def baseline_configured?
@@ -10,6 +10,11 @@ module StandardAudit
10
10
  def resolve_days!(task_name, arg)
11
11
  raw = arg.presence || StandardAudit.config.retention_days
12
12
 
13
+ if raw == StandardAudit::Configuration::INDEFINITE_RETENTION
14
+ abort "standard_audit:#{task_name}: retention_days is :indefinite (keep forever). " \
15
+ "Pass days explicitly (rake \"standard_audit:#{task_name}[90]\") to run it anyway."
16
+ end
17
+
13
18
  if raw.nil?
14
19
  abort "standard_audit:#{task_name}: no retention window. Pass days " \
15
20
  "(rake \"standard_audit:#{task_name}[90]\") or set config.retention_days " \
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: standard_audit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.13.3
4
+ version: 0.14.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -107,6 +107,7 @@ files:
107
107
  - lib/standard_audit.rb
108
108
  - lib/standard_audit/audit_scope.rb
109
109
  - lib/standard_audit/auditable.rb
110
+ - lib/standard_audit/auth_failure.rb
110
111
  - lib/standard_audit/checks/retention.rb
111
112
  - lib/standard_audit/checksum.rb
112
113
  - lib/standard_audit/checksum/key_order_search.rb
@@ -120,6 +121,7 @@ files:
120
121
  - lib/standard_audit/reference_preloading.rb
121
122
  - lib/standard_audit/rspec.rb
122
123
  - lib/standard_audit/rspec/baseline.rb
124
+ - lib/standard_audit/rspec/coverage.rb
123
125
  - lib/standard_audit/rspec/matchers.rb
124
126
  - lib/standard_audit/rspec/operation.rb
125
127
  - lib/standard_audit/sensitive_keys_dry_run.rb