fix: address external audit — criticals 1-5 + mediums
Critical 1 (approval forgery): audit_log is now hash-chained (prev_hash/entry_hash, verify_audit_chain); executor approve REQUIRES a BIP-340 signature over sha256(cayce-ops:approve:<id>) when CAYCE_OPS_OPERATOR_PUBKEY is set; deploy/roles.sql ships least-privilege per-daemon DB roles. Critical 2 (threading dead): sends create real OUTBOUND rows (own Message-ID, In-Reply-To -> inbound); inbound message_id never overwritten; maild compose adds the first-contact path; _match_proposal falls back to inbound chains. Critical 3 (duplicate sends): per-message transactions in send path — SMTP failure on message N no longer resurrects 1..N-1; failures audited (send_failed) and the row stays queued. Critical 4 (classifier): negated approvals (cannot accept / not approved / not accepting) classify REJECTION at 0.95 before the bare-keyword rule; LLM prompt braces escaped (.format no longer KeyError into silent fallback). Critical 5 (systemd): every daemon has a run subcommand (maild run = poll+send, publisher run = publish-due, monitor run = build); deploy README documents the separate daily publisher plan timer. Mediums: approved_hash binds drafts/captions to the approved text (edited text refuses to send/publish until re-approved); risk_score renamed match_score (migration 0002) and auto-approve orders HIGHEST relevance first; compose binds postgres to 127.0.0.1; IMAP marks \\Seen only after commit (fetch returns (num, raw), mark post-commit); disabled channels are terminal (ContentStatus.SKIPPED) and publish failures burn a bounded attempt budget before abandonment; make hooks installs the pre-push gate. tests: 12 new — 56 total, all green; ruff 0 findings; gitleaks clean.
This commit is contained in:
+63
-3
@@ -125,14 +125,14 @@ def test_nostr_without_config_fails_clean(cfg, sess):
|
||||
assert item.error and "nostr" in item.error
|
||||
|
||||
|
||||
def test_non_nostr_channels_skipped_by_default(cfg, sess):
|
||||
def test_non_nostr_channels_skipped_terminal(cfg, sess):
|
||||
item = _item(channel="facebook", theme="lightning-abroad")
|
||||
sess.add(item)
|
||||
sess.flush()
|
||||
stats = publisher._publish_due(cfg(), sess)
|
||||
assert stats["skipped"] == 1
|
||||
assert item.status is ContentStatus.APPROVED
|
||||
skip = sess.query(AuditEntry).filter_by(action="publish_skipped").one()
|
||||
assert item.status is ContentStatus.SKIPPED # terminal (audit fix)
|
||||
skip = sess.query(AuditEntry).filter_by(action="publish_skipped_terminal").one()
|
||||
assert skip.detail["channel"] == "facebook"
|
||||
|
||||
|
||||
@@ -162,3 +162,63 @@ def test_unknown_brand_rejected():
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
images.render_brand_image(brand="xx", kicker=None, lines=["A"], out_path="/tmp/x.jpg")
|
||||
|
||||
|
||||
# --- audit corrections ----------------------------------------------------------
|
||||
|
||||
|
||||
def test_publish_refuses_tampered_caption(cfg, sess):
|
||||
"""Medium finding: approved caption was not bound to content."""
|
||||
item = _item(status=ContentStatus.DRAFT)
|
||||
sess.add(item)
|
||||
sess.flush()
|
||||
assert publisher._approve(item.id, "gustavo", sess) == 0
|
||||
|
||||
item.caption = item.caption + " TAMPERED"
|
||||
config = cfg(CAYCE_OPS_NOSTR_PRIVATE_KEY=TEST_KEY, CAYCE_OPS_NOSTR_RELAYS="wss://r.example.org")
|
||||
stats = publisher._publish_due(config, sess, nostr_sender=lambda r, e, timeout=10: [])
|
||||
|
||||
assert stats["published"] == 0 and stats["tampered"] == 1
|
||||
assert item.status is ContentStatus.APPROVED # stays, needs re-approval
|
||||
action = sess.query(AuditEntry).filter_by(action="publish_refused_hash_mismatch").one()
|
||||
assert action.result == "error"
|
||||
|
||||
|
||||
def test_disabled_channel_is_terminal(cfg, sess):
|
||||
"""Medium finding: disabled channels wrote skip audits every 5 minutes."""
|
||||
item = _item(channel="facebook", theme="lightning-abroad")
|
||||
sess.add(item)
|
||||
sess.flush()
|
||||
config = cfg()
|
||||
|
||||
stats1 = publisher._publish_due(config, sess)
|
||||
assert stats1["skipped"] == 1
|
||||
assert item.status is ContentStatus.SKIPPED # terminal
|
||||
|
||||
stats2 = publisher._publish_due(config, sess)
|
||||
assert stats2["skipped"] == 0 # never revisited
|
||||
skips = sess.query(AuditEntry).filter_by(action="publish_skipped_terminal").count()
|
||||
assert skips == 1
|
||||
|
||||
|
||||
def test_publish_attempts_bounded(cfg, sess, monkeypatch):
|
||||
"""Medium finding: nostr failures retried without limit."""
|
||||
from cayceops.components.publisher import nostr as nostr_mod
|
||||
|
||||
item = _item()
|
||||
sess.add(item)
|
||||
sess.flush()
|
||||
|
||||
def always_fail(relays, event, timeout=10.0):
|
||||
raise RuntimeError("relay down")
|
||||
|
||||
monkeypatch.setattr(nostr_mod, "publish", always_fail)
|
||||
config = cfg(
|
||||
CAYCE_OPS_NOSTR_PRIVATE_KEY=TEST_KEY,
|
||||
CAYCE_OPS_NOSTR_RELAYS="wss://r.example.org",
|
||||
CAYCE_OPS_MAX_ATTEMPTS="1",
|
||||
)
|
||||
|
||||
publisher._publish_due(config, sess, nostr_sender=always_fail)
|
||||
assert item.attempts == 1
|
||||
assert item.status is ContentStatus.SKIPPED # budget exhausted -> terminal
|
||||
|
||||
Reference in New Issue
Block a user