maskflow-cli 0.9.0__tar.gz → 0.10.0__tar.gz
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.
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/PKG-INFO +3 -1
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/pyproject.toml +11 -1
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/app.py +2 -0
- maskflow_cli-0.10.0/src/maskflow_cli/audit_render.py +208 -0
- maskflow_cli-0.10.0/src/maskflow_cli/commands/audit_cmd.py +233 -0
- maskflow_cli-0.10.0/tests/audit/test_audit_extra_is_optional.py +69 -0
- maskflow_cli-0.10.0/tests/audit/test_cli_audit.py +342 -0
- maskflow_cli-0.10.0/tests/scan/__init__.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/tests/test_cli_agent.py +1 -1
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/.dockerignore +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/.gitignore +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/Dockerfile +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/README.md +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/examples/README.md +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/examples/generate_sample.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/examples/sample-llm-traffic.jsonl +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/packaging/README.md +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/packaging/_entry.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/packaging/maskflow.spec +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/__init__.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/bench_render.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/commands/__init__.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/commands/agent_cmd.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/commands/bench_cmd.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/commands/config_cmd.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/commands/doctor_cmd.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/commands/explain_cmd.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/doctor.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/doctor_render.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/evidence.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/explain.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/explain_render.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/render.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/__init__.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/aggregate.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/checkpoint.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/cmd.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/errors.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/fieldsel.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/pipeline.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/report/__init__.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/report/assets.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/report/build.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/report/csv_out.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/report/html.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/report/json_out.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/report/summary.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/severity.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/sources/__init__.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/sources/_api_common.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/sources/_files.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/sources/_http.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/sources/_meta.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/sources/base.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/sources/csv.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/sources/dir.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/sources/helicone.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/sources/jsonl.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/sources/langfuse.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/sources/langsmith.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/sources/postgres.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/sources/s3.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/spec.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/worker.py +0 -0
- {maskflow_cli-0.9.0/tests/scan → maskflow_cli-0.10.0/tests/audit}/__init__.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/tests/conftest.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/tests/fixtures/partial.toml +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/tests/fixtures/typo.toml +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/tests/fixtures/valid.toml +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/tests/scan/_fuzz_corpus.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/tests/scan/conftest.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/tests/scan/test_aggregate_and_severity.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/tests/scan/test_example_file.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/tests/scan/test_fieldsel.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/tests/scan/test_pipeline_and_report.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/tests/scan/test_report_no_pii_leak.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/tests/scan/test_sources_api.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/tests/scan/test_sources_files.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/tests/test_cli_bench.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/tests/test_cli_doctor.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/tests/test_cli_explain.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/tests/test_cli_pack_registration.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/tests/test_cli_show.py +0 -0
- {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/tests/test_cli_validate.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: maskflow-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.0
|
|
4
4
|
Summary: Command-line interface for MaskFlow: .maskflowrc config validation and inspection
|
|
5
5
|
License: MIT
|
|
6
6
|
Requires-Python: >=3.10
|
|
@@ -13,6 +13,8 @@ Requires-Dist: tomli-w>=1.0
|
|
|
13
13
|
Requires-Dist: typer>=0.12
|
|
14
14
|
Provides-Extra: agent
|
|
15
15
|
Requires-Dist: maskflow-agent<0.2,>=0.1.0; extra == 'agent'
|
|
16
|
+
Provides-Extra: audit
|
|
17
|
+
Requires-Dist: maskflow-audit<0.2,>=0.1.0; extra == 'audit'
|
|
16
18
|
Provides-Extra: bench
|
|
17
19
|
Requires-Dist: maskflow-bench<0.2,>=0.1.0; extra == 'bench'
|
|
18
20
|
Provides-Extra: dev
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "maskflow-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.10.0"
|
|
4
4
|
description = "Command-line interface for MaskFlow: .maskflowrc config validation and inspection"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
@@ -86,6 +86,15 @@ postgres = [
|
|
|
86
86
|
bench = [
|
|
87
87
|
"maskflow-bench>=0.1.0,<0.2",
|
|
88
88
|
]
|
|
89
|
+
# `maskflow audit` -- static discovery of LLM egress paths plus (from M2) the
|
|
90
|
+
# canary harness that proves which of them leak. Same policy as [bench] and
|
|
91
|
+
# [evidence] above -- opt-in so a bare `pip install maskflow-cli` (and the
|
|
92
|
+
# standalone PyInstaller binary) doesn't carry an AST analyser, a PyYAML
|
|
93
|
+
# dependency, and eventually an interceptor; audit_cmd.py imports it lazily
|
|
94
|
+
# and every other command runs without it.
|
|
95
|
+
audit = [
|
|
96
|
+
"maskflow-audit>=0.1.0,<0.2",
|
|
97
|
+
]
|
|
89
98
|
# `maskflow agent install|uninstall|doctor` -- passthrough to maskflow-agent's
|
|
90
99
|
# own console script (Claude Code PII guard). Lazy-imported by agent_cmd.py.
|
|
91
100
|
agent = [
|
|
@@ -94,6 +103,7 @@ agent = [
|
|
|
94
103
|
|
|
95
104
|
[tool.uv.sources]
|
|
96
105
|
maskflow-agent = { workspace = true }
|
|
106
|
+
maskflow-audit = { workspace = true }
|
|
97
107
|
maskflow-core = { workspace = true }
|
|
98
108
|
maskflow-pack-intl = { workspace = true }
|
|
99
109
|
maskflow-pack-india = { workspace = true }
|
|
@@ -12,6 +12,7 @@ import maskflow_pack_india # noqa: F401 -- import side effect registers pack-in
|
|
|
12
12
|
import typer
|
|
13
13
|
|
|
14
14
|
from .commands.agent_cmd import agent
|
|
15
|
+
from .commands.audit_cmd import audit
|
|
15
16
|
from .commands.bench_cmd import bench
|
|
16
17
|
from .commands.config_cmd import app as config_app
|
|
17
18
|
from .commands.doctor_cmd import doctor
|
|
@@ -24,6 +25,7 @@ app.command("doctor")(doctor)
|
|
|
24
25
|
app.command("explain")(explain)
|
|
25
26
|
app.command("scan")(scan)
|
|
26
27
|
app.command("bench")(bench)
|
|
28
|
+
app.command("audit")(audit)
|
|
27
29
|
app.command(
|
|
28
30
|
"agent",
|
|
29
31
|
context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Rich rendering for `maskflow audit`. Kept separate from
|
|
2
|
+
commands/audit_cmd.py so the command body stays thin, same split as
|
|
3
|
+
doctor.py/doctor_render.py and bench_render.py.
|
|
4
|
+
|
|
5
|
+
Only type-checked against maskflow-audit (TYPE_CHECKING guard below,
|
|
6
|
+
`from __future__ import annotations` keeps every annotation an unevaluated
|
|
7
|
+
string at runtime) -- this module never constructs an AuditSummary itself,
|
|
8
|
+
only duck-types over one handed in by audit_cmd.py, so it never needs the
|
|
9
|
+
optional [audit] extra installed just to be imported.
|
|
10
|
+
|
|
11
|
+
Nothing here reads a value: an AuditSummary carries identifiers and counts
|
|
12
|
+
only (see maskflow_audit.report.summary's docstring), so this renderer
|
|
13
|
+
cannot print PII even by accident."""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
from rich.console import Console
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from maskflow_audit.baseline.diff import CiVerdict
|
|
23
|
+
from maskflow_audit.fix.loop import FixReport
|
|
24
|
+
from maskflow_audit.report.summary import AuditSummary
|
|
25
|
+
|
|
26
|
+
# Same rationale as doctor_render.py's: a fixed width so output doesn't
|
|
27
|
+
# wrap unpredictably under a non-tty (CI logs, CliRunner in tests).
|
|
28
|
+
CONSOLE_WIDTH = 100
|
|
29
|
+
|
|
30
|
+
#: Longest source list shown per flow before it is summarised. The full
|
|
31
|
+
#: list is always in audit.json; the table is a triage view, and a cell
|
|
32
|
+
#: that folds character-by-character (which is what happens when five
|
|
33
|
+
#: column names meet a long fixture path at a fixed console width) is
|
|
34
|
+
#: worse than no cell at all.
|
|
35
|
+
MAX_SOURCES_SHOWN = 2
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
#: How a status reads in the terminal, and the colour it reads in.
|
|
39
|
+
_STATUS_STYLE: dict[str, tuple[str, str]] = {
|
|
40
|
+
"unmasked": ("UNMASKED", "bold red"),
|
|
41
|
+
"unknown": ("unknown", "yellow"),
|
|
42
|
+
"masked_via_mask_call": ("masked: mask()", "green"),
|
|
43
|
+
"masked_via_gateway_base_url": ("masked: gateway", "green"),
|
|
44
|
+
"masked_via_integration": ("masked: integration", "green"),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
#: A proven leak overrides the static status entirely -- it is the one
|
|
48
|
+
#: line in the output that is evidence rather than inference.
|
|
49
|
+
_LEAK_STYLE = ("LEAK PROVEN", "bold white on red")
|
|
50
|
+
|
|
51
|
+
#: What a flow's canary verdict adds to its detail line. `not_exercised`
|
|
52
|
+
#: is spelled out because it is the one a reader is most likely to
|
|
53
|
+
#: misread as safe.
|
|
54
|
+
_CANARY_NOTE: dict[str, str] = {
|
|
55
|
+
"escaped": "canary escaped",
|
|
56
|
+
"contained": "canary contained",
|
|
57
|
+
"not_exercised": "not exercised by the run",
|
|
58
|
+
"not_run": "",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _sources(flow: object) -> str:
|
|
63
|
+
"""A compact source summary: at most MAX_SOURCES_SHOWN, then a count."""
|
|
64
|
+
sources = flow.sources # type: ignore[attr-defined]
|
|
65
|
+
shown = [f"{s.kind}:{s.detail}" if s.detail else s.kind for s in sources]
|
|
66
|
+
if not shown:
|
|
67
|
+
return ""
|
|
68
|
+
if len(shown) <= MAX_SOURCES_SHOWN:
|
|
69
|
+
return ", ".join(shown)
|
|
70
|
+
return ", ".join(shown[:MAX_SOURCES_SHOWN]) + f" (+{len(shown) - MAX_SOURCES_SHOWN})"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def make_console(*, stderr: bool = True) -> Console:
|
|
74
|
+
"""Human output goes to stderr so `--out -` stays pipeable."""
|
|
75
|
+
return Console(width=CONSOLE_WIDTH, stderr=stderr, highlight=False, soft_wrap=False)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def render_summary(console: Console, summary: AuditSummary) -> None:
|
|
79
|
+
"""The headline, the flow table, and the honest caveat."""
|
|
80
|
+
coverage = summary.coverage
|
|
81
|
+
console.print()
|
|
82
|
+
console.print(f"[bold]{summary.headline}[/bold]")
|
|
83
|
+
|
|
84
|
+
if coverage.total_flows == 0:
|
|
85
|
+
console.print(
|
|
86
|
+
"[yellow]No LLM egress paths found.[/yellow] That means none of the sinks in "
|
|
87
|
+
"the registry appear in this tree -- not that none exist. See the limits note "
|
|
88
|
+
"in the report."
|
|
89
|
+
)
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
parts = []
|
|
93
|
+
if coverage.confirmed_leaks:
|
|
94
|
+
# Listed first and loudest: it is the only number here that is
|
|
95
|
+
# evidence rather than inference.
|
|
96
|
+
parts.append(f"[bold white on red] {coverage.confirmed_leaks} PROVEN LEAK(S) [/]")
|
|
97
|
+
if coverage.unmasked:
|
|
98
|
+
parts.append(f"[bold red]{coverage.unmasked} unmasked[/bold red]")
|
|
99
|
+
if coverage.unknown:
|
|
100
|
+
parts.append(f"[yellow]{coverage.unknown} unknown[/yellow]")
|
|
101
|
+
if coverage.masked:
|
|
102
|
+
parts.append(f"[green]{coverage.masked} masked[/green]")
|
|
103
|
+
console.print(" · ".join(parts))
|
|
104
|
+
console.print()
|
|
105
|
+
|
|
106
|
+
# Deliberately not a rich Table. At a fixed console width a table
|
|
107
|
+
# redistributes space between columns, so one long repo-relative path
|
|
108
|
+
# starves its neighbours until a cell folds one character per line --
|
|
109
|
+
# which is what a real audit of a real tree produces. A two-line block
|
|
110
|
+
# per flow stays readable at any width and never truncates a path.
|
|
111
|
+
for flow in summary.flows:
|
|
112
|
+
if flow.canary_status == "escaped":
|
|
113
|
+
label, style = _LEAK_STYLE
|
|
114
|
+
else:
|
|
115
|
+
label, style = _STATUS_STYLE.get(flow.masking_status, (flow.masking_status, ""))
|
|
116
|
+
badge = f"[{style}]{label:<20}[/{style}]" if style else f"{label:<20}"
|
|
117
|
+
console.print(
|
|
118
|
+
f"{badge} {flow.file}:{flow.line} [dim]{flow.sink_id} · {flow.flow_id}[/dim]"
|
|
119
|
+
)
|
|
120
|
+
sources = _sources(flow)
|
|
121
|
+
detail = f"from {sources}" if sources else "source not traced"
|
|
122
|
+
note = _CANARY_NOTE.get(flow.canary_status, "")
|
|
123
|
+
suffix = f" · {note}" if note else ""
|
|
124
|
+
console.print(f"{'':<20} [dim]{detail} · conf: {flow.confidence}{suffix}[/dim]")
|
|
125
|
+
for escape in flow.canary_escapes[:1]:
|
|
126
|
+
entities = ", ".join(sorted({e.entity_type for e in flow.canary_escapes}))
|
|
127
|
+
console.print(
|
|
128
|
+
f"{'':<20} [red]canary {entities} reached {escape.host}{escape.path}"
|
|
129
|
+
f" ({escape.form})[/red]"
|
|
130
|
+
)
|
|
131
|
+
for frame in escape.app_stack[-3:]:
|
|
132
|
+
console.print(f"{'':<22} [dim]{frame}[/dim]")
|
|
133
|
+
|
|
134
|
+
run = summary.canary_run
|
|
135
|
+
if not summary.scope.canaries_run:
|
|
136
|
+
console.print(
|
|
137
|
+
"[dim]Canaries were not run, so every finding above is predicted, not "
|
|
138
|
+
"proven. Re-run with --prove to confirm which paths actually leak.[/dim]"
|
|
139
|
+
)
|
|
140
|
+
elif run is not None:
|
|
141
|
+
console.print(
|
|
142
|
+
f"[dim]Canary run: seed {run.seed} · {run.requests_intercepted} request(s) "
|
|
143
|
+
f"intercepted (none sent) · {run.escapes_observed} escape(s) observed.[/dim]"
|
|
144
|
+
)
|
|
145
|
+
if run.unattributed_escapes:
|
|
146
|
+
# Discovery missed a sink. This is the most useful bug report
|
|
147
|
+
# the tool can produce about itself, so it is asked for.
|
|
148
|
+
console.print(
|
|
149
|
+
f"[yellow]{run.unattributed_escapes} escape(s) matched no discovered "
|
|
150
|
+
"flow -- a canary got out through a path static analysis did not find. "
|
|
151
|
+
"Please report it.[/yellow]"
|
|
152
|
+
)
|
|
153
|
+
if run.note:
|
|
154
|
+
console.print(f"[yellow]{run.note}[/yellow]")
|
|
155
|
+
for warning in run.warnings:
|
|
156
|
+
console.print(f"[yellow]--allow-env override: {warning}[/yellow]")
|
|
157
|
+
if summary.skipped_files:
|
|
158
|
+
console.print(
|
|
159
|
+
f"[dim]{len(summary.skipped_files)} file(s) skipped (unparseable or "
|
|
160
|
+
f"unreadable); see skipped_files in the report.[/dim]"
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def render_ci(console: Console, verdict: CiVerdict, summary: AuditSummary) -> None:
|
|
165
|
+
"""The `--ci` gate's output: short, and PR-friendly.
|
|
166
|
+
|
|
167
|
+
GitHub Actions workflow commands (`::error::`, `::notice::`) so a
|
|
168
|
+
failure annotates the run rather than hiding in a log, matching the
|
|
169
|
+
scan action's behaviour.
|
|
170
|
+
"""
|
|
171
|
+
if verdict.skipped:
|
|
172
|
+
console.print(f"[yellow]{verdict.summary_line()}[/yellow]")
|
|
173
|
+
console.print(f"::notice::{verdict.summary_line()}")
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
if verdict.ok:
|
|
177
|
+
console.print(f"[green]{verdict.summary_line()}[/green]")
|
|
178
|
+
console.print(f"::notice::{summary.headline}")
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
console.print(f"[bold red]{verdict.summary_line()}[/bold red]\n")
|
|
182
|
+
for problem in verdict.problems:
|
|
183
|
+
console.print(f" [red]•[/red] {problem}")
|
|
184
|
+
console.print(f"\n::error::{verdict.summary_line()}")
|
|
185
|
+
console.print(
|
|
186
|
+
"[dim]If these paths are intentional, re-run `maskflow audit --baseline` "
|
|
187
|
+
"and commit the updated baseline.[/dim]"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def render_fix(console: Console, report: FixReport) -> None:
|
|
192
|
+
"""What the fix loop did, or why it did nothing."""
|
|
193
|
+
if not report.model_available:
|
|
194
|
+
console.print(f"\n[yellow]{report.note}[/yellow]")
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
console.print()
|
|
198
|
+
for result in report.fixed:
|
|
199
|
+
gates = result.attempts[-1].gates
|
|
200
|
+
console.print(f"[green]FIXED[/green] {result.file}:{result.line} ({result.flow_id})")
|
|
201
|
+
if gates is not None:
|
|
202
|
+
console.print(f"{'':<6}[dim]{gates.summary()}[/dim]")
|
|
203
|
+
for result in report.unfixed:
|
|
204
|
+
console.print(f"[yellow]NOT FIXED[/yellow] {result.file}:{result.line}")
|
|
205
|
+
console.print(f"{'':<10}[dim]{result.reason}[/dim]")
|
|
206
|
+
if report.fixed:
|
|
207
|
+
console.print("\n[dim]Suggested PR body:[/dim]")
|
|
208
|
+
console.print(report.pr_body())
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""`maskflow audit <path>` -- find every place a codebase sends text to an
|
|
2
|
+
LLM, prove which of those paths leak, and fix them.
|
|
3
|
+
|
|
4
|
+
`maskflow scan` asks what PII already reached a provider, from logs. This
|
|
5
|
+
asks the forward question, from source: where can text leave, and is it
|
|
6
|
+
guarded? With `--prove` it stops guessing -- it seeds synthetic identities,
|
|
7
|
+
runs the project, intercepts every provider-bound request before it leaves
|
|
8
|
+
the process, and reports which canary reached which host from which line.
|
|
9
|
+
|
|
10
|
+
`maskflow-audit` is an **optional** dependency (`pip install
|
|
11
|
+
'maskflow-cli[audit]'`) -- imported lazily here, same policy as
|
|
12
|
+
`maskflow_cli.evidence` and `bench_cmd.py`, so a bare CLI install (and the
|
|
13
|
+
standalone PyInstaller binary) doesn't require it and every other command
|
|
14
|
+
runs without it.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
import typer
|
|
23
|
+
|
|
24
|
+
from ..audit_render import make_console, render_ci, render_fix, render_summary
|
|
25
|
+
|
|
26
|
+
_MISSING = (
|
|
27
|
+
"`maskflow audit` needs the audit extra -- install it with: pip install 'maskflow-cli[audit]'"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
_EVIDENCE_MISSING = (
|
|
31
|
+
"--evidence needs the evidence extra too -- install it with: "
|
|
32
|
+
"pip install 'maskflow-cli[audit,evidence]'"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# Ruff B008: ruff allows a bare typer.Argument()/Option() in a default, but
|
|
36
|
+
# not a nested call inside one (the Path(".") below), so the argument and
|
|
37
|
+
# the list/Path-defaulted options are module-level singletons -- same
|
|
38
|
+
# pattern as scan/cmd.py's and explain_cmd.py's.
|
|
39
|
+
_PATH_ARGUMENT = typer.Argument(Path("."), help="Directory or file to audit.")
|
|
40
|
+
_OUT_OPTION = typer.Option(
|
|
41
|
+
None, "--out", "-o", help="Write the report here ('-' for stdout). Default: audit.<format>."
|
|
42
|
+
)
|
|
43
|
+
_FORMAT_OPTION = typer.Option("json", "--format", help="json | html.")
|
|
44
|
+
_EXCLUDE_OPTION = typer.Option(
|
|
45
|
+
[], "--exclude", help="Directory name to skip, repeatable. Adds to the defaults."
|
|
46
|
+
)
|
|
47
|
+
_PROVE_OPTION = typer.Option(
|
|
48
|
+
False,
|
|
49
|
+
"--prove",
|
|
50
|
+
help="Seed canaries, run the project, and confirm which paths actually leak.",
|
|
51
|
+
)
|
|
52
|
+
_FLOWS_OPTION = typer.Option(
|
|
53
|
+
None,
|
|
54
|
+
"--flows",
|
|
55
|
+
help="Command --prove should run (default: python -m pytest -q).",
|
|
56
|
+
)
|
|
57
|
+
_SEED_OPTION = typer.Option(
|
|
58
|
+
None, "--seed", help="Canary seed, for reproducing a run. Default: fresh each time."
|
|
59
|
+
)
|
|
60
|
+
_IDENTITIES_OPTION = typer.Option(
|
|
61
|
+
1, "--identities", min=1, help="How many synthetic people to seed."
|
|
62
|
+
)
|
|
63
|
+
_ALLOW_ENV_OPTION = typer.Option(
|
|
64
|
+
False,
|
|
65
|
+
"--allow-env",
|
|
66
|
+
help="Seed even though the environment looks like a real deployment.",
|
|
67
|
+
)
|
|
68
|
+
_BASELINE_OPTION = typer.Option(
|
|
69
|
+
False, "--baseline", help="Write maskflow-audit.baseline.json and exit."
|
|
70
|
+
)
|
|
71
|
+
_CI_OPTION = typer.Option(
|
|
72
|
+
False, "--ci", help="Fail the build on a new unmasked path or a new confirmed leak."
|
|
73
|
+
)
|
|
74
|
+
_BASELINE_FILE_OPTION = typer.Option(
|
|
75
|
+
None, "--baseline-file", help="Baseline path (default: maskflow-audit.baseline.json)."
|
|
76
|
+
)
|
|
77
|
+
_FIX_OPTION = typer.Option(False, "--fix", help="Agentic fix loop. Needs your own model key.")
|
|
78
|
+
_HANDOFF_OPTION = typer.Option(
|
|
79
|
+
False,
|
|
80
|
+
"--handoff",
|
|
81
|
+
help="Write maskflow-audit-tasks.md for your own coding agent. No key needed.",
|
|
82
|
+
)
|
|
83
|
+
_EVIDENCE_OPTION = typer.Option(
|
|
84
|
+
False, "--evidence", help="Emit metadata-only evidence events for this run."
|
|
85
|
+
)
|
|
86
|
+
_QUIET_OPTION = typer.Option(False, "--quiet", help="Suppress the terminal summary.")
|
|
87
|
+
|
|
88
|
+
_FORMATS = ("json", "html")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _die(message: str, code: int = 1) -> None:
|
|
92
|
+
typer.echo(f"error: {message}", err=True)
|
|
93
|
+
raise typer.Exit(code=code)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def audit( # noqa: PLR0913 -- a CLI surface, each option is deliberate
|
|
97
|
+
path: Path = _PATH_ARGUMENT,
|
|
98
|
+
out: Path | None = _OUT_OPTION,
|
|
99
|
+
fmt: str = _FORMAT_OPTION,
|
|
100
|
+
exclude: list[str] = _EXCLUDE_OPTION,
|
|
101
|
+
prove: bool = _PROVE_OPTION,
|
|
102
|
+
flows: str | None = _FLOWS_OPTION,
|
|
103
|
+
seed: int | None = _SEED_OPTION,
|
|
104
|
+
identities: int = _IDENTITIES_OPTION,
|
|
105
|
+
allow_env: bool = _ALLOW_ENV_OPTION,
|
|
106
|
+
baseline: bool = _BASELINE_OPTION,
|
|
107
|
+
ci: bool = _CI_OPTION,
|
|
108
|
+
baseline_file: Path | None = _BASELINE_FILE_OPTION,
|
|
109
|
+
fix: bool = _FIX_OPTION,
|
|
110
|
+
handoff: bool = _HANDOFF_OPTION,
|
|
111
|
+
evidence: bool = _EVIDENCE_OPTION,
|
|
112
|
+
quiet: bool = _QUIET_OPTION,
|
|
113
|
+
) -> None:
|
|
114
|
+
"""Audits a source tree for unmasked LLM egress paths.
|
|
115
|
+
|
|
116
|
+
Writes a machine-readable report (audit.json) and prints a summary.
|
|
117
|
+
Your code is parsed, never executed; with --prove the project's own
|
|
118
|
+
outbound requests are intercepted before they reach a provider, so
|
|
119
|
+
nothing is ever transmitted. See docs/audit.md for the full reference."""
|
|
120
|
+
try:
|
|
121
|
+
from maskflow_audit.api import run_audit
|
|
122
|
+
from maskflow_audit.baseline import BASELINE_FILENAME, BaselineError, diff_against
|
|
123
|
+
from maskflow_audit.baseline import write_baseline as write_baseline_file
|
|
124
|
+
from maskflow_audit.canary.manifest import SelfCheckFailure
|
|
125
|
+
from maskflow_audit.discovery.walk import DEFAULT_EXCLUDES, discover
|
|
126
|
+
from maskflow_audit.fix import run_fix_loop, write_handoff
|
|
127
|
+
from maskflow_audit.fix.handoff import HANDOFF_FILENAME
|
|
128
|
+
from maskflow_audit.prove import parse_command, run_canary_proof
|
|
129
|
+
from maskflow_audit.report import render
|
|
130
|
+
from maskflow_audit.seeding.safety import ProductionLooksLikely
|
|
131
|
+
from maskflow_audit.sinks.loader import RegistryError, default_registry
|
|
132
|
+
except ModuleNotFoundError as exc:
|
|
133
|
+
typer.echo(_MISSING, err=True)
|
|
134
|
+
raise typer.Exit(code=1) from exc
|
|
135
|
+
|
|
136
|
+
if fmt not in _FORMATS:
|
|
137
|
+
_die(f"--format must be one of {', '.join(_FORMATS)} (got {fmt!r})")
|
|
138
|
+
if not path.exists():
|
|
139
|
+
_die(f"{path}: no such file or directory", code=2)
|
|
140
|
+
if fix and handoff:
|
|
141
|
+
_die("--fix and --handoff are alternatives; pass one or the other")
|
|
142
|
+
|
|
143
|
+
excludes = DEFAULT_EXCLUDES | frozenset(exclude)
|
|
144
|
+
console = make_console()
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
registry = default_registry()
|
|
148
|
+
except RegistryError as exc:
|
|
149
|
+
# A malformed shipped registry is a packaging bug, not user error.
|
|
150
|
+
_die(str(exc), code=1)
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
proof = None
|
|
154
|
+
if prove:
|
|
155
|
+
discovered, _, _ = discover(path, registry, exclude=excludes)
|
|
156
|
+
try:
|
|
157
|
+
proof = run_canary_proof(
|
|
158
|
+
path,
|
|
159
|
+
discovered,
|
|
160
|
+
command=parse_command(flows),
|
|
161
|
+
seed=seed,
|
|
162
|
+
identities=identities,
|
|
163
|
+
allow_env=allow_env,
|
|
164
|
+
)
|
|
165
|
+
except ProductionLooksLikely as exc:
|
|
166
|
+
_die(str(exc), code=2)
|
|
167
|
+
return
|
|
168
|
+
except SelfCheckFailure as exc:
|
|
169
|
+
# A canary MaskFlow would not detect proves nothing, so this is
|
|
170
|
+
# fatal rather than a warning.
|
|
171
|
+
_die(str(exc), code=1)
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
summary = run_audit(path, sinks=registry, exclude=excludes, prove=proof)
|
|
175
|
+
|
|
176
|
+
baseline_path = baseline_file or (Path.cwd() / BASELINE_FILENAME)
|
|
177
|
+
|
|
178
|
+
if baseline:
|
|
179
|
+
write_baseline_file(baseline_path, summary)
|
|
180
|
+
if not quiet:
|
|
181
|
+
console.print(
|
|
182
|
+
f"wrote {baseline_path} -- commit it, then gate your builds with "
|
|
183
|
+
"`maskflow audit --ci`"
|
|
184
|
+
)
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
if ci:
|
|
188
|
+
try:
|
|
189
|
+
verdict = diff_against(summary, baseline_path)
|
|
190
|
+
except BaselineError as exc:
|
|
191
|
+
_die(str(exc), code=1)
|
|
192
|
+
return
|
|
193
|
+
render_ci(console, verdict, summary)
|
|
194
|
+
raise typer.Exit(code=verdict.exit_code)
|
|
195
|
+
|
|
196
|
+
rendered = render(summary, fmt)
|
|
197
|
+
destination = out if out is not None else Path(f"audit.{fmt}")
|
|
198
|
+
if str(destination) == "-":
|
|
199
|
+
sys.stdout.write(rendered)
|
|
200
|
+
else:
|
|
201
|
+
destination.write_text(rendered, encoding="utf-8")
|
|
202
|
+
|
|
203
|
+
if handoff:
|
|
204
|
+
handoff_path = Path.cwd() / HANDOFF_FILENAME
|
|
205
|
+
tasks = write_handoff(handoff_path, summary)
|
|
206
|
+
if not quiet:
|
|
207
|
+
console.print(f"wrote {handoff_path} ({tasks} task(s))")
|
|
208
|
+
|
|
209
|
+
if fix:
|
|
210
|
+
# No model is wired in by default: the audit never carries a
|
|
211
|
+
# provider dependency or a key. `model=None` reports that plainly
|
|
212
|
+
# and points at --handoff, which needs neither.
|
|
213
|
+
report = run_fix_loop(path, summary, model=None)
|
|
214
|
+
render_fix(console, report)
|
|
215
|
+
|
|
216
|
+
if evidence:
|
|
217
|
+
try:
|
|
218
|
+
from maskflow_audit.evidence import EvidenceExtraMissing, emit_audit_evidence
|
|
219
|
+
|
|
220
|
+
count = emit_audit_evidence(summary, enabled=True)
|
|
221
|
+
except ModuleNotFoundError as exc:
|
|
222
|
+
typer.echo(_EVIDENCE_MISSING, err=True)
|
|
223
|
+
raise typer.Exit(code=1) from exc
|
|
224
|
+
except EvidenceExtraMissing as exc:
|
|
225
|
+
typer.echo(_EVIDENCE_MISSING, err=True)
|
|
226
|
+
raise typer.Exit(code=1) from exc
|
|
227
|
+
if not quiet:
|
|
228
|
+
console.print(f"emitted {count} metadata-only evidence event(s)")
|
|
229
|
+
|
|
230
|
+
if not quiet:
|
|
231
|
+
render_summary(console, summary)
|
|
232
|
+
if str(destination) != "-":
|
|
233
|
+
console.print(f"\nwrote {destination}")
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""`maskflow-audit` must stay an optional dependency.
|
|
2
|
+
|
|
3
|
+
A bare `pip install maskflow-cli`, and the standalone PyInstaller binary,
|
|
4
|
+
must keep working with every other command intact -- the binary's spec
|
|
5
|
+
collects every `maskflow_cli` submodule, so a module-level
|
|
6
|
+
`import maskflow_audit` anywhere in the CLI would either bundle the audit
|
|
7
|
+
package into the binary or break its build.
|
|
8
|
+
|
|
9
|
+
Checked by AST inspection rather than by uninstalling the package, so the
|
|
10
|
+
guarantee holds in a dev environment where the extra IS installed (which
|
|
11
|
+
is every environment this suite runs in).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import ast
|
|
17
|
+
import pathlib
|
|
18
|
+
|
|
19
|
+
import maskflow_cli.audit_render as audit_render_mod
|
|
20
|
+
import maskflow_cli.commands.audit_cmd as audit_cmd_mod
|
|
21
|
+
from maskflow_cli.app import app
|
|
22
|
+
from typer.testing import CliRunner
|
|
23
|
+
|
|
24
|
+
runner = CliRunner()
|
|
25
|
+
|
|
26
|
+
_MODULES = (audit_cmd_mod, audit_render_mod)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _toplevel_imports(module: object) -> set[str]:
|
|
30
|
+
"""Root package names imported at module level (not inside a function).
|
|
31
|
+
|
|
32
|
+
A TYPE_CHECKING block counts as module level textually but never runs,
|
|
33
|
+
so it is skipped -- that is exactly how audit_render types itself
|
|
34
|
+
against maskflow-audit without importing it.
|
|
35
|
+
"""
|
|
36
|
+
source = pathlib.Path(module.__file__).read_text(encoding="utf-8") # type: ignore[attr-defined]
|
|
37
|
+
tree = ast.parse(source)
|
|
38
|
+
roots: set[str] = set()
|
|
39
|
+
for node in tree.body:
|
|
40
|
+
if isinstance(node, ast.Import):
|
|
41
|
+
roots.update(alias.name.split(".")[0] for alias in node.names)
|
|
42
|
+
elif isinstance(node, ast.ImportFrom) and node.module and not node.level:
|
|
43
|
+
roots.add(node.module.split(".")[0])
|
|
44
|
+
return roots
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_no_cli_module_imports_maskflow_audit_at_module_level() -> None:
|
|
48
|
+
for module in _MODULES:
|
|
49
|
+
assert "maskflow_audit" not in _toplevel_imports(module), (
|
|
50
|
+
f"{module.__name__} imports maskflow_audit eagerly; it must be "
|
|
51
|
+
"lazy-imported inside the command body"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_the_guard_can_actually_see_imports() -> None:
|
|
56
|
+
"""Non-vacuity: the helper must find the imports that ARE there."""
|
|
57
|
+
assert "typer" in _toplevel_imports(audit_cmd_mod)
|
|
58
|
+
assert "rich" in _toplevel_imports(audit_render_mod)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_the_missing_extra_message_names_the_install_command() -> None:
|
|
62
|
+
# A user who hits this needs the exact command, not a concept.
|
|
63
|
+
assert "pip install 'maskflow-cli[audit]'" in audit_cmd_mod._MISSING
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_every_other_command_still_works() -> None:
|
|
67
|
+
# The binary ships without the extra; `doctor` must not care.
|
|
68
|
+
result = runner.invoke(app, ["doctor"], env={"HOME": "/nonexistent-home"})
|
|
69
|
+
assert result.exit_code == 0, result.output
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""`maskflow audit` through the real root app."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from maskflow_cli.app import app
|
|
10
|
+
from typer.testing import CliRunner
|
|
11
|
+
|
|
12
|
+
runner = CliRunner()
|
|
13
|
+
|
|
14
|
+
_APP = (
|
|
15
|
+
"from openai import OpenAI\n"
|
|
16
|
+
"client = OpenAI()\n"
|
|
17
|
+
"def summarize(cur):\n"
|
|
18
|
+
" row = cur.fetchone()\n"
|
|
19
|
+
" return client.chat.completions.create(messages=row['aadhaar'])\n"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.fixture
|
|
24
|
+
def project(tmp_path: Path) -> Path:
|
|
25
|
+
(tmp_path / "app.py").write_text(_APP, encoding="utf-8")
|
|
26
|
+
return tmp_path
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_writes_a_report_and_exits_zero(project: Path, tmp_path: Path) -> None:
|
|
30
|
+
out = tmp_path / "audit.json"
|
|
31
|
+
result = runner.invoke(app, ["audit", str(project), "--out", str(out)])
|
|
32
|
+
assert result.exit_code == 0, result.output
|
|
33
|
+
data = json.loads(out.read_text(encoding="utf-8"))
|
|
34
|
+
assert data["coverage"]["total_flows"] == 1
|
|
35
|
+
assert data["flows"][0]["masking_status"] == "unmasked"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _flat(output: str) -> str:
|
|
39
|
+
"""Rich wraps at a fixed width, so collapse whitespace before matching
|
|
40
|
+
a phrase -- otherwise the assertion pins the wrap points instead."""
|
|
41
|
+
return " ".join(output.split())
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_prints_the_headline_and_the_unproven_caveat(project: Path, tmp_path: Path) -> None:
|
|
45
|
+
result = runner.invoke(app, ["audit", str(project), "--out", str(tmp_path / "a.json")])
|
|
46
|
+
assert result.exit_code == 0, result.output
|
|
47
|
+
assert "0 of 1 LLM egress paths masked" in result.output
|
|
48
|
+
# The report must never let a reader think a static finding was proven.
|
|
49
|
+
assert "predicted, not proven" in _flat(result.output)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_quiet_suppresses_the_summary(project: Path, tmp_path: Path) -> None:
|
|
53
|
+
out = tmp_path / "a.json"
|
|
54
|
+
result = runner.invoke(app, ["audit", str(project), "--out", str(out), "--quiet"])
|
|
55
|
+
assert result.exit_code == 0, result.output
|
|
56
|
+
assert "LLM egress paths" not in result.output
|
|
57
|
+
assert out.is_file()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_out_dash_writes_to_stdout(project: Path) -> None:
|
|
61
|
+
result = runner.invoke(app, ["audit", str(project), "--out", "-", "--quiet"])
|
|
62
|
+
assert result.exit_code == 0, result.output
|
|
63
|
+
assert json.loads(result.stdout)["schema_version"] == "1"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_default_output_path_is_audit_json(
|
|
67
|
+
project: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
68
|
+
) -> None:
|
|
69
|
+
workdir = tmp_path / "cwd"
|
|
70
|
+
workdir.mkdir()
|
|
71
|
+
monkeypatch.chdir(workdir)
|
|
72
|
+
result = runner.invoke(app, ["audit", str(project), "--quiet"])
|
|
73
|
+
assert result.exit_code == 0, result.output
|
|
74
|
+
assert (workdir / "audit.json").is_file()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_a_missing_path_exits_two(tmp_path: Path) -> None:
|
|
78
|
+
result = runner.invoke(app, ["audit", str(tmp_path / "nope")])
|
|
79
|
+
assert result.exit_code == 2, result.output
|
|
80
|
+
assert "no such file or directory" in result.output
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_an_unknown_format_exits_one(project: Path) -> None:
|
|
84
|
+
result = runner.invoke(app, ["audit", str(project), "--format", "pdf"])
|
|
85
|
+
assert result.exit_code == 1, result.output
|
|
86
|
+
assert "--format must be one of" in result.output
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_exclude_is_repeatable(tmp_path: Path) -> None:
|
|
90
|
+
(tmp_path / "app.py").write_text(_APP, encoding="utf-8")
|
|
91
|
+
for name in ("vendor", "examples"):
|
|
92
|
+
(tmp_path / name).mkdir()
|
|
93
|
+
(tmp_path / name / "x.py").write_text(_APP, encoding="utf-8")
|
|
94
|
+
|
|
95
|
+
out = tmp_path / "a.json"
|
|
96
|
+
result = runner.invoke(
|
|
97
|
+
app,
|
|
98
|
+
[
|
|
99
|
+
"audit",
|
|
100
|
+
str(tmp_path),
|
|
101
|
+
"--exclude",
|
|
102
|
+
"vendor",
|
|
103
|
+
"--exclude",
|
|
104
|
+
"examples",
|
|
105
|
+
"--out",
|
|
106
|
+
str(out),
|
|
107
|
+
"--quiet",
|
|
108
|
+
],
|
|
109
|
+
)
|
|
110
|
+
assert result.exit_code == 0, result.output
|
|
111
|
+
files = [f["file"] for f in json.loads(out.read_text(encoding="utf-8"))["flows"]]
|
|
112
|
+
assert files == ["app.py"]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_a_clean_tree_reports_zero_without_failing(tmp_path: Path) -> None:
|
|
116
|
+
(tmp_path / "plain.py").write_text("def f():\n return 1\n", encoding="utf-8")
|
|
117
|
+
result = runner.invoke(app, ["audit", str(tmp_path), "--out", str(tmp_path / "a.json")])
|
|
118
|
+
assert result.exit_code == 0, result.output
|
|
119
|
+
# A clean report must not read as a clean bill of health.
|
|
120
|
+
assert "not that none exist" in _flat(result.output)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_audit_is_registered_on_the_root_app() -> None:
|
|
124
|
+
result = runner.invoke(app, ["--help"])
|
|
125
|
+
assert result.exit_code == 0, result.output
|
|
126
|
+
assert "audit" in result.output
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class TestNewSurfaces:
|
|
130
|
+
"""--prove / --baseline / --ci / --handoff / --format html."""
|
|
131
|
+
|
|
132
|
+
_LEAKY = (
|
|
133
|
+
"import os, sqlite3, httpx\n"
|
|
134
|
+
"DB = os.path.join(os.path.dirname(__file__), 'app.db')\n"
|
|
135
|
+
"client = httpx.Client(base_url='https://api.openai.com')\n"
|
|
136
|
+
"def summarize():\n"
|
|
137
|
+
" row = sqlite3.connect(DB).execute("
|
|
138
|
+
"'SELECT customer_name, aadhaar FROM tickets').fetchone()\n"
|
|
139
|
+
" return client.post('/v1/chat/completions',\n"
|
|
140
|
+
" json={'messages': [{'role': 'user', 'content': f'{row[0]} {row[1]}'}]})\n"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
@pytest.fixture
|
|
144
|
+
def leaky_project(self, tmp_path: Path) -> Path:
|
|
145
|
+
import sqlite3
|
|
146
|
+
|
|
147
|
+
(tmp_path / "app.py").write_text(self._LEAKY, encoding="utf-8")
|
|
148
|
+
(tmp_path / "run_flows.py").write_text("import app\napp.summarize()\n", encoding="utf-8")
|
|
149
|
+
connection = sqlite3.connect(tmp_path / "app.db")
|
|
150
|
+
connection.execute(
|
|
151
|
+
"CREATE TABLE tickets (id INTEGER PRIMARY KEY, customer_name TEXT, aadhaar TEXT)"
|
|
152
|
+
)
|
|
153
|
+
connection.commit()
|
|
154
|
+
connection.close()
|
|
155
|
+
return tmp_path
|
|
156
|
+
|
|
157
|
+
def test_html_is_self_contained(self, project: Path, tmp_path: Path) -> None:
|
|
158
|
+
out = tmp_path / "r.html"
|
|
159
|
+
result = runner.invoke(
|
|
160
|
+
app, ["audit", str(project), "--format", "html", "--out", str(out), "--quiet"]
|
|
161
|
+
)
|
|
162
|
+
assert result.exit_code == 0, result.output
|
|
163
|
+
html = out.read_text(encoding="utf-8")
|
|
164
|
+
assert html.startswith("<!doctype html>")
|
|
165
|
+
for needle in ("http://", "https://fonts", "cdn.", "<script src", "<link "):
|
|
166
|
+
assert needle not in html, needle
|
|
167
|
+
assert "DPDP Rule 6" in html
|
|
168
|
+
assert "<!-- DPDP_RULE6_APPENDIX -->" in html
|
|
169
|
+
assert "No raw PII value" in html
|
|
170
|
+
|
|
171
|
+
def test_prove_confirms_the_leak_with_a_call_stack(
|
|
172
|
+
self, leaky_project: Path, tmp_path: Path
|
|
173
|
+
) -> None:
|
|
174
|
+
out = tmp_path / "a.json"
|
|
175
|
+
result = runner.invoke(
|
|
176
|
+
app,
|
|
177
|
+
[
|
|
178
|
+
"audit",
|
|
179
|
+
str(leaky_project),
|
|
180
|
+
"--prove",
|
|
181
|
+
"--flows",
|
|
182
|
+
"python run_flows.py",
|
|
183
|
+
"--seed",
|
|
184
|
+
"7",
|
|
185
|
+
"--out",
|
|
186
|
+
str(out),
|
|
187
|
+
],
|
|
188
|
+
)
|
|
189
|
+
assert result.exit_code == 0, result.output
|
|
190
|
+
data = json.loads(out.read_text(encoding="utf-8"))
|
|
191
|
+
assert data["coverage"]["confirmed_leaks"] == 1
|
|
192
|
+
flow = data["flows"][0]
|
|
193
|
+
assert flow["canary_status"] == "escaped"
|
|
194
|
+
assert flow["confidence"] == "confirmed"
|
|
195
|
+
escapes = flow["canary_escapes"]
|
|
196
|
+
assert escapes and escapes[0]["host"] == "api.openai.com"
|
|
197
|
+
assert escapes[0]["app_stack"], "the call stack is the evidence; it must be present"
|
|
198
|
+
assert "LEAK PROVEN" in result.output
|
|
199
|
+
|
|
200
|
+
def test_prove_writes_no_canary_value_into_the_report(
|
|
201
|
+
self, leaky_project: Path, tmp_path: Path
|
|
202
|
+
) -> None:
|
|
203
|
+
"""The report is a document a DPO retains; it must carry no value."""
|
|
204
|
+
out = tmp_path / "a.json"
|
|
205
|
+
result = runner.invoke(
|
|
206
|
+
app,
|
|
207
|
+
[
|
|
208
|
+
"audit",
|
|
209
|
+
str(leaky_project),
|
|
210
|
+
"--prove",
|
|
211
|
+
"--flows",
|
|
212
|
+
"python run_flows.py",
|
|
213
|
+
"--seed",
|
|
214
|
+
"7",
|
|
215
|
+
"--quiet",
|
|
216
|
+
"--out",
|
|
217
|
+
str(out),
|
|
218
|
+
],
|
|
219
|
+
)
|
|
220
|
+
assert result.exit_code == 0, result.output
|
|
221
|
+
from maskflow_audit.canary.manifest import build_manifest
|
|
222
|
+
|
|
223
|
+
rendered = out.read_text(encoding="utf-8")
|
|
224
|
+
for value in build_manifest(seed=7).values():
|
|
225
|
+
assert value not in rendered
|
|
226
|
+
|
|
227
|
+
def test_prove_restores_the_tree(self, leaky_project: Path, tmp_path: Path) -> None:
|
|
228
|
+
"""An audit that left canary rows behind would be a bug."""
|
|
229
|
+
import sqlite3
|
|
230
|
+
|
|
231
|
+
result = runner.invoke(
|
|
232
|
+
app,
|
|
233
|
+
[
|
|
234
|
+
"audit",
|
|
235
|
+
str(leaky_project),
|
|
236
|
+
"--prove",
|
|
237
|
+
"--flows",
|
|
238
|
+
"python run_flows.py",
|
|
239
|
+
"--quiet",
|
|
240
|
+
"--out",
|
|
241
|
+
str(tmp_path / "a.json"),
|
|
242
|
+
],
|
|
243
|
+
)
|
|
244
|
+
assert result.exit_code == 0, result.output
|
|
245
|
+
connection = sqlite3.connect(leaky_project / "app.db")
|
|
246
|
+
assert connection.execute("SELECT COUNT(*) FROM tickets").fetchone()[0] == 0
|
|
247
|
+
|
|
248
|
+
def test_baseline_then_ci_passes_unchanged(self, project: Path, tmp_path: Path) -> None:
|
|
249
|
+
base = tmp_path / "base.json"
|
|
250
|
+
written = runner.invoke(
|
|
251
|
+
app, ["audit", str(project), "--baseline", "--baseline-file", str(base), "--quiet"]
|
|
252
|
+
)
|
|
253
|
+
assert written.exit_code == 0, written.output
|
|
254
|
+
assert base.is_file()
|
|
255
|
+
|
|
256
|
+
gate = runner.invoke(app, ["audit", str(project), "--ci", "--baseline-file", str(base)])
|
|
257
|
+
assert gate.exit_code == 0, gate.output
|
|
258
|
+
assert "no new unmasked" in gate.output
|
|
259
|
+
|
|
260
|
+
def test_ci_fails_on_a_newly_added_unmasked_call(self, project: Path, tmp_path: Path) -> None:
|
|
261
|
+
base = tmp_path / "base.json"
|
|
262
|
+
runner.invoke(
|
|
263
|
+
app, ["audit", str(project), "--baseline", "--baseline-file", str(base), "--quiet"]
|
|
264
|
+
)
|
|
265
|
+
(project / "app.py").write_text(
|
|
266
|
+
_APP + "def added(t):\n client.chat.completions.create(messages=t)\n",
|
|
267
|
+
encoding="utf-8",
|
|
268
|
+
)
|
|
269
|
+
gate = runner.invoke(app, ["audit", str(project), "--ci", "--baseline-file", str(base)])
|
|
270
|
+
assert gate.exit_code == 1, gate.output
|
|
271
|
+
assert "NEW unguarded egress path" in gate.output
|
|
272
|
+
assert "::error::" in gate.output
|
|
273
|
+
|
|
274
|
+
def test_ci_skips_without_a_baseline(self, project: Path, tmp_path: Path) -> None:
|
|
275
|
+
gate = runner.invoke(
|
|
276
|
+
app, ["audit", str(project), "--ci", "--baseline-file", str(tmp_path / "none.json")]
|
|
277
|
+
)
|
|
278
|
+
assert gate.exit_code == 0, gate.output
|
|
279
|
+
assert "skipped" in gate.output
|
|
280
|
+
|
|
281
|
+
def test_handoff_writes_a_task_file(
|
|
282
|
+
self, project: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
283
|
+
) -> None:
|
|
284
|
+
workdir = tmp_path / "cwd"
|
|
285
|
+
workdir.mkdir()
|
|
286
|
+
monkeypatch.chdir(workdir)
|
|
287
|
+
result = runner.invoke(
|
|
288
|
+
app, ["audit", str(project), "--handoff", "--quiet", "--out", str(tmp_path / "a.json")]
|
|
289
|
+
)
|
|
290
|
+
assert result.exit_code == 0, result.output
|
|
291
|
+
tasks = (workdir / "maskflow-audit-tasks.md").read_text(encoding="utf-8")
|
|
292
|
+
assert "## Task 1" in tasks
|
|
293
|
+
assert "Never edit a test" in tasks
|
|
294
|
+
|
|
295
|
+
def test_fix_without_a_model_points_at_handoff(self, project: Path, tmp_path: Path) -> None:
|
|
296
|
+
result = runner.invoke(
|
|
297
|
+
app, ["audit", str(project), "--fix", "--out", str(tmp_path / "a.json")]
|
|
298
|
+
)
|
|
299
|
+
assert result.exit_code == 0, result.output
|
|
300
|
+
assert "--handoff" in _flat(result.output)
|
|
301
|
+
|
|
302
|
+
def test_fix_and_handoff_are_mutually_exclusive(self, project: Path) -> None:
|
|
303
|
+
result = runner.invoke(app, ["audit", str(project), "--fix", "--handoff"])
|
|
304
|
+
assert result.exit_code == 1, result.output
|
|
305
|
+
assert "alternatives" in result.output
|
|
306
|
+
|
|
307
|
+
def test_allow_env_is_required_in_a_production_looking_env(
|
|
308
|
+
self, leaky_project: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
309
|
+
) -> None:
|
|
310
|
+
"""Seeding production would be a data incident caused by the tool
|
|
311
|
+
that exists to prevent data incidents."""
|
|
312
|
+
monkeypatch.setenv("APP_ENV", "production")
|
|
313
|
+
refused = runner.invoke(
|
|
314
|
+
app,
|
|
315
|
+
[
|
|
316
|
+
"audit",
|
|
317
|
+
str(leaky_project),
|
|
318
|
+
"--prove",
|
|
319
|
+
"--flows",
|
|
320
|
+
"python run_flows.py",
|
|
321
|
+
"--out",
|
|
322
|
+
str(tmp_path / "a.json"),
|
|
323
|
+
],
|
|
324
|
+
)
|
|
325
|
+
assert refused.exit_code == 2, refused.output
|
|
326
|
+
assert "refusing to seed" in refused.output
|
|
327
|
+
|
|
328
|
+
allowed = runner.invoke(
|
|
329
|
+
app,
|
|
330
|
+
[
|
|
331
|
+
"audit",
|
|
332
|
+
str(leaky_project),
|
|
333
|
+
"--prove",
|
|
334
|
+
"--flows",
|
|
335
|
+
"python run_flows.py",
|
|
336
|
+
"--allow-env",
|
|
337
|
+
"--quiet",
|
|
338
|
+
"--out",
|
|
339
|
+
str(tmp_path / "b.json"),
|
|
340
|
+
],
|
|
341
|
+
)
|
|
342
|
+
assert allowed.exit_code == 0, allowed.output
|
|
File without changes
|
|
@@ -10,6 +10,6 @@ def test_agent_passthrough_reaches_maskflow_agent() -> None:
|
|
|
10
10
|
|
|
11
11
|
def test_agent_install_writes_hooks(tmp_path, monkeypatch) -> None:
|
|
12
12
|
monkeypatch.chdir(tmp_path)
|
|
13
|
-
result = CliRunner().invoke(app, ["agent", "install", "--scope", "local"])
|
|
13
|
+
result = CliRunner().invoke(app, ["agent", "install", "--scope", "local", "--no-warm-up"])
|
|
14
14
|
assert result.exit_code == 0, result.output
|
|
15
15
|
assert (tmp_path / ".claude" / "settings.local.json").is_file()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|