Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
1,300 changes: 820 additions & 480 deletions .pmat/baseline.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ bzip2 = "0.6"
zstd = "0.13"
tar = "0.4"
sha2 = "0.10"
# HMAC-SHA256 for webhook request authentication (FJ-3104). One pure-Rust crate:
# sha2 is already a direct dep above, and hmac/digest/subtle were already in
# Cargo.lock transitively, so this adds no new transitive dependencies.
hmac = "0.12"
flate2 = "1"
rusqlite = { version = "0.32", features = ["bundled"] }
dhat = { version = "0.3.3", optional = true }
Expand Down
325 changes: 325 additions & 0 deletions contracts/webhook-receiver-v1.yaml

Large diffs are not rendered by default.

18 changes: 14 additions & 4 deletions examples/crypto_mcdc_undo_webhook.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,16 +104,26 @@ fn main() {
method: "POST".into(),
path: "/webhook".into(),
headers: HashMap::new(),
body: r#"{"action":"deploy","env":"prod"}"#.into(),
body: r#"{"action":"deploy","env":"prod"}"#.as_bytes().to_vec(),
source_ip: Some("10.0.0.5".into()),
};
println!(" Valid POST: {:?}", validate_request(&config, &req));

let event = request_to_event(&req).unwrap();
let event = request_to_event(&req, None, None).unwrap();
println!(" Event type: {}", event.event_type);
println!(" Payload action: {}", event.payload["action"]);
println!(" HMAC: {}...", &compute_hmac_hex("secret", "data")[..16]);
println!(" ACK: {}", ack_response(200, "ok").lines().next().unwrap());
println!(
" HMAC: {}...",
&forjar::core::webhook_sig::compute_hmac_hex("secret".as_bytes(), "data".as_bytes())[..16]
);
println!(
" ACK: {}",
String::from_utf8(forjar::core::webhook_http::response(200, "ok"))
.unwrap()
.lines()
.next()
.unwrap()
);

println!("\n{}", "=".repeat(50));
println!("All crypto/mcdc/undo/webhook criteria survived.");
Expand Down
28 changes: 20 additions & 8 deletions examples/migrate_webhook.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@ use forjar::core::migrate::{docker_to_pepita, migrate_config};
use forjar::core::parser::parse_config;
use forjar::core::types::*;
use forjar::core::webhook_source::{
compute_hmac_hex, parse_json_payload, request_to_event, validate_request, WebhookConfig,
WebhookRequest,
parse_json_payload, request_to_event, validate_request, WebhookConfig, WebhookRequest,
};
use std::collections::HashMap;

Expand Down Expand Up @@ -80,18 +79,31 @@ resources:
secret: Some("deploy-secret".into()),
max_body_bytes: 1024,
allowed_paths: vec!["/hooks/deploy".into()],
..WebhookConfig::default()
};

// Valid request with HMAC
let body = r#"{"action":"deploy","env":"production"}"#;
let sig = compute_hmac_hex("deploy-secret", body);
// The signature binds timestamp, method and path — not the body alone — so a
// digest minted for /hooks/deploy cannot be replayed at another allowed path.
let t_now = forjar::core::webhook_sig::unix_now();
let signed = forjar::core::webhook_sig::canonical_payload(
t_now,
"POST",
"/hooks/deploy",
body.as_bytes(),
);
let digest = forjar::core::webhook_sig::compute_hmac_hex(b"deploy-secret", &signed);
let mut headers = HashMap::new();
headers.insert("x-forjar-signature".into(), sig);
headers.insert(
"x-forjar-signature".into(),
format!("t={t_now},v1={digest}"),
);
let req = WebhookRequest {
method: "POST".into(),
path: "/hooks/deploy".into(),
headers,
body: body.into(),
body: body.as_bytes().to_vec(),
source_ip: Some("10.0.0.1".into()),
};
let vr = validate_request(&config, &req);
Expand All @@ -103,7 +115,7 @@ resources:
method: "GET".into(),
path: "/hooks/deploy".into(),
headers: HashMap::new(),
body: "".into(),
body: Vec::new(),
source_ip: None,
};
let vr = validate_request(&config, &bad_req);
Expand All @@ -112,11 +124,11 @@ resources:

