diff --git a/.github/SECURITY.md b/.github/SECURITY.md index e8e98d2..315eebd 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -26,9 +26,10 @@ The more detail you provide, the faster we can respond. | Version | Supported | |---------|-----------| -| 0.1.x | Yes | +| 0.3.x | Yes | +| 0.1.x | No | -Older versions are not patched. If you are running a version below 0.1.x, upgrade to the latest release before filing a report. +Older versions are not patched unless a GitHub Security Advisory explicitly says otherwise. Upgrade to the latest release before filing a report. --- @@ -71,7 +72,7 @@ We ask that you do not publicly disclose the vulnerability until step 6 is compl We value responsible disclosure. Researchers who report valid vulnerabilities will be: - Acknowledged by name (or pseudonym if preferred) in the release notes for the fix -- Listed in a `SECURITY_ACKNOWLEDGEMENTS.md` file we maintain in this repository +- Listed in [`SECURITY_ACKNOWLEDGEMENTS.md`](../SECURITY_ACKNOWLEDGEMENTS.md) We do not currently offer a bug bounty programme, but we are grateful for every report. @@ -79,4 +80,4 @@ We do not currently offer a bug bounty programme, but we are grateful for every ## Contact -**Email: vishnu.ajith@owasp.org** \ No newline at end of file +**Email: vishnu.ajith@owasp.org** diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ddbb30..8d47b1c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -397,6 +397,54 @@ jobs: - name: Run bandit run: bandit -r api/ scanner/ ai/ -ll + # ── SAST (Semgrep) ────────────────────────────────────────────────────────── + sast-semgrep: + name: SAST (Semgrep) + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Set up Python 3.11 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.11" + cache: pip + + - name: Install semgrep + run: pip install semgrep + + - name: Run semgrep + id: semgrep + continue-on-error: true + run: | + semgrep scan \ + --config p/security-audit \ + --config p/owasp-top-ten \ + --config p/python \ + --config p/javascript \ + --exclude venv \ + --exclude frontend/node_modules \ + --exclude frontend/dist \ + --metrics=off \ + --sarif --output semgrep.sarif \ + --error \ + . + + - name: Upload SARIF to GitHub code scanning + if: always() + uses: github/codeql-action/upload-sarif@411c4c9a36b3fca4d674f06b6396b2c6d23522c6 # v3.36.3 + with: + sarif_file: semgrep.sarif + category: semgrep + + - name: Fail job if Semgrep found issues + if: steps.semgrep.outcome == 'failure' + run: exit 1 + # ── SCA / dependency scanning (pip-audit) ───────────────────────────────── sca-pip-audit: name: SCA (pip-audit) @@ -531,7 +579,7 @@ jobs: env: DATABASE_URL: "postgresql://ci:ci@localhost:5432/ci_db" run: | - pytest tests/ -v --tb=short --cov=api --cov=scanner --cov-report=term --cov-report=xml --cov-fail-under=25 + pytest tests/ -v --tb=short --cov=api --cov=scanner --cov-report=term --cov-report=xml --cov-fail-under=80 # ── Frontend lint + build ──────────────────────────────────────────────── frontend: @@ -602,7 +650,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, website, enforce-source-branch] + [lint, rule-validation, secret-scan, sast-bandit, sast-semgrep, sca-pip-audit, sbom, container-scan, backend-tests, frontend, website, enforce-source-branch] if: always() steps: - name: Build summary @@ -611,6 +659,7 @@ jobs: RULE_VALIDATION: ${{ needs.rule-validation.result }} SECRET_SCAN: ${{ needs.secret-scan.result }} SAST_BANDIT: ${{ needs.sast-bandit.result }} + SAST_SEMGREP: ${{ needs.sast-semgrep.result }} SCA_PIP_AUDIT: ${{ needs.sca-pip-audit.result }} SBOM: ${{ needs.sbom.result }} CONTAINER_SCAN: ${{ needs.container-scan.result }} @@ -627,6 +676,7 @@ jobs: ("Rule & Compliance Validation", os.environ["RULE_VALIDATION"]), ("Secret Scan (Gitleaks)", os.environ["SECRET_SCAN"]), ("SAST (Bandit)", os.environ["SAST_BANDIT"]), + ("SAST (Semgrep)", os.environ["SAST_SEMGREP"]), ("SCA (pip-audit)", os.environ["SCA_PIP_AUDIT"]), ("SBOM (Syft)", os.environ["SBOM"]), ("Container Scan (Trivy, INFRA 1 pending)", os.environ["CONTAINER_SCAN"]), @@ -679,6 +729,7 @@ jobs: needs.rule-validation.result != 'success' || needs.secret-scan.result != 'success' || needs.sast-bandit.result != 'success' || + needs.sast-semgrep.result != 'success' || needs.sca-pip-audit.result != 'success' || needs.sbom.result != 'success' || needs.backend-tests.result != 'success' || diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bcd884..408c9da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ OpenShield uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added +- Semgrep SAST integrated into GitHub Actions CI as an open-source, account-free complement to CodeQL +- OpenSSF Best Practices Passing Badge achieved with 100% of applicable Passing-level criteria completed +- Official live OpenSSF badge and verified project record added to project documentation - 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c26b8e8..7c234f4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -203,6 +203,9 @@ Use the existing wrapper methods in `scanner/azure_client.py` rather than constr | `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_applications()` | Paginated App Registration dictionaries, or `None` on Graph failure | +| `azure_client.get_managed_identity_service_principals()` | Managed Identity service principals, or `None` on Graph failure | +| `azure_client.get_subscription_role_assignments()` | Subscription RBAC assignments, 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 | @@ -218,7 +221,7 @@ pip install -r requirements.txt # Installs Flask, Alembic, Azure SDK clients, requests, psycopg2, PyJWT, and PyYAML for CI workflow validation. # Frontend -# The frontend directory is currently a scaffold. The React dashboard MVP is on the roadmap. +# The React dashboard lives in frontend/ and uses the repository's npm scripts. # Database (Docker) docker run --name openshield-db \ @@ -268,6 +271,12 @@ All contributions must meet these standards before a pull request will be review - Follow Conventional Commits using prefixes such as `feat:`, `fix:`, `docs:`, `chore:`, and `test:`. - Reference the related issue number where applicable. +**Developer Certificate of Origin** + +- By adding `Signed-off-by: Your Name ` to a commit, a contributor certifies the [Developer Certificate of Origin 1.1](https://developercertificate.org/). +- Use `git commit -s` to add the sign-off. +- The project lead must approve and enable DCO enforcement before this becomes a required merge check; until then, sign-off is requested but not represented as enforced. + **Branch naming** - Use `feat/description` for new features. @@ -285,6 +294,7 @@ All contributions must meet these standards before a pull request will be review - Add a CLI remediation playbook for each new scanner rule. - Follow `.github/PULL_REQUEST_TEMPLATE.md`. - Obtain at least one reviewer approval before merge. +- Add regression tests for bug fixes whenever the behavior can be reproduced automatically. Major functionality must include automated tests. ## OpenSSF Best Practices diff --git a/Dockerfile b/Dockerfile index a88354d..9dceebf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,6 +11,12 @@ RUN pip install --no-cache-dir --upgrade \ COPY . . +RUN groupadd --system openshield && \ + useradd --system --gid openshield --no-create-home openshield && \ + chown -R openshield:openshield /app + +USER openshield + EXPOSE 8000 CMD ["gunicorn", "--workers", "2", "--threads", "2", "--timeout", "120", "--bind", "0.0.0.0:8000", "api.app:app"] diff --git a/GOVERNANCE.md b/GOVERNANCE.md new file mode 100644 index 0000000..f27446d --- /dev/null +++ b/GOVERNANCE.md @@ -0,0 +1,62 @@ +# OpenShield Governance + +OpenShield uses a maintainer-led, consensus-seeking governance model. Technical +discussion happens in public GitHub issues and pull requests. The maintainers +seek agreement through review; when consensus cannot be reached, the project +lead makes the final decision and records the reasoning publicly. + +## Roles + +- **Project lead:** sets project direction, appoints maintainers, manages + releases and resolves decisions that cannot reach consensus. +- **Maintainers:** triage issues, review changes, uphold security and quality + requirements, and merge approved pull requests in their assigned areas. +- **Security maintainers:** privately triage vulnerability reports, coordinate + fixes and advisories, and ensure reporter credit is handled according to the + security policy. +- **Contributors:** propose issues and changes, participate in review, add tests + and documentation, and follow the Code of Conduct and contribution policy. + +Current role holders and component responsibilities are listed in +[`MAINTAINERS.md`](MAINTAINERS.md). Repository paths also have review owners in +`.github/CODEOWNERS`. + +## Decision and change process + +1. Material changes begin with a GitHub issue describing the problem, scope and + acceptance criteria. +2. Implementation is submitted by pull request to `dev` and must pass required + automated checks. +3. At least one qualified reviewer must approve before merge. Authors do not + approve their own changes. +4. Releases are promoted from `dev` to `main` and published by an authorized + maintainer. +5. Security-sensitive decisions may be discussed privately until coordinated + disclosure, after which the advisory and fix are made public. + +## Appointing and removing maintainers + +Regular contributors may be nominated as maintainers based on sustained, +constructive work and demonstrated knowledge of the relevant component. The +project lead confirms appointments after consulting existing maintainers. +Maintainers may step down at any time. Access may be removed for inactivity, +security risk, repeated policy violations, or Code of Conduct violations. + +## Continuity + +The project intends to maintain at least two people capable of issue triage, +pull-request review, merging and release operations. Administrative access, +deployment access, domains and recovery methods must be held through +organization-controlled accounts or recoverable records rather than a single +person's undocumented credentials. + +The project lead reviews continuity access before each release. The names of +people who currently hold each capability are intentionally confirmed through +private organization records; `MAINTAINERS.md` lists the public accountable +roles without publishing secret locations or recovery details. + +## Governance changes + +Governance changes use the normal issue and pull-request process and require +approval from the project lead and one additional maintainer. Emergency +security changes may be merged first and documented immediately afterward. diff --git a/MAINTAINERS.md b/MAINTAINERS.md new file mode 100644 index 0000000..40f3eed --- /dev/null +++ b/MAINTAINERS.md @@ -0,0 +1,25 @@ +# OpenShield Maintainers + +This file identifies public project roles and responsibilities. It does not +assert organization permissions that have not been confirmed by the project +lead. + +| Role | Account | Responsibilities | +|---|---|---| +| Project lead and release owner | [@Vishnu2707](https://github.com/Vishnu2707) | Direction, final governance decisions, releases and organization administration | +| Scanner and playbook maintainer | [@TFT444](https://github.com/TFT444) | Azure scanner rules, remediation playbooks, tests and review | +| Scanner and compliance maintainer | [@SHAURYAKSHARMA24](https://github.com/SHAURYAKSHARMA24) | Scanner rules, playbooks and compliance mappings | +| API and AI maintainer | [@ritiksah141](https://github.com/ritiksah141) | API, AI layer, CI/infra review and backend tests | +| Frontend maintainer | [@vogonPrayas](https://github.com/vogonPrayas) | Dashboard implementation and frontend review | +| Test maintainer | [@parthrohit22](https://github.com/parthrohit22) | Test suite and documentation review | + +Path-specific review assignments are maintained in `.github/CODEOWNERS`. +Private security reports are coordinated using `.github/SECURITY.md`. + +## Owner confirmation required + +Before OpenShield claims the OpenSSF continuity criteria, the project lead must +confirm that at least two people can perform issue administration, merge +approved changes and publish a release if any one maintainer becomes +unavailable. Public role assignment alone is not proof of administrative +access. diff --git a/README.md b/README.md index 4e99c1a..0d9c900 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # OpenShield +[![OpenSSF Best Practices](https://www.bestpractices.dev/projects/13618/badge)](https://www.bestpractices.dev/projects/13618) [![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) @@ -47,27 +48,56 @@ Findings map to NIST FIPS 203 (ML-KEM), FIPS 204 (ML-DSA), and FIPS 205 (SLH-DSA | Feature | Description | |---|---| -| **Misconfiguration Scanner** | Runs 39 Azure security rules across storage, network, identity, database, compute, Key Vault, and post-quantum cryptography | +| **Misconfiguration Scanner** | Runs 51 Azure security rules across storage, network, identity, database, compute, Key Vault, AKS, and post-quantum cryptography | | **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) | +| **Remediation Playbooks** | Every rule ships with a matching Azure CLI remediation script (51 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 | | **Sentinel Integration** | Normalises findings and pushes them into Microsoft Sentinel via a Log Analytics custom table and KQL analytics rules | --- +## Security Assurance + +OpenShield has achieved the **OpenSSF Best Practices Passing Badge**, completing 100% of the applicable Passing-level criteria across project governance, change control, reporting, quality, security, and code analysis. + +

+ + OpenSSF Best Practices Passing Badge + +

+ +

+ OpenSSF Best Practices - Passing +

