cadence-diff 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.
- cadence_diff-0.1.0.dist-info/METADATA +245 -0
- cadence_diff-0.1.0.dist-info/RECORD +78 -0
- cadence_diff-0.1.0.dist-info/WHEEL +4 -0
- cadence_diff-0.1.0.dist-info/entry_points.txt +3 -0
- cadence_diff-0.1.0.dist-info/licenses/LICENSE +190 -0
- qc_tool/__init__.py +3 -0
- qc_tool/__main__.py +6 -0
- qc_tool/attestation.py +209 -0
- qc_tool/availability.py +273 -0
- qc_tool/cli.py +769 -0
- qc_tool/config/__init__.py +1 -0
- qc_tool/config/lint.py +480 -0
- qc_tool/config/profile.py +207 -0
- qc_tool/coverage.py +35 -0
- qc_tool/crosscheck/__init__.py +1 -0
- qc_tool/crosscheck/numbers.py +146 -0
- qc_tool/crosscheck/package.py +154 -0
- qc_tool/crosscheck/trace.py +374 -0
- qc_tool/engine.py +978 -0
- qc_tool/excel/__init__.py +1 -0
- qc_tool/excel/align.py +452 -0
- qc_tool/excel/charts.py +931 -0
- qc_tool/excel/context.py +106 -0
- qc_tool/excel/controls.py +266 -0
- qc_tool/excel/dependency.py +252 -0
- qc_tool/excel/diff_structure.py +326 -0
- qc_tool/excel/diff_values.py +327 -0
- qc_tool/excel/formulas.py +516 -0
- qc_tool/excel/interaction.py +548 -0
- qc_tool/excel/periods.py +117 -0
- qc_tool/excel/preflight.py +615 -0
- qc_tool/excel/references.py +436 -0
- qc_tool/excel/regions.py +201 -0
- qc_tool/findings.py +220 -0
- qc_tool/fingerprint.py +369 -0
- qc_tool/fingerprint.schema.json +15 -0
- qc_tool/history/__init__.py +1 -0
- qc_tool/history/store.py +287 -0
- qc_tool/io/__init__.py +1 -0
- qc_tool/io/decrypt.py +64 -0
- qc_tool/io/excel_formula.py +256 -0
- qc_tool/io/excel_formula_worker.py +280 -0
- qc_tool/io/formula_enrichment.py +95 -0
- qc_tool/io/libreoffice_formula.py +232 -0
- qc_tool/io/loader.py +973 -0
- qc_tool/io/model.py +356 -0
- qc_tool/io/ooxml_chart.py +530 -0
- qc_tool/io/ooxml_interaction.py +400 -0
- qc_tool/io/ooxml_worksheet.py +405 -0
- qc_tool/io/xlsb_formula.py +313 -0
- qc_tool/package-sanitize.schema.json +31 -0
- qc_tool/package_sanitize.py +255 -0
- qc_tool/ppt/__init__.py +1 -0
- qc_tool/ppt/chart_xml.py +422 -0
- qc_tool/ppt/diff.py +120 -0
- qc_tool/ppt/element_diff.py +878 -0
- qc_tool/ppt/element_match.py +317 -0
- qc_tool/ppt/extract.py +212 -0
- qc_tool/ppt/match.py +137 -0
- qc_tool/ppt/model.py +132 -0
- qc_tool/ppt/preflight.py +307 -0
- qc_tool/privacy.py +392 -0
- qc_tool/progress.py +78 -0
- qc_tool/report/__init__.py +1 -0
- qc_tool/report/excel_report.py +205 -0
- qc_tool/report/findings.schema.json +32 -0
- qc_tool/report/html_report.py +38 -0
- qc_tool/report/json_report.py +55 -0
- qc_tool/report/templates/report.html.j2 +117 -0
- qc_tool/sanitize.py +598 -0
- qc_tool/security.py +34 -0
- qc_tool/server_config.py +86 -0
- qc_tool/triage/__init__.py +1 -0
- qc_tool/triage/rules.py +211 -0
- qc_tool/ui/__init__.py +1 -0
- qc_tool/ui/app.py +1261 -0
- qc_tool/ui/guide.py +452 -0
- qc_tool/ui/theme.py +505 -0
qc_tool/cli.py
ADDED
|
@@ -0,0 +1,769 @@
|
|
|
1
|
+
"""Command-line interface: serve (default), run, sanitize, lint.
|
|
2
|
+
|
|
3
|
+
`cadence-diff` with no subcommand serves the web UI. The legacy `qc-tool`
|
|
4
|
+
entry point remains supported.
|
|
5
|
+
`qc-tool run` executes headless QC with CI-style exit codes, `sanitize`
|
|
6
|
+
supports local numeric scrambling or strict verified redaction,
|
|
7
|
+
`fingerprint` emits structural-only JSON, and `lint` validates a profile.
|
|
8
|
+
|
|
9
|
+
Exit codes: 0 ok · 1 usage/runtime error · 2 findings at/above the
|
|
10
|
+
--fail-on threshold (run) or lint errors (lint).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import getpass
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from platformdirs import user_data_dir
|
|
20
|
+
|
|
21
|
+
from qc_tool import __version__
|
|
22
|
+
from qc_tool.security import private_directory
|
|
23
|
+
|
|
24
|
+
_SUBCOMMANDS = {
|
|
25
|
+
"serve",
|
|
26
|
+
"run",
|
|
27
|
+
"sanitize",
|
|
28
|
+
"sanitize-package",
|
|
29
|
+
"verify-sanitized",
|
|
30
|
+
"fingerprint",
|
|
31
|
+
"verify-attestation",
|
|
32
|
+
"network",
|
|
33
|
+
"lint",
|
|
34
|
+
}
|
|
35
|
+
_MODE_ALIASES = {
|
|
36
|
+
"cycle": "cycle_comparison",
|
|
37
|
+
"preflight": "current_file_preflight",
|
|
38
|
+
"package": "final_package",
|
|
39
|
+
}
|
|
40
|
+
_ROLES = ("baseline_excel", "current_excel", "baseline_ppt", "current_ppt")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _program_name() -> str:
|
|
44
|
+
executable = Path(sys.argv[0]).name.casefold()
|
|
45
|
+
return "qc-tool" if executable.startswith("qc-tool") else "cadence-diff"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def default_data_dir() -> Path:
|
|
49
|
+
"""Per-user writable location for uploads, profiles, history, reports."""
|
|
50
|
+
return Path(user_data_dir("qc-tool", appauthor=False))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# --- serve -------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
57
|
+
parser = argparse.ArgumentParser(
|
|
58
|
+
prog=_program_name(),
|
|
59
|
+
description=(
|
|
60
|
+
"Local, read-only QC for cadence Excel/PowerPoint deliverables: "
|
|
61
|
+
"current-file preflight, baseline/current cycle comparison, and "
|
|
62
|
+
"final-package reconciliation. Serves a web UI on 127.0.0.1. "
|
|
63
|
+
"Subcommands: run (headless QC), sanitize (shareable scrambled "
|
|
64
|
+
"or verified-redacted copies), fingerprint (structural-only JSON), "
|
|
65
|
+
"lint (profile validation)."
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
parser.add_argument(
|
|
69
|
+
"--port", type=int, default=8080, help="localhost port (default: 8080)"
|
|
70
|
+
)
|
|
71
|
+
parser.add_argument(
|
|
72
|
+
"--data-dir",
|
|
73
|
+
type=Path,
|
|
74
|
+
default=None,
|
|
75
|
+
help=(
|
|
76
|
+
"directory for uploads, profiles, run history, and reports "
|
|
77
|
+
f"(default: {default_data_dir()})"
|
|
78
|
+
),
|
|
79
|
+
)
|
|
80
|
+
parser.add_argument(
|
|
81
|
+
"--version", action="version", version=f"%(prog)s {__version__}"
|
|
82
|
+
)
|
|
83
|
+
parser.add_argument(
|
|
84
|
+
"--network",
|
|
85
|
+
choices=["local", "lan"],
|
|
86
|
+
default=None,
|
|
87
|
+
help="process exposure override; LAN requires --expose-for",
|
|
88
|
+
)
|
|
89
|
+
parser.add_argument(
|
|
90
|
+
"--expose-for",
|
|
91
|
+
type=int,
|
|
92
|
+
default=None,
|
|
93
|
+
metavar="MINUTES",
|
|
94
|
+
help="temporary LAN duration (1-1440 minutes)",
|
|
95
|
+
)
|
|
96
|
+
return parser
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _cmd_serve(args: list[str]) -> int:
|
|
100
|
+
ns = build_parser().parse_args(args)
|
|
101
|
+
data_dir = ns.data_dir or default_data_dir()
|
|
102
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
from qc_tool.server_config import (
|
|
104
|
+
load_server_config,
|
|
105
|
+
local_config,
|
|
106
|
+
save_server_config,
|
|
107
|
+
temporary_lan_config,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
if ns.network == "lan":
|
|
111
|
+
if ns.expose_for is None:
|
|
112
|
+
raise ValueError("--network lan requires --expose-for MINUTES")
|
|
113
|
+
config = temporary_lan_config(ns.expose_for)
|
|
114
|
+
save_server_config(data_dir, config)
|
|
115
|
+
elif ns.network == "local":
|
|
116
|
+
if ns.expose_for is not None:
|
|
117
|
+
raise ValueError("--expose-for only applies to --network lan")
|
|
118
|
+
config = local_config()
|
|
119
|
+
save_server_config(data_dir, config)
|
|
120
|
+
else:
|
|
121
|
+
if ns.expose_for is not None:
|
|
122
|
+
raise ValueError("--expose-for requires --network lan")
|
|
123
|
+
config = load_server_config(data_dir)
|
|
124
|
+
from qc_tool.ui.app import run_app # deferred: keep --help/--version instant
|
|
125
|
+
|
|
126
|
+
run_app(
|
|
127
|
+
data_dir,
|
|
128
|
+
port=ns.port,
|
|
129
|
+
host=config.host,
|
|
130
|
+
network_mode=config.network,
|
|
131
|
+
expires_at=config.expires_at,
|
|
132
|
+
)
|
|
133
|
+
return 0
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# --- run ---------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _run_parser() -> argparse.ArgumentParser:
|
|
140
|
+
parser = argparse.ArgumentParser(
|
|
141
|
+
prog=f"{_program_name()} run",
|
|
142
|
+
description=(
|
|
143
|
+
"Headless QC run. Mode is inferred from the supplied files "
|
|
144
|
+
"(any baseline -> cycle; current Excel+PPT -> package; a single "
|
|
145
|
+
"current file -> preflight) unless --mode is given."
|
|
146
|
+
),
|
|
147
|
+
)
|
|
148
|
+
for role in _ROLES:
|
|
149
|
+
parser.add_argument(
|
|
150
|
+
f"--{role.replace('_', '-')}", type=Path, default=None, metavar="FILE"
|
|
151
|
+
)
|
|
152
|
+
parser.add_argument(
|
|
153
|
+
"--mode",
|
|
154
|
+
choices=[*_MODE_ALIASES, *_MODE_ALIASES.values()],
|
|
155
|
+
default=None,
|
|
156
|
+
help="cycle | preflight | package (default: inferred)",
|
|
157
|
+
)
|
|
158
|
+
parser.add_argument(
|
|
159
|
+
"--profile",
|
|
160
|
+
default=None,
|
|
161
|
+
metavar="NAME_OR_PATH",
|
|
162
|
+
help="profile name (resolved in <data-dir>/profiles) or a YAML path",
|
|
163
|
+
)
|
|
164
|
+
parser.add_argument("--data-dir", type=Path, default=None)
|
|
165
|
+
parser.add_argument(
|
|
166
|
+
"--json", type=Path, default=None, help="write machine-readable findings JSON"
|
|
167
|
+
)
|
|
168
|
+
parser.add_argument(
|
|
169
|
+
"--json-context",
|
|
170
|
+
action="store_true",
|
|
171
|
+
help=(
|
|
172
|
+
"include raw cell-neighborhood excerpts and mapping candidate values in JSON; "
|
|
173
|
+
"private/sensitive, disabled by default"
|
|
174
|
+
),
|
|
175
|
+
)
|
|
176
|
+
parser.add_argument(
|
|
177
|
+
"--password",
|
|
178
|
+
action="append",
|
|
179
|
+
default=[],
|
|
180
|
+
metavar="ROLE_OR_FILENAME=PW",
|
|
181
|
+
help=(
|
|
182
|
+
"INSECURE: inline password, visible in process lists and shell history; "
|
|
183
|
+
"prefer --password-env, --password-file, or --password-prompt"
|
|
184
|
+
),
|
|
185
|
+
)
|
|
186
|
+
parser.add_argument(
|
|
187
|
+
"--password-env",
|
|
188
|
+
action="append",
|
|
189
|
+
default=[],
|
|
190
|
+
metavar="ROLE_OR_FILENAME=ENV_VAR",
|
|
191
|
+
help="read a password from an environment variable (repeatable)",
|
|
192
|
+
)
|
|
193
|
+
parser.add_argument(
|
|
194
|
+
"--password-file",
|
|
195
|
+
action="append",
|
|
196
|
+
default=[],
|
|
197
|
+
metavar="ROLE_OR_FILENAME=PATH",
|
|
198
|
+
help="read a password from a private text file (repeatable)",
|
|
199
|
+
)
|
|
200
|
+
parser.add_argument(
|
|
201
|
+
"--password-prompt",
|
|
202
|
+
action="append",
|
|
203
|
+
default=[],
|
|
204
|
+
metavar="ROLE_OR_FILENAME",
|
|
205
|
+
help="prompt securely for a password (repeatable)",
|
|
206
|
+
)
|
|
207
|
+
parser.add_argument(
|
|
208
|
+
"--fail-on",
|
|
209
|
+
choices=["critical", "warning", "never"],
|
|
210
|
+
default="critical",
|
|
211
|
+
help="exit 2 when findings at/above this severity exist (default: critical)",
|
|
212
|
+
)
|
|
213
|
+
parser.add_argument("--top", type=int, default=10, help="findings to print")
|
|
214
|
+
parser.add_argument(
|
|
215
|
+
"--allow-large-workbooks",
|
|
216
|
+
action="store_true",
|
|
217
|
+
help=(
|
|
218
|
+
"override OOXML workload refusal for this run; requires sufficient "
|
|
219
|
+
"local memory and degrades workload coverage"
|
|
220
|
+
),
|
|
221
|
+
)
|
|
222
|
+
parser.add_argument(
|
|
223
|
+
"--progress",
|
|
224
|
+
action="store_true",
|
|
225
|
+
help="write phase progress to stderr",
|
|
226
|
+
)
|
|
227
|
+
parser.add_argument(
|
|
228
|
+
"--attestation",
|
|
229
|
+
type=Path,
|
|
230
|
+
default=None,
|
|
231
|
+
help="write an HMAC-signed QC evidence bundle",
|
|
232
|
+
)
|
|
233
|
+
parser.add_argument(
|
|
234
|
+
"--attestation-key-file",
|
|
235
|
+
type=Path,
|
|
236
|
+
default=None,
|
|
237
|
+
help="private HMAC key (default: <data-dir>/attestation.key)",
|
|
238
|
+
)
|
|
239
|
+
return parser
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _infer_mode(files: dict[str, Path]) -> str:
|
|
243
|
+
if "baseline_excel" in files or "baseline_ppt" in files:
|
|
244
|
+
return "cycle_comparison"
|
|
245
|
+
if "current_excel" in files and "current_ppt" in files:
|
|
246
|
+
return "final_package"
|
|
247
|
+
return "current_file_preflight"
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _resolve_profile(spec: str | None, data_dir: Path):
|
|
251
|
+
from qc_tool.config.profile import default_profile, load_profile
|
|
252
|
+
from qc_tool.ui.app import load_profile_by_name
|
|
253
|
+
|
|
254
|
+
if spec is None:
|
|
255
|
+
return default_profile()
|
|
256
|
+
path = Path(spec)
|
|
257
|
+
if path.suffix in {".yaml", ".yml"} or path.exists():
|
|
258
|
+
return load_profile(path)
|
|
259
|
+
return load_profile_by_name(data_dir / "profiles", spec)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _parse_passwords(pairs: list[str], files: dict[str, Path]) -> dict[str, str]:
|
|
263
|
+
passwords: dict[str, str] = {}
|
|
264
|
+
for pair in pairs:
|
|
265
|
+
key, sep, password = pair.partition("=")
|
|
266
|
+
if not sep:
|
|
267
|
+
raise ValueError(f"--password expects ROLE_OR_FILENAME=PW, got {pair!r}")
|
|
268
|
+
for role in _password_roles(key, files):
|
|
269
|
+
passwords[role] = password
|
|
270
|
+
return passwords
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _password_roles(key: str, files: dict[str, Path]) -> list[str]:
|
|
274
|
+
if key in _ROLES:
|
|
275
|
+
if key not in files:
|
|
276
|
+
raise ValueError(f"password role {key!r} has no supplied file")
|
|
277
|
+
return [key]
|
|
278
|
+
matches = [role for role, path in files.items() if path.name == key]
|
|
279
|
+
if not matches:
|
|
280
|
+
raise ValueError(f"password key {key!r} matches no supplied file or role")
|
|
281
|
+
return matches
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _parse_password_sources(ns: argparse.Namespace, files: dict[str, Path]) -> dict[str, str]:
|
|
285
|
+
passwords = _parse_passwords(ns.password, files)
|
|
286
|
+
for pair in ns.password_env:
|
|
287
|
+
key, sep, variable = pair.partition("=")
|
|
288
|
+
if not sep:
|
|
289
|
+
raise ValueError(f"--password-env expects ROLE_OR_FILENAME=ENV_VAR, got {pair!r}")
|
|
290
|
+
if variable not in os.environ:
|
|
291
|
+
raise ValueError(f"environment variable {variable!r} is not set")
|
|
292
|
+
for role in _password_roles(key, files):
|
|
293
|
+
passwords[role] = os.environ[variable]
|
|
294
|
+
for pair in ns.password_file:
|
|
295
|
+
key, sep, raw_path = pair.partition("=")
|
|
296
|
+
if not sep:
|
|
297
|
+
raise ValueError(f"--password-file expects ROLE_OR_FILENAME=PATH, got {pair!r}")
|
|
298
|
+
path = Path(raw_path)
|
|
299
|
+
password = path.read_text(encoding="utf-8").rstrip("\r\n")
|
|
300
|
+
if not password:
|
|
301
|
+
raise ValueError(f"password file {path} is empty")
|
|
302
|
+
if os.name == "posix" and path.stat().st_mode & 0o077:
|
|
303
|
+
raise ValueError(f"password file {path} must not be group/world accessible")
|
|
304
|
+
for role in _password_roles(key, files):
|
|
305
|
+
passwords[role] = password
|
|
306
|
+
for key in ns.password_prompt:
|
|
307
|
+
password = getpass.getpass(f"Password for {key}: ")
|
|
308
|
+
if not password:
|
|
309
|
+
raise ValueError(f"password for {key!r} is empty")
|
|
310
|
+
for role in _password_roles(key, files):
|
|
311
|
+
passwords[role] = password
|
|
312
|
+
return passwords
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _cmd_run(args: list[str]) -> int:
|
|
316
|
+
ns = _run_parser().parse_args(args)
|
|
317
|
+
files = {
|
|
318
|
+
role: getattr(ns, role)
|
|
319
|
+
for role in _ROLES
|
|
320
|
+
if getattr(ns, role) is not None
|
|
321
|
+
}
|
|
322
|
+
if not files:
|
|
323
|
+
raise ValueError(
|
|
324
|
+
f"{_program_name()} run: supply at least one file (see --help)"
|
|
325
|
+
)
|
|
326
|
+
for role, path in files.items():
|
|
327
|
+
if not path.exists():
|
|
328
|
+
raise ValueError(f"{role}: {path} not found")
|
|
329
|
+
|
|
330
|
+
from qc_tool.coverage import QCRunMode
|
|
331
|
+
from qc_tool.findings import Severity
|
|
332
|
+
from qc_tool.progress import ProgressEvent
|
|
333
|
+
from qc_tool.report.json_report import write_json_report
|
|
334
|
+
from qc_tool.ui.app import perform_run
|
|
335
|
+
|
|
336
|
+
data_dir = ns.data_dir or default_data_dir()
|
|
337
|
+
private_directory(data_dir)
|
|
338
|
+
mode_value = _MODE_ALIASES.get(ns.mode, ns.mode) if ns.mode else _infer_mode(files)
|
|
339
|
+
profile = _resolve_profile(ns.profile, data_dir)
|
|
340
|
+
passwords = _parse_password_sources(ns, files)
|
|
341
|
+
|
|
342
|
+
on_progress = None
|
|
343
|
+
if ns.progress:
|
|
344
|
+
|
|
345
|
+
def print_progress(event: ProgressEvent) -> None:
|
|
346
|
+
label = event.phase.value.replace("_", " ")
|
|
347
|
+
counts = (
|
|
348
|
+
f" ({event.processed}/{event.total})" if event.total else ""
|
|
349
|
+
)
|
|
350
|
+
detail = f" - {event.detail}" if event.detail else ""
|
|
351
|
+
print(f"progress: {label}{counts}{detail}", file=sys.stderr)
|
|
352
|
+
|
|
353
|
+
on_progress = print_progress
|
|
354
|
+
|
|
355
|
+
artifacts = perform_run(
|
|
356
|
+
data_dir,
|
|
357
|
+
files,
|
|
358
|
+
passwords,
|
|
359
|
+
profile,
|
|
360
|
+
mode=QCRunMode(mode_value),
|
|
361
|
+
allow_large_workbooks=ns.allow_large_workbooks,
|
|
362
|
+
on_progress=on_progress,
|
|
363
|
+
)
|
|
364
|
+
result = artifacts.result
|
|
365
|
+
|
|
366
|
+
print(f"mode: {result.mode.value} profile: {result.profile_name}")
|
|
367
|
+
print("files:", " ".join(f"{r}={n}" for r, n in result.files.items()))
|
|
368
|
+
print(
|
|
369
|
+
"counts:",
|
|
370
|
+
" ".join(f"{sev.value}={count}" for sev, count in result.counts.items()),
|
|
371
|
+
)
|
|
372
|
+
if result.coverage:
|
|
373
|
+
states: dict[str, int] = {}
|
|
374
|
+
for item in result.coverage:
|
|
375
|
+
states[item.state.value] = states.get(item.state.value, 0) + 1
|
|
376
|
+
print("coverage:", " ".join(f"{k}={v}" for k, v in sorted(states.items())))
|
|
377
|
+
if result.mapping_coverage is not None:
|
|
378
|
+
mc = result.mapping_coverage
|
|
379
|
+
print(
|
|
380
|
+
f"mapping: eligible={mc.eligible} mapped={mc.mapped} "
|
|
381
|
+
f"verified={mc.verified} mismatched={mc.mismatched} "
|
|
382
|
+
f"unmapped={mc.unmapped}"
|
|
383
|
+
)
|
|
384
|
+
for disclosure in result.disclosures:
|
|
385
|
+
print(f"note: {disclosure}")
|
|
386
|
+
ranked = [
|
|
387
|
+
f
|
|
388
|
+
for f in result.findings
|
|
389
|
+
if f.severity in (Severity.CRITICAL, Severity.WARNING)
|
|
390
|
+
]
|
|
391
|
+
for finding in ranked[: ns.top]:
|
|
392
|
+
severity = finding.severity.value if finding.severity else "?"
|
|
393
|
+
where = finding.sheet or finding.slide or ""
|
|
394
|
+
print(
|
|
395
|
+
f" {finding.finding_id} {severity:8s} {finding.finding_class.value:24s} "
|
|
396
|
+
f"{where}!{finding.location or finding.element or ''} {finding.message}"
|
|
397
|
+
)
|
|
398
|
+
if len(ranked) > ns.top:
|
|
399
|
+
print(f" ... {len(ranked) - ns.top} more (see reports)")
|
|
400
|
+
print("reports:", " ".join(str(p) for p in artifacts.report_paths.values()))
|
|
401
|
+
if ns.json is not None:
|
|
402
|
+
write_json_report(result, ns.json, include_context=ns.json_context)
|
|
403
|
+
print("json:", ns.json)
|
|
404
|
+
print(f"history: run #{artifacts.run_id} in {data_dir}")
|
|
405
|
+
if ns.attestation is not None:
|
|
406
|
+
from qc_tool.attestation import (
|
|
407
|
+
create_attestation,
|
|
408
|
+
load_attestation_key,
|
|
409
|
+
load_or_create_attestation_key,
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
if ns.attestation_key_file is not None:
|
|
413
|
+
key = load_attestation_key(ns.attestation_key_file)
|
|
414
|
+
else:
|
|
415
|
+
key_path, key = load_or_create_attestation_key(data_dir)
|
|
416
|
+
print(f"attestation key: {key_path}")
|
|
417
|
+
create_attestation(
|
|
418
|
+
ns.attestation,
|
|
419
|
+
result=result,
|
|
420
|
+
profile=profile,
|
|
421
|
+
input_files=files,
|
|
422
|
+
report_paths=artifacts.report_paths,
|
|
423
|
+
key=key,
|
|
424
|
+
)
|
|
425
|
+
print(f"attestation: {ns.attestation}")
|
|
426
|
+
|
|
427
|
+
counts = result.counts
|
|
428
|
+
failing = counts[Severity.CRITICAL]
|
|
429
|
+
if ns.fail_on == "warning":
|
|
430
|
+
failing += counts[Severity.WARNING]
|
|
431
|
+
if ns.fail_on != "never" and failing > 0:
|
|
432
|
+
return 2
|
|
433
|
+
return 0
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
# --- sanitize -------------------------------------------------------------------
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _sanitize_parser() -> argparse.ArgumentParser:
|
|
440
|
+
parser = argparse.ArgumentParser(
|
|
441
|
+
prog=f"{_program_name()} sanitize",
|
|
442
|
+
description=(
|
|
443
|
+
"Write a structure-preserving copy. Default mode is a local numeric "
|
|
444
|
+
"scrambler and is NOT safe to share. --redact-text enables strict "
|
|
445
|
+
"identifier/content removal and fail-closed privacy verification."
|
|
446
|
+
),
|
|
447
|
+
)
|
|
448
|
+
parser.add_argument("source", type=Path)
|
|
449
|
+
parser.add_argument("-o", "--output", type=Path, default=None)
|
|
450
|
+
parser.add_argument("--seed", type=int, default=0)
|
|
451
|
+
parser.add_argument(
|
|
452
|
+
"--redact-text",
|
|
453
|
+
action="store_true",
|
|
454
|
+
help="also replace text labels (period labels are always kept)",
|
|
455
|
+
)
|
|
456
|
+
return parser
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _cmd_sanitize(args: list[str]) -> int:
|
|
460
|
+
ns = _sanitize_parser().parse_args(args)
|
|
461
|
+
from qc_tool.sanitize import SanitizeError, sanitize_file
|
|
462
|
+
|
|
463
|
+
try:
|
|
464
|
+
output, stats = sanitize_file(
|
|
465
|
+
ns.source, ns.output, seed=ns.seed, redact_text=ns.redact_text
|
|
466
|
+
)
|
|
467
|
+
except SanitizeError as exc:
|
|
468
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
469
|
+
return 1
|
|
470
|
+
print(f"sanitized -> {output}")
|
|
471
|
+
print(
|
|
472
|
+
f"numbers scrambled: {stats.numbers_scrambled} "
|
|
473
|
+
f"texts redacted: {stats.texts_redacted} "
|
|
474
|
+
f"charts rebuilt: {stats.charts_rebuilt}"
|
|
475
|
+
+ (f" charts skipped: {stats.charts_skipped}" if stats.charts_skipped else "")
|
|
476
|
+
)
|
|
477
|
+
for note in stats.notes:
|
|
478
|
+
print(f"note: {note}")
|
|
479
|
+
if not ns.redact_text:
|
|
480
|
+
print(
|
|
481
|
+
"warning: numeric scramble only — NOT privacy verified or safe to share; "
|
|
482
|
+
"use --redact-text for strict verified redaction",
|
|
483
|
+
file=sys.stderr,
|
|
484
|
+
)
|
|
485
|
+
return 0
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _sanitize_package_parser() -> argparse.ArgumentParser:
|
|
489
|
+
parser = argparse.ArgumentParser(
|
|
490
|
+
prog=f"{_program_name()} sanitize-package",
|
|
491
|
+
description=(
|
|
492
|
+
"Strictly sanitize current Excel+PowerPoint together, re-project "
|
|
493
|
+
"confirmed mappings, verify privacy, and write a redaction manifest."
|
|
494
|
+
),
|
|
495
|
+
)
|
|
496
|
+
parser.add_argument("--excel", type=Path, required=True)
|
|
497
|
+
parser.add_argument("--ppt", type=Path, required=True)
|
|
498
|
+
parser.add_argument("--profile", required=True, metavar="NAME_OR_PATH")
|
|
499
|
+
parser.add_argument("--output-dir", type=Path, required=True)
|
|
500
|
+
parser.add_argument("--data-dir", type=Path, default=None)
|
|
501
|
+
parser.add_argument("--seed", type=int, default=0)
|
|
502
|
+
parser.add_argument(
|
|
503
|
+
"--forbid",
|
|
504
|
+
action="append",
|
|
505
|
+
default=[],
|
|
506
|
+
metavar="TOKEN",
|
|
507
|
+
help="case-insensitive token that must not remain (repeatable)",
|
|
508
|
+
)
|
|
509
|
+
return parser
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _cmd_sanitize_package(args: list[str]) -> int:
|
|
513
|
+
ns = _sanitize_package_parser().parse_args(args)
|
|
514
|
+
from qc_tool.package_sanitize import sanitize_package
|
|
515
|
+
from qc_tool.sanitize import SanitizeError
|
|
516
|
+
|
|
517
|
+
data_dir = ns.data_dir or default_data_dir()
|
|
518
|
+
profile = _resolve_profile(ns.profile, data_dir)
|
|
519
|
+
try:
|
|
520
|
+
manifest = sanitize_package(
|
|
521
|
+
ns.excel,
|
|
522
|
+
ns.ppt,
|
|
523
|
+
ns.output_dir,
|
|
524
|
+
profile,
|
|
525
|
+
seed=ns.seed,
|
|
526
|
+
forbidden_tokens=ns.forbid,
|
|
527
|
+
)
|
|
528
|
+
except SanitizeError as exc:
|
|
529
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
530
|
+
return 1
|
|
531
|
+
print(
|
|
532
|
+
f"package privacy: {'SAFE' if manifest.privacy_safe else 'UNSAFE'} "
|
|
533
|
+
f"mappings verified={manifest.mappings_verified} "
|
|
534
|
+
f"unverifiable={manifest.mappings_unverifiable}"
|
|
535
|
+
)
|
|
536
|
+
print(f"outputs: {ns.output_dir}")
|
|
537
|
+
print(f"manifest: {ns.output_dir / 'redaction-manifest.json'}")
|
|
538
|
+
return 0 if manifest.privacy_safe and not manifest.mappings_unverifiable else 2
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _verify_sanitized_parser() -> argparse.ArgumentParser:
|
|
542
|
+
parser = argparse.ArgumentParser(
|
|
543
|
+
prog=f"{_program_name()} verify-sanitized",
|
|
544
|
+
description="Fail-closed privacy verification for a strict sanitizer output.",
|
|
545
|
+
)
|
|
546
|
+
parser.add_argument("source", type=Path)
|
|
547
|
+
parser.add_argument(
|
|
548
|
+
"--forbid",
|
|
549
|
+
action="append",
|
|
550
|
+
default=[],
|
|
551
|
+
metavar="TOKEN",
|
|
552
|
+
help="case-insensitive token that must not remain (repeatable)",
|
|
553
|
+
)
|
|
554
|
+
parser.add_argument("--json", type=Path, default=None, help="write verification JSON")
|
|
555
|
+
return parser
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _cmd_verify_sanitized(args: list[str]) -> int:
|
|
559
|
+
ns = _verify_sanitized_parser().parse_args(args)
|
|
560
|
+
from qc_tool.privacy import verify_sanitized
|
|
561
|
+
from qc_tool.security import private_directory, private_file
|
|
562
|
+
|
|
563
|
+
report = verify_sanitized(ns.source, forbidden_tokens=ns.forbid)
|
|
564
|
+
for issue in report.issues:
|
|
565
|
+
print(f"unsafe {issue.code:24s} {issue.location}: {issue.message}")
|
|
566
|
+
print(
|
|
567
|
+
f"privacy: {'SAFE' if report.safe else 'UNSAFE'} "
|
|
568
|
+
f"checks={len(report.checks)} issues={len(report.issues)} sha256={report.sha256}"
|
|
569
|
+
)
|
|
570
|
+
if ns.json is not None:
|
|
571
|
+
|
|
572
|
+
private_directory(ns.json.parent)
|
|
573
|
+
ns.json.write_text(report.model_dump_json(indent=2), encoding="utf-8")
|
|
574
|
+
private_file(ns.json)
|
|
575
|
+
return 0 if report.safe else 2
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _fingerprint_parser() -> argparse.ArgumentParser:
|
|
579
|
+
parser = argparse.ArgumentParser(
|
|
580
|
+
prog=f"{_program_name()} fingerprint",
|
|
581
|
+
description=(
|
|
582
|
+
"Write structural-only JSON: dimensions, types, period grammars, "
|
|
583
|
+
"regions, hashed formula patterns, table/chart shapes — no source "
|
|
584
|
+
"values, visible text, paths, formulas, images, or identifiers."
|
|
585
|
+
),
|
|
586
|
+
)
|
|
587
|
+
parser.add_argument("source", type=Path)
|
|
588
|
+
parser.add_argument("-o", "--output", type=Path, required=True)
|
|
589
|
+
parser.add_argument("--password-env", default=None, metavar="ENV_VAR")
|
|
590
|
+
parser.add_argument("--password-file", type=Path, default=None)
|
|
591
|
+
parser.add_argument("--password-prompt", action="store_true")
|
|
592
|
+
return parser
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _cmd_fingerprint(args: list[str]) -> int:
|
|
596
|
+
ns = _fingerprint_parser().parse_args(args)
|
|
597
|
+
from qc_tool.fingerprint import write_fingerprint
|
|
598
|
+
|
|
599
|
+
password = None
|
|
600
|
+
sources = sum(
|
|
601
|
+
value is not None and value is not False
|
|
602
|
+
for value in (ns.password_env, ns.password_file, ns.password_prompt)
|
|
603
|
+
)
|
|
604
|
+
if sources > 1:
|
|
605
|
+
raise ValueError("choose only one fingerprint password source")
|
|
606
|
+
if ns.password_env:
|
|
607
|
+
if ns.password_env not in os.environ:
|
|
608
|
+
raise ValueError(f"environment variable {ns.password_env!r} is not set")
|
|
609
|
+
password = os.environ[ns.password_env]
|
|
610
|
+
elif ns.password_file:
|
|
611
|
+
if os.name == "posix" and ns.password_file.stat().st_mode & 0o077:
|
|
612
|
+
raise ValueError(
|
|
613
|
+
f"password file {ns.password_file} must not be group/world accessible"
|
|
614
|
+
)
|
|
615
|
+
password = ns.password_file.read_text(encoding="utf-8").rstrip("\r\n")
|
|
616
|
+
elif ns.password_prompt:
|
|
617
|
+
password = getpass.getpass(f"Password for {ns.source.name}: ")
|
|
618
|
+
payload = write_fingerprint(ns.source, ns.output, password=password)
|
|
619
|
+
print(f"fingerprint: {payload['fingerprint_id']} -> {ns.output}")
|
|
620
|
+
return 0
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _verify_attestation_parser() -> argparse.ArgumentParser:
|
|
624
|
+
parser = argparse.ArgumentParser(
|
|
625
|
+
prog=f"{_program_name()} verify-attestation",
|
|
626
|
+
description="Verify an attestation HMAC and every bundled member hash.",
|
|
627
|
+
)
|
|
628
|
+
parser.add_argument("source", type=Path)
|
|
629
|
+
parser.add_argument("--data-dir", type=Path, default=None)
|
|
630
|
+
parser.add_argument("--key-file", type=Path, default=None)
|
|
631
|
+
return parser
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _cmd_verify_attestation(args: list[str]) -> int:
|
|
635
|
+
ns = _verify_attestation_parser().parse_args(args)
|
|
636
|
+
from qc_tool.attestation import (
|
|
637
|
+
load_attestation_key,
|
|
638
|
+
load_or_create_attestation_key,
|
|
639
|
+
verify_attestation,
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
if ns.key_file is not None:
|
|
643
|
+
key = load_attestation_key(ns.key_file)
|
|
644
|
+
else:
|
|
645
|
+
_, key = load_or_create_attestation_key(ns.data_dir or default_data_dir())
|
|
646
|
+
result = verify_attestation(ns.source, key=key)
|
|
647
|
+
for issue in result.issues:
|
|
648
|
+
print(f"invalid {issue.code:20s} {issue.message}")
|
|
649
|
+
print(
|
|
650
|
+
f"attestation: {'VALID' if result.valid else 'INVALID'} "
|
|
651
|
+
f"key_id={result.key_id} issues={len(result.issues)}"
|
|
652
|
+
)
|
|
653
|
+
return 0 if result.valid else 2
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _network_parser() -> argparse.ArgumentParser:
|
|
657
|
+
parser = argparse.ArgumentParser(
|
|
658
|
+
prog=f"{_program_name()} network",
|
|
659
|
+
description=(
|
|
660
|
+
"Persist fail-safe server exposure: local loopback or temporary "
|
|
661
|
+
"unauthenticated LAN/WAN-capable binding."
|
|
662
|
+
),
|
|
663
|
+
)
|
|
664
|
+
parser.add_argument("action", choices=["status", "local", "lan"])
|
|
665
|
+
parser.add_argument("--minutes", type=int, default=None)
|
|
666
|
+
parser.add_argument("--data-dir", type=Path, default=None)
|
|
667
|
+
return parser
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _cmd_network(args: list[str]) -> int:
|
|
671
|
+
ns = _network_parser().parse_args(args)
|
|
672
|
+
from qc_tool.server_config import (
|
|
673
|
+
load_server_config,
|
|
674
|
+
local_config,
|
|
675
|
+
save_server_config,
|
|
676
|
+
temporary_lan_config,
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
data_dir = ns.data_dir or default_data_dir()
|
|
680
|
+
if ns.action == "local":
|
|
681
|
+
if ns.minutes is not None:
|
|
682
|
+
raise ValueError("--minutes only applies to network lan")
|
|
683
|
+
config = local_config()
|
|
684
|
+
save_server_config(data_dir, config)
|
|
685
|
+
elif ns.action == "lan":
|
|
686
|
+
if ns.minutes is None:
|
|
687
|
+
raise ValueError("network lan requires --minutes (1-1440)")
|
|
688
|
+
config = temporary_lan_config(ns.minutes)
|
|
689
|
+
save_server_config(data_dir, config)
|
|
690
|
+
else:
|
|
691
|
+
if ns.minutes is not None:
|
|
692
|
+
raise ValueError("--minutes does not apply to network status")
|
|
693
|
+
config = load_server_config(data_dir)
|
|
694
|
+
print(f"network: {config.network.value} host={config.host}")
|
|
695
|
+
if config.expires_at is not None:
|
|
696
|
+
print(f"expires: {config.expires_at.isoformat(timespec='seconds')}")
|
|
697
|
+
if config.network.value == "lan":
|
|
698
|
+
print("warning: no built-in authentication or TLS; router forwarding is separate")
|
|
699
|
+
return 0
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
# --- lint --------------------------------------------------------------------------
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def _lint_parser() -> argparse.ArgumentParser:
|
|
706
|
+
parser = argparse.ArgumentParser(
|
|
707
|
+
prog=f"{_program_name()} lint",
|
|
708
|
+
description="Validate a deliverable profile, optionally against real files.",
|
|
709
|
+
)
|
|
710
|
+
parser.add_argument("profile", type=Path, help="profile YAML path")
|
|
711
|
+
parser.add_argument("--against-excel", type=Path, default=None, metavar="XLSX")
|
|
712
|
+
parser.add_argument("--against-ppt", type=Path, default=None, metavar="PPTX")
|
|
713
|
+
return parser
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _cmd_lint(args: list[str]) -> int:
|
|
717
|
+
ns = _lint_parser().parse_args(args)
|
|
718
|
+
from qc_tool.config.lint import lint_profile
|
|
719
|
+
from qc_tool.config.profile import load_profile
|
|
720
|
+
|
|
721
|
+
try:
|
|
722
|
+
profile = load_profile(ns.profile)
|
|
723
|
+
except Exception as exc:
|
|
724
|
+
print(f"error: profile failed to load: {exc}", file=sys.stderr)
|
|
725
|
+
return 2
|
|
726
|
+
workbook = deck = None
|
|
727
|
+
if ns.against_excel is not None:
|
|
728
|
+
from qc_tool.io.loader import load_workbook_snapshot
|
|
729
|
+
|
|
730
|
+
workbook = load_workbook_snapshot(ns.against_excel)
|
|
731
|
+
if ns.against_ppt is not None:
|
|
732
|
+
from qc_tool.ppt.extract import load_deck_snapshot
|
|
733
|
+
|
|
734
|
+
deck = load_deck_snapshot(ns.against_ppt)
|
|
735
|
+
|
|
736
|
+
issues = lint_profile(profile, workbook=workbook, deck=deck)
|
|
737
|
+
for issue in issues:
|
|
738
|
+
print(f"{issue.level:7s} {issue.where}: {issue.message}")
|
|
739
|
+
errors = sum(1 for issue in issues if issue.level == "error")
|
|
740
|
+
warnings = len(issues) - errors
|
|
741
|
+
print(f"lint: {errors} error(s), {warnings} warning(s)")
|
|
742
|
+
return 2 if errors else 0
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
# --- dispatch -----------------------------------------------------------------------
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def main(argv: list[str] | None = None) -> int:
|
|
749
|
+
args = list(sys.argv[1:]) if argv is None else list(argv)
|
|
750
|
+
if args and args[0] in _SUBCOMMANDS:
|
|
751
|
+
command, rest = args[0], args[1:]
|
|
752
|
+
else:
|
|
753
|
+
command, rest = "serve", args # backward compatible bare invocation
|
|
754
|
+
handlers = {
|
|
755
|
+
"serve": _cmd_serve,
|
|
756
|
+
"run": _cmd_run,
|
|
757
|
+
"sanitize": _cmd_sanitize,
|
|
758
|
+
"sanitize-package": _cmd_sanitize_package,
|
|
759
|
+
"verify-sanitized": _cmd_verify_sanitized,
|
|
760
|
+
"fingerprint": _cmd_fingerprint,
|
|
761
|
+
"verify-attestation": _cmd_verify_attestation,
|
|
762
|
+
"network": _cmd_network,
|
|
763
|
+
"lint": _cmd_lint,
|
|
764
|
+
}
|
|
765
|
+
try:
|
|
766
|
+
return handlers[command](rest)
|
|
767
|
+
except (OSError, ValueError) as exc:
|
|
768
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
769
|
+
return 1
|