Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,6 @@ SENTRY_DSN=
# Optional - enables AZ-SC-007/008 (Azure DevOps pipeline scanning)
AZURE_DEVOPS_ORG_URL=
AZURE_DEVOPS_PROJECT=

# Optional - tenant identifier used for lifecycle tracking (defaults to subscription_id if unset)
OPENSHIELD_TENANT_ID=
6 changes: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ on:
branches:
- dev
- main
- feat/311-finding-lifecycle
- feat/331-evidence-graph-foundation
- feat/332-evidence-graph-node-edge-population

# Post-merge assurance: re-run the full gate on the merged result, so a green
# PR run against an older base is not the only evidence for dev and main.
push:
Expand Down Expand Up @@ -573,7 +577,7 @@ jobs:

- name: Start image
env:
POSTGRES_PORT: ${{ job.services.postgres.ports[5432] }}
POSTGRES_PORT: ${{ job.services.postgres.ports['5432'] }}
run: |
set -euo pipefail
JWT_SECRET="$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')"
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ on:
branches:
- dev
- main
- feat/311-finding-lifecycle
- feat/331-evidence-graph-foundation
- feat/332-evidence-graph-node-edge-population

push:
branches:
- dev
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/dco.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: Developer Certificate of Origin

on:
pull_request:
branches: [dev, main]
branches: [dev, main, feat/311-finding-lifecycle, feat/331-evidence-graph-foundation, feat/332-evidence-graph-node-edge-population]

permissions:
contents: read
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/dependency-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: Dependency Review

on:
pull_request:
branches: [dev, main]
branches: [dev, main, feat/311-finding-lifecycle, feat/331-evidence-graph-foundation, feat/332-evidence-graph-node-edge-population]

permissions:
contents: read
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/website.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ on:
push:
branches: [main]
pull_request:
branches: [dev, main]
branches: [dev, main, feat/311-finding-lifecycle, feat/331-evidence-graph-foundation, feat/332-evidence-graph-node-edge-population]
workflow_dispatch:

permissions:
Expand Down
163 changes: 163 additions & 0 deletions alembic/versions/a8d9c2e4f601_finding_lifecycle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
"""Add finding lifecycle tables: scan_rule_outcomes, scan_lifecycle_applications,
finding_fingerprints, finding_lifecycles, finding_lifecycle_transitions, patterns.

Revision ID: a8d9c2e4f601
Revises: b6d2f8a4c1e7
Create Date: 2026-08-30 00:00:00.000000
"""

from typing import Sequence, Union

from alembic import op

# Revision identifiers, used by Alembic.
revision: str = "a8d9c2e4f601"
down_revision: Union[str, Sequence[str], None] = "b6d2f8a4c1e7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.execute(
"""
CREATE TABLE scan_rule_outcomes (
id BIGSERIAL PRIMARY KEY,
scan_id UUID NOT NULL REFERENCES scans(scan_id),
rule_id TEXT NOT NULL,
rule_version TEXT NOT NULL DEFAULT '1',
status TEXT NOT NULL,
error_category TEXT,
collector_version TEXT NOT NULL DEFAULT '1',
inventory_boundary TEXT NOT NULL,
tenant_id TEXT NOT NULL,
subscription_id TEXT NOT NULL,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT ck_scan_rule_outcomes_status
CHECK (status IN (
'SUCCESS','EMPTY_SUCCESS','PERMISSION_DENIED',
'TIMEOUT','FAILED','NOT_APPLICABLE'
)),
CONSTRAINT uq_scan_rule_outcomes_scan_rule
UNIQUE (scan_id, rule_id)
)
"""
)

op.execute(
"""
CREATE TABLE scan_lifecycle_applications (
scan_id UUID PRIMARY KEY REFERENCES scans(scan_id),
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
applied_by TEXT NOT NULL DEFAULT 'system'
)
"""
)

op.execute(
"""
CREATE TABLE finding_fingerprints (
id BIGSERIAL PRIMARY KEY,
tenant_id TEXT NOT NULL,
subscription_id TEXT NOT NULL,
resource_id_normalized TEXT NOT NULL,
rule_id TEXT NOT NULL,
evidence_key TEXT NOT NULL DEFAULT '',
normalization_version TEXT NOT NULL DEFAULT '1',
fingerprint_version TEXT NOT NULL DEFAULT '1',
fingerprint_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_finding_fingerprints_hash UNIQUE (fingerprint_hash),
CONSTRAINT uq_finding_fingerprints_identity
UNIQUE (
tenant_id,
subscription_id,
resource_id_normalized,
rule_id,
evidence_key,
normalization_version
)
)
"""
)

op.execute(
"""
CREATE TABLE finding_lifecycles (
id BIGSERIAL PRIMARY KEY,
fingerprint_id BIGINT NOT NULL REFERENCES finding_fingerprints(id),
state TEXT NOT NULL,
first_seen_scan_id UUID,
last_seen_scan_id UUID,
occurrence_count INTEGER NOT NULL DEFAULT 1,
consecutive_success_count INTEGER NOT NULL DEFAULT 0,
reopen_count INTEGER NOT NULL DEFAULT 0,
row_version INTEGER NOT NULL DEFAULT 0, -- monotonic update counter (FOR UPDATE handles serializability)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT ck_finding_lifecycles_state
CHECK (state IN ('OPEN','RESOLVED','ACCEPTED','SUPPRESSED','REOPENED')),
CONSTRAINT uq_finding_lifecycles_fingerprint
UNIQUE (fingerprint_id)
)
"""
)

