Skip to content
Merged
17 changes: 15 additions & 2 deletions .github/instructions/workflows.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ Local reusable workflows referenced via relative paths are excluded from SHA pin

Workflows MUST declare explicit permissions following the principle of least privilege. The default permission set is `contents: read`. Additional permissions MUST be granted at the job level and only when required for a specific capability.

Every job MUST declare its own `permissions:` block whenever the workflow-level block grants any scope. A job with no block silently inherits the workflow grant, which is neither explicit nor auditable. The one exception is a workflow-level `permissions: {}`, which grants nothing, so a job beneath it that declares no block inherits an empty set and holds no scope.

An empty workflow-level block is a default, not a ceiling. A job that does declare its own block still receives what it declares, because job-level permissions replace the workflow-level set rather than being capped by it.

**Required pattern:**

```yaml
Expand All @@ -58,7 +62,16 @@ jobs:
echo "Running validation steps"
```

**Enforcement:** Violations are detected by `scripts/security/Test-WorkflowPermissions.ps1`. CI will fail on workflows missing a top-level `permissions:` block. The `copilot-setup-steps.yml` workflow is excluded by default.
**Enforcement:** Violations are detected by `scripts/security/Test-WorkflowPermissions.ps1`, which classifies each workflow and then each of its jobs:

| Workflow-level block | Job-level block | Effective scopes | Verdict |
|----------------------|-----------------|------------------------------------|-----------|
| absent | absent | repository or organization default | violation |
| `permissions: {}` | absent | none | pass |
| populated | absent | inherits the workflow grant | violation |
| any | present | job-declared | pass |

CI will fail on either a missing top-level block or a job that omits its own block under a populated workflow-level block. The `copilot-setup-steps.yml` workflow is excluded by default.

## Credentials and Secrets

Expand Down Expand Up @@ -252,7 +265,7 @@ All workflows MUST pass the following validation checks:
### Workflow Permissions Validation

* **Script:** `scripts/security/Test-WorkflowPermissions.ps1`
* **What it enforces:** All workflows declare a top-level `permissions:` block
* **What it enforces:** Every workflow declares a top-level `permissions:` block, and every job declares its own block unless the workflow-level block is empty
* **CI blocking:** Failures block CI when configured to enforce compliance

## Security Requirements
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/beval.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30

permissions:
contents: read

env:
AGENT_REPO_ROOT: ${{ github.workspace }}

Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/workflow-permissions-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ jobs:
with:
persist-credentials: false

- name: Setup PowerShell modules
uses: ./.github/actions/setup-ps-modules

- name: Run Workflow Permissions Validation
id: permissions
shell: pwsh
Expand Down
4 changes: 2 additions & 2 deletions docs/security/security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ title: Security Assurance Case and Security Model
description: Comprehensive security model and security assurance documentation demonstrating enterprise security practices
sidebar_position: 2
author: Microsoft
ms.date: 2026-07-03
ms.date: 2026-07-28
ms.topic: reference
keywords:
- security
Expand Down Expand Up @@ -338,7 +338,7 @@ Affected workflow jobs:

Defense-in-depth controls:

* All workflows declare job-level permissions, not workflow-level
* Workflows declare a top-level `permissions:` block, and every job under a populated block declares its own permissions rather than inheriting implicitly; `Test-WorkflowPermissions.ps1` enforces both
* `persist-credentials: false` set on all checkout steps
* Inline YAML comments document each `security-events: write` declaration
* SARIF upload is the only write operation performed under this permission
Expand Down
3 changes: 2 additions & 1 deletion scripts/security/Modules/SecurityClasses.psm1
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ class DependencyViolation {
- VersionMismatch: Version comment does not match the resolved pinned reference
- MissingVersionComment: Dependency is pinned but lacks a human-readable version comment
- MissingPermissions: Workflow file lacks required permissions declarations
- MissingJobPermissions: Workflow job lacks its own permissions declaration and inherits an implicit grant
- Empty string: Default or unclassified violation
#>

Expand All @@ -46,7 +47,7 @@ class DependencyViolation {
[string]$CurrentRef
[ValidateSet('High', 'Medium', 'Low', 'Info')]
[string]$Severity
[ValidateSet('Unpinned', 'Stale', 'VersionMismatch', 'MissingVersionComment', 'MissingPermissions', '')]
[ValidateSet('Unpinned', 'Stale', 'VersionMismatch', 'MissingVersionComment', 'MissingPermissions', 'MissingJobPermissions', '')]
[string]$ViolationType
[string]$Description
[string]$Remediation
Expand Down
29 changes: 24 additions & 5 deletions scripts/security/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
title: Security Scripts
description: PowerShell scripts for dependency pinning validation, SHA staleness monitoring, supply chain security, and centralized PS module installation
author: HVE Core Team
ms.date: 2026-06-26
ms.date: 2026-07-28
ms.topic: reference
keywords:
- powershell
Expand Down Expand Up @@ -230,15 +230,34 @@ reach production.

### `Test-WorkflowPermissions.ps1`

Validates that GitHub Actions workflow files include a top-level `permissions` block.
Validates that GitHub Actions workflow files declare permissions at the workflow
and job level.

Purpose: Ensure workflows explicitly declare token permissions to prevent
OpenSSF Scorecard Token-Permissions failures.
OpenSSF Scorecard Token-Permissions failures, and ensure each job declares the
scope it holds rather than inheriting the workflow grant implicitly.

#### Classification

| Workflow-level block | Job-level block | Effective scopes | Verdict |
|----------------------|-----------------|------------------------------------|-----------|
| absent | absent | repository or organization default | violation |
| `permissions: {}` | absent | none | pass |
| populated | absent | inherits the workflow grant | violation |
| any | present | job-declared | pass |

A job beneath an empty workflow-level block that declares no block of its own
inherits an empty set and therefore holds no scope, so that case passes. An empty
workflow-level block is a default, not a ceiling: a job that does declare its own
block still receives what it declares, because job-level permissions replace the
workflow-level set rather than being capped by it.

#### Features

* Scans `.github/workflows/*.yml` and `.yaml` files
* Uses regex-based detection (`^permissions:`) with zero false positives
* Parses each workflow with `ConvertFrom-Yaml`, so job indentation and the
`permissions` value shape do not affect detection
* Reports workflow-level and job-level compliance as separate metrics
* Outputs results in JSON, SARIF, or console format
* Configurable workflow exclusions
* Integrates with `npm run lint:permissions`
Expand All @@ -248,7 +267,7 @@ OpenSSF Scorecard Token-Permissions failures.
* `-Path` - Directory containing workflow YAML files (default: `.github/workflows`)
* `-Format` - Output format: `json`, `sarif`, or `console` (default: `json`)
* `-OutputPath` - Path for result output file (default: `logs/workflow-permissions-results.json`)
* `-FailOnViolation` (switch) - Exit with non-zero code if any workflow is missing permissions
* `-FailOnViolation` (switch) - Exit with non-zero code if any workflow or job is missing permissions
* `-ExcludePaths` - Workflow filenames to exclude (default: `copilot-setup-steps.yml`)

#### Usage
Expand Down
Loading
Loading