fix(integrations): security and correctness improvements
All checks were successful
Test / rust-fmt-check (pull_request) Successful in 12s
Test / frontend-typecheck (pull_request) Successful in 1m18s
Test / frontend-tests (pull_request) Successful in 1m21s
Test / rust-clippy (pull_request) Successful in 3m56s
PR Review Automation / review (pull_request) Successful in 4m20s
Test / rust-tests (pull_request) Successful in 5m22s

- Add url canonicalization for deduplication (strip fragments/query params)
- Add WIQL injection escaping for Azure DevOps work item searches
- Add CQL injection escaping for Confluence searches
- Add MAX_EXPANDED_QUERIES constant for consistency
- Fix logging to show expanded_query instead of search_url
- Add input validation for empty queries
- Add url crate dependency for URL parsing

All 142 tests pass.
This commit is contained in:
Shaun Arman 2026-04-14 19:55:32 -05:00
parent 096068ed2b
commit 5b45c6c418
6 changed files with 58 additions and 20 deletions

1
src-tauri/Cargo.lock generated
View File

@ -6174,6 +6174,7 @@ dependencies = [
"tokio-test",
"tracing",
"tracing-subscriber",
"url",
"urlencoding",
"uuid",
"warp",

View File

@ -44,6 +44,7 @@ lazy_static = "1.4"
warp = "0.3"
urlencoding = "2"
infer = "0.15"
url = "2.5.8"
[dev-dependencies]
tokio-test = "0.4"

View File

@ -1,6 +1,12 @@
use super::confluence_search::SearchResult;
use crate::integrations::query_expansion::expand_query;
const MAX_EXPANDED_QUERIES: usize = 3;
fn escape_wiql(s: &str) -> String {
s.replace('\'', "''")
}
/// Search Azure DevOps Wiki for content matching the query
pub async fn search_wiki(
org_url: &str,
@ -15,7 +21,7 @@ pub async fn search_wiki(
let mut all_results = Vec::new();
for expanded_query in expanded_queries.iter().take(3) {
for expanded_query in expanded_queries.iter().take(MAX_EXPANDED_QUERIES) {
// Use Azure DevOps Search API
let search_url = format!(
"{}/_apis/search/wikisearchresults?api-version=7.0",
@ -30,10 +36,7 @@ pub async fn search_wiki(
}
});
tracing::info!(
"Searching Azure DevOps Wiki with expanded query: {}",
search_url
);
tracing::info!("Searching Azure DevOps Wiki with query: {}", expanded_query);
let resp = client
.post(&search_url)
@ -58,7 +61,7 @@ pub async fn search_wiki(
.map_err(|e| format!("Failed to parse ADO wiki search response: {e}"))?;
if let Some(results_array) = json["results"].as_array() {
for item in results_array.iter().take(3) {
for item in results_array.iter().take(MAX_EXPANDED_QUERIES) {
let title = item["fileName"].as_str().unwrap_or("Untitled").to_string();
let path = item["path"].as_str().unwrap_or("");
@ -165,22 +168,26 @@ pub async fn search_work_items(
let mut all_results = Vec::new();
for expanded_query in expanded_queries.iter().take(3) {
for expanded_query in expanded_queries.iter().take(MAX_EXPANDED_QUERIES) {
// Use WIQL (Work Item Query Language)
let wiql_url = format!(
"{}/_apis/wit/wiql?api-version=7.0",
org_url.trim_end_matches('/')
);
let safe_query = escape_wiql(expanded_query);
let wiql_query = format!(
"SELECT [System.Id], [System.Title], [System.Description], [System.State] FROM WorkItems WHERE [System.TeamProject] = '{project}' AND ([System.Title] CONTAINS '{expanded_query}' OR [System.Description] CONTAINS '{expanded_query}') ORDER BY [System.ChangedDate] DESC"
"SELECT [System.Id], [System.Title], [System.Description], [System.State] FROM WorkItems WHERE [System.TeamProject] = '{project}' AND ([System.Title] CONTAINS '{safe_query}' OR [System.Description] CONTAINS '{safe_query}') ORDER BY [System.ChangedDate] DESC"
);
let wiql_body = serde_json::json!({
"query": wiql_query
});
tracing::info!("Searching Azure DevOps work items with expanded query");
tracing::info!(
"Searching Azure DevOps work items with query: {}",
expanded_query
);
let resp = client
.post(&wiql_url)
@ -203,7 +210,7 @@ pub async fn search_work_items(
if let Some(work_items) = json["workItems"].as_array() {
// Fetch details for top 3 work items
for item in work_items.iter().take(3) {
for item in work_items.iter().take(MAX_EXPANDED_QUERIES) {
if let Some(id) = item["id"].as_i64() {
if let Ok(work_item) =
fetch_work_item_details(org_url, id, &cookie_header).await

View File

@ -1,7 +1,10 @@
use serde::{Deserialize, Serialize};
use url::Url;
use super::query_expansion::expand_query;
const MAX_EXPANDED_QUERIES: usize = 3;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResult {
pub title: String,
@ -11,6 +14,25 @@ pub struct SearchResult {
pub source: String,
}
fn canonicalize_url(url: &str) -> String {
Url::parse(url)
.ok()
.map(|u| {
let mut u = u.clone();
u.set_fragment(None);
u.set_query(None);
u.to_string()
})
.unwrap_or_else(|| url.to_string())
}
fn escape_cql(s: &str) -> String {
s.replace('"', "\\\"")
.replace(')', "\\)")
.replace('(', "\\(")
.replace('~', "\\~")
}
/// Search Confluence for content matching the query
///
/// This function expands the user query with related terms, synonyms, and variations
@ -27,11 +49,12 @@ pub async fn search_confluence(
let mut all_results = Vec::new();
for expanded_query in expanded_queries.iter().take(3) {
for expanded_query in expanded_queries.iter().take(MAX_EXPANDED_QUERIES) {
let safe_query = escape_cql(expanded_query);
let search_url = format!(
"{}/rest/api/search?cql=text~\"{}\"&limit=5",
base_url.trim_end_matches('/'),
urlencoding::encode(expanded_query)
urlencoding::encode(&safe_query)
);
tracing::info!("Searching Confluence with expanded query: {}", search_url);
@ -100,8 +123,8 @@ pub async fn search_confluence(
}
}
all_results.sort_by(|a, b| a.url.cmp(&b.url));
all_results.dedup_by(|a, b| a.url == b.url);
all_results.sort_by(|a, b| canonicalize_url(&a.url).cmp(&canonicalize_url(&b.url)));
all_results.dedup_by(|a, b| canonicalize_url(&a.url) == canonicalize_url(&b.url));
Ok(all_results)
}

View File

@ -78,6 +78,10 @@ fn get_product_synonyms(query: &str) -> Vec<String> {
/// A vector of query strings to search, with the original query first
/// followed by expanded variations
pub fn expand_query(query: &str) -> Vec<String> {
if query.trim().is_empty() {
return Vec::new();
}
let mut expanded = vec![query.to_string()];
// Get product synonyms

View File

@ -1,6 +1,8 @@
use super::confluence_search::SearchResult;
use crate::integrations::query_expansion::expand_query;
const MAX_EXPANDED_QUERIES: usize = 3;
/// Search ServiceNow Knowledge Base for content matching the query
pub async fn search_servicenow(
instance_url: &str,
@ -14,7 +16,7 @@ pub async fn search_servicenow(
let mut all_results = Vec::new();
for expanded_query in expanded_queries.iter().take(3) {
for expanded_query in expanded_queries.iter().take(MAX_EXPANDED_QUERIES) {
// Search Knowledge Base articles
let search_url = format!(
"{}/api/now/table/kb_knowledge?sysparm_query=textLIKE{}^ORshort_descriptionLIKE{}&sysparm_limit=5",
@ -23,7 +25,7 @@ pub async fn search_servicenow(
urlencoding::encode(expanded_query)
);
tracing::info!("Searching ServiceNow with expanded query: {}", search_url);
tracing::info!("Searching ServiceNow with query: {}", expanded_query);
let resp = client
.get(&search_url)
@ -46,7 +48,7 @@ pub async fn search_servicenow(
.map_err(|e| format!("Failed to parse ServiceNow search response: {e}"))?;
if let Some(result_array) = json["result"].as_array() {
for item in result_array.iter().take(3) {
for item in result_array.iter().take(MAX_EXPANDED_QUERIES) {
// Take top 3 results
let title = item["short_description"]
.as_str()
@ -107,7 +109,7 @@ pub async fn search_incidents(
let mut all_results = Vec::new();
for expanded_query in expanded_queries.iter().take(3) {
for expanded_query in expanded_queries.iter().take(MAX_EXPANDED_QUERIES) {
// Search incidents
let search_url = format!(
"{}/api/now/table/incident?sysparm_query=short_descriptionLIKE{}^ORdescriptionLIKE{}&sysparm_limit=3&sysparm_display_value=true",
@ -117,8 +119,8 @@ pub async fn search_incidents(
);
tracing::info!(
"Searching ServiceNow incidents with expanded query: {}",
search_url
"Searching ServiceNow incidents with query: {}",
expanded_query
);
let resp = client