argus-probe 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- argus_probe/__init__.py +1 -0
- argus_probe/__main__.py +28 -0
- argus_probe/api_client.py +70 -0
- argus_probe/baseline_key.py +19 -0
- argus_probe/cmd_coverage.py +44 -0
- argus_probe/cmd_init.py +187 -0
- argus_probe/cmd_list_probes.py +19 -0
- argus_probe/cmd_report.py +20 -0
- argus_probe/cmd_run.py +172 -0
- argus_probe/cmd_validate_target.py +31 -0
- argus_probe/git_context.py +40 -0
- argus_probe/local_run.py +63 -0
- argus_probe/reports.py +196 -0
- argus_probe/severity.py +22 -0
- argus_probe/target_loader.py +96 -0
- argus_probe/templates/report.html.j2 +150 -0
- argus_probe-0.1.1.dist-info/METADATA +159 -0
- argus_probe-0.1.1.dist-info/RECORD +23 -0
- argus_probe-0.1.1.dist-info/WHEEL +5 -0
- argus_probe-0.1.1.dist-info/entry_points.txt +2 -0
- argus_probe-0.1.1.dist-info/licenses/LICENSE +216 -0
- argus_probe-0.1.1.dist-info/licenses/NOTICE +38 -0
- argus_probe-0.1.1.dist-info/top_level.txt +1 -0
argus_probe/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.1"
|
argus_probe/__main__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""argus-probe entry point."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from argus_probe.cmd_init import init_command
|
|
6
|
+
from argus_probe.cmd_run import cmd_run
|
|
7
|
+
from argus_probe.cmd_report import cmd_report
|
|
8
|
+
from argus_probe.cmd_list_probes import cmd_list_probes
|
|
9
|
+
from argus_probe.cmd_coverage import cmd_coverage
|
|
10
|
+
from argus_probe.cmd_validate_target import cmd_validate_target
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.group()
|
|
14
|
+
@click.version_option()
|
|
15
|
+
def main():
|
|
16
|
+
"""Argus red-team CLI."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
main.add_command(init_command, "init")
|
|
20
|
+
main.add_command(cmd_run, "run")
|
|
21
|
+
main.add_command(cmd_report, "report")
|
|
22
|
+
main.add_command(cmd_list_probes, "list-probes")
|
|
23
|
+
main.add_command(cmd_coverage, "coverage")
|
|
24
|
+
main.add_command(cmd_validate_target, "validate-target")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
if __name__ == "__main__":
|
|
28
|
+
main()
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Minimal httpx wrapper around the Argus REST API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def post_run(api_url: str, token: str, body: dict, timeout: float = 30.0) -> dict:
|
|
11
|
+
with httpx.Client(timeout=timeout) as client:
|
|
12
|
+
resp = client.post(f"{api_url}/api/v1/redteam/runs", json=body, headers={"x-api-token": token})
|
|
13
|
+
resp.raise_for_status()
|
|
14
|
+
return resp.json()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_run(api_url: str, token: str, run_id: str, timeout: float = 30.0) -> dict:
|
|
18
|
+
with httpx.Client(timeout=timeout) as client:
|
|
19
|
+
resp = client.get(f"{api_url}/api/v1/redteam/runs/{run_id}", headers={"x-api-token": token})
|
|
20
|
+
resp.raise_for_status()
|
|
21
|
+
return resp.json()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_report(api_url: str, token: str, run_id: str, format: str, timeout: float = 30.0) -> str:
|
|
25
|
+
with httpx.Client(timeout=timeout) as client:
|
|
26
|
+
resp = client.get(
|
|
27
|
+
f"{api_url}/api/v1/redteam/runs/{run_id}/report",
|
|
28
|
+
params={"format": format},
|
|
29
|
+
headers={"x-api-token": token},
|
|
30
|
+
)
|
|
31
|
+
resp.raise_for_status()
|
|
32
|
+
return resp.text
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def poll_until_complete(api_url: str, token: str, run_id: str, interval: float = 2.0, max_wait: float = 1800.0) -> dict:
|
|
36
|
+
deadline = time.monotonic() + max_wait
|
|
37
|
+
while time.monotonic() < deadline:
|
|
38
|
+
run = get_run(api_url, token, run_id)
|
|
39
|
+
if run["status"] in ("completed", "failed", "cancelled"):
|
|
40
|
+
return run
|
|
41
|
+
time.sleep(interval)
|
|
42
|
+
raise TimeoutError(f"run {run_id} did not complete within {max_wait}s")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def list_probes(api_url: str, token: str, timeout: float = 30.0) -> list[str]:
|
|
46
|
+
"""GET /api/v1/redteam/probes (TBD endpoint — Plan 3 Task 14 adds it)."""
|
|
47
|
+
with httpx.Client(timeout=timeout) as client:
|
|
48
|
+
resp = client.get(f"{api_url}/api/v1/redteam/probes", headers={"x-api-token": token})
|
|
49
|
+
resp.raise_for_status()
|
|
50
|
+
return resp.json()["probe_ids"]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_coverage(api_url: str, token: str, timeout: float = 30.0) -> dict:
|
|
54
|
+
"""GET /api/v1/redteam/coverage — what the probe library covers. Runs no probes, costs nothing."""
|
|
55
|
+
with httpx.Client(timeout=timeout) as client:
|
|
56
|
+
resp = client.get(f"{api_url}/api/v1/redteam/coverage", headers={"x-api-token": token})
|
|
57
|
+
resp.raise_for_status()
|
|
58
|
+
return resp.json()["coverage"]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_diff(api_url: str, token: str, run_id: str, base: str, timeout: float = 30.0) -> dict:
|
|
62
|
+
"""GET /api/v1/redteam/runs/{run_id}/diff — failures new, fixed or unchanged versus the latest run on `base`."""
|
|
63
|
+
with httpx.Client(timeout=timeout) as client:
|
|
64
|
+
resp = client.get(
|
|
65
|
+
f"{api_url}/api/v1/redteam/runs/{run_id}/diff",
|
|
66
|
+
params={"base": base},
|
|
67
|
+
headers={"x-api-token": token},
|
|
68
|
+
)
|
|
69
|
+
resp.raise_for_status()
|
|
70
|
+
return resp.json()
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Stable id of the target a run tests, so a baseline only matches runs against the same target."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
_SECRET_WORDS = ("key", "token", "secret", "password", "auth", "header", "cookie")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _clean(value):
|
|
11
|
+
if isinstance(value, dict):
|
|
12
|
+
return {k: _clean(v) for k, v in value.items() if not any(w in str(k).lower() for w in _SECRET_WORDS)}
|
|
13
|
+
if isinstance(value, list):
|
|
14
|
+
return [_clean(v) for v in value]
|
|
15
|
+
return value
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def baseline_key(target: dict) -> str:
|
|
19
|
+
return hashlib.sha256(json.dumps(_clean(target), sort_keys=True).encode()).hexdigest()[:32]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""argus-probe coverage — show what the probe library covers.
|
|
2
|
+
|
|
3
|
+
Runs no probes and costs nothing: a prospect must be able to ask what we
|
|
4
|
+
cover before paying for a scan. Prints every cell of each standard,
|
|
5
|
+
including the empty ones — silently omitting what we do not cover would
|
|
6
|
+
invert the point of this command.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
|
|
13
|
+
from argus_probe import api_client
|
|
14
|
+
|
|
15
|
+
STANDARDS = ["owasp-llm-top10", "mitre-atlas", "nist-ai-rmf", "eu-ai-act"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@click.command()
|
|
19
|
+
@click.option("--token", envvar="ARGUS_API_TOKEN", required=True)
|
|
20
|
+
@click.option("--api-url", envvar="ARGUS_API_URL", required=True, help="Argus API base URL (or env ARGUS_API_URL).")
|
|
21
|
+
@click.option("--standard", "standard", type=click.Choice(STANDARDS), default=None,
|
|
22
|
+
help="Show only this standard (default: all).")
|
|
23
|
+
@click.option("--json", "as_json", is_flag=True, help="Emit the raw coverage manifest as JSON.")
|
|
24
|
+
def cmd_coverage(token: str, api_url: str, standard: str, as_json: bool):
|
|
25
|
+
"""Show what the probe library covers, offline — no probes run."""
|
|
26
|
+
coverage = api_client.get_coverage(api_url, token)
|
|
27
|
+
if as_json:
|
|
28
|
+
if standard:
|
|
29
|
+
coverage = {**coverage, "standards": {standard: coverage["standards"][standard]}}
|
|
30
|
+
click.echo(json.dumps(coverage))
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
keys = [standard] if standard else STANDARDS
|
|
34
|
+
for key in keys:
|
|
35
|
+
data = coverage["standards"][key]
|
|
36
|
+
click.echo(f"\n{key} ({data['universe']} universe)")
|
|
37
|
+
for cell in data["cells"]:
|
|
38
|
+
name = cell.get("name", "")
|
|
39
|
+
click.echo(
|
|
40
|
+
f" {cell['id']:<8} {name:<40} {cell['probes_in_library']:>4} probes {cell['library_status']}"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
totals = coverage["totals"]
|
|
44
|
+
click.echo(f"\n{totals['probes_in_library']} probes in library, {totals['probes_run']} run this scan")
|
argus_probe/cmd_init.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""argus-probe init subcommand: scaffold a target.json + optional GH Action workflow."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from argus_probe.target_loader import _KNOWN_KINDS
|
|
10
|
+
|
|
11
|
+
# Per-kind question definitions. Each tuple: (field_name, prompt_text, default_or_None)
|
|
12
|
+
_KIND_QUESTIONS: dict[str, list[tuple[str, str, str | None]]] = {
|
|
13
|
+
"openai_compat": [
|
|
14
|
+
("endpoint_url", "Endpoint URL", "https://api.openai.com/v1/chat/completions"),
|
|
15
|
+
("model", "Model name", None),
|
|
16
|
+
("api_key_env", "API key env var", "OPENAI_API_KEY"),
|
|
17
|
+
],
|
|
18
|
+
"anthropic_native": [
|
|
19
|
+
("model", "Model name", "claude-haiku-4-5-20251001"),
|
|
20
|
+
("api_key_env", "API key env var", "ANTHROPIC_API_KEY"),
|
|
21
|
+
],
|
|
22
|
+
"custom_http": [
|
|
23
|
+
("request_url", "Request URL", None),
|
|
24
|
+
("request_body_template", "Request body template (Jinja2 with {{prompt}})",
|
|
25
|
+
'{"input": {{prompt|tojson}}}'),
|
|
26
|
+
("response_jsonpath", "Response JSONPath", "$.output"),
|
|
27
|
+
],
|
|
28
|
+
"grpc": [
|
|
29
|
+
("endpoint", "Endpoint (host:port)", "localhost:50051"),
|
|
30
|
+
("service_method", "Service method (pkg.Service/Method)", None),
|
|
31
|
+
("prompt_field", "Prompt field name", "user_input"),
|
|
32
|
+
("response_field", "Response field name", "response"),
|
|
33
|
+
],
|
|
34
|
+
"browser_use": [
|
|
35
|
+
("agent_url", "Browser-using agent URL", None),
|
|
36
|
+
("scenario_kind", "Scenario kind (dom_injection/ui_phishing/visual_injection/os_cmd)", "dom_injection"),
|
|
37
|
+
],
|
|
38
|
+
"payment_agent": [
|
|
39
|
+
("testbed_url", "Payment testbed URL", "http://127.0.0.1:8090"),
|
|
40
|
+
(
|
|
41
|
+
"inner_json",
|
|
42
|
+
"Inner target spec (JSON) for the transport that talks to your agent",
|
|
43
|
+
'{"kind": "openai_compat", "endpoint_url": "http://127.0.0.1:8091/v1/chat/completions", "model": "argus-demo-payment-agent"}',
|
|
44
|
+
),
|
|
45
|
+
],
|
|
46
|
+
"mcp_agent": [
|
|
47
|
+
("testbed_url", "MCP testbed URL", "http://127.0.0.1:8092"),
|
|
48
|
+
(
|
|
49
|
+
"inner_json",
|
|
50
|
+
"Inner target spec (JSON) for the transport that talks to your agent",
|
|
51
|
+
'{"kind": "openai_compat", "endpoint_url": "http://127.0.0.1:8093/v1/chat/completions", "model": "argus-demo-mcp-agent"}',
|
|
52
|
+
),
|
|
53
|
+
],
|
|
54
|
+
"testbed_agent": [
|
|
55
|
+
("environment", "Environment (mcp/payment)", "mcp"),
|
|
56
|
+
("testbed_url", "Testbed URL", "http://127.0.0.1:8092"),
|
|
57
|
+
(
|
|
58
|
+
"inner_json",
|
|
59
|
+
"Inner target spec (JSON) for the transport that talks to your agent",
|
|
60
|
+
'{"kind": "openai_compat", "endpoint_url": "http://127.0.0.1:8093/v1/chat/completions", "model": "argus-demo-mcp-agent"}',
|
|
61
|
+
),
|
|
62
|
+
],
|
|
63
|
+
"http_upload": [
|
|
64
|
+
("upload_url", "Upload URL", None),
|
|
65
|
+
("upload_field_name", "Upload form field name", "file"),
|
|
66
|
+
("upload_filename", "Upload filename", "payload.svg"),
|
|
67
|
+
("upload_content_type", "Upload content type", "image/svg+xml"),
|
|
68
|
+
("render_url_jsonpath", "Render URL JSONPath (extracts the render URL from the upload response)", "$.url"),
|
|
69
|
+
],
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
_WORKFLOW_TEMPLATE = """\
|
|
73
|
+
# .github/workflows/argus-probe.yml
|
|
74
|
+
# Generated by `argus-probe init`. Customize as needed.
|
|
75
|
+
name: argus-probe
|
|
76
|
+
on:
|
|
77
|
+
pull_request:
|
|
78
|
+
branches: [main]
|
|
79
|
+
push:
|
|
80
|
+
branches: [main]
|
|
81
|
+
schedule:
|
|
82
|
+
# Weekly Monday 06:00 UTC
|
|
83
|
+
- cron: '0 6 * * 1'
|
|
84
|
+
workflow_dispatch: {{}}
|
|
85
|
+
|
|
86
|
+
jobs:
|
|
87
|
+
redteam:
|
|
88
|
+
runs-on: ubuntu-latest
|
|
89
|
+
permissions:
|
|
90
|
+
security-events: write # upload-sarif
|
|
91
|
+
contents: read
|
|
92
|
+
steps:
|
|
93
|
+
- uses: actions/checkout@v4
|
|
94
|
+
- uses: actions/setup-python@v5
|
|
95
|
+
with:
|
|
96
|
+
python-version: '3.11'
|
|
97
|
+
- run: pip install "git+https://github.com/gy15901580825/Argus.git#subdirectory=cli"
|
|
98
|
+
# PRs gate only on failures new relative to the base branch; pushes to main record the baseline.
|
|
99
|
+
- run: >
|
|
100
|
+
argus-probe run --target {target_path}
|
|
101
|
+
--probes owasp_01_prompt_injection_basic,syscard_crescendo
|
|
102
|
+
--format sarif --out argus.sarif --fail-on critical
|
|
103
|
+
${{{{ github.event_name == 'pull_request' && format('--compare-to {{0}}', github.base_ref) || '' }}}}
|
|
104
|
+
env:
|
|
105
|
+
ARGUS_API_URL: ${{{{ vars.ARGUS_API_URL }}}}
|
|
106
|
+
ARGUS_API_TOKEN: ${{{{ secrets.ARGUS_API_TOKEN }}}}
|
|
107
|
+
- uses: github/codeql-action/upload-sarif@v3
|
|
108
|
+
with:
|
|
109
|
+
sarif_file: argus.sarif
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@click.command("init")
|
|
114
|
+
@click.option(
|
|
115
|
+
"--kind",
|
|
116
|
+
required=True,
|
|
117
|
+
type=click.Choice(sorted(_KNOWN_KINDS)),
|
|
118
|
+
help="Target adapter kind",
|
|
119
|
+
)
|
|
120
|
+
@click.option(
|
|
121
|
+
"--output",
|
|
122
|
+
default="argus-target.json",
|
|
123
|
+
show_default=True,
|
|
124
|
+
help="Output target.json path",
|
|
125
|
+
)
|
|
126
|
+
@click.option(
|
|
127
|
+
"--workflow/--no-workflow",
|
|
128
|
+
default=None,
|
|
129
|
+
help="Generate .github/workflows/argus-probe.yml (prompts if neither flag given)",
|
|
130
|
+
)
|
|
131
|
+
def init_command(kind: str, output: str, workflow: bool | None) -> None:
|
|
132
|
+
"""Generate a target.json (and optional GitHub Action workflow) for Argus."""
|
|
133
|
+
click.echo(f"Argus target initializer")
|
|
134
|
+
click.echo(f"Target kind: {kind}")
|
|
135
|
+
questions = _KIND_QUESTIONS[kind]
|
|
136
|
+
|
|
137
|
+
spec: dict[str, object] = {"kind": kind}
|
|
138
|
+
for field, prompt_text, default in questions:
|
|
139
|
+
if default is not None:
|
|
140
|
+
value = click.prompt(prompt_text, default=default, show_default=True)
|
|
141
|
+
else:
|
|
142
|
+
value = click.prompt(prompt_text)
|
|
143
|
+
spec[field] = value
|
|
144
|
+
|
|
145
|
+
# api_key_env → api_key (CLI run command resolves $-vars at runtime)
|
|
146
|
+
if "api_key_env" in spec:
|
|
147
|
+
env_var = spec.pop("api_key_env")
|
|
148
|
+
spec["api_key"] = "${" + str(env_var) + "}"
|
|
149
|
+
|
|
150
|
+
# inner_json → inner (payment_agent / mcp_agent / testbed_agent nest another target spec
|
|
151
|
+
# for the transport that actually talks to the agent)
|
|
152
|
+
if "inner_json" in spec:
|
|
153
|
+
raw_inner = spec.pop("inner_json")
|
|
154
|
+
try:
|
|
155
|
+
spec["inner"] = json.loads(str(raw_inner))
|
|
156
|
+
except json.JSONDecodeError as exc:
|
|
157
|
+
raise click.UsageError(f"inner target spec is not valid JSON: {exc}") from exc
|
|
158
|
+
|
|
159
|
+
# sandbox is a safety interlock, not a question: payment_agent,
|
|
160
|
+
# mcp_agent and testbed_agent drive an agent that spends money / reaches a live MCP server,
|
|
161
|
+
# and both api_service and the CLI loader refuse a spec without
|
|
162
|
+
# sandbox: true — there is no way to turn it off, so it must never be
|
|
163
|
+
# presented as something a user could answer "no" to.
|
|
164
|
+
if kind in ("payment_agent", "mcp_agent", "testbed_agent"):
|
|
165
|
+
spec["sandbox"] = True
|
|
166
|
+
|
|
167
|
+
# Confirm-overwrite if target file exists
|
|
168
|
+
target_path = Path(output)
|
|
169
|
+
if target_path.exists():
|
|
170
|
+
if not click.confirm(f"{output} exists. Overwrite?", default=False):
|
|
171
|
+
raise click.Abort()
|
|
172
|
+
|
|
173
|
+
target_path.write_text(json.dumps(spec, indent=2) + "\n")
|
|
174
|
+
click.echo(f"✅ Wrote {target_path}")
|
|
175
|
+
|
|
176
|
+
# Workflow file
|
|
177
|
+
if workflow is None:
|
|
178
|
+
workflow = click.confirm("Generate GitHub Action workflow?", default=False)
|
|
179
|
+
if workflow:
|
|
180
|
+
wf_path = Path(".github/workflows/argus-probe.yml")
|
|
181
|
+
wf_path.parent.mkdir(parents=True, exist_ok=True)
|
|
182
|
+
wf_path.write_text(_WORKFLOW_TEMPLATE.format(target_path=target_path.name))
|
|
183
|
+
click.echo(f"✅ Wrote {wf_path}")
|
|
184
|
+
|
|
185
|
+
click.echo("")
|
|
186
|
+
click.echo(f"Next: argus-probe run --target {target_path.name}")
|
|
187
|
+
click.echo("Set ARGUS_API_URL (repository variable) and ARGUS_API_TOKEN (secret) in CI to enable scheduled runs.")
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""argus-probe list-probes — show available probe ids."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from argus_probe import api_client
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@click.command()
|
|
9
|
+
@click.option("--token", envvar="ARGUS_API_TOKEN", required=True)
|
|
10
|
+
@click.option("--api-url", envvar="ARGUS_API_URL", required=True, help="Argus API base URL (or env ARGUS_API_URL).")
|
|
11
|
+
@click.option("--filter", "filter_substr", default="", help="Show only ids containing this substring.")
|
|
12
|
+
def cmd_list_probes(token: str, api_url: str, filter_substr: str):
|
|
13
|
+
"""List probe ids available on the connected Argus instance."""
|
|
14
|
+
ids = api_client.list_probes(api_url, token)
|
|
15
|
+
if filter_substr:
|
|
16
|
+
ids = [i for i in ids if filter_substr.lower() in i.lower()]
|
|
17
|
+
for i in sorted(ids):
|
|
18
|
+
click.echo(i)
|
|
19
|
+
click.echo(f"\n{len(ids)} probes")
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""argus-probe report — render an existing run's report."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from argus_probe import api_client
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.command()
|
|
11
|
+
@click.option("--run-id", required=True)
|
|
12
|
+
@click.option("--format", "report_format", type=click.Choice(["html", "sarif", "junit"]), default="html")
|
|
13
|
+
@click.option("--out", "out_file", type=click.Path(), required=True)
|
|
14
|
+
@click.option("--token", envvar="ARGUS_API_TOKEN", required=True)
|
|
15
|
+
@click.option("--api-url", envvar="ARGUS_API_URL", required=True, help="Argus API base URL (or env ARGUS_API_URL).")
|
|
16
|
+
def cmd_report(run_id: str, report_format: str, out_file: str, token: str, api_url: str):
|
|
17
|
+
"""Render an existing run's report to disk."""
|
|
18
|
+
report = api_client.get_report(api_url, token, run_id, report_format)
|
|
19
|
+
Path(out_file).write_text(report)
|
|
20
|
+
click.echo(f"Wrote {report_format} report for run {run_id} to {out_file}")
|
argus_probe/cmd_run.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""argus-probe run — POST a redteam run + poll + write report."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
import httpx
|
|
10
|
+
from click.core import ParameterSource
|
|
11
|
+
|
|
12
|
+
from argus_probe import api_client, git_context
|
|
13
|
+
from argus_probe.baseline_key import baseline_key
|
|
14
|
+
from argus_probe.local_run import LocalUnavailable, run_local_and_render
|
|
15
|
+
from argus_probe.severity import SEVERITY_ORDER, findings_at_or_above
|
|
16
|
+
from argus_probe.target_loader import load_target_spec, TargetLoadError
|
|
17
|
+
|
|
18
|
+
# Exit codes must fit 0-255; sys.exit(402) surfaced as 146 on Linux and macOS.
|
|
19
|
+
EXIT_COST_CAP = 4
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@click.command()
|
|
23
|
+
@click.option("--target", required=True, help="Target spec: JSON object (inline) or path to JSON file.")
|
|
24
|
+
@click.option("--probes", "probe_ids", required=True, help="Comma-separated probe ids OR 'all'.")
|
|
25
|
+
@click.option("--format", "report_format", type=click.Choice(["html", "sarif", "junit"]), default="html")
|
|
26
|
+
@click.option("--out", "out_file", type=click.Path(), required=True, help="Output report file path.")
|
|
27
|
+
@click.option("--token", envvar="ARGUS_API_TOKEN", default=None, help="Your Argus API token (or env ARGUS_API_TOKEN). Not used with --local.")
|
|
28
|
+
@click.option("--api-url", envvar="ARGUS_API_URL", default=None, help="Argus API base URL (or env ARGUS_API_URL). Not used with --local.")
|
|
29
|
+
@click.option("--max-wait", type=float, default=1800.0, help="Max seconds to wait for run completion. Ignored with --local.")
|
|
30
|
+
@click.option("--per-run-cap", "per_run_cap", type=float, default=None, help="Maximum USD cost for this run; enforced before any LLM call in both server and local mode. Default: $0.50.")
|
|
31
|
+
@click.option("--fail-on", "fail_on", type=click.Choice(SEVERITY_ORDER), default=None,
|
|
32
|
+
help="Exit 3 when any finding has verdict=fail and severity at or above this level.")
|
|
33
|
+
@click.option("--local", "local", is_flag=True, default=False,
|
|
34
|
+
help="Run probes in this process instead of submitting to an Argus API server. Needs the argus-orchestrator package and a judge API key in the environment.")
|
|
35
|
+
@click.option("--compare-to", "compare_to", default=None,
|
|
36
|
+
help="Base branch: report and gate only on failures that are new relative to the latest run on it. Server mode only.")
|
|
37
|
+
def cmd_run(target: str, probe_ids: str, report_format: str, out_file: str, token: str | None, api_url: str | None, max_wait: float, per_run_cap: float | None, fail_on: str | None, local: bool, compare_to: str | None):
|
|
38
|
+
"""Submit a red-team run and write the report to disk."""
|
|
39
|
+
try:
|
|
40
|
+
spec = load_target_spec(target)
|
|
41
|
+
except TargetLoadError as e:
|
|
42
|
+
click.echo(f"Error: {e}", file=sys.stderr)
|
|
43
|
+
sys.exit(1)
|
|
44
|
+
|
|
45
|
+
tokens = [s.strip() for s in probe_ids.split(",") if s.strip()]
|
|
46
|
+
if not tokens:
|
|
47
|
+
# An empty list is the wire convention for "the whole library", so an
|
|
48
|
+
# empty --probes must not fall through to it: `--probes ""` would
|
|
49
|
+
# quietly launch a full sweep the user never asked for.
|
|
50
|
+
raise click.UsageError("--probes needs at least one probe id, or 'all'")
|
|
51
|
+
if "all" in tokens:
|
|
52
|
+
# Silently dropping `all` from a mixed list would run a fraction of the
|
|
53
|
+
# intended probes while the run still looks successful — the worst
|
|
54
|
+
# outcome for a security gate. Refuse instead.
|
|
55
|
+
if len(tokens) > 1:
|
|
56
|
+
raise click.UsageError("'all' cannot be combined with specific probe ids")
|
|
57
|
+
# Empty probe_ids means "all" per orchestrator convention.
|
|
58
|
+
tokens = []
|
|
59
|
+
|
|
60
|
+
if local:
|
|
61
|
+
if compare_to:
|
|
62
|
+
raise click.UsageError("--compare-to needs run history on an Argus server; it does not work with --local")
|
|
63
|
+
ctx = click.get_current_context()
|
|
64
|
+
typed = [
|
|
65
|
+
name for name, param in (("--api-url", "api_url"), ("--token", "token"))
|
|
66
|
+
if ctx.get_parameter_source(param) == ParameterSource.COMMANDLINE
|
|
67
|
+
]
|
|
68
|
+
if typed:
|
|
69
|
+
raise click.UsageError(f"--local runs in this process; do not pass {' or '.join(typed)}")
|
|
70
|
+
_run_locally(spec, tokens, per_run_cap, report_format, out_file, fail_on)
|
|
71
|
+
return
|
|
72
|
+
if api_url is None:
|
|
73
|
+
raise click.UsageError("Missing option '--api-url'")
|
|
74
|
+
if token is None:
|
|
75
|
+
raise click.UsageError("Missing option '--token'")
|
|
76
|
+
|
|
77
|
+
body = {
|
|
78
|
+
"target": spec,
|
|
79
|
+
"probe_ids": tokens,
|
|
80
|
+
}
|
|
81
|
+
if per_run_cap is not None:
|
|
82
|
+
body["per_run_cap_usd"] = per_run_cap
|
|
83
|
+
body.update(git_context.detect())
|
|
84
|
+
if compare_to and "repo" not in body:
|
|
85
|
+
raise click.UsageError("--compare-to needs the repository name; run inside a git checkout with an origin remote, or set GITHUB_REPOSITORY")
|
|
86
|
+
body["baseline_key"] = baseline_key(spec)
|
|
87
|
+
|
|
88
|
+
click.echo(f"Submitting run to {api_url}/api/v1/redteam/runs ...")
|
|
89
|
+
try:
|
|
90
|
+
resp = api_client.post_run(api_url, token, body=body)
|
|
91
|
+
except httpx.HTTPStatusError as e:
|
|
92
|
+
if e.response.status_code == 402:
|
|
93
|
+
try:
|
|
94
|
+
detail = e.response.json().get("detail", "cost cap exceeded")
|
|
95
|
+
except Exception:
|
|
96
|
+
detail = "cost cap exceeded"
|
|
97
|
+
click.echo(f"Argus rejected run for cost: {detail}", file=sys.stderr)
|
|
98
|
+
sys.exit(EXIT_COST_CAP)
|
|
99
|
+
raise
|
|
100
|
+
run_id = resp["run_id"]
|
|
101
|
+
click.echo(f" run_id: {run_id}")
|
|
102
|
+
|
|
103
|
+
click.echo("Polling until completion...")
|
|
104
|
+
run = api_client.poll_until_complete(api_url, token, run_id, max_wait=max_wait)
|
|
105
|
+
click.echo(f" status: {run['status']}, findings: {len(run.get('findings', []))}")
|
|
106
|
+
|
|
107
|
+
click.echo(f"Fetching {report_format} report...")
|
|
108
|
+
report = api_client.get_report(api_url, token, run_id, report_format)
|
|
109
|
+
Path(out_file).write_text(report)
|
|
110
|
+
click.echo(f"Wrote report to {out_file}")
|
|
111
|
+
if run["status"] != "completed":
|
|
112
|
+
click.echo(f"Error: run {run_id} ended with status {run['status']}", file=sys.stderr)
|
|
113
|
+
sys.exit(1)
|
|
114
|
+
if compare_to:
|
|
115
|
+
try:
|
|
116
|
+
d = api_client.get_diff(api_url, token, run_id, compare_to)
|
|
117
|
+
except httpx.HTTPStatusError as e:
|
|
118
|
+
try:
|
|
119
|
+
detail = e.response.json().get("detail", e.response.status_code)
|
|
120
|
+
except Exception:
|
|
121
|
+
detail = e.response.status_code
|
|
122
|
+
click.echo(f"Error: could not compare with {compare_to}: {detail}", file=sys.stderr)
|
|
123
|
+
sys.exit(1)
|
|
124
|
+
base = d.get("base_run_id") or "none (first run on this branch)"
|
|
125
|
+
click.echo(f"Compared with {compare_to} (baseline run {base}): "
|
|
126
|
+
f"new: {len(d['new'])}, fixed: {len(d['fixed'])}, still failing: {len(d['still_failing'])}, "
|
|
127
|
+
f"not run: {len(d['not_run'])}")
|
|
128
|
+
for item in d["new"]:
|
|
129
|
+
click.echo(f" new {item['severity']:<9} {item['probe_id']}")
|
|
130
|
+
_apply_fail_on([{"verdict": "fail", **i} for i in d["new"]], fail_on)
|
|
131
|
+
else:
|
|
132
|
+
_apply_fail_on(run.get("findings", []), fail_on)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _apply_fail_on(findings: list[dict], fail_on: str | None) -> None:
|
|
136
|
+
"""Exit 3 when findings cross the --fail-on threshold. Report is already on disk."""
|
|
137
|
+
if fail_on is None:
|
|
138
|
+
return
|
|
139
|
+
hits = findings_at_or_above(findings, fail_on)
|
|
140
|
+
if hits:
|
|
141
|
+
ids = ", ".join(sorted({h.get("probe_id", "?") for h in hits}))
|
|
142
|
+
click.echo(
|
|
143
|
+
f"{len(hits)} finding(s) at or above {fail_on}: {ids}",
|
|
144
|
+
file=sys.stderr,
|
|
145
|
+
)
|
|
146
|
+
sys.exit(3)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _run_locally(spec, tokens, per_run_cap, report_format, out_file, fail_on) -> None:
|
|
150
|
+
if spec.get("kind") == "browser_use":
|
|
151
|
+
click.echo(
|
|
152
|
+
"Error: --local does not support browser_use targets; they need the "
|
|
153
|
+
"orchestrator service and a client_agent. Submit the run to an Argus API server instead.",
|
|
154
|
+
file=sys.stderr,
|
|
155
|
+
)
|
|
156
|
+
sys.exit(1)
|
|
157
|
+
click.echo(f"Running {('all probes' if not tokens else f'{len(tokens)} probe(s)')} locally ...")
|
|
158
|
+
try:
|
|
159
|
+
report, findings = run_local_and_render(spec, tokens, per_run_cap, report_format)
|
|
160
|
+
except LocalUnavailable as e:
|
|
161
|
+
click.echo(f"Error: {e}", file=sys.stderr)
|
|
162
|
+
sys.exit(2)
|
|
163
|
+
except Exception as e: # LocalRunError or a mid-run failure; no traceback for the operator
|
|
164
|
+
code = getattr(e, "code", None)
|
|
165
|
+
if code == "cost":
|
|
166
|
+
click.echo(f"Run rejected for cost: {e}", file=sys.stderr)
|
|
167
|
+
sys.exit(EXIT_COST_CAP)
|
|
168
|
+
click.echo(f"Error: {e}", file=sys.stderr)
|
|
169
|
+
sys.exit(1)
|
|
170
|
+
Path(out_file).write_text(report)
|
|
171
|
+
click.echo(f"Wrote report to {out_file}")
|
|
172
|
+
_apply_fail_on(findings, fail_on)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""argus-probe validate-target — quick reachability check on a target endpoint."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
import httpx
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.command()
|
|
11
|
+
@click.option("--target", "target_file", type=click.Path(exists=True), required=True)
|
|
12
|
+
def cmd_validate_target(target_file: str):
|
|
13
|
+
"""POST a tiny chat-completions probe to the target and report reachability + shape compliance."""
|
|
14
|
+
spec = yaml.safe_load(Path(target_file).read_text())
|
|
15
|
+
url = spec["endpoint_url"]
|
|
16
|
+
headers = {}
|
|
17
|
+
if spec.get("api_key"):
|
|
18
|
+
headers["Authorization"] = f"Bearer {spec['api_key']}"
|
|
19
|
+
body = {"model": spec["model"], "messages": [{"role": "user", "content": "ping"}], "max_tokens": 10}
|
|
20
|
+
try:
|
|
21
|
+
resp = httpx.post(url, headers=headers, json=body, timeout=15.0)
|
|
22
|
+
except httpx.RequestError as e:
|
|
23
|
+
raise click.ClickException(f"network error: {e}")
|
|
24
|
+
if resp.status_code != 200:
|
|
25
|
+
raise click.ClickException(f"target returned {resp.status_code}: {resp.text[:200]}")
|
|
26
|
+
try:
|
|
27
|
+
data = resp.json()
|
|
28
|
+
text = data["choices"][0]["message"]["content"]
|
|
29
|
+
except (KeyError, IndexError, ValueError) as e:
|
|
30
|
+
raise click.ClickException(f"target response shape invalid: {e}")
|
|
31
|
+
click.echo(f"OK — target {url} replied with {len(text)} chars")
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Which repo, branch and commit this run tests, from CI variables or git."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
from typing import Mapping
|
|
8
|
+
|
|
9
|
+
_REMOTE = re.compile(r"[:/]([^/:]+/[^/]+?)(?:\.git)?$")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _git(cwd: str | None, *args: str) -> str | None:
|
|
13
|
+
try:
|
|
14
|
+
out = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, timeout=5)
|
|
15
|
+
except (OSError, subprocess.SubprocessError):
|
|
16
|
+
return None
|
|
17
|
+
return out.stdout.strip() or None if out.returncode == 0 else None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def detect(env: Mapping[str, str] | None = None, cwd: str | None = None) -> dict[str, str]:
|
|
21
|
+
env = os.environ if env is None else env
|
|
22
|
+
ctx: dict[str, str] = {}
|
|
23
|
+
if env.get("GITHUB_REPOSITORY"):
|
|
24
|
+
ctx["repo"] = env["GITHUB_REPOSITORY"].lower()
|
|
25
|
+
branch = env.get("GITHUB_HEAD_REF") or env.get("GITHUB_REF_NAME")
|
|
26
|
+
if branch:
|
|
27
|
+
ctx["branch"] = branch
|
|
28
|
+
if env.get("GITHUB_SHA"):
|
|
29
|
+
ctx["commit"] = env["GITHUB_SHA"]
|
|
30
|
+
return ctx
|
|
31
|
+
remote = _git(cwd, "remote", "get-url", "origin")
|
|
32
|
+
if remote and (m := _REMOTE.search(remote)):
|
|
33
|
+
ctx["repo"] = m.group(1).lower()
|
|
34
|
+
branch = _git(cwd, "rev-parse", "--abbrev-ref", "HEAD")
|
|
35
|
+
if branch and branch != "HEAD":
|
|
36
|
+
ctx["branch"] = branch
|
|
37
|
+
commit = _git(cwd, "rev-parse", "HEAD")
|
|
38
|
+
if commit:
|
|
39
|
+
ctx["commit"] = commit
|
|
40
|
+
return ctx
|