Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,12 @@ Notes:
- Execution:
- Dry-run only (default): `python -m conviso.app tasks run --company-id 443 --project-id 26102`
- Apply directly: `python -m conviso.app tasks run --company-id 443 --project-id 26102 --apply`
- Approve commands without prompt: `python -m conviso.app tasks run --company-id 443 --project-id 26102 --auto-approve`
- Command approvals are stored locally in `~/.config/conviso/approved_tasks.json` and are keyed by the full command string.
- Manage approvals:
- List approvals: `python -m conviso.app tasks approvals list`
- Clear approvals: `python -m conviso.app tasks approvals clear`
- Remove approval by hash: `python -m conviso.app tasks approvals remove --hash <hash>`
- Pentest guide: `docs/pentest-tasks-guide.md`

### scan-json-lines (agnostic format)
Expand Down
6 changes: 6 additions & 0 deletions docs/pentest-tasks-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ This guide explains how to define requirements with YAML tasks and run them from
## Execution
- Dry-run only (default): `python -m conviso.app tasks run --company-id 443 --project-id 26102`
- Apply directly: `python -m conviso.app tasks run --company-id 443 --project-id 26102 --apply`
- Approve commands without prompt: `python -m conviso.app tasks run --company-id 443 --project-id 26102 --auto-approve`
- Command approvals are stored locally in `~/.config/conviso/approved_tasks.json` and are keyed by the full command string.
- Manage approvals:
- List approvals: `python -m conviso.app tasks approvals list`
- Clear approvals: `python -m conviso.app tasks approvals clear`
- Remove approval by hash: `python -m conviso.app tasks approvals remove --hash <hash>`

## Minimal YAML Structure
```yaml
Expand Down
2 changes: 2 additions & 0 deletions resume.cfg
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
resume_from=
index=0
2 changes: 1 addition & 1 deletion src/conviso/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.2.6
0.2.7
97 changes: 97 additions & 0 deletions src/conviso/commands/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@

import json
import html as html_lib
import hashlib
import os
import re
import subprocess
import time
import xml.etree.ElementTree as ET
from typing import Any, Dict, List, Optional
from urllib.parse import urlparse
Expand All @@ -27,9 +29,91 @@
yaml = None

app = typer.Typer(help="Execute YAML tasks defined in requirement activities.")
approvals_app = typer.Typer(help="Manage approved task commands.")
app.add_typer(approvals_app, name="approvals")

TASK_PREFIX_DEFAULT = "TASK"
_ASSET_LOOKUP_WARNED = False
APPROVALS_DIR = os.path.join(os.path.expanduser("~"), ".config", "conviso")
APPROVALS_FILE = os.path.join(APPROVALS_DIR, "approved_tasks.json")


def _load_approved_commands() -> Dict[str, Dict[str, Any]]:
try:
with open(APPROVALS_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict):
return data
except Exception:
pass
return {}


def _save_approved_commands(data: Dict[str, Dict[str, Any]]):
os.makedirs(APPROVALS_DIR, exist_ok=True)
with open(APPROVALS_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, sort_keys=True)


def _command_key(cmd: str) -> str:
return hashlib.sha256(cmd.encode("utf-8")).hexdigest()


def _is_command_approved(cmd: str) -> bool:
approvals = _load_approved_commands()
return _command_key(cmd) in approvals


def _approve_command(cmd: str):
approvals = _load_approved_commands()
key = _command_key(cmd)
approvals[key] = {"cmd": cmd, "approved_at": int(time.time())}
_save_approved_commands(approvals)


@approvals_app.command("list")
def list_approvals():
"""List locally approved task commands."""
approvals = _load_approved_commands()
if not approvals:
info("No approved commands found.")
return
rows = []
for key, data in approvals.items():
cmd = data.get("cmd") or ""
approved_at = data.get("approved_at") or 0
rows.append({"hash": key, "approvedAt": approved_at, "cmd": cmd})
for row in rows:
typer.echo(f"{row['hash']} {row['approvedAt']} {row['cmd']}")
summary(f"{len(rows)} approved command(s).")


@approvals_app.command("clear")
def clear_approvals():
"""Clear locally approved task commands."""
if os.path.exists(APPROVALS_FILE):
try:
os.remove(APPROVALS_FILE)
info("Approved commands cleared.")
return
except Exception as exc:
error(f"Failed to clear approvals: {exc}")
raise typer.Exit(code=1)
info("No approvals file found.")


@approvals_app.command("remove")
def remove_approval(
hash_value: str = typer.Option(..., "--hash", "-h", help="Approval hash to remove."),
):
"""Remove a single approved command by hash."""
approvals = _load_approved_commands()
if hash_value not in approvals:
warning("Approval hash not found.")
raise typer.Exit(code=1)
approvals.pop(hash_value, None)
_save_approved_commands(approvals)
info("Approval removed.")


def _require_yaml():
Expand Down Expand Up @@ -1291,6 +1375,7 @@ def run_task(
project_id: int = typer.Option(..., "--project-id", "-p", help="Project ID."),
requirement_prefix: str = typer.Option(TASK_PREFIX_DEFAULT, "--prefix", help="Requirement label prefix to match."),
dryrun: bool = typer.Option(True, "--dryrun/--apply", help="Run in dry-run mode (default). Use --apply to apply actions."),
auto_approve: bool = typer.Option(False, "--auto-approve", help="Approve and persist task commands without confirmation."),
):
"""Execute tasks defined as YAML in activity descriptions."""
_require_yaml()
Expand Down Expand Up @@ -1447,6 +1532,18 @@ def run_task(

cmd = _render_string(cmd, {}, context)
info(f"Running: {cmd}")
if not _is_command_approved(cmd):
if auto_approve:
_approve_command(cmd)
info("Command approved and cached locally.")
else:
info("Command requires approval to run.")
confirm = typer.confirm("Approve and run this command now?", default=False)
if not confirm:
warning("Command not approved. Skipping.")
continue
_approve_command(cmd)
info("Command approved and cached locally.")
result = subprocess.run(cmd, shell=True, text=True, capture_output=True)
if result.returncode != 0:
error(f"Command failed (code {result.returncode}): {result.stderr.strip()}")
Expand Down