diff --git a/iac/ql/lib/codeql/iac/azure/Pipelines.qll b/iac/ql/lib/codeql/iac/azure/Pipelines.qll index 1a7269b3679b..50a7ac09b050 100644 --- a/iac/ql/lib/codeql/iac/azure/Pipelines.qll +++ b/iac/ql/lib/codeql/iac/azure/Pipelines.qll @@ -2,22 +2,60 @@ 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" } + /** + * Gets a top-level trigger-like entry. + */ + YamlValue getTrigger(string name) { result = this.lookup(name) } + /** * Get the pipeline pool. */ Pool getPool() { result = this.lookup("pool") } + /** + * Gets the pipeline parameters. + */ + Parameter getParameters() { result = this.lookup("parameters").getAChild() } + /** * Get the pipeline variables. */ @@ -35,7 +73,31 @@ module AzurePipelines { /** * Get the pipeline steps. */ - Step getSteps() { result = this.lookup("steps").getAChild() } + Step getSteps() { result.getEnclosingDocument() = this } + + /** + * Gets the pipeline stages. + */ + Stage getStages() { result = this.lookup("stages").getAChild() } + + /** + * Gets the pipeline jobs. + */ + Job getJobs() { + result = this.lookup("jobs").getAChild() + or + result = this.getStages().getJobs() + } + + /** + * Gets the pipeline repository resources. + */ + RepositoryResource getRepositoryResources() { result.getEnclosingDocument() = this } + + /** + * Gets the pipeline resources. + */ + PipelineResource getPipelineResources() { result.getEnclosingDocument() = this } /** * Get the pipeline task steps. @@ -48,15 +110,114 @@ module AzurePipelines { Script getScriptSteps() { result = this.getSteps().(Script) } } + /** + * Azure DevOps Pipeline parameter. + */ + class Parameter extends YamlNode, YamlMapping { + Parameter() { exists(Document document | document.lookup("parameters").getChild(_) = this) } + + override string toString() { result = "Parameter '" + this.getName() + "'" } + + /** + * Gets the parameter name. + */ + string getName() { result = yamlToString(this.lookup("name")) } + + /** + * Gets the parameter type. + */ + string getType() { result = yamlToString(this.lookup("type")) } + + /** + * Gets the parameter default value. + */ + YamlValue getDefault() { result = this.lookup("default") } + + /** + * Gets an allowed value for the parameter. + */ + YamlValue getAllowedValue() { 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 = "Stage '" + this.getName() + "'" } + + /** + * Gets the stage name. + */ + string getName() { result = yamlToString(this.lookup("stage")) } + + /** + * Gets a job in the stage. + */ + Job getJobs() { result = this.lookup("jobs").getAChild() } + + /** + * Gets the stage condition. + */ + 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 = "Job '" + this.getName() + "'" } + + /** + * Gets the job name. + */ + string getName() { + result = yamlToString(this.lookup("job")) + or + result = yamlToString(this.lookup("deployment")) + } + + /** + * Gets the job pool. + */ + Pool getPool() { result = this.lookup("pool") } + + /** + * Gets a step in the job. + */ + Step getSteps() { result = this.lookup("steps").getAChild() } + + /** + * Gets the job condition. + */ + YamlValue getCondition() { result = this.lookup("condition") } + } + + /** + * Azure DevOps Pipeline deployment job. + */ + class DeploymentJob extends Job { + DeploymentJob() { exists(this.lookup("deployment")) } + } + /** * Azure DevOps Pipeline pool. * * 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. @@ -80,9 +241,13 @@ 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() + "'" } @@ -103,12 +268,23 @@ module AzurePipelines { * https://learn.microsoft.com/en-us/azure/devops/pipelines/yaml-schema/steps */ class Step extends YamlNode, YamlMapping { - private Document pipeline; - - Step() { pipeline.lookup("steps").getAChildNode() = this } + Step() { + exists(Document document | document.lookup("steps").getAChildNode() = this) + or + exists(Job job | job.lookup("steps").getAChildNode() = this) + } override string toString() { result = "Azure DevOps Pipeline step" } + /** + * Gets the enclosing Azure DevOps Pipeline document. + */ + Document getEnclosingDocument() { + exists(Document document | document.lookup("steps").getAChildNode() = this | result = document) + or + exists(Document document | this.getFile() = document.getFile() | result = document) + } + /** * Get the step display name. */ @@ -121,6 +297,16 @@ module AzurePipelines { 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" } } @@ -148,6 +334,123 @@ 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. + */ + 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() { + exists(Document document | + document.lookup("resources").(YamlMapping).lookup("repositories").getAChildNode() = this + | + result = document + ) + } + + /** + * 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() { + exists(Document document | + document.lookup("resources").(YamlMapping).lookup("pipelines").getAChildNode() = this + | + result = document + ) + } + + /** + * 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"