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
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/new_rule.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ assignees: ''

## Rule Details
- Rule ID: AZ-XXX-000
- Severity: HIGH / MEDIUM / LOW
- Severity: CRITICAL / HIGH / MEDIUM / LOW / INFO
- Category: Storage / Network / Identity / Database / Compute / Key Vault / Kubernetes / PostQuantum
- Frameworks: CIS / NIST / ISO 27001 / SOC 2

Expand Down
2 changes: 1 addition & 1 deletion .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

## Rule details (if applicable)
- Rule ID: AZ-XXX-000
- Severity: HIGH / MEDIUM / LOW
- Severity: CRITICAL / HIGH / MEDIUM / LOW / INFO
- Category: Storage / Network / Identity / Database / Compute / Key Vault / Kubernetes
- Frameworks mapped: CIS / NIST / ISO 27001 / SOC 2

Expand Down
10 changes: 7 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,10 @@ jobs:
import importlib.util
import sys
from collections import defaultdict
from openshield.severity import CANONICAL_SEVERITIES

rules_dir = "scanner/rules"
required_fields = ["RULE_ID", "SEVERITY", "FRAMEWORKS"]
valid_severities = {"CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"}
failures = []
seen_ids = defaultdict(list)

Expand Down Expand Up @@ -127,9 +127,10 @@ jobs:
failures.append(f"{filename}: missing field '{field}'")

if hasattr(mod, "SEVERITY"):
if mod.SEVERITY not in valid_severities:
if mod.SEVERITY not in CANONICAL_SEVERITIES:
failures.append(
f"{filename}: SEVERITY '{mod.SEVERITY}' not in {valid_severities}"
f"{filename}: SEVERITY '{mod.SEVERITY}' not in "
f"{sorted(CANONICAL_SEVERITIES)}"
)

if hasattr(mod, "FRAMEWORKS"):
Expand Down Expand Up @@ -619,6 +620,9 @@ jobs:
- name: Run dashboard load-state tests
run: node src/hooks/usePageData.test.mjs

- name: Run severity contract tests
run: npm run test:severity

- name: Run accessibility and internationalization checks
run: npm run test:a11y && npm run test:i18n

Expand Down
23 changes: 11 additions & 12 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,8 @@ jobs:
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
run: python scripts/render_deploy_preflight.py

# Creation and polling are separate so both exact deployment IDs are
# retained and independently monitored. POST creation is never retried.
# The API must become live before a new worker is created. API startup
# owns schema migration, and the worker for this SHA may require it.
- name: Create API deployment
id: create_api
env:
Expand All @@ -85,18 +85,8 @@ jobs:
GITHUB_SHA: ${{ github.sha }}
run: python scripts/render_deploy.py create

- name: Create worker deployment
id: create_worker
env:
RENDER_API_KEY: ${{ secrets.RENDER_API_KEY }}
RENDER_SERVICE_ID: ${{ env.RENDER_WORKER_SERVICE_ID }}
RENDER_SERVICE_NAME: worker
GITHUB_SHA: ${{ github.sha }}
run: python scripts/render_deploy.py create

- name: Wait for API deployment
id: wait_api
continue-on-error: true
env:
RENDER_API_KEY: ${{ secrets.RENDER_API_KEY }}
RENDER_SERVICE_ID: ${{ env.RENDER_API_SERVICE_ID }}
Expand All @@ -105,6 +95,15 @@ jobs:
GITHUB_SHA: ${{ github.sha }}
run: python scripts/render_deploy.py wait

- name: Create worker deployment
id: create_worker
env:
RENDER_API_KEY: ${{ secrets.RENDER_API_KEY }}
RENDER_SERVICE_ID: ${{ env.RENDER_WORKER_SERVICE_ID }}
RENDER_SERVICE_NAME: worker
GITHUB_SHA: ${{ github.sha }}
run: python scripts/render_deploy.py create

- name: Wait for worker deployment
id: wait_worker
continue-on-error: true
Expand Down
4 changes: 3 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ from typing import Any, Dict, List

