Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
9c9a389
feat(lifecycle): implement finding lifecycle tracking (#311)
TFT444 Aug 30, 2026
53d5d62
fix(security): close IDOR and tenant-bypass in patterns API
TFT444 Aug 30, 2026
938824d
fix(lifecycle): address all code review findings from #311
TFT444 Aug 30, 2026
9426a86
fix(security): strip internal exception detail from all 500 error res…
TFT444 Aug 30, 2026
a91b5c7
fix(lifecycle): address all reviewer-blocking issues before merge
TFT444 Aug 30, 2026
2f38d4a
fix(lint): remove unused imports and fix E402 in lifecycle test files
TFT444 Aug 30, 2026
d317607
fix(lint): apply ruff format to all lifecycle PR files
TFT444 Aug 30, 2026
76b6030
fix: apply code review findings for lifecycle PR
TFT444 Aug 30, 2026
4966e39
feat(graph): add graph_nodes, graph_edges, finding_graph_nodes migration
TFT444 Sep 24, 2026
88518fe
feat(graph): add snapshot_bridge to collect ARG inventory inside Scan…
TFT444 Sep 24, 2026
0b7b7ab
feat(graph): wire InventorySnapshot into ScanEngine.run_scan()
TFT444 Sep 24, 2026
8e403c7
fix(graph): remove unused pytest import from snapshot_bridge tests
TFT444 Sep 24, 2026
d1c9713
fix(lint): apply ruff format to graph foundation files
TFT444 Sep 24, 2026
4490a01
fix(lint): ruff 0.16.6 format for graph schema migration
TFT444 Sep 24, 2026
5e92bb8
fix(logging): preserve pytest caplog handler in configure_logging
TFT444 Sep 25, 2026
830e650
fix(alembic): preserve existing loggers when fileConfig runs
TFT444 Sep 25, 2026
73a5d81
fix(graph): add tenant_id to AzureClient, merge alembic heads, replac…
TFT444 Sep 27, 2026
0041854
fix(alembic): use single-parent down_revision for graph schema migration
TFT444 Sep 27, 2026
f5c02dd
fix(graph): resolve alembic head dynamically in admission test, move …
TFT444 Sep 27, 2026
cef1b31
fix(lint): add blank lines around _resolve_head for ruff format compl…
TFT444 Sep 28, 2026
5c61b76
fix(graph): repoint migration down_revision to 3a76ff935bf6 (dev head)
TFT444 Sep 30, 2026
5ca9de1
fix: add DATABASE_URL guard to migration test, use parameter name for…
TFT444 Oct 4, 2026
481d403
Merge remote-tracking branch 'origin/dev' into codex/fix-pr352-20261007
TFT444 Oct 7, 2026
552399a
fix(graph): chain foundation on current dev and isolate migration tests
TFT444 Oct 8, 2026
d76e66e
fix(lifecycle): integrate current dev and enforce verified resource s…
TFT444 Oct 8, 2026
9067f45
Merge branch 'codex/fix-pr326-20261007' into codex/fix-pr352-20261007
TFT444 Oct 8, 2026
7ae7ee4
fix(graph): stack foundation after lifecycle migration
TFT444 Oct 8, 2026
0cd0a76
ci: run required checks for the lifecycle and graph PR stack
TFT444 Oct 8, 2026
e982180
ci: use a string key for the PostgreSQL service port
TFT444 Oct 8, 2026
88c881f
fix(deps): require patched Werkzeug and multidict releases
TFT444 Oct 8, 2026
9339a3c
test(ci): match validated PostgreSQL service expression
TFT444 Oct 8, 2026
050424e
Merge commit '9339a3ce48a3981545e6ddd1f7631f59b7150382' into codex/fi…
TFT444 Oct 8, 2026
ff4c398
fix(graph): use relative downgrade target in migration test to avoid …
TFT444 Oct 9, 2026
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
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
2 changes: 1 addition & 1 deletion alembic/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
config = context.config

if config.config_file_name is not None:
fileConfig(config.config_file_name)
fileConfig(config.config_file_name, disable_existing_loggers=False)

# OpenShield intentionally uses raw SQL rather than ORM metadata. Migration
# revisions therefore define every schema operation explicitly.
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")
101 changes: 101 additions & 0 deletions alembic/versions/e1f2a3b4c5d6_graph_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"""Add graph_nodes, graph_edges, and finding_graph_nodes for attack graph.

Revision ID: e1f2a3b4c5d6
Revises: a8d9c2e4f601
Create Date: 2026-09-24 00:00:00.000000
"""

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

revision: str = "e1f2a3b4c5d6"
down_revision: str = "a8d9c2e4f601"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.create_table(
"graph_nodes",
sa.Column("node_id", postgresql.UUID(), nullable=False),
sa.Column("tenant_id", sa.Text(), nullable=False),
sa.Column("subscription_id", sa.Text(), nullable=False),
sa.Column("resource_id", sa.Text(), nullable=False),
sa.Column("resource_type", sa.Text(), nullable=False),
sa.Column("name", sa.Text(), nullable=False),
sa.Column("location", sa.Text(), nullable=False, server_default=sa.text("''")),
sa.Column("resource_group", sa.Text(), nullable=False, server_default=sa.text("''")),
sa.Column("snapshot_id", sa.Text(), nullable=False),
sa.Column("properties", postgresql.JSONB(), nullable=False, server_default=sa.text("'{}'::jsonb")),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.PrimaryKeyConstraint("node_id", name="graph_nodes_pkey"),
)
op.create_index("idx_graph_nodes_resource_id", "graph_nodes", ["resource_id"])
op.create_index("idx_graph_nodes_tenant_subscription", "graph_nodes", ["tenant_id", "subscription_id"])
op.create_index(
"uq_graph_nodes_tenant_resource",
"graph_nodes",
["tenant_id", "resource_id"],
unique=True,
)

op.create_table(
"graph_edges",
sa.Column("edge_id", postgresql.UUID(), nullable=False),
sa.Column("source_node_id", postgresql.UUID(), nullable=False),
sa.Column("target_node_id", postgresql.UUID(), nullable=False),
sa.Column("relationship_type", sa.Text(), nullable=False),
sa.Column("evidence_source", sa.Text(), nullable=False),
sa.Column("evidence_snapshot_id", sa.Text(), nullable=False),
sa.Column("confidence", sa.Float(), nullable=False, server_default=sa.text("1.0")),
sa.Column("collected_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.Column("properties", postgresql.JSONB(), nullable=False, server_default=sa.text("'{}'::jsonb")),
sa.ForeignKeyConstraint(
["source_node_id"], ["graph_nodes.node_id"], name="graph_edges_source_fkey", ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["target_node_id"], ["graph_nodes.node_id"], name="graph_edges_target_fkey", ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("edge_id", name="graph_edges_pkey"),
)
op.create_index("idx_graph_edges_source", "graph_edges", ["source_node_id"])
op.create_index("idx_graph_edges_target", "graph_edges", ["target_node_id"])
op.create_index(
"uq_graph_edges_source_target_type",
"graph_edges",
["source_node_id", "target_node_id", "relationship_type"],
unique=True,
)

op.create_table(
"finding_graph_nodes",
sa.Column("finding_id", sa.Integer(), nullable=False),
sa.Column("node_id", postgresql.UUID(), nullable=False),
sa.ForeignKeyConstraint(
["finding_id"], ["findings.id"], name="finding_graph_nodes_finding_fkey", ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["node_id"], ["graph_nodes.node_id"], name="finding_graph_nodes_node_fkey", ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("finding_id", "node_id", name="finding_graph_nodes_pkey"),
)
op.create_index("idx_finding_graph_nodes_node_id", "finding_graph_nodes", ["node_id"])


def downgrade() -> None:
op.drop_index("idx_finding_graph_nodes_node_id", table_name="finding_graph_nodes")
op.drop_table("finding_graph_nodes")

op.drop_index("uq_graph_edges_source_target_type", table_name="graph_edges")
op.drop_index("idx_graph_edges_target", table_name="graph_edges")
op.drop_index("idx_graph_edges_source", table_name="graph_edges")
op.drop_table("graph_edges")

op.drop_index("uq_graph_nodes_tenant_resource", table_name="graph_nodes")
op.drop_index("idx_graph_nodes_tenant_subscription", table_name="graph_nodes")
op.drop_index("idx_graph_nodes_resource_id", table_name="graph_nodes")
op.drop_table("graph_nodes")
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