All checks were successful
CI / Format (push) Successful in 4s
CI / Clippy (push) Successful in 4m19s
CI / Security Audit (push) Successful in 1m44s
CI / Detect Changes (push) Successful in 5s
CI / Tests (push) Successful in 5m15s
CI / Deploy Agent (push) Successful in 2s
CI / Deploy Dashboard (push) Successful in 2s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 2s
260 lines
9.4 KiB
Rust
260 lines
9.4 KiB
Rust
use mongodb::bson::doc;
|
|
|
|
use compliance_core::models::*;
|
|
|
|
use super::orchestrator::{extract_base_url, PipelineOrchestrator};
|
|
use super::tracker_dispatch::TrackerDispatch;
|
|
use crate::error::AgentError;
|
|
use crate::trackers;
|
|
|
|
impl PipelineOrchestrator {
|
|
/// Build an issue tracker client from a repository's tracker configuration.
|
|
/// Returns `None` if the repo has no tracker configured.
|
|
pub(super) fn build_tracker(&self, repo: &TrackedRepository) -> Option<TrackerDispatch> {
|
|
let tracker_type = repo.tracker_type.as_ref()?;
|
|
// Per-repo token takes precedence, fall back to global config
|
|
match tracker_type {
|
|
TrackerType::GitHub => {
|
|
let token = repo.tracker_token.clone().or_else(|| {
|
|
self.config.github_token.as_ref().map(|t| {
|
|
use secrecy::ExposeSecret;
|
|
t.expose_secret().to_string()
|
|
})
|
|
})?;
|
|
let secret = secrecy::SecretString::from(token);
|
|
match trackers::github::GitHubTracker::new(&secret) {
|
|
Ok(t) => Some(TrackerDispatch::GitHub(t)),
|
|
Err(e) => {
|
|
tracing::warn!("Failed to build GitHub tracker: {e}");
|
|
None
|
|
}
|
|
}
|
|
}
|
|
TrackerType::GitLab => {
|
|
let base_url = self
|
|
.config
|
|
.gitlab_url
|
|
.clone()
|
|
.unwrap_or_else(|| "https://gitlab.com".to_string());
|
|
let token = repo.tracker_token.clone().or_else(|| {
|
|
self.config.gitlab_token.as_ref().map(|t| {
|
|
use secrecy::ExposeSecret;
|
|
t.expose_secret().to_string()
|
|
})
|
|
})?;
|
|
let secret = secrecy::SecretString::from(token);
|
|
Some(TrackerDispatch::GitLab(
|
|
trackers::gitlab::GitLabTracker::new(base_url, secret),
|
|
))
|
|
}
|
|
TrackerType::Gitea => {
|
|
let token = repo.tracker_token.clone()?;
|
|
let base_url = extract_base_url(&repo.git_url)?;
|
|
let secret = secrecy::SecretString::from(token);
|
|
Some(TrackerDispatch::Gitea(trackers::gitea::GiteaTracker::new(
|
|
base_url, secret,
|
|
)))
|
|
}
|
|
TrackerType::Jira => {
|
|
let base_url = self.config.jira_url.clone()?;
|
|
let email = self.config.jira_email.clone()?;
|
|
let project_key = self.config.jira_project_key.clone()?;
|
|
let token = repo.tracker_token.clone().or_else(|| {
|
|
self.config.jira_api_token.as_ref().map(|t| {
|
|
use secrecy::ExposeSecret;
|
|
t.expose_secret().to_string()
|
|
})
|
|
})?;
|
|
let secret = secrecy::SecretString::from(token);
|
|
Some(TrackerDispatch::Jira(trackers::jira::JiraTracker::new(
|
|
base_url,
|
|
email,
|
|
secret,
|
|
project_key,
|
|
)))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Create tracker issues for new findings (severity >= Medium).
|
|
/// Checks for duplicates via fingerprint search before creating.
|
|
#[tracing::instrument(skip_all, fields(repo_id = %repo_id))]
|
|
pub(super) async fn create_tracker_issues(
|
|
&self,
|
|
repo: &TrackedRepository,
|
|
repo_id: &str,
|
|
new_findings: &[Finding],
|
|
) -> Result<(), AgentError> {
|
|
let tracker = match self.build_tracker(repo) {
|
|
Some(t) => t,
|
|
None => {
|
|
tracing::info!("[{repo_id}] No issue tracker configured, skipping");
|
|
return Ok(());
|
|
}
|
|
};
|
|
|
|
let owner = match repo.tracker_owner.as_deref() {
|
|
Some(o) => o,
|
|
None => {
|
|
tracing::warn!("[{repo_id}] tracker_owner not set, skipping issue creation");
|
|
return Ok(());
|
|
}
|
|
};
|
|
let tracker_repo_name = match repo.tracker_repo.as_deref() {
|
|
Some(r) => r,
|
|
None => {
|
|
tracing::warn!("[{repo_id}] tracker_repo not set, skipping issue creation");
|
|
return Ok(());
|
|
}
|
|
};
|
|
|
|
// Only create issues for medium+ severity findings
|
|
let actionable: Vec<&Finding> = new_findings
|
|
.iter()
|
|
.filter(|f| {
|
|
matches!(
|
|
f.severity,
|
|
Severity::Medium | Severity::High | Severity::Critical
|
|
)
|
|
})
|
|
.collect();
|
|
|
|
if actionable.is_empty() {
|
|
tracing::info!("[{repo_id}] No medium+ findings, skipping issue creation");
|
|
return Ok(());
|
|
}
|
|
|
|
tracing::info!(
|
|
"[{repo_id}] Creating issues for {} findings via {}",
|
|
actionable.len(),
|
|
tracker.name()
|
|
);
|
|
|
|
let mut created = 0u32;
|
|
for finding in actionable {
|
|
let title = format!(
|
|
"[{}] {}: {}",
|
|
finding.severity, finding.scanner, finding.title
|
|
);
|
|
|
|
// Check if an issue already exists by fingerprint first, then by title
|
|
let mut found_existing = false;
|
|
for search_term in [&finding.fingerprint, &title] {
|
|
match tracker
|
|
.find_existing_issue(owner, tracker_repo_name, search_term)
|
|
.await
|
|
{
|
|
Ok(Some(existing)) => {
|
|
tracing::debug!(
|
|
"[{repo_id}] Issue already exists for '{}': {}",
|
|
search_term,
|
|
existing.external_url
|
|
);
|
|
found_existing = true;
|
|
break;
|
|
}
|
|
Ok(None) => {}
|
|
Err(e) => {
|
|
tracing::warn!("[{repo_id}] Failed to search for existing issue: {e}");
|
|
}
|
|
}
|
|
}
|
|
if found_existing {
|
|
continue;
|
|
}
|
|
let body = format_issue_body(finding);
|
|
let labels = vec![
|
|
format!("severity:{}", finding.severity),
|
|
format!("scanner:{}", finding.scanner),
|
|
"compliance-scanner".to_string(),
|
|
];
|
|
|
|
match tracker
|
|
.create_issue(owner, tracker_repo_name, &title, &body, &labels)
|
|
.await
|
|
{
|
|
Ok(mut issue) => {
|
|
issue.finding_id = finding
|
|
.id
|
|
.as_ref()
|
|
.map(|id| id.to_hex())
|
|
.unwrap_or_default();
|
|
|
|
// Update the finding with the issue URL
|
|
if let Some(finding_id) = &finding.id {
|
|
let _ = self
|
|
.db
|
|
.findings()
|
|
.update_one(
|
|
doc! { "_id": finding_id },
|
|
doc! { "$set": { "tracker_issue_url": &issue.external_url } },
|
|
)
|
|
.await;
|
|
}
|
|
|
|
// Store the tracker issue record
|
|
if let Err(e) = self.db.tracker_issues().insert_one(&issue).await {
|
|
tracing::warn!("[{repo_id}] Failed to store tracker issue: {e}");
|
|
}
|
|
|
|
created += 1;
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
"[{repo_id}] Failed to create issue for {}: {e}",
|
|
finding.fingerprint
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
tracing::info!("[{repo_id}] Created {created} tracker issues");
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Format a finding into a markdown issue body for the tracker.
|
|
pub(super) fn format_issue_body(finding: &Finding) -> String {
|
|
let mut body = String::new();
|
|
|
|
body.push_str(&format!("## {} Finding\n\n", finding.severity));
|
|
body.push_str(&format!("**Scanner:** {}\n", finding.scanner));
|
|
body.push_str(&format!("**Severity:** {}\n", finding.severity));
|
|
|
|
if let Some(rule) = &finding.rule_id {
|
|
body.push_str(&format!("**Rule:** {}\n", rule));
|
|
}
|
|
if let Some(cwe) = &finding.cwe {
|
|
body.push_str(&format!("**CWE:** {}\n", cwe));
|
|
}
|
|
|
|
body.push_str(&format!("\n### Description\n\n{}\n", finding.description));
|
|
|
|
if let Some(file_path) = &finding.file_path {
|
|
body.push_str(&format!("\n### Location\n\n**File:** `{}`", file_path));
|
|
if let Some(line) = finding.line_number {
|
|
body.push_str(&format!(" (line {})", line));
|
|
}
|
|
body.push('\n');
|
|
}
|
|
|
|
if let Some(snippet) = &finding.code_snippet {
|
|
body.push_str(&format!("\n### Code\n\n```\n{}\n```\n", snippet));
|
|
}
|
|
|
|
if let Some(remediation) = &finding.remediation {
|
|
body.push_str(&format!("\n### Remediation\n\n{}\n", remediation));
|
|
}
|
|
|
|
if let Some(fix) = &finding.suggested_fix {
|
|
body.push_str(&format!("\n### Suggested Fix\n\n```\n{}\n```\n", fix));
|
|
}
|
|
|
|
body.push_str(&format!(
|
|
"\n---\n*Fingerprint:* `{}`\n*Generated by compliance-scanner*",
|
|
finding.fingerprint
|
|
));
|
|
|
|
body
|
|
}
|