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
50 changes: 48 additions & 2 deletions crates/jirakeep-core/src/guard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,18 @@ pub struct Guard {
pub policy: Policy,
}

/// Result of one search scan (client sees only `issues`).
/// Result of one search scan (client sees only `issues` and
/// `next_page_token`).
///
/// `scanned`, `dropped_keys`, and `scrubbed_keys` are server-side audit
/// data and must never reach the MCP client (I3).
#[derive(Debug, Clone, Default)]
pub struct SearchWindow {
pub issues: Vec<Value>,
pub scanned: u32,
pub dropped_keys: Vec<String>,
/// Linked issue keys removed from served issues by I14 scrubbing.
pub scrubbed_keys: Vec<String>,
pub next_page_token: Option<String>,
}

Expand Down Expand Up @@ -228,7 +234,13 @@ impl Guard {
(visible, dropped)
}

/// JQL search with silent post-filter (I3).
/// JQL search with silent post-filter (I3) and linked-key scrubbing (I14).
///
/// Issue keys referenced from served issues (`issuelinks`, `parent`,
/// `subtasks`) that are not positively disclosable — policy-denied,
/// unfetchable, or past the [`Guard::MAX_ASSESS_KEYS`] assessment bound —
/// are removed from the served bodies and reported only via
/// [`SearchWindow::scrubbed_keys`] (fail closed, I4).
#[allow(clippy::too_many_arguments)]
pub async fn search_filtered(
&self,
Expand All @@ -254,7 +266,40 @@ impl Guard {
.unwrap_or_default();
let scanned = raw.len() as u32;
let (mut visible, dropped) = self.filter_issue_list(&raw, caller);
// Keys classified above as at least Summary-visible are disclosable
// in this window without spending assessment budget on a re-fetch.
let mut disclosable: BTreeSet<String> = visible
.iter()
.filter_map(|i| i.get("key").and_then(Value::as_str))
.map(str::to_owned)
.collect();
visible.truncate(max_results as usize);

// I14: keys referenced from served issues (issuelinks, parent,
// subtasks) must themselves be disclosable; assess the unknown ones.
let mut candidates: BTreeSet<String> = BTreeSet::new();
for issue in &visible {
candidates.extend(Self::linked_keys(issue));
}
candidates.retain(|k| {
!disclosable.iter().any(|d| d.eq_ignore_ascii_case(k))
&& !dropped.iter().any(|d| d.eq_ignore_ascii_case(k))
});
if !candidates.is_empty() {
// Bounded by MAX_ASSESS_KEYS inside `assess`; candidates past the
// bound or with failed fetches stay non-disclosable and scrub (I4).
let extra = self.disclosable(jira, creds, &candidates, caller).await;
disclosable.extend(extra);
}
let mut scrubbed_keys: Vec<String> = Vec::new();
for issue in &mut visible {
let (clean, removed) = Self::scrub_links(issue, &disclosable);
*issue = clean;
scrubbed_keys.extend(removed);
}
scrubbed_keys.sort();
scrubbed_keys.dedup();

let next = envelope
.get("nextPageToken")
.and_then(Value::as_str)
Expand All @@ -263,6 +308,7 @@ impl Guard {
issues: visible,
scanned,
dropped_keys: dropped,
scrubbed_keys,
next_page_token: next,
})
}
Expand Down
138 changes: 138 additions & 0 deletions crates/jirakeep-core/tests/guard_wiremock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,144 @@ projects = ["SEC"]
// Client path would only see issues + nextPageToken — never dropped.
}

