From d0cffd3b05be34438227c72ed1e6fb8d2dbb4d35 Mon Sep 17 00:00:00 2001 From: Wagner Elias Date: Fri, 6 Feb 2026 15:41:27 -0300 Subject: [PATCH 1/2] Improve task and requirements command --- README.md | 9 +- src/conviso/commands/bulk.py | 56 ++++---- src/conviso/commands/requirements.py | 130 +++++++++++-------- src/conviso/commands/tasks.py | 183 +++++++++++++++++++++++++++ 4 files changed, 299 insertions(+), 79 deletions(-) diff --git a/README.md b/README.md index c55ce09..58852f5 100644 --- a/README.md +++ b/README.md @@ -83,13 +83,16 @@ conviso --help ## Usage (examples) - Projects: `python -m conviso.app projects list --company-id 443 --all` - Assets: `python -m conviso.app assets list --company-id 443 --tags cloud --attack-surface INTERNET_FACING --all` -- Requirements: `python -m conviso.app requirements create --company-id 443 --label "Req" --description "Desc" --activity "Login|Check login"` +- Requirements: `python -m conviso.app requirements create --company-id 443 --label "Req" --description "Desc" --activity "Login|Check login|REF-123"` - Requirements (project): `python -m conviso.app requirements project --company-id 443 --project-id 26102` - Requirements (activities): `python -m conviso.app requirements activities --company-id 443 --requirement-id 1503` - Requirements (project activities): `python -m conviso.app requirements activities --company-id 443 --project-id 26102` +- Tasks (create from YAML): `python -m conviso.app tasks create --company-id 443 --project-id 26102 --label "Nuclei Scan" --yaml-file samples/task-nuclei.yaml` +- Tasks (append to requirement): `python -m conviso.app tasks create --company-id 443 --requirement-id 2174 --label "Nuclei Scan" --yaml-file samples/task-nuclei.yaml` - Tasks (execute YAML from requirements): `python -m conviso.app tasks run --company-id 443 --project-id 26102` - Tasks (list project): `python -m conviso.app tasks list --company-id 443 --project-id 26102` - Tasks (only valid YAML): `python -m conviso.app tasks list --company-id 443 --project-id 26102 --only-valid` +- 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"` - Vulnerabilities: `python -m conviso.app vulns list --company-id 443 --severities HIGH,CRITICAL --asset-tags cloud --all` Output options: `--format table|json|csv`, `--output path` to save JSON/CSV. @@ -186,13 +189,13 @@ Automatic normalizations: | label | create/update | Text | | description | create/update | Text | | global | optional | true/false | - | activities | optional | Semicolon-separated; each activity uses `label|description|typeId|reference|item|category|actionPlan|templateId|sort` | + | activities | optional | Semicolon-separated; each activity uses `label|description|[typeId]|reference|item|category|actionPlan|templateId|sort` | - Examples: - Create: ``` label,description,global,activities - Requirement A,Do X,true,"Login|Check login|1|REF||Category||123|1;Logout|Check logout|1" + Requirement A,Do X,true,"Login|Check login|REF||Category||123|1;Logout|Check logout|1" ``` - Update/Delete: ``` diff --git a/src/conviso/commands/bulk.py b/src/conviso/commands/bulk.py index bd6ce4d..6254a82 100644 --- a/src/conviso/commands/bulk.py +++ b/src/conviso/commands/bulk.py @@ -306,26 +306,36 @@ def _parse_activities(val: str): warning(f"Ignoring activity (expected at least label|description): {raw}") continue act = {"label": parts[0], "description": parts[1]} - if len(parts) > 2 and parts[2]: - try: - act["typeId"] = int(parts[2]) - except Exception: - warning(f"Ignoring invalid typeId in activity: {parts[2]}") - if len(parts) > 3 and parts[3]: - act["reference"] = parts[3] - if len(parts) > 4 and parts[4]: - act["item"] = parts[4] - if len(parts) > 5 and parts[5]: - act["category"] = parts[5] - if len(parts) > 6 and parts[6]: - act["actionPlan"] = parts[6] - if len(parts) > 7 and parts[7]: - try: - act["vulnerabilityTemplateId"] = int(parts[7]) - except Exception: - warning(f"Ignoring invalid vulnerabilityTemplateId in activity: {parts[7]}") - if len(parts) > 8 and parts[8]: - act["sort"] = parts[8] + + rest = parts[2:] + if rest: + first = rest[0] + if first: + try: + act["typeId"] = int(first) + rest = rest[1:] + except Exception: + act["reference"] = first + rest = rest[1:] + else: + rest = rest[1:] + + field_order = ["reference", "item", "category", "actionPlan", "vulnerabilityTemplateId", "sort"] + start_index = 1 if "reference" in act else 0 + for idx, value in enumerate(rest): + field_idx = idx + start_index + if field_idx >= len(field_order): + break + if not value: + continue + field = field_order[field_idx] + if field == "vulnerabilityTemplateId": + try: + act[field] = int(value) + except Exception: + warning(f"Ignoring invalid vulnerabilityTemplateId in activity: {value}") + else: + act[field] = value activities.append(act) return activities @@ -1322,14 +1332,14 @@ def _show_requirement_template(): table.add_row( "activities", "optional", - "Semicolon-separated activities; each activity uses pipe-separated fields: label|description|typeId|reference|item|category|actionPlan|templateId|sort", - "Login|Check login|1|REF||Category||123|1;Logout|Check logout|1", + "Semicolon-separated activities; each activity uses pipe-separated fields: label|description|[typeId]|reference|item|category|actionPlan|templateId|sort", + "Login|Check login|REF||Category||123|1;Logout|Check logout|1", ) console.print(table) console.print("\nExample create CSV:\n") console.print("label,description,global,activities") - console.print("Req A,Do X,true,\"Login|Check login|1|REF||Category||123|1\"\n") + console.print("Req A,Do X,true,\"Login|Check login|REF||Category||123|1\"\n") console.print("Example update CSV:\n") console.print("id,label,description") diff --git a/src/conviso/commands/requirements.py b/src/conviso/commands/requirements.py index 1dc7bc6..5145e1b 100644 --- a/src/conviso/commands/requirements.py +++ b/src/conviso/commands/requirements.py @@ -2,7 +2,7 @@ """ Requirements Command Module --------------------------- -Lists requirements (playbooks) so users can pick valid IDs for project associations. +Lists requirements so users can pick valid IDs for project associations. """ import typer @@ -12,7 +12,7 @@ from conviso.core.output_manager import export_data from conviso.schemas.requirements_schema import schema -app = typer.Typer(help="List requirements/playbooks available in a given scope.") +app = typer.Typer(help="List requirements available in a given scope.") @app.command("list") @@ -25,7 +25,7 @@ def list_requirements( fmt: str = typer.Option("table", "--format", "-f", help="Output format: table, json, csv."), output: Optional[str] = typer.Option(None, "--output", "-o", help="Output file for json/csv."), ): - """List requirements (playbooks) for a scope.""" + """List requirements for a scope.""" info(f"Listing requirements for company {company_id} (page {page}, per_page {per_page})...") query = """ @@ -99,7 +99,7 @@ def list_project_requirements( fmt: str = typer.Option("table", "--format", "-f", help="Output format: table, json, csv."), output: Optional[str] = typer.Option(None, "--output", "-o", help="Output file for json/csv."), ): - """List requirements (playbooks) associated with a project.""" + """List requirements associated with a project.""" info(f"Listing requirements for project {project_id} in company {company_id}...") query_with_activities = """ @@ -213,7 +213,7 @@ def list_requirement_activities( fmt: str = typer.Option("table", "--format", "-f", help="Output format: table, json, csv."), output: Optional[str] = typer.Option(None, "--output", "-o", help="Output file for json/csv."), ): - """List activities (checks) inside a requirement.""" + """List activities inside a requirement.""" if not requirement_id and not project_id: error("You must provide either --requirement-id or --project-id.") raise typer.Exit(code=1) @@ -333,10 +333,14 @@ def create_requirement( None, "--activity", "-a", - help="Activity in format 'label|description|typeId|reference|item|category|actionPlan|templateId|sort'. Omit trailing fields if not needed.", + help=( + "Activity format: 'label|description|[typeId]|reference|item|category|actionPlan|templateId|sort'. " + "Required: label, description. If you don't have typeId, use 'label|description|reference' or " + "'label|description||reference'. Omit trailing fields if not needed." + ), ), ): - """Create a requirement (playbook).""" + """Create a requirement.""" info(f"Creating requirement '{label}' for company {company_id}...") mutation = """ @@ -360,31 +364,37 @@ def _parse_activities(raw_list: Optional[list[str]]): if len(parts) < 2: warning(f"Ignoring activity (expected at least label|description): {raw}") continue - act = { - "label": parts[0], - "description": parts[1], - } - # Optional fields by position - if len(parts) > 2 and parts[2]: - try: - act["typeId"] = int(parts[2]) - except ValueError: - warning(f"Ignoring invalid typeId in activity: {parts[2]}") - if len(parts) > 3 and parts[3]: - act["reference"] = parts[3] - if len(parts) > 4 and parts[4]: - act["item"] = parts[4] - if len(parts) > 5 and parts[5]: - act["category"] = parts[5] - if len(parts) > 6 and parts[6]: - act["actionPlan"] = parts[6] - if len(parts) > 7 and parts[7]: - try: - act["vulnerabilityTemplateId"] = int(parts[7]) - except ValueError: - warning(f"Ignoring invalid vulnerabilityTemplateId in activity: {parts[7]}") - if len(parts) > 8 and parts[8]: - act["sort"] = parts[8] + act = {"label": parts[0], "description": parts[1]} + + rest = parts[2:] + if rest: + first = rest[0] + if first: + try: + act["typeId"] = int(first) + rest = rest[1:] + except ValueError: + act["reference"] = first + rest = rest[1:] + else: + rest = rest[1:] + + field_order = ["reference", "item", "category", "actionPlan", "vulnerabilityTemplateId", "sort"] + start_index = 1 if "reference" in act else 0 + for idx, value in enumerate(rest): + field_idx = idx + start_index + if field_idx >= len(field_order): + break + if not value: + continue + field = field_order[field_idx] + if field == "vulnerabilityTemplateId": + try: + act[field] = int(value) + except ValueError: + warning(f"Ignoring invalid vulnerabilityTemplateId in activity: {value}") + else: + act[field] = value parsed.append(act) return parsed @@ -421,7 +431,11 @@ def update_requirement( None, "--activity", "-a", - help="Activity in format 'label|description|typeId|reference|item|category|actionPlan|templateId|sort'. Omit trailing fields if not needed.", + help=( + "Activity format: 'label|description|[typeId]|reference|item|category|actionPlan|templateId|sort'. " + "Required: label, description. If you don't have typeId, use 'label|description|reference' or " + "'label|description||reference'. Omit trailing fields if not needed." + ), ), ): """Update an existing requirement.""" @@ -449,26 +463,36 @@ def _parse_activities(raw_list: Optional[list[str]]): warning(f"Ignoring activity (expected at least label|description): {raw}") continue act = {"label": parts[0], "description": parts[1]} - if len(parts) > 2 and parts[2]: - try: - act["typeId"] = int(parts[2]) - except ValueError: - warning(f"Ignoring invalid typeId in activity: {parts[2]}") - if len(parts) > 3 and parts[3]: - act["reference"] = parts[3] - if len(parts) > 4 and parts[4]: - act["item"] = parts[4] - if len(parts) > 5 and parts[5]: - act["category"] = parts[5] - if len(parts) > 6 and parts[6]: - act["actionPlan"] = parts[6] - if len(parts) > 7 and parts[7]: - try: - act["vulnerabilityTemplateId"] = int(parts[7]) - except ValueError: - warning(f"Ignoring invalid vulnerabilityTemplateId in activity: {parts[7]}") - if len(parts) > 8 and parts[8]: - act["sort"] = parts[8] + + rest = parts[2:] + if rest: + first = rest[0] + if first: + try: + act["typeId"] = int(first) + rest = rest[1:] + except ValueError: + act["reference"] = first + rest = rest[1:] + else: + rest = rest[1:] + + field_order = ["reference", "item", "category", "actionPlan", "vulnerabilityTemplateId", "sort"] + start_index = 1 if "reference" in act else 0 + for idx, value in enumerate(rest): + field_idx = idx + start_index + if field_idx >= len(field_order): + break + if not value: + continue + field = field_order[field_idx] + if field == "vulnerabilityTemplateId": + try: + act[field] = int(value) + except ValueError: + warning(f"Ignoring invalid vulnerabilityTemplateId in activity: {value}") + else: + act[field] = value parsed.append(act) return parsed diff --git a/src/conviso/commands/tasks.py b/src/conviso/commands/tasks.py index cfbe594..ad4bab4 100644 --- a/src/conviso/commands/tasks.py +++ b/src/conviso/commands/tasks.py @@ -94,6 +94,57 @@ def _matches_prefix(label: str, prefix: str) -> bool: return re.match(pattern, label.strip(), flags=re.IGNORECASE) is not None +def _build_requirement_label(prefix: str, label: str) -> str: + base_label = (label or "").strip() + if not base_label: + return base_label + if not prefix or _matches_prefix(base_label, prefix): + return base_label + return f"{prefix.strip()} - {base_label}" + + +def _attach_requirement_to_project(company_id: int, project_id: int, requirement_id: int): + fetch_query = """ + query Project($id: ID!, $companyId: ID!) { + project(id: $id, companyId: $companyId) { + id + playbooks { id } + } + } + """ + data = graphql_request(fetch_query, {"id": project_id, "companyId": company_id}) + project = data.get("project") or {} + current_playbooks: List[int] = [] + for pb in project.get("playbooks") or []: + pid = pb.get("id") + if pid is None: + continue + try: + current_playbooks.append(int(pid)) + except ValueError: + warning(f"Requirement ID '{pid}' is not numeric; skipping.") + + if requirement_id in current_playbooks: + info(f"Requirement {requirement_id} already attached to project {project_id}.") + return + + merged = [*current_playbooks, requirement_id] + mutation = """ + mutation UpdateProject($input: UpdateProjectInput!) { + updateProject(input: $input) { + project { id label } + } + } + """ + graphql_request(mutation, {"input": {"id": project_id, "companyId": company_id, "playbooksIds": merged}}) + success(f"Requirement {requirement_id} attached to project {project_id}.") + + +def _normalize_activity_for_input(activity: Dict[str, Any]) -> Dict[str, Any]: + allowed = {"id", "label", "description", "reference", "item", "category", "actionPlan"} + return {k: v for k, v in activity.items() if k in allowed and v is not None} + + def _validate_task_yaml(desc: str) -> Dict[str, Any]: cleaned = _clean_description(desc) if not cleaned: @@ -956,6 +1007,138 @@ def _apply_actions(planned: List[Dict[str, Any]], company_id: int, apply: bool) return counts +@app.command("create") +def create_task( + company_id: int = typer.Option(..., "--company-id", "-c", help="Company/Scope ID."), + label: str = typer.Option(..., "--label", "-n", help="Task label (defaults to activity label)."), + yaml_text: Optional[str] = typer.Option(None, "--yaml", help="Inline YAML for the activity description."), + yaml_file: Optional[str] = typer.Option(None, "--yaml-file", help="Path to YAML file for the activity description."), + requirement_id: Optional[int] = typer.Option(None, "--requirement-id", help="Append activity to an existing requirement ID."), + requirement_label: Optional[str] = typer.Option(None, "--requirement-label", help="Requirement label (new requirement only)."), + requirement_description: Optional[str] = typer.Option(None, "--requirement-description", help="Requirement description (defaults to task label)."), + activity_label: Optional[str] = typer.Option(None, "--activity-label", help="Activity label (defaults to task label)."), + reference: Optional[str] = typer.Option(None, "--reference", help="Activity reference."), + type_id: Optional[int] = typer.Option(None, "--type-id", help="Activity typeId."), + requirement_prefix: str = typer.Option(TASK_PREFIX_DEFAULT, "--prefix", help="Requirement label prefix to use (new requirement)."), + project_id: Optional[int] = typer.Option(None, "--project-id", "-p", help="Attach requirement to project."), + global_flag: bool = typer.Option(False, "--global", help="Mark new requirement as global."), +): + """Create a TASK requirement with a YAML activity.""" + _require_yaml() + + if bool(yaml_text) == bool(yaml_file): + error("Provide exactly one of --yaml or --yaml-file.") + raise typer.Exit(code=1) + + if requirement_id and requirement_label: + warning("Ignoring --requirement-label because --requirement-id was provided.") + + if yaml_file: + try: + with open(yaml_file, "r", encoding="utf-8") as f: + yaml_text = f.read() + except Exception as exc: + error(f"Could not read YAML file '{yaml_file}': {exc}") + raise typer.Exit(code=1) + + yaml_text = yaml_text or "" + validation = _validate_task_yaml(yaml_text) + if not validation.get("ok"): + reason = validation.get("reason") or "invalid_yaml" + error(f"Invalid task YAML: {reason}") + raise typer.Exit(code=1) + + req_label_input = requirement_label or label + req_label = _build_requirement_label(requirement_prefix, req_label_input) + req_description = requirement_description if requirement_description is not None else label + act_label = activity_label or label + + description_payload = f"
{html_lib.escape(yaml_text)}
" + activity: Dict[str, Any] = { + "label": act_label, + "description": description_payload, + } + if reference: + activity["reference"] = reference + if type_id is not None: + activity["typeId"] = type_id + + mutation = """ + mutation CreateOrUpdateRequirement($input: RequirementInput!) { + createOrUpdateRequirement(input: $input) { + requirement { id label } + } + } + """ + + activities_payload = [_normalize_activity_for_input(activity)] + input_data: Dict[str, Any] = { + "companyId": company_id, + "label": req_label, + "description": req_description, + "type": "Procedures", + "global": global_flag or None, + "activities": activities_payload, + } + + if requirement_id: + fetch_query = """ + query Requirement($companyId: ID!, $id: ID!) { + requirement(companyId: $companyId, id: $id) { + id + label + description + global + check { + id + label + description + reference + item + category + actionPlan + sort + } + } + } + """ + try: + fetched = graphql_request(fetch_query, {"companyId": company_id, "id": requirement_id}, log_request=False) + req_data = fetched.get("requirement") or {} + except Exception as fetch_err: + error(f"Could not fetch existing requirement: {fetch_err}") + raise typer.Exit(code=1) + + current_label = req_data.get("label") + if current_label and not _matches_prefix(current_label, requirement_prefix): + warning( + f"Requirement {requirement_id} label '{current_label}' does not match prefix '{requirement_prefix}'. " + "Tasks may not be picked up by list/run." + ) + + existing_activities = [_normalize_activity_for_input(a) for a in (req_data.get("check") or [])] + input_data = { + "id": requirement_id, + "companyId": company_id, + "label": requirement_label or current_label, + "description": requirement_description if requirement_description is not None else req_data.get("description"), + "activities": [*existing_activities, _normalize_activity_for_input(activity)], + } + + input_data = {k: v for k, v in input_data.items() if v is not None} + + try: + data = graphql_request(mutation, {"input": input_data}) + req = data["createOrUpdateRequirement"]["requirement"] + req_id = req.get("id") + success(f"Task created: requirement {req_id} - {req.get('label')}") + if project_id and req_id: + _attach_requirement_to_project(company_id, project_id, int(req_id)) + except Exception as e: + error(f"Error creating task: {e}") + raise typer.Exit(code=1) + + @app.command("list") def list_tasks( company_id: int = typer.Option(..., "--company-id", "-c", help="Company/Scope ID."), From 4e8115c85322adba18b0e81af2697b7abe2063bf Mon Sep 17 00:00:00 2001 From: Wagner Elias Date: Fri, 6 Feb 2026 15:43:24 -0300 Subject: [PATCH 2/2] Improve task and requirements command - with version changed --- src/conviso/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/conviso/VERSION b/src/conviso/VERSION index 28af839..53a75d6 100644 --- a/src/conviso/VERSION +++ b/src/conviso/VERSION @@ -1 +1 @@ -0.2.5 \ No newline at end of file +0.2.6