diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6644fcf --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +.git +.github +.pytest_cache +.coverage +htmlcov +**/__pycache__ +**/*.pyc +*.egg-info +build +dist +tests +.venv +venv +.idea +.vscode +*.md +!README.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..605cce5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,49 @@ +name: Bug report +description: Report a defect in KubeRoast +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for filing a bug. Please include enough information for someone to reproduce. + - type: input + id: version + attributes: + label: KubeRoast version + description: Output of `kuberoast --version` + placeholder: "kuberoast 0.3.0" + validations: + required: true + - type: input + id: python + attributes: + label: Python version + placeholder: "3.12.3" + validations: + required: true + - type: textarea + id: command + attributes: + label: Command run + description: Exact CLI invocation + render: shell + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior (include error output / stack trace) + render: shell + validations: + required: true + - type: textarea + id: extra + attributes: + label: Anything else? + description: Cluster type (kind/minikube/EKS/...), kubeconfig sanitization status, etc. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..b038d3c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,25 @@ +name: Feature request +description: Suggest a new check, output, or capability +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: What problem are you trying to solve? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + - type: textarea + id: references + attributes: + label: References / prior art + description: CIS controls, MITRE ATT&CK techniques, blog posts, CVEs, etc. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..8ab496e --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,25 @@ + + +## Summary + +- + +## Type of change + +- [ ] Bug fix +- [ ] New security check +- [ ] New feature / output format +- [ ] Documentation +- [ ] CI / build / tooling + +## Checklist + +- [ ] Tests added / updated and `make test` passes +- [ ] `make lint` is clean +- [ ] New finding IDs mapped in `kuberoast/utils/compliance.py` +- [ ] README finding tables updated (if applicable) +- [ ] CHANGELOG entry added under `## [Unreleased]` + +## References + + diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..f3b966d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,27 @@ +version: 2 +updates: + - package-ecosystem: pip + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 + labels: + - dependencies + + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 + labels: + - ci + - dependencies + + - package-ecosystem: docker + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 + labels: + - docker + - dependencies diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c979fad --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,112 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + name: Test (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Run tests with coverage (excluding performance) + run: | + pytest --cov=kuberoast --cov-report=xml --cov-report=term -v -m "not performance" + + - name: Run performance regression tests + # Perf tests are non-blocking — slow runners shouldn't fail the matrix + continue-on-error: true + run: | + pytest -v -m performance + + - name: Upload coverage artifact + if: matrix.python-version == '3.12' + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: coverage.xml + + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: Install ruff + run: pip install "ruff>=0.4.0" + + - name: Lint with ruff + run: ruff check kuberoast tests + + - name: Format check with ruff + run: ruff format --check kuberoast tests + + build: + name: Build distribution + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Build wheel and sdist + run: | + python -m pip install --upgrade pip build + python -m build + + - name: Upload distribution + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + docker: + name: Docker build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build image + uses: docker/build-push-action@v6 + with: + context: . + push: false + tags: kuberoast:ci + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml new file mode 100644 index 0000000..2c5d11e --- /dev/null +++ b/.github/workflows/security-scan.yml @@ -0,0 +1,52 @@ +name: Security Scan (SARIF demo) + +# Demonstration of using kuberoast in a CI pipeline against a manifest +# directory and uploading SARIF results to GitHub code scanning. +on: + workflow_dispatch: + inputs: + manifests_path: + description: "Path to manifests to scan (relative to repo root)" + required: false + default: "examples" + +permissions: + contents: read + security-events: write + +jobs: + scan: + name: Scan manifests with KubeRoast + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install KubeRoast + run: pip install -e . + + - name: Scan manifests (SARIF) + run: | + kuberoast \ + --manifests "${{ github.event.inputs.manifests_path }}" \ + --report sarif \ + --out kuberoast.sarif \ + --no-compliance=false || true + + - name: Upload SARIF to GitHub code scanning + if: always() && hashFiles('kuberoast.sarif') != '' + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: kuberoast.sarif + category: kuberoast + + - name: Upload SARIF artifact + if: always() && hashFiles('kuberoast.sarif') != '' + uses: actions/upload-artifact@v4 + with: + name: kuberoast-sarif + path: kuberoast.sarif diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e5c15c7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,63 @@ +# Changelog + +All notable changes to this project are documented here. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.3.0] - 2026-05-08 + +### Added +- **Compliance enrichment** — every finding is automatically tagged with CIS + Kubernetes Benchmark controls, MITRE ATT&CK techniques, and CWE IDs. +- **SARIF v2.1.0 output** (`--report sarif`) for GitHub code scanning, Azure + DevOps, and other static-analysis dashboards. +- **JUnit XML output** (`--report junit`) for Jenkins / GitLab / CircleCI. +- **CSV output** (`--report csv`) for spreadsheets and analytics. +- **Offline manifest scanning** (`--manifests `) — scan YAML/JSON + manifests without a live cluster. Supports Pod, Deployment, StatefulSet, + DaemonSet, Job, CronJob, ReplicaSet, ReplicationController, RBAC, Secret, + Service, Ingress, Namespace, and CRD kinds. +- **Dockerfile** — non-root, multi-stage container image for portable scans. +- **GitHub Actions CI** — test matrix (Python 3.9–3.12), ruff lint, build, and + Docker image build. +- **GitHub Actions security-scan workflow** — example pipeline that uploads + SARIF results to GitHub code scanning. +- **Makefile** with `install`, `dev`, `test`, `coverage`, `lint`, `format`, + `build`, `docker`, `clean` targets. +- **`--version` flag**, `-q/--quiet` flag, ISO-8601 structured log timestamps, + and `--no-compliance` opt-out. +- Richer HTML report with severity stat cards and CIS/MITRE/CWE chips. +- `CONTRIBUTING.md`, `SECURITY.md`, `CHANGELOG.md`, and GitHub issue/PR + templates. + +### Changed +- Bumped package version from 0.2.0 to 0.3.0. +- Text reporter now displays the finding ID, namespace, and compliance + metadata when present. +- HTML reporter redesigned with a summary-stats header and tag chips. + +### Tests +- 146 tests passing (38 baseline → 146 with advanced suites). +- New test categories: + - **End-to-end golden tests** against the bundled `examples/` manifests. + - **Property-based fuzzing** (Hypothesis) of the manifest parser and + scanners — random valid manifests must not crash any scanner. + - **Scanner contract tests** — every scanner returns Findings with + valid IDs, severities, categories, remediations, and (where mapped) + correctly-formatted CIS / MITRE / CWE references. + - **Severity matrix tests** — comprehensive `--fail-on` and + `--min-severity` interaction matrix. + - **SARIF v2.1.0 schema validation** — output is validated against + the official OASIS SARIF schema. + - **Performance regression tests** — 1000-pod scans must complete in + bounded time; deselect with `-m "not performance"`. + +### Notes +This release is backwards-compatible at the CLI level: existing +`--report {json,text,html}` flows continue to work. + +## [0.2.0] + +Initial public release with 30+ checks across Pod Security, RBAC, Network, +Node, Secrets, Policy, and PSS categories. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..32e5912 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,65 @@ +# Contributing to KubeRoast + +Thanks for your interest in making KubeRoast better. This document covers how to set up a dev environment, the testing/linting expectations, and how to add new security checks. + +## Development setup + +```bash +git clone https://github.com/SnailSploit/KubeRoast_v1.git +cd KubeRoast_v1 +make dev +``` + +Or manually: + +```bash +python -m pip install -e ".[dev]" +``` + +## Running tests + +```bash +make test # quick run +make coverage # with coverage report +``` + +KubeRoast targets Python 3.9 through 3.12. CI runs the full matrix on every PR. + +## Linting & formatting + +We use [ruff](https://github.com/astral-sh/ruff) for both lint and format: + +```bash +make lint # check +make format # auto-fix +``` + +Ruff config lives in `pyproject.toml` under `[tool.ruff]`. + +## Adding a new check + +1. Decide which scanner module fits (`kuberoast/scanners/*.py`) or create a new one. +2. Add a `Finding` with a stable, namespaced ID like `POD-NEW-CHECK`. +3. Map the new ID to its CIS Kubernetes Benchmark control(s), MITRE ATT&CK technique(s), and CWE(s) in `kuberoast/utils/compliance.py`. Findings without a mapping still work, just without enrichment. +4. Add unit tests in `tests/` exercising both positive and negative cases. +5. Update the README finding tables. + +### Finding guidelines + +- **Severity** — Ground severity in public guidance (CIS, NIST, vendor docs) or reproducible attacker tradecraft. Don't inflate. Defaults: privilege escalation = critical, data exposure = high, hardening gap = medium/low. +- **Description** — One sentence stating *what* is wrong and *why* it matters. +- **Remediation** — Imperative, copy-pasteable next step. Include the K8s field/manifest snippet when possible. +- **References** — Prefer canonical Kubernetes docs, CIS, NIST, or peer-reviewed write-ups. Avoid vendor blogs unless they are the authoritative source. + +## Pull request checklist + +- [ ] `make test` passes locally +- [ ] `make lint` is clean +- [ ] New checks have unit tests for both detection and non-detection +- [ ] New finding IDs are mapped in `compliance.py` +- [ ] README finding tables updated if applicable +- [ ] CHANGELOG entry added under `## [Unreleased]` + +## Reporting security issues + +See [SECURITY.md](./SECURITY.md). Please do not open public issues for vulnerabilities. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f8e6f2c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM python:3.12-slim AS builder + +WORKDIR /build +COPY pyproject.toml README.md LICENSE ./ +COPY kuberoast ./kuberoast +RUN pip install --no-cache-dir --user . + +FROM python:3.12-slim +LABEL org.opencontainers.image.title="KubeRoast" +LABEL org.opencontainers.image.description="Offensive Kubernetes misconfiguration & attack-path scanner" +LABEL org.opencontainers.image.source="https://github.com/SnailSploit/KubeRoast_v1" +LABEL org.opencontainers.image.licenses="MIT" + +RUN groupadd -g 65532 kuberoast \ + && useradd -u 65532 -g kuberoast -m -s /usr/sbin/nologin kuberoast + +COPY --from=builder /root/.local /home/kuberoast/.local +RUN chown -R kuberoast:kuberoast /home/kuberoast/.local + +USER kuberoast +ENV PATH="/home/kuberoast/.local/bin:${PATH}" +WORKDIR /workspace + +ENTRYPOINT ["kuberoast"] +CMD ["--help"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..00286a2 --- /dev/null +++ b/Makefile @@ -0,0 +1,56 @@ +.PHONY: install dev test test-fast test-perf lint format coverage build docker clean help + +PYTHON ?= python +IMAGE ?= kuberoast +TAG ?= latest + +help: + @echo "Available targets:" + @echo " install Install package (production)" + @echo " dev Install package with dev dependencies" + @echo " test Run all pytest tests" + @echo " test-fast Run pytest excluding performance tests" + @echo " test-perf Run only performance regression tests" + @echo " coverage Run pytest with coverage" + @echo " lint Run ruff lint checks" + @echo " format Auto-format code with ruff" + @echo " build Build wheel + sdist into dist/" + @echo " docker Build Docker image ($(IMAGE):$(TAG))" + @echo " clean Remove build artifacts" + +install: + $(PYTHON) -m pip install . + +dev: + $(PYTHON) -m pip install -e ".[dev]" + +test: + $(PYTHON) -m pytest -v + +test-fast: + $(PYTHON) -m pytest -v -m "not performance" + +test-perf: + $(PYTHON) -m pytest -v -m performance + +coverage: + $(PYTHON) -m pytest --cov=kuberoast --cov-report=term-missing --cov-report=html + +lint: + $(PYTHON) -m ruff check kuberoast tests + $(PYTHON) -m ruff format --check kuberoast tests + +format: + $(PYTHON) -m ruff format kuberoast tests + $(PYTHON) -m ruff check --fix kuberoast tests + +build: + $(PYTHON) -m pip install --upgrade build + $(PYTHON) -m build + +docker: + docker build -t $(IMAGE):$(TAG) . + +clean: + rm -rf build dist *.egg-info .pytest_cache .coverage htmlcov coverage.xml + find . -type d -name __pycache__ -exec rm -rf {} + diff --git a/README.md b/README.md index 947ebc7..d088a62 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,73 @@ -

- Python 3.9+ - MIT License - Tests - Version -

+
-

KubeRoast

+``` + ██╗ ██╗██╗ ██╗██████╗ ███████╗██████╗ ██████╗ █████╗ ███████╗████████╗ + ██║ ██╔╝██║ ██║██╔══██╗██╔════╝██╔══██╗██╔═══██╗██╔══██╗██╔════╝╚══██╔══╝ + █████╔╝ ██║ ██║██████╔╝█████╗ ██████╔╝██║ ██║███████║███████╗ ██║ + ██╔═██╗ ██║ ██║██╔══██╗██╔══╝ ██╔══██╗██║ ██║██╔══██║╚════██║ ██║ + ██║ ██╗╚██████╔╝██████╔╝███████╗██║ ██║╚██████╔╝██║ ██║███████║ ██║ + ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝ ╚═╝ +``` -

- Red-team Kubernetes misconfiguration & attack-path scanner
- Fast, opinionated, read-only. Built for real-world escalation paths. -

+**Offensive Kubernetes misconfig & attack-path scanner.** +Fast · opinionated · read-only · built for real-world escalation paths. -

- Quick Start • - What It Finds • - Usage • - CI/CD • - Output • - Contributing -

+[![Python 3.9+](https://img.shields.io/badge/python-3.9%2B-blue?style=for-the-badge&logo=python&logoColor=white)](#) +[![License MIT](https://img.shields.io/badge/license-MIT-green?style=for-the-badge)](./LICENSE) +[![Version 0.3.0](https://img.shields.io/badge/version-0.3.0-orange?style=for-the-badge)](./CHANGELOG.md) +[![Tests 146](https://img.shields.io/badge/tests-146%20passing-brightgreen?style=for-the-badge)](#) + +[![SARIF v2.1.0](https://img.shields.io/badge/output-SARIF%20v2.1.0-blueviolet?style=flat-square)](#sarif) +[![CIS Kubernetes](https://img.shields.io/badge/maps_to-CIS%20Kubernetes-informational?style=flat-square)](#compliance-mappings) +[![MITRE ATT&CK](https://img.shields.io/badge/maps_to-MITRE%20ATT%26CK-red?style=flat-square)](#compliance-mappings) +[![CWE](https://img.shields.io/badge/maps_to-CWE-yellow?style=flat-square)](#compliance-mappings) + +[Quick Start](#quick-start) · +[What It Finds](#what-it-finds) · +[Usage](#usage) · +[CI/CD](#cicd-integration) · +[Output](#output-formats) · +[Contributing](#contributing) + +
--- -> **Ethical use only.** Run KubeRoast only on clusters you own or have explicit written permission to test. +> ⚠️ **Ethical use only.** Run KubeRoast only on clusters you own or have explicit written permission to test. ## Why KubeRoast Most Kubernetes security scanners generate noise. KubeRoast focuses on **what actually gets you owned** — privilege escalation paths, exposed kubelets, over-permissioned RBAC, network services open to the internet, and secrets sitting in plain sight. It reads, never writes. Safe to run in production. +Every finding is automatically mapped to the **CIS Kubernetes Benchmark**, **MITRE ATT&CK for Containers**, and **CWE**, and reports can be emitted as **SARIF v2.1.0** for direct upload to GitHub code scanning, **JUnit XML** for CI test dashboards, **CSV** for analytics, plus the original JSON / text / HTML formats. KubeRoast also runs **offline against YAML/JSON manifests** so you can shift-left in PR pipelines. + +### What it looks like + +```text + ██╗ ██╗██╗ ██╗██████╗ ███████╗██████╗ ██████╗ █████╗ ███████╗████████╗ + ██║ ██╔╝██║ ██║██╔══██╗██╔════╝██╔══██╗██╔═══██╗██╔══██╗██╔════╝╚══██╔══╝ + █████╔╝ ██║ ██║██████╔╝█████╗ ██████╔╝██║ ██║███████║███████╗ ██║ + ██╔═██╗ ██║ ██║██╔══██╗██╔══╝ ██╔══██╗██║ ██║██╔══██║╚════██║ ██║ + ██║ ██╗╚██████╔╝██████╔╝███████╗██║ ██║╚██████╔╝██║ ██║███████║ ██║ + ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝ ╚═╝ + v0.3.0 • Offensive Kubernetes misconfig & attack-path scanner + +KubeRoast scan results +──────────────────────────────────────────────────────────────────────── +Found 17 issues — 4 critical · 8 high · 5 medium + +✖ CRITICAL (4) +────────────── + [CRITICAL] Privileged container (POD-PRIV) + Resource pod/insecure-demo::app + Namespace default + Description Container runs in privileged mode, granting broad access to the host kernel. + Remediation Remove privileged=true. Grant narrow capabilities only if needed. + CIS CIS-K8s-5.2.1, CIS-K8s-5.2.2 + MITRE T1611, T1610 + CWE CWE-250, CWE-269 +``` + ## Quick Start ```bash @@ -37,11 +76,24 @@ git clone https://github.com/SnailSploit/KubeRoast_v1.git cd KubeRoast_v1 pip install -e . -# Scan your cluster +# Scan your live cluster kuberoast --report text + +# Or scan a directory of manifests (no cluster required) +kuberoast --manifests ./k8s --report text ``` -That's it. KubeRoast picks up your current kubeconfig context automatically. +That's it. Live scans use your current kubeconfig context automatically. + +### Container + +```bash +docker build -t kuberoast . +# Scan manifests mounted at /workspace +docker run --rm -v "$(pwd):/workspace:ro" kuberoast --manifests /workspace --report text +# Scan a cluster using your local kubeconfig +docker run --rm -v "$HOME/.kube:/home/kuberoast/.kube:ro" kuberoast --report text +``` ## What It Finds @@ -127,17 +179,21 @@ kuberoast [OPTIONS] | Flag | Default | Description | |---|---|---| -| `--report {json,text,html}` | `json` | Output format | -| `--out FILE` | — | Write report to file (required for HTML) | +| `--report {json,text,html,sarif,junit,csv}` | `json` | Output format | +| `--out FILE` | — | Write report to file (required for `html`, `sarif`, `junit`, `csv`) | +| `--manifests PATH` | — | Scan a directory or file of YAML/JSON manifests instead of a live cluster | | `--kubeconfig PATH` | — | Path to kubeconfig (defaults to `~/.kube/config`) | | `-n, --namespace NS` | — | Limit scan to a single namespace | | `--min-severity {info,low,medium,high,critical}` | `info` | Filter out findings below this severity | | `--fail-on {info,low,medium,high,critical}` | — | Exit code 1 if any finding meets this threshold | +| `--no-compliance` | `false` | Skip CIS / MITRE ATT&CK / CWE enrichment | | `--skip-nodes` | `false` | Skip kubelet port probes | | `--skip-secrets` | `false` | Skip secret inspection | | `--skip-attack-paths` | `false` | Skip RBAC attack-path analysis | | `--provider {generic,eks,aks,gke}` | `generic` | Cloud provider hint for remediation wording | | `-v, --verbose` | `false` | Progress logging to stderr | +| `-q, --quiet` | `false` | Suppress non-error logging | +| `--version` | — | Print version and exit | ### Examples @@ -166,18 +222,48 @@ kuberoast --fail-on critical --report json > results.json kuberoast -v --skip-nodes --report text ``` +**Offline scan of a manifest directory (no cluster needed):** +```bash +kuberoast --manifests ./k8s --report text +``` + +**SARIF for GitHub code scanning:** +```bash +kuberoast --manifests ./k8s --report sarif --out kuberoast.sarif +``` + +**JUnit XML for Jenkins / GitLab / CircleCI test reports:** +```bash +kuberoast --report junit --out kuberoast.xml +``` + ## CI/CD Integration KubeRoast is designed to gate deployments. Use `--fail-on` to set the threshold: ```yaml -# GitHub Actions example -- name: Security scan +# GitHub Actions — scan manifests in a PR and upload SARIF to code scanning +- uses: actions/checkout@v4 + +- name: Install KubeRoast + run: pip install kuberoast + +- name: Scan manifests run: | - pip install -e . - kuberoast --fail-on high --report json > kuberoast-results.json + kuberoast --manifests ./k8s --report sarif --out kuberoast.sarif + kuberoast --manifests ./k8s --fail-on high --report json > /dev/null + +- name: Upload SARIF + if: always() + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: kuberoast.sarif + category: kuberoast ``` +A ready-to-run version of this workflow lives at +[`.github/workflows/security-scan.yml`](./.github/workflows/security-scan.yml). + ### Exit Codes | Code | Meaning | @@ -207,14 +293,32 @@ Grouped by severity, with summary line and remediation per finding: ``` ### HTML -Dark-themed report with severity badges, sortable table, and remediation guidance. Open in any browser: +Dark-themed report with severity stat cards, severity badges, and CIS/MITRE/CWE chips per finding. Open in any browser: ```bash kuberoast --report html --out report.html && open report.html ``` +### SARIF +[SARIF v2.1.0](https://docs.oasis-open.org/sarif/sarif/v2.1.0/os/sarif-v2.1.0-os.html) for GitHub code scanning, Azure DevOps, and any tool that consumes the standard. Severity is mapped to SARIF `level` (critical/high → `error`, medium → `warning`, low/info → `note`) and a `security-severity` score: +```bash +kuberoast --report sarif --out kuberoast.sarif +``` + +### JUnit XML +For Jenkins, GitLab, CircleCI, and other CI test dashboards. Findings are grouped by category as test suites; critical findings emit ``, high findings emit ``: +```bash +kuberoast --report junit --out kuberoast.xml +``` + +### CSV +Flat tabular output with `id, severity, title, category, namespace, resource, description, remediation, cis_controls, mitre_attack, cwe, references`: +```bash +kuberoast --report csv --out kuberoast.csv +``` + ## Findings Schema -Every finding follows a structured format: +Every finding follows a structured format and is automatically enriched with industry-standard control mappings: ```json { @@ -227,7 +331,10 @@ Every finding follows a structured format: "resource": "pod/web-0::nginx", "metadata": {}, "remediation": "Remove privileged=true. Grant narrow capabilities only if needed.", - "references": ["https://kubernetes.io/docs/concepts/security/pod-security-standards/"] + "references": ["https://kubernetes.io/docs/concepts/security/pod-security-standards/"], + "cis_controls": ["CIS-K8s-5.2.1", "CIS-K8s-5.2.2"], + "mitre_attack": ["T1611", "T1610"], + "cwe": ["CWE-250", "CWE-269"] } ``` @@ -235,6 +342,16 @@ Every finding follows a structured format: **Categories:** Pod Security, RBAC, AttackPath, Network, Node, Secrets, Policy +### Compliance mappings + +Every finding ID is mapped in [`kuberoast/utils/compliance.py`](./kuberoast/utils/compliance.py) to: + +- **CIS Kubernetes Benchmark v1.9** controls (e.g. `5.2.1` for privileged containers) +- **MITRE ATT&CK for Containers** techniques (e.g. `T1611` Escape to Host) +- **CWE** weakness IDs (e.g. `CWE-250` Execution with Unnecessary Privileges) + +Disable enrichment with `--no-compliance` if you need raw findings. + ## Kubernetes RBAC KubeRoast only needs **read access**. Apply this minimal ClusterRole: @@ -286,7 +403,9 @@ Secrets and nodes are optional — KubeRoast continues gracefully if those APIs kuberoast/ cli.py # CLI entry point, arg parsing, orchestration utils/ - findings.py # Pydantic Finding model + findings.py # Pydantic Finding model (with CIS/MITRE/CWE fields) + compliance.py # CIS K8s / MITRE ATT&CK / CWE mappings per finding ID + manifests.py # Offline YAML/JSON manifest loader kube.py # K8s API clients, pagination, error handling scanners/ pods.py # 11 pod-level security checks @@ -302,14 +421,35 @@ kuberoast/ reporting/ json.py # JSON output text.py # Severity-grouped text output - html.py # Dark-themed HTML report + html.py # Dark-themed HTML report with stat cards + sarif.py # SARIF v2.1.0 (GitHub code scanning) + junit.py # JUnit XML (CI test dashboards) + csv_report.py # CSV (analytics / spreadsheets) tests/ test_pods.py # Pod scanner unit tests test_rbac.py # RBAC scanner unit tests test_network.py # Network scanner unit tests test_secrets.py # Secret scanner unit tests test_pss.py # PSS scanner unit tests + test_compliance.py # Compliance enrichment tests + test_sarif.py # SARIF level/score/tag tests + test_sarif_schema.py # SARIF validated against official OASIS schema + test_junit_csv.py # JUnit and CSV output tests + test_manifests.py # Offline manifest loading tests + test_property_manifests.py # Hypothesis property-based fuzzing + test_scanner_contracts.py # Cross-scanner Finding-shape contract tests + test_severity_matrix.py # --fail-on / --min-severity matrix + test_e2e_examples.py # Golden tests against examples/ + test_performance.py # Perf regression (1000 pods etc.) + test_cli.py # CLI flag, exit-code, and end-to-end tests test_reporting.py # Output format tests + fixtures/ + sarif-2.1.0-schema.json # OASIS SARIF v2.1.0 schema (bundled) +.github/workflows/ + ci.yml # Test matrix (3.9–3.12), ruff, build, Docker + security-scan.yml # Example: scan manifests + upload SARIF +Dockerfile # Non-root multi-stage container image +Makefile # install / dev / test / coverage / lint / build / docker ``` ## Troubleshooting @@ -324,21 +464,31 @@ tests/ ## Roadmap -- CIS Kubernetes Benchmark tagging -- Provider-specific remediation (EKS/AKS/GKE) -- Offline manifest scanning (`--manifests`) -- Gatekeeper/Kyverno policy inventory & drift -- MITRE ATT&CK technique tags per finding -- Dockerfile for containerized scanning +Shipped in 0.3.0: +- ✅ CIS Kubernetes Benchmark tagging +- ✅ MITRE ATT&CK technique tags per finding +- ✅ CWE weakness IDs per finding +- ✅ Offline manifest scanning (`--manifests`) +- ✅ Dockerfile for containerized scanning +- ✅ SARIF / JUnit / CSV output + +Next: +- Provider-specific remediation (EKS / AKS / GKE) +- Gatekeeper / Kyverno policy inventory & drift +- NetworkPolicy gap detection +- Helm-chart values rendering for `--manifests` ## Contributing -PRs welcome. Please: +See [CONTRIBUTING.md](./CONTRIBUTING.md) for the full guide. The short version: -1. Add/update unit tests for each new rule +1. Add/update unit tests for each new rule (`make test`) 2. Ground severities in public guidance or reproducible attacker tradecraft -3. Keep remediation text explicit and actionable -4. Run `pytest` before submitting +3. Map new finding IDs to CIS / MITRE / CWE in `kuberoast/utils/compliance.py` +4. Keep remediation text explicit and actionable +5. Run `make lint` and `make test` before submitting + +To report a security issue, see [SECURITY.md](./SECURITY.md). ## License diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..332d97f --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,28 @@ +# Security Policy + +## Supported versions + +The latest minor release of KubeRoast receives security fixes. Older versions are best-effort. + +| Version | Supported | +| ------- | ------------------ | +| 0.3.x | :white_check_mark: | +| < 0.3 | :x: | + +## Reporting a vulnerability + +Please report security issues privately. Do **not** open a public GitHub issue. + +- Use [GitHub Security Advisories](https://github.com/SnailSploit/KubeRoast_v1/security/advisories/new) (preferred), or +- Email the maintainer at the address listed in the repository profile. + +Please include: +- A clear description of the issue and impact +- Steps to reproduce, ideally a minimal proof-of-concept +- Affected versions + +We aim to acknowledge reports within 5 business days and to ship a fix within 30 days for high/critical issues. + +## Scope + +KubeRoast is a read-only scanner. It does not modify cluster state. If you find a code path that issues writes, parses untrusted input unsafely, or leaks credentials, that is in scope. diff --git a/examples/insecure-network.yaml b/examples/insecure-network.yaml new file mode 100644 index 0000000..64a98ef --- /dev/null +++ b/examples/insecure-network.yaml @@ -0,0 +1,30 @@ +apiVersion: v1 +kind: Service +metadata: + name: open-loadbalancer + namespace: default +spec: + type: LoadBalancer + ports: + - port: 80 + targetPort: 80 + selector: + app: web +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: plain-http + namespace: default +spec: + rules: + - host: api.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: open-loadbalancer + port: + number: 80 diff --git a/examples/insecure-pod.yaml b/examples/insecure-pod.yaml new file mode 100644 index 0000000..17a173f --- /dev/null +++ b/examples/insecure-pod.yaml @@ -0,0 +1,21 @@ +apiVersion: v1 +kind: Pod +metadata: + name: insecure-demo + namespace: default +spec: + hostNetwork: true + hostPID: true + containers: + - name: app + image: nginx:latest + securityContext: + privileged: true + runAsUser: 0 + allowPrivilegeEscalation: true + capabilities: + add: ["SYS_ADMIN", "NET_ADMIN"] + volumes: + - name: host-root + hostPath: + path: / diff --git a/examples/insecure-rbac.yaml b/examples/insecure-rbac.yaml new file mode 100644 index 0000000..c87f9e0 --- /dev/null +++ b/examples/insecure-rbac.yaml @@ -0,0 +1,31 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: dangerous-binding +subjects: + - kind: User + name: system:anonymous + apiGroup: rbac.authorization.k8s.io +roleRef: + kind: ClusterRole + name: cluster-admin + apiGroup: rbac.authorization.k8s.io +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: too-broad +rules: + - apiGroups: ["*"] + resources: ["*"] + verbs: ["*"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: can-escalate + namespace: default +rules: + - apiGroups: ["rbac.authorization.k8s.io"] + resources: ["clusterroles"] + verbs: ["escalate", "bind"] diff --git a/kuberoast/__init__.py b/kuberoast/__init__.py index a9a2c5b..3fab6df 100644 --- a/kuberoast/__init__.py +++ b/kuberoast/__init__.py @@ -1 +1,3 @@ -__all__ = [] +"""KubeRoast — offensive Kubernetes misconfiguration & attack-path scanner.""" +__version__ = "0.3.0" +__all__ = ["__version__"] diff --git a/kuberoast/attackpaths/rbac_escalation.py b/kuberoast/attackpaths/rbac_escalation.py index 4924482..40676d6 100644 --- a/kuberoast/attackpaths/rbac_escalation.py +++ b/kuberoast/attackpaths/rbac_escalation.py @@ -1,7 +1,9 @@ -from typing import List, Dict, Set, Tuple, DefaultDict from collections import defaultdict +from typing import DefaultDict, Dict, List, Set, Tuple + from ..utils.findings import Finding + def _principal_id(kind: str, name: str, namespace: str = None) -> str: if kind == "ServiceAccount" and namespace: return f"sa:{namespace}:{name}" diff --git a/kuberoast/cli.py b/kuberoast/cli.py index 5f3451a..a16cbe6 100644 --- a/kuberoast/cli.py +++ b/kuberoast/cli.py @@ -1,26 +1,58 @@ -import argparse, logging, sys -from typing import List -from .utils.kube import load_clients, list_all_pods, list_all_nodes, list_rbac, list_all_secrets, list_all_namespaces, list_all_services, list_all_ingresses, list_all_crds -from .scanners.pods import scan_pod_security +import argparse +import logging +import sys +from typing import List, Optional + +from . import __version__ +from .attackpaths.rbac_escalation import analyze_attack_paths +from .reporting import csv_report +from .reporting import html as html_report +from .reporting import json as json_report +from .reporting import junit as junit_report +from .reporting import sarif as sarif_report +from .reporting import text as text_report +from .scanners.network import scan_ingresses, scan_services from .scanners.nodes import scan_nodes +from .scanners.pods import scan_pod_security +from .scanners.policy import scan_policy_engines +from .scanners.pss import scan_namespace_pss from .scanners.rbac import scan_rbac from .scanners.secrets import scan_secrets -from .scanners.pss import scan_namespace_pss -from .scanners.network import scan_services, scan_ingresses -from .scanners.policy import scan_policy_engines -from .attackpaths.rbac_escalation import analyze_attack_paths -from .reporting import json as json_report, text as text_report, html as html_report +from .utils.compliance import enrich_findings from .utils.findings import Finding +from .utils.kube import ( + list_all_crds, + list_all_ingresses, + list_all_namespaces, + list_all_nodes, + list_all_pods, + list_all_secrets, + list_all_services, + list_rbac, + load_clients, +) +from .utils.manifests import load_manifests +from .utils.style import print_banner logger = logging.getLogger("kuberoast") SEVERITY_ORDER = {"info": 0, "low": 1, "medium": 2, "high": 3, "critical": 4} +REPORT_FORMATS = { + "json": json_report.emit, + "text": text_report.emit, + "html": html_report.emit, + "sarif": sarif_report.emit, + "junit": junit_report.emit, + "csv": csv_report.emit, +} + def run_cluster_scan(args) -> List[Finding]: ns = getattr(args, "namespace", None) clients = load_clients(kubeconfig=getattr(args, "kubeconfig", None)) - core, rbac_api, networking, apiext = clients["core"], clients["rbac"], clients["networking"], clients["apiextensions"] + core, rbac_api = clients["core"], clients["rbac"] + networking, apiext = clients["networking"], clients["apiextensions"] findings: List[Finding] = [] logger.info("Scanning pods...") @@ -65,71 +97,147 @@ def run_cluster_scan(args) -> List[Finding]: return findings +def run_manifest_scan(path: str, args) -> List[Finding]: + logger.info("Loading manifests from %s...", path) + objects = load_manifests(path) + findings: List[Finding] = [] + + for pod in objects["pods"]: + findings.extend(scan_pod_security(pod)) + + if objects["namespaces"]: + findings.extend(scan_namespace_pss(objects["namespaces"])) + + findings.extend( + scan_rbac( + objects["roles"], + objects["cluster_roles"], + objects["role_bindings"], + objects["cluster_role_bindings"], + ) + ) + + if not args.skip_secrets and objects["secrets"]: + findings.extend(scan_secrets(objects["secrets"])) + + if not args.skip_attack_paths: + findings.extend( + analyze_attack_paths( + objects["roles"], + objects["cluster_roles"], + objects["role_bindings"], + objects["cluster_role_bindings"], + objects["pods"], + ) + ) + + findings.extend(scan_services(objects["services"])) + findings.extend(scan_ingresses(objects["ingresses"])) + + if objects["crds"]: + findings.extend(scan_policy_engines(objects["crds"])) + + return findings + + def _max_severity(findings: List[Finding]) -> int: if not findings: return 0 return max(SEVERITY_ORDER.get(f.severity, 0) for f in findings) -def main(argv=None) -> int: - ap = argparse.ArgumentParser(description="kuberoast - offensive K8s misconfig & attack-path scanner") - ap.add_argument("--report", choices=["json", "text", "html"], default="json", help="Output format") - ap.add_argument("--out", help="Write report to file (required for HTML)") +def build_parser() -> argparse.ArgumentParser: + ap = argparse.ArgumentParser( + prog="kuberoast", + description="KubeRoast — offensive Kubernetes misconfiguration & attack-path scanner", + epilog="Run safely. Read-only by design. Ethical use only.", + ) + ap.add_argument("--version", action="version", version=f"kuberoast {__version__}") + ap.add_argument("--no-banner", action="store_true", + help="Suppress the startup banner") + ap.add_argument("--report", choices=sorted(REPORT_FORMATS.keys()), default="json", + help="Output format") + ap.add_argument("--out", help="Write report to file (required for HTML/SARIF/JUnit/CSV)") ap.add_argument("--skip-nodes", action="store_true", help="Skip node/kubelet probes") ap.add_argument("--skip-secrets", action="store_true", help="Skip secret heuristics") ap.add_argument("--skip-attack-paths", action="store_true", help="Skip RBAC attack-path analysis") - ap.add_argument("--manifests", help="Directory of YAML/JSON manifests to scan (MVP)") + ap.add_argument("--manifests", + help="Scan a directory or file of YAML/JSON Kubernetes manifests instead of a live cluster") ap.add_argument("--provider", choices=["generic", "eks", "aks", "gke"], default="generic", help="Cloud provider for context-aware remediation advice") ap.add_argument("--kubeconfig", help="Path to kubeconfig file (defaults to ~/.kube/config)") ap.add_argument("--namespace", "-n", help="Limit scan to a specific namespace") - ap.add_argument("--min-severity", choices=["info", "low", "medium", "high", "critical"], - default="info", help="Only include findings at or above this severity") - ap.add_argument("--fail-on", choices=["info", "low", "medium", "high", "critical"], - default=None, help="Exit with code 1 if any finding meets or exceeds this severity") + ap.add_argument("--min-severity", choices=list(SEVERITY_ORDER.keys()), default="info", + help="Only include findings at or above this severity") + ap.add_argument("--fail-on", choices=list(SEVERITY_ORDER.keys()), default=None, + help="Exit with code 1 if any finding meets or exceeds this severity") + ap.add_argument("--no-compliance", action="store_true", + help="Skip CIS / MITRE ATT&CK / CWE enrichment") ap.add_argument("-v", "--verbose", action="store_true", help="Enable verbose logging") - args = ap.parse_args(argv) + ap.add_argument("-q", "--quiet", action="store_true", help="Suppress non-error logging") + return ap + +def main(argv: Optional[List[str]] = None) -> int: + args = build_parser().parse_args(argv) + + if args.quiet: + log_level = logging.ERROR + elif args.verbose: + log_level = logging.DEBUG + else: + log_level = logging.WARNING logging.basicConfig( - level=logging.DEBUG if args.verbose else logging.WARNING, - format="%(levelname)s: %(message)s", + level=log_level, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + datefmt="%Y-%m-%dT%H:%M:%S%z", stream=sys.stderr, ) - if args.manifests: - logger.error("Manifest mode not yet implemented. Use cluster mode.") - return 2 - - if args.report == "html" and not args.out: - logger.error("--report html requires --out FILE") + # Banner: only when running interactively to text/html, never when piping + # machine-readable output, and never under --quiet or --no-banner. + if ( + not args.no_banner + and not args.quiet + and args.report in {"text", "html"} + and sys.stderr.isatty() + ): + print_banner() + + if args.report in {"html", "sarif", "junit", "csv"} and not args.out: + logger.error("--report %s requires --out FILE", args.report) return 2 try: - findings = run_cluster_scan(args) + if args.manifests: + findings = run_manifest_scan(args.manifests, args) + else: + findings = run_cluster_scan(args) + except FileNotFoundError as e: + logger.error("%s", e) + return 2 except Exception as e: logger.error("Scan failed: %s", e) + if args.verbose: + logger.exception("Traceback:") return 2 - # Filter by minimum severity + if not args.no_compliance: + enrich_findings(findings) + min_sev = SEVERITY_ORDER[args.min_severity] findings = [f for f in findings if SEVERITY_ORDER.get(f.severity, 0) >= min_sev] - # Pass provider to reporters for context-aware remediation - if args.report == "json": - output = json_report.emit(findings) - elif args.report == "html": - output = html_report.emit(findings) - else: - output = text_report.emit(findings) + emit = REPORT_FORMATS[args.report] + output = emit(findings) if args.out: - with open(args.out, "w") as f: - f.write(output) - print(f"Report written to {args.out}", file=sys.stderr) + with open(args.out, "w", encoding="utf-8") as fp: + fp.write(output) + logger.warning("Report written to %s (%d findings)", args.out, len(findings)) else: print(output) - # Exit code based on --fail-on threshold if args.fail_on: threshold = SEVERITY_ORDER[args.fail_on] if _max_severity(findings) >= threshold: @@ -137,5 +245,6 @@ def main(argv=None) -> int: return 0 + if __name__ == "__main__": raise SystemExit(main()) diff --git a/kuberoast/reporting/csv_report.py b/kuberoast/reporting/csv_report.py new file mode 100644 index 0000000..0c377d3 --- /dev/null +++ b/kuberoast/reporting/csv_report.py @@ -0,0 +1,45 @@ +"""CSV output for spreadsheets and analytics.""" +import csv +import io +from typing import List + +from ..utils.findings import Finding + +COLUMNS = [ + "id", + "severity", + "title", + "category", + "namespace", + "resource", + "description", + "remediation", + "cis_controls", + "mitre_attack", + "cwe", + "references", +] + + +def emit(findings: List[Finding]) -> str: + buf = io.StringIO() + writer = csv.writer(buf, quoting=csv.QUOTE_MINIMAL, lineterminator="\n") + writer.writerow(COLUMNS) + for f in findings: + writer.writerow( + [ + f.id, + f.severity, + f.title, + f.category, + f.namespace or "", + f.resource or "", + f.description, + f.remediation or "", + ";".join(f.cis_controls), + ";".join(f.mitre_attack), + ";".join(f.cwe), + ";".join(f.references), + ] + ) + return buf.getvalue() diff --git a/kuberoast/reporting/html.py b/kuberoast/reporting/html.py index 67c7346..f7ab96f 100644 --- a/kuberoast/reporting/html.py +++ b/kuberoast/reporting/html.py @@ -1,58 +1,204 @@ +"""HTML reporter: dark-themed, single-file, severity-aware report.""" +from __future__ import annotations + +import datetime +import html +from collections import Counter from typing import List + +from .. import __version__ from ..utils.findings import Finding -import html CSS = """ -:root{ --bg:#0b0d10; --card:#13161a; --text:#eef1f5; --muted:#a7b0bf; --hi:#ff7272; --md:#ffb84d; --lo:#7ed957; } -*{ box-sizing:border-box; } -body{ margin:32px; padding:0; background:var(--bg); color:var(--text); font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,'Helvetica Neue',Arial,'Noto Sans',sans-serif; line-height:1.5; } -h1{ font-size:1.8rem; margin:0 0 16px 0; } -.summary{ color:var(--muted); margin-bottom:24px; } -.table{ width:100%; border-collapse:separate; border-spacing:0; background:var(--card); border-radius:16px; overflow:hidden; } -thead th{ text-align:left; font-weight:600; padding:14px 16px; border-bottom:1px solid rgba(255,255,255,.08); } -tbody td{ vertical-align:top; padding:14px 16px; border-top:1px solid rgba(255,255,255,.05); } -tbody tr:first-child td{ border-top:none; } -.sev{ font-weight:700; } -.badge{ display:inline-block; padding:.15rem .5rem; border-radius:999px; font-size:.8rem; } -.badge.CRITICAL{ background:var(--hi); color:#1a1a1a; } -.badge.HIGH{ background:#ff8a65; color:#1a1a1a; } -.badge.MEDIUM{ background:var(--md); color:#1a1a1a; } -.badge.LOW{ background:#61c0ff; color:#1a1a1a; } -.badge.INFO{ background:#bdbdbd; color:#1a1a1a; } -.meta{ color:var(--muted); font-size:.9rem; margin-top:6px; } -.footer{ color:var(--muted); margin-top:24px; font-size:.85rem; } +:root{ + --bg:#0a0c10; --bg2:#0f1218; --card:#13161e; --text:#eef1f5; --muted:#9aa3b2; + --border:rgba(255,255,255,.06); --accent:#a78bfa; --link:#7dd3fc; + --crit:#ff4d6d; --high:#ff8a4c; --med:#ffc857; --low:#5eb3ff; --info:#a0a7b3; +} +*{box-sizing:border-box;} +body{ + margin:0; padding:0; background:linear-gradient(180deg,var(--bg),var(--bg2)); + color:var(--text); font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Inter,sans-serif; + line-height:1.55; min-height:100vh; +} +.wrap{max-width:1200px; margin:0 auto; padding:40px 28px 80px;} +header{display:flex; align-items:flex-end; justify-content:space-between; gap:24px; flex-wrap:wrap; margin-bottom:24px;} +.brand{display:flex; align-items:center; gap:14px;} +.logo{ + width:44px; height:44px; border-radius:12px; flex-shrink:0; + background:conic-gradient(from 220deg,#a78bfa,#ff4d6d,#ffc857,#a78bfa); + display:grid; place-items:center; font-weight:900; color:#0a0c10; font-size:1.1rem; + box-shadow:0 6px 20px rgba(167,139,250,.25); +} +h1{margin:0; font-size:1.6rem; letter-spacing:-.02em;} +.tag{color:var(--muted); font-size:.92rem; margin-top:2px;} +.meta{color:var(--muted); font-size:.85rem; text-align:right;} +.bar{ + display:flex; height:6px; border-radius:999px; overflow:hidden; + background:rgba(255,255,255,.04); margin:18px 0 24px; +} +.bar > span{display:block; height:100%;} +.bar .crit{background:var(--crit);} +.bar .high{background:var(--high);} +.bar .med{background:var(--med);} +.bar .low{background:var(--low);} +.bar .info{background:var(--info);} +.summary{display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:12px; margin-bottom:24px;} +.stat{ + background:var(--card); padding:14px 16px; border-radius:14px; border:1px solid var(--border); + position:relative; overflow:hidden; +} +.stat::before{ + content:""; position:absolute; left:0; top:0; bottom:0; width:3px; opacity:.85; +} +.stat.total::before{background:var(--accent);} +.stat.critical::before{background:var(--crit);} +.stat.high::before{background:var(--high);} +.stat.medium::before{background:var(--med);} +.stat.low::before{background:var(--low);} +.stat.info::before{background:var(--info);} +.stat .label{color:var(--muted); font-size:.75rem; text-transform:uppercase; letter-spacing:.08em;} +.stat .value{font-size:1.7rem; font-weight:800; margin-top:2px; letter-spacing:-.02em;} +table{ + width:100%; border-collapse:separate; border-spacing:0; + background:var(--card); border-radius:16px; overflow:hidden; border:1px solid var(--border); +} +thead th{ + text-align:left; font-weight:600; padding:14px 18px; border-bottom:1px solid var(--border); + background:rgba(255,255,255,.02); font-size:.78rem; text-transform:uppercase; letter-spacing:.08em; + color:var(--muted); +} +tbody td{vertical-align:top; padding:18px; border-top:1px solid rgba(255,255,255,.04);} +tbody tr:first-child td{border-top:none;} +tbody tr:hover{background:rgba(255,255,255,.02);} +.badge{ + display:inline-block; padding:.18rem .55rem; border-radius:999px; font-size:.7rem; + font-weight:800; text-transform:uppercase; letter-spacing:.08em; +} +.badge.CRITICAL{background:var(--crit); color:#1a1a1a;} +.badge.HIGH{background:var(--high); color:#1a1a1a;} +.badge.MEDIUM{background:var(--med); color:#1a1a1a;} +.badge.LOW{background:var(--low); color:#1a1a1a;} +.badge.INFO{background:var(--info); color:#1a1a1a;} +.title{font-weight:700; color:var(--text);} +.id{font-family:ui-monospace,SFMono-Regular,Menlo,monospace; color:var(--muted); font-size:.78rem; margin-top:4px;} +.tag-chip{ + display:inline-block; padding:2px 8px; border-radius:6px; + background:rgba(167,139,250,.10); color:var(--link); font-size:.72rem; + font-family:ui-monospace,monospace; margin:2px 4px 2px 0; border:1px solid rgba(125,211,252,.18); +} +.meta-row{color:var(--muted); font-size:.85rem; margin-top:8px;} +.meta-row strong{color:#cbd5e1;} +.remediation{ + margin-top:8px; padding:10px 12px; border-radius:8px; + background:rgba(94,179,255,.07); border:1px solid rgba(94,179,255,.18); + font-size:.88rem; +} +.empty{padding:48px; text-align:center; color:var(--muted);} +footer{color:var(--muted); margin-top:24px; font-size:.82rem; text-align:center;} +footer a{color:var(--link); text-decoration:none;} +@media (max-width:640px){ + thead{display:none;} + tbody td{display:block; padding:10px 18px;} + tbody tr{display:block; padding:14px 0;} +} """ + def _sev_badge(sev: str) -> str: s = (sev or "info").upper() return f"{html.escape(s)}" -def emit(findings: List[Finding], title: str = "kuberoast2 report") -> str: + +def _tags(items: List[str]) -> str: + return "".join(f"{html.escape(i)}" for i in items) + + +def _stat(label: str, value: int, css: str = "") -> str: + return ( + f"
" + f"
{html.escape(label)}
" + f"
{value}
" + ) + + +def _bar(counts: Counter, total: int) -> str: + if not total: + return "" + segments = [] + for sev_class, sev_key in [("crit", "critical"), ("high", "high"), ("med", "medium"), ("low", "low"), ("info", "info")]: + n = counts.get(sev_key, 0) + if not n: + continue + pct = (n / total) * 100 + segments.append(f"") + return f"" + + +def emit(findings: List[Finding], title: str = "KubeRoast Report") -> str: + counts = Counter(f.severity for f in findings) + now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + + summary_html = "".join([ + _stat("Total", len(findings), "total"), + _stat("Critical", counts.get("critical", 0), "critical"), + _stat("High", counts.get("high", 0), "high"), + _stat("Medium", counts.get("medium", 0), "medium"), + _stat("Low", counts.get("low", 0), "low"), + _stat("Info", counts.get("info", 0), "info"), + ]) + rows = [] for f in findings: + compliance: list = [] + if f.cis_controls: + compliance.append("
CIS Kubernetes: " + _tags(f.cis_controls) + "
") + if f.mitre_attack: + compliance.append("
MITRE ATT&CK: " + _tags(f.mitre_attack) + "
") + if f.cwe: + compliance.append("
CWE: " + _tags(f.cwe) + "
") + if f.namespace: + compliance.append(f"
Namespace: {html.escape(f.namespace)}
") rows.append( "" - f"{_sev_badge(f.severity)}" - f"
{html.escape(f.title or '')}
{html.escape(f.category or '')}
" - f"{html.escape(f.resource or '-')}" + f"{_sev_badge(f.severity)}" + f"
{html.escape(f.title or '')}
" + f"
{html.escape(f.id)} · {html.escape(f.category or '')}
" + f"{html.escape(f.resource or '-')}" f"
{html.escape(f.description or '')}
" - + (f"
Remediation: {html.escape(f.remediation or '')}
" if f.remediation else "") + + (f"
Remediation: {html.escape(f.remediation or '')}
" + if f.remediation else "") + + "".join(compliance) + "" "" ) - body = ( - "" - "" - f"{html.escape(title)}" - "" - f"

{html.escape(title)}

" - f"
Findings: {len(findings)}
" - "" - "" + body_table = ( + "
SeverityTitleResourceDescription
" + "" "" - + "".join(rows) + - "
SeverityFindingResourceDetails
Generated by kuberoast2
" - "" + + ("".join(rows) if rows else + "No findings.") + + "" + ) + + page = ( + '' + '' + f'{html.escape(title)}' + '
' + '
' + '
' + '' + f'

{html.escape(title)}

' + f'
Offensive Kubernetes misconfig & attack-path scanner
' + '
' + f'
v{html.escape(__version__)}
{html.escape(now)}
' + '
' + + _bar(counts, len(findings)) + + f'
{summary_html}
' + + body_table + + f'' + '
' ) - return body + return page diff --git a/kuberoast/reporting/json.py b/kuberoast/reporting/json.py index fb9e18e..537f065 100644 --- a/kuberoast/reporting/json.py +++ b/kuberoast/reporting/json.py @@ -1,6 +1,8 @@ import json from typing import List + from ..utils.findings import Finding + def emit(findings: List[Finding]) -> str: return json.dumps([f.model_dump() for f in findings], indent=2) diff --git a/kuberoast/reporting/junit.py b/kuberoast/reporting/junit.py new file mode 100644 index 0000000..2624202 --- /dev/null +++ b/kuberoast/reporting/junit.py @@ -0,0 +1,57 @@ +"""JUnit XML output for CI test reporting (Jenkins, GitLab, CircleCI, etc.).""" +import html as _html +from collections import defaultdict +from typing import List +from xml.sax.saxutils import quoteattr + +from ..utils.findings import Finding + + +def _escape(text: str) -> str: + return _html.escape(text or "", quote=False) + + +def emit(findings: List[Finding]) -> str: + """Group findings by category as test suites; each finding is a failed test case.""" + by_category: dict = defaultdict(list) + for f in findings: + by_category[f.category or "general"].append(f) + + total = len(findings) + failures = sum(1 for f in findings if f.severity in ("critical", "high")) + errors = sum(1 for f in findings if f.severity == "critical") + + lines = [''] + lines.append( + f'' + ) + + for category, items in sorted(by_category.items()): + cat_failures = sum(1 for f in items if f.severity in ("critical", "high")) + cat_errors = sum(1 for f in items if f.severity == "critical") + lines.append( + f' ' + ) + for f in items: + classname = quoteattr(f.category or "general") + test_name = quoteattr(f"{f.id}: {f.title}") + lines.append(f" ") + failure_message = quoteattr(f"[{f.severity.upper()}] {f.title}") + failure_type = quoteattr(f.id) + body = _escape( + f"{f.description}\n" + f"Resource: {f.resource or '-'}\n" + f"Namespace: {f.namespace or '-'}\n" + f"Remediation: {f.remediation or '-'}" + ) + tag = "error" if f.severity == "critical" else "failure" + lines.append( + f" <{tag} message={failure_message} type={failure_type}>{body}" + ) + lines.append(" ") + lines.append(" ") + + lines.append("") + return "\n".join(lines) diff --git a/kuberoast/reporting/sarif.py b/kuberoast/reporting/sarif.py new file mode 100644 index 0000000..e728dd7 --- /dev/null +++ b/kuberoast/reporting/sarif.py @@ -0,0 +1,139 @@ +"""SARIF v2.1.0 output for GitHub code scanning, Azure DevOps, and other tools. + +Spec: https://docs.oasis-open.org/sarif/sarif/v2.1.0/os/sarif-v2.1.0-os.html +""" +import json +from typing import List + +from .. import __version__ +from ..utils.findings import Finding + +SARIF_VERSION = "2.1.0" +SARIF_SCHEMA = "https://json.schemastore.org/sarif-2.1.0.json" + +# SARIF level mapping (note: SARIF only has error/warning/note/none) +SEVERITY_TO_LEVEL = { + "critical": "error", + "high": "error", + "medium": "warning", + "low": "note", + "info": "note", +} + +SEVERITY_TO_SCORE = { + "critical": "9.5", + "high": "7.5", + "medium": "5.0", + "low": "3.0", + "info": "0.0", +} + + +def _build_rules(findings: List[Finding]) -> List[dict]: + """Build a unique set of SARIF rule objects from finding IDs.""" + seen: dict = {} + for f in findings: + if f.id in seen: + continue + rule = { + "id": f.id, + "name": f.id.replace("-", ""), + "shortDescription": {"text": f.title}, + "fullDescription": {"text": f.description}, + "help": { + "text": f.remediation or "See references for remediation guidance.", + "markdown": _help_markdown(f), + }, + "defaultConfiguration": {"level": SEVERITY_TO_LEVEL.get(f.severity, "warning")}, + "properties": { + "category": f.category, + "security-severity": SEVERITY_TO_SCORE.get(f.severity, "5.0"), + "tags": _build_tags(f), + }, + } + if f.references: + rule["helpUri"] = f.references[0] + seen[f.id] = rule + return list(seen.values()) + + +def _build_tags(f: Finding) -> List[str]: + tags = ["security", f.category.lower().replace(" ", "-")] + tags.extend(f.cis_controls) + tags.extend(f.mitre_attack) + tags.extend(f.cwe) + return tags + + +def _help_markdown(f: Finding) -> str: + parts = [f"**{f.title}**", "", f.description] + if f.remediation: + parts.extend(["", f"**Remediation:** {f.remediation}"]) + if f.cis_controls: + parts.extend(["", f"**CIS Kubernetes Benchmark:** {', '.join(f.cis_controls)}"]) + if f.mitre_attack: + parts.extend(["", f"**MITRE ATT&CK:** {', '.join(f.mitre_attack)}"]) + if f.cwe: + parts.extend(["", f"**CWE:** {', '.join(f.cwe)}"]) + if f.references: + parts.append("") + parts.append("**References:**") + for ref in f.references: + parts.append(f"- {ref}") + return "\n".join(parts) + + +def _build_result(f: Finding) -> dict: + location_uri = f.resource or "cluster" + if f.namespace and f.resource: + location_uri = f"{f.namespace}/{f.resource}" + result = { + "ruleId": f.id, + "level": SEVERITY_TO_LEVEL.get(f.severity, "warning"), + "message": {"text": f.description}, + "locations": [ + { + "physicalLocation": { + "artifactLocation": {"uri": location_uri, "uriBaseId": "%SRCROOT%"}, + "region": {"startLine": 1}, + } + } + ], + "properties": { + "severity": f.severity, + "category": f.category, + "namespace": f.namespace or "", + "kuberoast-id": f.id, + }, + } + if f.metadata: + result["properties"].update(f.metadata) + return result + + +def emit(findings: List[Finding]) -> str: + rules = _build_rules(findings) + results = [_build_result(f) for f in findings] + + sarif = { + "$schema": SARIF_SCHEMA, + "version": SARIF_VERSION, + "runs": [ + { + "tool": { + "driver": { + "name": "KubeRoast", + "version": __version__, + "informationUri": "https://github.com/SnailSploit/KubeRoast_v1", + "rules": rules, + "shortDescription": { + "text": "Offensive Kubernetes misconfiguration & attack-path scanner" + }, + } + }, + "results": results, + "columnKind": "utf16CodeUnits", + } + ], + } + return json.dumps(sarif, indent=2) diff --git a/kuberoast/reporting/text.py b/kuberoast/reporting/text.py index 91ec2f7..07eb4a7 100644 --- a/kuberoast/reporting/text.py +++ b/kuberoast/reporting/text.py @@ -1,34 +1,110 @@ +"""Text reporter: severity-grouped, color-aware human-readable output.""" +from __future__ import annotations + +import sys from collections import Counter -from typing import List +from typing import IO, List, Optional + from ..utils.findings import Finding +from ..utils.style import ( + SEVERITY_COLOR, + _colors_enabled, + color, + severity_badge, +) SEVERITY_ORDER = ["critical", "high", "medium", "low", "info"] +SEVERITY_GLYPHS = { + "critical": "✖", + "high": "▲", + "medium": "●", + "low": "○", + "info": "·", +} -def emit(findings: List[Finding]) -> str: - if not findings: - return "No findings." +def _rule(stream: Optional[IO]) -> str: + return color("─" * 72, "gray", stream=stream) + + +def _summary(counts: Counter, total: int, stream: Optional[IO]) -> str: + """One-line summary of counts by severity, e.g. + 'Found 12 issues — 3 critical · 4 high · 5 medium'.""" + parts = [] + for sev in SEVERITY_ORDER: + n = counts.get(sev, 0) + if not n: + continue + parts.append(color(f"{n} {sev}", SEVERITY_COLOR[sev], bold=True, stream=stream)) + detail = color(" · ", "gray", stream=stream).join(parts) if parts else "no findings" + headline = color(f"Found {total} issue{'s' if total != 1 else ''}", "white", bold=True, stream=stream) + arrow = color("—", "gray", stream=stream) + return f"{headline} {arrow} {detail}" - lines: list[str] = [] - # Summary header +def emit(findings: List[Finding], *, stream: Optional[IO] = None) -> str: + """Render findings as text. Colors auto-enable on TTYs; opt out via NO_COLOR.""" + target = stream if stream is not None else sys.stdout + + if not findings: + glyph = color("✓", "green", bold=True, stream=target) + return f"{glyph} {color('No findings.', 'green', stream=target)}\n" + counts = Counter(f.severity for f in findings) - summary_parts = [f"{counts.get(s, 0)} {s}" for s in SEVERITY_ORDER if counts.get(s, 0)] - lines.append(f"=== kuberoast scan: {len(findings)} findings ({', '.join(summary_parts)}) ===") + lines: list = [] + + title = color("KubeRoast scan results", "magenta", bold=True, stream=target) + lines.append(title) + lines.append(_rule(target)) + lines.append(_summary(counts, len(findings), target)) lines.append("") - # Group by severity, ordered critical -> info for sev in SEVERITY_ORDER: group = [f for f in findings if f.severity == sev] if not group: continue - lines.append(f"--- {sev.upper()} ({len(group)}) ---") + glyph = SEVERITY_GLYPHS[sev] + header = color( + f"{glyph} {sev.upper()} ({len(group)})", + SEVERITY_COLOR[sev], + bold=True, + stream=target, + ) + lines.append(header) + lines.append(color("─" * (len(sev) + 8), "gray", stream=target)) for f in group: - lines.append(f" [{f.severity.upper()}] {f.title}") - lines.append(f" Resource: {f.resource or '-'}") - lines.append(f" Description: {f.description}") + badge = severity_badge(f.severity, stream=target) + title_part = color(f.title, "white", bold=True, stream=target) + id_part = color(f"({f.id})", "gray", stream=target) + lines.append(f" {badge} {title_part} {id_part}") + _line(lines, "Resource", f.resource or "-", target) + if f.namespace: + _line(lines, "Namespace", f.namespace, target) + _line(lines, "Description", f.description, target) if f.remediation: - lines.append(f" Remediation: {f.remediation}") + _line(lines, "Remediation", f.remediation, target, value_color="green") + if f.cis_controls: + _line(lines, "CIS", ", ".join(f.cis_controls), target, value_color="cyan") + if f.mitre_attack: + _line(lines, "MITRE", ", ".join(f.mitre_attack), target, value_color="cyan") + if f.cwe: + _line(lines, "CWE", ", ".join(f.cwe), target, value_color="cyan") lines.append("") return "\n".join(lines) + + +def _line( + lines: list, + label: str, + value: str, + stream: Optional[IO], + *, + value_color: Optional[str] = None, +) -> None: + label_str = color(f"{label:<11}", "gray", stream=stream) + value_str = color(value, value_color, stream=stream) if value_color else value + # When colors are disabled, indentation alignment still holds. + if not _colors_enabled(stream): + label_str = f"{label:<11}" + lines.append(f" {label_str} {value_str}") diff --git a/kuberoast/scanners/network.py b/kuberoast/scanners/network.py index 8947c80..52f5c23 100644 --- a/kuberoast/scanners/network.py +++ b/kuberoast/scanners/network.py @@ -1,6 +1,8 @@ from typing import List + from ..utils.findings import Finding + def scan_services(services) -> List[Finding]: """Scan Kubernetes Services for network exposure risks.""" findings: List[Finding] = [] diff --git a/kuberoast/scanners/nodes.py b/kuberoast/scanners/nodes.py index d55c7df..893c25f 100644 --- a/kuberoast/scanners/nodes.py +++ b/kuberoast/scanners/nodes.py @@ -1,7 +1,8 @@ import logging import socket from concurrent.futures import ThreadPoolExecutor, as_completed -from typing import List, Tuple, Optional +from typing import List, Tuple + from ..utils.findings import Finding logger = logging.getLogger("kuberoast") diff --git a/kuberoast/scanners/pods.py b/kuberoast/scanners/pods.py index 539b3ea..d269dff 100644 --- a/kuberoast/scanners/pods.py +++ b/kuberoast/scanners/pods.py @@ -1,6 +1,7 @@ from typing import List, Set -from .shared import iter_containers + from ..utils.findings import Finding +from .shared import iter_containers DANGEROUS_CAPS: Set[str] = { "SYS_ADMIN","SYS_MODULE","SYS_PTRACE","NET_ADMIN","DAC_READ_SEARCH","SYS_RAWIO" diff --git a/kuberoast/scanners/policy.py b/kuberoast/scanners/policy.py index dd4312b..371af60 100644 --- a/kuberoast/scanners/policy.py +++ b/kuberoast/scanners/policy.py @@ -1,4 +1,5 @@ from typing import List + from ..utils.findings import Finding POLICY_ENGINE_CRDS = { diff --git a/kuberoast/scanners/pss.py b/kuberoast/scanners/pss.py index 2e20600..8530693 100644 --- a/kuberoast/scanners/pss.py +++ b/kuberoast/scanners/pss.py @@ -1,4 +1,5 @@ from typing import List, Set + from ..utils.findings import Finding PSS_LABELS = [ @@ -19,7 +20,7 @@ def scan_namespace_pss(nslist) -> List[Finding]: for ns in nslist: ns_name = ns.metadata.name labels = (ns.metadata.labels or {}) - if not any(l in labels for l in PSS_LABELS): + if not any(label in labels for label in PSS_LABELS): is_system = ns_name in SYSTEM_NAMESPACES findings.append(Finding( id="PSS-NOT-ENFORCED", diff --git a/kuberoast/scanners/rbac.py b/kuberoast/scanners/rbac.py index 3bb7bd7..86c853c 100644 --- a/kuberoast/scanners/rbac.py +++ b/kuberoast/scanners/rbac.py @@ -1,5 +1,5 @@ -from typing import List, Dict, Set, Tuple, DefaultDict -from collections import defaultdict +from typing import List, Set, Tuple + from ..utils.findings import Finding SUS_VERBS_ESCALATE = {"escalate", "bind", "impersonate"} @@ -21,7 +21,6 @@ def bind_check(binding, is_cluster=False): role_name = f"{role_ref.kind}/{role_ref.name}" bname = f"{'CRB' if is_cluster else 'RB'}/{binding.metadata.name}" for s in (binding.subjects or []): - subj = f"{s.kind}:{s.namespace+'/'+s.name if getattr(s,'namespace',None) else s.name}" if s.kind == "Group" and s.name in {"system:unauthenticated","system:authenticated","*"}: findings.append(Finding( id="RBAC-BROAD-GROUP", diff --git a/kuberoast/scanners/secrets.py b/kuberoast/scanners/secrets.py index aa05927..92d6b91 100644 --- a/kuberoast/scanners/secrets.py +++ b/kuberoast/scanners/secrets.py @@ -1,5 +1,8 @@ -import base64, re, json +import base64 +import json +import re from typing import List + from ..utils.findings import Finding SUSPICIOUS_SECRET_KEYS = re.compile(r""" diff --git a/kuberoast/scanners/shared.py b/kuberoast/scanners/shared.py index 365a100..17ea490 100644 --- a/kuberoast/scanners/shared.py +++ b/kuberoast/scanners/shared.py @@ -1,4 +1,6 @@ -from typing import Iterator, Tuple +from collections.abc import Iterator +from typing import Tuple + def iter_containers(pod) -> Iterator[Tuple[object, str]]: # main containers diff --git a/kuberoast/utils/compliance.py b/kuberoast/utils/compliance.py new file mode 100644 index 0000000..3eccb2d --- /dev/null +++ b/kuberoast/utils/compliance.py @@ -0,0 +1,189 @@ +"""Compliance framework mappings: CIS Kubernetes Benchmark, MITRE ATT&CK, CWE. + +Mappings follow: +- CIS Kubernetes Benchmark v1.9 (https://www.cisecurity.org/benchmark/kubernetes) +- MITRE ATT&CK for Containers (https://attack.mitre.org/matrices/enterprise/containers/) +- Common Weakness Enumeration (https://cwe.mitre.org/) +""" +from typing import Dict, List + +# Map kuberoast finding IDs to industry-standard frameworks. +# Each entry: (cis_controls, mitre_attack_techniques, cwe_ids) +COMPLIANCE_MAP: Dict[str, Dict[str, List[str]]] = { + # Pod Security + "POD-PRIV": { + "cis": ["5.2.1", "5.2.2"], + "mitre": ["T1611", "T1610"], + "cwe": ["CWE-250", "CWE-269"], + }, + "POD-ROOT": { + "cis": ["5.2.6"], + "mitre": ["T1611"], + "cwe": ["CWE-250"], + }, + "POD-PE": { + "cis": ["5.2.5"], + "mitre": ["T1611", "T1548"], + "cwe": ["CWE-269"], + }, + "POD-HOSTNS": { + "cis": ["5.2.2", "5.2.3", "5.2.4"], + "mitre": ["T1611"], + "cwe": ["CWE-668"], + }, + "POD-CAPS": { + "cis": ["5.2.8", "5.2.9"], + "mitre": ["T1611"], + "cwe": ["CWE-250"], + }, + "POD-HOSTPATH": { + "cis": ["5.2.10"], + "mitre": ["T1611", "T1610"], + "cwe": ["CWE-552"], + }, + "POD-RWFS": { + "cis": ["5.2.12"], + "mitre": ["T1611"], + "cwe": ["CWE-732"], + }, + "POD-NO-SECCOMP": { + "cis": ["5.7.2"], + "mitre": ["T1611"], + "cwe": ["CWE-693"], + }, + "POD-NO-LIMITS": { + "cis": ["5.1.5"], + "mitre": ["T1499"], + "cwe": ["CWE-770"], + }, + "POD-SATOKEN": { + "cis": ["5.1.5", "5.1.6"], + "mitre": ["T1528"], + "cwe": ["CWE-732"], + }, + "POD-NO-APPARMOR": { + "cis": ["5.7.3"], + "mitre": ["T1611"], + "cwe": ["CWE-693"], + }, + # RBAC + "RBAC-ANON": { + "cis": ["5.1.1", "5.1.2"], + "mitre": ["T1078"], + "cwe": ["CWE-287", "CWE-862"], + }, + "RBAC-CLUSTER-ADMIN": { + "cis": ["5.1.1", "5.1.3"], + "mitre": ["T1078.004"], + "cwe": ["CWE-269"], + }, + "RBAC-ESCALATION-VERB": { + "cis": ["5.1.1"], + "mitre": ["T1548", "T1098"], + "cwe": ["CWE-269", "CWE-285"], + }, + "RBAC-WILDCARD": { + "cis": ["5.1.3"], + "mitre": ["T1078"], + "cwe": ["CWE-732"], + }, + "RBAC-SENSITIVE-WRITE": { + "cis": ["5.1.1", "5.1.4"], + "mitre": ["T1098", "T1552.007"], + "cwe": ["CWE-285"], + }, + "RBAC-BROAD-GROUP": { + "cis": ["5.1.1", "5.1.2"], + "mitre": ["T1078"], + "cwe": ["CWE-732"], + }, + # Attack Path + "AP-RBAC-ESC": { + "cis": ["5.1.1"], + "mitre": ["T1548", "T1098", "T1078.004"], + "cwe": ["CWE-269"], + }, + # Network + "NET-LB-OPEN": { + "cis": ["5.3.2"], + "mitre": ["T1190"], + "cwe": ["CWE-668"], + }, + "NET-EXTERNAL-IP": { + "cis": ["5.3.2"], + "mitre": ["T1190"], + "cwe": ["CWE-668"], + }, + "NET-INGRESS-NO-TLS": { + "cis": ["5.3.2"], + "mitre": ["T1040"], + "cwe": ["CWE-319"], + }, + "NET-NODEPORT": { + "cis": ["5.3.2"], + "mitre": ["T1190"], + "cwe": ["CWE-668"], + }, + "NET-INGRESS-WILDCARD": { + "cis": ["5.3.2"], + "mitre": ["T1190"], + "cwe": ["CWE-668"], + }, + # Node + "NODE-KUBELET-RO": { + "cis": ["4.2.4"], + "mitre": ["T1133", "T1190"], + "cwe": ["CWE-306"], + }, + "NODE-KUBELET-API": { + "cis": ["4.2.1", "4.2.2", "4.2.3"], + "mitre": ["T1133"], + "cwe": ["CWE-306"], + }, + # Secrets + "SECRET-SENSITIVE": { + "cis": ["5.4.1"], + "mitre": ["T1552.001", "T1552.007"], + "cwe": ["CWE-798", "CWE-256"], + }, + "SECRET-DOCKER-HUB": { + "cis": ["5.4.1"], + "mitre": ["T1552.001"], + "cwe": ["CWE-798"], + }, + "SECRET-TLS-MANUAL": { + "cis": ["5.4.2"], + "mitre": ["T1552.004"], + "cwe": ["CWE-321"], + }, + # Policy + "POLICY-NONE": { + "cis": ["5.2.1"], + "mitre": ["T1610"], + "cwe": ["CWE-693"], + }, + "PSS-NOT-ENFORCED": { + "cis": ["5.2.1"], + "mitre": ["T1611"], + "cwe": ["CWE-693"], + }, +} + + +def enrich_finding(finding) -> None: + """Mutate a Finding in place to add CIS, MITRE ATT&CK, and CWE mappings.""" + mapping = COMPLIANCE_MAP.get(finding.id) + if not mapping: + return + if not finding.cis_controls: + finding.cis_controls = [f"CIS-K8s-{c}" for c in mapping.get("cis", [])] + if not finding.mitre_attack: + finding.mitre_attack = list(mapping.get("mitre", [])) + if not finding.cwe: + finding.cwe = list(mapping.get("cwe", [])) + + +def enrich_findings(findings) -> None: + """Enrich a list of findings with compliance metadata.""" + for f in findings: + enrich_finding(f) diff --git a/kuberoast/utils/findings.py b/kuberoast/utils/findings.py index 6d05749..87db301 100644 --- a/kuberoast/utils/findings.py +++ b/kuberoast/utils/findings.py @@ -1,7 +1,9 @@ +from typing import Dict, List, Literal, Optional + from pydantic import BaseModel, Field -from typing import List, Optional, Literal, Dict -Severity = Literal["info","low","medium","high","critical"] +Severity = Literal["info", "low", "medium", "high", "critical"] + class Finding(BaseModel): id: str @@ -14,3 +16,15 @@ class Finding(BaseModel): metadata: Dict[str, str] = Field(default_factory=dict) remediation: Optional[str] = None references: List[str] = Field(default_factory=list) + cis_controls: List[str] = Field(default_factory=list) + mitre_attack: List[str] = Field(default_factory=list) + cwe: List[str] = Field(default_factory=list) + + +SEVERITY_TO_CVSS: Dict[str, float] = { + "critical": 9.5, + "high": 7.5, + "medium": 5.0, + "low": 3.0, + "info": 0.0, +} diff --git a/kuberoast/utils/kube.py b/kuberoast/utils/kube.py index dd676c4..55b4cf2 100644 --- a/kuberoast/utils/kube.py +++ b/kuberoast/utils/kube.py @@ -1,5 +1,6 @@ import logging -from typing import Dict, Any, Optional, List +from typing import Any, Dict, List, Optional + from kubernetes import client, config from kubernetes.client import ApiException diff --git a/kuberoast/utils/manifests.py b/kuberoast/utils/manifests.py new file mode 100644 index 0000000..b33595f --- /dev/null +++ b/kuberoast/utils/manifests.py @@ -0,0 +1,268 @@ +"""Offline manifest scanning — load YAML/JSON Kubernetes manifests from disk. + +Wraps raw dict manifests in attribute-style objects compatible with the +existing scanners (which expect Kubernetes Python client model objects). +""" +from __future__ import annotations + +import json +import logging +import os +from collections.abc import Iterable +from pathlib import Path +from typing import Any, Dict, List, Optional + +logger = logging.getLogger("kuberoast") + +try: + import yaml # PyYAML + HAS_YAML = True +except ImportError: + HAS_YAML = False + + +class ManifestObject: + """Recursive attribute-style wrapper around a manifest dict. + + Translates kebab/camelCase keys to snake_case attribute access so that + code written against kubernetes.client models works against raw YAML. + """ + + __slots__ = ("_data",) + + def __init__(self, data: Optional[Dict[str, Any]] = None): + self._data = data or {} + + def _wrap(self, value: Any) -> Any: + if isinstance(value, dict): + return ManifestObject(value) + if isinstance(value, list): + return [self._wrap(v) for v in value] + return value + + def __getattr__(self, item: str) -> Any: + if item.startswith("_"): + raise AttributeError(item) + # Direct match + if item in self._data: + return self._wrap(self._data[item]) + # Special-cased acronyms (K8s API doesn't use simple camelCase consistently) + alias = _SPECIAL_ALIASES.get(item) + if alias and alias in self._data: + return self._wrap(self._data[alias]) + # snake_case -> camelCase mapping + camel = _snake_to_camel(item) + if camel in self._data: + return self._wrap(self._data[camel]) + # Return None for missing attributes — matches kubernetes.client model + # behavior where unset fields are exposed as None rather than raising. + # Note: this means getattr(obj, "x", default) returns None, not default, + # but the existing scanners normalize via `or default` patterns. + return None + + def __getitem__(self, item: str) -> Any: + return self._wrap(self._data.get(item)) + + def get(self, item: str, default: Any = None) -> Any: + return self._wrap(self._data.get(item, default)) + + def __bool__(self) -> bool: + return bool(self._data) + + def __repr__(self) -> str: + kind = self._data.get("kind", "Manifest") + name = self._data.get("metadata", {}).get("name", "?") + return f"<{kind} {name}>" + + +_SPECIAL_ALIASES: Dict[str, str] = { + "host_pid": "hostPID", + "host_ipc": "hostIPC", + "external_i_ps": "externalIPs", + "external_ips": "externalIPs", + "load_balancer_source_ranges": "loadBalancerSourceRanges", + "automount_service_account_token": "automountServiceAccountToken", + "service_account_name": "serviceAccountName", + "host_network": "hostNetwork", + "init_containers": "initContainers", + "ephemeral_containers": "ephemeralContainers", + "security_context": "securityContext", + "run_as_user": "runAsUser", + "run_as_non_root": "runAsNonRoot", + "allow_privilege_escalation": "allowPrivilegeEscalation", + "read_only_root_filesystem": "readOnlyRootFilesystem", + "seccomp_profile": "seccompProfile", + "host_path": "hostPath", + "role_ref": "roleRef", + "role_kind": "roleKind", + "role_name": "roleName", + "api_groups": "apiGroups", + "api_group": "apiGroup", + "secret_name": "secretName", + "subjects": "subjects", +} + + +def _snake_to_camel(name: str) -> str: + parts = name.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + +def _wrap_pod(doc: dict) -> ManifestObject: + """Pod-shaped wrapper: ensures spec.containers/init_containers/ephemeral_containers exist as lists.""" + spec = doc.get("spec", {}) or {} + spec.setdefault("containers", []) + spec.setdefault("initContainers", []) + spec.setdefault("ephemeralContainers", []) + spec.setdefault("volumes", []) + doc["spec"] = spec + return ManifestObject(doc) + + +def _wrap_workload_template(doc: dict) -> Optional[ManifestObject]: + """Extract pod template from Deployment/StatefulSet/DaemonSet/Job/CronJob/ReplicaSet.""" + kind = doc.get("kind", "") + spec = doc.get("spec", {}) or {} + template: Optional[Dict[str, Any]] = None + + if kind == "CronJob": + template = ( + spec.get("jobTemplate", {}) + .get("spec", {}) + .get("template") + ) + elif kind in {"Deployment", "StatefulSet", "DaemonSet", "Job", "ReplicaSet", "ReplicationController"}: + template = spec.get("template") + + if not template: + return None + + pod_doc = { + "kind": "Pod", + "apiVersion": "v1", + "metadata": { + "name": (template.get("metadata", {}).get("name") + or f"{doc.get('metadata', {}).get('name', 'unknown')}-template"), + "namespace": doc.get("metadata", {}).get("namespace", "default"), + "labels": template.get("metadata", {}).get("labels", {}), + "annotations": template.get("metadata", {}).get("annotations", {}), + }, + "spec": template.get("spec", {}) or {}, + } + return _wrap_pod(pod_doc) + + +def _iter_manifest_files(path: Path) -> Iterable[Path]: + if path.is_file(): + yield path + return + for root, _, files in os.walk(path): + for fn in files: + if fn.lower().endswith((".yaml", ".yml", ".json")): + yield Path(root) / fn + + +def _parse_file(path: Path) -> List[dict]: + """Parse a YAML or JSON manifest file. Returns list of documents.""" + text = path.read_text(encoding="utf-8") + if path.suffix.lower() == ".json": + try: + data = json.loads(text) + except json.JSONDecodeError as e: + logger.warning("Failed to parse %s: %s", path, e) + return [] + return [data] if isinstance(data, dict) else (data if isinstance(data, list) else []) + + if not HAS_YAML: + logger.warning("PyYAML not installed; skipping %s. Install with `pip install pyyaml`.", path) + return [] + try: + docs = list(yaml.safe_load_all(text)) + except yaml.YAMLError as e: + logger.warning("Failed to parse %s: %s", path, e) + return [] + return [d for d in docs if isinstance(d, dict)] + + +def load_manifests(path: str) -> Dict[str, list]: + """Load Kubernetes manifests from a file or directory. + + Returns a dict mapping resource type to list of wrapped objects: + { + "pods": [...], + "namespaces": [...], + "roles": [...], + ... + } + """ + p = Path(path) + if not p.exists(): + raise FileNotFoundError(f"Manifest path not found: {path}") + + out: Dict[str, list] = { + "pods": [], + "namespaces": [], + "roles": [], + "cluster_roles": [], + "role_bindings": [], + "cluster_role_bindings": [], + "secrets": [], + "services": [], + "ingresses": [], + "crds": [], + } + + workload_kinds = { + "Deployment", "StatefulSet", "DaemonSet", "Job", + "CronJob", "ReplicaSet", "ReplicationController", + } + + for fp in _iter_manifest_files(p): + for doc in _parse_file(fp): + kind = doc.get("kind") + if not kind: + continue + if kind == "Pod": + out["pods"].append(_wrap_pod(doc)) + elif kind in workload_kinds: + wrapped = _wrap_workload_template(doc) + if wrapped: + out["pods"].append(wrapped) + elif kind == "Namespace": + out["namespaces"].append(ManifestObject(doc)) + elif kind == "Role": + out["roles"].append(ManifestObject(_normalize_rbac(doc))) + elif kind == "ClusterRole": + out["cluster_roles"].append(ManifestObject(_normalize_rbac(doc))) + elif kind == "RoleBinding": + out["role_bindings"].append(ManifestObject(_normalize_binding(doc))) + elif kind == "ClusterRoleBinding": + out["cluster_role_bindings"].append(ManifestObject(_normalize_binding(doc))) + elif kind == "Secret": + out["secrets"].append(ManifestObject(doc)) + elif kind == "Service": + out["services"].append(ManifestObject(doc)) + elif kind == "Ingress": + out["ingresses"].append(ManifestObject(doc)) + elif kind == "CustomResourceDefinition": + out["crds"].append(ManifestObject(doc)) + + return out + + +def _normalize_rbac(doc: dict) -> dict: + """Ensure RBAC role rules use snake_case-friendly keys.""" + rules = doc.get("rules") or [] + for r in rules: + if "apiGroups" in r and "api_groups" not in r: + r["api_groups"] = r["apiGroups"] + doc["rules"] = rules + return doc + + +def _normalize_binding(doc: dict) -> dict: + """Map roleRef -> role_ref attribute access for bindings.""" + role_ref = doc.get("roleRef") or doc.get("role_ref") or {} + doc["roleRef"] = role_ref + doc["role_ref"] = role_ref + return doc diff --git a/kuberoast/utils/style.py b/kuberoast/utils/style.py new file mode 100644 index 0000000..70df8a7 --- /dev/null +++ b/kuberoast/utils/style.py @@ -0,0 +1,102 @@ +"""Terminal styling: ASCII banner, ANSI colors, severity badges. + +Respects NO_COLOR (https://no-color.org) and KUBEROAST_NO_COLOR. Auto-detects +TTY on the target stream — never emits escape codes when piping to a file. +""" +from __future__ import annotations + +import os +import sys +from typing import IO, Optional + +from .. import __version__ + +RESET = "\033[0m" +BOLD = "\033[1m" +DIM = "\033[2m" + +# Foreground colors +FG = { + "red": "\033[38;5;203m", + "orange": "\033[38;5;209m", + "yellow": "\033[38;5;221m", + "blue": "\033[38;5;75m", + "cyan": "\033[38;5;87m", + "green": "\033[38;5;120m", + "magenta": "\033[38;5;213m", + "gray": "\033[38;5;245m", + "white": "\033[38;5;255m", +} + +# Severity → color +SEVERITY_COLOR = { + "critical": "red", + "high": "orange", + "medium": "yellow", + "low": "blue", + "info": "gray", +} + + +def _colors_enabled(stream: Optional[IO] = None) -> bool: + """Decide whether to emit ANSI colors on `stream` (default: stdout).""" + if os.environ.get("NO_COLOR"): + return False + if os.environ.get("KUBEROAST_NO_COLOR"): + return False + if os.environ.get("FORCE_COLOR"): + return True + s = stream if stream is not None else sys.stdout + return bool(getattr(s, "isatty", lambda: False)()) + + +def color(text: str, name: str, *, bold: bool = False, stream: Optional[IO] = None) -> str: + """Wrap text in ANSI color codes when the stream is a TTY.""" + if not _colors_enabled(stream): + return text + code = FG.get(name, "") + prefix = (BOLD if bold else "") + code + return f"{prefix}{text}{RESET}" if prefix else text + + +def severity_badge(severity: str, *, stream: Optional[IO] = None) -> str: + """Render an inline `[CRITICAL]`-style badge with severity color.""" + label = f"[{severity.upper()}]" + return color(label, SEVERITY_COLOR.get(severity, "gray"), bold=True, stream=stream) + + +_BANNER_ART = r""" + ██╗ ██╗██╗ ██╗██████╗ ███████╗██████╗ ██████╗ █████╗ ███████╗████████╗ + ██║ ██╔╝██║ ██║██╔══██╗██╔════╝██╔══██╗██╔═══██╗██╔══██╗██╔════╝╚══██╔══╝ + █████╔╝ ██║ ██║██████╔╝█████╗ ██████╔╝██║ ██║███████║███████╗ ██║ + ██╔═██╗ ██║ ██║██╔══██╗██╔══╝ ██╔══██╗██║ ██║██╔══██║╚════██║ ██║ + ██║ ██╗╚██████╔╝██████╔╝███████╗██║ ██║╚██████╔╝██║ ██║███████║ ██║ + ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝ ╚═╝ +""" + +_TAGLINE = "Offensive Kubernetes misconfig & attack-path scanner" + + +def banner(*, stream: Optional[IO] = None) -> str: + """Return the ASCII banner with version + tagline. + + Coloured on TTYs, plain text otherwise. + """ + art = _BANNER_ART.rstrip("\n") + if _colors_enabled(stream): + art = color(art, "magenta", bold=True, stream=stream) + version_line = ( + f" {color('v' + __version__, 'cyan', bold=True, stream=stream)}" + f" {color('•', 'gray', stream=stream)} " + f"{color(_TAGLINE, 'white', stream=stream)}" + ) + else: + version_line = f" v{__version__} • {_TAGLINE}" + return f"{art}\n{version_line}\n" + + +def print_banner(stream: Optional[IO] = None) -> None: + """Emit the banner to stderr (default), so JSON/SARIF output stays clean.""" + target = stream if stream is not None else sys.stderr + target.write(banner(stream=target)) + target.flush() diff --git a/pyproject.toml b/pyproject.toml index 1123d15..f92cdae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "kuberoast" -version = "0.2.0" +version = "0.3.0" description = "Offensive Kubernetes misconfiguration & attack-path scanner" readme = "README.md" license = {text = "MIT"} @@ -12,17 +12,56 @@ requires-python = ">=3.9" authors = [ {name = "SnailSploit | Kai Aizen"}, ] +keywords = [ + "kubernetes", + "security", + "scanner", + "rbac", + "audit", + "compliance", + "cis-benchmark", + "mitre-attack", + "sarif", + "devsecops", +] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: System Administrators", + "Intended Audience :: Information Technology", + "License :: OSI Approved :: MIT License", + "Operating System :: POSIX :: Linux", + "Operating System :: MacOS :: MacOS X", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Security", + "Topic :: System :: Monitoring", + "Topic :: System :: Systems Administration", +] dependencies = [ "kubernetes>=28.0", "pydantic>=2.0", + "pyyaml>=6.0", ] [project.optional-dependencies] dev = [ "pytest>=7.0", "pytest-cov>=4.0", + "ruff>=0.4.0", + "hypothesis>=6.0", + "jsonschema>=4.0", ] +[project.urls] +Homepage = "https://github.com/SnailSploit/KubeRoast_v1" +Repository = "https://github.com/SnailSploit/KubeRoast_v1" +Issues = "https://github.com/SnailSploit/KubeRoast_v1/issues" + [project.scripts] kuberoast = "kuberoast.cli:main" @@ -31,3 +70,52 @@ include = ["kuberoast*"] [tool.pytest.ini_options] testpaths = ["tests"] +addopts = [ + "--strict-markers", + "-ra", +] +markers = [ + "performance: scaling/perf-regression tests (deselect with '-m \"not performance\"')", +] + +[tool.coverage.run] +source = ["kuberoast"] +branch = true + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "if __name__ == .__main__.:", + "raise NotImplementedError", +] + +[tool.ruff] +line-length = 120 +target-version = "py39" + +[tool.ruff.lint] +select = [ + "E", + "W", + "F", + "I", + "B", + "S", +] +ignore = [ + "E501", + "S101", + "S603", + "S607", + # PEP 585 / PEP 604 generics not enforced — supports Python 3.9 syntax + "UP006", + "UP007", + "UP035", + # "secret_type" string is a Kubernetes API field, not a credential + "S105", + # Bare except / try-pass in defensive paths + "S110", +] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["S", "B", "E741"] diff --git a/tests/conftest.py b/tests/conftest.py index d180508..f532dec 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,4 @@ """Shared test fixtures providing mock Kubernetes objects.""" -import pytest from types import SimpleNamespace diff --git a/tests/fixtures/sarif-2.1.0-schema.json b/tests/fixtures/sarif-2.1.0-schema.json new file mode 100644 index 0000000..0f58372 --- /dev/null +++ b/tests/fixtures/sarif-2.1.0-schema.json @@ -0,0 +1,3389 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Static Analysis Results Format (SARIF) Version 2.1.0 JSON Schema", + "id": "https://docs.oasis-open.org/sarif/sarif/v2.1.0/errata01/os/schemas/sarif-schema-2.1.0.json", + "description": "Static Analysis Results Format (SARIF) Version 2.1.0 JSON Schema: a standard format for the output of static analysis tools.", + "additionalProperties": false, + "type": "object", + "properties": { + + "$schema": { + "description": "The URI of the JSON schema corresponding to the version.", + "type": "string", + "format": "uri" + }, + + "version": { + "description": "The SARIF format version of this log file.", + "enum": [ "2.1.0" ], + "type": "string" + }, + + "runs": { + "description": "The set of runs contained in this log file.", + "type": [ "array", "null" ], + "minItems": 0, + "uniqueItems": false, + "items": { + "$ref": "#/definitions/run" + } + }, + + "inlineExternalProperties": { + "description": "References to external property files that share data between runs.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "items": { + "$ref": "#/definitions/externalProperties" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the log file.", + "$ref": "#/definitions/propertyBag" + } + }, + + "required": [ "version", "runs" ], + + "definitions": { + + "address": { + "description": "A physical or virtual address, or a range of addresses, in an 'addressable region' (memory or a binary file).", + "additionalProperties": false, + "type": "object", + "properties": { + + "absoluteAddress": { + "description": "The address expressed as a byte offset from the start of the addressable region.", + "type": "integer", + "minimum": -1, + "default": -1 + + }, + + "relativeAddress": { + "description": "The address expressed as a byte offset from the absolute address of the top-most parent object.", + "type": "integer" + + }, + + "length": { + "description": "The number of bytes in this range of addresses.", + "type": "integer" + }, + + "kind": { + "description": "An open-ended string that identifies the address kind. 'data', 'function', 'header','instruction', 'module', 'page', 'section', 'segment', 'stack', 'stackFrame', 'table' are well-known values.", + "type": "string" + }, + + "name": { + "description": "A name that is associated with the address, e.g., '.text'.", + "type": "string" + }, + + "fullyQualifiedName": { + "description": "A human-readable fully qualified name that is associated with the address.", + "type": "string" + }, + + "offsetFromParent": { + "description": "The byte offset of this address from the absolute or relative address of the parent object.", + "type": "integer" + }, + + "index": { + "description": "The index within run.addresses of the cached object for this address.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "parentIndex": { + "description": "The index within run.addresses of the parent object.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the address.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "artifact": { + "description": "A single artifact. In some cases, this artifact might be nested within another artifact.", + "additionalProperties": false, + "type": "object", + "properties": { + + "description": { + "description": "A short description of the artifact.", + "$ref": "#/definitions/message" + }, + + "location": { + "description": "The location of the artifact.", + "$ref": "#/definitions/artifactLocation" + }, + + "parentIndex": { + "description": "Identifies the index of the immediate parent of the artifact, if this artifact is nested.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "offset": { + "description": "The offset in bytes of the artifact within its containing artifact.", + "type": "integer", + "minimum": 0 + }, + + "length": { + "description": "The length of the artifact in bytes.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "roles": { + "description": "The role or roles played by the artifact in the analysis.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "enum": [ + "analysisTarget", + "attachment", + "responseFile", + "resultFile", + "standardStream", + "tracedFile", + "unmodified", + "modified", + "added", + "deleted", + "renamed", + "uncontrolled", + "driver", + "extension", + "translation", + "taxonomy", + "policy", + "referencedOnCommandLine", + "memoryContents", + "directory", + "userSpecifiedConfiguration", + "toolSpecifiedConfiguration", + "debugOutputFile" + ], + "type": "string" + } + }, + + "mimeType": { + "description": "The MIME type (RFC 2045) of the artifact.", + "type": "string", + "pattern": "[^/]+/.+" + }, + + "contents": { + "description": "The contents of the artifact.", + "$ref": "#/definitions/artifactContent" + }, + + "encoding": { + "description": "Specifies the encoding for an artifact object that refers to a text file.", + "type": "string" + }, + + "sourceLanguage": { + "description": "Specifies the source language for any artifact object that refers to a text file that contains source code.", + "type": "string" + }, + + "hashes": { + "description": "A dictionary, each of whose keys is the name of a hash function and each of whose values is the hashed value of the artifact produced by the specified hash function.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + + "lastModifiedTimeUtc": { + "description": "The Coordinated Universal Time (UTC) date and time at which the artifact was most recently modified. See \"Date/time properties\" in the SARIF spec for the required format.", + "type": "string", + "format": "date-time" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the artifact.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "artifactChange": { + "description": "A change to a single artifact.", + "additionalProperties": false, + "type": "object", + "properties": { + + "artifactLocation": { + "description": "The location of the artifact to change.", + "$ref": "#/definitions/artifactLocation" + }, + + "replacements": { + "description": "An array of replacement objects, each of which represents the replacement of a single region in a single artifact specified by 'artifactLocation'.", + "type": "array", + "minItems": 1, + "uniqueItems": false, + "items": { + "$ref": "#/definitions/replacement" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the change.", + "$ref": "#/definitions/propertyBag" + } + + }, + + "required": [ "artifactLocation", "replacements" ] + }, + + "artifactContent": { + "description": "Represents the contents of an artifact.", + "type": "object", + "additionalProperties": false, + "properties": { + + "text": { + "description": "UTF-8-encoded content from a text artifact.", + "type": "string" + }, + + "binary": { + "description": "MIME Base64-encoded content from a binary artifact, or from a text artifact in its original encoding.", + "type": "string" + }, + + "rendered": { + "description": "An alternate rendered representation of the artifact (e.g., a decompiled representation of a binary region).", + "$ref": "#/definitions/multiformatMessageString" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the artifact content.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "artifactLocation": { + "description": "Specifies the location of an artifact.", + "additionalProperties": false, + "type": "object", + "properties": { + + "uri": { + "description": "A string containing a valid relative or absolute URI.", + "type": "string", + "format": "uri-reference" + }, + + "uriBaseId": { + "description": "A string which indirectly specifies the absolute URI with respect to which a relative URI in the \"uri\" property is interpreted.", + "type": "string" + }, + + "index": { + "description": "The index within the run artifacts array of the artifact object associated with the artifact location.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "description": { + "description": "A short description of the artifact location.", + "$ref": "#/definitions/message" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the artifact location.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "attachment": { + "description": "An artifact relevant to a result.", + "type": "object", + "additionalProperties": false, + "properties": { + + "description": { + "description": "A message describing the role played by the attachment.", + "$ref": "#/definitions/message" + }, + + "artifactLocation": { + "description": "The location of the attachment.", + "$ref": "#/definitions/artifactLocation" + }, + + "regions": { + "description": "An array of regions of interest within the attachment.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/region" + } + }, + + "rectangles": { + "description": "An array of rectangles specifying areas of interest within the image.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/rectangle" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the attachment.", + "$ref": "#/definitions/propertyBag" + } + }, + + "required": [ "artifactLocation" ] + }, + + "codeFlow": { + "description": "A set of threadFlows which together describe a pattern of code execution relevant to detecting a result.", + "additionalProperties": false, + "type": "object", + "properties": { + + "message": { + "description": "A message relevant to the code flow.", + "$ref": "#/definitions/message" + }, + + "threadFlows": { + "description": "An array of one or more unique threadFlow objects, each of which describes the progress of a program through a thread of execution.", + "type": "array", + "minItems": 1, + "uniqueItems": false, + "items": { + "$ref": "#/definitions/threadFlow" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the code flow.", + "$ref": "#/definitions/propertyBag" + } + }, + + "required": [ "threadFlows" ] + }, + + "configurationOverride": { + "description": "Information about how a specific rule or notification was reconfigured at runtime.", + "type": "object", + "additionalProperties": false, + "properties": { + + "configuration": { + "description": "Specifies how the rule or notification was configured during the scan.", + "$ref": "#/definitions/reportingConfiguration" + }, + + "descriptor": { + "description": "A reference used to locate the descriptor whose configuration was overridden.", + "$ref": "#/definitions/reportingDescriptorReference" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the configuration override.", + "$ref": "#/definitions/propertyBag" + } + }, + "required": [ "configuration", "descriptor" ] + }, + + "conversion": { + "description": "Describes how a converter transformed the output of a static analysis tool from the analysis tool's native output format into the SARIF format.", + "additionalProperties": false, + "type": "object", + "properties": { + + "tool": { + "description": "A tool object that describes the converter.", + "$ref": "#/definitions/tool" + }, + + "invocation": { + "description": "An invocation object that describes the invocation of the converter.", + "$ref": "#/definitions/invocation" + }, + + "analysisToolLogFiles": { + "description": "The locations of the analysis tool's per-run log files.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/artifactLocation" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the conversion.", + "$ref": "#/definitions/propertyBag" + } + + }, + + "required": [ "tool" ] + }, + + "edge": { + "description": "Represents a directed edge in a graph.", + "type": "object", + "additionalProperties": false, + "properties": { + + "id": { + "description": "A string that uniquely identifies the edge within its graph.", + "type": "string" + }, + + "label": { + "description": "A short description of the edge.", + "$ref": "#/definitions/message" + }, + + "sourceNodeId": { + "description": "Identifies the source node (the node at which the edge starts).", + "type": "string" + }, + + "targetNodeId": { + "description": "Identifies the target node (the node at which the edge ends).", + "type": "string" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the edge.", + "$ref": "#/definitions/propertyBag" + } + }, + + "required": [ "id", "sourceNodeId", "targetNodeId" ] + }, + + "edgeTraversal": { + "description": "Represents the traversal of a single edge during a graph traversal.", + "type": "object", + "additionalProperties": false, + "properties": { + + "edgeId": { + "description": "Identifies the edge being traversed.", + "type": "string" + }, + + "message": { + "description": "A message to display to the user as the edge is traversed.", + "$ref": "#/definitions/message" + }, + + "finalState": { + "description": "The values of relevant expressions after the edge has been traversed.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/multiformatMessageString" + } + }, + + "stepOverEdgeCount": { + "description": "The number of edge traversals necessary to return from a nested graph.", + "type": "integer", + "minimum": 0 + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the edge traversal.", + "$ref": "#/definitions/propertyBag" + } + }, + + "required": [ "edgeId" ] + }, + + "exception": { + "description": "Describes a runtime exception encountered during the execution of an analysis tool.", + "type": "object", + "additionalProperties": false, + "properties": { + + "kind": { + "type": "string", + "description": "A string that identifies the kind of exception, for example, the fully qualified type name of an object that was thrown, or the symbolic name of a signal." + }, + + "message": { + "description": "A message that describes the exception.", + "type": "string" + }, + + "stack": { + "description": "The sequence of function calls leading to the exception.", + "$ref": "#/definitions/stack" + }, + + "innerExceptions": { + "description": "An array of exception objects each of which is considered a cause of this exception.", + "type": "array", + "minItems": 0, + "uniqueItems": false, + "default": [], + "items": { + "$ref": "#/definitions/exception" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the exception.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "externalProperties": { + "description": "The top-level element of an external property file.", + "type": "object", + "additionalProperties": false, + "properties": { + + "schema": { + "description": "The URI of the JSON schema corresponding to the version of the external property file format.", + "type": "string", + "format": "uri" + }, + + "version": { + "description": "The SARIF format version of this external properties object.", + "enum": [ "2.1.0" ], + "type": "string" + }, + + "guid": { + "description": "A stable, unique identifier for this external properties object, in the form of a GUID.", + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + }, + + "runGuid": { + "description": "A stable, unique identifier for the run associated with this external properties object, in the form of a GUID.", + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + }, + + "conversion": { + "description": "A conversion object that will be merged with a separate run.", + "$ref": "#/definitions/conversion" + }, + + "graphs": { + "description": "An array of graph objects that will be merged with a separate run.", + "type": "array", + "minItems": 0, + "default": [], + "uniqueItems": true, + "items": { + "$ref": "#/definitions/graph" + } + }, + + "externalizedProperties": { + "description": "Key/value pairs that provide additional information that will be merged with a separate run.", + "$ref": "#/definitions/propertyBag" + }, + + "artifacts": { + "description": "An array of artifact objects that will be merged with a separate run.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "items": { + "$ref": "#/definitions/artifact" + } + }, + + "invocations": { + "description": "Describes the invocation of the analysis tool that will be merged with a separate run.", + "type": "array", + "minItems": 0, + "uniqueItems": false, + "default": [], + "items": { + "$ref": "#/definitions/invocation" + } + }, + + "logicalLocations": { + "description": "An array of logical locations such as namespaces, types or functions that will be merged with a separate run.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/logicalLocation" + } + }, + + "threadFlowLocations": { + "description": "An array of threadFlowLocation objects that will be merged with a separate run.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/threadFlowLocation" + } + }, + + "results": { + "description": "An array of result objects that will be merged with a separate run.", + "type": "array", + "minItems": 0, + "uniqueItems": false, + "default": [], + "items": { + "$ref": "#/definitions/result" + } + }, + + "taxonomies": { + "description": "Tool taxonomies that will be merged with a separate run.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/toolComponent" + } + }, + + "driver": { + "description": "The analysis tool object that will be merged with a separate run.", + "$ref": "#/definitions/toolComponent" + }, + + "extensions": { + "description": "Tool extensions that will be merged with a separate run.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/toolComponent" + } + }, + + "policies": { + "description": "Tool policies that will be merged with a separate run.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/toolComponent" + } + }, + + "translations": { + "description": "Tool translations that will be merged with a separate run.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/toolComponent" + } + }, + + "addresses": { + "description": "Addresses that will be merged with a separate run.", + "type": "array", + "minItems": 0, + "uniqueItems": false, + "default": [], + "items": { + "$ref": "#/definitions/address" + } + }, + + "webRequests": { + "description": "Requests that will be merged with a separate run.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/webRequest" + } + }, + + "webResponses": { + "description": "Responses that will be merged with a separate run.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/webResponse" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the external properties.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "externalPropertyFileReference": { + "description": "Contains information that enables a SARIF consumer to locate the external property file that contains the value of an externalized property associated with the run.", + "type": "object", + "additionalProperties": false, + "properties": { + + "location": { + "description": "The location of the external property file.", + "$ref": "#/definitions/artifactLocation" + }, + + "guid": { + "description": "A stable, unique identifier for the external property file in the form of a GUID.", + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + }, + + "itemCount": { + "description": "A non-negative integer specifying the number of items contained in the external property file.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the external property file.", + "$ref": "#/definitions/propertyBag" + } + }, + "anyOf": [ + { "required": [ "location" ] }, + { "required": [ "guid" ] } + ] + }, + + "externalPropertyFileReferences": { + "description": "References to external property files that should be inlined with the content of a root log file.", + "additionalProperties": false, + "type": "object", + "properties": { + + "conversion": { + "description": "An external property file containing a run.conversion object to be merged with the root log file.", + "$ref": "#/definitions/externalPropertyFileReference" + }, + + "graphs": { + "description": "An array of external property files containing a run.graphs object to be merged with the root log file.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/externalPropertyFileReference" + } + }, + + "externalizedProperties": { + "description": "An external property file containing a run.properties object to be merged with the root log file.", + "$ref": "#/definitions/externalPropertyFileReference" + }, + + "artifacts": { + "description": "An array of external property files containing run.artifacts arrays to be merged with the root log file.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/externalPropertyFileReference" + } + }, + + "invocations": { + "description": "An array of external property files containing run.invocations arrays to be merged with the root log file.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/externalPropertyFileReference" + } + }, + + "logicalLocations": { + "description": "An array of external property files containing run.logicalLocations arrays to be merged with the root log file.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/externalPropertyFileReference" + } + }, + + "threadFlowLocations": { + "description": "An array of external property files containing run.threadFlowLocations arrays to be merged with the root log file.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/externalPropertyFileReference" + } + }, + + "results": { + "description": "An array of external property files containing run.results arrays to be merged with the root log file.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/externalPropertyFileReference" + } + }, + + "taxonomies": { + "description": "An array of external property files containing run.taxonomies arrays to be merged with the root log file.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/externalPropertyFileReference" + } + }, + + "addresses": { + "description": "An array of external property files containing run.addresses arrays to be merged with the root log file.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/externalPropertyFileReference" + } + }, + + "driver": { + "description": "An external property file containing a run.driver object to be merged with the root log file.", + "$ref": "#/definitions/externalPropertyFileReference" + }, + + "extensions": { + "description": "An array of external property files containing run.extensions arrays to be merged with the root log file.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/externalPropertyFileReference" + } + }, + + "policies": { + "description": "An array of external property files containing run.policies arrays to be merged with the root log file.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/externalPropertyFileReference" + } + }, + + "translations": { + "description": "An array of external property files containing run.translations arrays to be merged with the root log file.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/externalPropertyFileReference" + } + }, + + "webRequests": { + "description": "An array of external property files containing run.requests arrays to be merged with the root log file.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/externalPropertyFileReference" + } + }, + + "webResponses": { + "description": "An array of external property files containing run.responses arrays to be merged with the root log file.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/externalPropertyFileReference" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the external property files.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "fix": { + "description": "A proposed fix for the problem represented by a result object. A fix specifies a set of artifacts to modify. For each artifact, it specifies a set of bytes to remove, and provides a set of new bytes to replace them.", + "additionalProperties": false, + "type": "object", + "properties": { + + "description": { + "description": "A message that describes the proposed fix, enabling viewers to present the proposed change to an end user.", + "$ref": "#/definitions/message" + }, + + "artifactChanges": { + "description": "One or more artifact changes that comprise a fix for a result.", + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "$ref": "#/definitions/artifactChange" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the fix.", + "$ref": "#/definitions/propertyBag" + } + }, + "required": [ "artifactChanges" ] + }, + + "graph": { + "description": "A network of nodes and directed edges that describes some aspect of the structure of the code (for example, a call graph).", + "type": "object", + "additionalProperties": false, + "properties": { + + "description": { + "description": "A description of the graph.", + "$ref": "#/definitions/message" + }, + + "nodes": { + "description": "An array of node objects representing the nodes of the graph.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/node" + } + }, + + "edges": { + "description": "An array of edge objects representing the edges of the graph.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/edge" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the graph.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "graphTraversal": { + "description": "Represents a path through a graph.", + "type": "object", + "additionalProperties": false, + "properties": { + + "runGraphIndex": { + "description": "The index within the run.graphs to be associated with the result.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "resultGraphIndex": { + "description": "The index within the result.graphs to be associated with the result.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "description": { + "description": "A description of this graph traversal.", + "$ref": "#/definitions/message" + }, + + "initialState": { + "description": "Values of relevant expressions at the start of the graph traversal that may change during graph traversal.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/multiformatMessageString" + } + }, + + "immutableState": { + "description": "Values of relevant expressions at the start of the graph traversal that remain constant for the graph traversal.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/multiformatMessageString" + } + }, + + "edgeTraversals": { + "description": "The sequences of edges traversed by this graph traversal.", + "type": "array", + "minItems": 0, + "uniqueItems": false, + "default": [], + "items": { + "$ref": "#/definitions/edgeTraversal" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the graph traversal.", + "$ref": "#/definitions/propertyBag" + } + }, + "oneOf": [ + { "required": [ "runGraphIndex" ] }, + { "required": [ "resultGraphIndex" ] } + ] + }, + + "invocation": { + "description": "The runtime environment of the analysis tool run.", + "additionalProperties": false, + "type": "object", + "properties": { + + "commandLine": { + "description": "The command line used to invoke the tool.", + "type": "string" + }, + + "arguments": { + "description": "An array of strings, containing in order the command line arguments passed to the tool from the operating system.", + "type": "array", + "minItems": 0, + "uniqueItems": false, + "items": { + "type": "string" + } + }, + + "responseFiles": { + "description": "The locations of any response files specified on the tool's command line.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "items": { + "$ref": "#/definitions/artifactLocation" + } + }, + + "startTimeUtc": { + "description": "The Coordinated Universal Time (UTC) date and time at which the invocation started. See \"Date/time properties\" in the SARIF spec for the required format.", + "type": "string", + "format": "date-time" + }, + + "endTimeUtc": { + "description": "The Coordinated Universal Time (UTC) date and time at which the invocation ended. See \"Date/time properties\" in the SARIF spec for the required format.", + "type": "string", + "format": "date-time" + }, + + "exitCode": { + "description": "The process exit code.", + "type": "integer" + }, + + "ruleConfigurationOverrides": { + "description": "An array of configurationOverride objects that describe rules related runtime overrides.", + "type": "array", + "minItems": 0, + "default": [], + "uniqueItems": true, + "items": { + "$ref": "#/definitions/configurationOverride" + } + }, + + "notificationConfigurationOverrides": { + "description": "An array of configurationOverride objects that describe notifications related runtime overrides.", + "type": "array", + "minItems": 0, + "default": [], + "uniqueItems": true, + "items": { + "$ref": "#/definitions/configurationOverride" + } + }, + + "toolExecutionNotifications": { + "description": "A list of runtime conditions detected by the tool during the analysis.", + "type": "array", + "minItems": 0, + "uniqueItems": false, + "default": [], + "items": { + "$ref": "#/definitions/notification" + } + }, + + "toolConfigurationNotifications": { + "description": "A list of conditions detected by the tool that are relevant to the tool's configuration.", + "type": "array", + "minItems": 0, + "uniqueItems": false, + "default": [], + "items": { + "$ref": "#/definitions/notification" + } + }, + + "exitCodeDescription": { + "description": "The reason for the process exit.", + "type": "string" + }, + + "exitSignalName": { + "description": "The name of the signal that caused the process to exit.", + "type": "string" + }, + + "exitSignalNumber": { + "description": "The numeric value of the signal that caused the process to exit.", + "type": "integer" + }, + + "processStartFailureMessage": { + "description": "The reason given by the operating system that the process failed to start.", + "type": "string" + }, + + "executionSuccessful": { + "description": "Specifies whether the tool's execution completed successfully.", + "type": "boolean" + }, + + "machine": { + "description": "The machine on which the invocation occurred.", + "type": "string" + }, + + "account": { + "description": "The account under which the invocation occurred.", + "type": "string" + }, + + "processId": { + "description": "The id of the process in which the invocation occurred.", + "type": "integer" + }, + + "executableLocation": { + "description": "An absolute URI specifying the location of the executable that was invoked.", + "$ref": "#/definitions/artifactLocation" + }, + + "workingDirectory": { + "description": "The working directory for the invocation.", + "$ref": "#/definitions/artifactLocation" + }, + + "environmentVariables": { + "description": "The environment variables associated with the analysis tool process, expressed as key/value pairs.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + + "stdin": { + "description": "A file containing the standard input stream to the process that was invoked.", + "$ref": "#/definitions/artifactLocation" + }, + + "stdout": { + "description": "A file containing the standard output stream from the process that was invoked.", + "$ref": "#/definitions/artifactLocation" + }, + + "stderr": { + "description": "A file containing the standard error stream from the process that was invoked.", + "$ref": "#/definitions/artifactLocation" + }, + + "stdoutStderr": { + "description": "A file containing the interleaved standard output and standard error stream from the process that was invoked.", + "$ref": "#/definitions/artifactLocation" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the invocation.", + "$ref": "#/definitions/propertyBag" + } + }, + "required": [ "executionSuccessful" ] + }, + + "location": { + "description": "A location within a programming artifact.", + "additionalProperties": false, + "type": "object", + "properties": { + + "id": { + "description": "Value that distinguishes this location from all other locations within a single result object.", + "type": "integer", + "minimum": -1, + "default": -1 + }, + + "physicalLocation": { + "description": "Identifies the artifact and region.", + "$ref": "#/definitions/physicalLocation" + }, + + "logicalLocations": { + "description": "The logical locations associated with the result.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/logicalLocation" + } + }, + + "message": { + "description": "A message relevant to the location.", + "$ref": "#/definitions/message" + }, + + "annotations": { + "description": "A set of regions relevant to the location.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/region" + } + }, + + "relationships": { + "description": "An array of objects that describe relationships between this location and others.", + "type": "array", + "default": [], + "minItems": 0, + "uniqueItems": true, + "items": { + "$ref": "#/definitions/locationRelationship" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the location.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "locationRelationship": { + "description": "Information about the relation of one location to another.", + "type": "object", + "additionalProperties": false, + "properties": { + + "target": { + "description": "A reference to the related location.", + "type": "integer", + "minimum": 0 + }, + + "kinds": { + "description": "A set of distinct strings that categorize the relationship. Well-known kinds include 'includes', 'isIncludedBy' and 'relevant'.", + "type": "array", + "default": [ "relevant" ], + "uniqueItems": true, + "items": { + "type": "string" + } + }, + + "description": { + "description": "A description of the location relationship.", + "$ref": "#/definitions/message" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the location relationship.", + "$ref": "#/definitions/propertyBag" + } + }, + "required": [ "target" ] + }, + + "logicalLocation": { + "description": "A logical location of a construct that produced a result.", + "additionalProperties": false, + "type": "object", + "properties": { + + "name": { + "description": "Identifies the construct in which the result occurred. For example, this property might contain the name of a class or a method.", + "type": "string" + }, + + "index": { + "description": "The index within the logical locations array.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "fullyQualifiedName": { + "description": "The human-readable fully qualified name of the logical location.", + "type": "string" + }, + + "decoratedName": { + "description": "The machine-readable name for the logical location, such as a mangled function name provided by a C++ compiler that encodes calling convention, return type and other details along with the function name.", + "type": "string" + }, + + "parentIndex": { + "description": "Identifies the index of the immediate parent of the construct in which the result was detected. For example, this property might point to a logical location that represents the namespace that holds a type.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "kind": { + "description": "The type of construct this logical location component refers to. Should be one of 'function', 'member', 'module', 'namespace', 'parameter', 'resource', 'returnType', 'type', 'variable', 'object', 'array', 'property', 'value', 'element', 'text', 'attribute', 'comment', 'declaration', 'dtd' or 'processingInstruction', if any of those accurately describe the construct.", + "type": "string" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the logical location.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "message": { + "description": "Encapsulates a message intended to be read by the end user.", + "type": "object", + "additionalProperties": false, + + "properties": { + + "text": { + "description": "A plain text message string.", + "type": "string" + }, + + "markdown": { + "description": "A Markdown message string.", + "type": "string" + }, + + "id": { + "description": "The identifier for this message.", + "type": "string" + }, + + "arguments": { + "description": "An array of strings to substitute into the message string.", + "type": "array", + "minItems": 0, + "uniqueItems": false, + "default": [], + "items": { + "type": "string" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the message.", + "$ref": "#/definitions/propertyBag" + } + }, + "anyOf": [ + { "required": [ "text" ] }, + { "required": [ "id" ] } + ] + }, + + "multiformatMessageString": { + "description": "A message string or message format string rendered in multiple formats.", + "type": "object", + "additionalProperties": false, + + "properties": { + + "text": { + "description": "A plain text message string or format string.", + "type": "string" + }, + + "markdown": { + "description": "A Markdown message string or format string.", + "type": "string" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the message.", + "$ref": "#/definitions/propertyBag" + } + }, + "required": [ "text" ] + }, + + "node": { + "description": "Represents a node in a graph.", + "type": "object", + "additionalProperties": false, + + "properties": { + + "id": { + "description": "A string that uniquely identifies the node within its graph.", + "type": "string" + }, + + "label": { + "description": "A short description of the node.", + "$ref": "#/definitions/message" + }, + + "location": { + "description": "A code location associated with the node.", + "$ref": "#/definitions/location" + }, + + "children": { + "description": "Array of child nodes.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/node" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the node.", + "$ref": "#/definitions/propertyBag" + } + }, + + "required": [ "id" ] + }, + + "notification": { + "description": "Describes a condition relevant to the tool itself, as opposed to being relevant to a target being analyzed by the tool.", + "type": "object", + "additionalProperties": false, + "properties": { + + "locations": { + "description": "The locations relevant to this notification.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/location" + } + }, + + "message": { + "description": "A message that describes the condition that was encountered.", + "$ref": "#/definitions/message" + }, + + "level": { + "description": "A value specifying the severity level of the notification.", + "default": "warning", + "enum": [ "none", "note", "warning", "error" ], + "type": "string" + }, + + "threadId": { + "description": "The thread identifier of the code that generated the notification.", + "type": "integer" + }, + + "timeUtc": { + "description": "The Coordinated Universal Time (UTC) date and time at which the analysis tool generated the notification.", + "type": "string", + "format": "date-time" + }, + + "exception": { + "description": "The runtime exception, if any, relevant to this notification.", + "$ref": "#/definitions/exception" + }, + + "descriptor": { + "description": "A reference used to locate the descriptor relevant to this notification.", + "$ref": "#/definitions/reportingDescriptorReference" + }, + + "associatedRule": { + "description": "A reference used to locate the rule descriptor associated with this notification.", + "$ref": "#/definitions/reportingDescriptorReference" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the notification.", + "$ref": "#/definitions/propertyBag" + } + }, + + "required": [ "message" ] + }, + + "physicalLocation": { + "description": "A physical location relevant to a result. Specifies a reference to a programming artifact together with a range of bytes or characters within that artifact.", + "additionalProperties": false, + "type": "object", + "properties": { + + "address": { + "description": "The address of the location.", + "$ref": "#/definitions/address" + }, + + "artifactLocation": { + "description": "The location of the artifact.", + "$ref": "#/definitions/artifactLocation" + }, + + "region": { + "description": "Specifies a portion of the artifact.", + "$ref": "#/definitions/region" + }, + + "contextRegion": { + "description": "Specifies a portion of the artifact that encloses the region. Allows a viewer to display additional context around the region.", + "$ref": "#/definitions/region" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the physical location.", + "$ref": "#/definitions/propertyBag" + } + }, + + "anyOf": [ + { + "required": [ "address" ] + }, + { + "required": [ "artifactLocation" ] + } + ] + }, + + "propertyBag": { + "description": "Key/value pairs that provide additional information about the object.", + "type": "object", + "additionalProperties": true, + "properties": { + "tags": { + + "description": "A set of distinct strings that provide additional information.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "type": "string" + } + } + } + }, + + "rectangle": { + "description": "An area within an image.", + "additionalProperties": false, + "type": "object", + "properties": { + + "top": { + "description": "The Y coordinate of the top edge of the rectangle, measured in the image's natural units.", + "type": "number" + }, + + "left": { + "description": "The X coordinate of the left edge of the rectangle, measured in the image's natural units.", + "type": "number" + }, + + "bottom": { + "description": "The Y coordinate of the bottom edge of the rectangle, measured in the image's natural units.", + "type": "number" + }, + + "right": { + "description": "The X coordinate of the right edge of the rectangle, measured in the image's natural units.", + "type": "number" + }, + + "message": { + "description": "A message relevant to the rectangle.", + "$ref": "#/definitions/message" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the rectangle.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "region": { + "description": "A region within an artifact where a result was detected.", + "additionalProperties": false, + "type": "object", + "properties": { + + "startLine": { + "description": "The line number of the first character in the region.", + "type": "integer", + "minimum": 1 + }, + + "startColumn": { + "description": "The column number of the first character in the region.", + "type": "integer", + "minimum": 1 + }, + + "endLine": { + "description": "The line number of the last character in the region.", + "type": "integer", + "minimum": 1 + }, + + "endColumn": { + "description": "The column number of the character following the end of the region.", + "type": "integer", + "minimum": 1 + }, + + "charOffset": { + "description": "The zero-based offset from the beginning of the artifact of the first character in the region.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "charLength": { + "description": "The length of the region in characters.", + "type": "integer", + "minimum": 0 + }, + + "byteOffset": { + "description": "The zero-based offset from the beginning of the artifact of the first byte in the region.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "byteLength": { + "description": "The length of the region in bytes.", + "type": "integer", + "minimum": 0 + }, + + "snippet": { + "description": "The portion of the artifact contents within the specified region.", + "$ref": "#/definitions/artifactContent" + }, + + "message": { + "description": "A message relevant to the region.", + "$ref": "#/definitions/message" + }, + + "sourceLanguage": { + "description": "Specifies the source language, if any, of the portion of the artifact specified by the region object.", + "type": "string" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the region.", + "$ref": "#/definitions/propertyBag" + } + }, + + "anyOf": [ + { "required": [ "startLine" ] }, + { "required": [ "charOffset" ] }, + { "required": [ "byteOffset" ] } + ] + }, + + "replacement": { + "description": "The replacement of a single region of an artifact.", + "additionalProperties": false, + "type": "object", + "properties": { + + "deletedRegion": { + "description": "The region of the artifact to delete.", + "$ref": "#/definitions/region" + }, + + "insertedContent": { + "description": "The content to insert at the location specified by the 'deletedRegion' property.", + "$ref": "#/definitions/artifactContent" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the replacement.", + "$ref": "#/definitions/propertyBag" + } + }, + + "required": [ "deletedRegion" ] + }, + + "reportingDescriptor": { + "description": "Metadata that describes a specific report produced by the tool, as part of the analysis it provides or its runtime reporting.", + "additionalProperties": false, + "type": "object", + "properties": { + + "id": { + "description": "A stable, opaque identifier for the report.", + "type": "string" + }, + + "deprecatedIds": { + "description": "An array of stable, opaque identifiers by which this report was known in some previous version of the analysis tool.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "items": { + "type": "string" + } + }, + + "guid": { + "description": "A unique identifier for the reporting descriptor in the form of a GUID.", + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + }, + + "deprecatedGuids": { + "description": "An array of unique identifies in the form of a GUID by which this report was known in some previous version of the analysis tool.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + } + }, + + "name": { + "description": "A report identifier that is understandable to an end user.", + "type": "string" + }, + + "deprecatedNames": { + "description": "An array of readable identifiers by which this report was known in some previous version of the analysis tool.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "items": { + "type": "string" + } + }, + + "shortDescription": { + "description": "A concise description of the report. Should be a single sentence that is understandable when visible space is limited to a single line of text.", + "$ref": "#/definitions/multiformatMessageString" + }, + + "fullDescription": { + "description": "A description of the report. Should, as far as possible, provide details sufficient to enable resolution of any problem indicated by the result.", + "$ref": "#/definitions/multiformatMessageString" + }, + + "messageStrings": { + "description": "A set of name/value pairs with arbitrary names. Each value is a multiformatMessageString object, which holds message strings in plain text and (optionally) Markdown format. The strings can include placeholders, which can be used to construct a message in combination with an arbitrary number of additional string arguments.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/multiformatMessageString" + } + }, + + "defaultConfiguration": { + "description": "Default reporting configuration information.", + "$ref": "#/definitions/reportingConfiguration" + }, + + "helpUri": { + "description": "A URI where the primary documentation for the report can be found.", + "type": "string", + "format": "uri" + }, + + "help": { + "description": "Provides the primary documentation for the report, useful when there is no online documentation.", + "$ref": "#/definitions/multiformatMessageString" + }, + + "relationships": { + "description": "An array of objects that describe relationships between this reporting descriptor and others.", + "type": "array", + "default": [], + "minItems": 0, + "uniqueItems": true, + "items": { + "$ref": "#/definitions/reportingDescriptorRelationship" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the report.", + "$ref": "#/definitions/propertyBag" + } + }, + "required": [ "id" ] + }, + + "reportingConfiguration": { + "description": "Information about a rule or notification that can be configured at runtime.", + "type": "object", + "additionalProperties": false, + "properties": { + + "enabled": { + "description": "Specifies whether the report may be produced during the scan.", + "type": "boolean", + "default": true + }, + + "level": { + "description": "Specifies the failure level for the report.", + "default": "warning", + "enum": [ "none", "note", "warning", "error" ], + "type": "string" + }, + + "rank": { + "description": "Specifies the relative priority of the report. Used for analysis output only.", + "type": "number", + "default": -1.0, + "minimum": -1.0, + "maximum": 100.0 + }, + + "parameters": { + "description": "Contains configuration information specific to a report.", + "$ref": "#/definitions/propertyBag" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the reporting configuration.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "reportingDescriptorReference": { + "description": "Information about how to locate a relevant reporting descriptor.", + "type": "object", + "additionalProperties": false, + "properties": { + + "id": { + "description": "The id of the descriptor.", + "type": "string" + }, + + "index": { + "description": "The index into an array of descriptors in toolComponent.ruleDescriptors, toolComponent.notificationDescriptors, or toolComponent.taxonomyDescriptors, depending on context.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "guid": { + "description": "A guid that uniquely identifies the descriptor.", + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + }, + + "toolComponent": { + "description": "A reference used to locate the toolComponent associated with the descriptor.", + "$ref": "#/definitions/toolComponentReference" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the reporting descriptor reference.", + "$ref": "#/definitions/propertyBag" + } + }, + "anyOf": [ + { "required": [ "index" ] }, + { "required": [ "guid" ] }, + { "required": [ "id" ] } + ] + }, + + "reportingDescriptorRelationship": { + "description": "Information about the relation of one reporting descriptor to another.", + "type": "object", + "additionalProperties": false, + "properties": { + + "target": { + "description": "A reference to the related reporting descriptor.", + "$ref": "#/definitions/reportingDescriptorReference" + }, + + "kinds": { + "description": "A set of distinct strings that categorize the relationship. Well-known kinds include 'canPrecede', 'canFollow', 'willPrecede', 'willFollow', 'superset', 'subset', 'equal', 'disjoint', 'relevant', and 'incomparable'.", + "type": "array", + "default": [ "relevant" ], + "uniqueItems": true, + "items": { + "type": "string" + } + }, + + "description": { + "description": "A description of the reporting descriptor relationship.", + "$ref": "#/definitions/message" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the reporting descriptor reference.", + "$ref": "#/definitions/propertyBag" + } + }, + "required": [ "target" ] + }, + + "result": { + "description": "A result produced by an analysis tool.", + "additionalProperties": false, + "type": "object", + "properties": { + + "ruleId": { + "description": "The stable, unique identifier of the rule, if any, to which this result is relevant.", + "type": "string" + }, + + "ruleIndex": { + "description": "The index within the tool component rules array of the rule object associated with this result.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "rule": { + "description": "A reference used to locate the rule descriptor relevant to this result.", + "$ref": "#/definitions/reportingDescriptorReference" + }, + + "kind": { + "description": "A value that categorizes results by evaluation state.", + "default": "fail", + "enum": [ "notApplicable", "pass", "fail", "review", "open", "informational" ], + "type": "string" + }, + + "level": { + "description": "A value specifying the severity level of the result.", + "default": "warning", + "enum": [ "none", "note", "warning", "error" ], + "type": "string" + }, + + "message": { + "description": "A message that describes the result. The first sentence of the message only will be displayed when visible space is limited.", + "$ref": "#/definitions/message" + }, + + "analysisTarget": { + "description": "Identifies the artifact that the analysis tool was instructed to scan. This need not be the same as the artifact where the result actually occurred.", + "$ref": "#/definitions/artifactLocation" + }, + + "locations": { + "description": "The set of locations where the result was detected. Specify only one location unless the problem indicated by the result can only be corrected by making a change at every specified location.", + "type": "array", + "minItems": 0, + "uniqueItems": false, + "default": [], + "items": { + "$ref": "#/definitions/location" + } + }, + + "guid": { + "description": "A stable, unique identifier for the result in the form of a GUID.", + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + }, + + "correlationGuid": { + "description": "A stable, unique identifier for the equivalence class of logically identical results to which this result belongs, in the form of a GUID.", + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + }, + + "occurrenceCount": { + "description": "A positive integer specifying the number of times this logically unique result was observed in this run.", + "type": "integer", + "minimum": 1 + }, + + "partialFingerprints": { + "description": "A set of strings that contribute to the stable, unique identity of the result.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + + "fingerprints": { + "description": "A set of strings each of which individually defines a stable, unique identity for the result.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + + "stacks": { + "description": "An array of 'stack' objects relevant to the result.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/stack" + } + }, + + "codeFlows": { + "description": "An array of 'codeFlow' objects relevant to the result.", + "type": "array", + "minItems": 0, + "uniqueItems": false, + "default": [], + "items": { + "$ref": "#/definitions/codeFlow" + } + }, + + "graphs": { + "description": "An array of zero or more unique graph objects associated with the result.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/graph" + } + }, + + "graphTraversals": { + "description": "An array of one or more unique 'graphTraversal' objects.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/graphTraversal" + } + }, + + "relatedLocations": { + "description": "A set of locations relevant to this result.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/location" + } + }, + + "suppressions": { + "description": "A set of suppressions relevant to this result.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "items": { + "$ref": "#/definitions/suppression" + } + }, + + "baselineState": { + "description": "The state of a result relative to a baseline of a previous run.", + "enum": [ + "new", + "unchanged", + "updated", + "absent" + ], + "type": "string" + }, + + "rank": { + "description": "A number representing the priority or importance of the result.", + "type": "number", + "default": -1.0, + "minimum": -1.0, + "maximum": 100.0 + }, + + "attachments": { + "description": "A set of artifacts relevant to the result.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/attachment" + } + }, + + "hostedViewerUri": { + "description": "An absolute URI at which the result can be viewed.", + "type": "string", + "format": "uri" + }, + + "workItemUris": { + "description": "The URIs of the work items associated with this result.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "items": { + "type": "string", + "format": "uri" + } + }, + + "provenance": { + "description": "Information about how and when the result was detected.", + "$ref": "#/definitions/resultProvenance" + }, + + "fixes": { + "description": "An array of 'fix' objects, each of which represents a proposed fix to the problem indicated by the result.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/fix" + } + }, + + "taxa": { + "description": "An array of references to taxonomy reporting descriptors that are applicable to the result.", + "type": "array", + "default": [], + "minItems": 0, + "uniqueItems": true, + "items": { + "$ref": "#/definitions/reportingDescriptorReference" + } + }, + + "webRequest": { + "description": "A web request associated with this result.", + "$ref": "#/definitions/webRequest" + }, + + "webResponse": { + "description": "A web response associated with this result.", + "$ref": "#/definitions/webResponse" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the result.", + "$ref": "#/definitions/propertyBag" + } + }, + "required": [ "message" ] + }, + + "resultProvenance": { + "description": "Contains information about how and when a result was detected.", + "additionalProperties": false, + "type": "object", + "properties": { + + "firstDetectionTimeUtc": { + "description": "The Coordinated Universal Time (UTC) date and time at which the result was first detected. See \"Date/time properties\" in the SARIF spec for the required format.", + "type": "string", + "format": "date-time" + }, + + "lastDetectionTimeUtc": { + "description": "The Coordinated Universal Time (UTC) date and time at which the result was most recently detected. See \"Date/time properties\" in the SARIF spec for the required format.", + "type": "string", + "format": "date-time" + }, + + "firstDetectionRunGuid": { + "description": "A GUID-valued string equal to the automationDetails.guid property of the run in which the result was first detected.", + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + }, + + "lastDetectionRunGuid": { + "description": "A GUID-valued string equal to the automationDetails.guid property of the run in which the result was most recently detected.", + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + }, + + "invocationIndex": { + "description": "The index within the run.invocations array of the invocation object which describes the tool invocation that detected the result.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "conversionSources": { + "description": "An array of physicalLocation objects which specify the portions of an analysis tool's output that a converter transformed into the result.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/physicalLocation" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the result.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "run": { + "description": "Describes a single run of an analysis tool, and contains the reported output of that run.", + "additionalProperties": false, + "type": "object", + "properties": { + + "tool": { + "description": "Information about the tool or tool pipeline that generated the results in this run. A run can only contain results produced by a single tool or tool pipeline. A run can aggregate results from multiple log files, as long as context around the tool run (tool command-line arguments and the like) is identical for all aggregated files.", + "$ref": "#/definitions/tool" + }, + + "invocations": { + "description": "Describes the invocation of the analysis tool.", + "type": "array", + "minItems": 0, + "uniqueItems": false, + "default": [], + "items": { + "$ref": "#/definitions/invocation" + } + }, + + "conversion": { + "description": "A conversion object that describes how a converter transformed an analysis tool's native reporting format into the SARIF format.", + "$ref": "#/definitions/conversion" + }, + + "language": { + "description": "The language of the messages emitted into the log file during this run (expressed as an ISO 639-1 two-letter lowercase culture code) and an optional region (expressed as an ISO 3166-1 two-letter uppercase subculture code associated with a country or region). The casing is recommended but not required (in order for this data to conform to RFC5646).", + "type": "string", + "default": "en-US", + "pattern": "^[a-zA-Z]{2}(-[a-zA-Z]{2})?$" + }, + + "versionControlProvenance": { + "description": "Specifies the revision in version control of the artifacts that were scanned.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/versionControlDetails" + } + }, + + "originalUriBaseIds": { + "description": "The artifact location specified by each uriBaseId symbol on the machine where the tool originally ran.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/artifactLocation" + } + }, + + "artifacts": { + "description": "An array of artifact objects relevant to the run.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "items": { + "$ref": "#/definitions/artifact" + } + }, + + "logicalLocations": { + "description": "An array of logical locations such as namespaces, types or functions.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/logicalLocation" + } + }, + + "graphs": { + "description": "An array of zero or more unique graph objects associated with the run.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/graph" + } + }, + + "results": { + "description": "The set of results contained in an SARIF log. The results array can be omitted when a run is solely exporting rules metadata. It must be present (but may be empty) if a log file represents an actual scan.", + "type": "array", + "minItems": 0, + "uniqueItems": false, + "items": { + "$ref": "#/definitions/result" + } + }, + + "automationDetails": { + "description": "Automation details that describe this run.", + "$ref": "#/definitions/runAutomationDetails" + }, + + "runAggregates": { + "description": "Automation details that describe the aggregate of runs to which this run belongs.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/runAutomationDetails" + } + }, + + "baselineGuid": { + "description": "The 'guid' property of a previous SARIF 'run' that comprises the baseline that was used to compute result 'baselineState' properties for the run.", + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + }, + + "redactionTokens": { + "description": "An array of strings used to replace sensitive information in a redaction-aware property.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "type": "string" + } + }, + + "defaultEncoding": { + "description": "Specifies the default encoding for any artifact object that refers to a text file.", + "type": "string" + }, + + "defaultSourceLanguage": { + "description": "Specifies the default source language for any artifact object that refers to a text file that contains source code.", + "type": "string" + }, + + "newlineSequences": { + "description": "An ordered list of character sequences that were treated as line breaks when computing region information for the run.", + "type": "array", + "minItems": 1, + "uniqueItems": true, + "default": [ "\r\n", "\n" ], + "items": { + "type": "string" + } + }, + + "columnKind": { + "description": "Specifies the unit in which the tool measures columns.", + "enum": [ "utf16CodeUnits", "unicodeCodePoints" ], + "type": "string" + }, + + "externalPropertyFileReferences": { + "description": "References to external property files that should be inlined with the content of a root log file.", + "$ref": "#/definitions/externalPropertyFileReferences" + }, + + "threadFlowLocations": { + "description": "An array of threadFlowLocation objects cached at run level.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/threadFlowLocation" + } + }, + + "taxonomies": { + "description": "An array of toolComponent objects relevant to a taxonomy in which results are categorized.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/toolComponent" + } + }, + + "addresses": { + "description": "Addresses associated with this run instance, if any.", + "type": "array", + "minItems": 0, + "uniqueItems": false, + "default": [], + "items": { + "$ref": "#/definitions/address" + } + }, + + "translations": { + "description": "The set of available translations of the localized data provided by the tool.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/toolComponent" + } + }, + + "policies": { + "description": "Contains configurations that may potentially override both reportingDescriptor.defaultConfiguration (the tool's default severities) and invocation.configurationOverrides (severities established at run-time from the command line).", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/toolComponent" + } + }, + + "webRequests": { + "description": "An array of request objects cached at run level.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/webRequest" + } + }, + + "webResponses": { + "description": "An array of response objects cached at run level.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/webResponse" + } + }, + + "specialLocations": { + "description": "A specialLocations object that defines locations of special significance to SARIF consumers.", + "$ref": "#/definitions/specialLocations" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the run.", + "$ref": "#/definitions/propertyBag" + } + }, + + "required": [ "tool" ] + }, + + "runAutomationDetails": { + "description": "Information that describes a run's identity and role within an engineering system process.", + "additionalProperties": false, + "type": "object", + "properties": { + + "description": { + "description": "A description of the identity and role played within the engineering system by this object's containing run object.", + "$ref": "#/definitions/message" + }, + + "id": { + "description": "A hierarchical string that uniquely identifies this object's containing run object.", + "type": "string" + }, + + "guid": { + "description": "A stable, unique identifier for this object's containing run object in the form of a GUID.", + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + }, + + "correlationGuid": { + "description": "A stable, unique identifier for the equivalence class of runs to which this object's containing run object belongs in the form of a GUID.", + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the run automation details.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "specialLocations": { + "description": "Defines locations of special significance to SARIF consumers.", + "type": "object", + "additionalProperties": false, + "properties": { + + "displayBase": { + "description": "Provides a suggestion to SARIF consumers to display file paths relative to the specified location.", + "$ref": "#/definitions/artifactLocation" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the special locations.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "stack": { + "description": "A call stack that is relevant to a result.", + "additionalProperties": false, + "type": "object", + "properties": { + + "message": { + "description": "A message relevant to this call stack.", + "$ref": "#/definitions/message" + }, + + "frames": { + "description": "An array of stack frames that represents a sequence of calls, rendered in reverse chronological order, that comprise the call stack.", + "type": "array", + "minItems": 0, + "uniqueItems": false, + "items": { + "$ref": "#/definitions/stackFrame" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the stack.", + "$ref": "#/definitions/propertyBag" + } + }, + "required": [ "frames" ] + }, + + "stackFrame": { + "description": "A function call within a stack trace.", + "additionalProperties": false, + "type": "object", + "properties": { + + "location": { + "description": "The location to which this stack frame refers.", + "$ref": "#/definitions/location" + }, + + "module": { + "description": "The name of the module that contains the code of this stack frame.", + "type": "string" + }, + + "threadId": { + "description": "The thread identifier of the stack frame.", + "type": "integer" + }, + + "parameters": { + "description": "The parameters of the call that is executing.", + "type": "array", + "minItems": 0, + "uniqueItems": false, + "default": [], + "items": { + "type": "string", + "default": [] + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the stack frame.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "suppression": { + "description": "A suppression that is relevant to a result.", + "additionalProperties": false, + "type": "object", + "properties": { + + "guid": { + "description": "A stable, unique identifier for the suprression in the form of a GUID.", + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + }, + + "kind": { + "description": "A string that indicates where the suppression is persisted.", + "enum": [ + "inSource", + "external" + ], + "type": "string" + }, + + "status": { + "description": "A string that indicates the review status of the suppression.", + "enum": [ + "accepted", + "underReview", + "rejected" + ], + "type": "string" + }, + + "justification": { + "description": "A string representing the justification for the suppression.", + "type": "string" + }, + + "location": { + "description": "Identifies the location associated with the suppression.", + "$ref": "#/definitions/location" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the suppression.", + "$ref": "#/definitions/propertyBag" + } + }, + "required": [ "kind" ] + }, + + "threadFlow": { + "description": "Describes a sequence of code locations that specify a path through a single thread of execution such as an operating system or fiber.", + "type": "object", + "additionalProperties": false, + "properties": { + + "id": { + "description": "An string that uniquely identifies the threadFlow within the codeFlow in which it occurs.", + "type": "string" + }, + + "message": { + "description": "A message relevant to the thread flow.", + "$ref": "#/definitions/message" + }, + + + "initialState": { + "description": "Values of relevant expressions at the start of the thread flow that may change during thread flow execution.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/multiformatMessageString" + } + }, + + "immutableState": { + "description": "Values of relevant expressions at the start of the thread flow that remain constant.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/multiformatMessageString" + } + }, + + "locations": { + "description": "A temporally ordered array of 'threadFlowLocation' objects, each of which describes a location visited by the tool while producing the result.", + "type": "array", + "minItems": 1, + "uniqueItems": false, + "items": { + "$ref": "#/definitions/threadFlowLocation" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the thread flow.", + "$ref": "#/definitions/propertyBag" + } + }, + + "required": [ "locations" ] + }, + + "threadFlowLocation": { + "description": "A location visited by an analysis tool while simulating or monitoring the execution of a program.", + "additionalProperties": false, + "type": "object", + "properties": { + + "index": { + "description": "The index within the run threadFlowLocations array.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "location": { + "description": "The code location.", + "$ref": "#/definitions/location" + }, + + "stack": { + "description": "The call stack leading to this location.", + "$ref": "#/definitions/stack" + }, + + "kinds": { + "description": "A set of distinct strings that categorize the thread flow location. Well-known kinds include 'acquire', 'release', 'enter', 'exit', 'call', 'return', 'branch', 'implicit', 'false', 'true', 'caution', 'danger', 'unknown', 'unreachable', 'taint', 'function', 'handler', 'lock', 'memory', 'resource', 'scope' and 'value'.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "type": "string" + } + }, + + "taxa": { + "description": "An array of references to rule or taxonomy reporting descriptors that are applicable to the thread flow location.", + "type": "array", + "default": [], + "minItems": 0, + "uniqueItems": true, + "items": { + "$ref": "#/definitions/reportingDescriptorReference" + } + }, + + "module": { + "description": "The name of the module that contains the code that is executing.", + "type": "string" + }, + + "state": { + "description": "A dictionary, each of whose keys specifies a variable or expression, the associated value of which represents the variable or expression value. For an annotation of kind 'continuation', for example, this dictionary might hold the current assumed values of a set of global variables.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/multiformatMessageString" + } + }, + + "nestingLevel": { + "description": "An integer representing a containment hierarchy within the thread flow.", + "type": "integer", + "minimum": 0 + }, + + "executionOrder": { + "description": "An integer representing the temporal order in which execution reached this location.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "executionTimeUtc": { + "description": "The Coordinated Universal Time (UTC) date and time at which this location was executed.", + "type": "string", + "format": "date-time" + }, + + "importance": { + "description": "Specifies the importance of this location in understanding the code flow in which it occurs. The order from most to least important is \"essential\", \"important\", \"unimportant\". Default: \"important\".", + "enum": [ "important", "essential", "unimportant" ], + "default": "important", + "type": "string" + }, + + "webRequest": { + "description": "A web request associated with this thread flow location.", + "$ref": "#/definitions/webRequest" + }, + + "webResponse": { + "description": "A web response associated with this thread flow location.", + "$ref": "#/definitions/webResponse" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the threadflow location.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "tool": { + "description": "The analysis tool that was run.", + "additionalProperties": false, + "type": "object", + "properties": { + + "driver": { + "description": "The analysis tool that was run.", + "$ref": "#/definitions/toolComponent" + }, + + "extensions": { + "description": "Tool extensions that contributed to or reconfigured the analysis tool that was run.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/toolComponent" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the tool.", + "$ref": "#/definitions/propertyBag" + } + }, + + "required": [ "driver" ] + }, + + "toolComponent": { + "description": "A component, such as a plug-in or the driver, of the analysis tool that was run.", + "additionalProperties": false, + "type": "object", + "properties": { + + "guid": { + "description": "A unique identifier for the tool component in the form of a GUID.", + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + }, + + "name": { + "description": "The name of the tool component.", + "type": "string" + }, + + "organization": { + "description": "The organization or company that produced the tool component.", + "type": "string" + }, + + "product": { + "description": "A product suite to which the tool component belongs.", + "type": "string" + }, + + "productSuite": { + "description": "A localizable string containing the name of the suite of products to which the tool component belongs.", + "type": "string" + }, + + "shortDescription": { + "description": "A brief description of the tool component.", + "$ref": "#/definitions/multiformatMessageString" + }, + + "fullDescription": { + "description": "A comprehensive description of the tool component.", + "$ref": "#/definitions/multiformatMessageString" + }, + + "fullName": { + "description": "The name of the tool component along with its version and any other useful identifying information, such as its locale.", + "type": "string" + }, + + "version": { + "description": "The tool component version, in whatever format the component natively provides.", + "type": "string" + }, + + "semanticVersion": { + "description": "The tool component version in the format specified by Semantic Versioning 2.0.", + "type": "string" + }, + + "dottedQuadFileVersion": { + "description": "The binary version of the tool component's primary executable file expressed as four non-negative integers separated by a period (for operating systems that express file versions in this way).", + "type": "string", + "pattern": "[0-9]+(\\.[0-9]+){3}" + }, + + "releaseDateUtc": { + "description": "A string specifying the UTC date (and optionally, the time) of the component's release.", + "type": "string" + }, + + "downloadUri": { + "description": "The absolute URI from which the tool component can be downloaded.", + "type": "string", + "format": "uri" + }, + + "informationUri": { + "description": "The absolute URI at which information about this version of the tool component can be found.", + "type": "string", + "format": "uri" + }, + + "globalMessageStrings": { + "description": "A dictionary, each of whose keys is a resource identifier and each of whose values is a multiformatMessageString object, which holds message strings in plain text and (optionally) Markdown format. The strings can include placeholders, which can be used to construct a message in combination with an arbitrary number of additional string arguments.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/multiformatMessageString" + } + }, + + "notifications": { + "description": "An array of reportingDescriptor objects relevant to the notifications related to the configuration and runtime execution of the tool component.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/reportingDescriptor" + } + }, + + "rules": { + "description": "An array of reportingDescriptor objects relevant to the analysis performed by the tool component.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/reportingDescriptor" + } + }, + + "taxa": { + "description": "An array of reportingDescriptor objects relevant to the definitions of both standalone and tool-defined taxonomies.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/reportingDescriptor" + } + }, + + "locations": { + "description": "An array of the artifactLocation objects associated with the tool component.", + "type": "array", + "minItems": 0, + "default": [], + "items": { + "$ref": "#/definitions/artifactLocation" + } + }, + + "language": { + "description": "The language of the messages emitted into the log file during this run (expressed as an ISO 639-1 two-letter lowercase language code) and an optional region (expressed as an ISO 3166-1 two-letter uppercase subculture code associated with a country or region). The casing is recommended but not required (in order for this data to conform to RFC5646).", + "type": "string", + "default": "en-US", + "pattern": "^[a-zA-Z]{2}(-[a-zA-Z]{2})?$" + }, + + "contents": { + "description": "The kinds of data contained in this object.", + "type": "array", + "uniqueItems": true, + "default": [ "localizedData", "nonLocalizedData" ], + "items": { + "enum": [ + "localizedData", + "nonLocalizedData" + ], + "type": "string" + } + }, + + "isComprehensive": { + "description": "Specifies whether this object contains a complete definition of the localizable and/or non-localizable data for this component, as opposed to including only data that is relevant to the results persisted to this log file.", + "type": "boolean", + "default": false + }, + + "localizedDataSemanticVersion": { + "description": "The semantic version of the localized strings defined in this component; maintained by components that provide translations.", + "type": "string" + }, + + "minimumRequiredLocalizedDataSemanticVersion": { + "description": "The minimum value of localizedDataSemanticVersion required in translations consumed by this component; used by components that consume translations.", + "type": "string" + }, + + "associatedComponent": { + "description": "The component which is strongly associated with this component. For a translation, this refers to the component which has been translated. For an extension, this is the driver that provides the extension's plugin model.", + "$ref": "#/definitions/toolComponentReference" + }, + + "translationMetadata": { + "description": "Translation metadata, required for a translation, not populated by other component types.", + "$ref": "#/definitions/translationMetadata" + }, + + "supportedTaxonomies": { + "description": "An array of toolComponentReference objects to declare the taxonomies supported by the tool component.", + "type": "array", + "minItems": 0, + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/definitions/toolComponentReference" + } + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the tool component.", + "$ref": "#/definitions/propertyBag" + } + }, + + "required": [ "name" ] + }, + + "toolComponentReference": { + "description": "Identifies a particular toolComponent object, either the driver or an extension.", + "type": "object", + "additionalProperties": false, + "properties": { + + "name": { + "description": "The 'name' property of the referenced toolComponent.", + "type": "string" + }, + + "index": { + "description": "An index into the referenced toolComponent in tool.extensions.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "guid": { + "description": "The 'guid' property of the referenced toolComponent.", + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the toolComponentReference.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "translationMetadata": { + "description": "Provides additional metadata related to translation.", + "type": "object", + "additionalProperties": false, + "properties": { + + "name": { + "description": "The name associated with the translation metadata.", + "type": "string" + }, + + "fullName": { + "description": "The full name associated with the translation metadata.", + "type": "string" + }, + + "shortDescription": { + "description": "A brief description of the translation metadata.", + "$ref": "#/definitions/multiformatMessageString" + }, + + "fullDescription": { + "description": "A comprehensive description of the translation metadata.", + "$ref": "#/definitions/multiformatMessageString" + }, + + "downloadUri": { + "description": "The absolute URI from which the translation metadata can be downloaded.", + "type": "string", + "format": "uri" + }, + + "informationUri": { + "description": "The absolute URI from which information related to the translation metadata can be downloaded.", + "type": "string", + "format": "uri" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the translation metadata.", + "$ref": "#/definitions/propertyBag" + } + }, + "required": [ "name" ] + }, + + "versionControlDetails": { + "description": "Specifies the information necessary to retrieve a desired revision from a version control system.", + "type": "object", + "additionalProperties": false, + "properties": { + + "repositoryUri": { + "description": "The absolute URI of the repository.", + "type": "string", + "format": "uri" + }, + + "revisionId": { + "description": "A string that uniquely and permanently identifies the revision within the repository.", + "type": "string" + }, + + "branch": { + "description": "The name of a branch containing the revision.", + "type": "string" + }, + + "revisionTag": { + "description": "A tag that has been applied to the revision.", + "type": "string" + }, + + "asOfTimeUtc": { + "description": "A Coordinated Universal Time (UTC) date and time that can be used to synchronize an enlistment to the state of the repository at that time.", + "type": "string", + "format": "date-time" + }, + + "mappedTo": { + "description": "The location in the local file system to which the root of the repository was mapped at the time of the analysis.", + "$ref": "#/definitions/artifactLocation" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the version control details.", + "$ref": "#/definitions/propertyBag" + } + }, + + "required": [ "repositoryUri" ] + }, + + "webRequest": { + "description": "Describes an HTTP request.", + "type": "object", + "additionalProperties": false, + "properties": { + + "index": { + "description": "The index within the run.webRequests array of the request object associated with this result.", + "type": "integer", + "default": -1, + "minimum": -1 + + }, + + "protocol": { + "description": "The request protocol. Example: 'http'.", + "type": "string" + }, + + "version": { + "description": "The request version. Example: '1.1'.", + "type": "string" + }, + + "target": { + "description": "The target of the request.", + "type": "string" + }, + + "method": { + "description": "The HTTP method. Well-known values are 'GET', 'PUT', 'POST', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', 'TRACE', 'CONNECT'.", + "type": "string" + }, + + "headers": { + "description": "The request headers.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + + "parameters": { + "description": "The request parameters.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + + "body": { + "description": "The body of the request.", + "$ref": "#/definitions/artifactContent" + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the request.", + "$ref": "#/definitions/propertyBag" + } + } + }, + + "webResponse": { + "description": "Describes the response to an HTTP request.", + "type": "object", + "additionalProperties": false, + "properties": { + + "index": { + "description": "The index within the run.webResponses array of the response object associated with this result.", + "type": "integer", + "default": -1, + "minimum": -1 + }, + + "protocol": { + "description": "The response protocol. Example: 'http'.", + "type": "string" + }, + + "version": { + "description": "The response version. Example: '1.1'.", + "type": "string" + }, + + "statusCode": { + "description": "The response status code. Example: 451.", + "type": "integer" + }, + + "reasonPhrase": { + "description": "The response reason. Example: 'Not found'.", + "type": "string" + }, + + "headers": { + "description": "The response headers.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + + "body": { + "description": "The body of the response.", + "$ref": "#/definitions/artifactContent" + }, + + "noResponseReceived": { + "description": "Specifies whether a response was received from the server.", + "type": "boolean", + "default": false + }, + + "properties": { + "description": "Key/value pairs that provide additional information about the response.", + "$ref": "#/definitions/propertyBag" + } + } + } + } +} diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..b97863b --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,191 @@ +import json +import textwrap +from pathlib import Path + +import pytest + +from kuberoast import __version__ +from kuberoast.cli import build_parser, main + + +def _write(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(textwrap.dedent(content), encoding="utf-8") + + +def test_parser_version_flag(capsys): + parser = build_parser() + with pytest.raises(SystemExit) as exc: + parser.parse_args(["--version"]) + assert exc.value.code == 0 + captured = capsys.readouterr() + assert __version__ in captured.out + + +def test_parser_defaults(): + parser = build_parser() + args = parser.parse_args([]) + assert args.report == "json" + assert args.min_severity == "info" + assert args.fail_on is None + assert args.no_compliance is False + + +def test_parser_supports_new_formats(): + parser = build_parser() + for fmt in ("json", "text", "html", "sarif", "junit", "csv"): + args = parser.parse_args(["--report", fmt]) + assert args.report == fmt + + +def test_html_requires_out(): + rc = main(["--report", "html"]) + assert rc == 2 + + +def test_sarif_requires_out(): + rc = main(["--report", "sarif"]) + assert rc == 2 + + +def test_manifest_scan_via_cli(tmp_path: Path, capsys): + pod_path = tmp_path / "pod.yaml" + _write( + pod_path, + """ + apiVersion: v1 + kind: Pod + metadata: + name: bad + namespace: default + spec: + containers: + - name: c + image: nginx + securityContext: + privileged: true + """, + ) + rc = main(["--manifests", str(tmp_path), "--report", "json", "--no-compliance"]) + assert rc == 0 + out = capsys.readouterr().out + data = json.loads(out) + ids = {f["id"] for f in data} + assert "POD-PRIV" in ids + + +def test_manifest_scan_with_compliance(tmp_path: Path, capsys): + pod_path = tmp_path / "pod.yaml" + _write( + pod_path, + """ + apiVersion: v1 + kind: Pod + metadata: + name: bad + namespace: default + spec: + containers: + - name: c + image: nginx + securityContext: + privileged: true + """, + ) + rc = main(["--manifests", str(tmp_path), "--report", "json"]) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + priv = next(f for f in data if f["id"] == "POD-PRIV") + assert priv["cis_controls"] + assert priv["mitre_attack"] + assert priv["cwe"] + + +def test_manifest_scan_fail_on_threshold(tmp_path: Path): + pod_path = tmp_path / "pod.yaml" + _write( + pod_path, + """ + apiVersion: v1 + kind: Pod + metadata: + name: bad + namespace: default + spec: + containers: + - name: c + image: nginx + securityContext: + privileged: true + """, + ) + rc = main([ + "--manifests", str(tmp_path), + "--report", "json", + "--fail-on", "critical", + ]) + assert rc == 1 + + +def test_manifest_scan_min_severity_filter(tmp_path: Path, capsys): + pod_path = tmp_path / "pod.yaml" + _write( + pod_path, + """ + apiVersion: v1 + kind: Pod + metadata: + name: bad + namespace: default + spec: + containers: + - name: c + image: nginx + securityContext: + privileged: true + """, + ) + rc = main([ + "--manifests", str(tmp_path), + "--report", "json", + "--min-severity", "critical", + ]) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + assert all(f["severity"] == "critical" for f in data) + + +def test_manifest_scan_sarif_to_file(tmp_path: Path): + pod_path = tmp_path / "pod.yaml" + out_path = tmp_path / "out.sarif" + _write( + pod_path, + """ + apiVersion: v1 + kind: Pod + metadata: + name: bad + namespace: default + spec: + containers: + - name: c + image: nginx + securityContext: + privileged: true + """, + ) + rc = main([ + "--manifests", str(tmp_path), + "--report", "sarif", + "--out", str(out_path), + ]) + assert rc == 0 + assert out_path.exists() + sarif = json.loads(out_path.read_text()) + assert sarif["version"] == "2.1.0" + assert sarif["runs"][0]["tool"]["driver"]["name"] == "KubeRoast" + + +def test_missing_manifest_path_returns_2(tmp_path: Path): + rc = main(["--manifests", str(tmp_path / "nope")]) + assert rc == 2 diff --git a/tests/test_compliance.py b/tests/test_compliance.py new file mode 100644 index 0000000..195d4f7 --- /dev/null +++ b/tests/test_compliance.py @@ -0,0 +1,61 @@ +from kuberoast.utils.compliance import COMPLIANCE_MAP, enrich_finding, enrich_findings +from kuberoast.utils.findings import Finding + + +def test_known_finding_enriched(): + f = Finding(id="POD-PRIV", title="Privileged container", description="x", severity="critical") + enrich_finding(f) + assert any(c.startswith("CIS-K8s-") for c in f.cis_controls) + assert "T1611" in f.mitre_attack + assert any(c.startswith("CWE-") for c in f.cwe) + + +def test_unknown_finding_passthrough(): + f = Finding(id="UNKNOWN-CHECK", title="x", description="y", severity="low") + enrich_finding(f) + assert f.cis_controls == [] + assert f.mitre_attack == [] + assert f.cwe == [] + + +def test_enrich_findings_handles_list(): + findings = [ + Finding(id="POD-PRIV", title="x", description="y", severity="critical"), + Finding(id="RBAC-CLUSTER-ADMIN", title="x", description="y", severity="critical"), + Finding(id="UNKNOWN", title="x", description="y", severity="low"), + ] + enrich_findings(findings) + assert findings[0].cis_controls + assert findings[1].cis_controls + assert findings[2].cis_controls == [] + + +def test_existing_compliance_not_overwritten(): + f = Finding( + id="POD-PRIV", + title="x", + description="y", + severity="critical", + cis_controls=["CUSTOM-1"], + ) + enrich_finding(f) + assert f.cis_controls == ["CUSTOM-1"] + + +def test_compliance_map_covers_all_known_ids(): + """Sanity check that key finding IDs have compliance mappings.""" + expected_ids = { + "POD-PRIV", "POD-ROOT", "POD-PE", "POD-HOSTNS", "POD-CAPS", + "POD-HOSTPATH", "POD-RWFS", "POD-NO-SECCOMP", "POD-NO-LIMITS", + "POD-SATOKEN", "POD-NO-APPARMOR", + "RBAC-ANON", "RBAC-CLUSTER-ADMIN", "RBAC-ESCALATION-VERB", + "RBAC-WILDCARD", "RBAC-SENSITIVE-WRITE", "RBAC-BROAD-GROUP", + "AP-RBAC-ESC", + "NET-LB-OPEN", "NET-EXTERNAL-IP", "NET-INGRESS-NO-TLS", + "NET-NODEPORT", "NET-INGRESS-WILDCARD", + "NODE-KUBELET-RO", "NODE-KUBELET-API", + "SECRET-SENSITIVE", "SECRET-DOCKER-HUB", "SECRET-TLS-MANUAL", + "POLICY-NONE", "PSS-NOT-ENFORCED", + } + missing = expected_ids - set(COMPLIANCE_MAP.keys()) + assert not missing, f"Missing compliance mappings for: {missing}" diff --git a/tests/test_e2e_examples.py b/tests/test_e2e_examples.py new file mode 100644 index 0000000..eb6a120 --- /dev/null +++ b/tests/test_e2e_examples.py @@ -0,0 +1,109 @@ +"""End-to-end golden tests against the bundled examples/ manifests. + +These lock in the *expected* findings for the curated insecure samples so +regressions in any scanner show up loudly. Each example file is a known-bad +artifact whose findings should remain stable across refactors. +""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from kuberoast.cli import main + +EXAMPLES = Path(__file__).resolve().parent.parent / "examples" + + +def _scan_to_json(tmp_path: Path, args: list) -> list: + out = tmp_path / "out.json" + rc = main(args + ["--report", "json", "--out", str(out)]) + assert rc in (0, 1), f"unexpected rc={rc}" + return json.loads(out.read_text()) + + +def test_examples_directory_exists(): + assert EXAMPLES.is_dir() + assert any(EXAMPLES.glob("*.yaml")) + + +def test_insecure_pod_finds_all_expected_critical_high(tmp_path: Path): + findings = _scan_to_json(tmp_path, ["--manifests", str(EXAMPLES / "insecure-pod.yaml")]) + ids = {f["id"] for f in findings} + expected = { + "POD-PRIV", "POD-ROOT", "POD-PE", "POD-HOSTNS", + "POD-CAPS", "POD-HOSTPATH", "POD-RWFS", + "POD-NO-SECCOMP", "POD-NO-LIMITS", "POD-NO-APPARMOR", + "POD-SATOKEN", + } + missing = expected - ids + assert not missing, f"missing expected findings: {missing}" + + +def test_insecure_rbac_finds_anon_admin_and_wildcard(tmp_path: Path): + findings = _scan_to_json(tmp_path, ["--manifests", str(EXAMPLES / "insecure-rbac.yaml")]) + ids = {f["id"] for f in findings} + assert "RBAC-ANON" in ids + assert "RBAC-CLUSTER-ADMIN" in ids + assert "RBAC-WILDCARD" in ids + assert "RBAC-ESCALATION-VERB" in ids + + +def test_insecure_network_finds_lb_and_no_tls(tmp_path: Path): + findings = _scan_to_json(tmp_path, ["--manifests", str(EXAMPLES / "insecure-network.yaml")]) + ids = {f["id"] for f in findings} + assert "NET-LB-OPEN" in ids + assert "NET-INGRESS-NO-TLS" in ids + + +def test_full_examples_directory_compliance_enriched(tmp_path: Path): + """Every finding in the example sweep should carry compliance metadata.""" + findings = _scan_to_json(tmp_path, ["--manifests", str(EXAMPLES)]) + assert findings, "expected non-empty findings from examples sweep" + enriched = [f for f in findings if f["cis_controls"] or f["mitre_attack"] or f["cwe"]] + # POLICY-NONE / PSS findings won't fire here (no namespaces/CRDs in examples) + # but every Pod/RBAC/Network finding should be enriched. + assert len(enriched) / len(findings) >= 0.9, ( + f"compliance enrichment coverage too low: {len(enriched)}/{len(findings)}" + ) + + +def test_fail_on_critical_with_examples_returns_1(tmp_path: Path): + out = tmp_path / "out.json" + rc = main([ + "--manifests", str(EXAMPLES), + "--report", "json", + "--out", str(out), + "--fail-on", "critical", + ]) + assert rc == 1 + + +def test_min_severity_critical_filters_examples(tmp_path: Path): + findings = _scan_to_json( + tmp_path, + ["--manifests", str(EXAMPLES), "--min-severity", "critical"], + ) + assert findings + assert all(f["severity"] == "critical" for f in findings) + + +@pytest.mark.parametrize("fmt,extension", [ + ("json", "json"), + ("text", "txt"), + ("html", "html"), + ("sarif", "sarif"), + ("junit", "xml"), + ("csv", "csv"), +]) +def test_every_format_emits_non_empty_against_examples(tmp_path: Path, fmt: str, extension: str): + out = tmp_path / f"out.{extension}" + rc = main([ + "--manifests", str(EXAMPLES), + "--report", fmt, + "--out", str(out), + ]) + assert rc == 0 + content = out.read_text() + assert content.strip(), f"{fmt} output was empty" diff --git a/tests/test_junit_csv.py b/tests/test_junit_csv.py new file mode 100644 index 0000000..8bad10c --- /dev/null +++ b/tests/test_junit_csv.py @@ -0,0 +1,81 @@ +import csv +import io +from xml.etree import ElementTree as ET + +from kuberoast.reporting import csv_report, junit +from kuberoast.utils.findings import Finding + + +def _findings(): + return [ + Finding( + id="POD-PRIV", + title="Privileged container", + description="Bad", + severity="critical", + category="Pod Security", + resource="pod/web::nginx", + namespace="default", + remediation="Don't be privileged", + cis_controls=["CIS-K8s-5.2.1"], + mitre_attack=["T1611"], + ), + Finding( + id="NET-LB-OPEN", + title="LB open", + description="Open to internet", + severity="high", + category="Network", + resource="service/web", + ), + ] + + +def test_junit_emits_valid_xml(): + out = junit.emit(_findings()) + root = ET.fromstring(out) + assert root.tag == "testsuites" + assert root.attrib["tests"] == "2" + assert int(root.attrib["failures"]) >= 1 + + +def test_junit_groups_by_category(): + out = junit.emit(_findings()) + root = ET.fromstring(out) + suites = {s.attrib["name"] for s in root.findall("testsuite")} + assert "Pod Security" in suites + assert "Network" in suites + + +def test_junit_critical_emits_error_tag(): + out = junit.emit(_findings()) + root = ET.fromstring(out) + pod_suite = next(s for s in root.findall("testsuite") if s.attrib["name"] == "Pod Security") + case = pod_suite.find("testcase") + assert case.find("error") is not None + + +def test_csv_header_and_rows(): + out = csv_report.emit(_findings()) + reader = csv.reader(io.StringIO(out)) + rows = list(reader) + assert rows[0][0] == "id" + assert "POD-PRIV" in [r[0] for r in rows[1:]] + assert "NET-LB-OPEN" in [r[0] for r in rows[1:]] + + +def test_csv_includes_compliance_columns(): + out = csv_report.emit(_findings()) + reader = csv.DictReader(io.StringIO(out)) + rows = list(reader) + pod = next(r for r in rows if r["id"] == "POD-PRIV") + assert "CIS-K8s-5.2.1" in pod["cis_controls"] + assert "T1611" in pod["mitre_attack"] + + +def test_csv_handles_empty(): + out = csv_report.emit([]) + reader = csv.reader(io.StringIO(out)) + rows = list(reader) + assert len(rows) == 1 + assert rows[0][0] == "id" diff --git a/tests/test_manifests.py b/tests/test_manifests.py new file mode 100644 index 0000000..6c25e3f --- /dev/null +++ b/tests/test_manifests.py @@ -0,0 +1,260 @@ +import textwrap +from pathlib import Path + +import pytest + +from kuberoast.scanners.network import scan_ingresses, scan_services +from kuberoast.scanners.pods import scan_pod_security +from kuberoast.scanners.rbac import scan_rbac +from kuberoast.utils.manifests import ManifestObject, load_manifests + + +def _write(tmp_path: Path, name: str, content: str) -> Path: + path = tmp_path / name + path.write_text(textwrap.dedent(content), encoding="utf-8") + return path + + +def test_load_pod_yaml(tmp_path: Path): + _write( + tmp_path, + "pod.yaml", + """ + apiVersion: v1 + kind: Pod + metadata: + name: insecure-pod + namespace: default + spec: + hostNetwork: true + containers: + - name: app + image: nginx + securityContext: + privileged: true + runAsUser: 0 + """, + ) + objects = load_manifests(str(tmp_path)) + assert len(objects["pods"]) == 1 + pod = objects["pods"][0] + assert pod.metadata.name == "insecure-pod" + assert pod.spec.host_network is True + assert pod.spec.containers[0].security_context.privileged is True + + +def test_pod_scanner_works_against_manifest_object(tmp_path: Path): + _write( + tmp_path, + "pod.yaml", + """ + apiVersion: v1 + kind: Pod + metadata: + name: bad + namespace: default + spec: + hostNetwork: true + containers: + - name: c + image: nginx + securityContext: + privileged: true + runAsUser: 0 + allowPrivilegeEscalation: true + capabilities: + add: [SYS_ADMIN] + """, + ) + objects = load_manifests(str(tmp_path)) + pod = objects["pods"][0] + findings = scan_pod_security(pod) + ids = {f.id for f in findings} + assert "POD-PRIV" in ids + assert "POD-ROOT" in ids + assert "POD-PE" in ids + assert "POD-HOSTNS" in ids + assert "POD-CAPS" in ids + + +def test_load_deployment_extracts_pod_template(tmp_path: Path): + _write( + tmp_path, + "deploy.yaml", + """ + apiVersion: apps/v1 + kind: Deployment + metadata: + name: web + namespace: default + spec: + replicas: 1 + selector: {matchLabels: {app: web}} + template: + metadata: + labels: {app: web} + spec: + containers: + - name: web + image: nginx + securityContext: + privileged: true + """, + ) + objects = load_manifests(str(tmp_path)) + assert len(objects["pods"]) == 1 + findings = scan_pod_security(objects["pods"][0]) + assert any(f.id == "POD-PRIV" for f in findings) + + +def test_load_cronjob_extracts_pod_template(tmp_path: Path): + _write( + tmp_path, + "cron.yaml", + """ + apiVersion: batch/v1 + kind: CronJob + metadata: {name: backup, namespace: default} + spec: + schedule: "0 0 * * *" + jobTemplate: + spec: + template: + spec: + containers: + - name: c + image: busybox + securityContext: + runAsUser: 0 + restartPolicy: OnFailure + """, + ) + objects = load_manifests(str(tmp_path)) + assert len(objects["pods"]) == 1 + ids = {f.id for f in scan_pod_security(objects["pods"][0])} + assert "POD-ROOT" in ids + + +def test_load_rbac_resources(tmp_path: Path): + _write( + tmp_path, + "rbac.yaml", + """ + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRole + metadata: {name: too-broad} + rules: + - apiGroups: ["*"] + resources: ["*"] + verbs: ["*"] + --- + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRoleBinding + metadata: {name: anon-admin} + subjects: + - kind: User + name: system:anonymous + apiGroup: rbac.authorization.k8s.io + roleRef: + kind: ClusterRole + name: cluster-admin + apiGroup: rbac.authorization.k8s.io + """, + ) + objects = load_manifests(str(tmp_path)) + findings = scan_rbac( + objects["roles"], + objects["cluster_roles"], + objects["role_bindings"], + objects["cluster_role_bindings"], + ) + ids = {f.id for f in findings} + assert "RBAC-WILDCARD" in ids + assert "RBAC-ANON" in ids + assert "RBAC-CLUSTER-ADMIN" in ids + + +def test_load_service_loadbalancer(tmp_path: Path): + _write( + tmp_path, + "svc.yaml", + """ + apiVersion: v1 + kind: Service + metadata: {name: web, namespace: default} + spec: + type: LoadBalancer + ports: [{port: 80}] + """, + ) + objects = load_manifests(str(tmp_path)) + findings = scan_services(objects["services"]) + assert any(f.id == "NET-LB-OPEN" for f in findings) + + +def test_load_ingress_no_tls(tmp_path: Path): + _write( + tmp_path, + "ing.yaml", + """ + apiVersion: networking.k8s.io/v1 + kind: Ingress + metadata: {name: app, namespace: default} + spec: + rules: + - host: app.example.com + http: + paths: + - path: / + pathType: Prefix + backend: {service: {name: web, port: {number: 80}}} + """, + ) + objects = load_manifests(str(tmp_path)) + findings = scan_ingresses(objects["ingresses"]) + assert any(f.id == "NET-INGRESS-NO-TLS" for f in findings) + + +def test_load_json_manifest(tmp_path: Path): + _write( + tmp_path, + "pod.json", + """ + { + "apiVersion": "v1", + "kind": "Pod", + "metadata": {"name": "j", "namespace": "default"}, + "spec": { + "containers": [{"name": "c", "image": "x", "securityContext": {"privileged": true}}] + } + } + """, + ) + objects = load_manifests(str(tmp_path)) + assert len(objects["pods"]) == 1 + + +def test_missing_path_raises(tmp_path: Path): + with pytest.raises(FileNotFoundError): + load_manifests(str(tmp_path / "does-not-exist")) + + +def test_skips_non_kube_kinds(tmp_path: Path): + _write( + tmp_path, + "kustomize.yaml", + """ + apiVersion: kustomize.config.k8s.io/v1beta1 + kind: Kustomization + resources: + - pod.yaml + """, + ) + objects = load_manifests(str(tmp_path)) + assert objects["pods"] == [] + + +def test_manifest_object_falls_back_to_none(): + obj = ManifestObject({"name": "x"}) + assert obj.name == "x" + assert obj.missing is None diff --git a/tests/test_network.py b/tests/test_network.py index 54d45e1..27c12b8 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -1,6 +1,7 @@ from types import SimpleNamespace -from kuberoast.scanners.network import scan_services, scan_ingresses -from tests.conftest import make_service, make_ingress + +from kuberoast.scanners.network import scan_ingresses, scan_services +from tests.conftest import make_ingress, make_service def test_nodeport_flagged(): diff --git a/tests/test_performance.py b/tests/test_performance.py new file mode 100644 index 0000000..cafa959 --- /dev/null +++ b/tests/test_performance.py @@ -0,0 +1,151 @@ +"""Performance regression tests. + +These guard against accidental O(n²) regressions in the scanners. The +thresholds are intentionally generous so they don't go red on slow CI +runners — they're meant to catch order-of-magnitude regressions, not +small slowdowns. +""" +from __future__ import annotations + +import time +from types import SimpleNamespace + +import pytest + +from kuberoast.attackpaths.rbac_escalation import analyze_attack_paths +from kuberoast.reporting import csv_report +from kuberoast.reporting import json as json_report +from kuberoast.reporting import sarif as sarif_report +from kuberoast.scanners.pods import scan_pod_security +from kuberoast.scanners.rbac import scan_rbac +from kuberoast.utils.compliance import enrich_findings +from tests.conftest import ( + make_binding, + make_container, + make_pod, + make_role, + make_rule, + make_subject, +) + +pytestmark = pytest.mark.performance + + +def _bench(fn, *args, **kwargs): + start = time.perf_counter() + out = fn(*args, **kwargs) + return out, time.perf_counter() - start + + +def _gen_pods(n: int) -> list: + pods = [] + for i in range(n): + pods.append( + make_pod( + name=f"pod-{i}", + namespace=f"ns-{i % 20}", + containers=[make_container(name=f"c-{i}", privileged=(i % 5 == 0))], + ) + ) + return pods + + +@pytest.mark.parametrize("n,limit_seconds", [(100, 1.0), (1000, 5.0)]) +def test_scan_pods_scales_linearly(n: int, limit_seconds: float): + pods = _gen_pods(n) + findings = [] + _, elapsed = _bench(lambda: [findings.extend(scan_pod_security(p)) for p in pods]) + assert elapsed < limit_seconds, f"scanning {n} pods took {elapsed:.2f}s (>{limit_seconds}s)" + assert findings + + +def test_rbac_scanner_scales_to_large_cluster(): + """500 roles + 500 cluster role bindings should finish well under 5s.""" + roles = [ + make_role( + name=f"r-{i}", + namespace="default", + rules=[make_rule(verbs=["get", "list"], resources=["pods"])], + ) + for i in range(500) + ] + crbs = [ + make_binding( + name=f"crb-{i}", + role_kind="ClusterRole", + role_name="view", + subjects=[make_subject(kind="ServiceAccount", name=f"sa-{i}", namespace="default")], + ) + for i in range(500) + ] + _, elapsed = _bench(scan_rbac, roles, [], [], crbs) + assert elapsed < 5.0, f"RBAC scan took {elapsed:.2f}s on 500+500" + + +def test_attack_path_analysis_handles_many_principals(): + """Attack-path analysis should be sub-linear-ish on a sparse principal graph.""" + role = make_role(rules=[make_rule(verbs=["create", "get"], resources=["pods", "secrets"])]) + crbs = [ + make_binding( + name=f"crb-{i}", + role_kind="ClusterRole", + role_name=f"r-{i}", + subjects=[make_subject(kind="ServiceAccount", name=f"sa-{i}", namespace=f"ns-{i % 20}")], + ) + for i in range(200) + ] + pods = _gen_pods(200) + _, elapsed = _bench(analyze_attack_paths, [role], [], [], crbs, pods) + assert elapsed < 5.0, f"attack-path analysis took {elapsed:.2f}s" + + +def test_compliance_enrichment_is_fast_on_many_findings(): + pods = _gen_pods(1000) + findings = [] + for p in pods: + findings.extend(scan_pod_security(p)) + _, elapsed = _bench(enrich_findings, findings) + assert elapsed < 1.0, f"compliance enrichment of {len(findings)} findings took {elapsed:.2f}s" + + +def test_json_emit_scales(): + pods = _gen_pods(1000) + findings = [] + for p in pods: + findings.extend(scan_pod_security(p)) + _, elapsed = _bench(json_report.emit, findings) + assert elapsed < 2.0 + + +def test_sarif_emit_scales(): + pods = _gen_pods(500) + findings = [] + for p in pods: + findings.extend(scan_pod_security(p)) + enrich_findings(findings) + _, elapsed = _bench(sarif_report.emit, findings) + assert elapsed < 2.0 + + +def test_csv_emit_scales(): + pods = _gen_pods(1000) + findings = [] + for p in pods: + findings.extend(scan_pod_security(p)) + _, elapsed = _bench(csv_report.emit, findings) + assert elapsed < 1.0 + + +def test_empty_inputs_are_instant(): + """Empty-input fast path must stay O(1).""" + _, elapsed = _bench(scan_pod_security, SimpleNamespace( + metadata=SimpleNamespace(name="x", namespace="x", annotations={}), + spec=SimpleNamespace( + containers=[], init_containers=[], ephemeral_containers=[], + host_network=False, host_pid=False, host_ipc=False, + automount_service_account_token=False, + volumes=[], service_account_name="default", + security_context=SimpleNamespace(seccomp_profile=None), + ), + )) + assert elapsed < 0.01 diff --git a/tests/test_pods.py b/tests/test_pods.py index 59a6203..3aefd23 100644 --- a/tests/test_pods.py +++ b/tests/test_pods.py @@ -1,5 +1,5 @@ from kuberoast.scanners.pods import scan_pod_security -from tests.conftest import make_pod, make_container +from tests.conftest import make_container, make_pod def test_privileged_container_flagged(): diff --git a/tests/test_property_manifests.py b/tests/test_property_manifests.py new file mode 100644 index 0000000..1bfb45b --- /dev/null +++ b/tests/test_property_manifests.py @@ -0,0 +1,232 @@ +"""Property-based tests using Hypothesis. + +These generate randomized manifests and verify scanner invariants: + - The parser never crashes on well-formed but oddly-shaped manifests. + - Scanners always return List[Finding] (or compatible) without raising. + - Findings always carry stable ID, severity, and category fields. + - Severity is always one of the allowed values. +""" +from __future__ import annotations + +from pathlib import Path + +import yaml +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from kuberoast.scanners.network import scan_ingresses, scan_services +from kuberoast.scanners.pods import scan_pod_security +from kuberoast.scanners.rbac import scan_rbac +from kuberoast.scanners.secrets import scan_secrets +from kuberoast.utils.findings import Finding +from kuberoast.utils.manifests import load_manifests + +VALID_SEVERITIES = {"info", "low", "medium", "high", "critical"} + +# A safe alphabet for K8s names: lowercase letters, digits, dashes +NAME = st.text(alphabet="abcdefghijklmnopqrstuvwxyz0123456789-", min_size=1, max_size=20).filter( + lambda s: not s.startswith("-") and not s.endswith("-") and "--" not in s +) + + +def _container_strategy(): + return st.fixed_dictionaries( + { + "name": NAME, + "image": st.text(alphabet="abcdefghijklmnopqrstuvwxyz0123456789:./-", min_size=1, max_size=30), + }, + optional={ + "securityContext": st.fixed_dictionaries( + {}, + optional={ + "privileged": st.booleans(), + "runAsUser": st.integers(min_value=0, max_value=65535), + "runAsNonRoot": st.booleans(), + "allowPrivilegeEscalation": st.booleans(), + "readOnlyRootFilesystem": st.booleans(), + "capabilities": st.fixed_dictionaries( + {}, + optional={ + "add": st.lists( + st.sampled_from([ + "SYS_ADMIN", "NET_ADMIN", "NET_BIND_SERVICE", + "SYS_PTRACE", "SYS_MODULE", "DAC_READ_SEARCH", + ]), + max_size=4, + ), + "drop": st.lists( + st.sampled_from(["ALL", "NET_RAW"]), max_size=2 + ), + }, + ), + }, + ), + }, + ) + + +def _pod_strategy(): + return st.fixed_dictionaries( + { + "apiVersion": st.just("v1"), + "kind": st.just("Pod"), + "metadata": st.fixed_dictionaries( + {"name": NAME}, + optional={"namespace": NAME, "labels": st.dictionaries(NAME, NAME, max_size=3)}, + ), + "spec": st.fixed_dictionaries( + {"containers": st.lists(_container_strategy(), min_size=1, max_size=3)}, + optional={ + "hostNetwork": st.booleans(), + "hostPID": st.booleans(), + "hostIPC": st.booleans(), + "automountServiceAccountToken": st.booleans(), + "serviceAccountName": NAME, + }, + ), + } + ) + + +@given(_pod_strategy()) +@settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow]) +def test_pod_scanner_never_crashes_on_random_pods(tmp_path_factory, pod): + """The pod scanner should handle any well-formed pod without raising.""" + tmp_path = tmp_path_factory.mktemp("pods") + (tmp_path / "p.yaml").write_text(yaml.safe_dump(pod), encoding="utf-8") + objects = load_manifests(str(tmp_path)) + findings = scan_pod_security(objects["pods"][0]) + for f in findings: + assert isinstance(f, Finding) + assert f.id + assert f.severity in VALID_SEVERITIES + assert f.category + + +@given( + svc_type=st.sampled_from(["ClusterIP", "NodePort", "LoadBalancer", "ExternalName"]), + name=NAME, + has_source_ranges=st.booleans(), + has_external_ips=st.booleans(), +) +@settings(max_examples=30) +def test_service_scanner_never_crashes(tmp_path_factory, svc_type, name, has_source_ranges, has_external_ips): + tmp_path = tmp_path_factory.mktemp("svc") + spec = {"type": svc_type, "ports": [{"port": 80}]} + if has_source_ranges: + spec["loadBalancerSourceRanges"] = ["10.0.0.0/8"] + if has_external_ips: + spec["externalIPs"] = ["1.2.3.4"] + doc = { + "apiVersion": "v1", + "kind": "Service", + "metadata": {"name": name, "namespace": "default"}, + "spec": spec, + } + (tmp_path / "s.yaml").write_text(yaml.safe_dump(doc), encoding="utf-8") + objects = load_manifests(str(tmp_path)) + findings = scan_services(objects["services"]) + for f in findings: + assert f.severity in VALID_SEVERITIES + + +@given( + name=NAME, + has_tls=st.booleans(), + host=st.one_of(NAME, st.just("*"), st.just("*.example.com")), +) +@settings(max_examples=30) +def test_ingress_scanner_never_crashes(tmp_path_factory, name, has_tls, host): + tmp_path = tmp_path_factory.mktemp("ing") + spec = {"rules": [{"host": host, "http": {"paths": [{"path": "/", "pathType": "Prefix"}]}}]} + if has_tls: + spec["tls"] = [{"hosts": [host], "secretName": "x"}] + doc = { + "apiVersion": "networking.k8s.io/v1", + "kind": "Ingress", + "metadata": {"name": name, "namespace": "default"}, + "spec": spec, + } + (tmp_path / "i.yaml").write_text(yaml.safe_dump(doc), encoding="utf-8") + objects = load_manifests(str(tmp_path)) + findings = scan_ingresses(objects["ingresses"]) + for f in findings: + assert f.severity in VALID_SEVERITIES + + +@given( + verbs=st.lists( + st.sampled_from(["get", "list", "watch", "create", "update", "patch", "delete", "*", "escalate", "bind"]), + min_size=1, + max_size=5, + unique=True, + ), + resources=st.lists( + st.sampled_from(["pods", "secrets", "services", "configmaps", "*", "clusterroles", "rolebindings"]), + min_size=1, + max_size=5, + unique=True, + ), + name=NAME, +) +@settings(max_examples=30) +def test_rbac_scanner_never_crashes_on_random_rules(tmp_path_factory, verbs, resources, name): + tmp_path = tmp_path_factory.mktemp("rbac") + doc = { + "apiVersion": "rbac.authorization.k8s.io/v1", + "kind": "ClusterRole", + "metadata": {"name": name}, + "rules": [{"apiGroups": [""], "resources": resources, "verbs": verbs}], + } + (tmp_path / "r.yaml").write_text(yaml.safe_dump(doc), encoding="utf-8") + objects = load_manifests(str(tmp_path)) + findings = scan_rbac([], objects["cluster_roles"], [], []) + for f in findings: + assert f.severity in VALID_SEVERITIES + assert f.category == "RBAC" + + +def test_parser_handles_garbage_yaml(tmp_path: Path): + """Truly malformed YAML should warn-and-skip, not crash.""" + (tmp_path / "broken.yaml").write_text(":\n - [\nthis is not yaml]: : :", encoding="utf-8") + objects = load_manifests(str(tmp_path)) + assert objects["pods"] == [] + + +def test_parser_skips_empty_files(tmp_path: Path): + (tmp_path / "empty.yaml").write_text("", encoding="utf-8") + (tmp_path / "comment.yaml").write_text("# just a comment\n", encoding="utf-8") + objects = load_manifests(str(tmp_path)) + assert all(v == [] for v in objects.values()) + + +def test_parser_handles_deeply_nested_pod(tmp_path: Path): + """An ephemeralContainers + initContainers + containers manifest must work.""" + doc = { + "apiVersion": "v1", + "kind": "Pod", + "metadata": {"name": "complex"}, + "spec": { + "initContainers": [{"name": "init", "image": "x"}], + "containers": [{"name": "main", "image": "x", "securityContext": {"privileged": True}}], + "ephemeralContainers": [{"name": "debug", "image": "x"}], + }, + } + (tmp_path / "p.yaml").write_text(yaml.safe_dump(doc), encoding="utf-8") + objects = load_manifests(str(tmp_path)) + findings = scan_pod_security(objects["pods"][0]) + assert any(f.id == "POD-PRIV" for f in findings) + + +@given(st.lists(_pod_strategy(), min_size=0, max_size=10)) +@settings(max_examples=20, suppress_health_check=[HealthCheck.too_slow]) +def test_secrets_scanner_handles_arbitrary_pod_count(tmp_path_factory, pods): + """Even with arbitrary numbers of unrelated pods, secret scanner returns [].""" + tmp_path = tmp_path_factory.mktemp("secrets") + if pods: + (tmp_path / "pods.yaml").write_text( + "\n---\n".join(yaml.safe_dump(p) for p in pods), encoding="utf-8" + ) + objects = load_manifests(str(tmp_path)) + findings = scan_secrets(objects["secrets"]) + assert findings == [] diff --git a/tests/test_rbac.py b/tests/test_rbac.py index 43cb7e8..12f013f 100644 --- a/tests/test_rbac.py +++ b/tests/test_rbac.py @@ -1,5 +1,5 @@ from kuberoast.scanners.rbac import scan_rbac -from tests.conftest import make_role, make_rule, make_binding, make_subject +from tests.conftest import make_binding, make_role, make_rule, make_subject def test_wildcard_verbs_flagged(): diff --git a/tests/test_reporting.py b/tests/test_reporting.py index 52c23ad..371c637 100644 --- a/tests/test_reporting.py +++ b/tests/test_reporting.py @@ -1,5 +1,7 @@ +from kuberoast.reporting import html as html_report +from kuberoast.reporting import json as json_report +from kuberoast.reporting import text as text_report from kuberoast.utils.findings import Finding -from kuberoast.reporting import json as json_report, text as text_report, html as html_report def _sample_findings(): @@ -29,7 +31,7 @@ def test_text_output_grouped_by_severity(): def test_text_output_summary(): output = text_report.emit(_sample_findings()) - assert "3 findings" in output + assert "3 issues" in output assert "1 critical" in output diff --git a/tests/test_sarif.py b/tests/test_sarif.py new file mode 100644 index 0000000..b20ee4e --- /dev/null +++ b/tests/test_sarif.py @@ -0,0 +1,99 @@ +import json + +from kuberoast.reporting import sarif +from kuberoast.utils.findings import Finding + + +def _sample(): + return [ + Finding( + id="POD-PRIV", + title="Privileged container", + description="Runs privileged.", + severity="critical", + category="Pod Security", + namespace="default", + resource="pod/web::nginx", + remediation="Set privileged=false.", + cis_controls=["CIS-K8s-5.2.1"], + mitre_attack=["T1611"], + cwe=["CWE-250"], + references=["https://kubernetes.io/docs/concepts/security/pod-security-standards/"], + ), + Finding( + id="RBAC-CLUSTER-ADMIN", + title="cluster-admin granted", + description="Bad.", + severity="critical", + category="RBAC", + cis_controls=["CIS-K8s-5.1.1"], + mitre_attack=["T1078.004"], + ), + ] + + +def test_sarif_valid_json_and_schema(): + out = sarif.emit(_sample()) + data = json.loads(out) + assert data["version"] == "2.1.0" + assert "$schema" in data + assert len(data["runs"]) == 1 + run = data["runs"][0] + assert run["tool"]["driver"]["name"] == "KubeRoast" + + +def test_sarif_includes_unique_rules(): + findings = _sample() + _sample() + out = sarif.emit(findings) + data = json.loads(out) + rules = data["runs"][0]["tool"]["driver"]["rules"] + rule_ids = [r["id"] for r in rules] + assert len(rule_ids) == len(set(rule_ids)) + assert "POD-PRIV" in rule_ids + assert "RBAC-CLUSTER-ADMIN" in rule_ids + + +def test_sarif_results_match_findings(): + out = sarif.emit(_sample()) + data = json.loads(out) + results = data["runs"][0]["results"] + assert len(results) == 2 + assert results[0]["ruleId"] == "POD-PRIV" + assert results[0]["level"] == "error" # critical -> error + + +def test_sarif_severity_levels(): + findings = [ + Finding(id="A", title="a", description="d", severity="critical"), + Finding(id="B", title="b", description="d", severity="high"), + Finding(id="C", title="c", description="d", severity="medium"), + Finding(id="D", title="d", description="d", severity="low"), + Finding(id="E", title="e", description="d", severity="info"), + ] + data = json.loads(sarif.emit(findings)) + levels = [r["level"] for r in data["runs"][0]["results"]] + assert levels == ["error", "error", "warning", "note", "note"] + + +def test_sarif_security_severity_score(): + out = sarif.emit(_sample()) + data = json.loads(out) + rule = data["runs"][0]["tool"]["driver"]["rules"][0] + assert rule["properties"]["security-severity"] == "9.5" + + +def test_sarif_tags_include_compliance(): + out = sarif.emit(_sample()) + data = json.loads(out) + rule = next(r for r in data["runs"][0]["tool"]["driver"]["rules"] if r["id"] == "POD-PRIV") + tags = rule["properties"]["tags"] + assert "CIS-K8s-5.2.1" in tags + assert "T1611" in tags + assert "CWE-250" in tags + + +def test_sarif_empty_findings(): + out = sarif.emit([]) + data = json.loads(out) + assert data["runs"][0]["results"] == [] + assert data["runs"][0]["tool"]["driver"]["rules"] == [] diff --git a/tests/test_sarif_schema.py b/tests/test_sarif_schema.py new file mode 100644 index 0000000..0e8c7e6 --- /dev/null +++ b/tests/test_sarif_schema.py @@ -0,0 +1,102 @@ +"""Validate KubeRoast SARIF output against the official OASIS v2.1.0 schema. + +Uses a locally-cached copy of the SARIF JSON schema so tests don't hit the +network. If the schema bundle is missing or jsonschema is unavailable, the +test is skipped — but in the standard dev install (`pip install -e .[dev]`) +we add jsonschema and ship the schema file, so it always runs. +""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from kuberoast.reporting import sarif as sarif_report +from kuberoast.utils.findings import Finding + +jsonschema = pytest.importorskip("jsonschema") + +SARIF_SCHEMA_PATH = Path(__file__).parent / "fixtures" / "sarif-2.1.0-schema.json" + + +@pytest.fixture(scope="module") +def sarif_schema() -> dict: + if not SARIF_SCHEMA_PATH.exists(): + pytest.skip(f"SARIF schema not bundled at {SARIF_SCHEMA_PATH}") + return json.loads(SARIF_SCHEMA_PATH.read_text(encoding="utf-8")) + + +def _findings(): + return [ + Finding( + id="POD-PRIV", + title="Privileged container", + description="Bad", + severity="critical", + category="Pod Security", + namespace="prod", + resource="pod/web-0::nginx", + remediation="Don't be privileged", + cis_controls=["CIS-K8s-5.2.1"], + mitre_attack=["T1611"], + cwe=["CWE-250"], + references=["https://kubernetes.io/docs/concepts/security/pod-security-standards/"], + ), + Finding( + id="RBAC-CLUSTER-ADMIN", + title="cluster-admin granted", + description="Bad", + severity="critical", + category="RBAC", + cis_controls=["CIS-K8s-5.1.1"], + mitre_attack=["T1078.004"], + ), + Finding( + id="NET-LB-OPEN", + title="LB open", + description="Bad", + severity="high", + category="Network", + namespace="prod", + resource="service/web", + ), + ] + + +def test_sarif_validates_against_official_schema(sarif_schema): + out = sarif_report.emit(_findings()) + data = json.loads(out) + jsonschema.validate(instance=data, schema=sarif_schema) + + +def test_empty_sarif_validates(sarif_schema): + out = sarif_report.emit([]) + data = json.loads(out) + jsonschema.validate(instance=data, schema=sarif_schema) + + +def test_single_finding_sarif_validates(sarif_schema): + out = sarif_report.emit([_findings()[0]]) + data = json.loads(out) + jsonschema.validate(instance=data, schema=sarif_schema) + + +def test_sarif_run_has_tool_driver_required_fields(): + """Even without schema validation, sanity-check the SARIF shape.""" + data = json.loads(sarif_report.emit(_findings())) + run = data["runs"][0] + driver = run["tool"]["driver"] + assert "name" in driver + assert "version" in driver + assert "rules" in driver + + +def test_sarif_results_have_required_fields(): + data = json.loads(sarif_report.emit(_findings())) + for result in data["runs"][0]["results"]: + assert "ruleId" in result + assert "level" in result + assert "message" in result + assert "text" in result["message"] + assert "locations" in result and len(result["locations"]) >= 1 diff --git a/tests/test_scanner_contracts.py b/tests/test_scanner_contracts.py new file mode 100644 index 0000000..12f560c --- /dev/null +++ b/tests/test_scanner_contracts.py @@ -0,0 +1,222 @@ +"""Contract tests: every scanner produces well-formed Findings. + +These tests guard against drift in the Finding schema and ensure that all +scanner outputs are consistent: stable IDs, valid severities, non-empty +descriptions, and (for known IDs) matching compliance metadata. +""" +from __future__ import annotations + +import re +from collections.abc import Iterable + +import pytest + +from kuberoast.attackpaths.rbac_escalation import analyze_attack_paths +from kuberoast.scanners.network import scan_ingresses, scan_services +from kuberoast.scanners.pods import scan_pod_security +from kuberoast.scanners.policy import scan_policy_engines +from kuberoast.scanners.pss import scan_namespace_pss +from kuberoast.scanners.rbac import scan_rbac +from kuberoast.scanners.secrets import scan_secrets +from kuberoast.utils.compliance import COMPLIANCE_MAP, enrich_findings +from kuberoast.utils.findings import SEVERITY_TO_CVSS, Finding +from tests.conftest import ( + make_binding, + make_container, + make_ingress, + make_namespace, + make_pod, + make_role, + make_rule, + make_secret, + make_service, + make_subject, +) + +VALID_SEVERITIES = {"info", "low", "medium", "high", "critical"} +VALID_CATEGORIES = {"Pod Security", "RBAC", "AttackPath", "Network", "Node", "Secrets", "Policy", "general"} + +# T#### or T####.### — MITRE technique format +MITRE_RE = re.compile(r"^T\d{4}(\.\d{3})?$") +# CIS-K8s-X.Y or CIS-K8s-X.Y.Z +CIS_RE = re.compile(r"^CIS-K8s-\d+(\.\d+)+$") +# CWE-### or CWE-#### +CWE_RE = re.compile(r"^CWE-\d+$") + + +def _comprehensive_findings() -> list: + """Run every scanner against deliberately-bad fixtures to collect all IDs.""" + findings: list = [] + + # Pods + findings.extend(scan_pod_security( + make_pod( + host_network=True, host_pid=True, host_ipc=True, + containers=[ + make_container( + privileged=True, run_as_user=0, + allow_privilege_escalation=True, + read_only_root_filesystem=False, + caps_add=["SYS_ADMIN", "NET_ADMIN"], + ), + ], + ) + )) + + # Namespaces + findings.extend(scan_namespace_pss([ + make_namespace(name="prod"), + make_namespace(name="kube-system"), + ])) + + # RBAC + role = make_role(rules=[make_rule(verbs=["*", "escalate", "bind"], resources=["*", "secrets"])]) + crb = make_binding( + role_kind="ClusterRole", role_name="cluster-admin", + subjects=[make_subject(kind="User", name="system:anonymous")], + ) + crb_group = make_binding( + role_kind="ClusterRole", role_name="view", + subjects=[make_subject(kind="Group", name="system:unauthenticated")], + ) + findings.extend(scan_rbac([role], [], [], [crb, crb_group])) + + # Attack paths — give a principal a juicy permission set + findings.extend(analyze_attack_paths([role], [], [], [crb], [])) + + # Network + findings.extend(scan_services([ + make_service(svc_type="LoadBalancer"), + make_service(svc_type="NodePort"), + make_service(svc_type="ClusterIP", external_ips=["1.2.3.4"]), + ])) + from types import SimpleNamespace + findings.extend(scan_ingresses([ + make_ingress(), # no TLS + make_ingress(rules=[SimpleNamespace(host="*.example.com", http=None)]), + ])) + + # Secrets + import base64 + val = base64.b64encode(b"verysecretpassword").decode() + findings.extend(scan_secrets([ + make_secret(data={"password": val}), + make_secret(secret_type="kubernetes.io/tls"), + ])) + + # Policy — empty CRD list to flag POLICY-NONE + findings.extend(scan_policy_engines([])) + + return findings + + +@pytest.fixture(scope="module") +def all_findings(): + findings = _comprehensive_findings() + enrich_findings(findings) + assert findings, "test fixture should produce findings" + return findings + + +def test_every_finding_is_a_finding_instance(all_findings): + assert all(isinstance(f, Finding) for f in all_findings) + + +def test_every_finding_has_required_fields(all_findings): + for f in all_findings: + assert f.id and isinstance(f.id, str) + assert f.title and len(f.title) > 3 + assert f.description and len(f.description) > 10 + assert f.severity in VALID_SEVERITIES + + +def test_every_finding_has_valid_category(all_findings): + for f in all_findings: + assert f.category in VALID_CATEGORIES, f"unknown category: {f.category!r} on {f.id}" + + +def test_every_finding_id_uses_namespaced_format(all_findings): + """IDs should look like CATEGORY-SUBJECT (e.g. POD-PRIV, RBAC-ANON).""" + for f in all_findings: + assert "-" in f.id, f"finding id {f.id!r} is not namespaced" + assert f.id == f.id.upper().replace("_", "-"), f"id {f.id!r} is not uppercase-dash" + + +def test_every_finding_has_remediation(all_findings): + """Remediation is the whole point — every finding should have one.""" + missing = [f.id for f in all_findings if not f.remediation] + assert not missing, f"findings without remediation: {missing}" + + +def test_known_ids_are_enriched(all_findings): + enriched = [f for f in all_findings if f.id in COMPLIANCE_MAP] + assert enriched, "expected at least one known-mapped finding" + for f in enriched: + assert f.cis_controls or f.mitre_attack or f.cwe, ( + f"{f.id} maps in COMPLIANCE_MAP but was not enriched" + ) + + +def test_mitre_technique_format(all_findings): + for f in all_findings: + for technique in f.mitre_attack: + assert MITRE_RE.match(technique), f"bad MITRE id: {technique!r} on {f.id}" + + +def test_cis_control_format(all_findings): + for f in all_findings: + for control in f.cis_controls: + assert CIS_RE.match(control), f"bad CIS id: {control!r} on {f.id}" + + +def test_cwe_format(all_findings): + for f in all_findings: + for cwe in f.cwe: + assert CWE_RE.match(cwe), f"bad CWE id: {cwe!r} on {f.id}" + + +def test_compliance_map_internal_consistency(): + """Every entry in COMPLIANCE_MAP uses the documented format.""" + for finding_id, mapping in COMPLIANCE_MAP.items(): + for cis in mapping.get("cis", []): + # Stored without the 'CIS-K8s-' prefix in the map + assert re.match(r"^\d+(\.\d+)+$", cis), f"bad CIS in map for {finding_id}: {cis!r}" + for technique in mapping.get("mitre", []): + assert MITRE_RE.match(technique), f"bad MITRE in map for {finding_id}: {technique!r}" + for cwe in mapping.get("cwe", []): + assert CWE_RE.match(cwe), f"bad CWE in map for {finding_id}: {cwe!r}" + + +def test_severity_cvss_mapping(): + """SEVERITY_TO_CVSS exists and orders correctly.""" + assert SEVERITY_TO_CVSS["critical"] > SEVERITY_TO_CVSS["high"] + assert SEVERITY_TO_CVSS["high"] > SEVERITY_TO_CVSS["medium"] + assert SEVERITY_TO_CVSS["medium"] > SEVERITY_TO_CVSS["low"] + assert SEVERITY_TO_CVSS["low"] >= SEVERITY_TO_CVSS["info"] + + +def test_scanner_signatures_return_lists(): + """All scanners return Iterable[Finding].""" + callables = [ + (scan_pod_security, [make_pod()]), + (scan_namespace_pss, [[make_namespace(name="x")]]), + (scan_rbac, [[], [], [], []]), + (scan_services, [[]]), + (scan_ingresses, [[]]), + (scan_secrets, [[]]), + (scan_policy_engines, [[]]), + (analyze_attack_paths, [[], [], [], [], []]), + ] + for fn, args in callables: + out = fn(*args) + assert isinstance(out, Iterable), f"{fn.__name__} returned non-iterable" + for f in out: + assert isinstance(f, Finding) + + +def test_finding_is_json_serializable(all_findings): + """Pydantic dump must always succeed and be json-serializable.""" + import json + for f in all_findings: + d = f.model_dump() + json.dumps(d) # must not raise diff --git a/tests/test_secrets.py b/tests/test_secrets.py index a4bb525..0ffca80 100644 --- a/tests/test_secrets.py +++ b/tests/test_secrets.py @@ -1,4 +1,5 @@ import base64 + from kuberoast.scanners.secrets import scan_secrets from tests.conftest import make_secret diff --git a/tests/test_severity_matrix.py b/tests/test_severity_matrix.py new file mode 100644 index 0000000..09b0a65 --- /dev/null +++ b/tests/test_severity_matrix.py @@ -0,0 +1,187 @@ +"""Comprehensive matrix tests for --fail-on and --min-severity logic.""" +from __future__ import annotations + +import json +import textwrap +from pathlib import Path + +import pytest + +from kuberoast.cli import main + +SEVERITIES = ["info", "low", "medium", "high", "critical"] +SEVERITY_INDEX = {s: i for i, s in enumerate(SEVERITIES)} + + +@pytest.fixture +def critical_pod_dir(tmp_path: Path) -> Path: + (tmp_path / "pod.yaml").write_text( + textwrap.dedent( + """ + apiVersion: v1 + kind: Pod + metadata: + name: bad + namespace: default + spec: + containers: + - name: c + image: nginx + securityContext: + privileged: true + """ + ), + encoding="utf-8", + ) + return tmp_path + + +@pytest.mark.parametrize("threshold", SEVERITIES) +def test_fail_on_at_or_below_critical_returns_1(critical_pod_dir: Path, threshold: str, tmp_path_factory): + """A privileged-container scan produces critical findings — every fail-on + threshold from info up through critical should return rc=1.""" + out = tmp_path_factory.mktemp("o") / "o.json" + rc = main([ + "--manifests", str(critical_pod_dir), + "--report", "json", + "--out", str(out), + "--fail-on", threshold, + ]) + assert rc == 1 + + +def test_no_fail_on_returns_0_with_findings(critical_pod_dir: Path, tmp_path_factory): + out = tmp_path_factory.mktemp("o") / "o.json" + rc = main([ + "--manifests", str(critical_pod_dir), + "--report", "json", + "--out", str(out), + ]) + assert rc == 0 + + +@pytest.mark.parametrize("min_sev", SEVERITIES) +def test_min_severity_filter_consistency(critical_pod_dir: Path, min_sev: str, tmp_path_factory, capsys): + """Filter must drop any finding below threshold.""" + rc = main([ + "--manifests", str(critical_pod_dir), + "--report", "json", + "--min-severity", min_sev, + ]) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + threshold_idx = SEVERITY_INDEX[min_sev] + for f in data: + assert SEVERITY_INDEX[f["severity"]] >= threshold_idx, ( + f"finding {f['id']} severity={f['severity']} below min-severity={min_sev}" + ) + + +def test_min_severity_critical_only_keeps_criticals(critical_pod_dir: Path, capsys): + rc = main([ + "--manifests", str(critical_pod_dir), + "--report", "json", + "--min-severity", "critical", + ]) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + assert data + assert all(f["severity"] == "critical" for f in data) + + +def test_clean_manifest_returns_0_no_findings(tmp_path: Path, capsys): + """A bare-minimum benign Pod should produce only Pod-hardening findings, + none of which are critical, so fail-on=critical returns 0.""" + (tmp_path / "ok.yaml").write_text( + textwrap.dedent( + """ + apiVersion: v1 + kind: Pod + metadata: {name: ok, namespace: default} + spec: + automountServiceAccountToken: false + containers: + - name: c + image: nginx + securityContext: + privileged: false + runAsUser: 1000 + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + seccompProfile: {type: RuntimeDefault} + capabilities: {drop: [ALL]} + resources: + limits: {cpu: 100m, memory: 128Mi} + """ + ), + encoding="utf-8", + ) + rc = main([ + "--manifests", str(tmp_path), + "--report", "json", + "--fail-on", "critical", + ]) + assert rc == 0 + + +def test_min_severity_higher_than_any_finding_returns_empty(tmp_path: Path, capsys): + """If no findings clear --min-severity, output is an empty list.""" + (tmp_path / "ok.yaml").write_text( + textwrap.dedent( + """ + apiVersion: v1 + kind: Pod + metadata: {name: ok, namespace: default} + spec: + containers: + - name: c + image: nginx + """ + ), + encoding="utf-8", + ) + rc = main([ + "--manifests", str(tmp_path), + "--report", "json", + "--min-severity", "critical", + ]) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + assert data == [] + + +@pytest.mark.parametrize("fmt", ["html", "sarif", "junit", "csv"]) +def test_file_output_formats_require_out(fmt: str): + rc = main(["--report", fmt]) + assert rc == 2 # usage error + + +def test_combined_min_severity_and_fail_on(critical_pod_dir: Path, tmp_path_factory): + """When --min-severity drops all findings below --fail-on, exit code is 0.""" + out = tmp_path_factory.mktemp("o") / "o.json" + # Filter out criticals; remaining findings are < critical → fail-on=critical doesn't trip + rc = main([ + "--manifests", str(critical_pod_dir), + "--report", "json", + "--out", str(out), + "--min-severity", "high", + "--fail-on", "critical", + ]) + # The privileged-container finding is critical so it survives min-severity=high, + # and trips fail-on=critical + assert rc == 1 + + +def test_no_compliance_strips_enrichment(critical_pod_dir: Path, capsys): + rc = main([ + "--manifests", str(critical_pod_dir), + "--report", "json", + "--no-compliance", + ]) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + assert data + for f in data: + assert f["cis_controls"] == [] + assert f["mitre_attack"] == [] + assert f["cwe"] == [] diff --git a/tests/test_style.py b/tests/test_style.py new file mode 100644 index 0000000..1e932ad --- /dev/null +++ b/tests/test_style.py @@ -0,0 +1,74 @@ +"""Tests for the styling / banner / color module.""" +from __future__ import annotations + +import io + +import pytest + +from kuberoast import __version__ +from kuberoast.utils import style + + +def test_banner_contains_version_and_tagline(): + out = style.banner() + assert __version__ in out + assert "Offensive Kubernetes" in out + assert "kuberoast".upper() in out.upper() or "KUBEROAST" in out.upper() or "K" in out # ASCII art + + +def test_banner_no_color_strips_ansi(monkeypatch): + monkeypatch.setenv("NO_COLOR", "1") + out = style.banner() + assert "\033[" not in out + + +def test_banner_force_color_emits_ansi(monkeypatch): + monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.delenv("KUBEROAST_NO_COLOR", raising=False) + monkeypatch.setenv("FORCE_COLOR", "1") + out = style.banner() + assert "\033[" in out + + +def test_color_returns_plain_when_no_tty(): + """A non-TTY stream should not get color codes.""" + buf = io.StringIO() + out = style.color("hello", "red", stream=buf) + assert out == "hello" + + +def test_color_with_force_color_emits(monkeypatch): + monkeypatch.setenv("FORCE_COLOR", "1") + out = style.color("hello", "red") + assert "\033[" in out + assert "hello" in out + + +def test_severity_badge_format(monkeypatch): + """Without colors, severity_badge is just `[CRITICAL]`.""" + monkeypatch.setenv("NO_COLOR", "1") + assert style.severity_badge("critical") == "[CRITICAL]" + assert style.severity_badge("info") == "[INFO]" + + +@pytest.mark.parametrize("severity", ["critical", "high", "medium", "low", "info"]) +def test_severity_color_map_complete(severity): + assert severity in style.SEVERITY_COLOR + assert style.SEVERITY_COLOR[severity] in style.FG + + +def test_print_banner_writes_to_stream(monkeypatch): + monkeypatch.setenv("NO_COLOR", "1") + buf = io.StringIO() + style.print_banner(stream=buf) + written = buf.getvalue() + assert __version__ in written + assert "Offensive Kubernetes" in written + + +def test_kuberoast_no_color_disables_colors(monkeypatch): + monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.delenv("FORCE_COLOR", raising=False) + monkeypatch.setenv("KUBEROAST_NO_COLOR", "1") + out = style.color("hi", "red") + assert "\033[" not in out