@1aboveio/skills 0.20.1 → 0.20.3
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.
- package/README.md +12 -5
- package/package.json +1 -1
- package/runtime/skills/distribution/generated/recipes.json +220 -25
- package/runtime/skills/distribution/scripts/bundles.mjs +49 -5
- package/runtime/skills/engineering/engineering-runtime/scripts/workflow-coherence.mjs +4 -0
- package/runtime/skills/engineering/engineering-runtime/scripts/workflow-policy.mjs +1 -1
- package/skills/cicd-pipeline/cloud-build/SKILL.md +6 -6
- package/skills/compliance/compliance-audit/LICENSE +3 -0
- package/skills/compliance/compliance-audit/SKILL.md +466 -0
- package/skills/compliance/compliance-audit/references/findings-schema.md +290 -0
- package/skills/compliance/compliance-audit/references/vars-checklist.md +1068 -0
- package/skills/compliance/compliance-audit/references/vars-findings-schema.md +395 -0
- package/skills/compliance/compliance-audit/scripts/reconcile_tracker.py +454 -0
- package/skills/compliance/compliance-audit/scripts/test_validate_tracker.py +90 -0
- package/skills/compliance/compliance-audit/scripts/validate_tracker.py +752 -0
- package/skills/compliance/doc-authenticity/LICENSE +3 -0
- package/skills/compliance/doc-authenticity/SKILL.md +158 -0
- package/skills/compliance/doc-authenticity/references/agreement.md +66 -0
- package/skills/compliance/doc-authenticity/references/bank-statement.md +150 -0
- package/skills/compliance/doc-authenticity/references/forensic-signals.md +157 -0
- package/skills/compliance/doc-authenticity/references/image-signals.md +114 -0
- package/skills/compliance/doc-authenticity/references/invoice.md +66 -0
- package/skills/compliance/doc-authenticity/references/issuer-profiles.md +86 -0
- package/skills/compliance/doc-authenticity/references/verdict-and-report.md +95 -0
- package/skills/compliance/doc-authenticity/references/verdict.schema.json +81 -0
- package/skills/compliance/doc-authenticity/scripts/image_forensics.py +294 -0
- package/skills/compliance/doc-authenticity/scripts/pdf_forensics.py +401 -0
- package/skills/compliance/doc-authenticity/scripts/validate_codes.py +224 -0
- package/skills/compliance/doc-authenticity/scripts/validate_verdict.py +308 -0
- package/skills/compliance/report-writing/LICENSE +3 -0
- package/skills/compliance/report-writing/SKILL.md +206 -0
- package/skills/compliance/report-writing/references/audit-report-template.md +121 -0
- package/skills/compliance/report-writing/references/external-assessment-template.md +167 -0
- package/skills/compliance/report-writing/references/management-report-template.md +145 -0
- package/skills/compliance/report-writing/references/vars-report-template.md +224 -0
- package/skills/document-utils/doc-to-md/LICENSE +3 -0
- package/skills/document-utils/doc-to-md/SKILL.md +77 -0
- package/skills/document-utils/doc-to-md/scripts/convert.py +157 -0
- package/skills/document-utils/reg-parser/LICENSE +3 -0
- package/skills/document-utils/reg-parser/SKILL.md +201 -0
- package/skills/document-utils/reg-parser/references/checklist-schema.md +223 -0
- package/skills/document-utils/reg-parser/references/checklists/hk-amlo-checklist.json +1306 -0
- package/skills/document-utils/reg-parser/references/checklists/hk-amlo-checklist.md +469 -0
- package/skills/document-utils/reg-parser/references/checklists/hk-fi-corporate-onboarding-cdd-checklist.json +634 -0
- package/skills/document-utils/reg-parser/references/checklists/hk-fi-corporate-onboarding-cdd-checklist.md +233 -0
- package/skills/document-utils/reg-parser/references/checklists/hk-mso-guideline-checklist.json +2194 -0
- package/skills/document-utils/reg-parser/references/checklists/hk-mso-guideline-checklist.md +771 -0
- package/skills/document-utils/reg-parser/references/checklists/kyc-aml-checklist.md +258 -0
- package/skills/document-utils/reg-parser/references/checklists/visa-vars-checklist.md +1058 -0
- package/skills/document-utils/reg-parser/references/mandates/hk-amlo-cap615.md +389 -0
- package/skills/document-utils/reg-parser/references/mandates/visa-vars.md +205 -0
- package/skills/document-utils/reg-parser/scripts/compare_checklist.py +228 -0
- package/skills/document-utils/reg-parser/scripts/dump_checklist.py +348 -0
- package/skills/document-utils/reg-parser/scripts/preprocess-mandate.sh +171 -0
- package/skills/document-utils/reg-parser/scripts/test_quarantine.sh +102 -0
- package/skills/document-utils/reg-parser/scripts/validate_checklist.py +532 -0
- package/skills/document-utils/reg-parser/scripts/validate_mandate.py +242 -0
- package/skills/engineering/engineering-runtime/coherence/workflow.json +17 -17
- package/skills/engineering/engineering-runtime/scripts/workflow-coherence.mjs +4 -0
- package/skills/engineering/engineering-runtime/scripts/workflow-policy.mjs +1 -1
- package/skills/engineering/rush-release/SKILL.md +46 -25
- package/skills/engineering/rush-release/references/cut.md +21 -4
- package/skills/engineering/rush-release/references/preflight.md +25 -6
- package/skills/engineering/rush-release/references/promotion.md +70 -0
- package/skills/engineering/rush-release/references/publish.md +49 -16
- package/skills/engineering/rush-release/scripts/plan.mjs +33 -7
- package/skills/engineering/smoke/SKILL.md +4 -4
- package/skills/engineering/smoke/references/manifest.md +3 -3
- package/skills/payment/similar-domain/LICENSE +3 -0
- package/skills/payment/similar-domain/SKILL.md +178 -0
- package/skills/payment/similar-domain/references/entities.md +58 -0
- package/skills/payment/similar-domain/references/fingerprints.md +82 -0
- package/skills/payment/similar-domain/references/pivots.md +66 -0
- package/skills/payment/similar-domain/references/report-template.md +76 -0
- package/skills/payment/similar-domain/references/scoring.md +61 -0
- package/skills/payment/similar-domain/references/terminology.md +37 -0
- package/skills/payment/similar-domain/references/workflow.md +79 -0
- package/skills/payment/tld-detection/SKILL.md +76 -21
- package/skills/payment/tld-detection/references/clean.md +1 -1
- package/skills/payment/tld-detection/references/clustering.md +21 -6
- package/skills/payment/tld-detection/references/decision.md +7 -2
- package/skills/payment/tld-detection/references/fx-conversion.md +229 -0
- package/skills/payment/tld-detection/references/report-template.md +63 -27
- package/skills/payment/tld-detection/references/streams.md +90 -0
- package/skills/payment/tld-detection/references/terminology.md +15 -7
- package/skills/payment/tld-detection/references/workflow.md +1 -1
- package/skills/payment/tld-detection/scripts/detect_streams.py +542 -0
- package/skills/engineering/ensure-coverage/evals/evals.json +0 -125
- package/skills/engineering/rush-issues/evals/evals.json +0 -65
- package/skills/engineering/rush-release/evals/evals.json +0 -44
- package/skills/fullstack/shadcn/evals/evals.json +0 -90
- package/skills/payment/fraud-analysis/evals/evals.json +0 -40
- package/skills/payment/tld-detection/evals/evals.json +0 -107
- package/skills/payment/tld-detection/tests/test_detect_ladders.py +0 -132
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Validate a compliance audit tracker against its checklist and evidence.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
python3 validate_tracker.py <tracker.md> --checklist <checklist.md> [--evidence DIR] [--json] [--strict]
|
|
6
|
+
|
|
7
|
+
Checks (Step 9A — structural):
|
|
8
|
+
1. row_coverage Every Control ID in checklist scope has exactly one tracker row.
|
|
9
|
+
2. item_coverage Every numbered item from the checklist row appears as an "Control N:"
|
|
10
|
+
bullet in the row's Observations cell.
|
|
11
|
+
3. rating_value Control Effectiveness Rating is one of the four canonical
|
|
12
|
+
outcomes: Satisfactory / Improvement Recommended /
|
|
13
|
+
Unsatisfactory / N/A.
|
|
14
|
+
4. rubric_consistency Effectiveness rating obeys the VARS rubric:
|
|
15
|
+
- any mandatory item "Partial" or "Not addressed" => Unsatisfactory
|
|
16
|
+
- all mandatory met but a recommended item is missing/partial
|
|
17
|
+
(or a mandatory item can be strengthened) => Improvement Recommended
|
|
18
|
+
- all items Covered => Satisfactory
|
|
19
|
+
- rows rated N/A are exempt (scoping decision, not derivable).
|
|
20
|
+
5. gap_xref Each gap referenced in Issue Description by "(Control N)"
|
|
21
|
+
or "[Control N: ...]" must correspond to an item marked
|
|
22
|
+
"Not addressed" or "Partial" in the same row's Observations
|
|
23
|
+
cell. (Recommendations may also reference a "Covered"
|
|
24
|
+
mandatory item flagged for strengthening.)
|
|
25
|
+
|
|
26
|
+
Checks (Step 9B — citation grounding, requires --evidence):
|
|
27
|
+
6. citation_grounding For every "Covered in <doc> section <clause>" (or similar)
|
|
28
|
+
citation in Observations, the cited clause string must
|
|
29
|
+
appear in the corresponding evidence markdown file.
|
|
30
|
+
|
|
31
|
+
Exit codes:
|
|
32
|
+
0 = no errors (warnings allowed unless --strict).
|
|
33
|
+
1 = errors found.
|
|
34
|
+
|
|
35
|
+
Errors vs warnings:
|
|
36
|
+
- row_coverage missing rows -> ERROR
|
|
37
|
+
- item_coverage missing bullets -> ERROR
|
|
38
|
+
- rating_value not in canonical set -> ERROR
|
|
39
|
+
- rubric_consistency violations -> ERROR
|
|
40
|
+
- gap_xref dangling reference -> WARNING
|
|
41
|
+
- citation_grounding missing clause -> WARNING
|
|
42
|
+
- citation_grounding missing doc -> WARNING
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import argparse
|
|
48
|
+
import json
|
|
49
|
+
import re
|
|
50
|
+
import sys
|
|
51
|
+
from pathlib import Path
|
|
52
|
+
from typing import Optional
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# Item-ID normalization
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
_ITEM_ID_PUNCT = re.compile(r"[()\s]+")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def normalize_item_id(raw: str) -> str:
|
|
62
|
+
"""Canonicalise an item identifier.
|
|
63
|
+
|
|
64
|
+
Examples
|
|
65
|
+
--------
|
|
66
|
+
"(2.a)" -> "2.a"
|
|
67
|
+
"Control 2a" -> "2a"
|
|
68
|
+
"1.b" -> "1.b"
|
|
69
|
+
"""
|
|
70
|
+
s = raw.strip().lower()
|
|
71
|
+
s = re.sub(r"^control\s+", "", s)
|
|
72
|
+
s = _ITEM_ID_PUNCT.sub("", s)
|
|
73
|
+
# collapse "2.a" and "2a" to the same canonical form by stripping
|
|
74
|
+
# the dot when it sits between digit(s) and letter(s)
|
|
75
|
+
s = re.sub(r"^(\d+)\.([a-z])", r"\1\2", s)
|
|
76
|
+
s = re.sub(r"^(\d+)\.([ivx]+)$", r"\1.\2", s) # keep 3.i, 3.ii intact
|
|
77
|
+
return s
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def ancestor_canons(raw_id: str) -> set[str]:
|
|
81
|
+
"""Canonical ids of an item plus all its dotted ancestors.
|
|
82
|
+
|
|
83
|
+
"6.a.i" -> {"6", "6a", "6a.i"}. Used so a parent bullet (e.g. the tracker
|
|
84
|
+
listing "Control 6.a") satisfies coverage of its descendant checklist
|
|
85
|
+
items (6.a.i … 6.a.x), which auditors routinely roll up rather than
|
|
86
|
+
enumerate leaf-by-leaf.
|
|
87
|
+
"""
|
|
88
|
+
parts = raw_id.split(".")
|
|
89
|
+
return {normalize_item_id(".".join(parts[:k])) for k in range(1, len(parts) + 1)}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
# Checklist parsing
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
CONTROL_HEAD_RE = re.compile(r"^###\s+([A-Z][A-Z0-9]*\.C\d+(?:\.\d+)?):\s+(.+?)\s*$")
|
|
97
|
+
SEVERITY_RE = re.compile(r"^-\s+\*\*Severity:\*\*\s*(\w+)", re.IGNORECASE)
|
|
98
|
+
ITEM_LINE_RE = re.compile(
|
|
99
|
+
r"^\s*-\s+\[\s\]\s+\*\*\(([^)]+)\)\*\*\s*(\(Recommended\)\s*)?(.*)$",
|
|
100
|
+
re.IGNORECASE,
|
|
101
|
+
)
|
|
102
|
+
SUB_ITEM_LINE_RE = re.compile(
|
|
103
|
+
r"^\s+-\s+\[\s\]\s+\*\*\(([^)]+)\)\*\*\s*(\(Recommended\)\s*)?(.*)$",
|
|
104
|
+
re.IGNORECASE,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class ChecklistItem:
|
|
109
|
+
__slots__ = ("id", "canonical", "mandatory", "text")
|
|
110
|
+
|
|
111
|
+
def __init__(self, raw_id: str, mandatory: bool, text: str):
|
|
112
|
+
self.id = raw_id
|
|
113
|
+
self.canonical = normalize_item_id(raw_id)
|
|
114
|
+
self.mandatory = mandatory
|
|
115
|
+
self.text = text
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class ChecklistControl:
|
|
119
|
+
__slots__ = ("id", "title", "severity", "items")
|
|
120
|
+
|
|
121
|
+
def __init__(self, ctrl_id: str, title: str, severity: str):
|
|
122
|
+
self.id = ctrl_id
|
|
123
|
+
self.title = title
|
|
124
|
+
self.severity = severity.lower() # "mandatory" or "recommended"
|
|
125
|
+
self.items: list[ChecklistItem] = []
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def parse_checklist(path: Path) -> dict[str, ChecklistControl]:
|
|
129
|
+
controls: dict[str, ChecklistControl] = {}
|
|
130
|
+
current: Optional[ChecklistControl] = None
|
|
131
|
+
in_verification = False
|
|
132
|
+
|
|
133
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
134
|
+
m = CONTROL_HEAD_RE.match(line)
|
|
135
|
+
if m:
|
|
136
|
+
current = ChecklistControl(m.group(1), m.group(2).strip(), "mandatory")
|
|
137
|
+
controls[current.id] = current
|
|
138
|
+
in_verification = False
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
if current is None:
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
sev = SEVERITY_RE.match(line)
|
|
145
|
+
if sev:
|
|
146
|
+
current.severity = sev.group(1).lower()
|
|
147
|
+
continue
|
|
148
|
+
|
|
149
|
+
if line.strip().startswith("- **Verification:**"):
|
|
150
|
+
in_verification = True
|
|
151
|
+
continue
|
|
152
|
+
if line.strip().startswith("- **Evidence:**") or line.strip().startswith("- **Source:**"):
|
|
153
|
+
in_verification = False
|
|
154
|
+
continue
|
|
155
|
+
|
|
156
|
+
if not in_verification:
|
|
157
|
+
continue
|
|
158
|
+
|
|
159
|
+
m = ITEM_LINE_RE.match(line) or SUB_ITEM_LINE_RE.match(line)
|
|
160
|
+
if not m:
|
|
161
|
+
continue
|
|
162
|
+
raw_id, recommended_marker, text = m.group(1), m.group(2), m.group(3)
|
|
163
|
+
# An item is recommended if the control is recommended-severity, the line
|
|
164
|
+
# carries the (Recommended) marker, or the id is R-prefixed (R1, R1.a, …)
|
|
165
|
+
# — the last covers sub-items of a recommended parent, which inherit the
|
|
166
|
+
# prefix but not the inline marker.
|
|
167
|
+
is_rec_id = normalize_item_id(raw_id).startswith("r")
|
|
168
|
+
mandatory = current.severity == "mandatory" and not recommended_marker and not is_rec_id
|
|
169
|
+
current.items.append(ChecklistItem(raw_id, mandatory, text.strip()))
|
|
170
|
+
|
|
171
|
+
return controls
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
# Tracker parsing
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
TABLE_HEADER_RE = re.compile(r"^\|\s*Control ID\s*\|", re.IGNORECASE)
|
|
179
|
+
SEPARATOR_RE = re.compile(r"^\|[\s\-:|]+\|\s*$")
|
|
180
|
+
PHASES_RE = re.compile(r"\*\*Audit Phases Completed:\*\*\s*(.+?)\s*$", re.IGNORECASE)
|
|
181
|
+
ARCHETYPES_RE = re.compile(r"\*\*Archetypes In Scope:\*\*\s*(.+?)\s*$", re.IGNORECASE)
|
|
182
|
+
SCOPE_RE = re.compile(r"\*\*Scope:\*\*\s*(.+?)\s*$", re.IGNORECASE)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class TrackerRow:
|
|
186
|
+
__slots__ = ("control_id", "title", "archetype", "observations", "issue", "rating", "remediation", "complete_by", "status", "raw")
|
|
187
|
+
|
|
188
|
+
def __init__(self, cells: list[str]):
|
|
189
|
+
# Expected column order: Control ID | Control Title | Archetype | Observations
|
|
190
|
+
# | Issue Description | Control Effectiveness Rating | Proposed Remediation
|
|
191
|
+
# | Complete By | Remediation Status
|
|
192
|
+
# Pad cells defensively in case of trailing-empty stripping.
|
|
193
|
+
cells = cells + [""] * (9 - len(cells))
|
|
194
|
+
self.control_id = cells[0].strip()
|
|
195
|
+
self.title = cells[1].strip()
|
|
196
|
+
self.archetype = cells[2].strip()
|
|
197
|
+
self.observations = cells[3]
|
|
198
|
+
self.issue = cells[4]
|
|
199
|
+
self.rating = cells[5].strip()
|
|
200
|
+
self.remediation = cells[6]
|
|
201
|
+
self.complete_by = cells[7].strip()
|
|
202
|
+
self.status = cells[8]
|
|
203
|
+
self.raw = cells
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def parse_tracker(path: Path) -> tuple[list[str], list[str], Optional[str], list[TrackerRow]]:
|
|
207
|
+
"""Return (phases, archetypes_in_scope, scope_string, rows)."""
|
|
208
|
+
text = path.read_text(encoding="utf-8")
|
|
209
|
+
phases: list[str] = []
|
|
210
|
+
archetypes: list[str] = []
|
|
211
|
+
scope: Optional[str] = None
|
|
212
|
+
rows: list[TrackerRow] = []
|
|
213
|
+
|
|
214
|
+
in_table = False
|
|
215
|
+
for line in text.splitlines():
|
|
216
|
+
m = PHASES_RE.search(line)
|
|
217
|
+
if m:
|
|
218
|
+
phase_str = m.group(1).strip().strip("[]")
|
|
219
|
+
phases = [p.strip() for p in re.split(r"[,;]", phase_str) if p.strip()]
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
m = ARCHETYPES_RE.search(line)
|
|
223
|
+
if m:
|
|
224
|
+
arch_str = m.group(1).strip().strip("[]")
|
|
225
|
+
# Each token may be annotated, e.g. "AACQ (22 controls)". Keep only the
|
|
226
|
+
# leading archetype code so it matches a control's "AACQ.Cx.y" prefix;
|
|
227
|
+
# otherwise the scope set comes out empty and every row is flagged
|
|
228
|
+
# "outside declared scope" (and the missing-row check is disabled).
|
|
229
|
+
archetypes = [
|
|
230
|
+
am.group(1).upper()
|
|
231
|
+
for a in re.split(r"[,;]", arch_str)
|
|
232
|
+
if (am := re.match(r"\s*([A-Za-z]+)", a))
|
|
233
|
+
]
|
|
234
|
+
continue
|
|
235
|
+
|
|
236
|
+
m = SCOPE_RE.search(line)
|
|
237
|
+
if m:
|
|
238
|
+
scope = m.group(1).strip()
|
|
239
|
+
continue
|
|
240
|
+
|
|
241
|
+
if not in_table:
|
|
242
|
+
if TABLE_HEADER_RE.match(line):
|
|
243
|
+
in_table = True
|
|
244
|
+
continue
|
|
245
|
+
|
|
246
|
+
if SEPARATOR_RE.match(line):
|
|
247
|
+
continue
|
|
248
|
+
if not line.strip().startswith("|"):
|
|
249
|
+
in_table = False
|
|
250
|
+
continue
|
|
251
|
+
|
|
252
|
+
cells = [c.strip() for c in line.strip().strip("|").split("|")]
|
|
253
|
+
if not cells or not cells[0]:
|
|
254
|
+
continue
|
|
255
|
+
rows.append(TrackerRow(cells))
|
|
256
|
+
|
|
257
|
+
return phases, archetypes, scope, rows
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# ---------------------------------------------------------------------------
|
|
261
|
+
# Observation cell parsing
|
|
262
|
+
# ---------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
BULLET_SPLIT_RE = re.compile(r"\s*<br\s*/?>\s*|\n+", re.IGNORECASE)
|
|
265
|
+
BULLET_PREFIX_RE = re.compile(r"^[\s•\-\*]+")
|
|
266
|
+
OBS_BULLET_RE = re.compile(
|
|
267
|
+
r"^Control\s+([^:]+?):\s*(.*)$",
|
|
268
|
+
re.IGNORECASE,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class Observation:
|
|
273
|
+
__slots__ = ("item_id", "canonical", "status", "text")
|
|
274
|
+
|
|
275
|
+
def __init__(self, item_id: str, status: str, text: str):
|
|
276
|
+
self.item_id = item_id
|
|
277
|
+
self.canonical = normalize_item_id(item_id)
|
|
278
|
+
self.status = status # "covered" | "partial" | "not_addressed" | "unknown"
|
|
279
|
+
self.text = text
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def classify_status(text: str) -> str:
|
|
283
|
+
low = text.lower().strip()
|
|
284
|
+
if "not addressed" in low:
|
|
285
|
+
return "not_addressed"
|
|
286
|
+
if low.startswith("partial") or "partial —" in low or "partial -" in low:
|
|
287
|
+
return "partial"
|
|
288
|
+
if "covered in" in low or low.startswith("covered"):
|
|
289
|
+
return "covered"
|
|
290
|
+
return "unknown"
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def parse_observations(cell: str) -> list[Observation]:
|
|
294
|
+
out: list[Observation] = []
|
|
295
|
+
for chunk in BULLET_SPLIT_RE.split(cell):
|
|
296
|
+
chunk = BULLET_PREFIX_RE.sub("", chunk).strip()
|
|
297
|
+
if not chunk:
|
|
298
|
+
continue
|
|
299
|
+
m = OBS_BULLET_RE.match(chunk)
|
|
300
|
+
if not m:
|
|
301
|
+
continue
|
|
302
|
+
item_id = m.group(1).strip()
|
|
303
|
+
text = m.group(2).strip()
|
|
304
|
+
out.append(Observation(item_id, classify_status(text), text))
|
|
305
|
+
return out
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# ---------------------------------------------------------------------------
|
|
309
|
+
# Issue Description gap parsing
|
|
310
|
+
# ---------------------------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
# Matches both the legacy parenthetical form "(Control 1c)" and the enumerated
|
|
313
|
+
# bracket form "[Control 1.a: Partial]" / "[Control R1.a]". The item id is
|
|
314
|
+
# captured up to the first ":" , ")" or "]".
|
|
315
|
+
GAP_REF_RE = re.compile(r"[\[(]\s*Control\s+([^\]:)]+?)\s*[\]:)]", re.IGNORECASE)
|
|
316
|
+
|
|
317
|
+
# Split point between the two Issue Description groups.
|
|
318
|
+
REC_LABEL_RE = re.compile(r"\*{0,2}\s*recommendations\s*\*{0,2}\s*:", re.IGNORECASE)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def extract_gap_refs(cell: str) -> list[str]:
|
|
322
|
+
return [normalize_item_id(m) for m in GAP_REF_RE.findall(cell)]
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def split_issue_sections(cell: str) -> tuple[str, str]:
|
|
326
|
+
"""Split an Issue Description cell into (actions_required, recommendations).
|
|
327
|
+
|
|
328
|
+
Everything before the "Recommendations:" label is Actions Required; the
|
|
329
|
+
label and everything after is Recommendations. Works for both the legacy
|
|
330
|
+
inline format and the enumerated format.
|
|
331
|
+
"""
|
|
332
|
+
m = REC_LABEL_RE.search(cell)
|
|
333
|
+
if m:
|
|
334
|
+
return cell[: m.start()], cell[m.start():]
|
|
335
|
+
return cell, ""
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# ---------------------------------------------------------------------------
|
|
339
|
+
# Citation grounding
|
|
340
|
+
# ---------------------------------------------------------------------------
|
|
341
|
+
|
|
342
|
+
CITATION_RE = re.compile(
|
|
343
|
+
r"covered in\s+(?P<rest>[^•<]+?)(?=(?:\s+and\s+covered\s+in\s|<br|$|•))",
|
|
344
|
+
re.IGNORECASE,
|
|
345
|
+
)
|
|
346
|
+
# Clause marker is the word "section" (prescribed). Legacy "§" is still accepted
|
|
347
|
+
# so trackers written under the old convention keep grounding. The "section"
|
|
348
|
+
# form requires a following digit so document names containing the word
|
|
349
|
+
# "section" aren't mistaken for a clause marker.
|
|
350
|
+
CLAUSE_RE = re.compile(r"(?:§\s*|\b[Ss]ection\s+(?=\d))([^\s,;]+)")
|
|
351
|
+
CLAUSE_SPLIT_RE = re.compile(r"§|\b[Ss]ection\s+(?=\d)")
|
|
352
|
+
# Sentence-break markers that end a citation phrase. After the citation
|
|
353
|
+
# "Covered in X section 3.2(a)", any prose introduced by " — ", "; ", " -- ", or
|
|
354
|
+
# ". " (period + space) is descriptive, not part of the citation, and its
|
|
355
|
+
# clause references should NOT be treated as citations to ground.
|
|
356
|
+
CITATION_END_RE = re.compile(r"\s+(?:—|--|;|\.\s)")
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def list_evidence_files(evidence_dir: Path) -> list[Path]:
|
|
360
|
+
return sorted([p for p in evidence_dir.rglob("*.md") if p.is_file()])
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def normalize_doc_token(s: str) -> str:
|
|
364
|
+
return re.sub(r"[^a-z0-9]+", "", s.lower())
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def find_evidence_file(citation: str, evidence_files: list[Path]) -> Optional[Path]:
|
|
368
|
+
"""Best-effort match of a free-text doc reference to an evidence file."""
|
|
369
|
+
cite_norm = normalize_doc_token(citation)
|
|
370
|
+
if not cite_norm:
|
|
371
|
+
return None
|
|
372
|
+
best = None
|
|
373
|
+
best_overlap = 0
|
|
374
|
+
for path in evidence_files:
|
|
375
|
+
stem = normalize_doc_token(path.stem)
|
|
376
|
+
if not stem:
|
|
377
|
+
continue
|
|
378
|
+
# use longest common contiguous run as a cheap similarity score
|
|
379
|
+
overlap = 0
|
|
380
|
+
for length in range(min(len(stem), len(cite_norm)), 3, -1):
|
|
381
|
+
for i in range(len(cite_norm) - length + 1):
|
|
382
|
+
if cite_norm[i : i + length] in stem:
|
|
383
|
+
overlap = length
|
|
384
|
+
break
|
|
385
|
+
if overlap:
|
|
386
|
+
break
|
|
387
|
+
if overlap > best_overlap:
|
|
388
|
+
best, best_overlap = path, overlap
|
|
389
|
+
return best
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def is_clause_grounded(clause: str, body: str) -> bool:
|
|
393
|
+
"""Return True if the clause string is anchored in the evidence body.
|
|
394
|
+
|
|
395
|
+
Tolerates nested clause references — a cited ``section 3.2(a)(i)`` still grounds
|
|
396
|
+
when the evidence has ``3.2(a)`` as a heading and ``(i)`` listed as a
|
|
397
|
+
sub-item underneath. This matches how regulatory and contractual prose
|
|
398
|
+
is actually structured: parent headings carry the section number, child
|
|
399
|
+
bullets carry only the inner marker.
|
|
400
|
+
"""
|
|
401
|
+
needle = clause.strip().rstrip(".,;")
|
|
402
|
+
if not needle:
|
|
403
|
+
return True
|
|
404
|
+
if needle in body:
|
|
405
|
+
return True
|
|
406
|
+
# Walk up by stripping the trailing parenthetical group. If the parent
|
|
407
|
+
# appears in the body and the leaf (e.g. "(i)") is also somewhere in the
|
|
408
|
+
# body, accept the citation as grounded.
|
|
409
|
+
parent_match = re.search(r"\(([^()]*)\)$", needle)
|
|
410
|
+
if not parent_match:
|
|
411
|
+
return False
|
|
412
|
+
parent = needle[: parent_match.start()].rstrip(".")
|
|
413
|
+
leaf = needle[parent_match.start():]
|
|
414
|
+
if not parent:
|
|
415
|
+
return False
|
|
416
|
+
if parent in body and leaf in body:
|
|
417
|
+
return True
|
|
418
|
+
return False
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def check_citation(text: str, evidence_files: list[Path]) -> list[tuple[str, str]]:
|
|
422
|
+
"""Return list of (level, message) for citation issues in one bullet text."""
|
|
423
|
+
issues: list[tuple[str, str]] = []
|
|
424
|
+
for cite in CITATION_RE.finditer(text):
|
|
425
|
+
rest = cite.group("rest").strip().rstrip(".,;")
|
|
426
|
+
# Trim descriptive prose after the citation phrase. Without this, a
|
|
427
|
+
# bullet like "Covered in MA section 3.2(a) — also see section 99.99 for context"
|
|
428
|
+
# would falsely flag section 99.99 as a missing citation.
|
|
429
|
+
end = CITATION_END_RE.search(rest)
|
|
430
|
+
if end:
|
|
431
|
+
rest = rest[: end.start()].strip()
|
|
432
|
+
clauses = CLAUSE_RE.findall(rest)
|
|
433
|
+
# doc-name fragment is the part before the first clause marker
|
|
434
|
+
doc_part = CLAUSE_SPLIT_RE.split(rest, maxsplit=1)[0].strip().rstrip("(),. ")
|
|
435
|
+
evidence = find_evidence_file(doc_part, evidence_files)
|
|
436
|
+
if evidence is None:
|
|
437
|
+
issues.append(("warning", f"no evidence file matches '{doc_part}'"))
|
|
438
|
+
continue
|
|
439
|
+
body = evidence.read_text(encoding="utf-8", errors="replace")
|
|
440
|
+
for clause in clauses:
|
|
441
|
+
if not is_clause_grounded(clause, body):
|
|
442
|
+
# Use the clause as cited, only stripping trailing punctuation
|
|
443
|
+
# (not closing parens, which are part of identifiers like (i)).
|
|
444
|
+
shown = clause.strip().rstrip(".,;")
|
|
445
|
+
issues.append(
|
|
446
|
+
(
|
|
447
|
+
"warning",
|
|
448
|
+
f"clause 'section {shown}' not found in {evidence.name}",
|
|
449
|
+
)
|
|
450
|
+
)
|
|
451
|
+
return issues
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
# ---------------------------------------------------------------------------
|
|
455
|
+
# Validation
|
|
456
|
+
# ---------------------------------------------------------------------------
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
# Canonical VARS effectiveness outcomes (strict set). N/A covers controls that
|
|
460
|
+
# are not applicable to the entity's scope.
|
|
461
|
+
CANONICAL_RATINGS = {"satisfactory", "improvement recommended", "unsatisfactory", "na"}
|
|
462
|
+
NA_VALUES = {"na", "n/a", "n.a.", "not applicable"}
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def normalize_rating(raw: str) -> str:
|
|
466
|
+
"""Lower-case + collapse the N/A variants to a single 'na' token."""
|
|
467
|
+
r = raw.strip().lower()
|
|
468
|
+
return "na" if r in NA_VALUES else r
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def derive_expected_rating(observations: list[Observation], items: list[ChecklistItem]) -> Optional[str]:
|
|
472
|
+
"""Apply the VARS effectiveness rubric to predict the rating.
|
|
473
|
+
|
|
474
|
+
Rubric (rated prior to remediation):
|
|
475
|
+
- Unsatisfactory — any MANDATORY item is not fulfilled, i.e.
|
|
476
|
+
"Partial" or "Not addressed" (these become
|
|
477
|
+
Actions Required).
|
|
478
|
+
- Improvement Recommended — all mandatory items fulfilled, but at least
|
|
479
|
+
one RECOMMENDED item is missing or partial
|
|
480
|
+
(these become Recommendations).
|
|
481
|
+
- Satisfactory — every item (mandatory and recommended) Covered.
|
|
482
|
+
|
|
483
|
+
Returns one of "Unsatisfactory", "Improvement Recommended", "Satisfactory",
|
|
484
|
+
or None if the input is incomplete (missing observations). N/A is never
|
|
485
|
+
derived — it is a human scoping decision applied to the row directly.
|
|
486
|
+
"""
|
|
487
|
+
if not items:
|
|
488
|
+
return None
|
|
489
|
+
|
|
490
|
+
observed_by_canon: dict[str, Observation] = {obs.canonical: obs for obs in observations}
|
|
491
|
+
|
|
492
|
+
def effective_status(item: ChecklistItem) -> str:
|
|
493
|
+
"""Status of an item, inheriting from the nearest bulleted ancestor.
|
|
494
|
+
|
|
495
|
+
Mirrors item_coverage: if a leaf (e.g. 3.b.i) has no direct bullet but
|
|
496
|
+
its parent (3.b) is bulleted, the leaf takes the parent's status. Returns
|
|
497
|
+
'missing' when neither the item nor any ancestor is observed.
|
|
498
|
+
"""
|
|
499
|
+
parts = item.id.split(".")
|
|
500
|
+
for k in range(len(parts), 0, -1): # self -> root, most specific first
|
|
501
|
+
canon = normalize_item_id(".".join(parts[:k]))
|
|
502
|
+
if canon in observed_by_canon:
|
|
503
|
+
return observed_by_canon[canon].status
|
|
504
|
+
return "missing"
|
|
505
|
+
|
|
506
|
+
UNMET = ("missing", "not_addressed", "partial")
|
|
507
|
+
|
|
508
|
+
# any mandatory item not fully covered (Partial or Not addressed) -> Unsatisfactory
|
|
509
|
+
if any(item.mandatory and effective_status(item) in UNMET for item in items):
|
|
510
|
+
return "Unsatisfactory"
|
|
511
|
+
|
|
512
|
+
# all mandatory covered; any recommended item missing or partial -> Improvement Recommended
|
|
513
|
+
if any((not item.mandatory) and effective_status(item) in UNMET for item in items):
|
|
514
|
+
return "Improvement Recommended"
|
|
515
|
+
|
|
516
|
+
return "Satisfactory"
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def derive_scope(
|
|
520
|
+
controls: dict[str, ChecklistControl],
|
|
521
|
+
archetypes: list[str],
|
|
522
|
+
scope_str: Optional[str],
|
|
523
|
+
explicit_scope: list[str],
|
|
524
|
+
) -> set[str]:
|
|
525
|
+
"""Determine which Control IDs are in scope for this audit.
|
|
526
|
+
|
|
527
|
+
Precedence: --scope CLI flag > tracker "Scope: targeted: ..." > tracker
|
|
528
|
+
"Archetypes In Scope" > all controls.
|
|
529
|
+
"""
|
|
530
|
+
if explicit_scope:
|
|
531
|
+
return {cid for cid in explicit_scope if cid in controls}
|
|
532
|
+
|
|
533
|
+
if scope_str and scope_str.lower().startswith("targeted"):
|
|
534
|
+
# form: "targeted: AACQ.C2.1, AACQ.C14.1"
|
|
535
|
+
rest = scope_str.split(":", 1)[1] if ":" in scope_str else ""
|
|
536
|
+
ids = [s.strip() for s in re.split(r"[,;]", rest) if s.strip()]
|
|
537
|
+
return {cid for cid in ids if cid in controls}
|
|
538
|
+
|
|
539
|
+
if archetypes:
|
|
540
|
+
return {cid for cid in controls if cid.split(".")[0].upper() in archetypes}
|
|
541
|
+
|
|
542
|
+
return set(controls.keys())
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def validate(
|
|
546
|
+
tracker_path: Path,
|
|
547
|
+
checklist_path: Path,
|
|
548
|
+
evidence_dir: Optional[Path],
|
|
549
|
+
explicit_scope: Optional[list[str]] = None,
|
|
550
|
+
) -> list[dict]:
|
|
551
|
+
findings: list[dict] = []
|
|
552
|
+
controls = parse_checklist(checklist_path)
|
|
553
|
+
phases, archetypes, scope_str, rows = parse_tracker(tracker_path)
|
|
554
|
+
|
|
555
|
+
if not rows:
|
|
556
|
+
findings.append(
|
|
557
|
+
{
|
|
558
|
+
"check": "tracker_parse",
|
|
559
|
+
"level": "error",
|
|
560
|
+
"control_id": None,
|
|
561
|
+
"message": "no tracker rows parsed (table not found or empty)",
|
|
562
|
+
}
|
|
563
|
+
)
|
|
564
|
+
return findings
|
|
565
|
+
|
|
566
|
+
in_scope = derive_scope(controls, archetypes, scope_str, explicit_scope or [])
|
|
567
|
+
rows_by_id = {r.control_id: r for r in rows}
|
|
568
|
+
evidence_files: list[Path] = list_evidence_files(evidence_dir) if evidence_dir else []
|
|
569
|
+
|
|
570
|
+
# 1. row_coverage — only check controls in scope
|
|
571
|
+
for ctrl_id in in_scope:
|
|
572
|
+
if ctrl_id not in rows_by_id:
|
|
573
|
+
findings.append(
|
|
574
|
+
{
|
|
575
|
+
"check": "row_coverage",
|
|
576
|
+
"level": "error",
|
|
577
|
+
"control_id": ctrl_id,
|
|
578
|
+
"message": f"checklist control {ctrl_id} has no tracker row",
|
|
579
|
+
}
|
|
580
|
+
)
|
|
581
|
+
for ctrl_id in rows_by_id:
|
|
582
|
+
if ctrl_id not in controls:
|
|
583
|
+
findings.append(
|
|
584
|
+
{
|
|
585
|
+
"check": "row_coverage",
|
|
586
|
+
"level": "error",
|
|
587
|
+
"control_id": ctrl_id,
|
|
588
|
+
"message": f"tracker row {ctrl_id} not present in checklist",
|
|
589
|
+
}
|
|
590
|
+
)
|
|
591
|
+
elif ctrl_id not in in_scope:
|
|
592
|
+
findings.append(
|
|
593
|
+
{
|
|
594
|
+
"check": "row_coverage",
|
|
595
|
+
"level": "warning",
|
|
596
|
+
"control_id": ctrl_id,
|
|
597
|
+
"message": f"tracker row {ctrl_id} present but outside declared scope",
|
|
598
|
+
}
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
for row in rows:
|
|
602
|
+
control = controls.get(row.control_id)
|
|
603
|
+
if control is None:
|
|
604
|
+
continue
|
|
605
|
+
|
|
606
|
+
observations = parse_observations(row.observations)
|
|
607
|
+
observed = {obs.canonical for obs in observations}
|
|
608
|
+
item_canon = {item.canonical for item in control.items}
|
|
609
|
+
|
|
610
|
+
# 2. item_coverage — a checklist item is covered if it OR any dotted
|
|
611
|
+
# ancestor is bulleted (parent bullets roll up their sub-items).
|
|
612
|
+
for item in control.items:
|
|
613
|
+
if not (ancestor_canons(item.id) & observed):
|
|
614
|
+
findings.append(
|
|
615
|
+
{
|
|
616
|
+
"check": "item_coverage",
|
|
617
|
+
"level": "error",
|
|
618
|
+
"control_id": row.control_id,
|
|
619
|
+
"message": f"item ({item.id}) missing from Observations",
|
|
620
|
+
}
|
|
621
|
+
)
|
|
622
|
+
for canon in observed - item_canon:
|
|
623
|
+
findings.append(
|
|
624
|
+
{
|
|
625
|
+
"check": "item_coverage",
|
|
626
|
+
"level": "warning",
|
|
627
|
+
"control_id": row.control_id,
|
|
628
|
+
"message": f"Observations bullet 'Control {canon}' has no matching checklist item",
|
|
629
|
+
}
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
# 3. rating_value — must be one of the four canonical outcomes
|
|
633
|
+
rating_norm = normalize_rating(row.rating) if row.rating else ""
|
|
634
|
+
if row.rating and rating_norm not in CANONICAL_RATINGS:
|
|
635
|
+
findings.append(
|
|
636
|
+
{
|
|
637
|
+
"check": "rating_value",
|
|
638
|
+
"level": "error",
|
|
639
|
+
"control_id": row.control_id,
|
|
640
|
+
"message": (
|
|
641
|
+
f"rating '{row.rating}' is not a canonical outcome "
|
|
642
|
+
"(Satisfactory / Improvement Recommended / Unsatisfactory / N/A)"
|
|
643
|
+
),
|
|
644
|
+
}
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
# 4. rubric_consistency — N/A rows are exempt (scoping decision)
|
|
648
|
+
expected = derive_expected_rating(observations, control.items)
|
|
649
|
+
if expected and row.rating and rating_norm != "na" and rating_norm != expected.lower():
|
|
650
|
+
findings.append(
|
|
651
|
+
{
|
|
652
|
+
"check": "rubric_consistency",
|
|
653
|
+
"level": "error",
|
|
654
|
+
"control_id": row.control_id,
|
|
655
|
+
"message": f"rating '{row.rating}' inconsistent with rubric (expected '{expected}')",
|
|
656
|
+
}
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
# 5. gap_xref — Actions Required gaps must not be "Covered"; Recommendations
|
|
660
|
+
# may reference a Covered mandatory item flagged for strengthening.
|
|
661
|
+
actions_text, rec_text = split_issue_sections(row.issue)
|
|
662
|
+
for section, refs, enforce_not_covered in (
|
|
663
|
+
("Actions Required", extract_gap_refs(actions_text), True),
|
|
664
|
+
("Recommendations", extract_gap_refs(rec_text), False),
|
|
665
|
+
):
|
|
666
|
+
for gap_canon in refs:
|
|
667
|
+
obs = next((o for o in observations if o.canonical == gap_canon), None)
|
|
668
|
+
if obs is None:
|
|
669
|
+
findings.append(
|
|
670
|
+
{
|
|
671
|
+
"check": "gap_xref",
|
|
672
|
+
"level": "warning",
|
|
673
|
+
"control_id": row.control_id,
|
|
674
|
+
"message": f"{section} references (Control {gap_canon}) which has no Observations bullet",
|
|
675
|
+
}
|
|
676
|
+
)
|
|
677
|
+
elif enforce_not_covered and obs.status == "covered":
|
|
678
|
+
findings.append(
|
|
679
|
+
{
|
|
680
|
+
"check": "gap_xref",
|
|
681
|
+
"level": "warning",
|
|
682
|
+
"control_id": row.control_id,
|
|
683
|
+
"message": f"{section} references (Control {gap_canon}) but Observation is 'Covered'",
|
|
684
|
+
}
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
# 6. citation_grounding
|
|
688
|
+
if evidence_files:
|
|
689
|
+
for obs in observations:
|
|
690
|
+
if obs.status not in ("covered", "partial"):
|
|
691
|
+
continue
|
|
692
|
+
for level, msg in check_citation(obs.text, evidence_files):
|
|
693
|
+
findings.append(
|
|
694
|
+
{
|
|
695
|
+
"check": "citation_grounding",
|
|
696
|
+
"level": level,
|
|
697
|
+
"control_id": row.control_id,
|
|
698
|
+
"message": f"({obs.item_id}) {msg}",
|
|
699
|
+
}
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
return findings
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
# ---------------------------------------------------------------------------
|
|
706
|
+
# CLI
|
|
707
|
+
# ---------------------------------------------------------------------------
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def main() -> int:
|
|
711
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
712
|
+
ap.add_argument("tracker", type=Path)
|
|
713
|
+
ap.add_argument("--checklist", type=Path, required=True)
|
|
714
|
+
ap.add_argument("--evidence", type=Path, default=None, help="evidence markdown directory (enables citation grounding)")
|
|
715
|
+
ap.add_argument(
|
|
716
|
+
"--scope",
|
|
717
|
+
type=str,
|
|
718
|
+
default=None,
|
|
719
|
+
help="comma-separated Control IDs to validate (overrides tracker-declared scope)",
|
|
720
|
+
)
|
|
721
|
+
ap.add_argument("--json", action="store_true", help="emit findings as JSON")
|
|
722
|
+
ap.add_argument("--strict", action="store_true", help="exit non-zero on warnings as well")
|
|
723
|
+
args = ap.parse_args()
|
|
724
|
+
|
|
725
|
+
explicit_scope = (
|
|
726
|
+
[s.strip() for s in args.scope.split(",") if s.strip()] if args.scope else None
|
|
727
|
+
)
|
|
728
|
+
findings = validate(args.tracker, args.checklist, args.evidence, explicit_scope)
|
|
729
|
+
|
|
730
|
+
errors = [f for f in findings if f["level"] == "error"]
|
|
731
|
+
warnings = [f for f in findings if f["level"] == "warning"]
|
|
732
|
+
|
|
733
|
+
if args.json:
|
|
734
|
+
json.dump({"errors": errors, "warnings": warnings}, sys.stdout, indent=2)
|
|
735
|
+
sys.stdout.write("\n")
|
|
736
|
+
else:
|
|
737
|
+
for f in findings:
|
|
738
|
+
tag = "ERROR" if f["level"] == "error" else "WARN"
|
|
739
|
+
ctl = f["control_id"] or "-"
|
|
740
|
+
print(f"[{tag}] [{f['check']}] {ctl}: {f['message']}", file=sys.stderr)
|
|
741
|
+
print(
|
|
742
|
+
f"\n{len(errors)} error(s), {len(warnings)} warning(s)",
|
|
743
|
+
file=sys.stderr,
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
if errors or (args.strict and warnings):
|
|
747
|
+
return 1
|
|
748
|
+
return 0
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
if __name__ == "__main__":
|
|
752
|
+
sys.exit(main())
|