use crate::core::{Finding, Severity};
use crate::plugins::traits::{PluginError, PluginReport, ScanContext, ScanPhase, SecurityPlugin};
use async_trait::async_trait;
use lazy_static::lazy_static;
use regex::Regex;
use std::path::Path;
use std::time::Instant;
lazy_static! {
static ref GENERIC_CI_PATTERNS: Vec<(Regex, &'static str, Severity, &'static str)> = vec![
(
Regex::new(r"curl\s+.*\$\{?\{?\s*secrets\.").unwrap(),
"CI secret exfiltration via curl",
Severity::Critical,
"Secrets are being sent to an external endpoint. CWE-200.",
),
(
Regex::new(r"wget\s+.*\$\{?\{?\s*secrets\.").unwrap(),
"CI secret exfiltration via wget",
Severity::Critical,
"Secrets are being sent to an external endpoint. CWE-200.",
),
(
Regex::new(r"\$\{\{\s*secrets\.\w+\s*\}\}").unwrap(),
"Direct secret interpolation in run step",
Severity::Medium,
"Secrets interpolated in shell context can leak via process listing or error messages. Use environment variables instead.",
),
(
Regex::new(r"(?i)(nc|ncat|netcat)\s+(-e|-c)").unwrap(),
"Netcat reverse shell in CI pipeline",
Severity::Critical,
"Netcat with -e/-c flag creates a reverse shell. CWE-78.",
),
(
Regex::new(r"/dev/tcp/").unwrap(),
"Bash reverse shell via /dev/tcp in CI",
Severity::Critical,
"Bash /dev/tcp used for network connection, common reverse shell pattern.",
),
(
Regex::new(r"(?i)curl\s+.*\|\s*(ba)?sh").unwrap(),
"Pipe curl to shell in CI pipeline",
Severity::High,
"Downloading and executing remote scripts in CI is a supply chain risk.",
),
(
Regex::new(r"(?i)wget\s+.*\|\s*(ba)?sh").unwrap(),
"Pipe wget to shell in CI pipeline",
Severity::High,
"Downloading and executing remote scripts in CI is a supply chain risk.",
),
];
static ref GITHUB_ACTIONS_PATTERNS: Vec<(Regex, &'static str, Severity, &'static str)> = vec![
(
Regex::new(r"(?i)pull_request_target").unwrap(),
"GitHub Actions pull_request_target trigger",
Severity::Critical,
"pull_request_target runs with write access and secrets from the base repo. \
Combined with actions/checkout of PR code, this enables code execution with elevated privileges. CWE-863.",
),
(
Regex::new(r"(?i)permissions:\s*write-all").unwrap(),
"GitHub Actions overly permissive OIDC token",
Severity::High,
"write-all grants the GITHUB_TOKEN full read/write access to all scopes. Use least-privilege permissions.",
),
(
Regex::new(r"(?i)\$\{\{\s*github\.event\.(issue|pull_request|comment)\.body\s*\}\}").unwrap(),
"Untrusted input in GitHub Actions expression",
Severity::High,
"User-controlled input directly in expression context enables injection attacks. CWE-94.",
),
(
Regex::new(r"(?i)\$\{\{\s*github\.event\.pull_request\.title\s*\}\}").unwrap(),
"PR title injection in GitHub Actions",
Severity::High,
"PR titles are attacker-controlled and can inject commands when used in run: steps. CWE-78.",
),
(
Regex::new(r"(?i)\$\{\{\s*github\.head_ref\s*\}\}").unwrap(),
"Branch name injection in GitHub Actions",
Severity::Medium,
"Branch names are attacker-controlled in fork PRs and can inject commands. CWE-78.",
),
(
Regex::new(r"(?i)uses:\s*actions/checkout@.*\n\s*with:\s*\n\s*ref:\s*\$\{\{.*github\.event\.pull_request\.head\.sha").unwrap(),
"Checkout of untrusted PR code with pull_request_target",
Severity::Critical,
"Checking out PR head in pull_request_target context runs untrusted code with write access. CWE-829.",
),
(
Regex::new(r"(?i)ACTIONS_RUNTIME_TOKEN").unwrap(),
"GitHub Actions runtime token exposure",
Severity::High,
"ACTIONS_RUNTIME_TOKEN provides access to the Actions cache and artifacts.",
),
];
static ref GITLAB_CI_PATTERNS: Vec<(Regex, &'static str, Severity, &'static str)> = vec![
(
Regex::new(r"(?i)include:\s*\n\s*-?\s*(remote|project):").unwrap(),
"GitLab CI remote include",
Severity::Medium,
"Remote includes can be tampered with if the source is compromised.",
),
(
Regex::new(r"(?i)allow_failure:\s*true").unwrap(),
"GitLab CI allow_failure bypasses security checks",
Severity::Medium,
"allow_failure: true on security scanning stages lets vulnerable code through.",
),
(
Regex::new(r"(?i)when:\s*manual").unwrap(),
"GitLab CI manual gate on security stage",
Severity::Low,
"Manual gates on security stages may be skipped in practice.",
),
];
static ref JENKINS_PATTERNS: Vec<(Regex, &'static str, Severity, &'static str)> = vec![
(
Regex::new(r"(?i)@Grab\s*\(").unwrap(),
"Jenkins @Grab dependency injection",
Severity::Critical,
"@Grab in Jenkinsfile downloads arbitrary dependencies at runtime. CWE-829.",
),
(
Regex::new(r#"(?i)sh\s+['"].*\$\{.*\}"#).unwrap(),
"Shell injection via parameter interpolation in Jenkinsfile",
Severity::High,
"String interpolation in sh steps enables command injection. Use sh(script:) with single quotes.",
),
(
Regex::new(r#"(?i)load\s+['"]"#).unwrap(),
"Jenkins pipeline library load",
Severity::Medium,
"Dynamic pipeline library loading can execute untrusted Groovy code.",
),
];
static ref TRAVIS_PATTERNS: Vec<(Regex, &'static str, Severity, &'static str)> = vec![
(
Regex::new(r"(?i)sudo:\s*(true|required|enabled)").unwrap(),
"Travis CI sudo enabled",
Severity::Medium,
"Sudo access in CI enables privilege escalation attacks.",
),
];
static ref CIRCLECI_PATTERNS: Vec<(Regex, &'static str, Severity, &'static str)> = vec![
(
Regex::new(r"(?i)setup:\s*true").unwrap(),
"CircleCI dynamic config (setup workflow)",
Severity::Medium,
"Dynamic configuration can be manipulated to alter pipeline behavior.",
),
];
static ref UNTRUSTED_IMAGE_PATTERNS: Vec<(Regex, &'static str, Severity, &'static str)> = vec![
(
Regex::new(r#"(?i)image:\s*['"]?[a-z0-9.-]+\.[a-z]{2,}/"#).unwrap(),
"CI uses Docker image from third-party registry",
Severity::Medium,
"Docker images from non-standard registries may contain malicious code. Prefer official images or pin digests.",
),
];
static ref TRUSTED_REGISTRIES: Vec<&'static str> = vec![
"docker.io", "ghcr.io", "gcr.io", "quay.io", "public.ecr.aws",
"registry.hub.docker.com", "docker.pkg.github.com",
];
}
pub struct CicdScanner;
impl Default for CicdScanner {
fn default() -> Self {
Self::new()
}
}
impl CicdScanner {
pub fn new() -> Self {
Self
}
fn is_cicd_file(path: &Path) -> bool {
let path_str = path.to_string_lossy().to_lowercase();
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if path_str.contains(".github/workflows/")
&& (path_str.ends_with(".yml") || path_str.ends_with(".yaml"))
{
return true;
}
if filename == ".gitlab-ci.yml" || path_str.contains(".gitlab-ci") {
return true;
}
if filename == "Jenkinsfile" || filename.starts_with("Jenkinsfile.") {
return true;
}
if filename == ".travis.yml" {
return true;
}
if path_str.contains(".circleci/")
&& (path_str.ends_with(".yml") || path_str.ends_with(".yaml"))
{
return true;
}
if filename == "azure-pipelines.yml" || path_str.contains("azure-pipelines/") {
return true;
}
false
}
fn is_cicd_content(path: &Path, content: &str) -> bool {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if ext != "yml" && ext != "yaml" {
return false;
}
if content.contains("runs-on:") && (content.contains("steps:") || content.contains("jobs:"))
{
return true;
}
if content.contains("stages:") && content.contains("script:") {
return true;
}
false
}
fn detect_ci_type(path: &Path, content: Option<&str>) -> CiType {
let path_str = path.to_string_lossy().to_lowercase();
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if path_str.contains(".github/workflows/") {
CiType::GitHubActions
} else if filename == ".gitlab-ci.yml" || path_str.contains(".gitlab-ci") {
CiType::GitLabCi
} else if filename == "Jenkinsfile" || filename.starts_with("Jenkinsfile.") {
CiType::Jenkins
} else if filename == ".travis.yml" {
CiType::Travis
} else if path_str.contains(".circleci/") {
CiType::CircleCi
} else if let Some(c) = content {
if c.contains("runs-on:") && (c.contains("steps:") || c.contains("jobs:")) {
CiType::GitHubActions
} else if c.contains("stages:") && c.contains("script:") {
CiType::GitLabCi
} else {
CiType::Unknown
}
} else {
CiType::Unknown
}
}
fn scan_content(path: &Path, content: &str) -> Vec<Finding> {
let mut findings = Vec::new();
let ci_type = Self::detect_ci_type(path, Some(content));
Self::apply_patterns(path, content, &GENERIC_CI_PATTERNS, &mut findings);
Self::apply_patterns_with_allowlist(
path,
content,
&UNTRUSTED_IMAGE_PATTERNS,
&TRUSTED_REGISTRIES,
&mut findings,
);
match ci_type {
CiType::GitHubActions => {
Self::apply_patterns(path, content, &GITHUB_ACTIONS_PATTERNS, &mut findings);
}
CiType::GitLabCi => {
Self::apply_patterns(path, content, &GITLAB_CI_PATTERNS, &mut findings);
}
CiType::Jenkins => {
Self::apply_patterns(path, content, &JENKINS_PATTERNS, &mut findings);
}
CiType::Travis => {
Self::apply_patterns(path, content, &TRAVIS_PATTERNS, &mut findings);
}
CiType::CircleCi => {
Self::apply_patterns(path, content, &CIRCLECI_PATTERNS, &mut findings);
}
CiType::Unknown => {}
}
findings
}
fn apply_patterns(
path: &Path,
content: &str,
patterns: &[(Regex, &'static str, Severity, &'static str)],
findings: &mut Vec<Finding>,
) {
Self::apply_patterns_with_allowlist(path, content, patterns, &[], findings);
}
fn apply_patterns_with_allowlist(
path: &Path,
content: &str,
patterns: &[(Regex, &'static str, Severity, &'static str)],
allowlist: &[&str],
findings: &mut Vec<Finding>,
) {
for (line_num, line) in content.lines().enumerate() {
for (pattern, title, severity, description) in patterns.iter() {
if pattern.is_match(line) {
if !allowlist.is_empty() && allowlist.iter().any(|a| line.contains(a)) {
continue;
}
findings.push(
Finding::new(
format!("CICD-{:03}", findings.len() + 1),
title.to_string(),
*severity,
)
.with_file(path.to_path_buf())
.with_line((line_num + 1) as u32)
.with_evidence(line.trim().to_string())
.with_description(description.to_string()),
);
}
}
}
}
}
#[derive(Debug, Clone, Copy)]
enum CiType {
GitHubActions,
GitLabCi,
Jenkins,
Travis,
CircleCi,
Unknown,
}
#[async_trait]
impl SecurityPlugin for CicdScanner {
fn name(&self) -> &str {
"cicd"
}
fn version(&self) -> &str {
"0.1.0"
}
fn description(&self) -> &str {
"Detect CI/CD pipeline poisoning and misconfigurations"
}
fn scan_phase(&self) -> ScanPhase {
ScanPhase::All
}
async fn initialize(&mut self) -> Result<(), PluginError> {
Ok(())
}
async fn scan(&self, context: &ScanContext<'_>) -> Result<PluginReport, PluginError> {
let start = Instant::now();
let mut report = PluginReport::new(self.name().to_string());
let is_ci_by_path = Self::is_cicd_file(context.path);
let is_ci_by_content = if !is_ci_by_path {
context
.file_content
.map(|c| Self::is_cicd_content(context.path, &String::from_utf8_lossy(c)))
.unwrap_or(false)
} else {
false
};
if !is_ci_by_path && !is_ci_by_content {
report.duration_ms = start.elapsed().as_millis() as u64;
return Ok(report);
}
if let Some(content) = context.file_content {
let content_str = String::from_utf8_lossy(content);
report.findings = Self::scan_content(context.path, &content_str);
report.scanned_files = 1;
}
report.duration_ms = start.elapsed().as_millis() as u64;
Ok(report)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::plugins::traits::ScanContext;
use std::collections::HashMap;
#[tokio::test]
async fn test_pull_request_target() {
let scanner = CicdScanner::new();
let content = b"on:\n pull_request_target:\n types: [opened]";
let context = ScanContext {
path: Path::new(".github/workflows/ci.yml"),
scan_phase: ScanPhase::PostExtract,
file_content: Some(content),
metadata: HashMap::new(),
};
let report = scanner.scan(&context).await.unwrap();
assert!(!report.findings.is_empty());
assert!(report
.findings
.iter()
.any(|f| f.title.contains("pull_request_target")));
}
#[tokio::test]
async fn test_curl_pipe_bash() {
let scanner = CicdScanner::new();
let content = b"run: curl -sL https://example.com/install.sh | bash";
let context = ScanContext {
path: Path::new(".github/workflows/build.yml"),
scan_phase: ScanPhase::PostExtract,
file_content: Some(content),
metadata: HashMap::new(),
};
let report = scanner.scan(&context).await.unwrap();
assert!(!report.findings.is_empty());
}
#[tokio::test]
async fn test_non_ci_file_skipped() {
let scanner = CicdScanner::new();
let content = b"pull_request_target";
let context = ScanContext {
path: Path::new("src/main.rs"),
scan_phase: ScanPhase::PostExtract,
file_content: Some(content),
metadata: HashMap::new(),
};
let report = scanner.scan(&context).await.unwrap();
assert!(report.findings.is_empty());
}
#[tokio::test]
async fn test_jenkins_grab() {
let scanner = CicdScanner::new();
let content = b"@Grab('org.apache.commons:commons-exec:1.3')";
let context = ScanContext {
path: Path::new("Jenkinsfile"),
scan_phase: ScanPhase::PostExtract,
file_content: Some(content),
metadata: HashMap::new(),
};
let report = scanner.scan(&context).await.unwrap();
assert!(!report.findings.is_empty());
}
}