Skip to content
Merged
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,7 @@ A complete, commented example ships in
| `max_attachment_bytes` | integer | `2097152` (2 MiB) | Largest attachment `download_attachment` may return, and the same ceiling on what `add_attachment` may upload — both measured on the decoded size. `0` removes this cap; over http the transport still refuses a request body above 4 MiB, so an upload stays bounded either way. Downloaded content is embedded base64 in the tool result and lands in the model's context — raise deliberately |
| `identity_source` | `"whoami"` \| `"declared"` | `"whoami"` | How `created_by_me` resolves the caller's login. `whoami` calls Bugzilla's `GET /rest/whoami` — a fork/BMO extension absent from stock Bugzilla Core v1. `declared` names an operator-configured login instead (see `identity_login`), verified once at startup against the *stock* `GET /rest/valid_login` endpoint and never looked up again per call — the portable path when the deployment has no identity endpoint at all. See the `created_by_me` row below and "Identity resolution" in `docs/DESIGN.md` |
| `identity_login` | string | none | Required (and must be non-blank) exactly when `identity_source = "declared"`; a hard startup error if set under `identity_source = "whoami"` (it would otherwise be silently ignored). Names the account that owns *this server's* API key, so it is only meaningful under a server-held key (stdio, or http server-held mode) — a startup error under http per-request key custody, where there is no server-held key for it to describe. Bugzilla compares logins case-sensitively (Perl `eq`); declare it exactly as Bugzilla stores it |
| `allow_discovery` | boolean | `false` | Exposes `bugzilla_products` and `bug_fields`, two read-only tools that return this Bugzilla instance's product and bug-field metadata **exactly as Bugzilla returns it to this server's key, never filtered by this guard policy** — filtering the catalog would itself be a way to probe the policy's rules. Leave this off (the default) if product or field names are themselves confidential; `disabled_tools` still works independently once discovery is on. Older bugwarden versions reject a policy using this key at startup (strict parsing fails closed) |

### `[[rule]]`

Expand Down Expand Up @@ -685,6 +686,8 @@ none of the three needs an API key. Every other tool does, including
| `add_attachment` | Upload a base64-encoded attachment to a bug, optionally private or flagged as a patch, capped by `max_attachment_bytes` (decoded size) | `attach` on the target bug |
| `bug_url` | Compute `{server}/show_bug.cgi?id={id}` locally | none (contacts nothing) |
| `bugzilla_server_info` | Bugzilla version, extensions, timezone, time, parameters | none |
| `bugzilla_products` | *(needs `global.allow_discovery = true`)* Lists enterable product names, or fetches components/versions/milestones for up to 5 named products — as Bugzilla reports it to this server's key, never filtered by this policy | none |
| `bug_fields` | *(needs `global.allow_discovery = true`)* Lists bug fields (without legal values), or fetches up to 5 named fields with their legal values — as Bugzilla reports it to this server's key, never filtered by this policy | none |
| `quicksearch_syntax` | Bugzilla's quicksearch syntax documentation (HTML) | none |
| `mcp_server_info` | This server's name and version, the Bugzilla URL, the transport, and a coarse policy summary: rule count, default action, `min_bug_age_days`, read-only flag, disabled tool names. Never a rule name or a match criterion | none |

Expand Down
94 changes: 94 additions & 0 deletions crates/bugwarden-core/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,73 @@ impl BugzillaClient {
.filter(|att| !att.is_null()))
}

/// GET `/rest/product_enterable` — ids of the products the caller's key
/// may file a bug into.
///
/// Bugzilla's documented example encodes ids as strings; some
/// deployments return JSON numbers instead. Both are accepted; any
/// other shape is an error, never a silently dropped entry.
pub async fn enterable_product_ids(&self, key: &str) -> Result<Vec<u64>> {
let v = self.get_json(key, "/product_enterable", &[]).await?;
let ids = v.get("ids").and_then(Value::as_array).ok_or_else(|| {
anyhow!("bugzilla /product_enterable response carries no usable \"ids\" array")
})?;
ids.iter()
.map(|id| {
parse_id(id).ok_or_else(|| {
anyhow!("bugzilla /product_enterable response contains a non-numeric id")
})
})
.collect()
}

/// GET `/rest/product?ids=..&names=..[&include_fields=..]` — returns the
/// whole response envelope (`{"products":[..]}`). `ids`/`names` are
/// independently optional; Bugzilla accepts either, both, or neither
/// (neither means "every accessible product").
pub async fn products(
&self,
key: &str,
ids: &[u64],
names: &[&str],
include_fields: Option<&[&str]>,
) -> Result<Value> {
let mut query: Vec<(&str, String)> = Vec::new();
for id in ids {
query.push(("ids", id.to_string()));
}
for name in names {
query.push(("names", (*name).to_string()));
}
if let Some(fields) = include_fields {
query.push(("include_fields", fields.join(",")));
}
self.get_json(key, "/product", &query).await
}

