diff --git a/.github/ISSUE_TEMPLATE/new_rule.md b/.github/ISSUE_TEMPLATE/new_rule.md index c08f63b..3606574 100644 --- a/.github/ISSUE_TEMPLATE/new_rule.md +++ b/.github/ISSUE_TEMPLATE/new_rule.md @@ -9,7 +9,7 @@ assignees: '' ## Rule Details - Rule ID: AZ-XXX-000 - Severity: HIGH / MEDIUM / LOW -- Category: Storage / Network / Identity / Database / Compute / Key Vault / PostQuantum +- Category: Storage / Network / Identity / Database / Compute / Key Vault / Kubernetes / PostQuantum - Frameworks: CIS / NIST / ISO 27001 / SOC 2 ## What does it detect? diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 66635b8..f5b031c 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -13,7 +13,7 @@ ## Rule details (if applicable) - Rule ID: AZ-XXX-000 - Severity: HIGH / MEDIUM / LOW -- Category: Storage / Network / Identity / Database / Compute / Key Vault +- Category: Storage / Network / Identity / Database / Compute / Key Vault / Kubernetes - Frameworks mapped: CIS / NIST / ISO 27001 / SOC 2 ## Testing diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 3b3b208..39d0911 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,16 +5,22 @@ updates: directory: "/" schedule: interval: "weekly" + cooldown: + default-days: 7 open-pull-requests-limit: 0 - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" + cooldown: + default-days: 7 open-pull-requests-limit: 0 - package-ecosystem: "npm" directory: "/frontend" schedule: interval: "weekly" + cooldown: + default-days: 7 open-pull-requests-limit: 0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b4a0b3..3ddbb30 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -431,7 +431,16 @@ jobs: uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Install syft - run: curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin v1.46.0 + env: + SYFT_VERSION: "1.46.0" + SYFT_SHA256: d654f678b709eb53c393d38519d5ed7d2e57205529404018614cfefa0fb2b5ca + run: | + SYFT_ARCHIVE="syft_${SYFT_VERSION}_linux_amd64.tar.gz" + curl --fail --silent --show-error --location \ + --output "$SYFT_ARCHIVE" \ + "https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/${SYFT_ARCHIVE}" + echo "${SYFT_SHA256} ${SYFT_ARCHIVE}" | sha256sum --check --strict + sudo tar --extract --gzip --file "$SYFT_ARCHIVE" --directory /usr/local/bin syft - name: Generate SBOM run: syft dir:. --source-name openshield --source-version "${{ github.sha }}" -o cyclonedx-json=sbom.cyclonedx.json @@ -548,9 +557,27 @@ jobs: - name: Lint run: npm run lint + - name: Run aiSettings tests + run: node src/utils/aiApi.test.mjs + - name: Build run: npm run build + website: + name: Website (script tests) + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "22" + + - name: Run website script tests + run: node website/test_toEmbedUrl.mjs + # ── Enforce branch flow: main may only receive PRs from dev ─────────────── # No-op on dev PRs (step skipped -> job succeeds). To allow hotfixes straight # to main, widen the condition below to also accept a 'hotfix/*' head branch. @@ -575,7 +602,7 @@ jobs: name: CI Summary runs-on: ubuntu-latest needs: - [lint, rule-validation, secret-scan, sast-bandit, sca-pip-audit, sbom, container-scan, backend-tests, frontend, enforce-source-branch] + [lint, rule-validation, secret-scan, sast-bandit, sca-pip-audit, sbom, container-scan, backend-tests, frontend, website, enforce-source-branch] if: always() steps: - name: Build summary @@ -589,6 +616,7 @@ jobs: CONTAINER_SCAN: ${{ needs.container-scan.result }} BACKEND_TESTS: ${{ needs.backend-tests.result }} FRONTEND: ${{ needs.frontend.result }} + WEBSITE: ${{ needs.website.result }} ENFORCE_SOURCE: ${{ needs.enforce-source-branch.result }} run: | python3 - <<'PYEOF' @@ -604,6 +632,7 @@ jobs: ("Container Scan (Trivy, INFRA 1 pending)", os.environ["CONTAINER_SCAN"]), ("Backend Tests (pytest + coverage)", os.environ["BACKEND_TESTS"]), ("Frontend (lint + build)", os.environ["FRONTEND"]), + ("Website (script tests)", os.environ["WEBSITE"]), ("Enforce dev to main source", os.environ["ENFORCE_SOURCE"]), ] @@ -654,5 +683,6 @@ jobs: needs.sbom.result != 'success' || needs.backend-tests.result != 'success' || needs.frontend.result != 'success' || + needs.website.result != 'success' || needs.enforce-source-branch.result != 'success' run: exit 1 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 95dc39a..b67e860 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -12,8 +12,12 @@ jobs: dependency-review: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/dependency-review-action@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4 with: fail-on-severity: high + # The wheel omits license metadata, but Microsoft's upstream Azure + # SDK repository licenses this package under MIT: + # https://github.com/Azure/azure-sdk-for-python/blob/main/LICENSE + allow-dependencies-licenses: pkg:pypi/azure-mgmt-containerservice@41.3.0 comment-summary-in-pr: always diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 61af1ce..702a02a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -20,9 +20,6 @@ concurrency: group: deploy-${{ inputs.environment }} cancel-in-progress: false -permissions: - contents: read - permissions: id-token: write contents: read @@ -35,15 +32,15 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Set up Python 3.11 - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.11" - name: Cache pip dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }} @@ -135,6 +132,8 @@ jobs: echo "API deploy $API_DEPLOY_ID and worker deploy $WORKER_DEPLOY_ID are live at SHA $GITHUB_SHA." - name: Health gate check + env: + DEPLOY_ENVIRONMENT: ${{ inputs.environment }} run: | MAX_RETRIES=5 RETRY_DELAY=15 @@ -151,7 +150,7 @@ jobs: echo "Got HTTP $HTTP_STATUS; retrying in ${RETRY_DELAY}s..." sleep $RETRY_DELAY done - echo "ERROR: Health gate failed after $MAX_RETRIES attempts on ${{ inputs.environment }}." + echo "ERROR: Health gate failed after $MAX_RETRIES attempts on $DEPLOY_ENVIRONMENT." exit 1 - name: Run smoke tests against live deployment diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 1426245..e28dc00 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -14,10 +14,10 @@ jobs: docker: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: registry: ghcr.io username: ${{ github.actor }} @@ -25,7 +25,7 @@ jobs: - name: Extract metadata id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 with: images: ghcr.io/openshield-org/openshield tags: | @@ -34,7 +34,7 @@ jobs: type=raw,value=latest,enable={{is_default_branch}} - name: Build and push - uses: docker/build-push-action@v5 + uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 with: context: . push: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 22d5ee9..6db3909 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,10 +12,10 @@ jobs: release: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 - - uses: softprops/action-gh-release@v2 + - uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 with: generate_release_notes: true make_latest: true diff --git a/.github/workflows/sbom-release.yml b/.github/workflows/sbom-release.yml index 9a94e53..3748dba 100644 --- a/.github/workflows/sbom-release.yml +++ b/.github/workflows/sbom-release.yml @@ -17,7 +17,16 @@ jobs: uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Install syft - run: curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin v1.46.0 + env: + SYFT_VERSION: "1.46.0" + SYFT_SHA256: d654f678b709eb53c393d38519d5ed7d2e57205529404018614cfefa0fb2b5ca + run: | + SYFT_ARCHIVE="syft_${SYFT_VERSION}_linux_amd64.tar.gz" + curl --fail --silent --show-error --location \ + --output "$SYFT_ARCHIVE" \ + "https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/${SYFT_ARCHIVE}" + echo "${SYFT_SHA256} ${SYFT_ARCHIVE}" | sha256sum --check --strict + sudo tar --extract --gzip --file "$SYFT_ARCHIVE" --directory /usr/local/bin syft - name: Generate SBOM env: diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 88fd8c5..b329c1a 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -9,7 +9,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@v9 + - uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0 with: stale-issue-message: 'This issue has been inactive for 30 days and will be closed in 7 days unless there is activity.' stale-pr-message: 'This PR has been inactive for 14 days. Please update or it will be closed in 7 days.' diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9bcd884 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,116 @@ +# Changelog + +All notable changes to OpenShield are documented in this file. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +OpenShield uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- CBOM endpoints with per-asset quantum risk scoring and migration guidance +- NCSC UK and ENISA post-quantum compliance framework mappings +- Harvest Now Decrypt Later exposure window calculation per cryptographic asset +- Deterministic Render deployment workflow for separate API and worker services +- Terraform configuration for Render, Vercel, and GitHub OIDC + +### Fixed + +- High-severity CodeQL findings in Python and JavaScript code +- Security findings identified during Semgrep analysis +- Sensitive identity metadata removed from scanner debug logging + +### Security + +- AI provider errors no longer expose upstream response details +- Request body limits, AI rate limiting, and playbook path validation added +- GitHub Actions dependencies pinned to immutable commit SHAs + +## [0.3.0] - 2026-07-08 + +### Added + +- Async scan execution with a background worker and PostgreSQL-backed job queue +- Scan state persistence and Render restart recovery with retry logic +- Structured JSON logging with request correlation via `X-Request-ID` +- Prometheus metrics for HTTP, scan, NVD, and LLM latency +- Separate `/health` liveness and `/ready` readiness probes +- Optional Sentry integration configured through `SENTRY_DSN` +- AZ-IDN-005 to AZ-IDN-009 Entra ID identity scanner rules +- AZ-PQC-001 to AZ-PQC-003 post-quantum cryptography scanner rules +- MockAzureClient offline rule regression test harness +- Azure scanner validation documentation framework +- CODEOWNERS with domain-based reviewer assignment +- Issue templates for bugs, features, and scanner rules +- Docker and Docker Compose support for self-hosted deployment +- Stale issue and pull request management workflow +- Dependency review workflow for high-severity vulnerabilities +- Release workflow triggered by version tags +- Docker image publishing to GHCR on release tags + +### Fixed + +- Duplicate CIS benchmark mappings across rules that shared control IDs +- TLS version comparison changed from lexicographic to numeric comparison +- Microsoft Graph pagination now follows `nextLink` +- Render automatic deployment replaced with a manual workflow dispatch + +### Security + +- Unsafe HTML rendering removed from website content updates +- Clear-text logging removed from identity scanner rules +- Vulnerable dependencies upgraded, including cryptography and wheel + +## [0.2.3] - 2026-06-28 + +### Added + +- CVE enrichment decoupled from the scan lifecycle into an on-demand endpoint +- OpenShield Learn portal updated with current architecture and rule coverage +- React Router dependency updated to 7.18.0 + +## [0.2.2] - 2026-06-15 + +### Added + +- Live data wiring between the frontend and backend API +- Seven-page React dashboard for monitoring, discovery, prioritization, scanning, compliance, drift, and AI +- Project website at `openshield-website.vercel.app` +- JWT authentication production hardening + +### Fixed + +- Mock and demonstration data removed from the frontend +- Dashboard updated to serve persisted scan data end to end + +## [0.2.0] - 2026-05-20 + +### Added + +- RAG pipeline with ChromaDB and Azure security knowledge skills +- AI executive summary, prioritization, question answering, and remediation endpoints +- CVE correlation through NVD with CVSS scoring and CISA KEV exploit signals +- AZ-NET-012 to AZ-NET-016 network scanner rules +- AZ-IDN-004 privileged identity management scanner rule +- PostgreSQL-backed asynchronous scan persistence + +## [0.1.0] - 2026-05-09 + +### Added + +- Initial OpenShield release +- 20 Azure scanner rules across Storage, Network, Identity, Database, Compute, and Key Vault +- CIS Azure Benchmark, NIST CSF, ISO 27001, and SOC 2 mappings +- Flask REST API with JWT authentication +- CLI remediation playbooks for scanner rules +- Microsoft Sentinel integration with Log Analytics ingestion and KQL rules +- GitHub Actions continuous integration pipeline +- SBOM generation with Syft + +[Unreleased]: https://github.com/openshield-org/openshield/compare/v0.3.0...HEAD +[0.3.0]: https://github.com/openshield-org/openshield/releases/tag/v0.3.0 +[0.2.3]: https://github.com/openshield-org/openshield/commit/3d6d7cc +[0.2.2]: https://github.com/openshield-org/openshield/commit/9575a33 +[0.2.0]: https://github.com/openshield-org/openshield/commit/484eb9b +[0.1.0]: https://github.com/openshield-org/openshield/releases/tag/v0.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index aad7d71..c26b8e8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing to OpenShield -Welcome! OpenShield is built by the community — students, developers, and security engineers at every level. This guide will get you contributing in under 30 minutes. +Welcome! OpenShield is built by the community - students, developers, and security engineers at every level. This guide will get you contributing in under 30 minutes. --- @@ -16,7 +16,7 @@ Welcome! OpenShield is built by the community — students, developers, and secu | KQL detection rule (Sentinel) | Advanced | 3–5 hrs | | Scanner engine feature | Advanced | 4–8 hrs | -**Start with a scan rule — it's the most impactful and beginner-friendly contribution.** +**Start with a scan rule - it is the most impactful and beginner-friendly contribution.** --- @@ -24,13 +24,13 @@ Welcome! OpenShield is built by the community — students, developers, and secu Every misconfiguration rule is a self-contained Python file in `scanner/rules/`. -### Step 1 — Pick an Issue +### Step 1 - Pick an Issue Browse issues labelled [`good-first-issue`](https://github.com/openshield-org/openshield/issues?q=label%3Agood-first-issue) or [`help-wanted`](https://github.com/openshield-org/openshield/issues?q=label%3Ahelp-wanted). -Comment on the issue: **"I'd like to work on this"** — we'll assign it to you. +Comment on the issue: **"I'd like to work on this"** - we will assign it to you. -### Step 2 — Fork & Clone +### Step 2 - Fork & Clone ```bash # Fork the repo on GitHub, then: @@ -39,7 +39,7 @@ cd openshield git checkout -b rule/your-rule-name ``` -### Step 3 — Write Your Rule +### Step 3 - Write Your Rule Create a new file in `scanner/rules/`. Every rule follows this exact template: @@ -51,7 +51,7 @@ from typing import Any, Dict, List RULE_ID = "AZ-STOR-001" RULE_NAME = "Public Blob Access Enabled on Storage Account" SEVERITY = "HIGH" # HIGH / MEDIUM / LOW / INFO -CATEGORY = "Storage" # Storage / Network / Identity / Database / Compute / Key Vault +CATEGORY = "Storage" # Storage / Network / Identity / Database / Compute / Key Vault / Kubernetes FRAMEWORKS = { "CIS": "3.5", "NIST": "PR.AC-3", @@ -92,7 +92,7 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: That's it. One file, one rule. -### Step 4 — Add a Remediation Playbook +### Step 4 - Add a Remediation Playbook Create the matching fix in `playbooks/cli/`: @@ -114,7 +114,7 @@ az storage account update \ echo "Public blob access disabled for $STORAGE_ACCOUNT" ``` -### Step 5 — Test Your Rule +### Step 5 - Test Your Rule ```bash # Set up test credentials (use a free Azure trial account) @@ -135,7 +135,7 @@ print(f'Found {len(findings)} issue(s)') " ``` -### Step 6 — Submit Your PR +### Step 6 - Submit Your PR ```bash git add . @@ -147,7 +147,7 @@ Then open a Pull Request on GitHub. Use this PR template: ``` ## What does this PR do? -Adds scan rule AZ-STOR-001 — detects storage accounts with public blob access enabled. +Adds scan rule AZ-STOR-001 - detects storage accounts with public blob access enabled. ## Rule details - Rule ID: AZ-STOR-001 @@ -178,6 +178,7 @@ Use the format: `AZ-[CATEGORY]-[NUMBER]` | Database | DB | AZ-DB-001 | | Compute | CMP | AZ-CMP-001 | | Key Vault | KV | AZ-KV-001 | +| Kubernetes (AKS) | AKS | AZ-AKS-001 | Check existing rules before picking a number to avoid clashes. @@ -201,6 +202,7 @@ Use the existing wrapper methods in `scanner/azure_client.py` rather than constr | `azure_client.get_sql_servers()` | List of Azure SQL Server objects | | `azure_client.get_sql_server_auditing_policy(resource_group, server_name)` | ServerBlobAuditingPolicy or None | | `azure_client.get_key_vaults()` | List of Key Vault objects | +| `azure_client.get_managed_clusters()` | List of AKS ManagedCluster objects, or `None` on API failure | | `azure_client.get_service_principals()` | List of role assignments for service principals | | `azure_client.get_conditional_access_policies()` | List of Conditional Access policy dicts from Microsoft Graph | @@ -238,13 +240,64 @@ does not use ORM metadata. --- -## Code Standards +## Coding Standards -- Python: follow PEP8, use type hints where possible -- Dashboard work: functional React components only, Tailwind for styling when the dashboard app lands -- Every rule must have a RULE_ID, SEVERITY, FRAMEWORKS mapping, and a remediation playbook -- Every PR must pass the seven GitHub Actions CI checks before merge -- All PRs need at least one reviewer approval before merge +All contributions must meet these standards before a pull request will be reviewed. + +**Python** + +- Follow PEP 8 as enforced by Ruff. +- Run `ruff check .` and `ruff format .` before pushing. +- Use `%s` style logging instead of f-strings in logger calls. +- Add type hints to all new functions. +- Use `getattr(obj, "field", default)` for optional SDK object fields instead of direct attribute access. + +**JavaScript** + +- ESLint must pass with zero errors. +- Run `npm run lint` from `frontend/` before pushing frontend changes. + +**Shell scripts** + +- Scripts must pass `bash -n` syntax validation. +- Scripts must start with `set -euo pipefail`. +- Quote all variable expansions. + +**Commit messages** + +- Follow Conventional Commits using prefixes such as `feat:`, `fix:`, `docs:`, `chore:`, and `test:`. +- Reference the related issue number where applicable. + +**Branch naming** + +- Use `feat/description` for new features. +- Use `fix/description` for bug fixes. +- Use `docs/description` for documentation. +- Use `infra/description` for infrastructure changes. + +**Pull request requirements** + +- All CI checks must pass. +- Do not commit credentials, tokens, or secrets. +- Add automated tests for major new functionality and bug fixes. If tests are not applicable, explain why in the pull request. +- New scanner rules require compliant and non-compliant test cases. +- Update all four compliance framework JSON files for new scanner rules. +- Add a CLI remediation playbook for each new scanner rule. +- Follow `.github/PULL_REQUEST_TEMPLATE.md`. +- Obtain at least one reviewer approval before merge. + +## OpenSSF Best Practices + +OpenShield is working toward the OpenSSF Best Practices Gold badge. The repository currently enforces or provides: + +- Automated testing on pull requests through GitHub Actions. +- Static analysis through CodeQL, Bandit, and Ruff. +- Dependency vulnerability checks through pip-audit, GitHub Dependency Review, Dependabot alerts, and Trivy. +- SBOM generation for releases through Syft. +- Pull request dependency review that rejects newly introduced high-severity vulnerabilities. +- Private vulnerability reporting through the process in `.github/SECURITY.md`. + +Gold status must not be claimed until the project has completed the official OpenSSF assessment and satisfied every mandatory criterion. --- @@ -261,7 +314,7 @@ If you contribute 3+ rules or a major feature, you get: ## Need Help? -- **Discord:** Join `#openshield-dev` — ask anything, no question is too basic +- **Discord:** Join `#openshield-dev` - ask anything, no question is too basic - **GitHub Discussions:** For longer technical questions - **Issues:** Tag `@core-team` if you're stuck on a PR diff --git a/README.md b/README.md index 0999c5b..4e99c1a 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # OpenShield +[![OpenShield CI](https://github.com/openshield-org/openshield/actions/workflows/ci.yml/badge.svg)](https://github.com/openshield-org/openshield/actions/workflows/ci.yml) +[![CodeQL](https://github.com/openshield-org/openshield/actions/workflows/codeql.yml/badge.svg)](https://github.com/openshield-org/openshield/actions/workflows/codeql.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![CHANGELOG](https://img.shields.io/badge/changelog-here-blue)](CHANGELOG.md) + > **Open source Cloud Security Posture Management (CSPM) for Azure - detect misconfigurations, map to CIS/NIST/ISO27001/SOC2, fix them with one command, and identify cryptographic assets requiring quantum-safe migration.** [![GitHub Repo stars](https://img.shields.io/github/stars/openshield-org/openshield?style=flat-square)](https://github.com/openshield-org/openshield/stargazers) @@ -7,9 +12,7 @@ [![GitHub contributors](https://img.shields.io/github/contributors/openshield-org/openshield?style=flat-square)](https://github.com/openshield-org/openshield/graphs/contributors) [![GitHub last commit](https://img.shields.io/github/last-commit/openshield-org/openshield?style=flat-square)](https://github.com/openshield-org/openshield/commits/main) [![GitHub issues](https://img.shields.io/github/issues/openshield-org/openshield?style=flat-square)](https://github.com/openshield-org/openshield/issues) -[![GitHub license](https://img.shields.io/github/license/openshield-org/openshield?style=flat-square)](LICENSE) [![Python 3.11](https://img.shields.io/badge/python-3.11-blue.svg)](https://www.python.org/downloads/release/python-3110/) -[![CI](https://github.com/openshield-org/openshield/actions/workflows/ci.yml/badge.svg?branch=dev)](https://github.com/openshield-org/openshield/actions/workflows/ci.yml) [![Deploy](https://github.com/openshield-org/openshield/actions/workflows/deploy.yml/badge.svg?branch=dev)](https://github.com/openshield-org/openshield/actions/workflows/deploy.yml) [![Security Policy](https://img.shields.io/badge/security-policy-green.svg)](.github/SECURITY.md) [![OWASP](https://img.shields.io/badge/OWASP-listing%20review-orange.svg)](https://owasp.org) @@ -48,8 +51,8 @@ Findings map to NIST FIPS 203 (ML-KEM), FIPS 204 (ML-DSA), and FIPS 205 (SLH-DSA | **Compliance Mapper** | Maps findings to CIS Benchmarks, NIST CSF, ISO 27001, and SOC 2 framework JSON files | | **Scan History API** | Stores scans and findings in PostgreSQL and exposes findings, score, scan history, compliance posture, drift, and resource inventory over REST | | **Remediation Playbooks** | Every rule ships with a matching Azure CLI remediation script (36 playbooks) | -| **Security Dashboard** | Full React dashboard deployed on Vercel — live monitoring, findings, compliance, drift, prioritization, and AI-layer views | -| **Project Website** | Documentation and reference site at [openshield-website.vercel.app](https://openshield-website.vercel.app) — blog, rules gallery, docs, roadmap, releases, and interactive playground | +| **Security Dashboard** | Full React dashboard deployed on Vercel - live monitoring, findings, compliance, drift, prioritization, and AI-layer views | +| **Project Website** | Documentation and reference site at [openshield-website.vercel.app](https://openshield-website.vercel.app) - blog, rules gallery, docs, roadmap, releases, and interactive playground | | **Sentinel Integration** | Normalises findings and pushes them into Microsoft Sentinel via a Log Analytics custom table and KQL analytics rules | --- @@ -133,7 +136,7 @@ openshield/ │ ├── routes/ │ └── models/ ├── frontend/ # React security dashboard (Vercel) -├── website/ # Project website — docs, blog, rules gallery (Vercel) +├── website/ # Project website - docs, blog, rules gallery (Vercel) ├── sentinel/ # Sentinel integration & KQL rules ├── .github/workflows/ # CI checks ├── docs/ # Documentation @@ -188,14 +191,14 @@ one-time onboarding step required for existing production databases. cd frontend npm install -# Local dev — points at http://localhost:5000 by default +# Local dev - points at http://localhost:5000 by default npm run dev # To develop against the live Render backend: VITE_API_URL=https://openshield-api.onrender.com npm run dev ``` -No token required — all read endpoints are public. Only scan trigger and AI endpoints require a JWT (POST only). +No token is required in public demo mode. By default, API endpoints require a JWT, and POST endpoints always require one. --- @@ -210,7 +213,7 @@ We actively welcome contributions from students and developers at all levels. - Fix a bug - Improve documentation -See [CONTRIBUTING.md](CONTRIBUTING.md) for a full guide — including how to add your first rule in under 30 minutes. +See [CONTRIBUTING.md](CONTRIBUTING.md) for a full guide, including how to add your first rule in under 30 minutes. Contributors are credited below. @@ -240,7 +243,7 @@ Contributors are credited below. ## License -MIT — free to use, modify, and distribute. +MIT - free to use, modify, and distribute. --- @@ -263,3 +266,11 @@ Live Learning Portal: https://openshieldlearn.netlify.app/learn/ Full documentation, the security rules gallery, blog, and interactive playground are available at the project website: **[openshield-website.vercel.app](https://openshield-website.vercel.app)** + +## API Reference + +Full API documentation is available at [docs/api-reference.md](docs/api-reference.md). + +## Changelog + +See [CHANGELOG.md](CHANGELOG.md) for the full release history. diff --git a/ai/knowledge/skills/post-quantum-cryptography-azure/SKILL.md b/ai/knowledge/skills/post-quantum-cryptography-azure/SKILL.md index dc4f8e8..e6afc56 100644 --- a/ai/knowledge/skills/post-quantum-cryptography-azure/SKILL.md +++ b/ai/knowledge/skills/post-quantum-cryptography-azure/SKILL.md @@ -80,6 +80,69 @@ Document all findings with resource ID, algorithm type, key size, expiry date, a | FIPS 204 | ML-DSA | Digital signatures, replacing RSA-PSS and ECDSA | | FIPS 205 | SLH-DSA | Digital signatures, hash-based alternative | +## NCSC UK PQC Migration Guidance + +The UK National Cyber Security Centre requires organisations to treat PQC migration as a multi-year technology programme. Complete cryptographic discovery, define migration goals, and produce an initial plan by 2028. Complete the highest-priority migrations and refine the roadmap by 2031. Complete migration across systems, services, and products by 2035. + +Key requirements: + +- Maintain a complete inventory of systems and services that depend on public-key cryptography. +- Prioritise services that protect critical, sensitive, or long-lived data. +- Record supplier, protocol, hardware, and trust-chain dependencies. +- Build cryptographic agility into transition designs and plan for classical and PQC mechanisms to coexist temporarily. +- Use standards-compliant implementations from trusted libraries and products instead of creating custom algorithms. + +Guidance: https://www.ncsc.gov.uk/guidance/pqc-migration-timelines + +## ENISA Post-Quantum Cryptography Recommendations + +ENISA recommends preparing protocols and systems for PQC integration before cryptographically relevant quantum computers arrive. Migration planning must consider application-specific trade-offs, protocol changes, interoperability, and the operational cost of replacing established public-key infrastructure. + +Key requirements: + +- Assess each cryptographic use case and its confidentiality or authenticity lifetime. +- Make new protocols and major protocol revisions PQC-aware. +- Consider hybrid implementations that add post-quantum protection to established classical mechanisms during transition. +- Evaluate algorithm families, parameter choices, implementation maturity, and performance for each workload. +- Preserve defence in depth and avoid weakening current security while adding quantum resistance. + +Recommendations: https://www.enisa.europa.eu/publications/post-quantum-cryptography-current-state-and-quantum-mitigation + +## Harvest Now Decrypt Later Risk Assessment + +Harvest Now Decrypt Later attacks capture encrypted information today so that an adversary can retain it until a quantum computer can break the classical key establishment. The risk exists before a cryptographically relevant quantum computer is available because collection can already be under way. + +Assess HNDL risk using: + +- The sensitivity and useful lifetime of encrypted data. +- Internet exposure and the feasibility of passive traffic collection. +- Whether RSA, finite-field Diffie-Hellman, or elliptic-curve key establishment protects the session. +- Key reuse, key age, certificate lifetime, and the blast radius of compromise. +- Dependencies on suppliers, protocols, hardware security modules, and certificate authorities. +- The time required to discover, test, deploy, and validate a replacement. + +OpenShield marks internet-facing App Service TLS findings as directly HNDL-exposed. Key assets are also marked exposed when they may protect stored or transmitted data. Certificate-only signature risks are tracked separately because their primary quantum threat is authentication or signature forgery. + +## CBOM Schema + +OpenShield generates an `OpenShield-CBOM` document with schema version `1.0` from AZ-PQC findings in the latest completed scan. + +| Field | Meaning | +|-------|---------| +| `generated_at` | ISO 8601 completion time of the source scan | +| `subscription_id` | Azure subscription assessed by the source scan | +| `summary` | Counts by asset type, high-risk assets, and HNDL exposure | +| `migration_guidance` | Canonical NIST FIPS 203, 204, and 205 plus NCSC and ENISA references | +| `assets` | Risk-ranked classical cryptographic assets | +| `cbom_asset_type` | `tls_configuration`, `asymmetric_key`, or `certificate` | +| `algorithm_type` | Classical cryptographic algorithm or protocol mechanism observed | +| `quantum_risk_score` | Integer from 1 to 10 used to order migration work | +| `internet_exposed` | Whether the asset is directly internet-facing | +| `harvest_now_decrypt_later_exposed` | Whether retained data or traffic may be decrypted in the future | +| `hndl_risk_description` | Explanation of the HNDL or related quantum exposure | +| `migration_priority` | `HIGH`, `MEDIUM`, or `LOW` migration priority | +| `recommended_target_algorithm` | Recommended NIST-standardised PQC replacement | + ## Compliance Mapping | Framework | Control | Requirement | diff --git a/api/app.py b/api/app.py index 738826a..217742f 100644 --- a/api/app.py +++ b/api/app.py @@ -185,8 +185,8 @@ def verify_jwt() -> None: g.user = payload except jwt.ExpiredSignatureError: return jsonify({"error": "Token has expired", "request_id": get_request_id()}), 401 - except jwt.InvalidTokenError as exc: - logger.warning("Invalid JWT token: %s", exc) + except jwt.InvalidTokenError: + logger.warning("Invalid JWT token") return jsonify({"error": "Invalid token", "request_id": get_request_id()}), 401 return None @@ -195,6 +195,7 @@ def verify_jwt() -> None: # Blueprints # # ------------------------------------------------------------------ # from api.routes.ai import ai_bp + from api.routes.cbom import cbom_bp from api.routes.compliance import compliance_bp from api.routes.drift import drift_bp from api.routes.findings import findings_bp @@ -204,6 +205,7 @@ def verify_jwt() -> None: from api.routes.score import score_bp app.register_blueprint(ai_bp) + app.register_blueprint(cbom_bp) app.register_blueprint(compliance_bp) app.register_blueprint(drift_bp) app.register_blueprint(findings_bp) @@ -244,7 +246,8 @@ def ready(): @app.errorhandler(400) def bad_request(exc): - return jsonify({"error": "Bad request", "detail": str(exc), "request_id": get_request_id()}), 400 + logger.warning("Bad request: %s", exc) + return jsonify({"error": "Bad request", "request_id": get_request_id()}), 400 @app.errorhandler(401) def unauthorized(exc): diff --git a/api/models/finding.py b/api/models/finding.py index 83dd9e3..2b5c1cc 100644 --- a/api/models/finding.py +++ b/api/models/finding.py @@ -45,6 +45,8 @@ def _get_pool(dsn: str) -> "psycopg2.pool.ThreadedConnectionPool": "nist": "nist_csf.json", "iso27001": "iso27001.json", "soc2": "soc2.json", + "ncsc_pqc": "ncsc_pqc.json", + "enisa_pqc": "enisa_pqc.json", } @@ -235,30 +237,25 @@ def save_scan(self, scan_result: Dict[str, Any]) -> None: def get_findings(self, filters: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]: """Return findings, optionally filtered by severity, category, or rule_id.""" filters = filters or {} - clauses: List[str] = [] - params: List[Any] = [] - - if "severity" in filters: - clauses.append("severity = %s") - params.append(filters["severity"].upper()) - if "category" in filters: - clauses.append("LOWER(category) = LOWER(%s)") - params.append(filters["category"]) - if "rule_id" in filters: - clauses.append("rule_id = %s") - params.append(filters["rule_id"]) - if "scan_id" in filters: - clauses.append("scan_id = %s") - params.append(filters["scan_id"]) - else: - # Default to the latest completed scan (includes clean scans with 0 findings) - clauses.append( - "scan_id = (SELECT scan_id FROM scans WHERE status = 'completed' ORDER BY started_at DESC LIMIT 1)" - ) - - where = "WHERE " + " AND ".join(clauses) if clauses else "" - # Bandit false positive: clauses are fixed, parameterized fragments; values go through `params` - sql = f"SELECT * FROM findings {where} ORDER BY detected_at DESC LIMIT 1000" # nosec B608 + severity = filters.get("severity") + severity = severity.upper() if severity is not None else None + category = filters.get("category") + rule_id = filters.get("rule_id") + scan_id = filters.get("scan_id") + + sql = """ + SELECT * FROM findings + WHERE (%s IS NULL OR severity = %s) + AND (%s IS NULL OR LOWER(category) = LOWER(%s)) + AND (%s IS NULL OR rule_id = %s) + AND scan_id = COALESCE( + %s, + (SELECT scan_id FROM scans WHERE status = 'completed' ORDER BY started_at DESC LIMIT 1) + ) + ORDER BY detected_at DESC + LIMIT 1000 + """ + params = (severity, severity, category, category, rule_id, rule_id, scan_id) conn = self._get_conn() with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: @@ -273,6 +270,21 @@ def get_finding_by_id(self, finding_id: int) -> Optional[Dict[str, Any]]: row = cur.fetchone() return dict(row) if row else None + def get_latest_completed_scan(self) -> Optional[Dict[str, Any]]: + """Return the most recently started completed scan.""" + conn = self._get_conn() + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute( + """ + SELECT * FROM scans + WHERE status = 'completed' + ORDER BY started_at DESC + LIMIT 1 + """ + ) + row = cur.fetchone() + return dict(row) if row else None + def update_cve_fields(self, findings: List[Dict[str, Any]]) -> None: """Persist CVE enrichment fields for existing findings. diff --git a/api/routes/ai.py b/api/routes/ai.py index 5059ea4..776796a 100644 --- a/api/routes/ai.py +++ b/api/routes/ai.py @@ -158,6 +158,25 @@ def _read_request(): return body, None +_AI_ERROR_MESSAGES = { + 503: "AI knowledge base is not available", + 400: "Invalid AI request parameters", + 502: "AI provider request failed", +} + + +def _ai_error_response(exc: Exception, status: int, log_context: str): + """Return a safe, generic error response, logging the real exception server-side. + + Never reflect str(exc) back to the client: get_completion() wraps + third-party provider errors (e.g. "Anthropic request failed: {exc}"), + which could carry response bodies or connection details from the + underlying HTTP client that shouldn't reach the caller. + """ + logger.error("%s: %s", log_context, exc) + return jsonify({"error": _AI_ERROR_MESSAGES.get(status, "AI request failed")}), status + + @ai_bp.post("/api/ai/insights") @rate_limit(_AI_RATE_LIMIT) def insights(): @@ -223,7 +242,7 @@ def ai_summary(): try: context, sources = _context_for(findings_text) except VectorStoreNotBuilt as exc: - return jsonify({"error": str(exc)}), 503 + return _ai_error_response(exc, 503, "Vector store unavailable in ai_summary") prompt = ( "You are a cloud security advisor. Using ONLY the grounded knowledge " @@ -234,9 +253,9 @@ def ai_summary(): try: answer = get_completion(body["provider"], body["api_key"], prompt, model=body.get("model")) except ValueError as exc: - return jsonify({"error": str(exc)}), 400 + return _ai_error_response(exc, 400, "Invalid request in ai_summary") except RuntimeError as exc: - return jsonify({"error": str(exc)}), 502 + return _ai_error_response(exc, 502, "Provider failure in ai_summary") return jsonify( { @@ -262,7 +281,7 @@ def ai_prioritise(): try: context, sources = _context_for(findings_text) except VectorStoreNotBuilt as exc: - return jsonify({"error": str(exc)}), 503 + return _ai_error_response(exc, 503, "Vector store unavailable in ai_prioritise") prompt = ( "You are a cloud security advisor. Using ONLY the grounded knowledge " @@ -275,9 +294,9 @@ def ai_prioritise(): try: raw = get_completion(body["provider"], body["api_key"], prompt, model=body.get("model")) except ValueError as exc: - return jsonify({"error": str(exc)}), 400 + return _ai_error_response(exc, 400, "Invalid request in ai_prioritise") except RuntimeError as exc: - return jsonify({"error": str(exc)}), 502 + return _ai_error_response(exc, 502, "Provider failure in ai_prioritise") try: prioritised = json.loads(raw) @@ -307,7 +326,7 @@ def ai_ask(): try: context, sources = _context_for(question) except VectorStoreNotBuilt as exc: - return jsonify({"error": str(exc)}), 503 + return _ai_error_response(exc, 503, "Vector store unavailable in ai_ask") findings = body.get("findings", []) findings_text = _findings_to_text(findings) if findings else "Not provided." @@ -322,9 +341,9 @@ def ai_ask(): try: answer = get_completion(body["provider"], body["api_key"], prompt, model=body.get("model")) except ValueError as exc: - return jsonify({"error": str(exc)}), 400 + return _ai_error_response(exc, 400, "Invalid request in ai_ask") except RuntimeError as exc: - return jsonify({"error": str(exc)}), 502 + return _ai_error_response(exc, 502, "Provider failure in ai_ask") return jsonify( { @@ -352,15 +371,15 @@ def ai_threat_simulation(): try: context, sources = _context_for(findings_text) except VectorStoreNotBuilt as exc: - return jsonify({"error": str(exc)}), 503 + return _ai_error_response(exc, 503, "Vector store unavailable in ai_threat_simulation") prompt = _build_threat_simulation_prompt(findings_text, context) try: raw = get_completion(body["provider"], body["api_key"], prompt, model=body.get("model")) except ValueError as exc: - return jsonify({"error": str(exc)}), 400 + return _ai_error_response(exc, 400, "Invalid request in ai_threat_simulation") except RuntimeError as exc: - return jsonify({"error": str(exc)}), 502 + return _ai_error_response(exc, 502, "Provider failure in ai_threat_simulation") try: simulation = json.loads(raw) diff --git a/api/routes/cbom.py b/api/routes/cbom.py new file mode 100644 index 0000000..82f63e7 --- /dev/null +++ b/api/routes/cbom.py @@ -0,0 +1,169 @@ +"""Cryptographic Bill of Materials routes for post-quantum findings.""" + +import logging +import os +from datetime import datetime, timezone +from typing import Any, Dict, List + +from flask import Blueprint, g, jsonify + +from api.models.finding import DatabaseManager + +cbom_bp = Blueprint("cbom", __name__) +logger = logging.getLogger(__name__) + +_PQC_RULE_IDS = {"AZ-PQC-001", "AZ-PQC-002", "AZ-PQC-003"} +_MIGRATION_GUIDANCE = { + "nist_fips_203": "https://csrc.nist.gov/pubs/fips/203/final", + "nist_fips_204": "https://csrc.nist.gov/pubs/fips/204/final", + "nist_fips_205": "https://csrc.nist.gov/pubs/fips/205/final", + "ncsc_guidance": "https://www.ncsc.gov.uk/guidance/pqc-migration-timelines", + "enisa_guidance": ( + "https://www.enisa.europa.eu/publications/post-quantum-cryptography-current-state-and-quantum-mitigation" + ), +} + + +def _get_db() -> DatabaseManager: + if "db" not in g: + g.db = DatabaseManager(os.environ["DATABASE_URL"]) + g.db.connect() + return g.db + + +def _serialise(value: Any) -> Any: + """Convert database date values to JSON-safe ISO strings.""" + return value.isoformat() if hasattr(value, "isoformat") else value + + +def _asset(finding: Dict[str, Any]) -> Dict[str, Any]: + metadata = dict(finding.get("metadata") or {}) + quantum_risk = dict(finding.get("quantum_risk") or metadata.pop("quantum_risk", {}) or {}) + score = int(quantum_risk.get("quantum_risk_score", 1)) + return { + "finding_id": finding.get("id"), + "scan_id": str(finding.get("scan_id") or ""), + "rule_id": finding.get("rule_id"), + "resource_id": finding.get("resource_id"), + "resource_name": finding.get("resource_name"), + "resource_type": finding.get("resource_type"), + "severity": finding.get("severity"), + "description": finding.get("description"), + "remediation": finding.get("remediation"), + "detected_at": _serialise(finding.get("detected_at")), + "metadata": metadata, + **quantum_risk, + "quantum_risk_score": score, + } + + +def _summary(assets: List[Dict[str, Any]]) -> Dict[str, int]: + return { + "total_classical_assets": len(assets), + "tls_configurations": sum(a.get("cbom_asset_type") == "tls_configuration" for a in assets), + "asymmetric_keys": sum(a.get("cbom_asset_type") == "asymmetric_key" for a in assets), + "certificates": sum(a.get("cbom_asset_type") == "certificate" for a in assets), + "high_risk_assets": sum(a["quantum_risk_score"] >= 8 for a in assets), + "harvest_now_decrypt_later_exposed": sum(bool(a.get("harvest_now_decrypt_later_exposed")) for a in assets), + } + + +def _latest_cbom() -> Dict[str, Any] | None: + db = _get_db() + scan = db.get_latest_completed_scan() + if not scan: + return None + findings = db.get_findings({"scan_id": str(scan["scan_id"])}) + assets = sorted( + (_asset(finding) for finding in findings if finding.get("rule_id") in _PQC_RULE_IDS), + key=lambda asset: asset["quantum_risk_score"], + reverse=True, + ) + generated_at = scan.get("completed_at") or datetime.now(timezone.utc) + return { + "cbom_version": "1.0", + "schema": "OpenShield-CBOM", + "generated_at": _serialise(generated_at), + "subscription_id": str(scan.get("subscription_id") or ""), + "summary": _summary(assets), + "migration_guidance": _MIGRATION_GUIDANCE, + "assets": assets, + } + + +@cbom_bp.get("/api/cbom") +def get_cbom(): + """Return the full CBOM from the latest completed scan.""" + try: + cbom = _latest_cbom() + if cbom is None: + return jsonify({"error": "No completed scan found"}), 404 + return jsonify(cbom) + except Exception as exc: + logger.error("Failed to retrieve CBOM: %s", exc) + return jsonify({"error": "Failed to retrieve CBOM"}), 500 + + +@cbom_bp.get("/api/cbom/summary") +def get_cbom_summary(): + """Return the CBOM summary and five highest-risk assets.""" + try: + cbom = _latest_cbom() + if cbom is None: + return jsonify({"error": "No completed scan found"}), 404 + return jsonify( + { + "generated_at": cbom["generated_at"], + "subscription_id": cbom["subscription_id"], + "summary": cbom["summary"], + "top_risk_assets": cbom["assets"][:5], + } + ) + except Exception as exc: + logger.error("Failed to retrieve CBOM summary: %s", exc) + return jsonify({"error": "Failed to retrieve CBOM summary"}), 500 + + +@cbom_bp.get("/api/cbom/migration-roadmap") +def get_migration_roadmap(): + """Return a three-phase migration roadmap grouped by quantum risk.""" + try: + cbom = _latest_cbom() + if cbom is None: + return jsonify({"error": "No completed scan found"}), 404 + assets = cbom["assets"] + phases = [ + { + "phase": 1, + "name": "Immediate", + "timeline": "Immediate", + "risk_score": ">= 8", + "assets": [a for a in assets if a["quantum_risk_score"] >= 8], + }, + { + "phase": 2, + "name": "Short term", + "timeline": "12 months", + "risk_score": "5-7", + "assets": [a for a in assets if 5 <= a["quantum_risk_score"] <= 7], + }, + { + "phase": 3, + "name": "Long term", + "timeline": "24 months", + "risk_score": "< 5", + "assets": [a for a in assets if a["quantum_risk_score"] < 5], + }, + ] + for phase in phases: + phase["asset_count"] = len(phase["assets"]) + return jsonify( + { + "generated_at": cbom["generated_at"], + "subscription_id": cbom["subscription_id"], + "phases": phases, + } + ) + except Exception as exc: + logger.error("Failed to retrieve CBOM migration roadmap: %s", exc) + return jsonify({"error": "Failed to retrieve CBOM migration roadmap"}), 500 diff --git a/api/routes/compliance.py b/api/routes/compliance.py index 45dfef8..e6024e9 100644 --- a/api/routes/compliance.py +++ b/api/routes/compliance.py @@ -9,7 +9,7 @@ compliance_bp = Blueprint("compliance", __name__) logger = logging.getLogger(__name__) -SUPPORTED_FRAMEWORKS = ("cis", "nist", "iso27001", "soc2") +SUPPORTED_FRAMEWORKS = ("cis", "nist", "iso27001", "soc2", "ncsc_pqc", "enisa_pqc") def _get_db() -> DatabaseManager: @@ -26,7 +26,7 @@ def _get_db() -> DatabaseManager: def get_compliance(framework: str): """Return pass/fail compliance breakdown for a framework. - Supported frameworks: cis, nist, iso27001, soc2 + Supported frameworks: cis, nist, iso27001, soc2, ncsc_pqc, enisa_pqc Returns control-level pass/fail status mapped to current open findings. """ @@ -47,7 +47,8 @@ def get_compliance(framework: str): return jsonify(result) except FileNotFoundError as exc: - return jsonify({"error": f"Frameworks directory not found: {exc}"}), 500 + logger.error("Frameworks directory not found: %s", exc) + return jsonify({"error": "Compliance frameworks are not available"}), 500 except Exception as exc: logger.error("Failed to retrieve compliance score for %s: %s", framework, exc) - return jsonify({"error": "Compliance calculation failed", "detail": str(exc)}), 500 + return jsonify({"error": "Compliance calculation failed"}), 500 diff --git a/api/routes/drift.py b/api/routes/drift.py index 3ed61f4..427fa3d 100644 --- a/api/routes/drift.py +++ b/api/routes/drift.py @@ -147,4 +147,4 @@ def _rg(resource_id: str) -> str: except Exception as exc: logger.error("Failed to compute drift: %s", exc) - return jsonify({"error": "Failed to retrieve drift", "detail": str(exc)}), 500 + return jsonify({"error": "Failed to retrieve drift"}), 500 diff --git a/api/routes/findings.py b/api/routes/findings.py index 3a4939b..91f6afb 100644 --- a/api/routes/findings.py +++ b/api/routes/findings.py @@ -43,7 +43,7 @@ def list_findings(): return jsonify({"count": len(findings), "findings": findings}) except Exception as exc: logger.error("Failed to list findings: %s", exc) - return jsonify({"error": "Failed to retrieve findings", "detail": str(exc)}), 500 + return jsonify({"error": "Failed to retrieve findings"}), 500 @findings_bp.get("/api/findings/") @@ -57,7 +57,7 @@ def get_finding(finding_id: int): return jsonify(finding) except Exception as exc: logger.error("Failed to get finding %d: %s", finding_id, exc) - return jsonify({"error": "Database error", "detail": str(exc)}), 500 + return jsonify({"error": "Database error"}), 500 @findings_bp.get("/api/findings//playbook") @@ -126,4 +126,4 @@ def get_playbook(finding_id: int): except Exception as exc: logger.error("Failed to get playbook for finding %d: %s", finding_id, exc) - return jsonify({"error": "Failed to retrieve playbook", "detail": str(exc)}), 500 + return jsonify({"error": "Failed to retrieve playbook"}), 500 diff --git a/api/routes/prioritization.py b/api/routes/prioritization.py index 3353252..d19fe3d 100644 --- a/api/routes/prioritization.py +++ b/api/routes/prioritization.py @@ -180,4 +180,4 @@ def get_prioritization(): except Exception as exc: logger.error("Failed to build prioritization: %s", exc) - return jsonify({"error": "Failed to retrieve prioritization", "detail": str(exc)}), 500 + return jsonify({"error": "Failed to retrieve prioritization"}), 500 diff --git a/api/routes/resources.py b/api/routes/resources.py index ad0a4db..56a0309 100644 --- a/api/routes/resources.py +++ b/api/routes/resources.py @@ -121,4 +121,4 @@ def get_resources(): except Exception as exc: logger.error("Failed to build resources: %s", exc) - return jsonify({"error": "Failed to retrieve resources", "detail": str(exc)}), 500 + return jsonify({"error": "Failed to retrieve resources"}), 500 diff --git a/api/routes/scans.py b/api/routes/scans.py index c87914a..17c2c59 100644 --- a/api/routes/scans.py +++ b/api/routes/scans.py @@ -32,7 +32,7 @@ def list_scans(): return jsonify(result) except Exception as exc: logger.error("Failed to list scans: %s", exc) - return jsonify({"error": "Failed to retrieve scans", "detail": str(exc)}), 500 + return jsonify({"error": "Failed to retrieve scans"}), 500 @scans_bp.get("/api/scans/") @@ -46,7 +46,7 @@ def get_scan_status(scan_id): return jsonify(scan) except Exception as exc: logger.error("Failed to get scan status: %s", exc) - return jsonify({"error": "Database error", "detail": str(exc)}), 500 + return jsonify({"error": "Database error"}), 500 @scans_bp.post("/api/scans/trigger") @@ -73,7 +73,7 @@ def trigger_scan(): db.create_pending_scan(scan_id, subscription_id) except Exception as exc: logger.error("Failed to create pending scan: %s", exc, exc_info=True) - return jsonify({"error": "Database error", "detail": str(exc)}), 500 + return jsonify({"error": "Database error"}), 500 return jsonify( {"scan_id": scan_id, "status": "pending", "message": "Scan has been queued and will start shortly."} @@ -81,7 +81,36 @@ def trigger_scan(): except Exception as exc: logger.error("Critical error in trigger_scan route: %s", exc, exc_info=True) - return jsonify({"error": "Critical route failure", "detail": str(exc)}), 500 + return jsonify({"error": "Critical route failure"}), 500 + + +def _run_enrichment_in_background(scan_id: str, findings: list, db_url: str) -> None: + """Run CVE enrichment off the request thread and persist the result. + + Runs outside the Flask request/app context (it's started via + threading.Thread), so it opens its own DatabaseManager rather than + reusing flask.g. + """ + db = DatabaseManager(db_url) + try: + enriched = enrich_findings(findings) + db.update_cve_fields(enriched) + db.update_scan_enrichment_status(scan_id, "COMPLETED") + logger.info("Background CVE enrichment complete for scan %s (%d findings)", scan_id, len(enriched)) + except Exception as exc: + logger.error("Background enrichment failed for scan %s: %s", scan_id, exc) + try: + # A failed write (e.g. in update_cve_fields) can leave db.conn in + # an aborted-transaction state. Roll back first, or this status + # update itself raises InFailedSqlTransaction and gets swallowed + # below, leaving the scan stuck at ENRICHING forever. + if db.conn is not None: + db.conn.rollback() + db.update_scan_enrichment_status(scan_id, "FAILED") + except Exception as status_exc: + logger.error("Failed to record FAILED status for scan %s: %s", scan_id, status_exc) + finally: + db.close() def _run_enrichment_in_background(scan_id: str, findings: list, db_url: str) -> None: @@ -162,4 +191,4 @@ def enrich_scan(scan_id): except Exception as exc: logger.error("Failed to start enrichment for scan %s: %s", scan_id, exc) - return jsonify({"error": "Internal server error", "detail": str(exc)}), 500 + return jsonify({"error": "Internal server error"}), 500 diff --git a/api/routes/score.py b/api/routes/score.py index 9d0e125..22d157d 100644 --- a/api/routes/score.py +++ b/api/routes/score.py @@ -34,7 +34,7 @@ def get_score(): return jsonify(result) except Exception as exc: logger.error("Failed to calculate score: %s", exc) - return jsonify({"error": "Failed to calculate score", "detail": str(exc)}), 500 + return jsonify({"error": "Failed to calculate score"}), 500 @score_bp.get("/api/score/cve-summary") @@ -46,4 +46,4 @@ def get_cve_summary(): return jsonify(result) except Exception as exc: logger.error("Failed to fetch CVE summary: %s", exc) - return jsonify({"error": "Failed to fetch CVE summary", "detail": str(exc)}), 500 + return jsonify({"error": "Failed to fetch CVE summary"}), 500 diff --git a/compliance/frameworks/cis_azure_benchmark.json b/compliance/frameworks/cis_azure_benchmark.json index 12a53a1..7620192 100644 --- a/compliance/frameworks/cis_azure_benchmark.json +++ b/compliance/frameworks/cis_azure_benchmark.json @@ -227,6 +227,36 @@ "control_id": "8.9", "control_name": "Ensure certificates use quantum-safe signature algorithms", "description": "Key Vault certificates signed with RSA or ECDSA are vulnerable to quantum attacks. CIS 8.9 requires that certificate management includes monitoring of algorithm strength. Certificates should be migrated to post-quantum safe signature algorithms such as ML-DSA when CA support is available." + }, + "AZ-AKS-001": { + "control_id": "N/A-AKS-001", + "control_name": "AKS private cluster baseline (not mapped in CIS Azure Foundations 2.0.0)", + "description": "Microsoft recommends private AKS clusters to keep control-plane traffic on private networks. This OpenShield check is intentionally marked not applicable to the repository's CIS Azure Foundations 2.0.0 benchmark rather than claiming an unsupported CIS mapping." + }, + "AZ-AKS-002": { + "control_id": "N/A-AKS-002", + "control_name": "AKS local account baseline (not mapped in CIS Azure Foundations 2.0.0)", + "description": "Microsoft recommends disabling AKS local accounts so authentication is governed through Microsoft Entra ID. This check has no direct control in the repository's CIS Azure Foundations 2.0.0 benchmark." + }, + "AZ-AKS-003": { + "control_id": "N/A-AKS-003", + "control_name": "AKS managed identity baseline (not mapped in CIS Azure Foundations 2.0.0)", + "description": "Microsoft recommends managed identities instead of AKS service-principal credentials. This check has no direct control in the repository's CIS Azure Foundations 2.0.0 benchmark." + }, + "AZ-AKS-004": { + "control_id": "N/A-AKS-004", + "control_name": "AKS Workload Identity baseline (not mapped in CIS Azure Foundations 2.0.0)", + "description": "Microsoft recommends Workload Identity for scoped, secretless access from pods to Azure resources. This check has no direct control in the repository's CIS Azure Foundations 2.0.0 benchmark." + }, + "AZ-AKS-005": { + "control_id": "N/A-AKS-005", + "control_name": "AKS Azure Policy baseline (not mapped in CIS Azure Foundations 2.0.0)", + "description": "Microsoft recommends the Azure Policy add-on for centralized Kubernetes governance. This check has no direct control in the repository's CIS Azure Foundations 2.0.0 benchmark." + }, + "AZ-AKS-006": { + "control_id": "N/A-AKS-006", + "control_name": "AKS node OS upgrade baseline (not mapped in CIS Azure Foundations 2.0.0)", + "description": "Microsoft recommends a managed node OS upgrade channel for timely security patches. This check has no direct control in the repository's CIS Azure Foundations 2.0.0 benchmark." } } } diff --git a/compliance/frameworks/enisa_pqc.json b/compliance/frameworks/enisa_pqc.json new file mode 100644 index 0000000..839a823 --- /dev/null +++ b/compliance/frameworks/enisa_pqc.json @@ -0,0 +1,28 @@ +{ + "framework": "ENISA Post-Quantum Cryptography Recommendations", + "version": "2021", + "published": "2021-05", + "controls": { + "AZ-PQC-001": { + "control_id": "ENISA-PQC-HYBRID", + "control_name": "Protect communications with quantum-resistant designs", + "description": "Assess classical TLS key establishment for quantum exposure and prepare hybrid implementations that combine pre-quantum and post-quantum mechanisms while protocols and products mature.", + "framework": "ENISA Post-Quantum Cryptography Recommendations", + "url": "https://www.enisa.europa.eu/publications/post-quantum-cryptography-current-state-and-quantum-mitigation" + }, + "AZ-PQC-002": { + "control_id": "ENISA-PQC-INVENTORY", + "control_name": "Inventory and transition quantum-vulnerable keys", + "description": "Identify RSA and elliptic-curve keys, assess their use cases and security lifetime, and prepare migration to standardised quantum-resistant key establishment and signature schemes.", + "framework": "ENISA Post-Quantum Cryptography Recommendations", + "url": "https://www.enisa.europa.eu/publications/post-quantum-cryptography-current-state-and-quantum-mitigation" + }, + "AZ-PQC-003": { + "control_id": "ENISA-PQC-INTEGRATION", + "control_name": "Integrate post-quantum signatures into PKI", + "description": "Evaluate certificate and protocol dependencies, account for the operational trade-offs of post-quantum signatures, and design migration paths that retain security throughout the transition.", + "framework": "ENISA Post-Quantum Cryptography Recommendations", + "url": "https://www.enisa.europa.eu/publications/post-quantum-cryptography-current-state-and-quantum-mitigation" + } + } +} diff --git a/compliance/frameworks/iso27001.json b/compliance/frameworks/iso27001.json index 0f327bb..87a30fd 100644 --- a/compliance/frameworks/iso27001.json +++ b/compliance/frameworks/iso27001.json @@ -227,6 +227,36 @@ "control_id": "A.10.1.1", "control_name": "Policy on the use of cryptographic controls", "description": "Certificates using classical signature algorithms expose the organisation to quantum-enabled signature forgery. A.10.1.1 requires that the cryptographic controls policy covers all cryptographic assets including certificates. Migration planning to post-quantum safe signature algorithms is required." + }, + "AZ-AKS-001": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "Private AKS API access restricts the control plane to approved network paths and reduces exposure to internet-originated attacks." + }, + "AZ-AKS-002": { + "control_id": "A.9.2.1", + "control_name": "User registration and de-registration", + "description": "Disabling local accounts ensures AKS administrator access follows the centralized Microsoft Entra identity lifecycle." + }, + "AZ-AKS-003": { + "control_id": "A.9.2.1", + "control_name": "User registration and de-registration", + "description": "Managed identity removes separately managed service-principal credentials from the AKS control plane." + }, + "AZ-AKS-004": { + "control_id": "A.9.2.3", + "control_name": "Management of privileged access rights", + "description": "Workload Identity allows Azure permissions to be scoped and lifecycle-managed for individual Kubernetes workloads." + }, + "AZ-AKS-005": { + "control_id": "A.12.1.2", + "control_name": "Change management", + "description": "Azure Policy provides controlled and repeatable governance for Kubernetes resource admission and configuration changes." + }, + "AZ-AKS-006": { + "control_id": "A.12.6.1", + "control_name": "Management of technical vulnerabilities", + "description": "Automatic AKS node OS upgrades help deploy tested security patches within a managed maintenance process." } } } diff --git a/compliance/frameworks/ncsc_pqc.json b/compliance/frameworks/ncsc_pqc.json new file mode 100644 index 0000000..2b07d9d --- /dev/null +++ b/compliance/frameworks/ncsc_pqc.json @@ -0,0 +1,28 @@ +{ + "framework": "NCSC UK PQC Migration Guidance", + "version": "2025", + "published": "2025-03", + "controls": { + "AZ-PQC-001": { + "control_id": "NCSC-PQC-DISCOVERY", + "control_name": "Discover quantum-vulnerable network cryptography", + "description": "Identify internet-facing services that depend on classical public-key cryptography, assess the lifetime of protected data, and include their TLS configurations in the organisation's PQC migration plan.", + "framework": "NCSC UK PQC Migration Guidance", + "url": "https://www.ncsc.gov.uk/guidance/pqc-migration-timelines" + }, + "AZ-PQC-002": { + "control_id": "NCSC-PQC-PRIORITY", + "control_name": "Prioritise migration of critical cryptographic keys", + "description": "Inventory RSA and elliptic-curve keys, identify their system and supplier dependencies, and migrate keys protecting critical or long-lived data as a highest-priority activity.", + "framework": "NCSC UK PQC Migration Guidance", + "url": "https://www.ncsc.gov.uk/guidance/pqc-migration-timelines" + }, + "AZ-PQC-003": { + "control_id": "NCSC-PQC-PKI", + "control_name": "Plan migration of public key infrastructure", + "description": "Discover certificates and trust dependencies that rely on quantum-vulnerable signatures, then plan a staged migration that supports cryptographic agility and ecosystem readiness.", + "framework": "NCSC UK PQC Migration Guidance", + "url": "https://www.ncsc.gov.uk/guidance/pqc-migration-timelines" + } + } +} diff --git a/compliance/frameworks/nist_csf.json b/compliance/frameworks/nist_csf.json index d8abe98..faf101b 100644 --- a/compliance/frameworks/nist_csf.json +++ b/compliance/frameworks/nist_csf.json @@ -227,6 +227,36 @@ "control_id": "PR.DS-2", "control_name": "Data in transit is protected", "description": "Certificates using classical signature algorithms are vulnerable to quantum attacks, undermining authentication and integrity guarantees. PR.DS-2 requires that data protection includes integrity mechanisms. Migration to ML-DSA or SLH-DSA signature algorithms should be planned." + }, + "AZ-AKS-001": { + "control_id": "PR.AC-3", + "control_name": "Remote access is managed", + "description": "A private AKS API endpoint reduces public control-plane exposure and ensures administrative access is mediated through approved private network paths." + }, + "AZ-AKS-002": { + "control_id": "PR.AC-1", + "control_name": "Identities and credentials are issued, managed, verified, revoked, and audited", + "description": "Disabling local AKS accounts ensures cluster access uses centrally governed Microsoft Entra identities instead of unmanaged static credentials." + }, + "AZ-AKS-003": { + "control_id": "PR.AC-1", + "control_name": "Identities and credentials are issued, managed, verified, revoked, and audited", + "description": "Managed identities remove manually rotated service-principal credentials from AKS control-plane access to Azure resources." + }, + "AZ-AKS-004": { + "control_id": "PR.AC-4", + "control_name": "Access permissions and authorizations are managed", + "description": "Workload Identity supports workload-specific, least-privilege authorization to Azure resources without shared application secrets." + }, + "AZ-AKS-005": { + "control_id": "PR.IP-1", + "control_name": "A baseline configuration is created and maintained", + "description": "The Azure Policy add-on enables centrally defined Kubernetes security baselines to be audited and enforced consistently." + }, + "AZ-AKS-006": { + "control_id": "PR.IP-12", + "control_name": "A vulnerability management plan is developed and implemented", + "description": "Managed node OS upgrade channels apply tested security updates to reduce exposure to known operating-system vulnerabilities." } } } diff --git a/compliance/frameworks/soc2.json b/compliance/frameworks/soc2.json index 47fe542..e931e9f 100644 --- a/compliance/frameworks/soc2.json +++ b/compliance/frameworks/soc2.json @@ -227,6 +227,36 @@ "control_id": "CC6.7", "control_name": "Protects Data in Transit", "description": "Certificates using classical signature algorithms will be vulnerable to quantum-enabled forgery, undermining authentication and data integrity. CC6.7 requires that data integrity is maintained through encryption and signing. Migration to post-quantum safe certificate algorithms should be planned." + }, + "AZ-AKS-001": { + "control_id": "CC6.6", + "control_name": "Restricts Access to Information Assets", + "description": "A private AKS API endpoint restricts control-plane access to approved private network paths." + }, + "AZ-AKS-002": { + "control_id": "CC6.1", + "control_name": "Logical and Physical Access Controls", + "description": "Disabling AKS local accounts makes centrally governed Microsoft Entra identities the required authentication path." + }, + "AZ-AKS-003": { + "control_id": "CC6.1", + "control_name": "Logical and Physical Access Controls", + "description": "Managed identities reduce reliance on long-lived service-principal credentials for AKS control-plane operations." + }, + "AZ-AKS-004": { + "control_id": "CC6.3", + "control_name": "Role-Based Access", + "description": "Workload Identity enables permissions to be assigned to specific Kubernetes workloads according to least privilege." + }, + "AZ-AKS-005": { + "control_id": "CC8.1", + "control_name": "Change Management", + "description": "The Azure Policy add-on supports consistent audit and enforcement of approved Kubernetes configurations." + }, + "AZ-AKS-006": { + "control_id": "CC7.1", + "control_name": "Detects and Monitors Configuration Changes", + "description": "Managed node OS upgrade channels maintain worker-node security patches through an observable Azure-controlled process." } } } diff --git a/docker-compose.yml b/docker-compose.yml index 7f44484..aca8bc8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,6 +3,12 @@ version: '3.8' services: db: image: postgres:15-alpine + read_only: true + security_opt: + - no-new-privileges:true + tmpfs: + - /tmp + - /var/run/postgresql environment: POSTGRES_DB: openshield POSTGRES_USER: openshield @@ -32,6 +38,11 @@ services: frontend: image: node:20-alpine + read_only: true + security_opt: + - no-new-privileges:true + tmpfs: + - /tmp working_dir: /app volumes: - ./frontend:/app diff --git a/docs/adding-a-rule.md b/docs/adding-a-rule.md index 46e29de..a8ee856 100644 --- a/docs/adding-a-rule.md +++ b/docs/adding-a-rule.md @@ -27,7 +27,7 @@ logger = logging.getLogger(__name__) RULE_ID = "AZ-XXXX-000" # Unique ID. Check existing rules to avoid clashes. RULE_NAME = "Human-readable name" # Shown in the dashboard and reports. SEVERITY = "HIGH" # HIGH | MEDIUM | LOW | INFO -CATEGORY = "Storage" # Storage | Network | Identity | Database | Compute | Key Vault +CATEGORY = "Storage" # Storage | Network | Identity | Database | Compute | Key Vault | Kubernetes FRAMEWORKS = { "CIS": "3.5", # CIS Azure Benchmark control ID "NIST": "PR.AC-3", # NIST CSF subcategory @@ -124,6 +124,7 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: | `azure_client.get_sql_servers()` | List of Server objects (Azure SQL) | | `azure_client.get_sql_server_auditing_policy(rg, name)` | ServerBlobAuditingPolicy or None | | `azure_client.get_key_vaults()` | List of Vault objects (with full properties) | +| `azure_client.get_managed_clusters()` | List of AKS ManagedCluster objects, or `None` on API failure | | `azure_client.get_service_principals()` | List of RoleAssignment objects for service principals | | `azure_client.get_conditional_access_policies()` | List of CA policy dicts from MS Graph | | `azure_client.parse_resource_id(id)` | Dict with `resource_group` and `name` | diff --git a/docs/aks-security-rules.md b/docs/aks-security-rules.md new file mode 100644 index 0000000..3a8541b --- /dev/null +++ b/docs/aks-security-rules.md @@ -0,0 +1,54 @@ +# AKS Security Rules + +OpenShield evaluates Azure Kubernetes Service control-plane configuration through +the Azure Resource Manager API. The scanner does not download kubeconfig files, +request Kubernetes administrator credentials, or inspect in-cluster workloads. + +## Coverage + +| Rule | Control | +|---|---| +| `AZ-AKS-001` | Private API server endpoint | +| `AZ-AKS-002` | Local account disablement | +| `AZ-AKS-003` | Control-plane managed identity | +| `AZ-AKS-004` | OIDC issuer and Workload Identity | +| `AZ-AKS-005` | Azure Policy add-on | +| `AZ-AKS-006` | Managed node OS security upgrades | + +## Required permissions + +The scanning identity needs the following Azure Resource Manager action at the +subscription or relevant resource-group scope: + +```text +Microsoft.ContainerService/managedClusters/read +``` + +Azure's built-in **Reader** role includes this action. No Kubernetes RBAC role, +cluster credential, Microsoft Graph permission, or data-plane access is needed. + +If Azure denies or fails the inventory request, the AKS accessor returns an +indeterminate state. Rules skip evaluation and log the failure instead of +reporting the subscription as compliant. + +## Remediation safety + +The matching CLI playbooks validate the selected Azure account and cluster, +describe operational impact, and require the operator to type `APPLY`. Enabling +a private endpoint, disabling local credentials, migrating identity, or changing +node patch behavior should first be tested in a non-production cluster. + +## Compliance note + +This repository currently models CIS Microsoft Azure Foundations Benchmark +2.0.0. That benchmark does not directly identify these six AKS configuration +checks, so their CIS values are explicitly recorded as `N/A-AKS-*`. The rules +do not claim CIS Kubernetes Benchmark coverage. NIST CSF 1.1, ISO/IEC 27001:2013, +and SOC 2 mappings follow the framework versions already used by OpenShield. + +## Authoritative references + +- [Azure Policy built-ins for AKS](https://learn.microsoft.com/azure/aks/policy-reference) +- [AKS baseline architecture](https://learn.microsoft.com/azure/architecture/reference-architectures/containers/aks/baseline-aks) +- [AKS managed-cluster API](https://learn.microsoft.com/rest/api/aks/managed-clusters/get) +- [AKS node OS automatic upgrades](https://learn.microsoft.com/azure/aks/auto-upgrade-node-os-image) diff --git a/docs/rules-reference.md b/docs/rules-reference.md index 35b0a05..e8cd964 100644 --- a/docs/rules-reference.md +++ b/docs/rules-reference.md @@ -48,6 +48,12 @@ OpenShield currently ships 44 Azure scan rules. This table is generated from the | AZ-STOR-003 | Storage Account Has No Lifecycle Management Policy | MEDIUM | Storage | 3.7 | PR.DS-3 | A.8.3.1 | | AZ-STOR-004 | Storage Account Diagnostic Logging Disabled | MEDIUM | Storage | 3.3 | DE.CM-7 | A.12.4.1 | | AZ-STOR-005 | Storage Account Not Using Geo-Redundant Replication | MEDIUM | Storage | 3.8 | PR.IP-4 | A.17.2.1 | +| AZ-AKS-001 | AKS Private Cluster Not Enabled | HIGH | Kubernetes | N/A-AKS-001 | PR.AC-3 | A.13.1.1 | +| AZ-AKS-002 | AKS Local Accounts Enabled | HIGH | Kubernetes | N/A-AKS-002 | PR.AC-1 | A.9.2.1 | +| AZ-AKS-003 | AKS Cluster Not Using Managed Identity | HIGH | Kubernetes | N/A-AKS-003 | PR.AC-1 | A.9.2.1 | +| AZ-AKS-004 | AKS Workload Identity Not Fully Enabled | MEDIUM | Kubernetes | N/A-AKS-004 | PR.AC-4 | A.9.2.3 | +| AZ-AKS-005 | AKS Azure Policy Add-on Not Enabled | MEDIUM | Kubernetes | N/A-AKS-005 | PR.IP-1 | A.12.1.2 | +| AZ-AKS-006 | AKS Node OS Automatic Upgrades Disabled | HIGH | Kubernetes | N/A-AKS-006 | PR.IP-12 | A.12.6.1 | SOC 2 mappings are maintained in `compliance/frameworks/soc2.json`. diff --git a/frontend/src/utils/aiApi.js b/frontend/src/utils/aiApi.js index d393990..6c8d62a 100644 --- a/frontend/src/utils/aiApi.js +++ b/frontend/src/utils/aiApi.js @@ -15,19 +15,35 @@ const API_BASE = import.meta.env.VITE_API_URL || (import.meta.env.DEV ? 'http://localhost:5000' : 'https://openshield-api.onrender.com'); const TIMEOUT = 30000; -// ── Provider settings stored in localStorage ─────────────────────────────── +// ── Provider settings ─────────────────────────────────────────────────────── +// The API key is the user's own bring-your-own AI provider credential. It is +// kept in memory only for the life of the page — never written to +// localStorage/sessionStorage, which any script running on the page can read +// (XSS-exfiltrable) and which persists indefinitely. The trade-off is that +// the key does not survive a page reload; provider/model (not secret) still +// persist in localStorage as before. +let apiKeyInMemory = ''; + +// One-time migration: builds before the in-memory switch (issue #180) stored +// the key at localStorage['ai_api_key']. Purge it on load so existing users +// don't keep a plaintext key sitting in storage indefinitely. Deliberately not +// read into apiKeyInMemory — the whole point is that the secret no longer lives +// in a persistent, XSS-readable store. +localStorage.removeItem('ai_api_key'); + export const aiSettings = { getProvider: () => localStorage.getItem('ai_provider') || 'anthropic', - getApiKey: () => localStorage.getItem('ai_api_key') || '', + getApiKey: () => apiKeyInMemory, getModel: () => localStorage.getItem('ai_model') || '', save: ({ provider, apiKey, model }) => { - if (provider) localStorage.setItem('ai_provider', provider); - if (apiKey !== undefined) /* key storage removed */; - if (model !== undefined) localStorage.setItem('ai_model', model || ''); + if (provider) localStorage.setItem('ai_provider', provider); + if (apiKey !== undefined) apiKeyInMemory = apiKey; + if (model !== undefined) localStorage.setItem('ai_model', model || ''); }, - isConfigured: () => !!localStorage.getItem('ai_api_key'), + isConfigured: () => !!apiKeyInMemory, clear: () => { - localStorage.removeItem('ai_api_key'); + apiKeyInMemory = ''; + localStorage.removeItem('ai_api_key'); // defence in depth: also drop any legacy stored key localStorage.removeItem('ai_provider'); localStorage.removeItem('ai_model'); }, diff --git a/frontend/src/utils/aiApi.test.mjs b/frontend/src/utils/aiApi.test.mjs new file mode 100644 index 0000000..5cd7294 --- /dev/null +++ b/frontend/src/utils/aiApi.test.mjs @@ -0,0 +1,143 @@ +// Minimal, dependency-free test for aiSettings in aiApi.js (issue #180). +// +// frontend/ has no test runner configured (only eslint + vite). aiApi.js has +// no imports and only one Vite-only construct (import.meta.env), so this +// loads the real source (no duplication of the logic under test), replaces +// that one construct with a plain string, and evaluates it with a stubbed +// localStorage — no new devDependency required. +// +// Run with: node frontend/src/utils/aiApi.test.mjs + +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +function loadAiApiModule(seed = {}) { + let source = readFileSync(path.join(__dirname, 'aiApi.js'), 'utf8'); + + // Neutralize the one Vite-only construct so this can run under plain Node. + source = source.replace( + /import\.meta\.env\.VITE_API_URL\s*\|\|\s*\(import\.meta\.env\.DEV \? '[^']*' : '[^']*'\)/, + "'http://localhost:5000'", + ); + assert.ok(!source.includes('import.meta'), 'failed to neutralize import.meta usage — test harness is stale'); + + // Turn `export const x = ...` into `const x = ...` and return the bindings + // via a wrapper function, so the real module body runs unmodified. + source = source.replace(/^export const /gm, 'const '); + source += '\nreturn { aiSettings, aiApi };'; + + // `seed` pre-populates localStorage before the module body runs, so tests can + // simulate a browser that already has values from a previous app version + // (e.g. a legacy plaintext ai_api_key that predates the in-memory switch). + const backingStore = new Map(Object.entries(seed)); + const localStorageStub = { + getItem: (k) => (backingStore.has(k) ? backingStore.get(k) : null), + setItem: (k, v) => backingStore.set(k, String(v)), + removeItem: (k) => backingStore.delete(k), + }; + + const load = new Function('localStorage', 'fetch', 'AbortController', 'setTimeout', 'clearTimeout', source); + const noopFetch = () => Promise.reject(new Error('fetch should not be called in this test')); + const mod = load(localStorageStub, noopFetch, AbortController, setTimeout, clearTimeout); + return { ...mod, backingStore }; +} + +let failures = 0; +function check(description, fn) { + try { + fn(); + console.log(`PASS: ${description}`); + } catch (err) { + failures++; + console.error(`FAIL: ${description}\n ${err.message}`); + } +} + +// ── Fresh module per check: apiKeyInMemory is module-scoped state ────────── + +check('starts unconfigured with no key', () => { + const { aiSettings } = loadAiApiModule(); + assert.equal(aiSettings.isConfigured(), false); + assert.equal(aiSettings.getApiKey(), ''); +}); + +check('save() persists the key in memory and isConfigured() reflects it', () => { + const { aiSettings } = loadAiApiModule(); + aiSettings.save({ provider: 'anthropic', apiKey: 'sk-test-12345' }); + assert.equal(aiSettings.isConfigured(), true); + assert.equal(aiSettings.getApiKey(), 'sk-test-12345'); +}); + +check('the API key is never written to localStorage under any key', () => { + const { aiSettings, backingStore } = loadAiApiModule(); + aiSettings.save({ provider: 'groq', apiKey: 'super-secret-key-value', model: 'llama' }); + for (const storedValue of backingStore.values()) { + assert.ok( + !String(storedValue).includes('super-secret-key-value'), + `API key leaked into localStorage: ${JSON.stringify([...backingStore.entries()])}`, + ); + } +}); + +check('provider and model (non-secret) do persist in localStorage', () => { + const { aiSettings, backingStore } = loadAiApiModule(); + aiSettings.save({ provider: 'gemini', apiKey: 'irrelevant', model: 'gemini-pro' }); + assert.equal(backingStore.get('ai_provider'), 'gemini'); + assert.equal(backingStore.get('ai_model'), 'gemini-pro'); +}); + +check('clear() removes the in-memory key and localStorage entries', () => { + const { aiSettings, backingStore } = loadAiApiModule(); + aiSettings.save({ provider: 'anthropic', apiKey: 'to-be-cleared' }); + assert.equal(aiSettings.isConfigured(), true); + aiSettings.clear(); + assert.equal(aiSettings.isConfigured(), false); + assert.equal(aiSettings.getApiKey(), ''); + assert.equal(backingStore.has('ai_provider'), false); + assert.equal(backingStore.has('ai_model'), false); +}); + +check('a fresh module load never sees a key from a previous instance (no shared storage)', () => { + const first = loadAiApiModule(); + first.aiSettings.save({ provider: 'anthropic', apiKey: 'first-instance-key' }); + + const second = loadAiApiModule(); + assert.equal(second.aiSettings.isConfigured(), false); + assert.equal(second.aiSettings.getApiKey(), ''); +}); + +check('loading the module purges a legacy plaintext ai_api_key left by an older version', () => { + // Users who ran a build before the in-memory switch (issue #180) still have + // their key sitting in localStorage. The module must delete it on load, or + // the very leak the fix was meant to close persists indefinitely for them. + const { backingStore } = loadAiApiModule({ ai_api_key: 'legacy-plaintext-key' }); + assert.equal(backingStore.has('ai_api_key'), false); + assert.ok( + ![...backingStore.values()].some((v) => String(v).includes('legacy-plaintext-key')), + 'legacy key still present in localStorage after module load', + ); +}); + +check('the migration does not resurrect the legacy key into the in-memory field', () => { + // Purging the stored key must not silently load it into memory either — a + // fresh load with a legacy key present should still start unconfigured. + const { aiSettings } = loadAiApiModule({ ai_api_key: 'legacy-plaintext-key' }); + assert.equal(aiSettings.isConfigured(), false); + assert.equal(aiSettings.getApiKey(), ''); +}); + +check('clear() also removes any legacy ai_api_key still in storage', () => { + const { aiSettings, backingStore } = loadAiApiModule({ ai_api_key: 'legacy-plaintext-key' }); + aiSettings.clear(); + assert.equal(backingStore.has('ai_api_key'), false); +}); + +if (failures > 0) { + console.error(`\n${failures} test(s) failed`); + process.exit(1); +} +console.log('\nAll aiSettings tests passed'); diff --git a/playbooks/cli/fix_az_aks_001.sh b/playbooks/cli/fix_az_aks_001.sh new file mode 100644 index 0000000..f0cfccc --- /dev/null +++ b/playbooks/cli/fix_az_aks_001.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# OpenShield Remediation Playbook +# Rule: AZ-AKS-001 - AKS private cluster not enabled +# Usage: ./fix_az_aks_001.sh +# Severity: HIGH + +set -euo pipefail + +RESOURCE_GROUP=${1:-} +CLUSTER_NAME=${2:-} +if [ -z "$RESOURCE_GROUP" ] || [ -z "$CLUSTER_NAME" ]; then + echo "Usage: $0 " + exit 1 +fi + +az account show --output none +az aks show --resource-group "$RESOURCE_GROUP" --name "$CLUSTER_NAME" --output none +echo "WARNING: Enabling a private API endpoint changes cluster connectivity and DNS requirements." +echo "Confirm that administrators and automation have private network access before continuing." +read -r -p "Type APPLY to enable private-cluster mode on '$CLUSTER_NAME': " CONFIRM +[ "$CONFIRM" = "APPLY" ] || { echo "Cancelled."; exit 1; } + +az aks update --resource-group "$RESOURCE_GROUP" --name "$CLUSTER_NAME" --enable-private-cluster +az aks show --resource-group "$RESOURCE_GROUP" --name "$CLUSTER_NAME" \ + --query "apiServerAccessProfile.enablePrivateCluster" --output tsv diff --git a/playbooks/cli/fix_az_aks_002.sh b/playbooks/cli/fix_az_aks_002.sh new file mode 100644 index 0000000..4668de4 --- /dev/null +++ b/playbooks/cli/fix_az_aks_002.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# OpenShield Remediation Playbook +# Rule: AZ-AKS-002 - AKS local accounts enabled +# Usage: ./fix_az_aks_002.sh +# Severity: HIGH + +set -euo pipefail + +RESOURCE_GROUP=${1:-} +CLUSTER_NAME=${2:-} +if [ -z "$RESOURCE_GROUP" ] || [ -z "$CLUSTER_NAME" ]; then + echo "Usage: $0 " + exit 1 +fi + +az account show --output none +az aks show --resource-group "$RESOURCE_GROUP" --name "$CLUSTER_NAME" --output none +echo "WARNING: Existing local kubeconfig credentials will stop working." +echo "Verify Microsoft Entra administrator access before continuing." +read -r -p "Type APPLY to disable local accounts on '$CLUSTER_NAME': " CONFIRM +[ "$CONFIRM" = "APPLY" ] || { echo "Cancelled."; exit 1; } + +az aks update --resource-group "$RESOURCE_GROUP" --name "$CLUSTER_NAME" --disable-local-accounts +az aks show --resource-group "$RESOURCE_GROUP" --name "$CLUSTER_NAME" \ + --query "disableLocalAccounts" --output tsv diff --git a/playbooks/cli/fix_az_aks_003.sh b/playbooks/cli/fix_az_aks_003.sh new file mode 100644 index 0000000..4d7a074 --- /dev/null +++ b/playbooks/cli/fix_az_aks_003.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# OpenShield Remediation Playbook +# Rule: AZ-AKS-003 - AKS cluster not using managed identity +# Usage: ./fix_az_aks_003.sh +# Severity: HIGH + +set -euo pipefail + +RESOURCE_GROUP=${1:-} +CLUSTER_NAME=${2:-} +if [ -z "$RESOURCE_GROUP" ] || [ -z "$CLUSTER_NAME" ]; then + echo "Usage: $0 " + exit 1 +fi + +az account show --output none +az aks show --resource-group "$RESOURCE_GROUP" --name "$CLUSTER_NAME" --output none +echo "WARNING: Migration changes the cluster control-plane identity." +echo "Review dependent role assignments and allow time for permission propagation." +read -r -p "Type APPLY to enable managed identity on '$CLUSTER_NAME': " CONFIRM +[ "$CONFIRM" = "APPLY" ] || { echo "Cancelled."; exit 1; } + +az aks update --resource-group "$RESOURCE_GROUP" --name "$CLUSTER_NAME" --enable-managed-identity +az aks show --resource-group "$RESOURCE_GROUP" --name "$CLUSTER_NAME" \ + --query "identity.type" --output tsv diff --git a/playbooks/cli/fix_az_aks_004.sh b/playbooks/cli/fix_az_aks_004.sh new file mode 100644 index 0000000..0ed80dd --- /dev/null +++ b/playbooks/cli/fix_az_aks_004.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# OpenShield Remediation Playbook +# Rule: AZ-AKS-004 - AKS Workload Identity not fully enabled +# Usage: ./fix_az_aks_004.sh +# Severity: MEDIUM + +set -euo pipefail + +RESOURCE_GROUP=${1:-} +CLUSTER_NAME=${2:-} +if [ -z "$RESOURCE_GROUP" ] || [ -z "$CLUSTER_NAME" ]; then + echo "Usage: $0 " + exit 1 +fi + +az account show --output none +az aks show --resource-group "$RESOURCE_GROUP" --name "$CLUSTER_NAME" --output none +echo "This enables the OIDC issuer and Workload Identity platform features." +echo "Each workload still requires a federated identity credential and service-account annotation." +read -r -p "Type APPLY to continue for '$CLUSTER_NAME': " CONFIRM +[ "$CONFIRM" = "APPLY" ] || { echo "Cancelled."; exit 1; } + +az aks update --resource-group "$RESOURCE_GROUP" --name "$CLUSTER_NAME" \ + --enable-oidc-issuer --enable-workload-identity +az aks show --resource-group "$RESOURCE_GROUP" --name "$CLUSTER_NAME" \ + --query "{oidc:oidcIssuerProfile.enabled,workloadIdentity:securityProfile.workloadIdentity.enabled}" diff --git a/playbooks/cli/fix_az_aks_005.sh b/playbooks/cli/fix_az_aks_005.sh new file mode 100644 index 0000000..cb72cc5 --- /dev/null +++ b/playbooks/cli/fix_az_aks_005.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# OpenShield Remediation Playbook +# Rule: AZ-AKS-005 - AKS Azure Policy add-on not enabled +# Usage: ./fix_az_aks_005.sh +# Severity: MEDIUM + +set -euo pipefail + +RESOURCE_GROUP=${1:-} +CLUSTER_NAME=${2:-} +if [ -z "$RESOURCE_GROUP" ] || [ -z "$CLUSTER_NAME" ]; then + echo "Usage: $0 " + exit 1 +fi + +az account show --output none +az aks show --resource-group "$RESOURCE_GROUP" --name "$CLUSTER_NAME" --output none +echo "Enable the add-on first, then roll out policy initiatives in audit mode before deny mode." +read -r -p "Type APPLY to enable Azure Policy on '$CLUSTER_NAME': " CONFIRM +[ "$CONFIRM" = "APPLY" ] || { echo "Cancelled."; exit 1; } + +az aks enable-addons --resource-group "$RESOURCE_GROUP" --name "$CLUSTER_NAME" --addons azure-policy +az aks show --resource-group "$RESOURCE_GROUP" --name "$CLUSTER_NAME" \ + --query "addonProfiles.azurepolicy.enabled" --output tsv diff --git a/playbooks/cli/fix_az_aks_006.sh b/playbooks/cli/fix_az_aks_006.sh new file mode 100644 index 0000000..77b2878 --- /dev/null +++ b/playbooks/cli/fix_az_aks_006.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# OpenShield Remediation Playbook +# Rule: AZ-AKS-006 - AKS node OS automatic upgrades disabled +# Usage: ./fix_az_aks_006.sh +# Severity: HIGH + +set -euo pipefail + +RESOURCE_GROUP=${1:-} +CLUSTER_NAME=${2:-} +if [ -z "$RESOURCE_GROUP" ] || [ -z "$CLUSTER_NAME" ]; then + echo "Usage: $0 " + exit 1 +fi + +az account show --output none +az aks show --resource-group "$RESOURCE_GROUP" --name "$CLUSTER_NAME" --output none +echo "WARNING: SecurityPatch updates can reimage nodes when required." +echo "Configure and validate an AKS planned-maintenance window for production clusters." +read -r -p "Type APPLY to enable SecurityPatch on '$CLUSTER_NAME': " CONFIRM +[ "$CONFIRM" = "APPLY" ] || { echo "Cancelled."; exit 1; } + +az aks update --resource-group "$RESOURCE_GROUP" --name "$CLUSTER_NAME" \ + --node-os-upgrade-channel SecurityPatch +az aks show --resource-group "$RESOURCE_GROUP" --name "$CLUSTER_NAME" \ + --query "autoUpgradeProfile.nodeOsUpgradeChannel" --output tsv diff --git a/requirements.txt b/requirements.txt index 00f933d..321fcb1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,6 +14,7 @@ azure-mgmt-web==7.3.1 azure-monitor-ingestion==1.0.3 azure-mgmt-monitor==6.0.0 azure-mgmt-dns==8.0.0 +azure-mgmt-containerservice==41.3.0 psycopg2-binary==2.9.9 python-dotenv==1.2.2 pyjwt==2.13.0 diff --git a/scanner/azure_client.py b/scanner/azure_client.py index e645415..c44aa9d 100644 --- a/scanner/azure_client.py +++ b/scanner/azure_client.py @@ -7,6 +7,7 @@ from azure.identity import DefaultAzureCredential from azure.mgmt.authorization import AuthorizationManagementClient from azure.mgmt.compute import ComputeManagementClient +from azure.mgmt.containerservice import ContainerServiceClient from azure.mgmt.keyvault import KeyVaultManagementClient from azure.mgmt.network import NetworkManagementClient from azure.mgmt.rdbms.postgresql import PostgreSQLManagementClient @@ -19,6 +20,7 @@ # Azure built-in role definition GUIDs (subscription-scoped) OWNER_ROLE_ID = "8e3af657-a8ff-443c-a75c-2fe8c4bcb635" +_UNSET = object() class AzureClient: @@ -32,6 +34,7 @@ class AzureClient: def __init__(self, subscription_id: str, credential: Optional[Any] = None) -> None: self.subscription_id = subscription_id self.credential = credential or DefaultAzureCredential() + self._managed_clusters_cache: Any = _UNSET # ------------------------------------------------------------------ # # Static helpers # @@ -287,6 +290,41 @@ def get_dns_record_sets(self, resource_group: str, zone_name: str) -> List[Any]: logger.error("get_dns_record_sets failed for zone %s: %s", zone_name, exc) return [] + # ------------------------------------------------------------------ # + # Kubernetes / AKS # + # ------------------------------------------------------------------ # + + def get_managed_clusters(self) -> Optional[List[Any]]: + """List AKS managed clusters, preserving an indeterminate failure state. + + The result is cached for the lifetime of this client because every AKS + rule evaluates the same subscription-level collection. + + Returns: + A list (including an empty list) when Azure responds successfully, + or ``None`` when permissions, networking, or the SDK prevent the + collection from being evaluated. Callers must never interpret + ``None`` as a compliant result. + """ + if self._managed_clusters_cache is not _UNSET: + return self._managed_clusters_cache + + try: + client = ContainerServiceClient(self.credential, self.subscription_id) + self._managed_clusters_cache = list(client.managed_clusters.list()) + except HttpResponseError as exc: + logger.error( + "get_managed_clusters HTTP %s - check Microsoft.ContainerService/managedClusters/read: %s", + exc.status_code, + exc, + ) + self._managed_clusters_cache = None + except Exception as exc: + logger.error("get_managed_clusters failed: %s", exc) + self._managed_clusters_cache = None + + return self._managed_clusters_cache + # ------------------------------------------------------------------ # # Compute # # ------------------------------------------------------------------ # diff --git a/scanner/cve_correlator.py b/scanner/cve_correlator.py index 91ff0dc..0ac3de4 100644 --- a/scanner/cve_correlator.py +++ b/scanner/cve_correlator.py @@ -42,6 +42,8 @@ # Identity "AZ-IDN": "Azure Active Directory", "AZ-IDN-001": "Azure RBAC privilege escalation", + # Azure Kubernetes Service + "AZ-AKS": "Azure Kubernetes Service", # App Service "AZ-APP": "Azure App Service", } diff --git a/scanner/nvd_client.py b/scanner/nvd_client.py index 522cb18..989ea0d 100644 --- a/scanner/nvd_client.py +++ b/scanner/nvd_client.py @@ -151,6 +151,14 @@ def query_nvd(keyword: str, results_per_page: int = _RESULTS_PER_PAGE) -> list[d } ) url = f"{_NVD_BASE_URL}?{params}" + parsed_url = urllib.parse.urlsplit(url) + if ( + parsed_url.scheme != "https" + or parsed_url.hostname != "services.nvd.nist.gov" + or parsed_url.port not in (None, 443) + ): + logger.error("Refusing request to an untrusted NVD endpoint") + return [] for attempt in range(1, _MAX_RETRIES + 1): try: @@ -163,7 +171,10 @@ def query_nvd(keyword: str, results_per_page: int = _RESULTS_PER_PAGE) -> list[d ) # URL host is the hardcoded NVD API base, not user-controlled with NVD_REQUEST_LATENCY_SECONDS.time(): - with urllib.request.urlopen(req, timeout=10) as resp: # nosec B310 + # The URL is built from the fixed HTTPS NVD endpoint; only its query string varies. + with urllib.request.urlopen( # nosec B310 # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected # noqa: E501 + req, timeout=10 + ) as resp: data = json.loads(resp.read()) vulnerabilities = data.get("vulnerabilities", []) diff --git a/scanner/rules/_aks_common.py b/scanner/rules/_aks_common.py new file mode 100644 index 0000000..9e237ad --- /dev/null +++ b/scanner/rules/_aks_common.py @@ -0,0 +1,55 @@ +"""Shared helpers for Azure Kubernetes Service rule modules.""" + +from typing import Any, Dict, Mapping + +_MISSING = object() + + +def cluster_property(cluster: Any, name: str, default: Any = None) -> Any: + """Read an AKS property across both flattened and nested SDK models.""" + value = getattr(cluster, name, _MISSING) + if value is not _MISSING: + return value + + properties = getattr(cluster, "properties", None) + if properties is None: + return default + return getattr(properties, name, default) + + +def resource_identity(cluster: Any) -> tuple[str, str]: + """Return a validated AKS resource ID and display name.""" + resource_id = getattr(cluster, "id", "") or "" + resource_name = getattr(cluster, "name", "") or "" + return resource_id, resource_name + + +def finding( + cluster: Any, + *, + rule_id: str, + rule_name: str, + severity: str, + category: str, + description: str, + remediation: str, + playbook: str, + frameworks: Mapping[str, str], + metadata: Mapping[str, Any], +) -> Dict[str, Any]: + """Build the repository-standard finding payload for an AKS cluster.""" + resource_id, resource_name = resource_identity(cluster) + return { + "rule_id": rule_id, + "rule_name": rule_name, + "severity": severity, + "category": category, + "resource_id": resource_id, + "resource_name": resource_name, + "resource_type": "Microsoft.ContainerService/managedClusters", + "description": description, + "remediation": remediation, + "playbook": playbook, + "frameworks": dict(frameworks), + "metadata": dict(metadata), + } diff --git a/scanner/rules/az_aks_001.py b/scanner/rules/az_aks_001.py new file mode 100644 index 0000000..1b5d7cd --- /dev/null +++ b/scanner/rules/az_aks_001.py @@ -0,0 +1,54 @@ +"""AZ-AKS-001: AKS cluster does not use a private API server endpoint.""" + +import logging +from typing import Any, Dict, List + +from scanner.rules._aks_common import cluster_property, finding, resource_identity + +RULE_ID = "AZ-AKS-001" +RULE_NAME = "AKS Private Cluster Not Enabled" +SEVERITY = "HIGH" +CATEGORY = "Kubernetes" +FRAMEWORKS = {"CIS": "N/A-AKS-001", "NIST": "PR.AC-3", "ISO27001": "A.13.1.1", "SOC2": "CC6.6"} +DESCRIPTION = ( + "The AKS API server is reachable through a public endpoint. Public control-plane exposure " + "increases the opportunity for credential attacks and unauthorized cluster access." +) +REMEDIATION = "Enable the AKS private cluster feature after validating private DNS and administrator connectivity." +PLAYBOOK = "playbooks/cli/fix_az_aks_001.sh" + +logger = logging.getLogger(__name__) + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + clusters = azure_client.get_managed_clusters() + if clusters is None: + logger.warning("%s: AKS clusters could not be enumerated", RULE_ID) + return findings + + for cluster in clusters: + resource_id, resource_name = resource_identity(cluster) + if not resource_id or not resource_name: + continue + profile = cluster_property(cluster, "api_server_access_profile") + enabled = getattr(profile, "enable_private_cluster", None) if profile is not None else None + if enabled is None: + logger.warning("%s: private-cluster status unknown for %s", RULE_ID, resource_name) + continue + if enabled is False: + findings.append( + finding( + cluster, + rule_id=RULE_ID, + rule_name=RULE_NAME, + severity=SEVERITY, + category=CATEGORY, + description=DESCRIPTION, + remediation=REMEDIATION, + playbook=PLAYBOOK, + frameworks=FRAMEWORKS, + metadata={"private_cluster_enabled": False}, + ) + ) + return findings diff --git a/scanner/rules/az_aks_002.py b/scanner/rules/az_aks_002.py new file mode 100644 index 0000000..7614edc --- /dev/null +++ b/scanner/rules/az_aks_002.py @@ -0,0 +1,52 @@ +"""AZ-AKS-002: AKS local administrator accounts are enabled.""" + +import logging +from typing import Any, Dict, List + +from scanner.rules._aks_common import cluster_property, finding, resource_identity + +RULE_ID = "AZ-AKS-002" +RULE_NAME = "AKS Local Accounts Enabled" +SEVERITY = "HIGH" +CATEGORY = "Kubernetes" +FRAMEWORKS = {"CIS": "N/A-AKS-002", "NIST": "PR.AC-1", "ISO27001": "A.9.2.1", "SOC2": "CC6.1"} +DESCRIPTION = ( + "Local AKS accounts bypass centralized Microsoft Entra identity controls and can provide " + "long-lived cluster access outside normal conditional-access and lifecycle processes." +) +REMEDIATION = "Disable AKS local accounts after confirming Microsoft Entra administrators can access the cluster." +PLAYBOOK = "playbooks/cli/fix_az_aks_002.sh" + +logger = logging.getLogger(__name__) + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + clusters = azure_client.get_managed_clusters() + if clusters is None: + logger.warning("%s: AKS clusters could not be enumerated", RULE_ID) + return findings + for cluster in clusters: + resource_id, resource_name = resource_identity(cluster) + if not resource_id or not resource_name: + continue + disabled = cluster_property(cluster, "disable_local_accounts") + if disabled is None: + logger.warning("%s: local-account status unknown for %s", RULE_ID, resource_name) + continue + if disabled is False: + findings.append( + finding( + cluster, + rule_id=RULE_ID, + rule_name=RULE_NAME, + severity=SEVERITY, + category=CATEGORY, + description=DESCRIPTION, + remediation=REMEDIATION, + playbook=PLAYBOOK, + frameworks=FRAMEWORKS, + metadata={"local_accounts_disabled": False}, + ) + ) + return findings diff --git a/scanner/rules/az_aks_003.py b/scanner/rules/az_aks_003.py new file mode 100644 index 0000000..8111080 --- /dev/null +++ b/scanner/rules/az_aks_003.py @@ -0,0 +1,51 @@ +"""AZ-AKS-003: AKS cluster uses a legacy service principal.""" + +import logging +from typing import Any, Dict, List + +from scanner.rules._aks_common import finding, resource_identity + +RULE_ID = "AZ-AKS-003" +RULE_NAME = "AKS Cluster Not Using Managed Identity" +SEVERITY = "HIGH" +CATEGORY = "Kubernetes" +FRAMEWORKS = {"CIS": "N/A-AKS-003", "NIST": "PR.AC-1", "ISO27001": "A.9.2.1", "SOC2": "CC6.1"} +DESCRIPTION = ( + "The AKS control plane is not configured with a managed identity. Legacy service principals " + "depend on credentials that require rotation and can be exposed or expire unexpectedly." +) +REMEDIATION = "Migrate the AKS cluster from its service principal to a system- or user-assigned managed identity." +PLAYBOOK = "playbooks/cli/fix_az_aks_003.sh" + +logger = logging.getLogger(__name__) +_MANAGED_TYPES = {"systemassigned", "userassigned", "systemassigned,userassigned"} + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + clusters = azure_client.get_managed_clusters() + if clusters is None: + logger.warning("%s: AKS clusters could not be enumerated", RULE_ID) + return findings + for cluster in clusters: + resource_id, resource_name = resource_identity(cluster) + if not resource_id or not resource_name: + continue + identity = getattr(cluster, "identity", None) + identity_type = str(getattr(identity, "type", "") or "").replace(" ", "").lower() + if identity_type not in _MANAGED_TYPES: + findings.append( + finding( + cluster, + rule_id=RULE_ID, + rule_name=RULE_NAME, + severity=SEVERITY, + category=CATEGORY, + description=DESCRIPTION, + remediation=REMEDIATION, + playbook=PLAYBOOK, + frameworks=FRAMEWORKS, + metadata={"identity_type": identity_type or "service-principal"}, + ) + ) + return findings diff --git a/scanner/rules/az_aks_004.py b/scanner/rules/az_aks_004.py new file mode 100644 index 0000000..e234a7c --- /dev/null +++ b/scanner/rules/az_aks_004.py @@ -0,0 +1,53 @@ +"""AZ-AKS-004: AKS Workload Identity or OIDC issuer is disabled.""" + +import logging +from typing import Any, Dict, List + +from scanner.rules._aks_common import cluster_property, finding, resource_identity + +RULE_ID = "AZ-AKS-004" +RULE_NAME = "AKS Workload Identity Not Fully Enabled" +SEVERITY = "MEDIUM" +CATEGORY = "Kubernetes" +FRAMEWORKS = {"CIS": "N/A-AKS-004", "NIST": "PR.AC-4", "ISO27001": "A.9.2.3", "SOC2": "CC6.3"} +DESCRIPTION = ( + "AKS Workload Identity and its OIDC issuer are not both enabled. Applications may consequently " + "depend on shared credentials or broader node identities when accessing Azure resources." +) +REMEDIATION = "Enable both the AKS OIDC issuer and Microsoft Entra Workload Identity, then federate each workload." +PLAYBOOK = "playbooks/cli/fix_az_aks_004.sh" + +logger = logging.getLogger(__name__) + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + clusters = azure_client.get_managed_clusters() + if clusters is None: + logger.warning("%s: AKS clusters could not be enumerated", RULE_ID) + return findings + for cluster in clusters: + resource_id, resource_name = resource_identity(cluster) + if not resource_id or not resource_name: + continue + oidc_profile = cluster_property(cluster, "oidc_issuer_profile") + security_profile = cluster_property(cluster, "security_profile") + workload_profile = getattr(security_profile, "workload_identity", None) if security_profile else None + oidc_enabled = bool(getattr(oidc_profile, "enabled", False)) + workload_enabled = bool(getattr(workload_profile, "enabled", False)) + if not (oidc_enabled and workload_enabled): + findings.append( + finding( + cluster, + rule_id=RULE_ID, + rule_name=RULE_NAME, + severity=SEVERITY, + category=CATEGORY, + description=DESCRIPTION, + remediation=REMEDIATION, + playbook=PLAYBOOK, + frameworks=FRAMEWORKS, + metadata={"oidc_issuer_enabled": oidc_enabled, "workload_identity_enabled": workload_enabled}, + ) + ) + return findings diff --git a/scanner/rules/az_aks_005.py b/scanner/rules/az_aks_005.py new file mode 100644 index 0000000..55a0cbf --- /dev/null +++ b/scanner/rules/az_aks_005.py @@ -0,0 +1,51 @@ +"""AZ-AKS-005: Azure Policy add-on is disabled for AKS.""" + +import logging +from typing import Any, Dict, List, Mapping + +from scanner.rules._aks_common import cluster_property, finding, resource_identity + +RULE_ID = "AZ-AKS-005" +RULE_NAME = "AKS Azure Policy Add-on Not Enabled" +SEVERITY = "MEDIUM" +CATEGORY = "Kubernetes" +FRAMEWORKS = {"CIS": "N/A-AKS-005", "NIST": "PR.IP-1", "ISO27001": "A.12.1.2", "SOC2": "CC8.1"} +DESCRIPTION = ( + "The Azure Policy add-on is not enabled for the AKS cluster. The organization cannot centrally " + "audit or enforce Kubernetes admission controls through Azure Policy." +) +REMEDIATION = "Enable the Azure Policy add-on and assign an appropriate AKS security initiative in audit mode first." +PLAYBOOK = "playbooks/cli/fix_az_aks_005.sh" + +logger = logging.getLogger(__name__) + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + clusters = azure_client.get_managed_clusters() + if clusters is None: + logger.warning("%s: AKS clusters could not be enumerated", RULE_ID) + return findings + for cluster in clusters: + resource_id, resource_name = resource_identity(cluster) + if not resource_id or not resource_name: + continue + profiles = cluster_property(cluster, "addon_profiles", {}) or {} + azure_policy = profiles.get("azurepolicy") if isinstance(profiles, Mapping) else None + enabled = bool(getattr(azure_policy, "enabled", False)) + if not enabled: + findings.append( + finding( + cluster, + rule_id=RULE_ID, + rule_name=RULE_NAME, + severity=SEVERITY, + category=CATEGORY, + description=DESCRIPTION, + remediation=REMEDIATION, + playbook=PLAYBOOK, + frameworks=FRAMEWORKS, + metadata={"azure_policy_enabled": False}, + ) + ) + return findings diff --git a/scanner/rules/az_aks_006.py b/scanner/rules/az_aks_006.py new file mode 100644 index 0000000..cf7eb10 --- /dev/null +++ b/scanner/rules/az_aks_006.py @@ -0,0 +1,51 @@ +"""AZ-AKS-006: AKS node OS automatic security upgrades are disabled.""" + +import logging +from typing import Any, Dict, List + +from scanner.rules._aks_common import cluster_property, finding, resource_identity + +RULE_ID = "AZ-AKS-006" +RULE_NAME = "AKS Node OS Automatic Upgrades Disabled" +SEVERITY = "HIGH" +CATEGORY = "Kubernetes" +FRAMEWORKS = {"CIS": "N/A-AKS-006", "NIST": "PR.IP-12", "ISO27001": "A.12.6.1", "SOC2": "CC7.1"} +DESCRIPTION = ( + "The AKS node OS upgrade channel does not automatically apply managed security updates. " + "Worker nodes can remain exposed to operating-system vulnerabilities without a separate patch process." +) +REMEDIATION = "Set the AKS node OS upgrade channel to SecurityPatch or NodeImage and configure a maintenance window." +PLAYBOOK = "playbooks/cli/fix_az_aks_006.sh" + +logger = logging.getLogger(__name__) +_COMPLIANT_CHANNELS = {"securitypatch", "nodeimage"} + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + clusters = azure_client.get_managed_clusters() + if clusters is None: + logger.warning("%s: AKS clusters could not be enumerated", RULE_ID) + return findings + for cluster in clusters: + resource_id, resource_name = resource_identity(cluster) + if not resource_id or not resource_name: + continue + profile = cluster_property(cluster, "auto_upgrade_profile") + channel = str(getattr(profile, "node_os_upgrade_channel", "") or "") + if channel.lower() not in _COMPLIANT_CHANNELS: + findings.append( + finding( + cluster, + rule_id=RULE_ID, + rule_name=RULE_NAME, + severity=SEVERITY, + category=CATEGORY, + description=DESCRIPTION, + remediation=REMEDIATION, + playbook=PLAYBOOK, + frameworks=FRAMEWORKS, + metadata={"node_os_upgrade_channel": channel or "None"}, + ) + ) + return findings diff --git a/scanner/rules/az_idn_006.py b/scanner/rules/az_idn_006.py index 99e9d4a..9a8065a 100644 --- a/scanner/rules/az_idn_006.py +++ b/scanner/rules/az_idn_006.py @@ -87,9 +87,8 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: already_expired = end_dt < now except ValueError: logger.debug( - "AZ-IDN-006: Invalid endDateTime for app_id=%s key_id=%s: %r", + "AZ-IDN-006: Invalid endDateTime for app_id=%s: %r", app_id, - key_id, end_dt_str, ) diff --git a/scanner/rules/az_pqc_001.py b/scanner/rules/az_pqc_001.py index 8f968cc..1f3a650 100644 --- a/scanner/rules/az_pqc_001.py +++ b/scanner/rules/az_pqc_001.py @@ -30,6 +30,36 @@ logger = logging.getLogger(__name__) +NCSC_GUIDANCE_URL = "https://www.ncsc.gov.uk/guidance/pqc-migration-timelines" +ENISA_GUIDANCE_URL = ( + "https://www.enisa.europa.eu/publications/post-quantum-cryptography-current-state-and-quantum-mitigation" +) + + +def _quantum_risk_score() -> int: + """Return the quantum risk score for an internet-facing TLS endpoint.""" + return 10 + + +def _quantum_risk() -> Dict[str, Any]: + """Build the CBOM risk record for a vulnerable App Service TLS policy.""" + return { + "quantum_risk_score": _quantum_risk_score(), + "algorithm_type": "RSA/ECDH classical TLS key exchange", + "internet_exposed": True, + "harvest_now_decrypt_later_exposed": True, + "hndl_risk_description": ( + "Internet-facing encrypted traffic can be collected now and decrypted " + "later when a cryptographically relevant quantum computer is available." + ), + "migration_priority": "HIGH", + "recommended_target_algorithm": "ML-KEM (NIST FIPS 203)", + "nist_guidance_url": "https://csrc.nist.gov/pubs/fips/203/final", + "ncsc_guidance_url": NCSC_GUIDANCE_URL, + "enisa_guidance_url": ENISA_GUIDANCE_URL, + "cbom_asset_type": "tls_configuration", + } + def _parse_version(version: Any) -> Optional[Tuple[int, ...]]: """Parse a dotted version string into a tuple of ints for numeric @@ -70,6 +100,7 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: min_tls = getattr(site_config, "min_tls_version", None) if site_config else None if _tls_version_below_13(min_tls): + quantum_risk = _quantum_risk() findings.append( { "rule_id": RULE_ID, @@ -83,9 +114,11 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: "remediation": REMEDIATION, "playbook": PLAYBOOK, "frameworks": FRAMEWORKS, + "quantum_risk": quantum_risk, "metadata": { "resource_group": parsed.get("resource_group", ""), "min_tls_version": str(min_tls), + "quantum_risk": quantum_risk, }, } ) diff --git a/scanner/rules/az_pqc_002.py b/scanner/rules/az_pqc_002.py index 0c85925..00bd175 100644 --- a/scanner/rules/az_pqc_002.py +++ b/scanner/rules/az_pqc_002.py @@ -32,6 +32,37 @@ logger = logging.getLogger(__name__) _CLASSICAL_KEY_TYPES = {"RSA", "EC", "EC-HSM", "RSA-HSM"} +NCSC_GUIDANCE_URL = "https://www.ncsc.gov.uk/guidance/pqc-migration-timelines" +ENISA_GUIDANCE_URL = ( + "https://www.enisa.europa.eu/publications/post-quantum-cryptography-current-state-and-quantum-mitigation" +) + + +def _quantum_risk_score(algorithm_type: str) -> int: + """Score RSA above EC because of its common encryption and HNDL exposure.""" + return 9 if algorithm_type.upper().startswith("RSA") else 8 + + +def _quantum_risk(algorithm_type: str) -> Dict[str, Any]: + """Build the CBOM risk record for a classical asymmetric key.""" + return { + "quantum_risk_score": _quantum_risk_score(algorithm_type), + "algorithm_type": algorithm_type, + "internet_exposed": False, + "harvest_now_decrypt_later_exposed": True, + "hndl_risk_description": ( + "Data protected by this key may already be collected for later decryption. " + "The exposure depends on the key's use, data lifetime, and dependent services." + ), + "migration_priority": "HIGH", + "recommended_target_algorithm": ( + "ML-KEM (NIST FIPS 203) for key encapsulation or ML-DSA (NIST FIPS 204) for signing" + ), + "nist_guidance_url": "https://csrc.nist.gov/pubs/fips/203/final", + "ncsc_guidance_url": NCSC_GUIDANCE_URL, + "enisa_guidance_url": ENISA_GUIDANCE_URL, + "cbom_asset_type": "asymmetric_key", + } def _key_type_value(key: Any) -> str: @@ -64,6 +95,7 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: if key_type.upper() in _CLASSICAL_KEY_TYPES: key_id = getattr(key, "id", "") or f"{vault_id}/keys/{getattr(key, 'name', '')}" key_name = getattr(key, "name", "") or key_id.rstrip("/").split("/")[-1] + quantum_risk = _quantum_risk(key_type) findings.append( { "rule_id": RULE_ID, @@ -77,10 +109,12 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: "remediation": REMEDIATION, "playbook": PLAYBOOK, "frameworks": FRAMEWORKS, + "quantum_risk": quantum_risk, "metadata": { "resource_group": resource_group, "vault_name": vault_name, "key_type": key_type, + "quantum_risk": quantum_risk, }, } ) diff --git a/scanner/rules/az_pqc_003.py b/scanner/rules/az_pqc_003.py index db63300..9a77044 100644 --- a/scanner/rules/az_pqc_003.py +++ b/scanner/rules/az_pqc_003.py @@ -31,6 +31,35 @@ logger = logging.getLogger(__name__) _CLASSICAL_KEY_TYPES = {"RSA", "EC", "EC-HSM", "RSA-HSM"} +NCSC_GUIDANCE_URL = "https://www.ncsc.gov.uk/guidance/pqc-migration-timelines" +ENISA_GUIDANCE_URL = ( + "https://www.enisa.europa.eu/publications/post-quantum-cryptography-current-state-and-quantum-mitigation" +) + + +def _quantum_risk_score(algorithm_type: str) -> int: + """Score RSA certificates above EC certificates for migration ordering.""" + return 8 if algorithm_type.upper().startswith("RSA") else 7 + + +def _quantum_risk(algorithm_type: str) -> Dict[str, Any]: + """Build the CBOM risk record for a classical certificate.""" + return { + "quantum_risk_score": _quantum_risk_score(algorithm_type), + "algorithm_type": algorithm_type, + "internet_exposed": False, + "harvest_now_decrypt_later_exposed": False, + "hndl_risk_description": ( + "Recorded sessions may be exposed when this certificate authenticates " + "classical key exchange, and future quantum attacks could forge signatures." + ), + "migration_priority": "MEDIUM", + "recommended_target_algorithm": ("ML-DSA (NIST FIPS 204) or SLH-DSA (NIST FIPS 205)"), + "nist_guidance_url": "https://csrc.nist.gov/pubs/fips/204/final", + "ncsc_guidance_url": NCSC_GUIDANCE_URL, + "enisa_guidance_url": ENISA_GUIDANCE_URL, + "cbom_asset_type": "certificate", + } def _certificate_key_type(cert: Any) -> str: @@ -65,6 +94,7 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: if key_type.upper() in _CLASSICAL_KEY_TYPES: cert_id = getattr(cert, "id", "") or f"{vault_id}/certificates/{getattr(cert, 'name', '')}" cert_name = getattr(cert, "name", "") or cert_id.rstrip("/").split("/")[-1] + quantum_risk = _quantum_risk(key_type) findings.append( { "rule_id": RULE_ID, @@ -78,10 +108,12 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: "remediation": REMEDIATION, "playbook": PLAYBOOK, "frameworks": FRAMEWORKS, + "quantum_risk": quantum_risk, "metadata": { "resource_group": resource_group, "vault_name": vault_name, "key_type": key_type, + "quantum_risk": quantum_risk, }, } ) diff --git a/scripts/render_deploy.py b/scripts/render_deploy.py index 8c92045..506a5d6 100644 --- a/scripts/render_deploy.py +++ b/scripts/render_deploy.py @@ -12,6 +12,7 @@ import sys import time import urllib.error +import urllib.parse import urllib.request from typing import NoReturn @@ -65,7 +66,14 @@ def _decode_json(body: str, context: str) -> dict: def _open(request: urllib.request.Request) -> str: - with urllib.request.urlopen(request, timeout=30) as response: + parsed = urllib.parse.urlsplit(request.full_url) + if parsed.scheme != "https" or parsed.hostname != "api.render.com" or parsed.port not in (None, 443): + _fail("refusing request to an untrusted Render API endpoint") + with ( + urllib.request.urlopen( # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected # noqa: E501 + request, timeout=30 + ) as response + ): return response.read().decode("utf-8") diff --git a/tests/helpers/mock_azure.py b/tests/helpers/mock_azure.py index 59a9104..508619e 100644 --- a/tests/helpers/mock_azure.py +++ b/tests/helpers/mock_azure.py @@ -81,6 +81,7 @@ def __init__(self) -> None: self._dns_zones: List[Any] = [] self._dns_record_sets: Dict[Tuple[str, str], List[Any]] = {} self._web_apps: List[Any] = [] + self._managed_clusters: Optional[List[Any]] = [] # Some rules read azure_client.subscription_id when constructing an # SDK management client inside scan() (e.g. AZ-NET-007..010). self.subscription_id = "00000000-0000-0000-0000-000000000001" @@ -89,6 +90,14 @@ def set_storage_accounts(self, accounts: List[Any]) -> "MockAzureClient": self._storage_accounts = accounts return self + def set_managed_clusters(self, clusters: Optional[List[Any]]) -> "MockAzureClient": + """Configure AKS inventory; ``None`` represents an API failure.""" + self._managed_clusters = clusters + return self + + def get_managed_clusters(self) -> Optional[List[Any]]: + return self._managed_clusters + def set_network_security_groups(self, nsgs: List[Any]) -> "MockAzureClient": self._network_security_groups = nsgs return self diff --git a/tests/test_ai_error_exposure.py b/tests/test_ai_error_exposure.py new file mode 100644 index 0000000..781dd76 --- /dev/null +++ b/tests/test_ai_error_exposure.py @@ -0,0 +1,79 @@ +"""Tests that /api/ai/{summary,prioritise,ask,threat-simulation} never leak +exception internals to the client (issue #177, CodeQL: information exposure +through an exception). + +get_completion() in api/services/ai_provider.py wraps raw third-party +provider errors into RuntimeError messages (e.g. "Anthropic request failed: +{exc}"), which could carry response bodies or connection details from the +underlying HTTP client. These routes must never reflect that (or any other +exception's) message back to the caller. +""" + +import secrets +from unittest.mock import patch + +from ai.retriever import VectorStoreNotBuilt + +_LEAK_MARKER = "provider-internal-secret-token-abc123" + +_VALID_FINDINGS = [ + { + "rule_id": "AZ-NET-001", + "severity": "HIGH", + "title": "Example finding", + "description": "Example description.", + "remediation": "Example remediation.", + } +] + + +def _fake_api_key() -> str: + return secrets.token_urlsafe(24) + + +def _assert_no_leak(resp, expected_status): + assert resp.status_code == expected_status + body = resp.get_json() + assert _LEAK_MARKER not in str(body) + + +ENDPOINTS = { + "/api/ai/summary": {"provider": "anthropic", "api_key": None, "findings": _VALID_FINDINGS}, + "/api/ai/prioritise": {"provider": "anthropic", "api_key": None, "findings": _VALID_FINDINGS}, + "/api/ai/ask": {"provider": "anthropic", "api_key": None, "question": "What is my risk?"}, + "/api/ai/threat-simulation": {"provider": "anthropic", "api_key": None, "findings": _VALID_FINDINGS}, +} + + +def _payload(endpoint): + body = dict(ENDPOINTS[endpoint]) + body["api_key"] = _fake_api_key() + return body + + +@patch("api.routes.ai._context_for") +def test_vector_store_not_built_does_not_leak_exception(mock_context, client, auth_headers): + mock_context.side_effect = VectorStoreNotBuilt(_LEAK_MARKER) + for endpoint in ENDPOINTS: + resp = client.post(endpoint, json=_payload(endpoint), headers=auth_headers) + _assert_no_leak(resp, 503) + + +@patch("api.routes.ai.get_completion") +@patch("api.routes.ai._context_for") +def test_provider_value_error_does_not_leak_exception(mock_context, mock_gc, client, auth_headers): + mock_context.return_value = ("grounded context", []) + mock_gc.side_effect = ValueError(_LEAK_MARKER) + for endpoint in ENDPOINTS: + resp = client.post(endpoint, json=_payload(endpoint), headers=auth_headers) + _assert_no_leak(resp, 400) + + +@patch("api.routes.ai.get_completion") +@patch("api.routes.ai._context_for") +def test_provider_runtime_error_does_not_leak_exception(mock_context, mock_gc, client, auth_headers): + mock_context.return_value = ("grounded context", []) + mock_gc.side_effect = RuntimeError(f"Anthropic request failed: {_LEAK_MARKER}") + for endpoint in ENDPOINTS: + resp = client.post(endpoint, json=_payload(endpoint), headers=auth_headers) + _assert_no_leak(resp, 502) diff --git a/tests/test_azure_client_aks.py b/tests/test_azure_client_aks.py new file mode 100644 index 0000000..ad476f2 --- /dev/null +++ b/tests/test_azure_client_aks.py @@ -0,0 +1,44 @@ +"""Tests for the AzureClient AKS inventory accessor.""" + +from unittest.mock import MagicMock, patch + +from azure.core.exceptions import HttpResponseError + +from scanner.azure_client import AzureClient + + +@patch("scanner.azure_client.ContainerServiceClient") +def test_get_managed_clusters_returns_and_caches_successful_inventory(client_type): + sdk_client = client_type.return_value + sdk_client.managed_clusters.list.return_value = ["cluster-a", "cluster-b"] + client = AzureClient("sub-1", credential=MagicMock()) + + assert client.get_managed_clusters() == ["cluster-a", "cluster-b"] + assert client.get_managed_clusters() == ["cluster-a", "cluster-b"] + sdk_client.managed_clusters.list.assert_called_once_with() + + +@patch("scanner.azure_client.ContainerServiceClient") +def test_get_managed_clusters_preserves_empty_successful_inventory(client_type): + client_type.return_value.managed_clusters.list.return_value = [] + client = AzureClient("sub-1", credential=MagicMock()) + + assert client.get_managed_clusters() == [] + + +@patch("scanner.azure_client.ContainerServiceClient") +def test_get_managed_clusters_returns_and_caches_none_on_http_failure(client_type): + client_type.return_value.managed_clusters.list.side_effect = HttpResponseError(message="forbidden") + client = AzureClient("sub-1", credential=MagicMock()) + + assert client.get_managed_clusters() is None + assert client.get_managed_clusters() is None + client_type.return_value.managed_clusters.list.assert_called_once_with() + + +@patch("scanner.azure_client.ContainerServiceClient") +def test_get_managed_clusters_returns_none_on_unexpected_failure(client_type): + client_type.return_value.managed_clusters.list.side_effect = RuntimeError("network unavailable") + client = AzureClient("sub-1", credential=MagicMock()) + + assert client.get_managed_clusters() is None diff --git a/tests/test_engine_integration.py b/tests/test_engine_integration.py index ff554ac..aa797a8 100644 --- a/tests/test_engine_integration.py +++ b/tests/test_engine_integration.py @@ -43,11 +43,11 @@ def _nsg_id(name): return f"/subscriptions/{_SUB}/resourceGroups/{_RG}/providers/Microsoft.Network/networkSecurityGroups/{name}" -def test_engine_loads_all_45_rules(monkeypatch): - """The engine must dynamically load all 45 rule modules.""" +def test_engine_loads_all_51_rules(monkeypatch): + """The engine must dynamically load the complete rule set.""" _patch_engine_client(monkeypatch, _offline_mock()) eng = ScanEngine(_SUB) - assert len(eng.rules) >= 45 + assert len(eng.rules) >= 51 # Every loaded rule must expose a callable scan() and a RULE_ID. for rule in eng.rules: assert callable(getattr(rule, "scan", None)) @@ -129,7 +129,7 @@ def _boom(*args, **kwargs): result = eng.run_scan() # must not raise assert result["status"] == "completed" - # The other 44 rules still ran (empty mock -> no findings) and the scan + # The other rules still ran (empty mock -> no findings) and the scan # completed. Crucially, there is NO field in the result naming the failed # rule -- this is the observability gap flagged in the validation report. assert "errored_rules" not in result diff --git a/tests/test_error_exposure.py b/tests/test_error_exposure.py new file mode 100644 index 0000000..2322c78 --- /dev/null +++ b/tests/test_error_exposure.py @@ -0,0 +1,127 @@ +"""Tests that API error responses never leak exception internals (issue #177, +CodeQL: information exposure through an exception). + +Each route's _get_db() (or equivalent) is patched to raise, mirroring the +established per-module DB-mocking convention (see test_findings_playbook.py). +A secret-shaped marker string is used as the exception message so a leak +would be unambiguous rather than coincidentally matching real wording. +""" + +from unittest.mock import MagicMock, patch + +import api.routes.compliance as compliance_route +import api.routes.drift as drift_route +import api.routes.findings as findings_route +import api.routes.prioritization as prioritization_route +import api.routes.resources as resources_route +import api.routes.scans as scans_route +import api.routes.score as score_route + +_LEAK_MARKER = "connection to db-internal-host-secret-1234 refused" + + +def _raising_db(method_name): + db = MagicMock() + getattr(db, method_name).side_effect = RuntimeError(_LEAK_MARKER) + return db + + +def _assert_no_leak(resp, expected_status): + assert resp.status_code == expected_status + body = resp.get_json() + assert "detail" not in body + assert _LEAK_MARKER not in str(body) + + +def test_list_scans_error_does_not_leak_exception(client, auth_headers): + with patch.object(scans_route, "_get_db", return_value=_raising_db("get_scans")): + resp = client.get("/api/scans", headers=auth_headers) + _assert_no_leak(resp, 500) + + +def test_get_scan_status_error_does_not_leak_exception(client, auth_headers): + with patch.object(scans_route, "_get_db", return_value=_raising_db("get_scan")): + resp = client.get("/api/scans/some-id", headers=auth_headers) + _assert_no_leak(resp, 500) + + +def test_trigger_scan_db_error_does_not_leak_exception(client, auth_headers): + with patch.object(scans_route, "_get_db", return_value=_raising_db("create_pending_scan")): + resp = client.post( + "/api/scans/trigger", + json={"subscription_id": "00000000-0000-0000-0000-000000000001"}, + headers=auth_headers, + ) + _assert_no_leak(resp, 500) + + +def test_list_findings_error_does_not_leak_exception(client, auth_headers): + with patch.object(findings_route, "_get_db", return_value=_raising_db("get_findings")): + resp = client.get("/api/findings", headers=auth_headers) + _assert_no_leak(resp, 500) + + +def test_get_finding_error_does_not_leak_exception(client, auth_headers): + with patch.object(findings_route, "_get_db", return_value=_raising_db("get_finding_by_id")): + resp = client.get("/api/findings/1", headers=auth_headers) + _assert_no_leak(resp, 500) + + +def test_get_playbook_error_does_not_leak_exception(client, auth_headers): + with patch.object(findings_route, "_get_db", return_value=_raising_db("get_finding_by_id")): + resp = client.get("/api/findings/1/playbook", headers=auth_headers) + _assert_no_leak(resp, 500) + + +def test_score_error_does_not_leak_exception(client, auth_headers): + with patch.object(score_route, "_get_db", return_value=_raising_db("get_score")): + resp = client.get("/api/score", headers=auth_headers) + _assert_no_leak(resp, 500) + + +def test_cve_summary_error_does_not_leak_exception(client, auth_headers): + with patch.object(score_route, "_get_db", return_value=_raising_db("get_cve_summary")): + resp = client.get("/api/score/cve-summary", headers=auth_headers) + _assert_no_leak(resp, 500) + + +def test_resources_error_does_not_leak_exception(client, auth_headers): + with patch.object(resources_route, "_get_db", return_value=_raising_db("get_findings")): + resp = client.get("/api/resources", headers=auth_headers) + _assert_no_leak(resp, 500) + + +def test_prioritization_error_does_not_leak_exception(client, auth_headers): + with patch.object(prioritization_route, "_get_db", return_value=_raising_db("get_findings")): + resp = client.get("/api/prioritization", headers=auth_headers) + _assert_no_leak(resp, 500) + + +def test_drift_error_does_not_leak_exception(client, auth_headers): + with patch.object(drift_route, "_get_db", return_value=_raising_db("get_findings")): + resp = client.get("/api/drift", headers=auth_headers) + _assert_no_leak(resp, 500) + + +def test_compliance_generic_error_does_not_leak_exception(client, auth_headers): + with patch.object(compliance_route, "_get_db", return_value=_raising_db("get_compliance_score")): + resp = client.get("/api/compliance/cis", headers=auth_headers) + _assert_no_leak(resp, 500) + + +def test_compliance_missing_frameworks_dir_does_not_leak_exception(client, auth_headers): + db = MagicMock() + db.get_compliance_score.side_effect = FileNotFoundError(_LEAK_MARKER) + with patch.object(compliance_route, "_get_db", return_value=db): + resp = client.get("/api/compliance/cis", headers=auth_headers) + _assert_no_leak(resp, 500) + + +def test_bad_request_errorhandler_does_not_leak_exception(client, auth_headers): + resp = client.post( + "/api/scans/trigger", + data=b"{not valid json", + headers={**auth_headers, "Content-Type": "application/json"}, + ) + body = resp.get_json() + assert "detail" not in body diff --git a/tests/test_render_deploy.py b/tests/test_render_deploy.py index a0746d2..2c1312e 100644 --- a/tests/test_render_deploy.py +++ b/tests/test_render_deploy.py @@ -25,6 +25,12 @@ def http_error(code): return urllib.error.HTTPError("https://api.render.com", code, "error", {}, io.BytesIO(b"detail")) +def test_open_rejects_untrusted_endpoint(): + request = render_deploy.urllib.request.Request("file:///etc/passwd") + with pytest.raises(SystemExit): + render_deploy._open(request) + + def sequence(monkeypatch, values): calls = [] iterator = iter(values) diff --git a/tests/test_rules_aks.py b/tests/test_rules_aks.py new file mode 100644 index 0000000..50a32f9 --- /dev/null +++ b/tests/test_rules_aks.py @@ -0,0 +1,151 @@ +"""Unit tests for the AZ-AKS-001 through AZ-AKS-006 rule pack.""" + +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from scanner.rules import az_aks_001, az_aks_002, az_aks_003, az_aks_004, az_aks_005, az_aks_006 + + +def ns(**kwargs): + return SimpleNamespace(**kwargs) + + +def cluster(**overrides): + values = { + "id": "/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.ContainerService/managedClusters/aks-1", + "name": "aks-1", + "api_server_access_profile": ns(enable_private_cluster=True), + "disable_local_accounts": True, + "identity": ns(type="SystemAssigned"), + "oidc_issuer_profile": ns(enabled=True), + "security_profile": ns(workload_identity=ns(enabled=True)), + "addon_profiles": {"azurepolicy": ns(enabled=True)}, + "auto_upgrade_profile": ns(node_os_upgrade_channel="SecurityPatch"), + } + values.update(overrides) + return ns(**values) + + +RULES = [az_aks_001, az_aks_002, az_aks_003, az_aks_004, az_aks_005, az_aks_006] + + +@pytest.mark.parametrize("rule", RULES) +def test_compliant_cluster_returns_no_findings(rule): + client = MagicMock() + client.get_managed_clusters.return_value = [cluster()] + + assert rule.scan(client, "sub-1") == [] + + +@pytest.mark.parametrize( + ("rule", "override", "metadata_key"), + [ + (az_aks_001, {"api_server_access_profile": ns(enable_private_cluster=False)}, "private_cluster_enabled"), + (az_aks_002, {"disable_local_accounts": False}, "local_accounts_disabled"), + (az_aks_003, {"identity": None}, "identity_type"), + ( + az_aks_004, + {"oidc_issuer_profile": ns(enabled=False)}, + "oidc_issuer_enabled", + ), + (az_aks_005, {"addon_profiles": {}}, "azure_policy_enabled"), + (az_aks_006, {"auto_upgrade_profile": ns(node_os_upgrade_channel="None")}, "node_os_upgrade_channel"), + ], +) +def test_non_compliant_cluster_returns_standard_finding(rule, override, metadata_key): + client = MagicMock() + client.get_managed_clusters.return_value = [cluster(**override)] + + findings = rule.scan(client, "sub-1") + + assert len(findings) == 1 + result = findings[0] + assert result["rule_id"] == rule.RULE_ID + assert result["resource_name"] == "aks-1" + assert result["resource_type"] == "Microsoft.ContainerService/managedClusters" + assert result["playbook"] == rule.PLAYBOOK + assert metadata_key in result["metadata"] + + +@pytest.mark.parametrize("rule", RULES) +def test_inventory_failure_never_creates_false_finding(rule): + client = MagicMock() + client.get_managed_clusters.return_value = None + + assert rule.scan(client, "sub-1") == [] + + +@pytest.mark.parametrize("rule", RULES) +def test_invalid_resource_identity_is_skipped(rule): + client = MagicMock() + client.get_managed_clusters.return_value = [cluster(id="")] + + assert rule.scan(client, "sub-1") == [] + + +def test_unknown_private_cluster_state_is_skipped(): + client = MagicMock() + client.get_managed_clusters.return_value = [cluster(api_server_access_profile=None)] + + assert az_aks_001.scan(client, "sub-1") == [] + + +def test_unknown_local_account_state_is_skipped(): + client = MagicMock() + client.get_managed_clusters.return_value = [cluster(disable_local_accounts=None)] + + assert az_aks_002.scan(client, "sub-1") == [] + + +def test_current_sdk_nested_properties_are_supported(): + current_model_cluster = ns( + id="/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.ContainerService/managedClusters/aks-2", + name="aks-2", + identity=ns(type="SystemAssigned"), + properties=ns( + api_server_access_profile=ns(enable_private_cluster=False), + disable_local_accounts=False, + oidc_issuer_profile=ns(enabled=False), + security_profile=ns(workload_identity=ns(enabled=False)), + addon_profiles={}, + auto_upgrade_profile=ns(node_os_upgrade_channel="None"), + ), + ) + client = MagicMock() + client.get_managed_clusters.return_value = [current_model_cluster] + + assert [len(rule.scan(client, "sub-1")) for rule in RULES] == [1, 1, 0, 1, 1, 1] + + +def test_multiple_clusters_are_evaluated_independently(): + client = MagicMock() + client.get_managed_clusters.return_value = [ + cluster(), + cluster( + id="/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.ContainerService/managedClusters/aks-2", + name="aks-2", + disable_local_accounts=False, + ), + ] + + findings = az_aks_002.scan(client, "sub-1") + + assert [item["resource_name"] for item in findings] == ["aks-2"] + + +@pytest.mark.parametrize("identity_type", ["SystemAssigned", "UserAssigned", "SystemAssigned, UserAssigned"]) +def test_supported_managed_identity_types_are_compliant(identity_type): + client = MagicMock() + client.get_managed_clusters.return_value = [cluster(identity=ns(type=identity_type))] + + assert az_aks_003.scan(client, "sub-1") == [] + + +@pytest.mark.parametrize("channel", ["SecurityPatch", "NodeImage", "securitypatch", "nodeimage"]) +def test_managed_node_os_channels_are_compliant(channel): + client = MagicMock() + client.get_managed_clusters.return_value = [cluster(auto_upgrade_profile=ns(node_os_upgrade_channel=channel))] + + assert az_aks_006.scan(client, "sub-1") == [] diff --git a/tests/test_rules_identity.py b/tests/test_rules_identity.py index 10321ae..18be4ef 100644 --- a/tests/test_rules_identity.py +++ b/tests/test_rules_identity.py @@ -288,6 +288,38 @@ def test_idn_006_noncompliant_secret_no_expiry_returns_finding(mock_azure, subsc assert findings[0]["metadata"]["no_expiry"] is True +def test_idn_006_malformed_end_date_time_does_not_log_key_id(mock_azure, subscription_id, monkeypatch, caplog): + """CodeQL: clear-text logging of sensitive information. keyId is a Graph + API credential-slot identifier (not the secret itself), but the debug log + for a malformed endDateTime must not include it regardless — the value + isn't needed to diagnose a date-parsing failure.""" + sentinel_key_id = "sentinel-key-id-should-not-appear-in-logs" + apps = { + "value": [ + { + "id": "app1", + "displayName": "App One", + "appId": "client-1", + "passwordCredentials": [ + { + "keyId": sentinel_key_id, + "hint": "ab", + "startDateTime": "2020-01-01T00:00:00Z", + "endDateTime": "not-a-valid-date", + } + ], + } + ] + } + _install_router(monkeypatch, [("/applications", _Resp(apps))]) + with caplog.at_level("DEBUG", logger="scanner.rules.az_idn_006"): + findings = az_idn_006.scan(mock_azure, subscription_id) + + # Malformed endDateTime alone doesn't matter here: the secret is already stale by age. + assert len(findings) == 1 + assert sentinel_key_id not in caplog.text + + # ── AZ-IDN-007: active user with no MFA registered ────────────────────────── diff --git a/website/content.js b/website/content.js index 920437c..e0d989b 100644 --- a/website/content.js +++ b/website/content.js @@ -523,3 +523,7 @@ const siteContent = { } ] }; + +if (typeof module !== 'undefined' && module.exports) { + module.exports = siteContent; +} diff --git a/website/generate_rss.js b/website/generate_rss.js index e411638..cd1d14d 100644 --- a/website/generate_rss.js +++ b/website/generate_rss.js @@ -1,4 +1,3 @@ -const fs = require('fs'); const path = require('path'); /** @@ -8,29 +7,7 @@ const path = require('path'); function generateRSS() { try { - const contentPath = path.join(__dirname, 'content.js'); - const contentFile = fs.readFileSync(contentPath, 'utf8'); - - // Extract the JSON object from the siteContent constant - const startIndex = contentFile.indexOf('const siteContent = {') + 'const siteContent = '.length; - const endIndex = contentFile.lastIndexOf('};') + 1; - - if (startIndex === -1 || endIndex === 0) { - console.error("Could not find siteContent in content.js"); - return; - } - - const jsonString = contentFile.substring(startIndex, endIndex); - - // We use eval here safely because we are in a trusted build environment - let siteContent; - try { - siteContent = eval('(' + jsonString + ')'); - } catch (e) { - console.error("Eval error:", e.message); - // Fallback: try to find the last }; more precisely if needed - return; - } + const siteContent = require(path.join(__dirname, 'content.js')); let rssItems = ''; diff --git a/website/index.html b/website/index.html index 61445c1..552a590 100644 --- a/website/index.html +++ b/website/index.html @@ -25,13 +25,13 @@ } - + - + - + - + ', + 'https://player.vimeo.com/video/1%22%3E%3Cscript%3Ealert(1)%3C/script%3E', + 'script-injection payload on allowed vimeo host is percent-encoded', + ], +]; + +let failures = 0; + +function assertNoDoubleQuote(value, description) { + assert.ok( + !String(value).includes('"'), + `${description}: return value must not contain a raw double-quote (iframe src breakout): ${JSON.stringify(value)}`, + ); +} + +for (const [input, expected, description] of cases) { + const actual = toEmbedUrl(input); + try { + assert.equal(actual, expected); + console.log(`PASS: ${description}`); + } catch { + failures++; + console.error(`FAIL: ${description} — input=${JSON.stringify(input)} got=${JSON.stringify(actual)} want=${JSON.stringify(expected)}`); + } +} + +for (const input of rejected) { + const actual = toEmbedUrl(input); + try { + assert.equal(actual, ''); + console.log(`PASS: rejects bypass attempt (${JSON.stringify(input.slice(0, 40))}...)`); + } catch { + failures++; + console.error(`FAIL: bypass NOT rejected — input=${JSON.stringify(input)} got=${JSON.stringify(actual)}`); + } +} + +for (const [input, expected, description] of sanitizedPassthrough) { + const actual = toEmbedUrl(input); + try { + assert.equal(actual, expected); + assertNoDoubleQuote(actual, description); + console.log(`PASS: ${description}`); + } catch (err) { + failures++; + console.error(`FAIL: ${description} — input=${JSON.stringify(input)} got=${JSON.stringify(actual)}\n ${err.message}`); + } +} + +if (failures > 0) { + console.error(`\n${failures} test(s) failed`); + process.exit(1); +} +console.log('\nAll toEmbedUrl tests passed');