reqthread 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.
- reqthread/__init__.py +4 -0
- reqthread/__main__.py +5 -0
- reqthread/build.py +467 -0
- reqthread/cli.py +256 -0
- reqthread/formats.py +337 -0
- reqthread/gates.py +138 -0
- reqthread/ingest.py +221 -0
- reqthread/report.py +224 -0
- reqthread/scan.py +345 -0
- reqthread/schema/architecture.schema.json +13 -0
- reqthread/schema/bundle.schema.json +1149 -0
- reqthread/schema/cause.schema.json +74 -0
- reqthread/schema/config.schema.json +368 -0
- reqthread/schema/design.schema.json +17 -0
- reqthread/schema/deviations.schema.json +32 -0
- reqthread/schema/hazard.schema.json +29 -0
- reqthread/schema/requirement.schema.json +88 -0
- reqthread/schema/standard-data.schema.json +138 -0
- reqthread-0.2.0.dist-info/METADATA +148 -0
- reqthread-0.2.0.dist-info/RECORD +24 -0
- reqthread-0.2.0.dist-info/WHEEL +5 -0
- reqthread-0.2.0.dist-info/entry_points.txt +2 -0
- reqthread-0.2.0.dist-info/licenses/LICENSE +202 -0
- reqthread-0.2.0.dist-info/top_level.txt +1 -0
reqthread/__init__.py
ADDED
reqthread/__main__.py
ADDED
reqthread/build.py
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
"""Assemble an evidence bundle from a project configuration."""
|
|
2
|
+
import datetime as dt
|
|
3
|
+
import os
|
|
4
|
+
import pathlib
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
|
|
9
|
+
from . import GENERATOR, __version__
|
|
10
|
+
from .formats import (
|
|
11
|
+
PREFIX, FormatError, StandardIndex, canonical_hash, check_bundle_schema, check_item_files,
|
|
12
|
+
check_references, check_standard, file_hash, load_schema, load_yaml, schema_errors,
|
|
13
|
+
)
|
|
14
|
+
from .ingest import (
|
|
15
|
+
OUTCOME_RANK, FileCoverage, match_junit, parse_cobertura, parse_junit, parse_lcov, parse_sarif, unit_metrics,
|
|
16
|
+
)
|
|
17
|
+
from .scan import scan_file
|
|
18
|
+
|
|
19
|
+
KIND_ORDER = {k: i for i, k in enumerate(PREFIX)}
|
|
20
|
+
LANG = {"c": "c", "cpp": "cpp", "python": "python"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class BuildResult:
|
|
25
|
+
bundle: dict | None
|
|
26
|
+
errors: list = field(default_factory=list)
|
|
27
|
+
warnings: list = field(default_factory=list)
|
|
28
|
+
root: pathlib.Path | None = None
|
|
29
|
+
standard: StandardIndex | None = None
|
|
30
|
+
standard_path: pathlib.Path | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_config(path):
|
|
34
|
+
path = pathlib.Path(path)
|
|
35
|
+
cfg = load_yaml(path, "config")
|
|
36
|
+
return cfg, schema_errors(load_schema("config.schema.json"), cfg, f"config {path}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _git(root, *args):
|
|
40
|
+
try:
|
|
41
|
+
out = subprocess.run(["git", "-C", str(root), *args], capture_output=True, text=True, timeout=10)
|
|
42
|
+
except (OSError, subprocess.SubprocessError):
|
|
43
|
+
return None
|
|
44
|
+
return out.stdout.strip() if out.returncode == 0 and out.stdout.strip() else None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _now():
|
|
48
|
+
return dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _slug(s):
|
|
52
|
+
return re.sub(r"[^a-z0-9._-]+", "-", s.lower()).strip("-") or "tool"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _rel(root, p):
|
|
56
|
+
try:
|
|
57
|
+
return pathlib.Path(p).resolve().relative_to(root.resolve()).as_posix()
|
|
58
|
+
except ValueError:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class _Builder:
|
|
63
|
+
def __init__(self, config_path, now):
|
|
64
|
+
self.config_path = pathlib.Path(config_path)
|
|
65
|
+
self.root = self.config_path.resolve().parent
|
|
66
|
+
self.now = now or _now()
|
|
67
|
+
self.errors, self.warnings = [], []
|
|
68
|
+
self.entities, self.links, self.artifacts = {}, set(), {}
|
|
69
|
+
self.keys = {} # human key -> entity id, for requirements, architecture, design
|
|
70
|
+
|
|
71
|
+
# -- helpers
|
|
72
|
+
def add(self, ent):
|
|
73
|
+
if ent["id"] in self.entities:
|
|
74
|
+
self.errors.append(f"duplicate entity {ent['id']}")
|
|
75
|
+
return
|
|
76
|
+
self.entities[ent["id"]] = ent
|
|
77
|
+
|
|
78
|
+
def link(self, typ, frm, to, declared_at=None):
|
|
79
|
+
self.links.add((typ, frm, to, tuple(sorted(declared_at.items())) if declared_at else None))
|
|
80
|
+
|
|
81
|
+
def artifact(self, path, section, media_type, description=None):
|
|
82
|
+
rel = _rel(self.root, path)
|
|
83
|
+
if rel is None:
|
|
84
|
+
self.errors.append(f"{path}: outside the project root {self.root}")
|
|
85
|
+
return
|
|
86
|
+
a = {"path": rel, "hash": file_hash(path), "media_type": media_type, "section": section}
|
|
87
|
+
if description:
|
|
88
|
+
a["description"] = description
|
|
89
|
+
self.artifacts[rel] = a
|
|
90
|
+
|
|
91
|
+
def resolve(self, key, kinds, where):
|
|
92
|
+
for prefix in kinds:
|
|
93
|
+
eid = self.keys.get((prefix, key))
|
|
94
|
+
if eid:
|
|
95
|
+
return eid
|
|
96
|
+
what = " or ".join({"req": "requirement", "arch": "architecture element", "design": "design element",
|
|
97
|
+
"haz": "hazard", "cause": "cause"}[k] for k in kinds)
|
|
98
|
+
self.errors.append(f"{where}: unknown {what} {key}")
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
# -- stages
|
|
102
|
+
def items(self, cfg):
|
|
103
|
+
reqs, archs, designs, hazards, causes = {}, {}, {}, {}, {}
|
|
104
|
+
for section_key, suffix, store in (("requirements", ".req.md", reqs), ("architecture", ".arch.md", archs),
|
|
105
|
+
("design", ".design.md", designs), ("hazards", ".hazard.md", hazards),
|
|
106
|
+
("causes", ".cause.md", causes)):
|
|
107
|
+
spec = cfg.get(section_key)
|
|
108
|
+
if not spec:
|
|
109
|
+
continue
|
|
110
|
+
for d in spec["paths"]:
|
|
111
|
+
directory = self.root / d
|
|
112
|
+
if not directory.is_dir():
|
|
113
|
+
self.errors.append(f"config: {section_key} path {d} is not a directory")
|
|
114
|
+
continue
|
|
115
|
+
errs, items = check_item_files(directory, suffix)
|
|
116
|
+
self.errors += errs
|
|
117
|
+
for key, item in items.items():
|
|
118
|
+
if key in reqs or key in archs or key in designs or key in hazards or key in causes:
|
|
119
|
+
self.errors.append(f"{item[0]}: id {key} is already used by another item")
|
|
120
|
+
continue
|
|
121
|
+
store[key] = item
|
|
122
|
+
self.artifact(item[0], spec["section"], "text/markdown")
|
|
123
|
+
|
|
124
|
+
for key, (f, fm, title, text, rationale) in reqs.items():
|
|
125
|
+
e = {"id": f"req:{key}", "kind": "requirement", "key": key, "level": fm["level"], "type": fm["type"],
|
|
126
|
+
"integrity_level": fm.get("integrity_level"), "title": title, "text": text,
|
|
127
|
+
"verification": fm.get("verification", []), "test_levels": fm.get("test_levels", []),
|
|
128
|
+
"status": fm["status"], "source": {"path": _rel(self.root, f)}}
|
|
129
|
+
if rationale:
|
|
130
|
+
e["rationale"] = rationale
|
|
131
|
+
self.add(e)
|
|
132
|
+
self.keys[("req", key)] = e["id"]
|
|
133
|
+
for key, (f, fm, title, text, _r) in archs.items():
|
|
134
|
+
e = {"id": f"arch:{key}", "kind": "architecture_element", "key": key, "title": title,
|
|
135
|
+
"safety_related": fm["safety_related"], "integrity_level": fm.get("integrity_level"),
|
|
136
|
+
"source": {"path": _rel(self.root, f)}}
|
|
137
|
+
if text:
|
|
138
|
+
e["description"] = text
|
|
139
|
+
self.add(e)
|
|
140
|
+
self.keys[("arch", key)] = e["id"]
|
|
141
|
+
for key, (f, fm, title, text, _r) in designs.items():
|
|
142
|
+
e = {"id": f"design:{key}", "kind": "design_element", "key": key, "title": title,
|
|
143
|
+
"source": {"path": _rel(self.root, f)}}
|
|
144
|
+
if text:
|
|
145
|
+
e["description"] = text
|
|
146
|
+
self.add(e)
|
|
147
|
+
self.keys[("design", key)] = e["id"]
|
|
148
|
+
|
|
149
|
+
for key, (f, fm, title, text, _r) in hazards.items():
|
|
150
|
+
e = {"id": f"haz:{key}", "kind": "hazard", "key": key, "title": title, "text": text,
|
|
151
|
+
"severity": fm.get("severity"), "status": fm["status"], "source": {"path": _rel(self.root, f)}}
|
|
152
|
+
self.add(e)
|
|
153
|
+
self.keys[("haz", key)] = e["id"]
|
|
154
|
+
for key, (f, fm, title, text, _r) in causes.items():
|
|
155
|
+
acc = fm.get("accepted")
|
|
156
|
+
e = {"id": f"cause:{key}", "kind": "cause", "key": key, "title": title, "cause_type": fm["type"],
|
|
157
|
+
"accepted": ({"justification": acc["justification"], "approved_by": f"person:{acc['approved_by']}",
|
|
158
|
+
"approved_at": acc["approved_at"]} if acc else None),
|
|
159
|
+
"status": fm["status"], "source": {"path": _rel(self.root, f)}}
|
|
160
|
+
if text:
|
|
161
|
+
e["text"] = text
|
|
162
|
+
self.add(e)
|
|
163
|
+
self.keys[("cause", key)] = e["id"]
|
|
164
|
+
for key, (f, fm, *_x) in causes.items():
|
|
165
|
+
where = _rel(self.root, f)
|
|
166
|
+
for k in fm["hazards"]:
|
|
167
|
+
to = self.resolve(k, ["haz"], where)
|
|
168
|
+
if to:
|
|
169
|
+
self.link("leads_to", f"cause:{key}", to)
|
|
170
|
+
if fm.get("component"):
|
|
171
|
+
to = self.resolve(fm["component"], ["arch"], where)
|
|
172
|
+
if to:
|
|
173
|
+
self.link("located_in", f"cause:{key}", to)
|
|
174
|
+
|
|
175
|
+
for key, (f, fm, *_x) in reqs.items():
|
|
176
|
+
where = _rel(self.root, f)
|
|
177
|
+
for typ, field_, kinds in (("derives_from", "derives_from", ["req"]), ("allocated_to", "allocated_to", ["arch"]),
|
|
178
|
+
("supersedes", "supersedes", ["req"]), ("mitigates", "mitigates", ["haz", "cause"])):
|
|
179
|
+
for k in fm.get(field_, []):
|
|
180
|
+
to = self.resolve(k, kinds, where)
|
|
181
|
+
if to:
|
|
182
|
+
self.link(typ, f"req:{key}", to)
|
|
183
|
+
for key, (f, fm, *_x) in designs.items():
|
|
184
|
+
where = _rel(self.root, f)
|
|
185
|
+
for k in fm.get("derives_from", []):
|
|
186
|
+
to = self.resolve(k, ["arch"], where)
|
|
187
|
+
if to:
|
|
188
|
+
self.link("derives_from", f"design:{key}", to)
|
|
189
|
+
for k in fm.get("satisfies", []):
|
|
190
|
+
to = self.resolve(k, ["req"], where)
|
|
191
|
+
if to:
|
|
192
|
+
self.link("satisfies", f"design:{key}", to)
|
|
193
|
+
|
|
194
|
+
def code(self, cfg):
|
|
195
|
+
self.unit_ranges = {} # path -> [(start, end, code_id)] for function-level units
|
|
196
|
+
self.tests = {} # test id -> (path, symbol)
|
|
197
|
+
claimed = {}
|
|
198
|
+
for role, globs in (("source", cfg.get("sources", [])), ("test", cfg.get("tests", []))):
|
|
199
|
+
for g in globs:
|
|
200
|
+
matched = sorted(p for p in self.root.glob(g["glob"]) if p.is_file())
|
|
201
|
+
if not matched:
|
|
202
|
+
self.warnings.append(f"config: {role} glob {g['glob']!r} matches no files")
|
|
203
|
+
for p in matched:
|
|
204
|
+
rel = _rel(self.root, p)
|
|
205
|
+
if rel in claimed:
|
|
206
|
+
if claimed[rel] != role:
|
|
207
|
+
self.errors.append(f"{rel}: matched by both a source glob and a test glob")
|
|
208
|
+
continue
|
|
209
|
+
claimed[rel] = role
|
|
210
|
+
units, errs = scan_file(rel, p.read_text(errors="replace"), g["language"], role)
|
|
211
|
+
self.errors += errs
|
|
212
|
+
if role == "source":
|
|
213
|
+
self._code_units(rel, g["language"], units)
|
|
214
|
+
else:
|
|
215
|
+
self._test_cases(rel, g["level"], units)
|
|
216
|
+
|
|
217
|
+
def _code_units(self, rel, language, units):
|
|
218
|
+
for u in units:
|
|
219
|
+
cid = f"code:{rel}#{u.symbol}" if u.symbol else f"code:{rel}"
|
|
220
|
+
self.add({"id": cid, "kind": "code_unit", "path": rel, "symbol": u.symbol, "language": LANG[language],
|
|
221
|
+
"infrastructure": {"reason": u.infrastructure} if u.infrastructure else None,
|
|
222
|
+
"source": {"path": rel, "line": u.start}})
|
|
223
|
+
if u.symbol:
|
|
224
|
+
self.unit_ranges.setdefault(rel, []).append((u.start, u.end, cid))
|
|
225
|
+
for k in u.satisfies:
|
|
226
|
+
to = self.resolve(k, ["req"], f"{rel}:{u.tag_lines[('satisfies', k)]}")
|
|
227
|
+
if to:
|
|
228
|
+
self.link("satisfies", cid, to, {"path": rel, "line": u.tag_lines[("satisfies", k)]})
|
|
229
|
+
for k in u.implements:
|
|
230
|
+
to = self.resolve(k, ["design", "arch"], f"{rel}:{u.tag_lines[('implements', k)]}")
|
|
231
|
+
if to:
|
|
232
|
+
self.link("implements", cid, to, {"path": rel, "line": u.tag_lines[("implements", k)]})
|
|
233
|
+
|
|
234
|
+
def _test_cases(self, rel, level, units):
|
|
235
|
+
for u in units:
|
|
236
|
+
tid = f"test:{rel}::{u.symbol}"
|
|
237
|
+
self.add({"id": tid, "kind": "test_case", "name": f"{rel}::{u.symbol}", "level": u.test_level or level,
|
|
238
|
+
"source": {"path": rel, "line": u.start}})
|
|
239
|
+
self.tests[tid] = (rel, u.symbol)
|
|
240
|
+
for k in u.verifies:
|
|
241
|
+
to = self.resolve(k, ["req", "design", "arch"], f"{rel}:{u.tag_lines[('verifies', k)]}")
|
|
242
|
+
if to:
|
|
243
|
+
self.link("verifies", tid, to, {"path": rel, "line": u.tag_lines[("verifies", k)]})
|
|
244
|
+
|
|
245
|
+
def people_tools(self, cfg):
|
|
246
|
+
for p in cfg.get("people", []):
|
|
247
|
+
self.add({"id": f"person:{p['id']}", "kind": "person", "name": p["name"], "roles": p["roles"],
|
|
248
|
+
"competence_record": p.get("competence_record")})
|
|
249
|
+
self.tool_by_name = {}
|
|
250
|
+
for t in cfg.get("tools", []):
|
|
251
|
+
tid = f"tool:{t['id']}"
|
|
252
|
+
self.add({"id": tid, "kind": "tool", "name": t["name"], "version": t["version"], "class": t["class"],
|
|
253
|
+
"evidence": t.get("evidence", [])})
|
|
254
|
+
self.tool_by_name[t["name"].lower()] = tid
|
|
255
|
+
self.tool_by_name[t["id"].lower()] = tid
|
|
256
|
+
for p in cfg.get("preexisting", []):
|
|
257
|
+
e = {"id": f"pre:{p['id']}", "kind": "preexisting_element", "name": p["name"], "version": p["version"],
|
|
258
|
+
"route": p["route"], "justification": p.get("justification", [])}
|
|
259
|
+
if p.get("origin"):
|
|
260
|
+
e["origin"] = p["origin"]
|
|
261
|
+
self.add(e)
|
|
262
|
+
self.env_ids = set()
|
|
263
|
+
for env in cfg.get("environments", []):
|
|
264
|
+
e = {"id": f"env:{env['id']}", "kind": "environment", "name": env["name"], "env_type": env["env_type"]}
|
|
265
|
+
for opt in ("board", "description"):
|
|
266
|
+
if env.get(opt):
|
|
267
|
+
e[opt] = env[opt]
|
|
268
|
+
self.add(e)
|
|
269
|
+
self.env_ids.add(env["id"])
|
|
270
|
+
|
|
271
|
+
def ingest(self, cfg):
|
|
272
|
+
known = sorted(self.unit_ranges)
|
|
273
|
+
results = {} # (test id, env) -> (outcome, ts)
|
|
274
|
+
coverage = {} # (env, level) -> {path: FileCoverage}
|
|
275
|
+
sarif = []
|
|
276
|
+
for i, entry in enumerate(cfg.get("ingest", [])):
|
|
277
|
+
path = self.root / entry["path"]
|
|
278
|
+
if not path.is_file():
|
|
279
|
+
(self.warnings if entry.get("optional") else self.errors).append(
|
|
280
|
+
f"ingest[{i}]: {entry['path']} not found")
|
|
281
|
+
continue
|
|
282
|
+
env = entry.get("environment")
|
|
283
|
+
if env is not None and env not in self.env_ids:
|
|
284
|
+
self.errors.append(f"ingest[{i}]: unknown environment {env!r}")
|
|
285
|
+
continue
|
|
286
|
+
kind = entry["type"]
|
|
287
|
+
media = {"junit": "application/xml", "cobertura": "application/xml", "lcov": "text/plain",
|
|
288
|
+
"sarif": "application/sarif+json"}[kind]
|
|
289
|
+
self.artifact(path, entry["section"], media, f"{kind} input")
|
|
290
|
+
try:
|
|
291
|
+
if kind == "junit":
|
|
292
|
+
outcomes, unmatched = match_junit(parse_junit(path), self.tests)
|
|
293
|
+
for tid, (outcome, ts) in outcomes.items():
|
|
294
|
+
prev = results.get((tid, env))
|
|
295
|
+
if prev is None or OUTCOME_RANK[outcome] > OUTCOME_RANK[prev[0]]:
|
|
296
|
+
results[(tid, env)] = (outcome, ts)
|
|
297
|
+
for u in unmatched:
|
|
298
|
+
self.warnings.append(f"{entry['path']}: testcase {u} matches no test in the configured test globs")
|
|
299
|
+
elif kind in ("lcov", "cobertura"):
|
|
300
|
+
parse = parse_lcov if kind == "lcov" else parse_cobertura
|
|
301
|
+
files, unmapped = parse(path, self.root, known)
|
|
302
|
+
bucket = coverage.setdefault((env, entry["test_level"]), {})
|
|
303
|
+
for rel, fc in files.items():
|
|
304
|
+
merged = bucket.setdefault(rel, FileCoverage({}, {}))
|
|
305
|
+
for ln, h in fc.lines.items():
|
|
306
|
+
merged.lines[ln] = max(merged.lines.get(ln, 0), h)
|
|
307
|
+
for ln, b in fc.branches.items():
|
|
308
|
+
merged.branches[ln] = max(merged.branches.get(ln, (0, 0)), b)
|
|
309
|
+
if unmapped:
|
|
310
|
+
self.warnings.append(f"{entry['path']}: {len(unmapped)} file(s) outside the project ignored")
|
|
311
|
+
else:
|
|
312
|
+
found, unmapped = parse_sarif(path, self.root, known)
|
|
313
|
+
sarif += found
|
|
314
|
+
if unmapped:
|
|
315
|
+
self.warnings.append(f"{entry['path']}: {len(unmapped)} result(s) outside the project ignored")
|
|
316
|
+
except (OSError, ValueError, KeyError) as e:
|
|
317
|
+
self.errors.append(f"ingest[{i}]: cannot parse {entry['path']} as {kind}: {e}")
|
|
318
|
+
|
|
319
|
+
ci_job = os.environ.get("CI_JOB_ID") or os.environ.get("GITHUB_RUN_ID")
|
|
320
|
+
for (tid, env), (outcome, ts) in sorted(results.items()):
|
|
321
|
+
e = {"id": f"result:{tid.removeprefix('test:')}@{env}", "kind": "test_result", "test": tid,
|
|
322
|
+
"environment": f"env:{env}", "outcome": outcome, "run_at": ts or self.now}
|
|
323
|
+
if ci_job:
|
|
324
|
+
e["ci_job"] = ci_job
|
|
325
|
+
self.add(e)
|
|
326
|
+
|
|
327
|
+
for (env, level), files in sorted(coverage.items()):
|
|
328
|
+
for rel, fc in sorted(files.items()):
|
|
329
|
+
for start, end, cid in self.unit_ranges.get(rel, []):
|
|
330
|
+
m = unit_metrics(fc, start, end)
|
|
331
|
+
if m:
|
|
332
|
+
self.add({"id": f"cov:{cid.removeprefix('code:')}@{env}/{level}", "kind": "coverage_record",
|
|
333
|
+
"code_unit": cid, "environment": f"env:{env}", "test_level": level, "metrics": m})
|
|
334
|
+
self.sarif = sarif
|
|
335
|
+
|
|
336
|
+
def findings(self, cfg):
|
|
337
|
+
devs = []
|
|
338
|
+
spec = cfg.get("deviations")
|
|
339
|
+
if spec:
|
|
340
|
+
p = self.root / spec["path"]
|
|
341
|
+
try:
|
|
342
|
+
data = load_yaml(p, "deviations file") or []
|
|
343
|
+
except FormatError as e:
|
|
344
|
+
self.errors.append(str(e))
|
|
345
|
+
data = []
|
|
346
|
+
errs = schema_errors(load_schema("deviations.schema.json"), data, f"deviations {spec['path']}")
|
|
347
|
+
self.errors += errs
|
|
348
|
+
if not errs:
|
|
349
|
+
self.artifact(p, spec["section"], "application/yaml", "deviation register")
|
|
350
|
+
devs = data
|
|
351
|
+
for d in devs:
|
|
352
|
+
person = f"person:{d['approved_by']}"
|
|
353
|
+
if person not in self.entities:
|
|
354
|
+
self.errors.append(f"deviation {d['key']}: approved_by {d['approved_by']!r} is not in people")
|
|
355
|
+
self.add({"id": f"dev:{d['key']}", "kind": "deviation", "key": d["key"], "justification": d["justification"],
|
|
356
|
+
"approved_by": person, "approved_at": d["approved_at"], "expires_at": d["expires_at"],
|
|
357
|
+
**({"scope": d["scope"]} if d.get("scope") else {})})
|
|
358
|
+
|
|
359
|
+
used_devs, seen = set(), {}
|
|
360
|
+
for r in sorted(self.sarif, key=lambda r: (r.tool, r.rule_id, r.path, r.line or 0)):
|
|
361
|
+
tool_id = self.tool_by_name.get(r.tool.lower())
|
|
362
|
+
if tool_id is None:
|
|
363
|
+
tool_id = f"tool:{_slug(r.tool)}"
|
|
364
|
+
self.tool_by_name[r.tool.lower()] = tool_id
|
|
365
|
+
self.add({"id": tool_id, "kind": "tool", "name": r.tool, "version": r.tool_version or "unknown",
|
|
366
|
+
"class": None, "evidence": []})
|
|
367
|
+
self.warnings.append(f"tool {r.tool!r} reported findings but is not in config tools; recorded as unclassified")
|
|
368
|
+
# Prefer the tool's fingerprint: line numbers shift with unrelated edits and would churn gap ids.
|
|
369
|
+
anchor = f"#{r.fingerprint[:16]}" if r.fingerprint else f":{r.line or 0}"
|
|
370
|
+
base = f"finding:{tool_id.removeprefix('tool:')}/{r.rule_id}/{r.path}{anchor}"
|
|
371
|
+
seen[base] = seen.get(base, 0) + 1
|
|
372
|
+
fid = base if seen[base] == 1 else f"{base}#{seen[base]}"
|
|
373
|
+
matching = [d for d in devs if self._dev_matches(d["match"], r, tool_id)]
|
|
374
|
+
e = {"id": fid, "kind": "finding", "tool": tool_id, "rule_id": r.rule_id, "message": r.message,
|
|
375
|
+
"location": {"path": r.path, **({"line": r.line} if r.line else {})},
|
|
376
|
+
"severity": r.level if r.level in ("error", "warning", "note", "none") else "warning",
|
|
377
|
+
"state": "deviated" if matching else "open"}
|
|
378
|
+
if r.fingerprint:
|
|
379
|
+
e["fingerprint"] = r.fingerprint
|
|
380
|
+
if r.suppressed_in_source:
|
|
381
|
+
e["extra"] = {"suppressed_in_source": True}
|
|
382
|
+
self.add(e)
|
|
383
|
+
for d in matching:
|
|
384
|
+
self.link("justifies", f"dev:{d['key']}", fid)
|
|
385
|
+
used_devs.add(d["key"])
|
|
386
|
+
for d in devs:
|
|
387
|
+
if d["key"] not in used_devs:
|
|
388
|
+
self.warnings.append(f"deviation {d['key']} matches no current finding (stale?)")
|
|
389
|
+
|
|
390
|
+
def _dev_matches(self, m, r, tool_id):
|
|
391
|
+
if m["tool"].lower() not in (r.tool.lower(), tool_id.removeprefix("tool:")):
|
|
392
|
+
return False
|
|
393
|
+
if m["rule"] != r.rule_id:
|
|
394
|
+
return False
|
|
395
|
+
if "path" in m and m["path"] != r.path:
|
|
396
|
+
return False
|
|
397
|
+
if "line" in m and m["line"] != r.line:
|
|
398
|
+
return False
|
|
399
|
+
if "fingerprint" in m and m["fingerprint"] != r.fingerprint:
|
|
400
|
+
return False
|
|
401
|
+
return True
|
|
402
|
+
|
|
403
|
+
def bundle(self, cfg, std, std_path, previous, baseline_id, commit):
|
|
404
|
+
commit = commit or _git(self.root, "rev-parse", "HEAD")
|
|
405
|
+
if not commit:
|
|
406
|
+
self.warnings.append("not a git checkout; baseline commit recorded as all zeros")
|
|
407
|
+
commit = "0" * 40
|
|
408
|
+
tag = _git(self.root, "describe", "--tags", "--exact-match")
|
|
409
|
+
baseline = {"id": baseline_id or tag or f"commit-{commit[:12]}", "commit": commit,
|
|
410
|
+
"previous_bundle_hash": canonical_hash(previous) if previous else None}
|
|
411
|
+
if tag:
|
|
412
|
+
baseline["tag"] = tag
|
|
413
|
+
for e in self.entities.values():
|
|
414
|
+
if e["kind"] == "cause" and e["accepted"] and e["accepted"]["approved_by"] not in self.entities:
|
|
415
|
+
self.errors.append(f"cause {e['key']}: accepted.approved_by {e['accepted']['approved_by'].removeprefix('person:')!r} is not in people")
|
|
416
|
+
ents = sorted(self.entities.values(), key=lambda e: (KIND_ORDER[e["kind"]], e["id"]))
|
|
417
|
+
links = []
|
|
418
|
+
for typ, frm, to, decl in sorted(self.links, key=lambda l: (l[0], l[1], l[2], l[3] or ())):
|
|
419
|
+
l = {"type": typ, "from": frm, "to": to}
|
|
420
|
+
if decl:
|
|
421
|
+
l["declared_at"] = dict(decl)
|
|
422
|
+
links.append(l)
|
|
423
|
+
return {
|
|
424
|
+
"bundle_format": "0.2.0",
|
|
425
|
+
"generator": {"name": GENERATOR, "version": __version__},
|
|
426
|
+
"created_at": self.now,
|
|
427
|
+
"project": {"id": cfg["project"]["id"], "name": cfg["project"]["name"],
|
|
428
|
+
"integrity_claim": cfg["project"]["integrity_claim"],
|
|
429
|
+
"standard": {"id": std.id, "edition": std.edition, "data_hash": file_hash(std_path)}},
|
|
430
|
+
"baseline": baseline,
|
|
431
|
+
"entities": ents,
|
|
432
|
+
"links": links,
|
|
433
|
+
"artifacts": [self.artifacts[k] for k in sorted(self.artifacts)],
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def build(config_path, previous=None, baseline_id=None, commit=None, now=None):
|
|
438
|
+
b = _Builder(config_path, now)
|
|
439
|
+
try:
|
|
440
|
+
cfg, errs = load_config(config_path)
|
|
441
|
+
except FormatError as e:
|
|
442
|
+
return BuildResult(None, [str(e)])
|
|
443
|
+
if errs:
|
|
444
|
+
return BuildResult(None, errs, root=b.root)
|
|
445
|
+
|
|
446
|
+
std_path = b.root / cfg["standard"]
|
|
447
|
+
try:
|
|
448
|
+
std_data, errs = check_standard(std_path)
|
|
449
|
+
except FormatError as e:
|
|
450
|
+
return BuildResult(None, [str(e)], root=b.root)
|
|
451
|
+
if errs:
|
|
452
|
+
return BuildResult(None, errs, root=b.root)
|
|
453
|
+
std = StandardIndex(std_data)
|
|
454
|
+
|
|
455
|
+
b.items(cfg)
|
|
456
|
+
b.people_tools(cfg)
|
|
457
|
+
b.code(cfg)
|
|
458
|
+
b.ingest(cfg)
|
|
459
|
+
b.findings(cfg)
|
|
460
|
+
bundle = b.bundle(cfg, std, std_path, previous, baseline_id, commit)
|
|
461
|
+
|
|
462
|
+
errors = list(b.errors)
|
|
463
|
+
if not errors:
|
|
464
|
+
errors += check_bundle_schema(bundle)
|
|
465
|
+
if not errors:
|
|
466
|
+
errors += check_references(bundle, std, std_path, b.root)
|
|
467
|
+
return BuildResult(bundle if not errors else None, errors, b.warnings, b.root, std, std_path)
|