use crate::error::RepoLensError;
use crate::config::Config;
use crate::providers::github::GitHubProvider;
use crate::rules::engine::RuleCategory;
use crate::rules::results::{Finding, Severity};
use crate::scanner::Scanner;
pub struct SecurityRules;
#[async_trait::async_trait]
impl RuleCategory for SecurityRules {
fn name(&self) -> &'static str {
"security"
}
async fn run(&self, scanner: &Scanner, config: &Config) -> Result<Vec<Finding>, RepoLensError> {
let mut findings = Vec::new();
if config.is_rule_enabled("security/dependencies") {
findings.extend(check_dependencies(scanner).await?);
}
if config.is_rule_enabled("security/branch-protection") {
findings.extend(check_branch_protection(scanner).await?);
}
if config.is_rule_enabled("security/vulnerability-alerts") {
findings.extend(check_vulnerability_alerts().await?);
}
if config.is_rule_enabled("security/dependabot-updates") {
findings.extend(check_dependabot_updates().await?);
}
if config.is_rule_enabled("security/secret-scanning") {
findings.extend(check_secret_scanning().await?);
}
if config.is_rule_enabled("security/push-protection") {
findings.extend(check_push_protection().await?);
}
if config.is_rule_enabled("security/actions-permissions") {
findings.extend(check_actions_permissions().await?);
}
if config.is_rule_enabled("security/workflow-permissions") {
findings.extend(check_workflow_permissions().await?);
}
if config.is_rule_enabled("security/fork-pr-approval") {
findings.extend(check_fork_pr_approval().await?);
}
if config.is_rule_enabled("security/access-control") {
findings.extend(check_access_control().await?);
}
if config.is_rule_enabled("security/infrastructure") {
findings.extend(check_infrastructure().await?);
}
Ok(findings)
}
}
async fn check_dependencies(scanner: &Scanner) -> Result<Vec<Finding>, RepoLensError> {
let mut findings = Vec::new();
let _lock_files = [
("package-lock.json", "npm"),
("yarn.lock", "Yarn"),
("pnpm-lock.yaml", "pnpm"),
("Cargo.lock", "Cargo"),
("Gemfile.lock", "Bundler"),
("poetry.lock", "Poetry"),
("Pipfile.lock", "Pipenv"),
("composer.lock", "Composer"),
("go.sum", "Go modules"),
];
let package_files = [
("package.json", "package-lock.json"),
("Cargo.toml", "Cargo.lock"),
("Gemfile", "Gemfile.lock"),
("pyproject.toml", "poetry.lock"),
("Pipfile", "Pipfile.lock"),
("composer.json", "composer.lock"),
("go.mod", "go.sum"),
];
for (package_file, lock_file) in package_files {
if scanner.file_exists(package_file) && !scanner.file_exists(lock_file) {
findings.push(
Finding::new(
"SECURITY002",
"security",
Severity::Warning,
format!("Lock file {} is missing", lock_file),
)
.with_description(
"Lock files ensure reproducible builds and protect against supply chain attacks."
)
.with_remediation(
"Generate the lock file by running your package manager's install command."
)
);
}
}
let version_managers = [
(".nvmrc", "Node.js version"),
(".node-version", "Node.js version"),
(".python-version", "Python version"),
(".ruby-version", "Ruby version"),
("rust-toolchain.toml", "Rust toolchain"),
];
let has_any_version_file = version_managers.iter().any(|(f, _)| scanner.file_exists(f));
let is_node = scanner.file_exists("package.json");
let is_python =
scanner.file_exists("pyproject.toml") || scanner.file_exists("requirements.txt");
let is_ruby = scanner.file_exists("Gemfile");
let is_rust = scanner.file_exists("Cargo.toml");
if !has_any_version_file && (is_node || is_python || is_ruby || is_rust) {
findings.push(
Finding::new(
"SECURITY003",
"security",
Severity::Info,
"No runtime version file found",
)
.with_description(
"Specifying runtime versions (e.g., .nvmrc, .python-version) ensures consistent development environments."
)
);
}
Ok(findings)
}
async fn check_branch_protection(scanner: &Scanner) -> Result<Vec<Finding>, RepoLensError> {
let mut findings = Vec::new();
let settings_path = ".github/settings.yml";
if !scanner.file_exists(settings_path) {
findings.push(
Finding::new(
"SEC007",
"security",
Severity::Info,
"GitHub settings file (.github/settings.yml) is absent",
)
.with_description(
"The .github/settings.yml file allows you to configure repository settings, \
including branch protection rules, as code using the probot/settings app."
)
.with_remediation(
"Consider adding a .github/settings.yml file to manage repository settings as code. \
See https://probot.github.io/apps/settings/ for more information."
)
);
return Ok(findings);
}
let content = match scanner.read_file(settings_path) {
Ok(c) => c,
Err(_) => return Ok(findings),
};
let settings: serde_yaml::Value = match serde_yaml::from_str(&content) {
Ok(v) => v,
Err(_) => {
return Ok(findings);
}
};
let branches = settings.get("branches");
if branches.is_none() {
findings.push(
Finding::new(
"SEC008",
"security",
Severity::Warning,
"No branch protection rules defined in settings.yml",
)
.with_location(settings_path)
.with_description(
"Branch protection rules help prevent accidental force pushes, \
require code reviews, and enforce status checks before merging.",
)
.with_remediation(
"Add a 'branches:' section to your .github/settings.yml to configure \
branch protection for important branches like main/master.",
),
);
return Ok(findings);
}
let branches_arr = match branches.and_then(|b| b.as_sequence()) {
Some(arr) => arr,
None => return Ok(findings),
};
let mut has_pr_reviews = false;
let mut has_status_checks = false;
for branch in branches_arr {
if let Some(protection) = branch.get("protection") {
if protection.get("required_pull_request_reviews").is_some() {
has_pr_reviews = true;
}
if protection.get("required_status_checks").is_some() {
has_status_checks = true;
}
}
}
if !has_pr_reviews {
findings.push(
Finding::new(
"SEC009",
"security",
Severity::Warning,
"required_pull_request_reviews not configured in branch protection",
)
.with_location(settings_path)
.with_description(
"Requiring pull request reviews ensures that changes are reviewed \
by at least one other team member before merging.",
)
.with_remediation(
"Add 'required_pull_request_reviews' to your branch protection settings in \
.github/settings.yml. Example:\n\
branches:\n\
- name: main\n\
protection:\n\
required_pull_request_reviews:\n\
required_approving_review_count: 1",
),
);
}
if !has_status_checks {
findings.push(
Finding::new(
"SEC010",
"security",
Severity::Warning,
"required_status_checks not configured in branch protection",
)
.with_location(settings_path)
.with_description(
"Requiring status checks ensures that CI/CD pipelines pass \
before changes can be merged.",
)
.with_remediation(
"Add 'required_status_checks' to your branch protection settings in \
.github/settings.yml. Example:\n\
branches:\n\
- name: main\n\
protection:\n\
required_status_checks:\n\
strict: true\n\
contexts:\n\
- ci",
),
);
}
Ok(findings)
}
async fn check_vulnerability_alerts() -> Result<Vec<Finding>, RepoLensError> {
let mut findings = Vec::new();
if !GitHubProvider::is_available() {
return Ok(findings);
}
let provider = match GitHubProvider::new() {
Ok(p) => p,
Err(_) => return Ok(findings),
};
match provider.has_vulnerability_alerts() {
Ok(enabled) => {
if !enabled {
findings.push(
Finding::new(
"SEC011",
"security",
Severity::Warning,
"Vulnerability alerts are disabled",
)
.with_description(
"GitHub vulnerability alerts notify you when dependencies in your repository \
have known security vulnerabilities. This helps you stay informed about \
potential security issues."
)
.with_remediation(
"Enable vulnerability alerts in your repository settings: \
Settings > Security & analysis > Dependabot alerts."
),
);
}
}
Err(_) => {
}
}
Ok(findings)
}
async fn check_dependabot_updates() -> Result<Vec<Finding>, RepoLensError> {
let mut findings = Vec::new();
if !GitHubProvider::is_available() {
return Ok(findings);
}
let provider = match GitHubProvider::new() {
Ok(p) => p,
Err(_) => return Ok(findings),
};
match provider.has_dependabot_security_updates() {
Ok(enabled) => {
if !enabled {
findings.push(
Finding::new(
"SEC012",
"security",
Severity::Warning,
"Dependabot security updates are disabled",
)
.with_description(
"Dependabot security updates automatically create pull requests to update \
dependencies with known vulnerabilities. This helps keep your project secure \
with minimal manual effort."
)
.with_remediation(
"Enable Dependabot security updates in your repository settings: \
Settings > Security & analysis > Dependabot security updates."
),
);
}
}
Err(_) => {
}
}
Ok(findings)
}
async fn check_secret_scanning() -> Result<Vec<Finding>, RepoLensError> {
let mut findings = Vec::new();
if !GitHubProvider::is_available() {
return Ok(findings);
}
let provider = match GitHubProvider::new() {
Ok(p) => p,
Err(_) => return Ok(findings),
};
match provider.get_secret_scanning() {
Ok(settings) => {
if !settings.enabled {
findings.push(
Finding::new(
"SEC013",
"security",
Severity::Info,
"Secret scanning is disabled",
)
.with_description(
"Secret scanning detects secrets (like API keys, tokens, and passwords) \
that have been accidentally committed to your repository. Enabling this \
feature helps prevent credential exposure."
)
.with_remediation(
"Enable secret scanning in your repository settings: \
Settings > Security & analysis > Secret scanning. \
Note: This feature may require GitHub Advanced Security for private repos."
),
);
}
}
Err(_) => {
}
}
Ok(findings)
}
async fn check_push_protection() -> Result<Vec<Finding>, RepoLensError> {
let mut findings = Vec::new();
if !GitHubProvider::is_available() {
return Ok(findings);
}
let provider = match GitHubProvider::new() {
Ok(p) => p,
Err(_) => return Ok(findings),
};
match provider.get_secret_scanning() {
Ok(settings) => {
if settings.enabled && !settings.push_protection_enabled {
findings.push(
Finding::new(
"SEC014",
"security",
Severity::Info,
"Push protection is disabled",
)
.with_description(
"Push protection prevents secrets from being pushed to your repository \
by blocking commits that contain detected secrets. This provides \
proactive protection against credential exposure."
)
.with_remediation(
"Enable push protection in your repository settings: \
Settings > Security & analysis > Secret scanning > Push protection. \
Note: This feature may require GitHub Advanced Security for private repos."
),
);
}
}
Err(_) => {
}
}
Ok(findings)
}
async fn check_actions_permissions() -> Result<Vec<Finding>, RepoLensError> {
let mut findings = Vec::new();
if !GitHubProvider::is_available() {
return Ok(findings);
}
let provider = match GitHubProvider::new() {
Ok(p) => p,
Err(_) => return Ok(findings),
};
match provider.get_actions_permissions() {
Ok(perms) => {
if perms.enabled {
if let Some(ref allowed) = perms.allowed_actions {
if allowed == "all" {
findings.push(
Finding::new(
"SEC015",
"security",
Severity::Warning,
"GitHub Actions allows all actions",
)
.with_description(
"Allowing all actions to run increases the risk of supply chain attacks. \
Malicious or compromised third-party actions could access your repository \
secrets and code."
)
.with_remediation(
"Restrict actions to verified creators or selected actions only: \
Settings > Actions > General > Actions permissions > \
'Allow select actions and reusable workflows'."
),
);
}
}
}
}
Err(_) => {
}
}
Ok(findings)
}
async fn check_workflow_permissions() -> Result<Vec<Finding>, RepoLensError> {
let mut findings = Vec::new();
if !GitHubProvider::is_available() {
return Ok(findings);
}
let provider = match GitHubProvider::new() {
Ok(p) => p,
Err(_) => return Ok(findings),
};
match provider.get_actions_workflow_permissions() {
Ok(perms) => {
if let Some(ref default_perms) = perms.default_workflow_permissions {
if default_perms == "write" {
findings.push(
Finding::new(
"SEC016",
"security",
Severity::Warning,
"Default workflow permissions are set to 'write'",
)
.with_description(
"When default GITHUB_TOKEN permissions are set to 'write', workflows \
have broad access to modify repository contents, packages, and more. \
Following the principle of least privilege, workflows should request \
only the permissions they need."
)
.with_remediation(
"Set default permissions to 'read' and explicitly grant write permissions \
in workflows that need them: Settings > Actions > General > \
Workflow permissions > 'Read repository contents and packages permission'."
),
);
}
}
}
Err(_) => {
}
}
Ok(findings)
}
async fn check_fork_pr_approval() -> Result<Vec<Finding>, RepoLensError> {
let mut findings = Vec::new();
if !GitHubProvider::is_available() {
return Ok(findings);
}
let provider = match GitHubProvider::new() {
Ok(p) => p,
Err(_) => return Ok(findings),
};
match provider.get_fork_pr_workflows_policy() {
Ok(requires_approval) => {
if !requires_approval {
findings.push(
Finding::new(
"SEC017",
"security",
Severity::Info,
"Fork pull request workflows may not require approval",
)
.with_description(
"By default, first-time contributors may be able to run workflows \
on fork pull requests without approval. This could allow untrusted \
code to run in your CI environment.",
)
.with_remediation(
"Configure fork PR workflow approval settings: \
Settings > Actions > General > Fork pull request workflows > \
'Require approval for all outside collaborators'.",
),
);
}
}
Err(_) => {
}
}
Ok(findings)
}
async fn check_access_control() -> Result<Vec<Finding>, RepoLensError> {
let mut findings = Vec::new();
if !GitHubProvider::is_available() {
return Ok(findings);
}
let provider = match GitHubProvider::new() {
Ok(p) => p,
Err(_) => return Ok(findings),
};
if let Ok(collaborators) = provider.list_collaborators() {
for collab in &collaborators {
if collab.permissions.admin {
findings.push(
Finding::new(
"TEAM001",
"security",
Severity::Info,
format!("Direct collaborator '{}' has admin access", collab.login),
)
.with_description(
"Direct collaborators with admin access can modify repository settings, \
manage access, and perform destructive operations.",
)
.with_remediation(
"Review if admin access is necessary. Consider using team-based access \
control for better auditability and management.",
),
);
}
if collab.user_type == "User" && collab.permissions.push && !collab.permissions.admin {
findings.push(
Finding::new(
"TEAM002",
"security",
Severity::Warning,
format!("External collaborator '{}' has push access", collab.login),
)
.with_description(
"External collaborators with push access can directly modify the codebase.",
)
.with_remediation(
"Review external collaborator access regularly. Consider requiring PR reviews \
for all changes.",
),
);
}
}
}
if let Ok(teams) = provider.list_teams() {
for team in &teams {
let has_write_access = team.permission == "push"
|| team.permission == "admin"
|| team.permission == "maintain";
if has_write_access {
findings.push(
Finding::new(
"TEAM003",
"security",
Severity::Info,
format!("Team '{}' has '{}' access", team.name, team.permission),
)
.with_description(
"Teams with write access or higher can modify the repository.",
)
.with_remediation(
"Review team membership periodically. Remove inactive members.",
),
);
}
}
}
if let Ok(keys) = provider.list_deploy_keys() {
for key in &keys {
if !key.read_only {
findings.push(
Finding::new(
"KEY001",
"security",
Severity::Warning,
format!("Deploy key '{}' has write access", key.title),
)
.with_description(
"Deploy keys with write access can push changes to the repository.",
)
.with_remediation(
"Review if write access is necessary. Use read-only keys when possible.",
),
);
}
findings.push(
Finding::new(
"KEY002",
"security",
Severity::Info,
format!("Deploy key '{}' has no expiration", key.title),
)
.with_description(
"Deploy keys don't expire automatically. Implement a key rotation policy.",
)
.with_remediation(
"Implement a regular key rotation schedule (e.g., every 90 days).",
),
);
}
}
if let Ok(installations) = provider.list_installations() {
for inst in &installations {
let has_admin = inst.permissions.administration.as_deref() == Some("write");
let has_contents_write = inst.permissions.contents.as_deref() == Some("write");
if has_admin || has_contents_write {
let app_name = inst.app_slug.as_deref().unwrap_or("Unknown app");
findings.push(
Finding::new(
"APP001",
"security",
Severity::Info,
format!("GitHub App '{}' has broad permissions", app_name),
)
.with_description(
"This GitHub App has administrative or write access to repository contents.",
)
.with_remediation(
"Review the GitHub App's permissions in Settings > Integrations.",
),
);
}
}
}
Ok(findings)
}
async fn check_infrastructure() -> Result<Vec<Finding>, RepoLensError> {
let mut findings = Vec::new();
if !GitHubProvider::is_available() {
return Ok(findings);
}
let provider = match GitHubProvider::new() {
Ok(p) => p,
Err(_) => return Ok(findings),
};
if let Ok(webhooks) = provider.list_webhooks() {
for hook in &webhooks {
let url = hook.config.url.as_deref().unwrap_or("");
if !url.is_empty() && !url.starts_with("https://") {
findings.push(
Finding::new(
"HOOK001",
"security",
Severity::Warning,
format!("Webhook '{}' uses non-HTTPS URL", hook.name),
)
.with_location(format!("Webhook ID: {}", hook.id))
.with_description("Webhooks should use HTTPS to ensure data is encrypted.")
.with_remediation("Update the webhook URL to use HTTPS."),
);
}
if hook.config.secret.is_none() {
findings.push(
Finding::new(
"HOOK002",
"security",
Severity::Warning,
format!("Webhook '{}' has no secret configured", hook.name),
)
.with_location(format!("Webhook ID: {}", hook.id))
.with_description(
"Webhooks without a secret cannot verify the authenticity of payloads.",
)
.with_remediation(
"Configure a webhook secret and validate X-Hub-Signature-256 header.",
),
);
}
if !hook.active {
findings.push(
Finding::new(
"HOOK003",
"security",
Severity::Info,
format!("Webhook '{}' is inactive", hook.name),
)
.with_location(format!("Webhook ID: {}", hook.id))
.with_description(
"Inactive webhooks may be leftover from previous integrations.",
)
.with_remediation("Review if this webhook is still needed. If not, delete it."),
);
}
}
}
if let Ok(environments) = provider.list_environments() {
for env in &environments {
let protection = match provider.get_environment_protection(&env.name) {
Ok(p) => p,
Err(_) => continue,
};
let is_production =
env.name.to_lowercase().contains("prod") || env.name.to_lowercase() == "production";
let has_protection_rules = !protection.protection_rules.is_empty();
let has_required_reviewers = protection
.protection_rules
.iter()
.any(|r| r.rule_type == "required_reviewers");
let has_branch_policy = protection.deployment_branch_policy.is_some();
if !has_protection_rules {
findings.push(
Finding::new(
"ENV001",
"security",
Severity::Info,
format!("Environment '{}' has no protection rules", env.name),
)
.with_description(
"Environments without protection rules allow any workflow to deploy.",
)
.with_remediation("Add protection rules in Settings > Environments."),
);
}
if is_production && !has_required_reviewers {
findings.push(
Finding::new(
"ENV002",
"security",
Severity::Warning,
format!(
"Production environment '{}' has no required reviewers",
env.name
),
)
.with_description(
"Production deployments should require approval from designated reviewers.",
)
.with_remediation("Add required reviewers in Settings > Environments."),
);
}
if !has_branch_policy {
findings.push(
Finding::new(
"ENV003",
"security",
Severity::Info,
format!("Environment '{}' has no branch policies", env.name),
)
.with_description(
"Environments without branch policies allow deployments from any branch.",
)
.with_remediation(
"Configure deployment branch policy in Settings > Environments.",
),
);
}
}
}
Ok(findings)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scanner::Scanner;
use std::fs;
use tempfile::TempDir;
#[tokio::test]
async fn test_check_dependencies_missing_lock_file() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
let package_json = root.join("package.json");
fs::write(&package_json, r#"{"name": "test"}"#).unwrap();
let scanner = Scanner::new(root.to_path_buf());
let findings = check_dependencies(&scanner).await.unwrap();
assert!(!findings.is_empty());
assert!(findings.iter().any(|f| f.rule_id == "SECURITY002"));
}
#[tokio::test]
async fn test_check_dependencies_no_version_file() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
let package_json = root.join("package.json");
fs::write(&package_json, r#"{"name": "test"}"#).unwrap();
let scanner = Scanner::new(root.to_path_buf());
let findings = check_dependencies(&scanner).await.unwrap();
assert!(findings.iter().any(|f| f.rule_id == "SECURITY003"));
}
#[tokio::test]
async fn test_check_branch_protection_no_settings_file() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
let scanner = Scanner::new(root.to_path_buf());
let findings = check_branch_protection(&scanner).await.unwrap();
assert_eq!(findings.len(), 1);
assert!(findings.iter().any(|f| f.rule_id == "SEC007"));
assert!(findings.iter().any(|f| f.severity == Severity::Info));
}
#[tokio::test]
async fn test_check_branch_protection_no_branches_config() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
let github_dir = root.join(".github");
fs::create_dir_all(&github_dir).unwrap();
fs::write(
github_dir.join("settings.yml"),
r#"
repository:
name: my-repo
description: A test repo
"#,
)
.unwrap();
let scanner = Scanner::new(root.to_path_buf());
let findings = check_branch_protection(&scanner).await.unwrap();
assert_eq!(findings.len(), 1);
assert!(findings.iter().any(|f| f.rule_id == "SEC008"));
}
#[tokio::test]
async fn test_check_branch_protection_no_pr_reviews() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
let github_dir = root.join(".github");
fs::create_dir_all(&github_dir).unwrap();
fs::write(
github_dir.join("settings.yml"),
r#"
branches:
- name: main
protection:
required_status_checks:
strict: true
contexts:
- ci
"#,
)
.unwrap();
let scanner = Scanner::new(root.to_path_buf());
let findings = check_branch_protection(&scanner).await.unwrap();
assert!(findings.iter().any(|f| f.rule_id == "SEC009"));
assert!(!findings.iter().any(|f| f.rule_id == "SEC010"));
}
#[tokio::test]
async fn test_check_branch_protection_no_status_checks() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
let github_dir = root.join(".github");
fs::create_dir_all(&github_dir).unwrap();
fs::write(
github_dir.join("settings.yml"),
r#"
branches:
- name: main
protection:
required_pull_request_reviews:
required_approving_review_count: 1
"#,
)
.unwrap();
let scanner = Scanner::new(root.to_path_buf());
let findings = check_branch_protection(&scanner).await.unwrap();
assert!(!findings.iter().any(|f| f.rule_id == "SEC009"));
assert!(findings.iter().any(|f| f.rule_id == "SEC010"));
}
#[tokio::test]
async fn test_check_branch_protection_both_missing() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
let github_dir = root.join(".github");
fs::create_dir_all(&github_dir).unwrap();
fs::write(
github_dir.join("settings.yml"),
r#"
branches:
- name: main
protection:
enforce_admins: true
"#,
)
.unwrap();
let scanner = Scanner::new(root.to_path_buf());
let findings = check_branch_protection(&scanner).await.unwrap();
assert!(findings.iter().any(|f| f.rule_id == "SEC009"));
assert!(findings.iter().any(|f| f.rule_id == "SEC010"));
}
#[tokio::test]
async fn test_check_branch_protection_fully_configured() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
let github_dir = root.join(".github");
fs::create_dir_all(&github_dir).unwrap();
fs::write(
github_dir.join("settings.yml"),
r#"
branches:
- name: main
protection:
required_pull_request_reviews:
required_approving_review_count: 2
dismiss_stale_reviews: true
required_status_checks:
strict: true
contexts:
- ci
- tests
enforce_admins: true
"#,
)
.unwrap();
let scanner = Scanner::new(root.to_path_buf());
let findings = check_branch_protection(&scanner).await.unwrap();
assert!(findings.is_empty());
}
#[tokio::test]
async fn test_check_branch_protection_invalid_yaml() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
let github_dir = root.join(".github");
fs::create_dir_all(&github_dir).unwrap();
fs::write(github_dir.join("settings.yml"), "invalid: yaml: content: [").unwrap();
let scanner = Scanner::new(root.to_path_buf());
let findings = check_branch_protection(&scanner).await.unwrap();
assert!(findings.is_empty());
}
#[tokio::test]
async fn test_check_branch_protection_multiple_branches() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
let github_dir = root.join(".github");
fs::create_dir_all(&github_dir).unwrap();
fs::write(
github_dir.join("settings.yml"),
r#"
branches:
- name: main
protection:
required_pull_request_reviews:
required_approving_review_count: 1
- name: develop
protection:
required_status_checks:
strict: true
contexts:
- ci
"#,
)
.unwrap();
let scanner = Scanner::new(root.to_path_buf());
let findings = check_branch_protection(&scanner).await.unwrap();
assert!(findings.is_empty());
}
#[tokio::test]
async fn test_check_vulnerability_alerts_returns_empty_when_no_github() {
let findings = check_vulnerability_alerts().await.unwrap();
assert!(findings.len() <= 1);
}
#[tokio::test]
async fn test_check_dependabot_updates_returns_empty_when_no_github() {
let findings = check_dependabot_updates().await.unwrap();
assert!(findings.len() <= 1);
}
#[tokio::test]
async fn test_check_secret_scanning_returns_empty_when_no_github() {
let findings = check_secret_scanning().await.unwrap();
assert!(findings.len() <= 1);
}
#[tokio::test]
async fn test_check_push_protection_returns_empty_when_no_github() {
let findings = check_push_protection().await.unwrap();
assert!(findings.len() <= 1);
}
#[tokio::test]
async fn test_check_actions_permissions_returns_empty_when_no_github() {
let findings = check_actions_permissions().await.unwrap();
assert!(findings.len() <= 1);
}
#[tokio::test]
async fn test_check_workflow_permissions_returns_empty_when_no_github() {
let findings = check_workflow_permissions().await.unwrap();
assert!(findings.len() <= 1);
}
#[tokio::test]
async fn test_check_fork_pr_approval_returns_empty_when_no_github() {
let findings = check_fork_pr_approval().await.unwrap();
assert!(findings.len() <= 1);
}
#[test]
fn test_sec011_finding_construction() {
let finding = Finding::new(
"SEC011",
"security",
Severity::Warning,
"Vulnerability alerts are disabled",
)
.with_description("Test description")
.with_remediation("Test remediation");
assert_eq!(finding.rule_id, "SEC011");
assert_eq!(finding.category, "security");
assert_eq!(finding.severity, Severity::Warning);
assert!(finding.description.is_some());
assert!(finding.remediation.is_some());
}
#[test]
fn test_sec012_finding_construction() {
let finding = Finding::new(
"SEC012",
"security",
Severity::Warning,
"Dependabot security updates are disabled",
);
assert_eq!(finding.rule_id, "SEC012");
assert_eq!(finding.severity, Severity::Warning);
}
#[test]
fn test_sec013_finding_construction() {
let finding = Finding::new(
"SEC013",
"security",
Severity::Info,
"Secret scanning is disabled",
);
assert_eq!(finding.rule_id, "SEC013");
assert_eq!(finding.severity, Severity::Info);
}
#[test]
fn test_sec014_finding_construction() {
let finding = Finding::new(
"SEC014",
"security",
Severity::Info,
"Push protection is disabled",
);
assert_eq!(finding.rule_id, "SEC014");
assert_eq!(finding.severity, Severity::Info);
}
#[test]
fn test_sec015_finding_construction() {
let finding = Finding::new(
"SEC015",
"security",
Severity::Warning,
"GitHub Actions allows all actions",
);
assert_eq!(finding.rule_id, "SEC015");
assert_eq!(finding.severity, Severity::Warning);
}
#[test]
fn test_sec016_finding_construction() {
let finding = Finding::new(
"SEC016",
"security",
Severity::Warning,
"Default workflow permissions are set to 'write'",
);
assert_eq!(finding.rule_id, "SEC016");
assert_eq!(finding.severity, Severity::Warning);
}
#[test]
fn test_sec017_finding_construction() {
let finding = Finding::new(
"SEC017",
"security",
Severity::Info,
"Fork pull request workflows may not require approval",
);
assert_eq!(finding.rule_id, "SEC017");
assert_eq!(finding.severity, Severity::Info);
}
#[tokio::test]
async fn test_check_access_control_returns_empty_when_no_github() {
let findings = check_access_control().await.unwrap();
let _ = findings.len();
}
#[test]
fn test_team001_finding_construction() {
let finding = Finding::new(
"TEAM001",
"security",
Severity::Info,
"Direct collaborator 'testuser' has admin access",
)
.with_description("Test description")
.with_remediation("Test remediation");
assert_eq!(finding.rule_id, "TEAM001");
assert_eq!(finding.category, "security");
assert_eq!(finding.severity, Severity::Info);
}
#[test]
fn test_team002_finding_construction() {
let finding = Finding::new(
"TEAM002",
"security",
Severity::Warning,
"External collaborator 'external-user' has push access",
);
assert_eq!(finding.rule_id, "TEAM002");
assert_eq!(finding.severity, Severity::Warning);
}
#[test]
fn test_team003_finding_construction() {
let finding = Finding::new(
"TEAM003",
"security",
Severity::Info,
"Team 'developers' has 'push' access",
);
assert_eq!(finding.rule_id, "TEAM003");
assert_eq!(finding.severity, Severity::Info);
}
#[test]
fn test_key001_finding_construction() {
let finding = Finding::new(
"KEY001",
"security",
Severity::Warning,
"Deploy key 'production-key' has write access",
);
assert_eq!(finding.rule_id, "KEY001");
assert_eq!(finding.severity, Severity::Warning);
}
#[test]
fn test_key002_finding_construction() {
let finding = Finding::new(
"KEY002",
"security",
Severity::Info,
"Deploy key 'ci-key' has no expiration",
);
assert_eq!(finding.rule_id, "KEY002");
assert_eq!(finding.severity, Severity::Info);
}
#[test]
fn test_app001_finding_construction() {
let finding = Finding::new(
"APP001",
"security",
Severity::Info,
"GitHub App 'my-app' has broad permissions",
);
assert_eq!(finding.rule_id, "APP001");
assert_eq!(finding.severity, Severity::Info);
}
#[tokio::test]
async fn test_check_infrastructure_returns_empty_when_no_github() {
let findings = check_infrastructure().await.unwrap();
let _ = findings.len();
}
#[test]
fn test_hook001_finding_construction() {
let finding = Finding::new(
"HOOK001",
"security",
Severity::Warning,
"Webhook 'web' uses non-HTTPS URL",
)
.with_location("Webhook ID: 123");
assert_eq!(finding.rule_id, "HOOK001");
assert_eq!(finding.severity, Severity::Warning);
assert!(finding.location.is_some());
}
#[test]
fn test_hook002_finding_construction() {
let finding = Finding::new(
"HOOK002",
"security",
Severity::Warning,
"Webhook 'web' has no secret configured",
);
assert_eq!(finding.rule_id, "HOOK002");
assert_eq!(finding.severity, Severity::Warning);
}
#[test]
fn test_hook003_finding_construction() {
let finding = Finding::new(
"HOOK003",
"security",
Severity::Info,
"Webhook 'web' is inactive",
);
assert_eq!(finding.rule_id, "HOOK003");
assert_eq!(finding.severity, Severity::Info);
}
#[test]
fn test_env001_finding_construction() {
let finding = Finding::new(
"ENV001",
"security",
Severity::Info,
"Environment 'staging' has no protection rules",
);
assert_eq!(finding.rule_id, "ENV001");
assert_eq!(finding.severity, Severity::Info);
}
#[test]
fn test_env002_finding_construction() {
let finding = Finding::new(
"ENV002",
"security",
Severity::Warning,
"Production environment 'production' has no required reviewers",
);
assert_eq!(finding.rule_id, "ENV002");
assert_eq!(finding.severity, Severity::Warning);
}
#[test]
fn test_env003_finding_construction() {
let finding = Finding::new(
"ENV003",
"security",
Severity::Info,
"Environment 'staging' has no branch policies",
);
assert_eq!(finding.rule_id, "ENV003");
assert_eq!(finding.severity, Severity::Info);
}
}