Skip to content

Commit c295bec

Browse files
authored
Add options to filter timeline by project associated vulns (#15)
Add options to filter timeline by project associated vulns
2 parents 21be529 + e2e56fc commit c295bec

4 files changed

Lines changed: 236 additions & 48 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,9 @@ conviso --help
9999
- Vulnerabilities (last 7 days): `python -m conviso.app vulns list --company-id 443 --days-back 7 --severities HIGH,CRITICAL --all`
100100
- Vulnerabilities by author: `python -m conviso.app vulns list --company-id 443 --author "Fernando" --all`
101101
- Vulnerability timeline (by vulnerability ID): `python -m conviso.app vulns timeline --id 12345`
102+
- Vulnerabilities timeline by project: `python -m conviso.app vulns timeline --company-id 443 --project-id 26102`
102103
- Last user who changed vuln status: `python -m conviso.app vulns timeline --id 12345 --last-status-change-only`
104+
- Last user who changed status per vuln in a project: `python -m conviso.app vulns timeline --company-id 443 --project-id 26102 --last-status-change-only`
103105
- Last user who changed vuln status to ANALYSIS: `python -m conviso.app vulns timeline --id 12345 --status ANALYSIS --last-status-change-only`
104106

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

src/conviso/VERSION

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

src/conviso/commands/vulnerabilities.py

Lines changed: 190 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from conviso.clients.client_graphql import graphql_request
1515
from conviso.core.output_manager import export_data
1616
from conviso.schemas.vulnerabilities_schema import schema
17+
from conviso.schemas.vulnerabilities_timeline_schema import timeline_schema, timeline_last_schema
1718

1819
app = typer.Typer(help="List and manage vulnerabilities (WEB, NETWORK, SOURCE).")
1920

@@ -478,7 +479,9 @@ def _extract_status_change_fields(history_item: dict) -> tuple[str, str, str]:
478479

479480
@app.command("timeline", help="Show vulnerability timeline/history and filter by actor/status.")
480481
def vulnerability_timeline(
481-
issue_id: int = typer.Option(..., "--id", "-i", help="Vulnerability/issue ID."),
482+
issue_id: Optional[int] = typer.Option(None, "--id", "-i", help="Vulnerability/issue ID."),
483+
company_id: Optional[int] = typer.Option(None, "--company-id", "-c", help="Company ID (required with --project-id)."),
484+
project_id: Optional[int] = typer.Option(None, "--project-id", "-P", help="Project ID to aggregate timelines from related vulnerabilities."),
482485
user_email: Optional[str] = typer.Option(None, "--user-email", help="Filter by actor email or name (contains, case-insensitive)."),
483486
status: Optional[str] = typer.Option(None, "--status", help="Filter status-change events by target status (IssueStatusLabel)."),
484487
history_start: Optional[str] = typer.Option(None, "--history-start", help="History created_at >= this value (YYYY-MM-DD or ISO-8601)."),
@@ -487,14 +490,30 @@ def vulnerability_timeline(
487490
fmt: str = typer.Option("table", "--format", "-f", help="Output format: table, json, csv."),
488491
output: Optional[str] = typer.Option(None, "--output", "-o", help="Output file for json/csv."),
489492
):
490-
info(f"Listing timeline for vulnerability {issue_id}...")
493+
if issue_id is not None and project_id is not None:
494+
error("Use either --id or --project-id, not both.")
495+
raise typer.Exit(code=1)
496+
if issue_id is None and project_id is None:
497+
error("Provide --id or --project-id (with --company-id).")
498+
raise typer.Exit(code=1)
499+
if project_id is not None and company_id is None:
500+
error("--company-id is required when using --project-id.")
501+
raise typer.Exit(code=1)
502+
if company_id is not None and project_id is None and issue_id is None:
503+
error("--company-id alone is not enough; use with --project-id or provide --id.")
504+
raise typer.Exit(code=1)
505+
506+
if issue_id is not None:
507+
info(f"Listing timeline for vulnerability {issue_id}...")
508+
else:
509+
info(f"Listing timeline for vulnerabilities in project {project_id} (company {company_id})...")
491510

492511
status_filter = status.strip().upper() if status else None
493512
email_filter = (user_email or "").strip().lower() or None
494513
history_start_dt = _parse_dt_filter(history_start, end_of_day=False)
495514
history_end_dt = _parse_dt_filter(history_end, end_of_day=True)
496515

497-
query = """
516+
issue_timeline_query = """
498517
query IssueTimeline($id: ID!) {
499518
issue(id: $id) {
500519
id
@@ -515,54 +534,114 @@ def vulnerability_timeline(
515534
}
516535
"""
517536

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 []
537+
project_issues_query = """
538+
query IssuesByProject($companyId: ID!, $pagination: PaginationInput!, $filters: IssuesFiltersInput) {
539+
issues(companyId: $companyId, pagination: $pagination, filters: $filters) {
540+
collection {
541+
id
542+
title
543+
status
544+
}
545+
metadata {
546+
currentPage
547+
totalPages
548+
totalCount
549+
}
550+
}
551+
}
552+
"""
553+
554+
def _fetch_project_issues(cid: int, pid: int) -> list[dict]:
555+
current_page = 1
556+
per_page = 100
557+
out = []
558+
while True:
559+
data = graphql_request(
560+
project_issues_query,
561+
{
562+
"companyId": str(cid),
563+
"pagination": {"page": current_page, "perPage": per_page},
564+
"filters": {"projectIds": [pid]},
565+
},
566+
log_request=True,
567+
verbose_only=True,
568+
)
569+
issues = (data.get("issues") or {})
570+
collection = issues.get("collection") or []
571+
metadata = issues.get("metadata") or {}
572+
total_pages = metadata.get("totalPages")
573+
out.extend(collection)
574+
if not collection:
575+
break
576+
if total_pages is not None and current_page >= total_pages:
577+
break
578+
if len(collection) < per_page:
579+
break
580+
current_page += 1
581+
return out
525582

583+
try:
526584
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):
585+
target_issues = []
586+
if issue_id is not None:
587+
target_issues = [{"id": str(issue_id)}]
588+
else:
589+
target_issues = _fetch_project_issues(company_id, project_id)
590+
if not target_issues:
591+
warning("No vulnerabilities found for the given project.")
592+
raise typer.Exit()
593+
594+
for target in target_issues:
595+
current_issue_id = target.get("id")
596+
if not current_issue_id:
544597
continue
545-
if history_end_dt and (created_at_dt is None or created_at_dt > history_end_dt):
598+
599+
data = graphql_request(issue_timeline_query, {"id": str(current_issue_id)}, log_request=True, verbose_only=True)
600+
issue = data.get("issue")
601+
if not issue:
546602
continue
547-
if status_filter:
548-
if not is_status_change:
603+
history_rows = issue.get("history") or []
604+
605+
for h in history_rows:
606+
action_type = (h.get("action") or "").upper()
607+
actor_email = (h.get("authorEmail") or "").strip()
608+
actor_name = actor_email.split("@", 1)[0] if actor_email else ""
609+
created_at = h.get("at") or ""
610+
created_at_dt = _safe_parse_iso(created_at)
611+
from_status = (h.get("previousStatus") or "").upper()
612+
to_status = (h.get("status") or "").upper()
613+
event_status = to_status
614+
kind = (h.get("kind") or "").lower()
615+
is_status_change = bool(kind == "status" or from_status or to_status)
616+
617+
if email_filter:
618+
haystack = f"{actor_email.lower()} {actor_name.lower()}".strip()
619+
if email_filter not in haystack:
620+
continue
621+
if history_start_dt and (created_at_dt is None or created_at_dt < history_start_dt):
549622
continue
550-
if (to_status or event_status) != status_filter:
623+
if history_end_dt and (created_at_dt is None or created_at_dt > history_end_dt):
551624
continue
625+
if status_filter:
626+
if not is_status_change:
627+
continue
628+
if (to_status or event_status) != status_filter:
629+
continue
552630

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-
})
631+
rows.append({
632+
"projectId": str(project_id) if project_id is not None else "",
633+
"issueId": issue.get("id") or current_issue_id,
634+
"issueTitle": issue.get("title") or target.get("title") or "",
635+
"currentIssueStatus": issue.get("status") or target.get("status") or "",
636+
"eventId": h.get("eventId") or "",
637+
"createdAt": created_at,
638+
"actorName": actor_name,
639+
"actorEmail": actor_email,
640+
"actionType": action_type,
641+
"fromStatus": from_status,
642+
"toStatus": to_status or event_status,
643+
"statusChange": "true" if is_status_change else "false",
644+
})
566645

567646
if not rows:
568647
warning("No timeline events found for the given filters.")
@@ -581,6 +660,7 @@ def vulnerability_timeline(
581660
)
582661
latest = status_rows[-1]
583662
latest = {
663+
"projectId": latest.get("projectId"),
584664
"issueId": latest.get("issueId"),
585665
"issueTitle": latest.get("issueTitle"),
586666
"currentIssueStatus": latest.get("currentIssueStatus"),
@@ -591,17 +671,80 @@ def vulnerability_timeline(
591671
"toStatus": latest.get("toStatus"),
592672
"actionType": latest.get("actionType"),
593673
}
594-
export_data([latest], fmt=fmt, output=output, title=f"Vulnerability {issue_id} - Last Status Change")
674+
if project_id is not None:
675+
grouped_latest = {}
676+
for r in status_rows:
677+
key = str(r.get("issueId") or "")
678+
curr = grouped_latest.get(key)
679+
if curr is None:
680+
grouped_latest[key] = r
681+
continue
682+
curr_dt = _safe_parse_iso(curr.get("createdAt") or "") or datetime.min.replace(tzinfo=timezone.utc)
683+
r_dt = _safe_parse_iso(r.get("createdAt") or "") or datetime.min.replace(tzinfo=timezone.utc)
684+
if r_dt > curr_dt or (r_dt == curr_dt and str(r.get("eventId") or "") > str(curr.get("eventId") or "")):
685+
grouped_latest[key] = r
686+
latest_rows = []
687+
for r in grouped_latest.values():
688+
latest_rows.append({
689+
"projectId": r.get("projectId"),
690+
"issueId": r.get("issueId"),
691+
"issueTitle": r.get("issueTitle"),
692+
"currentIssueStatus": r.get("currentIssueStatus"),
693+
"lastChangedAt": r.get("createdAt"),
694+
"lastChangedBy": r.get("actorName"),
695+
"lastChangedByEmail": r.get("actorEmail"),
696+
"fromStatus": r.get("fromStatus"),
697+
"toStatus": r.get("toStatus"),
698+
"actionType": r.get("actionType"),
699+
})
700+
latest_rows.sort(
701+
key=lambda r: (
702+
_safe_parse_iso(r.get("lastChangedAt") or "") or datetime.min.replace(tzinfo=timezone.utc),
703+
str(r.get("issueId") or ""),
704+
)
705+
)
706+
export_data(
707+
latest_rows,
708+
schema=timeline_last_schema,
709+
fmt=fmt,
710+
output=output,
711+
title=f"Project {project_id} - Last Status Change Per Vulnerability",
712+
)
713+
summary(f"{len(latest_rows)} vulnerability(ies) with last status-change listed.")
714+
return
715+
716+
export_data(
717+
[latest],
718+
schema=timeline_last_schema,
719+
fmt=fmt,
720+
output=output,
721+
title=f"Vulnerability {issue_id} - Last Status Change",
722+
)
595723
summary("1 last status-change event listed.")
596724
return
597725

598-
export_data(rows, fmt=fmt, output=output, title=f"Vulnerability {issue_id} - Timeline")
726+
if project_id is not None:
727+
export_data(
728+
rows,
729+
schema=timeline_schema,
730+
fmt=fmt,
731+
output=output,
732+
title=f"Project {project_id} - Vulnerabilities Timeline",
733+
)
734+
else:
735+
export_data(
736+
rows,
737+
schema=timeline_schema,
738+
fmt=fmt,
739+
output=output,
740+
title=f"Vulnerability {issue_id} - Timeline",
741+
)
599742
summary(f"{len(rows)} timeline event(s) listed.")
600743

601744
except typer.Exit:
602745
raise
603746
except Exception as exc:
604-
if "RECORD_NOT_FOUND" in str(exc):
747+
if "RECORD_NOT_FOUND" in str(exc) and issue_id is not None:
605748
error(f"Issue {issue_id} not found. Use the vulnerability ID (not project ID).")
606749
raise typer.Exit(code=1)
607750
error(f"Error listing vulnerability timeline: {exc}")
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""
2+
Vulnerabilities Timeline Schemas
3+
--------------------------------
4+
Friendly table/csv headers for vulnerability timeline outputs.
5+
"""
6+
7+
8+
class VulnerabilitiesTimelineSchema:
9+
def __init__(self):
10+
self.display_headers = {
11+
"projectId": "Project ID",
12+
"issueId": "Vulnerability ID",
13+
"issueTitle": "Vulnerability",
14+
"currentIssueStatus": "Current Status",
15+
"eventId": "Event ID",
16+
"createdAt": "Event At",
17+
"actorName": "Changed By",
18+
"actorEmail": "Changed By Email",
19+
"actionType": "Action",
20+
"fromStatus": "From Status",
21+
"toStatus": "To Status",
22+
"statusChange": "Status Change",
23+
}
24+
25+
26+
class VulnerabilitiesTimelineLastSchema:
27+
def __init__(self):
28+
self.display_headers = {
29+
"projectId": "Project ID",
30+
"issueId": "Vulnerability ID",
31+
"issueTitle": "Vulnerability",
32+
"currentIssueStatus": "Current Status",
33+
"lastChangedAt": "Last Changed At",
34+
"lastChangedBy": "Last Changed By",
35+
"lastChangedByEmail": "Last Changed By Email",
36+
"fromStatus": "From Status",
37+
"toStatus": "To Status",
38+
"actionType": "Action",
39+
}
40+
41+
42+
timeline_schema = VulnerabilitiesTimelineSchema()
43+
timeline_last_schema = VulnerabilitiesTimelineLastSchema()

0 commit comments

Comments
 (0)