fix(jira): expose non-admin jira-config, drop security-test label; catalog paging

GET /system/jira-config was admin-only, so non-admin users hit a 403
and the frontend silently fell back to a hardcoded jira.atlassian.com
base URL when building /browse/{key} links. Opened it to any
authenticated user (still no secrets returned).

Also drops the redundant 'security-test' Jira label, and adds a
page-size selector (10/25/50/100) and a 'no existing test yet' filter
to the Test Catalog.
This commit is contained in:
kitos
2026-07-10 10:24:13 +02:00
parent 2afb886cd9
commit f32f636f05
5 changed files with 77 additions and 17 deletions
+5 -3
View File
@@ -17,7 +17,7 @@ from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.database import SessionLocal, get_db
from app.dependencies.auth import require_role
from app.dependencies.auth import require_role, get_current_user
from app.models.user import User
from app.services.mitre_sync_service import sync_mitre
from app.services.intel_service import scan_intel
@@ -285,11 +285,13 @@ _JIRA_KEYS = {
@router.get("/jira-config", response_model=JiraConfigOut)
def get_jira_config(
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
current_user: User = Depends(get_current_user),
):
"""Return current Jira configuration (merged DB + env).
**Requires** the ``admin`` role. Credential values are never returned.
Open to any authenticated user — the frontend needs ``url`` to build
correct ``/browse/{key}`` links for non-admin users too. Credential
values (tokens, admin email) are never returned, only booleans.
"""
from app.services.jira_service import (
get_jira_url, get_jira_project_key, is_jira_enabled,
+1 -1
View File
@@ -700,7 +700,7 @@ def auto_create_test_issue(
poc = test.procedure_text or "N/A"
severity = _technique_severity(technique).capitalize()
labels = ["aegis", "security-test", mitre_id.replace(".", "-")]
labels = ["aegis", mitre_id.replace(".", "-")]
if test.platform:
# Jira labels can't contain whitespace — normalize to kebab-case.
labels.append(re.sub(r"[^a-z0-9_-]+", "-", test.platform.lower()).strip("-"))
@@ -0,0 +1,21 @@
"""GET /system/jira-config must be readable by any authenticated user.
Regression: it used to be admin-only, so non-admin users (e.g. red_lead)
got a 403 and the frontend silently fell back to a hardcoded
"https://jira.atlassian.com" base URL when building ticket links.
"""
def test_non_admin_can_read_jira_config(client, red_lead_headers):
resp = client.get("/api/v1/system/jira-config", headers=red_lead_headers)
assert resp.status_code == 200, resp.text
assert "url" in resp.json()
def test_non_admin_cannot_update_jira_config(client, red_lead_headers):
resp = client.patch(
"/api/v1/system/jira-config",
json={"url": "https://evil.example.com"},
headers=red_lead_headers,
)
assert resp.status_code == 403
+2 -2
View File
@@ -414,11 +414,11 @@ def test_auto_create_test_issue_adds_platform_label(
jira_service.auto_create_test_issue(db, test, actor, technique=technique)
labels = mock_jira.issue_create.call_args.kwargs["fields"]["labels"]
assert ["aegis", "security-test", "T1003-006"] == labels[:3]
assert ["aegis", "T1003-006"] == labels[:2]
if expected_label:
assert expected_label in labels
else:
assert len(labels) == 3
assert len(labels) == 2
def _make_user(**overrides):
+45 -8
View File
@@ -19,7 +19,8 @@ import { useAuth } from "../context/AuthContext";
// ── Constants ──────────────────────────────────────────────────────
const PAGE_SIZE = 12;
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
const DEFAULT_PAGE_SIZE = 25;
const SOURCE_OPTIONS = [
{ value: "", label: "All Sources" },
@@ -89,7 +90,9 @@ export default function TestCatalogPage() {
const [source, setSource] = useState(searchParams.get("source") || "");
const [platform, setPlatform] = useState(searchParams.get("platform") || "");
const [severity, setSeverity] = useState(searchParams.get("severity") || "");
const [hideCovered, setHideCovered] = useState(searchParams.get("hide_covered") === "1");
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
// Build filters
const filters = {
@@ -97,15 +100,21 @@ export default function TestCatalogPage() {
source: source || undefined,
platform: platform || undefined,
severity: severity || undefined,
offset: page * PAGE_SIZE,
limit: PAGE_SIZE,
offset: page * pageSize,
limit: pageSize,
};
const { data: templates = [], isLoading } = useQuery({
const { data: allTemplates = [], isLoading } = useQuery({
queryKey: ["test-templates", filters],
queryFn: () => getTemplates(filters),
});
// "No tests yet" is a client-side filter — existing_test_count is already
// returned per-template, no need for a separate backend round-trip.
const templates = hideCovered
? allTemplates.filter((t) => t.existing_test_count === 0)
: allTemplates;
// ── Filter handlers ──────────────────────────────────────────────
const applyFilters = () => {
@@ -115,6 +124,7 @@ export default function TestCatalogPage() {
if (source) params.set("source", source);
if (platform) params.set("platform", platform);
if (severity) params.set("severity", severity);
if (hideCovered) params.set("hide_covered", "1");
setSearchParams(params);
};
@@ -123,11 +133,12 @@ export default function TestCatalogPage() {
setSource("");
setPlatform("");
setSeverity("");
setHideCovered(false);
setPage(0);
setSearchParams({});
};
const hasActiveFilters = search || source || platform || severity;
const hasActiveFilters = search || source || platform || severity || hideCovered;
// ── Render ───────────────────────────────────────────────────────
@@ -220,6 +231,17 @@ export default function TestCatalogPage() {
</button>
)}
</div>
{/* No-coverage-yet toggle */}
<label className="mt-3 flex w-fit items-center gap-2 text-sm text-gray-400">
<input
type="checkbox"
checked={hideCovered}
onChange={(e) => { setHideCovered(e.target.checked); setPage(0); }}
className="h-4 w-4 rounded border-gray-600 bg-gray-800 text-cyan-500 focus:ring-cyan-500"
/>
Only show techniques with no existing test
</label>
</div>
{/* Results */}
@@ -253,11 +275,26 @@ export default function TestCatalogPage() {
{/* Pagination */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<p className="text-sm text-gray-500">
Showing {page * PAGE_SIZE + 1}
Showing {page * pageSize + 1}
{" - "}
{page * PAGE_SIZE + templates.length}
{page * pageSize + allTemplates.length}
{hideCovered && templates.length !== allTemplates.length && ` (${templates.length} without existing tests)`}
</p>
<label className="flex items-center gap-1.5 text-sm text-gray-500">
Show
<select
value={pageSize}
onChange={(e) => { setPageSize(Number(e.target.value)); setPage(0); }}
className="rounded-lg border border-gray-700 bg-gray-800 px-2 py-1 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none"
>
{PAGE_SIZE_OPTIONS.map((n) => (
<option key={n} value={n}>{n}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
@@ -270,7 +307,7 @@ export default function TestCatalogPage() {
<span className="text-sm text-gray-400">Page {page + 1}</span>
<button
onClick={() => setPage((p) => p + 1)}
disabled={templates.length < PAGE_SIZE}
disabled={allTemplates.length < pageSize}
className="flex items-center gap-1 rounded-lg border border-gray-700 px-3 py-1.5 text-sm text-gray-400 hover:bg-gray-800 disabled:opacity-40 transition-colors"
>
Next