diff --git a/skills/devsecops/pipeline-security/SKILL.md b/skills/devsecops/pipeline-security/SKILL.md index 66de2470..3a641f86 100644 --- a/skills/devsecops/pipeline-security/SKILL.md +++ b/skills/devsecops/pipeline-security/SKILL.md @@ -12,7 +12,7 @@ phase: [build, deploy] frameworks: [SLSA-v1.0, OWASP-CICD-Top-10] difficulty: intermediate time_estimate: "30-60min" -version: "1.0.0" +version: "1.0.1" author: unitoneai license: MIT allowed-tools: Read, Grep, Glob @@ -266,6 +266,19 @@ on: pull_request_target **Finding format:** Report any `pull_request_target` usage, direct expression injection in `run:` steps, fork workflow policies, and whether PR code can influence privileged pipelines. +##### `workflow_run` Artifact Handoff Evidence Gate + +Do not treat absence of `pull_request_target` as sufficient PPE protection. A lower-trust producer workflow can upload artifacts, caches, coverage reports, build outputs, or generated scripts that a later privileged `workflow_run` consumer downloads and executes with write permissions, secrets, package tokens, signing keys, or deployment credentials. + +- `PIPE-HANDOFF-01` - Map every producer/consumer chain: producer workflow name, trigger, event type, head repository, head branch/ref, head SHA, actor, artifact/cache/report identity, consumer workflow, consumer trigger, and consumer permissions. +- `PIPE-HANDOFF-02` - Require trusted source checks before privileged consumption: repository owner, fork status, branch protection, environment, actor/team membership, event type, conclusion, and expected workflow name must be verified before download, execution, signing, publishing, or deployment. +- `PIPE-HANDOFF-03` - Bind artifacts to immutable source and workflow identity using digest verification, signed artifact attestations, SLSA provenance, in-toto links, or rebuild-from-trusted-source before executing generated files or publishing outputs. +- `PIPE-HANDOFF-04` - Treat downloaded PR-controlled scripts, archives, coverage reports, dependency caches, build directories, generated release notes, and package metadata as untrusted until verification proves they came from the trusted commit and workflow. +- `PIPE-HANDOFF-05` - Isolate caches across trust boundaries. Untrusted PR workflows must not share cache keys, restore key prefixes, package manager caches, Docker layer caches, build caches, or tool caches with release, signing, deployment, or package-publish workflows. +- `PIPE-HANDOFF-06` - Verify consumer permissions are least-privilege and gated by environment protections. `write-all`, package publish, release creation, signing, cloud deployment, and secret access require stronger source/provenance checks. +- `PIPE-HANDOFF-07` - Require explicit deny behavior for untrusted, forked, stale, rerun, cancelled, failed, or manually modified producer runs. The consumer should fail closed before artifact download or cache restore. +- `PIPE-HANDOFF-08` - Cap status at Critical when a privileged consumer executes or publishes unverified artifacts from an untrusted producer; cap at High when trust checks or cache isolation are incomplete; mark Not Evaluable when the producer/consumer chain cannot be mapped. + --- #### CICD-SEC-5: Insufficient PBAC (Pipeline-Based Access Controls) @@ -480,6 +493,23 @@ Produce the final report using the following structure: | CICD-SEC-2 | Inadequate IAM | ... | ... | ... | | ... | ... | ... | ... | ... | +### Privileged Workflow Handoff Evidence + +| Chain ID | Producer Workflow / Trigger | Producer Trust Source | Artifact or Cache | Consumer Workflow / Permissions | Verification Before Use | Cache Isolation | Status | +|----------|-----------------------------|-----------------------|------------------|---------------------------------|-------------------------|----------------|--------| +| | | | | | | | | + +| Gate | Evidence Required | Result | Finding | +|------|-------------------|--------|---------| +| PIPE-HANDOFF-01 | Complete producer/consumer workflow chain mapping | | | +| PIPE-HANDOFF-02 | Trusted source checks before privileged artifact/cache consumption | | | +| PIPE-HANDOFF-03 | Artifact digest, signature, provenance, or trusted rebuild binding | | | +| PIPE-HANDOFF-04 | PR-controlled generated files/reports/scripts treated as untrusted until verified | | | +| PIPE-HANDOFF-05 | Cache key and restore-key isolation across trust boundaries | | | +| PIPE-HANDOFF-06 | Consumer permissions and environment gates scoped to verified source | | | +| PIPE-HANDOFF-07 | Fail-closed behavior for untrusted, stale, rerun, cancelled, failed, or manually modified producers | | | +| PIPE-HANDOFF-08 | Severity/status cap applied for unmapped or unverified privileged handoffs | | | + ### Detailed Findings #### [CICD-SEC-X] diff --git a/skills/devsecops/pipeline-security/tests/benign/verified_workflow_run_artifact_handoff.json b/skills/devsecops/pipeline-security/tests/benign/verified_workflow_run_artifact_handoff.json new file mode 100644 index 00000000..801b0337 --- /dev/null +++ b/skills/devsecops/pipeline-security/tests/benign/verified_workflow_run_artifact_handoff.json @@ -0,0 +1,165 @@ +{ + "case_id": "pipeline_workflow_run_verified_artifact_handoff", + "description": "A privileged workflow_run consumer only publishes artifacts after mapping the producer chain, verifying trusted source, validating artifact provenance, and isolating caches from PR workflows.", + "repository": "acme/payments-api", + "chains": [ + { + "chain_id": "release-build-to-publish", + "producer": { + "workflow_name": "trusted-release-build", + "trigger": "push", + "event_type": "push", + "head_repository": "acme/payments-api", + "head_repository_owner": "acme", + "fork": false, + "head_branch": "main", + "head_sha": "sha-example-trusted-release-20260609", + "actor": "release-bot", + "actor_team": "release-engineering", + "conclusion": "success", + "workflow_file_ref": ".github/workflows/release-build.yml@sha-example-trusted-release-20260609" + }, + "produced_material": [ + { + "type": "artifact", + "name": "dist-package", + "artifact_id": "artifact-981245", + "digest": "sha256-example-dist-package-20260609", + "provenance": { + "type": "slsa-v1", + "attestation": "attestation-dist-package-981245", + "builder_id": "github-actions-hosted", + "source_sha": "sha-example-trusted-release-20260609", + "workflow_name": "trusted-release-build", + "verified_by_consumer": true + } + }, + { + "type": "coverage_report", + "name": "coverage-summary", + "digest": "sha256-example-coverage-summary-20260609", + "executed_by_consumer": false + } + ], + "consumer": { + "workflow_name": "publish-release", + "trigger": "workflow_run", + "listens_to": [ + "trusted-release-build" + ], + "permissions": { + "contents": "write", + "packages": "write", + "id-token": "write", + "actions": "read" + }, + "secrets_available": [ + "package-publish-token" + ], + "environment": { + "name": "production-release", + "required_reviewers": [ + "release-engineering" + ], + "protected_branch_only": true + }, + "pre_download_checks": [ + "workflow_run.head_repository.full_name == acme/payments-api", + "workflow_run.head_branch == main", + "workflow_run.head_sha matches protected branch tip", + "workflow_run.actor is release-bot or release-engineering member", + "workflow_run.event == push", + "workflow_run.conclusion == success", + "workflow_run.name == trusted-release-build" + ], + "artifact_verification": [ + "downloaded artifact digest equals expected digest from attestation", + "SLSA provenance subject digest equals artifact digest", + "provenance source sha equals workflow_run.head_sha", + "rebuild fallback exists for missing attestation" + ], + "fail_closed_before_download": true + }, + "cache_isolation": { + "producer_cache_key": "release-main-${{ github.sha }}", + "pr_cache_key_prefix": "pr-${{ github.event.pull_request.head.sha }}", + "release_restore_keys": [ + "release-main-" + ], + "pr_restore_keys": [ + "pr-" + ], + "shared_with_pull_request": false, + "docker_layer_cache_shared": false, + "package_manager_cache_shared": false + }, + "expected_status": "Pass" + }, + { + "chain_id": "pr-build-to-analysis", + "producer": { + "workflow_name": "pull-request-build", + "trigger": "pull_request", + "event_type": "pull_request", + "head_repository": "contributor/payments-api", + "head_repository_owner": "contributor", + "fork": true, + "head_branch": "feature", + "head_sha": "sha-example-fork-pr-20260609", + "actor": "external-contributor", + "conclusion": "success" + }, + "produced_material": [ + { + "type": "artifact", + "name": "test-results", + "digest": "sha256-example-pr-test-results-20260609", + "executed_by_consumer": false, + "published_by_consumer": false + } + ], + "consumer": { + "workflow_name": "pr-result-commenter", + "trigger": "workflow_run", + "permissions": { + "contents": "read", + "pull-requests": "write" + }, + "secrets_available": [], + "pre_download_checks": [ + "workflow_run.name == pull-request-build", + "workflow_run.conclusion == success", + "consumer never executes downloaded files", + "consumer only parses fixed-format JSON after schema validation" + ], + "artifact_verification": [ + "schema validation before parsing", + "path traversal checks before extraction", + "no shell execution" + ], + "fail_closed_before_download": true + }, + "cache_isolation": { + "shared_with_release_or_deploy": false, + "restore_keys_overlap_release": false + }, + "expected_status": "Pass" + } + ], + "expected_gate_results": { + "PIPE-HANDOFF-01": "Pass", + "PIPE-HANDOFF-02": "Pass", + "PIPE-HANDOFF-03": "Pass", + "PIPE-HANDOFF-04": "Pass", + "PIPE-HANDOFF-05": "Pass", + "PIPE-HANDOFF-06": "Pass", + "PIPE-HANDOFF-07": "Pass", + "PIPE-HANDOFF-08": "Pass" + }, + "expected_assessment": { + "cicd_sec_4": "Pass", + "cicd_sec_9": "Pass", + "status_cap": "None", + "finding": "Privileged workflow_run consumers are restricted to trusted producers, verified source and artifact identity, isolated caches, least-privilege permissions, and fail-closed behavior." + } +} diff --git a/skills/devsecops/pipeline-security/tests/vulnerable/untrusted_workflow_run_artifact_execution.json b/skills/devsecops/pipeline-security/tests/vulnerable/untrusted_workflow_run_artifact_execution.json new file mode 100644 index 00000000..5b495356 --- /dev/null +++ b/skills/devsecops/pipeline-security/tests/vulnerable/untrusted_workflow_run_artifact_execution.json @@ -0,0 +1,186 @@ +{ + "case_id": "pipeline_workflow_run_untrusted_artifact_execution", + "description": "A pull_request workflow from forks uploads a dist artifact, then a privileged workflow_run publish job downloads and executes it with write permissions and package secrets without trusted source, digest, provenance, or cache isolation checks.", + "repository": "acme/payments-api", + "chains": [ + { + "chain_id": "pr-build-to-publish", + "producer": { + "workflow_name": "pr-build", + "trigger": "pull_request", + "event_type": "pull_request", + "head_repository": "external-user/payments-api", + "head_repository_owner": "external-user", + "fork": true, + "head_branch": "feature-release-script", + "head_sha": "sha-example-untrusted-pr-20260609", + "actor": "external-user", + "conclusion": "success", + "workflow_file_ref": ".github/workflows/pr-build.yml from pull request head" + }, + "produced_material": [ + { + "type": "artifact", + "name": "dist", + "artifact_id": "artifact-unsafe-dist", + "contents": [ + "dist/release.sh", + "dist/package.json", + "dist/generated-release-notes.md" + ], + "digest": "not_recorded", + "provenance": "missing", + "attestation": "missing" + }, + { + "type": "cache", + "name": "node-build-cache", + "cache_key": "node-${{ hashFiles('package-lock.json') }}", + "restore_keys": [ + "node-" + ], + "shared_with_release": true + } + ], + "consumer": { + "workflow_name": "publish-release", + "trigger": "workflow_run", + "listens_to": [ + "pr-build" + ], + "permissions": "write-all", + "secrets_available": [ + "NPM_PUBLISH_TOKEN", + "RELEASE_SIGNING_KEY", + "CLOUD_DEPLOY_TOKEN" + ], + "environment": { + "name": "release", + "required_reviewers": [], + "protected_branch_only": false + }, + "pre_download_checks": [ + "workflow_run.conclusion == success" + ], + "missing_checks": [ + "head_repository owner trusted", + "fork status denied", + "head_branch protected", + "actor/team authorization", + "event type constrained to push", + "workflow name allowlist", + "head SHA bound to protected branch" + ], + "artifact_use": [ + "actions/download-artifact using github.event.workflow_run.id", + "chmod +x dist/release.sh", + "./dist/release.sh", + "npm publish ./dist", + "cosign sign generated image using release signing key" + ], + "artifact_verification": [], + "fail_closed_before_download": false + }, + "cache_isolation": { + "producer_cache_key": "node-${{ hashFiles('package-lock.json') }}", + "consumer_restore_keys": [ + "node-" + ], + "shared_with_pull_request": true, + "shared_with_release": true, + "docker_layer_cache_shared": true, + "package_manager_cache_shared": true + } + }, + { + "chain_id": "coverage-to-deploy-metadata", + "producer": { + "workflow_name": "fork-coverage", + "trigger": "pull_request", + "event_type": "pull_request", + "head_repository": "external-user/payments-api", + "fork": true, + "head_sha": "sha-example-untrusted-coverage-20260609", + "actor": "external-user", + "conclusion": "success" + }, + "produced_material": [ + { + "type": "coverage_report", + "name": "coverage-summary", + "digest": "not_recorded", + "provenance": "missing", + "trusted_format_validation": false + } + ], + "consumer": { + "workflow_name": "deployment-metadata", + "trigger": "workflow_run", + "permissions": { + "deployments": "write", + "contents": "write" + }, + "secrets_available": [ + "DEPLOYMENT_METADATA_TOKEN" + ], + "pre_download_checks": [ + "workflow_run.conclusion == success" + ], + "artifact_use": [ + "extract coverage archive without path restrictions", + "execute generated summary hook", + "attach generated deployment metadata" + ], + "artifact_verification": [], + "fail_closed_before_download": false + }, + "cache_isolation": { + "shared_with_release_or_deploy": "unknown", + "restore_keys_overlap_release": "unknown" + } + } + ], + "expected_gate_results": { + "PIPE-HANDOFF-01": "Fail", + "PIPE-HANDOFF-02": "Fail", + "PIPE-HANDOFF-03": "Fail", + "PIPE-HANDOFF-04": "Fail", + "PIPE-HANDOFF-05": "Fail", + "PIPE-HANDOFF-06": "Fail", + "PIPE-HANDOFF-07": "Fail", + "PIPE-HANDOFF-08": "Fail" + }, + "expected_findings": [ + { + "gate": "PIPE-HANDOFF-02", + "severity": "Critical", + "finding": "The privileged publish workflow only checks workflow_run.conclusion and does not verify trusted repository, fork status, protected branch, actor/team, event type, workflow name, or head SHA before consuming artifacts." + }, + { + "gate": "PIPE-HANDOFF-03", + "severity": "Critical", + "finding": "The consumer executes dist/release.sh and publishes packages without artifact digest verification, signed attestation, SLSA provenance, in-toto link, or trusted rebuild." + }, + { + "gate": "PIPE-HANDOFF-05", + "severity": "High", + "finding": "Untrusted PR and release workflows share broad cache restore keys, package manager caches, and Docker layer caches across the trust boundary." + }, + { + "gate": "PIPE-HANDOFF-06", + "severity": "Critical", + "finding": "The workflow_run consumer uses write-all and has package, signing, and cloud deployment secrets without source/provenance gates." + }, + { + "gate": "PIPE-HANDOFF-07", + "severity": "High", + "finding": "The consumer does not fail closed before artifact download for forked, stale, rerun, cancelled, failed, or manually modified producer runs." + } + ], + "expected_assessment": { + "cicd_sec_4": "Fail", + "cicd_sec_9": "Fail", + "status_cap": "Critical", + "reason": "A privileged workflow_run consumer executes and publishes unverified artifacts from an untrusted pull_request producer with shared caches and write-all permissions." + } +}