countersign-cli 0.2.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.
- countersign/__init__.py +8 -0
- countersign/__main__.py +4 -0
- countersign/claims.py +245 -0
- countersign/claimsdiff.py +134 -0
- countersign/cli.py +411 -0
- countersign/config.py +213 -0
- countersign/engine.py +309 -0
- countersign/jsscan.py +422 -0
- countersign/pack.py +243 -0
- countersign/plain.py +111 -0
- countersign/receipt.py +255 -0
- countersign/register.py +205 -0
- countersign/reportclaims.py +182 -0
- countersign/reproduce.py +167 -0
- countersign/starter.py +242 -0
- countersign/stubscan.py +257 -0
- countersign_cli-0.2.0.dist-info/METADATA +152 -0
- countersign_cli-0.2.0.dist-info/RECORD +23 -0
- countersign_cli-0.2.0.dist-info/WHEEL +5 -0
- countersign_cli-0.2.0.dist-info/entry_points.txt +2 -0
- countersign_cli-0.2.0.dist-info/licenses/LICENSE +202 -0
- countersign_cli-0.2.0.dist-info/licenses/NOTICE +5 -0
- countersign_cli-0.2.0.dist-info/top_level.txt +1 -0
countersign/engine.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
# audited on 20260903
|
|
2
|
+
"""The gate: run every check, write every result to the register.
|
|
3
|
+
|
|
4
|
+
One verify run does five things, in this order:
|
|
5
|
+
|
|
6
|
+
1. Records what it is about to read (config, claims, git state) with SHA-256
|
|
7
|
+
fingerprints, so a later reproduce can prove it read the same files.
|
|
8
|
+
This entry is on disk before any check runs: a run that is killed
|
|
9
|
+
halfway still left a trace of having started.
|
|
10
|
+
2. Runs the marker scan and appends every finding to the register.
|
|
11
|
+
3. Runs every declared claim's command and appends each verdict as soon as
|
|
12
|
+
it is known. A claim the config requires but the file does not declare
|
|
13
|
+
is recorded as missing.
|
|
14
|
+
4. When a base revision is given, diffs the claims file against it and
|
|
15
|
+
records every change; a weakened claim fails the gate unless the config
|
|
16
|
+
says otherwise.
|
|
17
|
+
5. Records the verdict and the register head.
|
|
18
|
+
|
|
19
|
+
The verdict rule is deliberately simple enough to check by hand: any
|
|
20
|
+
finding, any failed, timed-out or missing claim, or any weakened claim
|
|
21
|
+
means the run is not countersigned. A skipped check (no claims file
|
|
22
|
+
declared) is reported as a skip on the receipt, never silently folded into
|
|
23
|
+
a pass.
|
|
24
|
+
|
|
25
|
+
A claims file or config that cannot be honoured raises before anything is
|
|
26
|
+
written; a run that dies after it started appends a ``run_aborted`` entry
|
|
27
|
+
on its way out so the register never shows a start without an ending.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import subprocess
|
|
33
|
+
import uuid
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
|
+
from datetime import datetime, timezone
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
|
|
38
|
+
from . import __version__
|
|
39
|
+
from .claims import NOT_PASSED, ClaimResult, load_claims, missing_claim, run_claim
|
|
40
|
+
from .claimsdiff import ClaimChange, diff_against_ref
|
|
41
|
+
from .config import Config, ConfigError, file_sha256
|
|
42
|
+
from .register import Register
|
|
43
|
+
from .stubscan import Finding, scan_tree
|
|
44
|
+
|
|
45
|
+
PASS_VERDICT = "pass"
|
|
46
|
+
FAIL_VERDICT = "fail"
|
|
47
|
+
|
|
48
|
+
TEST_EXCLUSION_NOTE = "test files were excluded from the marker scan by policy (exclude_tests = true)"
|
|
49
|
+
GIT_NOT_AVAILABLE = "git not available"
|
|
50
|
+
GIT_NOT_A_REPOSITORY = "not a git repository"
|
|
51
|
+
GIT_NO_COMMITS = "no commits yet"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class GateResult:
|
|
56
|
+
run_id: str
|
|
57
|
+
recorded_at: str
|
|
58
|
+
verdict: str
|
|
59
|
+
config_path: str
|
|
60
|
+
config_sha256: str
|
|
61
|
+
claims_sha256: str | None
|
|
62
|
+
git_commit: str
|
|
63
|
+
files_scanned: int
|
|
64
|
+
git_dirty: bool | None = None
|
|
65
|
+
tests_excluded: bool = True
|
|
66
|
+
findings: list[Finding] = field(default_factory=list)
|
|
67
|
+
exemptions: int = 0
|
|
68
|
+
claim_results: list[ClaimResult] | None = None
|
|
69
|
+
claims_status: str = "skipped"
|
|
70
|
+
claims_base: str | None = None
|
|
71
|
+
claims_diff: list[ClaimChange] | None = None
|
|
72
|
+
claims_base_problem: str | None = None
|
|
73
|
+
register_index: int = 0
|
|
74
|
+
register_hash: str = ""
|
|
75
|
+
duration_ms: int = 0
|
|
76
|
+
notes: list[str] = field(default_factory=list)
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def failed_claims(self) -> list[ClaimResult]:
|
|
80
|
+
if self.claim_results is None:
|
|
81
|
+
return []
|
|
82
|
+
return [c for c in self.claim_results if c.status in NOT_PASSED]
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def weakened_claims(self) -> list[ClaimChange]:
|
|
86
|
+
return [c for c in (self.claims_diff or []) if c.weakened]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _git(root: Path, *args: str) -> subprocess.CompletedProcess[str] | None:
|
|
90
|
+
try:
|
|
91
|
+
return subprocess.run(
|
|
92
|
+
["git", *args],
|
|
93
|
+
cwd=str(root), capture_output=True, text=True, timeout=15,
|
|
94
|
+
)
|
|
95
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _git_state(root: Path) -> tuple[str, bool | None]:
|
|
100
|
+
"""(commit, dirty). ``dirty`` is None when it could not be determined.
|
|
101
|
+
|
|
102
|
+
A receipt that names a commit while the scanned files differ from that
|
|
103
|
+
commit would misstate what was checked, so the working tree state is
|
|
104
|
+
recorded next to the hash. Untracked files count as dirty: they are
|
|
105
|
+
scanned, and the commit does not contain them.
|
|
106
|
+
"""
|
|
107
|
+
inside = _git(root, "rev-parse", "--is-inside-work-tree")
|
|
108
|
+
if inside is None:
|
|
109
|
+
return GIT_NOT_AVAILABLE, None
|
|
110
|
+
if inside.returncode != 0 or inside.stdout.strip() != "true":
|
|
111
|
+
return GIT_NOT_A_REPOSITORY, None
|
|
112
|
+
head = _git(root, "rev-parse", "--verify", "HEAD")
|
|
113
|
+
if head is None:
|
|
114
|
+
return GIT_NOT_AVAILABLE, None
|
|
115
|
+
if head.returncode != 0:
|
|
116
|
+
# An initialised repository with nothing committed: every scanned
|
|
117
|
+
# file is uncommitted by definition.
|
|
118
|
+
return GIT_NO_COMMITS, True
|
|
119
|
+
commit = head.stdout.strip()
|
|
120
|
+
status = _git(root, "status", "--porcelain", "--untracked-files=normal")
|
|
121
|
+
if status is None or status.returncode != 0:
|
|
122
|
+
return commit, None
|
|
123
|
+
return commit, bool(status.stdout.strip())
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def run_gate(config: Config, *, register: Register | None = None, claims_base: str | None = None) -> GateResult:
|
|
127
|
+
"""Run every check. Appends evidence to the register; writes nothing else."""
|
|
128
|
+
config_path = Path(config.config_path)
|
|
129
|
+
if not config_path.is_file():
|
|
130
|
+
raise ConfigError(f"no config file at {config_path}; the run would have nothing to fingerprint")
|
|
131
|
+
if claims_base and not config.claims_file:
|
|
132
|
+
raise ConfigError("a claims base revision was given but no claims file is configured")
|
|
133
|
+
|
|
134
|
+
started = datetime.now(timezone.utc)
|
|
135
|
+
run_id = f"{started.strftime('%Y%m%dT%H%M%S')}-{uuid.uuid4().hex[:8]}"
|
|
136
|
+
register = register or Register(config.register_path())
|
|
137
|
+
|
|
138
|
+
# Everything that can refuse the run refuses here, before any evidence
|
|
139
|
+
# is written: a bad config, claims file or base revision is a usage
|
|
140
|
+
# error, not a run.
|
|
141
|
+
files = config.collect_files()
|
|
142
|
+
claims_path = config.claims_path()
|
|
143
|
+
claims_sha256 = file_sha256(claims_path) if claims_path else None
|
|
144
|
+
config_sha256 = file_sha256(config_path)
|
|
145
|
+
|
|
146
|
+
notes: list[str] = []
|
|
147
|
+
if config.exclude_tests:
|
|
148
|
+
notes.append(TEST_EXCLUSION_NOTE)
|
|
149
|
+
|
|
150
|
+
claims = None
|
|
151
|
+
if not config.claims_file:
|
|
152
|
+
notes.append("the claims check was skipped by request (no claims file configured); a skipped check is not a passed check")
|
|
153
|
+
elif claims_path is None:
|
|
154
|
+
notes.append(f"no claims file found at {config.claims_file}; the claims check was skipped, not passed")
|
|
155
|
+
else:
|
|
156
|
+
claims = load_claims(claims_path)
|
|
157
|
+
|
|
158
|
+
declared_ids = {c.claim_id for c in (claims or [])}
|
|
159
|
+
missing_ids = [claim_id for claim_id in config.required_claims if claim_id not in declared_ids]
|
|
160
|
+
if missing_ids:
|
|
161
|
+
notes.append(
|
|
162
|
+
f"{len(missing_ids)} claim(s) required by the config are not declared: {', '.join(missing_ids)}; "
|
|
163
|
+
"each is recorded as missing and fails the gate"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
changes: list[ClaimChange] | None = None
|
|
167
|
+
base_problem: str | None = None
|
|
168
|
+
if claims_base:
|
|
169
|
+
changes, base_problem = diff_against_ref(config.root, claims_base, config.claims_file or "", claims)
|
|
170
|
+
if base_problem:
|
|
171
|
+
notes.append(f"claims at {claims_base} could not be parsed ({base_problem}); every current claim is shown as added")
|
|
172
|
+
|
|
173
|
+
git_commit, git_dirty = _git_state(config.root)
|
|
174
|
+
|
|
175
|
+
started_entry = register.append(
|
|
176
|
+
"run_started",
|
|
177
|
+
{
|
|
178
|
+
"run_id": run_id,
|
|
179
|
+
"countersign_version": __version__,
|
|
180
|
+
"git_commit": git_commit,
|
|
181
|
+
"git_dirty": git_dirty,
|
|
182
|
+
"inputs": [
|
|
183
|
+
{"role": "config", "path": str(config_path), "sha256": config_sha256},
|
|
184
|
+
*(
|
|
185
|
+
[{"role": "claims", "path": str(claims_path), "sha256": claims_sha256}]
|
|
186
|
+
if claims_path
|
|
187
|
+
else []
|
|
188
|
+
),
|
|
189
|
+
],
|
|
190
|
+
"files_scanned": len(files),
|
|
191
|
+
"tests_excluded": config.exclude_tests,
|
|
192
|
+
"required_claims": list(config.required_claims),
|
|
193
|
+
"claims_base": claims_base,
|
|
194
|
+
"notes": notes,
|
|
195
|
+
},
|
|
196
|
+
at=started,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
findings, exemptions, inert_markers, files_scanned = scan_tree(config, files)
|
|
201
|
+
if inert_markers:
|
|
202
|
+
notes.append(
|
|
203
|
+
f"{inert_markers} exemption marker(s) sit on lines no rule flags; they suppress nothing today "
|
|
204
|
+
"and are not counted as used, but would suppress a finding if those lines changed"
|
|
205
|
+
)
|
|
206
|
+
for finding in findings:
|
|
207
|
+
register.append(
|
|
208
|
+
"finding",
|
|
209
|
+
{
|
|
210
|
+
"run_id": run_id,
|
|
211
|
+
"path": finding.path,
|
|
212
|
+
"line": finding.line,
|
|
213
|
+
"rule_id": finding.rule_id,
|
|
214
|
+
"why": finding.why,
|
|
215
|
+
"evidence": finding.evidence,
|
|
216
|
+
},
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
claim_results: list[ClaimResult] | None = None
|
|
220
|
+
claims_status = "skipped"
|
|
221
|
+
if claims is not None or missing_ids:
|
|
222
|
+
claim_results = []
|
|
223
|
+
claims_status = "ran"
|
|
224
|
+
for claim in claims or []:
|
|
225
|
+
result = run_claim(claim, config.root, config.timeout_s, config.max_output_bytes)
|
|
226
|
+
claim_results.append(result)
|
|
227
|
+
register.append("claim", {"run_id": run_id, **_claim_body(result)})
|
|
228
|
+
for claim_id in missing_ids:
|
|
229
|
+
result = missing_claim(claim_id)
|
|
230
|
+
claim_results.append(result)
|
|
231
|
+
register.append("claim", {"run_id": run_id, **_claim_body(result)})
|
|
232
|
+
|
|
233
|
+
if changes is not None:
|
|
234
|
+
register.append(
|
|
235
|
+
"claims_diff",
|
|
236
|
+
{
|
|
237
|
+
"run_id": run_id,
|
|
238
|
+
"base": claims_base,
|
|
239
|
+
"base_problem": base_problem,
|
|
240
|
+
"changes": [change.__dict__ for change in changes],
|
|
241
|
+
},
|
|
242
|
+
)
|
|
243
|
+
except BaseException as exc:
|
|
244
|
+
register.append("run_aborted", {"run_id": run_id, "reason": f"{type(exc).__name__}: {exc}"[:500]})
|
|
245
|
+
raise
|
|
246
|
+
|
|
247
|
+
verdict = FAIL_VERDICT if findings else PASS_VERDICT
|
|
248
|
+
if any(result.status in NOT_PASSED for result in (claim_results or [])):
|
|
249
|
+
verdict = FAIL_VERDICT
|
|
250
|
+
weakened = [c for c in (changes or []) if c.weakened]
|
|
251
|
+
if weakened:
|
|
252
|
+
if config.fail_on_weakened:
|
|
253
|
+
verdict = FAIL_VERDICT
|
|
254
|
+
notes.append(f"{len(weakened)} claim(s) weakened against {claims_base}; the gate fails on weakened claims (fail_on_weakened = true)")
|
|
255
|
+
else:
|
|
256
|
+
notes.append(f"{len(weakened)} claim(s) weakened against {claims_base}; recorded, not failed (fail_on_weakened = false)")
|
|
257
|
+
|
|
258
|
+
finished = datetime.now(timezone.utc)
|
|
259
|
+
head = register.append(
|
|
260
|
+
"run_finished",
|
|
261
|
+
{
|
|
262
|
+
"run_id": run_id,
|
|
263
|
+
"verdict": verdict,
|
|
264
|
+
"findings": len(findings),
|
|
265
|
+
"exemptions": exemptions,
|
|
266
|
+
"claims_status": claims_status,
|
|
267
|
+
"claims_total": len(claim_results) if claim_results is not None else 0,
|
|
268
|
+
"claims_failed": len([r for r in (claim_results or []) if r.status in NOT_PASSED]),
|
|
269
|
+
"claims_weakened": len(weakened),
|
|
270
|
+
},
|
|
271
|
+
at=finished,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
return GateResult(
|
|
275
|
+
run_id=run_id,
|
|
276
|
+
recorded_at=finished.isoformat(),
|
|
277
|
+
verdict=verdict,
|
|
278
|
+
config_path=str(config_path),
|
|
279
|
+
config_sha256=config_sha256,
|
|
280
|
+
claims_sha256=claims_sha256,
|
|
281
|
+
git_commit=started_entry["body"]["git_commit"],
|
|
282
|
+
git_dirty=git_dirty,
|
|
283
|
+
files_scanned=files_scanned,
|
|
284
|
+
tests_excluded=config.exclude_tests,
|
|
285
|
+
findings=findings,
|
|
286
|
+
exemptions=exemptions,
|
|
287
|
+
claim_results=claim_results,
|
|
288
|
+
claims_status=claims_status,
|
|
289
|
+
claims_base=claims_base,
|
|
290
|
+
claims_diff=changes,
|
|
291
|
+
claims_base_problem=base_problem,
|
|
292
|
+
register_index=head["index"],
|
|
293
|
+
register_hash=head["hash"],
|
|
294
|
+
duration_ms=int((finished - started).total_seconds() * 1000),
|
|
295
|
+
notes=notes,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _claim_body(result: ClaimResult) -> dict:
|
|
300
|
+
return {
|
|
301
|
+
"claim_id": result.claim_id,
|
|
302
|
+
"statement": result.statement,
|
|
303
|
+
"command": result.command,
|
|
304
|
+
"expect": result.expect,
|
|
305
|
+
"status": result.status,
|
|
306
|
+
"exit_code": result.exit_code,
|
|
307
|
+
"duration_ms": result.duration_ms,
|
|
308
|
+
"output_excerpt": result.output_excerpt,
|
|
309
|
+
}
|