standard_audit 0.13.3 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +71 -0
- data/README.md +97 -10
- data/app/jobs/standard_audit/cleanup_job.rb +9 -1
- data/lib/generators/standard_audit/install/templates/initializer.rb.erb +11 -6
- data/lib/standard_audit/auth_failure.rb +136 -0
- data/lib/standard_audit/checks/retention.rb +6 -3
- data/lib/standard_audit/configuration.rb +45 -9
- data/lib/standard_audit/record_reference.rb +2 -2
- data/lib/standard_audit/rspec/coverage.rb +133 -0
- data/lib/standard_audit/rspec.rb +10 -0
- data/lib/standard_audit/version.rb +1 -1
- data/lib/standard_audit.rb +58 -3
- data/lib/tasks/standard_audit_tasks.rake +5 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 05062631e84ab99965b101b5381535c345929c0282cb4c0ca645f2636a5d42b3
|
|
4
|
+
data.tar.gz: 9acf2c4b4f8059ca4290655e065ce2e0fbd093a5747b87658ea426ec2b6d32f6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 48c43519e8dfff7d04ea521ca6700cd85b3ac9096f89288b4c2bdc3a94a1b51d1c941fa6c9f7278e06672e9f1318805b0d0c34e0da282b65fcd85e67cf07ee0f
|
|
7
|
+
data.tar.gz: 59d4d4dbba01407436d16c6c6007b09b63ac0024f58c7f44d8880dac7f46f38baf279c5142264e2e03a150fec1ca1dcba0ce538d8105fa2c96a0c2fb29979cc2
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,77 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.14.0] - 2026-10-22
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`config.retention_days = :indefinite`** (or
|
|
15
|
+
`STANDARD_AUDIT_RETENTION_DAYS=indefinite`) records keeping audit rows
|
|
16
|
+
forever as a decision. Rows are kept exactly as with `nil`, but the production
|
|
17
|
+
advisory `StandardAudit::Checks::Retention` reports `ok` with
|
|
18
|
+
`retention: "indefinite"` instead of warning. Hosts that set a huge day count
|
|
19
|
+
to quiet the advisory (nutripod-web uses 36,500) should switch: a day count is
|
|
20
|
+
a window, and `CleanupJob` acts on it. `CleanupJob` does nothing for
|
|
21
|
+
`:indefinite`, and `standard_audit:cleanup`/`:archive` abort unless given days.
|
|
22
|
+
New readers: `Configuration#retention_window_days`, `#retention_indefinite?`
|
|
23
|
+
and `#retention_misconfigured?`; `CleanupJob` warns and deletes nothing for a
|
|
24
|
+
value that is neither a positive Integer nor `:indefinite` (e.g. `"90"`).
|
|
25
|
+
- **`standard_audit/rspec` fails loudly when configuration would be lost.**
|
|
26
|
+
A `before(:suite)` hook calls the new `StandardAudit.verify_baseline_configuration!`,
|
|
27
|
+
which raises `StandardAudit::BaselineConfigurationError`, naming each call
|
|
28
|
+
site, if any plain `StandardAudit.configure` (without `baseline: true`) ran
|
|
29
|
+
at boot. The per-example reset replays only the baseline block, so those
|
|
30
|
+
settings, `before_checksum` hooks included, disappeared from the second
|
|
31
|
+
example on, and the specs that would have noticed passed vacuously.
|
|
32
|
+
`configure` calls inside an example are unaffected. **Upgrading:** move any
|
|
33
|
+
boot-time plain `configure` into the single `configure(baseline: true)` block.
|
|
34
|
+
- **`StandardAudit::AuthFailure`** records authentication failures: requests
|
|
35
|
+
refused before any operation runs. Include
|
|
36
|
+
`StandardAudit::AuthFailure::Controller` for a private
|
|
37
|
+
`record_auth_failure(event_type, error_code, actor: nil, **extra)`, or call
|
|
38
|
+
`StandardAudit::AuthFailure.record(..., request:/path:)` outside a controller.
|
|
39
|
+
It writes allow-listed metadata (`error_code`, `path`, `method`, plus the
|
|
40
|
+
caller's extras), the request id/IP/user agent as columns (user agent
|
|
41
|
+
truncated to 1024 characters), drops a non-GlobalID actor, and never raises,
|
|
42
|
+
including for a request whose forwarded-IP headers trip Rails' spoofing
|
|
43
|
+
check (it falls back to Rack's `request.ip`, which ignores `Client-IP`).
|
|
44
|
+
`config.log_auth_failures = true` (default `false`) adds a structured warn
|
|
45
|
+
line whose extras pass through `MetadataFilter` like the row's; if the log
|
|
46
|
+
line fails, the row is still written. It replaces the hand-copied
|
|
47
|
+
`AuditAuthFailure` concerns in nutripod-web, luminality-web and sidekick-web.
|
|
48
|
+
- **`"standard_audit catalogue coverage"`** shared example
|
|
49
|
+
(`require "standard_audit/rspec/coverage"`): every catalogue action must be
|
|
50
|
+
asserted by a positive literal `have_audited("…")` somewhere in the suite,
|
|
51
|
+
apart from an explicit `pending:` list that may only shrink. It is a static
|
|
52
|
+
scan of the spec files, so it behaves the same under `parallel_rspec` and in
|
|
53
|
+
partial runs. Commented-out lines don't count. Predicates:
|
|
54
|
+
`StandardAudit::RSpec::Coverage.covered_actions` and `.report`.
|
|
55
|
+
It replaces the hand-synced `CATALOGUE_ACTIONS` copies in the
|
|
56
|
+
apps' `spec/support/audit_log_coverage.rb`.
|
|
57
|
+
|
|
58
|
+
### Fixed
|
|
59
|
+
|
|
60
|
+
- A logger that raises no longer turns a swallowed audit failure into a raised
|
|
61
|
+
one. `report_write_error` and `report_error`'s own fallback both guard their
|
|
62
|
+
`Rails.logger` call, so `record(raise: false)`, the subscribers and
|
|
63
|
+
`AuthFailure.record` keep their never-raise guarantee.
|
|
64
|
+
|
|
65
|
+
### Changed
|
|
66
|
+
|
|
67
|
+
- **`config.filter_nested_metadata` now defaults to `true`.** Redaction
|
|
68
|
+
descends into nested Hashes (and Hashes inside Arrays) unless a host turns it
|
|
69
|
+
off, so `metadata: { stripe: { client_secret: … } }` is no longer written
|
|
70
|
+
intact by default. Every rarebit-one consumer already set it to true, so for
|
|
71
|
+
them nothing changes. A host that relied on the old default will have nested
|
|
72
|
+
sensitive keys redacted from now on; to keep the old behaviour, set
|
|
73
|
+
`config.filter_nested_metadata = false` after a dry run
|
|
74
|
+
(`rake "standard_audit:sensitive_keys:dry_run[...]" NESTED=1`).
|
|
75
|
+
`raise_on_audit_write_error` keeps its `false` default: three of the five
|
|
76
|
+
consumers report and swallow failed audit writes on purpose.
|
|
77
|
+
- The README configuration reference and the install generator's commented
|
|
78
|
+
`scope_extractor` example show the record guard the 0.13.3 default has, so a
|
|
79
|
+
host copying them no longer reintroduces FUNDBRIGHT-WEB-41.
|
|
80
|
+
|
|
10
81
|
## [0.13.3] - 2026-10-21
|
|
11
82
|
|
|
12
83
|
### 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
|
-
|
|
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
|
|
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
|
|
@@ -866,7 +951,8 @@ end
|
|
|
866
951
|
|
|
867
952
|
When `APP_ENVIRONMENT == "production"` (falling back to `Rails.env.production?`
|
|
868
953
|
when that var is unset — so staging is not flagged) and `retention_days` is nil,
|
|
869
|
-
the check returns `:warn`.
|
|
954
|
+
the check returns `:warn`. With `:indefinite` it returns `:ok` with
|
|
955
|
+
`retention: "indefinite"`. That rolls `GET /health/ready` up to `:degraded`,
|
|
870
956
|
which is **still HTTP 200** — it surfaces the advisory in the readiness JSON
|
|
871
957
|
without failing the probe or blocking a deploy. The check is duck-typed and has
|
|
872
958
|
no hard dependency on `standard_health`.
|
|
@@ -1138,7 +1224,7 @@ canonical-checksum cutover do not depend on key order; earlier rows may — see
|
|
|
1138
1224
|
config.sensitive_keys += %i[medical_record_number] # extend the built-in defaults
|
|
1139
1225
|
config.sensitive_key_patterns = [/secret/i] # catch a whole family
|
|
1140
1226
|
config.sensitive_key_exceptions = %i[input_tokens] # ...minus the real ones
|
|
1141
|
-
config.filter_nested_metadata = true #
|
|
1227
|
+
config.filter_nested_metadata = true # the default since 0.14.0
|
|
1142
1228
|
```
|
|
1143
1229
|
|
|
1144
1230
|
`sensitive_keys` matches **exactly** on the key name. There is deliberately no
|
|
@@ -1153,12 +1239,13 @@ bin/rails "standard_audit:sensitive_keys:dry_run[secret]" # NESTED=1 to model
|
|
|
1153
1239
|
```
|
|
1154
1240
|
|
|
1155
1241
|
The dry run writes nothing. It reports per key what would be stripped, what
|
|
1156
|
-
would be kept, and nested matches that survive
|
|
1157
|
-
`filter_nested_metadata`
|
|
1242
|
+
would be kept, and nested matches that would survive if
|
|
1243
|
+
`filter_nested_metadata` were off.
|
|
1158
1244
|
|
|
1159
|
-
Nested metadata is
|
|
1160
|
-
|
|
1161
|
-
|
|
1245
|
+
Nested metadata is redacted by default (`filter_nested_metadata`, true since
|
|
1246
|
+
0.14.0): `metadata: { stripe: { client_secret: ... } }` has `client_secret`
|
|
1247
|
+
stripped on both write paths. Before 0.14.0 the default was false and such
|
|
1248
|
+
values were written intact.
|
|
1162
1249
|
|
|
1163
1250
|
### Records in metadata
|
|
1164
1251
|
|
|
@@ -3,7 +3,15 @@ module StandardAudit
|
|
|
3
3
|
queue_as { StandardAudit.config.queue_name }
|
|
4
4
|
|
|
5
5
|
def perform
|
|
6
|
-
|
|
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) {
|
|
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
|
-
#
|
|
60
|
-
#
|
|
61
|
-
#
|
|
62
|
-
# config.filter_nested_metadata =
|
|
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
|
|
@@ -33,15 +33,18 @@ module StandardAudit
|
|
|
33
33
|
return { status: :ok, detail: "retention advisory only runs on production deployments" }
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
config = StandardAudit.config
|
|
37
|
+
return { status: :ok, retention: "indefinite" } if config.retention_indefinite?
|
|
38
|
+
|
|
39
|
+
days = config.retention_window_days
|
|
37
40
|
return { status: :ok, retention_days: days } if days
|
|
38
41
|
|
|
39
42
|
{
|
|
40
43
|
status: :warn,
|
|
41
44
|
message: "audit_logs retention is unbounded on production. Set " \
|
|
42
45
|
"STANDARD_AUDIT_RETENTION_DAYS (or config.retention_days) and schedule " \
|
|
43
|
-
"StandardAudit::CleanupJob, or
|
|
44
|
-
"compliance decision."
|
|
46
|
+
"StandardAudit::CleanupJob, or record indefinite retention as a deliberate " \
|
|
47
|
+
"compliance decision with config.retention_days = :indefinite."
|
|
45
48
|
}
|
|
46
49
|
end
|
|
47
50
|
|
|
@@ -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
|
|
109
|
-
# Arrays), so
|
|
110
|
-
#
|
|
111
|
-
#
|
|
112
|
-
|
|
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,
|
|
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,
|
|
250
|
-
# unset/blank/zero/negative/
|
|
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`
|
|
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
|
data/lib/standard_audit/rspec.rb
CHANGED
|
@@ -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!
|
data/lib/standard_audit.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
4
|
+
version: 0.14.0
|
|
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
|