Skip to content

Commit 21be529

Browse files
authored
include filters in vuln timeline and author (#14)
include filters in vuln timeline and author
2 parents 775ebd9 + 6c295a6 commit 21be529

3 files changed

Lines changed: 219 additions & 4 deletions

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,10 @@ conviso --help
9797
- Tasks (create with inline YAML): `python -m conviso.app tasks create --company-id 443 --label "Quick Task" --yaml "name: quick\nsteps:\n - action: echo\n message: ok"`
9898
- Vulnerabilities: `python -m conviso.app vulns list --company-id 443 --severities HIGH,CRITICAL --asset-tags cloud --all`
9999
- Vulnerabilities (last 7 days): `python -m conviso.app vulns list --company-id 443 --days-back 7 --severities HIGH,CRITICAL --all`
100+
- Vulnerabilities by author: `python -m conviso.app vulns list --company-id 443 --author "Fernando" --all`
101+
- Vulnerability timeline (by vulnerability ID): `python -m conviso.app vulns timeline --id 12345`
102+
- Last user who changed vuln status: `python -m conviso.app vulns timeline --id 12345 --last-status-change-only`
103+
- Last user who changed vuln status to ANALYSIS: `python -m conviso.app vulns timeline --id 12345 --status ANALYSIS --last-status-change-only`
100104

101105
Output options: `--format table|json|csv`, `--output path` to save JSON/CSV.
102106

src/conviso/VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.3.1
1+
0.3.2

src/conviso/commands/vulnerabilities.py

Lines changed: 214 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@
88
import typer
99
from typing import Optional
1010
import json
11-
from datetime import date, timedelta
12-
from conviso.core.notifier import info, error, summary, success
11+
import re
12+
from datetime import date, datetime, timedelta, timezone
13+
from conviso.core.notifier import info, error, summary, success, warning
1314
from conviso.clients.client_graphql import graphql_request
1415
from conviso.core.output_manager import export_data
1516
from conviso.schemas.vulnerabilities_schema import schema
@@ -37,6 +38,7 @@ def list_vulnerabilities(
3738
business_impact: Optional[str] = typer.Option(None, "--business-impact", help="Comma-separated business impact levels (LOW,MEDIUM,HIGH,NOT_DEFINED)."),
3839
exploitability: Optional[str] = typer.Option(None, "--attack-surface", "-A", help="Attack surface (INTERNET_FACING,INTERNAL,NOT_DEFINED)."),
3940
assignee_emails: Optional[str] = typer.Option(None, "--assignees", help="Comma-separated assignee emails."),
41+
author: Optional[str] = typer.Option(None, "--author", help="Filter by author name (contains, case-insensitive)."),
4042
page: int = typer.Option(1, "--page", "-p", help="Page number."),
4143
per_page: int = typer.Option(50, "--per-page", "-l", help="Items per page."),
4244
all_pages: bool = typer.Option(False, "--all", help="Fetch all pages."),
@@ -212,6 +214,7 @@ def _split_strs(value: Optional[str]):
212214
"pagination": {"page": page, "perPage": per_page},
213215
"filters": filters or None,
214216
}
217+
author_filter = (author or "").strip().lower() or None
215218

216219
try:
217220
fetch_all = all_pages # Respect user pagination choices for all formats
@@ -252,6 +255,9 @@ def _split_strs(value: Optional[str]):
252255
for vuln in collection:
253256
asset = vuln.get("asset") or {}
254257
tags = ", ".join(asset.get("assetsTagList") or [])
258+
author_name = (vuln.get("author") or {}).get("name", "")
259+
if author_filter and author_filter not in author_name.lower():
260+
continue
255261
severity_value = vuln.get("severity") or ""
256262
severity_raw = severity_value
257263
sev_color_map = {
@@ -279,7 +285,7 @@ def _split_strs(value: Optional[str]):
279285
"severity_raw": severity_raw,
280286
"asset": asset.get("name") or "",
281287
"tags": tags,
282-
"author": (vuln.get("author") or {}).get("name", ""),
288+
"author": author_name,
283289
"assignee": assignee,
284290
"company": ((asset.get("company") or {}).get("label")) or "",
285291
"description": vuln.get("description"),
@@ -397,6 +403,211 @@ def sev_to_level(sev: str):
397403
}
398404

399405

406+
def _parse_dt_filter(value: Optional[str], end_of_day: bool = False) -> Optional[datetime]:
407+
if not value:
408+
return None
409+
raw = value.strip()
410+
try:
411+
if len(raw) == 10 and raw[4] == "-" and raw[7] == "-":
412+
date_obj = datetime.strptime(raw, "%Y-%m-%d")
413+
if end_of_day:
414+
return date_obj.replace(hour=23, minute=59, second=59, microsecond=999999, tzinfo=timezone.utc)
415+
return date_obj.replace(tzinfo=timezone.utc)
416+
raw = raw.replace("Z", "+00:00")
417+
parsed = datetime.fromisoformat(raw)
418+
if parsed.tzinfo is None:
419+
parsed = parsed.replace(tzinfo=timezone.utc)
420+
return parsed.astimezone(timezone.utc)
421+
except Exception:
422+
warning(f"Ignoring invalid date/datetime filter: {value}")
423+
return None
424+
425+
426+
def _safe_parse_iso(value: Optional[str]) -> Optional[datetime]:
427+
if not value:
428+
return None
429+
try:
430+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
431+
if parsed.tzinfo is None:
432+
parsed = parsed.replace(tzinfo=timezone.utc)
433+
return parsed.astimezone(timezone.utc)
434+
except Exception:
435+
return None
436+
437+
438+
def _extract_status_change_fields(history_item: dict) -> tuple[str, str, str]:
439+
from_status = ""
440+
to_status = ""
441+
event_status = ""
442+
443+
candidates = [
444+
("fromStatus", "toStatus"),
445+
("oldStatus", "newStatus"),
446+
("previousStatus", "status"),
447+
]
448+
for left_key, right_key in candidates:
449+
left = history_item.get(left_key)
450+
right = history_item.get(right_key)
451+
if left and not from_status:
452+
from_status = str(left).upper()
453+
if right and not to_status:
454+
to_status = str(right).upper()
455+
456+
status_value = history_item.get("status")
457+
if status_value:
458+
event_status = str(status_value).upper()
459+
if not to_status:
460+
to_status = event_status
461+
462+
if not to_status:
463+
action_type = (history_item.get("actionType") or "").upper()
464+
if "STATUS" in action_type:
465+
tokens = [t for t in re.split(r"[^A-Z0-9_]+", action_type) if t]
466+
for idx, token in enumerate(tokens):
467+
if token == "STATUS" and idx + 1 < len(tokens):
468+
to_status = tokens[idx + 1]
469+
break
470+
if not to_status:
471+
for token in reversed(tokens):
472+
if token != "STATUS":
473+
to_status = token
474+
break
475+
476+
return from_status, to_status, event_status
477+
478+
479+
@app.command("timeline", help="Show vulnerability timeline/history and filter by actor/status.")
480+
def vulnerability_timeline(
481+
issue_id: int = typer.Option(..., "--id", "-i", help="Vulnerability/issue ID."),
482+
user_email: Optional[str] = typer.Option(None, "--user-email", help="Filter by actor email or name (contains, case-insensitive)."),
483+
status: Optional[str] = typer.Option(None, "--status", help="Filter status-change events by target status (IssueStatusLabel)."),
484+
history_start: Optional[str] = typer.Option(None, "--history-start", help="History created_at >= this value (YYYY-MM-DD or ISO-8601)."),
485+
history_end: Optional[str] = typer.Option(None, "--history-end", help="History created_at <= this value (YYYY-MM-DD or ISO-8601)."),
486+
last_status_change_only: bool = typer.Option(False, "--last-status-change-only", help="Show only the latest status-change event after filters."),
487+
fmt: str = typer.Option("table", "--format", "-f", help="Output format: table, json, csv."),
488+
output: Optional[str] = typer.Option(None, "--output", "-o", help="Output file for json/csv."),
489+
):
490+
info(f"Listing timeline for vulnerability {issue_id}...")
491+
492+
status_filter = status.strip().upper() if status else None
493+
email_filter = (user_email or "").strip().lower() or None
494+
history_start_dt = _parse_dt_filter(history_start, end_of_day=False)
495+
history_end_dt = _parse_dt_filter(history_end, end_of_day=True)
496+
497+
query = """
498+
query IssueTimeline($id: ID!) {
499+
issue(id: $id) {
500+
id
501+
title
502+
status
503+
history {
504+
eventId
505+
at
506+
action
507+
authorEmail
508+
assigneeEmail
509+
previousStatus
510+
status
511+
kind
512+
reason
513+
}
514+
}
515+
}
516+
"""
517+
518+
try:
519+
data = graphql_request(query, {"id": str(issue_id)})
520+
issue = data.get("issue")
521+
if not issue:
522+
warning(f"Issue {issue_id} not found.")
523+
raise typer.Exit(code=1)
524+
history_rows = issue.get("history") or []
525+
526+
rows = []
527+
for h in history_rows:
528+
action_type = (h.get("action") or "").upper()
529+
actor_email = (h.get("authorEmail") or "").strip()
530+
actor_name = actor_email.split("@", 1)[0] if actor_email else ""
531+
created_at = h.get("at") or ""
532+
created_at_dt = _safe_parse_iso(created_at)
533+
from_status = (h.get("previousStatus") or "").upper()
534+
to_status = (h.get("status") or "").upper()
535+
event_status = to_status
536+
kind = (h.get("kind") or "").lower()
537+
is_status_change = bool(kind == "status" or from_status or to_status)
538+
539+
if email_filter:
540+
haystack = f"{actor_email.lower()} {actor_name.lower()}".strip()
541+
if email_filter not in haystack:
542+
continue
543+
if history_start_dt and (created_at_dt is None or created_at_dt < history_start_dt):
544+
continue
545+
if history_end_dt and (created_at_dt is None or created_at_dt > history_end_dt):
546+
continue
547+
if status_filter:
548+
if not is_status_change:
549+
continue
550+
if (to_status or event_status) != status_filter:
551+
continue
552+
553+
rows.append({
554+
"issueId": issue.get("id") or issue_id,
555+
"issueTitle": issue.get("title") or "",
556+
"currentIssueStatus": issue.get("status") or "",
557+
"eventId": h.get("eventId") or "",
558+
"createdAt": created_at,
559+
"actorName": actor_name,
560+
"actorEmail": actor_email,
561+
"actionType": action_type,
562+
"fromStatus": from_status,
563+
"toStatus": to_status or event_status,
564+
"statusChange": "true" if is_status_change else "false",
565+
})
566+
567+
if not rows:
568+
warning("No timeline events found for the given filters.")
569+
raise typer.Exit()
570+
571+
if last_status_change_only:
572+
status_rows = [r for r in rows if r.get("statusChange") == "true"]
573+
if not status_rows:
574+
warning("No status-change events found for the given filters.")
575+
raise typer.Exit()
576+
status_rows.sort(
577+
key=lambda r: (
578+
_safe_parse_iso(r.get("createdAt") or "") or datetime.min.replace(tzinfo=timezone.utc),
579+
str(r.get("eventId") or ""),
580+
)
581+
)
582+
latest = status_rows[-1]
583+
latest = {
584+
"issueId": latest.get("issueId"),
585+
"issueTitle": latest.get("issueTitle"),
586+
"currentIssueStatus": latest.get("currentIssueStatus"),
587+
"lastChangedAt": latest.get("createdAt"),
588+
"lastChangedBy": latest.get("actorName"),
589+
"lastChangedByEmail": latest.get("actorEmail"),
590+
"fromStatus": latest.get("fromStatus"),
591+
"toStatus": latest.get("toStatus"),
592+
"actionType": latest.get("actionType"),
593+
}
594+
export_data([latest], fmt=fmt, output=output, title=f"Vulnerability {issue_id} - Last Status Change")
595+
summary("1 last status-change event listed.")
596+
return
597+
598+
export_data(rows, fmt=fmt, output=output, title=f"Vulnerability {issue_id} - Timeline")
599+
summary(f"{len(rows)} timeline event(s) listed.")
600+
601+
except typer.Exit:
602+
raise
603+
except Exception as exc:
604+
if "RECORD_NOT_FOUND" in str(exc):
605+
error(f"Issue {issue_id} not found. Use the vulnerability ID (not project ID).")
606+
raise typer.Exit(code=1)
607+
error(f"Error listing vulnerability timeline: {exc}")
608+
raise typer.Exit(code=1)
609+
610+
400611
# ---------------------- CREATE COMMAND ---------------------- #
401612
@app.command("create")
402613
def create_vulnerability(

0 commit comments

Comments
 (0)