Skip to content

Commit c3fc0b2

Browse files
hyperpolymathclaude
andcommitted
feat(echidnabot): harden webhook signing + prover-unavailable distinction
- Webhook hardening: refuse unsigned GitHub webhooks with 503 when webhook_secret is absent; reject bad signatures with 401. Previous behaviour silently accepted unsigned payloads. - Prover-unavailable: record a synthetic ProofStatus::SystemError attempt in VeriSimDB when a prover is missing/unavailable, so strategy-learning distinguishes infra gaps from proof failures. - VeriSimWriter: read VERISIM_PROOF_ATTEMPTS_TOKEN from env and attach it as X-Proof-Attempts-Token on every POST to verisim-api. - Config: note VERISIM_PROOF_ATTEMPTS_TOKEN in example.toml. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 84121ce commit c3fc0b2

4 files changed

Lines changed: 111 additions & 27 deletions

File tree

echidnabot/echidnabot.example.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ queue_size = 100
4040
# # Option 2: Personal Access Token (simpler for testing)
4141
# token = "ghp_xxxxxxxxxxxxxxxxxxxx"
4242
#
43-
# # Webhook secret for signature verification
43+
# # Webhook secret for signature verification (required if serving /webhooks/github)
4444
# webhook_secret = "your-webhook-secret"
4545

4646
# GitLab Integration (optional)
@@ -56,3 +56,4 @@ queue_size = 100
5656
# ECHIDNABOT__ECHIDNA__REST_ENDPOINT=http://...
5757
# ECHIDNABOT__ECHIDNA__MODE=rest
5858
# ECHIDNABOT__GITHUB__TOKEN=ghp_...
59+
# VERISIM_PROOF_ATTEMPTS_TOKEN=... # only if verisim-api enables token auth