RULE_ID = "AZ-STOR-001"
RULE_NAME = "Public Blob Access Enabled on Storage Account"
SEVERITY = "HIGH" # HIGH / MEDIUM / LOW / INFO
SEVERITY = "HIGH" # CRITICAL / HIGH / MEDIUM / LOW / INFO
CATEGORY = "Storage" # Storage / Network / Identity / Database / Compute / Key Vault / Kubernetes
FRAMEWORKS = {"CIS": "3.5", "NIST": "PR.AC-3", "ISO27001": "A.9.4.1"}
DESCRIPTION = (
Expand Down Expand Up @@ -90,6 +90,8 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]:

That's it. One file, one rule.

Choose severity using the versioned [finding severity contract](docs/severity-contract.md). Rule files must use a canonical contract value; aliases such as `INFORMATIONAL` are accepted at API boundaries but are not valid rule declarations.

### Step 4 - Add a Remediation Playbook

Create the matching fix in `playbooks/cli/`:
Expand Down
100 changes: 100 additions & 0 deletions alembic/versions/d8e4f6a1b2c3_severity_contract_v1.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""Enforce severity contract v1 and repair historical scan scores.

Revision ID: d8e4f6a1b2c3
Revises: c7a2e9f1b3d4
Create Date: 2026-08-21 00:00:00.000000
"""

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa

# Revision identifiers, used by Alembic.
revision: str = "d8e4f6a1b2c3"
down_revision: Union[str, Sequence[str], None] = "c7a2e9f1b3d4"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is where the fork with PR #310's migration lives - both chain off c7a2e9f1b3d4. Not something fixable from this side alone (confirmed by trying the equivalent on my own PR's migration - pointing down_revision at an unmerged sibling's revision file breaks that PR's own CI outright). This needs a merge-order call: whichever of #308/#310 merges second rebases onto the new head and repoints this. Not blocking my approval - flagging for whoever merges.

branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None

_CONSTRAINT = "ck_findings_severity_v1"


def upgrade() -> None:
"""Normalize known aliases, reject unknown data, constrain and rescore."""
op.execute(
"""
DO $$
DECLARE invalid_values text;
BEGIN
SELECT string_agg(value, ', ' ORDER BY value)
INTO invalid_values
FROM (
SELECT DISTINCT UPPER(BTRIM(severity)) AS value
FROM findings
WHERE UPPER(BTRIM(severity)) NOT IN
('CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO', 'INFORMATIONAL')
) invalid;

IF invalid_values IS NOT NULL THEN
RAISE EXCEPTION 'Cannot apply severity contract v1; unsupported values: %', invalid_values;
END IF;
END $$;
"""
)
op.execute(
"""
UPDATE findings
SET severity = CASE UPPER(BTRIM(severity))
WHEN 'INFORMATIONAL' THEN 'INFO'
ELSE UPPER(BTRIM(severity))
END
WHERE severity <> CASE UPPER(BTRIM(severity))
WHEN 'INFORMATIONAL' THEN 'INFO'
ELSE UPPER(BTRIM(severity))
END
"""
)
op.add_column(
"scans",
sa.Column(
"severity_contract_version",
sa.Text(),
nullable=True,
),
)
op.execute(
"""
ALTER TABLE findings
ADD CONSTRAINT ck_findings_severity_v1
CHECK (severity IN ('CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO'))
NOT VALID
"""
)
op.execute("ALTER TABLE findings VALIDATE CONSTRAINT ck_findings_severity_v1")
op.execute(
"""
UPDATE scans AS scan
SET score = GREATEST(
0,
100 - COALESCE((
SELECT SUM(CASE finding.severity
WHEN 'CRITICAL' THEN 20
WHEN 'HIGH' THEN 10
WHEN 'MEDIUM' THEN 5
WHEN 'LOW' THEN 2
WHEN 'INFO' THEN 0
END)
FROM findings AS finding
WHERE finding.scan_id = scan.scan_id
), 0)
),
severity_contract_version = '1.0.0'
WHERE scan.status = 'completed';
"""
)


def downgrade() -> None:
"""Remove the v1 constraint; corrected historical scores remain corrected."""
op.drop_constraint(_CONSTRAINT, "findings", type_="check")
op.drop_column("scans", "severity_contract_version")
Loading
Loading