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:
@@ -148,6 +148,16 @@ def verify_event(event: dict[str, Any]) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def verify_schnorr(sig_hex: str, message: bytes, pubkey_hex: str) -> bool:
|
||||
"""Verify a BIP-340 signature over an arbitrary 32-byte message."""
|
||||
try:
|
||||
return _schnorr_verify(
|
||||
bytes.fromhex(sig_hex), message, bytes.fromhex(pubkey_hex)
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def relay_publish(relay_url: str, event: dict[str, Any], timeout: float = 10.0) -> str:
|
||||
"""Publish one event to one relay; return the relay's OK message."""
|
||||
from websockets.sync.client import connect
|
||||
|
||||
@@ -16,7 +16,13 @@ from datetime import UTC, datetime, timedelta
|
||||
from cayceops.components.publisher import images, nostr
|
||||
from cayceops.config import Config
|
||||
from cayceops.db import session_scope
|
||||
from cayceops.models import ContentItem, ContentStatus, DaemonRun, audit
|
||||
from cayceops.models import (
|
||||
ContentItem,
|
||||
ContentStatus,
|
||||
DaemonRun,
|
||||
_content_hash,
|
||||
audit,
|
||||
)
|
||||
|
||||
# rotation: one theme per slot; channel rotation nostr -> facebook -> instagram
|
||||
THEMES = [
|
||||
@@ -120,11 +126,12 @@ def _approve(item_id: int, actor: str, sess) -> int:
|
||||
)
|
||||
return 1
|
||||
item.status = ContentStatus.APPROVED
|
||||
item.approved_hash = _content_hash(item.caption) # binding: caption as approved
|
||||
audit(
|
||||
sess,
|
||||
actor=actor,
|
||||
action="approve_content",
|
||||
detail={"item": item.id, "channel": item.channel},
|
||||
detail={"item": item.id, "channel": item.channel, "hash": item.approved_hash},
|
||||
)
|
||||
return 0
|
||||
|
||||
@@ -174,7 +181,7 @@ def _publish_nostr(item: ContentItem, cfg: Config, sess, sender=None) -> str:
|
||||
def _publish_due(cfg: Config, sess, nostr_sender=None) -> dict:
|
||||
"""Publish approved items that are due. Non-nostr channels are skipped
|
||||
unless their adapters are explicitly enabled (default: skipped, audited)."""
|
||||
stats = {"published": 0, "skipped": 0, "failed": 0}
|
||||
stats = {"published": 0, "skipped": 0, "failed": 0, "tampered": 0}
|
||||
due = (
|
||||
sess.query(ContentItem)
|
||||
.filter(ContentItem.status == ContentStatus.APPROVED)
|
||||
@@ -184,31 +191,56 @@ def _publish_due(cfg: Config, sess, nostr_sender=None) -> dict:
|
||||
for item in due:
|
||||
if item.scheduled_for and item.scheduled_for > now:
|
||||
continue # not due yet
|
||||
|
||||
# binding: caption must be exactly what was approved (audit finding)
|
||||
if item.approved_hash and _content_hash(item.caption) != item.approved_hash:
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:publisher",
|
||||
action="publish_refused_hash_mismatch",
|
||||
result="error",
|
||||
detail={"item": item.id},
|
||||
)
|
||||
stats["tampered"] += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
if item.channel == "nostr":
|
||||
item.post_ref = _publish_nostr(item, cfg, sess, sender=nostr_sender)
|
||||
else:
|
||||
# disabled channel is TERMINAL (audit finding: this used to
|
||||
# write a skip audit row every 5 minutes, forever)
|
||||
item.status = ContentStatus.SKIPPED
|
||||
item.error = f"{item.channel} adapter disabled by default"
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:publisher",
|
||||
action="publish_skipped",
|
||||
detail={
|
||||
"item": item.id,
|
||||
"channel": item.channel,
|
||||
"reason": f"{item.channel} adapter disabled by default",
|
||||
},
|
||||
action="publish_skipped_terminal",
|
||||
detail={"item": item.id, "channel": item.channel},
|
||||
)
|
||||
stats["skipped"] += 1
|
||||
continue
|
||||
except Exception as exc: # noqa: BLE001 — publish boundary: audit and continue
|
||||
item.attempts += 1
|
||||
item.error = str(exc)[:300]
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:publisher",
|
||||
action="publish_failed",
|
||||
result="error",
|
||||
detail={"item": item.id, "error": item.error},
|
||||
)
|
||||
if item.attempts >= cfg.max_attempts:
|
||||
# attempt budget exhausted -> terminal (no infinite retries)
|
||||
item.status = ContentStatus.SKIPPED
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:publisher",
|
||||
action="publish_abandoned",
|
||||
result="error",
|
||||
detail={"item": item.id, "attempts": item.attempts},
|
||||
)
|
||||
else:
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:publisher",
|
||||
action="publish_failed",
|
||||
result="error",
|
||||
detail={"item": item.id, "error": item.error, "attempt": item.attempts},
|
||||
)
|
||||
stats["failed"] += 1
|
||||
continue
|
||||
item.status = ContentStatus.PUBLISHED
|
||||
|
||||
Reference in New Issue
Block a user