use crate::error::Result;
use crate::models::{rule_codes, Issue, Pipeline, Provider, Severity};
use regex::Regex;
use std::sync::OnceLock;
static SET_OUTPUT_REGEX: OnceLock<Regex> = OnceLock::new();
static SAVE_STATE_REGEX: OnceLock<Regex> = OnceLock::new();
static OLD_VERSION_REGEX: OnceLock<Regex> = OnceLock::new();
const EOL_NODE_TAGS: &[&str] = &["node:12", "node:16", "node:12-", "node:16-"];
const OLD_VERSIONS: &[&str] = &["@v1", "@v2"];
const COMMON_ACTIONS: &[&str] = &[
"actions/checkout",
"actions/setup-node",
"actions/setup-python",
"actions/setup-java",
"actions/setup-go",
"actions/cache",
"actions/upload-artifact",
"actions/download-artifact",
"actions/setup-dotnet",
"actions/setup-php",
"actions/setup-ruby",
"actions/setup-java-github",
];
pub fn audit(pipeline: &Pipeline) -> Result<Vec<Issue>> {
let mut issues = Vec::new();
if pipeline.provider != Provider::GitHubActions {
return Ok(issues);
}
let set_output_re =
SET_OUTPUT_REGEX.get_or_init(|| Regex::new(r"::set-output\s+name=\w+::").unwrap());
let save_state_re =
SAVE_STATE_REGEX.get_or_init(|| Regex::new(r"::save-state\s+name=\w+::").unwrap());
let _old_version_re = OLD_VERSION_REGEX.get_or_init(|| Regex::new(r"@\w+").unwrap());
for job in &pipeline.jobs {
if let Some(image) = &job.container_image {
let lower = image.to_lowercase();
for eol_tag in EOL_NODE_TAGS {
if lower.starts_with(eol_tag) || lower == *eol_tag {
let (line, col) = pipeline.find_job_line(&job.id, "container");
issues.push(Issue::for_job_with_code(
Severity::Warning,
&format!(
"Job '{}' uses end-of-life container image: {}",
job.id, image
),
&job.id,
line,
col,
Some("Upgrade to node:18 or node:20".to_string()),
rule_codes::DEPRECATED_ACTION,
));
break;
}
}
}
for step in &job.steps {
if let Some(run) = &step.run {
if set_output_re.is_match(run) {
let (line, col) = pipeline.find_job_line(&job.id, "run");
issues.push(Issue::for_job_with_code(
Severity::Warning,
&format!("Job '{}' uses deprecated 'set-output' command", job.id),
&job.id,
line,
col,
Some(
"Use $GITHUB_OUTPUT instead: echo 'name=value' >> $GITHUB_OUTPUT"
.to_string(),
),
rule_codes::DEPRECATED_ACTION,
));
}
if save_state_re.is_match(run) {
let (line, col) = pipeline.find_job_line(&job.id, "run");
issues.push(Issue::for_job_with_code(
Severity::Warning,
&format!("Job '{}' uses deprecated 'save-state' command", job.id),
&job.id,
line,
col,
Some(
"Use $GITHUB_STATE instead: echo 'name=value' >> $GITHUB_STATE"
.to_string(),
),
rule_codes::DEPRECATED_ACTION,
));
}
}
if let Some(uses) = &step.uses {
for common in COMMON_ACTIONS {
if uses.starts_with(common) {
for old_ver in OLD_VERSIONS {
if uses.ends_with(old_ver) {
let (line, col) = pipeline.find_job_line(&job.id, "uses");
issues.push(Issue::for_job_with_code(
Severity::Warning,
&format!(
"Job '{}' uses old version of {}: {}",
job.id, common, uses
),
&job.id,
line,
col,
Some(format!(
"Upgrade to the latest version (e.g. {}@v4)",
common
)),
rule_codes::DEPRECATED_ACTION,
));
break;
}
}
}
}
}
}
}
Ok(issues)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::Provider;
fn make_pipeline(source: &str, jobs: Vec<crate::models::Job>) -> Pipeline {
Pipeline {
provider: Provider::GitHubActions,
jobs,
env: vec![],
source: source.to_string(),
is_reusable: false,
workflow_call_inputs: Vec::new(),
workflow_call_secrets: Vec::new(),
workflow_rules: Vec::new(),
}
}
fn make_job(
id: &str,
run: Option<String>,
uses: Option<String>,
container: Option<String>,
) -> crate::models::Job {
crate::models::Job {
id: id.to_string(),
name: None,
depends_on: vec![],
steps: vec![crate::models::Step {
name: None,
uses,
run,
env: vec![],
with_inputs: None,
}],
env: vec![],
container_image: container,
service_images: vec![],
timeout_minutes: None,
rules: Vec::new(),
}
}
#[test]
fn test_set_output_detected() {
let source = "jobs:\n build:\n runs-on: ubuntu\n steps:\n - run: echo \"::set-output name=result::done\"\n";
let pipeline = make_pipeline(
source,
vec![make_job(
"build",
Some("echo \"::set-output name=result::done\"".into()),
None,
None,
)],
);
let issues = audit(&pipeline).unwrap();
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].severity, Severity::Warning);
assert!(issues[0].message.contains("set-output"));
assert_eq!(issues[0].rule_code, Some(rule_codes::DEPRECATED_ACTION));
}
#[test]
fn test_save_state_detected() {
let source = "jobs:\n build:\n runs-on: ubuntu\n steps:\n - run: echo \"::save-state name=result::done\"\n";
let pipeline = make_pipeline(
source,
vec![make_job(
"build",
Some("echo \"::save-state name=result::done\"".into()),
None,
None,
)],
);
let issues = audit(&pipeline).unwrap();
assert_eq!(issues.len(), 1);
assert!(issues[0].message.contains("save-state"));
}
#[test]
fn test_eol_node_image_detected() {
let source = "jobs:\n build:\n runs-on: ubuntu\n container: node:12\n steps:\n - run: echo hi\n";
let pipeline = make_pipeline(
source,
vec![make_job(
"build",
Some("echo hi".into()),
None,
Some("node:12".into()),
)],
);
let issues = audit(&pipeline).unwrap();
assert!(issues.iter().any(|i| i.message.contains("node:12")));
}
#[test]
fn test_old_action_version_detected() {
let source =
"jobs:\n build:\n runs-on: ubuntu\n steps:\n - uses: actions/checkout@v1\n";
let pipeline = make_pipeline(
source,
vec![make_job(
"build",
None,
Some("actions/checkout@v1".into()),
None,
)],
);
let issues = audit(&pipeline).unwrap();
assert!(issues
.iter()
.any(|i| i.message.contains("actions/checkout@v1")));
}
#[test]
fn test_no_issues_for_modern_workflow() {
let source = "jobs:\n build:\n runs-on: ubuntu\n container: node:20\n steps:\n - uses: actions/checkout@v4\n - run: echo \"result=$value\" >> $GITHUB_OUTPUT\n";
let pipeline = make_pipeline(
source,
vec![make_job(
"build",
Some("echo \"result=$value\" >> $GITHUB_OUTPUT".into()),
Some("actions/checkout@v4".into()),
Some("node:20".into()),
)],
);
let issues = audit(&pipeline).unwrap();
assert!(issues.is_empty());
}
#[test]
fn test_gitlab_provider_skipped() {
let source = "stages:\n - build\nbuild:\n script: echo hi\n";
let pipeline = Pipeline {
provider: Provider::GitLabCI,
jobs: vec![],
env: vec![],
source: source.to_string(),
is_reusable: false,
workflow_call_inputs: Vec::new(),
workflow_call_secrets: Vec::new(),
workflow_rules: Vec::new(),
};
let issues = audit(&pipeline).unwrap();
assert!(issues.is_empty());
}
}