standard_audit 0.15.1 → 0.15.2
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 +12 -0
- data/README.md +4 -0
- data/app/models/standard_audit/audit_log.rb +49 -3
- data/lib/standard_audit/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0f28d1e0742cc3754f99b25e1242c69c70f72cbf781d2fc696b3be326fccd74c
|
|
4
|
+
data.tar.gz: 06f1dfe7a30578b6f8df0d3a672996802963dcdf9c316af967042b5b8f02648b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 32f665b055842c67a00bb099f4919ae230b0f304f7c493a2ad4b783fa83540099fc7cf2b2259e0c7d08d71024a87af6707548110813062ee795e137b123fa73f
|
|
7
|
+
data.tar.gz: 3a8a1dc2fc568ee1668eb1dcd06fdc00620cb33f202c6b535d955e2b826ca2df0cd08e5ce3f9a214dfdbe741dc1f0bbf6def9add32531d96dabf92b6db2212fe
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.15.2] - 2026-10-30
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- `verify_chain` and `relink_checksums!` now find a parent that sorts **after**
|
|
14
|
+
its child: two concurrent writers, the parent committed first (so the child
|
|
15
|
+
read its checksum as the tip) but stamped `created_at` a few milliseconds
|
|
16
|
+
later. The backward recovery window never held it, so a sound row was
|
|
17
|
+
reported as `:digest_mismatch` and never relinked (3 rows on one production
|
|
18
|
+
host, 2026-10-30). A row that reproduces no earlier digest is now held for
|
|
19
|
+
the next `lookahead` rows (default 64, `lookahead: 0` disables) and counted
|
|
20
|
+
as `:recovered` / relinked when one of them is its parent.
|
|
21
|
+
|
|
10
22
|
## [0.15.1] - 2026-10-28
|
|
11
23
|
|
|
12
24
|
### Fixed
|
data/README.md
CHANGED
|
@@ -1128,6 +1128,10 @@ They have no `previous_checksum`. Verification falls back to the preceding row
|
|
|
1128
1128
|
in the walk and, failing that, searches back through the last `recovery_window`
|
|
1129
1129
|
(default 256) digests for the one that reproduces the row's checksum — which
|
|
1130
1130
|
recovers the true parent of a row that forked, **without re-signing anything**.
|
|
1131
|
+
A parent can also sort *after* its child (two concurrent writers: the parent
|
|
1132
|
+
committed first, so the child read its checksum as the tip, but was stamped a
|
|
1133
|
+
few milliseconds later), so a row that reproduces no earlier digest is held for
|
|
1134
|
+
the next `lookahead` rows (default 64) before it is reported.
|
|
1131
1135
|
This does not weaken tamper detection: a row whose fields were altered
|
|
1132
1136
|
reproduces no candidate's digest. Pass `strict: true` to skip the search and see
|
|
1133
1137
|
every fork as a failure.
|
|
@@ -360,6 +360,13 @@ module StandardAudit
|
|
|
360
360
|
# this window search, so a legacy row that was both forked and
|
|
361
361
|
# reordered stays unverifiable.)
|
|
362
362
|
#
|
|
363
|
+
# A parent can also sort AFTER its child: two concurrent writers, the
|
|
364
|
+
# parent committed first (so the child read its checksum as the tip) but
|
|
365
|
+
# stamped `created_at` a few milliseconds later. The backward window never
|
|
366
|
+
# holds it, so a row that reproduces no earlier digest is held for the next
|
|
367
|
+
# `lookahead` rows and counted as :recovered when one of them is its
|
|
368
|
+
# parent; only then is it a :digest_mismatch. (`lookahead: 0` disables it.)
|
|
369
|
+
#
|
|
363
370
|
# The chain links across the cutover like anywhere else: the first
|
|
364
371
|
# canonical row's parent is the last legacy row's stored checksum.
|
|
365
372
|
#
|
|
@@ -379,7 +386,7 @@ module StandardAudit
|
|
|
379
386
|
# `created_at`. Rows whose two timestamps disagree (a backdated
|
|
380
387
|
# `occurred_at`) can leave a hole rather than a prefix, and a hole is
|
|
381
388
|
# reported — truthfully, since rows really are missing.
|
|
382
|
-
def self.verify_chain(scope: nil, batch_size: 1000, recovery_window: 256, strict: false,
|
|
389
|
+
def self.verify_chain(scope: nil, batch_size: 1000, recovery_window: 256, lookahead: 64, strict: false,
|
|
383
390
|
key_order_search_limit: StandardAudit::Checksum::KeyOrderSearch::DEFAULT_LIMIT,
|
|
384
391
|
fail_on_legacy_unverifiable: false)
|
|
385
392
|
relation = scope ? where(scope_gid: scope.to_global_id.to_s) : all
|
|
@@ -395,6 +402,10 @@ module StandardAudit
|
|
|
395
402
|
unverifiable = []
|
|
396
403
|
failures = []
|
|
397
404
|
window = []
|
|
405
|
+
# Rows whose parent may still be AHEAD in the walk (see `lookahead`):
|
|
406
|
+
# [{ record:, algorithm:, expected:, seen: }], resolved against each
|
|
407
|
+
# later row's checksum, given up after `lookahead` rows.
|
|
408
|
+
pending = []
|
|
398
409
|
first_row = true
|
|
399
410
|
pruned_parents = []
|
|
400
411
|
|
|
@@ -426,6 +437,17 @@ module StandardAudit
|
|
|
426
437
|
next
|
|
427
438
|
end
|
|
428
439
|
|
|
440
|
+
pending.delete_if do |p|
|
|
441
|
+
if walk_digester(p[:record], p[:algorithm]).call(record.checksum) == p[:record].checksum
|
|
442
|
+
recovered += 1
|
|
443
|
+
elsif (p[:seen] += 1) >= lookahead
|
|
444
|
+
failures << chain_failure(p[:record], expected: p[:expected], reason: :digest_mismatch)
|
|
445
|
+
else
|
|
446
|
+
next false
|
|
447
|
+
end
|
|
448
|
+
true
|
|
449
|
+
end
|
|
450
|
+
|
|
429
451
|
declared = declared_parents ? record.previous_checksum : nil
|
|
430
452
|
|
|
431
453
|
if record.anonymized?
|
|
@@ -465,6 +487,10 @@ module StandardAudit
|
|
|
465
487
|
unverifiable << entry
|
|
466
488
|
failures << entry if fail_on_legacy_unverifiable
|
|
467
489
|
end
|
|
490
|
+
elsif declared.blank? && !strict && lookahead.positive?
|
|
491
|
+
# Its parent may sort AFTER it (committed first, stamped later by a
|
|
492
|
+
# concurrent writer); decided when the next `lookahead` rows pass.
|
|
493
|
+
pending << { record: record, algorithm: algorithm, expected: expected, seen: 0 }
|
|
468
494
|
else
|
|
469
495
|
failures << chain_failure(record, expected: expected, reason: :digest_mismatch)
|
|
470
496
|
end
|
|
@@ -476,6 +502,8 @@ module StandardAudit
|
|
|
476
502
|
window.shift if window.size > recovery_window
|
|
477
503
|
end
|
|
478
504
|
|
|
505
|
+
pending.each { |p| failures << chain_failure(p[:record], expected: p[:expected], reason: :digest_mismatch) }
|
|
506
|
+
|
|
479
507
|
{
|
|
480
508
|
valid: failures.empty?,
|
|
481
509
|
verified: verified,
|
|
@@ -499,7 +527,7 @@ module StandardAudit
|
|
|
499
527
|
# the row has held since it was written. Rows whose parent cannot be
|
|
500
528
|
# reproduced are left untouched and counted in :unresolved — they are the
|
|
501
529
|
# rows verification should keep reporting.
|
|
502
|
-
def self.relink_checksums!(batch_size: 1000, recovery_window: 256)
|
|
530
|
+
def self.relink_checksums!(batch_size: 1000, recovery_window: 256, lookahead: 64)
|
|
503
531
|
return { relinked: 0, unresolved: 0, skipped: 0 } unless chain_parent_column?
|
|
504
532
|
|
|
505
533
|
previous_checksum = nil
|
|
@@ -507,6 +535,7 @@ module StandardAudit
|
|
|
507
535
|
unresolved = 0
|
|
508
536
|
skipped = 0
|
|
509
537
|
window = []
|
|
538
|
+
pending = [] # rows whose parent may still be ahead (see verify_chain's `lookahead`)
|
|
510
539
|
|
|
511
540
|
each_in_chain_order(all, batch_size: batch_size) do |record|
|
|
512
541
|
if record.checksum.blank?
|
|
@@ -515,12 +544,28 @@ module StandardAudit
|
|
|
515
544
|
next
|
|
516
545
|
end
|
|
517
546
|
|
|
547
|
+
pending.delete_if do |p|
|
|
548
|
+
if walk_digester(p[:record], p[:algorithm]).call(record.checksum) == p[:record].checksum
|
|
549
|
+
p[:record].update_columns(previous_checksum: record.checksum)
|
|
550
|
+
relinked += 1
|
|
551
|
+
elsif (p[:seen] += 1) >= lookahead
|
|
552
|
+
unresolved += 1
|
|
553
|
+
else
|
|
554
|
+
next false
|
|
555
|
+
end
|
|
556
|
+
true
|
|
557
|
+
end
|
|
558
|
+
|
|
518
559
|
found = resolve_parent(record, previous_checksum, window) if record.previous_checksum.blank?
|
|
519
560
|
|
|
520
561
|
if record.previous_checksum.present?
|
|
521
562
|
skipped += 1
|
|
522
563
|
elsif found.nil?
|
|
523
|
-
|
|
564
|
+
if lookahead.positive?
|
|
565
|
+
pending << { record: record, algorithm: StandardAudit::Checksum.algorithm_for(record.created_at), seen: 0 }
|
|
566
|
+
else
|
|
567
|
+
unresolved += 1
|
|
568
|
+
end
|
|
524
569
|
elsif found.first.present?
|
|
525
570
|
record.update_columns(previous_checksum: found.first)
|
|
526
571
|
relinked += 1
|
|
@@ -534,6 +579,7 @@ module StandardAudit
|
|
|
534
579
|
window.shift if window.size > recovery_window
|
|
535
580
|
end
|
|
536
581
|
|
|
582
|
+
unresolved += pending.size
|
|
537
583
|
{ relinked: relinked, unresolved: unresolved, skipped: skipped }
|
|
538
584
|
end
|
|
539
585
|
|