fix(jira,evidence,tempo,settings): 4-issue fix batch
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:
kitos
2026-05-28 11:06:31 +02:00
parent d8a0b0c449
commit c886b6e8bb
7 changed files with 86 additions and 9 deletions

View File

@@ -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"

View File

@@ -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)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -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:

View File

@@ -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:

View File

@@ -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,

View File

@@ -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}

View File

@@ -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>
</> </>
); );