From b53cd290ce828a6872f52eabfc9732de47ae1a3f Mon Sep 17 00:00:00 2001 From: velvetway Date: Fri, 17 Apr 2026 18:25:58 +0300 Subject: [PATCH 01/29] migration: add threat_sources table with S1..S4 seed --- migrations/007_threat_sources.down.sql | 1 + migrations/007_threat_sources.up.sql | 14 ++++++++++++++ 2 files changed, 15 insertions(+) create mode 100644 migrations/007_threat_sources.down.sql create mode 100644 migrations/007_threat_sources.up.sql diff --git a/migrations/007_threat_sources.down.sql b/migrations/007_threat_sources.down.sql new file mode 100644 index 0000000..147c753 --- /dev/null +++ b/migrations/007_threat_sources.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS threat_sources; diff --git a/migrations/007_threat_sources.up.sql b/migrations/007_threat_sources.up.sql new file mode 100644 index 0000000..c869d54 --- /dev/null +++ b/migrations/007_threat_sources.up.sql @@ -0,0 +1,14 @@ +-- Справочник источников угроз (S1..S4 из модели ПТСЗИ) +CREATE TABLE threat_sources ( + id SMALLSERIAL PRIMARY KEY, + code VARCHAR(8) NOT NULL UNIQUE, -- S1, S2, S3, S4 + name TEXT NOT NULL, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +INSERT INTO threat_sources (code, name, description) VALUES + ('S1', 'Конкуренты', 'Внешние организации, заинтересованные в получении ЧТ и вредоносной деятельности'), + ('S2', 'Недобросовестные партнёры','Контрагенты, злоупотребляющие делегированным доступом'), + ('S3', 'Персонал организации', 'Внутренние сотрудники (инсайдеры, халатность)'), + ('S4', 'Хакеры, мошенники', 'Внешние атакующие без специфической мотивации'); From 9a526317b48f1bba2ab63386ab084579394bb97c Mon Sep 17 00:00:00 2001 From: velvetway Date: Fri, 17 Apr 2026 18:28:34 +0300 Subject: [PATCH 02/29] migration: add destructive_actions table with DA1..DA7 seed --- migrations/008_destructive_actions.down.sql | 1 + migrations/008_destructive_actions.up.sql | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 migrations/008_destructive_actions.down.sql create mode 100644 migrations/008_destructive_actions.up.sql diff --git a/migrations/008_destructive_actions.down.sql b/migrations/008_destructive_actions.down.sql new file mode 100644 index 0000000..d390df4 --- /dev/null +++ b/migrations/008_destructive_actions.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS destructive_actions; diff --git a/migrations/008_destructive_actions.up.sql b/migrations/008_destructive_actions.up.sql new file mode 100644 index 0000000..8fff23f --- /dev/null +++ b/migrations/008_destructive_actions.up.sql @@ -0,0 +1,21 @@ +-- Справочник деструктивных действий (DA1..DA7) +-- Каждое действие затрагивает одну или несколько граней CIA +CREATE TABLE destructive_actions ( + id SMALLSERIAL PRIMARY KEY, + code VARCHAR(8) NOT NULL UNIQUE, + name TEXT NOT NULL, + affects_confidentiality BOOLEAN NOT NULL DEFAULT FALSE, + affects_integrity BOOLEAN NOT NULL DEFAULT FALSE, + affects_availability BOOLEAN NOT NULL DEFAULT FALSE, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +INSERT INTO destructive_actions (code, name, affects_confidentiality, affects_integrity, affects_availability, description) VALUES + ('DA1', 'Копирование (чтение) информации', TRUE, FALSE, FALSE, 'Несанкционированное чтение/снятие копии защищаемых данных'), + ('DA2', 'Перехват информации', TRUE, FALSE, FALSE, 'Перехват данных в канале связи'), + ('DA3', 'Уничтожение информации', FALSE, FALSE, TRUE, 'Полное удаление данных с носителя'), + ('DA4', 'Модификация информации', FALSE, TRUE, FALSE, 'Несанкционированное изменение данных'), + ('DA5', 'Блокирование информации', FALSE, FALSE, TRUE, 'Сокрытие или временный отказ в доступе'), + ('DA6', 'Хищение информации', TRUE, FALSE, TRUE, 'Изъятие защищаемых данных (носителя)'), + ('DA7', 'Нарушение работоспособности системы', FALSE, TRUE, TRUE, 'Deface, DoS, компрометация целостности среды'); From 3315897ee6a52bd5d868ebe088561fecebe627e8 Mon Sep 17 00:00:00 2001 From: velvetway Date: Fri, 17 Apr 2026 18:30:03 +0300 Subject: [PATCH 03/29] migration: add 4 junction tables for S->ST->VL->DA graph --- migrations/009_graph_edges.down.sql | 4 ++++ migrations/009_graph_edges.up.sql | 32 +++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 migrations/009_graph_edges.down.sql create mode 100644 migrations/009_graph_edges.up.sql diff --git a/migrations/009_graph_edges.down.sql b/migrations/009_graph_edges.down.sql new file mode 100644 index 0000000..dbbc180 --- /dev/null +++ b/migrations/009_graph_edges.down.sql @@ -0,0 +1,4 @@ +DROP TABLE IF EXISTS vulnerability_controls; +DROP TABLE IF EXISTS threat_destructive_actions; +DROP TABLE IF EXISTS threat_vulnerable_links; +DROP TABLE IF EXISTS source_threats; diff --git a/migrations/009_graph_edges.up.sql b/migrations/009_graph_edges.up.sql new file mode 100644 index 0000000..363f01b --- /dev/null +++ b/migrations/009_graph_edges.up.sql @@ -0,0 +1,32 @@ +-- S → ST: какие источники могут применить какую угрозу +CREATE TABLE source_threats ( + threat_source_id SMALLINT NOT NULL REFERENCES threat_sources(id) ON DELETE CASCADE, + threat_id BIGINT NOT NULL REFERENCES threats(id) ON DELETE CASCADE, + PRIMARY KEY (threat_source_id, threat_id) +); +CREATE INDEX idx_st_threat ON source_threats(threat_id); + +-- ST → VL: через какие уязвимые звенья реализуется угроза +CREATE TABLE threat_vulnerable_links ( + threat_id BIGINT NOT NULL REFERENCES threats(id) ON DELETE CASCADE, + vulnerability_id BIGINT NOT NULL REFERENCES vulnerabilities(id) ON DELETE CASCADE, + PRIMARY KEY (threat_id, vulnerability_id) +); +CREATE INDEX idx_tvl_vuln ON threat_vulnerable_links(vulnerability_id); + +-- ST → DA: к каким деструктивным действиям ведёт угроза +CREATE TABLE threat_destructive_actions ( + threat_id BIGINT NOT NULL REFERENCES threats(id) ON DELETE CASCADE, + destructive_action_id SMALLINT NOT NULL REFERENCES destructive_actions(id) ON DELETE CASCADE, + PRIMARY KEY (threat_id, destructive_action_id) +); +CREATE INDEX idx_tda_da ON threat_destructive_actions(destructive_action_id); + +-- VL → Control: какой метод противодействия закрывает какое уязвимое звено +CREATE TABLE vulnerability_controls ( + vulnerability_id BIGINT NOT NULL REFERENCES vulnerabilities(id) ON DELETE CASCADE, + control_id BIGINT NOT NULL REFERENCES controls(id) ON DELETE CASCADE, + coverage NUMERIC(3,2) NOT NULL DEFAULT 1.0 CHECK (coverage BETWEEN 0 AND 1), + PRIMARY KEY (vulnerability_id, control_id) +); +CREATE INDEX idx_vc_control ON vulnerability_controls(control_id); From 7a5744ae62bf4ec6650a2fdf9d6a094ba11eca1d Mon Sep 17 00:00:00 2001 From: velvetway Date: Fri, 17 Apr 2026 18:32:00 +0300 Subject: [PATCH 04/29] migration: add q_threat/q_severity to threats and seed graph edges --- migrations/010_threat_q_fields.down.sql | 8 +++ migrations/010_threat_q_fields.up.sql | 78 +++++++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 migrations/010_threat_q_fields.down.sql create mode 100644 migrations/010_threat_q_fields.up.sql diff --git a/migrations/010_threat_q_fields.down.sql b/migrations/010_threat_q_fields.down.sql new file mode 100644 index 0000000..6cc44d7 --- /dev/null +++ b/migrations/010_threat_q_fields.down.sql @@ -0,0 +1,8 @@ +DELETE FROM vulnerability_controls; +DELETE FROM threat_vulnerable_links; +DELETE FROM threat_destructive_actions; +DELETE FROM source_threats; + +ALTER TABLE threats + DROP COLUMN IF EXISTS q_threat, + DROP COLUMN IF EXISTS q_severity; diff --git a/migrations/010_threat_q_fields.up.sql b/migrations/010_threat_q_fields.up.sql new file mode 100644 index 0000000..7384496 --- /dev/null +++ b/migrations/010_threat_q_fields.up.sql @@ -0,0 +1,78 @@ +-- Add Q-threat and q-severity fields for W_i formula +ALTER TABLE threats + ADD COLUMN IF NOT EXISTS q_threat NUMERIC(3,2) NOT NULL DEFAULT 0.5 + CHECK (q_threat BETWEEN 0 AND 1), + ADD COLUMN IF NOT EXISTS q_severity NUMERIC(3,2) NOT NULL DEFAULT 0.5 + CHECK (q_severity BETWEEN 0 AND 1); + +-- Backfill: q_threat from base_likelihood (1..5 → 0.1..0.9) +UPDATE threats SET q_threat = GREATEST(0.1, LEAST(0.9, base_likelihood::NUMERIC / 5.0)); + +-- Backfill: q_severity from CIA impact flags +-- sum of flags / 3, clipped to [0.2, 1.0] +UPDATE threats SET q_severity = GREATEST(0.2, LEAST(1.0, + (COALESCE(impact_confidentiality::INT, 0) + + COALESCE(impact_integrity::INT, 0) + + COALESCE(impact_availability::INT, 0))::NUMERIC / 3.0 +)); + +-- Seed edges: S -> ST (канонические связи из модели ПТСЗИ) +INSERT INTO source_threats (threat_source_id, threat_id) +SELECT s.id, t.id +FROM threat_sources s +CROSS JOIN threats t +WHERE + (s.code='S3' AND (t.name ILIKE '%вирус%' OR t.name ILIKE '%malware%')) + OR + (s.code='S4' AND (t.name ILIKE '%вирус%' OR t.name ILIKE '%несанкциониров%' + OR t.name ILIKE '%DDoS%' OR t.name ILIKE '%перехват%' + OR t.name ILIKE '%проникнов%' OR t.name ILIKE '%сканиров%' + OR t.name ILIKE '%spam%' OR t.name ILIKE '%подмен%')) + OR + (s.code='S1' AND (t.name ILIKE '%несанкциониров%' OR t.name ILIKE '%DDoS%' + OR t.name ILIKE '%перехват%' OR t.name ILIKE '%сканиров%' + OR t.name ILIKE '%spam%' OR t.name ILIKE '%подмен%')) + OR + (s.code='S2' AND (t.name ILIKE '%несанкциониров%' OR t.name ILIKE '%spam%' OR t.name ILIKE '%подмен%')) +ON CONFLICT DO NOTHING; + +-- Seed edges: ST -> DA +INSERT INTO threat_destructive_actions (threat_id, destructive_action_id) +SELECT t.id, da.id +FROM threats t +CROSS JOIN destructive_actions da +WHERE + (t.impact_confidentiality AND da.code IN ('DA1','DA2','DA6')) + OR + (t.impact_integrity AND da.code IN ('DA4','DA7')) + OR + (t.impact_availability AND da.code IN ('DA3','DA5','DA7')) +ON CONFLICT DO NOTHING; + +-- Seed edges: ST -> VL (heuristic by name) +INSERT INTO threat_vulnerable_links (threat_id, vulnerability_id) +SELECT t.id, v.id +FROM threats t, vulnerabilities v +WHERE + (t.name ILIKE '%brute%' AND (v.name ILIKE '%пароль%' OR v.name ILIKE '%password%')) + OR + (t.name ILIKE '%вирус%' AND (v.name ILIKE '%устарев%' OR v.name ILIKE '%outdated%')) + OR + (t.name ILIKE '%перехват%' AND (v.name ILIKE '%шифров%' OR v.name ILIKE '%encryption%')) + OR + (t.name ILIKE '%DDoS%' AND (v.name ILIKE '%фильтр%' OR v.name ILIKE '%rate%')) +ON CONFLICT DO NOTHING; + +-- Seed VL -> Control: heuristic by name keywords +INSERT INTO vulnerability_controls (vulnerability_id, control_id, coverage) +SELECT v.id, c.id, 1.0 +FROM vulnerabilities v, controls c +WHERE + (v.name ILIKE '%пароль%' AND c.name ILIKE '%MFA%') + OR + (v.name ILIKE '%устарев%' AND c.name ILIKE '%patch%') + OR + (v.name ILIKE '%шифров%' AND c.name ILIKE '%encrypt%') + OR + (v.name ILIKE '%фильтр%' AND c.name ILIKE '%firewall%') +ON CONFLICT DO NOTHING; From 292bc9c764079705579de9168462a412d31cdd6e Mon Sep 17 00:00:00 2001 From: velvetway Date: Fri, 17 Apr 2026 18:34:37 +0300 Subject: [PATCH 05/29] domain: add ThreatSource, DestructiveAction, AttackPath types --- internal/domain/destructive_action.go | 14 +++++++++ internal/domain/models.go | 4 +++ internal/domain/risk_graph.go | 44 +++++++++++++++++++++++++++ internal/domain/threat_source.go | 11 +++++++ 4 files changed, 73 insertions(+) create mode 100644 internal/domain/destructive_action.go create mode 100644 internal/domain/risk_graph.go create mode 100644 internal/domain/threat_source.go diff --git a/internal/domain/destructive_action.go b/internal/domain/destructive_action.go new file mode 100644 index 0000000..5fe58e9 --- /dev/null +++ b/internal/domain/destructive_action.go @@ -0,0 +1,14 @@ +package domain + +import "time" + +type DestructiveAction struct { + ID int16 `db:"id" json:"id"` + Code string `db:"code" json:"code"` + Name string `db:"name" json:"name"` + AffectsConfidentiality bool `db:"affects_confidentiality" json:"affects_confidentiality"` + AffectsIntegrity bool `db:"affects_integrity" json:"affects_integrity"` + AffectsAvailability bool `db:"affects_availability" json:"affects_availability"` + Description string `db:"description" json:"description,omitempty"` + CreatedAt time.Time `db:"created_at" json:"created_at"` +} diff --git a/internal/domain/models.go b/internal/domain/models.go index f8f1e85..3a69025 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -191,6 +191,10 @@ type Threat struct { Description *string `db:"description"` BaseLikelihood int16 `db:"base_likelihood"` + // PTSZI модель + QThreat float64 `db:"q_threat" json:"q_threat"` + QSeverity float64 `db:"q_severity" json:"q_severity"` + // БДУ ФСТЭК BDUID *string `db:"bdu_id"` // УБИ.001, УБИ.002, etc. AttackVector *string `db:"attack_vector"` // Сетевой, Локальный, Физический diff --git a/internal/domain/risk_graph.go b/internal/domain/risk_graph.go new file mode 100644 index 0000000..e6341b0 --- /dev/null +++ b/internal/domain/risk_graph.go @@ -0,0 +1,44 @@ +package domain + +// AttackPath — развёрнутая цепочка S → ST → VL → DA для одного актива и одной угрозы. +type AttackPath struct { + Asset AssetRef `json:"asset"` + Threat ThreatRef `json:"threat"` + Sources []ThreatSource `json:"sources"` + VulnerableLinks []VLNode `json:"vulnerable_links"` + DestructiveActions []DestructiveAction `json:"destructive_actions"` + W float64 `json:"w"` // [0,1] + QThreat float64 `json:"q_threat"` + QSeverity float64 `json:"q_severity"` + QReaction float64 `json:"q_reaction"` + Z float64 `json:"z"` + Level string `json:"level"` // low/medium/high/critical +} + +type AssetRef struct { + ID int64 `json:"id"` + Name string `json:"name"` +} + +type ThreatRef struct { + ID int64 `json:"id"` + Name string `json:"name"` + BDUID string `json:"bdu_id,omitempty"` +} + +// VLNode — уязвимое звено плюс средства защиты, закрывающие его на конкретном активе. +type VLNode struct { + VulnerabilityID int64 `json:"vulnerability_id"` + Name string `json:"name"` + Severity int `json:"severity"` // 1..10 + CoverageControls []ControlCoverage `json:"coverage_controls"` // controls present on this asset that cover this VL + Uncovered bool `json:"uncovered"` +} + +// ControlCoverage is the runtime view of a control that covers a given VL. +// (Separate from the `Control` DB struct if one exists elsewhere; this type is for graph output.) +type ControlCoverage struct { + ID int64 `json:"id"` + Name string `json:"name"` + Coverage float64 `json:"coverage"` // 0..1 from vulnerability_controls.coverage +} diff --git a/internal/domain/threat_source.go b/internal/domain/threat_source.go new file mode 100644 index 0000000..6b01484 --- /dev/null +++ b/internal/domain/threat_source.go @@ -0,0 +1,11 @@ +package domain + +import "time" + +type ThreatSource struct { + ID int16 `db:"id" json:"id"` + Code string `db:"code" json:"code"` + Name string `db:"name" json:"name"` + Description string `db:"description" json:"description,omitempty"` + CreatedAt time.Time `db:"created_at" json:"created_at"` +} From a61a442794c439a82c66187513d7fb9556cb2aa2 Mon Sep 17 00:00:00 2001 From: velvetway Date: Fri, 17 Apr 2026 18:37:15 +0300 Subject: [PATCH 06/29] repo: add threat_source, destructive_action, and risk_graph repositories --- .../destructive_action_repository.go | 76 +++++++++++++++ internal/repository/risk_graph_repository.go | 92 +++++++++++++++++++ .../repository/threat_source_repository.go | 68 ++++++++++++++ 3 files changed, 236 insertions(+) create mode 100644 internal/repository/destructive_action_repository.go create mode 100644 internal/repository/risk_graph_repository.go create mode 100644 internal/repository/threat_source_repository.go diff --git a/internal/repository/destructive_action_repository.go b/internal/repository/destructive_action_repository.go new file mode 100644 index 0000000..c89b682 --- /dev/null +++ b/internal/repository/destructive_action_repository.go @@ -0,0 +1,76 @@ +package repository + +import ( + "context" + + "Diplom/internal/domain" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type DestructiveActionRepository interface { + List(ctx context.Context) ([]domain.DestructiveAction, error) + ForThreat(ctx context.Context, threatID int64) ([]domain.DestructiveAction, error) +} + +type destructiveActionRepository struct { + pool *pgxpool.Pool +} + +func NewDestructiveActionRepository(pool *pgxpool.Pool) DestructiveActionRepository { + return &destructiveActionRepository{pool: pool} +} + +func (r *destructiveActionRepository) List(ctx context.Context) ([]domain.DestructiveAction, error) { + const q = ` + SELECT id, code, name, + affects_confidentiality, affects_integrity, affects_availability, + COALESCE(description, ''), created_at + FROM destructive_actions + ORDER BY id` + rows, err := r.pool.Query(ctx, q) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make([]domain.DestructiveAction, 0) + for rows.Next() { + var d domain.DestructiveAction + if err := rows.Scan(&d.ID, &d.Code, &d.Name, + &d.AffectsConfidentiality, &d.AffectsIntegrity, &d.AffectsAvailability, + &d.Description, &d.CreatedAt); err != nil { + return nil, err + } + out = append(out, d) + } + return out, rows.Err() +} + +func (r *destructiveActionRepository) ForThreat(ctx context.Context, threatID int64) ([]domain.DestructiveAction, error) { + const q = ` + SELECT da.id, da.code, da.name, + da.affects_confidentiality, da.affects_integrity, da.affects_availability, + COALESCE(da.description, ''), da.created_at + FROM destructive_actions da + JOIN threat_destructive_actions tda ON tda.destructive_action_id = da.id + WHERE tda.threat_id = $1 + ORDER BY da.id` + rows, err := r.pool.Query(ctx, q, threatID) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make([]domain.DestructiveAction, 0) + for rows.Next() { + var d domain.DestructiveAction + if err := rows.Scan(&d.ID, &d.Code, &d.Name, + &d.AffectsConfidentiality, &d.AffectsIntegrity, &d.AffectsAvailability, + &d.Description, &d.CreatedAt); err != nil { + return nil, err + } + out = append(out, d) + } + return out, rows.Err() +} diff --git a/internal/repository/risk_graph_repository.go b/internal/repository/risk_graph_repository.go new file mode 100644 index 0000000..5be122b --- /dev/null +++ b/internal/repository/risk_graph_repository.go @@ -0,0 +1,92 @@ +package repository + +import ( + "context" + "encoding/json" + + "Diplom/internal/domain" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type RiskGraphRepository interface { + LoadVulnerableLinks(ctx context.Context, assetID, threatID int64) ([]domain.VLNode, error) +} + +type riskGraphRepository struct { + pool *pgxpool.Pool +} + +func NewRiskGraphRepository(pool *pgxpool.Pool) RiskGraphRepository { + return &riskGraphRepository{pool: pool} +} + +func (r *riskGraphRepository) LoadVulnerableLinks(ctx context.Context, assetID, threatID int64) ([]domain.VLNode, error) { + const q = ` +WITH threat_vls AS ( + -- VL, относящиеся к угрозе + SELECT v.id, v.name, v.severity + FROM vulnerabilities v + JOIN threat_vulnerable_links tvl ON tvl.vulnerability_id = v.id + WHERE tvl.threat_id = $2 +), +asset_vls AS ( + -- VL, которые реально присутствуют на активе + SELECT v.id + FROM vulnerabilities v + JOIN asset_vulnerabilities av ON av.vulnerability_id = v.id + WHERE av.asset_id = $1 AND av.status IN ('open','mitigated') +), +vl_covering_controls AS ( + -- контроли, закрывающие VL угрозы, И внедрённые на активе + SELECT vc.vulnerability_id, + c.id AS control_id, + c.name AS control_name, + vc.coverage + FROM vulnerability_controls vc + JOIN controls c ON c.id = vc.control_id + JOIN asset_controls ac ON ac.control_id = c.id AND ac.asset_id = $1 +) +SELECT tv.id, + tv.name, + tv.severity, + COALESCE( + json_agg(json_build_object( + 'id', vcc.control_id, + 'name', vcc.control_name, + 'coverage', vcc.coverage + )) FILTER (WHERE vcc.control_id IS NOT NULL), + '[]'::json + ) AS controls_json, + -- uncovered: присутствует на активе И не имеет ни одного covering control + (EXISTS (SELECT 1 FROM asset_vls av WHERE av.id = tv.id)) + AND NOT EXISTS ( + SELECT 1 FROM vl_covering_controls vcc2 WHERE vcc2.vulnerability_id = tv.id + ) AS uncovered +FROM threat_vls tv +LEFT JOIN vl_covering_controls vcc ON vcc.vulnerability_id = tv.id +GROUP BY tv.id, tv.name, tv.severity +ORDER BY tv.id` + + rows, err := r.pool.Query(ctx, q, assetID, threatID) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make([]domain.VLNode, 0) + for rows.Next() { + var v domain.VLNode + var raw []byte + if err := rows.Scan(&v.VulnerabilityID, &v.Name, &v.Severity, &raw, &v.Uncovered); err != nil { + return nil, err + } + if len(raw) > 0 { + if err := json.Unmarshal(raw, &v.CoverageControls); err != nil { + return nil, err + } + } + out = append(out, v) + } + return out, rows.Err() +} diff --git a/internal/repository/threat_source_repository.go b/internal/repository/threat_source_repository.go new file mode 100644 index 0000000..153e003 --- /dev/null +++ b/internal/repository/threat_source_repository.go @@ -0,0 +1,68 @@ +package repository + +import ( + "context" + + "Diplom/internal/domain" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type ThreatSourceRepository interface { + List(ctx context.Context) ([]domain.ThreatSource, error) + ForThreat(ctx context.Context, threatID int64) ([]domain.ThreatSource, error) +} + +type threatSourceRepository struct { + pool *pgxpool.Pool +} + +func NewThreatSourceRepository(pool *pgxpool.Pool) ThreatSourceRepository { + return &threatSourceRepository{pool: pool} +} + +func (r *threatSourceRepository) List(ctx context.Context) ([]domain.ThreatSource, error) { + const q = ` + SELECT id, code, name, COALESCE(description, ''), created_at + FROM threat_sources + ORDER BY id` + rows, err := r.pool.Query(ctx, q) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make([]domain.ThreatSource, 0) + for rows.Next() { + var s domain.ThreatSource + if err := rows.Scan(&s.ID, &s.Code, &s.Name, &s.Description, &s.CreatedAt); err != nil { + return nil, err + } + out = append(out, s) + } + return out, rows.Err() +} + +func (r *threatSourceRepository) ForThreat(ctx context.Context, threatID int64) ([]domain.ThreatSource, error) { + const q = ` + SELECT s.id, s.code, s.name, COALESCE(s.description, ''), s.created_at + FROM threat_sources s + JOIN source_threats st ON st.threat_source_id = s.id + WHERE st.threat_id = $1 + ORDER BY s.id` + rows, err := r.pool.Query(ctx, q, threatID) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make([]domain.ThreatSource, 0) + for rows.Next() { + var s domain.ThreatSource + if err := rows.Scan(&s.ID, &s.Code, &s.Name, &s.Description, &s.CreatedAt); err != nil { + return nil, err + } + out = append(out, s) + } + return out, rows.Err() +} From 9ac313122c7ccb4b5a9caafe65e6cd8b35283c59 Mon Sep 17 00:00:00 2001 From: velvetway Date: Fri, 17 Apr 2026 18:39:53 +0300 Subject: [PATCH 07/29] risk: add W calculator (PTSZI formula) with unit tests Implements W = (Q_threat + q_severity + (1 - Q_reaction)) / 3 * Z alongside the existing Impact x Likelihood calculator. Introduces: - CalculateW: clamped formula over Q/Z inputs - LevelFromW: thresholds 0.25/0.50/0.75 -> low/medium/high/critical - QReactionFromVLs: share of VLs covered by >=1 non-zero control - ZFromAsset: contour coefficient (isolated 0.5, prod 1.0, stage 0.75, otherwise 0.5) 14 new unit tests, full risk suite green (29/29). Existing calculator.go is untouched; the service switches to CalculateW in task 8. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/service/risk/calculator_v2.go | 81 ++++++++++++ internal/service/risk/calculator_v2_test.go | 133 ++++++++++++++++++++ 2 files changed, 214 insertions(+) create mode 100644 internal/service/risk/calculator_v2.go create mode 100644 internal/service/risk/calculator_v2_test.go diff --git a/internal/service/risk/calculator_v2.go b/internal/service/risk/calculator_v2.go new file mode 100644 index 0000000..7163923 --- /dev/null +++ b/internal/service/risk/calculator_v2.go @@ -0,0 +1,81 @@ +package risk + +import "Diplom/internal/domain" + +// CalculateW implements the PTSZI formula W = (Q_threat + q_severity + (1 - Q_reaction)) / 3 * Z. +// All Q inputs are clamped to [0,1]; Z is clamped to [0.5, 1.0]. +func CalculateW(qThreat, qSeverity, qReaction, z float64) float64 { + qThreat = clamp01(qThreat) + qSeverity = clamp01(qSeverity) + qReaction = clamp01(qReaction) + if z < 0.5 { + z = 0.5 + } else if z > 1.0 { + z = 1.0 + } + return (qThreat + qSeverity + (1.0 - qReaction)) / 3.0 * z +} + +// LevelFromW maps a W score to a qualitative risk level. +func LevelFromW(w float64) string { + switch { + case w >= 0.75: + return "critical" + case w >= 0.50: + return "high" + case w >= 0.25: + return "medium" + default: + return "low" + } +} + +// QReactionFromVLs is the share of a threat's vulnerable links that have at least one +// control with non-zero coverage deployed on the asset. +// Returns 0 when the threat has no VLs (i.e., nothing can be "covered"). +func QReactionFromVLs(vls []domain.VLNode) float64 { + if len(vls) == 0 { + return 0.0 + } + covered := 0 + for _, v := range vls { + for _, c := range v.CoverageControls { + if c.Coverage > 0 { + covered++ + break + } + } + } + return float64(covered) / float64(len(vls)) +} + +// ZFromAsset assigns the contour-criticality coefficient to an asset: +// +// isolated → 0.5 (угроза актуальна только для одного контура) +// prod → 1.0 +// stage/staging → 0.75 +// otherwise (dev) → 0.5 +func ZFromAsset(a domain.Asset) float64 { + if a.IsIsolated { + return 0.5 + } + switch a.Environment { + case "prod", "production": + return 1.0 + case "stage", "staging": + return 0.75 + default: + return 0.5 + } +} + +func clamp01(v float64) float64 { + switch { + case v < 0: + return 0 + case v > 1: + return 1 + default: + return v + } +} diff --git a/internal/service/risk/calculator_v2_test.go b/internal/service/risk/calculator_v2_test.go new file mode 100644 index 0000000..27841dd --- /dev/null +++ b/internal/service/risk/calculator_v2_test.go @@ -0,0 +1,133 @@ +package risk + +import ( + "math" + "testing" + + "Diplom/internal/domain" +) + +func approxEq(a, b float64) bool { return math.Abs(a-b) < 1e-6 } + +func TestCalculateW_AllUncoveredMaxSeverity(t *testing.T) { + // Q_threat=1, q_severity=1, Q_reaction=0, Z=1 → W = (1 + 1 + 1) / 3 * 1 = 1.0 + got := CalculateW(1.0, 1.0, 0.0, 1.0) + if !approxEq(got, 1.0) { + t.Fatalf("expected 1.0, got %v", got) + } +} + +func TestCalculateW_FullyCovered(t *testing.T) { + // Q_threat=1, q_severity=1, Q_reaction=1, Z=1 → W = (1 + 1 + 0) / 3 * 1 = 2/3 + got := CalculateW(1.0, 1.0, 1.0, 1.0) + if !approxEq(got, 2.0/3.0) { + t.Fatalf("expected 0.6666..., got %v", got) + } +} + +func TestCalculateW_OneContour(t *testing.T) { + // Z=0.5 halves the result + got := CalculateW(1.0, 1.0, 0.0, 0.5) + if !approxEq(got, 0.5) { + t.Fatalf("expected 0.5, got %v", got) + } +} + +func TestCalculateW_ClampedToUnitInterval(t *testing.T) { + // Inputs out of [0,1] are clamped. Z clamped to [0.5, 1.0]. + got := CalculateW(1.5, 1.5, -0.5, 2.0) + // (1 + 1 + (1 - 0)) / 3 * 1 = 1.0 + if !approxEq(got, 1.0) { + t.Fatalf("expected 1.0, got %v", got) + } +} + +func TestCalculateW_ClampsNegativeZ(t *testing.T) { + // Z below 0.5 clamps to 0.5. + got := CalculateW(1.0, 1.0, 0.0, -1.0) + if !approxEq(got, 0.5) { + t.Fatalf("expected 0.5, got %v", got) + } +} + +func TestLevelFromW(t *testing.T) { + cases := map[float64]string{ + 0.00: "low", + 0.24: "low", + 0.25: "medium", + 0.49: "medium", + 0.50: "high", + 0.74: "high", + 0.75: "critical", + 1.00: "critical", + } + for w, want := range cases { + if got := LevelFromW(w); got != want { + t.Errorf("LevelFromW(%v) = %q; want %q", w, got, want) + } + } +} + +func TestQReactionFromVLs_AllCovered(t *testing.T) { + vls := []domain.VLNode{ + {VulnerabilityID: 1, CoverageControls: []domain.ControlCoverage{{Coverage: 1.0}}}, + {VulnerabilityID: 2, CoverageControls: []domain.ControlCoverage{{Coverage: 1.0}}}, + } + if got := QReactionFromVLs(vls); !approxEq(got, 1.0) { + t.Fatalf("expected 1.0, got %v", got) + } +} + +func TestQReactionFromVLs_HalfCovered(t *testing.T) { + vls := []domain.VLNode{ + {VulnerabilityID: 1, CoverageControls: []domain.ControlCoverage{{Coverage: 1.0}}}, + {VulnerabilityID: 2, CoverageControls: nil}, + } + if got := QReactionFromVLs(vls); !approxEq(got, 0.5) { + t.Fatalf("expected 0.5, got %v", got) + } +} + +func TestQReactionFromVLs_ZeroCoverageIgnored(t *testing.T) { + // A control with coverage=0 does NOT count as "covering" + vls := []domain.VLNode{ + {VulnerabilityID: 1, CoverageControls: []domain.ControlCoverage{{Coverage: 0.0}}}, + } + if got := QReactionFromVLs(vls); !approxEq(got, 0.0) { + t.Fatalf("expected 0.0, got %v", got) + } +} + +func TestQReactionFromVLs_Empty(t *testing.T) { + if got := QReactionFromVLs(nil); !approxEq(got, 0.0) { + t.Fatalf("empty VL list must yield 0.0, got %v", got) + } +} + +func TestZFromAsset_ProdNonIsolated(t *testing.T) { + a := domain.Asset{Environment: "prod", IsIsolated: false} + if got := ZFromAsset(a); !approxEq(got, 1.0) { + t.Fatalf("prod+non-isolated must be 1.0, got %v", got) + } +} + +func TestZFromAsset_Isolated(t *testing.T) { + a := domain.Asset{Environment: "prod", IsIsolated: true} + if got := ZFromAsset(a); !approxEq(got, 0.5) { + t.Fatalf("isolated asset must be 0.5, got %v", got) + } +} + +func TestZFromAsset_Stage(t *testing.T) { + a := domain.Asset{Environment: "stage", IsIsolated: false} + if got := ZFromAsset(a); !approxEq(got, 0.75) { + t.Fatalf("stage asset must be 0.75, got %v", got) + } +} + +func TestZFromAsset_Dev(t *testing.T) { + a := domain.Asset{Environment: "dev", IsIsolated: false} + if got := ZFromAsset(a); !approxEq(got, 0.5) { + t.Fatalf("dev asset must be 0.5, got %v", got) + } +} From 87fc88088d660085ebbc73ec06f6e0901c7fb4ac Mon Sep 17 00:00:00 2001 From: velvetway Date: Fri, 17 Apr 2026 18:42:47 +0300 Subject: [PATCH 08/29] risk: assemble attack path from repos with W calculator --- internal/service/risk/service.go | 71 +++++++++++++++++++++++++++++++ internal/transport/http/server.go | 5 ++- 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/internal/service/risk/service.go b/internal/service/risk/service.go index 29d2986..312cce0 100644 --- a/internal/service/risk/service.go +++ b/internal/service/risk/service.go @@ -17,6 +17,8 @@ type Service interface { Overview(ctx context.Context) ([]OverviewPoint, error) // Автоматический расчёт всех рисков для конкретного актива. AssetRiskProfile(ctx context.Context, assetID int64) ([]AssetRisk, error) + // Новый PTSZI граф атаки для пары актив+угроза с формулой W_i. + AssembleAttackPath(ctx context.Context, assetID, threatID int64) (*domain.AttackPath, error) } // OverviewPoint — точка на глобальной карте рисков. @@ -51,6 +53,9 @@ type service struct { threatsRepo repository.ThreatRepository vulnsRepo repository.VulnerabilityRepository assetVulnsRepo repository.AssetVulnerabilityRepository + sourceRepo repository.ThreatSourceRepository + daRepo repository.DestructiveActionRepository + graphRepo repository.RiskGraphRepository calculator *Calculator // тип и логика определены в calculator.go } @@ -61,12 +66,18 @@ func NewService( threats repository.ThreatRepository, vulns repository.VulnerabilityRepository, assetVulns repository.AssetVulnerabilityRepository, + sources repository.ThreatSourceRepository, + das repository.DestructiveActionRepository, + graph repository.RiskGraphRepository, ) Service { return &service{ assetsRepo: assets, threatsRepo: threats, vulnsRepo: vulns, assetVulnsRepo: assetVulns, + sourceRepo: sources, + daRepo: das, + graphRepo: graph, calculator: NewCalculator(), } } @@ -204,6 +215,66 @@ func (s *service) AssetRiskProfile(ctx context.Context, assetID int64) ([]AssetR return results, nil } +// AssembleAttackPath — строит полную цепочку S → ST → VL → DA для пары +// (актив, угроза) и считает W_i по формуле ПТСЗИ. +func (s *service) AssembleAttackPath(ctx context.Context, assetID, threatID int64) (*domain.AttackPath, error) { + if assetID <= 0 || threatID <= 0 { + return nil, fmt.Errorf("assetID and threatID must be positive") + } + + asset, err := s.assetsRepo.GetByID(ctx, assetID) + if err != nil { + return nil, fmt.Errorf("get asset: %w", err) + } + if asset == nil { + return nil, fmt.Errorf("asset not found") + } + + threat, err := s.threatsRepo.GetByID(ctx, threatID) + if err != nil { + return nil, fmt.Errorf("get threat: %w", err) + } + if threat == nil { + return nil, fmt.Errorf("threat not found") + } + + sources, err := s.sourceRepo.ForThreat(ctx, threatID) + if err != nil { + return nil, fmt.Errorf("load sources: %w", err) + } + das, err := s.daRepo.ForThreat(ctx, threatID) + if err != nil { + return nil, fmt.Errorf("load destructive actions: %w", err) + } + vls, err := s.graphRepo.LoadVulnerableLinks(ctx, assetID, threatID) + if err != nil { + return nil, fmt.Errorf("load vulnerable links: %w", err) + } + + qR := QReactionFromVLs(vls) + z := ZFromAsset(*asset) + w := CalculateW(threat.QThreat, threat.QSeverity, qR, z) + + bduID := "" + if threat.BDUID != nil { + bduID = *threat.BDUID + } + + return &domain.AttackPath{ + Asset: domain.AssetRef{ID: asset.ID, Name: asset.Name}, + Threat: domain.ThreatRef{ID: threat.ID, Name: threat.Name, BDUID: bduID}, + Sources: sources, + VulnerableLinks: vls, + DestructiveActions: das, + QThreat: threat.QThreat, + QSeverity: threat.QSeverity, + QReaction: qR, + Z: z, + W: w, + Level: LevelFromW(w), + }, nil +} + // vulnsForAsset — вспомогательный метод: уязвимости, привязанные к активу. func (s *service) vulnsForAsset(ctx context.Context, assetID int64) ([]domain.Vulnerability, error) { links, err := s.assetVulnsRepo.ListByAsset(ctx, assetID) diff --git a/internal/transport/http/server.go b/internal/transport/http/server.go index 1b0dfc1..5dd079f 100644 --- a/internal/transport/http/server.go +++ b/internal/transport/http/server.go @@ -89,7 +89,10 @@ func NewServer(_ context.Context, db *pgxpool.Pool, jwtSecret string) *fiber.App assetVulnHandler := NewAssetVulnerabilityHandler(assetVulnSvc) // Risk - riskSvc := riskService.NewService(assetRepo, threatRepo, vulnRepo, assetVulnRepo) + threatSourceRepo := repository.NewThreatSourceRepository(db) + destructiveActionRepo := repository.NewDestructiveActionRepository(db) + riskGraphRepo := repository.NewRiskGraphRepository(db) + riskSvc := riskService.NewService(assetRepo, threatRepo, vulnRepo, assetVulnRepo, threatSourceRepo, destructiveActionRepo, riskGraphRepo) riskHandler := NewRiskHandler(riskSvc) // ---------- Public routes (no auth) ---------- From ee161cab8feced2772a07632655295b1b0a09862 Mon Sep 17 00:00:00 2001 From: velvetway Date: Fri, 17 Apr 2026 21:11:17 +0300 Subject: [PATCH 09/29] api: add /api/risk/graph/:asset/:threat endpoint exposing PTSZI attack path --- internal/service/risk/service.go | 13 ++++++++ internal/transport/http/risk_handlers.go | 40 ++++++++++++++++++++++++ internal/transport/http/server.go | 3 ++ 3 files changed, 56 insertions(+) diff --git a/internal/service/risk/service.go b/internal/service/risk/service.go index 312cce0..d28cde6 100644 --- a/internal/service/risk/service.go +++ b/internal/service/risk/service.go @@ -19,6 +19,9 @@ type Service interface { AssetRiskProfile(ctx context.Context, assetID int64) ([]AssetRisk, error) // Новый PTSZI граф атаки для пары актив+угроза с формулой W_i. AssembleAttackPath(ctx context.Context, assetID, threatID int64) (*domain.AttackPath, error) + // Справочники для PTSZI-графа + ListThreatSources(ctx context.Context) ([]domain.ThreatSource, error) + ListDestructiveActions(ctx context.Context) ([]domain.DestructiveAction, error) } // OverviewPoint — точка на глобальной карте рисков. @@ -275,6 +278,16 @@ func (s *service) AssembleAttackPath(ctx context.Context, assetID, threatID int6 }, nil } +// ListThreatSources — справочник всех источников угроз (S1..Sn). +func (s *service) ListThreatSources(ctx context.Context) ([]domain.ThreatSource, error) { + return s.sourceRepo.List(ctx) +} + +// ListDestructiveActions — справочник всех деструктивных действий (DA1..DAn). +func (s *service) ListDestructiveActions(ctx context.Context) ([]domain.DestructiveAction, error) { + return s.daRepo.List(ctx) +} + // vulnsForAsset — вспомогательный метод: уязвимости, привязанные к активу. func (s *service) vulnsForAsset(ctx context.Context, assetID int64) ([]domain.Vulnerability, error) { links, err := s.assetVulnsRepo.ListByAsset(ctx, assetID) diff --git a/internal/transport/http/risk_handlers.go b/internal/transport/http/risk_handlers.go index d769c82..e0a93c2 100644 --- a/internal/transport/http/risk_handlers.go +++ b/internal/transport/http/risk_handlers.go @@ -2,6 +2,8 @@ package http import ( + "strconv" + "Diplom/internal/report" "Diplom/internal/service/risk" @@ -143,3 +145,41 @@ func (h *RiskHandler) GenerateRiskPDF(c *fiber.Ctx) error { c.Set("Content-Disposition", "attachment; filename=risk_report.pdf") return c.Send(pdfBytes) } + +//////////////////// PTSZI GRAPH //////////////////// + +// GET /api/risk/graph/:asset_id/:threat_id +func (h *RiskHandler) riskGraph(c *fiber.Ctx) error { + assetID, err := strconv.ParseInt(c.Params("asset_id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid asset_id"}) + } + threatID, err := strconv.ParseInt(c.Params("threat_id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid threat_id"}) + } + + path, err := h.svc.AssembleAttackPath(c.Context(), assetID, threatID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(path) +} + +// GET /api/threat-sources +func (h *RiskHandler) listThreatSources(c *fiber.Ctx) error { + out, err := h.svc.ListThreatSources(c.Context()) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(out) +} + +// GET /api/destructive-actions +func (h *RiskHandler) listDestructiveActions(c *fiber.Ctx) error { + out, err := h.svc.ListDestructiveActions(c.Context()) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(out) +} diff --git a/internal/transport/http/server.go b/internal/transport/http/server.go index 5dd079f..147a87b 100644 --- a/internal/transport/http/server.go +++ b/internal/transport/http/server.go @@ -145,6 +145,9 @@ func NewServer(_ context.Context, db *pgxpool.Pool, jwtSecret string) *fiber.App // Risk readOnly.Get("/risk/overview", riskHandler.overview) readOnly.Get("/risk/asset/:id", riskHandler.assetRiskProfile) + readOnly.Get("/risk/graph/:asset_id/:threat_id", riskHandler.riskGraph) + readOnly.Get("/threat-sources", riskHandler.listThreatSources) + readOnly.Get("/destructive-actions", riskHandler.listDestructiveActions) write.Post("/risk/preview", riskHandler.previewRisk) write.Post("/risk/report/pdf", riskHandler.GenerateRiskPDF) From 14bdc3eda2ea17357f738010f0b7b8c5e33acb25 Mon Sep 17 00:00:00 2001 From: velvetway Date: Fri, 17 Apr 2026 21:14:09 +0300 Subject: [PATCH 10/29] fix(repo): read q_threat/q_severity columns in Threat SELECTs --- internal/repository/threat_repository.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/internal/repository/threat_repository.go b/internal/repository/threat_repository.go index bc92701..112eeaf 100644 --- a/internal/repository/threat_repository.go +++ b/internal/repository/threat_repository.go @@ -70,6 +70,8 @@ SELECT source_type, description, base_likelihood, + q_threat, + q_severity, created_at, updated_at FROM threats @@ -83,6 +85,8 @@ WHERE id = $1 &t.SourceType, &t.Description, &t.BaseLikelihood, + &t.QThreat, + &t.QSeverity, &t.CreatedAt, &t.UpdatedAt, ) @@ -112,6 +116,8 @@ SELECT source_type, description, base_likelihood, + q_threat, + q_severity, created_at, updated_at FROM threats @@ -134,6 +140,8 @@ LIMIT $1 OFFSET $2 &t.SourceType, &t.Description, &t.BaseLikelihood, + &t.QThreat, + &t.QSeverity, &t.CreatedAt, &t.UpdatedAt, ); err != nil { From 1a26b8c9cd19daa3dc0aeaece3cfc5fee51bd069 Mon Sep 17 00:00:00 2001 From: velvetway Date: Fri, 17 Apr 2026 21:15:52 +0300 Subject: [PATCH 11/29] frontend: add d3-sankey and TypeScript types for PTSZI graph --- frontend/package-lock.json | 97 +++++++++++++++++++++++---------- frontend/package.json | 2 + frontend/src/types/riskGraph.ts | 44 +++++++++++++++ 3 files changed, 113 insertions(+), 30 deletions(-) create mode 100644 frontend/src/types/riskGraph.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 190fede..809574a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,11 +12,13 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^13.5.0", + "@types/d3-sankey": "^0.12.5", "@types/jest": "^27.5.2", "@types/node": "^16.18.126", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "d3": "^7.9.0", + "d3-sankey": "^0.12.3", "framer-motion": "^12.38.0", "lucide-react": "^1.8.0", "react": "^19.2.0", @@ -77,7 +79,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -727,7 +728,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.27.1.tgz", "integrity": "sha512-p9OkPbZ5G7UT1MofwYFigGebnrzGJacoBSQM0/6bi/PUMVE+qlWDD/OalvQKbwgQzU6dl0xAv6r4X7Jme0RYxA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1611,7 +1611,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.27.1.tgz", "integrity": "sha512-2KH4LWGSrJIkVf5tSiBFYuXDAoWRq2MMwgivCf+93dd0GQi8RXLjKA/0EvRnVV5G0hrHczsquXuD01L8s6dmBw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.1", "@babel/helper-module-imports": "^7.27.1", @@ -3341,7 +3340,6 @@ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -3744,6 +3742,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/d3-sankey": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/d3-sankey/-/d3-sankey-0.12.5.tgz", + "integrity": "sha512-/3RZSew0cLAtzGQ+C89hq/Rp3H20QJuVRSqFy6RKLe7E0B8kd2iOS1oBsodrgds4PcNVpqWhdUEng/SHvBcJ6Q==", + "license": "MIT", + "dependencies": { + "@types/d3-shape": "^1" + } + }, + "node_modules/@types/d3-sankey/node_modules/@types/d3-path": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.11.tgz", + "integrity": "sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw==", + "license": "MIT" + }, + "node_modules/@types/d3-sankey/node_modules/@types/d3-shape": { + "version": "1.3.12", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.12.tgz", + "integrity": "sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "^1" + } + }, "node_modules/@types/d3-scale": { "version": "4.0.9", "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", @@ -4021,7 +4043,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4031,7 +4052,6 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4146,7 +4166,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz", "integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==", "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.4.0", "@typescript-eslint/scope-manager": "5.62.0", @@ -4200,7 +4219,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz", "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "5.62.0", "@typescript-eslint/types": "5.62.0", @@ -4570,7 +4588,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4669,7 +4686,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5580,7 +5596,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -6622,8 +6637,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/d3": { "version": "7.9.0", @@ -6917,6 +6931,46 @@ "node": ">=12" } }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, "node_modules/d3-scale": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", @@ -6951,7 +7005,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -7841,7 +7894,6 @@ "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -10624,7 +10676,6 @@ "resolved": "https://registry.npmjs.org/jest/-/jest-27.5.1.tgz", "integrity": "sha512-Yn0mADZB89zTtjkPJEXwrac3LHudkQMR+Paqa8uxJHCBr9agxztUifWCyiYrjhMPBoUVBjyny0I7XH6ozDr7QQ==", "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^27.5.1", "import-local": "^3.0.2", @@ -11522,7 +11573,6 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -12893,7 +12943,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -14028,7 +14077,6 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -14388,7 +14436,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -14520,7 +14567,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -14562,7 +14608,6 @@ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", "integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -15066,7 +15111,6 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz", "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", "license": "MIT", - "peer": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -15315,7 +15359,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -16883,7 +16926,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -17052,7 +17094,6 @@ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "license": "(MIT OR CC0-1.0)", - "peer": true, "engines": { "node": ">=10" }, @@ -17161,7 +17202,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -17476,7 +17516,6 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.103.0.tgz", "integrity": "sha512-HU1JOuV1OavsZ+mfigY0j8d1TgQgbZ6M+J75zDkpEAwYeXjWSqrGJtgnPblJjd/mAyTNQ7ygw0MiKOn6etz8yw==", "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -17548,7 +17587,6 @@ "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.15.2.tgz", "integrity": "sha512-0XavAZbNJ5sDrCbkpWL8mia0o5WPOd2YGtxrEiZkBK9FjLppIUK2TgxK6qGD2P3hUXTJNNPVibrerKcx5WkR1g==", "license": "MIT", - "peer": true, "dependencies": { "@types/bonjour": "^3.5.9", "@types/connect-history-api-fallback": "^1.3.5", @@ -17961,7 +17999,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", diff --git a/frontend/package.json b/frontend/package.json index 5e06526..e585bb0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,11 +8,13 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^13.5.0", + "@types/d3-sankey": "^0.12.5", "@types/jest": "^27.5.2", "@types/node": "^16.18.126", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "d3": "^7.9.0", + "d3-sankey": "^0.12.3", "framer-motion": "^12.38.0", "lucide-react": "^1.8.0", "react": "^19.2.0", diff --git a/frontend/src/types/riskGraph.ts b/frontend/src/types/riskGraph.ts new file mode 100644 index 0000000..9954be4 --- /dev/null +++ b/frontend/src/types/riskGraph.ts @@ -0,0 +1,44 @@ +export interface ThreatSource { + id: number; + code: string; + name: string; + description?: string; +} + +export interface DestructiveAction { + id: number; + code: string; + name: string; + affects_confidentiality: boolean; + affects_integrity: boolean; + affects_availability: boolean; + description?: string; +} + +export interface ControlCoverage { + id: number; + name: string; + coverage: number; +} + +export interface VLNode { + vulnerability_id: number; + name: string; + severity: number; + coverage_controls: ControlCoverage[]; + uncovered: boolean; +} + +export interface AttackPath { + asset: { id: number; name: string }; + threat: { id: number; name: string; bdu_id?: string }; + sources: ThreatSource[]; + vulnerable_links: VLNode[]; + destructive_actions: DestructiveAction[]; + w: number; + q_threat: number; + q_severity: number; + q_reaction: number; + z: number; + level: 'low' | 'medium' | 'high' | 'critical'; +} From f0676a418e1f5d2860cdf48052801a6e7f7ef4cb Mon Sep 17 00:00:00 2001 From: velvetway Date: Fri, 17 Apr 2026 21:17:39 +0300 Subject: [PATCH 12/29] frontend: add RiskGraphSankey component (d3-sankey S->ST->VL->DA) --- frontend/src/components/RiskGraphSankey.tsx | 131 ++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 frontend/src/components/RiskGraphSankey.tsx diff --git a/frontend/src/components/RiskGraphSankey.tsx b/frontend/src/components/RiskGraphSankey.tsx new file mode 100644 index 0000000..311c3ee --- /dev/null +++ b/frontend/src/components/RiskGraphSankey.tsx @@ -0,0 +1,131 @@ +import React, { useMemo } from "react"; +import { sankey, sankeyLinkHorizontal, SankeyGraph, SankeyLink, SankeyNode } from "d3-sankey"; +import { AttackPath } from "../types/riskGraph"; + +type NodeDatum = { name: string; kind: "S" | "ST" | "VL" | "DA"; uncovered?: boolean }; +type LinkDatum = { source: number; target: number; value: number }; + +export const RiskGraphSankey: React.FC<{ path: AttackPath }> = ({ path }) => { + const { nodes, links } = useMemo(() => buildGraph(path), [path]); + const width = 960; + const height = 360; + + const graph: SankeyGraph = useMemo(() => { + const layout = sankey() + .nodeWidth(18) + .nodePadding(12) + .extent([[24, 24], [width - 24, height - 24]]); + return layout({ + nodes: nodes.map(n => ({ ...n })), + links: links.map(l => ({ ...l })), + }); + }, [nodes, links]); + + if (graph.nodes.length === 0) { + return ( +
+ Недостаточно данных для построения графа. +
+ ); + } + + return ( + + {(graph.links as SankeyLink[]).map((l, i) => { + const target = l.target as SankeyNode; + return ( + + ); + })} + {(graph.nodes as SankeyNode[]).map((n, i) => { + const nodeWidth = (n.x1 ?? 0) - (n.x0 ?? 0); + const nodeHeight = (n.y1 ?? 0) - (n.y0 ?? 0); + const isLeftHalf = (n.x0 ?? 0) < width / 2; + return ( + + + {n.name} + + ); + })} + + ); +}; + +function colorFor(kind: NodeDatum["kind"], uncovered?: boolean): string { + if (kind === "VL" && uncovered) return "var(--danger)"; + switch (kind) { + case "S": return "var(--command)"; + case "ST": return "var(--warning)"; + case "VL": return "var(--threat-medium)"; + case "DA": return "var(--danger)"; + } +} + +function buildGraph(p: AttackPath): { nodes: NodeDatum[]; links: LinkDatum[] } { + const nodes: NodeDatum[] = []; + const links: LinkDatum[] = []; + + const sIdx: Record = {}; + p.sources.forEach(s => { + sIdx[s.id] = nodes.length; + nodes.push({ name: `${s.code}: ${s.name}`, kind: "S" }); + }); + + const stIdx = nodes.length; + nodes.push({ name: p.threat.name, kind: "ST" }); + + const vlIdx: Record = {}; + p.vulnerable_links.forEach(v => { + vlIdx[v.vulnerability_id] = nodes.length; + nodes.push({ name: v.name, kind: "VL", uncovered: v.uncovered }); + }); + + const daIdx: Record = {}; + p.destructive_actions.forEach(d => { + daIdx[d.id] = nodes.length; + nodes.push({ name: `${d.code}: ${d.name}`, kind: "DA" }); + }); + + // S → ST + p.sources.forEach(s => links.push({ source: sIdx[s.id], target: stIdx, value: 1 })); + + // ST → VL + p.vulnerable_links.forEach(v => + links.push({ source: stIdx, target: vlIdx[v.vulnerability_id], value: v.severity || 1 }) + ); + + // ST → DA (direct edge if there are no VLs; otherwise VL → DA fan-out) + if (p.vulnerable_links.length === 0) { + p.destructive_actions.forEach(d => + links.push({ source: stIdx, target: daIdx[d.id], value: 1 }) + ); + } else { + p.vulnerable_links.forEach(v => { + p.destructive_actions.forEach(d => { + links.push({ source: vlIdx[v.vulnerability_id], target: daIdx[d.id], value: 1 }); + }); + }); + } + + return { nodes, links }; +} From d95bb50517e6216e2005cd4db8a8ed89aff00204 Mon Sep 17 00:00:00 2001 From: velvetway Date: Fri, 17 Apr 2026 21:19:43 +0300 Subject: [PATCH 13/29] frontend: add RiskGraphPage with Sankey and stat breakdown; link from RiskMap --- frontend/src/App.tsx | 2 + frontend/src/pages/RiskGraphPage.tsx | 73 ++++++++++++++++++++++++++++ frontend/src/pages/RiskMapPage.tsx | 3 ++ 3 files changed, 78 insertions(+) create mode 100644 frontend/src/pages/RiskGraphPage.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a1d332d..9e042fe 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,7 @@ import { ProtectedRoute } from "./components/ProtectedRoute"; import { AssetsPage } from "./pages/AssetsPage"; import { RiskPreviewPage } from "./pages/RiskPreviewPage"; import { RiskMapPage } from "./pages/RiskMapPage"; +import { RiskGraphPage } from "./pages/RiskGraphPage"; import { AssetFormPage } from "./pages/AssetFormPage"; import { AssetRiskProfilePage } from "./pages/AssetRiskProfilePage"; import { SoftwareCatalogPage } from "./pages/SoftwareCatalogPage"; @@ -108,6 +109,7 @@ const AppLayout: React.FC = () => { } /> } /> } /> + } /> } /> diff --git a/frontend/src/pages/RiskGraphPage.tsx b/frontend/src/pages/RiskGraphPage.tsx new file mode 100644 index 0000000..d724e40 --- /dev/null +++ b/frontend/src/pages/RiskGraphPage.tsx @@ -0,0 +1,73 @@ +import React, { useEffect, useState } from "react"; +import { useParams, useSearchParams, useNavigate } from "react-router-dom"; +import { authFetch } from "../api/client"; +import { AttackPath } from "../types/riskGraph"; +import { RiskGraphSankey } from "../components/RiskGraphSankey"; + +export const RiskGraphPage: React.FC = () => { + const { assetId } = useParams(); + const [params] = useSearchParams(); + const threatId = params.get("threat"); + const nav = useNavigate(); + const [path, setPath] = useState(null); + const [err, setErr] = useState(null); + + useEffect(() => { + if (!assetId || !threatId) return; + authFetch(`/api/risk/graph/${assetId}/${threatId}`) + .then(r => r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))) + .then(setPath) + .catch(e => setErr(e.message)); + }, [assetId, threatId]); + + if (!threatId) return

Укажите ?threat=<id> в URL.

; + if (err) return

Ошибка: {err}

; + if (!path) return

Загрузка...

; + + return ( +
+ + +

Граф атаки

+
+ {path.asset.name} ← {path.threat.name} + {path.threat.bdu_id && · {path.threat.bdu_id}} +
+ +
+ + + + + +
+ + +
+ ); +}; + +const Stat: React.FC<{ label: string; value: string; tone?: string }> = ({ label, value, tone }) => { + const color = tone === "critical" ? "var(--danger)" + : tone === "high" ? "var(--threat-high)" + : tone === "medium" ? "var(--warning)" + : tone === "low" ? "var(--success)" + : "var(--ink)"; + return ( +
+
{label}
+
{value}
+
+ ); +}; diff --git a/frontend/src/pages/RiskMapPage.tsx b/frontend/src/pages/RiskMapPage.tsx index 9134e42..1b32768 100644 --- a/frontend/src/pages/RiskMapPage.tsx +++ b/frontend/src/pages/RiskMapPage.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useState, useMemo } from "react"; +import { useNavigate } from "react-router-dom"; import * as d3 from "d3"; import { api } from "../api/client"; import { RiskOverviewPoint } from "../types"; @@ -11,6 +12,7 @@ export const RiskMapPage: React.FC = () => { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [hover, setHover] = useState(null); + const navigate = useNavigate(); useEffect(() => { api.getRiskOverview() @@ -124,6 +126,7 @@ export const RiskMapPage: React.FC = () => { fill={color} fillOpacity={hover === p ? 1 : 0.8} stroke="white" strokeWidth={hover === p ? 2 : 1} onMouseEnter={() => setHover(p)} onMouseLeave={() => setHover(null)} + onClick={() => navigate(`/risk/graph/${p.asset_id}?threat=${p.threat_id}`)} style={{ cursor: "pointer", transition: "all 0.2s" }} /> ); From 7bd53007a68c974c0816c50405dc4005daea1283 Mon Sep 17 00:00:00 2001 From: velvetway Date: Fri, 17 Apr 2026 21:21:42 +0300 Subject: [PATCH 14/29] risk: switch overview to W formula; keep score for back-compat display --- internal/service/risk/service.go | 45 +++++++++++++++++++------------- 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/internal/service/risk/service.go b/internal/service/risk/service.go index d28cde6..e4a2ee5 100644 --- a/internal/service/risk/service.go +++ b/internal/service/risk/service.go @@ -4,6 +4,7 @@ package risk import ( "context" "fmt" + "math" "Diplom/internal/domain" "Diplom/internal/repository" @@ -31,10 +32,18 @@ type OverviewPoint struct { ThreatID int64 `json:"threat_id"` ThreatName string `json:"threat_name"` + // Legacy back-compat display (derived from W below) Impact int16 `json:"impact"` Likelihood int16 `json:"likelihood"` Score int16 `json:"score"` Level string `json:"level"` + + // PTSZI model — W in [0,1] plus its components + W float64 `json:"w"` + QThreat float64 `json:"q_threat"` + QSeverity float64 `json:"q_severity"` + QReaction float64 `json:"q_reaction"` + Z float64 `json:"z"` } // AssetRisk — риск для актива от конкретной угрозы с рекомендациями. @@ -130,32 +139,32 @@ func (s *service) Overview(ctx context.Context) ([]OverviewPoint, error) { return nil, fmt.Errorf("list threats: %w", err) } - // заранее кешируем уязвимости по активам, чтобы не дергать БД в цикле NxM - vulnsByAsset := make(map[int64][]domain.Vulnerability, len(assets)) - for _, a := range assets { - vv, err := s.vulnsForAsset(ctx, a.ID) - if err != nil { - return nil, err - } - vulnsByAsset[a.ID] = vv - } - - var result []OverviewPoint + result := make([]OverviewPoint, 0, len(assets)*len(threats)) for _, a := range assets { for _, t := range threats { - vulns := vulnsByAsset[a.ID] - r := s.calculator.Calculate(&a, &t, vulns) - + path, err := s.AssembleAttackPath(ctx, a.ID, t.ID) + if err != nil { + // Если один путь не собрался — не валим весь overview, пропускаем пару. + continue + } result = append(result, OverviewPoint{ AssetID: a.ID, AssetName: a.Name, ThreatID: t.ID, ThreatName: t.Name, - Impact: r.Impact, - Likelihood: r.Likelihood, - Score: r.Score, - Level: r.Level, + + // Back-compat display: W scaled to legacy 1..25 / 1..5 ranges + Impact: int16(math.Round(path.QSeverity * 5)), + Likelihood: int16(math.Round(path.QThreat * 5)), + Score: int16(math.Round(path.W * 25)), + Level: path.Level, + + W: path.W, + QThreat: path.QThreat, + QSeverity: path.QSeverity, + QReaction: path.QReaction, + Z: path.Z, }) } } From 2ac4a4fd4d395a058e2d5bbf9764f309258e4978 Mon Sep 17 00:00:00 2001 From: velvetway Date: Fri, 17 Apr 2026 21:23:37 +0300 Subject: [PATCH 15/29] docs: document PTSZI risk model and W formula --- README.md | 2 ++ docs/risk-model.md | 86 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 docs/risk-model.md diff --git a/README.md b/README.md index 1fa3b6d..2d91051 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,8 @@ go run cmd/server/main.go ## Расчёт рисков и рекомендации +> **Формальная модель риска (ПТСЗИ)**: см. [docs/risk-model.md](docs/risk-model.md) — граф `S → ST → VL → DA` и формула `W_i`, которая заменила старую схему `Impact × Likelihood`. + Формулы сконцентрированы в `internal/service/risk/calculator.go`: - `Impact = f(BusinessCriticality, Confidentiality, Integrity, Availability)` + поправки за наличие угрозы, влияющей на соответствующие CIA‑метрики, и максимальную серьёзность уязвимости, привязанной к активу. diff --git a/docs/risk-model.md b/docs/risk-model.md new file mode 100644 index 0000000..3c12294 --- /dev/null +++ b/docs/risk-model.md @@ -0,0 +1,86 @@ +# Модель риска ПТСЗИ + +Платформа использует формальную модель вероятностно-тактического сценария защиты информации (ПТСЗИ). + +## Граф + +``` +S (источник) ──► ST (угроза) ──► VL (уязвимое звено) ──► DA (деструктивное действие) + ▲ + └─ средства противодействия (asset_controls) +``` + +- **S1..S4** — источники угроз (конкуренты, недобросовестные партнёры, персонал, хакеры). +- **ST_i** — конкретная угроза (связана с БДУ ФСТЭК через `threats.bdu_id`). +- **VL_i** — уязвимое звено на активе (CVE, устаревшее ПО, слабая конфигурация). +- **DA_i** — деструктивное действие, затрагивающее одну из граней CIA (копирование, модификация, блокировка …). + +## Формула + +$$W_i = \frac{Q_i^{threat} + q_i^{threat} + (1 - Q_i^{reaction})}{3} \cdot Z$$ + +| Обозначение | Смысл | Диапазон | Источник данных | +|----------------|-------------------------------------|-------------------|---------------------------------------------------------------------------------| +| `Q^threat` | Вероятность реализации угрозы | [0, 1] | `threats.q_threat` | +| `q^threat` | Степень опасности последствий | [0, 1] | `threats.q_severity` | +| `Q^reaction` | Степень покрытия СЗИ | [0, 1] | доля VL, имеющих хотя бы один внедрённый `control` на активе | +| `Z` | Коэффициент контура | {0.5, 0.75, 1.0} | `asset.environment` + `asset.is_isolated` | + +### Вычисление `Q^reaction` + +Для пары *(актив, угроза)*: +1. Берём все VL, связанные с угрозой (`threat_vulnerable_links`). +2. Для каждого VL проверяем, есть ли хотя бы один `control` из таблицы `vulnerability_controls`, который одновременно внедрён на активе (`asset_controls`). +3. `Q^reaction = |covered_VL| / |threat_VL|`. Если у угрозы нет VL — возвращается 0. + +### Вычисление `Z` + +| Условие | Z | +|------------------------------------------|------| +| `asset.is_isolated = TRUE` | 0.5 | +| `asset.environment ∈ {prod, production}` | 1.0 | +| `asset.environment ∈ {stage, staging}` | 0.75 | +| прочие (dev, test, …) | 0.5 | + +## Уровни риска + +| W | Уровень | +|------------------|-----------| +| [0.00, 0.25) | low | +| [0.25, 0.50) | medium | +| [0.50, 0.75) | high | +| [0.75, 1.00] | critical | + +## Сопоставление с легаси-шкалой 1–25 + +Эндпоинт `GET /api/risk/overview` возвращает *и* W-поля, *и* устаревшие `impact` / `likelihood` / `score` для совместимости с существующей матрицей 5×5 на фронте: + +``` +impact = round(q_severity × 5) +likelihood = round(q_threat × 5) +score = round(W × 25) +level = LevelFromW(W) +``` + +Новые интерфейсы должны использовать `w` и компоненты напрямую. + +## API + +- `GET /api/risk/graph/:asset_id/:threat_id` — полный `AttackPath` (источники, уязвимые звенья с покрытием, деструктивные действия, разложение W). +- `GET /api/threat-sources` — справочник источников угроз. +- `GET /api/destructive-actions` — справочник деструктивных действий. +- `GET /api/risk/overview` — W для всех пар {actif × threat} с обратной совместимостью. + +## Таблицы БД + +| Таблица | Назначение | +|--------------------------------|-------------------------------------------| +| `threat_sources` | справочник S1..S4 | +| `destructive_actions` | справочник DA1..DA7 | +| `source_threats` | ребро S ↔ ST | +| `threat_vulnerable_links` | ребро ST ↔ VL | +| `threat_destructive_actions` | ребро ST ↔ DA | +| `vulnerability_controls` | покрытие VL контролем с весом `coverage` | +| `threats.q_threat`, `.q_severity` | параметры Q формулы | + +Миграции `007`–`010` содержат DDL и базовый сид (heuristic-заполнение рёбер по названиям угроз/уязвимостей). From 46c17504ed0d697b810cb55f93f29f35ad8d3ccc Mon Sep 17 00:00:00 2001 From: velvetway Date: Fri, 17 Apr 2026 22:22:45 +0300 Subject: [PATCH 16/29] design: import tokens.css, add typed Icon map and primitive components --- frontend/public/index.html | 3 + frontend/src/components/design/Icon.tsx | 96 ++++++ frontend/src/components/design/Primitives.tsx | 294 +++++++++++++++++ frontend/src/components/design/index.ts | 2 + frontend/src/index.css | 302 ++++++++---------- frontend/src/index.tsx | 1 + frontend/src/tokens.css | 191 +++++++++++ 7 files changed, 716 insertions(+), 173 deletions(-) create mode 100644 frontend/src/components/design/Icon.tsx create mode 100644 frontend/src/components/design/Primitives.tsx create mode 100644 frontend/src/components/design/index.ts create mode 100644 frontend/src/tokens.css diff --git a/frontend/public/index.html b/frontend/public/index.html index aa069f2..06e0541 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -10,6 +10,9 @@ content="Web site created using create-react-app" /> + + +