# Hardening the CI System Itself — DevSecOps

Source: https://www.skillbyai.com/en/devsecops/dso-ci-hardening

> Lock down CI tokens, pin third-party actions to commit SHAs, and avoid dangerous triggers such as pull_request_target with untrusted code.

## Your pipeline is production

A CI system holds the keys to everything: source code, signing keys, cloud deploy rights. Attackers know this, so the pipeline needs its own hardening. First, **least-privilege tokens**: in GitHub Actions, set a top-level `permissions:` block (for example `contents: read`) and grant extra scopes only to the jobs that need them. Second, **pin third-party actions** to a full commit SHA rather than a tag; tags can be moved to point at malicious code, as happened in real compromises of popular actions. Third, **be careful with triggers**: `pull_request_target` runs with the base repository's secrets and write token, so checking out and running code from a fork's pull request in that context hands an attacker your secrets. Fourth, avoid **script injection**: never interpolate untrusted values like `${{ github.event.pull_request.title }}` directly inside a `run:` script; pass them through an environment variable. Finally, protect branches and require reviews for workflow file changes.

## Finding unpinned actions

A small checker that flags `uses:` lines not pinned to a 40-character commit SHA. Local actions (`./`) are skipped. It also flags `actions/checkout@v4`, the tag style earlier examples use for readability; in a real repository pin those to SHAs too and let Dependabot or Renovate bump them.

```python
import re

workflow = '''
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # full SHA of a release
      - uses: some-org/deploy-action@main
      - uses: ./.github/actions/local
'''
USES = re.compile(r"uses:\s*([^\s#]+)")
SHA = re.compile(r"@[0-9a-f]{40}$")
for ref in USES.findall(workflow):
    if ref.startswith("./"):
        continue
    status = "pinned" if SHA.search(ref) else "NOT PINNED"
    print(f"{status:<10} {ref}")
```

Output:

```
NOT PINNED actions/checkout@v4
pinned     actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
NOT PINNED some-org/deploy-action@main
```

## Pass untrusted input through env

Instead of `run: echo "${{ github.event.issue.title }}"`, set `env: TITLE: ${{ github.event.issue.title }}` and use `"$TITLE"` in the script. The shell then treats it as data, not as part of the script text.

## A master key on a hook

Leaving deploy credentials in a CI system anyone can trigger is like hanging the hostel's master key on a hook by the entrance with a sign saying 'staff only'. The sign is not a lock.

**Quiz:** Why pin a third-party GitHub Action to a full commit SHA instead of a tag like @v3?

- [ ] SHAs make workflows run faster
- [ ] Tags are not allowed in workflow files
- [x] A tag can be moved to different code, while a commit SHA always refers to the same content
- [ ] SHAs automatically update to the latest version

*Answer:* A tag can be moved to different code, while a commit SHA always refers to the same content. Git tags are mutable references; a compromised maintainer account can repoint them. A full SHA is immutable.