echidnabot/src/api/webhooks.rs

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,26 @@ async fn handle_github_webhook(
4848
) -> impl IntoResponse {
4949
tracing::info!("Received GitHub webhook");
5050

51-
// Verify signature if secret is configured
52-
if let Some(ref gh_config) = state.config.github {
53-
if let Some(ref secret) = gh_config.webhook_secret {
54-
if let Err(e) = verify_github_signature(&headers, &body, secret) {
55-
tracing::warn!("GitHub webhook signature verification failed: {}", e);
56-
return (StatusCode::UNAUTHORIZED, "Invalid signature");
57-
}
58-
}
51+
// Always require signature validation for GitHub webhooks.
52+
let github_secret = state
53+
.config
54+
.github
55+
.as_ref()
56+
.and_then(|c| c.webhook_secret.as_deref())
57+
.map(str::trim)
58+
.filter(|s| !s.is_empty());
59+
60+
let Some(secret) = github_secret else {
61+
tracing::error!("GitHub webhook secret missing; refusing unsigned webhook");
62+
return (
63+
StatusCode::SERVICE_UNAVAILABLE,
64+
"GitHub webhook secret not configured",
65+
);
66+
};
67+
68+
if let Err(e) = verify_github_signature(&headers, &body, secret) {
69+
tracing::warn!("GitHub webhook signature verification failed: {}", e);
70+
return (StatusCode::UNAUTHORIZED, "Invalid signature");
5971
}
6072

6173
// Parse event type

echidnabot/src/main.rs

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use echidnabot::adapters::gitlab::GitLabAdapter;
1111
use echidnabot::api::graphql::GraphQLState;
1212
use echidnabot::api::{create_schema, webhook_router};
1313
use chrono::Utc;
14-
use echidnabot::dispatcher::{EchidnaClient, ProverKind};
14+
use echidnabot::dispatcher::{EchidnaClient, ProofResult, ProofStatus, ProverKind};
1515
use echidnabot::dispatcher::echidna_client::ProverStatus;
1616
use echidnabot::scheduler::{JobScheduler, ProofJob};
1717
use echidnabot::store::{SqliteStore, Store};
@@ -556,11 +556,36 @@ async fn process_job(
556556

557557
let status = echidna.prover_status(job.prover).await?;
558558
if status != ProverStatus::Available {
559-
return Err(echidnabot::Error::Echidna(format!(
559+
let message = format!(
560560
"Prover {} not available (status: {})",
561561
job.prover.display_name(),
562562
format_prover_status(status)
563-
)));
563+
);
564+
565+
// Record a synthetic attempt so strategy learning can distinguish
566+
// missing/unavailable prover infrastructure from proof-level failures.
567+
if let Some(repo_record) = store.get_repository(job.repo_id).await? {
568+
let verisim_writer = VeriSimWriter::from_env();
569+
let synthetic = ProofResult {
570+
status: ProofStatus::SystemError,
571+
message: message.clone(),
572+
prover_output: String::new(),
573+
duration_ms: start.elapsed().as_millis() as u64,
574+
artifacts: Vec::new(),
575+
};
576+
let repo_slug = format!("{}/{}", repo_record.owner, repo_record.name);
577+
verisim_writer
578+
.record(
579+
&synthetic,
580+
job.prover,
581+
&repo_slug,
582+
"__prover_unavailable__",
583+
Utc::now(),
584+
)
585+
.await;
586+
}
587+
588+
return Err(echidnabot::Error::Echidna(message));
564589
}
565590

566591
let repo = store
@@ -629,7 +654,7 @@ async fn process_job(
629654
.record(&result, job.prover, &repo_slug, path, attempt_started)
630655
.await;
631656

632-
if result.status == echidnabot::dispatcher::ProofStatus::Verified {
657+
if result.status.is_verified() {
633658
verified.push(path.to_string());
634659
} else {
635660
failed.push(path.to_string());

echidnabot/src/verisim_writer.rs

Lines changed: 60 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@
2828
//! (e.g. `http://localhost:8080`). If the variable is absent or empty,
2929
//! the writer is disabled and every call to `record` is a no-op.
3030
//!
31+
//! If verisim-api sets `VERISIM_PROOF_ATTEMPTS_TOKEN`, set the same value in
32+
//! the echidnabot process environment so writes include
33+
//! `X-Proof-Attempts-Token`.
34+
//!
3135
//! # Prover-agnosticism
3236
//!
3337
//! `prover_to_str` maps every `ProverKind` variant to the lowercase
@@ -104,6 +108,7 @@ pub struct VeriSimWriter {
104108
base_url: String,
105109
http: Client,
106110
enabled: bool,
111+
write_token: Option<String>,
107112
}
108113

109114
impl VeriSimWriter {
@@ -122,6 +127,10 @@ impl VeriSimWriter {
122127
base_url: url.trim_end_matches('/').to_string(),
123128
http,
124129
enabled: true,
130+
write_token: std::env::var("VERISIM_PROOF_ATTEMPTS_TOKEN")
131+
.ok()
132+
.map(|s| s.trim().to_string())
133+
.filter(|s| !s.is_empty()),
125134
}
126135
}
127136
_ => {
@@ -132,6 +141,7 @@ impl VeriSimWriter {
132141
base_url: String::new(),
133142
http: Client::new(),
134143
enabled: false,
144+
write_token: None,
135145
}
136146
}
137147
}
@@ -163,9 +173,10 @@ impl VeriSimWriter {
163173

164174
let completed_at = Utc::now();
165175
let obligation_class = classify_obligation_from_path(file_path, prover);
166-
let outcome = outcome_str(&result.status);
176+
let outcome = outcome_str(result);
167177
let confidence = confidence_from_status(&result.status);
168178
let prover_str = prover_to_str(prover);
179+
let verified = result.status.is_verified();
169180

170181
let row = ProofAttemptRow {
171182
attempt_id: Uuid::new_v4().to_string(),
@@ -183,16 +194,16 @@ impl VeriSimWriter {
183194
started_at: started_at.format("%Y-%m-%dT%H:%M:%S%.3f").to_string(),
184195
completed_at: completed_at.format("%Y-%m-%dT%H:%M:%S%.3f").to_string(),
185196
prover_output: truncate_utf8(&result.prover_output, 8 * 1024),
186-
error_message: if result.status == ProofStatus::Verified {
187-
None
188-
} else {
189-
Some(result.message.clone())
190-
},
197+
error_message: if verified { None } else { Some(build_error_message(result)) },
191198
};
192199

193200
let url = format!("{}/api/v1/proof_attempts", self.base_url);
201+
let mut request = self.http.post(&url).json(&row);
202+
if let Some(token) = &self.write_token {
203+
request = request.header("X-Proof-Attempts-Token", token);
204+
}
194205

195-
match self.http.post(&url).json(&row).send().await {
206+
match request.send().await {
196207
Ok(resp) if resp.status().is_success() => {
197208
debug!(
198209
attempt_id = %row.attempt_id,
@@ -228,11 +239,21 @@ impl VeriSimWriter {
228239
// ─── Helpers ──────────────────────────────────────────────────────────────────
229240

230241
/// Map `ProofStatus` to the lowercase ClickHouse Enum8 outcome string.
231-
fn outcome_str(status: &ProofStatus) -> String {
232-
match status {
233-
ProofStatus::Verified => "success",
242+
fn outcome_str(result: &ProofResult) -> String {
243+
if result.status.is_verified() {
244+
return "success".to_string();
245+
}
246+
247+
match result.status {
248+
ProofStatus::Proved | ProofStatus::Verified => "success",
249+
ProofStatus::NoProofFound | ProofStatus::Failed => "failure",
250+
ProofStatus::InvalidInput
251+
| ProofStatus::UnsupportedFeature
252+
| ProofStatus::InconsistentPremises
253+
| ProofStatus::ProverError => "failure",
234254
ProofStatus::Timeout => "timeout",
235-
ProofStatus::Failed | ProofStatus::Error => "failure",
255+
ProofStatus::SystemError if is_prover_unavailable(result) => "unknown",
256+
ProofStatus::SystemError => "failure",
236257
ProofStatus::Unknown => "unknown",
237258
}
238259
.to_string()
@@ -272,14 +293,39 @@ fn classify_obligation_from_path(file_path: &str, prover: ProverKind) -> String
272293
/// is per-attempt metadata, not a prediction.
273294
fn confidence_from_status(status: &ProofStatus) -> f32 {
274295
match status {
275-
ProofStatus::Verified => 0.95,
276-
ProofStatus::Failed => 0.10,
296+
ProofStatus::Proved | ProofStatus::Verified => 0.95,
297+
ProofStatus::NoProofFound | ProofStatus::Failed => 0.10,
298+
ProofStatus::InvalidInput | ProofStatus::UnsupportedFeature => 0.20,
299+
ProofStatus::InconsistentPremises => 0.05,
277300
ProofStatus::Timeout => 0.05,
278-
ProofStatus::Error => 0.10,
301+
ProofStatus::ProverError | ProofStatus::SystemError => 0.05,
279302
ProofStatus::Unknown => 0.50,
280303
}
281304
}
282305

306+
fn build_error_message(result: &ProofResult) -> String {
307+
if is_prover_unavailable(result) {
308+
format!("prover_unavailable: {}", result.message)
309+
} else {
310+
result.message.clone()
311+
}
312+
}
313+
314+
fn is_prover_unavailable(result: &ProofResult) -> bool {
315+
matches!(result.status, ProofStatus::SystemError)
316+
&& {
317+
let merged = format!(
318+
"{}\n{}",
319+
result.message.to_lowercase(),
320+
result.prover_output.to_lowercase()
321+
);
322+
merged.contains("not installed")
323+
|| merged.contains("not available")
324+
|| merged.contains("unavailable")
325+
|| merged.contains("missing prover")
326+
}
327+
}
328+
283329
/// Stable 16-char hex obligation ID derived from `(repo, file)`.
284330
///
285331
/// Uses a non-cryptographic hash — good enough for grouping retries.

0 commit comments

Comments
 (0)