fix(jira,evidence,tempo,settings): 4-issue fix batch
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Jira — PoC custom field: - Add customfield_10309 (Proof of Concept) to issue fields when creating test tickets so the attack procedure appears in the dedicated Jira field Tempo — blue team exclusion: - Remove blue_team_evaluation from _TEMPO_ACTIVITY_TYPES; blue team time is tracked internally (worklogs) for SLA but never sent to Tempo since blue team has no Jira access Evidence — uploaded_at NULL fix: - Set uploaded_at=datetime.utcnow() explicitly in upload_evidence router; the DB column has no server default so it was saving as NULL Evidence — presigned URL browser access: - Add MINIO_PUBLIC_ENDPOINT setting (config.py, docker-compose.prod.yml) - storage.py uses a dedicated _public_client for presigned URL generation so browsers receive URLs with the publicly accessible hostname instead of the internal Docker service name (minio:9000) - Expose MinIO port 9000 in docker-compose.prod.yml Evidence — Jira attachment: - After upload to MinIO, call jira.add_attachment() to attach the file to the linked Jira ticket (non-fatal; errors are logged and swallowed) Settings — hide Jira/Tempo from blue team: - ProfileSection checks user role; blue_lead and blue_tech do not see the Jira Integration or Tempo Integration personal settings sections Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -36,6 +36,10 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# ── MinIO / S3 ───────────────────────────────────────────────────
|
# ── MinIO / S3 ───────────────────────────────────────────────────
|
||||||
MINIO_ENDPOINT: str = "minio:9000"
|
MINIO_ENDPOINT: str = "minio:9000"
|
||||||
|
# Public hostname used in presigned URLs returned to browsers.
|
||||||
|
# In production set this to <server-ip>:9000 (or a public FQDN) so
|
||||||
|
# the browser can reach MinIO directly. Defaults to MINIO_ENDPOINT.
|
||||||
|
MINIO_PUBLIC_ENDPOINT: str = ""
|
||||||
MINIO_ACCESS_KEY: str = "minioadmin"
|
MINIO_ACCESS_KEY: str = "minioadmin"
|
||||||
MINIO_SECRET_KEY: str = "minioadmin"
|
MINIO_SECRET_KEY: str = "minioadmin"
|
||||||
MINIO_BUCKET: str = "evidence"
|
MINIO_BUCKET: str = "evidence"
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ Access Control
|
|||||||
import hashlib
|
import hashlib
|
||||||
import os
|
import os
|
||||||
import uuid as _uuid
|
import uuid as _uuid
|
||||||
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, Form, Query, Request, UploadFile, status
|
from fastapi import APIRouter, Depends, File, Form, Query, Request, UploadFile, status
|
||||||
@@ -119,6 +120,7 @@ async def upload_evidence(
|
|||||||
file_path=key,
|
file_path=key,
|
||||||
sha256_hash=sha256,
|
sha256_hash=sha256,
|
||||||
uploaded_by=current_user.id,
|
uploaded_by=current_user.id,
|
||||||
|
uploaded_at=datetime.utcnow(), # set explicitly — DB column has no server default
|
||||||
team=team,
|
team=team,
|
||||||
notes=notes,
|
notes=notes,
|
||||||
)
|
)
|
||||||
@@ -140,9 +142,40 @@ async def upload_evidence(
|
|||||||
uow.commit()
|
uow.commit()
|
||||||
db.refresh(evidence)
|
db.refresh(evidence)
|
||||||
|
|
||||||
|
# 7. Attach to Jira ticket if one exists (non-fatal)
|
||||||
|
_attach_evidence_to_jira(db, test_id, content, safe_name, current_user)
|
||||||
|
|
||||||
return _evidence_to_out(evidence)
|
return _evidence_to_out(evidence)
|
||||||
|
|
||||||
|
|
||||||
|
def _attach_evidence_to_jira(
|
||||||
|
db,
|
||||||
|
test_id: _uuid.UUID,
|
||||||
|
content: bytes,
|
||||||
|
file_name: str,
|
||||||
|
actor,
|
||||||
|
) -> None:
|
||||||
|
"""Attach uploaded evidence to the linked Jira ticket (non-fatal)."""
|
||||||
|
try:
|
||||||
|
from app.services.jira_service import get_test_jira_key, get_user_jira_client, has_jira_configured
|
||||||
|
if not has_jira_configured(actor, db):
|
||||||
|
return
|
||||||
|
issue_key = get_test_jira_key(db, test_id)
|
||||||
|
if not issue_key:
|
||||||
|
return
|
||||||
|
jira = get_user_jira_client(actor, db)
|
||||||
|
jira.add_attachment(issue_key, filename=file_name, content=content)
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).info(
|
||||||
|
"Attached evidence '%s' to Jira ticket %s", file_name, issue_key
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).warning(
|
||||||
|
"Failed to attach evidence '%s' to Jira: %s", file_name, exc, exc_info=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# GET /tests/{test_id}/evidence — list (with optional team filter)
|
# GET /tests/{test_id}/evidence — list (with optional team filter)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -488,12 +488,15 @@ def auto_create_test_issue(
|
|||||||
parent = parent_ticket_override or get_jira_parent_ticket_standalone(db)
|
parent = parent_ticket_override or get_jira_parent_ticket_standalone(db)
|
||||||
issue_type = settings.JIRA_ISSUE_TYPE_TEST # always Task
|
issue_type = settings.JIRA_ISSUE_TYPE_TEST # always Task
|
||||||
|
|
||||||
|
poc = test.procedure_text or "N/A"
|
||||||
fields: dict = {
|
fields: dict = {
|
||||||
"project": {"key": project_key},
|
"project": {"key": project_key},
|
||||||
"summary": f"[Aegis] {mitre_id} — {test.name}",
|
"summary": f"[Aegis] {mitre_id} — {test.name}",
|
||||||
"description": _build_test_description(test, technique),
|
"description": _build_test_description(test, technique),
|
||||||
"issuetype": {"name": issue_type},
|
"issuetype": {"name": issue_type},
|
||||||
"labels": ["aegis", "security-test", mitre_id.replace(".", "-")],
|
"labels": ["aegis", "security-test", mitre_id.replace(".", "-")],
|
||||||
|
# customfield_10309 = Proof of Concept field (required by team's Jira config)
|
||||||
|
"customfield_10309": f"{{code}}{poc}{{code}}",
|
||||||
}
|
}
|
||||||
|
|
||||||
if parent:
|
if parent:
|
||||||
|
|||||||
@@ -29,8 +29,10 @@ from app.models.jira_link import JiraLink, JiraLinkEntityType
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Activity types forwarded to Tempo.
|
# Only red team execution time goes to Tempo.
|
||||||
_TEMPO_ACTIVITY_TYPES = {"red_team_execution", "blue_team_evaluation"}
|
# Blue team evaluation time is tracked internally (worklogs table) for SLA
|
||||||
|
# purposes but is NOT forwarded to Tempo — blue team has no Jira access.
|
||||||
|
_TEMPO_ACTIVITY_TYPES = {"red_team_execution"}
|
||||||
|
|
||||||
|
|
||||||
def has_tempo_configured(user) -> bool:
|
def has_tempo_configured(user) -> bool:
|
||||||
|
|||||||
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
Provides thin wrappers around boto3 for bucket management, file upload
|
Provides thin wrappers around boto3 for bucket management, file upload
|
||||||
and presigned-URL generation.
|
and presigned-URL generation.
|
||||||
|
|
||||||
|
Two clients are maintained:
|
||||||
|
- ``_client`` — uses the internal Docker endpoint (``MINIO_ENDPOINT``) for
|
||||||
|
all server-side operations (upload, head_bucket, create_bucket).
|
||||||
|
- ``_public_client`` — used **only** for presigned URL generation. It uses
|
||||||
|
``MINIO_PUBLIC_ENDPOINT`` when set so the resulting URLs are reachable from
|
||||||
|
browsers. If ``MINIO_PUBLIC_ENDPOINT`` is not configured it falls back to
|
||||||
|
``MINIO_ENDPOINT`` (backwards-compatible).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import boto3
|
import boto3
|
||||||
@@ -10,11 +18,12 @@ from botocore.exceptions import ClientError
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Shared client (module-level singleton)
|
# Shared clients (module-level singletons)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
_scheme = "https" if settings.MINIO_SECURE else "http"
|
_scheme = "https" if settings.MINIO_SECURE else "http"
|
||||||
|
|
||||||
|
# Internal client — used for uploads and bucket management
|
||||||
_client = boto3.client(
|
_client = boto3.client(
|
||||||
"s3",
|
"s3",
|
||||||
endpoint_url=f"{_scheme}://{settings.MINIO_ENDPOINT}",
|
endpoint_url=f"{_scheme}://{settings.MINIO_ENDPOINT}",
|
||||||
@@ -23,6 +32,17 @@ _client = boto3.client(
|
|||||||
region_name="us-east-1", # MinIO ignores this but boto3 requires it
|
region_name="us-east-1", # MinIO ignores this but boto3 requires it
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Public client — used only for presigned URL generation so URLs contain the
|
||||||
|
# publicly accessible hostname instead of the internal Docker service name.
|
||||||
|
_public_endpoint = settings.MINIO_PUBLIC_ENDPOINT or settings.MINIO_ENDPOINT
|
||||||
|
_public_client = boto3.client(
|
||||||
|
"s3",
|
||||||
|
endpoint_url=f"{_scheme}://{_public_endpoint}",
|
||||||
|
aws_access_key_id=settings.MINIO_ACCESS_KEY,
|
||||||
|
aws_secret_access_key=settings.MINIO_SECRET_KEY,
|
||||||
|
region_name="us-east-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Public helpers
|
# Public helpers
|
||||||
@@ -51,8 +71,13 @@ def upload_file(content: bytes, key: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def get_presigned_url(key: str, expiration: int = 3600) -> str:
|
def get_presigned_url(key: str, expiration: int = 3600) -> str:
|
||||||
"""Return a presigned GET URL for *key* valid for *expiration* seconds."""
|
"""Return a presigned GET URL for *key* valid for *expiration* seconds.
|
||||||
return _client.generate_presigned_url(
|
|
||||||
|
Uses ``_public_client`` so the URL contains the publicly accessible
|
||||||
|
hostname (``MINIO_PUBLIC_ENDPOINT``) rather than the internal Docker
|
||||||
|
service name (``MINIO_ENDPOINT``).
|
||||||
|
"""
|
||||||
|
return _public_client.generate_presigned_url(
|
||||||
"get_object",
|
"get_object",
|
||||||
Params={"Bucket": settings.MINIO_BUCKET, "Key": key},
|
Params={"Bucket": settings.MINIO_BUCKET, "Key": key},
|
||||||
ExpiresIn=expiration,
|
ExpiresIn=expiration,
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ services:
|
|||||||
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-minioadmin}
|
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-minioadmin}
|
||||||
volumes:
|
volumes:
|
||||||
- minio_data:/data
|
- minio_data:/data
|
||||||
|
ports:
|
||||||
|
# Expose MinIO API so browsers can access presigned evidence URLs.
|
||||||
|
# Override with MINIO_PORT env var if port 9000 is already in use.
|
||||||
|
- "${MINIO_PORT:-9000}:9000"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "mc", "ready", "local"]
|
test: ["CMD", "mc", "ready", "local"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
@@ -75,6 +79,9 @@ services:
|
|||||||
ALGORITHM: HS256
|
ALGORITHM: HS256
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES: ${TOKEN_EXPIRE_MINUTES:-60}
|
ACCESS_TOKEN_EXPIRE_MINUTES: ${TOKEN_EXPIRE_MINUTES:-60}
|
||||||
MINIO_ENDPOINT: minio:9000
|
MINIO_ENDPOINT: minio:9000
|
||||||
|
# Public hostname browsers use to fetch presigned evidence URLs.
|
||||||
|
# Set to <server-ip>:9000 (or a public FQDN) in your .env file.
|
||||||
|
MINIO_PUBLIC_ENDPOINT: ${MINIO_PUBLIC_ENDPOINT:-}
|
||||||
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:-minioadmin}
|
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:-minioadmin}
|
||||||
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:-minioadmin}
|
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:-minioadmin}
|
||||||
MINIO_BUCKET: ${MINIO_BUCKET:-evidence}
|
MINIO_BUCKET: ${MINIO_BUCKET:-evidence}
|
||||||
|
|||||||
@@ -810,6 +810,9 @@ function NotificationSection() {
|
|||||||
function ProfileSection() {
|
function ProfileSection() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [toast, setToast] = useState<{ msg: string; type: "success" | "error" } | null>(null);
|
const [toast, setToast] = useState<{ msg: string; type: "success" | "error" } | null>(null);
|
||||||
|
const { user } = useAuth();
|
||||||
|
// Blue team roles have no Jira/Tempo access — hide those settings for them
|
||||||
|
const showJiraTempoSettings = !["blue_lead", "blue_tech"].includes(user?.role ?? "");
|
||||||
|
|
||||||
const { data: me, isLoading } = useQuery({
|
const { data: me, isLoading } = useQuery({
|
||||||
queryKey: ["me-prefs"],
|
queryKey: ["me-prefs"],
|
||||||
@@ -927,7 +930,7 @@ function ProfileSection() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-gray-800 pt-4">
|
{showJiraTempoSettings && <div className="border-t border-gray-800 pt-4">
|
||||||
<p className="text-sm font-semibold text-gray-300 mb-1">Jira Integration (personal settings)</p>
|
<p className="text-sm font-semibold text-gray-300 mb-1">Jira Integration (personal settings)</p>
|
||||||
<p className="text-xs text-gray-500 mb-4">
|
<p className="text-xs text-gray-500 mb-4">
|
||||||
Configure your personal Atlassian credentials for Jira integration.
|
Configure your personal Atlassian credentials for Jira integration.
|
||||||
@@ -1020,10 +1023,10 @@ function ProfileSection() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>}
|
||||||
|
|
||||||
{/* ── Tempo Integration ─────────────────────────────────── */}
|
{/* ── Tempo Integration ─────────────────────────────────── */}
|
||||||
<div className="border-t border-gray-800 pt-4">
|
{showJiraTempoSettings && <div className="border-t border-gray-800 pt-4">
|
||||||
<p className="text-sm font-semibold text-gray-300 mb-1">Tempo Integration (personal settings)</p>
|
<p className="text-sm font-semibold text-gray-300 mb-1">Tempo Integration (personal settings)</p>
|
||||||
<p className="text-xs text-gray-500 mb-4">
|
<p className="text-xs text-gray-500 mb-4">
|
||||||
Your personal Tempo API token logs work time on Jira tickets automatically.
|
Your personal Tempo API token logs work time on Jira tickets automatically.
|
||||||
@@ -1119,7 +1122,7 @@ function ProfileSection() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user