Metadata-Version: 2.4
Name: taintly
Version: 1.2.0
Summary: Zero-dependency CI/CD pipeline security auditor for GitHub Actions and GitLab CI
Author: Asaf Yashayev
License-Expression: MIT
Project-URL: Homepage, https://github.com/Nellur35/taintly
Project-URL: Repository, https://github.com/Nellur35/taintly
Project-URL: Issues, https://github.com/Nellur35/taintly/issues
Project-URL: Releases, https://github.com/Nellur35/taintly/releases
Keywords: security,cicd,github-actions,gitlab,supply-chain,audit,owasp
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: System Administrators
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Security
Classifier: Topic :: Software Development :: Quality Assurance
Classifier: Topic :: Software Development :: Testing
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
Requires-Dist: pytest-timeout>=2.3; extra == "dev"
Requires-Dist: coverage>=7.0; extra == "dev"
Requires-Dist: pytest-cov>=5.0; extra == "dev"
Requires-Dist: hypothesis>=6.100; extra == "dev"
Requires-Dist: mypy>=1.14; extra == "dev"
Requires-Dist: pytest-snapshot>=0.9; extra == "dev"
Dynamic: license-file

# taintly

[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/Nellur35/taintly/blob/main/LICENSE)
[![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://github.com/Nellur35/taintly)

taintly is a security scanner for CI/CD pipelines. It reads GitHub Actions, GitLab CI, and Jenkins configuration and reports misconfigurations mapped to the [OWASP CI/CD Top 10](https://owasp.org/www-project-top-10-ci-cd-security-risks/).

What it adds over a single-rule linter:

- **Multi-stage taint analysis with provenance.** Traces attacker-controlled values through `env`, `$GITHUB_ENV`, `$GITHUB_OUTPUT`, and AI-agent step outputs across steps in a job, with order-sensitive semantics and fixed-point multi-hop env resolution. Each finding includes the full source-to-sink chain.
- **Contextual exploitability.** Same rule, different verdict depending on whether the job has secrets, write permissions, or a fork-reachable trigger — surfaced alongside severity and confidence.
- **AI / ML category.** A dedicated rule pack for workflows that load models or run AI coding agents (pickle deserialization, `trust_remote_code=True`, agent-output taint, MCP server hygiene).

Pure Python 3.10+. Zero runtime dependencies. No telemetry.

In practice: it stops attackers from running their code on your build runners by exploiting mistakes in your workflow YAML.

## See it work

Point taintly at a workflow with the `pull_request_target` checkout pattern (the "pwn-request" shape used in public CI compromises):

```text
$ taintly examples/07-pull-request-target-checkout --score

Summary
  Files scanned:  2
  Total findings: 9
  Distinct risks: 4 confirmed
  Score:          86/100 (B)
  By severity:    CRITICAL:2  HIGH:4  MEDIUM:3

Top distinct risks
  [CRITICAL] User-controlled input reaching shell execution (4 findings across 2 files)
      PR titles, commit messages, issue bodies, and branch names are attacker-controlled.
      Rules: LOTP-GH-001, LOTP-GH-003, SEC4-GH-012
  [HIGH] Privileged PR-trigger exposure (2 findings across 1 file)
      Workflows that run with write permissions or secrets in response to fork-controlled
      events are the single biggest source of public CI compromises.
      Rules: SEC4-GH-002, XF-GH-004
  [MEDIUM exploitability:high] Credential persistence and exfiltration surface (1 finding across 1 file)
      Rules: SEC4-GH-005
  [MEDIUM] Over-broad token permissions (2 findings across 1 file)
      Rules: SEC2-GH-002, XF-GH-003
```

Nine findings collapse into **four distinct risks**. The same `pull_request_target` mistake fires across eight rules at once — build-tool LOTP, npm script-execution, `secrets: inherit`, fork-reachable trigger, write-context reusable workflow, missing permissions, persisted credentials, fanout-hub. Cluster-as-diagnosis: you fix one root cause, not eight bug reports.

### Block regressions, not pre-existing findings

```text
$ taintly examples/08-baseline-diff/before-fix --baseline baseline.json
$ taintly examples/08-baseline-diff/after-fix --diff baseline.json

Diff mode (baseline.json): 1 NEW

  [HIGH] SEC3-GH-001: Unpinned action (mutable tag reference) [exploitability:high]
    File: examples/08-baseline-diff/after-fix/.github/workflows/release.yml:29
    Code: uses: actions/upload-artifact@v4
    Fix: Pin to full 40-char commit SHA: uses: org/action@<sha> # vtag
```

`--baseline` snapshots existing findings; `--diff` reports only new ones. Pre-existing tech debt stays out of CI; the day someone introduces a new regression, the gate fires on that finding alone.

Eight worked examples in [`examples/`](examples/) — multi-hop env taint, AI-agent output injection, cache-prefix collision, cross-job needs, fork-identity guards, and more. CI runs `bash scripts/verify-examples.sh` against them on every push.

## Install

```bash
pip install taintly
taintly /path/to/your/repo
```

Or install an unreleased commit directly from the repo:

```bash
pip install git+https://github.com/Nellur35/taintly@v1
```

`@v1` is a floating tag that tracks the latest `v1.x.y` release; pin to a specific `v1.x.y` for reproducibility.

## CI integration

**GitHub Actions**

```yaml
- uses: Nellur35/taintly@v1
  with:
    fail-on: HIGH
```

**GitLab CI (16.11+)**

```yaml
include:
  - component: $CI_SERVER_FQDN/nellur35/taintly/taintly@v1
    inputs:
      fail-on: HIGH
```

See [`docs/INTEGRATION.md`](docs/INTEGRATION.md) for SARIF upload, baseline+diff, scheduled scans, pre-commit, auto-fix PRs, and org-wide scans.

## How taintly thinks about findings

Most scanners print one line per rule hit. taintly reports **distinct risks**.

### Finding families

A workflow that pins a reusable workflow to `@main` usually trips three related rules (mutable ref, unpinned action, unresolvable version). Rather than three findings, taintly shows one **family** called "Mutable dependency references" with the three rule IDs underneath.

Every finding belongs to exactly one family. Families are named by root cause, not by rule ID:

- **Mutable dependency references** — e.g. `uses: actions/checkout@v4` instead of a commit SHA.
- **Privileged PR-trigger exposure** — workflows that run with repo write permissions while checking out a contributor's PR code.
- **User-controlled input reaching shell execution** — e.g. a PR title ending up inside a `run:` block.
- **Credential persistence** — tokens written to disk or env vars that outlive the step that needed them.
- **Release / artifact integrity** — build outputs uploaded to registries without signatures or provenance.
- **Identity / Access** — missing `permissions:` blocks, `write-all` tokens, `secrets: inherit` passthrough.

The full rule catalog (grouped by family) is in [`docs/RULES.md`](docs/RULES.md).

### Three signals per finding

| Signal | What it tells you |
|---|---|
| **Severity** | How serious the policy violation is. E.g. `CRITICAL` for "workflow checks out attacker's code with write permissions". Same across workflows. |
| **Confidence** | How sure the detector is it found a real instance. An exact `uses: actions/checkout@v4` match is `high`; a shallow taint heuristic that could be quoted safely is `medium`. |
| **Exploitability** | How much damage is actually reachable in this particular workflow. The same rule in a release workflow with production secrets is `high`; in a `cron` job with `permissions: read-all` it's `low`. |

Severity tells you how serious the policy violation is. Confidence tells you whether to trust the detector. Exploitability tells you whether this particular job is a viable target.

### Review-needed

Some patterns are only dangerous depending on design intent. A `pull_request_target` trigger with `permissions: {}` and no checkout step isn't an attack — it might just be a comment bot. taintly routes these into a **review-needed** bucket instead of counting them as confirmed issues.

### Score and debt profile

`--score` prints a 0–100 grade, deducting per family weighted by `confidence × exploitability`. Review-needed families don't deduct. The full score footer breaks the score down by category and labels each family Strong, Moderate, Weak, or Needs review (real output from `examples/07-pull-request-target-checkout`):

```text
=== CI/CD SECURITY SCORE ===

  Score: 86/100 (B)
  Distinct risks: 4 confirmed cluster(s)

Deductions:
  Clusters:   4 distinct      ->  -19.0 pts

Bonuses:
  X   No critical findings             0
  OK  All actions pinned to SHA       +5
  X   All permissions explicit         0

Breakdown by category:
  Pipeline Execution (PPE)        24/30  - 2 CRITICAL, 4 HIGH, 1 MEDIUM
  Supply Chain                    25/25  - clean
  Credential Hygiene              20/20  - clean
  Identity / Access               10/10  - 2 MEDIUM
  ...

Security debt profile:
  Mutable dependency references                    - Strong
  Privileged PR-trigger exposure                   - Moderate  (2 finding(s), expl:medium)
  User-controlled input reaching shell execution   - Moderate  (4 finding(s), expl:low)
  Credential persistence and exfiltration surface  - Moderate  (1 finding(s), expl:high)
  Over-broad token permissions                     - Moderate  (2 finding(s), expl:medium)
  AI / ML model and agent risk                     - Strong
```

This is the CI-gate surface. Block on `--fail-on HIGH` and watch the score move over time.

## Capabilities

### Taint analysis

Most injection rules look at a single line. taintly traces attacker-controlled values through the workflow — through env vars, `$GITHUB_ENV`, `$GITHUB_OUTPUT`, step outputs, and across workflow boundaries (reusable workflows, `workflow_run` handlers, Jenkins downstream builds).

Each taint finding includes the full provenance chain:

```
github.event.comment.body -> env.RAW -> env.MID
    -> $GITHUB_ENV.COMMENT -> echo "$COMMENT"
```

### Platform posture audit

`--platform-audit` calls the GitHub or GitLab API and checks settings no YAML scan can see — repo settings, not workflow code. Branch protection, default `GITHUB_TOKEN` permission, fork-PR approval, CODEOWNERS coverage, GitLab variable Protected/Masked flags. Needs a token.

### Transitive action analysis

`--transitive` walks into composite actions. Catches the "outer pin doesn't protect inner pins" gap: a SHA-pinned action that itself references `@main` sub-actions.

### AI / ML

A dedicated category for workflows that load models or run AI coding agents — pickle-based `torch.load` / `joblib.load` deserialization, HuggingFace `trust_remote_code=True`, agentic actions on events anyone can trigger by opening a PR or comment, MCP server hygiene, and LLM output that reaches a shell. Listed in [`docs/RULES.md`](docs/RULES.md) under the AI / ML section.

### Auto-fix

`--fix` applies deterministic fixes: pin action tags to SHAs, add `persist-credentials: false`, add missing `permissions:` blocks. Opt-in fixers that change build semantics (like `--fix-npm-ignore-scripts`) aren't in the default set.

### Baseline mode

`--baseline` snapshots the current findings. `--diff` then reports only new ones. Use when adopting the tool on a repo with pre-existing findings without breaking CI on day one.

## Coverage

<!-- AUTOGEN:summary -->
221 file-based rules and 21 platform-posture checks. The file-based rules break down as 111 GitHub, 58 GitLab, and 52 Jenkins, and include a dedicated AI / ML category (63 rules across the three platforms, plus multi-platform taint analysis) for workflows that run model loads or AI coding agents. The posture checks (12 GitHub, 9 GitLab) only run in `--platform-audit` mode.
<!-- /AUTOGEN:summary -->

<!-- AUTOGEN:coverage -->
| Category | Description | GitHub | GitLab | Jenkins |
|----------|-------------|--------|--------|---------|
| SEC-1 | Insufficient Flow Control | 2 | 2 | 2 |
| SEC-2 | Inadequate IAM | 4 | 3 | 3 |
| SEC-3 | Dependency Chain Abuse | 8 | 5 | 5 |
| SEC-4 | Poisoned Pipeline Execution | 24 | 9 | 8 |
| SEC-5 | Insufficient PBAC | 2 | 1 | 1 |
| SEC-6 | Insufficient Credential Hygiene | 8 | 9 | 8 |
| SEC-7 | Insecure System Configuration | 4 | 1 | 3 |
| SEC-8 | Ungoverned 3rd Party Services | 4 | 3 | 4 |
| SEC-9 | Improper Artifact Integrity | 5 | 3 | 3 |
| SEC-10 | Insufficient Logging | 4 | 2 | 1 |
| AI / ML | AI model, agent, MCP | 35 | 16 | 12 |
| TAINT | Multi-stage taint flows | 11 | 4 | 2 |

Plus 21 platform-posture rules (`PLAT-GH-001/002/005/007/008/009/010/011/012/013/014/016`, `PLAT-GL-001/002/003/004/008/009/010/011/012`).
<!-- /AUTOGEN:coverage -->

Full rule catalog: [`docs/RULES.md`](docs/RULES.md).

### Limitations

The taint analyzer covers in-job env-mediated flows and cross-job `needs.<j>.outputs.<n>` flows into shell sinks. Boundaries the analyzer does not cross:

- **`workflow_call` callee-side dataflow.** TAINT-GH-006 surfaces every `${{ inputs.X }}` reference in a reusable workflow as review-needed; the analyzer does not propagate caller-passed taint into the callee's `run:` blocks. TAINT-GH-007 surfaces the caller side.
- **`workflow_run` artefact propagation.** Files / cache entries / artefacts written by a triggering workflow and read by the triggered workflow are not tracked across the boundary. TAINT-GH-008 catches the YAML-level reference but not the runtime inheritance.
- **Cross-job sinks other than `run:`.** TAINT-GH-009 covers `${{ needs.X.outputs.Y }}` reaching a shell sink. The same source can also reach `runs-on:` (self-hosted runner hijack), `container.image:` (attacker-image pull), `strategy.matrix:` (matrix DoS), and `if:` (gate suppression). Those are tracked as future rule shapes, not subsumed under the run: sink.
- **Cross-job artefacts and cache.** A step writing attacker bytes to a file, uploading it as an artifact, and a downstream job downloading and reading the file is not traced.
- **Shell quoting forms.** `_shell_quote_context_at` handles single quotes, double quotes, and ANSI-C `$'...'` quoting. Heredocs and multi-line string-continuation forms need cross-line state and fall through; they are conservatively treated as sinks.

The next major analyzer milestone is `workflow_call` callee-side dataflow — see [`docs/ROADMAP.md`](docs/ROADMAP.md).

## Usage

```bash
python -m taintly [path] [options]
```

The non-obvious flags:

| Flag | What it does |
|---|---|
| `--score` | Print the score, distinct-risk count, and debt profile |
| `--format {text,json,csv,sarif,html}` | Report format |
| `--fix` / `--fix-dry-run` | Apply or preview safe auto-fixes |
| `--platform-audit` | API-based posture check. Use with `--github-repo` or `--gitlab-project`. |
| `--baseline [FILE]` / `--diff [FILE]` | Save a baseline, then later report only new findings |
| `--transitive` | Walk into composite actions and check their sub-actions |
| `--guide [RULE_ID]` | Step-by-step remediation guide |
| `--token-stdin` | Read the API token from stdin (keeps it out of `ps` and shell history) |

Run `python -m taintly --help` for the full list.

### Config file

Drop a `.taintly.yml` at the repo root for per-project defaults:

```yaml
version: 1
min-severity: HIGH
fail-on: CRITICAL

exclude-rules:
  - SEC2-GH-001

ignore:
  - id: SEC3-GH-001              # path-scoped suppression
    path: legacy/
  - id: SEC4-GH-002              # justified, time-limited exception
    path: .github/workflows/internal.yml
    reason: internal workflow with no fork triggers
    owner: platform-security@example.com
    expires: 2026-09-01          # warning printed after this date
```

CLI flags override config values. Expired suppressions still apply but print a warning on every scan.

### Inline suppressions

```yaml
- uses: actions/checkout@v4  # taintly: ignore
- uses: actions/checkout@v4  # taintly: ignore[SEC4-GH-005]
- uses: actions/checkout@v4  # taintly: ignore[SEC3-GH-001,SEC4-GH-005]
```

### Remote scans

```bash
GITHUB_TOKEN=ghp_... taintly --github-org my-org
GITLAB_TOKEN=glpat-... taintly --gitlab-group my-group
taintly /path/to/repo --platform jenkins   # Jenkins has no single discovery file
```

### SARIF output

```yaml
- run: python -m taintly . --format sarif > taintly.sarif
- uses: github/codeql-action/upload-sarif@v3
  with:
    sarif_file: taintly.sarif
```

Each result carries a `properties` block with `finding_family`, `confidence`, `exploitability`, and `review_needed`. GitHub Advanced Security and the GitLab security dashboard both preserve these so you can filter by family or context, not just severity.

## Architecture

```
taintly/
├── __main__.py             CLI entry point
├── engine.py               Scan driver
├── models.py               Rule, Finding, pattern types
├── taint.py                Multi-stage taint analyzer
├── families.py             Finding-family clustering
├── workflow_context.py     Per-file signals for exploitability
├── scorer.py               Score and debt profile
├── fixes.py                Auto-fix implementations
├── transitive.py           Composite-action sub-call analysis
├── yaml_path.py            Structural YAML path extractor
├── platform/               API-based posture scanning
├── reporters/              text, json, csv, sarif, html, score_text
└── rules/                  github, gitlab, jenkins rule packs
```

Rules are pure data (`@dataclass Rule`). Adding a rule is adding an entry to a rules file — no engine changes. Five pattern types cover the detection surface: `RegexPattern` (line-level), `ContextPattern` (co-occurrence), `SequencePattern` (ordered absence), `BlockPattern` (indent-scoped), and `PathPattern` (YAML path queries).

## Network behaviour

Local scans make no network calls. `--fix` calls `git ls-remote` to resolve action tags to commit SHAs. `--platform-audit`, `--github-org`, `--gitlab-group`, and `--transitive` call the GitHub or GitLab API and need a token.

## Requirements

Python 3.10+. No third-party dependencies.

## Resources

| Document | What's in it |
|---|---|
| [`docs/INTEGRATION.md`](docs/INTEGRATION.md) | Nine copy-pasteable CI patterns |
| [`docs/RULES.md`](docs/RULES.md) | Full rule catalog, grouped by family |
| [`docs/ROADMAP.md`](docs/ROADMAP.md) | Phased plan to v2 |
| [`CONTRIBUTING.md`](CONTRIBUTING.md) | Dev setup, rule-authoring template |
| [`CHANGELOG.md`](CHANGELOG.md) | Version history |
| [`SECURITY.md`](SECURITY.md) | Disclosure policy |

## Prior art

Several rule patterns are inspired by — and in places lifted directly from — earlier scanners. Credit where it's due:

- [zizmor](https://github.com/woodruffw/zizmor) — pull_request_target / fork-checkout patterns, `secrets.*` lint surface.
- [poutine](https://github.com/boostsecurityio/poutine) — pipeline poisoning taxonomy, dependency-confusion shapes.
- [actionlint](https://github.com/rhysd/actionlint) — expression-syntax classification, untrusted-context catalogue.

Where taintly's detection logic builds on theirs, the rule's source links to the upstream finding.

## License

MIT
