use anyhow::{Context, Result, ensure};
use rstest::rstest;
use serde_yaml::Value;
use super::{reader, rules};
pub(super) fn parse(source: &str) -> Result<Value> { reader::parse("fixture", source) }
const BREACHING_PULL_REQUEST: &str = r"
on:
pull_request:
jobs:
build:
steps:
- uses: leynos/shared-actions/.github/actions/generate-coverage@abc
with:
output-path: lcov.info
- env:
CS_ACCESS_TOKEN: ${{ secrets.CS_ACCESS_TOKEN }}
uses: leynos/shared-actions/.github/actions/upload-codescene-coverage@abc
- run: cs-coverage upload --format lcov
- run: curl https://api.codescene.io/v2/projects
";
const COMPLIANT_PULL_REQUEST: &str = r"
on:
pull_request:
jobs:
build:
steps:
- uses: leynos/shared-actions/.github/actions/generate-coverage@abc
with:
output-path: lcov.info
with-ratchet: 'true'
publish-artefact: 'false'
";
#[rstest]
#[case::breaching(BREACHING_PULL_REQUEST, 6)]
#[case::compliant(COMPLIANT_PULL_REQUEST, 0)]
fn the_pull_request_rule_reports_what_it_should(
#[case] source: &str,
#[case] expected: usize,
) -> Result<()> {
let findings = rules::pull_request_findings(&parse(source)?);
ensure!(
findings.len() == expected,
"expected {expected}, saw {findings:?}"
);
Ok(())
}
#[rstest]
#[case::run_body("steps:\n - run: echo ${{ secrets.CS_ACCESS_TOKEN }}\n")]
#[case::action_input(
"steps:\n - uses: x/y@abc\n with:\n token: ${{ secrets.CS_ACCESS_TOKEN }}\n"
)]
#[case::env_value("env:\n T: ${{ secrets.CS_ACCESS_TOKEN }}\nsteps:\n - run: 'true'\n")]
#[case::named_forwarding(
"uses: ./.github/workflows/c.yml\nsecrets:\n CS_ACCESS_TOKEN: ${{ secrets.CS_ACCESS_TOKEN \
}}\n"
)]
#[case::inherit("uses: ./.github/workflows/c.yml\nsecrets: inherit\n")]
#[case::computed_name("steps:\n - run: echo ${{ secrets[format('CS_{0}', 'ACCESS_TOKEN')] }}\n")]
#[case::whole_context("steps:\n - run: echo '${{ toJSON(secrets) }}'\n")]
fn every_route_to_the_token_is_reported(#[case] job: &str) -> Result<()> {
let indented: String = job
.lines()
.map(|line| [" ", line, "\n"].concat())
.collect();
let source = format!("on: pull_request\njobs:\n lane:\n{indented}");
let findings = rules::pull_request_findings(&parse(&source)?);
ensure!(!findings.is_empty(), "the route was not reported: {source}");
Ok(())
}
#[test]
fn a_named_secret_and_prose_are_not_computed_references() -> Result<()> {
let source = concat!(
"on: pull_request\njobs:\n lane:\n steps:\n",
" - run: echo ${{ secrets.GITHUB_TOKEN }} keeps secrets out of logs\n",
);
let findings = rules::pull_request_findings(&parse(source)?);
ensure!(findings.is_empty(), "unexpected findings: {findings:?}");
Ok(())
}
#[test]
fn a_call_to_a_missing_workflow_is_reported() -> Result<()> {
let caller = "on: pull_request\njobs:\n call:\n uses: ./.github/workflows/missing.yml\n";
let all: reader::Workflows = [("caller.yml".to_owned(), parse(caller)?)].into();
let missing = reader::missing_callees(&all, &reader::pull_request_closure(&all));
ensure!(
missing.len() == 1 && missing[0].contains("missing.yml"),
"expected the missing callee, saw {missing:?}"
);
Ok(())
}
#[rstest]
#[case::dot_prefixed("./.github/workflows/called.yml")]
#[case::bare(".github/workflows/called.yml")]
fn a_called_workflow_is_inside_the_pull_request_closure(#[case] call: &str) -> Result<()> {
let caller =
format!("on: [pull_request]\njobs:\n call:\n uses: {call}\n secrets: inherit\n");
let callee = r#"
on: workflow_call
jobs:
leak:
steps:
- run: |
curl -H "Authorization: ${{ secrets.CS_ACCESS_TOKEN }}" https://codescene.io/api
"#;
let all: reader::Workflows = [
("caller.yml".to_owned(), parse(&caller)?),
("called.yml".to_owned(), parse(callee)?),
]
.into();
let closure = reader::pull_request_closure(&all);
ensure!(
closure.contains("called.yml"),
"the callee escaped: {closure:?}"
);
let callee_findings = rules::pull_request_findings(all.get("called.yml").context("callee")?);
ensure!(
callee_findings.len() == 2,
"expected token and host, saw {callee_findings:?}"
);
Ok(())
}
#[rstest]
#[case::dot_prefixed("./.github/workflows/called.yml", reader::Call::Local("called.yml"))]
#[case::dollar_prefixed("$/.github/workflows/called.yml", reader::Call::Local("called.yml"))]
#[case::remote(
"leynos/shared-actions/.github/workflows/called.yml@abc",
reader::Call::Remote
)]
#[case::dollar_with_ref("$/.github/workflows/called.yml@main", reader::Call::Refused)]
#[case::dot_with_ref("./.github/workflows/called.yml@main", reader::Call::Refused)]
#[case::nested("./.github/workflows/sub/called.yml", reader::Call::Refused)]
fn each_call_spelling_is_classified(#[case] reference: &str, #[case] expected: reader::Call) {
assert_eq!(
reader::classify_call(reference),
expected,
"for {reference}"
);
}
#[test]
fn a_refused_call_is_a_finding() -> Result<()> {
let source = "on: pull_request\njobs:\n call:\n uses: $/.github/workflows/c.yml@main\n";
let findings = rules::pull_request_findings(&parse(source)?);
ensure!(
findings.len() == 1 && findings[0].contains("resolves to no workflow"),
"expected one refused call, saw {findings:?}"
);
Ok(())
}
#[test]
fn the_closure_follows_a_chain_of_calls() -> Result<()> {
let caller = "on: pull_request\njobs:\n first:\n uses: ./.github/workflows/middle.yml\n";
let middle = "on: workflow_call\njobs:\n second:\n uses: $/.github/workflows/leaf.yml\n";
let leaf = "on: workflow_call\njobs:\n leak:\n steps:\n - run: curl https://codescene.io/api\n";
let all: reader::Workflows = [
("caller.yml".to_owned(), parse(caller)?),
("middle.yml".to_owned(), parse(middle)?),
("leaf.yml".to_owned(), parse(leaf)?),
]
.into();
let closure = reader::pull_request_closure(&all);
ensure!(
closure.contains("middle.yml") && closure.contains("leaf.yml"),
"the chain was not followed: {closure:?}"
);
let leaf_findings = rules::pull_request_findings(all.get("leaf.yml").context("leaf")?);
ensure!(
leaf_findings.len() == 1,
"expected the host, saw {leaf_findings:?}"
);
Ok(())
}
#[rstest]
#[case::scalar("on: pull_request\njobs: {}\n")]
#[case::sequence("on: [push, pull_request]\njobs: {}\n")]
#[case::mapping("on:\n pull_request:\njobs: {}\n")]
#[case::quoted_key("'on':\n pull_request:\njobs: {}\n")]
#[case::boolean_key("true:\n pull_request:\njobs: {}\n")]
#[case::target("on: [pull_request_target]\njobs: {}\n")]
fn every_trigger_form_is_read(#[case] source: &str) -> Result<()> {
ensure!(
reader::starts_on_pull_request(&parse(source)?),
"not read as a pull request: {source:?}"
);
Ok(())
}
#[test]
fn a_duplicate_key_is_refused() {
let source = "on: pull_request\njobs:\n a:\n runs-on: x\n runs-on: y\n";
let Err(error) = reader::parse("fixture", source) else {
panic!("a duplicate key was accepted");
};
assert!(
format!("{error:#}").contains("duplicate entry"),
"refused for another reason: {error:#}"
);
}