feat: load separate configs for input groups (#1094)
Some checks failed
Benchmark baseline / Continuous Benchmarking with Bencher (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Test site build (push) Has been cancelled
zizmor wheel builds for PyPI 🐍 / Build Linux wheels (manylinux) (push) Has been cancelled
zizmor wheel builds for PyPI 🐍 / Build Linux wheels (musllinux) (push) Has been cancelled
zizmor wheel builds for PyPI 🐍 / Build Windows wheels (push) Has been cancelled
zizmor wheel builds for PyPI 🐍 / Build macOS wheels (push) Has been cancelled
zizmor wheel builds for PyPI 🐍 / Build source distribution (push) Has been cancelled
Deploy zizmor documentation site 🌐 / Deploy zizmor documentation to GitHub Pages 🌐 (push) Has been cancelled
GitHub Actions Security Analysis with zizmor 🌈 / Run zizmor 🌈 (push) Has been cancelled
CI / All tests pass (push) Has been cancelled
zizmor wheel builds for PyPI 🐍 / Release (push) Has been cancelled

This commit is contained in:
William Woodruff 2025-08-27 23:39:13 -04:00 committed by GitHub
parent 05fd75f958
commit d75933e72d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
136 changed files with 1528 additions and 664 deletions

View file

@ -19,13 +19,14 @@ audit_meta!(
);
impl Audit for AnonymousDefinition {
fn new(_state: &AuditState<'_>) -> Result<Self, AuditLoadError> {
fn new(_state: &AuditState) -> Result<Self, AuditLoadError> {
Ok(Self)
}
fn audit_workflow<'doc>(
&self,
workflow: &'doc crate::models::workflow::Workflow,
_config: &crate::config::Config,
) -> anyhow::Result<Vec<crate::finding::Finding<'doc>>> {
let mut findings = vec![];

View file

@ -162,13 +162,14 @@ impl Artipacked {
}
impl Audit for Artipacked {
fn new(_state: &AuditState<'_>) -> Result<Self, AuditLoadError> {
fn new(_state: &AuditState) -> Result<Self, AuditLoadError> {
Ok(Self)
}
fn audit_action<'doc>(
&self,
action: &'doc crate::models::action::Action,
_config: &crate::config::Config,
) -> anyhow::Result<Vec<Finding<'doc>>> {
let Some(steps) = action.steps() else {
return Ok(vec![]);
@ -177,7 +178,11 @@ impl Audit for Artipacked {
self.process_steps(steps)
}
fn audit_normal_job<'doc>(&self, job: &super::NormalJob<'doc>) -> Result<Vec<Finding<'doc>>> {
fn audit_normal_job<'doc>(
&self,
job: &super::NormalJob<'doc>,
_config: &crate::config::Config,
) -> Result<Vec<Finding<'doc>>> {
self.process_steps(job.steps())
}
}
@ -186,7 +191,7 @@ impl Audit for Artipacked {
mod tests {
use super::*;
use crate::{
github_api::GitHubHost,
config::Config,
models::{AsDocument, workflow::Workflow},
registry::input::InputKey,
state::AuditState,
@ -205,14 +210,9 @@ mod tests {
($audit_type:ty, $filename:expr, $workflow_content:expr, $test_fn:expr) => {{
let key = InputKey::local("fakegroup".into(), $filename, None::<&str>).unwrap();
let workflow = Workflow::from_string($workflow_content.to_string(), key).unwrap();
let audit_state = AuditState {
config: &Default::default(),
no_online_audits: false,
gh_client: None,
gh_hostname: GitHubHost::Standard("github.com".into()),
};
let audit_state = AuditState::default();
let audit = <$audit_type>::new(&audit_state).unwrap();
let findings = audit.audit_workflow(&workflow).unwrap();
let findings = audit.audit_workflow(&workflow, &Config::default()).unwrap();
$test_fn(&workflow, findings)
}};

View file

@ -54,7 +54,7 @@ const BOT_ACTOR_IDS: &[&str] = &[
];
impl Audit for BotConditions {
fn new(_state: &AuditState<'_>) -> Result<Self, AuditLoadError>
fn new(_state: &AuditState) -> Result<Self, AuditLoadError>
where
Self: Sized,
{
@ -64,6 +64,7 @@ impl Audit for BotConditions {
fn audit_normal_job<'doc>(
&self,
job: &super::NormalJob<'doc>,
_config: &crate::config::Config,
) -> anyhow::Result<Vec<super::Finding<'doc>>> {
let mut findings = vec![];
@ -404,8 +405,8 @@ impl BotConditions {
mod tests {
use super::*;
use crate::{
config::Config,
finding::Finding,
github_api::GitHubHost,
models::{AsDocument, workflow::Workflow},
registry::input::InputKey,
state::AuditState,
@ -416,14 +417,9 @@ mod tests {
($audit_type:ty, $filename:expr, $workflow_content:expr, $test_fn:expr) => {{
let key = InputKey::local("fakegroup".into(), $filename, None::<&str>).unwrap();
let workflow = Workflow::from_string($workflow_content.to_string(), key).unwrap();
let audit_state = AuditState {
config: &Default::default(),
no_online_audits: false,
gh_client: None,
gh_hostname: GitHubHost::Standard("github.com".into()),
};
let audit_state = AuditState::default();
let audit = <$audit_type>::new(&audit_state).unwrap();
let findings = audit.audit_workflow(&workflow).unwrap();
let findings = audit.audit_workflow(&workflow, &Config::default()).unwrap();
$test_fn(&workflow, findings)
}};

View file

@ -4,6 +4,7 @@ use github_actions_models::workflow::Trigger;
use github_actions_models::workflow::event::{BareEvent, BranchFilters, OptionalBody};
use crate::audit::{Audit, audit_meta};
use crate::config::Config;
use crate::finding::location::{Locatable as _, Routable};
use crate::finding::{Confidence, Finding, Fix, FixDisposition, Severity};
use crate::models::StepCommon;
@ -442,14 +443,18 @@ impl CachePoisoning {
}
impl Audit for CachePoisoning {
fn new(_state: &AuditState<'_>) -> Result<Self, AuditLoadError>
fn new(_state: &AuditState) -> Result<Self, AuditLoadError>
where
Self: Sized,
{
Ok(Self)
}
fn audit_normal_job<'doc>(&self, job: &NormalJob<'doc>) -> anyhow::Result<Vec<Finding<'doc>>> {
fn audit_normal_job<'doc>(
&self,
job: &NormalJob<'doc>,
_config: &Config,
) -> anyhow::Result<Vec<Finding<'doc>>> {
let mut findings = vec![];
let steps = job.steps();
let trigger = &job.parent().on;
@ -472,8 +477,7 @@ impl Audit for CachePoisoning {
mod tests {
use super::*;
use crate::{
github_api::GitHubHost, models::workflow::Workflow, registry::input::InputKey,
state::AuditState,
config::Config, models::workflow::Workflow, registry::input::InputKey, state::AuditState,
};
/// Macro for testing workflow audits with common boilerplate
@ -489,14 +493,9 @@ mod tests {
($audit_type:ty, $filename:expr, $workflow_content:expr, $test_fn:expr) => {{
let key = InputKey::local("fakegroup".into(), $filename, None::<&str>).unwrap();
let workflow = Workflow::from_string($workflow_content.to_string(), key).unwrap();
let audit_state = AuditState {
config: &Default::default(),
no_online_audits: false,
gh_client: None,
gh_hostname: GitHubHost::Standard("github.com".into()),
};
let audit_state = AuditState::default();
let audit = <$audit_type>::new(&audit_state).unwrap();
let findings = audit.audit_workflow(&workflow).unwrap();
let findings = audit.audit_workflow(&workflow, &Config::default()).unwrap();
$test_fn(findings)
}};

View file

@ -1,6 +1,7 @@
use anyhow::Result;
use super::{Audit, AuditLoadError, audit_meta};
use crate::config::Config;
use crate::finding::{Confidence, Finding, Severity};
use crate::models::workflow::Workflow;
use crate::state::AuditState;
@ -14,11 +15,15 @@ audit_meta!(
);
impl Audit for DangerousTriggers {
fn new(_state: &AuditState<'_>) -> Result<Self, AuditLoadError> {
fn new(_state: &AuditState) -> Result<Self, AuditLoadError> {
Ok(Self)
}
fn audit_workflow<'doc>(&self, workflow: &'doc Workflow) -> Result<Vec<Finding<'doc>>> {
fn audit_workflow<'doc>(
&self,
workflow: &'doc Workflow,
_config: &Config,
) -> Result<Vec<Finding<'doc>>> {
let mut findings = vec![];
if workflow.has_pull_request_target() {
findings.push(

View file

@ -40,7 +40,7 @@ audit_meta!(
pub(crate) struct ExcessivePermissions;
impl Audit for ExcessivePermissions {
fn new(_state: &AuditState<'_>) -> Result<Self, AuditLoadError>
fn new(_state: &AuditState) -> Result<Self, AuditLoadError>
where
Self: Sized,
{
@ -50,6 +50,7 @@ impl Audit for ExcessivePermissions {
fn audit_workflow<'doc>(
&self,
workflow: &'doc crate::models::workflow::Workflow,
_config: &crate::config::Config,
) -> anyhow::Result<Vec<crate::finding::Finding<'doc>>> {
let mut findings = vec![];

View file

@ -1,20 +1,16 @@
use anyhow::{Context, anyhow};
use github_actions_models::common::Uses;
use super::{Audit, AuditLoadError, AuditState, audit_meta};
use crate::config::{Config, ForbiddenUsesConfig};
use crate::finding::{Confidence, Finding, Persona, Severity};
use crate::models::uses::RepositoryUsesPattern;
use crate::models::{StepCommon, action::CompositeStep, workflow::Step};
use serde::Deserialize;
pub(crate) struct ForbiddenUses {
config: ForbiddenUsesConfig,
}
pub(crate) struct ForbiddenUses;
audit_meta!(ForbiddenUses, "forbidden-uses", "forbidden action used");
impl ForbiddenUses {
fn use_denied(&self, uses: &Uses) -> bool {
fn use_denied(&self, uses: &Uses, config: &ForbiddenUsesConfig) -> bool {
match uses {
// Local uses are never denied.
Uses::Local(_) => false,
@ -25,7 +21,7 @@ impl ForbiddenUses {
tracing::warn!("can't evaluate direct Docker uses");
false
}
Uses::Repository(uses) => match &self.config {
Uses::Repository(uses) => match config {
ForbiddenUsesConfig::Allow { allow } => {
!allow.iter().any(|pattern| pattern.matches(uses))
}
@ -39,14 +35,20 @@ impl ForbiddenUses {
fn process_step<'doc>(
&self,
step: &impl StepCommon<'doc>,
config: &Config,
) -> anyhow::Result<Vec<Finding<'doc>>> {
let mut findings = vec![];
let Some(config) = config.forbidden_uses_config.as_ref() else {
tracing::trace!("no forbidden-uses config for this input; skipping");
return Ok(findings);
};
let Some(uses) = step.uses() else {
return Ok(findings);
};
if self.use_denied(uses) {
if self.use_denied(uses, config) {
findings.push(
Self::finding()
.confidence(Confidence::High)
@ -67,37 +69,26 @@ impl ForbiddenUses {
}
impl Audit for ForbiddenUses {
fn new(state: &AuditState<'_>) -> Result<Self, AuditLoadError>
fn new(_state: &AuditState) -> Result<Self, AuditLoadError>
where
Self: Sized,
{
let Some(config) = state
.config
.rule_config(Self::ident())
.context("invalid configuration")
.map_err(AuditLoadError::Fail)?
else {
return Err(AuditLoadError::Skip(anyhow!("audit not configured")));
};
Ok(Self { config })
Ok(Self)
}
fn audit_step<'doc>(&self, step: &Step<'doc>) -> anyhow::Result<Vec<Finding<'doc>>> {
self.process_step(step)
fn audit_step<'doc>(
&self,
step: &Step<'doc>,
config: &Config,
) -> anyhow::Result<Vec<Finding<'doc>>> {
self.process_step(step, config)
}
fn audit_composite_step<'a>(
&self,
step: &CompositeStep<'a>,
config: &Config,
) -> anyhow::Result<Vec<Finding<'a>>> {
self.process_step(step)
self.process_step(step, config)
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "kebab-case", untagged)]
enum ForbiddenUsesConfig {
Allow { allow: Vec<RepositoryUsesPattern> },
Deny { deny: Vec<RepositoryUsesPattern> },
}

View file

@ -10,6 +10,7 @@ use tree_sitter::{
};
use super::{Audit, AuditLoadError, audit_meta};
use crate::config::Config;
use crate::finding::location::Locatable as _;
use crate::finding::{Confidence, Finding, Severity};
use crate::models::{workflow::JobExt as _, workflow::Step};
@ -316,7 +317,7 @@ impl GitHubEnv {
}
impl Audit for GitHubEnv {
fn new(_state: &AuditState<'_>) -> Result<Self, AuditLoadError>
fn new(_state: &AuditState) -> Result<Self, AuditLoadError>
where
Self: Sized,
{
@ -344,7 +345,11 @@ impl Audit for GitHubEnv {
})
}
fn audit_step<'doc>(&self, step: &Step<'doc>) -> anyhow::Result<Vec<Finding<'doc>>> {
fn audit_step<'doc>(
&self,
step: &Step<'doc>,
_config: &Config,
) -> anyhow::Result<Vec<Finding<'doc>>> {
let mut findings = vec![];
let workflow = step.workflow();
@ -395,6 +400,7 @@ impl Audit for GitHubEnv {
fn audit_composite_step<'doc>(
&self,
step: &super::CompositeStep<'doc>,
_config: &Config,
) -> Result<Vec<Finding<'doc>>> {
let mut findings = vec![];
@ -426,7 +432,6 @@ impl Audit for GitHubEnv {
mod tests {
use crate::audit::Audit;
use crate::audit::github_env::{GITHUB_ENV_WRITE_CMD, GitHubEnv};
use crate::github_api::GitHubHost;
use crate::state::AuditState;
#[test]
@ -491,12 +496,7 @@ mod tests {
("echo 'completely-static' \"foo\" >> $GITHUB_ENV", false), // LHS is completely static
("echo \"completely-static\" >> $GITHUB_ENV", false), // LHS is completely static
] {
let audit_state = AuditState {
config: &Default::default(),
no_online_audits: false,
gh_client: None,
gh_hostname: GitHubHost::Standard("github.com".into()),
};
let audit_state = AuditState::default();
let sut = GitHubEnv::new(&audit_state).expect("failed to create audit");
@ -607,12 +607,7 @@ mod tests {
false,
), // GITHUB_ENV is not a variable
] {
let audit_state = AuditState {
config: &Default::default(),
no_online_audits: false,
gh_client: None,
gh_hostname: GitHubHost::Standard("github.com".into()),
};
let audit_state = AuditState::default();
let sut = GitHubEnv::new(&audit_state).expect("failed to create audit");

View file

@ -18,7 +18,7 @@ audit_meta!(
);
impl Audit for HardcodedContainerCredentials {
fn new(_state: &AuditState<'_>) -> Result<Self, AuditLoadError>
fn new(_state: &AuditState) -> Result<Self, AuditLoadError>
where
Self: Sized,
{
@ -28,6 +28,7 @@ impl Audit for HardcodedContainerCredentials {
fn audit_workflow<'doc>(
&self,
workflow: &'doc crate::models::workflow::Workflow,
_config: &crate::config::Config,
) -> anyhow::Result<Vec<crate::finding::Finding<'doc>>> {
let mut findings = vec![];

View file

@ -10,6 +10,7 @@ use github_actions_models::common::{RepositoryUses, Uses};
use super::{Audit, AuditLoadError, Job, audit_meta};
use crate::{
config::Config,
finding::{Confidence, Finding, Severity, location::Locatable as _},
github_api::{self, ComparisonStatus},
models::{StepCommon, uses::RepositoryUsesExt as _, workflow::Workflow},
@ -113,7 +114,7 @@ impl ImpostorCommit {
}
impl Audit for ImpostorCommit {
fn new(state: &AuditState<'_>) -> Result<Self, AuditLoadError> {
fn new(state: &AuditState) -> Result<Self, AuditLoadError> {
if state.no_online_audits {
return Err(AuditLoadError::Skip(anyhow!(
"offline audits only requested"
@ -127,7 +128,11 @@ impl Audit for ImpostorCommit {
.map(|client| ImpostorCommit { client })
}
fn audit_workflow<'doc>(&self, workflow: &'doc Workflow) -> Result<Vec<Finding<'doc>>> {
fn audit_workflow<'doc>(
&self,
workflow: &'doc Workflow,
_config: &Config,
) -> Result<Vec<Finding<'doc>>> {
let mut findings = vec![];
for job in workflow.jobs() {
@ -179,6 +184,7 @@ impl Audit for ImpostorCommit {
fn audit_composite_step<'a>(
&self,
step: &super::CompositeStep<'a>,
_config: &Config,
) -> Result<Vec<Finding<'a>>> {
let mut findings = vec![];
let Some(Uses::Repository(uses)) = step.uses() else {

View file

@ -9,6 +9,7 @@ use yamlpatch::{Op, Patch};
use super::{AuditLoadError, Job, audit_meta};
use crate::audit::Audit;
use crate::config::Config;
use crate::finding::location::Locatable as _;
use crate::finding::{
Confidence, Finding, Fix, FixDisposition, Persona, Severity, location::SymbolicLocation,
@ -117,14 +118,18 @@ impl InsecureCommands {
}
impl Audit for InsecureCommands {
fn new(_state: &AuditState<'_>) -> Result<Self, AuditLoadError>
fn new(_state: &AuditState) -> Result<Self, AuditLoadError>
where
Self: Sized,
{
Ok(Self)
}
fn audit_workflow<'doc>(&self, workflow: &'doc Workflow) -> anyhow::Result<Vec<Finding<'doc>>> {
fn audit_workflow<'doc>(
&self,
workflow: &'doc Workflow,
_config: &Config,
) -> anyhow::Result<Vec<Finding<'doc>>> {
let mut results = vec![];
match &workflow.env {
@ -161,6 +166,7 @@ impl Audit for InsecureCommands {
fn audit_composite_step<'doc>(
&self,
step: &super::CompositeStep<'doc>,
_config: &Config,
) -> Result<Vec<Finding<'doc>>> {
let mut findings = vec![];
@ -187,7 +193,7 @@ impl Audit for InsecureCommands {
mod tests {
use super::*;
use crate::{
github_api::GitHubHost,
config::Config,
models::{AsDocument, workflow::Workflow},
registry::input::InputKey,
state::AuditState,
@ -198,14 +204,9 @@ mod tests {
($audit_type:ty, $filename:expr, $workflow_content:expr, $test_fn:expr) => {{
let key = InputKey::local("fakegroup".into(), $filename, None::<&str>).unwrap();
let workflow = Workflow::from_string($workflow_content.to_string(), key).unwrap();
let audit_state = AuditState {
config: &Default::default(),
no_online_audits: false,
gh_client: None,
gh_hostname: GitHubHost::Standard("github.com".into()),
};
let audit_state = AuditState::default();
let audit = <$audit_type>::new(&audit_state).unwrap();
let findings = audit.audit_workflow(&workflow).unwrap();
let findings = audit.audit_workflow(&workflow, &Config::default()).unwrap();
$test_fn(&workflow, findings)
}};

View file

@ -10,6 +10,7 @@ use github_actions_models::common::{RepositoryUses, Uses};
use super::{Audit, AuditLoadError, audit_meta};
use crate::{
config::Config,
finding::{Confidence, Finding, Fix, Severity, location::Routable as _},
github_api,
models::{StepCommon, action::CompositeStep, uses::RepositoryUsesExt as _, workflow::Step},
@ -256,7 +257,7 @@ impl KnownVulnerableActions {
}
impl Audit for KnownVulnerableActions {
fn new(state: &AuditState<'_>) -> Result<Self, AuditLoadError>
fn new(state: &AuditState) -> Result<Self, AuditLoadError>
where
Self: Sized,
{
@ -273,19 +274,21 @@ impl Audit for KnownVulnerableActions {
.map(|client| KnownVulnerableActions { client })
}
fn audit_step<'doc>(&self, step: &Step<'doc>) -> Result<Vec<Finding<'doc>>> {
fn audit_step<'doc>(&self, step: &Step<'doc>, _config: &Config) -> Result<Vec<Finding<'doc>>> {
self.process_step(step)
}
fn audit_composite_step<'doc>(&self, step: &CompositeStep<'doc>) -> Result<Vec<Finding<'doc>>> {
fn audit_composite_step<'doc>(
&self,
step: &CompositeStep<'doc>,
_config: &Config,
) -> Result<Vec<Finding<'doc>>> {
self.process_step(step)
}
}
#[cfg(test)]
mod tests {
use std::path::Path;
use insta::assert_snapshot;
use super::*;
@ -296,20 +299,17 @@ mod tests {
// Helper function to create a test KnownVulnerableActions instance
fn create_test_audit() -> KnownVulnerableActions {
let config = crate::config::Config::default();
let state = crate::state::AuditState {
config: &config,
no_online_audits: false,
gh_client: Some(
let state = crate::state::AuditState::new(
false,
Some(
github_api::Client::new(
&github_api::GitHubHost::Standard("github.com".to_string()),
&github_api::GitHubToken::new("fake").unwrap(),
Path::new("/tmp"),
github_api::GitHubHost::default(),
github_api::GitHubToken::new("fake").unwrap(),
"/tmp".into(),
)
.unwrap(),
),
gh_hostname: crate::github_api::GitHubHost::Standard("github.com".to_string()),
};
);
KnownVulnerableActions::new(&state).unwrap()
}
@ -718,13 +718,7 @@ jobs:
#[test]
fn test_offline_audit_state_creation() {
// Test that we can create an audit state without a GitHub token
let config = crate::config::Config::default();
let state = crate::state::AuditState {
config: &config,
no_online_audits: true,
gh_client: None,
gh_hostname: crate::github_api::GitHubHost::Standard("github.com".to_string()),
};
let state = crate::state::AuditState::default();
// This should fail because no GitHub token is provided
let audit_result = KnownVulnerableActions::new(&state);
@ -750,25 +744,22 @@ jobs:
let key = InputKey::local("fakegroup".into(), "dummy.yml", None::<&str>).unwrap();
let workflow = Workflow::from_string(workflow_content.to_string(), key).unwrap();
let config = crate::config::Config::default();
let state = crate::state::AuditState {
config: &config,
no_online_audits: false,
gh_client: Some(
let state = crate::state::AuditState::new(
false,
Some(
github_api::Client::new(
&github_api::GitHubHost::Standard("github.com".to_string()),
&github_api::GitHubToken::new(&std::env::var("GH_TOKEN").unwrap()).unwrap(),
Path::new("/tmp"),
github_api::GitHubHost::default(),
github_api::GitHubToken::new(&std::env::var("GH_TOKEN").unwrap()).unwrap(),
"/tmp".into(),
)
.unwrap(),
),
gh_hostname: crate::github_api::GitHubHost::Standard("github.com".to_string()),
};
);
let audit = KnownVulnerableActions::new(&state).unwrap();
let input = workflow.into();
let findings = audit.audit(&input).unwrap();
let findings = audit.audit(&input, &Config::default()).unwrap();
assert_eq!(findings.len(), 1);
let new_doc = findings[0].fixes[0].apply(input.as_document()).unwrap();
@ -806,25 +797,22 @@ jobs:
let key = InputKey::local("fakegroup".into(), "dummy.yml", None::<&str>).unwrap();
let workflow = Workflow::from_string(workflow_content.to_string(), key).unwrap();
let config = crate::config::Config::default();
let state = crate::state::AuditState {
config: &config,
no_online_audits: false,
gh_client: Some(
let state = crate::state::AuditState::new(
false,
Some(
github_api::Client::new(
&github_api::GitHubHost::Standard("github.com".to_string()),
&github_api::GitHubToken::new(&std::env::var("GH_TOKEN").unwrap()).unwrap(),
Path::new("/tmp"),
github_api::GitHubHost::default(),
github_api::GitHubToken::new(&std::env::var("GH_TOKEN").unwrap()).unwrap(),
"/tmp".into(),
)
.unwrap(),
),
gh_hostname: crate::github_api::GitHubHost::Standard("github.com".to_string()),
};
);
let audit = KnownVulnerableActions::new(&state).unwrap();
let input = workflow.into();
let findings = audit.audit(&input).unwrap();
let findings = audit.audit(&input, &Config::default()).unwrap();
assert_eq!(findings.len(), 1);
let new_doc = findings[0].fixes[0].apply(input.as_document()).unwrap();

View file

@ -6,10 +6,12 @@ use tracing::instrument;
use yamlpath::Document;
use crate::{
config::Config,
finding::{Finding, FindingBuilder, location::SymbolicLocation},
models::{
AsDocument, action::Action, action::CompositeStep, workflow::Job, workflow::NormalJob,
workflow::ReusableWorkflowCallJob, workflow::Step, workflow::Workflow,
AsDocument,
action::{Action, CompositeStep},
workflow::{Job, NormalJob, ReusableWorkflowCallJob, Step, Workflow},
},
registry::input::InputKey,
state::AuditState,
@ -197,18 +199,26 @@ pub(crate) enum AuditLoadError {
/// **only** [`Audit::audit`] and not [`Audit::audit_normal_job`] or
/// [`Audit::audit_step`].
pub(crate) trait Audit: AuditCore {
fn new(state: &AuditState<'_>) -> Result<Self, AuditLoadError>
fn new(state: &AuditState) -> Result<Self, AuditLoadError>
where
Self: Sized;
fn audit_step<'doc>(&self, _step: &Step<'doc>) -> anyhow::Result<Vec<Finding<'doc>>> {
fn audit_step<'doc>(
&self,
_step: &Step<'doc>,
_config: &Config,
) -> anyhow::Result<Vec<Finding<'doc>>> {
Ok(vec![])
}
fn audit_normal_job<'doc>(&self, job: &NormalJob<'doc>) -> anyhow::Result<Vec<Finding<'doc>>> {
fn audit_normal_job<'doc>(
&self,
job: &NormalJob<'doc>,
config: &Config,
) -> anyhow::Result<Vec<Finding<'doc>>> {
let mut results = vec![];
for step in job.steps() {
results.extend(self.audit_step(&step)?);
results.extend(self.audit_step(&step, config)?);
}
Ok(results)
}
@ -216,20 +226,25 @@ pub(crate) trait Audit: AuditCore {
fn audit_reusable_job<'doc>(
&self,
_job: &ReusableWorkflowCallJob<'doc>,
_config: &Config,
) -> anyhow::Result<Vec<Finding<'doc>>> {
Ok(vec![])
}
fn audit_workflow<'doc>(&self, workflow: &'doc Workflow) -> anyhow::Result<Vec<Finding<'doc>>> {
fn audit_workflow<'doc>(
&self,
workflow: &'doc Workflow,
config: &Config,
) -> anyhow::Result<Vec<Finding<'doc>>> {
let mut results = vec![];
for job in workflow.jobs() {
match job {
Job::NormalJob(normal) => {
results.extend(self.audit_normal_job(&normal)?);
results.extend(self.audit_normal_job(&normal, config)?);
}
Job::ReusableWorkflowCallJob(reusable) => {
results.extend(self.audit_reusable_job(&reusable)?);
results.extend(self.audit_reusable_job(&reusable, config)?);
}
}
}
@ -240,23 +255,32 @@ pub(crate) trait Audit: AuditCore {
fn audit_composite_step<'doc>(
&self,
_step: &CompositeStep<'doc>,
_config: &Config,
) -> anyhow::Result<Vec<Finding<'doc>>> {
Ok(vec![])
}
fn audit_action<'doc>(&self, action: &'doc Action) -> anyhow::Result<Vec<Finding<'doc>>> {
fn audit_action<'doc>(
&self,
action: &'doc Action,
config: &Config,
) -> anyhow::Result<Vec<Finding<'doc>>> {
let mut results = vec![];
if let Some(steps) = action.steps() {
for step in steps {
results.extend(self.audit_composite_step(&step)?);
results.extend(self.audit_composite_step(&step, config)?);
}
}
Ok(results)
}
fn audit_raw<'doc>(&self, _input: &'doc AuditInput) -> anyhow::Result<Vec<Finding<'doc>>> {
fn audit_raw<'doc>(
&self,
_input: &'doc AuditInput,
_config: &Config,
) -> anyhow::Result<Vec<Finding<'doc>>> {
Ok(vec![])
}
@ -264,14 +288,18 @@ pub(crate) trait Audit: AuditCore {
///
/// Implementors **should not** override this blanket implementation,
/// since it's marked with tracing instrumentation.
#[instrument(skip(self))]
fn audit<'doc>(&self, input: &'doc AuditInput) -> anyhow::Result<Vec<Finding<'doc>>> {
#[instrument(skip(self, config))]
fn audit<'doc>(
&self,
input: &'doc AuditInput,
config: &Config,
) -> anyhow::Result<Vec<Finding<'doc>>> {
let mut results = match input {
AuditInput::Workflow(workflow) => self.audit_workflow(workflow),
AuditInput::Action(action) => self.audit_action(action),
AuditInput::Workflow(workflow) => self.audit_workflow(workflow, config),
AuditInput::Action(action) => self.audit_action(action, config),
}?;
results.extend(self.audit_raw(input)?);
results.extend(self.audit_raw(input, config)?);
Ok(results)
}

View file

@ -3,6 +3,7 @@ use github_actions_models::common::{RepositoryUses, Uses};
use crate::{
Confidence, Severity,
config::Config,
finding::{
Finding, Persona,
location::{Feature, Location},
@ -124,14 +125,18 @@ impl Obfuscation {
}
impl Audit for Obfuscation {
fn new(_state: &AuditState<'_>) -> Result<Self, AuditLoadError>
fn new(_state: &AuditState) -> Result<Self, AuditLoadError>
where
Self: Sized,
{
Ok(Self)
}
fn audit_raw<'doc>(&self, input: &'doc AuditInput) -> anyhow::Result<Vec<Finding<'doc>>> {
fn audit_raw<'doc>(
&self,
input: &'doc AuditInput,
_config: &Config,
) -> anyhow::Result<Vec<Finding<'doc>>> {
let mut findings = vec![];
for (expr, expr_span) in parse_fenced_expressions_from_input(input) {
@ -161,13 +166,18 @@ impl Audit for Obfuscation {
Ok(findings)
}
fn audit_step<'doc>(&self, step: &Step<'doc>) -> anyhow::Result<Vec<Finding<'doc>>> {
fn audit_step<'doc>(
&self,
step: &Step<'doc>,
_config: &Config,
) -> anyhow::Result<Vec<Finding<'doc>>> {
self.process_step(step)
}
fn audit_composite_step<'a>(
&self,
step: &CompositeStep<'a>,
_config: &Config,
) -> anyhow::Result<Vec<Finding<'a>>> {
self.process_step(step)
}

View file

@ -31,6 +31,7 @@ impl Audit for OverprovisionedSecrets {
fn audit_raw<'doc>(
&self,
input: &'doc AuditInput,
_config: &crate::config::Config,
) -> anyhow::Result<Vec<super::Finding<'doc>>> {
let mut findings = vec![];

View file

@ -49,7 +49,7 @@ impl RefConfusion {
}
impl Audit for RefConfusion {
fn new(state: &AuditState<'_>) -> Result<Self, AuditLoadError>
fn new(state: &AuditState) -> Result<Self, AuditLoadError>
where
Self: Sized,
{
@ -69,6 +69,7 @@ impl Audit for RefConfusion {
fn audit_workflow<'doc>(
&self,
workflow: &'doc crate::models::workflow::Workflow,
_config: &crate::config::Config,
) -> anyhow::Result<Vec<crate::finding::Finding<'doc>>> {
let mut findings = vec![];
@ -122,7 +123,11 @@ impl Audit for RefConfusion {
Ok(findings)
}
fn audit_composite_step<'a>(&self, step: &CompositeStep<'a>) -> Result<Vec<Finding<'a>>> {
fn audit_composite_step<'a>(
&self,
step: &CompositeStep<'a>,
_config: &crate::config::Config,
) -> Result<Vec<Finding<'a>>> {
let mut findings = vec![];
let Some(Uses::Repository(uses)) = step.uses() else {

View file

@ -15,7 +15,7 @@ audit_meta!(
);
impl Audit for SecretsInherit {
fn new(_state: &AuditState<'_>) -> Result<Self, AuditLoadError>
fn new(_state: &AuditState) -> Result<Self, AuditLoadError>
where
Self: Sized,
{
@ -25,6 +25,7 @@ impl Audit for SecretsInherit {
fn audit_reusable_job<'doc>(
&self,
job: &super::ReusableWorkflowCallJob<'doc>,
_config: &crate::config::Config,
) -> anyhow::Result<Vec<super::Finding<'doc>>> {
let mut findings = vec![];

View file

@ -27,7 +27,7 @@ audit_meta!(
);
impl Audit for SelfHostedRunner {
fn new(_state: &AuditState<'_>) -> Result<Self, AuditLoadError>
fn new(_state: &AuditState) -> Result<Self, AuditLoadError>
where
Self: Sized,
{
@ -37,6 +37,7 @@ impl Audit for SelfHostedRunner {
fn audit_workflow<'doc>(
&self,
workflow: &'doc crate::models::workflow::Workflow,
_config: &crate::config::Config,
) -> Result<Vec<crate::finding::Finding<'doc>>> {
let mut results = vec![];

View file

@ -6,6 +6,7 @@ use github_actions_models::common::{RepositoryUses, Uses};
use super::{Audit, AuditLoadError, audit_meta};
use crate::{
Persona,
config::Config,
finding::{Confidence, Finding, Severity},
github_api,
models::{StepCommon, action::CompositeStep, uses::RepositoryUsesExt as _, workflow::Step},
@ -57,7 +58,7 @@ impl StaleActionRefs {
}
impl Audit for StaleActionRefs {
fn new(state: &AuditState<'_>) -> Result<Self, AuditLoadError>
fn new(state: &AuditState) -> Result<Self, AuditLoadError>
where
Self: Sized,
{
@ -74,11 +75,15 @@ impl Audit for StaleActionRefs {
.map(|client| StaleActionRefs { client })
}
fn audit_step<'w>(&self, step: &Step<'w>) -> Result<Vec<Finding<'w>>> {
fn audit_step<'w>(&self, step: &Step<'w>, _config: &Config) -> Result<Vec<Finding<'w>>> {
self.process_step(step)
}
fn audit_composite_step<'a>(&self, step: &CompositeStep<'a>) -> Result<Vec<Finding<'a>>> {
fn audit_composite_step<'a>(
&self,
step: &CompositeStep<'a>,
_config: &Config,
) -> Result<Vec<Finding<'a>>> {
self.process_step(step)
}
}

View file

@ -27,6 +27,7 @@ use itertools::Itertools as _;
use super::{Audit, AuditLoadError, audit_meta};
use crate::{
config::Config,
finding::{
Confidence, Finding, Fix, Persona, Severity,
location::{Routable as _, SymbolicLocation},
@ -599,20 +600,25 @@ impl TemplateInjection {
}
impl Audit for TemplateInjection {
fn new(_state: &AuditState<'_>) -> Result<Self, AuditLoadError>
fn new(_state: &AuditState) -> Result<Self, AuditLoadError>
where
Self: Sized,
{
Ok(Self)
}
fn audit_step<'doc>(&self, step: &Step<'doc>) -> anyhow::Result<Vec<Finding<'doc>>> {
fn audit_step<'doc>(
&self,
step: &Step<'doc>,
_config: &Config,
) -> anyhow::Result<Vec<Finding<'doc>>> {
self.process_step(step)
}
fn audit_composite_step<'a>(
&self,
step: &CompositeStep<'a>,
_config: &Config,
) -> anyhow::Result<Vec<Finding<'a>>> {
self.process_step(step)
}
@ -624,7 +630,7 @@ mod tests {
use crate::audit::Audit;
use crate::audit::template_injection::{Capability, TemplateInjection};
use crate::github_api::GitHubHost;
use crate::config::Config;
use crate::models::AsDocument;
use crate::models::workflow::Workflow;
use crate::registry::input::InputKey;
@ -635,14 +641,9 @@ mod tests {
($audit_type:ty, $filename:expr, $workflow_content:expr, $test_fn:expr) => {{
let key = InputKey::local("fakegroup".into(), $filename, None::<&str>).unwrap();
let workflow = Workflow::from_string($workflow_content.to_string(), key).unwrap();
let audit_state = AuditState {
config: &Default::default(),
no_online_audits: false,
gh_client: None,
gh_hostname: GitHubHost::Standard("github.com".into()),
};
let audit_state = AuditState::default();
let audit = <$audit_type>::new(&audit_state).unwrap();
let findings = audit.audit_workflow(&workflow).unwrap();
let findings = audit.audit_workflow(&workflow, &Config::default()).unwrap();
$test_fn(&workflow, findings)
}};

View file

@ -42,13 +42,14 @@ audit_meta!(
);
impl Audit for UnpinnedImages {
fn new(_state: &AuditState<'_>) -> Result<Self, AuditLoadError> {
fn new(_state: &AuditState) -> Result<Self, AuditLoadError> {
Ok(Self)
}
fn audit_normal_job<'doc>(
&self,
job: &super::NormalJob<'doc>,
_config: &crate::config::Config,
) -> anyhow::Result<Vec<Finding<'doc>>> {
let mut findings = vec![];
let mut image_refs_with_locations: Vec<(DockerUses, SymbolicLocation<'doc>)> = vec![];

View file

@ -1,22 +1,21 @@
use std::collections::HashMap;
use anyhow::Context;
use github_actions_models::common::{RepositoryUses, Uses};
use serde::Deserialize;
use github_actions_models::common::Uses;
use super::{Audit, AuditLoadError, AuditState, audit_meta};
use crate::config::{Config, UsesPolicy};
use crate::finding::{Confidence, Finding, Persona, Severity};
use crate::models::uses::RepositoryUsesPattern;
use crate::models::{StepCommon, action::CompositeStep, uses::UsesExt as _, workflow::Step};
pub(crate) struct UnpinnedUses {
policies: UnpinnedUsesPolicies,
}
pub(crate) struct UnpinnedUses;
audit_meta!(UnpinnedUses, "unpinned-uses", "unpinned action reference");
impl UnpinnedUses {
pub fn evaluate_pinning(&self, uses: &Uses) -> Option<(String, Severity, Persona)> {
pub fn evaluate_pinning(
&self,
uses: &Uses,
config: &Config,
) -> Option<(String, Severity, Persona)> {
match uses {
// Don't evaluate pinning for local `uses:`, since unpinned references
// are fully controlled by the repository anyways.
@ -46,7 +45,7 @@ impl UnpinnedUses {
}
}
Uses::Repository(repo_uses) => {
let (pattern, policy) = self.policies.get_policy(repo_uses);
let (pattern, policy) = config.unpinned_uses_policies.get_policy(repo_uses);
let pat_desc = match pattern {
Some(RepositoryUsesPattern::Any) | None => "blanket".into(),
@ -90,6 +89,7 @@ impl UnpinnedUses {
fn process_step<'doc>(
&self,
step: &impl StepCommon<'doc>,
config: &Config,
) -> anyhow::Result<Vec<Finding<'doc>>> {
let mut findings = vec![];
@ -97,7 +97,7 @@ impl UnpinnedUses {
return Ok(findings);
};
if let Some((annotation, severity, persona)) = self.evaluate_pinning(uses) {
if let Some((annotation, severity, persona)) = self.evaluate_pinning(uses, config) {
findings.push(
Self::finding()
.confidence(Confidence::High)
@ -118,184 +118,26 @@ impl UnpinnedUses {
}
impl Audit for UnpinnedUses {
fn new(state: &AuditState<'_>) -> Result<Self, AuditLoadError>
fn new(_state: &AuditState) -> Result<Self, AuditLoadError>
where
Self: Sized,
{
let config = state
.config
.rule_config::<UnpinnedUsesConfig>(Self::ident())
.context("invalid configuration")
.map_err(AuditLoadError::Fail)?
.unwrap_or_default();
let policies = UnpinnedUsesPolicies::try_from(config)
.context("invalid configuration")
.map_err(AuditLoadError::Fail)?;
Ok(Self { policies })
Ok(Self)
}
fn audit_step<'doc>(&self, step: &Step<'doc>) -> anyhow::Result<Vec<Finding<'doc>>> {
self.process_step(step)
fn audit_step<'doc>(
&self,
step: &Step<'doc>,
config: &Config,
) -> anyhow::Result<Vec<Finding<'doc>>> {
self.process_step(step, config)
}
fn audit_composite_step<'a>(
&self,
step: &CompositeStep<'a>,
config: &Config,
) -> anyhow::Result<Vec<Finding<'a>>> {
self.process_step(step)
}
}
/// Config for the `unpinned-uses` rule.
///
/// This configuration is reified into an `UnpinnedUsesPolicies`.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
struct UnpinnedUsesConfig {
/// A mapping of `uses:` patterns to policies.
policies: HashMap<RepositoryUsesPattern, UsesPolicy>,
}
impl Default for UnpinnedUsesConfig {
fn default() -> Self {
Self {
policies: [
(
RepositoryUsesPattern::InOwner("actions".into()),
UsesPolicy::RefPin,
),
(
RepositoryUsesPattern::InOwner("github".into()),
UsesPolicy::RefPin,
),
(
RepositoryUsesPattern::InOwner("dependabot".into()),
UsesPolicy::RefPin,
),
(RepositoryUsesPattern::Any, UsesPolicy::HashPin),
]
.into(),
}
}
}
/// A singular policy for a `uses:` reference.
#[derive(Copy, Clone, Debug, Deserialize)]
#[serde(rename_all = "kebab-case")]
enum UsesPolicy {
/// No policy; all `uses:` references are allowed, even unpinned ones.
Any,
/// `uses:` references must be pinned to a tag, branch, or hash ref.
RefPin,
/// `uses:` references must be pinned to a hash ref.
HashPin,
}
/// Represents the set of policies used to evaluate `uses:` references.
struct UnpinnedUsesPolicies {
/// The policy tree is a mapping of `owner` slugs to a list of
/// `(pattern, policy)` pairs under that owner, ordered by specificity.
///
/// For example, a config containing both `foo/*: hash-pin` and
/// `foo/bar: ref-pin` would produce a policy tree like this:
///
/// ```text
/// foo:
/// - foo/bar: ref-pin
/// - foo/*: hash-pin
/// ```
///
/// This is done for performance reasons: a two-level structure here
/// means that checking a `uses:` is a linear scan of the policies
/// for that owner, rather than a full scan of all policies.
policy_tree: HashMap<String, Vec<(RepositoryUsesPattern, UsesPolicy)>>,
/// This is the policy that's applied if nothing in the policy tree matches.
///
/// Normally is this configured by an `*` entry in the config or by
/// `UnpinnedUsesConfig::default()`. However, if the user explicitly
/// omits a `*` rule, this will be `UsesPolicy::HashPin`.
default_policy: UsesPolicy,
}
impl UnpinnedUsesPolicies {
/// Returns the most specific policy for the given repository `uses` reference,
/// or the default policy if none match.
fn get_policy(&self, uses: &RepositoryUses) -> (Option<&RepositoryUsesPattern>, UsesPolicy) {
match self.policy_tree.get(&uses.owner) {
Some(policies) => {
// Policies are ordered by specificity, so we can
// iterate and return eagerly.
for (uses_pattern, policy) in policies {
if uses_pattern.matches(uses) {
return (Some(uses_pattern), *policy);
}
}
// The policies under `owner/` might be fully divergent
// if there isn't an `owner/*` rule, so we fall back
// to the default policy.
(None, self.default_policy)
}
None => (None, self.default_policy),
}
}
}
impl TryFrom<UnpinnedUsesConfig> for UnpinnedUsesPolicies {
type Error = anyhow::Error;
fn try_from(config: UnpinnedUsesConfig) -> Result<Self, Self::Error> {
let mut policy_tree: HashMap<String, Vec<(RepositoryUsesPattern, UsesPolicy)>> =
HashMap::new();
let mut default_policy = UsesPolicy::HashPin;
for (pattern, policy) in config.policies {
match pattern {
// Patterns with refs don't make sense in this context, since
// we're establishing policies for the refs themselves.
RepositoryUsesPattern::ExactWithRef { .. } => {
return Err(anyhow::anyhow!("can't use exact ref patterns here"));
}
RepositoryUsesPattern::ExactPath { ref owner, .. } => {
policy_tree
.entry(owner.clone())
.or_default()
.push((pattern, policy));
}
RepositoryUsesPattern::ExactRepo { ref owner, .. } => {
policy_tree
.entry(owner.clone())
.or_default()
.push((pattern, policy));
}
RepositoryUsesPattern::InRepo { ref owner, .. } => {
policy_tree
.entry(owner.clone())
.or_default()
.push((pattern, policy));
}
RepositoryUsesPattern::InOwner(ref owner) => {
policy_tree
.entry(owner.clone())
.or_default()
.push((pattern, policy));
}
RepositoryUsesPattern::Any => {
default_policy = policy;
}
}
}
// Sort the policies for each owner by specificity.
for policies in policy_tree.values_mut() {
policies.sort_by(|a, b| a.0.cmp(&b.0));
}
Ok(Self {
policy_tree,
default_policy,
})
self.process_step(step, config)
}
}

View file

@ -19,7 +19,7 @@ audit_meta!(
);
impl Audit for UnredactedSecrets {
fn new(_state: &AuditState<'_>) -> Result<Self, AuditLoadError>
fn new(_state: &AuditState) -> Result<Self, AuditLoadError>
where
Self: Sized,
{
@ -29,6 +29,7 @@ impl Audit for UnredactedSecrets {
fn audit_raw<'doc>(
&self,
input: &'doc super::AuditInput,
_config: &crate::config::Config,
) -> anyhow::Result<Vec<crate::finding::Finding<'doc>>> {
let mut findings = vec![];

View file

@ -91,7 +91,7 @@ impl UnsoundCondition {
}
impl Audit for UnsoundCondition {
fn new(_state: &crate::state::AuditState<'_>) -> Result<Self, super::AuditLoadError>
fn new(_state: &crate::state::AuditState) -> Result<Self, super::AuditLoadError>
where
Self: Sized,
{
@ -101,6 +101,7 @@ impl Audit for UnsoundCondition {
fn audit_normal_job<'doc>(
&self,
job: &crate::models::workflow::NormalJob<'doc>,
_config: &crate::config::Config,
) -> anyhow::Result<Vec<crate::finding::Finding<'doc>>> {
self.process_conditions(job.parent(), job.conditions())
}
@ -108,6 +109,7 @@ impl Audit for UnsoundCondition {
fn audit_reusable_job<'doc>(
&self,
job: &crate::models::workflow::ReusableWorkflowCallJob<'doc>,
_config: &crate::config::Config,
) -> anyhow::Result<Vec<crate::finding::Finding<'doc>>> {
let conds = job.r#if.iter().map(|cond| (cond, job.location()));
self.process_conditions(job.parent(), conds)
@ -116,6 +118,7 @@ impl Audit for UnsoundCondition {
fn audit_action<'doc>(
&self,
action: &'doc crate::models::action::Action,
_config: &crate::config::Config,
) -> anyhow::Result<Vec<crate::finding::Finding<'doc>>> {
self.process_conditions(action, action.conditions())
}

View file

@ -33,7 +33,7 @@ audit_meta!(
);
impl Audit for UnsoundContains {
fn new(_state: &AuditState<'_>) -> Result<Self, AuditLoadError>
fn new(_state: &AuditState) -> Result<Self, AuditLoadError>
where
Self: Sized,
{
@ -43,6 +43,7 @@ impl Audit for UnsoundContains {
fn audit_normal_job<'w>(
&self,
job: &super::NormalJob<'w>,
_config: &crate::config::Config,
) -> anyhow::Result<Vec<super::Finding<'w>>> {
let conditions = job.conditions().filter_map(|(cond, loc)| {
if let If::Expr(expr) = cond {

View file

@ -278,7 +278,7 @@ impl UseTrustedPublishing {
}
impl Audit for UseTrustedPublishing {
fn new(_state: &AuditState<'_>) -> Result<Self, AuditLoadError> {
fn new(_state: &AuditState) -> Result<Self, AuditLoadError> {
let bash: Language = tree_sitter_bash::LANGUAGE.into();
let pwsh: Language = tree_sitter_powershell::LANGUAGE.into();
@ -295,6 +295,7 @@ impl Audit for UseTrustedPublishing {
fn audit_step<'doc>(
&self,
step: &crate::models::workflow::Step<'doc>,
_config: &crate::config::Config,
) -> anyhow::Result<Vec<super::Finding<'doc>>> {
let mut findings = self.process_step(step)?;
@ -353,6 +354,7 @@ impl Audit for UseTrustedPublishing {
fn audit_composite_step<'doc>(
&self,
step: &crate::models::action::CompositeStep<'doc>,
_config: &crate::config::Config,
) -> anyhow::Result<Vec<Finding<'doc>>> {
self.process_step(step)
}

View file

@ -1,12 +1,24 @@
use std::{collections::HashMap, fs, num::NonZeroUsize, str::FromStr};
use anyhow::{Context as _, Result, anyhow};
use anyhow::{Context, Result, anyhow};
use camino::Utf8Path;
use github_actions_models::common::RepositoryUses;
use serde::{
Deserialize,
de::{self, DeserializeOwned},
};
use crate::{App, finding::Finding};
use crate::{
App, CollectionOptions,
audit::{AuditCore, forbidden_uses::ForbiddenUses, unpinned_uses::UnpinnedUses},
finding::Finding,
github_api::Client,
models::uses::RepositoryUsesPattern,
registry::input::RepoSlug,
tips,
};
const CONFIG_CANDIDATES: &[&str] = &[".github/zizmor.yml", "zizmor.yml"];
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct WorkflowRule {
@ -74,53 +86,406 @@ pub(crate) struct AuditRuleConfig {
config: Option<serde_yaml::Mapping>,
}
/// Runtime configuration, corresponding to a `zizmor.yml` file.
/// Data model for zizmor's configuration file.
///
/// This is a "raw" representation that matches exactly what
/// we parse from a `zizmor.yml` file.
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Config {
struct RawConfig {
rules: HashMap<String, AuditRuleConfig>,
}
impl Config {
pub(crate) fn new(app: &App) -> Result<Self> {
if app.no_config {
return Ok(Self::default());
}
impl RawConfig {
fn load(contents: &str) -> Result<Self> {
serde_yaml::from_str(contents).map_err(|e| {
anyhow!(tips(
format!("failed to load config: {e:#}"),
&[
"check your configuration file for errors",
"see: https://docs.zizmor.sh/configuration/"
]
))
})
}
let config = match &app.config {
Some(path) => serde_yaml::from_str(&fs::read_to_string(path)?)?,
None => {
// If the user didn't pass a config path explicitly with
// `--config`, then we attempt to discover one relative to $CWD
// Our procedure is to first look for `$CWD/.github/zizmor.yml`,
// then `$CWD/zizmor.yml`, and then bail.
let cwd = std::env::current_dir()
.with_context(|| "config discovery couldn't access CWD")?;
fn rule_config<T>(&self, ident: &str) -> Result<Option<T>>
where
T: DeserializeOwned,
{
Ok(self
.rules
.get(ident)
.and_then(|rule_config| rule_config.config.as_ref())
.map(|policy| serde_yaml::from_value::<T>(serde_yaml::Value::Mapping(policy.clone())))
.transpose()?)
}
}
let path = cwd.join(".github").join("zizmor.yml");
if path.is_file() {
serde_yaml::from_str(&fs::read_to_string(path)?)?
} else {
let path = cwd.join("zizmor.yml");
if path.is_file() {
serde_yaml::from_str(&fs::read_to_string(path)?)?
} else {
tracing::debug!("no config discovered; loading default");
Config::default()
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "kebab-case", untagged)]
pub(crate) enum ForbiddenUsesConfig {
Allow { allow: Vec<RepositoryUsesPattern> },
Deny { deny: Vec<RepositoryUsesPattern> },
}
/// Config for the `unpinned-uses` rule.
///
/// This configuration is reified into an `UnpinnedUsesPolicies`.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub(crate) struct UnpinnedUsesConfig {
/// A mapping of `uses:` patterns to policies.
policies: HashMap<RepositoryUsesPattern, UsesPolicy>,
}
/// A singular policy for a `uses:` reference.
#[derive(Copy, Clone, Debug, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum UsesPolicy {
/// No policy; all `uses:` references are allowed, even unpinned ones.
Any,
/// `uses:` references must be pinned to a tag, branch, or hash ref.
RefPin,
/// `uses:` references must be pinned to a hash ref.
HashPin,
}
/// Represents the set of policies used to evaluate `uses:` references.
#[derive(Clone, Debug)]
pub(crate) struct UnpinnedUsesPolicies {
/// The policy tree is a mapping of `owner` slugs to a list of
/// `(pattern, policy)` pairs under that owner, ordered by specificity.
///
/// For example, a config containing both `foo/*: hash-pin` and
/// `foo/bar: ref-pin` would produce a policy tree like this:
///
/// ```text
/// foo:
/// - foo/bar: ref-pin
/// - foo/*: hash-pin
/// ```
///
/// This is done for performance reasons: a two-level structure here
/// means that checking a `uses:` is a linear scan of the policies
/// for that owner, rather than a full scan of all policies.
policy_tree: HashMap<String, Vec<(RepositoryUsesPattern, UsesPolicy)>>,
/// This is the policy that's applied if nothing in the policy tree matches.
///
/// Normally is this configured by an `*` entry in the config or by
/// `UnpinnedUsesConfig::default()`. However, if the user explicitly
/// omits a `*` rule, this will be `UsesPolicy::HashPin`.
default_policy: UsesPolicy,
}
impl UnpinnedUsesPolicies {
/// Returns the most specific policy for the given repository `uses` reference,
/// or the default policy if none match.
pub(crate) fn get_policy(
&self,
uses: &RepositoryUses,
) -> (Option<&RepositoryUsesPattern>, UsesPolicy) {
match self.policy_tree.get(&uses.owner) {
Some(policies) => {
// Policies are ordered by specificity, so we can
// iterate and return eagerly.
for (uses_pattern, policy) in policies {
if uses_pattern.matches(uses) {
return (Some(uses_pattern), *policy);
}
}
// The policies under `owner/` might be fully divergent
// if there isn't an `owner/*` rule, so we fall back
// to the default policy.
(None, self.default_policy)
}
None => (None, self.default_policy),
}
}
}
impl Default for UnpinnedUsesPolicies {
fn default() -> Self {
Self {
policy_tree: [
(
"actions".into(),
vec![(
RepositoryUsesPattern::InOwner("actions".into()),
UsesPolicy::RefPin,
)],
),
(
"github".into(),
vec![(
RepositoryUsesPattern::InOwner("github".into()),
UsesPolicy::RefPin,
)],
),
(
"dependabot".into(),
vec![(
RepositoryUsesPattern::InOwner("dependabot".into()),
UsesPolicy::RefPin,
)],
),
]
.into(),
default_policy: UsesPolicy::HashPin,
}
}
}
impl TryFrom<UnpinnedUsesConfig> for UnpinnedUsesPolicies {
type Error = anyhow::Error;
fn try_from(config: UnpinnedUsesConfig) -> Result<Self, Self::Error> {
let mut policy_tree: HashMap<String, Vec<(RepositoryUsesPattern, UsesPolicy)>> =
HashMap::new();
let mut default_policy = UsesPolicy::HashPin;
for (pattern, policy) in config.policies {
match pattern {
// Patterns with refs don't make sense in this context, since
// we're establishing policies for the refs themselves.
RepositoryUsesPattern::ExactWithRef { .. } => {
return Err(anyhow::anyhow!("can't use exact ref patterns here"));
}
RepositoryUsesPattern::ExactPath { ref owner, .. } => {
policy_tree
.entry(owner.clone())
.or_default()
.push((pattern, policy));
}
RepositoryUsesPattern::ExactRepo { ref owner, .. } => {
policy_tree
.entry(owner.clone())
.or_default()
.push((pattern, policy));
}
RepositoryUsesPattern::InRepo { ref owner, .. } => {
policy_tree
.entry(owner.clone())
.or_default()
.push((pattern, policy));
}
RepositoryUsesPattern::InOwner(ref owner) => {
policy_tree
.entry(owner.clone())
.or_default()
.push((pattern, policy));
}
RepositoryUsesPattern::Any => {
default_policy = policy;
}
}
}
// Sort the policies for each owner by specificity.
for policies in policy_tree.values_mut() {
policies.sort_by(|a, b| a.0.cmp(&b.0));
}
Ok(Self {
policy_tree,
default_policy,
})
}
}
/// zizmor's configuration.
///
/// This is a wrapper around [`RawConfig`] that pre-computes various
/// audit-specific fields so that failures are caught up-front
/// rather than at audit time. This also saves us some runtime
/// cost by avoiding potentially (very) repetitive deserialization
/// of per-audit configs (K audits * N inputs).
#[derive(Clone, Debug, Default)]
pub(crate) struct Config {
raw: RawConfig,
pub(crate) forbidden_uses_config: Option<ForbiddenUsesConfig>,
pub(crate) unpinned_uses_policies: UnpinnedUsesPolicies,
}
impl Config {
/// Loads a [`Config`] from the given contents.
fn load(contents: &str) -> Result<Self> {
let raw = RawConfig::load(contents)?;
let forbidden_uses_config = raw
.rule_config(ForbiddenUses::ident())
.context("invalid forbidden-uses configuration")?;
let unpinned_uses_policies = {
if let Some(unpinned_uses_config) = raw
.rule_config::<UnpinnedUsesConfig>(UnpinnedUses::ident())
.context("invalid unpinned-uses configuration")?
{
UnpinnedUsesPolicies::try_from(unpinned_uses_config)
.context("invalid unpinned-uses configuration")?
} else {
UnpinnedUsesPolicies::default()
}
};
tracing::debug!("loaded config: {config:?}");
Ok(Self {
raw,
forbidden_uses_config,
unpinned_uses_policies,
})
}
Ok(config)
/// Discover a [`Config`] according to the collection options.
///
/// This function models zizmor's current precedence rules for
/// configuration discovery:
/// 1. `--no-config` disables all config loading.
/// 2. `--config <file>` uses the given config file globally,
/// which we've already loaded into `options.global_config`.
/// 3. Otherwise, we use the provided `discover_fn` to attempt
/// to discover a config file. This function is typically one
/// of [`Config::discover_local`] or [`Config::discover_remote`]
/// depending on the input type.
pub(crate) fn discover<F>(options: &CollectionOptions, discover_fn: F) -> Result<Self>
where
F: FnOnce() -> Result<Option<Self>>,
{
if options.no_config {
// User has explicitly disabled config loading.
tracing::debug!("skipping config discovery: explicitly disabled");
Ok(Self::default())
} else if let Some(config) = &options.global_config {
// The user gave us a (legacy) global config file,
// which takes precedence over any discovered config.
tracing::debug!("config discovery: using global config: {config:?}");
Ok(config.clone())
} else {
// Attempt to discover a config file using the provided function.
discover_fn().map(|conf| conf.unwrap_or_default())
}
}
/// Discover a [`Config`] in the given directory.
///
/// This uses the following discovery procedure:
/// 1. If the given directory is `blahblah/.github/workflows/`,
/// start at the parent (i.e. `blahblah/.github/`). Otherwise, start
/// at the given directory. This first directory is the
/// first candidate path.
/// 2. Look for `.github/zizmor.yml` or `zizmor.yml` in the
/// candidate path. If found, load and return it.
/// 3. Otherwise, continue the search in the candidate path's
/// parent directory, repeating step 2, terminating when
/// we reach the filesystem root or the first .git directory.
fn discover_in_dir(path: &Utf8Path) -> Result<Option<Self>> {
tracing::debug!("attempting config discovery in `{path}`");
let canonical = path.canonicalize_utf8()?;
let mut candidate_path = if canonical.file_name() == Some("workflows") {
// TODO: Return None here instead of failing?
canonical.parent().ok_or_else(|| {
anyhow!("cannot discover config: no parent directory of `{canonical}`")
})?
} else {
&canonical
};
loop {
for candidate in CONFIG_CANDIDATES {
let candidate_path = candidate_path.join(candidate);
if candidate_path.is_file() {
tracing::debug!("found config candidate at `{candidate_path}`");
return Ok(Some(Self::load(&fs::read_to_string(&candidate_path)?)?));
}
}
if candidate_path.join(".git").is_dir() {
tracing::debug!("found `{candidate_path}/.git`, stopping search");
return Ok(None);
}
let Some(parent) = candidate_path.parent() else {
tracing::debug!("reached filesystem root without finding a config");
return Ok(None);
};
candidate_path = parent;
}
}
/// Discover a [`Config`] using rules applicable to the given path.
///
/// For files, this attempts to walk up the directory tree,
/// looking for either a `zizmor.yml`.
/// The walk starts at the file's grandparent directory.
///
/// For directories, this attempts to find a `.github/zizmor.yml` or
/// `zizmor.yml` in the directory itself.
pub(crate) fn discover_local(path: &Utf8Path) -> Result<Option<Self>> {
tracing::debug!("discovering config for local input `{path}`");
if path.is_dir() {
Self::discover_in_dir(path)
} else if path.is_file() {
let Some(parent) = path.parent() else {
tracing::debug!("no parent for {path:?}, cannot discover config");
return Ok(None);
};
Self::discover_in_dir(parent)
} else {
Err(anyhow!(
"cannot discover config for `{path}`: not a file or directory"
))
}
}
/// Discover a [`Config`] for a repository slug.
///
/// This will look for a `.github/zizmor.yml` or `zizmor.yml`
/// in the repository's root directory.
pub(crate) fn discover_remote(client: &Client, slug: &RepoSlug) -> Result<Option<Self>> {
let conf = CONFIG_CANDIDATES
.iter()
.find_map(|candidate| client.fetch_single_file(slug, candidate).transpose())
.map(|contents| {
contents.and_then(|contents| {
tracing::debug!("retrieved config for {slug}");
Self::load(&contents)
})
})
.transpose()?;
Ok(conf)
}
/// Loads a global [`Config`] for the given [`App`].
///
/// Returns `Ok(None)` unless the user explicitly specifies
/// a config file with `--config`.
pub(crate) fn global(app: &App) -> Result<Option<Self>> {
if app.no_config {
Ok(None)
} else if let Some(path) = &app.config {
tracing::debug!("loading config from `{path}`");
let contents = fs::read_to_string(path)
.with_context(|| format!("failed to read config file at `{path}`"))?;
Ok(Some(Self::load(&contents).with_context(|| {
format!("failed to load config file at `{path}`")
})?))
} else {
Ok(None)
}
}
/// Returns `true` if this [`Config`] has an ignore rule for the
/// given finding.
pub(crate) fn ignores(&self, finding: &Finding<'_>) -> bool {
let Some(rule_config) = self.rules.get(finding.ident) else {
let Some(rule_config) = self.raw.rules.get(finding.ident) else {
return false;
};
@ -131,6 +496,9 @@ impl Config {
// This will hopefully minimize confusion when a finding spans
// multiple files, as the first location is the one a user will
// typically ignore, suppressing the rest in the process.
// TODO: This needs to filter on something other than filename,
// since that doesn't work for action definitions (which are
// all `action.yml`).
for loc in &finding.locations {
for rule in ignores
.iter()
@ -166,18 +534,6 @@ impl Config {
false
}
pub(crate) fn rule_config<T>(&self, ident: &str) -> Result<Option<T>>
where
T: DeserializeOwned,
{
Ok(self
.rules
.get(ident)
.and_then(|rule_config| rule_config.config.as_ref())
.map(|policy| serde_yaml::from_value::<T>(serde_yaml::Value::Mapping(policy.clone())))
.transpose()?)
}
}
#[cfg(test)]

View file

@ -5,7 +5,7 @@ use clap::ValueEnum;
use serde::{Deserialize, Serialize};
use self::location::{Location, SymbolicLocation};
use crate::{InputKey, models::AsDocument};
use crate::{InputKey, models::AsDocument, registry::input::Group};
use yamlpatch::{self, Patch};
pub(crate) mod location;
@ -187,6 +187,14 @@ impl Finding<'_> {
.find(|l| l.symbolic.is_primary())
.unwrap()
}
/// Return the input group for this finding's primary location.
///
/// We assume that all locations in a finding belong to the same group,
/// if not the same file within that group.
pub(crate) fn input_group(&self) -> &Group {
self.primary_location().symbolic.key.group()
}
}
pub(crate) struct FindingBuilder<'doc> {

View file

@ -3,10 +3,10 @@
//! Build on synchronous reqwest to avoid octocrab's need to taint
//! the whole codebase with async.
use std::{io::Read, ops::Deref, path::Path};
use std::{fmt::Display, io::Read, ops::Deref, str::FromStr};
use anyhow::{Context, Result, anyhow};
use camino::Utf8Path;
use camino::{Utf8Path, Utf8PathBuf};
use flate2::read::GzDecoder;
use http_cache_reqwest::{
CACacheManager, Cache, CacheMode, CacheOptions, HttpCache, HttpCacheOptions,
@ -59,6 +59,29 @@ impl GitHubHost {
}
}
impl Default for GitHubHost {
fn default() -> Self {
Self::Standard("github.com".into())
}
}
impl Display for GitHubHost {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Enterprise(host) => write!(f, "{host}"),
Self::Standard(host) => write!(f, "{host}"),
}
}
}
impl FromStr for GitHubHost {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::new(s)
}
}
/// A sanitized GitHub access token.
#[derive(Clone)]
pub(crate) struct GitHubToken(String);
@ -80,14 +103,15 @@ impl GitHubToken {
#[derive(Clone)]
pub(crate) struct Client {
api_base: String,
host: GitHubHost,
http: ClientWithMiddleware,
}
impl Client {
pub(crate) fn new(
hostname: &GitHubHost,
token: &GitHubToken,
cache_dir: &Path,
host: GitHubHost,
token: GitHubToken,
cache_dir: Utf8PathBuf,
) -> anyhow::Result<Self> {
let mut headers = HeaderMap::new();
headers.insert(USER_AGENT, "zizmor".parse().unwrap());
@ -127,11 +151,16 @@ impl Client {
.build();
Ok(Self {
api_base: hostname.to_api_url(),
api_base: host.to_api_url(),
host,
http,
})
}
pub(crate) fn host(&self) -> &GitHubHost {
&self.host
}
async fn paginate<T: DeserializeOwned>(
&self,
endpoint: &str,
@ -339,6 +368,57 @@ impl Client {
.map_err(Into::into)
}
/// Fetch a single file from the given remote repository slug.
///
/// Returns the file contents as a `String` if the file exists,
/// or `None` if the request produces a 404.
#[instrument(skip(self, slug, file))]
#[tokio::main]
pub(crate) async fn fetch_single_file(
&self,
slug: &RepoSlug,
file: &str,
) -> Result<Option<String>> {
self.fetch_single_file_async(slug, file).await
}
pub(crate) async fn fetch_single_file_async(
&self,
slug: &RepoSlug,
file: &str,
) -> Result<Option<String>> {
tracing::debug!("fetching {file} from {slug}");
let url = format!(
"{api_base}/repos/{owner}/{repo}/contents/{file}",
api_base = self.api_base,
owner = slug.owner,
repo = slug.repo,
file = file
);
let resp = self
.http
.get(&url)
.header(ACCEPT, "application/vnd.github.raw+json")
.pipe(|req| match slug.git_ref.as_ref() {
Some(g) => req.query(&[("ref", g)]),
None => req,
})
.send()
.await?;
match resp.status() {
StatusCode::OK => {
let contents = resp.text().await?;
Ok(Some(contents))
}
StatusCode::NOT_FOUND => Ok(None),
_ => Err(resp.error_for_status().unwrap_err().into()),
}
}
/// Collect all workflows (and only workflows) defined in the given remote
/// repository slug into the given input group.
///
@ -382,22 +462,15 @@ impl Client {
.into_iter()
.filter(|file| file.name.ends_with(".yml") || file.name.ends_with(".yaml"))
{
let file_url = format!("{url}/{file}", file = file.name);
tracing::debug!("fetching {file_url}");
let contents = self
.http
.get(file_url)
.header(ACCEPT, "application/vnd.github.raw+json")
.pipe(|req| match git_ref.as_ref() {
Some(g) => req.query(&[("ref", g)]),
None => req,
})
.send()
.await?
.error_for_status()?
.text()
.await?;
let Some(contents) = self.fetch_single_file_async(slug, &file.path).await? else {
// This can only happen if we have some kind of TOCTOU
// discrepancy with the listing call above, e.g. a file
// was deleted on a branch immediately after we listed it.
anyhow::bail!(
"couldn't fetch workflow file {file} from {slug}",
file = file.name
);
};
let key = InputKey::remote(slug, file.path)?;
// TODO: Make strictness configurable here?

View file

@ -11,6 +11,7 @@ use crate::finding::location::Point;
use crate::finding::{Persona, Severity};
use crate::models::action::Action;
use crate::models::workflow::Workflow;
use crate::registry::input::{InputGroup, InputRegistry};
use crate::registry::{FindingRegistry, input::InputKey};
use crate::{AuditRegistry, AuditState};
@ -181,10 +182,17 @@ impl Backend {
anyhow::bail!("asked to audit unexpected file: {path}");
};
let config = Config::default();
let mut registry = FindingRegistry::new(None, None, Persona::Regular, &config);
for (_, audit) in self.audit_registry.iter_audits() {
registry.extend(audit.audit(&input)?);
let mut group = InputGroup::new(Config::default());
group.register_input(input)?;
let mut input_registry = InputRegistry::new();
input_registry.groups.insert("lsp".into(), group);
let mut registry = FindingRegistry::new(&input_registry, None, None, Persona::Regular);
for (input_key, input) in input_registry.iter_inputs() {
for (_, audit) in self.audit_registry.iter_audits() {
registry.extend(audit.audit(input, input_registry.get_config(input_key.group()))?);
}
}
let diagnostics = registry
@ -256,16 +264,9 @@ pub(crate) async fn run() -> anyhow::Result<()> {
let stdin = tokio::io::stdin();
let stdout = tokio::io::stdout();
let config = Config::default();
let state = AuditState::default();
let audit_state = AuditState {
config: &config,
no_online_audits: false,
gh_client: None,
gh_hostname: crate::GitHubHost::Standard("github.com".into()),
};
let audits = AuditRegistry::default_audits(&audit_state)?;
let audits = AuditRegistry::default_audits(&state)?;
let (service, socket) = LspService::new(|client| Backend {
audit_registry: audits,
client,

View file

@ -8,10 +8,11 @@ use std::{
use annotate_snippets::{Level, Renderer};
use anstream::{eprintln, println, stream::IsTerminal};
use anyhow::{Context, Result, anyhow};
use camino::Utf8PathBuf;
use clap::{Args, CommandFactory, Parser, ValueEnum, builder::NonEmptyStringValueParser};
use clap_complete::Generator;
use clap_verbosity_flag::InfoLevel;
use config::Config;
use etcetera::AppStrategy as _;
use finding::{Confidence, Persona, Severity};
use github_api::{GitHubHost, GitHubToken};
use indicatif::ProgressStyle;
@ -24,6 +25,8 @@ use tracing::{Span, info_span, instrument};
use tracing_indicatif::{IndicatifLayer, span_ext::IndicatifSpanExt};
use tracing_subscriber::{EnvFilter, layer::SubscriberExt as _, util::SubscriberInitExt as _};
use crate::{config::Config, github_api::Client};
mod audit;
mod config;
mod finding;
@ -70,7 +73,7 @@ struct App {
gh_token: Option<GitHubToken>,
/// The GitHub Server Hostname. Defaults to github.com
#[arg(long, env = "GH_HOST", default_value = "github.com", value_parser = GitHubHost::new)]
#[arg(long, env = "GH_HOST", default_value_t)]
gh_hostname: GitHubHost,
/// Perform only offline audits.
@ -96,8 +99,9 @@ struct App {
#[arg(long, value_enum, value_name = "MODE")]
color: Option<ColorMode>,
/// The configuration file to load. By default, any config will be
/// discovered relative to $CWD.
/// The configuration file to load.
/// This loads a single configuration file across all input groups,
/// which may not be what you intend.
#[arg(
short,
long,
@ -125,8 +129,8 @@ struct App {
/// The directory to use for HTTP caching. By default, a
/// host-appropriate user-caching directory will be used.
#[arg(long)]
cache_dir: Option<String>,
#[arg(long, default_value_t = App::default_cache_dir(), hide_default_value = true)]
cache_dir: Utf8PathBuf,
/// Control which kinds of inputs are collected for auditing.
///
@ -176,6 +180,20 @@ struct App {
inputs: Vec<String>,
}
impl App {
fn default_cache_dir() -> Utf8PathBuf {
etcetera::choose_app_strategy(etcetera::AppStrategyArgs {
top_level_domain: "io.github".into(),
author: "woodruffw".into(),
app_name: "zizmor".into(),
})
.expect("failed to determine default cache directory")
.cache_dir()
.try_into()
.expect("failed to turn cache directory into a sane path")
}
}
#[cfg(feature = "lsp")]
#[derive(Args)]
#[group(multiple = true, conflicts_with = "inputs")]
@ -353,17 +371,25 @@ pub(crate) fn tips(err: impl AsRef<str>, tips: &[impl AsRef<str>]) -> String {
format!("{}", renderer.render(message))
}
/// State used when collecting input groups.
pub(crate) struct CollectionOptions {
pub(crate) mode: CollectionMode,
pub(crate) strict: bool,
pub(crate) no_config: bool,
/// Global configuration, if any.
pub(crate) global_config: Option<Config>,
}
#[instrument(skip_all)]
fn collect_inputs(
inputs: Vec<String>,
mode: CollectionMode,
strict: bool,
state: &AuditState,
options: &CollectionOptions,
gh_client: Option<&Client>,
) -> Result<InputRegistry> {
let mut registry = InputRegistry::new();
for input in inputs.into_iter() {
registry.register_group(input, mode, strict, state)?;
registry.register_group(input, options, gh_client)?;
}
if registry.len() == 0 {
@ -477,23 +503,28 @@ fn run() -> Result<ExitCode> {
reg.with(indicatif_layer).init();
}
let config = Config::new(&app).map_err(|e| {
anyhow!(tips(
format!("failed to load config: {e:#}"),
&[
"check your configuration file for errors",
"see: https://docs.zizmor.sh/configuration/"
]
))
})?;
let global_config = Config::global(&app)?;
let audit_state = AuditState::new(&app, &config)?;
let registry = collect_inputs(app.inputs, app.collect, app.strict_collection, &audit_state)?;
let gh_client = app
.gh_token
.map(|token| Client::new(app.gh_hostname, token, app.cache_dir))
.transpose()?;
let audit_registry = AuditRegistry::default_audits(&audit_state)?;
let collection_options = CollectionOptions {
mode: app.collect,
strict: app.strict_collection,
no_config: app.no_config,
global_config,
};
let registry = collect_inputs(app.inputs, &collection_options, gh_client.as_ref())?;
let state = AuditState::new(app.no_online_audits, gh_client);
let audit_registry = AuditRegistry::default_audits(&state)?;
let mut results =
FindingRegistry::new(app.min_severity, app.min_confidence, app.persona, &config);
FindingRegistry::new(&registry, app.min_severity, app.min_confidence, app.persona);
{
// Note: block here so that we drop the span here at the right time.
let span = info_span!("audit");
@ -504,15 +535,16 @@ fn run() -> Result<ExitCode> {
let _guard = span.enter();
for (_, input) in registry.iter_inputs() {
for (input_key, input) in registry.iter_inputs() {
Span::current().pb_set_message(input.key().filename());
let config = registry.get_config(input_key.group());
for (name, audit) in audit_registry.iter_audits() {
tracing::debug!(
"running {name} on {input}",
name = name,
input = input.key()
);
results.extend(audit.audit(input).with_context(|| {
results.extend(audit.audit(input, config).with_context(|| {
format!("{name} failed on {input}", input = input.key().filename())
})?);
Span::current().pb_inc(1);

View file

@ -37,7 +37,7 @@ static REPOSITORY_USES_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
/// Represents a pattern for matching repository `uses` references.
/// These patterns are ordered by specificity; more specific patterns
/// should be listed first.
#[derive(Debug, Eq, PartialEq, Hash, PartialOrd, Ord)]
#[derive(Clone, Debug, Eq, PartialEq, Hash, PartialOrd, Ord)]
pub(crate) enum RepositoryUsesPattern {
/// Matches exactly `owner/repo/subpath@ref`.
ExactWithRef {

View file

@ -7,9 +7,8 @@ use indexmap::IndexMap;
use crate::{
audit::{self, Audit, AuditLoadError},
config::Config,
finding::{Confidence, Finding, Persona, Severity},
registry::input::InputKey,
registry::input::{InputKey, InputRegistry},
state::AuditState,
tips,
};
@ -110,7 +109,7 @@ impl std::fmt::Debug for AuditRegistry {
/// A registry of all findings discovered during a `zizmor` run.
pub(crate) struct FindingRegistry<'a> {
config: &'a Config,
input_registry: &'a InputRegistry,
minimum_severity: Option<Severity>,
minimum_confidence: Option<Confidence>,
persona: Persona,
@ -122,13 +121,13 @@ pub(crate) struct FindingRegistry<'a> {
impl<'a> FindingRegistry<'a> {
pub(crate) fn new(
input_registry: &'a InputRegistry,
minimum_severity: Option<Severity>,
minimum_confidence: Option<Confidence>,
persona: Persona,
config: &'a Config,
) -> Self {
Self {
config,
input_registry,
minimum_severity,
minimum_confidence,
persona,
@ -154,7 +153,10 @@ impl<'a> FindingRegistry<'a> {
|| self
.minimum_confidence
.is_some_and(|min| min > finding.determinations.confidence)
|| self.config.ignores(&finding)
|| self
.input_registry
.get_config(finding.input_group())
.ignores(&finding)
{
self.ignored.push(finding);
} else {

View file

@ -5,18 +5,18 @@ use std::{
str::FromStr as _,
};
use anyhow::Context as _;
use anyhow::Context;
use camino::{Utf8Path, Utf8PathBuf};
use owo_colors::OwoColorize as _;
use serde::Serialize;
use thiserror::Error;
use crate::{
CollectionMode,
CollectionMode, CollectionOptions,
audit::AuditInput,
github_api::GitHubHost,
config::Config,
github_api::{Client, GitHubHost},
models::{action::Action, workflow::Workflow},
state::AuditState,
tips,
};
@ -243,7 +243,7 @@ impl InputKey {
}
/// Returns the group this input belongs to.
fn group(&self) -> &Group {
pub(crate) fn group(&self) -> &Group {
match self {
InputKey::Local(local) => &local.group,
InputKey::Remote(remote) => &remote.group,
@ -270,18 +270,20 @@ impl From<&RepoSlug> for Group {
/// A group of inputs collected from the same source.
pub(crate) struct InputGroup {
/// The collected inputs.
pub(crate) inputs: BTreeMap<InputKey, AuditInput>,
// TODO: Config will go here.
inputs: BTreeMap<InputKey, AuditInput>,
/// The configuration for this group.
config: Config,
}
impl InputGroup {
fn new() -> Self {
pub(crate) fn new(config: Config) -> Self {
Self {
inputs: Default::default(),
config,
}
}
fn register_input(&mut self, input: AuditInput) -> anyhow::Result<()> {
pub(crate) fn register_input(&mut self, input: AuditInput) -> anyhow::Result<()> {
if self.inputs.contains_key(input.key()) {
return Err(anyhow::anyhow!(
"can't register {key} more than once",
@ -324,7 +326,12 @@ impl InputGroup {
}
}
fn collect_from_file(&mut self, path: &Utf8Path, strict: bool) -> anyhow::Result<()> {
fn collect_from_file(path: &Utf8Path, options: &CollectionOptions) -> anyhow::Result<Self> {
let config = Config::discover(options, || Config::discover_local(path))
.with_context(|| format!("failed to discover configuration for {path}"))?;
let mut group = Self::new(config);
// When collecting individual files, we don't know which part
// of the input path is the prefix.
let (key, kind) = match (path.file_stem(), path.extension()) {
@ -340,15 +347,17 @@ impl InputGroup {
};
let contents = std::fs::read_to_string(path)?;
self.register(kind, contents, key, strict)
group.register(kind, contents, key, options.strict)?;
Ok(group)
}
fn collect_from_dir(
&mut self,
path: &Utf8Path,
mode: CollectionMode,
strict: bool,
) -> anyhow::Result<()> {
fn collect_from_dir(path: &Utf8Path, options: &CollectionOptions) -> anyhow::Result<Self> {
let config = Config::discover(options, || Config::discover_local(path))
.with_context(|| format!("failed to discover configuration for directory {path}"))?;
let mut group = Self::new(config);
// Start with all filters disabled, i.e. walk everything.
let mut walker = ignore::WalkBuilder::new(path);
let walker = walker.standard_filters(false);
@ -364,7 +373,7 @@ impl InputGroup {
// zizmor integrators.
//
// See: https://github.com/zizmorcore/zizmor/issues/596
if mode.respects_gitignore() {
if options.mode.respects_gitignore() {
walker
.require_git(false)
.git_ignore(true)
@ -376,7 +385,7 @@ impl InputGroup {
let entry = entry?;
let entry = <&Utf8Path>::try_from(entry.path())?;
if mode.workflows()
if options.mode.workflows()
&& entry.is_file()
&& matches!(entry.extension(), Some("yml" | "yaml"))
&& entry
@ -385,29 +394,27 @@ impl InputGroup {
{
let key = InputKey::local(Group(path.as_str().into()), entry, Some(path))?;
let contents = std::fs::read_to_string(entry)?;
self.register(InputKind::Workflow, contents, key, strict)?;
group.register(InputKind::Workflow, contents, key, options.strict)?;
}
if mode.actions()
if options.mode.actions()
&& entry.is_file()
&& matches!(entry.file_name(), Some("action.yml" | "action.yaml"))
{
let key = InputKey::local(Group(path.as_str().into()), entry, Some(path))?;
let contents = std::fs::read_to_string(entry)?;
self.register(InputKind::Action, contents, key, strict)?;
group.register(InputKind::Action, contents, key, options.strict)?;
}
}
Ok(())
Ok(group)
}
fn collect_from_repo_slug(
&mut self,
raw_slug: &str,
state: &AuditState,
mode: CollectionMode,
_strict: bool,
) -> anyhow::Result<()> {
options: &CollectionOptions,
gh_client: Option<&Client>,
) -> anyhow::Result<Self> {
let Ok(slug) = RepoSlug::from_str(raw_slug) else {
return Err(anyhow::anyhow!(tips(
format!("invalid input: {raw_slug}"),
@ -420,7 +427,7 @@ impl InputGroup {
)));
};
let client = state.gh_client.as_ref().ok_or_else(|| {
let client = gh_client.ok_or_else(|| {
anyhow::anyhow!(tips(
format!(
"can't retrieve repository: {raw_slug}",
@ -434,30 +441,36 @@ impl InputGroup {
))
})?;
if matches!(mode, CollectionMode::WorkflowsOnly) {
let config = Config::discover(options, || Config::discover_remote(client, &slug))
.with_context(|| format!("failed to discover configuration for {slug}"))?;
let mut group = Self::new(config);
if matches!(options.mode, CollectionMode::WorkflowsOnly) {
// Performance: if we're *only* collecting workflows, then we
// can save ourselves a full repo download and only fetch the
// repo's workflow files.
client.fetch_workflows(&slug, self)?;
client.fetch_workflows(&slug, &mut group)?;
} else {
let before = self.len();
let host = match &state.gh_hostname {
let before = group.len();
let host = match client.host() {
GitHubHost::Enterprise(address) => address.as_str(),
GitHubHost::Standard(_) => "github.com",
};
client.fetch_audit_inputs(&slug, self).with_context(|| {
tips(
format!(
"couldn't collect inputs from https://{host}/{owner}/{repo}",
host = host,
owner = slug.owner,
repo = slug.repo
),
&["confirm the repository exists and that you have access to it"],
)
})?;
let after = self.len();
client
.fetch_audit_inputs(&slug, &mut group)
.with_context(|| {
tips(
format!(
"couldn't collect inputs from https://{host}/{owner}/{repo}",
host = host,
owner = slug.owner,
repo = slug.repo
),
&["confirm the repository exists and that you have access to it"],
)
})?;
let after = group.len();
let len = after - before;
tracing::info!(
@ -467,26 +480,22 @@ impl InputGroup {
);
}
Ok(())
Ok(group)
}
pub(crate) fn collect(
request: &str,
mode: CollectionMode,
strict: bool,
state: &AuditState,
options: &CollectionOptions,
gh_client: Option<&Client>,
) -> anyhow::Result<Self> {
let path = Utf8Path::new(request);
let mut group = Self::new();
if path.is_file() {
group.collect_from_file(path, strict)?;
Self::collect_from_file(path, options)
} else if path.is_dir() {
group.collect_from_dir(path, mode, strict)?;
Self::collect_from_dir(path, options)
} else {
group.collect_from_repo_slug(request, state, mode, strict)?;
Self::collect_from_repo_slug(request, options, gh_client)
}
Ok(group)
}
pub(crate) fn len(&self) -> usize {
@ -518,15 +527,14 @@ impl InputRegistry {
pub(crate) fn register_group(
&mut self,
name: String,
mode: CollectionMode,
strict: bool,
state: &AuditState,
options: &CollectionOptions,
gh_client: Option<&Client>,
) -> anyhow::Result<()> {
// If the group has already been registered, then the user probably
// duplicated the input multiple times on the command line by accident.
// We just ignore any duplicate registrations.
if let btree_map::Entry::Vacant(e) = self.groups.entry(Group(name.clone())) {
e.insert(InputGroup::collect(&name, mode, strict, state)?);
e.insert(InputGroup::collect(&name, options, gh_client)?);
}
Ok(())
@ -544,6 +552,15 @@ impl InputRegistry {
.and_then(|group| group.inputs.get(key))
.expect("API misuse: requested an un-registered input")
}
/// Get a reference to the configuration for a given input group.
pub(crate) fn get_config(&self, group: &Group) -> &Config {
&self
.groups
.get(group)
.expect("API misuse: requested config for an un-registered input")
.config
}
}
#[cfg(test)]

View file

@ -1,51 +1,28 @@
//! zizmor's runtime state, including application-level caching.
use std::path::PathBuf;
use crate::github_api::Client;
use etcetera::{AppStrategy, AppStrategyArgs, choose_app_strategy};
use crate::{
App,
config::Config,
github_api::{Client, GitHubHost},
};
#[derive(Clone)]
pub(crate) struct AuditState<'a> {
pub(crate) config: &'a Config,
pub(crate) struct AuditState {
/// Whether online audits should be skipped.
pub(crate) no_online_audits: bool,
/// A cache-configured GitHub API client, if a GitHub API token is given.
pub(crate) gh_client: Option<Client>,
pub(crate) gh_hostname: GitHubHost,
}
impl<'a> AuditState<'a> {
pub(crate) fn new(app: &App, config: &'a Config) -> anyhow::Result<Self> {
let cache_dir = match &app.cache_dir {
Some(cache_dir) => PathBuf::from(cache_dir),
None => choose_app_strategy(AppStrategyArgs {
top_level_domain: "io.github".into(),
author: "woodruffw".into(),
app_name: "zizmor".into(),
})
// NOTE: no point in failing gracefully here.
.expect("failed to determine default cache directory")
.cache_dir(),
};
tracing::debug!("using cache directory: {cache_dir:?}");
let gh_client = app
.gh_token
.as_ref()
.map(|token| Client::new(&app.gh_hostname, token, &cache_dir))
.transpose()?;
Ok(Self {
config,
no_online_audits: app.no_online_audits,
impl AuditState {
pub(crate) fn new(no_online_audits: bool, gh_client: Option<Client>) -> Self {
Self {
no_online_audits,
gh_client,
gh_hostname: app.gh_hostname.clone(),
})
}
}
}
impl Default for AuditState {
fn default() -> Self {
Self {
no_online_audits: true,
gh_client: None,
}
}
}

View file

@ -1,26 +1,33 @@
use anyhow::{Context as _, Result};
use camino::Utf8PathBuf;
use regex::{Captures, Regex};
use std::{env::current_dir, io::ErrorKind};
use std::{env::current_dir, io::ErrorKind, sync::LazyLock};
use assert_cmd::Command;
pub fn input_under_test(name: &str) -> String {
static TEST_PREFIX: LazyLock<Utf8PathBuf> = LazyLock::new(|| {
let current_dir = current_dir().expect("Cannot figure out current directory");
let file_path = current_dir
.join("tests")
.join("integration")
.join("test-data")
.join(name);
.join("test-data");
if !file_path.exists() {
panic!("Cannot find input under test: {}", file_path.display());
panic!("Cannot find test data directory: {}", file_path.display());
}
file_path
.to_str()
.expect("Cannot create string reference for file path")
.to_string()
Utf8PathBuf::try_from(file_path).expect("Cannot create UTF-8 path from test data directory")
});
pub fn input_under_test(name: &str) -> String {
let file_path = TEST_PREFIX.join(name);
if !file_path.exists() {
panic!("Cannot find input under test: {file_path}");
}
file_path.to_string()
}
pub enum OutputMode {
@ -36,6 +43,7 @@ pub struct Zizmor {
offline: bool,
inputs: Vec<String>,
config: Option<String>,
no_config: bool,
output: OutputMode,
expects_failure: bool,
}
@ -51,6 +59,7 @@ impl Zizmor {
offline: true,
inputs: vec![],
config: None,
no_config: false,
output: OutputMode::Stdout,
expects_failure: false,
}
@ -81,6 +90,11 @@ impl Zizmor {
self
}
pub fn no_config(mut self, flag: bool) -> Self {
self.no_config = flag;
self
}
pub fn unbuffer(mut self, flag: bool) -> Self {
self.unbuffer = flag;
self
@ -113,12 +127,18 @@ impl Zizmor {
std::env::var("GH_TOKEN").context("online tests require GH_TOKEN to be set")?;
}
if let Some(config) = self.config {
self.cmd.arg("--config").arg(config);
} else {
if self.no_config && self.config.is_some() {
anyhow::bail!("API misuse: cannot set both --no-config and --config");
}
if self.no_config {
self.cmd.arg("--no-config");
}
if let Some(config) = &self.config {
self.cmd.arg("--config").arg(config);
}
for input in &self.inputs {
self.cmd.arg(input);
}
@ -170,6 +190,11 @@ impl Zizmor {
}
}
let config_placeholder = "@@CONFIG@@";
if let Some(config) = &self.config {
raw = raw.replace(config, config_placeholder);
}
let input_placeholder = "@@INPUT@@";
for input in &self.inputs {
raw = raw.replace(input, input_placeholder);
@ -185,6 +210,12 @@ impl Zizmor {
.into_owned();
}
// Fallback: replace any lingering absolute paths.
// TODO: Maybe just use this everywhere instead of the special
// replacements above?
let test_prefix_placeholder = "@@TEST_PREFIX@@";
raw = raw.replace(TEST_PREFIX.as_str(), test_prefix_placeholder);
Ok(raw)
}
}

View file

@ -0,0 +1,178 @@
//! Configuration discovery tests.
use crate::common::{OutputMode, input_under_test, zizmor};
/// Ensures we correctly discover a configuration file at the root
/// of a given input directory, i.e. `config-in-root/zizmor.yml` in
/// this case.
#[test]
fn test_discovers_config_in_root() -> anyhow::Result<()> {
insta::assert_snapshot!(
zizmor()
.input(input_under_test("config-scenarios/config-in-root"))
.setenv("RUST_LOG", "zizmor::config=debug")
.output(OutputMode::Both)
.run()?
);
Ok(())
}
/// Ensures we correctly discover a configuration file in the root
/// directory from an input filename, i.e. going from
/// `config-in-root/.github/workflows/hackme.yml`
/// to `config-in-root/zizmor.yml` in this case.
#[test]
fn test_discovers_config_in_root_from_file_input() -> anyhow::Result<()> {
insta::assert_snapshot!(
zizmor()
.input(input_under_test(
"config-scenarios/config-in-root/.github/workflows/hackme.yml"
))
.setenv("RUST_LOG", "zizmor::config=debug")
.output(OutputMode::Both)
.run()?
);
Ok(())
}
/// Ensures we correctly discover a configuration file in the root
/// directory from a child input directory, i.e. going from
/// `config-in-root/.github/workflows/` to `config-in-root/zizmor.yml`
/// in this case.
#[test]
fn test_discovers_config_in_root_from_child_dir() -> anyhow::Result<()> {
insta::assert_snapshot!(
zizmor()
.input(input_under_test(
"config-scenarios/config-in-root/.github/workflows"
))
.setenv("RUST_LOG", "zizmor::config=debug")
.output(OutputMode::Both)
.run()?
);
Ok(())
}
/// Ensures we ignore a configuration file in the root of a given
/// input directory when `--no-config` is specified.
#[test]
fn test_ignores_config_in_root() -> anyhow::Result<()> {
insta::assert_snapshot!(
zizmor()
.no_config(true)
.input(input_under_test("config-scenarios/config-in-root"))
.setenv("RUST_LOG", "zizmor::config=debug")
.output(OutputMode::Both)
.run()?
);
Ok(())
}
/// Ensures we ignore a configuration file in the root directory
/// from an input filename when `--no-config` is specified.
#[test]
fn test_ignores_config_in_root_from_file_input() -> anyhow::Result<()> {
insta::assert_snapshot!(
zizmor()
.no_config(true)
.input(input_under_test(
"config-scenarios/config-in-root/.github/workflows/hackme.yml"
))
.setenv("RUST_LOG", "zizmor::config=debug")
.output(OutputMode::Both)
.run()?
);
Ok(())
}
/// Ensures we ignore a configuration file in the root directory
/// from a child input directory when `--no-config` is specified.
#[test]
fn test_ignores_config_in_root_from_child_dir() -> anyhow::Result<()> {
insta::assert_snapshot!(
zizmor()
.no_config(true)
.input(input_under_test(
"config-scenarios/config-in-root/.github/workflows"
))
.setenv("RUST_LOG", "zizmor::config=debug")
.output(OutputMode::Both)
.run()?
);
Ok(())
}
/// Ensures we correctly discover a configuration file in a `.github`
/// subdirectory of a given input directory, i.e.
/// `config-in-dotgithub/.github/zizmor.yml` in this case.
#[test]
fn test_discovers_config_in_dotgithub() -> anyhow::Result<()> {
insta::assert_snapshot!(
zizmor()
.input(input_under_test("config-scenarios/config-in-dotgithub"))
.setenv("RUST_LOG", "zizmor::config=debug")
.output(OutputMode::Both)
.run()?
);
Ok(())
}
/// Ensures we correctly discover a configuration file in a `.github`
/// subdirectory from an input filename, i.e. going from
/// `config-in-dotgithub/.github/workflows/hackme.yml`
/// to `config-in-dotgithub/.github/zizmor.yml` in this case.
#[test]
fn test_discovers_config_in_dotgithub_from_file_input() -> anyhow::Result<()> {
insta::assert_snapshot!(
zizmor()
.input(input_under_test(
"config-scenarios/config-in-dotgithub/.github/workflows/hackme.yml"
))
.setenv("RUST_LOG", "zizmor::config=debug")
.output(OutputMode::Both)
.run()?
);
Ok(())
}
/// Ensures we ignore a configuration file in a `.github` subdirectory
/// of a given input directory when `--no-config` is specified.
#[test]
fn test_ignores_config_in_dotgithub() -> anyhow::Result<()> {
insta::assert_snapshot!(
zizmor()
.no_config(true)
.input(input_under_test("config-scenarios/config-in-dotgithub"))
.setenv("RUST_LOG", "zizmor::config=debug")
.output(OutputMode::Both)
.run()?
);
Ok(())
}
/// Ensures we ignore a configuration file in a `.github` subdirectory
/// from an input filename when `--no-config` is specified.
#[test]
fn test_ignores_config_in_dotgithub_from_file_input() -> anyhow::Result<()> {
insta::assert_snapshot!(
zizmor()
.no_config(true)
.input(input_under_test(
"config-scenarios/config-in-dotgithub/.github/workflows/hackme.yml"
))
.setenv("RUST_LOG", "zizmor::config=debug")
.output(OutputMode::Both)
.run()?
);
Ok(())
}

View file

@ -4,6 +4,8 @@
mod acceptance;
/// Helpers.
mod common;
/// Configuration discovery tests.
mod config;
/// "Big picture" end-to-end tests, i.e. tests that typically exercise
/// more than one audit or complex CLI functionality.
mod e2e;

View file

@ -0,0 +1,9 @@
---
source: crates/zizmor/tests/integration/config.rs
expression: "zizmor().input(input_under_test(\"config-scenarios/config-in-dotgithub\")).setenv(\"RUST_LOG\",\n\"zizmor::config=debug\").output(OutputMode::Both).run()?"
snapshot_kind: text
---
DEBUG zizmor::config: discovering config for local input `@@INPUT@@`
DEBUG zizmor::config: attempting config discovery in `@@INPUT@@`
DEBUG zizmor::config: found config candidate at `@@INPUT@@/.github/zizmor.yml`
No findings to report. Good job! (1 ignored, 1 suppressed)

View file

@ -0,0 +1,9 @@
---
source: crates/zizmor/tests/integration/config.rs
expression: "zizmor().input(input_under_test(\"config-scenarios/config-in-dotgithub/.github/workflows/hackme.yml\")).setenv(\"RUST_LOG\",\n\"zizmor::config=debug\").output(OutputMode::Both).run()?"
snapshot_kind: text
---
DEBUG zizmor::config: discovering config for local input `@@INPUT@@`
DEBUG zizmor::config: attempting config discovery in `@@TEST_PREFIX@@/config-scenarios/config-in-dotgithub/.github/workflows`
DEBUG zizmor::config: found config candidate at `@@TEST_PREFIX@@/config-scenarios/config-in-dotgithub/.github/zizmor.yml`
No findings to report. Good job! (1 ignored, 1 suppressed)

View file

@ -0,0 +1,9 @@
---
source: crates/zizmor/tests/integration/config.rs
expression: "zizmor().input(input_under_test(\"config-scenarios/config-in-root\")).setenv(\"RUST_LOG\",\n\"zizmor::config=debug\").output(OutputMode::Both).run()?"
snapshot_kind: text
---
DEBUG zizmor::config: discovering config for local input `@@INPUT@@`
DEBUG zizmor::config: attempting config discovery in `@@INPUT@@`
DEBUG zizmor::config: found config candidate at `@@INPUT@@/zizmor.yml`
No findings to report. Good job! (1 ignored, 1 suppressed)

View file

@ -0,0 +1,9 @@
---
source: crates/zizmor/tests/integration/config.rs
expression: "zizmor().input(input_under_test(\"config-scenarios/config-in-root/.github/workflows\")).setenv(\"RUST_LOG\",\n\"zizmor::config=debug\").output(OutputMode::Both).run()?"
snapshot_kind: text
---
DEBUG zizmor::config: discovering config for local input `@@INPUT@@`
DEBUG zizmor::config: attempting config discovery in `@@INPUT@@`
DEBUG zizmor::config: found config candidate at `@@TEST_PREFIX@@/config-scenarios/config-in-root/zizmor.yml`
No findings to report. Good job! (1 ignored, 1 suppressed)

View file

@ -0,0 +1,9 @@
---
source: crates/zizmor/tests/integration/config.rs
expression: "zizmor().input(input_under_test(\"config-scenarios/config-in-root/.github/workflows/hackme.yml\")).setenv(\"RUST_LOG\",\n\"zizmor::config=debug\").output(OutputMode::Both).run()?"
snapshot_kind: text
---
DEBUG zizmor::config: discovering config for local input `@@INPUT@@`
DEBUG zizmor::config: attempting config discovery in `@@TEST_PREFIX@@/config-scenarios/config-in-root/.github/workflows`
DEBUG zizmor::config: found config candidate at `@@TEST_PREFIX@@/config-scenarios/config-in-root/zizmor.yml`
No findings to report. Good job! (1 ignored, 1 suppressed)

View file

@ -0,0 +1,20 @@
---
source: crates/zizmor/tests/integration/config.rs
expression: "zizmor().no_config(true).input(input_under_test(\"config-scenarios/config-in-dotgithub\")).setenv(\"RUST_LOG\",\n\"zizmor::config=debug\").output(OutputMode::Both).run()?"
snapshot_kind: text
---
DEBUG zizmor::config: skipping config discovery: explicitly disabled
error[template-injection]: code injection via template expansion
--> @@INPUT@@/.github/workflows/hackme.yml:16:40
|
13 | - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # tag=v7.0.1
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ action accepts arbitrary code
14 | with:
15 | script: |
| ^^^^^^ via this input
16 | return "doing a thing: ${{ github.event.issue.title }}"
| ^^^^^^^^^^^^^^^^^^^^^^^^ may expand into attacker-controllable code
|
= note: audit confidence → High
2 findings (1 suppressed): 0 unknown, 0 informational, 0 low, 0 medium, 1 high

View file

@ -0,0 +1,20 @@
---
source: crates/zizmor/tests/integration/config.rs
expression: "zizmor().no_config(true).input(input_under_test(\"config-scenarios/config-in-dotgithub/.github/workflows/hackme.yml\")).setenv(\"RUST_LOG\",\n\"zizmor::config=debug\").output(OutputMode::Both).run()?"
snapshot_kind: text
---
DEBUG zizmor::config: skipping config discovery: explicitly disabled
error[template-injection]: code injection via template expansion
--> @@INPUT@@:16:40
|
13 | - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # tag=v7.0.1
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ action accepts arbitrary code
14 | with:
15 | script: |
| ^^^^^^ via this input
16 | return "doing a thing: ${{ github.event.issue.title }}"
| ^^^^^^^^^^^^^^^^^^^^^^^^ may expand into attacker-controllable code
|
= note: audit confidence → High
2 findings (1 suppressed): 0 unknown, 0 informational, 0 low, 0 medium, 1 high

View file

@ -0,0 +1,20 @@
---
source: crates/zizmor/tests/integration/config.rs
expression: "zizmor().no_config(true).input(input_under_test(\"config-scenarios/config-in-root\")).setenv(\"RUST_LOG\",\n\"zizmor::config=debug\").output(OutputMode::Both).run()?"
snapshot_kind: text
---
DEBUG zizmor::config: skipping config discovery: explicitly disabled
error[template-injection]: code injection via template expansion
--> @@INPUT@@/.github/workflows/hackme.yml:16:40
|
13 | - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # tag=v7.0.1
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ action accepts arbitrary code
14 | with:
15 | script: |
| ^^^^^^ via this input
16 | return "doing a thing: ${{ github.event.issue.title }}"
| ^^^^^^^^^^^^^^^^^^^^^^^^ may expand into attacker-controllable code
|
= note: audit confidence → High
2 findings (1 suppressed): 0 unknown, 0 informational, 0 low, 0 medium, 1 high

View file

@ -0,0 +1,20 @@
---
source: crates/zizmor/tests/integration/config.rs
expression: "zizmor().no_config(true).input(input_under_test(\"config-scenarios/config-in-root/.github/workflows\")).setenv(\"RUST_LOG\",\n\"zizmor::config=debug\").output(OutputMode::Both).run()?"
snapshot_kind: text
---
DEBUG zizmor::config: skipping config discovery: explicitly disabled
error[template-injection]: code injection via template expansion
--> @@INPUT@@/hackme.yml:16:40
|
13 | - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # tag=v7.0.1
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ action accepts arbitrary code
14 | with:
15 | script: |
| ^^^^^^ via this input
16 | return "doing a thing: ${{ github.event.issue.title }}"
| ^^^^^^^^^^^^^^^^^^^^^^^^ may expand into attacker-controllable code
|
= note: audit confidence → High
2 findings (1 suppressed): 0 unknown, 0 informational, 0 low, 0 medium, 1 high

View file

@ -0,0 +1,20 @@
---
source: crates/zizmor/tests/integration/config.rs
expression: "zizmor().no_config(true).input(input_under_test(\"config-scenarios/config-in-root/.github/workflows/hackme.yml\")).setenv(\"RUST_LOG\",\n\"zizmor::config=debug\").output(OutputMode::Both).run()?"
snapshot_kind: text
---
DEBUG zizmor::config: skipping config discovery: explicitly disabled
error[template-injection]: code injection via template expansion
--> @@INPUT@@:16:40
|
13 | - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # tag=v7.0.1
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ action accepts arbitrary code
14 | with:
15 | script: |
| ^^^^^^ via this input
16 | return "doing a thing: ${{ github.event.issue.title }}"
| ^^^^^^^^^^^^^^^^^^^^^^^^ may expand into attacker-controllable code
|
= note: audit confidence → High
2 findings (1 suppressed): 0 unknown, 0 informational, 0 low, 0 medium, 1 high

View file

@ -7,7 +7,6 @@ snapshot_kind: text
INFO zizmor::registry: skipping impostor-commit: offline audits only requested
INFO zizmor::registry: skipping ref-confusion: offline audits only requested
INFO zizmor::registry: skipping known-vulnerable-actions: offline audits only requested
INFO zizmor::registry: skipping forbidden-uses: audit not configured
INFO zizmor::registry: skipping stale-action-refs: offline audits only requested
INFO audit: zizmor: 🌈 completed .github/workflows/artipacked.yml
INFO audit: zizmor: 🌈 completed .github/workflows/bot-conditions.yml

View file

@ -1,9 +1,12 @@
---
source: tests/integration/e2e.rs
source: crates/zizmor/tests/integration/e2e.rs
expression: "zizmor().expects_failure(true).config(if cfg!(windows) { \"NUL\" } else\n{ \"/dev/null\" }).input(input_under_test(\"e2e-menagerie\")).run()?"
snapshot_kind: text
---
fatal: no audit was performed
error: failed to load config: missing field `rules`
= note: check your configuration file for errors
= note: see: https://docs.zizmor.sh/configuration/
failed to load config file at `@@CONFIG@@`
Caused by:
error: failed to load config: missing field `rules`
= note: check your configuration file for errors
= note: see: https://docs.zizmor.sh/configuration/

View file

@ -1,6 +1,7 @@
---
source: crates/zizmor/tests/integration/e2e.rs
expression: "zizmor().expects_failure(true).input(input_under_test(&format!(\"invalid/{workflow_tc}.yml\"))).args([\"--strict-collection\"]).run()?"
snapshot_kind: text
---
fatal: no audit was performed
failed to load file://@@INPUT@@ as action

View file

@ -1,6 +1,7 @@
---
source: crates/zizmor/tests/integration/e2e.rs
expression: "zizmor().expects_failure(true).input(input_under_test(&format!(\"invalid/{workflow_tc}.yml\"))).args([\"--strict-collection\"]).run()?"
snapshot_kind: text
---
fatal: no audit was performed
failed to load file://@@INPUT@@ as workflow

View file

@ -1,6 +1,7 @@
---
source: crates/zizmor/tests/integration/e2e.rs
expression: "zizmor().expects_failure(true).input(input_under_test(&format!(\"invalid/{workflow_tc}.yml\"))).args([\"--strict-collection\"]).run()?"
snapshot_kind: text
---
fatal: no audit was performed
failed to load file://@@INPUT@@ as workflow

View file

@ -1,6 +1,7 @@
---
source: crates/zizmor/tests/integration/e2e.rs
expression: "zizmor().expects_failure(true).input(input_under_test(&format!(\"invalid/{workflow_tc}.yml\"))).args([\"--strict-collection\"]).run()?"
snapshot_kind: text
---
fatal: no audit was performed
failed to load file://@@INPUT@@ as workflow

View file

@ -1,6 +1,7 @@
---
source: crates/zizmor/tests/integration/e2e.rs
expression: "zizmor().expects_failure(true).input(input_under_test(&format!(\"invalid/{workflow_tc}.yml\"))).args([\"--strict-collection\"]).run()?"
snapshot_kind: text
---
fatal: no audit was performed
failed to load file://@@INPUT@@ as workflow

View file

@ -1,6 +1,7 @@
---
source: crates/zizmor/tests/integration/e2e.rs
expression: "zizmor().expects_failure(true).input(input_under_test(&format!(\"invalid/{workflow_tc}.yml\"))).args([\"--strict-collection\"]).run()?"
snapshot_kind: text
---
fatal: no audit was performed
failed to load file://@@INPUT@@ as workflow

View file

@ -1,6 +1,7 @@
---
source: crates/zizmor/tests/integration/e2e.rs
expression: "zizmor().expects_failure(true).input(input_under_test(&format!(\"invalid/{workflow_tc}.yml\"))).args([\"--strict-collection\"]).run()?"
snapshot_kind: text
---
fatal: no audit was performed
failed to load file://@@INPUT@@ as workflow

View file

@ -1,6 +1,7 @@
---
source: crates/zizmor/tests/integration/e2e.rs
expression: "zizmor().expects_failure(true).input(input_under_test(&format!(\"invalid/{workflow_tc}.yml\"))).args([\"--strict-collection\"]).run()?"
snapshot_kind: text
---
fatal: no audit was performed
failed to load file://@@INPUT@@ as action

View file

@ -1,6 +1,7 @@
---
source: crates/zizmor/tests/integration/e2e.rs
expression: "zizmor().expects_failure(true).input(input_under_test(&format!(\"invalid/{workflow_tc}.yml\"))).args([\"--strict-collection\"]).run()?"
snapshot_kind: text
---
fatal: no audit was performed
failed to load file://@@INPUT@@ as action

View file

@ -1,6 +1,7 @@
---
source: crates/zizmor/tests/integration/e2e.rs
expression: "zizmor().expects_failure(true).input(input_under_test(&format!(\"invalid/{workflow_tc}.yml\"))).args([\"--strict-collection\"]).run()?"
snapshot_kind: text
---
fatal: no audit was performed
failed to load file://@@INPUT@@ as workflow

View file

@ -1,11 +1,11 @@
---
source: crates/zizmor/tests/integration/e2e.rs
expression: "zizmor().offline(false).output(OutputMode::Both).args([\"--no-online-audits\",\n\"--collect=workflows-only\"]).input(\"python/cpython@f963239ff1f986742d4c6bab2ab7b73f5a4047f6\").run()?"
snapshot_kind: text
---
INFO zizmor::registry: skipping impostor-commit: offline audits only requested
INFO zizmor::registry: skipping ref-confusion: offline audits only requested
INFO zizmor::registry: skipping known-vulnerable-actions: offline audits only requested
INFO zizmor::registry: skipping forbidden-uses: audit not configured
INFO zizmor::registry: skipping stale-action-refs: offline audits only requested
INFO audit: zizmor: 🌈 completed .github/workflows/add-issue-header.yml
INFO audit: zizmor: 🌈 completed .github/workflows/build.yml
@ -91,18 +91,6 @@ help[obfuscation]: obfuscated usage of GitHub Actions features
|
= note: audit confidence → High
error[dangerous-triggers]: use of fundamentally insecure workflow trigger
--> .github/workflows/documentation-links.yml:5:1
|
5 | / on:
6 | | pull_request_target:
... |
10 | | - 'Doc/**'
11 | | - '.github/workflows/doc.yml'
| |_________________________________^ pull_request_target is almost always used insecurely
|
= note: audit confidence → Medium
error[unpinned-uses]: unpinned action reference
--> .github/workflows/documentation-links.yml:25:9
|
@ -183,4 +171,4 @@ error[unpinned-uses]: unpinned action reference
|
= note: audit confidence → High
78 findings (59 suppressed): 0 unknown, 0 informational, 1 low, 0 medium, 18 high
78 findings (1 ignored, 59 suppressed): 0 unknown, 0 informational, 1 low, 0 medium, 17 high

View file

@ -7,7 +7,6 @@ snapshot_kind: text
INFO zizmor::registry: skipping impostor-commit: offline audits only requested
INFO zizmor::registry: skipping ref-confusion: offline audits only requested
INFO zizmor::registry: skipping known-vulnerable-actions: offline audits only requested
INFO zizmor::registry: skipping forbidden-uses: audit not configured
INFO zizmor::registry: skipping stale-action-refs: offline audits only requested
INFO audit: zizmor: 🌈 completed .github/actions/custom-action/action.yml
INFO audit: zizmor: 🌈 completed .github/workflows/actions/custom-action/action.yml

View file

@ -1,11 +1,11 @@
---
source: crates/zizmor/tests/integration/e2e.rs
expression: "zizmor().output(OutputMode::Both).args([\"--collect=all\"]).input(input_under_test(\"e2e-menagerie\")).run()?"
snapshot_kind: text
---
INFO zizmor::registry: skipping impostor-commit: can't run without a GitHub API token
INFO zizmor::registry: skipping ref-confusion: can't run without a GitHub API token
INFO zizmor::registry: skipping known-vulnerable-actions: can't run without a GitHub API token
INFO zizmor::registry: skipping forbidden-uses: audit not configured
INFO zizmor::registry: skipping stale-action-refs: can't run without a GitHub API token
INFO audit: zizmor: 🌈 completed @@INPUT@@/.github/dummy-action-2/action.yml
INFO audit: zizmor: 🌈 completed @@INPUT@@/.github/workflows/another-dummy.yml

View file

@ -1,11 +1,11 @@
---
source: crates/zizmor/tests/integration/e2e.rs
expression: "zizmor().output(OutputMode::Both).input(input_under_test(\"e2e-menagerie\")).run()?"
snapshot_kind: text
---
INFO zizmor::registry: skipping impostor-commit: can't run without a GitHub API token
INFO zizmor::registry: skipping ref-confusion: can't run without a GitHub API token
INFO zizmor::registry: skipping known-vulnerable-actions: can't run without a GitHub API token
INFO zizmor::registry: skipping forbidden-uses: audit not configured
INFO zizmor::registry: skipping stale-action-refs: can't run without a GitHub API token
INFO audit: zizmor: 🌈 completed @@INPUT@@/.github/dummy-action-2/action.yml
INFO audit: zizmor: 🌈 completed @@INPUT@@/.github/workflows/another-dummy.yml

View file

@ -1,11 +1,11 @@
---
source: crates/zizmor/tests/integration/e2e.rs
expression: "zizmor().output(OutputMode::Both).input(input_under_test(\"pr-960-backstop\")).run()?"
snapshot_kind: text
---
INFO zizmor::registry: skipping impostor-commit: can't run without a GitHub API token
INFO zizmor::registry: skipping ref-confusion: can't run without a GitHub API token
INFO zizmor::registry: skipping known-vulnerable-actions: can't run without a GitHub API token
INFO zizmor::registry: skipping forbidden-uses: audit not configured
INFO zizmor::registry: skipping stale-action-refs: can't run without a GitHub API token
INFO audit: zizmor: 🌈 completed @@INPUT@@/action.yml
No findings to report. Good job!

View file

@ -1,6 +1,7 @@
---
source: crates/zizmor/tests/integration/snapshot.rs
expression: "zizmor().input(input_under_test(\"artipacked.yml\")).run()?"
snapshot_kind: text
---
warning[artipacked]: credential persistence through GitHub Actions artifacts
--> @@INPUT@@:18:9

View file

@ -1,6 +1,7 @@
---
source: crates/zizmor/tests/integration/snapshot.rs
expression: "zizmor().input(input_under_test(\"artipacked.yml\")).args([\"--persona=auditor\"]).run()?"
snapshot_kind: text
---
warning[artipacked]: credential persistence through GitHub Actions artifacts
--> @@INPUT@@:18:9

View file

@ -1,6 +1,7 @@
---
source: crates/zizmor/tests/integration/snapshot.rs
expression: "zizmor().input(input_under_test(\"artipacked/issue-447-repro.yml\")).args([\"--persona=auditor\"]).run()?"
snapshot_kind: text
---
warning[artipacked]: credential persistence through GitHub Actions artifacts
--> @@INPUT@@:20:9

View file

@ -1,6 +1,7 @@
---
source: crates/zizmor/tests/integration/snapshot.rs
expression: "zizmor().input(input_under_test(\"artipacked/demo-action/action.yml\")).args([\"--persona=auditor\"]).run()?"
snapshot_kind: text
---
warning[artipacked]: credential persistence through GitHub Actions artifacts
--> @@INPUT@@:9:7

View file

@ -1,6 +1,7 @@
---
source: crates/zizmor/tests/integration/snapshot.rs
expression: "zizmor().input(input_under_test(\"artipacked.yml\")).args([\"--persona=pedantic\"]).run()?"
snapshot_kind: text
---
warning[artipacked]: credential persistence through GitHub Actions artifacts
--> @@INPUT@@:18:9

View file

@ -1,6 +1,7 @@
---
source: crates/zizmor/tests/integration/snapshot.rs
expression: "zizmor().input(input_under_test(\"bot-conditions.yml\")).run()?"
snapshot_kind: text
---
error[dangerous-triggers]: use of fundamentally insecure workflow trigger
--> @@INPUT@@:1:1

View file

@ -1,6 +1,7 @@
---
source: crates/zizmor/tests/integration/snapshot.rs
expression: "zizmor().input(input_under_test(\"cache-poisoning/publisher-step.yml\")).run()?"
snapshot_kind: text
---
error[cache-poisoning]: runtime artifacts potentially vulnerable to a cache poisoning attack
--> @@INPUT@@:28:9

View file

@ -1,6 +1,7 @@
---
source: crates/zizmor/tests/integration/snapshot.rs
expression: "zizmor().input(input_under_test(\"cache-poisoning/issue-343-repro.yml\")).run()?"
snapshot_kind: text
---
error[cache-poisoning]: runtime artifacts potentially vulnerable to a cache poisoning attack
--> @@INPUT@@:5:1

View file

@ -1,6 +1,7 @@
---
source: crates/zizmor/tests/integration/snapshot.rs
expression: "zizmor().input(input_under_test(\"cache-poisoning/issue-642-repro.yml\")).run()?"
snapshot_kind: text
---
error[cache-poisoning]: runtime artifacts potentially vulnerable to a cache poisoning attack
--> @@INPUT@@:21:9

View file

@ -1,6 +1,7 @@
---
source: crates/zizmor/tests/integration/snapshot.rs
expression: "zizmor().input(input_under_test(\"cache-poisoning/caching-opt-in-boolean-toggle.yml\")).run()?"
snapshot_kind: text
---
error[cache-poisoning]: runtime artifacts potentially vulnerable to a cache poisoning attack
--> @@INPUT@@:1:1

View file

@ -1,6 +1,7 @@
---
source: crates/zizmor/tests/integration/snapshot.rs
expression: "zizmor().input(input_under_test(\"cache-poisoning/caching-opt-in-multi-value-toggle.yml\")).run()?"
snapshot_kind: text
---
error[cache-poisoning]: runtime artifacts potentially vulnerable to a cache poisoning attack
--> @@INPUT@@:1:1

View file

@ -1,6 +1,7 @@
---
source: crates/zizmor/tests/integration/snapshot.rs
expression: "zizmor().input(input_under_test(\"cache-poisoning/caching-opt-in-boolish-toggle.yml\")).run()?"
snapshot_kind: text
---
error[cache-poisoning]: runtime artifacts potentially vulnerable to a cache poisoning attack
--> @@INPUT@@:4:1

View file

@ -1,6 +1,7 @@
---
source: crates/zizmor/tests/integration/snapshot.rs
expression: "zizmor().input(input_under_test(\"excessive-permissions/issue-472-repro.yml\")).run()?"
snapshot_kind: text
---
warning[excessive-permissions]: overly broad permissions
--> @@INPUT@@:20:3

View file

@ -1,6 +1,7 @@
---
source: crates/zizmor/tests/integration/snapshot.rs
expression: "zizmor().input(input_under_test(\"excessive-permissions/reusable-workflow-call.yml\")).run()?"
snapshot_kind: text
---
warning[excessive-permissions]: overly broad permissions
--> @@INPUT@@:7:3

View file

@ -1,6 +1,7 @@
---
source: crates/zizmor/tests/integration/snapshot.rs
expression: "zizmor().input(input_under_test(\"excessive-permissions/reusable-workflow-other-triggers.yml\")).run()?"
snapshot_kind: text
---
warning[excessive-permissions]: overly broad permissions
--> @@INPUT@@:1:1

View file

@ -1,6 +1,7 @@
---
source: crates/zizmor/tests/integration/snapshot.rs
expression: "zizmor().input(input_under_test(\"excessive-permissions/issue-336-repro.yml\")).args([\"--pedantic\"]).run()?"
snapshot_kind: text
---
error[excessive-permissions]: overly broad permissions
--> @@INPUT@@:6:3

View file

@ -1,6 +1,7 @@
---
source: crates/zizmor/tests/integration/snapshot.rs
expression: "zizmor().input(input_under_test(\"excessive-permissions/workflow-read-all.yml\")).run()?"
snapshot_kind: text
---
warning[excessive-permissions]: overly broad permissions
--> @@INPUT@@:5:1

View file

@ -1,6 +1,7 @@
---
source: crates/zizmor/tests/integration/snapshot.rs
expression: "zizmor().input(input_under_test(\"excessive-permissions/workflow-write-all.yml\")).run()?"
snapshot_kind: text
---
error[excessive-permissions]: overly broad permissions
--> @@INPUT@@:5:1

View file

@ -1,6 +1,7 @@
---
source: crates/zizmor/tests/integration/snapshot.rs
expression: "zizmor().input(input_under_test(\"excessive-permissions/jobs-broaden-permissions.yml\")).run()?"
snapshot_kind: text
---
warning[excessive-permissions]: overly broad permissions
--> @@INPUT@@:8:3

View file

@ -1,6 +1,7 @@
---
source: crates/zizmor/tests/integration/snapshot.rs
expression: "zizmor().input(input_under_test(\"excessive-permissions/workflow-write-explicit.yml\")).run()?"
snapshot_kind: text
---
error[excessive-permissions]: overly broad permissions
--> @@INPUT@@:7:3

View file

@ -1,6 +1,7 @@
---
source: crates/zizmor/tests/integration/snapshot.rs
expression: "zizmor().config(input_under_test(&format!(\"forbidden-uses/configs/{config}.yml\"))).input(input_under_test(\"forbidden-uses/forbidden-uses-menagerie.yml\")).run()?"
snapshot_kind: text
---
error[forbidden-uses]: forbidden action used
--> @@INPUT@@:13:9

View file

@ -1,6 +1,7 @@
---
source: crates/zizmor/tests/integration/snapshot.rs
expression: "zizmor().config(input_under_test(&format!(\"forbidden-uses/configs/{config}.yml\"))).input(input_under_test(\"forbidden-uses/forbidden-uses-menagerie.yml\")).run()?"
snapshot_kind: text
---
error[forbidden-uses]: forbidden action used
--> @@INPUT@@:13:9

View file

@ -1,6 +1,7 @@
---
source: crates/zizmor/tests/integration/snapshot.rs
expression: "zizmor().config(input_under_test(&format!(\"forbidden-uses/configs/{config}.yml\"))).input(input_under_test(\"forbidden-uses/forbidden-uses-menagerie.yml\")).run()?"
snapshot_kind: text
---
error[forbidden-uses]: forbidden action used
--> @@INPUT@@:14:9

View file

@ -1,6 +1,7 @@
---
source: crates/zizmor/tests/integration/snapshot.rs
expression: "zizmor().config(input_under_test(&format!(\"forbidden-uses/configs/{config}.yml\"))).input(input_under_test(\"forbidden-uses/forbidden-uses-menagerie.yml\")).run()?"
snapshot_kind: text
---
error[forbidden-uses]: forbidden action used
--> @@INPUT@@:13:9

View file

@ -1,6 +1,7 @@
---
source: crates/zizmor/tests/integration/snapshot.rs
expression: "zizmor().config(input_under_test(&format!(\"forbidden-uses/configs/{config}.yml\"))).input(input_under_test(\"forbidden-uses/forbidden-uses-menagerie.yml\")).run()?"
snapshot_kind: text
---
error[forbidden-uses]: forbidden action used
--> @@INPUT@@:15:9

View file

@ -1,6 +1,7 @@
---
source: crates/zizmor/tests/integration/snapshot.rs
expression: "zizmor().input(input_under_test(\"github-env/github-path.yml\")).run()?"
snapshot_kind: text
---
error[github-env]: dangerous use of environment file
--> @@INPUT@@:17:9

View file

@ -1,6 +1,7 @@
---
source: crates/zizmor/tests/integration/snapshot.rs
expression: "zizmor().input(input_under_test(\"github-env/issue-397-repro.yml\")).run()?"
snapshot_kind: text
---
error[github-env]: dangerous use of environment file
--> @@INPUT@@:17:9

View file

@ -1,6 +1,7 @@
---
source: tests/integration/snapshot.rs
source: crates/zizmor/tests/integration/snapshot.rs
expression: "zizmor().input(input_under_test(\"github-env/action.yml\")).run()?"
snapshot_kind: text
---
error[github-env]: dangerous use of environment file
--> @@INPUT@@:10:7

View file

@ -1,6 +1,7 @@
---
source: crates/zizmor/tests/integration/snapshot.rs
expression: "zizmor().offline(true).input(input_under_test(\"several-vulnerabilities.yml\")).args([\"--persona=auditor\",\n\"--format=github\"]).run()?"
snapshot_kind: text
---
::error file=@@INPUT@@,line=5,title=excessive-permissions::several-vulnerabilities.yml:5: overly broad permissions: uses write-all permissions
::error file=@@INPUT@@,line=11,title=excessive-permissions::several-vulnerabilities.yml:11: overly broad permissions: uses write-all permissions

View file

@ -1,6 +1,7 @@
---
source: crates/zizmor/tests/integration/snapshot.rs
expression: "zizmor().input(input_under_test(\"insecure-commands.yml\")).run()?"
snapshot_kind: text
---
error[insecure-commands]: execution of insecure workflow commands is enabled
--> @@INPUT@@:11:5

View file

@ -1,6 +1,7 @@
---
source: crates/zizmor/tests/integration/snapshot.rs
expression: "zizmor().input(input_under_test(\"insecure-commands/action.yml\")).args([\"--persona=auditor\"]).run()?"
snapshot_kind: text
---
error[insecure-commands]: execution of insecure workflow commands is enabled
--> @@INPUT@@:18:7

View file

@ -1,6 +1,7 @@
---
source: crates/zizmor/tests/integration/snapshot.rs
expression: "zizmor().input(input_under_test(\"insecure-commands/issue-839-repro.yml\")).args([\"--persona=auditor\"]).run()?"
snapshot_kind: text
---
error[insecure-commands]: execution of insecure workflow commands is enabled
--> @@INPUT@@:11:5

View file

@ -1,6 +1,7 @@
---
source: crates/zizmor/tests/integration/snapshot.rs
expression: "zizmor().input(input_under_test(\"insecure-commands.yml\")).args([\"--persona=auditor\"]).run()?"
snapshot_kind: text
---
error[insecure-commands]: execution of insecure workflow commands is enabled
--> @@INPUT@@:11:5

View file

@ -1,6 +1,7 @@
---
source: crates/zizmor/tests/integration/snapshot.rs
expression: "zizmor().input(input_under_test(\"obfuscation/computed-indices.yml\")).args([\"--persona=pedantic\"]).run()?"
snapshot_kind: text
---
help[obfuscation]: obfuscated usage of GitHub Actions features
--> @@INPUT@@:14:23

View file

@ -1,6 +1,7 @@
---
source: crates/zizmor/tests/integration/snapshot.rs
expression: "zizmor().input(input_under_test(\"obfuscation.yml\")).run()?"
snapshot_kind: text
---
help[obfuscation]: obfuscated usage of GitHub Actions features
--> @@INPUT@@:13:9

Some files were not shown because too many files have changed in this diff Show more