Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ on:
branches: [main]
workflow_dispatch:

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
Expand All @@ -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
Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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`:

Expand Down
5 changes: 5 additions & 0 deletions docs/FIELD_INSTALLATION_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions docs/SECURITY_ASSESSMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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

Expand All @@ -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.
1 change: 1 addition & 0 deletions requirements-constraints.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions src/strataone/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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"}

Expand Down
14 changes: 13 additions & 1 deletion src/strataone/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
15 changes: 15 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
30 changes: 30 additions & 0 deletions tests/test_store_jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []

Expand Down
Loading