Develop a security and compliance plan
Who and what your automation runs as, how secrets are stored and delivered without ever touching source control, and how vulnerabilities in code, dependencies and containers are caught automatically before they ship.
- Authentication & authorization — service principals vs managed identities, GitHub & Azure DevOps auth, permissions and access levels
- Managing sensitive information — Key Vault, secretless auth (OIDC), secure files, preventing leakage
- Automate security & compliance scanning — GHAS, Defender for Cloud DevOps security, container scanning, Dependabot
4.1 Authentication & authorization methods
Service principal vs managed identity
The most heavily tested comparison in the domain. Both are Microsoft Entra identities that non-human workloads use to authenticate to Azure.
| Service principal | Managed identity | |
|---|---|---|
| What it is | An app identity in Entra ID (the local instance of an app registration) | An Azure-managed service principal you never see the credentials for |
| Credentials | Client secret or certificate — you store and rotate them | None to manage — Azure rotates automatically |
| Works from | Anywhere (other clouds, on-prem, external CI) | Only from an Azure resource that supports it |
| Federation (OIDC) | ✅ workload identity federation — no secret | ✅ (system-assigned, inherently federated) |
| Types | One kind | System-assigned and user-assigned |
| System-assigned MI | User-assigned MI | |
|---|---|---|
| Lifecycle | Tied to one resource; deleted with it | Standalone resource; independent lifecycle |
| Sharing | 1:1 — one identity, one resource | Assignable to many resources |
| Pre-provision before the resource exists | No | Yes |
| Best for | A single, self-contained resource | Many resources needing the same permissions; stable identity across redeploys |
Caveat — the decision tree
Runs inside Azure and calls Azure services → managed identity (no secret to leak). Many resources need the same role, or you must pre-create the identity → user-assigned. One dedicated resource → system-assigned. Runs outside Azure (GitHub Actions, external CI, another cloud) → service principal, ideally with workload identity federation so there is still no stored secret.
GitHub authentication
| Method | Identity | Best for |
|---|---|---|
| GitHub App | Its own identity, fine-grained repo/org permissions, high rate limits | Recommended for integrations and automation |
GITHUB_TOKEN | Auto-generated per workflow run, scoped to the repo, expires at run end | Actions working within the same repository |
| Fine-grained PAT | A user, scoped to specific repos, mandatory expiry | Personal/cross-repo scripts when an App is overkill |
| Classic PAT | A user, coarse scopes | Legacy — avoid |
permissions: # start from nothing, grant only what's needed
contents: read
pull-requests: write
id-token: write # enables OIDC to Azure / cloud providers
Caveat — GITHUB_TOKEN vs App vs PAT
Same repo, from a workflow → GITHUB_TOKEN (zero setup, auto-expires). Cross-repo or a third-party integration acting as a service → GitHub App (not tied to a person, higher limits). A PAT ties automation to an individual and is the answer only when neither of the above fits. Default-deny permissions and grant per workflow.
Azure DevOps authentication
| Service connection type | Connects to |
|---|---|
| Azure Resource Manager | Azure subscriptions — prefer workload identity federation |
| Docker Registry | ACR, Docker Hub |
| Kubernetes | AKS or any cluster |
| GitHub | GitHub repositories |
| Generic / SSH | REST APIs, custom services, SSH targets |
Prefer workload identity federation for the ARM connection
An ARM service connection using workload identity federation stores no secret or certificate — Entra ID trusts a short-lived token issued to the pipeline. It is the recommended default: nothing to rotate, nothing to leak. Fall back to a service-principal secret only where federation is not supported, and use a managed identity connection when the agent runs on an Azure VM.
Permissions, roles and access levels
| Azure DevOps group | Scope |
|---|---|
| Project Collection Administrators | Organization-wide control |
| Project Administrators | Full control within a project |
| Contributors | Read/write most project resources |
| Readers | Read-only |
| Build / Release Administrators | Manage pipelines / releases |
| Azure DevOps access level | Grants |
|---|---|
| Stakeholder free | Work items, dashboards, view pipelines — no repo/code access, no Test Plans |
| Basic | Full access to Repos, Pipelines, Artifacts, Boards |
| Basic + Test Plans | Basic plus the Test Plans feature |
| Visual Studio subscriber | Entitlement via a VS subscription |
Caveat — Stakeholder access and outside collaborators
Stakeholder is the free Azure DevOps access level for non-technical people who need Boards/dashboards but not code — the answer to "give the product owner backlog access at no cost". On GitHub, an outside collaborator is a non-org member granted access to specific repos; prefer teams for manageability, and remember Deny wins in Azure DevOps while the highest permission wins in GitHub (from Domain 2).
Projects and teams in Azure DevOps
An organization contains projects; each project contains teams (each with its own backlog, boards and area/iteration paths). Use one project with multiple teams for related work that shares process and reporting; use separate projects for hard security or process boundaries. Security groups and permissions are configured at org, project and object level.
4.2 Managing sensitive information in automation
Azure Key Vault
| Object | Stores |
|---|---|
| Secrets | Passwords, connection strings, API keys |
| Keys | RSA/EC keys for encryption & signing (wrap/unwrap, sign/verify) |
| Certificates | TLS/SSL certs with lifecycle & auto-renewal (integrates with issuers) |
| Standard | Premium | |
|---|---|---|
| Secrets, certs, software-protected keys | ✅ | ✅ |
| HSM-backed keys (FIPS 140-2 Level 2) | ❌ | ✅ |
Caveat — RBAC over access policies; HSM only in Premium
Use the Azure RBAC permission model (fine-grained, auditable, roles like Key Vault Secrets User) rather than the legacy vault access policies (all-or-nothing per operation). HSM-backed keys require the Premium SKU; for a customer-managed key that Microsoft can never extract, that is the answer. Enable soft-delete and purge protection (soft-delete is on by default) so a deleted secret can be recovered.
- task: AzureKeyVault@2
inputs:
azureSubscription: 'MyServiceConnection'
KeyVaultName: 'my-kv'
SecretsFilter: 'DbPassword,ApiKey'
RunAsPreJob: true # fetch before steps run
- script: ./deploy.sh
env:
DB_PASSWORD: $(DbPassword) # masked in logs
- uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- run: |
echo "DB_PASSWORD=$(az keyvault secret show \
--vault-name my-kv --name DbPassword \
--query value -o tsv)" >> "$GITHUB_ENV"
Secretless authentication — OIDC / workload identity federation
The modern answer to "deploy to Azure from CI without storing a credential." The CI platform issues a short-lived OIDC token; Entra ID is configured to trust tokens from that specific repo/branch/environment and exchanges it for an Azure access token. No secret exists to steal or rotate.
permissions:
id-token: write # REQUIRED to request the OIDC token
contents: read
steps:
- uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
# no client-secret — Entra federated credential trusts this repo/branch
Caveat — OIDC needs id-token: write and a scoped subject
Two things the exam checks: the workflow must grant permissions: id-token: write, and the Entra federated credential's subject must match the exact repo and ref/environment (e.g. repo:org/app:ref:refs/heads/main or repo:org/app:environment:production). A mismatch is why "the OIDC login is rejected" — the fix is aligning the subject, never adding a secret back.
Secrets in pipelines and Actions
| Scope | Azure Pipelines | GitHub Actions |
|---|---|---|
| Single pipeline/repo | Secret pipeline variable (lock icon) | Repository secret |
| Shared across pipelines/repos | Variable group (optionally Key Vault-linked) | Organization secret |
| Per deployment target | Variables on the environment | Environment secret (behind required reviewers) |
steps:
- script: echo "$(publicVar)" # fine
- script: echo "$(mySecret)" # prints *** — masked, but don't do this
# Correct: map the secret into the process environment explicitly
- script: ./deploy.sh
env:
API_KEY: $(mySecret) # secrets are NOT auto-injected as env vars
Caveat — secret variables aren't auto-exposed
Unlike normal variables, secret variables are not automatically available as environment variables to scripts — you must map them in explicitly under env:. Both platforms mask registered secrets in logs (***), but masking is best-effort: transformed or base64-encoded values can slip through, so never deliberately print them. Secrets are not passed to fork PR builds.
Azure Pipelines secure files
- task: DownloadSecureFile@1
name: cert # reference by this name
inputs:
secureFile: 'signing-cert.p12'
- script: |
echo "Cert at $(cert.secureFilePath)"
# import / sign using the temp path; the file is deleted after the job
Secure files store signing certificates, provisioning profiles, key files and the like outside source control, with per-pipeline authorization. They live in the Library and are removed from the agent when the job ends.
Preventing secret leakage
| Vector | Control |
|---|---|
| Secret committed to Git | Pre-commit hooks (gitleaks, detect-secrets) + push protection |
| Secret in a PR diff | Secret scanning blocks the push; Advanced Security alerts |
| Secret printed in logs | Auto-masking of registered secrets; never echo them |
| Secret baked into an artifact/image | Scan artifacts and images; use build-time secrets, not ENV layers |
| Secret in env-var dumps | Limit diagnostic logging; use secure files/Key Vault |
| Long-lived credentials | Replace with OIDC / managed identity; rotate what remains |
Designing pipelines to not leak
Fetch secrets at the last moment (Key Vault at runtime), scope them to environments, never write them to artifacts, and prefer secretless auth so there is nothing to leak. If a secret is exposed, the response is the incident flow from Domain 2: rotate first, then rewrite history.
4.3 Automate security & compliance scanning
The shift-left model
linting · secrets"] --> PR["PR / CI
SAST · SCA · secret scan"] PR --> BUILD["Build
SBOM · license check"] BUILD --> DEP["Deploy
image scan · IaC scan"] DEP --> RUN["Runtime
DAST · CSPM · Defender"]
| Scan | Finds | When |
|---|---|---|
| SAST (static) | Vulnerabilities in your source code | PR / build (CodeQL) |
| DAST (dynamic) | Vulnerabilities in the running app | Against staging |
| SCA (composition) | Vulnerable / mis-licensed dependencies | PR / build (Dependabot, dependency review) |
| Secret scanning | Committed credentials | Push / commit |
| Container scanning | CVEs in base images and OS packages | Build / registry (Defender, Trivy) |
| IaC scanning | Insecure infrastructure config | PR (PSRule, checkov) |
The four scan categories in the skills measured
The objective names them explicitly: dependency, code, secret, and licensing scanning. Map each to a tool — dependency → Dependabot/SCA, code → CodeQL, secret → secret scanning, licensing → dependency review / SCA license policy.
GitHub Advanced Security (GHAS)
| Feature | Does |
|---|---|
| Code scanning (CodeQL) | SAST — semantic analysis for security bugs |
| Secret scanning + push protection | Detects committed secrets; push protection blocks the push |
| Dependency review | Shows vulnerable/newly-added dependencies in the PR diff |
| Dependabot alerts | Notifies on dependencies with known CVEs |
| Dependabot security / version updates | Opens PRs to patch or refresh dependencies |
name: CodeQL
on:
push: { branches: [main] }
pull_request: { branches: [main] }
schedule: [ { cron: '0 2 * * 1' } ]
jobs:
analyze:
runs-on: ubuntu-latest
permissions:
security-events: write # upload results to the Security tab
contents: read
strategy:
matrix:
language: [javascript, python, csharp]
steps:
- uses: actions/checkout@v4
- uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
queries: security-extended
- uses: github/codeql-action/autobuild@v3
- uses: github/codeql-action/analyze@v3
Caveat — GHAS for Azure DevOps
GitHub Advanced Security is available for Azure DevOps too — it brings CodeQL, secret scanning (with push protection on Azure Repos) and dependency scanning to Azure Repos and Pipelines, surfaced in the Advanced Security tab. This is exactly the kind of GitHub-into-Azure-DevOps convergence the July 2026 refresh leans on. Don't assume GHAS is GitHub-only.
- task: AdvancedSecurity-Codeql-Init@1
inputs: { languages: 'csharp' }
- task: AdvancedSecurity-Dependency-Scanning@1
- task: AdvancedSecurity-Codeql-Analyze@1
# Secret scanning + push protection are enabled in Project/Repo settings
Microsoft Defender for Cloud — DevOps security
Provides a single security posture view across Azure DevOps, GitHub and GitLab.
- Connects your repos via Defender for Cloud → Environment settings → DevOps.
- Surfaces exposed secrets, IaC misconfigurations, dependency and code-scanning findings and container CVEs in one dashboard.
- Pull-request annotations comment findings directly on the PR.
- Integrates GHAS results — enable GHAS on the repos, connect the org to Defender for Cloud, and code/secret/Dependabot findings flow into the unified view.
Caveat — Defender for Cloud vs GHAS
GHAS is the scanning engine (CodeQL, secret scanning, dependency review) living in the repo. Defender for Cloud DevOps security is the aggregation and posture-management layer across many repos/orgs and clouds. "Unified security posture across GitHub and Azure DevOps in one pane" → Defender for Cloud. "Block a secret at push time" → GHAS secret scanning push protection.
Container and dependency scanning
- run: docker build -t myapp:${{ github.sha }} .
- uses: aquasecurity/trivy-action@master
with:
image-ref: 'myapp:${{ github.sha }}'
format: sarif
output: trivy.sarif
severity: 'CRITICAL,HIGH'
exit-code: '1' # fail the build on findings
- uses: github/codeql-action/upload-sarif@v3
with: { sarif_file: trivy.sarif }
version: 2
updates:
- package-ecosystem: npm
directory: "/"
schedule: { interval: weekly }
open-pull-requests-limit: 10
- package-ecosystem: nuget
directory: "/src"
schedule: { interval: daily }
- package-ecosystem: docker
directory: "/"
schedule: { interval: weekly }
- package-ecosystem: github-actions # keep pinned actions current
directory: "/"
schedule: { interval: weekly }
SARIF is the universal format
Any scanner that emits SARIF can upload results to the GitHub Security tab (or Azure DevOps Advanced Security) via upload-sarif, so third-party tools (Trivy, Semgrep, tfsec) appear alongside CodeQL. "Surface a non-GitHub scanner's results in GitHub" → upload the SARIF file.
| CVSS score | Severity |
|---|---|
| 9.0–10.0 | Critical |
| 7.0–8.9 | High |
| 4.0–6.9 | Medium |
| 0.1–3.9 | Low |
CodeQL in a container build
For compiled languages CodeQL must observe the build to analyse it. When the build runs in a container, use github/codeql-action/autobuild (or a manual build inside the same container step) so CodeQL traces the compilation — otherwise it has nothing to analyse.
Domain 4 rapid recap
| The requirement says… | Answer |
|---|---|
| Azure resource calls another Azure service, no secrets | Managed identity |
| Many resources need the same permissions | User-assigned managed identity |
| Deploy to Azure from GitHub Actions with no secret | OIDC / workload identity federation + id-token: write |
| OIDC login rejected | Fix the Entra federated credential subject to match repo/ref |
| Automation runs outside Azure | Service principal (federated if possible) |
| Store connection strings for a pipeline securely | Key Vault → variable group / AzureKeyVault@2 |
| Customer-managed key Microsoft can't extract | HSM-backed key — Key Vault Premium |
| Block a secret at push time | GHAS secret scanning push protection |
| SAST in PR reviews on GitHub | CodeQL (code scanning) |
| Auto-patch vulnerable npm packages | Dependabot security updates |
| Scan container images for CVEs in CI | Trivy / Defender for Containers |
| Unified security view across GitHub + Azure DevOps | Defender for Cloud — DevOps security |
| Show a third-party scanner's results in GitHub | Upload the SARIF file |
| Store a code-signing cert for the pipeline | Secure files |
| Free Boards access for a product owner | Stakeholder access level |
| Protect prod credentials from PR workflows | Environment secrets behind required reviewers |