Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 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
d76e66e
fix(lifecycle): integrate current dev and enforce verified resource s…
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
76a656c
fix(lifecycle): add exc_info, autocommit guard, and remove dead param
TFT444 Oct 9, 2026
e33ff03
fix(lifecycle): resolve evaluate()-only rules when no rule_outcomes e…
TFT444 Oct 9, 2026
ef8f6e7
fix(lint): apply ruff format to lifecycle_service evaluate-only fix
TFT444 Oct 9, 2026
cd0c23d
revert(ci): remove ephemeral branch triggers and port-expression change
TFT444 Oct 15, 2026
4d99b3b
fix(lifecycle): normalize tenant_id to lowercase on write and read paths
TFT444 Oct 15, 2026
db0d1fa
fix(lifecycle): exclude rules in failed_rule_ids from resolution
TFT444 Oct 15, 2026
0cd0e8f
fix(lifecycle): add durable lifecycle_jobs queue with bounded retry a…
TFT444 Oct 15, 2026
f7be383
ci: re-trigger CI on fix commits for PR #326
TFT444 Oct 15, 2026
c83dea5
ci: nudge CI for PR #326 fix commits
TFT444 Oct 15, 2026
be5b22f
fix(lifecycle): guard apply_scan against out-of-order scan replay
TFT444 Oct 17, 2026
16dafd4
feat(graph): add graph_nodes, graph_edges, finding_graph_nodes migration
TFT444 Sep 24, 2026
6f014b7
feat(graph): add snapshot_bridge to collect ARG inventory inside Scan…
TFT444 Sep 24, 2026
1fd54ca
feat(graph): wire InventorySnapshot into ScanEngine.run_scan()
TFT444 Sep 24, 2026
d84d805
fix(graph): remove unused pytest import from snapshot_bridge tests
TFT444 Sep 24, 2026
ff07df8
fix(lint): apply ruff format to graph foundation files
TFT444 Sep 24, 2026
0740cc6
fix(lint): ruff 0.16.6 format for graph schema migration
TFT444 Sep 24, 2026
fd089bf
fix(logging): preserve pytest caplog handler in configure_logging
TFT444 Sep 25, 2026
ca0117d
fix(alembic): preserve existing loggers when fileConfig runs
TFT444 Sep 25, 2026
310a3d4
fix(graph): add tenant_id to AzureClient, merge alembic heads, replac…
TFT444 Sep 27, 2026
9731f7d
fix(alembic): use single-parent down_revision for graph schema migration
TFT444 Sep 27, 2026
076b226
fix(graph): resolve alembic head dynamically in admission test, move …
TFT444 Sep 27, 2026
08721e9
fix(lint): add blank lines around _resolve_head for ruff format compl…
TFT444 Sep 28, 2026
f8aae45
fix(graph): repoint migration down_revision to 3a76ff935bf6 (dev head)
TFT444 Sep 30, 2026
7341e11
fix: add DATABASE_URL guard to migration test, use parameter name for…
TFT444 Oct 4, 2026
889d43f
fix(graph): chain foundation on current dev and isolate migration tests
TFT444 Oct 8, 2026
d043008
feat(lifecycle): implement finding lifecycle tracking (#311)
TFT444 Aug 30, 2026
145ff89
fix(security): close IDOR and tenant-bypass in patterns API
TFT444 Aug 30, 2026
3401c33
fix(lifecycle): address all code review findings from #311
TFT444 Aug 30, 2026
30cff36
fix(security): strip internal exception detail from all 500 error res…
TFT444 Aug 30, 2026
893950c
fix(lifecycle): address all reviewer-blocking issues before merge
TFT444 Aug 30, 2026
1946406
fix(lint): remove unused imports and fix E402 in lifecycle test files
TFT444 Aug 30, 2026
cc80d48
fix(lint): apply ruff format to all lifecycle PR files
TFT444 Aug 30, 2026
78799ac
fix: apply code review findings for lifecycle PR
TFT444 Aug 30, 2026
dcd2ad4
fix(graph): stack foundation after lifecycle migration
TFT444 Oct 8, 2026
e346d6e
ci: run required checks for the lifecycle and graph PR stack
TFT444 Oct 8, 2026
9ffbc8a
ci: use a string key for the PostgreSQL service port
TFT444 Oct 8, 2026
d0b695c
fix(deps): require patched Werkzeug and multidict releases
TFT444 Oct 8, 2026
474b121
test(ci): match validated PostgreSQL service expression
TFT444 Oct 8, 2026
ff9d4bc
fix(graph): use relative downgrade target in migration test to avoid …
TFT444 Oct 9, 2026
2a032a7
fix(graph): rename migration revision ID to avoid collision with life…
TFT444 Oct 17, 2026
c43e9d7
fix(graph): pin Alembic config URL to scratch database in migration t…
TFT444 Oct 17, 2026
cd995e7
fix(graph): fix migration chain and missing enrich_findings import
TFT444 Oct 20, 2026
87e870d
fix(lifecycle): fence durable application and scope verified pattern …
TFT444 Oct 22, 2026
e54c44c
fix(graph): stack foundation on durable lifecycle and repair migratio…
TFT444 Oct 22, 2026
e5247fc
fix(graph): normalize collected tenant scope to verified token casing
TFT444 Oct 22, 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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,11 @@ SENTRY_DSN=
# Optional - enables AZ-SC-007/008 (Azure DevOps pipeline scanning)
AZURE_DEVOPS_ORG_URL=
AZURE_DEVOPS_PROJECT=

# Optional override of AZURE_TENANT_ID for lifecycle tracking.
# Set to your Entra tenant ID (lowercase UUID), e.g. 12345678-abcd-ef01-2345-6789abcdef01.
# If both variables are unset, lifecycle application fails closed and retries.
# Standard OIDC tokens use this tenant plus OPENSHIELD_AUTHORIZED_SUBSCRIPTIONS
# (or a single AZURE_SUBSCRIPTION_ID) for patterns access without a custom claim.
OPENSHIELD_TENANT_ID=

4 changes: 4 additions & 0 deletions .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
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
164 changes: 164 additions & 0 deletions alembic/versions/a8d9c2e4f601_finding_lifecycle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
"""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),
tenant_id TEXT NOT NULL,
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/b7c3d9e2f841_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: b7c3d9e2f841
Revises: e1b3f7a9c2d5
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 = "b7c3d9e2f841"
down_revision: str = "e1b3f7a9c2d5"
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")
Loading
Loading