Initial commit: Compliance Scanner Agent
Autonomous security and compliance scanning agent for git repositories. Features: SAST (Semgrep), SBOM (Syft), CVE monitoring (OSV.dev/NVD), GDPR/OAuth pattern detection, LLM triage, issue creation (GitHub/GitLab/Jira), PR reviews, and Dioxus fullstack dashboard. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
161
compliance-agent/src/trackers/github.rs
Normal file
161
compliance-agent/src/trackers/github.rs
Normal file
@@ -0,0 +1,161 @@
|
||||
use compliance_core::error::CoreError;
|
||||
use compliance_core::models::{TrackerIssue, TrackerType};
|
||||
use compliance_core::traits::issue_tracker::{IssueTracker, ReviewComment};
|
||||
use octocrab::Octocrab;
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
|
||||
pub struct GitHubTracker {
|
||||
client: Octocrab,
|
||||
}
|
||||
|
||||
impl GitHubTracker {
|
||||
pub fn new(token: &SecretString) -> Result<Self, CoreError> {
|
||||
let client = Octocrab::builder()
|
||||
.personal_token(token.expose_secret().to_string())
|
||||
.build()
|
||||
.map_err(|e| CoreError::IssueTracker(format!("Failed to create GitHub client: {e}")))?;
|
||||
Ok(Self { client })
|
||||
}
|
||||
}
|
||||
|
||||
impl IssueTracker for GitHubTracker {
|
||||
fn name(&self) -> &str {
|
||||
"github"
|
||||
}
|
||||
|
||||
async fn create_issue(
|
||||
&self,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
title: &str,
|
||||
body: &str,
|
||||
labels: &[String],
|
||||
) -> Result<TrackerIssue, CoreError> {
|
||||
let issues_handler = self.client.issues(owner, repo);
|
||||
let mut builder = issues_handler.create(title).body(body);
|
||||
if !labels.is_empty() {
|
||||
builder = builder.labels(labels.to_vec());
|
||||
}
|
||||
let issue = builder
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| CoreError::IssueTracker(format!("GitHub create issue failed: {e}")))?;
|
||||
|
||||
Ok(TrackerIssue::new(
|
||||
String::new(),
|
||||
TrackerType::GitHub,
|
||||
issue.number.to_string(),
|
||||
issue.html_url.to_string(),
|
||||
title.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn update_issue_status(
|
||||
&self,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
external_id: &str,
|
||||
status: &str,
|
||||
) -> Result<(), CoreError> {
|
||||
let issue_number: u64 = external_id
|
||||
.parse()
|
||||
.map_err(|_| CoreError::IssueTracker("Invalid issue number".to_string()))?;
|
||||
|
||||
let state_str = match status {
|
||||
"closed" | "resolved" => "closed",
|
||||
_ => "open",
|
||||
};
|
||||
|
||||
// Use the REST API directly for state update
|
||||
let route = format!("/repos/{owner}/{repo}/issues/{issue_number}");
|
||||
let body = serde_json::json!({ "state": state_str });
|
||||
self.client
|
||||
.post::<serde_json::Value, _>(route, Some(&body))
|
||||
.await
|
||||
.map_err(|e| CoreError::IssueTracker(format!("GitHub update issue failed: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn add_comment(
|
||||
&self,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
external_id: &str,
|
||||
body: &str,
|
||||
) -> Result<(), CoreError> {
|
||||
let issue_number: u64 = external_id
|
||||
.parse()
|
||||
.map_err(|_| CoreError::IssueTracker("Invalid issue number".to_string()))?;
|
||||
|
||||
self.client
|
||||
.issues(owner, repo)
|
||||
.create_comment(issue_number, body)
|
||||
.await
|
||||
.map_err(|e| CoreError::IssueTracker(format!("GitHub add comment failed: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_pr_review(
|
||||
&self,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
pr_number: u64,
|
||||
body: &str,
|
||||
comments: Vec<ReviewComment>,
|
||||
) -> Result<(), CoreError> {
|
||||
let review_comments: Vec<serde_json::Value> = comments
|
||||
.iter()
|
||||
.map(|c| {
|
||||
serde_json::json!({
|
||||
"path": c.path,
|
||||
"line": c.line,
|
||||
"body": c.body,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let review_body = serde_json::json!({
|
||||
"body": body,
|
||||
"event": "COMMENT",
|
||||
"comments": review_comments,
|
||||
});
|
||||
|
||||
let route = format!("/repos/{owner}/{repo}/pulls/{pr_number}/reviews");
|
||||
self.client
|
||||
.post::<serde_json::Value, ()>(route, Some(&review_body))
|
||||
.await
|
||||
.map_err(|e| CoreError::IssueTracker(format!("GitHub PR review failed: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn find_existing_issue(
|
||||
&self,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
fingerprint: &str,
|
||||
) -> Result<Option<TrackerIssue>, CoreError> {
|
||||
let query = format!("repo:{owner}/{repo} is:issue {fingerprint}");
|
||||
let results = self
|
||||
.client
|
||||
.search()
|
||||
.issues_and_pull_requests(&query)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| CoreError::IssueTracker(format!("GitHub search failed: {e}")))?;
|
||||
|
||||
if let Some(issue) = results.items.first() {
|
||||
Ok(Some(TrackerIssue::new(
|
||||
String::new(),
|
||||
TrackerType::GitHub,
|
||||
issue.number.to_string(),
|
||||
issue.html_url.to_string(),
|
||||
issue.title.clone(),
|
||||
)))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
201
compliance-agent/src/trackers/gitlab.rs
Normal file
201
compliance-agent/src/trackers/gitlab.rs
Normal file
@@ -0,0 +1,201 @@
|
||||
use compliance_core::error::CoreError;
|
||||
use compliance_core::models::{TrackerIssue, TrackerType};
|
||||
use compliance_core::traits::issue_tracker::{IssueTracker, ReviewComment};
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
|
||||
pub struct GitLabTracker {
|
||||
base_url: String,
|
||||
http: reqwest::Client,
|
||||
token: SecretString,
|
||||
}
|
||||
|
||||
impl GitLabTracker {
|
||||
pub fn new(base_url: String, token: SecretString) -> Self {
|
||||
Self {
|
||||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
http: reqwest::Client::new(),
|
||||
token,
|
||||
}
|
||||
}
|
||||
|
||||
fn api_url(&self, path: &str) -> String {
|
||||
format!("{}/api/v4{}", self.base_url, path)
|
||||
}
|
||||
|
||||
fn project_path(owner: &str, repo: &str) -> String {
|
||||
urlencoding::encode(&format!("{owner}/{repo}")).to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl IssueTracker for GitLabTracker {
|
||||
fn name(&self) -> &str {
|
||||
"gitlab"
|
||||
}
|
||||
|
||||
async fn create_issue(
|
||||
&self,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
title: &str,
|
||||
body: &str,
|
||||
labels: &[String],
|
||||
) -> Result<TrackerIssue, CoreError> {
|
||||
let project = Self::project_path(owner, repo);
|
||||
let url = self.api_url(&format!("/projects/{project}/issues"));
|
||||
|
||||
let mut payload = serde_json::json!({
|
||||
"title": title,
|
||||
"description": body,
|
||||
});
|
||||
if !labels.is_empty() {
|
||||
payload["labels"] = serde_json::Value::String(labels.join(","));
|
||||
}
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.post(&url)
|
||||
.header("PRIVATE-TOKEN", self.token.expose_secret())
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| CoreError::IssueTracker(format!("GitLab create issue failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(CoreError::IssueTracker(format!("GitLab returned {status}: {body}")));
|
||||
}
|
||||
|
||||
let issue: serde_json::Value = resp.json().await
|
||||
.map_err(|e| CoreError::IssueTracker(format!("Failed to parse GitLab response: {e}")))?;
|
||||
|
||||
Ok(TrackerIssue::new(
|
||||
String::new(),
|
||||
TrackerType::GitLab,
|
||||
issue["iid"].to_string(),
|
||||
issue["web_url"].as_str().unwrap_or("").to_string(),
|
||||
title.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn update_issue_status(
|
||||
&self,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
external_id: &str,
|
||||
status: &str,
|
||||
) -> Result<(), CoreError> {
|
||||
let project = Self::project_path(owner, repo);
|
||||
let url = self.api_url(&format!("/projects/{project}/issues/{external_id}"));
|
||||
|
||||
let state_event = match status {
|
||||
"closed" | "resolved" => "close",
|
||||
_ => "reopen",
|
||||
};
|
||||
|
||||
self.http
|
||||
.put(&url)
|
||||
.header("PRIVATE-TOKEN", self.token.expose_secret())
|
||||
.json(&serde_json::json!({ "state_event": state_event }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| CoreError::IssueTracker(format!("GitLab update issue failed: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn add_comment(
|
||||
&self,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
external_id: &str,
|
||||
body: &str,
|
||||
) -> Result<(), CoreError> {
|
||||
let project = Self::project_path(owner, repo);
|
||||
let url = self.api_url(&format!("/projects/{project}/issues/{external_id}/notes"));
|
||||
|
||||
self.http
|
||||
.post(&url)
|
||||
.header("PRIVATE-TOKEN", self.token.expose_secret())
|
||||
.json(&serde_json::json!({ "body": body }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| CoreError::IssueTracker(format!("GitLab add comment failed: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_pr_review(
|
||||
&self,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
pr_number: u64,
|
||||
body: &str,
|
||||
comments: Vec<ReviewComment>,
|
||||
) -> Result<(), CoreError> {
|
||||
let project = Self::project_path(owner, repo);
|
||||
|
||||
// Post overall review as MR note
|
||||
let note_url = self.api_url(&format!("/projects/{project}/merge_requests/{pr_number}/notes"));
|
||||
self.http
|
||||
.post(¬e_url)
|
||||
.header("PRIVATE-TOKEN", self.token.expose_secret())
|
||||
.json(&serde_json::json!({ "body": body }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| CoreError::IssueTracker(format!("GitLab MR note failed: {e}")))?;
|
||||
|
||||
// Post individual line comments as MR discussions
|
||||
for comment in comments {
|
||||
let disc_url = self.api_url(&format!("/projects/{project}/merge_requests/{pr_number}/discussions"));
|
||||
let payload = serde_json::json!({
|
||||
"body": comment.body,
|
||||
"position": {
|
||||
"position_type": "text",
|
||||
"new_path": comment.path,
|
||||
"new_line": comment.line,
|
||||
}
|
||||
});
|
||||
let _ = self
|
||||
.http
|
||||
.post(&disc_url)
|
||||
.header("PRIVATE-TOKEN", self.token.expose_secret())
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn find_existing_issue(
|
||||
&self,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
fingerprint: &str,
|
||||
) -> Result<Option<TrackerIssue>, CoreError> {
|
||||
let project = Self::project_path(owner, repo);
|
||||
let url = self.api_url(&format!("/projects/{project}/issues?search={fingerprint}"));
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.get(&url)
|
||||
.header("PRIVATE-TOKEN", self.token.expose_secret())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| CoreError::IssueTracker(format!("GitLab search failed: {e}")))?;
|
||||
|
||||
let issues: Vec<serde_json::Value> = resp.json().await.unwrap_or_default();
|
||||
if let Some(issue) = issues.first() {
|
||||
Ok(Some(TrackerIssue::new(
|
||||
String::new(),
|
||||
TrackerType::GitLab,
|
||||
issue["iid"].to_string(),
|
||||
issue["web_url"].as_str().unwrap_or("").to_string(),
|
||||
issue["title"].as_str().unwrap_or("").to_string(),
|
||||
)))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
231
compliance-agent/src/trackers/jira.rs
Normal file
231
compliance-agent/src/trackers/jira.rs
Normal file
@@ -0,0 +1,231 @@
|
||||
use compliance_core::error::CoreError;
|
||||
use compliance_core::models::{TrackerIssue, TrackerType};
|
||||
use compliance_core::traits::issue_tracker::{IssueTracker, ReviewComment};
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
|
||||
pub struct JiraTracker {
|
||||
base_url: String,
|
||||
email: String,
|
||||
api_token: SecretString,
|
||||
project_key: String,
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
impl JiraTracker {
|
||||
pub fn new(base_url: String, email: String, api_token: SecretString, project_key: String) -> Self {
|
||||
Self {
|
||||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
email,
|
||||
api_token,
|
||||
project_key,
|
||||
http: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn auth_header(&self) -> String {
|
||||
use base64::Engine;
|
||||
let credentials = format!("{}:{}", self.email, self.api_token.expose_secret());
|
||||
format!("Basic {}", base64::engine::general_purpose::STANDARD.encode(credentials))
|
||||
}
|
||||
}
|
||||
|
||||
impl IssueTracker for JiraTracker {
|
||||
fn name(&self) -> &str {
|
||||
"jira"
|
||||
}
|
||||
|
||||
async fn create_issue(
|
||||
&self,
|
||||
_owner: &str,
|
||||
_repo: &str,
|
||||
title: &str,
|
||||
body: &str,
|
||||
labels: &[String],
|
||||
) -> Result<TrackerIssue, CoreError> {
|
||||
let url = format!("{}/rest/api/3/issue", self.base_url);
|
||||
|
||||
let mut payload = serde_json::json!({
|
||||
"fields": {
|
||||
"project": { "key": self.project_key },
|
||||
"summary": title,
|
||||
"description": {
|
||||
"type": "doc",
|
||||
"version": 1,
|
||||
"content": [{
|
||||
"type": "paragraph",
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": body,
|
||||
}]
|
||||
}]
|
||||
},
|
||||
"issuetype": { "name": "Bug" },
|
||||
}
|
||||
});
|
||||
|
||||
if !labels.is_empty() {
|
||||
payload["fields"]["labels"] = serde_json::Value::Array(
|
||||
labels.iter().map(|l| serde_json::Value::String(l.clone())).collect(),
|
||||
);
|
||||
}
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.post(&url)
|
||||
.header("Authorization", self.auth_header())
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| CoreError::IssueTracker(format!("Jira create issue failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(CoreError::IssueTracker(format!("Jira returned {status}: {body}")));
|
||||
}
|
||||
|
||||
let issue: serde_json::Value = resp.json().await
|
||||
.map_err(|e| CoreError::IssueTracker(format!("Failed to parse Jira response: {e}")))?;
|
||||
|
||||
let key = issue["key"].as_str().unwrap_or("").to_string();
|
||||
let url = format!("{}/browse/{}", self.base_url, key);
|
||||
|
||||
Ok(TrackerIssue::new(
|
||||
String::new(),
|
||||
TrackerType::Jira,
|
||||
key,
|
||||
url,
|
||||
title.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn update_issue_status(
|
||||
&self,
|
||||
_owner: &str,
|
||||
_repo: &str,
|
||||
external_id: &str,
|
||||
status: &str,
|
||||
) -> Result<(), CoreError> {
|
||||
// Get available transitions
|
||||
let url = format!("{}/rest/api/3/issue/{external_id}/transitions", self.base_url);
|
||||
let resp = self
|
||||
.http
|
||||
.get(&url)
|
||||
.header("Authorization", self.auth_header())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| CoreError::IssueTracker(format!("Jira get transitions failed: {e}")))?;
|
||||
|
||||
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
let transitions = body["transitions"].as_array();
|
||||
|
||||
// Find matching transition
|
||||
if let Some(transitions) = transitions {
|
||||
let target = match status {
|
||||
"closed" | "resolved" => "Done",
|
||||
"in_progress" => "In Progress",
|
||||
_ => "To Do",
|
||||
};
|
||||
|
||||
if let Some(transition) = transitions.iter().find(|t| {
|
||||
t["name"].as_str().map(|n| n.eq_ignore_ascii_case(target)).unwrap_or(false)
|
||||
}) {
|
||||
let transition_id = transition["id"].as_str().unwrap_or("");
|
||||
self.http
|
||||
.post(&format!("{}/rest/api/3/issue/{external_id}/transitions", self.base_url))
|
||||
.header("Authorization", self.auth_header())
|
||||
.json(&serde_json::json!({ "transition": { "id": transition_id } }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| CoreError::IssueTracker(format!("Jira transition failed: {e}")))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn add_comment(
|
||||
&self,
|
||||
_owner: &str,
|
||||
_repo: &str,
|
||||
external_id: &str,
|
||||
body: &str,
|
||||
) -> Result<(), CoreError> {
|
||||
let url = format!("{}/rest/api/3/issue/{external_id}/comment", self.base_url);
|
||||
|
||||
self.http
|
||||
.post(&url)
|
||||
.header("Authorization", self.auth_header())
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&serde_json::json!({
|
||||
"body": {
|
||||
"type": "doc",
|
||||
"version": 1,
|
||||
"content": [{
|
||||
"type": "paragraph",
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": body,
|
||||
}]
|
||||
}]
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| CoreError::IssueTracker(format!("Jira add comment failed: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_pr_review(
|
||||
&self,
|
||||
_owner: &str,
|
||||
_repo: &str,
|
||||
_pr_number: u64,
|
||||
_body: &str,
|
||||
_comments: Vec<ReviewComment>,
|
||||
) -> Result<(), CoreError> {
|
||||
// Jira doesn't have native PR reviews - this is a no-op
|
||||
tracing::info!("Jira doesn't support PR reviews natively, skipping");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn find_existing_issue(
|
||||
&self,
|
||||
_owner: &str,
|
||||
_repo: &str,
|
||||
fingerprint: &str,
|
||||
) -> Result<Option<TrackerIssue>, CoreError> {
|
||||
let jql = format!(
|
||||
"project = {} AND text ~ \"{}\"",
|
||||
self.project_key, fingerprint
|
||||
);
|
||||
let url = format!("{}/rest/api/3/search", self.base_url);
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.get(&url)
|
||||
.header("Authorization", self.auth_header())
|
||||
.query(&[("jql", &jql), ("maxResults", &"1".to_string())])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| CoreError::IssueTracker(format!("Jira search failed: {e}")))?;
|
||||
|
||||
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
if let Some(issue) = body["issues"].as_array().and_then(|arr| arr.first()) {
|
||||
let key = issue["key"].as_str().unwrap_or("").to_string();
|
||||
let url = format!("{}/browse/{}", self.base_url, key);
|
||||
let title = issue["fields"]["summary"].as_str().unwrap_or("").to_string();
|
||||
Ok(Some(TrackerIssue::new(
|
||||
String::new(),
|
||||
TrackerType::Jira,
|
||||
key,
|
||||
url,
|
||||
title,
|
||||
)))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
3
compliance-agent/src/trackers/mod.rs
Normal file
3
compliance-agent/src/trackers/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod github;
|
||||
pub mod gitlab;
|
||||
pub mod jira;
|
||||
Reference in New Issue
Block a user