model-fortress-cli 0.1.0__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.
- mf/__init__.py +3 -0
- mf/__main__.py +6 -0
- mf/cli.py +302 -0
- mf/client.py +88 -0
- model_fortress_cli-0.1.0.dist-info/METADATA +93 -0
- model_fortress_cli-0.1.0.dist-info/RECORD +9 -0
- model_fortress_cli-0.1.0.dist-info/WHEEL +5 -0
- model_fortress_cli-0.1.0.dist-info/entry_points.txt +2 -0
- model_fortress_cli-0.1.0.dist-info/top_level.txt +1 -0
mf/__init__.py
ADDED
mf/__main__.py
ADDED
mf/cli.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""`mf` — the Model Fortress CI/CD command line.
|
|
2
|
+
|
|
3
|
+
Scan or red-team a model from a pipeline and fail the build on a policy-gate breach.
|
|
4
|
+
|
|
5
|
+
mf scan --model-id <id> --tool modelscan # static scan + gate
|
|
6
|
+
mf redteam --model-id <id> --probes dan,encoding # red-team + gate
|
|
7
|
+
mf gate --scan-id <id> | --run-id <id> # verdict for an existing job
|
|
8
|
+
mf export --job-id <id> --format sarif -o out.sarif.json
|
|
9
|
+
mf models list
|
|
10
|
+
|
|
11
|
+
Config comes from flags or the environment:
|
|
12
|
+
MF_API_BASE e.g. https://app.au2mait.com/api (your instance's API base)
|
|
13
|
+
MF_TOKEN a Personal Access Token
|
|
14
|
+
MF_TENANT your tenant id
|
|
15
|
+
|
|
16
|
+
Exit codes: 0 = passed 1 = gate breached / job failed / timed out 2 = usage or transport error
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import sys
|
|
24
|
+
import time
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from .client import ApiError, Client, GateTimeout
|
|
28
|
+
from . import __version__
|
|
29
|
+
|
|
30
|
+
# Terminal statuses per job kind.
|
|
31
|
+
SCAN_TERMINAL = {"DONE", "FAILED", "COMPLETED", "SUCCESS", "CLEAN", "ERROR", "CANCELLED"}
|
|
32
|
+
RT_TERMINAL = {"DONE", "PARTIAL", "FAILED", "CANCELLED"}
|
|
33
|
+
|
|
34
|
+
EXT = {"sarif": "sarif.json", "json": "json", "csv": "csv", "pdf": "pdf"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _err(msg: str) -> None:
|
|
38
|
+
print(msg, file=sys.stderr)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _info(msg: str) -> None:
|
|
42
|
+
print(msg, file=sys.stderr)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _client(args: argparse.Namespace) -> Client:
|
|
46
|
+
return Client(
|
|
47
|
+
api_base=args.api_base,
|
|
48
|
+
token=args.token,
|
|
49
|
+
tenant=args.tenant,
|
|
50
|
+
timeout=args.http_timeout,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _poll(client: Client, path: str, status_key: str, terminal: set[str], timeout: int, interval: int) -> dict:
|
|
55
|
+
deadline = time.time() + timeout
|
|
56
|
+
last: Any = {}
|
|
57
|
+
while time.time() < deadline:
|
|
58
|
+
last = client.data(client.get(path))
|
|
59
|
+
status = str((last or {}).get(status_key, "")).upper()
|
|
60
|
+
if status in terminal:
|
|
61
|
+
return last
|
|
62
|
+
time.sleep(interval)
|
|
63
|
+
raise GateTimeout(f"timed out after {timeout}s waiting for {status_key} to finish")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _compliance_gate(client: Client, run_id: str, spec: str) -> int:
|
|
67
|
+
"""Fail (exit 1) if any required OWASP LLM category has findings.
|
|
68
|
+
`spec` is a comma/space list of category ids (e.g. "LLM01,LLM02") or "all"."""
|
|
69
|
+
rep = client.data(client.get(f"/model-governance/llm-red-teaming/{run_id}/compliance"))
|
|
70
|
+
cats = (
|
|
71
|
+
((rep or {}).get("frameworks") or {}).get("owasp_llm_top_10") or {}
|
|
72
|
+
).get("categories") or []
|
|
73
|
+
want = [s.strip().upper() for s in str(spec).replace(",", " ").split() if s.strip()]
|
|
74
|
+
all_mode = want == ["ALL"]
|
|
75
|
+
failed = [
|
|
76
|
+
str(c.get("id", "")).upper()
|
|
77
|
+
for c in cats
|
|
78
|
+
if str(c.get("status")) == "failed"
|
|
79
|
+
and (all_mode or str(c.get("id", "")).upper() in want)
|
|
80
|
+
]
|
|
81
|
+
if failed:
|
|
82
|
+
_err(f"FAIL — OWASP categories with findings: {', '.join(sorted(set(failed)))}")
|
|
83
|
+
return 1
|
|
84
|
+
_info(f"PASS — required OWASP categories clean ({spec})")
|
|
85
|
+
return 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _emit_gate(args: argparse.Namespace, verdict: dict, job_id: str, kind: str) -> int:
|
|
89
|
+
print(json.dumps(verdict, indent=2))
|
|
90
|
+
if not getattr(args, "gate", True):
|
|
91
|
+
return 0
|
|
92
|
+
gate = (verdict or {}).get("gate", {}) if isinstance(verdict, dict) else {}
|
|
93
|
+
if gate.get("passed"):
|
|
94
|
+
_info(f"PASS — {kind} {job_id}")
|
|
95
|
+
return 0
|
|
96
|
+
if not gate.get("evaluated"):
|
|
97
|
+
# No governing policy / nothing to gate on. Don't fail the build on its own,
|
|
98
|
+
# unless the job itself failed (passed will be False then too, handled below).
|
|
99
|
+
if gate.get("passed") is not False:
|
|
100
|
+
_info(f"PASS — {kind} {job_id} (no policy gate evaluated)")
|
|
101
|
+
return 0
|
|
102
|
+
reason = "gate breached" if gate.get("breached") else f"{kind} did not pass"
|
|
103
|
+
_err(f"FAIL — {reason} (fail_on={gate.get('fail_on')})")
|
|
104
|
+
return 1
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ------------------------------- commands -------------------------------
|
|
108
|
+
def cmd_scan(args: argparse.Namespace) -> int:
|
|
109
|
+
c = _client(args)
|
|
110
|
+
scan_id = args.scan_id
|
|
111
|
+
if not scan_id:
|
|
112
|
+
if not args.model_id:
|
|
113
|
+
_err("provide --scan-id (existing) or --model-id (to trigger a new scan)")
|
|
114
|
+
return 2
|
|
115
|
+
body: dict[str, Any] = {"scan_tool": args.tool}
|
|
116
|
+
if args.metadata:
|
|
117
|
+
body["scan_metadata"] = json.loads(args.metadata)
|
|
118
|
+
created = c.data(c.post(f"/model-governance/model/{args.model_id}/model-scan", body))
|
|
119
|
+
scan_id = created.get("scan_id")
|
|
120
|
+
_info(f"started scan {scan_id}")
|
|
121
|
+
if not args.wait:
|
|
122
|
+
print(scan_id)
|
|
123
|
+
return 0
|
|
124
|
+
verdict = _poll(
|
|
125
|
+
c,
|
|
126
|
+
f"/model-governance/model-scan/{scan_id}/gate",
|
|
127
|
+
status_key="scan_status",
|
|
128
|
+
terminal=SCAN_TERMINAL,
|
|
129
|
+
timeout=args.timeout,
|
|
130
|
+
interval=args.poll,
|
|
131
|
+
)
|
|
132
|
+
return _emit_gate(args, verdict, str(scan_id), "scan")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def cmd_redteam(args: argparse.Namespace) -> int:
|
|
136
|
+
c = _client(args)
|
|
137
|
+
run_id = args.run_id
|
|
138
|
+
if not run_id:
|
|
139
|
+
if not args.model_id:
|
|
140
|
+
_err("provide --run-id (existing) or --model-id (to trigger a new run)")
|
|
141
|
+
return 2
|
|
142
|
+
rto: dict[str, Any] = {}
|
|
143
|
+
if args.probes:
|
|
144
|
+
rto["probes"] = args.probes
|
|
145
|
+
if args.generations:
|
|
146
|
+
rto["generations"] = args.generations
|
|
147
|
+
metadata: dict[str, Any] = {"red_team_options": rto}
|
|
148
|
+
if args.metadata:
|
|
149
|
+
metadata.update(json.loads(args.metadata))
|
|
150
|
+
body = {"llm_red_teaming_tool": args.tool, "llm_red_teaming_metadata": metadata}
|
|
151
|
+
created = c.data(c.post(f"/model-governance/model/{args.model_id}/llm-red-teaming", body))
|
|
152
|
+
run_id = created.get("llm_red_teaming_id")
|
|
153
|
+
_info(f"started red-team run {run_id}")
|
|
154
|
+
if not args.wait:
|
|
155
|
+
print(run_id)
|
|
156
|
+
return 0
|
|
157
|
+
_poll(
|
|
158
|
+
c,
|
|
159
|
+
f"/model-governance/llm-red-teaming/{run_id}",
|
|
160
|
+
status_key="llm_red_teaming_status",
|
|
161
|
+
terminal=RT_TERMINAL,
|
|
162
|
+
timeout=args.timeout,
|
|
163
|
+
interval=args.poll,
|
|
164
|
+
)
|
|
165
|
+
verdict = c.data(c.get(f"/model-governance/llm-red-teaming/{run_id}/gate"))
|
|
166
|
+
rc = _emit_gate(args, verdict, str(run_id), "redteam")
|
|
167
|
+
if getattr(args, "require_pass", None):
|
|
168
|
+
rc = max(rc, _compliance_gate(c, str(run_id), args.require_pass))
|
|
169
|
+
return rc
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def cmd_gate(args: argparse.Namespace) -> int:
|
|
173
|
+
c = _client(args)
|
|
174
|
+
if args.scan_id:
|
|
175
|
+
v = c.data(c.get(f"/model-governance/model-scan/{args.scan_id}/gate"))
|
|
176
|
+
return _emit_gate(args, v, args.scan_id, "scan")
|
|
177
|
+
if args.run_id:
|
|
178
|
+
v = c.data(c.get(f"/model-governance/llm-red-teaming/{args.run_id}/gate"))
|
|
179
|
+
rc = _emit_gate(args, v, args.run_id, "redteam")
|
|
180
|
+
if getattr(args, "require_pass", None):
|
|
181
|
+
rc = max(rc, _compliance_gate(c, args.run_id, args.require_pass))
|
|
182
|
+
return rc
|
|
183
|
+
_err("provide --scan-id or --run-id")
|
|
184
|
+
return 2
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def cmd_export(args: argparse.Namespace) -> int:
|
|
188
|
+
c = _client(args)
|
|
189
|
+
raw = c.request("GET", f"/jobs/{args.job_id}/export", query=f"format={args.format}", raw=True)
|
|
190
|
+
out = args.output or f"{args.job_id}.{EXT.get(args.format, args.format)}"
|
|
191
|
+
with open(out, "wb") as f:
|
|
192
|
+
f.write(raw)
|
|
193
|
+
_info(f"wrote {out} ({len(raw)} bytes)")
|
|
194
|
+
print(json.dumps({"job_id": args.job_id, "format": args.format, "output": out, "bytes": len(raw)}))
|
|
195
|
+
return 0
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def cmd_models(args: argparse.Namespace) -> int:
|
|
199
|
+
c = _client(args)
|
|
200
|
+
resp = c.data(c.get("/model-governance/models", query="page=1&page_size=100"))
|
|
201
|
+
items = resp.get("items", []) if isinstance(resp, dict) else (resp or [])
|
|
202
|
+
if args.json:
|
|
203
|
+
print(json.dumps(items, indent=2))
|
|
204
|
+
return 0
|
|
205
|
+
for m in items:
|
|
206
|
+
print(
|
|
207
|
+
"\t".join(
|
|
208
|
+
[
|
|
209
|
+
str(m.get("model_id", "")),
|
|
210
|
+
str(m.get("model_name", "")),
|
|
211
|
+
str(m.get("model_source", "")),
|
|
212
|
+
str(m.get("model_upload_status", m.get("upload_status", ""))),
|
|
213
|
+
]
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
return 0
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# ------------------------------- parser -------------------------------
|
|
220
|
+
def _add_common(p: argparse.ArgumentParser) -> None:
|
|
221
|
+
p.add_argument("--api-base", default=os.environ.get("MF_API_BASE"), help="e.g. https://app.au2mait.com/api [env MF_API_BASE]")
|
|
222
|
+
p.add_argument("--token", default=os.environ.get("MF_TOKEN"), help="Personal Access Token [env MF_TOKEN]")
|
|
223
|
+
p.add_argument("--tenant", default=os.environ.get("MF_TENANT"), help="tenant id [env MF_TENANT]")
|
|
224
|
+
p.add_argument("--http-timeout", type=int, default=int(os.environ.get("MF_HTTP_TIMEOUT", "30")), help="per-request timeout seconds")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _add_wait(p: argparse.ArgumentParser) -> None:
|
|
228
|
+
p.add_argument("--wait", dest="wait", action="store_true", default=True, help="wait for the job to finish (default)")
|
|
229
|
+
p.add_argument("--no-wait", dest="wait", action="store_false", help="submit and print the id, don't wait")
|
|
230
|
+
p.add_argument("--gate", dest="gate", action="store_true", default=True, help="fail the build on a gate breach (default)")
|
|
231
|
+
p.add_argument("--no-gate", dest="gate", action="store_false", help="print the verdict but always exit 0")
|
|
232
|
+
p.add_argument("--timeout", type=int, default=1800, help="max seconds to wait for completion")
|
|
233
|
+
p.add_argument("--poll", type=int, default=5, help="seconds between status polls")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
237
|
+
ap = argparse.ArgumentParser(prog="mf", description="Model Fortress CI/CD client")
|
|
238
|
+
ap.add_argument("--version", action="version", version=f"mf {__version__}")
|
|
239
|
+
sub = ap.add_subparsers(dest="command", required=True)
|
|
240
|
+
|
|
241
|
+
p = sub.add_parser("scan", help="run a static scan and gate on its policy verdict")
|
|
242
|
+
_add_common(p); _add_wait(p)
|
|
243
|
+
p.add_argument("--model-id", help="model to scan (triggers a new scan)")
|
|
244
|
+
p.add_argument("--scan-id", help="gate on an existing scan instead")
|
|
245
|
+
p.add_argument("--tool", default="modelscan", help="scanner: modelscan | modelaudit | picklescan | gguf-scan")
|
|
246
|
+
p.add_argument("--metadata", help="extra scan_metadata as a JSON string")
|
|
247
|
+
p.set_defaults(func=cmd_scan)
|
|
248
|
+
|
|
249
|
+
p = sub.add_parser("redteam", help="run a red-team assessment and gate on its policy verdict")
|
|
250
|
+
_add_common(p); _add_wait(p)
|
|
251
|
+
p.add_argument("--model-id", help="model to red-team (triggers a new run)")
|
|
252
|
+
p.add_argument("--run-id", help="gate on an existing run instead")
|
|
253
|
+
p.add_argument("--tool", default="model_red_teaming", help="engine: model_red_teaming | openrt | pyrit | art")
|
|
254
|
+
p.add_argument("--probes", help="comma-separated probe spec, e.g. dan,encoding")
|
|
255
|
+
p.add_argument("--generations", type=int, help="generations per probe")
|
|
256
|
+
p.add_argument("--metadata", help="extra llm_red_teaming_metadata as a JSON string")
|
|
257
|
+
p.add_argument("--require-pass", help="fail if these OWASP LLM categories have findings, e.g. LLM01,LLM02 (or 'all')")
|
|
258
|
+
p.set_defaults(func=cmd_redteam)
|
|
259
|
+
|
|
260
|
+
p = sub.add_parser("gate", help="fetch the policy-gate verdict for an existing job")
|
|
261
|
+
_add_common(p)
|
|
262
|
+
p.add_argument("--scan-id")
|
|
263
|
+
p.add_argument("--run-id")
|
|
264
|
+
p.add_argument("--require-pass", help="(run-id only) fail if these OWASP LLM categories have findings, e.g. LLM01,LLM02 (or 'all')")
|
|
265
|
+
p.set_defaults(func=cmd_gate, gate=True)
|
|
266
|
+
|
|
267
|
+
p = sub.add_parser("export", help="download a job's findings (SARIF/JSON/CSV/PDF)")
|
|
268
|
+
_add_common(p)
|
|
269
|
+
p.add_argument("--job-id", required=True, help="a scan id or red-team run id")
|
|
270
|
+
p.add_argument("--format", default="sarif", choices=["sarif", "json", "csv", "pdf"])
|
|
271
|
+
p.add_argument("-o", "--output", help="output file (default <job-id>.<ext>)")
|
|
272
|
+
p.set_defaults(func=cmd_export)
|
|
273
|
+
|
|
274
|
+
p = sub.add_parser("models", help="model inventory")
|
|
275
|
+
psub = p.add_subparsers(dest="models_command", required=True)
|
|
276
|
+
pl = psub.add_parser("list", help="list models")
|
|
277
|
+
_add_common(pl)
|
|
278
|
+
pl.add_argument("--json", action="store_true", help="output JSON")
|
|
279
|
+
pl.set_defaults(func=cmd_models)
|
|
280
|
+
|
|
281
|
+
return ap
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def main(argv: list[str] | None = None) -> int:
|
|
285
|
+
args = build_parser().parse_args(argv)
|
|
286
|
+
try:
|
|
287
|
+
return int(args.func(args))
|
|
288
|
+
except GateTimeout as e:
|
|
289
|
+
_err(str(e))
|
|
290
|
+
return 1
|
|
291
|
+
except ApiError as e:
|
|
292
|
+
_err(str(e))
|
|
293
|
+
return 2
|
|
294
|
+
except ValueError as e:
|
|
295
|
+
_err(f"bad --metadata JSON: {e}")
|
|
296
|
+
return 2
|
|
297
|
+
except KeyboardInterrupt:
|
|
298
|
+
return 130
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
if __name__ == "__main__":
|
|
302
|
+
sys.exit(main())
|
mf/client.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Minimal, dependency-free HTTP client for the Model Fortress API.
|
|
2
|
+
|
|
3
|
+
Stdlib only (urllib) so it runs in any CI image without `pip install` of extras.
|
|
4
|
+
Auth is a Personal Access Token sent as a Bearer token. The base URL points at the
|
|
5
|
+
customer's own instance (SaaS at au2mait.com, or a marketplace-provisioned tenant) —
|
|
6
|
+
nothing here is cloud-specific.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import urllib.error
|
|
12
|
+
import urllib.request
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ApiError(Exception):
|
|
17
|
+
"""Transport / HTTP / usage error (maps to exit code 2)."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, message: str, *, code: int | None = None) -> None:
|
|
20
|
+
super().__init__(message)
|
|
21
|
+
self.code = code
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class GateTimeout(Exception):
|
|
25
|
+
"""Timed out waiting for a job to finish (maps to exit code 1 — build fails)."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Client:
|
|
29
|
+
def __init__(self, api_base: str, token: str, tenant: str, timeout: int = 30) -> None:
|
|
30
|
+
if not api_base:
|
|
31
|
+
raise ApiError("API base URL is required (--api-base or MF_API_BASE)")
|
|
32
|
+
if not token:
|
|
33
|
+
raise ApiError("a Personal Access Token is required (--token or MF_TOKEN)")
|
|
34
|
+
if not tenant:
|
|
35
|
+
raise ApiError("a tenant id is required (--tenant or MF_TENANT)")
|
|
36
|
+
self.base = api_base.rstrip("/")
|
|
37
|
+
self.token = token
|
|
38
|
+
self.tenant = tenant
|
|
39
|
+
self.timeout = timeout
|
|
40
|
+
|
|
41
|
+
def _url(self, path: str, query: str = "") -> str:
|
|
42
|
+
url = f"{self.base}/tenant/{self.tenant}{path}"
|
|
43
|
+
return f"{url}?{query}" if query else url
|
|
44
|
+
|
|
45
|
+
def request(
|
|
46
|
+
self,
|
|
47
|
+
method: str,
|
|
48
|
+
path: str,
|
|
49
|
+
body: dict | None = None,
|
|
50
|
+
query: str = "",
|
|
51
|
+
raw: bool = False,
|
|
52
|
+
) -> Any:
|
|
53
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
54
|
+
headers = {"Authorization": f"Bearer {self.token}", "Accept": "*/*"}
|
|
55
|
+
if data is not None:
|
|
56
|
+
headers["Content-Type"] = "application/json"
|
|
57
|
+
req = urllib.request.Request(
|
|
58
|
+
self._url(path, query), data=data, headers=headers, method=method
|
|
59
|
+
)
|
|
60
|
+
try:
|
|
61
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
62
|
+
payload = resp.read()
|
|
63
|
+
except urllib.error.HTTPError as e:
|
|
64
|
+
detail = e.read().decode("utf-8", "ignore")[:400]
|
|
65
|
+
raise ApiError(f"HTTP {e.code} on {method} {path}: {detail}", code=e.code)
|
|
66
|
+
except urllib.error.URLError as e:
|
|
67
|
+
raise ApiError(f"could not reach {self.base}: {e.reason}")
|
|
68
|
+
if raw:
|
|
69
|
+
return payload
|
|
70
|
+
if not payload:
|
|
71
|
+
return {}
|
|
72
|
+
try:
|
|
73
|
+
return json.loads(payload.decode())
|
|
74
|
+
except ValueError:
|
|
75
|
+
raise ApiError("unexpected non-JSON response from the API")
|
|
76
|
+
|
|
77
|
+
def get(self, path: str, query: str = "") -> Any:
|
|
78
|
+
return self.request("GET", path, query=query)
|
|
79
|
+
|
|
80
|
+
def post(self, path: str, body: dict | None = None) -> Any:
|
|
81
|
+
return self.request("POST", path, body=body)
|
|
82
|
+
|
|
83
|
+
@staticmethod
|
|
84
|
+
def data(resp: Any) -> Any:
|
|
85
|
+
"""Unwrap the API's { "data": ... } envelope when present."""
|
|
86
|
+
if isinstance(resp, dict) and "data" in resp:
|
|
87
|
+
return resp["data"]
|
|
88
|
+
return resp
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: model-fortress-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Model Fortress CI/CD client — scan & red-team your models from a pipeline and fail the build on a policy-gate breach.
|
|
5
|
+
License: Proprietary
|
|
6
|
+
Keywords: model-fortress,ai-security,ci,sarif,red-teaming,model-scanning
|
|
7
|
+
Requires-Python: >=3.8
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# Model Fortress — CI/CD & API
|
|
11
|
+
|
|
12
|
+
Scan and red-team your models from a pipeline and **fail the build on a policy-gate
|
|
13
|
+
breach**. Ships as a small, dependency-free Python CLI (`mf`) over the public API.
|
|
14
|
+
|
|
15
|
+
Cloud-agnostic by design: the CLI talks to **your instance's API base URL** with a
|
|
16
|
+
Personal Access Token, so the same pipeline works whether you bought Model Fortress on
|
|
17
|
+
the **Azure, AWS or GCP marketplace** — only `MF_API_BASE` changes.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install ./backend/ci # from this repo
|
|
23
|
+
# or, once published: pip install model-fortress-cli
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The CLI is stdlib-only (no third-party deps), so it runs in any CI image.
|
|
27
|
+
|
|
28
|
+
## Configure
|
|
29
|
+
|
|
30
|
+
| Env | Flag | Meaning |
|
|
31
|
+
|-----|------|---------|
|
|
32
|
+
| `MF_API_BASE` | `--api-base` | your instance's API base, e.g. `https://app.au2mait.com/api` |
|
|
33
|
+
| `MF_TOKEN` | `--token` | a Personal Access Token (create one in the app → settings) |
|
|
34
|
+
| `MF_TENANT` | `--tenant` | your tenant id |
|
|
35
|
+
|
|
36
|
+
## Commands
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# Static scan: submit, wait, fail on the policy gate
|
|
40
|
+
mf scan --model-id <id> --tool modelscan # modelscan | modelaudit | picklescan | gguf-scan
|
|
41
|
+
|
|
42
|
+
# Red-team: submit, wait, fail on the policy gate
|
|
43
|
+
mf redteam --model-id <id> --probes dan,encoding --generations 5
|
|
44
|
+
|
|
45
|
+
# Verdict for an existing job (no wait)
|
|
46
|
+
mf gate --scan-id <id>
|
|
47
|
+
mf gate --run-id <id>
|
|
48
|
+
|
|
49
|
+
# Download findings (SARIF for code-scanning dashboards, or JSON/CSV/PDF)
|
|
50
|
+
mf export --job-id <id> --format sarif -o mf.sarif.json
|
|
51
|
+
|
|
52
|
+
# Inventory
|
|
53
|
+
mf models list
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Useful flags on `scan` / `redteam`: `--no-wait` (submit and print the id),
|
|
57
|
+
`--no-gate` (print the verdict but never fail), `--timeout` (max wait, default 1800s),
|
|
58
|
+
`--poll` (seconds between polls, default 5), `--metadata '<json>'`.
|
|
59
|
+
|
|
60
|
+
## Exit codes
|
|
61
|
+
|
|
62
|
+
| Code | Meaning |
|
|
63
|
+
|------|---------|
|
|
64
|
+
| `0` | gate passed (or `--no-gate`) |
|
|
65
|
+
| `1` | **gate breached, job failed, or timed out** — fail the build |
|
|
66
|
+
| `2` | usage or transport error (bad flags, unreachable API, auth) |
|
|
67
|
+
|
|
68
|
+
The gate is **fail-closed**: a failed scan/run never passes, a breach fails the build,
|
|
69
|
+
and a job with no governing policy doesn't fail on its own.
|
|
70
|
+
|
|
71
|
+
## The gate endpoints
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
GET /tenant/<t>/model-governance/model-scan/<scan_id>/gate
|
|
75
|
+
GET /tenant/<t>/model-governance/llm-red-teaming/<run_id>/gate
|
|
76
|
+
```
|
|
77
|
+
both return `{ ..._status, policy_id, policy_name, gate: { evaluated, fail_on, breached, passed } }`.
|
|
78
|
+
Auth: `Authorization: Bearer <PAT>`. The "fail on <severity>" threshold comes from the
|
|
79
|
+
Scan Policy governing that model.
|
|
80
|
+
|
|
81
|
+
## Pipeline examples
|
|
82
|
+
|
|
83
|
+
- `examples/github-actions.yml` — PR gate + SARIF upload to the Security tab
|
|
84
|
+
- `examples/gitlab-ci.yml` — MR gate + SAST report
|
|
85
|
+
- `examples/azure-pipelines.yml`
|
|
86
|
+
- `examples/generic.sh` — Jenkins / CircleCI / cron
|
|
87
|
+
|
|
88
|
+
## Notes
|
|
89
|
+
|
|
90
|
+
- Create a **Personal Access Token** per pipeline and store it as a CI secret; scope it
|
|
91
|
+
to the tenant you're gating.
|
|
92
|
+
- `mf_scan_gate.py` (the earlier single-file script) still works and is kept for
|
|
93
|
+
back-compat, but the `mf` CLI supersedes it and adds red-teaming, export and inventory.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
mf/__init__.py,sha256=5ROQqLBChXb6BM15HQyCpCf2hJ4TuJoqrizW1fSb9JY,92
|
|
2
|
+
mf/__main__.py,sha256=4JMK66Wj4uLZTKbF-sT3LAxOsr6buig77PmOkJCRRxw,83
|
|
3
|
+
mf/cli.py,sha256=-VyJ4qKDJAW1b_8wMAz-P8lEm_BJTWnTy1ABbtY9Xns,12370
|
|
4
|
+
mf/client.py,sha256=oDB77u6mJC0hVdE0We2Q1PXtzKILhlRLXtYD9A6ynaM,3200
|
|
5
|
+
model_fortress_cli-0.1.0.dist-info/METADATA,sha256=fbS0X7K43UiwAZakzHyCnUBCkR-vXwV3VO95P_DJ_Pc,3376
|
|
6
|
+
model_fortress_cli-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
7
|
+
model_fortress_cli-0.1.0.dist-info/entry_points.txt,sha256=LJAmno7KOAoWo4PTgQ-0Q0-zpoOQ74uiSd97hyoRwcE,35
|
|
8
|
+
model_fortress_cli-0.1.0.dist-info/top_level.txt,sha256=XehwrO1-bBgqEOAy6U3hWJPJXt2A2cwgNIsPFieCbZM,3
|
|
9
|
+
model_fortress_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mf
|