enginehall 2.0.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.
- enginehall-2.0.0.dist-info/METADATA +511 -0
- enginehall-2.0.0.dist-info/RECORD +75 -0
- enginehall-2.0.0.dist-info/WHEEL +4 -0
- enginehall-2.0.0.dist-info/entry_points.txt +4 -0
- enginehall-2.0.0.dist-info/licenses/LICENSE +51 -0
- pcba/THIRD_PARTY_NOTICES.md +72 -0
- pcba/__init__.py +6 -0
- pcba/adapters/__init__.py +10 -0
- pcba/adapters/altium_bridge/MANIFEST.json +5 -0
- pcba/adapters/altium_bridge/__init__.py +1 -0
- pcba/adapters/altium_bridge/bridge.pas +3342 -0
- pcba/adapters/altium_drive.py +1160 -0
- pcba/adapters/altium_house.py +591 -0
- pcba/adapters/altium_ops.py +858 -0
- pcba/adapters/altium_outjob.py +247 -0
- pcba/adapters/altium_uia.py +304 -0
- pcba/adapters/altium_vcs.py +197 -0
- pcba/adapters/altium_win32.py +457 -0
- pcba/adapters/cubemx_drive.py +248 -0
- pcba/baseline.py +151 -0
- pcba/changes.py +81 -0
- pcba/cli.py +891 -0
- pcba/components.py +323 -0
- pcba/config.py +180 -0
- pcba/data/components/generic.json +61 -0
- pcba/discover.py +350 -0
- pcba/doctor.py +131 -0
- pcba/house.py +123 -0
- pcba/mcp/__init__.py +5 -0
- pcba/mcp/altium_server.py +1358 -0
- pcba/mcp/protocol.py +224 -0
- pcba/mcp/server.py +332 -0
- pcba/model/__init__.py +38 -0
- pcba/model/design.py +232 -0
- pcba/model/finding.py +148 -0
- pcba/model/pinmap.py +200 -0
- pcba/parsers/__init__.py +0 -0
- pcba/parsers/altium/__init__.py +37 -0
- pcba/parsers/altium/_sheet.py +293 -0
- pcba/parsers/altium/geometry.py +103 -0
- pcba/parsers/altium/netlist.py +302 -0
- pcba/parsers/altium/pinmap.py +336 -0
- pcba/parsers/altium/project.py +249 -0
- pcba/parsers/altium/records.py +227 -0
- pcba/parsers/firmware/__init__.py +5 -0
- pcba/parsers/firmware/index.py +250 -0
- pcba/parsers/ioc/__init__.py +49 -0
- pcba/parsers/ioc/mcu_db.py +180 -0
- pcba/parsers/ioc/reader.py +182 -0
- pcba/parsers/ioc/validate.py +192 -0
- pcba/parsers/kicad/__init__.py +103 -0
- pcba/parsers/kicad/board.py +269 -0
- pcba/parsers/kicad/kicad_cli.py +190 -0
- pcba/parsers/kicad/pinmap.py +90 -0
- pcba/parsers/kicad/schematic.py +517 -0
- pcba/parsers/kicad/sexpr.py +136 -0
- pcba/publish.py +269 -0
- pcba/report/__init__.py +7 -0
- pcba/report/json_report.py +39 -0
- pcba/report/sarif.py +128 -0
- pcba/report/text.py +304 -0
- pcba/requirements.py +320 -0
- pcba/review.py +177 -0
- pcba/rules/__init__.py +312 -0
- pcba/rules/cross_domain.py +496 -0
- pcba/rules/kicad_board.py +775 -0
- pcba/rules/kicad_common.py +167 -0
- pcba/rules/kicad_components.py +603 -0
- pcba/rules/kicad_schematic.py +621 -0
- pcba/rules/locate.py +22 -0
- pcba/rules/schematic.py +123 -0
- pcba/rules/signals.py +149 -0
- pcba/rules/spice.py +59 -0
- pcba/rules/stm32.py +155 -0
- pcba/simulation.py +143 -0
pcba/publish.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Publish a review report to a development project's check runs (`enginehall review --publish`).
|
|
2
|
+
|
|
3
|
+
Only the report leaves the machine: findings, their locations and evidence, and coverage. No
|
|
4
|
+
design file, firmware source or absolute local path is sent (principle 3): the project paths in
|
|
5
|
+
the report are made project-relative, and the local root and home folder are scrubbed from every
|
|
6
|
+
other string. Stdlib only, like the rest of the engine.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import subprocess
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.parse
|
|
17
|
+
import urllib.request
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path, PurePath
|
|
20
|
+
|
|
21
|
+
from pcba import __version__
|
|
22
|
+
|
|
23
|
+
DEFAULT_TOKEN_ENV = "PCBA_WORKSPACE_TOKEN"
|
|
24
|
+
MAX_BODY_BYTES = 8 * 1024 * 1024
|
|
25
|
+
TIMEOUT_SECONDS = 30
|
|
26
|
+
MAX_REF = 255
|
|
27
|
+
|
|
28
|
+
_KEY = re.compile(r"^[A-Za-z][A-Za-z0-9]{1,9}$")
|
|
29
|
+
_SHA = re.compile(r"^[0-9a-fA-F]{7,64}$")
|
|
30
|
+
_ENV_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
31
|
+
_LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
|
32
|
+
_UUID = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
|
|
33
|
+
# `git@github.com:owner/name.git`, the scp-like form `git remote` reports for SSH clones.
|
|
34
|
+
_SCP_REMOTE = re.compile(r"^(?:[A-Za-z0-9._-]+@)?([A-Za-z0-9.-]+):(?!//)([^\s]+)$")
|
|
35
|
+
MAX_REPOSITORY = 500
|
|
36
|
+
GIT_TIMEOUT_SECONDS = 5
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class PublishError(Exception):
|
|
40
|
+
"""A publish that could not be prepared or delivered; the CLI reports it with exit 2."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class Target:
|
|
45
|
+
project: str
|
|
46
|
+
url: str
|
|
47
|
+
token: str
|
|
48
|
+
commit: str | None
|
|
49
|
+
ref: str | None
|
|
50
|
+
source: str
|
|
51
|
+
repository: str | None = None
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def endpoint(self) -> str:
|
|
55
|
+
return f"{self.url}/api/p/{urllib.parse.quote(self.project)}/check-runs"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _truthy(value: str | None) -> bool:
|
|
59
|
+
return (value or "").strip().lower() not in ("", "0", "false", "no")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _origin(value: str) -> str:
|
|
63
|
+
url = value.strip().rstrip("/")
|
|
64
|
+
parsed = urllib.parse.urlsplit(url)
|
|
65
|
+
if parsed.scheme not in ("https", "http") or not parsed.hostname:
|
|
66
|
+
raise PublishError(f"--url must be an http(s) origin such as https://enginehall.com, not {value!r}")
|
|
67
|
+
if parsed.scheme == "http" and parsed.hostname not in _LOCAL_HOSTS:
|
|
68
|
+
raise PublishError("--url must use https (plain http is accepted only for localhost)")
|
|
69
|
+
if parsed.query or parsed.fragment or parsed.username or parsed.password:
|
|
70
|
+
raise PublishError("--url must be the workspace origin, without credentials, query or fragment")
|
|
71
|
+
return url
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def repository_url(value: str | None) -> str | None:
|
|
75
|
+
"""A repository id, or a clone URL as https without credentials, `.git` or query; None if unusable.
|
|
76
|
+
|
|
77
|
+
`git@host:owner/name.git`, `ssh://git@host:22/owner/name` and `https://user:token@host/owner/name`
|
|
78
|
+
all become `https://host/owner/name`, so no credential embedded in a remote ever leaves the machine.
|
|
79
|
+
"""
|
|
80
|
+
text = (value or "").strip()
|
|
81
|
+
if not text:
|
|
82
|
+
return None
|
|
83
|
+
if _UUID.match(text):
|
|
84
|
+
return text.lower()
|
|
85
|
+
scp = _SCP_REMOTE.match(text)
|
|
86
|
+
if scp and "://" not in text:
|
|
87
|
+
host, path = scp.group(1), scp.group(2)
|
|
88
|
+
else:
|
|
89
|
+
try:
|
|
90
|
+
parsed = urllib.parse.urlsplit(text)
|
|
91
|
+
except ValueError:
|
|
92
|
+
return None
|
|
93
|
+
if parsed.scheme not in ("https", "http", "ssh", "git", "git+ssh") or not parsed.hostname:
|
|
94
|
+
return None
|
|
95
|
+
host, path = parsed.hostname, parsed.path
|
|
96
|
+
path = re.sub(r"\.git$", "", path.strip("/").rstrip("/"))
|
|
97
|
+
# A dotted host only: `C:/repo` or a single-label name is a local path or a LAN host, never a match.
|
|
98
|
+
if not path or not re.match(r"^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$", host or ""):
|
|
99
|
+
return None
|
|
100
|
+
url = f"https://{host.lower()}/{path}"
|
|
101
|
+
return url if len(url) <= MAX_REPOSITORY and " " not in url else None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _git(root: Path, *args: str) -> str | None:
|
|
105
|
+
"""One `git` answer for the reviewed folder, or None: no git, no repository, or no answer in time."""
|
|
106
|
+
try:
|
|
107
|
+
done = subprocess.run(["git", "-C", str(root), *args], capture_output=True, text=True,
|
|
108
|
+
timeout=GIT_TIMEOUT_SECONDS, check=False)
|
|
109
|
+
except (OSError, subprocess.SubprocessError, ValueError):
|
|
110
|
+
return None
|
|
111
|
+
out = done.stdout.strip() if done.returncode == 0 else ""
|
|
112
|
+
return out or None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def detect_commit(root: Path) -> str | None:
|
|
116
|
+
head = _git(root, "rev-parse", "HEAD")
|
|
117
|
+
return head.lower() if head and _SHA.match(head) else None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def detect_repository(root: Path) -> str | None:
|
|
121
|
+
return repository_url(_git(root, "remote", "get-url", "origin"))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def target(
|
|
125
|
+
project: str | None,
|
|
126
|
+
url: str | None,
|
|
127
|
+
token_env: str | None,
|
|
128
|
+
commit: str | None,
|
|
129
|
+
environ: dict[str, str] | None = None,
|
|
130
|
+
repository: str | None = None,
|
|
131
|
+
root: Path | None = None,
|
|
132
|
+
) -> Target:
|
|
133
|
+
"""Check every publish argument before the review runs, so a typo never costs a full review.
|
|
134
|
+
|
|
135
|
+
Without `commit` the commit is `$GITHUB_SHA`, else `git rev-parse HEAD` in `root`; without
|
|
136
|
+
`repository` it is the `origin` remote's URL. A folder without git publishes without them.
|
|
137
|
+
"""
|
|
138
|
+
env = os.environ if environ is None else environ
|
|
139
|
+
if not project or not _KEY.match(project):
|
|
140
|
+
raise PublishError("--publish needs --project KEY: the development project's key (2 to 10 letters or digits)")
|
|
141
|
+
if not url:
|
|
142
|
+
raise PublishError("--publish needs --url, the workspace origin (e.g. https://enginehall.com)")
|
|
143
|
+
name = token_env or DEFAULT_TOKEN_ENV
|
|
144
|
+
if not _ENV_NAME.match(name):
|
|
145
|
+
raise PublishError(f"--token-env must name an environment variable, not {name!r}")
|
|
146
|
+
token = (env.get(name) or "").strip()
|
|
147
|
+
if not token:
|
|
148
|
+
raise PublishError(f"--publish needs a project token in the environment variable {name}")
|
|
149
|
+
if commit is not None and not _SHA.match(commit):
|
|
150
|
+
raise PublishError("--commit must be a commit hash (7 to 64 hex digits)")
|
|
151
|
+
if commit is None and _SHA.match(env.get("GITHUB_SHA", "")):
|
|
152
|
+
commit = env["GITHUB_SHA"]
|
|
153
|
+
if repository is not None:
|
|
154
|
+
named = repository_url(repository)
|
|
155
|
+
if named is None:
|
|
156
|
+
raise PublishError("--repository must be a repository id or its URL (https, ssh or git@host:path)")
|
|
157
|
+
repository = named
|
|
158
|
+
if root is not None:
|
|
159
|
+
if commit is None:
|
|
160
|
+
commit = detect_commit(root)
|
|
161
|
+
if repository is None:
|
|
162
|
+
repository = detect_repository(root)
|
|
163
|
+
ref = (env.get("GITHUB_REF_NAME") or "").strip()[:MAX_REF] or None
|
|
164
|
+
source = "ci" if _truthy(env.get("CI")) or _truthy(env.get("GITHUB_ACTIONS")) else "cli"
|
|
165
|
+
return Target(project.upper(), _origin(url), token, commit.lower() if commit else None, ref, source, repository)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _relative(value: str | None, root: Path) -> str | None:
|
|
169
|
+
if not value:
|
|
170
|
+
return None
|
|
171
|
+
path = Path(value)
|
|
172
|
+
try:
|
|
173
|
+
return path.resolve().relative_to(root).as_posix() or "."
|
|
174
|
+
except ValueError:
|
|
175
|
+
return PurePath(os.path.relpath(path.resolve(), root)).as_posix()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _scrubber(root: Path):
|
|
179
|
+
"""Replace the local root, then the home folder, in a string."""
|
|
180
|
+
pairs = []
|
|
181
|
+
for base, label in ((root, "."), (Path.home(), "~")):
|
|
182
|
+
for form in {str(base), base.as_posix()}:
|
|
183
|
+
if len(form) > 1:
|
|
184
|
+
pairs.append((form, label))
|
|
185
|
+
pairs.sort(key=lambda pair: -len(pair[0]))
|
|
186
|
+
|
|
187
|
+
def scrub(text: str) -> str:
|
|
188
|
+
for old, new in pairs:
|
|
189
|
+
text = text.replace(old, new)
|
|
190
|
+
return text
|
|
191
|
+
|
|
192
|
+
return scrub
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _scrub(value, scrub):
|
|
196
|
+
if isinstance(value, str):
|
|
197
|
+
return scrub(value)
|
|
198
|
+
if isinstance(value, list):
|
|
199
|
+
return [_scrub(item, scrub) for item in value]
|
|
200
|
+
if isinstance(value, dict):
|
|
201
|
+
return {key: _scrub(item, scrub) for key, item in value.items()}
|
|
202
|
+
return value
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def report(result) -> dict:
|
|
206
|
+
"""The JSON report of a review, with no absolute local path in it."""
|
|
207
|
+
payload = json.loads(result.render("json"))
|
|
208
|
+
root = Path(result.discovery.root).resolve()
|
|
209
|
+
project = payload.get("project") or {}
|
|
210
|
+
project["root"] = "."
|
|
211
|
+
for key in ("schematic_dir", "kicad_project", "ioc"):
|
|
212
|
+
project[key] = _relative(project.get(key), root)
|
|
213
|
+
project["firmware_roots"] = [_relative(p, root) for p in project.get("firmware_roots") or []]
|
|
214
|
+
payload["project"] = project
|
|
215
|
+
return _scrub(payload, _scrubber(root))
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def body(result, destination: Target) -> bytes:
|
|
219
|
+
data: dict = {"report": report(result), "source": destination.source}
|
|
220
|
+
if destination.commit:
|
|
221
|
+
data["commit_sha"] = destination.commit
|
|
222
|
+
if destination.ref:
|
|
223
|
+
data["ref"] = destination.ref
|
|
224
|
+
if destination.repository:
|
|
225
|
+
data["repository"] = destination.repository
|
|
226
|
+
encoded = json.dumps(data, separators=(",", ":")).encode("utf-8")
|
|
227
|
+
if len(encoded) > MAX_BODY_BYTES:
|
|
228
|
+
raise PublishError(f"the report is larger than {MAX_BODY_BYTES // (1024 * 1024)} MiB and was not published")
|
|
229
|
+
return encoded
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _reason(error: urllib.error.HTTPError) -> str:
|
|
233
|
+
try:
|
|
234
|
+
detail = json.loads(error.read().decode("utf-8", "replace") or "{}")
|
|
235
|
+
except (ValueError, OSError):
|
|
236
|
+
detail = {}
|
|
237
|
+
message = detail.get("statusMessage") or detail.get("message") if isinstance(detail, dict) else None
|
|
238
|
+
return f"HTTP {error.code}" + (f" ({message})" if isinstance(message, str) and message else "")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def publish(result, destination: Target) -> dict:
|
|
242
|
+
"""POST the report; returns the server's answer (`id`, `url`). Raises PublishError."""
|
|
243
|
+
request = urllib.request.Request(
|
|
244
|
+
destination.endpoint,
|
|
245
|
+
data=body(result, destination),
|
|
246
|
+
method="POST",
|
|
247
|
+
headers={
|
|
248
|
+
"Authorization": f"Bearer {destination.token}",
|
|
249
|
+
"Content-Type": "application/json",
|
|
250
|
+
"Accept": "application/json",
|
|
251
|
+
"User-Agent": f"pcba/{__version__}",
|
|
252
|
+
},
|
|
253
|
+
)
|
|
254
|
+
try:
|
|
255
|
+
with urllib.request.urlopen(request, timeout=TIMEOUT_SECONDS) as response:
|
|
256
|
+
raw = response.read()
|
|
257
|
+
except urllib.error.HTTPError as exc:
|
|
258
|
+
raise PublishError(f"could not publish the report: {_reason(exc)}") from None
|
|
259
|
+
except urllib.error.URLError as exc:
|
|
260
|
+
raise PublishError(f"could not publish the report: {exc.reason}") from None
|
|
261
|
+
except (TimeoutError, OSError) as exc:
|
|
262
|
+
raise PublishError(f"could not publish the report: {exc}") from None
|
|
263
|
+
try:
|
|
264
|
+
answer = json.loads(raw.decode("utf-8"))
|
|
265
|
+
except ValueError:
|
|
266
|
+
raise PublishError("could not publish the report: the server's answer is not JSON") from None
|
|
267
|
+
if not isinstance(answer, dict) or not isinstance(answer.get("id"), str):
|
|
268
|
+
raise PublishError("could not publish the report: the server's answer has no check run id")
|
|
269
|
+
return answer
|
pcba/report/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Rendering findings: text for humans, JSON for agents, SARIF for CI."""
|
|
2
|
+
|
|
3
|
+
from pcba.report.json_report import to_json
|
|
4
|
+
from pcba.report.sarif import to_sarif
|
|
5
|
+
from pcba.report.text import to_markdown, to_text
|
|
6
|
+
|
|
7
|
+
__all__ = ["to_json", "to_markdown", "to_sarif", "to_text"]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Machine-readable output.
|
|
2
|
+
|
|
3
|
+
Every consumer that is not a human reads this: the MCP server, CI, and any agent. It
|
|
4
|
+
validates against `schemas/finding.schema.json`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
|
|
11
|
+
from pcba import __version__
|
|
12
|
+
from pcba.model.finding import Finding, Severity
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def to_json(
|
|
16
|
+
findings: list[Finding],
|
|
17
|
+
coverage: dict[str, list[str]] | None = None,
|
|
18
|
+
project: dict | None = None,
|
|
19
|
+
indent: int | None = 2,
|
|
20
|
+
*,
|
|
21
|
+
baseline: dict | None = None,
|
|
22
|
+
changes: dict | None = None,
|
|
23
|
+
) -> str:
|
|
24
|
+
counts = {str(s): sum(1 for f in findings if f.severity is s) for s in Severity}
|
|
25
|
+
payload = {
|
|
26
|
+
"schema_version": 1,
|
|
27
|
+
"tool": {"name": "pcba", "version": __version__},
|
|
28
|
+
"project": project or {},
|
|
29
|
+
"summary": {"total": len(findings), **counts},
|
|
30
|
+
# Callers gate on this. It is not the same as "no findings" and must not be
|
|
31
|
+
# inferred from an empty list.
|
|
32
|
+
"coverage": coverage or {},
|
|
33
|
+
"findings": [f.to_dict() for f in findings],
|
|
34
|
+
}
|
|
35
|
+
if baseline is not None:
|
|
36
|
+
payload["baseline"] = baseline
|
|
37
|
+
if changes is not None:
|
|
38
|
+
payload["changes"] = changes
|
|
39
|
+
return json.dumps(payload, indent=indent, sort_keys=False)
|
pcba/report/sarif.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""SARIF 2.1.0 output.
|
|
2
|
+
|
|
3
|
+
Cheap to emit and it unlocks the VS Code Problems panel, GitHub code scanning, and most CI
|
|
4
|
+
annotators for free — which is why it ships in v1 rather than waiting for a bespoke UI.
|
|
5
|
+
|
|
6
|
+
The awkward part is that SARIF assumes findings live at a line in a text file, and ours
|
|
7
|
+
often live at a pin on a schematic sheet inside a binary container. Rather than fake a line
|
|
8
|
+
number, artifacts get `region` only when we genuinely have one, and the schematic location
|
|
9
|
+
travels in the message and in `properties`.
|
|
10
|
+
|
|
11
|
+
Artifact URIs are the project-relative paths every finding already carries, declared
|
|
12
|
+
relative to `%SRCROOT%` so a viewer opens the right file no matter where the report was
|
|
13
|
+
produced or which directory the consumer runs in.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
from pathlib import Path, PurePosixPath, PureWindowsPath
|
|
21
|
+
from urllib.parse import quote
|
|
22
|
+
|
|
23
|
+
from pcba import __version__
|
|
24
|
+
from pcba.model.finding import Finding, Severity
|
|
25
|
+
|
|
26
|
+
_LEVEL = {Severity.ERROR: "error", Severity.WARNING: "warning", Severity.INFO: "note"}
|
|
27
|
+
_ROOT_ID = "%SRCROOT%"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _artifact_location(artifact: str, windows: bool = os.name == "nt") -> dict:
|
|
31
|
+
"""A URI reference for a finding's artifact: percent-encoded, so a space, `#` or `%` in a
|
|
32
|
+
file name cannot cut the path short; relative to `%SRCROOT%` unless it lies outside the
|
|
33
|
+
project, where the absolute path becomes a file URI. That path is read with the rules of
|
|
34
|
+
the machine that wrote it: `//server/share` is a UNC share on Windows and a plain path
|
|
35
|
+
elsewhere."""
|
|
36
|
+
flavours = (PureWindowsPath, PurePosixPath) if windows else (PurePosixPath, PureWindowsPath)
|
|
37
|
+
for flavour in flavours:
|
|
38
|
+
path = flavour(artifact)
|
|
39
|
+
if path.is_absolute():
|
|
40
|
+
return {"uri": path.as_uri()}
|
|
41
|
+
return {"uri": quote(artifact, safe="/"), "uriBaseId": _ROOT_ID}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def to_sarif(
|
|
45
|
+
findings: list[Finding],
|
|
46
|
+
root: Path | None = None,
|
|
47
|
+
indent: int | None = 2,
|
|
48
|
+
*,
|
|
49
|
+
coverage: dict | None = None,
|
|
50
|
+
discovery: dict | None = None,
|
|
51
|
+
baseline: dict | None = None,
|
|
52
|
+
changes: dict | None = None,
|
|
53
|
+
) -> str:
|
|
54
|
+
rules: dict[str, dict] = {}
|
|
55
|
+
results: list[dict] = []
|
|
56
|
+
|
|
57
|
+
for f in findings:
|
|
58
|
+
rules.setdefault(
|
|
59
|
+
f.rule_id,
|
|
60
|
+
{
|
|
61
|
+
"id": f.rule_id,
|
|
62
|
+
"name": f.rule_id,
|
|
63
|
+
"shortDescription": {"text": f.title},
|
|
64
|
+
"fullDescription": {"text": f.explanation},
|
|
65
|
+
"help": {"text": f"{f.consequence}\n\n{f.recommendation}"},
|
|
66
|
+
"defaultConfiguration": {"level": _LEVEL[f.severity]},
|
|
67
|
+
"properties": {"detector": str(f.detector), "domain": str(f.domain)},
|
|
68
|
+
},
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
location: dict = {
|
|
72
|
+
"physicalLocation": {"artifactLocation": _artifact_location(f.location.artifact)}
|
|
73
|
+
}
|
|
74
|
+
if f.location.line:
|
|
75
|
+
location["physicalLocation"]["region"] = {"startLine": f.location.line}
|
|
76
|
+
|
|
77
|
+
evidence = " ".join(e.value for e in f.evidence)
|
|
78
|
+
results.append(
|
|
79
|
+
{
|
|
80
|
+
"ruleId": f.rule_id,
|
|
81
|
+
"level": _LEVEL[f.severity],
|
|
82
|
+
"message": {"text": f"{f.title}. {f.explanation} {evidence}".strip()},
|
|
83
|
+
"locations": [location],
|
|
84
|
+
"partialFingerprints": {"pcbaFindingId": f.id},
|
|
85
|
+
"properties": {
|
|
86
|
+
"detector": str(f.detector),
|
|
87
|
+
"confidence": f.confidence,
|
|
88
|
+
"pin": f.location.pin,
|
|
89
|
+
"sheet": f.location.sheet,
|
|
90
|
+
"component": f.location.component,
|
|
91
|
+
"recommendation": f.recommendation,
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
run: dict = {
|
|
97
|
+
"tool": {
|
|
98
|
+
"driver": {
|
|
99
|
+
"name": "EngineHall",
|
|
100
|
+
"version": __version__,
|
|
101
|
+
"informationUri": "https://enginehall.com",
|
|
102
|
+
"rules": list(rules.values()),
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
"results": results,
|
|
106
|
+
}
|
|
107
|
+
if root:
|
|
108
|
+
run["originalUriBaseIds"] = {_ROOT_ID: {"uri": Path(root).resolve().as_uri() + "/"}}
|
|
109
|
+
properties = {}
|
|
110
|
+
if coverage is not None:
|
|
111
|
+
properties["coverage"] = coverage
|
|
112
|
+
if discovery is not None:
|
|
113
|
+
properties["discovery"] = discovery
|
|
114
|
+
if baseline is not None:
|
|
115
|
+
properties["baseline"] = baseline
|
|
116
|
+
if changes is not None:
|
|
117
|
+
properties["changes"] = changes
|
|
118
|
+
if properties:
|
|
119
|
+
run["properties"] = properties
|
|
120
|
+
|
|
121
|
+
return json.dumps(
|
|
122
|
+
{
|
|
123
|
+
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
|
|
124
|
+
"version": "2.1.0",
|
|
125
|
+
"runs": [run],
|
|
126
|
+
},
|
|
127
|
+
indent=indent,
|
|
128
|
+
)
|