op.execute(
"""
CREATE TABLE finding_lifecycle_transitions (
id BIGSERIAL PRIMARY KEY,
lifecycle_id BIGINT NOT NULL REFERENCES finding_lifecycles(id),
from_state TEXT,
to_state TEXT NOT NULL,
scan_id UUID,
reason TEXT NOT NULL DEFAULT '',
transitioned_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
"""
)

op.execute(
"""
CREATE TABLE patterns (
id BIGSERIAL PRIMARY KEY,
pattern_type TEXT NOT NULL,
lifecycle_id BIGINT REFERENCES finding_lifecycles(id),
tenant_id TEXT NOT NULL,
subscription_id TEXT NOT NULL,
scan_id UUID,
finding_ids JSONB NOT NULL DEFAULT '[]',
threshold INTEGER NOT NULL,
algorithm_version TEXT NOT NULL DEFAULT '1',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT ck_patterns_type
CHECK (pattern_type IN (
'persistent_finding',
'cross_resource_recurrence',
'reopened_finding'
)),
CONSTRAINT uq_patterns_type_lifecycle_scan
UNIQUE (pattern_type, lifecycle_id, scan_id)
)
"""
)

# Indexes for hot query paths
op.execute("CREATE INDEX ix_finding_fingerprints_tenant_sub ON finding_fingerprints (tenant_id, subscription_id)")
op.execute(
"CREATE INDEX ix_finding_lifecycles_state ON finding_lifecycles (state) WHERE state IN ('OPEN', 'REOPENED')"
)
op.execute(
"CREATE INDEX ix_finding_lifecycle_transitions_lifecycle_id ON finding_lifecycle_transitions (lifecycle_id)"
)
op.execute("CREATE INDEX ix_patterns_sub_created ON patterns (tenant_id, subscription_id, created_at DESC)")


def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS patterns CASCADE")
op.execute("DROP TABLE IF EXISTS finding_lifecycle_transitions CASCADE")
op.execute("DROP TABLE IF EXISTS finding_lifecycles CASCADE")
op.execute("DROP TABLE IF EXISTS finding_fingerprints CASCADE")
op.execute("DROP TABLE IF EXISTS scan_lifecycle_applications CASCADE")
op.execute("DROP TABLE IF EXISTS scan_rule_outcomes CASCADE")
2 changes: 2 additions & 0 deletions api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@ def verify_jwt() -> None:
from api.routes.compliance import compliance_bp
from api.routes.drift import drift_bp
from api.routes.findings import findings_bp
from api.routes.patterns import patterns_bp
from api.routes.prioritization import prioritization_bp
from api.routes.resources import resources_bp
from api.routes.scans import scans_bp
Expand All @@ -271,6 +272,7 @@ def verify_jwt() -> None:
app.register_blueprint(compliance_bp)
app.register_blueprint(drift_bp)
app.register_blueprint(findings_bp)
app.register_blueprint(patterns_bp)
app.register_blueprint(prioritization_bp)
app.register_blueprint(resources_bp)
app.register_blueprint(scans_bp)
Expand Down
22 changes: 20 additions & 2 deletions api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,9 +200,10 @@ def _verify_shared_secret(self, token: str) -> Dict[str, Any]:
return {
"sub": claims["sub"],
"role": role,
"tenant": None,
"tenant": _verified_scope_claim(claims, "tid"),
"issuer": claims.get("iss"),
"auth_mode": SHARED_SECRET_MODE,
**_subscription_scope(claims),
}

def _get_jwks_client(self) -> Any:
Expand Down Expand Up @@ -247,7 +248,8 @@ def _verify_oidc(self, token: str) -> Dict[str, Any]:
logger.warning("OIDC authorization rejected: %s", type(exc).__name__)
raise TokenRejected("Invalid token") from exc

tenant = str(claims.get("tid") or "").lower() or None
tenant_claim = _verified_scope_claim(claims, "tid")
tenant = tenant_claim.lower() if tenant_claim else None
if settings.allowed_tenants and tenant not in settings.allowed_tenants:
logger.warning("OIDC token rejected: tenant %r is not allowed", tenant)
raise TokenRejected("Invalid token")
Expand All @@ -266,6 +268,7 @@ def _verify_oidc(self, token: str) -> Dict[str, Any]:
"tenant": tenant,
"issuer": claims["iss"],
"auth_mode": AUTH_MODE_OIDC,
**_subscription_scope(claims),
}


Expand All @@ -274,3 +277,18 @@ def build_verifier(shared_secret: Callable[[], str], env: Mapping[str, str] = os
mode = load_auth_mode(env)
oidc = load_oidc_settings(env) if mode == AUTH_MODE_OIDC else None
return TokenVerifier(mode, shared_secret, oidc=oidc, env=env)


def _verified_scope_claim(claims: Dict[str, Any], key: str) -> Optional[str]:
"""Validate scope only after signature, issuer and audience verification."""
value = claims.get(key)
if value is None:
return None
if not isinstance(value, str) or not value.strip() or len(value) > 256:
raise TokenRejected("Invalid token")
return value.strip()


def _subscription_scope(claims: Dict[str, Any]) -> Dict[str, str]:
subscription = _verified_scope_claim(claims, "subscription_id")
return {"subscription_id": subscription} if subscription else {}
Loading
Loading