diff --git a/iac/ql/lib/codeql/iac/azure/Pipelines.qll b/iac/ql/lib/codeql/iac/azure/Pipelines.qll index 1a7269b3679b..f5b029b95ec7 100644 --- a/iac/ql/lib/codeql/iac/azure/Pipelines.qll +++ b/iac/ql/lib/codeql/iac/azure/Pipelines.qll @@ -2,50 +2,209 @@ private import codeql.iac.YAML private import codeql.files.FileSystem module AzurePipelines { + private predicate hasPipelineBaseName(YamlDocument doc) { + doc.getFile().getBaseName() = ["azure-pipelines.yml", "azure-pipelines.yaml"] + } + + private predicate hasPipelineShape(YamlMapping doc) { + exists(doc.lookup("steps")) or + exists(doc.lookup("jobs")) or + exists(doc.lookup("stages")) or + exists(doc.lookup("extends")) + } + /** - * Azure DevOps Pipeline file. + * Holds if `doc` is a GitHub Actions workflow rather than an Azure DevOps + * pipeline. + * + * GitHub Actions workflows live under `.github/workflows/` and are required + * to declare an `on:` trigger, whereas Azure DevOps pipelines are triggered + * with `trigger:`/`pr:` and never use a top-level `on:` key. Both formats + * share `jobs:`/`steps:` keys, so without this exclusion a workflow would be + * misclassified as a pipeline by `hasPipelineShape`. + */ + private predicate isGitHubActionsWorkflow(YamlDocument doc) { + doc.getFile().getRelativePath().matches("%.github/workflows/%") + or + exists(doc.(YamlMapping).lookup("on")) + } + + /** + * Azure DevOps Pipeline file or referenced template. */ class Document extends YamlNode, YamlDocument, YamlMapping { Document() { - // Check the filename - this.getFile().getBaseName() = ["azure-pipelines.yml", "azure-pipelines.yaml"] + this.getFile().getExtension() = ["yml", "yaml"] and + (hasPipelineBaseName(this) or hasPipelineShape(this)) and + not isGitHubActionsWorkflow(this) } override string toString() { result = "Azure DevOps Pipeline" } /** - * Get the pipeline pool. + * Gets the top-level trigger-like entry named `name`, if any. + */ + YamlValue getTrigger(string name) { result = this.lookup(name) } + + /** + * Gets the pipeline pool, if any. */ Pool getPool() { result = this.lookup("pool") } /** - * Get the pipeline variables. + * Gets a pipeline parameter, if any. + */ + Parameter getAParameter() { result = this.lookup("parameters").getAChild() } + + /** + * Gets a pipeline variable, if any. */ - Variable getVariables() { result = this.lookup("variables").getAChild() } + Variable getAVariable() { result = this.lookup("variables").getAChild() } /** - * Get the pipeline variable with the given name. + * Gets the pipeline variable with the given name, if any. */ YamlValue getVariable(string name) { - exists(Variable var | var = this.getVariables() and var.getName() = name | + exists(Variable var | var = this.getAVariable() and var.getName() = name | result = var.getValue() ) } /** - * Get the pipeline steps. + * Gets a pipeline step, if any. + */ + Step getAStep() { result.getEnclosingDocument() = this } + + /** + * Gets a pipeline stage, if any. + */ + Stage getAStage() { result = this.lookup("stages").getAChild() } + + /** + * Gets a pipeline job, if any. + */ + Job getAJob() { + result = this.lookup("jobs").getAChild() + or + result = this.getAStage().getAJob() + } + + /** + * Gets a pipeline repository resource, if any. + */ + RepositoryResource getARepositoryResource() { result.getEnclosingDocument() = this } + + /** + * Gets a pipeline resource, if any. + */ + PipelineResource getAPipelineResource() { result.getEnclosingDocument() = this } + + /** + * Gets a pipeline task step, if any. + */ + Task getATaskStep() { result = this.getAStep().(Task) } + + /** + * Gets a pipeline script step, if any. + */ + Script getAScriptStep() { result = this.getAStep().(Script) } + } + + /** + * Azure DevOps Pipeline parameter. + */ + class Parameter extends YamlNode, YamlMapping { + Parameter() { exists(Document document | document.lookup("parameters").getChild(_) = this) } + + override string toString() { result = this.getName() } + + /** + * Gets the parameter name. + */ + string getName() { result = yamlToString(this.lookup("name")) } + + /** + * Gets the parameter type, if any. + */ + string getType() { result = yamlToString(this.lookup("type")) } + + /** + * Gets the parameter default value, if any. + */ + YamlValue getDefault() { result = this.lookup("default") } + + /** + * Gets an allowed value for the parameter, if any. + */ + YamlValue getAnAllowedValue() { result = this.lookup("values").getAChild() } + } + + /** + * Azure DevOps Pipeline stage. + */ + class Stage extends YamlNode, YamlMapping { + Stage() { exists(Document document | document.lookup("stages").getAChildNode() = this) } + + override string toString() { result = this.getName() } + + /** + * Gets the stage name. + */ + string getName() { result = yamlToString(this.lookup("stage")) } + + /** + * Gets a job in the stage, if any. + */ + Job getAJob() { result = this.lookup("jobs").getAChild() } + + /** + * Gets the stage condition, if any. + */ + YamlValue getCondition() { result = this.lookup("condition") } + } + + /** + * Azure DevOps Pipeline job. + */ + class Job extends YamlNode, YamlMapping { + Job() { + exists(Document document | document.lookup("jobs").getAChildNode() = this) + or + exists(Stage stage | stage.lookup("jobs").getAChildNode() = this) + } + + override string toString() { result = this.getName() } + + /** + * Gets the job name. + */ + string getName() { + result = yamlToString(this.lookup("job")) + or + result = yamlToString(this.lookup("deployment")) + } + + /** + * Gets the job pool, if any. */ - Step getSteps() { result = this.lookup("steps").getAChild() } + Pool getPool() { result = this.lookup("pool") } /** - * Get the pipeline task steps. + * Gets a step in the job, if any. */ - Task getTaskSteps() { result = this.getSteps().(Task) } + Step getAStep() { result = this.lookup("steps").getAChild() } /** - * Get the pipeline script steps. + * Gets the job condition, if any. */ - Script getScriptSteps() { result = this.getSteps().(Script) } + YamlValue getCondition() { result = this.lookup("condition") } + } + + /** + * Azure DevOps Pipeline deployment job. + */ + class DeploymentJob extends Job { + DeploymentJob() { exists(this.lookup("deployment")) } } /** @@ -54,24 +213,26 @@ module AzurePipelines { * https://learn.microsoft.com/en-us/azure/devops/pipelines/yaml-schema/pool */ class Pool extends YamlNode, YamlMapping { - private Document pipeline; - - Pool() { pipeline.lookup("pool") = this } + Pool() { + exists(Document document | document.lookup("pool") = this) + or + exists(Job job | job.lookup("pool") = this) + } /** - * Get the pool name. + * Gets the pool name, if any. */ string getName() { result = yamlToString(this.lookup("name")) } /** - * Get the pool VM image. + * Gets the pool VM image, if any. */ string getVmImage() { result = yamlToString(this.lookup("vmImage")) } /** - * Get the pool demands. + * Gets the pool demands, if any. */ - string getDemands() { result = yamlToString(this.lookup("demands")) } + string getADemand() { result = yamlToString(this.lookup("demands")) } } /** @@ -80,11 +241,15 @@ module AzurePipelines { * https://learn.microsoft.com/en-us/azure/devops/pipelines/process/variables */ class Variable extends YamlNode, YamlMapping { - private Document document; - - Variable() { document.lookup("variables").getChild(_) = this } + Variable() { + exists(Document document | document.lookup("variables").getChild(_) = this) + or + exists(Stage stage | stage.lookup("variables").getChild(_) = this) + or + exists(Job job | job.lookup("variables").getChild(_) = this) + } - override string toString() { result = "Variable '" + this.getName() + "'" } + override string toString() { result = this.getName() } /** * Get the variable name. @@ -92,7 +257,7 @@ module AzurePipelines { string getName() { result = yamlToString(this.lookup("name")) } /** - * Get the variable value. + * Gets the variable value, if any. */ YamlValue getValue() { result = this.lookup("value") } } @@ -103,24 +268,45 @@ module AzurePipelines { * https://learn.microsoft.com/en-us/azure/devops/pipelines/yaml-schema/steps */ class Step extends YamlNode, YamlMapping { - private Document pipeline; + Step() { + exists(Document document | document.lookup("steps").getAChildNode() = this) + or + exists(Job job | job.lookup("steps").getAChildNode() = this) + } - Step() { pipeline.lookup("steps").getAChildNode() = this } + override string toString() { result = this.getDisplayName() } - override string toString() { result = "Azure DevOps Pipeline step" } + /** + * Gets the enclosing Azure DevOps Pipeline document. + */ + Document getEnclosingDocument() { + result.lookup("steps").getAChildNode() = this + or + this.getFile() = result.getFile() + } /** - * Get the step display name. + * Gets the step display name, if any. */ - string displayName() { result = yamlToString(this.lookup("displayName")) } + string getDisplayName() { result = yamlToString(this.lookup("displayName")) } /** - * Get the step type based on the presence of a `task` or `script` key. + * Gets the step type based on its defining key, if any. */ string getType() { exists(this.lookup("task")) and result = "task" or exists(this.lookup("script")) and result = "script" + or + exists(this.lookup("bash")) and result = "bash" + or + exists(this.lookup("powershell")) and result = "powershell" + or + exists(this.lookup("pwsh")) and result = "pwsh" + or + exists(this.lookup("checkout")) and result = "checkout" + or + exists(this.lookup("template")) and result = "template" } } @@ -141,6 +327,9 @@ module AzurePipelines { TaskInputs() { task.lookup("inputs") = this } + /** + * Gets the input named `name`, if any. + */ YamlValue getInput(string name) { result = this.lookup(name) } } @@ -148,6 +337,115 @@ module AzurePipelines { * Azure DevOps Pipeline script step. */ class Script extends Step { - Script() { this.getType() = "script" } + Script() { this.getType() = ["script", "bash", "powershell", "pwsh"] } + + /** + * Gets the script step kind. + */ + string getScriptKind() { result = this.getType() } + + /** + * Gets the inline script content. + */ + YamlValue getScriptContent() { result = this.lookup(this.getScriptKind()) } + } + + /** + * Azure DevOps Pipeline checkout step. + */ + class Checkout extends Step { + Checkout() { this.getType() = "checkout" } + + /** + * Gets the checkout target. + */ + string getRepository() { result = yamlToString(this.lookup("checkout")) } + + /** + * Gets the `persistCredentials` setting, if any. + */ + YamlValue getPersistCredentials() { result = this.lookup("persistCredentials") } + } + + /** + * Azure DevOps Pipeline template step. + */ + class TemplateStep extends Step { + TemplateStep() { this.getType() = "template" } + + /** + * Gets the referenced template path. + */ + string getTemplate() { result = yamlToString(this.lookup("template")) } + } + + /** + * Azure DevOps repository resource. + */ + class RepositoryResource extends YamlNode, YamlMapping { + RepositoryResource() { + exists(Document document | + document.lookup("resources").(YamlMapping).lookup("repositories").getAChildNode() = this + ) + } + + override string toString() { result = "Repository resource '" + this.getAlias() + "'" } + + /** + * Gets the enclosing Azure DevOps Pipeline document. + */ + Document getEnclosingDocument() { + result.lookup("resources").(YamlMapping).lookup("repositories").getAChildNode() = this + } + + /** + * Gets the resource alias. + */ + string getAlias() { result = yamlToString(this.lookup("repository")) } + + /** + * Gets the repository name. + */ + string getName() { result = yamlToString(this.lookup("name")) } + + /** + * Gets the referenced revision. + */ + string getRef() { result = yamlToString(this.lookup("ref")) } + } + + /** + * Azure DevOps pipeline resource. + */ + class PipelineResource extends YamlNode, YamlMapping { + PipelineResource() { + exists(Document document | + document.lookup("resources").(YamlMapping).lookup("pipelines").getAChildNode() = this + ) + } + + override string toString() { result = "Pipeline resource '" + this.getAlias() + "'" } + + /** + * Gets the enclosing Azure DevOps Pipeline document. + */ + Document getEnclosingDocument() { + result.lookup("resources").(YamlMapping).lookup("pipelines").getAChildNode() = this + } + + /** + * Gets the resource alias. + */ + string getAlias() { result = yamlToString(this.lookup("pipeline")) } + + /** + * Gets the source pipeline. + */ + string getSource() { result = yamlToString(this.lookup("source")) } + + /** + * Gets the branch selector + */ + string getBranch() { result = yamlToString(this.lookup("branch")) } } } diff --git a/iac/ql/test/library-tests/azure/pipelines/AST.expected b/iac/ql/test/library-tests/azure/pipelines/AST.expected index f6c44af8a897..4044767857e3 100644 --- a/iac/ql/test/library-tests/azure/pipelines/AST.expected +++ b/iac/ql/test/library-tests/azure/pipelines/AST.expected @@ -1,15 +1,46 @@ adopipeline -| azure-pipelines.yml:1:1:33:26 | Azure DevOps Pipeline | +| azure-pipelines.yml:1:1:85:38 | Azure DevOps Pipeline | +| build-template.yml:1:1:11:33 | Azure DevOps Pipeline | +adopipelineParameter +| azure-pipelines.yml:8:5:13:13 | Parameter 'target' | +| build-template.yml:2:5:4:17 | Parameter 'target' | adopipelineVariable -| azure-pipelines.yml:16:5:17:24 | Variable 'one' | +| azure-pipelines.yml:38:5:39:24 | Variable 'one' | +adopipelineStage +| azure-pipelines.yml:65:5:77:40 | Stage 'deploy' | +adopipelineJob +| azure-pipelines.yml:67:9:77:40 | Job 'deploy_web' | +| azure-pipelines.yml:80:5:85:38 | Job 'lint' | +adopipelineDeploymentJob +| azure-pipelines.yml:67:9:77:40 | Job 'deploy_web' | adopipelineSteps -| azure-pipelines.yml:20:5:25:2 | Azure DevOps Pipeline step | -| azure-pipelines.yml:25:5:30:2 | Azure DevOps Pipeline step | -| azure-pipelines.yml:30:5:33:26 | Azure DevOps Pipeline step | +| azure-pipelines.yml:42:5:45:2 | Azure DevOps Pipeline step | +| azure-pipelines.yml:45:5:49:2 | Azure DevOps Pipeline step | +| azure-pipelines.yml:49:5:54:2 | Azure DevOps Pipeline step | +| azure-pipelines.yml:54:5:59:2 | Azure DevOps Pipeline step | +| azure-pipelines.yml:59:5:62:26 | Azure DevOps Pipeline step | +| azure-pipelines.yml:84:9:85:6 | Azure DevOps Pipeline step | +| azure-pipelines.yml:85:9:85:38 | Azure DevOps Pipeline step | +| build-template.yml:7:5:8:2 | Azure DevOps Pipeline step | +| build-template.yml:8:5:11:33 | Azure DevOps Pipeline step | adopipelinePool -| azure-pipelines.yml:5:3:5:25 | vmImage ... -latest | +| azure-pipelines.yml:27:3:27:25 | vmImage ... -latest | +| azure-pipelines.yml:69:11:70:8 | name: PrivatePool | +| azure-pipelines.yml:82:7:83:4 | vmImage ... -latest | adopipelineTask -| azure-pipelines.yml:20:5:25:2 | Azure DevOps Pipeline step | +| azure-pipelines.yml:49:5:54:2 | Azure DevOps Pipeline step | +| build-template.yml:8:5:11:33 | Azure DevOps Pipeline step | adopipelineScript -| azure-pipelines.yml:25:5:30:2 | Azure DevOps Pipeline step | -| azure-pipelines.yml:30:5:33:26 | Azure DevOps Pipeline step | +| azure-pipelines.yml:54:5:59:2 | Azure DevOps Pipeline step | +| azure-pipelines.yml:59:5:62:26 | Azure DevOps Pipeline step | +| azure-pipelines.yml:84:9:85:6 | Azure DevOps Pipeline step | +| azure-pipelines.yml:85:9:85:38 | Azure DevOps Pipeline step | +| build-template.yml:7:5:8:2 | Azure DevOps Pipeline step | +adopipelineCheckout +| azure-pipelines.yml:42:5:45:2 | Azure DevOps Pipeline step | +adopipelineTemplateStep +| azure-pipelines.yml:45:5:49:2 | Azure DevOps Pipeline step | +adopipelineRepositoryResource +| azure-pipelines.yml:17:7:21:2 | Repository resource 'templates' | +adopipelinePipelineResource +| azure-pipelines.yml:22:7:24:30 | Pipeline resource 'build' | diff --git a/iac/ql/test/library-tests/azure/pipelines/AST.ql b/iac/ql/test/library-tests/azure/pipelines/AST.ql index ca02be0e106d..2f558452fb1e 100644 --- a/iac/ql/test/library-tests/azure/pipelines/AST.ql +++ b/iac/ql/test/library-tests/azure/pipelines/AST.ql @@ -2,8 +2,16 @@ private import iac query predicate adopipeline(AzurePipelines::Document n) { any() } +query predicate adopipelineParameter(AzurePipelines::Parameter n) { any() } + query predicate adopipelineVariable(AzurePipelines::Variable n) { any() } +query predicate adopipelineStage(AzurePipelines::Stage n) { any() } + +query predicate adopipelineJob(AzurePipelines::Job n) { any() } + +query predicate adopipelineDeploymentJob(AzurePipelines::DeploymentJob n) { any() } + query predicate adopipelineSteps(AzurePipelines::Step n) { any() } query predicate adopipelinePool(AzurePipelines::Pool n) { any() } @@ -11,3 +19,11 @@ query predicate adopipelinePool(AzurePipelines::Pool n) { any() } query predicate adopipelineTask(AzurePipelines::Task n) { any() } query predicate adopipelineScript(AzurePipelines::Script n) { any() } + +query predicate adopipelineCheckout(AzurePipelines::Checkout n) { any() } + +query predicate adopipelineTemplateStep(AzurePipelines::TemplateStep n) { any() } + +query predicate adopipelineRepositoryResource(AzurePipelines::RepositoryResource n) { any() } + +query predicate adopipelinePipelineResource(AzurePipelines::PipelineResource n) { any() } diff --git a/iac/ql/test/library-tests/azure/pipelines/azure-pipelines.yml b/iac/ql/test/library-tests/azure/pipelines/azure-pipelines.yml index 47741c915d24..13d9493adacf 100644 --- a/iac/ql/test/library-tests/azure/pipelines/azure-pipelines.yml +++ b/iac/ql/test/library-tests/azure/pipelines/azure-pipelines.yml @@ -1,6 +1,28 @@ trigger: - main +pr: + - main + +parameters: + - name: target + type: string + default: dev + values: + - dev + - prod + +resources: + repositories: + - repository: templates + type: git + name: Shared/Templates + ref: refs/heads/main + pipelines: + - pipeline: build + source: BuildPipeline + branch: refs/heads/main + pool: vmImage: ubuntu-latest strategy: @@ -17,6 +39,13 @@ variables: value: initialValue steps: + - checkout: self + persistCredentials: true + + - template: build-template.yml + parameters: + target: ${{ parameters.target }} + - task: UsePythonVersion@0 inputs: versionSpec: "$(python.version)" @@ -31,3 +60,26 @@ steps: pip install pytest pytest-azurepipelines pytest displayName: "pytest" + +stages: + - stage: deploy + jobs: + - deployment: deploy_web + pool: + name: PrivatePool + environment: prod + strategy: + runOnce: + deploy: + steps: + - pwsh: | + ./deploy.ps1 -Target ${{ parameters.target }} + displayName: "Deploy" + +jobs: + - job: lint + pool: + vmImage: windows-latest + steps: + - bash: echo "lint" + - powershell: Write-Host "lint" diff --git a/iac/ql/test/library-tests/azure/pipelines/build-template.yml b/iac/ql/test/library-tests/azure/pipelines/build-template.yml new file mode 100644 index 000000000000..2f83ac5d0df0 --- /dev/null +++ b/iac/ql/test/library-tests/azure/pipelines/build-template.yml @@ -0,0 +1,11 @@ +parameters: + - name: target + type: string + default: dev + +steps: + - script: echo "building ${{ parameters.target }}" + - task: Bash@3 + inputs: + targetType: inline + script: echo "task script" diff --git a/iac/ql/test/library-tests/azure/pipelines/github-workflow.yml b/iac/ql/test/library-tests/azure/pipelines/github-workflow.yml new file mode 100644 index 000000000000..8a9c64405d4b --- /dev/null +++ b/iac/ql/test/library-tests/azure/pipelines/github-workflow.yml @@ -0,0 +1,11 @@ +name: CI +on: + push: + branches: + - main +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: echo "build"