Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ jobs:
- name: cargo fmt -- --check
run: cargo fmt --all -- --check

log-redaction:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Check for Nostr key/identity leaks in tracing calls
run: python3 scripts/check_log_redaction.py

clippy:
runs-on: ubuntu-latest
steps:
Expand All @@ -32,7 +39,7 @@ jobs:

test:
runs-on: ubuntu-latest
needs: [fmt, clippy]
needs: [fmt, clippy, log-redaction]
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
Expand Down
124 changes: 124 additions & 0 deletions scripts/check_log_redaction.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""CI gate for AGENTS.md:48 ("Scrub logs that might leak invoices or Nostr
keys"). Flags any `tracing::{trace,debug,info,warn,error}!(...)` call whose
argument list interpolates an identifier that looks like a Nostr
key/identity, so a new log-scrubbing regression (issue #836's pattern) fails
CI instead of shipping quietly.

Not a Rust parser: string literals are skipped so a key-shaped *word* inside
a log message's own text doesn't trigger a false positive, but the paren
matching is a plain depth counter — a macro call containing a raw string
literal with unbalanced parens would confuse it. None of this codebase's
tracing calls do that today; if one ever needs to, exempt it inline (see
ALLOW_COMMENT below) rather than fighting the matcher.
"""

import re
import sys
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parent.parent
SRC_ROOT = REPO_ROOT / "src"

MACRO_RE = re.compile(r"\b(?:tracing::)?(trace|debug|info|warn|error)!\s*\(")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

# Identifiers that name a Nostr key/identity in this codebase. Extend this
# list, don't loosen it to a bare `key` — that also matches innocuous things
# like HashMap iteration variables.
SUSPICIOUS_RE = re.compile(
r"\b("
r"\w*pubkey\w*"
r"|identity"
r"|sender"
r"|master_key"
r"|trade_key"
r"|nsec\w*"
r"|priv(?:ate)?_?key\w*"
r")\b"
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# A `// pubkey-log-allow: <reason>` comment on the line right before a
# flagged macro call exempts it — for a documented, deliberate exception
# (e.g. an already-redacted/truncated value) rather than a silent miss.
ALLOW_COMMENT = "pubkey-log-allow:"


def find_call_span(text: str, open_paren: int) -> tuple[int, str]:
"""Return (index just past the `)` matching `text[open_paren] == '('`,
the call's source with string-literal *contents* blanked out).

Blanking string contents (not just skipping them for paren-matching)
matters: a format string's own English prose can contain a key-shaped
word ("...pubkey in order...") that isn't an interpolated argument at
all — only the blanked version should be searched for suspicious
identifiers, or every message that merely *mentions* a pubkey false-
positives.
"""
depth = 0
i = open_paren
n = len(text)
out = []
while i < n:
c = text[i]
if c == '"':
start = i
i += 1
while i < n and text[i] != '"':
i += 2 if text[i] == "\\" else 1
i += 1
out.append('"' * (i - start))
continue
out.append(c)
if c == "(":
depth += 1
elif c == ")":
depth -= 1
if depth == 0:
return i + 1, "".join(out)
i += 1
return n, "".join(out) # unbalanced — best effort


def line_before(text: str, index: int) -> str:
line_start = text.rfind("\n", 0, index)
prev_start = text.rfind("\n", 0, line_start) + 1 if line_start != -1 else 0
return text[prev_start:line_start] if line_start != -1 else ""


def check_file(path: Path) -> list[tuple[int, str]]:
text = path.read_text(encoding="utf-8")
violations = []
for m in MACRO_RE.finditer(text):
open_paren = text.index("(", m.end() - 1)
_end, code_only = find_call_span(text, open_paren)
found = SUSPICIOUS_RE.search(code_only)
if not found:
continue
if ALLOW_COMMENT in line_before(text, m.start()):
continue
line_no = text.count("\n", 0, m.start()) + 1
violations.append((line_no, found.group(0)))
return violations


def main() -> int:
total = 0
for path in sorted(SRC_ROOT.rglob("*.rs")):
for line_no, ident in check_file(path):
rel = path.relative_to(REPO_ROOT)
print(
f"{rel}:{line_no}: tracing call interpolates `{ident}` — "
f"looks like a Nostr key/identity (AGENTS.md:48). Drop it from "
f"the log line, or mark a deliberate exception with a "
f"`// {ALLOW_COMMENT} <reason>` comment on the line above."
)
total += 1
if total:
print(f"\n{total} log-redaction violation(s) found.", file=sys.stderr)
return 1
print("check_log_redaction: clean.")
return 0


if __name__ == "__main__":
sys.exit(main())
11 changes: 4 additions & 7 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -340,9 +340,9 @@ async fn accept_event(
// we decrypt. New orders/takes legitimately arrive
// here — so does spam, hence the PoW toll.
if !gate.is_known(&event.pubkey.to_string()) && !event.check_pow(pow_first_contact) {
// No key in the log line — sender pubkey (AGENTS.md:48).
tracing::info!(
"Dropping first-contact kind-14 event from unknown key {} below pow_first_contact ({} bits)",
event.pubkey,
"Dropping first-contact kind-14 event below pow_first_contact ({} bits)",
pow_first_contact
);
return None;
Expand Down Expand Up @@ -385,11 +385,8 @@ async fn accept_event(
// signature — unwrap_message already verified it, so if identity
// and sender differ here without a signature we bail out.
if unwrapped.identity != unwrapped.sender && unwrapped.signature.is_none() {
tracing::warn!(
"Missing inner signature: identity {} differs from trade key {}",
unwrapped.identity,
unwrapped.sender
);
// No keys in the log line — identity/trade key (AGENTS.md:48).
tracing::warn!("Missing inner signature: identity differs from trade key");
return None;
}

Expand Down
12 changes: 5 additions & 7 deletions src/app/admin_take_dispute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,9 @@ pub async fn pubkey_event_can_solve(
) -> bool {
let sender_pubkey = ev_pubkey.to_string();

// Is mostro admin taking dispute?
info!(
"admin pubkey {} -event pubkey {} ",
my_keys.public_key().to_string(),
sender_pubkey
);
// Is mostro admin taking dispute? No keys in the log line — admin/event
// pubkeys (AGENTS.md:48).
info!("Checking whether the dispute event was sent by the mostro admin");
if sender_pubkey == my_keys.public_key().to_string()
&& matches!(status, DisputeStatus::InProgress | DisputeStatus::Initiated)
{
Expand Down Expand Up @@ -192,7 +189,8 @@ pub async fn admin_take_dispute_action(
dispute.solver_pubkey = Some(event.identity.to_string());
dispute.taken_at = Timestamp::now().as_secs() as i64;

info!("Dispute {} taken by {}", dispute.id, event.identity);
// No key in the log line — solver identity (AGENTS.md:48).
info!("Dispute {} taken by a solver", dispute.id);

// Save it to DB
dispute
Expand Down
6 changes: 3 additions & 3 deletions src/app/bond/payout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -454,11 +454,11 @@ async fn request_payout_invoice(
return Ok(());
}

// No key in the log fields — recipient pubkey (AGENTS.md:48).
info!(
bond_id = %bond.id,
order_id = %bond.order_id,
amount_sats = counterparty_share,
recipient = %recipient_pubkey,
slashed_at,
attempt = bond.invoice_request_attempts + 1,
"bond payout: requesting invoice from counterparty"
Expand Down Expand Up @@ -1349,18 +1349,18 @@ pub async fn add_bond_invoice_action(

match apply_payout_invoice(pool, &bond, &payment_request, now, claim_window_seconds).await? {
InvoiceApplyOutcome::Persisted => {
// No key in the log fields — sender pubkey (AGENTS.md:48).
info!(
bond_id = %bond.id,
order_id = %bond.order_id,
sender = %sender,
"bond payout: invoice accepted; awaiting scheduler tick for payout"
);
}
InvoiceApplyOutcome::Resurrected => {
// No key in the log fields — sender pubkey (AGENTS.md:48).
info!(
bond_id = %bond.id,
order_id = %bond.order_id,
sender = %sender,
"bond payout: Failed -> PendingPayout (user submitted fresh invoice within claim window); payout_attempts reset, awaiting scheduler tick for payout"
);
}
Expand Down
41 changes: 5 additions & 36 deletions src/app/cancel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,6 @@ async fn cancel_order_by_taker<L: CancelLightning + Send>(
my_keys: &Keys,
request_id: Option<u64>,
ln_client: &mut L,
taker_pubkey: PublicKey,
) -> Result<(), MostroError> {
let order_id = order.id;
let sender_str = event.sender.to_string();
Expand Down Expand Up @@ -263,16 +262,7 @@ async fn cancel_order_by_taker<L: CancelLightning + Send>(

// No surviving bonds: run the full reset-and-republish path so
// the order goes back into the book exactly as before.
cancel_order_by_taker_inner(
pool,
event,
order,
my_keys,
request_id,
ln_client,
taker_pubkey,
)
.await
cancel_order_by_taker_inner(pool, event, order, my_keys, request_id, ln_client).await
}

async fn cancel_order_by_taker_inner<L: CancelLightning + Send>(
Expand All @@ -282,7 +272,6 @@ async fn cancel_order_by_taker_inner<L: CancelLightning + Send>(
my_keys: &Keys,
request_id: Option<u64>,
ln_client: &mut L,
taker_pubkey: PublicKey,
) -> Result<(), MostroError> {
// Cancel hold invoice if present
if let Some(hash) = &order.hash {
Expand Down Expand Up @@ -318,10 +307,8 @@ async fn cancel_order_by_taker_inner<L: CancelLightning + Send>(
.await
.map_err(|e| MostroInternalErr(ServiceError::NostrError(e.to_string())))?;

info!(
"{}: Canceled order Id {} republishing order",
taker_pubkey, order.id
);
// No key in the log line — taker pubkey (AGENTS.md:48).
info!("Canceled order Id {} republishing order", order.id);

// Notify the creator about the republished order after the taker-side cancellation flow completes
notify_creator(&order_updated, request_id).await?;
Expand Down Expand Up @@ -551,16 +538,7 @@ async fn cancel_action_generic<L: CancelLightning + Send>(
.as_deref()
.is_some_and(|p| p == sender_str && p != order.creator_pubkey);
if bond_match || order_taker_match {
cancel_order_by_taker(
pool,
event,
order,
my_keys,
request_id,
ln_client,
event.sender,
)
.await?;
cancel_order_by_taker(pool, event, order, my_keys, request_id, ln_client).await?;
return Ok(());
}
return Err(MostroCantDo(CantDoReason::IsNotYourOrder));
Expand Down Expand Up @@ -672,16 +650,7 @@ async fn cancel_not_active_order<L: CancelLightning + Send>(
)
.await?;
} else if event.sender == taker_pubkey {
cancel_order_by_taker(
pool,
event,
order,
my_keys,
request_id,
ln_client,
taker_pubkey,
)
.await?;
cancel_order_by_taker(pool, event, order, my_keys, request_id, ln_client).await?;
} else {
return Err(MostroCantDo(CantDoReason::InvalidPubkey));
}
Expand Down
8 changes: 3 additions & 5 deletions src/app/last_trade_index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,9 @@ pub async fn last_trade_index(
.as_json()
.map_err(|_| MostroError::MostroInternalErr(ServiceError::MessageSerializationError))?;

// Print the last trade index message
tracing::info!(
"User with pubkey: {} requested last trade index",
user.pubkey
);
// Print the last trade index message. No key in the log line — user
// pubkey (AGENTS.md:48).
tracing::info!("User requested last trade index");
tracing::info!("Last trade index: {}", user.last_trade_index);

// Send message back to the requester
Expand Down
7 changes: 2 additions & 5 deletions src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1254,11 +1254,8 @@ pub async fn is_assigned_solver(
solver_pubkey: &str,
order_id: Uuid,
) -> Result<bool, MostroError> {
tracing::info!(
"Solver_pubkey: {} assigned to order {}",
solver_pubkey,
order_id
);
// No key in the log line — solver pubkey (AGENTS.md:48).
tracing::info!("Solver assigned to order {}", order_id);
let result = sqlx::query(
"SELECT EXISTS(SELECT 1 FROM disputes WHERE solver_pubkey = ? AND order_id = ?)",
)
Expand Down
6 changes: 2 additions & 4 deletions src/rpc/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -318,10 +318,8 @@ impl AdminService for AdminServiceImpl {
request: Request<AddSolverRequest>,
) -> Result<Response<AddSolverResponse>, Status> {
let req = request.into_inner();
info!(
"Received add solver request for pubkey: {}",
req.solver_pubkey
);
// No key in the log line — solver pubkey (AGENTS.md:48).
info!("Received add solver request");

match self
.call_admin_add_solver(req.solver_pubkey, req.request_id)
Expand Down
5 changes: 2 additions & 3 deletions src/scheduler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -299,10 +299,9 @@ async fn notify_users_canceled_order(
}
};

// No keys in the log line — maker/taker pubkeys (AGENTS.md:48).
tracing::info!(
"Notifying maker {} that taker {} canceled the order {}",
maker_pubkey.to_string(),
taker_pubkey.to_string(),
"Notifying maker and taker that order {} was canceled",
old_order.id
);

Expand Down
Loading