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
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:
parent
096068ed2b
commit
5b45c6c418
1
src-tauri/Cargo.lock
generated
1
src-tauri/Cargo.lock
generated
@ -6174,6 +6174,7 @@ dependencies = [
|
||||
"tokio-test",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
"warp",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user