PTSIP 0.3.1__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.
- ptsip/__init__.py +3 -0
- ptsip/__main__.py +4 -0
- ptsip/app/__init__.py +5 -0
- ptsip/app/client.py +53 -0
- ptsip/app/github_client.py +200 -0
- ptsip/app/server.py +130 -0
- ptsip/app/service.py +285 -0
- ptsip/app/store.py +233 -0
- ptsip/artifact_evidence.py +150 -0
- ptsip/build_resolution.py +298 -0
- ptsip/clarification/__init__.py +6 -0
- ptsip/clarification/generator.py +120 -0
- ptsip/clarification/generator_core.py +98 -0
- ptsip/clarification/i18n.py +106 -0
- ptsip/clarification/model.py +33 -0
- ptsip/clarification/render.py +78 -0
- ptsip/clarification/resolution/__init__.py +29 -0
- ptsip/clarification/resolution/model.py +33 -0
- ptsip/clarification/resolution/parser.py +56 -0
- ptsip/clarification/resolution/profile_projection.py +198 -0
- ptsip/clarification/resolution/resolver.py +30 -0
- ptsip/clarification/transports/__init__.py +1 -0
- ptsip/clarification/transports/github_issue.py +162 -0
- ptsip/cli.py +402 -0
- ptsip/conformance.py +531 -0
- ptsip/conformance_audit.py +143 -0
- ptsip/conformance_engine.py +396 -0
- ptsip/constants.py +9 -0
- ptsip/doctor.py +33 -0
- ptsip/inspection/__init__.py +1 -0
- ptsip/inspection/components.py +74 -0
- ptsip/inspection/dependencies.py +496 -0
- ptsip/inspection/dependencies_030.py +69 -0
- ptsip/inspection/dotnet.py +188 -0
- ptsip/inspection/go.py +157 -0
- ptsip/inspection/inventory.py +161 -0
- ptsip/inspection/javascript.py +376 -0
- ptsip/inspection/lexing.py +121 -0
- ptsip/inspection/source_adapters.py +60 -0
- ptsip/lifecycle_evidence.py +245 -0
- ptsip/model.py +105 -0
- ptsip/pilot/__init__.py +1 -0
- ptsip/pilot/runner.py +170 -0
- ptsip/repository/__init__.py +1 -0
- ptsip/repository/discover.py +68 -0
- ptsip/repository/remote.py +54 -0
- ptsip/repository/snapshot.py +173 -0
- ptsip/review_evidence.py +406 -0
- ptsip/spec_identity.py +22 -0
- ptsip/specdata/ptsip-agent-classification.schema.json +35 -0
- ptsip/specdata/ptsip-artifact-evidence.schema.json +68 -0
- ptsip/specdata/ptsip-diagnostic.schema.json +29 -0
- ptsip/specdata/ptsip-profile.schema.json +310 -0
- ptsip/specdata/ptsip-registry.yaml +189 -0
- ptsip/storage/__init__.py +1 -0
- ptsip/storage/local_state.py +32 -0
- ptsip/validation/__init__.py +1 -0
- ptsip/validation/components.py +164 -0
- ptsip/validation/profile.py +172 -0
- ptsip/validation/rules.py +163 -0
- ptsip-0.3.1.dist-info/METADATA +338 -0
- ptsip-0.3.1.dist-info/RECORD +66 -0
- ptsip-0.3.1.dist-info/WHEEL +5 -0
- ptsip-0.3.1.dist-info/entry_points.txt +3 -0
- ptsip-0.3.1.dist-info/licenses/LICENSE +201 -0
- ptsip-0.3.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import re
|
|
5
|
+
import shlex
|
|
6
|
+
import sys
|
|
7
|
+
import tokenize
|
|
8
|
+
import tomllib
|
|
9
|
+
import xml.etree.ElementTree as ET
|
|
10
|
+
from dataclasses import asdict, dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
from ..model import (
|
|
16
|
+
DependencyEdge,
|
|
17
|
+
DependencyPhase,
|
|
18
|
+
EdgeType,
|
|
19
|
+
EvidenceNodeScope,
|
|
20
|
+
EvidenceProvenance,
|
|
21
|
+
ResolutionStatus,
|
|
22
|
+
)
|
|
23
|
+
from ..repository.snapshot import repository_files
|
|
24
|
+
|
|
25
|
+
_REQUIREMENT_NAME_RE = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9_.-]*)")
|
|
26
|
+
_SCRIPT_SUFFIXES = {".py", ".ps1", ".sh", ".bash", ".bat", ".cmd"}
|
|
27
|
+
_PYTHON_RUNNERS = {"python", "python3", "python.exe", "python3.exe", "py", "py.exe"}
|
|
28
|
+
_POWERSHELL_RUNNERS = {"pwsh", "pwsh.exe", "powershell", "powershell.exe"}
|
|
29
|
+
_SHELL_RUNNERS = {"bash", "sh"}
|
|
30
|
+
_CMD_RUNNERS = {"cmd", "cmd.exe"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class DependencyScanIssue:
|
|
35
|
+
adapter: str
|
|
36
|
+
path: str
|
|
37
|
+
message: str
|
|
38
|
+
|
|
39
|
+
def as_dict(self) -> dict[str, str]:
|
|
40
|
+
return asdict(self)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class DependencyScan:
|
|
45
|
+
edges: tuple[DependencyEdge, ...]
|
|
46
|
+
issues: tuple[DependencyScanIssue, ...]
|
|
47
|
+
adapters: tuple[str, ...]
|
|
48
|
+
|
|
49
|
+
def as_dict(self) -> dict[str, object]:
|
|
50
|
+
return {
|
|
51
|
+
"edges": [edge.as_dict() for edge in self.edges],
|
|
52
|
+
"issues": [issue.as_dict() for issue in self.issues],
|
|
53
|
+
"adapters": list(self.adapters),
|
|
54
|
+
"edge_count": len(self.edges),
|
|
55
|
+
"coverage_complete": not self.issues,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _normalize_dependency_name(value: str) -> str:
|
|
60
|
+
return re.sub(r"[-.]", "_", value.strip().lower())
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _declared_python_dependencies(root: Path) -> tuple[set[str], list[DependencyScanIssue]]:
|
|
64
|
+
names: set[str] = set()
|
|
65
|
+
issues: list[DependencyScanIssue] = []
|
|
66
|
+
pyproject = root / "pyproject.toml"
|
|
67
|
+
if pyproject.is_file():
|
|
68
|
+
try:
|
|
69
|
+
payload = tomllib.loads(pyproject.read_text(encoding="utf-8-sig"))
|
|
70
|
+
project = payload.get("project", {}) if isinstance(payload, dict) else {}
|
|
71
|
+
if isinstance(project, dict):
|
|
72
|
+
groups: list[object] = [project.get("dependencies", [])]
|
|
73
|
+
optional = project.get("optional-dependencies", {})
|
|
74
|
+
if isinstance(optional, dict):
|
|
75
|
+
groups.extend(optional.values())
|
|
76
|
+
for group in groups:
|
|
77
|
+
if not isinstance(group, list):
|
|
78
|
+
continue
|
|
79
|
+
for item in group:
|
|
80
|
+
if not isinstance(item, str):
|
|
81
|
+
continue
|
|
82
|
+
match = _REQUIREMENT_NAME_RE.match(item)
|
|
83
|
+
if match:
|
|
84
|
+
names.add(_normalize_dependency_name(match.group(1)))
|
|
85
|
+
except (OSError, UnicodeError, tomllib.TOMLDecodeError) as exc:
|
|
86
|
+
issues.append(DependencyScanIssue("python-declarations", "pyproject.toml", str(exc)))
|
|
87
|
+
|
|
88
|
+
for requirements in sorted(root.glob("requirements*.txt")) + sorted(root.glob("requirements*.in")):
|
|
89
|
+
rel = requirements.relative_to(root).as_posix()
|
|
90
|
+
try:
|
|
91
|
+
lines = requirements.read_text(encoding="utf-8-sig").splitlines()
|
|
92
|
+
except (OSError, UnicodeError) as exc:
|
|
93
|
+
issues.append(DependencyScanIssue("python-declarations", rel, str(exc)))
|
|
94
|
+
continue
|
|
95
|
+
for line in lines:
|
|
96
|
+
stripped = line.strip()
|
|
97
|
+
if not stripped or stripped.startswith(("#", "-r", "--requirement", "-c", "--constraint", "-e", "--editable")):
|
|
98
|
+
continue
|
|
99
|
+
match = _REQUIREMENT_NAME_RE.match(stripped)
|
|
100
|
+
if match:
|
|
101
|
+
names.add(_normalize_dependency_name(match.group(1)))
|
|
102
|
+
return names, issues
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _resolve_module(root: Path, module: str) -> str | None:
|
|
106
|
+
if not module:
|
|
107
|
+
return None
|
|
108
|
+
rel = Path(*module.split("."))
|
|
109
|
+
candidates = [
|
|
110
|
+
root / rel.with_suffix(".py"),
|
|
111
|
+
root / rel / "__init__.py",
|
|
112
|
+
root / "src" / rel.with_suffix(".py"),
|
|
113
|
+
root / "src" / rel / "__init__.py",
|
|
114
|
+
]
|
|
115
|
+
for candidate in candidates:
|
|
116
|
+
if candidate.is_file():
|
|
117
|
+
return candidate.relative_to(root).as_posix()
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _source_package_parts(root: Path, rel: str) -> list[str]:
|
|
122
|
+
path = root / rel
|
|
123
|
+
parent = path.parent
|
|
124
|
+
parts: list[str] = []
|
|
125
|
+
while parent != root and (parent / "__init__.py").is_file():
|
|
126
|
+
parts.insert(0, parent.name)
|
|
127
|
+
parent = parent.parent
|
|
128
|
+
return parts
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _resolve_relative_import(root: Path, rel: str, node: ast.ImportFrom) -> tuple[str, str | None]:
|
|
132
|
+
package = _source_package_parts(root, rel)
|
|
133
|
+
if not package or node.level <= 0 or node.level > len(package):
|
|
134
|
+
target = ("." * node.level) + (node.module or "")
|
|
135
|
+
return target or "<relative>", None
|
|
136
|
+
|
|
137
|
+
keep = len(package) - node.level + 1
|
|
138
|
+
base_parts = package[:keep]
|
|
139
|
+
if node.module:
|
|
140
|
+
base_parts.extend(node.module.split("."))
|
|
141
|
+
target = ".".join(base_parts)
|
|
142
|
+
return target, _resolve_module(root, target)
|
|
143
|
+
|
|
144
|
+
for alias in node.names:
|
|
145
|
+
if alias.name == "*":
|
|
146
|
+
continue
|
|
147
|
+
target = ".".join([*base_parts, *alias.name.split(".")])
|
|
148
|
+
resolved = _resolve_module(root, target)
|
|
149
|
+
if resolved:
|
|
150
|
+
return target, resolved
|
|
151
|
+
|
|
152
|
+
target = ".".join(base_parts)
|
|
153
|
+
return target, _resolve_module(root, target)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _python_target_state(
|
|
157
|
+
module: str,
|
|
158
|
+
resolved: str | None,
|
|
159
|
+
declared_dependencies: set[str],
|
|
160
|
+
) -> tuple[ResolutionStatus, EvidenceNodeScope, str | None]:
|
|
161
|
+
if resolved:
|
|
162
|
+
return ResolutionStatus.RESOLVED, EvidenceNodeScope.PROJECT_COMPONENT, None
|
|
163
|
+
root_name = module.lstrip(".").split(".", 1)[0]
|
|
164
|
+
if root_name and root_name in sys.stdlib_module_names:
|
|
165
|
+
return ResolutionStatus.EXTERNAL, EvidenceNodeScope.PLATFORM, "Python standard-library target"
|
|
166
|
+
if root_name and _normalize_dependency_name(root_name) in declared_dependencies:
|
|
167
|
+
return ResolutionStatus.EXTERNAL, EvidenceNodeScope.EXTERNAL_DEPENDENCY, "Target matches a declared Python dependency"
|
|
168
|
+
return ResolutionStatus.UNRESOLVED, EvidenceNodeScope.UNRESOLVED_TARGET, None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _python_edges(
|
|
172
|
+
root: Path,
|
|
173
|
+
rel: str,
|
|
174
|
+
declared_dependencies: set[str],
|
|
175
|
+
) -> tuple[list[DependencyEdge], list[DependencyScanIssue]]:
|
|
176
|
+
path = root / rel
|
|
177
|
+
try:
|
|
178
|
+
with tokenize.open(path) as handle:
|
|
179
|
+
source = handle.read()
|
|
180
|
+
tree = ast.parse(source, filename=rel)
|
|
181
|
+
except (OSError, UnicodeError, SyntaxError) as exc:
|
|
182
|
+
return [], [DependencyScanIssue("python", rel, str(exc))]
|
|
183
|
+
|
|
184
|
+
edges: list[DependencyEdge] = []
|
|
185
|
+
for node in ast.walk(tree):
|
|
186
|
+
if isinstance(node, ast.Import):
|
|
187
|
+
for alias in node.names:
|
|
188
|
+
resolved = _resolve_module(root, alias.name)
|
|
189
|
+
resolution, scope, note = _python_target_state(alias.name, resolved, declared_dependencies)
|
|
190
|
+
edges.append(
|
|
191
|
+
DependencyEdge(
|
|
192
|
+
evidence_id=f"python:{rel}:{node.lineno}:{alias.name}",
|
|
193
|
+
source=rel,
|
|
194
|
+
target=alias.name,
|
|
195
|
+
edge_type=EdgeType.IMPORTS,
|
|
196
|
+
phase=DependencyPhase.UNKNOWN,
|
|
197
|
+
resolution=resolution,
|
|
198
|
+
target_scope=scope,
|
|
199
|
+
provenance=EvidenceProvenance.OBSERVED,
|
|
200
|
+
line=node.lineno,
|
|
201
|
+
resolved_path=resolved,
|
|
202
|
+
adapter="python",
|
|
203
|
+
note=note,
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
elif isinstance(node, ast.ImportFrom):
|
|
207
|
+
if node.level:
|
|
208
|
+
target, resolved = _resolve_relative_import(root, rel, node)
|
|
209
|
+
else:
|
|
210
|
+
target = node.module or "<unknown-import>"
|
|
211
|
+
resolved = _resolve_module(root, node.module or "")
|
|
212
|
+
resolution, scope, note = _python_target_state(target, resolved, declared_dependencies)
|
|
213
|
+
if node.level and not resolved and note is None:
|
|
214
|
+
note = "Relative import target could not be resolved from repository package evidence"
|
|
215
|
+
edges.append(
|
|
216
|
+
DependencyEdge(
|
|
217
|
+
evidence_id=f"python:{rel}:{node.lineno}:{target}",
|
|
218
|
+
source=rel,
|
|
219
|
+
target=target,
|
|
220
|
+
edge_type=EdgeType.IMPORTS,
|
|
221
|
+
phase=DependencyPhase.UNKNOWN,
|
|
222
|
+
resolution=resolution,
|
|
223
|
+
target_scope=scope,
|
|
224
|
+
provenance=EvidenceProvenance.OBSERVED,
|
|
225
|
+
line=node.lineno,
|
|
226
|
+
resolved_path=resolved,
|
|
227
|
+
adapter="python",
|
|
228
|
+
note=note,
|
|
229
|
+
)
|
|
230
|
+
)
|
|
231
|
+
elif isinstance(node, ast.Call):
|
|
232
|
+
is_importlib = (
|
|
233
|
+
isinstance(node.func, ast.Attribute)
|
|
234
|
+
and isinstance(node.func.value, ast.Name)
|
|
235
|
+
and node.func.value.id == "importlib"
|
|
236
|
+
and node.func.attr == "import_module"
|
|
237
|
+
)
|
|
238
|
+
is_builtin = isinstance(node.func, ast.Name) and node.func.id == "__import__"
|
|
239
|
+
if not (is_importlib or is_builtin):
|
|
240
|
+
continue
|
|
241
|
+
module_value = node.args[0] if node.args else None
|
|
242
|
+
if isinstance(module_value, ast.Constant) and isinstance(module_value.value, str):
|
|
243
|
+
target = module_value.value
|
|
244
|
+
resolved = _resolve_module(root, target)
|
|
245
|
+
resolution, scope, note = _python_target_state(target, resolved, declared_dependencies)
|
|
246
|
+
else:
|
|
247
|
+
resolved = None
|
|
248
|
+
resolution = ResolutionStatus.DYNAMIC
|
|
249
|
+
scope = EvidenceNodeScope.UNRESOLVED_TARGET
|
|
250
|
+
target = "<dynamic-import>"
|
|
251
|
+
note = "Dynamic import target is not statically known"
|
|
252
|
+
edges.append(
|
|
253
|
+
DependencyEdge(
|
|
254
|
+
evidence_id=f"python-dynamic:{rel}:{node.lineno}",
|
|
255
|
+
source=rel,
|
|
256
|
+
target=target,
|
|
257
|
+
edge_type=EdgeType.LOADS,
|
|
258
|
+
phase=DependencyPhase.UNKNOWN,
|
|
259
|
+
resolution=resolution,
|
|
260
|
+
target_scope=scope,
|
|
261
|
+
provenance=EvidenceProvenance.OBSERVED,
|
|
262
|
+
line=node.lineno,
|
|
263
|
+
resolved_path=resolved,
|
|
264
|
+
adapter="python",
|
|
265
|
+
note=note,
|
|
266
|
+
)
|
|
267
|
+
)
|
|
268
|
+
return edges, []
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _csproj_edges(root: Path, rel: str) -> tuple[list[DependencyEdge], list[DependencyScanIssue]]:
|
|
272
|
+
path = root / rel
|
|
273
|
+
try:
|
|
274
|
+
tree = ET.parse(path)
|
|
275
|
+
except (OSError, ET.ParseError) as exc:
|
|
276
|
+
return [], [DependencyScanIssue("dotnet-csproj", rel, str(exc))]
|
|
277
|
+
edges: list[DependencyEdge] = []
|
|
278
|
+
for element in tree.iter():
|
|
279
|
+
if not element.tag.endswith("ProjectReference"):
|
|
280
|
+
continue
|
|
281
|
+
include = element.attrib.get("Include")
|
|
282
|
+
if not include:
|
|
283
|
+
continue
|
|
284
|
+
candidate = (path.parent / include).resolve()
|
|
285
|
+
try:
|
|
286
|
+
resolved = candidate.relative_to(root).as_posix() if candidate.is_file() and candidate.is_relative_to(root) else None
|
|
287
|
+
except (OSError, ValueError):
|
|
288
|
+
resolved = None
|
|
289
|
+
edges.append(
|
|
290
|
+
DependencyEdge(
|
|
291
|
+
evidence_id=f"dotnet-project-reference:{rel}:{include}",
|
|
292
|
+
source=rel,
|
|
293
|
+
target=include.replace("\\", "/"),
|
|
294
|
+
edge_type=EdgeType.LINKS,
|
|
295
|
+
phase=DependencyPhase.UNKNOWN,
|
|
296
|
+
resolution=ResolutionStatus.RESOLVED if resolved else ResolutionStatus.UNRESOLVED,
|
|
297
|
+
target_scope=EvidenceNodeScope.PROJECT_COMPONENT if resolved else EvidenceNodeScope.UNRESOLVED_TARGET,
|
|
298
|
+
provenance=EvidenceProvenance.DECLARED,
|
|
299
|
+
resolved_path=resolved,
|
|
300
|
+
adapter="dotnet-csproj",
|
|
301
|
+
)
|
|
302
|
+
)
|
|
303
|
+
return edges, []
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _workflow_phase(rel: str, job_name: str) -> DependencyPhase:
|
|
307
|
+
text = f"{rel} {job_name}".lower()
|
|
308
|
+
if "release" in text or "publish" in text or "deploy" in text:
|
|
309
|
+
return DependencyPhase.RELEASE
|
|
310
|
+
if "test" in text:
|
|
311
|
+
return DependencyPhase.TEST
|
|
312
|
+
return DependencyPhase.BUILD
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _working_directory(payload: dict[str, object], job: dict[str, object], step: dict[str, object]) -> str:
|
|
316
|
+
step_value = step.get("working-directory")
|
|
317
|
+
if isinstance(step_value, str) and step_value.strip():
|
|
318
|
+
return step_value.strip()
|
|
319
|
+
for owner in (job, payload):
|
|
320
|
+
defaults = owner.get("defaults")
|
|
321
|
+
if not isinstance(defaults, dict):
|
|
322
|
+
continue
|
|
323
|
+
run_defaults = defaults.get("run")
|
|
324
|
+
if not isinstance(run_defaults, dict):
|
|
325
|
+
continue
|
|
326
|
+
value = run_defaults.get("working-directory")
|
|
327
|
+
if isinstance(value, str) and value.strip():
|
|
328
|
+
return value.strip()
|
|
329
|
+
return "."
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _clean_shell_token(value: str) -> str:
|
|
333
|
+
return value.strip().strip("'\"")
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _is_script_token(value: str) -> bool:
|
|
337
|
+
return Path(_clean_shell_token(value)).suffix.lower() in _SCRIPT_SUFFIXES
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _script_targets_from_command(command: str) -> list[str]:
|
|
341
|
+
targets: list[str] = []
|
|
342
|
+
for line in command.splitlines():
|
|
343
|
+
stripped = line.strip()
|
|
344
|
+
if not stripped or stripped.startswith("#"):
|
|
345
|
+
continue
|
|
346
|
+
for segment in re.split(r"\s*(?:&&|\|\||;)\s*", stripped):
|
|
347
|
+
if not segment:
|
|
348
|
+
continue
|
|
349
|
+
try:
|
|
350
|
+
tokens = [_clean_shell_token(item) for item in shlex.split(segment, posix=False)]
|
|
351
|
+
except ValueError:
|
|
352
|
+
tokens = [_clean_shell_token(item) for item in segment.split()]
|
|
353
|
+
tokens = [item for item in tokens if item]
|
|
354
|
+
while tokens and "=" in tokens[0] and not _is_script_token(tokens[0]):
|
|
355
|
+
tokens.pop(0)
|
|
356
|
+
if not tokens:
|
|
357
|
+
continue
|
|
358
|
+
|
|
359
|
+
first = tokens[0]
|
|
360
|
+
executable = Path(first).name.lower()
|
|
361
|
+
candidate: str | None = None
|
|
362
|
+
|
|
363
|
+
if _is_script_token(first):
|
|
364
|
+
candidate = first
|
|
365
|
+
elif executable in _PYTHON_RUNNERS:
|
|
366
|
+
for token in tokens[1:]:
|
|
367
|
+
if token in {"-c", "-m"}:
|
|
368
|
+
break
|
|
369
|
+
if token.startswith("-"):
|
|
370
|
+
continue
|
|
371
|
+
if Path(token).suffix.lower() == ".py":
|
|
372
|
+
candidate = token
|
|
373
|
+
break
|
|
374
|
+
elif executable in _POWERSHELL_RUNNERS:
|
|
375
|
+
for index, token in enumerate(tokens[1:], start=1):
|
|
376
|
+
if token.lower() in {"-file", "/file"} and index + 1 < len(tokens):
|
|
377
|
+
value = tokens[index + 1]
|
|
378
|
+
if Path(value).suffix.lower() == ".ps1":
|
|
379
|
+
candidate = value
|
|
380
|
+
break
|
|
381
|
+
if Path(token).suffix.lower() == ".ps1":
|
|
382
|
+
candidate = token
|
|
383
|
+
break
|
|
384
|
+
elif executable in _SHELL_RUNNERS:
|
|
385
|
+
for token in tokens[1:]:
|
|
386
|
+
if token.startswith("-"):
|
|
387
|
+
continue
|
|
388
|
+
if Path(token).suffix.lower() in {".sh", ".bash"}:
|
|
389
|
+
candidate = token
|
|
390
|
+
break
|
|
391
|
+
elif executable in _CMD_RUNNERS:
|
|
392
|
+
for token in tokens[1:]:
|
|
393
|
+
if token.lower() in {"/c", "/k"}:
|
|
394
|
+
continue
|
|
395
|
+
if Path(token).suffix.lower() in {".bat", ".cmd"}:
|
|
396
|
+
candidate = token
|
|
397
|
+
break
|
|
398
|
+
|
|
399
|
+
if candidate:
|
|
400
|
+
normalized = candidate.replace("\\", "/")
|
|
401
|
+
if normalized not in targets:
|
|
402
|
+
targets.append(normalized)
|
|
403
|
+
return targets
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _resolve_workflow_script(root: Path, working_directory: str, raw_target: str) -> str | None:
|
|
407
|
+
target_path = Path(raw_target)
|
|
408
|
+
if target_path.is_absolute():
|
|
409
|
+
return None
|
|
410
|
+
cwd = (root / working_directory).resolve()
|
|
411
|
+
try:
|
|
412
|
+
if not cwd.is_relative_to(root):
|
|
413
|
+
return None
|
|
414
|
+
except (OSError, ValueError):
|
|
415
|
+
return None
|
|
416
|
+
candidate = (cwd / target_path).resolve()
|
|
417
|
+
try:
|
|
418
|
+
return candidate.relative_to(root).as_posix() if candidate.is_file() and candidate.is_relative_to(root) else None
|
|
419
|
+
except (OSError, ValueError):
|
|
420
|
+
return None
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _workflow_edges(root: Path, rel: str) -> tuple[list[DependencyEdge], list[DependencyScanIssue]]:
|
|
424
|
+
path = root / rel
|
|
425
|
+
try:
|
|
426
|
+
payload = yaml.safe_load(path.read_text(encoding="utf-8-sig")) or {}
|
|
427
|
+
except (OSError, UnicodeError, yaml.YAMLError) as exc:
|
|
428
|
+
return [], [DependencyScanIssue("github-actions", rel, str(exc))]
|
|
429
|
+
if not isinstance(payload, dict):
|
|
430
|
+
return [], [DependencyScanIssue("github-actions", rel, "Workflow root is not a mapping")]
|
|
431
|
+
jobs = payload.get("jobs", {})
|
|
432
|
+
if not isinstance(jobs, dict):
|
|
433
|
+
return [], [DependencyScanIssue("github-actions", rel, "Workflow jobs is not a mapping")]
|
|
434
|
+
edges: list[DependencyEdge] = []
|
|
435
|
+
for job_name, job in jobs.items():
|
|
436
|
+
if not isinstance(job, dict):
|
|
437
|
+
continue
|
|
438
|
+
steps = job.get("steps", [])
|
|
439
|
+
if not isinstance(steps, list):
|
|
440
|
+
continue
|
|
441
|
+
for index, step in enumerate(steps):
|
|
442
|
+
if not isinstance(step, dict) or not isinstance(step.get("run"), str):
|
|
443
|
+
continue
|
|
444
|
+
command = step["run"]
|
|
445
|
+
working_directory = _working_directory(payload, job, step)
|
|
446
|
+
for raw_target in _script_targets_from_command(command):
|
|
447
|
+
if raw_target.startswith("/"):
|
|
448
|
+
continue
|
|
449
|
+
resolved = _resolve_workflow_script(root, working_directory, raw_target)
|
|
450
|
+
looks_explicitly_local = raw_target.startswith(("./", "../")) or "/" in raw_target
|
|
451
|
+
if not resolved and not looks_explicitly_local:
|
|
452
|
+
continue
|
|
453
|
+
edges.append(
|
|
454
|
+
DependencyEdge(
|
|
455
|
+
evidence_id=f"github-actions:{rel}:{job_name}:{index}:{raw_target}",
|
|
456
|
+
source=rel,
|
|
457
|
+
target=raw_target,
|
|
458
|
+
edge_type=EdgeType.INVOKES,
|
|
459
|
+
phase=_workflow_phase(rel, str(job_name)),
|
|
460
|
+
resolution=ResolutionStatus.RESOLVED if resolved else ResolutionStatus.UNRESOLVED,
|
|
461
|
+
target_scope=EvidenceNodeScope.PROJECT_COMPONENT if resolved else EvidenceNodeScope.UNRESOLVED_TARGET,
|
|
462
|
+
provenance=EvidenceProvenance.DECLARED,
|
|
463
|
+
resolved_path=resolved,
|
|
464
|
+
adapter="github-actions",
|
|
465
|
+
working_directory=working_directory,
|
|
466
|
+
note=None if resolved else "Explicit local-script invocation could not be resolved from its effective working-directory",
|
|
467
|
+
)
|
|
468
|
+
)
|
|
469
|
+
return edges, []
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def scan_dependency_edges(root: str | Path) -> DependencyScan:
|
|
473
|
+
root = Path(root).resolve()
|
|
474
|
+
_mode, paths, discovery_errors = repository_files(root)
|
|
475
|
+
edges: list[DependencyEdge] = []
|
|
476
|
+
issues = [DependencyScanIssue("repository", "<repository>", item) for item in discovery_errors]
|
|
477
|
+
adapters: set[str] = set()
|
|
478
|
+
declared_python_dependencies, declaration_issues = _declared_python_dependencies(root)
|
|
479
|
+
issues.extend(declaration_issues)
|
|
480
|
+
for rel in paths:
|
|
481
|
+
suffix = Path(rel).suffix.lower()
|
|
482
|
+
if suffix == ".py":
|
|
483
|
+
found, found_issues = _python_edges(root, rel, declared_python_dependencies)
|
|
484
|
+
adapters.add("python")
|
|
485
|
+
elif suffix == ".csproj":
|
|
486
|
+
found, found_issues = _csproj_edges(root, rel)
|
|
487
|
+
adapters.add("dotnet-csproj")
|
|
488
|
+
elif rel.startswith(".github/workflows/") and suffix in {".yaml", ".yml"}:
|
|
489
|
+
found, found_issues = _workflow_edges(root, rel)
|
|
490
|
+
adapters.add("github-actions")
|
|
491
|
+
else:
|
|
492
|
+
continue
|
|
493
|
+
edges.extend(found)
|
|
494
|
+
issues.extend(found_issues)
|
|
495
|
+
edges.sort(key=lambda item: (item.source, item.line or 0, item.target, item.evidence_id))
|
|
496
|
+
return DependencyScan(tuple(edges), tuple(issues), tuple(sorted(adapters)))
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from ..repository.snapshot import repository_files
|
|
6
|
+
from .dependencies import DependencyScan, DependencyScanIssue, scan_dependency_edges as scan_legacy_dependency_edges
|
|
7
|
+
from .dotnet import discover_dotnet_projects, source_edges as dotnet_source_edges
|
|
8
|
+
from .go import discover_go_modules, source_edges as go_source_edges
|
|
9
|
+
from .javascript import discover_npm_packages, manifest_edges, source_edges
|
|
10
|
+
from .source_adapters import JAVASCRIPT_TYPESCRIPT_SOURCE_SUFFIXES
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def scan_dependency_edges(root: str | Path) -> DependencyScan:
|
|
14
|
+
root = Path(root).resolve()
|
|
15
|
+
legacy = scan_legacy_dependency_edges(root)
|
|
16
|
+
_mode, paths, discovery_errors = repository_files(root)
|
|
17
|
+
|
|
18
|
+
edges = list(legacy.edges)
|
|
19
|
+
issues = list(legacy.issues)
|
|
20
|
+
adapters = set(legacy.adapters)
|
|
21
|
+
for error in discovery_errors:
|
|
22
|
+
issue = DependencyScanIssue("repository", "<repository>", error)
|
|
23
|
+
if issue not in issues:
|
|
24
|
+
issues.append(issue)
|
|
25
|
+
|
|
26
|
+
by_path, by_name, npm_issues = discover_npm_packages(root, paths)
|
|
27
|
+
if by_path or npm_issues:
|
|
28
|
+
adapters.add("npm-manifest")
|
|
29
|
+
for rel, message in npm_issues:
|
|
30
|
+
issues.append(DependencyScanIssue("npm-manifest", rel, message))
|
|
31
|
+
for package in by_path.values():
|
|
32
|
+
edges.extend(manifest_edges(package, by_name))
|
|
33
|
+
|
|
34
|
+
go_modules, go_issues = discover_go_modules(root, paths)
|
|
35
|
+
if go_modules or go_issues or any(Path(rel).suffix.lower() == ".go" for rel in paths):
|
|
36
|
+
adapters.add("go-source")
|
|
37
|
+
for rel, message in go_issues:
|
|
38
|
+
issues.append(DependencyScanIssue("go-source", rel, message))
|
|
39
|
+
|
|
40
|
+
dotnet_projects, dotnet_issues = discover_dotnet_projects(root, paths)
|
|
41
|
+
if dotnet_projects or dotnet_issues or any(Path(rel).suffix.lower() == ".cs" for rel in paths):
|
|
42
|
+
adapters.add("dotnet-source")
|
|
43
|
+
for rel, message in dotnet_issues:
|
|
44
|
+
issues.append(DependencyScanIssue("dotnet-source", rel, message))
|
|
45
|
+
|
|
46
|
+
for rel in paths:
|
|
47
|
+
suffix = Path(rel).suffix.lower()
|
|
48
|
+
if suffix in JAVASCRIPT_TYPESCRIPT_SOURCE_SUFFIXES:
|
|
49
|
+
adapters.add("javascript-typescript")
|
|
50
|
+
found, found_issues = source_edges(root, rel, by_path, by_name)
|
|
51
|
+
edges.extend(found)
|
|
52
|
+
issues.extend(DependencyScanIssue("javascript-typescript", rel, message) for message in found_issues)
|
|
53
|
+
elif suffix == ".go":
|
|
54
|
+
found, found_issues = go_source_edges(root, rel, go_modules)
|
|
55
|
+
edges.extend(found)
|
|
56
|
+
issues.extend(DependencyScanIssue("go-source", rel, message) for message in found_issues)
|
|
57
|
+
elif suffix == ".cs":
|
|
58
|
+
found, found_issues = dotnet_source_edges(root, rel, dotnet_projects)
|
|
59
|
+
edges.extend(found)
|
|
60
|
+
issues.extend(DependencyScanIssue("dotnet-source", rel, message) for message in found_issues)
|
|
61
|
+
|
|
62
|
+
unique_edges = {item.evidence_id: item for item in edges}
|
|
63
|
+
ordered_edges = sorted(
|
|
64
|
+
unique_edges.values(),
|
|
65
|
+
key=lambda item: (item.source, item.line or 0, item.target, item.evidence_id),
|
|
66
|
+
)
|
|
67
|
+
unique_issues = {(item.adapter, item.path, item.message): item for item in issues}
|
|
68
|
+
ordered_issues = [unique_issues[key] for key in sorted(unique_issues)]
|
|
69
|
+
return DependencyScan(tuple(ordered_edges), tuple(ordered_issues), tuple(sorted(adapters)))
|