diff --git a/.gitignore b/.gitignore index 5e9dde0..fb40dee 100644 --- a/.gitignore +++ b/.gitignore @@ -68,6 +68,7 @@ Cargo.lock !.vscode/launch.json !.vscode/extensions.json !.vscode/*.code-snippets +.vscode/launch.json # Local History for Visual Studio Code .history/ diff --git a/CHANGELOG.md b/CHANGELOG.md index d89083c..f21fc28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes to this project will be documented in this file. +## [1.26.0] +- Added rule for ElevenLabs +- Added support for scanning Jira issues via a given JQL (Jira Query Language) + ## [1.25.0] - Fixed GitLab authentication bug - Added pre-commit and pre-receive installation hooks diff --git a/Cargo.toml b/Cargo.toml index 930056a..eff7a26 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ publish = false [package] name = "kingfisher" -version = "1.25.0" +version = "1.26.0" description = "MongoDB's blazingly fast secret scanning and validation tool" edition.workspace = true rust-version.workspace = true @@ -185,6 +185,7 @@ semver = "1.0.26" globset = "0.4.16" jsonwebtoken = "9.3.1" ipnet = "2.11.0" +jira_query = "1.6.0" [dependencies.tikv-jemallocator] version = "0.6" @@ -223,6 +224,7 @@ codegen-units = 256 [patch.crates-io] vectorscan-rs = { path = "vendor/vectorscan-rs/vectorscan-rs" } vectorscan-rs-sys = { path = "vendor/vectorscan-rs/vectorscan-rs-sys" } +jira_query = { path = "vendor/jira_query" } [profile.profiling] inherits = "release" diff --git a/NOTICE b/NOTICE index 62c3bf2..6dd6a63 100644 --- a/NOTICE +++ b/NOTICE @@ -1,5 +1,14 @@ NOTICE file corresponding to Section 4 (d) of the Apache License, Version 2.0 +-------------------------------------------------------------------- +Notices for Kingfisher +-------------------------------------------------------------------- +Copyright 2025 MongoDB, Inc. +https://www.mongodb.com + +Source repository: https://github.com/mongodb/kingfisher + + -------------------------------------------------------------------- Upstream notices -------------------------------------------------------------------- @@ -21,11 +30,3 @@ distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. - --------------------------------------------------------------------- -Additional notices for Kingfisher --------------------------------------------------------------------- -Copyright 2025 MongoDB, Inc. -https://www.mongodb.com - -Source repository: https://github.com/mongodb/kingfisher diff --git a/README.md b/README.md index f7e7c0b..7ce5dad 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Kingfisher extends Nosey Parker with live secret validation via cloud-provider A - **Language‑Aware Accuracy**: AST parsing in 20+ languages via Tree‑Sitter reduces contextless regex matches. see [docs/PARSING.md](/docs/PARSING.md) - **Built-In Validation**: Hundreds of built-in detection rules, many with live-credential validators that call the relevant service APIs (AWS, Azure, GCP, Stripe, etc.) to confirm a secret is active. You can extend or override the library by adding YAML-defined rules on the command line—see [docs/RULES.md](/docs/RULES.md) for details - **Git History Scanning**: Scan local repos, remote GitHub/GitLab orgs/users, or arbitrary GitHub/GitLab repos +- **Jira Scanning**: Scan issues returned from a JQL search using `--jira-url` and `--jql` - **Baseline Support:** Generate and manage baseline files to ignore known secrets and report only newly introduced ones. See ([docs/BASELINE.md](docs/BASELINE.md)) for details. # Getting Started @@ -285,14 +286,34 @@ kingfisher scan --git-url https://gitlab.com/group/project.git kingfisher gitlab repos list --group my-group ``` +## Scanning Jira + +### Scan Jira issues matching a JQL query + +```bash +KF_JIRA_TOKEN="token" kingfisher scan \ + --jira-url https://jira.company.com \ + --jql "project = TEST AND status = Open" \ + --max-results 500 +``` + +### Scan the last 1,000 Jira issues: +```bash +KF_JIRA_TOKEN="token" kingfisher scan \ + --jira-url https://jira.mongodb.org \ + --jql 'ORDER BY created DESC' \ + --max-results 1000 +``` --- + ## Environment Variables for Tokens | Variable | Purpose | | ----------------- | ---------------------------- | | `KF_GITHUB_TOKEN` | GitHub Personal Access Token | | `KF_GITLAB_TOKEN` | GitLab Personal Access Token | +| `KF_JIRA_TOKEN` | Jira API token | Set them temporarily per command: @@ -306,6 +327,11 @@ Or export for the session: export KF_GITLAB_TOKEN="glpat-…" ``` +To authenticate Jira requests: +```bash +export KF_JIRA_TOKEN="token" +``` + _If no token is provided Kingfisher still works for public repositories._ --- diff --git a/data/rules/elevenlabs.yml b/data/rules/elevenlabs.yml new file mode 100644 index 0000000..ade342c --- /dev/null +++ b/data/rules/elevenlabs.yml @@ -0,0 +1,36 @@ +rules: + - name: ElevenLabs API Key + id: kingfisher.elevenlabs.1 + pattern: | + (?xi) + \b + ( + sk_ + [0-9a-f]{48} + ) + \b + min_entropy: 3.5 + confidence: medium + examples: + - sk_2a30e5a0d39d5f2c5f6a9d2f95cd016049a6323985479bfd + - sk_da9c0613fdeecfab10b302d6f39a3e371f774feb9eafed56 + - sk_82a331629e2128ef70396600809b6a2ff4e433154fa27e1b + references: + - https://elevenlabs.io/docs/api-reference/authentication + - https://elevenlabs.io/docs/api-reference/user/subscription/get + + validation: + type: Http + content: + request: + method: GET + url: https://api.elevenlabs.io/v1/user/subscription + headers: + xi-api-key: '{{ TOKEN }}' + response_matcher: + - report_response: true + - type: WordMatch + match_all_words: false + words: + - '"tier"' + - '"missing_permissions"' diff --git a/data/rules/jira.yml b/data/rules/jira.yml index c0b14a2..82ac195 100644 --- a/data/rules/jira.yml +++ b/data/rules/jira.yml @@ -12,7 +12,7 @@ rules: confidence: medium examples: - example-jira.atlassian.net - - jira.sprintUri= https://leakyday.atlassian.net/rest + - jira.sprintUri= https://example.atlassian.net/rest - name: Jira Token id: kingfisher.jira.2 diff --git a/src/cli/commands/inputs.rs b/src/cli/commands/inputs.rs index 20d3dde..c7a59bc 100644 --- a/src/cli/commands/inputs.rs +++ b/src/cli/commands/inputs.rs @@ -25,7 +25,8 @@ pub struct InputSpecifierArgs { "gitlab_group", "git_url", "all_github_organizations", - "all_gitlab_groups" + "all_gitlab_groups", + "jira_url" ]), value_hint = ValueHint::AnyPath )] @@ -84,6 +85,18 @@ pub struct InputSpecifierArgs { #[arg(long, default_value_t = GitLabRepoType::Owner)] pub gitlab_repo_type: GitLabRepoType, + /// Jira base URL (e.g. https://jira.example.com) + #[arg(long, value_hint = ValueHint::Url, requires = "jql")] + pub jira_url: Option, + + /// JQL query to select Jira issues + #[arg(long, requires = "jira_url")] + pub jql: Option, + + /// Maximum number of Jira results to fetch + #[arg(long, default_value_t = 100)] + pub max_results: usize, + /// Select how to clone Git repositories #[arg(long, default_value_t=GitCloneMode::Bare, alias="git-clone-mode")] pub git_clone: GitCloneMode, diff --git a/src/findings_store.rs b/src/findings_store.rs index 07d20bf..7d3cd76 100644 --- a/src/findings_store.rs +++ b/src/findings_store.rs @@ -280,6 +280,13 @@ impl FindingsStore { self.clone_dir.join(repo_identifier) } + /// Return the directory used to store cloned repositories and other + /// temporary artifacts. + pub fn clone_root(&self) -> PathBuf { + self.clone_dir.clone() + } + + pub fn get_finding_data_iter( &self, ) -> impl Iterator + '_ { diff --git a/src/jira.rs b/src/jira.rs new file mode 100644 index 0000000..9b9e4fb --- /dev/null +++ b/src/jira.rs @@ -0,0 +1,52 @@ +use anyhow::{Context, Result}; +use jira_query::{Auth, JiraInstance, Pagination}; +use reqwest::Client; +use url::Url; + +// Re-export the Issue type from jira_query so callers don't depend on the crate. +pub use jira_query::Issue as JiraIssue; +pub async fn fetch_issues( + jira_url: Url, + jql: &str, + max_results: usize, + ignore_certs: bool, +) -> Result> { + // build a &str without any trailing `/` + let base = jira_url.as_str().trim_end_matches('/'); + + let client = Client::builder() + .danger_accept_invalid_certs(ignore_certs) + .build() + .context("Failed to build HTTP client")?; + + let mut jira = JiraInstance::at(base.to_string())? // no trailing slash here + .with_client(client) + .paginate(Pagination::MaxResults(max_results as u32)); + + if let Ok(token) = std::env::var("KF_JIRA_TOKEN") { + jira = jira.authenticate(Auth::ApiKey(token)); + } + + let issues = jira.search(jql).await?; + Ok(issues) +} + +use std::path::PathBuf; + +pub async fn download_issues_to_dir( + jira_url: Url, + jql: &str, + max_results: usize, + ignore_certs: bool, + output_dir: &PathBuf, +) -> Result> { + std::fs::create_dir_all(output_dir)?; + let issues = fetch_issues(jira_url, jql, max_results, ignore_certs).await?; + let mut paths = Vec::new(); + for issue in issues { + let file = output_dir.join(format!("{}.json", issue.key)); + std::fs::write(&file, serde_json::to_vec(&issue)?)?; + paths.push(file); + } + Ok(paths) +} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 26703fc..af74e7b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,6 +18,7 @@ pub mod git_url; pub mod github; pub mod gitlab; pub mod guesser; +pub mod jira; pub mod liquid_filters; pub mod location; pub mod matcher; diff --git a/src/main.rs b/src/main.rs index 44a3ec3..3b0d444 100644 --- a/src/main.rs +++ b/src/main.rs @@ -279,6 +279,10 @@ fn create_default_scan_args() -> cli::commands::scan::ScanArgs { gitlab_api_url: Url::parse("https://gitlab.com/").unwrap(), gitlab_repo_type: GitLabRepoType::Owner, + jira_url: None, + jql: None, + max_results: 50, + // git clone / history options git_clone: GitCloneMode::Bare, git_history: GitHistoryMode::Full, diff --git a/src/reporter.rs b/src/reporter.rs index 93583fa..f6a3331 100644 --- a/src/reporter.rs +++ b/src/reporter.rs @@ -112,6 +112,34 @@ impl DetailsReporter { None } } + + + /// If the given file path corresponds to a Jira issue downloaded to disk, + /// return the online Jira URL for that issue. + fn jira_issue_url( + &self, + path: &std::path::Path, + args: &cli::commands::scan::ScanArgs, + ) -> Option { + // drop any trailing slash so we don’t end up with “//browse/…” + let jira_url = args + .input_specifier_args + .jira_url + .as_ref()? + .as_str() + .trim_end_matches('/'); + + let ds = self.datastore.lock().ok()?; + let root = ds.clone_root(); + let jira_dir = root.join("jira_issues"); + if path.starts_with(&jira_dir) { + let key = path.file_stem()?.to_string_lossy(); + Some(format!("{}/browse/{}", jira_url, key)) + } else { + None + } + } + fn gather_findings(&self) -> Result> { let metadata_list = self.get_finding_data()?; let all_matches = self.get_filtered_matches()?; @@ -288,7 +316,7 @@ impl Reportable for DetailsReporter { ReportOutputFormat::Json => self.json_format(writer, args), ReportOutputFormat::Jsonl => self.jsonl_format(writer, args), ReportOutputFormat::Bson => self.bson_format(writer, args), - ReportOutputFormat::Sarif => self.sarif_format(writer, args.no_dedup), + ReportOutputFormat::Sarif => self.sarif_format(writer, args.no_dedup, args), } } } diff --git a/src/reporter/bson_format.rs b/src/reporter/bson_format.rs index fc1b48c..c1470d0 100644 --- a/src/reporter/bson_format.rs +++ b/src/reporter/bson_format.rs @@ -39,14 +39,14 @@ impl DetailsReporter { }; // Process to JSON first, then convert to BSON - let json_finding = self.process_match_to_json(&single_origin_rm)?; + let json_finding = self.process_match_to_json(&single_origin_rm, args)?; if let Ok(bson_doc) = json_to_bson_document(&json_finding) { bson_findings.push(bson_doc); } } } else { // Process normally for deduped matches or matches with only one origin - let json_finding = self.process_match_to_json(&rm)?; + let json_finding = self.process_match_to_json(&rm, args)?; if let Ok(bson_doc) = json_to_bson_document(&json_finding) { bson_findings.push(bson_doc); } diff --git a/src/reporter/json_format.rs b/src/reporter/json_format.rs index a4e8730..31123b6 100644 --- a/src/reporter/json_format.rs +++ b/src/reporter/json_format.rs @@ -101,7 +101,11 @@ impl DetailsReporter { .iter() .find_map(|origin| { if let Origin::File(e) = origin { - Some(e.path.display().to_string()) + if let Some(url) = self.jira_issue_url(&e.path, args) { + Some(url) + } else { + Some(e.path.display().to_string()) + } } else { None } @@ -173,12 +177,12 @@ impl DetailsReporter { }; // Process this single-origin match into a JSON finding - let json_finding = self.process_match_to_json(&single_origin_rm)?; + let json_finding = self.process_match_to_json(&single_origin_rm, args)?; findings.push(json_finding); } } else { // Process normally for deduped matches or matches with only one origin - let json_finding = self.process_match_to_json(&rm)?; + let json_finding = self.process_match_to_json(&rm, args)?; findings.push(json_finding); } } @@ -192,7 +196,11 @@ impl DetailsReporter { } // Add a helper method to convert a ReportMatch to a JSON finding - pub fn process_match_to_json(&self, rm: &ReportMatch) -> Result { + pub fn process_match_to_json( + &self, + rm: &ReportMatch, + args: &cli::commands::scan::ScanArgs, + ) -> Result { // Extract the relevant data from the match as you already do in your current implementation let source_span = &rm.m.location.source_span; let line_num = source_span.start.line; @@ -242,7 +250,11 @@ impl DetailsReporter { .iter() .find_map(|origin| { if let Origin::File(e) = origin { - Some(e.path.display().to_string()) + if let Some(url) = self.jira_issue_url(&e.path, args) { + Some(url) + } else { + Some(e.path.display().to_string()) + } } else { None } @@ -325,13 +337,13 @@ impl DetailsReporter { }; // Process this single-origin match into a JSON finding and write it - let json_finding = self.process_match_to_json(&single_origin_rm)?; + let json_finding = self.process_match_to_json(&single_origin_rm, args)?; serde_json::to_writer(&mut writer, &json_finding)?; writeln!(writer)?; } } else { // Process normally for deduped matches or matches with only one origin - let json_finding = self.process_match_to_json(&rm)?; + let json_finding = self.process_match_to_json(&rm, args)?; serde_json::to_writer(&mut writer, &json_finding)?; writeln!(writer)?; } @@ -413,7 +425,10 @@ mod tests { all_gitlab_groups: false, gitlab_api_url: Url::parse("https://gitlab.com/").unwrap(), gitlab_repo_type: GitLabRepoType::Owner, - + // Jira options + jira_url: None, + jql: None, + max_results: 50, // clone / history options git_clone: GitCloneMode::Bare, git_history: GitHistoryMode::Full, diff --git a/src/reporter/pretty_format.rs b/src/reporter/pretty_format.rs index b9c868c..0b4a46c 100644 --- a/src/reporter/pretty_format.rs +++ b/src/reporter/pretty_format.rs @@ -214,14 +214,18 @@ impl<'a> Display for PrettyFinding<'a> { for p in rm.origin.iter() { match p { Origin::File(e) => { + let display_path = if let Some(url) = reporter.jira_issue_url(&e.path, args) { + url + } else { + e.path.display().to_string() + }; writeln!( f, " |Path..........: {}", if rm.validation_success { - reporter.style_active_creds(e.path.display()).to_string().to_string() - // Convert StyledObject to String + reporter.style_active_creds(&display_path).to_string() } else { - e.path.display().to_string() + display_path } )?; } @@ -337,7 +341,10 @@ fn test_pretty_format_with_nan_entropy_panics() { all_gitlab_groups: false, gitlab_api_url: Url::parse("https://gitlab.com/").unwrap(), gitlab_repo_type: GitLabRepoType::Owner, - + // Jira options + jira_url: None, + jql: None, + max_results: 50, // git clone / history options git_clone: GitCloneMode::Bare, git_history: GitHistoryMode::Full, diff --git a/src/reporter/sarif_format.rs b/src/reporter/sarif_format.rs index dc0106f..f771c17 100644 --- a/src/reporter/sarif_format.rs +++ b/src/reporter/sarif_format.rs @@ -14,7 +14,12 @@ struct LocationKey { text: String, } impl DetailsReporter { - fn make_sarif_result(&self, finding: &Finding, no_dedup: bool) -> Result { + fn make_sarif_result( + &self, + finding: &Finding, + no_dedup: bool, + args: &cli::commands::scan::ScanArgs, + ) -> Result { // Deduplicate exactly as in the JSON reporter // let matches = self.deduplicate_matches(finding.matches.clone(), no_dedup); // Deduplicate exactly as in the JSON reporter - but only if no_dedup is false @@ -66,11 +71,13 @@ impl DetailsReporter { for p in prov.iter() { match p { Origin::File(e) => { + let uri = if let Some(url) = self.jira_issue_url(&e.path, args) { + url + } else { + e.path.display().to_string() + }; artifact_locations.push( - sarif::ArtifactLocationBuilder::default() - .uri(e.path.display().to_string()) - .build() - .ok()?, + sarif::ArtifactLocationBuilder::default().uri(uri).build().ok()?, ); } Origin::GitRepo(e) => { @@ -199,7 +206,13 @@ impl DetailsReporter { let p = first_match.origin.first(); match p { Origin::File(e) => { - msg.push_str(&format!("Location: {}\n", e.path.display())); + + let uri = if let Some(url) = self.jira_issue_url(&e.path, args) { + url + } else { + e.path.display().to_string() + }; + msg.push_str(&format!("Location: {}\n", uri)); } Origin::GitRepo(e) => { if let Some(cs) = &e.first_commit { @@ -242,7 +255,12 @@ impl DetailsReporter { Ok(result) } - pub fn sarif_format(&self, mut writer: W, no_dedup: bool) -> Result<()> { + pub fn sarif_format( + &self, + mut writer: W, + no_dedup: bool, + args: &cli::commands::scan::ScanArgs, + ) -> Result<()> { // Gather findings first let mut findings = self.gather_findings()?; @@ -329,8 +347,11 @@ impl DetailsReporter { .build()?, ) .build()?; - let sarif_results: Vec = - findings.par_iter().filter_map(|f| self.make_sarif_result(f, no_dedup).ok()).collect(); + + let sarif_results: Vec = findings + .par_iter() + .filter_map(|f| self.make_sarif_result(f, no_dedup, args).ok()) + .collect(); let run = sarif::RunBuilder::default().tool(tool).results(sarif_results).build()?; let sarif = sarif::SarifBuilder::default() .version(sarif::Version::V2_1_0.to_string()) diff --git a/src/scanner/repos.rs b/src/scanner/repos.rs index 4fb5c58..f84b758 100644 --- a/src/scanner/repos.rs +++ b/src/scanner/repos.rs @@ -20,7 +20,7 @@ use crate::{ findings_store, git_binary::{CloneMode, Git}, git_url::GitUrl, - github, gitlab, + github, gitlab, jira, matcher::Match, origin::OriginSet, PathBuf, @@ -224,3 +224,32 @@ pub async fn enumerate_gitlab_repos( repo_urls.dedup(); Ok(repo_urls) } + + +pub async fn fetch_jira_issues( + args: &scan::ScanArgs, + global_args: &global::GlobalArgs, + datastore: &Arc>, +) -> Result> { + let Some(jira_url) = args.input_specifier_args.jira_url.clone() else { + return Ok(Vec::new()); + }; + let Some(jql) = args.input_specifier_args.jql.as_deref() else { + return Ok(Vec::new()); + }; + let max_results = args.input_specifier_args.max_results; + let output_dir = { + let ds = datastore.lock().unwrap(); + ds.clone_root() + }; + let output_dir = output_dir.join("jira_issues"); + let _paths = jira::download_issues_to_dir( + jira_url, + jql, + max_results, + global_args.ignore_certs, + &output_dir, + ) + .await?; + Ok(vec![output_dir]) +} \ No newline at end of file diff --git a/src/scanner/runner.rs b/src/scanner/runner.rs index 7c2f964..99ad466 100644 --- a/src/scanner/runner.rs +++ b/src/scanner/runner.rs @@ -18,7 +18,9 @@ use crate::{ rules_database::RulesDatabase, scanner::{ clone_or_update_git_repos, enumerate_filesystem_inputs, enumerate_github_repos, - repos::enumerate_gitlab_repos, run_secret_validation, summary::print_scan_summary, + repos::{enumerate_gitlab_repos, fetch_jira_issues}, + run_secret_validation, + summary::print_scan_summary, }, }; @@ -61,7 +63,11 @@ pub async fn run_async_scan( repo_urls.sort(); repo_urls.dedup(); - let input_roots = clone_or_update_git_repos(args, global_args, &repo_urls, &datastore)?; + let mut input_roots = clone_or_update_git_repos(args, global_args, &repo_urls, &datastore)?; + // Fetch Jira issues if requested + let jira_dirs = fetch_jira_issues(args, global_args, &datastore).await?; + input_roots.extend(jira_dirs); + if input_roots.is_empty() { bail!("No inputs to scan"); } diff --git a/tests/cli_failure.rs b/tests/cli_failure.rs index 7e18195..6d67dbe 100644 --- a/tests/cli_failure.rs +++ b/tests/cli_failure.rs @@ -56,6 +56,11 @@ rules: request: method: BREW url: "https://example.com/" + response_matcher: + - report_response: true + - status: + - 200 + type: StatusMatch "#, ) .unwrap(); diff --git a/tests/int_dedup.rs b/tests/int_dedup.rs index 64c2c92..4c4975c 100644 --- a/tests/int_dedup.rs +++ b/tests/int_dedup.rs @@ -79,6 +79,9 @@ rules: gitlab_api_url: Url::parse("https://gitlab.com/").unwrap(), gitlab_repo_type: GitLabRepoType::Owner, + jira_url: None, + jql: None, + max_results: 50, // git clone / history options git_clone: GitCloneMode::Bare, git_history: GitHistoryMode::Full, diff --git a/tests/int_github.rs b/tests/int_github.rs index 330299b..c8256c8 100644 --- a/tests/int_github.rs +++ b/tests/int_github.rs @@ -66,6 +66,9 @@ fn test_github_remote_scan() -> Result<()> { gitlab_api_url: Url::parse("https://gitlab.com/").unwrap(), gitlab_repo_type: GitLabRepoType::Owner, + jira_url: None, + jql: None, + max_results: 50, // git clone / history options git_clone: GitCloneMode::Bare, git_history: GitHistoryMode::Full, diff --git a/tests/int_gitlab.rs b/tests/int_gitlab.rs index 66a7f37..6ec6e19 100644 --- a/tests/int_gitlab.rs +++ b/tests/int_gitlab.rs @@ -64,6 +64,10 @@ fn test_gitlab_remote_scan() -> Result<()> { all_gitlab_groups: false, gitlab_api_url: Url::parse("https://gitlab.com/")?, gitlab_repo_type: GitLabRepoType::Owner, + + jira_url: None, + jql: None, + max_results: 50, git_clone: GitCloneMode::Bare, git_history: GitHistoryMode::Full, scan_nested_repos: true, diff --git a/tests/int_validation_cache.rs b/tests/int_validation_cache.rs index 7e422e1..933c068 100644 --- a/tests/int_validation_cache.rs +++ b/tests/int_validation_cache.rs @@ -122,6 +122,9 @@ async fn test_validation_cache_and_depvars() -> Result<()> { gitlab_api_url: Url::parse("https://gitlab.com/").unwrap(), gitlab_repo_type: GitLabRepoType::Owner, + jira_url: None, + jql: None, + max_results: 50, // git clone / history options git_clone: GitCloneMode::Bare, git_history: GitHistoryMode::Full, diff --git a/tests/int_vulnerable_files.rs b/tests/int_vulnerable_files.rs index bb1d064..0da7868 100644 --- a/tests/int_vulnerable_files.rs +++ b/tests/int_vulnerable_files.rs @@ -65,6 +65,9 @@ impl TestContext { gitlab_api_url: Url::parse("https://gitlab.com/").unwrap(), gitlab_repo_type: GitLabRepoType::Owner, + jira_url: None, + jql: None, + max_results: 50, // git clone / history options git_clone: GitCloneMode::Bare, git_history: GitHistoryMode::Full, @@ -130,6 +133,9 @@ impl TestContext { gitlab_api_url: Url::parse("https://gitlab.com/").unwrap(), gitlab_repo_type: GitLabRepoType::Owner, + jira_url: None, + jql: None, + max_results: 50, // git clone / history options git_clone: GitCloneMode::Bare, git_history: GitHistoryMode::Full, diff --git a/vendor/jira_query/.gitignore b/vendor/jira_query/.gitignore new file mode 100644 index 0000000..96ef6c0 --- /dev/null +++ b/vendor/jira_query/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/vendor/jira_query/CONTRIBUTING.md b/vendor/jira_query/CONTRIBUTING.md new file mode 100644 index 0000000..f3d0489 --- /dev/null +++ b/vendor/jira_query/CONTRIBUTING.md @@ -0,0 +1,4 @@ +## Certificate of Origin + +By contributing to this project you agree to the Developer Certificate of Origin (DCO). This document was created by the Linux Kernel community and is a simple statement that you, as a contributor, have the legal right to make the contribution. See the [DCO.md](DCO.md) file for details. + diff --git a/vendor/jira_query/Cargo.toml b/vendor/jira_query/Cargo.toml new file mode 100644 index 0000000..9140aff --- /dev/null +++ b/vendor/jira_query/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "jira_query" +authors = ["Marek Suchánek "] +version = "1.6.0" +edition = "2021" +# Check the Rust version using `cargo msrv verify`. +rust-version = "1.81" +license = "Apache-2.0" +description = "Access tickets on a remote Jira instance." +readme = "README.md" +documentation = "https://docs.rs/jira_query/" +homepage = "https://github.com/msuchane/jira_query" +repository = "https://github.com/msuchane/jira_query" +keywords = ["jira", "atlassian", "rest"] +categories = ["api-bindings"] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +log = "0.4" +thiserror = "2.0" +reqwest = { version = "0.12", default-features = false, features = ["json","rustls-tls"] } + +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +# Version with a security patch: +chrono = { version = ">=0.4.20", features = ["serde"] } + +[dev-dependencies] +tokio = { version = ">=1.45", features = ["full"] } diff --git a/vendor/jira_query/DCO.md b/vendor/jira_query/DCO.md new file mode 100644 index 0000000..f94b001 --- /dev/null +++ b/vendor/jira_query/DCO.md @@ -0,0 +1,14 @@ +## What is the DCO? + +The DCO is a certification normally associated with every contribution to a project made by every contributor. It signifies that the contributor has the right to submit the contribution under the applicable open source license of the project. The entire certification text (maintained by the Linux Foundation as version 1.1 at https://developercertificate.org/) is the following: + +By making a contribution to this project, I certify that: + +(a) The contribution was created in whole or in part by me and I have the right to submit it under the open source license indicated in the file; or + +(b) The contribution is based upon previous work that, to the best of my knowledge, is covered under an appropriate open source license and I have the right under that license to submit that work with modifications, whether created in whole or in part by me, under the same open source license (unless I am permitted to submit under a different license), as indicated in the file; or + +(c) The contribution was provided directly to me by some other person who certified (a), (b) or (c) and I have not modified it. + +(d) I understand and agree that this project and the contribution are public and that a record of the contribution (including all personal information I submit with it, including my sign-off) is maintained indefinitely and may be redistributed consistent with this project or the open source license(s) involved. + diff --git a/vendor/jira_query/LICENSE b/vendor/jira_query/LICENSE new file mode 100644 index 0000000..59e493a --- /dev/null +++ b/vendor/jira_query/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2022 Marek Suchánek + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/vendor/jira_query/README.md b/vendor/jira_query/README.md new file mode 100644 index 0000000..0789130 --- /dev/null +++ b/vendor/jira_query/README.md @@ -0,0 +1,72 @@ +# jira_query + +[![Crates.io](https://img.shields.io/crates/v/jira_query.svg)](https://crates.io/crates/jira_query) +[![Apache-2.0 license](https://img.shields.io/crates/l/jira_query)](https://crates.io/crates/jira_query) +[![Documentation](https://docs.rs/jira_query/badge.svg)](https://docs.rs/jira_query) + +[![CI tests](https://github.com/msuchane/jira_query/actions/workflows/rust-tests.yml/badge.svg)](https://github.com/msuchane/jira_query/actions/workflows/rust-tests.yml) +[![Dependency status](https://deps.rs/repo/github/msuchane/jira_query/status.svg)](https://deps.rs/repo/github/msuchane/jira_query) + +Access issues on a remote Jira instance. + +## Description + +The `jira_query` crate is a Rust library that can query a Jira instance using its REST API. It returns a strongly typed representation of the requested issues. + +This library provides no functionality to create or modify issues. The access is read-only. + +## Usage + +### Basic anonymous query + +Without logging in, search for a single ticket and check for its priority: + +```rust +use tokio; +use jira_query::JiraInstance; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let jira = JiraInstance::at("https://issues.redhat.com".to_string())?; + + let issue = jira.issue("CS-1113").await?; + + assert_eq!(issue.fields.priority.name, "Normal"); + + Ok(()) +} +``` + +### Advanced query + +Use an API key to log into Jira. Search for all CentOS Stream tickets that are of the Blocker priority. Check that there is more than one ticket: + +```rust +use tokio; +use jira_query::{Auth, JiraInstance, Pagination}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let jira = JiraInstance::at("https://bugzilla.redhat.com".to_string())? + .authenticate(Auth::ApiKey("My API Key".to_string())) + .paginate(Pagination::ChunkSize(32)); + + let query = r#"project="CentOS Stream" AND priority=Blocker"#; + + let issues = jira.search(query).await?; + + assert!(issues.len() > 1); + + Ok(()) +} +``` + +## A note on semantic versioning + +This crate reserves the right to make limited breaking changes to the Jira structs in minor versions (`X.Y`). + +The reason is that the official Jira documentation does not specify which fields in the JSON body are optional (`Option`) and which are mandatory (`T`). Rather than exposing all fields as optional, this crate tries to process fields as mandatory until proven otherwise in testing. As a consequence, minor releases must occasionally turn a mandatory field to an optional field. + +## See also + +* [`bugzilla_query`](https://crates.io/crates/bugzilla_query), a similar interface to Bugzilla diff --git a/vendor/jira_query/src/access.rs b/vendor/jira_query/src/access.rs new file mode 100644 index 0000000..359e6ca --- /dev/null +++ b/vendor/jira_query/src/access.rs @@ -0,0 +1,297 @@ +/* +Copyright 2022 Marek Suchánek + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Jira API documentation: +// * https://docs.atlassian.com/software/jira/docs/api/REST/latest/ +// * https://docs.atlassian.com/jira-software/REST/latest/ + +use crate::errors::JiraQueryError; +use crate::issue_model::{Issue, JqlResults}; + +// The prefix of every subsequent REST request. +// This string comes directly after the host in the URL. +const REST_PREFIX: &str = "rest/api/2"; + +/// Configuration and credentials to access a Jira instance. +pub struct JiraInstance { + pub host: String, + pub auth: Auth, + pub pagination: Pagination, + client: reqwest::Client, +} + +/// The authentication method used to contact Jira. +pub enum Auth { + Anonymous, + ApiKey(String), + Basic { user: String, password: String }, +} + +// We could set a default enum variant and derive, but that raises the MSRV to 1.62. +impl Default for Auth { + fn default() -> Self { + Self::Anonymous + } +} + +/// Controls the upper limit of how many tickets the response from Jira can contain: +/// +/// * `Default`: Use the default settings of this instance, which sets an arbitrary limit on the number of tickets. +/// * `MaxResults`: Set the upper limit to this value. Note that each instance has a maximum allowed value, +/// and if you set `MaxResults` higher than that, the instance uses its own maximum allowed value. +/// * `ChunkSize`: Access the tickets in a series of requests, each accessing the number of tickets equal to the chunk size. +/// This enables you to access an unlimited number of tickets, as long as the chunk size is smaller +/// than the maximum allowed results size for the instance. +pub enum Pagination { + Default, + MaxResults(u32), + ChunkSize(u32), +} + +// We could set a default enum variant and derive, but that raises the MSRV to 1.62. +impl Default for Pagination { + fn default() -> Self { + Self::Default + } +} + +/// The method of the request to Jira. Either request specific IDs, +/// or use a free-form JQL search query. +enum Method<'a> { + Key(&'a str), + Keys(&'a [&'a str]), + Search(&'a str), +} + +impl<'a> Method<'a> { + fn url_fragment(&self) -> String { + match self { + Self::Key(id) => format!("issue/{id}"), + Self::Keys(ids) => format!("search?jql=id%20in%20({})", ids.join(",")), + Self::Search(query) => format!("search?jql={query}"), + } + } +} + +impl JiraInstance { + /// Create a new `BzInstance` struct using a host URL, with default values + /// for all options. + pub fn at(host: String) -> Result { + // TODO: This function takes host as a String, even though client is happy with &str. + // The String is only used in the host struct attribute. + let client = reqwest::Client::new(); + + Ok(Self { + host, + client, + auth: Auth::default(), + pagination: Pagination::default(), + }) + } + + /// Set the authentication method of this `JiraInstance`. + #[must_use] + pub fn authenticate(mut self, auth: Auth) -> Self { + self.auth = auth; + self + } + + /// Set the http client of this `JiraInstance`. + #[must_use] + pub fn with_client(mut self, client: reqwest::Client) -> Self { + self.client = client; + self + } + + /// Set the pagination method of this `JiraInstance`. + #[must_use] + pub const fn paginate(mut self, pagination: Pagination) -> Self { + self.pagination = pagination; + self + } + + /// Based on the request method, form a complete, absolute URL + /// to download the tickets from the REST API. + #[must_use] + fn path(&self, method: &Method, start_at: u32) -> String { + let max_results = match self.pagination { + Pagination::Default => String::new(), + // For both MaxResults and ChunkSIze, set the maxResults size to the value set in the variant. + // The maxResults size is relevant for ChunkSize in that each chunk requires its own results + // to be at least this large. + Pagination::MaxResults(n) | Pagination::ChunkSize(n) => format!("&maxResults={n}"), + }; + + // The `startAt` option is only valid with JQL. With a URL by key, it breaks the REST query. + let start_at = match method { + Method::Key(_) => String::new(), + Method::Keys(_) | Method::Search(_) => format!("&startAt={start_at}"), + }; + + format!( + "{}/{}/{}{}{}", + self.host, + REST_PREFIX, + method.url_fragment(), + max_results, + start_at, + ) + } + + /// Download the specified URL using the configured authentication. + async fn authenticated_get(&self, url: &str) -> Result { + let request_builder = self.client.get(url); + let authenticated = match &self.auth { + Auth::Anonymous => request_builder, + Auth::ApiKey(key) => request_builder.header("Authorization", &format!("Bearer {key}")), + Auth::Basic { user, password } => request_builder.basic_auth(user, Some(password)), + }; + authenticated.send().await + } + + // This method uses a separate implementation from `issues` because Jira provides a way + // to request a single ticket specifically. That conveniently handles error cases + // where no tickets might match, or more than one might. + /// Access a single issue by its key. + pub async fn issue(&self, key: &str) -> Result { + let url = self.path(&Method::Key(key), 0); + + // Gets an issue by ID and deserializes the JSON to data variable + let issue = self.authenticated_get(&url).await?.json::().await?; + + log::debug!("{:#?}", issue); + + Ok(issue) + } + + /// Access several issues by their keys. + /// + /// If the list of keys is empty, returns an empty list back with no errors. + pub async fn issues(&self, keys: &[&str]) -> Result, JiraQueryError> { + // If the user specifies no keys, skip network requests and return no bugs. + // Returning an error could also be valid, but I believe that this behavior + // is less surprising and more practical. + if keys.is_empty() { + return Ok(Vec::new()); + } + + let method = Method::Keys(keys); + + // If Pagination is set to ChunkSize, split the issue keys into chunk by chunk size + // and request each chunk separately. + if let Pagination::ChunkSize(chunk_size) = self.pagination { + self.paginated_issues(&method, chunk_size).await + // If Pagination is not set to ChunkSize, use a single chunk request for all issues. + } else { + let issues = self.chunk_of_issues(&method, 0).await?; + + // If the resulting list is empty, return an error. + // TODO: The REST parsing above already results in an error if the results are empty. + // Try to catch the error there. + if issues.is_empty() { + Err(JiraQueryError::NoIssues) + } else { + Ok(issues) + } + } + } + + /// Download all issues specified in the request as a series of chunks or pages. + /// The request controls whether the download works with IDs or JQL. + /// This function only processes the resulting pages coming back from Jira + /// and stops the iteration at the last page. + /// + /// See the Jira documentation: + /// . + async fn paginated_issues( + &self, + method: &Method<'_>, + chunk_size: u32, + ) -> Result, JiraQueryError> { + let mut all_issues = Vec::new(); + let mut start_at = 0; + + loop { + let mut chunk_issues = self.chunk_of_issues(method, start_at).await?; + // Calculate the length now before the content moves to `all_issues`. + let page_size = chunk_issues.len(); + all_issues.append(&mut chunk_issues); + + // If this page contains fewer issues than the chunk size, + // it's the last page. Stop the loop. + if page_size < chunk_size as usize { + break; + } + + start_at += chunk_size; + } + + Ok(all_issues) + } + + /// Download a specific list (chunk) of issues. + /// Reused elsewhere as a building block of different pagination methods. + async fn chunk_of_issues( + &self, + method: &Method<'_>, + start_at: u32, + ) -> Result, JiraQueryError> { + let url = self.path(method, start_at); + + let results = self + .authenticated_get(&url) + .await? + .json::() + .await?; + + log::debug!("{:#?}", results); + + Ok(results.issues) + } + + /// Access issues using a free-form JQL search. + /// + /// An example of a query: `project="CentOS Stream" AND priority = High`. + pub async fn search(&self, query: &str) -> Result, JiraQueryError> { + let method = Method::Search(query); + + // If Pagination is set to ChunkSize, split the issue keys into chunk by chunk size + // and request each chunk separately. + if let Pagination::ChunkSize(chunk_size) = self.pagination { + self.paginated_issues(&method, chunk_size).await + // If Pagination is not set to ChunkSize, use a single chunk request for all issues. + } else { + let issues = self.chunk_of_issues(&method, 0).await?; + + Ok(issues) + } + } +} + +#[cfg(test)] +mod tests { + #[test] + fn it_works() { + let result = 2 + 2; + assert_eq!(result, 4); + } + // #[test] + // fn issues() { + // let results = crate::issues("todo", &["todo"], "todo"); + // eprintln!("{:#?}", results); + // assert_eq!(results.issues.len(), todo); + // } +} diff --git a/vendor/jira_query/src/errors.rs b/vendor/jira_query/src/errors.rs new file mode 100644 index 0000000..67136fa --- /dev/null +++ b/vendor/jira_query/src/errors.rs @@ -0,0 +1,28 @@ +/* +Copyright 2022 Marek Suchánek + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +use thiserror::Error; + +/// All errors that might occur in this crate. +#[derive(Error, Debug)] +pub enum JiraQueryError { + #[error("Required issues are missing in the Jira response: {}.", .0.join(", "))] + MissingIssues(Vec), + #[error("The Jira query returned no issues.")] + NoIssues, + #[error("Error in accessing the Jira REST API.")] + Request(#[from] reqwest::Error), +} diff --git a/vendor/jira_query/src/issue_model.rs b/vendor/jira_query/src/issue_model.rs new file mode 100644 index 0000000..9d89317 --- /dev/null +++ b/vendor/jira_query/src/issue_model.rs @@ -0,0 +1,440 @@ +/* +Copyright 2022 Marek Suchánek + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +use chrono::{DateTime, NaiveDate, Utc}; +/// This module replicates the fields in a Jira issue as strongly typed structs. +/// Any extra fields that come from a custom Jira configuration are captured +/// in the `extra` hash map in the parent struct. +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// The response from Jira to a JQL query, +/// which includes the list of requested issues and additional metadata. +#[derive(Clone, Debug, Deserialize)] +pub struct JqlResults { + pub issues: Vec, + #[serde(flatten)] + #[allow(dead_code)] + pub extra: Value, +} + +/// A single Jira issue with all its fields. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct Issue { + pub id: String, + pub key: String, + pub expand: String, + pub fields: Fields, + #[serde(rename = "self")] + pub self_link: String, + #[serde(flatten)] + pub extra: Value, +} + +/// A container for most fields of a Jira issue. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct Fields { + #[serde(rename = "lastViewed")] + pub last_viewed: Option>, + pub labels: Vec, + #[serde(default)] + pub assignee: Option, + pub description: Option, + pub duedate: Option, + // Both `versions` and `fixVersions` are optional fields and they might + // either be missing or set to an empty list. + // I'm consolidating both cases as an empty list, because I don't believe + // that there's a meaningful semantic difference between them here. + #[serde(default)] + pub versions: Vec, + #[serde(default)] + #[serde(rename = "fixVersions")] + pub fix_versions: Vec, + #[serde(default)] + pub reporter: Option, + pub status: Status, + pub created: DateTime, + pub updated: DateTime, + pub issuetype: IssueType, + pub timeestimate: Option, + pub aggregatetimeestimate: Option, + pub timeoriginalestimate: Option, + pub timespent: Option, + pub aggregatetimespent: Option, + pub aggregatetimeoriginalestimate: Option, + pub progress: Option, + pub aggregateprogress: Option, + pub workratio: i64, + pub summary: String, + #[serde(default)] + pub creator: Option, + pub project: Project, + pub priority: Option, + #[serde(default)] + pub components: Vec, + pub watches: Watches, + pub archiveddate: Option>, + pub archivedby: Option>, + pub resolution: Option, + pub resolutiondate: Option>, + pub comment: Option, + pub issuelinks: Vec, + pub votes: Votes, + pub parent: Option, + pub subtasks: Vec, + pub environment: Option, + pub security: Option, + #[serde(flatten)] + pub extra: Value, +} + +/// The representation of a Jira user account. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct User { + pub active: bool, + #[serde(rename = "displayName")] + pub display_name: String, + #[serde(rename = "emailAddress")] + pub email_address: Option, + pub key: Option, + pub name: Option, + #[serde(rename = "timeZone")] + pub time_zone: String, + #[serde(rename = "avatarUrls")] + pub avatar_urls: AvatarUrls, + #[serde(rename = "self")] + pub self_link: String, + #[serde(flatten)] + pub extra: Value, + #[serde(rename = "accountId")] + pub account_id: Option, +} + +/// The representation of a Jira product version. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct Version { + pub id: String, + pub description: Option, + pub name: String, + pub archived: bool, + pub released: bool, + /// Jira stores `releaseDate` only as `YYYY-MM-DD`, so it can't Serialize, Deserialize to full `DateTime`. + #[serde(rename = "releaseDate")] + pub release_date: Option, + #[serde(rename = "self")] + pub self_link: String, + #[serde(flatten)] + pub extra: Value, +} + +/// The Jira issue status. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct Status { + pub description: String, + #[serde(rename = "iconUrl")] + pub icon_url: String, + pub id: String, + pub name: String, + #[serde(rename = "statusCategory")] + pub status_category: StatusCategory, + #[serde(rename = "self")] + pub self_link: String, + #[serde(flatten)] + pub extra: Value, +} + +/// The category of a Jira issue status. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct StatusCategory { + #[serde(rename = "colorName")] + pub color_name: String, + pub id: i32, + pub key: String, + pub name: String, + #[serde(rename = "self")] + pub self_link: String, + #[serde(flatten)] + pub extra: Value, +} + +/// The resolution of a Jira issue when it's closed. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct Resolution { + pub description: String, + pub id: String, + pub name: String, + #[serde(rename = "self")] + pub self_link: String, + #[serde(flatten)] + pub extra: Value, +} + +/// The type of a Jira issue. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct IssueType { + #[serde(rename = "avatarId")] + pub avatar_id: Option, + pub description: String, + #[serde(rename = "iconUrl")] + pub icon_url: String, + pub id: String, + pub name: String, + pub subtask: bool, + #[serde(rename = "self")] + pub self_link: String, + #[serde(flatten)] + pub extra: Value, +} + +/// A project namespace that groups Jira issues. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct Project { + pub id: String, + pub key: String, + pub name: String, + #[serde(rename = "projectTypeKey")] + pub project_type_key: String, + #[serde(rename = "projectCategory")] + pub project_category: Option, + #[serde(rename = "avatarUrls")] + pub avatar_urls: AvatarUrls, + #[serde(rename = "self")] + pub self_link: String, + #[serde(flatten)] + pub extra: Value, +} + +/// The category of a Jira project. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct ProjectCategory { + pub description: String, + pub id: String, + pub name: String, + #[serde(rename = "self")] + pub self_link: String, + #[serde(flatten)] + pub extra: Value, +} + +/// The priority of a Jira issue. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct Priority { + #[serde(rename = "iconUrl")] + pub icon_url: String, + pub id: String, + pub name: String, + #[serde(rename = "self")] + pub self_link: String, + #[serde(flatten)] + pub extra: Value, +} + +/// The component of a Jira issue. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct Component { + pub description: Option, + pub id: String, + pub name: String, + #[serde(rename = "self")] + pub self_link: String, + #[serde(flatten)] + pub extra: Value, +} + +/// Users watching a Jira issue. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct Watches { + #[serde(rename = "isWatching")] + pub is_watching: bool, + #[serde(rename = "watchCount")] + pub watch_count: i32, + #[serde(rename = "self")] + pub self_link: String, + #[serde(flatten)] + pub extra: Value, +} + +/// The progress of a Jira issue. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct Progress { + pub progress: i32, + pub total: i32, + #[serde(flatten)] + pub extra: Value, +} + +/// A comment below a Jira issue. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct Comment { + #[serde(default)] + pub author: Option, + pub body: String, + pub created: DateTime, + pub id: String, + #[serde(rename = "updateAuthor")] + pub update_author: Option, + pub updated: DateTime, + pub visibility: Option, + #[serde(rename = "self")] + pub self_link: String, + #[serde(flatten)] + pub extra: Value, +} + +/// A container for all comments below a Jira issue. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct Comments { + pub comments: Vec, + #[serde(rename = "maxResults")] + pub max_results: i32, + #[serde(rename = "startAt")] + pub start_at: i32, + pub total: i32, + #[serde(flatten)] + pub extra: Value, +} + +/// A link from one Jira issue to another. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct IssueLink { + pub id: String, + #[serde(rename = "outwardIssue")] + pub outward_issue: Option, + #[serde(rename = "inwardIssue")] + pub inward_issue: Option, + #[serde(rename = "type")] + pub link_type: IssueLinkType, + #[serde(rename = "self")] + pub self_link: String, + #[serde(flatten)] + pub extra: Value, +} + +/// A Jira issue linked from another one. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct LinkedIssue { + pub id: String, + pub key: String, + pub fields: LinkedIssueFields, + #[serde(rename = "self")] + pub self_link: String, + #[serde(flatten)] + pub extra: Value, +} + +/// The reduced fields of a linked Jira issue. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct LinkedIssueFields { + pub issuetype: IssueType, + pub priority: Option, + pub status: Status, + pub summary: String, + #[serde(flatten)] + pub extra: Value, +} + +/// The direction of a link to a Jira issue. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct IssueLinkType { + pub id: String, + pub inward: String, + pub name: String, + pub outward: String, + #[serde(rename = "self")] + pub self_link: String, + #[serde(flatten)] + pub extra: Value, +} + +/// The votes for a Jira issue. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct Votes { + #[serde(rename = "hasVoted")] + pub has_voted: bool, + pub votes: i32, + #[serde(rename = "self")] + pub self_link: String, + #[serde(flatten)] + pub extra: Value, +} + +/// A Jira avatar in several different sizes: +/// +/// * `xsmall` = 16x16 px +/// * `small` = 24x24 px +/// * `medium` = 48x48 px +/// * `full` = maximum +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct AvatarUrls { + #[serde(rename = "16x16")] + pub xsmall: String, + #[serde(rename = "24x24")] + pub small: String, + #[serde(rename = "32x32")] + pub medium: String, + #[serde(rename = "48x48")] + pub full: String, + #[serde(flatten)] + pub extra: Value, +} + +/// A minimal, reduced representation of a Jira issue. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct CondensedIssue { + pub fields: CondensedFields, + pub id: String, + pub key: String, + #[serde(rename = "self")] + pub self_link: String, + #[serde(flatten)] + pub extra: Value, +} + +/// A minimal, reduced listing of the fields of a Jira issue. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct CondensedFields { + pub issuetype: IssueType, + pub priority: Option, + pub status: Status, + pub summary: String, + #[serde(flatten)] + pub extra: Value, +} + +/// The visibility of a Jira issue. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct Visibility { + pub r#type: String, + pub value: String, + #[serde(flatten)] + pub extra: Value, +} + +/// The security level of a Jira issue. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +// TODO: This seems to be a generic container, similar to several other structs. +// In a future major release, try to consolidate them into one generic struct with: +// description, id, name. +// Also see if Serde can convert id to a number after all, somehow. +pub struct Security { + pub description: String, + pub id: String, + pub name: String, + #[serde(rename = "self")] + pub self_link: String, + #[serde(flatten)] + pub extra: Value, +} diff --git a/vendor/jira_query/src/lib.rs b/vendor/jira_query/src/lib.rs new file mode 100644 index 0000000..50edfd4 --- /dev/null +++ b/vendor/jira_query/src/lib.rs @@ -0,0 +1,41 @@ +/* +Copyright 2022 Marek Suchánek + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Enable additional clippy lints by default. +#![warn( + clippy::pedantic, + clippy::unwrap_used, + clippy::expect_used, + clippy::clone_on_ref_ptr, + clippy::todo +)] +// Forbid unsafe code in this program. +#![forbid(unsafe_code)] + +mod access; +mod errors; +mod issue_model; + +pub use access::{Auth, JiraInstance, Pagination}; +pub use errors::JiraQueryError; +pub use issue_model::{ + AvatarUrls, Comment, Comments, Component, CondensedFields, CondensedIssue, Fields, Issue, + IssueLink, IssueLinkType, IssueType, LinkedIssue, LinkedIssueFields, Priority, Progress, + Project, ProjectCategory, Resolution, Status, StatusCategory, User, Version, Visibility, Votes, + Watches, +}; +// Re-export JSON Value because it's an integral part of the issue model. +pub use serde_json::Value; diff --git a/vendor/jira_query/tests/integration.rs b/vendor/jira_query/tests/integration.rs new file mode 100644 index 0000000..5217d79 --- /dev/null +++ b/vendor/jira_query/tests/integration.rs @@ -0,0 +1,165 @@ +use tokio; + +use jira_query::*; + +/// A common convenience function to get anonymous access +/// to the Red Hat Jira instance. +fn rh_jira() -> JiraInstance { + JiraInstance::at("https://issues.redhat.com".to_string()).unwrap() +} + +/// A common convenience function to get anonymous access +/// to the Atlassian Jira instance. +fn atlassian_jira() -> JiraInstance { + JiraInstance::at("https://jira.atlassian.com/".to_string()).unwrap() +} + +/// A common convenience function to get anonymous access +/// to the Apache Jira instance. +fn apache_jira() -> JiraInstance { + JiraInstance::at("https://issues.apache.org/jira/".to_string()).unwrap() +} + +/// A common convenience function to get anonymous access +/// to the Whamcloud Jira instance. +fn whamcloud_jira() -> JiraInstance { + JiraInstance::at("https://jira.whamcloud.com".to_string()).unwrap() +} + +/// Try accessing several public issues separately +/// to test the client and the deserialization. +#[tokio::test] +async fn access_issue() { + let instance = rh_jira(); + let _issue1 = instance.issue("CS-1113").await.unwrap(); + let _issue2 = instance.issue("CS-1111").await.unwrap(); +} + +/// Try accessing several public issues at once +/// to test the client and the deserialization. +#[tokio::test] +async fn access_issues() { + let instance = rh_jira(); + let issues = instance.issues(&["CS-1086", "CS-1084"]).await.unwrap(); + + assert_eq!(issues.len(), 2); +} + +/// Try accessing several public issues at once +/// to test the client and the deserialization. +#[tokio::test] +async fn access_missing_issue() { + let instance = rh_jira(); + let issues = instance.issues(&["CS-11111111111111111111"]).await; + + assert!(issues.is_err()); + // TODO: This case should actually match JiraQueryError::NoIssues, not JiraQueryError::Rest. Fix it. + assert!(matches!(issues.unwrap_err(), JiraQueryError::Request(_))); +} + +/// Check that the issue fields contain the expected values. +/// Work with fields that are standard in Jira, rather than custom extensions. +#[tokio::test] +async fn check_standard_fields() { + let instance = rh_jira(); + let issue = instance.issue("CS-1113").await.unwrap(); + + assert_eq!(issue.id, "14658916"); + assert_eq!(issue.key, "CS-1113"); + assert_eq!( + issue.fields.summary, + "Set gitlab.com/redhat/centos-stream/tests to public" + ); + assert_eq!(issue.fields.assignee.unwrap().display_name, "aoife moloney"); + assert_eq!(issue.fields.reporter.display_name, "Donald Zickus"); + assert_eq!(issue.fields.issuetype.name, "Task"); + assert_eq!(issue.fields.project.key, "CS"); + assert_eq!(issue.fields.project.name, "CentOS Stream Pipeline"); + assert_eq!(issue.fields.priority.unwrap().name, "Normal"); +} + +/// Check that the issue was created at the expected date, and that time deserialization +/// works as expected. +#[tokio::test] +async fn check_time() { + let instance = rh_jira(); + let issue = instance.issue("CS-1113").await.unwrap(); + + let date_created = chrono::NaiveDate::from_ymd_opt(2022, 5, 24).unwrap(); + assert_eq!(issue.fields.created.date_naive(), date_created); +} + +/// Try accessing issues that match a JQL search. +#[tokio::test] +async fn search_for_issues() { + let instance = rh_jira(); + let query = r#"project="CentOS Stream Pipeline" AND priority=Blocker"#; + let issues = instance.search(query).await.unwrap(); + + // There should be at least a couple of blocker tickets for CentOS Stream. + assert!(issues.len() > 1); +} + +/// Make sure that no IDs on the input result in no bugs, without errors. +#[tokio::test] +async fn check_no_issues() { + let instance = rh_jira(); + let issues = instance.issues(&[]).await; + + assert_eq!(issues.ok(), Some(vec![])); +} + +/// Try accessing issues that match a JQL search. +/// Check that their number isn't limited by a page size. +#[tokio::test] +async fn search_for_issues_start_at() { + let instance = rh_jira().paginate(Pagination::ChunkSize(30)); + let query = r#"project="CentOS Stream Pipeline""#; + let issues = instance.search(query).await.unwrap(); + // The query should result in at least 1,000 issues. + assert!(issues.len() > 1000); +} + +/// Try accessing several public Atlassian issues +/// to test the client and the deserialization. +#[tokio::test] +async fn access_atlassian_issues() { + let instance = atlassian_jira(); + let _issues = instance + .issues(&["ACCESS-1427", "ACCESS-1364", "CLOUD-11546", "CLOUD-11236"]) + .await + .unwrap(); +} + +/// Try accessing Atlassian issues created between two dates, +/// using a JQL search. +#[tokio::test] +async fn search_for_atlassian_issues() { + let instance = atlassian_jira(); + // Search for all closed CONFCLOUD issues created between 2022-11-12 and 2023-02-12. + let query = r#"project = CONFCLOUD AND status = Closed AND created >= 2022-11-12 AND created <= 2023-02-12"#; + let issues = instance.search(query).await.unwrap(); + + // There should be at least 39 such issues. + assert!(issues.len() >= 39); +} + +/// Try accessing several public Apache issues +/// to test the client and the deserialization. +#[tokio::test] +async fn access_apache_issues() { + let instance = apache_jira(); + let _issues = instance + .issues(&["SVN-748", "SVN-750", "SPARK-41075", "SLING-10585"]) + .await + .unwrap(); +} + +#[tokio::test] +async fn access_whamcloud_issues() { + let instance = whamcloud_jira(); + let _issues = instance + .issues(&["LU-10647", "LU-13009", "LU-8002", "LU-8874"]) + .await + .unwrap(); +}