/// GET `/rest/field/bug[/{name}]` — returns the whole response envelope
/// (`{"fields":[..]}`), every field when `name` is `None`.
///
/// `name` is a caller-supplied string, so it is percent-encoded as a
/// single URL path segment (`Url::path_segments_mut`) rather than
/// interpolated into a format string: an unescaped `/` in `name` must
/// not be able to address a different endpoint.
pub async fn bug_fields(&self, key: &str, name: Option<&str>) -> Result<Value> {
match name {
Some(n) => {
let mut url = reqwest::Url::parse(&self.api_url)
.map_err(|e| anyhow!("bugzilla api_url is not a valid URL: {e}"))?;
url.path_segments_mut()
.map_err(|()| anyhow!("bugzilla api_url cannot be a base for path segments"))?
.push("field")
.push("bug")
.push(n);
self.get_json_url(key, url).await
}
None => self.get_json(key, "/field/bug", &[]).await,
}
}

/// GET `{base_url}/page.cgi?id=quicksearch.html` — the quicksearch
/// syntax documentation page. This is a plain HTML page, not a REST
/// endpoint, and needs no authentication: no API key is attached.
Expand Down Expand Up @@ -467,6 +534,22 @@ impl BugzillaClient {
parse_response(status, &body)
}

/// Authenticated GET of a full, already-built URL — for callers that
/// must percent-encode a caller-supplied path segment
/// (`Url::path_segments_mut`) instead of interpolating it into a
/// format string. Logs the URL's path only, same as [`Self::get_json`]
/// (I12).
async fn get_json_url(&self, key: &str, url: reqwest::Url) -> Result<Value> {
let path = url.path().to_string();
let rb = self
.http
.get(url)
.header(reqwest::header::ACCEPT, "application/json");
let rb = self.apply_auth(rb, key);
let (status, body) = self.send(rb, "GET", &path).await?;
parse_response(status, &body)
}

/// Authenticated POST/PUT of a JSON payload to a REST path, returning
/// the parsed JSON body.
async fn send_json_body(
Expand Down Expand Up @@ -495,6 +578,17 @@ fn sanitize(e: reqwest::Error) -> anyhow::Error {
anyhow::Error::new(e.without_url())
}

/// Parse a Bugzilla-reported id that may be a JSON number or a numeric
/// string — `product_enterable`'s documented example encodes them as
/// strings, some deployments as numbers.
fn parse_id(v: &Value) -> Option<u64> {
match v {
Value::Number(n) => n.as_u64(),
Value::String(s) => s.parse().ok(),
_ => None,
}
}

/// Fail on HTTP-level errors: a non-2xx status, or a Bugzilla error body
/// (`{"error": true, ...}`) even under a 200 status. The error text carries
/// the HTTP status and the Bugzilla `message` field when present — never the
Expand Down
19 changes: 19 additions & 0 deletions crates/bugwarden-core/src/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,15 @@ pub struct GlobalGuards {
/// Bugzilla's own `eq` — case-sensitive (see DESIGN.md).
#[serde(default)]
pub identity_login: Option<String>,
/// Whether the product/field discovery tools (`bugzilla_products`,
/// `bug_fields`) are exposed at all. Defaults to `false`: they return
/// instance metadata exactly as Bugzilla returned it, unfiltered by
/// this policy (I16), so an operator who treats product or field names
/// as confidential must opt in explicitly rather than rely on a
/// filtered catalog — filtering it would itself be a
/// policy-enumeration oracle.
#[serde(default)]
pub allow_discovery: bool,
}

fn default_max_attachment_bytes() -> u64 {
Expand All @@ -607,6 +616,7 @@ impl Default for GlobalGuards {
max_attachment_bytes: default_max_attachment_bytes(),
identity_source: IdentitySource::default(),
identity_login: None,
allow_discovery: false,
}
}
}
Expand Down Expand Up @@ -1765,6 +1775,15 @@ products = ["SUSE*"]
assert!(!d.global.allow_private_comments); // I5
assert!(!d.global.read_only);
assert_eq!(d.global.min_bug_age_days, 0);
assert!(!d.global.allow_discovery); // I16: off unless opted in
}

#[test]
fn allow_discovery_defaults_false_and_parses_true() {
let p = Policy::from_toml_str("").unwrap();
assert!(!p.global.allow_discovery);
let p = Policy::from_toml_str("[global]\nallow_discovery = true\n").unwrap();
assert!(p.global.allow_discovery);
}

#[test]
Expand Down
199 changes: 199 additions & 0 deletions crates/bugwarden-core/tests/discovery_wiremock.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
//! HTTP-level integration tests for the discovery client endpoints
//! (wiremock): `enterable_product_ids`, `products`, `bug_fields`. Covers the
//! documented envelope shapes, a malformed envelope, and that no error text
//! contains the API key (I12).

