|
8 | 8 | import typer |
9 | 9 | from typing import Optional |
10 | 10 | 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 |
13 | 14 | from conviso.clients.client_graphql import graphql_request |
14 | 15 | from conviso.core.output_manager import export_data |
15 | 16 | from conviso.schemas.vulnerabilities_schema import schema |
@@ -37,6 +38,7 @@ def list_vulnerabilities( |
37 | 38 | business_impact: Optional[str] = typer.Option(None, "--business-impact", help="Comma-separated business impact levels (LOW,MEDIUM,HIGH,NOT_DEFINED)."), |
38 | 39 | exploitability: Optional[str] = typer.Option(None, "--attack-surface", "-A", help="Attack surface (INTERNET_FACING,INTERNAL,NOT_DEFINED)."), |
39 | 40 | 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)."), |
40 | 42 | page: int = typer.Option(1, "--page", "-p", help="Page number."), |
41 | 43 | per_page: int = typer.Option(50, "--per-page", "-l", help="Items per page."), |
42 | 44 | all_pages: bool = typer.Option(False, "--all", help="Fetch all pages."), |
@@ -212,6 +214,7 @@ def _split_strs(value: Optional[str]): |
212 | 214 | "pagination": {"page": page, "perPage": per_page}, |
213 | 215 | "filters": filters or None, |
214 | 216 | } |
| 217 | + author_filter = (author or "").strip().lower() or None |
215 | 218 |
|
216 | 219 | try: |
217 | 220 | fetch_all = all_pages # Respect user pagination choices for all formats |
@@ -252,6 +255,9 @@ def _split_strs(value: Optional[str]): |
252 | 255 | for vuln in collection: |
253 | 256 | asset = vuln.get("asset") or {} |
254 | 257 | 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 |
255 | 261 | severity_value = vuln.get("severity") or "" |
256 | 262 | severity_raw = severity_value |
257 | 263 | sev_color_map = { |
@@ -279,7 +285,7 @@ def _split_strs(value: Optional[str]): |
279 | 285 | "severity_raw": severity_raw, |
280 | 286 | "asset": asset.get("name") or "", |
281 | 287 | "tags": tags, |
282 | | - "author": (vuln.get("author") or {}).get("name", ""), |
| 288 | + "author": author_name, |
283 | 289 | "assignee": assignee, |
284 | 290 | "company": ((asset.get("company") or {}).get("label")) or "", |
285 | 291 | "description": vuln.get("description"), |
@@ -397,6 +403,211 @@ def sev_to_level(sev: str): |
397 | 403 | } |
398 | 404 |
|
399 | 405 |
|
| 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 | + |
400 | 611 | # ---------------------- CREATE COMMAND ---------------------- # |
401 | 612 | @app.command("create") |
402 | 613 | def create_vulnerability( |
|
0 commit comments