diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5e3697..206ee6a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,9 @@ on: branches: [main] workflow_dispatch: +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest @@ -34,9 +37,46 @@ jobs: - name: Static security scan run: python -m bandit -r src --severity-level medium - docker: + supply-chain: runs-on: ubuntu-latest needs: test + permissions: + contents: read + id-token: write + attestations: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install build and SBOM tooling + run: python -m pip install -c requirements-constraints.txt --upgrade pip setuptools build cyclonedx-bom + + - name: Build wheel + run: python -m build --wheel + + - name: Generate CycloneDX SBOM + run: python -m cyclonedx_py environment --output-format JSON --output-file sbom.cdx.json + + - name: Upload SBOM artifact + uses: actions/upload-artifact@v4 + with: + name: strataone-sbom + path: sbom.cdx.json + + - name: Attest SBOM + uses: actions/attest-sbom@v2 + with: + subject-path: "dist/*.whl" + sbom-path: sbom.cdx.json + + docker: + runs-on: ubuntu-latest + needs: [test, supply-chain] steps: - name: Checkout uses: actions/checkout@v4 diff --git a/README.md b/README.md index 22ef76a..7b4f2c8 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,12 @@ For file-backed secrets, set `STRATAONE_VAULT_PROVIDER=file` and `STRATAONE_VAUL } ``` +When `STRATAONE_EXECUTION_MODE=queued`, live credential-bearing jobs such as +`inventory`, `mount-iso`, and `eject-iso` reject inline `username` and +`password` payloads. Use `credential_ref` or an environment, file, or +Vault-compatible secret provider so workers can resolve credentials without +persisting raw secrets in durable job state. + ## API Start the API directly: @@ -323,7 +329,8 @@ Production startup blocks insecure control-plane posture when bootstrap, admin, and PostgreSQL passwords; keep authentication and tenant enforcement enabled; use PostgreSQL for durable state; and use Redis when queued execution is enabled. CI and container builds use `requirements-constraints.txt` -to keep dependency resolution repeatable and auditable. +to keep dependency resolution repeatable and auditable. CI also generates a +CycloneDX SBOM and publishes a GitHub attestation for the built wheel. Set `STRATAONE_REQUIRE_OEM_VALIDATION=true` to require OEM lab validation evidence before live Redfish operations are allowed. Validation records are read from `STRATAONE_OEM_VALIDATION_FILE`: diff --git a/docs/FIELD_INSTALLATION_GUIDE.md b/docs/FIELD_INSTALLATION_GUIDE.md index d1a36da..e8dd8c3 100644 --- a/docs/FIELD_INSTALLATION_GUIDE.md +++ b/docs/FIELD_INSTALLATION_GUIDE.md @@ -32,6 +32,11 @@ STRATAONE_REDIS_URL=redis://redis:6379/0 STRATAONE_REQUIRE_APPROVALS=true ``` +Queued production workers do not accept inline BMC `username` or `password` +payloads for inventory or virtual-media jobs. Configure `credential_ref`, file +secrets, environment secrets, or a Vault-compatible provider before running +queued live-impact workflows. + For lab-validated live Redfish execution: ```text diff --git a/docs/SECURITY_ASSESSMENT.md b/docs/SECURITY_ASSESSMENT.md index 4835da6..3e72d63 100644 --- a/docs/SECURITY_ASSESSMENT.md +++ b/docs/SECURITY_ASSESSMENT.md @@ -29,15 +29,17 @@ Commands executed during validation: python -m pytest -q python -m pip_audit --skip-editable python -m bandit -r src --severity-level medium +python -m cyclonedx_py environment --output-format JSON --output-file sbom.cdx.json docker compose config --quiet git diff --check ``` Latest local results: -- Unit and regression tests: `78 passed` +- Unit and regression tests: `81 passed` - Dependency audit: no known vulnerabilities found - Static security scan: no medium-or-high-severity Bandit findings +- SBOM and attestation: CI generates a CycloneDX SBOM and GitHub SBOM attestation for the built wheel - Docker Compose configuration: valid - Git diff whitespace check: clean @@ -55,9 +57,10 @@ The assessment identified and remediated the following security issues: | Audit secrecy | Provider configuration audit events could include sensitive values. | Provider configuration audit payloads now redact sensitive key names. | | Discovery tenancy | Discovery runs were globally visible and import wrote sites outside the requester tenant scope. | Discovery runs are tenant-owned and scoped for list, execute, delete, and import; imported sites inherit requester tenant scope. | | Durable secret exposure | Transient job credentials could be persisted in job params, approval details, and operational event data. | Durable job params, approvals, audit details, job events, and discovery results are redacted before storage; inline execution keeps one-time params in memory only. | +| Queued credential safety | Queued workers could receive redacted inline credentials and fail later. | Queued inventory and virtual-media jobs reject inline username/password payloads and require `credential_ref` or a configured secret provider. | | Dependency hygiene | Dev dependency declaration used `httpx2`; local audit flagged vulnerable pytest. | Dev dependencies now use `httpx`, pytest is pinned to a fixed major line, and audit tools are included. | | Dependency repeatability | CI and container builds resolved dependencies without a shared constraints file. | CI and Docker installs now use `requirements-constraints.txt` with audited minimum versions. | -| CI visibility | CI did not run dependency or static security gates. | CI now runs tests, `pip-audit`, Bandit medium-or-higher severity scanning, Docker build, and Compose validation. | +| CI visibility | CI did not run dependency, static security, or supply-chain evidence gates. | CI now runs tests, `pip-audit`, Bandit medium-or-higher severity scanning, SBOM generation and attestation, Docker build, and Compose validation. | ## Current Security Controls @@ -76,6 +79,8 @@ Implemented controls include: - Production startup guard for auth, tenant enforcement, PostgreSQL state, and Redis queue posture. - Dependency audit and static security gates in CI. - Durable redaction for sensitive job, approval, audit, event, and discovery payloads. +- Queued job credential guard requiring `credential_ref` or a secret provider for worker-executed BMC actions. +- CycloneDX SBOM generation and GitHub SBOM attestation in CI. ## Limitations @@ -94,7 +99,6 @@ Before production use, StrataOne should still undergo environment-specific valid - Add third-party penetration testing before production customer deployment. - Add live lab validation evidence for each hardware and hypervisor provider. - Add SAST/DAST reporting artifacts to CI. -- Generate SBOMs and signed release attestations. - Add container image vulnerability scanning. - Add OIDC/SAML enterprise identity provider integration. - Add centralized audit export to SIEM or log analytics. diff --git a/requirements-constraints.txt b/requirements-constraints.txt index ac3d726..bb32cf6 100644 --- a/requirements-constraints.txt +++ b/requirements-constraints.txt @@ -2,6 +2,7 @@ # # Keep CI, Docker, and operator installs on audited minimums while allowing # compatible patch releases above these floors. +pip>=26.2 starlette>=1.3.1 msgpack>=1.2.1 setuptools>=83.0.0 diff --git a/src/strataone/api.py b/src/strataone/api.py index 1c09994..f8eaf25 100644 --- a/src/strataone/api.py +++ b/src/strataone/api.py @@ -1065,7 +1065,11 @@ def run_site_job(site_name: str, action: str, payload: JobPayload | None = None, ) store.add_audit(context.username, "approval.requested", f"approval:{pending.id}", {"site": site_name, "action": action, "required_approvals": pending.required_approvals}) return {"approval_id": pending.id, "status": "approval-required"} - return {"job_id": jobs.submit(site_name, action, params)} + try: + job_id = jobs.submit(site_name, action, params) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + return {"job_id": job_id} @app.get("/jobs") @@ -1229,7 +1233,10 @@ def approve_and_run(approval_id: str, context: AuthContext = Depends(require_per return {"approval": approval.model_dump(mode="json"), "job_id": None, "status": "pending-approval"} params = dict(approval.detail.get("params") or {}) params["approval_id"] = approval_id - job_id = jobs.submit(approval.site_name, approval.action, params) + try: + job_id = jobs.submit(approval.site_name, approval.action, params) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc store.add_audit(context.username, "approval.approved.run", f"approval:{approval_id}", {"job_id": job_id, "action": approval.action, "site": approval.site_name}) return {"approval": approval.model_dump(mode="json"), "job_id": job_id, "status": "queued"} diff --git a/src/strataone/jobs.py b/src/strataone/jobs.py index beccb46..dec5409 100644 --- a/src/strataone/jobs.py +++ b/src/strataone/jobs.py @@ -14,6 +14,10 @@ from strataone.validation import live_operation_allowed +INLINE_CREDENTIAL_ACTIONS = {"inventory", "mount-iso", "eject-iso"} +INLINE_CREDENTIAL_FIELDS = {"username", "password"} + + class JobRunner: def __init__(self, store: StrataStore, max_workers: int = 4) -> None: self.store = store @@ -22,8 +26,10 @@ def __init__(self, store: StrataStore, max_workers: int = 4) -> None: def submit(self, site_name: str, action: str, params: dict[str, Any] | None = None) -> str: raw_params = params or {} - job = self.store.create_job(site_name, action, raw_params) execution_mode = os.getenv("STRATAONE_EXECUTION_MODE", "inline").lower() + if execution_mode == "queued" and _uses_inline_credentials(action, raw_params): + raise ValueError(f"{action} queued jobs require credential_ref or an environment/file/Vault secret provider") + job = self.store.create_job(site_name, action, raw_params) queue = get_job_queue() if execution_mode == "inline": self._volatile_params[job.id] = raw_params @@ -317,3 +323,9 @@ def _credentials(self, site_name: str, params: dict[str, Any]): if resolved is not None: return resolved return resolve_bmc_credentials(site_name, params) + + +def _uses_inline_credentials(action: str, params: dict[str, Any]) -> bool: + if action not in INLINE_CREDENTIAL_ACTIONS or params.get("credential_ref"): + return False + return any(params.get(field) for field in INLINE_CREDENTIAL_FIELDS) diff --git a/tests/test_api.py b/tests/test_api.py index c9bce35..062dd7a 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -60,6 +60,21 @@ def test_persistent_site_job_and_provider_endpoints() -> None: assert "job_id" in jobs_response.json() +def test_queued_inventory_api_rejects_inline_credentials(monkeypatch) -> None: + monkeypatch.setenv("STRATAONE_EXECUTION_MODE", "queued") + client = TestClient(app) + site = client.get("/sites/example").json() + client.post("/sites", json={"site": site}) + + response = client.post( + "/sites/branch-001/jobs/inventory", + json={"username": "admin", "password": "secret"}, + ) + + assert response.status_code == 422 + assert "credential_ref" in response.json()["detail"] + + def test_site_validation_rejects_invalid_bmc_ip() -> None: client = TestClient(app) site = client.get("/sites/example").json() diff --git a/tests/test_store_jobs.py b/tests/test_store_jobs.py index bf4decd..d531bea 100644 --- a/tests/test_store_jobs.py +++ b/tests/test_store_jobs.py @@ -79,6 +79,36 @@ def test_job_runner_can_leave_jobs_for_durable_worker(tmp_path: Path, monkeypatc assert job.result["valid"] is True +def test_queued_inventory_rejects_inline_credentials(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setenv("STRATAONE_EXECUTION_MODE", "queued") + store = StrataStore(tmp_path / "strataone.db") + store.upsert_site(load_site_spec(Path("examples/azure-local-branch.yaml"))) + runner = JobRunner(store) + + try: + runner.submit("branch-001", "inventory", {"username": "admin", "password": "secret"}) + except ValueError as exc: + message = str(exc) + else: + raise AssertionError("queued inventory accepted inline credentials") + + assert "credential_ref" in message + assert store.list_jobs("branch-001") == [] + + +def test_queued_inventory_accepts_credential_ref(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setenv("STRATAONE_EXECUTION_MODE", "queued") + store = StrataStore(tmp_path / "strataone.db") + store.upsert_site(load_site_spec(Path("examples/azure-local-branch.yaml"))) + runner = JobRunner(store) + + job_id = runner.submit("branch-001", "inventory", {"credential_ref": "branch-bmc"}) + + job = store.get_job(job_id) + assert job.status == "queued" + assert job.params == {"credential_ref": "branch-bmc"} + + def test_job_runner_dispatches_queued_jobs_to_queue_backend(tmp_path: Path, monkeypatch) -> None: dispatched = []