use crate::config::Level;
use crate::domain::workflow_actions::{JobId, StepIndex};
use crate::domain::workflow_parsed::{Job, Parsed, Step, Trigger};
use crate::lint::{Context, Diagnostic, Rule, RuleName};
use crate::regex::static_regex;
pub struct UnprotectedSecretsRule;
const FORK_GATE_FRAGMENTS: &[&str] = &[
"github.event.pull_request.head.repo.full_name == github.repository",
"github.repository_owner ==",
];
static_regex!(SECRET_RE, r"secrets\.([A-Za-z_][A-Za-z0-9_]*)");
impl UnprotectedSecretsRule {
pub fn check_workflow(workflow: &Parsed) -> Vec<Diagnostic> {
if !workflow.has_trigger(&Trigger::PullRequest) {
return Vec::new();
}
if workflow.has_trigger(&Trigger::PullRequestTarget)
|| workflow.has_trigger(&Trigger::WorkflowRun)
{
return Vec::new();
}
let mut out = Vec::new();
for job in &workflow.jobs {
for (idx, step) in job.steps.iter().enumerate() {
let secrets = unguarded_secrets(job, step);
if secrets.is_empty() {
continue;
}
let msg = format!(
"{}: job `{}` step {} references {} without a fork-PR `if:` gate — guard the step with `if: github.event.pull_request.head.repo.full_name == github.repository` so the secret never reaches fork PR code",
workflow.path,
job.id,
idx,
format_secret_list(&secrets),
);
let mut diag = Diagnostic::new(RuleName::UnprotectedSecrets, Level::Error, msg)
.with_workflow(workflow.path.clone())
.with_job(JobId::from(job.id.clone()));
if let Ok(si) = StepIndex::try_from(idx) {
diag = diag.with_step(si);
}
out.push(diag);
}
}
out
}
}
fn unguarded_secrets(job: &Job, step: &Step) -> Vec<String> {
let names = step_secret_names(step);
if names.is_empty() {
return names;
}
if has_fork_gate(job, step) {
return Vec::new();
}
names
}
fn step_secret_names(step: &Step) -> Vec<String> {
let mut found: Vec<String> = Vec::new();
let mut visit = |text: &str| {
for cap in SECRET_RE.captures_iter(text) {
let name = cap[1].to_string();
if name == "GITHUB_TOKEN" {
continue;
}
if !found.contains(&name) {
found.push(name);
}
}
};
for v in step.with.values() {
visit(v.as_str());
}
for v in step.env.values() {
visit(v.as_str());
}
if let Some(run) = &step.run {
visit(run);
}
found
}
fn has_fork_gate(job: &Job, step: &Step) -> bool {
let mut combined = String::new();
if let Some(job_if) = &job.if_cond {
combined.push_str(job_if);
combined.push('\n');
}
if let Some(step_if) = &step.if_cond {
combined.push_str(step_if);
}
FORK_GATE_FRAGMENTS
.iter()
.any(|frag| combined.contains(frag))
}
fn format_secret_list(secrets: &[String]) -> String {
match secrets {
[single] => format!("`secrets.{single}`"),
many => many
.iter()
.map(|s| format!("`secrets.{s}`"))
.collect::<Vec<_>>()
.join(", "),
}
}
impl Rule for UnprotectedSecretsRule {
fn name(&self) -> RuleName {
RuleName::UnprotectedSecrets
}
fn default_level(&self) -> Level {
Level::Error
}
fn check(&self, ctx: &Context) -> Vec<Diagnostic> {
ctx.workflows_full
.iter()
.flat_map(Self::check_workflow)
.collect()
}
}
#[cfg(test)]
#[expect(
clippy::unwrap_used,
clippy::indexing_slicing,
reason = "tests use unwrap and indexing freely"
)]
mod tests {
use super::*;
use crate::domain::workflow_actions::WorkflowPath;
fn parse(content: &str) -> Parsed {
Parsed::from_yaml(WorkflowPath::new(".github/workflows/x.yml"), content).unwrap()
}
#[test]
fn rule_metadata() {
let r = UnprotectedSecretsRule;
assert_eq!(r.name(), RuleName::UnprotectedSecrets);
assert_eq!(r.default_level(), Level::Error);
}
#[test]
fn pr_workflow_user_secret_no_gate_errors() {
let p = parse(
"on: pull_request
jobs:
build:
steps:
- uses: docker/login-action@v3
with:
username: foo
password: ${{ secrets.DOCKER_HUB_TOKEN }}
",
);
let diags = UnprotectedSecretsRule::check_workflow(&p);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].level, Level::Error);
assert!(diags[0].message.contains("DOCKER_HUB_TOKEN"));
assert_eq!(diags[0].job.as_ref().unwrap().as_str(), "build");
assert_eq!(diags[0].step.unwrap().as_u16(), 0);
}
#[test]
fn pr_workflow_user_secret_with_canonical_gate_is_clean() {
let p = parse(
"on: pull_request
jobs:
build:
steps:
- if: github.event.pull_request.head.repo.full_name == github.repository
uses: docker/login-action@v3
with:
password: ${{ secrets.DOCKER_HUB_TOKEN }}
",
);
assert!(UnprotectedSecretsRule::check_workflow(&p).is_empty());
}
#[test]
fn pr_workflow_only_github_token_is_clean() {
let p = parse(
"on: pull_request
jobs:
build:
steps:
- uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
",
);
assert!(UnprotectedSecretsRule::check_workflow(&p).is_empty());
}
#[test]
fn pull_request_target_workflow_with_secret_is_clean() {
let p = parse(
"on: pull_request_target
jobs:
build:
steps:
- uses: docker/login-action@v3
with:
password: ${{ secrets.DOCKER_HUB_TOKEN }}
",
);
assert!(UnprotectedSecretsRule::check_workflow(&p).is_empty());
}
#[test]
fn workflow_run_workflow_with_secret_is_clean() {
let p = parse(
"on:
workflow_run:
workflows: [CI]
types: [completed]
jobs:
publish:
steps:
- run: npm publish --token ${{ secrets.NPM_TOKEN }}
",
);
assert!(UnprotectedSecretsRule::check_workflow(&p).is_empty());
}
#[test]
fn non_pr_workflow_with_secret_is_clean() {
let p = parse(
"on: push
jobs:
publish:
steps:
- uses: docker/login-action@v3
with:
password: ${{ secrets.DOCKER_HUB_TOKEN }}
",
);
assert!(UnprotectedSecretsRule::check_workflow(&p).is_empty());
}
#[test]
fn pr_workflow_with_non_canonical_custom_gate_still_errors() {
let p = parse(
"on: pull_request
jobs:
build:
steps:
- if: env.IS_FORK != 'true'
uses: docker/login-action@v3
with:
password: ${{ secrets.DOCKER_HUB_TOKEN }}
",
);
let diags = UnprotectedSecretsRule::check_workflow(&p);
assert_eq!(diags.len(), 1);
}
#[test]
fn job_level_gate_propagates_to_steps() {
let p = parse(
"on: pull_request
jobs:
build:
if: github.event.pull_request.head.repo.full_name == github.repository
steps:
- uses: docker/login-action@v3
with:
password: ${{ secrets.DOCKER_HUB_TOKEN }}
",
);
assert!(UnprotectedSecretsRule::check_workflow(&p).is_empty());
}
#[test]
fn run_command_reference_is_detected() {
let p = parse(
"on: pull_request
jobs:
build:
steps:
- run: echo ${{ secrets.NPM_TOKEN }}
",
);
let diags = UnprotectedSecretsRule::check_workflow(&p);
assert_eq!(diags.len(), 1);
assert!(diags[0].message.contains("NPM_TOKEN"));
}
#[test]
fn env_block_reference_is_detected() {
let p = parse(
"on: pull_request
jobs:
build:
steps:
- run: ./deploy.sh
env:
API_KEY: ${{ secrets.PROD_API_KEY }}
",
);
let diags = UnprotectedSecretsRule::check_workflow(&p);
assert_eq!(diags.len(), 1);
assert!(diags[0].message.contains("PROD_API_KEY"));
}
}