use bugwarden_core::client::BugzillaClient;
use serde_json::json;
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};

/// Deliberately distinctive so a leak into any error text is unmistakable (I12).
const KEY: &str = "SUPERSECRETKEY123";

/// Any identity will do here — the client requires one (#55) but these
/// suites assert nothing about it; `user_agent_wiremock.rs` owns that
/// proof. Names neither crate, so a check for either finds nothing.
const TEST_USER_AGENT: &str = "probe-agent/0.0.0";

fn client(server: &MockServer) -> BugzillaClient {
BugzillaClient::new(&server.uri(), false, TEST_USER_AGENT).expect("client must build")
}

#[tokio::test]
async fn enterable_product_ids_parses_string_ids() {
// Bugzilla's own documented example encodes ids as strings.
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/product_enterable"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "ids": ["2", "3", "19"] })))
.mount(&server)
.await;

let ids = client(&server)
.enterable_product_ids(KEY)
.await
.expect("request must succeed");
assert_eq!(ids, vec![2, 3, 19]);
}

#[tokio::test]
async fn enterable_product_ids_parses_numeric_ids() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/product_enterable"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "ids": [2, 3, 19] })))
.mount(&server)
.await;

let ids = client(&server)
.enterable_product_ids(KEY)
.await
.expect("request must succeed");
assert_eq!(ids, vec![2, 3, 19]);
}

#[tokio::test]
async fn enterable_product_ids_errors_on_malformed_envelope() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/product_enterable"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "nope": [] })))
.mount(&server)
.await;

let err = client(&server)
.enterable_product_ids(KEY)
.await
.expect_err("a missing ids array must be an error, never an empty list");
assert!(err.to_string().contains("ids"));
}

#[tokio::test]
async fn products_sends_ids_names_and_include_fields() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/product"))
.and(query_param("ids", "1"))
.and(query_param("names", "TestProduct"))
.and(query_param("include_fields", "id,name"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"products": [{ "id": 1, "name": "TestProduct" }]
})))
.mount(&server)
.await;

let v = client(&server)
.products(KEY, &[1], &["TestProduct"], Some(&["id", "name"]))
.await
.expect("request must succeed");
assert_eq!(v["products"][0]["name"], json!("TestProduct"));
}

#[tokio::test]
async fn products_returns_the_full_envelope() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/product"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"products": [{
"id": 1,
"name": "TestProduct",
"components": [{
"name": "core",
"default_assigned_to": "admin@bugzilla.org",
}],
}]
})))
.mount(&server)
.await;

let v = client(&server)
.products(KEY, &[], &[], None)
.await
.expect("request must succeed");
// The client is a raw pass-through; local projection is the server
// tool's job, not the client's — this pins that the client itself does
// not already strip anything.
assert_eq!(
v["products"][0]["components"][0]["default_assigned_to"],
json!("admin@bugzilla.org")
);
}

#[tokio::test]
async fn bug_fields_with_no_name_fetches_the_full_catalog() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/field/bug"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"fields": [{ "name": "priority", "display_name": "Priority" }]
})))
.mount(&server)
.await;

let v = client(&server)
.bug_fields(KEY, None)
.await
.expect("request must succeed");
assert_eq!(v["fields"][0]["name"], json!("priority"));
}

#[tokio::test]
async fn bug_fields_with_a_name_addresses_that_field_only() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/field/bug/priority"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"fields": [{ "name": "priority", "values": [{ "name": "P1" }] }]
})))
.mount(&server)
.await;

let v = client(&server)
.bug_fields(KEY, Some("priority"))
.await
.expect("request must succeed");
assert_eq!(v["fields"][0]["values"][0]["name"], json!("P1"));
}

#[tokio::test]
async fn bug_fields_percent_encodes_the_name_segment() {
// A name containing '/' must not be able to address a different
// endpoint by escaping the path segment.
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/field/bug/a%2Fb"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "fields": [] })))
.mount(&server)
.await;

let v = client(&server)
.bug_fields(KEY, Some("a/b"))
.await
.expect("the escaped segment must reach the mock, not /rest/field/bug/a/b");
assert_eq!(v["fields"], json!([]));
}

#[tokio::test]
async fn discovery_errors_never_leak_the_key() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/product_enterable"))
.respond_with(ResponseTemplate::new(401).set_body_json(json!({
"error": true, "message": "You must log in before using this part of Bugzilla."
})))
.mount(&server)
.await;

let err = client(&server)
.enterable_product_ids(KEY)
.await
.expect_err("401 must be an error");
let text = format!("{err:#}");
assert!(text.contains("401"), "status must be reported: {text}");
assert!(
!text.contains(KEY),
"API key leaked into error text: {text}"
);
}
Loading