// ── FJ-3104: Payload parsing ──
println!("\n[FJ-3104] Webhook Payload Parsing:");
let payload = parse_json_payload(body).unwrap();
let payload = parse_json_payload(body.as_bytes()).unwrap();
println!(" action={}, env={}", payload["action"], payload["env"]);
assert_eq!(payload["action"], "deploy");

let event = request_to_event(&req).unwrap();
let event = request_to_event(&req, None, None).unwrap();
println!(" Event type: {:?}", event.event_type);
println!(
" Payload keys: {:?}",
Expand Down
28 changes: 17 additions & 11 deletions examples/webhook_source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@

use forjar::core::webhook_server;
use forjar::core::webhook_source::{
ack_response, compute_hmac_hex, parse_json_payload, request_to_event, validate_request,
WebhookConfig, WebhookRequest,
parse_json_payload, request_to_event, validate_request, WebhookConfig, WebhookRequest,
};
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
Expand Down Expand Up @@ -43,7 +42,7 @@ fn main() {
method: "POST".into(),
path: "/webhook".into(),
headers: HashMap::new(),
body: r#"{"action":"deploy","env":"production"}"#.into(),
body: r#"{"action":"deploy","env":"production"}"#.as_bytes().to_vec(),
source_ip: Some("10.0.0.1".into()),
};
let result = validate_request(&config, &good_req);
Expand All @@ -53,7 +52,7 @@ fn main() {
method: "GET".into(),
path: "/webhook".into(),
headers: HashMap::new(),
body: String::new(),
body: Vec::new(),
source_ip: None,
};
let result = validate_request(&config, &get_req);
Expand All @@ -63,7 +62,7 @@ fn main() {
method: "POST".into(),
path: "/admin/hack".into(),
headers: HashMap::new(),
body: "{}".into(),
body: "{}".as_bytes().to_vec(),
source_ip: None,
};
let result = validate_request(&config, &bad_path);
Expand All @@ -73,7 +72,13 @@ fn main() {
println!("\n3. HMAC Signature Verification:");
let secret = "my-webhook-secret";
let body = r#"{"event":"deploy"}"#;
let sig = compute_hmac_hex(secret, body);
let t_now = forjar::core::webhook_sig::unix_now();
let signed =
forjar::core::webhook_sig::canonical_payload(t_now, "POST", "/webhook", body.as_bytes());
let sig = format!(
"t={t_now},v1={}",
forjar::core::webhook_sig::compute_hmac_hex(secret.as_bytes(), &signed)
);
println!(" Secret: {secret}");
println!(" Signature: {}...", &sig[..16]);

Expand All @@ -86,7 +91,7 @@ fn main() {
method: "POST".into(),
path: "/webhook".into(),
headers: HashMap::new(),
body: body.into(),
body: body.as_bytes().to_vec(),
source_ip: None,
};
signed_req.headers.insert("x-forjar-signature".into(), sig);
Expand All @@ -106,7 +111,7 @@ fn main() {
r#"{"count":42,"tags":["web","prod"]}"#,
];
for body in &payloads {
match parse_json_payload(body) {
match parse_json_payload(body.as_bytes()) {
Ok(kv) => {
let pairs: Vec<_> = kv.iter().map(|(k, v)| format!("{k}={v}")).collect();
println!(" {} → {}", body, pairs.join(", "));
Expand All @@ -117,7 +122,7 @@ fn main() {

// 5. Convert to InfraEvent
println!("\n5. Request → InfraEvent:");
let event = request_to_event(&good_req).unwrap();
let event = request_to_event(&good_req, None, None).unwrap();
println!(" Type: {:?}", event.event_type);
println!(" Payload:");
for (k, v) in &event.payload {
Expand All @@ -126,9 +131,10 @@ fn main() {

// 6. HTTP response formatting
println!("\n6. Response Formatting:");
let resp = ack_response(200, "accepted");
let resp = String::from_utf8(forjar::core::webhook_http::response(200, "accepted")).unwrap();
println!(" 200: {}", resp.lines().next().unwrap());
let resp = ack_response(401, "unauthorized");
let resp =
String::from_utf8(forjar::core::webhook_http::response(401, "unauthorized")).unwrap();
println!(" 401: {}", resp.lines().next().unwrap());

// 7. Webhook server: start, receive, and stop
Expand Down
42 changes: 42 additions & 0 deletions src/cli/commands/subcmd_args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,48 @@ pub enum RulesCmd {
#[arg(long)]
json: bool,
},
/// Serve webhooks and evaluate them against a rulebook
///
/// The webhook receiver had no entry point at all: `run_webhook_server` had
/// zero non-test callers, so it could not be started, dogfooded, or reached by
/// any sender. Unreachable code cannot be verified by using it, which is why
/// its defects survived ~35 passing tests.
Serve {
/// Path to rulebook YAML file
#[arg(short, long, default_value = "forjar.yaml")]
file: PathBuf,
/// Address to bind. A non-loopback bind requires --tls-terminated-upstream.
#[arg(long, default_value = "127.0.0.1")]
bind: String,
/// Port to listen on
#[arg(long, default_value_t = 8484)]
port: u16,
/// Read the HMAC-SHA256 shared secret from this file
///
/// A file rather than a flag: a secret on the command line is visible in
/// `ps` output and lands in shell history.
#[arg(long)]
secret_file: Option<PathBuf>,
/// Request paths to accept (repeatable). Empty denies everything.
#[arg(long = "path", default_values_t = [String::from("/webhook")])]
paths: Vec<String>,
/// Accept unsigned requests. Every accepted request can fire rulebook
/// actions, so this must be stated explicitly.
#[arg(long)]
allow_unauthenticated: bool,
/// Assert TLS is terminated upstream, permitting a non-loopback bind
#[arg(long)]
tls_terminated_upstream: bool,
/// Signature freshness window in seconds
#[arg(long, default_value_t = 300)]
tolerance_secs: u64,
/// Validate the configuration and exit without binding
#[arg(long)]
check: bool,
/// JSON output
#[arg(long)]
json: bool,
},
}

/// FJ-3403: Plugin management subcommands.
Expand Down
1 change: 1 addition & 0 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ mod remote_state;
mod repro_proof;
mod reseal;
mod rules;
mod rules_serve;
mod run_task;
mod runtime_invariants;
mod saga_coordinator;
Expand Down
30 changes: 30 additions & 0 deletions src/cli/rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,36 @@ pub fn dispatch_rules(cmd: RulesCmd) -> Result<(), String> {
match cmd {
RulesCmd::Validate { file, json } => cmd_rules_validate(&file, json),
RulesCmd::Coverage { file, json } => cmd_rules_coverage(&file, json),
RulesCmd::Serve {
file,
bind,
port,
secret_file,
paths,
allow_unauthenticated,
tls_terminated_upstream,
tolerance_secs,
check,
json,
} => {
let config = super::rules_serve::build_config(
bind,
port,
secret_file.as_deref(),
paths,
allow_unauthenticated,
tls_terminated_upstream,
tolerance_secs,
)?;
// Rulebook first: a bad rulebook must not cost a bound port.
let rulebook = super::rules_serve::load_rulebook(&file)?;
if check {
config.validate_startup()?;
super::rules_serve::print_check(&config, rulebook.rulebooks.len(), json);
return Ok(());
}
super::rules_serve::serve(config, rulebook, json)
}
}
}

Expand Down
Loading
Loading