#[tokio::test]
async fn search_filtered_scrubs_denied_linked_keys() {
let server = MockServer::start().await;
// PUB-7 references denied and unavailable issues through its link fields.
let mut pub7 = issue("PUB-7", "PUB", None);
pub7["fields"]["issuelinks"] = json!([
{"outwardIssue": {"key": "SEC-42"}},
{"inwardIssue": {"key": "PUB-8"}},
]);
pub7["fields"]["parent"] = json!({"key": "SEC-1"});
pub7["fields"]["subtasks"] = json!([{"key": "SEC-2"}, {"key": "PUB-9"}]);
Mock::given(method("POST"))
.and(path("/rest/api/3/search/jql"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
// PUB-8 is in the served window: its key must stay disclosable
// without a classification re-fetch (no GET mock for PUB-8).
"issues": [pub7, issue("PUB-8", "PUB", None)],
})))
.mount(&server)
.await;
// Assessment fetches for linked keys outside the served window.
Mock::given(method("GET"))
.and(path("/rest/api/3/issue/SEC-42"))
.respond_with(ResponseTemplate::new(200).set_body_json(issue("SEC-42", "SEC", None)))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/api/3/issue/SEC-1"))
.respond_with(ResponseTemplate::new(200).set_body_json(issue("SEC-1", "SEC", None)))
.mount(&server)
.await;
// SEC-2 cannot be fetched at all: fail closed, scrub (I4).
Mock::given(method("GET"))
.and(path("/rest/api/3/issue/SEC-2"))
.respond_with(ResponseTemplate::new(500).set_body_json(json!({})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/api/3/issue/PUB-9"))
.respond_with(ResponseTemplate::new(200).set_body_json(issue("PUB-9", "PUB", None)))
.mount(&server)
.await;

let g = guard(
r#"
default_action = "allow"
[[rule]]
name = "hide"
action = "deny"
[rule.match]
projects = ["SEC"]
"#,
);
let window = g
.search_filtered(
&client(&server),
&creds(),
"project = PUB",
10,
None,
None,
None,
)
.await
.unwrap();
assert_eq!(window.issues.len(), 2);
let served = &window.issues[0];
assert_eq!(served["key"], json!("PUB-7"));
let links = served["fields"]["issuelinks"].as_array().unwrap();
assert_eq!(links.len(), 1, "denied SEC-42 link must be scrubbed");
assert_eq!(links[0]["inwardIssue"]["key"], json!("PUB-8"));
assert!(
served["fields"].get("parent").is_none(),
"denied parent must be scrubbed"
);
let subs = served["fields"]["subtasks"].as_array().unwrap();
assert_eq!(subs.len(), 1, "unfetchable SEC-2 must scrub fail-closed");
assert_eq!(subs[0]["key"], json!("PUB-9"));
// Scrubbed keys stay on the audit side (I3); issues_search returns
// window.issues verbatim, so no served body may name a denied key.
assert_eq!(
window.scrubbed_keys,
vec!["SEC-1".to_string(), "SEC-2".into(), "SEC-42".into()]
);
let body = serde_json::to_string(&window.issues).unwrap();
assert!(!body.contains("SEC-"), "denied key leaked: {body}");
}

#[tokio::test]
async fn search_filtered_scrubs_linked_keys_past_assess_bound() {
let server = MockServer::start().await;
let total = Guard::MAX_ASSESS_KEYS + 5;
let subtasks: Vec<serde_json::Value> = (1..=total)
.map(|i| json!({"key": format!("LNK-{i}")}))
.collect();
let mut parent = issue("PUB-1", "PUB", None);
parent["fields"]["subtasks"] = json!(subtasks);
Mock::given(method("POST"))
.and(path("/rest/api/3/search/jql"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"issues": [parent]})))
.mount(&server)
.await;
// Every linked key would classify as allowed if it were assessed.
Mock::given(method("GET"))
.and(path_regex(r"^/rest/api/3/issue/LNK-\d+$"))
.respond_with(ResponseTemplate::new(200).set_body_json(issue("LNK-0", "LNK", None)))
.mount(&server)
.await;

let g = guard("default_action = \"allow\"\n");
let window = g
.search_filtered(
&client(&server),
&creds(),
"project = PUB",
10,
None,
None,
None,
)
.await
.unwrap();
assert_eq!(window.issues.len(), 1);
let subs = window.issues[0]["fields"]["subtasks"].as_array().unwrap();
assert_eq!(
subs.len(),
Guard::MAX_ASSESS_KEYS,
"keys past the assessment bound must scrub, not pass (I4)"
);
assert_eq!(window.scrubbed_keys.len(), total - Guard::MAX_ASSESS_KEYS);
for key in &window.scrubbed_keys {
assert!(
!subs.iter().any(|s| s["key"] == json!(key.as_str())),
"scrubbed key {key} still served"
);
}
}

#[tokio::test]
async fn denial_text_has_no_token() {
let msg = Guard::denial("SEC-1");
Expand Down
5 changes: 5 additions & 0 deletions crates/jirakeep/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -625,6 +625,11 @@ impl JiraKeep {
cell.note_verdict(Verdict::Filtered);
cell.note_suppressed(window.dropped_keys.clone());
cell.note_suppressed_count(window.dropped_keys.len() as u64);
// I14: linked keys scrubbed from served issues are
// audit-side only, never client-visible.
if !window.scrubbed_keys.is_empty() {
cell.note_suppressed(window.scrubbed_keys.clone());
}
cell.note_scan(u64::from(window.scanned), window.dropped_keys.len() as u64);
}
// I3: do not return scanned/dropped counts to the client.
Expand Down