+ +The project's OpenSSF status is publicly verifiable through the official OpenSSF Best Practices project record. OpenShield continues to strengthen its engineering, security assurance, and open source governance practices as it progresses through the higher-level criteria. + +**[View OpenShield's verified OpenSSF Best Practices record](https://www.bestpractices.dev/projects/13618)** + +Project policies and assurance evidence: + +- [Governance](GOVERNANCE.md) and [maintainer responsibilities](MAINTAINERS.md) +- [July 2026–June 2027 roadmap](ROADMAP.md) +- [Support and upgrade policy](SUPPORT.md) +- [Security requirements](docs/security-requirements.md) and [security assurance case](docs/security-assurance-case.md) +- [Release security](docs/release-security.md) and [accessibility/i18n policy](docs/accessibility-and-i18n.md) +- [OpenSSF Silver evidence register](docs/openssf-silver-evidence.md) + +--- + ## Architecture ```mermaid flowchart TD A["React Dashboard\nVercel · Live"] B["Flask REST API\nJWT · CORS · Blueprints"] - C["Scanner Engine\n39 Python rules"] + C["Scanner Engine\n51 Python rules"] D["Azure Subscription\nScanned via Azure SDK + Graph"] E["Compliance Framework JSON\nCIS · NIST · ISO 27001 · SOC 2"] F["PostgreSQL Database\nFindings · Scans"] - G["Azure CLI Playbooks\n39 remediation scripts"] + G["Azure CLI Playbooks\n51 remediation scripts"] H["sentinel/ingest.py\nNormalise + HMAC upload"] I["Microsoft Sentinel\nOpenShieldFindings_CL · KQL rules"] diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..3a6ce1b --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,43 @@ +# OpenShield Roadmap: July 2026 to June 2027 + +This roadmap communicates direction rather than a delivery guarantee. Security +fixes and community needs may change priorities. Work is accepted only through +reviewed issues and pull requests. + +## July–September 2026 + +- Complete OpenSSF Best Practices Silver readiness and evidence. +- Expand enterprise Azure coverage while preserving low false-positive rates. +- Improve scanner inventory error handling and rule test coverage. +- Reconcile documentation, website rule counts and current APIs. + +## October–December 2026 + +- Stabilize the scanner and REST API contracts for a supported 1.0 release. +- Improve deployment, upgrade and database migration guidance. +- Expand Sentinel detections and operational security monitoring. +- Establish signed release artifacts and public verification instructions. + +## January–March 2027 + +- Add organization-scale scanning and clearer multi-subscription workflows. +- Improve accessibility and internationalization readiness in user interfaces. +- Expand evidence-based compliance reporting and exception handling. +- Evaluate a provider interface for future clouds without weakening Azure + support. + +## April–June 2027 + +- Review reliability, performance and recovery objectives using production + feedback. +- Mature compatibility, deprecation and long-term maintenance policies. +- Reassess readiness for OpenSSF Gold without claiming it prematurely. + +## Explicitly out of scope for this period + +- Automatic remediation without explicit operator confirmation. +- Claims of formal certification for CIS, ISO 27001, SOC 2 or NIST. +- Collection of Azure resource contents, secrets or customer workload data. +- Guaranteed detection of every cloud vulnerability or configuration risk. +- Multi-cloud parity until the Azure implementation and provider boundary are + stable. diff --git a/SECURITY_ACKNOWLEDGEMENTS.md b/SECURITY_ACKNOWLEDGEMENTS.md new file mode 100644 index 0000000..97c2bae --- /dev/null +++ b/SECURITY_ACKNOWLEDGEMENTS.md @@ -0,0 +1,9 @@ +# Security Acknowledgements + +OpenShield credits reporters of resolved vulnerabilities unless they request +anonymity. Credits are added here and to the relevant security advisory or +release notes after coordinated disclosure. + +No externally reported vulnerability has been publicly disclosed by the project +as of 16 July 2026. This statement should be updated whenever a coordinated +disclosure is completed. diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000..8a716b8 --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,30 @@ +# Support and Version Policy + +## Supported releases + +OpenShield supports the latest published minor release. Security fixes may also +be issued for the immediately preceding minor release when an upgrade cannot be +completed promptly. Older releases are unsupported unless a GitHub Security +Advisory explicitly states otherwise. + +The authoritative release list is the project's GitHub Releases page. Users +should include the release tag or commit SHA when reporting a defect. + +## Upgrading + +1. Read `CHANGELOG.md` and the target GitHub Release notes. +2. Back up the database and deployment configuration. +3. Install the target source and its pinned dependency manifests. +4. Run `alembic upgrade head` before starting the API. +5. Validate `/health`, authentication, scanning and dashboard connectivity in a + non-production environment before promotion. + +Database-specific migration instructions are in +`docs/database-migrations.md`. Breaking changes and required operator actions +must be identified in release notes. + +## Security support + +Do not report vulnerabilities through a public issue. Follow +`.github/SECURITY.md` for the private reporting and coordinated disclosure +process. diff --git a/compliance/frameworks/cis_azure_benchmark.json b/compliance/frameworks/cis_azure_benchmark.json index 7620192..78112c3 100644 --- a/compliance/frameworks/cis_azure_benchmark.json +++ b/compliance/frameworks/cis_azure_benchmark.json @@ -257,6 +257,36 @@ "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." + }, + "AZ-IDN-010": { + "control_id": "TBD-IDN-010", + "control_name": "App Registration ownership (not mapped in CIS Azure Foundations 2.0.0)", + "description": "Microsoft recommends accountable App Registration ownership. This check has no direct control in the repository's CIS Azure Foundations 2.0.0 benchmark." + }, + "AZ-IDN-011": { + "control_id": "TBD-IDN-011", + "control_name": "App Registration redirect URI security (not mapped in CIS Azure Foundations 2.0.0)", + "description": "Microsoft requires secure redirect URI handling. This check has no direct control in the repository's CIS Azure Foundations 2.0.0 benchmark." + }, + "AZ-IDN-012": { + "control_id": "TBD-IDN-012", + "control_name": "OAuth implicit grant security (not mapped in CIS Azure Foundations 2.0.0)", + "description": "Microsoft recommends authorization code flow instead of implicit grant. This check has no direct control in the repository's CIS Azure Foundations 2.0.0 benchmark." + }, + "AZ-IDN-013": { + "control_id": "TBD-IDN-013", + "control_name": "App Registration password credentials (not mapped in CIS Azure Foundations 2.0.0)", + "description": "Microsoft recommends managed identity, federation, or certificates instead of client secrets. This check has no direct control in the repository's CIS Azure Foundations 2.0.0 benchmark." + }, + "AZ-IDN-014": { + "control_id": "TBD-IDN-014", + "control_name": "Application-instance property lock (not mapped in CIS Azure Foundations 2.0.0)", + "description": "Microsoft recommends locking sensitive service-principal instance properties. This check has no direct control in the repository's CIS Azure Foundations 2.0.0 benchmark." + }, + "AZ-IDN-015": { + "control_id": "TBD-IDN-015", + "control_name": "Managed Identity least privilege (not mapped in CIS Azure Foundations 2.0.0)", + "description": "Microsoft recommends least-privilege roles and scopes for managed identities. This check has no direct control in the repository's CIS Azure Foundations 2.0.0 benchmark." } } } diff --git a/compliance/frameworks/iso27001.json b/compliance/frameworks/iso27001.json index 87a30fd..c4b88d8 100644 --- a/compliance/frameworks/iso27001.json +++ b/compliance/frameworks/iso27001.json @@ -257,6 +257,36 @@ "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." + }, + "AZ-IDN-010": { + "control_id": "A.9.2.1", + "control_name": "User registration and de-registration", + "description": "App Registration ownership supports accountable identity lifecycle administration." + }, + "AZ-IDN-011": { + "control_id": "A.14.1.2", + "control_name": "Securing application services on public networks", + "description": "Secure redirect URIs protect identity protocol responses traversing public networks." + }, + "AZ-IDN-012": { + "control_id": "A.9.4.2", + "control_name": "Secure log-on procedures", + "description": "Modern authorization code flow with PKCE provides stronger token handling than implicit grant." + }, + "AZ-IDN-013": { + "control_id": "A.9.4.3", + "control_name": "Password management system", + "description": "Avoiding client secrets reduces password-style application credential exposure." + }, + "AZ-IDN-014": { + "control_id": "A.12.1.2", + "control_name": "Change management", + "description": "Property lock prevents unauthorized changes to sensitive service-principal instance configuration." + }, + "AZ-IDN-015": { + "control_id": "A.9.2.3", + "control_name": "Management of privileged access rights", + "description": "Subscription Owner and Contributor assignments to managed identities require least-privilege reduction." } } } diff --git a/compliance/frameworks/nist_csf.json b/compliance/frameworks/nist_csf.json index faf101b..563c614 100644 --- a/compliance/frameworks/nist_csf.json +++ b/compliance/frameworks/nist_csf.json @@ -257,6 +257,36 @@ "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." + }, + "AZ-IDN-010": { + "control_id": "PR.AC-4", + "control_name": "Access permissions and authorizations are managed", + "description": "Assigned owners establish accountability for reviewing and maintaining application access." + }, + "AZ-IDN-011": { + "control_id": "PR.DS-2", + "control_name": "Data in transit is protected", + "description": "HTTPS redirect URIs protect authorization responses from interception and modification in transit." + }, + "AZ-IDN-012": { + "control_id": "PR.AC-3", + "control_name": "Remote access is managed", + "description": "Disabling implicit grant reduces exposure of front-channel tokens used for remote application access." + }, + "AZ-IDN-013": { + "control_id": "PR.AC-1", + "control_name": "Identities and credentials are managed", + "description": "Replacing client secrets with managed credentials reduces credential leakage and rotation risk." + }, + "AZ-IDN-014": { + "control_id": "PR.IP-1", + "control_name": "A baseline configuration is created and maintained", + "description": "Application-instance property lock preserves the approved sensitive-property baseline across tenants." + }, + "AZ-IDN-015": { + "control_id": "PR.AC-4", + "control_name": "Access permissions and authorizations are managed", + "description": "Managed identities should receive only the minimum role and scope required by their workloads." } } } diff --git a/compliance/frameworks/soc2.json b/compliance/frameworks/soc2.json index e931e9f..5645793 100644 --- a/compliance/frameworks/soc2.json +++ b/compliance/frameworks/soc2.json @@ -257,6 +257,36 @@ "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." + }, + "AZ-IDN-010": { + "control_id": "CC6.2", + "control_name": "Registers and Authorizes Users", + "description": "Assigned application owners establish responsibility for authorization and lifecycle review." + }, + "AZ-IDN-011": { + "control_id": "CC6.7", + "control_name": "Protects Data in Transit", + "description": "HTTPS redirect URIs protect authorization responses transmitted between identity and application endpoints." + }, + "AZ-IDN-012": { + "control_id": "CC6.1", + "control_name": "Logical and Physical Access Controls", + "description": "Disabling implicit grant reduces exposure of browser-delivered access and identity tokens." + }, + "AZ-IDN-013": { + "control_id": "CC6.1", + "control_name": "Logical and Physical Access Controls", + "description": "Secretless or certificate authentication reduces compromise of application access credentials." + }, + "AZ-IDN-014": { + "control_id": "CC6.6", + "control_name": "Restricts Access to Information Assets", + "description": "Property lock prevents tenant service-principal instances from changing sensitive credentials and encryption settings." + }, + "AZ-IDN-015": { + "control_id": "CC6.3", + "control_name": "Role-Based Access", + "description": "Managed identities should not receive broad subscription roles beyond workload requirements." } } } diff --git a/docs/accessibility-and-i18n.md b/docs/accessibility-and-i18n.md new file mode 100644 index 0000000..4665859 --- /dev/null +++ b/docs/accessibility-and-i18n.md @@ -0,0 +1,13 @@ +# Accessibility and Internationalization + +OpenShield aims for keyboard-operable interfaces, visible focus, semantic HTML, +text alternatives for meaningful images, labeled controls, adequate contrast, +responsive layouts and reduced-motion compatibility where practical. New UI +changes should be reviewed against WCAG 2.2 AA guidance and tested with keyboard +navigation. Accessibility defects are tracked like other product defects. + +English is currently the only supported interface language. User-facing text +should be kept separate from security data and logic where practical so a +future localization system can replace it without changing scanner behavior. +OpenShield must not claim full internationalization until the frontend and +website use message catalogs, locale-aware formatting and localization tests. diff --git a/docs/adding-a-rule.md b/docs/adding-a-rule.md index a8ee856..e62f43b 100644 --- a/docs/adding-a-rule.md +++ b/docs/adding-a-rule.md @@ -125,6 +125,9 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: | `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_applications()` | Paginated App Registration dictionaries, or `None` on Graph failure | +| `azure_client.get_managed_identity_service_principals()` | Managed Identity service principals, or `None` on Graph failure | +| `azure_client.get_subscription_role_assignments()` | Subscription RBAC assignments, 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/architecture.md b/docs/architecture.md index a920329..2249851 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,7 +2,7 @@ ## Overview -OpenShield is a modular, open source Cloud Security Posture Management (CSPM) platform for Azure. It scans your Azure subscription against 36 security rules, maps findings to compliance frameworks (CIS, NIST CSF, ISO 27001, SOC 2), stores results in PostgreSQL, and exposes posture data through a Flask REST API consumed by a live React dashboard. +OpenShield is a modular, open source Cloud Security Posture Management (CSPM) platform for Azure. It scans your Azure subscription against 51 security rules, maps findings to compliance frameworks (CIS, NIST CSF, ISO 27001, SOC 2), stores results in PostgreSQL, and exposes posture data through a Flask REST API consumed by a live React dashboard. --- @@ -43,8 +43,8 @@ OpenShield is a modular, open source Cloud Security Posture Management (CSPM) pl ┌───────────▼──────────────────────────────────────────────────────┐ │ Rule Modules (scanner/rules/) │ │ │ -│ 20 rule files across Storage, Network, Identity, Database, │ -│ Compute, and Key Vault │ +│ 51 rule files across Storage, Network, Identity, Database, │ +│ Compute, Key Vault, AKS, and post-quantum cryptography │ └───────────┬───────────────────────────────────────────────────────┘ │ calls ┌───────────▼──────────────────────────────────────────────────────┐ @@ -110,16 +110,18 @@ result = engine.run_scan() ### 4. Current Rule Modules -There are 36 rule files in `scanner/rules/`. See `docs/rules-reference.md` for the full table. +There are 51 rule files in `scanner/rules/`. See `docs/rules-reference.md` for the full table. | Category | Count | Rules | |---|---|---| | Storage | 5 | AZ-STOR-001 to 005 | -| Network | 14 | AZ-NET-001 to 014 | +| Network | 15 | AZ-NET-001 to 015 | | Identity | 4 | AZ-IDN-001 to 004 | | Database | 4 | AZ-DB-001 to 004 | | Compute | 4 | AZ-CMP-001 to 004 | | Key Vault | 5 | AZ-KV-001 to 005 | +| Kubernetes | 6 | AZ-AKS-001 to 006 | +| Post-quantum | 3 | AZ-PQC-001 to 003 | Every rule has a matching Azure CLI playbook in `playbooks/cli/`. diff --git a/docs/assets/openssf-best-practices.svg b/docs/assets/openssf-best-practices.svg new file mode 100644 index 0000000..4fed636 --- /dev/null +++ b/docs/assets/openssf-best-practices.svg @@ -0,0 +1,524 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/ci-pipeline.md b/docs/ci-pipeline.md index ba11313..0b5c015 100644 --- a/docs/ci-pipeline.md +++ b/docs/ci-pipeline.md @@ -16,10 +16,11 @@ This document explains each job, how to reproduce every check locally before ope | **Rule & Compliance Validation** | 7 static checks (below) | Rule/playbook/compliance-JSON problems | | **Secret Scan (Gitleaks)** | `gitleaks detect` | A hardcoded secret in the working tree | | **SAST (Bandit)** | `bandit -r api/ scanner/ ai/ -ll` | A medium+ severity insecure-code pattern | +| **SAST (Semgrep)** | `semgrep scan --config p/security-audit --config p/owasp-top-ten --config p/python --config p/javascript --error` | A finding from the security-audit / OWASP Top 10 / Python / JS rulesets | | **SCA (pip-audit)** | `pip-audit -r requirements.txt` | A dependency with a known CVE (minus documented ignores) | | **SBOM (Syft)** | CycloneDX SBOM generated + uploaded as an artifact | SBOM generation error | | **Container Scan (Trivy)** | Dormant scaffold — skips until a `Dockerfile` exists (INFRA 1 / #154) | (nothing today) | -| **Backend Tests (pytest + coverage)** | Full `tests/` suite against an ephemeral Postgres, `--cov-fail-under=25` | A failing test or coverage below the floor | +| **Backend Tests (pytest + coverage)** | Full `tests/` suite against an ephemeral Postgres, `--cov-fail-under=80` | A failing test or coverage below the Silver-level floor | | **Frontend (lint + build)** | `npm ci` → `eslint` → `vite build` | An eslint error or a broken dashboard build | | **Enforce dev to main source** | `main` PRs must come from `dev` | A non-`dev` branch opening a PR into `main` | | **CI Summary** | Aggregates all job results into the run summary and fails if any required job failed | Any required job failing | @@ -37,7 +38,7 @@ The **Container Scan** job is intentionally **not** a required check yet: no `Do ```bash python -m venv .venv && source .venv/bin/activate pip install -r requirements.txt -pip install ruff bandit pip-audit # lint / SAST / SCA tools +pip install ruff bandit pip-audit semgrep # lint / SAST / SCA tools ``` A local PostgreSQL is needed for the backend tests. Any Postgres 14+ works; create the CI-matching role/db once: @@ -96,6 +97,21 @@ bandit -r api/ scanner/ ai/ -ll `-ll` reports medium severity and above. Confirmed false positives are annotated inline with `# nosec ` and a one-line justification (e.g. binding `0.0.0.0` inside a container, a parameterized SQL string). +### SAST (Semgrep) + +```bash +semgrep scan \ + --config p/security-audit \ + --config p/owasp-top-ten \ + --config p/python \ + --config p/javascript \ + --exclude venv --exclude frontend/node_modules --exclude frontend/dist \ + --metrics=off \ + --error . +``` + +Uses the public Semgrep Registry rulesets (`p/...`) directly, not `--config auto`, since `auto` nudges toward a semgrep.dev account/login, which this project deliberately avoids. `--metrics=off` disables Semgrep's anonymous telemetry. `venv/`, `node_modules/`, and `dist/` are already excluded by Semgrep's built-in default ignores and this repo's `.gitignore`, so no `.semgrepignore` is needed; the `--exclude` flags above just make that exclusion visible in the command itself. In CI, findings are also written to SARIF and uploaded to the GitHub Security → Code scanning tab alongside CodeQL's results. + ### SCA (pip-audit) ```bash @@ -109,10 +125,10 @@ The three ignores are advisories in `transformers` (a transitive dependency of ` ```bash DATABASE_URL="postgresql://ci:ci@localhost/ci_db" OPENSHIELD_ENV="testing" \ - pytest tests/ -v --tb=short --cov=api --cov=scanner --cov-report=term --cov-fail-under=25 + pytest tests/ -v --tb=short --cov=api --cov=scanner --cov-report=term --cov-fail-under=80 ``` -Runs the **entire** `tests/` suite once (not just rule tests). Tests requiring a vector store or an AI API key skip cleanly. `--cov-fail-under=25` is a floor to prevent backsliding; raise it as coverage grows. +Runs the **entire** `tests/` suite once (not just rule tests). Tests requiring a vector store or an AI API key skip cleanly. `--cov-fail-under=80` enforces the OpenSSF Silver statement-coverage requirement for the measured API and scanner packages. ### Frontend (lint + build) @@ -144,8 +160,10 @@ CI runs at **both** merge points (`on: pull_request` targets `dev` and `main`), - **Gitleaks via the release binary, not `gitleaks-action`.** The Action requires a (free) `GITLEAKS_LICENSE` for **organization**-owned repos; the CLI binary is Apache-2.0 with no key, so it runs with zero license friction. - **All third-party actions are pinned to a full commit SHA** with a `# vX.Y.Z` comment (GitHub's supply-chain hardening standard). `aquasecurity/trivy-action` is SHA-pinned specifically because its mutable tags were compromised in 2026. - **Dependabot is notify-only** (`open-pull-requests-limit: 0` for pip / github-actions / npm): it still raises alerts but does not auto-open version-bump PRs. -- **Least-privilege token:** `ci.yml` declares `permissions: contents: read`; CodeQL scopes its own `security-events: write`. +- **Least-privilege token:** `ci.yml` declares `permissions: contents: read`; CodeQL and Semgrep each scope their own `security-events: write`. - **`concurrency` cancels superseded runs** on the same PR to save runner minutes. +- **Semgrep added as a second, open-source SAST layer**, run as a pure CLI invocation (`semgrep scan --config p/...`) with `--metrics=off`, no semgrep.dev account, login, or telemetry dependency. It complements CodeQL's deeper data-flow/taint analysis with fast, pattern-based rules (OWASP Top 10, general security-audit, Python/JS-specific), uploading SARIF to the same GitHub code scanning UI. Added per maintainer direction to reduce reliance on GitHub's proprietary, soon-to-be-paid "Code Quality" product in favor of open-source tooling. +- **The `p/...` rulesets are pulled from the public Semgrep Registry at run time**, not vendored, so a registry outage fails the `SAST (Semgrep)` job. Accepted as a normal SAST-gate network dependency; a spurious red on that job is worth checking against Semgrep Registry status before assuming a real finding. --- @@ -180,8 +198,9 @@ The `ci-summary` job uses `needs: [...]` + `if: always()` so it runs after every |---|---| | `ruff check` / `format` fails | `ruff format .` then `ruff check --fix .`; re-run both | | `bandit` medium+ finding | Fix it, or if a confirmed false positive add `# nosec ` with a one-line reason | +| `semgrep` finding | Fix the flagged pattern, or if a confirmed false positive add a `# nosemgrep: ` (Python) / `// nosemgrep: ` (JS) comment with a one-line justification on the flagged line | | `pip-audit` reports a CVE | Bump the pin to a fixed version; only add `--ignore-vuln` with a documented reason | -| `pytest` coverage below 25% | Add tests, or investigate the regression that removed coverage | +| `pytest` coverage below 80% | Add tests, or investigate the regression that removed coverage | | Frontend eslint error | Fix the reported rule (e.g. remove an unused import); warnings do not fail CI | | `Enforce dev to main source` fails | Open the PR from `dev`; merge feature work into `dev` first | | `missing field 'RULE_ID'` | Add `RULE_ID = "AZ-XXX-000"` at module level in the rule file | diff --git a/docs/enterprise-identity-rules.md b/docs/enterprise-identity-rules.md new file mode 100644 index 0000000..d8ff37e --- /dev/null +++ b/docs/enterprise-identity-rules.md @@ -0,0 +1,56 @@ +# Enterprise App Registration and Managed Identity Rules + +OpenShield evaluates Microsoft Entra App Registration configuration and correlates +managed-identity service principals with Azure subscription RBAC assignments. + +## Coverage + +| Rule | Control | +|---|---| +| `AZ-IDN-010` | App Registration ownership | +| `AZ-IDN-011` | Non-loopback HTTP redirect URIs | +| `AZ-IDN-012` | OAuth implicit grant | +| `AZ-IDN-013` | Password credential presence | +| `AZ-IDN-014` | Multi-tenant application-instance property lock | +| `AZ-IDN-015` | Owner/Contributor managed identity at subscription scope | + +`AZ-IDN-006` continues to detect stale, expired, and non-expiring password +credentials, but now reuses the same cached application inventory. + +## Required permissions + +- Microsoft Graph application permission `Application.Read.All` reads App + Registrations, minimal owner IDs, and managed-identity service principals. +- Azure action `Microsoft.Authorization/roleAssignments/read` reads subscription + RBAC assignments for managed-identity correlation. + +The scanner never requests application write permissions. Remediation commands +run separately under an operator identity and require explicit confirmation where +an automated change is safe. + +## Data minimization + +Findings do not contain tokens, credential values, credential key identifiers, +credential hints, complete redirect URIs, or owner personal details. Findings use +counts and boolean configuration states sufficient for triage. + +## Reliability + +Graph inventories follow `@odata.nextLink` and are cached for the scan lifetime. +Graph or Azure RBAC failures return an indeterminate `None` state; rules skip +evaluation and log the unavailable inventory rather than claiming compliance. + +## Assignment restrictions + +Resource-provider assignment restrictions for user-assigned managed identities +were intentionally deferred. Microsoft documents the portal feature, but the +current public Managed Identity REST response reliably exposes only +`isolationScope`. OpenShield will not infer a control from an undocumented field. + +## References + +- [App Registration security guidance](https://learn.microsoft.com/entra/identity-platform/security-best-practices-for-app-registration) +- [List applications](https://learn.microsoft.com/graph/api/application-list) +- [List application owners](https://learn.microsoft.com/graph/api/application-list-owners) +- [Service-principal property lock](https://learn.microsoft.com/graph/api/resources/serviceprincipallockconfiguration) +- [Managed Identity best practices](https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/managed-identity-best-practice-recommendations) diff --git a/docs/learn/index.html b/docs/learn/index.html index 7ca6219..a9aaa4f 100644 --- a/docs/learn/index.html +++ b/docs/learn/index.html @@ -841,7 +841,7 @@

Production-shaped, MVP-friendly architecture

Rule coverage

-

39 Azure security rules

+

51 Azure security rules

OpenShield currently has 39 dynamic rules. The strongest contributor work improves rule accuracy, reduces false positives, strengthens validation, or improves remediation quality. @@ -931,7 +931,7 @@

Current cleanup items

Documentation drift

    -
  • Some README/docs references still mention 20 rules while the repo has 39.
  • +
  • Rule coverage and documentation counts are checked and updated with each release.
  • Some startup commands assume python, but local environments may only expose python3.
  • API docs and implementation should stay aligned, especially score response shape.
diff --git a/docs/openssf-silver-evidence.md b/docs/openssf-silver-evidence.md new file mode 100644 index 0000000..921d1c5 --- /dev/null +++ b/docs/openssf-silver-evidence.md @@ -0,0 +1,83 @@ +# OpenSSF Silver Evidence Register + +This register supports issue #199 and the official OpenSSF assessment. It is an +audit aid, not a badge claim. The BadgeApp owner must review every answer and +submit the public URL or justification. + +## Ready for evidence review + +| Criterion | Public evidence | +|---|---| +| `achieve_passing` | README badge and official project record | +| `contribution_requirements` | `CONTRIBUTING.md` | +| `governance` | `GOVERNANCE.md` | +| `code_of_conduct` | `.github/CODE_OF_CONDUCT.md` | +| `roles_responsibilities` | `GOVERNANCE.md`, `MAINTAINERS.md`, `.github/CODEOWNERS` | +| `documentation_roadmap` | `ROADMAP.md` | +| `documentation_architecture` | `docs/architecture.md` | +| `documentation_security` | `docs/security-requirements.md` | +| `documentation_quick_start` | README Quick Start | +| `documentation_current` | Corrected rule counts and support documentation in issue #199 PR | +| `documentation_achievements` | README Security Assurance section | +| `maintenance_or_update` | `SUPPORT.md`, `CHANGELOG.md`, database migration documentation | +| `report_tracker` | Public GitHub Issues | +| `vulnerability_response_process` | `.github/SECURITY.md` | +| `vulnerability_report_credit` | `SECURITY_ACKNOWLEDGEMENTS.md`; currently N/A because no external disclosure is recorded | +| `coding_standards` | `CONTRIBUTING.md` | +| `coding_standards_enforced` | Ruff/ESLint jobs in `.github/workflows/ci.yml` | +| `installation_common` | README Docker and source installation instructions | +| `installation_development_quick` | `CONTRIBUTING.md` Local Dev Setup | +| `external_dependencies` | `requirements.txt` and npm lockfiles | +| `dependency_monitoring` | Dependabot, Dependency Review, pip-audit and Trivy workflows | +| `updateable_reused_components` | Pinned dependency manifests and standard package managers | +| `automated_integration_testing` | GitHub Actions CI on every pull request | +| `test_statement_coverage80` | CI `--cov-fail-under=80` plus targeted Azure client tests | +| `test_policy_mandated` | Governance change process and contribution requirements | +| `tests_documented_added` | `CONTRIBUTING.md` pull-request requirements | +| `warnings_strict` | Ruff/ESLint zero-error CI gates | +| `implement_secure_design` | Security requirements and assurance case | +| `crypto_weaknesses` | Production JWT secret validation and standard maintained TLS clients | +| `crypto_credential_agility` | Runtime environment configuration without recompilation | +| `crypto_used_network` | HTTPS deployments and secure provider endpoints | +| `crypto_tls12` | Hosted HTTPS endpoints and maintained TLS libraries | +| `crypto_certificate_verification` | Standard verification defaults; no disabled verification in source | +| `crypto_verification_private` | Verification occurs in the TLS client before HTTP data is sent | +| `hardening` | Website/frontend CSP and security headers, production fail-closed configuration | +| `assurance_case` | `docs/security-assurance-case.md` | +| `static_analysis_common_vulnerabilities` | CodeQL, Bandit and Semgrep | +| `dynamic_analysis_unsafe` | N/A: project code is Python/JavaScript, not C/C++ | + +## Owner or operational confirmation required + +| Criterion | Required confirmation/action | +|---|---| +| `dco` | Project lead approves DCO policy and enables the merge check | +| `access_continuity` | Confirm two people hold real issue, merge, release and recovery capability | +| `bus_factor` | Confirm capability is distributed, not only documented in CODEOWNERS | +| `signed_releases` | Select and operate the signing/attestation process in `docs/release-security.md` | +| `version_tags_signed` | Create future important tags as signed annotated tags or approve equivalent attestation | +| `vulnerability_report_credit` | Confirm the acknowledgement record is complete before selecting N/A | + +## Evidence or implementation still incomplete + +| Criterion | Gap | +|---|---| +| `accessibility_best_practices` | Policy exists; complete keyboard/semantic/WCAG review and record results | +| `internationalization` | English-only UI; implement localization or mark Unmet with justification | +| `regression_tests_added50` | Preliminary audit shows 9 of 12 fixes with test changes; verify behavioral assertions before marking Met | +| `interfaces_current` | Review deprecated API warnings and document the periodic check | +| `input_validation` | Complete route-by-route allowlist audit and close discovered gaps | +| `crypto_algorithm_agility` | Review JWT and signing algorithm agility; document supported migration path | +| `build_repeatable` | Demonstrate repeatable frontend/release output or provide an accurate scripting-language N/A rationale | + +## Likely N/A after owner review + +- `sites_password_security`: OpenShield does not operate its own external-user + password database; authentication uses JWT/deployment identity mechanisms. +- `build_standard_variables`, `build_preserve_debug`, and + `build_non_recursive`: no native C/C++ build is produced. +- `installation_standard_variables`: source/container deployment does not use a + system-wide POSIX installer or `DESTDIR` installation step. + +N/A answers must explain the actual architecture. They must not be used to hide +missing work. diff --git a/docs/regression-test-audit.md b/docs/regression-test-audit.md new file mode 100644 index 0000000..4449e32 --- /dev/null +++ b/docs/regression-test-audit.md @@ -0,0 +1,33 @@ +# Regression Test Audit + +OpenShield requires reproducible bug fixes to include automated regression +tests. This audit covers merged bug-fix pull requests from the project's start +through 16 July 2026, within the six-month OpenSSF Silver review window. + +The audit searched merged pull requests whose title identified a fix and checked +whether the pull request changed a file under `tests/` or another automated test +file. Release-only aggregation PRs were not counted as individual bug fixes. + +| Pull request | Automated test changed? | +|---|---| +| #108 scanner abstraction fix | No | +| #130 compliance mapping fix | No | +| #143 Flask test-safety/CI fix | No separate regression file | +| #145 latest-scan endpoint fix | Yes | +| #162 reliability fixes | Yes | +| #166 compliance, TLS and pagination fixes | Yes | +| #169 async scan recovery fix | Yes | +| #175 container vulnerability fix | Yes | +| #183 CodeQL security fixes | Yes | +| #184 identity logging fix | Yes | +| #185 Semgrep workflow fixes | Yes | +| #188 XSS and AI-key fixes | Yes | + +Nine of twelve audited bug-fix pull requests changed automated tests: **75% by +file-change evidence**. This is above the Silver 50% threshold, subject to a +maintainer confirming that the changed tests reproduce the corrected behavior. + +This preliminary repository-history audit is not a claim that every test fully +exercises every fixed behavior. Maintainers must verify the behavioral +assertions before entering this criterion as Met, and refresh the audit before +each OpenSSF assessment. diff --git a/docs/release-security.md b/docs/release-security.md new file mode 100644 index 0000000..505674a --- /dev/null +++ b/docs/release-security.md @@ -0,0 +1,37 @@ +# Release Security and Verification + +## Current process + +Version tags trigger `.github/workflows/release.yml`, which creates GitHub +release notes. Published releases trigger `.github/workflows/sbom-release.yml`, +which generates and uploads a CycloneDX SBOM. Release actions are pinned to +specific commits. + +## Required signing process + +OpenShield must not mark the OpenSSF `signed_releases` criterion Met until the +following process is implemented and exercised for a real release: + +1. Build a deterministic source or distribution archive from the reviewed tag. +2. Generate SHA-256 checksums and a cryptographic signature or keyless Sigstore + attestation for each distributed artifact and its SBOM. +3. Keep signing authority separate from the public download location. A keyless + workflow must bind the identity, repository, workflow and commit in the + transparency record. +4. Upload artifacts, checksums, signatures/attestations and verification + instructions to the release. +5. Verify the published artifacts in a separate CI step before announcing the + release. +6. Create important release tags as signed annotated tags, or document why the + artifact-attestation mechanism provides the authoritative release identity. + +The project lead must approve the signing model and access policy before this is +automated. Existing `v0.1.0` and `v0.3.0` tags are lightweight tags and do not +by themselves satisfy signed-release requirements. + +## User verification target + +The final workflow must provide copyable verification commands that validate +the artifact digest, signer identity, repository and workflow identity. Those +commands will replace this section once the signing mechanism is selected and +tested. diff --git a/docs/rules-reference.md b/docs/rules-reference.md index e8cd964..8b2b4f5 100644 --- a/docs/rules-reference.md +++ b/docs/rules-reference.md @@ -1,6 +1,6 @@ # Rules Reference -OpenShield currently ships 44 Azure scan rules. This table is generated from the module-level constants in `scanner/rules/`. +OpenShield currently ships 51 Azure scan rules. This table is generated from the module-level constants in `scanner/rules/`. | Rule ID | Name | Severity | Category | CIS | NIST | ISO 27001 | |---|---|---|---|---|---|---| @@ -21,6 +21,12 @@ OpenShield currently ships 44 Azure scan rules. This table is generated from the | AZ-IDN-007 | Active User with No MFA Registered in Entra ID | HIGH | Identity | 1.1 | PR.AC-7 | A.9.4.2 | | AZ-IDN-008 | Custom RBAC Role with Wildcard Permissions at Subscription Scope | HIGH | Identity | 1.23 | PR.AC-4 | A.9.2.3 | | AZ-IDN-009 | No Activity Log Alert for Role Assignment Changes | MEDIUM | Identity | 5.2.1 | DE.CM-3 | A.12.4.1 | +| AZ-IDN-010 | App Registration Has No Owner | MEDIUM | Identity | TBD-IDN-010 | PR.AC-4 | A.9.2.1 | +| AZ-IDN-011 | App Registration Uses Insecure Redirect URI | HIGH | Identity | TBD-IDN-011 | PR.DS-2 | A.14.1.2 | +| AZ-IDN-012 | App Registration Enables OAuth Implicit Grant | MEDIUM | Identity | TBD-IDN-012 | PR.AC-3 | A.9.4.2 | +| AZ-IDN-013 | App Registration Uses Password Credentials | MEDIUM | Identity | TBD-IDN-013 | PR.AC-1 | A.9.4.3 | +| AZ-IDN-014 | Multi-Tenant App Registration Lacks Property Lock | HIGH | Identity | TBD-IDN-014 | PR.IP-1 | A.12.1.2 | +| AZ-IDN-015 | Managed Identity Has Privileged Subscription Role | HIGH | Identity | TBD-IDN-015 | PR.AC-4 | A.9.2.3 | | AZ-KV-001 | Key Vault with Soft Delete Disabled | MEDIUM | KeyVault | 8.8 | PR.IP-4 | A.17.2.1 | | AZ-KV-002 | Key Vault Allows Public Network Access Without Private Endpoint | HIGH | Key Vault | 8.7 | AC-17 | A.13.1.1 | | AZ-KV-003 | Key Vault Without Diagnostic Logging Enabled | MEDIUM | Key Vault | 8.4 | DE.CM-7 | A.12.4.1 | @@ -40,6 +46,7 @@ OpenShield currently ships 44 Azure scan rules. This table is generated from the | AZ-NET-012 | NSG Flow Logs Not Enabled | MEDIUM | Network | 6.7 | DE.CM-1 | A.12.4.1 | | AZ-NET-013 | Azure Firewall Not Enabled on Virtual Network | HIGH | Network | 6.4 | PR.AC-5 | A.13.1.1 | | AZ-NET-014 | VNet Peering Configured Without Gateway Transit Restrictions | MEDIUM | Network | 6.6 | PR.AC-5 | A.13.1.1 | +| AZ-NET-015 | Public DNS Zone Exposes Internal Infrastructure Details | MEDIUM | Network | 9.8 | PR.AC-5 | A.13.1.1 | | AZ-PQC-001 | TLS Using Classical Key Exchange Algorithm | HIGH | PostQuantum | 9.9 | PR.DS-2 | A.10.1.1 | | AZ-PQC-002 | Key Vault Key Using Non-Quantum-Safe Algorithm | HIGH | PostQuantum | 8.1 | PR.DS-2 | A.10.1.1 | | AZ-PQC-003 | Key Vault Certificate Using Non-Quantum-Safe Signature Algorithm | MEDIUM | PostQuantum | 8.9 | PR.DS-2 | A.10.1.1 | diff --git a/docs/security-assurance-case.md b/docs/security-assurance-case.md new file mode 100644 index 0000000..895a328 --- /dev/null +++ b/docs/security-assurance-case.md @@ -0,0 +1,78 @@ +# OpenShield Security Assurance Case + +## Claim + +When deployed according to the documented production requirements, OpenShield +provides a defensible, least-privilege Azure configuration assessment without +intentionally collecting workload secrets or contents. This claim is bounded by +the limitations in `docs/security-requirements.md`. + +## Assets and threats + +Protected assets include Azure credentials, JWT signing material, scan results, +resource metadata, database records, remediation authority and release +integrity. Relevant threat actors include unauthenticated internet clients, +malicious or compromised contributors, compromised dependencies, overprivileged +cloud identities, malicious insiders and attackers controlling Azure API data. + +Primary threats are unauthorized scan execution or data modification, secret +disclosure, injection, path traversal, server-side request abuse, forged tokens, +unsafe remediation, dependency compromise, false compliance and tampered +releases. + +## Trust boundaries + +```text +Browser ──HTTPS──> Frontend ──HTTPS──> Flask API ──> PostgreSQL + │ + ├──> Azure management APIs / Graph + ├──> NVD and configured AI providers + └──> optional Sentinel ingestion + +Contributor ──pull request──> GitHub review and CI ──> protected project code +Operator ──explicit confirmation──> remediation playbook ──> Azure resource +``` + +Data crossing each boundary is untrusted until authenticated and validated. +Azure and third-party API responses are also treated as potentially malformed +or incomplete. + +## Secure design argument + +- **Least privilege:** CI permissions are scoped per workflow; Azure checks use + management metadata and document minimum read permissions. +- **Fail-safe defaults:** production rejects weak JWT configuration; API or + permission failures do not silently become compliant results. +- **Complete mediation:** protected write routes apply authentication centrally. +- **Separation of privilege:** changes require review and CI; destructive + remediation requires explicit operator confirmation; security disclosures use + a private process. +- **Economy of mechanism:** scanner rules use a shared Azure client and normalized + finding schema rather than embedding credentials or clients in every rule. +- **Open design:** policies, source, workflows, tests and security evidence are + public and reviewable. +- **Minimize sensitive data:** findings contain the configuration context needed + for remediation, not secrets or customer workload contents. + +## Implementation weakness argument + +- Injection and malformed input are countered with typed JSON handling, + allowlist validation, path normalization and tests for security boundaries. +- Authentication weaknesses are countered by production secret validation, + token verification and protected state-changing routes. +- Supply-chain risks are countered by pinned GitHub Actions, dependency review, + pip-audit, Dependabot, SBOM generation, Trivy and release review. +- Code weaknesses are checked using Ruff, CodeQL, Bandit, Semgrep, Gitleaks and + automated regression tests. +- Browser risks are reduced by CSP and other security headers on deployed web + surfaces. +- Availability risks from remediation are reduced through validation, warnings + and explicit confirmation rather than automatic changes. + +## Residual risk and evidence review + +Cloud APIs can change, checks can be incomplete, mappings can be contextual, and +maintainers or dependencies can be compromised. Findings therefore support—not +replace—professional risk assessment. Evidence is reviewed through CI, release +work, security advisories and the OpenSSF assessment. Gaps remain tracked in +issue #199 until the project can support each claim with current public proof. diff --git a/docs/security-requirements.md b/docs/security-requirements.md new file mode 100644 index 0000000..5254935 --- /dev/null +++ b/docs/security-requirements.md @@ -0,0 +1,49 @@ +# OpenShield Security Requirements + +## Security objectives + +OpenShield must: + +- authenticate state-changing API operations and fail closed in production when + the JWT secret is missing, weak or set to the development default; +- request only the Azure management-plane and Microsoft Graph permissions + documented for enabled checks; +- avoid collecting secrets, credentials, application settings, customer data or + workload contents when configuration metadata is sufficient; +- validate restricted untrusted inputs using allowlists, length limits and + typed parsing before they reach files, databases, subprocesses or external + services; +- preserve API and permission failures as unknown rather than reporting false + compliance; +- protect network communication using HTTPS/TLS with certificate verification; +- keep dependencies, workflows and release contents reviewable and + machine-inventoried; +- log security-relevant failures without disclosing credentials or sensitive + provider responses. + +## Expected deployment controls + +Operators are responsible for TLS termination, database access control, secret +storage, log retention, backup and recovery, Azure least-privilege role +assignment, and restricting administrative access to trusted users and +networks. Production deployments must use unique secrets and supported +dependencies. + +## Security limitations + +OpenShield is a posture assessment aid, not a penetration test, formal audit or +guarantee of security. A clean scan does not prove that an Azure environment is +secure. Rules cover documented configuration states and may be limited by API +permissions, service tiers, Azure API behavior and organizational context. +Remediation playbooks can affect availability and require operator review. + +OpenShield does not claim that its framework mappings constitute certification +or complete benchmark coverage. Unsupported versions receive no guaranteed +security fixes. + +## Verification evidence + +The security policy, architecture, assurance case, automated test suite, SAST, +secret scanning, dependency review, SBOM generation and container scanning form +the public evidence for these requirements. Known defects must be tracked and +resolved through GitHub issues or private advisories as appropriate. diff --git a/frontend/package.json b/frontend/package.json index 62091c6..631146a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "vite", "build": "vite build", - "lint": "eslint .", + "lint": "eslint . --max-warnings=0", "preview": "vite preview" }, "dependencies": { diff --git a/frontend/src/components/ai/ChatPanel.jsx b/frontend/src/components/ai/ChatPanel.jsx index 9566694..5a302e4 100644 --- a/frontend/src/components/ai/ChatPanel.jsx +++ b/frontend/src/components/ai/ChatPanel.jsx @@ -37,7 +37,7 @@ const ChatPanel = forwardRef(function ChatPanel({ initialMessages = [], contextF timestamp: new Date().toISOString(), }; setMessages((prev) => [...prev, msg]); - }, [contextFinding?.ruleId]); + }, [contextFinding]); async function handleSend(text) { if (!text?.trim()) return; diff --git a/frontend/src/components/layout/Header.jsx b/frontend/src/components/layout/Header.jsx index 8d9770d..feb635f 100644 --- a/frontend/src/components/layout/Header.jsx +++ b/frontend/src/components/layout/Header.jsx @@ -63,7 +63,7 @@ function ScanToast({ result, error, onClose }) { useEffect(() => { const t = setTimeout(onClose, 6000); return () => clearTimeout(t); - }, []); + }, [onClose]); const isSuccess = !!result; diff --git a/frontend/src/pages/AILayer.jsx b/frontend/src/pages/AILayer.jsx index 63afa18..ed37840 100644 --- a/frontend/src/pages/AILayer.jsx +++ b/frontend/src/pages/AILayer.jsx @@ -98,13 +98,13 @@ export default function AILayer() { const [cveData, setCveData] = useState(null); const [cveLoading, setCveLoading] = useState(true); const [settingsKey, setSettingsKey] = useState(0); // force re-render on settings save + const initialFinding = location.state?.finding; useEffect(() => { api.getFindings() .then((scans) => { setFindings(scans); - const fromScan = location.state?.finding; - if (fromScan) setSelectedFinding(fromScan); + if (initialFinding) setSelectedFinding(initialFinding); aiApi.getSummary(scans).then(setSummary).finally(() => setSummaryLoading(false)); }) .catch(() => { @@ -112,7 +112,7 @@ export default function AILayer() { aiApi.getSummary([]).then(setSummary).finally(() => setSummaryLoading(false)); }); aiApi.getCVEAnalysis().then(setCveData).finally(() => setCveLoading(false)); - }, []); + }, [initialFinding]); const handleFindingSelect = (f) => setSelectedFinding((prev) => (prev?.id === f.id ? null : f)); diff --git a/frontend/src/pages/DetailedScan.jsx b/frontend/src/pages/DetailedScan.jsx index a2a7e66..41c8fee 100644 --- a/frontend/src/pages/DetailedScan.jsx +++ b/frontend/src/pages/DetailedScan.jsx @@ -81,18 +81,18 @@ export default function DetailedScan() { const [selected, setSelected] = useState(null); const [playbook, setPlaybook] = useState(null); const [showBanner, setShowBanner] = useState(!!location.state?.fromPrioritization); + const preSelectRuleId = location.state?.ruleId; // Load findings on mount useEffect(() => { api.getFindings().then((data) => { setFindings(data); - const preSelectRuleId = location.state?.ruleId; const initial = preSelectRuleId ? (data.find((f) => f.ruleId === preSelectRuleId) ?? data[0]) : data[0]; selectFinding(initial); }); - }, []); + }, [preSelectRuleId]); // Fetch playbook whenever selected finding changes async function selectFinding(f) { diff --git a/playbooks/cli/fix_az_idn_010.sh b/playbooks/cli/fix_az_idn_010.sh new file mode 100644 index 0000000..95068b1 --- /dev/null +++ b/playbooks/cli/fix_az_idn_010.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# OpenShield Remediation Playbook +# Rule: AZ-IDN-010 - App Registration has no owner +# Usage: ./fix_az_idn_010.sh + +set -euo pipefail + +APP_ID=${1:-} +OWNER_ID=${2:-} +if [ -z "$APP_ID" ] || [ -z "$OWNER_ID" ]; then + echo "Usage: $0 " + exit 1 +fi + +az account show --output none +az ad app show --id "$APP_ID" --query "{appId:appId,displayName:displayName}" --output table +read -r -p "Type APPLY to add owner '$OWNER_ID': " CONFIRM +[ "$CONFIRM" = "APPLY" ] || { echo "Cancelled."; exit 1; } + +az ad app owner add --id "$APP_ID" --owner-object-id "$OWNER_ID" +az ad app owner list --id "$APP_ID" --query "[].id" --output table diff --git a/playbooks/cli/fix_az_idn_011.sh b/playbooks/cli/fix_az_idn_011.sh new file mode 100644 index 0000000..a6fbd45 --- /dev/null +++ b/playbooks/cli/fix_az_idn_011.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# OpenShield Remediation Playbook +# Rule: AZ-IDN-011 - App Registration uses insecure redirect URI +# Usage: ./fix_az_idn_011.sh + +set -euo pipefail + +APP_ID=${1:-} +if [ -z "$APP_ID" ]; then + echo "Usage: $0 " + exit 1 +fi + +az account show --output none +echo "Current redirect URIs:" +az ad app show --id "$APP_ID" \ + --query "{web:web.redirectUris,spa:spa.redirectUris,publicClient:publicClient.redirectUris}" +echo "Review every client first, then replace non-loopback HTTP entries with exact HTTPS URIs." +echo "Use 'az ad app update --id --web-redirect-uris ' for web clients." +echo "No automatic change was made because replacing an incomplete URI list can break authentication." diff --git a/playbooks/cli/fix_az_idn_012.sh b/playbooks/cli/fix_az_idn_012.sh new file mode 100644 index 0000000..2b5eaca --- /dev/null +++ b/playbooks/cli/fix_az_idn_012.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# OpenShield Remediation Playbook +# Rule: AZ-IDN-012 - App Registration enables OAuth implicit grant +# Usage: ./fix_az_idn_012.sh + +set -euo pipefail + +OBJECT_ID=${1:-} +if [ -z "$OBJECT_ID" ]; then + echo "Usage: $0 " + exit 1 +fi + +az account show --output none +echo "WARNING: Existing implicit-flow clients must migrate to authorization code with PKCE first." +read -r -p "Type APPLY to disable implicit token issuance: " CONFIRM +[ "$CONFIRM" = "APPLY" ] || { echo "Cancelled."; exit 1; } + +az rest --method PATCH \ + --uri "https://graph.microsoft.com/v1.0/applications/$OBJECT_ID" \ + --headers "Content-Type=application/json" \ + --body '{"web":{"implicitGrantSettings":{"enableAccessTokenIssuance":false,"enableIdTokenIssuance":false}}}' +echo "Implicit access-token and ID-token issuance disabled." diff --git a/playbooks/cli/fix_az_idn_013.sh b/playbooks/cli/fix_az_idn_013.sh new file mode 100644 index 0000000..9a30410 --- /dev/null +++ b/playbooks/cli/fix_az_idn_013.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# OpenShield Remediation Playbook +# Rule: AZ-IDN-013 - App Registration uses password credentials +# Usage: ./fix_az_idn_013.sh + +set -euo pipefail + +APP_ID=${1:-} +if [ -z "$APP_ID" ]; then + echo "Usage: $0 " + exit 1 +fi + +az account show --output none +az ad app credential list --id "$APP_ID" --query "[].{type:type,displayName:displayName,endDateTime:endDateTime}" --output table +echo "Migrate the workload to managed identity, workload federation, or a certificate before deleting secrets." +echo "No credential was deleted automatically because doing so can cause an immediate production outage." diff --git a/playbooks/cli/fix_az_idn_014.sh b/playbooks/cli/fix_az_idn_014.sh new file mode 100644 index 0000000..f5e863c --- /dev/null +++ b/playbooks/cli/fix_az_idn_014.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# OpenShield Remediation Playbook +# Rule: AZ-IDN-014 - Multi-tenant app lacks application-instance property lock +# Usage: ./fix_az_idn_014.sh + +set -euo pipefail + +OBJECT_ID=${1:-} +if [ -z "$OBJECT_ID" ]; then + echo "Usage: $0 " + exit 1 +fi + +az account show --output none +echo "WARNING: Property lock prevents tenant service-principal instances from changing sensitive properties." +read -r -p "Type APPLY to enable the full sensitive-property lock: " CONFIRM +[ "$CONFIRM" = "APPLY" ] || { echo "Cancelled."; exit 1; } + +az rest --method PATCH \ + --uri "https://graph.microsoft.com/v1.0/applications/$OBJECT_ID" \ + --headers "Content-Type=application/json" \ + --body '{"servicePrincipalLockConfiguration":{"isEnabled":true,"allProperties":true}}' +echo "Application-instance sensitive-property lock enabled." diff --git a/playbooks/cli/fix_az_idn_015.sh b/playbooks/cli/fix_az_idn_015.sh new file mode 100644 index 0000000..885017e --- /dev/null +++ b/playbooks/cli/fix_az_idn_015.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# OpenShield Remediation Playbook +# Rule: AZ-IDN-015 - Managed identity has privileged subscription role +# Usage: ./fix_az_idn_015.sh + +set -euo pipefail + +ASSIGNMENT_ID=${1:-} +if [ -z "$ASSIGNMENT_ID" ]; then + echo "Usage: $0 " + exit 1 +fi + +az account show --output none +az role assignment list --query "[?id=='$ASSIGNMENT_ID'].{principalId:principalId,role:roleDefinitionName,scope:scope}" \ + --output table +echo "WARNING: Confirm the workload has a narrower replacement assignment before removal." +read -r -p "Type APPLY to delete this privileged assignment: " CONFIRM +[ "$CONFIRM" = "APPLY" ] || { echo "Cancelled."; exit 1; } + +az role assignment delete --ids "$ASSIGNMENT_ID" +echo "Privileged subscription-scope assignment removed." diff --git a/scanner/azure_client.py b/scanner/azure_client.py index c44aa9d..f0e4abb 100644 --- a/scanner/azure_client.py +++ b/scanner/azure_client.py @@ -23,6 +23,22 @@ _UNSET = object() +def enum_str(value: Any, default: str = "") -> str: + """Safely coerce an Azure SDK field to its plain string form. + + Azure SDK models often return fields typed as enums (e.g. + SecurityRuleDirection, BlobAuditingPolicyState) rather than plain + strings. ``str(enum_member)`` yields something like + "SecurityRuleDirection.INBOUND", not the underlying value "Inbound", + which breaks naive string comparisons. This prefers ``.value`` when + present (covers real SDK enums and enum-like objects) and falls back + to ``str()`` for plain strings, None, or anything else. + """ + if value is None: + return default + return str(getattr(value, "value", value)) + + class AzureClient: """Wraps Azure SDK management clients for all CSPM scan operations. @@ -35,6 +51,9 @@ def __init__(self, subscription_id: str, credential: Optional[Any] = None) -> No self.subscription_id = subscription_id self.credential = credential or DefaultAzureCredential() self._managed_clusters_cache: Any = _UNSET + self._applications_cache: Any = _UNSET + self._managed_identity_principals_cache: Any = _UNSET + self._subscription_role_assignments_cache: Any = _UNSET # ------------------------------------------------------------------ # # Static helpers # @@ -42,9 +61,17 @@ def __init__(self, subscription_id: str, credential: Optional[Any] = None) -> No @staticmethod def parse_resource_id(resource_id: str) -> Dict[str, str]: - """Return resource_group and name parsed from an Azure resource ID.""" - parts = resource_id.split("/") - result: Dict[str, str] = {"name": parts[-1] if parts else ""} + """Return resource_group and name parsed from an Azure resource ID. + + Always returns both keys, even for malformed or empty IDs, so + callers can safely use parsed["resource_group"] without risking + a KeyError. + """ + parts = (resource_id or "").split("/") + result: Dict[str, str] = { + "name": parts[-1] if parts else "", + "resource_group": "", + } for idx, segment in enumerate(parts): if segment.lower() == "resourcegroups" and idx + 1 < len(parts): result["resource_group"] = parts[idx + 1] @@ -491,16 +518,72 @@ def get_diagnostic_settings(self, resource_id: str) -> Optional[bool]: # Identity / Authorization # # ------------------------------------------------------------------ # - def get_service_principals(self) -> List[Any]: - """Return role assignments whose principal type is ServicePrincipal.""" + def _get_graph_collection(self, url: str, operation: str) -> Optional[List[Dict[str, Any]]]: + """Fetch a paginated Microsoft Graph collection or return ``None`` on failure.""" + import requests + + items: List[Dict[str, Any]] = [] + try: + token = self.credential.get_token("https://graph.microsoft.com/.default") + headers = { + "Authorization": f"Bearer {token.token}", + "ConsistencyLevel": "eventual", + } + while url: + response = requests.get(url, headers=headers, timeout=30) + response.raise_for_status() + data = response.json() + items.extend(data.get("value", [])) + url = data.get("@odata.nextLink", "") + return items + except Exception as exc: + logger.error("%s failed: %s", operation, exc) + return None + + def get_applications(self) -> Optional[List[Dict[str, Any]]]: + """Return cached App Registrations with security-relevant properties and owner IDs.""" + if self._applications_cache is _UNSET: + select = ( + "id,displayName,appId,signInAudience,passwordCredentials,keyCredentials," + "web,spa,publicClient,servicePrincipalLockConfiguration" + ) + url = f"https://graph.microsoft.com/v1.0/applications?$select={select}&$expand=owners($select=id)&$top=100" + self._applications_cache = self._get_graph_collection(url, "get_applications") + return self._applications_cache + + def get_managed_identity_service_principals(self) -> Optional[List[Dict[str, Any]]]: + """Return cached Microsoft Entra service principals representing managed identities.""" + if self._managed_identity_principals_cache is _UNSET: + url = ( + "https://graph.microsoft.com/v1.0/servicePrincipals" + "?$filter=servicePrincipalType eq 'ManagedIdentity'" + "&$select=id,displayName,servicePrincipalType&$count=true&$top=100" + ) + self._managed_identity_principals_cache = self._get_graph_collection( + url, + "get_managed_identity_service_principals", + ) + return self._managed_identity_principals_cache + + def get_subscription_role_assignments(self) -> Optional[List[Any]]: + """Return cached subscription-scope RBAC assignments, preserving API failure as ``None``.""" + if self._subscription_role_assignments_cache is not _UNSET: + return self._subscription_role_assignments_cache try: client = AuthorizationManagementClient(self.credential, self.subscription_id) scope = f"/subscriptions/{self.subscription_id}" - assignments = list(client.role_assignments.list_for_scope(scope)) - return [a for a in assignments if getattr(a, "principal_type", "") == "ServicePrincipal"] + self._subscription_role_assignments_cache = list(client.role_assignments.list_for_scope(scope)) except Exception as exc: - logger.error("get_service_principals failed: %s", exc) + logger.error("get_subscription_role_assignments failed: %s", exc) + self._subscription_role_assignments_cache = None + return self._subscription_role_assignments_cache + + def get_service_principals(self) -> List[Any]: + """Return role assignments whose principal type is ServicePrincipal.""" + assignments = self.get_subscription_role_assignments() + if assignments is None: return [] + return [a for a in assignments if getattr(a, "principal_type", "") == "ServicePrincipal"] def get_postgresql_flexible_servers(self) -> List[Any]: """List all PostgreSQL Flexible Server instances in the subscription.""" diff --git a/scanner/rules/_identity_common.py b/scanner/rules/_identity_common.py new file mode 100644 index 0000000..b122dba --- /dev/null +++ b/scanner/rules/_identity_common.py @@ -0,0 +1,40 @@ +"""Shared helpers for App Registration and managed-identity rules.""" + +from typing import Any, Dict, Mapping + + +def application_identity(application: Mapping[str, Any]) -> tuple[str, str]: + """Return the Graph application object ID and a safe display name.""" + object_id = str(application.get("id", "") or "") + display_name = str(application.get("displayName") or application.get("appId") or object_id) + return object_id, display_name + + +def application_finding( + application: Mapping[str, Any], + *, + rule_id: str, + rule_name: str, + severity: str, + description: str, + remediation: str, + playbook: str, + frameworks: Mapping[str, str], + metadata: Mapping[str, Any], +) -> Dict[str, Any]: + """Build the repository-standard finding for a Microsoft Graph application.""" + object_id, display_name = application_identity(application) + return { + "rule_id": rule_id, + "rule_name": rule_name, + "severity": severity, + "category": "Identity", + "resource_id": f"/applications/{object_id}", + "resource_name": display_name, + "resource_type": "Microsoft.Graph/applications", + "description": description, + "remediation": remediation, + "playbook": playbook, + "frameworks": dict(frameworks), + "metadata": dict(metadata), + } diff --git a/scanner/rules/az_db_002.py b/scanner/rules/az_db_002.py index 6c0752b..67b697b 100644 --- a/scanner/rules/az_db_002.py +++ b/scanner/rules/az_db_002.py @@ -1,7 +1,10 @@ """AZ-DB-002: Azure SQL server has no auditing configured.""" +import logging from typing import Any, Dict, List +from scanner.azure_client import enum_str + RULE_ID = "AZ-DB-002" RULE_NAME = "Azure SQL Server Has No Auditing Configured" SEVERITY = "MEDIUM" @@ -19,6 +22,8 @@ ) PLAYBOOK = "playbooks/cli/fix_az_db_002.sh" +logger = logging.getLogger(__name__) + def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: """Detect SQL servers where server-level blob auditing is disabled.""" @@ -28,15 +33,27 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: parsed = azure_client.parse_resource_id(server.id) resource_group = parsed.get("resource_group", "") if not resource_group: + logger.warning( + "Skipping AZ-DB-002 check for %s: could not parse resource group from malformed ARM ID %r", + getattr(server, "name", ""), + getattr(server, "id", ""), + ) continue policy = azure_client.get_sql_server_auditing_policy(resource_group, server.name) if policy is None: - # Could not retrieve policy — treat as unaudited - is_disabled = True - else: - state = str(getattr(policy, "state", "Disabled")) - is_disabled = state.lower() != "enabled" + # Could not retrieve the policy (API/auth failure, throttling, etc). + # Skip this resource rather than flagging it — we don't actually + # know its auditing state, so treating a failed call as + # "disabled" produces a false positive. + logger.warning( + "Skipping AZ-DB-002 check for %s: could not retrieve auditing policy", + server.name, + ) + continue + + state = enum_str(getattr(policy, "state", None), default="Disabled") + is_disabled = state.lower() != "enabled" if is_disabled: findings.append( @@ -54,7 +71,7 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: "frameworks": FRAMEWORKS, "metadata": { "resource_group": resource_group, - "auditing_state": getattr(policy, "state", "Unknown") if policy else "Unknown", + "auditing_state": state, }, } ) diff --git a/scanner/rules/az_idn_006.py b/scanner/rules/az_idn_006.py index 9a8065a..ebffe97 100644 --- a/scanner/rules/az_idn_006.py +++ b/scanner/rules/az_idn_006.py @@ -33,25 +33,9 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: """Detect service principals with stale or non-expiring client secrets.""" findings: List[Dict[str, Any]] = [] - try: - import requests - - token = azure_client.credential.get_token("https://graph.microsoft.com/.default") - headers = {"Authorization": f"Bearer {token.token}"} - - next_url = ( - "https://graph.microsoft.com/v1.0/applications?$select=id,displayName,appId,passwordCredentials&$top=100" - ) - applications = [] - while next_url: - response = requests.get(next_url, headers=headers, timeout=30) - response.raise_for_status() - data = response.json() - applications.extend(data.get("value", [])) - next_url = data.get("@odata.nextLink") - - except Exception as exc: - logger.error("AZ-IDN-006: Failed to fetch applications from Graph API: %s", exc) + applications = azure_client.get_applications() + if applications is None: + logger.error("AZ-IDN-006: Application inventory is unavailable") logger.warning( "AZ-IDN-006: Ensure the service principal has Application.Read.All permission on Microsoft Graph." ) diff --git a/scanner/rules/az_idn_010.py b/scanner/rules/az_idn_010.py new file mode 100644 index 0000000..e09b94f --- /dev/null +++ b/scanner/rules/az_idn_010.py @@ -0,0 +1,51 @@ +"""AZ-IDN-010: App Registration has no assigned owner.""" + +import logging +from typing import Any, Dict, List + +from scanner.rules._identity_common import application_finding, application_identity + +RULE_ID = "AZ-IDN-010" +RULE_NAME = "App Registration Has No Owner" +SEVERITY = "MEDIUM" +CATEGORY = "Identity" +FRAMEWORKS = {"CIS": "TBD-IDN-010", "NIST": "PR.AC-4", "ISO27001": "A.9.2.1", "SOC2": "CC6.2"} +DESCRIPTION = ( + "The App Registration has no assigned owner. Unowned applications can escape periodic review, " + "credential rotation, permission cleanup, and accountable incident response." +) +REMEDIATION = "Assign at least one accountable owner and establish a periodic application ownership review." +PLAYBOOK = "playbooks/cli/fix_az_idn_010.sh" + +logger = logging.getLogger(__name__) + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + applications = azure_client.get_applications() + if applications is None: + logger.warning("%s: application inventory is unavailable", RULE_ID) + return findings + for application in applications: + object_id, _ = application_identity(application) + if not object_id: + continue + owners = application.get("owners") + if owners is None: + logger.warning("%s: owner state is unavailable for application %s", RULE_ID, object_id) + continue + if not owners: + findings.append( + application_finding( + application, + rule_id=RULE_ID, + rule_name=RULE_NAME, + severity=SEVERITY, + description=DESCRIPTION, + remediation=REMEDIATION, + playbook=PLAYBOOK, + frameworks=FRAMEWORKS, + metadata={"owner_count": 0}, + ) + ) + return findings diff --git a/scanner/rules/az_idn_011.py b/scanner/rules/az_idn_011.py new file mode 100644 index 0000000..c93e6f1 --- /dev/null +++ b/scanner/rules/az_idn_011.py @@ -0,0 +1,75 @@ +"""AZ-IDN-011: App Registration contains an insecure HTTP redirect URI.""" + +import ipaddress +import logging +from typing import Any, Dict, Iterable, List, Mapping +from urllib.parse import urlparse + +from scanner.rules._identity_common import application_finding, application_identity + +RULE_ID = "AZ-IDN-011" +RULE_NAME = "App Registration Uses Insecure Redirect URI" +SEVERITY = "HIGH" +CATEGORY = "Identity" +FRAMEWORKS = {"CIS": "TBD-IDN-011", "NIST": "PR.DS-2", "ISO27001": "A.14.1.2", "SOC2": "CC6.7"} +DESCRIPTION = ( + "The App Registration contains an HTTP redirect URI for a non-loopback host. Authorization " + "responses can be intercepted or modified before reaching the application." +) +REMEDIATION = "Replace non-loopback HTTP redirect URIs with exact HTTPS URIs and remove obsolete entries." +PLAYBOOK = "playbooks/cli/fix_az_idn_011.sh" + +logger = logging.getLogger(__name__) + + +def _redirect_uris(application: Mapping[str, Any]) -> Iterable[str]: + for profile_name in ("web", "spa", "publicClient"): + profile = application.get(profile_name) or {} + for uri in profile.get("redirectUris", []) or []: + if isinstance(uri, str): + yield uri + + +def _is_loopback(hostname: str) -> bool: + if hostname.lower() == "localhost": + return True + try: + return ipaddress.ip_address(hostname).is_loopback + except ValueError: + return False + + +def _is_insecure_http_redirect(uri: str) -> bool: + parsed = urlparse(uri) + if parsed.scheme.lower() != "http": + return False + return not parsed.hostname or not _is_loopback(parsed.hostname) + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + applications = azure_client.get_applications() + if applications is None: + logger.warning("%s: application inventory is unavailable", RULE_ID) + return findings + for application in applications: + object_id, _ = application_identity(application) + if not object_id: + continue + insecure_uris = [uri for uri in _redirect_uris(application) if _is_insecure_http_redirect(uri)] + if insecure_uris: + schemes = sorted({urlparse(uri).scheme.lower() for uri in insecure_uris}) + findings.append( + application_finding( + application, + rule_id=RULE_ID, + rule_name=RULE_NAME, + severity=SEVERITY, + description=DESCRIPTION, + remediation=REMEDIATION, + playbook=PLAYBOOK, + frameworks=FRAMEWORKS, + metadata={"insecure_redirect_count": len(insecure_uris), "schemes": schemes}, + ) + ) + return findings diff --git a/scanner/rules/az_idn_012.py b/scanner/rules/az_idn_012.py new file mode 100644 index 0000000..a9674fc --- /dev/null +++ b/scanner/rules/az_idn_012.py @@ -0,0 +1,50 @@ +"""AZ-IDN-012: App Registration enables OAuth implicit grant.""" + +import logging +from typing import Any, Dict, List + +from scanner.rules._identity_common import application_finding, application_identity + +RULE_ID = "AZ-IDN-012" +RULE_NAME = "App Registration Enables OAuth Implicit Grant" +SEVERITY = "MEDIUM" +CATEGORY = "Identity" +FRAMEWORKS = {"CIS": "TBD-IDN-012", "NIST": "PR.AC-3", "ISO27001": "A.9.4.2", "SOC2": "CC6.1"} +DESCRIPTION = ( + "The App Registration enables access-token or ID-token issuance through the legacy implicit " + "grant flow. Tokens can be exposed to browser history, extensions, or front-channel leakage." +) +REMEDIATION = "Disable implicit grant and migrate clients to authorization code flow with PKCE." +PLAYBOOK = "playbooks/cli/fix_az_idn_012.sh" + +logger = logging.getLogger(__name__) + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + applications = azure_client.get_applications() + if applications is None: + logger.warning("%s: application inventory is unavailable", RULE_ID) + return findings + for application in applications: + object_id, _ = application_identity(application) + if not object_id: + continue + settings = (application.get("web") or {}).get("implicitGrantSettings") or {} + access_tokens = bool(settings.get("enableAccessTokenIssuance", False)) + id_tokens = bool(settings.get("enableIdTokenIssuance", False)) + if access_tokens or id_tokens: + findings.append( + application_finding( + application, + rule_id=RULE_ID, + rule_name=RULE_NAME, + severity=SEVERITY, + description=DESCRIPTION, + remediation=REMEDIATION, + playbook=PLAYBOOK, + frameworks=FRAMEWORKS, + metadata={"access_token_issuance": access_tokens, "id_token_issuance": id_tokens}, + ) + ) + return findings diff --git a/scanner/rules/az_idn_013.py b/scanner/rules/az_idn_013.py new file mode 100644 index 0000000..703021d --- /dev/null +++ b/scanner/rules/az_idn_013.py @@ -0,0 +1,51 @@ +"""AZ-IDN-013: App Registration uses password credentials.""" + +import logging +from typing import Any, Dict, List + +from scanner.rules._identity_common import application_finding, application_identity + +RULE_ID = "AZ-IDN-013" +RULE_NAME = "App Registration Uses Password Credentials" +SEVERITY = "MEDIUM" +CATEGORY = "Identity" +FRAMEWORKS = {"CIS": "TBD-IDN-013", "NIST": "PR.AC-1", "ISO27001": "A.9.4.3", "SOC2": "CC6.1"} +DESCRIPTION = ( + "The App Registration has one or more password credentials. Client secrets are commonly copied, " + "logged, leaked, or left unrotated and are weaker than managed identity or certificate authentication." +) +REMEDIATION = "Migrate to managed identity, workload identity federation, or certificates, then remove client secrets." +PLAYBOOK = "playbooks/cli/fix_az_idn_013.sh" + +logger = logging.getLogger(__name__) + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + applications = azure_client.get_applications() + if applications is None: + logger.warning("%s: application inventory is unavailable", RULE_ID) + return findings + for application in applications: + object_id, _ = application_identity(application) + if not object_id: + continue + password_credentials = application.get("passwordCredentials") or [] + if password_credentials: + findings.append( + application_finding( + application, + rule_id=RULE_ID, + rule_name=RULE_NAME, + severity=SEVERITY, + description=DESCRIPTION, + remediation=REMEDIATION, + playbook=PLAYBOOK, + frameworks=FRAMEWORKS, + metadata={ + "password_credential_count": len(password_credentials), + "certificate_credential_count": len(application.get("keyCredentials") or []), + }, + ) + ) + return findings diff --git a/scanner/rules/az_idn_014.py b/scanner/rules/az_idn_014.py new file mode 100644 index 0000000..0fc0ffd --- /dev/null +++ b/scanner/rules/az_idn_014.py @@ -0,0 +1,55 @@ +"""AZ-IDN-014: Multi-tenant App Registration lacks application-instance property lock.""" + +import logging +from typing import Any, Dict, List + +from scanner.rules._identity_common import application_finding, application_identity + +RULE_ID = "AZ-IDN-014" +RULE_NAME = "Multi-Tenant App Registration Lacks Property Lock" +SEVERITY = "HIGH" +CATEGORY = "Identity" +FRAMEWORKS = {"CIS": "TBD-IDN-014", "NIST": "PR.IP-1", "ISO27001": "A.12.1.2", "SOC2": "CC6.6"} +DESCRIPTION = ( + "The multi-tenant App Registration does not lock all sensitive properties on its service-principal " + "instances. Tenant administrators can modify credentials or token-encryption settings unexpectedly." +) +REMEDIATION = "Enable application-instance property lock and lock all sensitive service-principal properties." +PLAYBOOK = "playbooks/cli/fix_az_idn_014.sh" + +logger = logging.getLogger(__name__) +_MULTI_TENANT_AUDIENCES = {"AzureADMultipleOrgs", "AzureADandPersonalMicrosoftAccount"} + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + applications = azure_client.get_applications() + if applications is None: + logger.warning("%s: application inventory is unavailable", RULE_ID) + return findings + for application in applications: + object_id, _ = application_identity(application) + if not object_id or application.get("signInAudience") not in _MULTI_TENANT_AUDIENCES: + continue + lock = application.get("servicePrincipalLockConfiguration") or {} + enabled = bool(lock.get("isEnabled", False)) + all_properties = bool(lock.get("allProperties", False)) + if not (enabled and all_properties): + findings.append( + application_finding( + application, + rule_id=RULE_ID, + rule_name=RULE_NAME, + severity=SEVERITY, + description=DESCRIPTION, + remediation=REMEDIATION, + playbook=PLAYBOOK, + frameworks=FRAMEWORKS, + metadata={ + "sign_in_audience": application.get("signInAudience"), + "lock_enabled": enabled, + "all_sensitive_properties_locked": all_properties, + }, + ) + ) + return findings diff --git a/scanner/rules/az_idn_015.py b/scanner/rules/az_idn_015.py new file mode 100644 index 0000000..1b78a4d --- /dev/null +++ b/scanner/rules/az_idn_015.py @@ -0,0 +1,63 @@ +"""AZ-IDN-015: Managed identity has a privileged subscription-scope role.""" + +import logging +from typing import Any, Dict, List + +RULE_ID = "AZ-IDN-015" +RULE_NAME = "Managed Identity Has Privileged Subscription Role" +SEVERITY = "HIGH" +CATEGORY = "Identity" +FRAMEWORKS = {"CIS": "TBD-IDN-015", "NIST": "PR.AC-4", "ISO27001": "A.9.2.3", "SOC2": "CC6.3"} +DESCRIPTION = ( + "A managed identity holds Owner or Contributor at subscription scope. Compromise of any resource " + "that can use the identity would provide an unnecessarily large Azure control-plane blast radius." +) +REMEDIATION = "Replace the assignment with the narrowest role and resource scope required by the workload." +PLAYBOOK = "playbooks/cli/fix_az_idn_015.sh" + +OWNER_ROLE_GUID = "8e3af657-a8ff-443c-a75c-2fe8c4bcb635" +CONTRIBUTOR_ROLE_GUID = "b24988ac-6180-42a0-ab88-20f7382dd24c" +_PRIVILEGED_ROLES = {OWNER_ROLE_GUID: "Owner", CONTRIBUTOR_ROLE_GUID: "Contributor"} + +logger = logging.getLogger(__name__) + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + principals = azure_client.get_managed_identity_service_principals() + assignments = azure_client.get_subscription_role_assignments() + if principals is None or assignments is None: + logger.warning("%s: managed-identity or RBAC inventory is unavailable", RULE_ID) + return findings + + principal_names = {str(item.get("id")): str(item.get("displayName") or item.get("id")) for item in principals} + subscription_scope = f"/subscriptions/{subscription_id}".lower() + for assignment in assignments: + principal_id = str(getattr(assignment, "principal_id", "") or "") + if principal_id not in principal_names: + continue + scope = str(getattr(assignment, "scope", "") or "") + if scope.rstrip("/").lower() != subscription_scope: + continue + role_definition_id = str(getattr(assignment, "role_definition_id", "") or "") + role_guid = role_definition_id.rstrip("/").split("/")[-1].lower() + role_name = _PRIVILEGED_ROLES.get(role_guid) + if not role_name: + continue + findings.append( + { + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": str(getattr(assignment, "id", "") or ""), + "resource_name": principal_names[principal_id], + "resource_type": "Microsoft.Authorization/roleAssignments", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": {"principal_id": principal_id, "role": role_name, "scope": scope}, + } + ) + return findings diff --git a/scanner/rules/az_net_003.py b/scanner/rules/az_net_003.py index f2d1bb4..f6a042b 100644 --- a/scanner/rules/az_net_003.py +++ b/scanner/rules/az_net_003.py @@ -3,6 +3,8 @@ import logging from typing import Any, Dict, List +from scanner.azure_client import enum_str + RULE_ID = "AZ-NET-003" RULE_NAME = "NSG allows unrestricted inbound on port 443" SEVERITY = "HIGH" @@ -33,10 +35,21 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: for nsg in azure_client.get_network_security_groups(): for rule in getattr(nsg, "security_rules", []) or []: + direction = enum_str(getattr(rule, "direction", None)) + access = enum_str(getattr(rule, "access", None)) + allowed_sources = {"*", "0.0.0.0/0", "internet", "any"} + single_prefix = enum_str(getattr(rule, "source_address_prefix", None)) + plural_prefixes = getattr(rule, "source_address_prefixes", None) or [] + matched_plural_prefix = next( + (prefix for prefix in plural_prefixes if enum_str(prefix).lower() in allowed_sources), + None, + ) + source_matches = single_prefix.lower() in allowed_sources or matched_plural_prefix is not None + if ( - getattr(rule, "direction", "") == "Inbound" - and getattr(rule, "access", "") == "Allow" - and getattr(rule, "source_address_prefix", "") in ("*", "0.0.0.0/0", "Internet", "Any") + direction.lower() == "inbound" + and access.lower() == "allow" + and source_matches and getattr(rule, "destination_port_range", "") in ("443", "*") ): findings.append( @@ -54,7 +67,8 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: "frameworks": FRAMEWORKS, "metadata": { "rule_name": getattr(rule, "name", ""), - "source_prefix": getattr(rule, "source_address_prefix", ""), + "source_prefix": single_prefix if single_prefix.lower() in allowed_sources else "", + "matched_source_address_prefix": matched_plural_prefix, }, } ) diff --git a/tests/helpers/mock_azure.py b/tests/helpers/mock_azure.py index 508619e..7cb3aa2 100644 --- a/tests/helpers/mock_azure.py +++ b/tests/helpers/mock_azure.py @@ -82,6 +82,9 @@ def __init__(self) -> None: self._dns_record_sets: Dict[Tuple[str, str], List[Any]] = {} self._web_apps: List[Any] = [] self._managed_clusters: Optional[List[Any]] = [] + self._applications: Optional[List[Dict[str, Any]]] = [] + self._managed_identity_principals: Optional[List[Dict[str, Any]]] = [] + self._subscription_role_assignments: 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" @@ -98,6 +101,27 @@ def set_managed_clusters(self, clusters: Optional[List[Any]]) -> "MockAzureClien def get_managed_clusters(self) -> Optional[List[Any]]: return self._managed_clusters + def set_applications(self, applications: Optional[List[Dict[str, Any]]]) -> "MockAzureClient": + self._applications = applications + return self + + def get_applications(self) -> Optional[List[Dict[str, Any]]]: + return self._applications + + def set_managed_identity_service_principals(self, principals: Optional[List[Dict[str, Any]]]) -> "MockAzureClient": + self._managed_identity_principals = principals + return self + + def get_managed_identity_service_principals(self) -> Optional[List[Dict[str, Any]]]: + return self._managed_identity_principals + + def set_subscription_role_assignments(self, assignments: Optional[List[Any]]) -> "MockAzureClient": + self._subscription_role_assignments = assignments + return self + + def get_subscription_role_assignments(self) -> Optional[List[Any]]: + return self._subscription_role_assignments + def set_network_security_groups(self, nsgs: List[Any]) -> "MockAzureClient": self._network_security_groups = nsgs return self @@ -346,9 +370,17 @@ def get_web_apps(self) -> List[Any]: @staticmethod def parse_resource_id(resource_id: str) -> Dict[str, str]: - """Parse an Azure resource ID into a dict with name and resource_group.""" - parts = resource_id.split("/") - result: Dict[str, str] = {"name": parts[-1] if parts else ""} + """Parse an Azure resource ID into a dict with name and resource_group. + + Always returns both keys, even for malformed or empty IDs, so + callers can safely use parsed["resource_group"] without risking + a KeyError. + """ + parts = (resource_id or "").split("/") + result: Dict[str, str] = { + "name": parts[-1] if parts else "", + "resource_group": "", + } for idx, segment in enumerate(parts): if segment.lower() == "resourcegroups" and idx + 1 < len(parts): result["resource_group"] = parts[idx + 1] diff --git a/tests/test_azure_client_identity_inventory.py b/tests/test_azure_client_identity_inventory.py new file mode 100644 index 0000000..9728c4c --- /dev/null +++ b/tests/test_azure_client_identity_inventory.py @@ -0,0 +1,63 @@ +"""Tests for cached Microsoft Graph and RBAC identity inventory accessors.""" + +from unittest.mock import MagicMock, patch + +from scanner.azure_client import AzureClient + + +class Response: + def __init__(self, payload): + self.payload = payload + + def raise_for_status(self): + return None + + def json(self): + return self.payload + + +@patch("requests.get") +def test_application_inventory_follows_pagination_and_caches(request_get): + request_get.side_effect = [ + Response({"value": [{"id": "a1"}], "@odata.nextLink": "https://graph.microsoft.com/page2"}), + Response({"value": [{"id": "a2"}]}), + ] + client = AzureClient("sub-1", credential=MagicMock()) + + assert client.get_applications() == [{"id": "a1"}, {"id": "a2"}] + assert client.get_applications() == [{"id": "a1"}, {"id": "a2"}] + assert request_get.call_count == 2 + + +@patch("requests.get") +def test_application_inventory_preserves_graph_failure(request_get): + request_get.side_effect = RuntimeError("graph unavailable") + client = AzureClient("sub-1", credential=MagicMock()) + assert client.get_applications() is None + assert client.get_applications() is None + request_get.assert_called_once() + + +@patch("requests.get") +def test_managed_identity_principal_inventory_uses_type_filter(request_get): + request_get.return_value = Response({"value": [{"id": "mi-1"}]}) + client = AzureClient("sub-1", credential=MagicMock()) + assert client.get_managed_identity_service_principals() == [{"id": "mi-1"}] + requested_url = request_get.call_args.args[0] + assert "servicePrincipalType eq 'ManagedIdentity'" in requested_url + + +@patch("scanner.azure_client.AuthorizationManagementClient") +def test_subscription_role_assignments_cache_success(auth_client_type): + auth_client_type.return_value.role_assignments.list_for_scope.return_value = ["assignment"] + client = AzureClient("sub-1", credential=MagicMock()) + assert client.get_subscription_role_assignments() == ["assignment"] + assert client.get_subscription_role_assignments() == ["assignment"] + auth_client_type.return_value.role_assignments.list_for_scope.assert_called_once_with("/subscriptions/sub-1") + + +@patch("scanner.azure_client.AuthorizationManagementClient") +def test_subscription_role_assignments_preserves_failure(auth_client_type): + auth_client_type.return_value.role_assignments.list_for_scope.side_effect = RuntimeError("denied") + client = AzureClient("sub-1", credential=MagicMock()) + assert client.get_subscription_role_assignments() is None diff --git a/tests/test_azure_client_management.py b/tests/test_azure_client_management.py new file mode 100644 index 0000000..a0f6552 --- /dev/null +++ b/tests/test_azure_client_management.py @@ -0,0 +1,96 @@ +"""Direct tests for AzureClient management-plane wrappers and failure states.""" + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from scanner.azure_client import AzureClient + + +@pytest.fixture +def client(): + return AzureClient("sub-1", credential=MagicMock()) + + +def test_parse_resource_id_handles_full_and_short_ids(): + full = "/subscriptions/s/resourceGroups/RG/providers/Microsoft.Web/sites/app" + assert AzureClient.parse_resource_id(full) == {"resource_group": "RG", "name": "app"} + assert AzureClient.parse_resource_id("app") == {"name": "app", "resource_group": ""} + + +@pytest.mark.parametrize( + ("method", "patch_target", "operation"), + [ + ("get_storage_accounts", "scanner.azure_client.StorageManagementClient", "storage_accounts.list"), + ( + "get_network_security_groups", + "scanner.azure_client.NetworkManagementClient", + "network_security_groups.list_all", + ), + ("get_virtual_networks", "scanner.azure_client.NetworkManagementClient", "virtual_networks.list_all"), + ("get_public_ip_addresses", "scanner.azure_client.NetworkManagementClient", "public_ip_addresses.list_all"), + ("get_load_balancers", "scanner.azure_client.NetworkManagementClient", "load_balancers.list_all"), + ("get_virtual_machines", "scanner.azure_client.ComputeManagementClient", "virtual_machines.list_all"), + ("get_postgresql_servers", "scanner.azure_client.PostgreSQLManagementClient", "servers.list"), + ("get_sql_servers", "scanner.azure_client.SqlManagementClient", "servers.list"), + ("get_key_vaults", "scanner.azure_client.KeyVaultManagementClient", "vaults.list_by_subscription"), + ], +) +def test_list_wrappers_return_results_and_fail_closed(client, method, patch_target, operation): + with patch(patch_target) as constructor: + sdk = constructor.return_value + target = sdk + parts = operation.split(".") + for part in parts[:-1]: + target = getattr(target, part) + call = getattr(target, parts[-1]) + call.return_value = [SimpleNamespace(name="one")] + assert [item.name for item in getattr(client, method)()] == ["one"] + + call.side_effect = RuntimeError("Azure unavailable") + assert getattr(client, method)() == [] + + +def test_single_resource_and_policy_wrappers(client): + with patch("scanner.azure_client.NetworkManagementClient") as constructor: + constructor.return_value.network_interfaces.get.return_value = SimpleNamespace(name="nic") + assert client.get_network_interface("rg", "nic").name == "nic" + constructor.return_value.network_interfaces.get.side_effect = RuntimeError("denied") + assert client.get_network_interface("rg", "nic") is None + + with patch("scanner.azure_client.SqlManagementClient") as constructor: + policy = SimpleNamespace(state="Enabled") + constructor.return_value.server_blob_auditing_policies.get.return_value = policy + assert client.get_sql_server_auditing_policy("rg", "sql") is policy + constructor.return_value.server_blob_auditing_policies.get.side_effect = RuntimeError("denied") + assert client.get_sql_server_auditing_policy("rg", "sql") is None + + +def test_firewall_and_peering_wrappers(client): + with patch("scanner.azure_client.NetworkManagementClient") as constructor: + sdk = constructor.return_value + sdk.azure_firewalls.list.return_value = [SimpleNamespace(name="fw")] + sdk.azure_firewalls.list_all.return_value = [SimpleNamespace(name="fw-all")] + sdk.virtual_network_peerings.list.return_value = [SimpleNamespace(name="peer")] + + assert client.get_azure_firewalls("rg")[0].name == "fw" + assert client.get_all_azure_firewalls()[0].name == "fw-all" + assert client.get_vnet_peerings("rg", "vnet")[0].name == "peer" + + sdk.azure_firewalls.list.side_effect = RuntimeError("denied") + sdk.azure_firewalls.list_all.side_effect = RuntimeError("denied") + sdk.virtual_network_peerings.list.side_effect = RuntimeError("denied") + assert client.get_azure_firewalls("rg") == [] + assert client.get_all_azure_firewalls() is None + assert client.get_vnet_peerings("rg", "vnet") == [] + + +def test_vm_extensions_normalize_sdk_page_and_failure(client): + with patch("scanner.azure_client.ComputeManagementClient") as constructor: + constructor.return_value.virtual_machine_extensions.list.return_value = SimpleNamespace( + value=[SimpleNamespace(name="agent")] + ) + assert client.get_vm_extensions("rg", "vm")[0].name == "agent" + constructor.return_value.virtual_machine_extensions.list.side_effect = RuntimeError("denied") + assert client.get_vm_extensions("rg", "vm") is None diff --git a/tests/test_engine_integration.py b/tests/test_engine_integration.py index aa797a8..90401ef 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_51_rules(monkeypatch): +def test_engine_loads_all_57_rules(monkeypatch): """The engine must dynamically load the complete rule set.""" _patch_engine_client(monkeypatch, _offline_mock()) eng = ScanEngine(_SUB) - assert len(eng.rules) >= 51 + assert len(eng.rules) >= 57 # Every loaded rule must expose a callable scan() and a RULE_ID. for rule in eng.rules: assert callable(getattr(rule, "scan", None)) diff --git a/tests/test_rules_database.py b/tests/test_rules_database.py index cef4e73..a89d240 100644 --- a/tests/test_rules_database.py +++ b/tests/test_rules_database.py @@ -1,11 +1,20 @@ """Rule regression tests for the database rules AZ-DB-001 .. AZ-DB-004.""" +import pytest + import scanner.rules.az_db_001 as az_db_001 import scanner.rules.az_db_002 as az_db_002 import scanner.rules.az_db_003 as az_db_003 import scanner.rules.az_db_004 as az_db_004 from tests.helpers.mock_azure import make_resource +try: + from azure.mgmt.sql.models import BlobAuditingPolicyState, ServerBlobAuditingPolicy + + _AZURE_SQL_SDK_AVAILABLE = True +except ImportError: # pragma: no cover - exercised only when SDK isn't installed + _AZURE_SQL_SDK_AVAILABLE = False + _REQUIRED_FIELDS = { "rule_id", "rule_name", @@ -73,6 +82,87 @@ def test_db_004_no_firewall_rules_returns_no_findings(mock_azure, subscription_i assert findings == [] +def test_db_002_disabled_policy_returns_one_finding(mock_azure, subscription_id): + """A SQL Server with auditing explicitly disabled must produce one finding.""" + server = make_resource(id=_sql_id("sql-unaudited"), name="sql-unaudited") + policy = make_resource(state="Disabled") + mock_azure.set_sql_servers([server]) + mock_azure.set_sql_server_auditing_policy(_RG, "sql-unaudited", policy) + findings = az_db_002.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["rule_id"] == "AZ-DB-002" + + +def test_db_002_enabled_policy_returns_no_findings(mock_azure, subscription_id): + """A SQL Server with auditing enabled must produce no findings.""" + server = make_resource(id=_sql_id("sql-audited"), name="sql-audited") + policy = make_resource(state="Enabled") + mock_azure.set_sql_servers([server]) + mock_azure.set_sql_server_auditing_policy(_RG, "sql-audited", policy) + findings = az_db_002.scan(mock_azure, subscription_id) + assert findings == [] + + +def test_db_002_api_failure_returns_no_findings(mock_azure, subscription_id): + """COR-003: a failed policy lookup (None) must be skipped, not flagged. + + Previously, a None policy (API/auth failure) was treated the same as an + explicitly disabled policy, producing a false positive. + """ + server = make_resource(id=_sql_id("sql-api-failed"), name="sql-api-failed") + mock_azure.set_sql_servers([server]) + mock_azure.set_sql_server_auditing_policy(_RG, "sql-api-failed", None) + findings = az_db_002.scan(mock_azure, subscription_id) + assert findings == [] + + +def test_db_002_malformed_arm_id_does_not_raise(mock_azure, subscription_id): + """COR-004: a server with a malformed ARM ID must not raise an error.""" + server = make_resource(id="not-a-valid-arm-id", name="sql-malformed") + mock_azure.set_sql_servers([server]) + findings = az_db_002.scan(mock_azure, subscription_id) + assert findings == [] + + +def test_db_004_malformed_arm_id_does_not_raise_keyerror(mock_azure, subscription_id): + """COR-004: az_db_004 indexes parsed["resource_group"] directly, so a + malformed ARM ID with no resourceGroups segment previously raised a + KeyError. parse_resource_id must always include the key. + """ + server = make_resource(id="not-a-valid-arm-id", name="sql-malformed") + mock_azure.set_sql_servers([server]) + mock_azure.set_sql_server_firewall_rules("", "sql-malformed", []) + findings = az_db_004.scan(mock_azure, subscription_id) + assert findings == [] + + +@pytest.mark.skipif(not _AZURE_SQL_SDK_AVAILABLE, reason="azure-mgmt-sql not installed") +def test_db_002_disabled_policy_with_real_sdk_enum_returns_one_finding(mock_azure, subscription_id): + """COR-003 (SDK model): a real ServerBlobAuditingPolicy with state as a + BlobAuditingPolicyState.DISABLED enum, not a plain string, must still be + flagged. str(enum) yields e.g. "BlobAuditingPolicyState.DISABLED" rather + than "Disabled", so the rule must normalise via .value instead of naive str().""" + server = make_resource(id=_sql_id("sql-unaudited-enum"), name="sql-unaudited-enum") + policy = ServerBlobAuditingPolicy(state=BlobAuditingPolicyState.DISABLED) + mock_azure.set_sql_servers([server]) + mock_azure.set_sql_server_auditing_policy(_RG, "sql-unaudited-enum", policy) + findings = az_db_002.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["metadata"]["auditing_state"] == "Disabled" + + +@pytest.mark.skipif(not _AZURE_SQL_SDK_AVAILABLE, reason="azure-mgmt-sql not installed") +def test_db_002_enabled_policy_with_real_sdk_enum_returns_no_findings(mock_azure, subscription_id): + """COR-003 (SDK model): a real enabled policy using the SDK enum must + not be falsely flagged as disabled.""" + server = make_resource(id=_sql_id("sql-audited-enum"), name="sql-audited-enum") + policy = ServerBlobAuditingPolicy(state=BlobAuditingPolicyState.ENABLED) + mock_azure.set_sql_servers([server]) + mock_azure.set_sql_server_auditing_policy(_RG, "sql-audited-enum", policy) + findings = az_db_002.scan(mock_azure, subscription_id) + assert findings == [] + + def _pg_id(name, provider="Microsoft.DBforPostgreSQL/servers"): return f"/subscriptions/{_SUB}/resourceGroups/{_RG}/providers/{provider}/{name}" diff --git a/tests/test_rules_identity.py b/tests/test_rules_identity.py index 18be4ef..b2ea214 100644 --- a/tests/test_rules_identity.py +++ b/tests/test_rules_identity.py @@ -258,7 +258,7 @@ def test_idn_006_compliant_fresh_secret_returns_no_findings(mock_azure, subscrip } ] } - _install_router(monkeypatch, [("/applications", _Resp(apps))]) + mock_azure.set_applications(apps["value"]) assert az_idn_006.scan(mock_azure, subscription_id) == [] @@ -280,7 +280,7 @@ def test_idn_006_noncompliant_secret_no_expiry_returns_finding(mock_azure, subsc } ] } - _install_router(monkeypatch, [("/applications", _Resp(apps))]) + mock_azure.set_applications(apps["value"]) findings = az_idn_006.scan(mock_azure, subscription_id) assert len(findings) == 1 assert findings[0]["rule_id"] == "AZ-IDN-006" @@ -311,7 +311,7 @@ def test_idn_006_malformed_end_date_time_does_not_log_key_id(mock_azure, subscri } ] } - _install_router(monkeypatch, [("/applications", _Resp(apps))]) + mock_azure.set_applications(apps["value"]) with caplog.at_level("DEBUG", logger="scanner.rules.az_idn_006"): findings = az_idn_006.scan(mock_azure, subscription_id) diff --git a/tests/test_rules_identity_enterprise.py b/tests/test_rules_identity_enterprise.py new file mode 100644 index 0000000..74d21ee --- /dev/null +++ b/tests/test_rules_identity_enterprise.py @@ -0,0 +1,154 @@ +"""Tests for enterprise identity rules AZ-IDN-010 through AZ-IDN-015.""" + +from types import SimpleNamespace + +import pytest + +from scanner.rules import az_idn_010, az_idn_011, az_idn_012, az_idn_013, az_idn_014, az_idn_015 + + +def app(**overrides): + values = { + "id": "app-object-1", + "displayName": "Enterprise App", + "appId": "client-id-1", + "owners": [{"id": "owner-1"}], + "signInAudience": "AzureADMyOrg", + "passwordCredentials": [], + "keyCredentials": [], + "web": {"redirectUris": ["https://app.example/callback"], "implicitGrantSettings": {}}, + "spa": {"redirectUris": []}, + "publicClient": {"redirectUris": []}, + "servicePrincipalLockConfiguration": None, + } + values.update(overrides) + return values + + +APP_RULES = [az_idn_010, az_idn_011, az_idn_012, az_idn_013, az_idn_014] + + +@pytest.mark.parametrize("rule", APP_RULES) +def test_compliant_application_returns_no_findings(rule, mock_azure, subscription_id): + mock_azure.set_applications([app()]) + assert rule.scan(mock_azure, subscription_id) == [] + + +@pytest.mark.parametrize("rule", APP_RULES) +def test_application_inventory_failure_returns_no_findings(rule, mock_azure, subscription_id): + mock_azure.set_applications(None) + assert rule.scan(mock_azure, subscription_id) == [] + + +@pytest.mark.parametrize("rule", APP_RULES) +def test_application_without_object_id_is_skipped(rule, mock_azure, subscription_id): + mock_azure.set_applications([app(id="")]) + assert rule.scan(mock_azure, subscription_id) == [] + + +def test_idn_010_unowned_application_returns_finding(mock_azure, subscription_id): + mock_azure.set_applications([app(owners=[])]) + findings = az_idn_010.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["metadata"] == {"owner_count": 0} + + +def test_idn_010_missing_expanded_owner_state_is_unknown(mock_azure, subscription_id): + application = app() + application.pop("owners") + mock_azure.set_applications([application]) + assert az_idn_010.scan(mock_azure, subscription_id) == [] + + +@pytest.mark.parametrize( + "uri", + [ + "http://app.example/callback", + "http://10.1.2.3/callback", + "http://example.com/callback", + ], +) +def test_idn_011_non_loopback_http_redirect_returns_finding(uri, mock_azure, subscription_id): + mock_azure.set_applications([app(web={"redirectUris": [uri], "implicitGrantSettings": {}})]) + findings = az_idn_011.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["metadata"]["insecure_redirect_count"] == 1 + assert uri not in str(findings[0]["metadata"]) + + +@pytest.mark.parametrize( + "uri", + [ + "http://localhost/callback", + "http://127.0.0.1:8080/callback", + "http://[::1]/callback", + "https://app.example/callback", + "msalclient-id://auth", + ], +) +def test_idn_011_secure_or_native_redirect_is_allowed(uri, mock_azure, subscription_id): + mock_azure.set_applications([app(web={"redirectUris": [uri], "implicitGrantSettings": {}})]) + assert az_idn_011.scan(mock_azure, subscription_id) == [] + + +def test_idn_012_implicit_access_token_returns_finding(mock_azure, subscription_id): + web = {"redirectUris": [], "implicitGrantSettings": {"enableAccessTokenIssuance": True}} + mock_azure.set_applications([app(web=web)]) + findings = az_idn_012.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["metadata"]["access_token_issuance"] is True + + +def test_idn_013_password_credential_returns_finding_without_identifiers(mock_azure, subscription_id): + credential = {"keyId": "must-not-leak", "hint": "xy", "displayName": "prod-secret"} + mock_azure.set_applications([app(passwordCredentials=[credential])]) + findings = az_idn_013.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["metadata"]["password_credential_count"] == 1 + assert "must-not-leak" not in str(findings[0]) + + +def test_idn_014_multitenant_without_lock_returns_finding(mock_azure, subscription_id): + mock_azure.set_applications([app(signInAudience="AzureADMultipleOrgs")]) + findings = az_idn_014.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["metadata"]["lock_enabled"] is False + + +def test_idn_014_multitenant_full_lock_is_compliant(mock_azure, subscription_id): + lock = {"isEnabled": True, "allProperties": True} + mock_azure.set_applications([app(signInAudience="AzureADMultipleOrgs", servicePrincipalLockConfiguration=lock)]) + assert az_idn_014.scan(mock_azure, subscription_id) == [] + + +def assignment(role_guid, principal_id="mi-1", scope="/subscriptions/sub-id"): + return SimpleNamespace( + id="/subscriptions/sub-id/providers/Microsoft.Authorization/roleAssignments/assignment-1", + principal_id=principal_id, + role_definition_id=f"/subscriptions/sub-id/providers/Microsoft.Authorization/roleDefinitions/{role_guid}", + scope=scope, + ) + + +@pytest.mark.parametrize("role_guid", [az_idn_015.OWNER_ROLE_GUID, az_idn_015.CONTRIBUTOR_ROLE_GUID]) +def test_idn_015_privileged_managed_identity_returns_finding(role_guid, mock_azure, subscription_id): + mock_azure.set_managed_identity_service_principals([{"id": "mi-1", "displayName": "payments-mi"}]) + mock_azure.set_subscription_role_assignments([assignment(role_guid, scope=f"/subscriptions/{subscription_id}")]) + findings = az_idn_015.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["resource_name"] == "payments-mi" + + +def test_idn_015_resource_group_scope_is_compliant(mock_azure, subscription_id): + mock_azure.set_managed_identity_service_principals([{"id": "mi-1", "displayName": "payments-mi"}]) + mock_azure.set_subscription_role_assignments( + [assignment(az_idn_015.CONTRIBUTOR_ROLE_GUID, scope=f"/subscriptions/{subscription_id}/resourceGroups/rg")] + ) + assert az_idn_015.scan(mock_azure, subscription_id) == [] + + +@pytest.mark.parametrize("failed_inventory", ["principals", "assignments"]) +def test_idn_015_inventory_failure_returns_no_findings(failed_inventory, mock_azure, subscription_id): + mock_azure.set_managed_identity_service_principals(None if failed_inventory == "principals" else []) + mock_azure.set_subscription_role_assignments(None if failed_inventory == "assignments" else []) + assert az_idn_015.scan(mock_azure, subscription_id) == [] diff --git a/tests/test_rules_network.py b/tests/test_rules_network.py index 6f876f3..f8dcfa2 100644 --- a/tests/test_rules_network.py +++ b/tests/test_rules_network.py @@ -1,4 +1,4 @@ -"""Rule regression tests for the network rules AZ-NET-001 .. AZ-NET-014. +"""Rule regression tests for the network rules AZ-NET-001 .. AZ-NET-015. AZ-NET-007/009/010 construct a NetworkManagementClient inside scan(); those tests monkeypatch azure.mgmt.network.NetworkManagementClient. The rest read data @@ -7,6 +7,8 @@ from types import SimpleNamespace +import pytest + import scanner.rules.az_net_001 as az_net_001 import scanner.rules.az_net_002 as az_net_002 import scanner.rules.az_net_003 as az_net_003 @@ -24,6 +26,13 @@ import scanner.rules.az_net_015 as az_net_015 from tests.helpers.mock_azure import make_resource +try: + from azure.mgmt.network.models import SecurityRule, SecurityRuleAccess, SecurityRuleDirection + + _AZURE_SDK_AVAILABLE = True +except ImportError: # pragma: no cover - exercised only when SDK isn't installed + _AZURE_SDK_AVAILABLE = False + def _install_network_client(monkeypatch, **collections): """Patch azure.mgmt.network.NetworkManagementClient with a fake exposing @@ -149,6 +158,17 @@ def _vnet_id(name): return f"/subscriptions/{_SUB}/resourceGroups/{_RG}/providers/Microsoft.Network/virtualNetworks/{name}" +def _net_003_rule(name, direction="Inbound", access="Allow", source="0.0.0.0/0", source_list=None, port="443"): + return make_resource( + name=name, + direction=direction, + access=access, + source_address_prefix=source, + source_address_prefixes=source_list or [], + destination_port_range=port, + ) + + # ── AZ-NET-003: unrestricted inbound on 443 ───────────────────────────────── @@ -175,6 +195,105 @@ def test_net_003_noncompliant_returns_one_finding(mock_azure, subscription_id): assert findings[0]["severity"] == "HIGH" +def test_net_003_direction_is_case_insensitive(mock_azure, subscription_id): + """COR-001: lowercase/mixed-case direction values must still be detected.""" + nsg = make_resource( + id=_nsg_id("nsg-lowercase-direction"), + name="nsg-lowercase-direction", + security_rules=[_net_003_rule("AllowHTTPSLower", direction="inbound")], + ) + mock_azure.set_network_security_groups([nsg]) + findings = az_net_003.scan(mock_azure, subscription_id) + assert len(findings) == 1 + + +def test_net_003_detects_plural_source_prefixes(mock_azure, subscription_id): + """COR-002: an open source listed only in source_address_prefixes must be detected.""" + nsg = make_resource( + id=_nsg_id("nsg-plural-prefix"), + name="nsg-plural-prefix", + security_rules=[ + _net_003_rule( + "AllowHTTPSPluralOpen", + source="", + source_list=["0.0.0.0/0"], + ) + ], + ) + mock_azure.set_network_security_groups([nsg]) + findings = az_net_003.scan(mock_azure, subscription_id) + assert len(findings) == 1 + + +@pytest.mark.skipif(not _AZURE_SDK_AVAILABLE, reason="azure-mgmt-network not installed") +def test_net_003_detects_finding_with_real_sdk_enum_direction_and_access(mock_azure, subscription_id): + """COR-001 (SDK model): real SecurityRuleDirection/Access enums, not plain strings, + must still be detected. str(enum) yields e.g. "SecurityRuleDirection.INBOUND" rather + than "Inbound", so the rule must normalise via .value instead of naive str().""" + rule = SecurityRule( + name="AllowHTTPSFromInternetEnum", + direction=SecurityRuleDirection.INBOUND, + access=SecurityRuleAccess.ALLOW, + source_address_prefix="0.0.0.0/0", + source_address_prefixes=[], + destination_port_range="443", + ) + nsg = make_resource( + id=_nsg_id("nsg-443-open-enum"), + name="nsg-443-open-enum", + security_rules=[rule], + ) + mock_azure.set_network_security_groups([nsg]) + findings = az_net_003.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["rule_id"] == "AZ-NET-003" + + +@pytest.mark.skipif(not _AZURE_SDK_AVAILABLE, reason="azure-mgmt-network not installed") +def test_net_003_compliant_with_real_sdk_enum_returns_no_findings(mock_azure, subscription_id): + """COR-001 (SDK model): real SDK enums on a restricted rule must produce no findings.""" + rule = SecurityRule( + name="AllowHTTPSFromTrustedEnum", + direction=SecurityRuleDirection.INBOUND, + access=SecurityRuleAccess.ALLOW, + source_address_prefix="10.0.0.0/24", + source_address_prefixes=[], + destination_port_range="443", + ) + nsg = make_resource( + id=_nsg_id("nsg-443-restricted-enum"), + name="nsg-443-restricted-enum", + security_rules=[rule], + ) + mock_azure.set_network_security_groups([nsg]) + findings = az_net_003.scan(mock_azure, subscription_id) + assert findings == [] + + +@pytest.mark.skipif(not _AZURE_SDK_AVAILABLE, reason="azure-mgmt-network not installed") +def test_net_003_detects_plural_source_prefixes_with_real_sdk_model(mock_azure, subscription_id): + """COR-002 (SDK model): an open source only in source_address_prefixes, using a real + SecurityRule instance with SDK enum fields, must still be detected and reported in + finding metadata.""" + rule = SecurityRule( + name="AllowHTTPSPluralOpenEnum", + direction=SecurityRuleDirection.INBOUND, + access=SecurityRuleAccess.ALLOW, + source_address_prefix=None, + source_address_prefixes=["0.0.0.0/0"], + destination_port_range="443", + ) + nsg = make_resource( + id=_nsg_id("nsg-plural-prefix-enum"), + name="nsg-plural-prefix-enum", + security_rules=[rule], + ) + mock_azure.set_network_security_groups([nsg]) + findings = az_net_003.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["metadata"]["matched_source_address_prefix"] == "0.0.0.0/0" + + # ── AZ-NET-004: NSG with no rules ─────────────────────────────────────────── diff --git a/website/content.js b/website/content.js index e0d989b..72af029 100644 --- a/website/content.js +++ b/website/content.js @@ -82,6 +82,11 @@ const siteContent = { {"id": "AZ-IDN-002", "name": "No MFA Enforced on Admin Accounts via Conditional Access", "severity": "HIGH", "category": "Identity", "description": "No Conditional Access policy is enabled that requires multi-factor authentication for administrator accounts. Without MFA enforcement, a single compromised password is sufficient for an attacker to gain privileged access.", "frameworks": {"CIS": "1.2.4", "NIST": "PR.AC-1", "ISO27001": "A.9.4.2"}}, {"id": "AZ-IDN-003", "name": "Guest User Invitations Not Restricted to Admins in Entra ID", "severity": "MEDIUM", "category": "Identity", "description": "Guest user invitations in Entra ID are not restricted to administrators. Any organisation member can invite external users without centralised review, bypassing formal external identity provisioning controls.", "frameworks": {"CIS": "1.15", "NIST": "PR.AC-1", "ISO27001": "A.9.2.1"}}, {"id": "AZ-IDN-004", "name": "No Privileged Identity Management for Admin Roles", "severity": "HIGH", "category": "Identity", "description": "Privileged Identity Management (PIM) is not configured for one or more admin roles in Entra ID. Without PIM, admin roles are permanently assigned with no just-in-time access controls or time-bound activation.", "frameworks": {"CIS": "1.16", "NIST": "PR.AC-4", "ISO27001": "A.9.2.3", "SOC2": "CC6.3"}}, + {"id": "AZ-IDN-005", "name": "Guest User with High Privilege Role in Entra ID", "severity": "HIGH", "category": "Identity", "description": "A guest identity holds a privileged directory role, increasing the impact of external account compromise.", "frameworks": {"CIS": "1.3", "NIST": "PR.AC-4", "ISO27001": "A.9.2.3"}}, + {"id": "AZ-IDN-006", "name": "Service Principal Client Secret Older Than 90 Days", "severity": "HIGH", "category": "Identity", "description": "A service principal credential is older than the approved rotation period.", "frameworks": {"CIS": "1.14", "NIST": "PR.AC-1", "ISO27001": "A.9.4.3"}}, + {"id": "AZ-IDN-007", "name": "Active User with No MFA Registered in Entra ID", "severity": "HIGH", "category": "Identity", "description": "An active member account has no registered multi-factor authentication method.", "frameworks": {"CIS": "1.1", "NIST": "PR.AC-7", "ISO27001": "A.9.4.2"}}, + {"id": "AZ-IDN-008", "name": "Custom RBAC Role with Wildcard Permissions at Subscription Scope", "severity": "HIGH", "category": "Identity", "description": "A custom role grants wildcard permissions at subscription scope and can exceed intended least privilege.", "frameworks": {"CIS": "1.23", "NIST": "PR.AC-4", "ISO27001": "A.9.2.3"}}, + {"id": "AZ-IDN-009", "name": "No Activity Log Alert for Role Assignment Changes", "severity": "MEDIUM", "category": "Identity", "description": "No enabled activity-log alert detects changes to Azure role assignments.", "frameworks": {"CIS": "5.2.1", "NIST": "DE.CM-3", "ISO27001": "A.12.4.1"}}, // KeyVault (5) {"id": "AZ-KV-001", "name": "Key Vault with Soft Delete Disabled", "severity": "MEDIUM", "category": "KeyVault", "description": "Azure Key Vault soft delete is disabled. Without soft delete, secrets, keys, and certificates can be permanently destroyed immediately upon deletion by accident, a disgruntled insider, or an attacker.", "frameworks": {"CIS": "8.8", "NIST": "PR.IP-4", "ISO27001": "A.17.2.1"}}, {"id": "AZ-KV-002", "name": "Key Vault Allows Public Network Access Without Private Endpoint", "severity": "HIGH", "category": "KeyVault", "description": "The Azure Key Vault is accessible over the public internet without a private endpoint configured. This increases the risk of unauthorized access to sensitive secrets, keys, and certificates.", "frameworks": {"CIS": "8.7", "NIST": "AC-17", "ISO27001": "A.13.1.1"}}, @@ -103,6 +108,7 @@ const siteContent = { {"id": "AZ-NET-012", "name": "NSG Flow Logs Not Enabled", "severity": "MEDIUM", "category": "Network", "description": "Network Security Group flow logs are not enabled. Without flow logs, network traffic is not auditable and attacker movement through the network cannot be reconstructed.", "frameworks": {"CIS": "6.7", "NIST": "DE.CM-1", "ISO27001": "A.12.4.1", "SOC2": "CC7.2"}}, {"id": "AZ-NET-013", "name": "Azure Firewall Not Enabled on Virtual Network", "severity": "HIGH", "category": "Network", "description": "The virtual network has no Azure Firewall deployed or associated. Relying only on NSGs leaves the network without a centralized perimeter inspection, logging, and threat-filtering layer.", "frameworks": {"CIS": "6.4", "NIST": "PR.AC-5", "ISO27001": "A.13.1.1", "SOC2": "CC6.6"}}, {"id": "AZ-NET-014", "name": "VNet Peering Configured Without Gateway Transit Restrictions", "severity": "MEDIUM", "category": "Network", "description": "A Virtual Network peering connection has gateway transit enabled, potentially enabling lateral movement between network zones that should be isolated from each other.", "frameworks": {"CIS": "6.6", "NIST": "PR.AC-5", "ISO27001": "A.13.1.1", "SOC2": "CC6.6"}}, + {"id": "AZ-NET-015", "name": "Public DNS Zone Exposes Internal Infrastructure Details", "severity": "MEDIUM", "category": "Network", "description": "A public DNS zone exposes private RFC1918 addresses or internal-service names that assist infrastructure reconnaissance.", "frameworks": {"CIS": "9.8", "NIST": "PR.AC-5", "ISO27001": "A.13.1.1", "SOC2": "CC6.6"}}, // Storage (5) {"id": "AZ-STOR-001", "name": "Public Blob Access Enabled on Storage Account", "severity": "HIGH", "category": "Storage", "description": "Storage accounts with public blob access enabled allow unauthenticated read access to blob data over the internet. This setting can expose sensitive files, backups, or configuration data to any external actor.", "frameworks": {"CIS": "3.5", "NIST": "PR.AC-3", "ISO27001": "A.9.4.1"}}, {"id": "AZ-STOR-002", "name": "Storage Account Allows HTTP Traffic (Not HTTPS-Only)", "severity": "HIGH", "category": "Storage", "description": "Storage accounts that do not enforce HTTPS-only traffic allow data to be transmitted in plaintext over HTTP. This exposes credentials and data to man-in-the-middle attacks and interception.", "frameworks": {"CIS": "3.1", "NIST": "PR.DS-2", "ISO27001": "A.10.1.1"}}, @@ -112,7 +118,13 @@ const siteContent = { // Post-Quantum Cryptography (3) — unique to OpenShield; no competitor OSS CSPM tool scans for quantum-vulnerable assets {"id": "AZ-PQC-001", "name": "TLS Using Classical Key Exchange Algorithm", "severity": "HIGH", "category": "PostQuantum", "description": "The resource is configured with TLS using classical key exchange algorithms such as RSA or ECDH. Adversaries are executing Harvest Now Decrypt Later attacks — collecting encrypted traffic today to decrypt once quantum computers are available. Maps to NIST FIPS 203 (ML-KEM) migration requirements.", "frameworks": {"CIS": "9.9", "NIST": "PR.DS-2", "ISO27001": "A.10.1.1", "SOC2": "CC6.7"}}, {"id": "AZ-PQC-002", "name": "Key Vault Key Using Non-Quantum-Safe Algorithm", "severity": "HIGH", "category": "PostQuantum", "description": "The Key Vault contains RSA or ECC keys vulnerable to Shor's algorithm on quantum computers. A sufficiently powerful quantum computer can break these keys, compromising all data encrypted or signed with them. Maps to NIST FIPS 204 (ML-DSA) migration requirements.", "frameworks": {"CIS": "8.1", "NIST": "PR.DS-2", "ISO27001": "A.10.1.2", "SOC2": "CC6.7"}}, - {"id": "AZ-PQC-003", "name": "Key Vault Certificate Using Non-Quantum-Safe Signature Algorithm", "severity": "MEDIUM", "category": "PostQuantum", "description": "The Key Vault contains certificates signed using RSA or ECDSA algorithms, vulnerable to Shor's algorithm. Certificates used for TLS, authentication, and code signing will need migration to ML-DSA (FIPS 204) or SLH-DSA (FIPS 205) as certificate authorities add post-quantum support.", "frameworks": {"CIS": "8.9", "NIST": "PR.DS-2", "ISO27001": "A.10.1.2", "SOC2": "CC6.7"}} + {"id": "AZ-PQC-003", "name": "Key Vault Certificate Using Non-Quantum-Safe Signature Algorithm", "severity": "MEDIUM", "category": "PostQuantum", "description": "The Key Vault contains certificates signed using RSA or ECDSA algorithms, vulnerable to Shor's algorithm. Certificates used for TLS, authentication, and code signing will need migration to ML-DSA (FIPS 204) or SLH-DSA (FIPS 205) as certificate authorities add post-quantum support.", "frameworks": {"CIS": "8.9", "NIST": "PR.DS-2", "ISO27001": "A.10.1.2", "SOC2": "CC6.7"}}, + {"id": "AZ-AKS-001", "name": "AKS Private Cluster Not Enabled", "severity": "HIGH", "category": "Kubernetes", "description": "The AKS control-plane API is reachable through a public endpoint.", "frameworks": {"CIS": "N/A-AKS-001", "NIST": "PR.AC-3", "ISO27001": "A.13.1.1"}}, + {"id": "AZ-AKS-002", "name": "AKS Local Accounts Enabled", "severity": "HIGH", "category": "Kubernetes", "description": "Local AKS accounts permit authentication outside centrally governed Entra identities.", "frameworks": {"CIS": "N/A-AKS-002", "NIST": "PR.AC-1", "ISO27001": "A.9.2.1"}}, + {"id": "AZ-AKS-003", "name": "AKS Cluster Not Using Managed Identity", "severity": "HIGH", "category": "Kubernetes", "description": "The AKS control plane does not use an Azure managed identity.", "frameworks": {"CIS": "N/A-AKS-003", "NIST": "PR.AC-1", "ISO27001": "A.9.2.1"}}, + {"id": "AZ-AKS-004", "name": "AKS Workload Identity Not Fully Enabled", "severity": "MEDIUM", "category": "Kubernetes", "description": "OIDC issuer or Workload Identity support is not fully enabled for the cluster.", "frameworks": {"CIS": "N/A-AKS-004", "NIST": "PR.AC-4", "ISO27001": "A.9.2.3"}}, + {"id": "AZ-AKS-005", "name": "AKS Azure Policy Add-on Not Enabled", "severity": "MEDIUM", "category": "Kubernetes", "description": "The cluster does not use the Azure Policy add-on for centralized governance.", "frameworks": {"CIS": "N/A-AKS-005", "NIST": "PR.IP-1", "ISO27001": "A.12.1.2"}}, + {"id": "AZ-AKS-006", "name": "AKS Node OS Automatic Upgrades Disabled", "severity": "HIGH", "category": "Kubernetes", "description": "The cluster lacks a managed node operating-system security upgrade channel.", "frameworks": {"CIS": "N/A-AKS-006", "NIST": "PR.IP-12", "ISO27001": "A.12.6.1"}} ], ecosystem: [ { @@ -464,7 +476,7 @@ const siteContent = { "Flask REST API with JWT authentication and CORS", "Scanner engine with 20 Azure misconfiguration rules across Storage, Network, Identity, Database, Compute, and Key Vault", "Compliance framework mappings for CIS Azure Benchmark, NIST CSF, ISO 27001, and SOC 2", - "36 Azure CLI remediation playbooks — one per scanner rule", + "51 Azure CLI remediation playbooks — one per scanner rule", "PostgreSQL persistence for scan history and findings", "Microsoft Sentinel integration via Log Analytics custom table and KQL analytics rules", "GitHub Actions CI pipeline with 7 automated checks"