feat(techniques): add external_references storage and display MITRE sources with links
Aegis CI / lint-and-test (push) Waiting to run
Snyk Security Scan / Python vulnerabilities (backend) (push) Waiting to run
Snyk Security Scan / npm vulnerabilities (frontend) (push) Waiting to run
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Waiting to run
Aegis CI / lint-and-test (push) Waiting to run
Snyk Security Scan / Python vulnerabilities (backend) (push) Waiting to run
Snyk Security Scan / npm vulnerabilities (frontend) (push) Waiting to run
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Waiting to run
This commit is contained in:
@@ -0,0 +1,26 @@
|
|||||||
|
"""Add external_references column to techniques table.
|
||||||
|
|
||||||
|
Revision ID: b049
|
||||||
|
Revises: b048
|
||||||
|
Create Date: 2026-06-18
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
|
||||||
|
revision = "b049"
|
||||||
|
down_revision = "b048"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"techniques",
|
||||||
|
sa.Column("external_references", JSONB, nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("techniques", "external_references")
|
||||||
@@ -59,6 +59,8 @@ class Technique(Base):
|
|||||||
review_required = Column(Boolean, default=False)
|
review_required = Column(Boolean, default=False)
|
||||||
# Assign last_review_date = Column(DateTime, nullable=True)
|
# Assign last_review_date = Column(DateTime, nullable=True)
|
||||||
last_review_date = Column(DateTime, nullable=True)
|
last_review_date = Column(DateTime, nullable=True)
|
||||||
|
# Assign external_references = Column(JSONB, nullable=True, default=list)
|
||||||
|
external_references = Column(JSONB, nullable=True, default=list)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
tests = relationship("Test", back_populates="technique")
|
tests = relationship("Test", back_populates="technique")
|
||||||
|
|||||||
@@ -263,6 +263,8 @@ def sync_mitre(db: Session) -> dict:
|
|||||||
is_subtechnique = "." in mitre_id
|
is_subtechnique = "." in mitre_id
|
||||||
# Assign parent_mitre_id = mitre_id.split(".")[0] if is_subtechnique else None
|
# Assign parent_mitre_id = mitre_id.split(".")[0] if is_subtechnique else None
|
||||||
parent_mitre_id = mitre_id.split(".")[0] if is_subtechnique else None
|
parent_mitre_id = mitre_id.split(".")[0] if is_subtechnique else None
|
||||||
|
# Assign external_references = obj.get("external_references", [])
|
||||||
|
external_references = obj.get("external_references", [])
|
||||||
|
|
||||||
# Assign existing = existing_techniques.get(mitre_id)
|
# Assign existing = existing_techniques.get(mitre_id)
|
||||||
existing = existing_techniques.get(mitre_id)
|
existing = existing_techniques.get(mitre_id)
|
||||||
@@ -289,6 +291,8 @@ def sync_mitre(db: Session) -> dict:
|
|||||||
is_subtechnique=is_subtechnique,
|
is_subtechnique=is_subtechnique,
|
||||||
# Keyword argument: parent_mitre_id
|
# Keyword argument: parent_mitre_id
|
||||||
parent_mitre_id=parent_mitre_id,
|
parent_mitre_id=parent_mitre_id,
|
||||||
|
# Keyword argument: external_references
|
||||||
|
external_references=external_references,
|
||||||
# Keyword argument: status_global
|
# Keyword argument: status_global
|
||||||
status_global=TechniqueStatus.not_evaluated,
|
status_global=TechniqueStatus.not_evaluated,
|
||||||
# Keyword argument: review_required
|
# Keyword argument: review_required
|
||||||
@@ -331,6 +335,8 @@ def sync_mitre(db: Session) -> dict:
|
|||||||
existing.is_subtechnique = is_subtechnique
|
existing.is_subtechnique = is_subtechnique
|
||||||
# Assign existing.parent_mitre_id = parent_mitre_id
|
# Assign existing.parent_mitre_id = parent_mitre_id
|
||||||
existing.parent_mitre_id = parent_mitre_id
|
existing.parent_mitre_id = parent_mitre_id
|
||||||
|
# Assign existing.external_references = external_references
|
||||||
|
existing.external_references = external_references
|
||||||
|
|
||||||
# Check: changes
|
# Check: changes
|
||||||
if changes:
|
if changes:
|
||||||
|
|||||||
@@ -131,4 +131,5 @@ def get_technique_detail(db: Session, mitre_id: str) -> dict:
|
|||||||
}
|
}
|
||||||
for item in intel_items
|
for item in intel_items
|
||||||
],
|
],
|
||||||
|
"external_references": technique.external_references or [],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,6 +151,12 @@ function TechniqueJiraSection({ technique }: { technique: ReturnType<typeof Obje
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getMitreUrl(mitreId: string, externalRefs?: Array<{ source_name: string; url?: string }>): string {
|
||||||
|
const ref = externalRefs?.find((r) => r.source_name === "mitre-attack" && r.url);
|
||||||
|
if (ref?.url) return ref.url;
|
||||||
|
return `https://attack.mitre.org/techniques/${mitreId.replace(".", "/")}/`;
|
||||||
|
}
|
||||||
|
|
||||||
export default function TechniqueDetailPage() {
|
export default function TechniqueDetailPage() {
|
||||||
const { mitreId } = useParams<{ mitreId: string }>();
|
const { mitreId } = useParams<{ mitreId: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -349,6 +355,20 @@ export default function TechniqueDetailPage() {
|
|||||||
<dt className="text-xs font-medium uppercase text-gray-500">MITRE Version</dt>
|
<dt className="text-xs font-medium uppercase text-gray-500">MITRE Version</dt>
|
||||||
<dd className="mt-1 text-sm text-gray-300">{technique.mitre_version || "—"}</dd>
|
<dd className="mt-1 text-sm text-gray-300">{technique.mitre_version || "—"}</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-medium uppercase text-gray-500">MITRE ATT&CK</dt>
|
||||||
|
<dd className="mt-1">
|
||||||
|
<a
|
||||||
|
href={getMitreUrl(technique.mitre_id, technique.external_references)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-sm text-cyan-400 hover:underline"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
|
View on MITRE ATT&CK
|
||||||
|
</a>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -655,6 +675,39 @@ export default function TechniqueDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* External References */}
|
||||||
|
{technique.external_references && technique.external_references.filter((r) => r.source_name !== "mitre-attack" && r.url).length > 0 && (
|
||||||
|
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold text-white">References</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{technique.external_references
|
||||||
|
.filter((r) => r.source_name !== "mitre-attack" && r.url)
|
||||||
|
.map((ref, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-start justify-between rounded-lg border border-gray-800 bg-gray-800/30 p-3"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-gray-200">{ref.source_name}</p>
|
||||||
|
{ref.description && (
|
||||||
|
<p className="mt-0.5 line-clamp-2 text-xs text-gray-500">{ref.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={safeUrl(ref.url!)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="ml-3 flex shrink-0 items-center gap-1 text-sm text-cyan-400 hover:underline"
|
||||||
|
>
|
||||||
|
View
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Jira Integration — shows tickets linked to tests of this technique */}
|
{/* Jira Integration — shows tickets linked to tests of this technique */}
|
||||||
{technique && <TechniqueJiraSection technique={technique} />}
|
{technique && <TechniqueJiraSection technique={technique} />}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ export interface User {
|
|||||||
|
|
||||||
// ── Techniques ─────────────────────────────────────────────────────
|
// ── Techniques ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface TechniqueExternalReference {
|
||||||
|
source_name: string;
|
||||||
|
url?: string;
|
||||||
|
external_id?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Technique {
|
export interface Technique {
|
||||||
id: string;
|
id: string;
|
||||||
mitre_id: string;
|
mitre_id: string;
|
||||||
@@ -25,6 +32,7 @@ export interface Technique {
|
|||||||
status_global: TechniqueStatus;
|
status_global: TechniqueStatus;
|
||||||
review_required: boolean;
|
review_required: boolean;
|
||||||
last_review_date: string | null;
|
last_review_date: string | null;
|
||||||
|
external_references?: TechniqueExternalReference[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TechniqueStatus =
|
export type TechniqueStatus =
|
||||||
|
|||||||
Reference in New Issue
Block a user