468 lines
13 KiB
Rego
468 lines
13 KiB
Rego
# regal ignore:directory-package-mismatch
|
|
package mlflow
|
|
|
|
import rego.v1
|
|
|
|
deny_jobs_without_permissions contains msg if {
|
|
jobs := jobs_without_permissions(input.jobs)
|
|
count(jobs) > 0
|
|
msg := sprintf(
|
|
"The following jobs are missing permissions: %s",
|
|
[concat(", ", jobs)],
|
|
)
|
|
}
|
|
|
|
deny_top_level_permissions contains msg if {
|
|
# Workflow files only (composite actions have 'runs')
|
|
input.jobs
|
|
not input.permissions
|
|
msg := concat("", [
|
|
"Workflow must set top-level 'permissions: {}' to deny all by default. ",
|
|
"Grant least-privilege permissions per job.",
|
|
])
|
|
}
|
|
|
|
deny_top_level_permissions contains msg if {
|
|
input.jobs
|
|
input.permissions != {}
|
|
msg := "Top-level 'permissions' must be empty ({}). Grant least-privilege permissions per job instead."
|
|
}
|
|
|
|
deny_unsafe_checkout contains msg if {
|
|
# The "on" key gets transformed by conftest into "true" due to some legacy
|
|
# YAML standards, see https://stackoverflow.com/q/42283732/2148786 - so
|
|
# "on.push" becomes "true.push" which is why below statements use "true"
|
|
# instead of "on".
|
|
input["true"].pull_request_target
|
|
not safe_pull_request_target_workflow
|
|
some job in input.jobs
|
|
some step in job.steps
|
|
startswith(step.uses, "actions/checkout@")
|
|
step["with"].ref
|
|
msg := concat("", [
|
|
"Explicit checkout in a pull_request_target workflow is unsafe. ",
|
|
"See https://securitylab.github.com/resources/github-actions-preventing-pwn-requests for more information.",
|
|
])
|
|
}
|
|
|
|
# Workflows that are safe to use pull_request_target with explicit checkout
|
|
# because they restrict execution to trusted authors via author_association.
|
|
safe_pull_request_target_workflow if {
|
|
input.name == "UI Preview"
|
|
}
|
|
|
|
deny_create_app_token_without_permissions contains msg if {
|
|
some job_id, job in input.jobs
|
|
some step in job.steps
|
|
startswith(step.uses, "actions/create-github-app-token@")
|
|
not step_has_app_token_permissions(step)
|
|
msg := sprintf(
|
|
concat("", [
|
|
"actions/create-github-app-token in job '%s' must explicitly request permissions ",
|
|
"via 'permission-<name>: <level>' inputs (e.g., permission-contents: write) for ",
|
|
"least-privilege access. See ",
|
|
"https://github.com/actions/create-github-app-token#create-a-token-with-specific-permissions",
|
|
]),
|
|
[job_id],
|
|
)
|
|
}
|
|
|
|
deny_create_app_token_with_app_id contains msg if {
|
|
some job_id, job in input.jobs
|
|
some step in job.steps
|
|
startswith(step.uses, "actions/create-github-app-token@")
|
|
step["with"]["app-id"]
|
|
msg := sprintf(
|
|
"actions/create-github-app-token in job '%s' uses deprecated 'app-id'. Use 'client-id' instead.",
|
|
[job_id],
|
|
)
|
|
}
|
|
|
|
step_has_app_token_permissions(step) if {
|
|
some key, _ in step["with"]
|
|
startswith(key, "permission-")
|
|
}
|
|
|
|
deny_unnecessary_github_token contains msg if {
|
|
some job in input.jobs
|
|
some step in job.steps
|
|
startswith(step.uses, "actions/github-script@")
|
|
regex.match(`\$\{\{\s*(secrets\.GITHUB_TOKEN|github\.token)\s*\}\}`, step["with"]["github-token"])
|
|
msg := "Unnecessary use of github-token for actions/github-script."
|
|
}
|
|
|
|
deny_github_token_env_var contains msg if {
|
|
some job in input.jobs
|
|
some step in job.steps
|
|
step.env.GITHUB_TOKEN
|
|
msg := "Use GH_TOKEN instead of GITHUB_TOKEN for environment variable names."
|
|
}
|
|
|
|
deny_github_token_env_var contains msg if {
|
|
some job in input.jobs
|
|
job.env.GITHUB_TOKEN
|
|
msg := "Use GH_TOKEN instead of GITHUB_TOKEN for environment variable names."
|
|
}
|
|
|
|
deny_github_token_shorthand contains msg if {
|
|
some job in input.jobs
|
|
some step in job.steps
|
|
some key, value in step["with"]
|
|
contains_github_token(value)
|
|
msg := sprintf(
|
|
"Use secrets.GITHUB_TOKEN instead of github.token for consistency (found in step with.%s).",
|
|
[key],
|
|
)
|
|
}
|
|
|
|
deny_github_token_shorthand contains msg if {
|
|
some job in input.jobs
|
|
some step in job.steps
|
|
some key, value in step.env
|
|
contains_github_token(value)
|
|
msg := sprintf(
|
|
"Use secrets.GITHUB_TOKEN instead of github.token for consistency (found in step env.%s).",
|
|
[key],
|
|
)
|
|
}
|
|
|
|
deny_github_token_shorthand contains msg if {
|
|
some job in input.jobs
|
|
some key, value in job.env
|
|
contains_github_token(value)
|
|
msg := sprintf(
|
|
"Use secrets.GITHUB_TOKEN instead of github.token for consistency (found in job env.%s).",
|
|
[key],
|
|
)
|
|
}
|
|
|
|
deny_github_token_shorthand contains msg if {
|
|
some key, value in input.env
|
|
contains_github_token(value)
|
|
msg := sprintf(
|
|
"Use secrets.GITHUB_TOKEN instead of github.token for consistency (found in top-level env.%s).",
|
|
[key],
|
|
)
|
|
}
|
|
|
|
deny_jobs_without_timeout contains msg if {
|
|
jobs := jobs_without_timeout(input.jobs)
|
|
count(jobs) > 0
|
|
msg := sprintf(
|
|
"The following jobs are missing timeout-minutes: %s",
|
|
[concat(", ", jobs)],
|
|
)
|
|
}
|
|
|
|
deny_ubuntu_slim_long_timeout contains msg if {
|
|
jobs := ubuntu_slim_jobs_with_long_timeout(input.jobs)
|
|
count(jobs) > 0
|
|
msg := sprintf(
|
|
"The following ubuntu-slim jobs have timeout-minutes > 15: %s. ubuntu-slim has a 15-minute timeout limit.",
|
|
[concat(", ", jobs)],
|
|
)
|
|
}
|
|
|
|
deny_unpinned_actions contains msg if {
|
|
actions := unpinned_actions(input)
|
|
count(actions) > 0
|
|
msg := sprintf(
|
|
concat("", [
|
|
"The following actions are not pinned by full commit SHA: %s. ",
|
|
"Use the full commit SHA instead ",
|
|
"(e.g., actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683).",
|
|
]),
|
|
[concat(", ", actions)],
|
|
)
|
|
}
|
|
|
|
deny_missing_shell_defaults contains msg if {
|
|
# Only check workflow files (not composite actions)
|
|
# Composite actions have 'runs' instead of 'jobs'
|
|
input.jobs
|
|
not input.defaults.run.shell
|
|
msg := "Workflow must have 'defaults.run.shell: bash' to enable pipefail by default"
|
|
}
|
|
|
|
deny_wrong_shell_defaults contains msg if {
|
|
# Only check workflow files (not composite actions)
|
|
input.jobs
|
|
shell := input.defaults.run.shell
|
|
shell != "bash"
|
|
msg := sprintf(
|
|
"Workflow has 'defaults.run.shell: %s' but it must be 'bash' to enable pipefail by default",
|
|
[shell],
|
|
)
|
|
}
|
|
|
|
deny_github_script_without_retries contains msg if {
|
|
some job_id, job in input.jobs
|
|
some step in job.steps
|
|
startswith(step.uses, "actions/github-script@")
|
|
not step["with"].retries
|
|
msg := sprintf(
|
|
concat("", [
|
|
"actions/github-script in job '%s' must have 'retries' set ",
|
|
"(e.g., retries: 3) for resilience against transient GitHub API failures.",
|
|
]),
|
|
[job_id],
|
|
)
|
|
}
|
|
|
|
deny_scheduled_workflow_without_repo_check contains msg if {
|
|
input["true"].schedule
|
|
not any_job_has_repo_check(input.jobs)
|
|
msg := "Scheduled workflows must have at least one job with 'if: github.repository == ...' condition"
|
|
}
|
|
|
|
deny_push_without_branches contains msg if {
|
|
"push" in object.keys(input["true"])
|
|
not is_object(input["true"].push)
|
|
msg := "Push trigger must have a branches filter to avoid running on every branch."
|
|
}
|
|
|
|
deny_push_without_branches contains msg if {
|
|
is_object(input["true"].push)
|
|
not input["true"].push.branches
|
|
msg := "Push trigger must have a branches filter to avoid running on every branch."
|
|
}
|
|
|
|
deny_interpolation_in_run contains msg if {
|
|
some job_id, job in input.jobs
|
|
some step in job.steps
|
|
regex.match(`\$\{\{`, step.run)
|
|
msg := sprintf(
|
|
concat("", [
|
|
"Direct ${{ }} interpolation in run block of job '%s'. ",
|
|
"Use env: to pass the value and reference it as $VAR in the script.",
|
|
]),
|
|
[job_id],
|
|
)
|
|
}
|
|
|
|
deny_interpolation_in_run contains msg if {
|
|
not input.jobs
|
|
input.runs.steps
|
|
some i, step in input.runs.steps
|
|
regex.match(`\$\{\{`, step.run)
|
|
msg := sprintf(
|
|
concat("", [
|
|
"Direct ${{ }} interpolation in run block of composite action step #%d. ",
|
|
"Use env: to pass the value and reference it as $VAR in the script.",
|
|
]),
|
|
[i + 1],
|
|
)
|
|
}
|
|
|
|
deny_interpolation_in_github_script contains msg if {
|
|
some job_id, job in input.jobs
|
|
some step in job.steps
|
|
startswith(step.uses, "actions/github-script@")
|
|
regex.match(`\$\{\{`, step["with"].script)
|
|
msg := sprintf(
|
|
concat("", [
|
|
"Direct ${{ }} interpolation in github-script of job '%s'. ",
|
|
"Use env: to pass the value and reference it as process.env.VAR in the script.",
|
|
]),
|
|
[job_id],
|
|
)
|
|
}
|
|
|
|
deny_interpolation_in_github_script contains msg if {
|
|
not input.jobs
|
|
input.runs.steps
|
|
some i, step in input.runs.steps
|
|
startswith(step.uses, "actions/github-script@")
|
|
regex.match(`\$\{\{`, step["with"].script)
|
|
msg := sprintf(
|
|
concat("", [
|
|
"Direct ${{ }} interpolation in github-script of composite action step #%d. ",
|
|
"Use env: to pass the value and reference it as process.env.VAR in the script.",
|
|
]),
|
|
[i + 1],
|
|
)
|
|
}
|
|
|
|
deny_interpolation_in_job_if contains msg if {
|
|
some job_id, job in input.jobs
|
|
is_string(job["if"])
|
|
regex.match(`\$\{\{`, job["if"])
|
|
msg := sprintf(
|
|
"Unnecessary ${{ }} in 'if' of job '%s'. Use quotes instead if the expression starts with '!' (e.g., if: \"!expr\").",
|
|
[job_id],
|
|
)
|
|
}
|
|
|
|
deny_interpolation_in_step_if contains msg if {
|
|
some job_id, job in input.jobs
|
|
some step in job.steps
|
|
is_string(step["if"])
|
|
regex.match(`\$\{\{`, step["if"])
|
|
msg := sprintf(
|
|
concat("", [
|
|
"Unnecessary ${{ }} in 'if' of step '%s' in job '%s'. ",
|
|
"Use quotes instead if the expression starts with '!' (e.g., if: \"!expr\").",
|
|
]),
|
|
[step.name, job_id],
|
|
)
|
|
}
|
|
|
|
contains_github_token(value) if {
|
|
regex.match(`\$\{\{\s*github\.token\s*\}\}`, value)
|
|
}
|
|
|
|
jobs_without_permissions(jobs) := {job_id |
|
|
some job_id, job in jobs
|
|
not job.permissions
|
|
}
|
|
|
|
jobs_without_timeout(jobs) := {job_id |
|
|
some job_id, job in jobs
|
|
not job["timeout-minutes"]
|
|
}
|
|
|
|
ubuntu_slim_jobs_with_long_timeout(jobs) := {job_id |
|
|
some job_id, job in jobs
|
|
job["runs-on"] == "ubuntu-slim"
|
|
job["timeout-minutes"] > 15
|
|
}
|
|
|
|
is_step_unpinned(step) if {
|
|
not startswith(step.uses, "./")
|
|
not regex.match(`^[^@]+@[0-9a-f]{40}$`, step.uses)
|
|
}
|
|
|
|
unpinned_actions(inp) := unpinned if {
|
|
# For workflow files with jobs
|
|
inp.jobs
|
|
unpinned := {step.uses |
|
|
some job in inp.jobs
|
|
some step in job.steps
|
|
is_step_unpinned(step)
|
|
}
|
|
}
|
|
|
|
unpinned_actions(inp) := unpinned if {
|
|
# For composite action files with runs
|
|
not inp.jobs
|
|
inp.runs.steps
|
|
unpinned := {step.uses |
|
|
some step in inp.runs.steps
|
|
is_step_unpinned(step)
|
|
}
|
|
}
|
|
|
|
any_job_has_repo_check(jobs) if {
|
|
some job in jobs
|
|
job_has_repo_check(job)
|
|
}
|
|
|
|
job_has_repo_check(job) if {
|
|
regex.match(`github\.repository\s*==\s*'mlflow/`, job["if"])
|
|
}
|
|
|
|
deny_secrets_in_top_level_env contains msg if {
|
|
some key, value in input.env
|
|
contains_secret(value)
|
|
msg := sprintf(
|
|
"Secret in top-level env.%s. Move secrets to step-level env for least-privilege scope.",
|
|
[key],
|
|
)
|
|
}
|
|
|
|
deny_secrets_in_job_level_env contains msg if {
|
|
some job_id, job in input.jobs
|
|
some key, value in job.env
|
|
contains_secret(value)
|
|
msg := sprintf(
|
|
"Secret in job-level env.%s of job '%s'. Move secrets to step-level env for least-privilege scope.",
|
|
[key, job_id],
|
|
)
|
|
}
|
|
|
|
contains_secret(value) if {
|
|
regex.match(`\$\{\{\s*secrets\.`, value)
|
|
}
|
|
|
|
deny_checkout_missing_persist_credentials contains msg if {
|
|
some job_id, job in input.jobs
|
|
some step in job.steps
|
|
startswith(step.uses, "actions/checkout@")
|
|
not has_explicit_persist_credentials(step)
|
|
msg := sprintf(
|
|
"actions/checkout in job '%s' must set 'persist-credentials' explicitly (false for read-only, true if pushing).",
|
|
[job_id],
|
|
)
|
|
}
|
|
|
|
has_explicit_persist_credentials(step) if {
|
|
step["with"]["persist-credentials"] == false
|
|
}
|
|
|
|
has_explicit_persist_credentials(step) if {
|
|
step["with"]["persist-credentials"] == true
|
|
}
|
|
|
|
deny_upload_artifact_without_retention contains msg if {
|
|
some job_id, job in input.jobs
|
|
some step in job.steps
|
|
startswith(step.uses, "actions/upload-artifact@")
|
|
not step["with"]["retention-days"]
|
|
msg := sprintf(
|
|
"actions/upload-artifact in job '%s' must set 'retention-days' explicitly.",
|
|
[job_id],
|
|
)
|
|
}
|
|
|
|
deny_upload_artifact_without_if_no_files_found contains msg if {
|
|
some job_id, job in input.jobs
|
|
some step in job.steps
|
|
startswith(step.uses, "actions/upload-artifact@")
|
|
not step["with"]["if-no-files-found"]
|
|
msg := sprintf(
|
|
"actions/upload-artifact in job '%s' must set 'if-no-files-found' explicitly.",
|
|
[job_id],
|
|
)
|
|
}
|
|
|
|
deny_matrix_without_fail_fast contains msg if {
|
|
some job_id, job in input.jobs
|
|
job.strategy.matrix
|
|
not has_explicit_fail_fast(job.strategy)
|
|
msg := sprintf(
|
|
"strategy.matrix in job '%s' must set 'fail-fast' explicitly (either true or false).",
|
|
[job_id],
|
|
)
|
|
}
|
|
|
|
has_explicit_fail_fast(strategy) if {
|
|
strategy["fail-fast"] == false
|
|
}
|
|
|
|
has_explicit_fail_fast(strategy) if {
|
|
strategy["fail-fast"] == true
|
|
}
|
|
|
|
deny_mutable_install contains msg if {
|
|
some job_id, job in input.jobs
|
|
some step in job.steps
|
|
some line in split(step.run, "\n")
|
|
regex.match(`\bnpm install\b`, line)
|
|
not regex.match(`--package-lock-only\b`, line)
|
|
msg := sprintf(
|
|
"'npm install' in job '%s' modifies the lockfile. Use 'npm ci' for reproducible builds.",
|
|
[job_id],
|
|
)
|
|
}
|
|
|
|
deny_mutable_install contains msg if {
|
|
some job_id, job in input.jobs
|
|
some step in job.steps
|
|
regex.match(`(?m)^\s*yarn(\s+install)?\s*(?:#.*)?$`, step.run)
|
|
not regex.match(`\byarn install\s+--immutable\b`, step.run)
|
|
msg := sprintf(
|
|
"yarn or yarn install in job '%s' may modify the lockfile. Use 'yarn install --immutable'.",
|
|
[job_id],
|
|
)
|
|
}
|