toolgraph 0.4.0__tar.gz → 0.4.1__tar.gz
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.
- {toolgraph-0.4.0 → toolgraph-0.4.1}/CHANGELOG.md +86 -0
- {toolgraph-0.4.0 → toolgraph-0.4.1}/PKG-INFO +1 -1
- {toolgraph-0.4.0 → toolgraph-0.4.1}/pyproject.toml +1 -1
- {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/__init__.py +1 -1
- {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/artifact_safety.py +12 -6
- {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/cli.py +2 -2
- {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/config.py +4 -3
- {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/graph/driver.py +8 -5
- {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/policy_propose.py +13 -6
- {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/redaction.py +133 -48
- {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/yaml_loader.py +6 -2
- {toolgraph-0.4.0 → toolgraph-0.4.1}/.gitignore +0 -0
- {toolgraph-0.4.0 → toolgraph-0.4.1}/LICENSE +0 -0
- {toolgraph-0.4.0 → toolgraph-0.4.1}/README.md +0 -0
- {toolgraph-0.4.0 → toolgraph-0.4.1}/SECURITY.md +0 -0
- {toolgraph-0.4.0 → toolgraph-0.4.1}/contracts/control-plan.schema.json +0 -0
- {toolgraph-0.4.0 → toolgraph-0.4.1}/contracts/control-preflight.schema.json +0 -0
- {toolgraph-0.4.0 → toolgraph-0.4.1}/contracts/observation-window.schema.json +0 -0
- {toolgraph-0.4.0 → toolgraph-0.4.1}/contracts/policy-bundle.schema.json +0 -0
- {toolgraph-0.4.0 → toolgraph-0.4.1}/contracts/policy-impact.schema.json +0 -0
- {toolgraph-0.4.0 → toolgraph-0.4.1}/contracts/policy-review-plan.schema.json +0 -0
- {toolgraph-0.4.0 → toolgraph-0.4.1}/contracts/preflight.schema.json +0 -0
- {toolgraph-0.4.0 → toolgraph-0.4.1}/contracts/review-annotations.schema.json +0 -0
- {toolgraph-0.4.0 → toolgraph-0.4.1}/contracts/tracegraph/README.md +0 -0
- {toolgraph-0.4.0 → toolgraph-0.4.1}/contracts/tracegraph/review-candidates.schema.json +0 -0
- {toolgraph-0.4.0 → toolgraph-0.4.1}/contracts/tracegraph/v1.json +0 -0
- {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/artifacts.py +0 -0
- {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/control_plan.py +0 -0
- {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/crawler/__init__.py +0 -0
- {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/crawler/client.py +0 -0
- {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/crawler/crawl.py +0 -0
- {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/crawler/transports.py +0 -0
- {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/error_text.py +0 -0
- {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/errors.py +0 -0
- {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/graph/__init__.py +0 -0
- {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/graph/loader.py +0 -0
- {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/graph/queries.py +0 -0
- {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/graph/schema.py +0 -0
- {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/graph/selector.py +0 -0
- {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/graph/store.py +0 -0
- {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/health.py +0 -0
- {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/log_safety.py +0 -0
- {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/manifest/__init__.py +0 -0
- {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/manifest/ingest.py +0 -0
- {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/manifest/parser.py +0 -0
- {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/models.py +0 -0
- {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/policy_bundle.py +0 -0
- {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/policy_impact.py +0 -0
- {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/policy_impact_models.py +0 -0
- {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/policy_review.py +0 -0
- {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/preflight.py +0 -0
- {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/quickstart/README.md +0 -0
- {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/quickstart/governance.yaml +0 -0
- {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/quickstart/policy_gateway_server.py +0 -0
- {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/quickstart/servers.yaml +0 -0
- {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/review_candidates.py +0 -0
- {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/server/__init__.py +0 -0
- {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/server/app.py +0 -0
- {toolgraph-0.4.0 → toolgraph-0.4.1}/toolgraph/uris.py +0 -0
|
@@ -2,6 +2,92 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Toolgraph are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.4.1 - 2026-11-09
|
|
6
|
+
|
|
7
|
+
Sixth alpha, a patch release: diagnostics only. Nothing that 0.4.0 accepted
|
|
8
|
+
is refused, and no command, flag, default, function signature or JSON key
|
|
9
|
+
changes. Read **Upgrading from 0.4.0** below if a script or tool matches
|
|
10
|
+
Toolgraph's error text, or reads the configuration path it reports: more of
|
|
11
|
+
it is hidden or withheld whole.
|
|
12
|
+
|
|
13
|
+
### Upgrading from 0.4.0
|
|
14
|
+
|
|
15
|
+
- Diagnostics hide more, and more of them are withheld whole (#241, #245,
|
|
16
|
+
#262). An `Authorization` value is hidden to the end of the message
|
|
17
|
+
(`Authorization: Bearer abc; retrying in 5s` now reads
|
|
18
|
+
`Authorization=<redacted>`). A message whose URL ends in a credential name
|
|
19
|
+
that assigns a value after it is withheld, in each form the scrubber reads
|
|
20
|
+
as an assignment (`GET https://h/authorization=Basic returned 404`). A
|
|
21
|
+
credential-shaped path in a YAML, `policy propose` export, `doctor` or
|
|
22
|
+
Ladybug backend error reads `<path withheld: credential-shaped>`, and so
|
|
23
|
+
does the configuration `path` in `doctor --json`, where 0.4.0 showed a
|
|
24
|
+
partly scrubbed path or withheld the line.
|
|
25
|
+
`toolgraph.config.check_config_health()` returns that display value in
|
|
26
|
+
`path` and `detail` too, where 0.4.0 returned the path as given. Tooling
|
|
27
|
+
that matches stderr, MCP error text or `doctor` output should expect these
|
|
28
|
+
forms; the entries under Fixed give each one.
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- Scrubbing a diagnostic is faster (#263), with the same output as before
|
|
33
|
+
that change: a check that needs a `%`, a backslash, a quote or `://` is
|
|
34
|
+
skipped when the message holds none, and each repr literal is parsed once
|
|
35
|
+
instead of twice. Against 0.4.0, measured locally (CPU time, best of three),
|
|
36
|
+
19 of the 21 adversarial inputs in `tests/test_redaction.py` run at least
|
|
37
|
+
1.4 times faster and a short plain message about 4.5 times; the two
|
|
38
|
+
percent-encoded URL inputs run about 12% slower, the cost of the decoded
|
|
39
|
+
check #241 added.
|
|
40
|
+
- Distributions are built with `hatchling` 1.33.0 (was 1.32.4); the sdist
|
|
41
|
+
pins it in `[build-system] requires`.
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
|
|
45
|
+
- A credential name at the end of a URL is caught in the forms the scrubber
|
|
46
|
+
reads as an assignment, not only those the refusal rule does (#241). 0.4.0
|
|
47
|
+
withheld `fetch http://h/password =secret` but printed
|
|
48
|
+
`http://h/authorization=Basic secret` as `http://h secret`, and likewise
|
|
49
|
+
`Authorization=Token`, `proxy_authorization=Basic`, `password :secret`,
|
|
50
|
+
`password:[a, secret]` and `credential:{a: secret}`. Such a message is now
|
|
51
|
+
withheld whole, and so is one where the name or its delimiter is
|
|
52
|
+
percent-encoded (`http://h/pass%77ord :secret`,
|
|
53
|
+
`http://h/password%3A[a, secret]`), judged against each URL where the
|
|
54
|
+
rewrite finds it. More diagnostics are withheld for the same reason: a URL
|
|
55
|
+
path ending in a credential name and a scheme word with text after it
|
|
56
|
+
(`GET https://h/authorization=Basic returned 404`), or a bracket opening
|
|
57
|
+
inside the URL after a `:` (`GET https://h/password:[id] returned 404`),
|
|
58
|
+
as one after `=` already was. Not caught: an encoded delimiter that joins
|
|
59
|
+
the text after a URL to the start of another one
|
|
60
|
+
(`http://h/password %3Ahunter2://host`); an encoded name before a URL's
|
|
61
|
+
scheme (`%70assword://hunter2`); and a repr-escaped URL followed by an
|
|
62
|
+
assignment (`'http://h/password\t':hunter2`) (#246).
|
|
63
|
+
- Diagnostics hide everything after an `Authorization` name, not only the
|
|
64
|
+
first word of its value (#245). 0.4.0 knew only the `Bearer`, `Basic` and
|
|
65
|
+
`Token` schemes and took any other scheme word as the whole value, so
|
|
66
|
+
`Authorization: Digest username=alice, response=hunter2` printed
|
|
67
|
+
`Authorization=<redacted> username=alice, response=hunter2`; Negotiate,
|
|
68
|
+
AWS4-HMAC-SHA256 `Signature=`, OAuth, SCRAM and Hawk parameters showed the
|
|
69
|
+
same way, and so did a URL ending in the name
|
|
70
|
+
(`http://h/authorization=Digest username=alice,response=hunter2` printed
|
|
71
|
+
`http://h username=alice,response=hunter2`; it is now withheld). A name with
|
|
72
|
+
a prefix is scrubbed too: `proxy_authorization:hunter2` and
|
|
73
|
+
`HTTP_AUTHORIZATION:hunter2` were printed as given. Where such a value ends
|
|
74
|
+
cannot be told from the text, so the rest of the message goes with it:
|
|
75
|
+
`Authorization: Bearer abc; retrying in 5s` now prints
|
|
76
|
+
`Authorization=<redacted>`, and a URL holding an Authorization name with
|
|
77
|
+
text after it is withheld (`GET https://h/authorization=abc returned 404`).
|
|
78
|
+
- More error lines show a credential-shaped path as
|
|
79
|
+
`<path withheld: credential-shaped>` and keep the rest of the line (#262):
|
|
80
|
+
a YAML file over the size limit or without a mapping at its root, a
|
|
81
|
+
`policy propose` export over the size limit or with a bad envelope or row,
|
|
82
|
+
the manifest digest hint for an export without an envelope, `doctor`'s
|
|
83
|
+
configuration check, and the `BACKEND_CORRUPT`, `BACKEND_UNAVAILABLE` and
|
|
84
|
+
`BACKEND_LOCKED` errors for a Ladybug database. 0.4.0 scrubbed these paths
|
|
85
|
+
along with the line, so a directory named `password=x` printed a path that
|
|
86
|
+
does not exist (`expected a YAML mapping at document root:
|
|
87
|
+
/tmp/password=<redacted>`; an export error lost its `:1:` line number with
|
|
88
|
+
it), and a percent-encoded name (`/tmp/?%74oken=x`) withheld the whole
|
|
89
|
+
line, error code included.
|
|
90
|
+
|
|
5
91
|
## 0.4.0 - 2026-10-21
|
|
6
92
|
|
|
7
93
|
Fifth alpha. Read **Upgrading from 0.3.0** below before installing: it lists
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: toolgraph
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.1
|
|
4
4
|
Summary: Graph-native MCP/tool registry, policy compiler, and impact analyzer
|
|
5
5
|
Project-URL: Homepage, https://github.com/memtomem/toolgraph
|
|
6
6
|
Project-URL: Documentation, https://github.com/memtomem/toolgraph/blob/main/docs/beginner-guide.md
|
|
@@ -77,7 +77,7 @@ notebook = ["nbclient>=0.10", "ipykernel>=6"]
|
|
|
77
77
|
# different bytes behind the same reviewed commit. `scripts/verify-artifacts.sh`
|
|
78
78
|
# asserts the sdist rebuilds the wheel byte for byte, which only holds while
|
|
79
79
|
# the backend is fixed. Bump deliberately, then rehearse.
|
|
80
|
-
requires = ["hatchling==1.
|
|
80
|
+
requires = ["hatchling==1.33.0"]
|
|
81
81
|
build-backend = "hatchling.build"
|
|
82
82
|
|
|
83
83
|
[tool.hatch.version]
|
|
@@ -73,9 +73,12 @@ def display_resource(value: str) -> str:
|
|
|
73
73
|
one percent-decoded form; the output is never decoded.
|
|
74
74
|
"""
|
|
75
75
|
end = value.find("://")
|
|
76
|
+
if end == -1 and "%" not in value:
|
|
77
|
+
return value
|
|
76
78
|
# An encoded scheme separator before the first raw one (`neo4j%3A%2F%2F`)
|
|
77
79
|
# marks a URI the checks below would not see.
|
|
78
|
-
|
|
80
|
+
prefix = value if end == -1 else value[:end]
|
|
81
|
+
if "%" in prefix and "://" in unquote(prefix):
|
|
79
82
|
return _HIDDEN
|
|
80
83
|
if end == -1:
|
|
81
84
|
return value
|
|
@@ -93,12 +96,13 @@ def display_resource(value: str) -> str:
|
|
|
93
96
|
quote = _QUOTE.search(uri, end + 3 - first)
|
|
94
97
|
if quote is not None and uri.rfind("@") > quote.start():
|
|
95
98
|
return hidden
|
|
96
|
-
decoded = unquote(uri)
|
|
97
|
-
if decoded.count("@") > uri.count("@"):
|
|
99
|
+
decoded = unquote(uri) if "%" in uri else uri
|
|
100
|
+
if decoded != uri and decoded.count("@") > uri.count("@"):
|
|
98
101
|
return hidden
|
|
99
102
|
try:
|
|
100
103
|
parts = urlsplit(uri)
|
|
101
|
-
|
|
104
|
+
forms = (parts,) if decoded == uri else (parts, urlsplit(decoded))
|
|
105
|
+
if any("@" in part for form in forms for part in form[2:]):
|
|
102
106
|
return hidden
|
|
103
107
|
except ValueError:
|
|
104
108
|
return hidden
|
|
@@ -129,7 +133,7 @@ def _redact_text(text: str) -> str:
|
|
|
129
133
|
while True:
|
|
130
134
|
scheme = _SCHEME.search(word, position)
|
|
131
135
|
head = word[position:] if scheme is None else word[position : scheme.start()]
|
|
132
|
-
if unquote(head).count("://") > head.count("://"):
|
|
136
|
+
if "%" in head and unquote(head).count("://") > head.count("://"):
|
|
133
137
|
return "".join(shown) + text[done : base + position] + _HIDDEN
|
|
134
138
|
if scheme is None:
|
|
135
139
|
# A scheme with no letter (`+++://secret=x`) is left alone, so
|
|
@@ -166,10 +170,12 @@ _RESOURCE_LIST_KEYS = frozenset(
|
|
|
166
170
|
def _redact_string(value: str, *, whole: bool) -> str:
|
|
167
171
|
# A string that is one URI from its start (raw or decoded, whitespace
|
|
168
172
|
# around it aside) is one value too, wherever it sits.
|
|
173
|
+
if "://" not in value and "%" not in value:
|
|
174
|
+
return value
|
|
169
175
|
stripped = value.strip()
|
|
170
176
|
if whole or (
|
|
171
177
|
not any(char.isspace() for char in stripped)
|
|
172
|
-
and (_SCHEME.match(stripped) or _SCHEME.match(unquote(stripped)))
|
|
178
|
+
and (_SCHEME.match(stripped) or ("%" in stripped and _SCHEME.match(unquote(stripped))))
|
|
173
179
|
):
|
|
174
180
|
return display_resource(value)
|
|
175
181
|
return _redact_text(value)
|
|
@@ -97,8 +97,8 @@ _DURABILITY_WARNINGS = (ArtifactDurabilityWarning, ReviewCandidateDurabilityWarn
|
|
|
97
97
|
def _credential_shaped_argument(args: list[str]) -> bool:
|
|
98
98
|
"""Whether any command-line token is credential-shaped (the refusal rule).
|
|
99
99
|
|
|
100
|
-
Checked on the tokens as typed
|
|
101
|
-
(`\\x09`, `\\xa0`)
|
|
100
|
+
Checked on the tokens as typed, before Click and Typer quote or escape
|
|
101
|
+
them (`\\x09`, `\\xa0`) in the message. The
|
|
102
102
|
tokens joined by spaces are checked too: Click prints unexpected extra
|
|
103
103
|
arguments that way, so `password` and `<TAB>=value` make one assignment.
|
|
104
104
|
"""
|
|
@@ -33,16 +33,17 @@ def runtime_config_path() -> Path:
|
|
|
33
33
|
def check_config_health() -> dict[str, Any]:
|
|
34
34
|
"""Inspect active configuration file existence, schema validity, and permissions."""
|
|
35
35
|
path = runtime_config_path()
|
|
36
|
+
shown_path = display_path(path)
|
|
36
37
|
exists = path.is_file()
|
|
37
38
|
if not exists:
|
|
38
39
|
return {
|
|
39
|
-
"path":
|
|
40
|
+
"path": shown_path,
|
|
40
41
|
"exists": False,
|
|
41
42
|
"valid_json": False,
|
|
42
43
|
"permissions_ok": None,
|
|
43
44
|
"permissions": None,
|
|
44
45
|
"healthy": False,
|
|
45
|
-
"detail": f"configuration file not found at {
|
|
46
|
+
"detail": f"configuration file not found at {shown_path}",
|
|
46
47
|
"backend_source": backend_source(),
|
|
47
48
|
}
|
|
48
49
|
|
|
@@ -64,7 +65,7 @@ def check_config_health() -> dict[str, Any]:
|
|
|
64
65
|
|
|
65
66
|
healthy = exists and valid_json and (permissions_ok is not False)
|
|
66
67
|
return {
|
|
67
|
-
"path":
|
|
68
|
+
"path": shown_path,
|
|
68
69
|
"exists": exists,
|
|
69
70
|
"valid_json": valid_json,
|
|
70
71
|
"permissions_ok": permissions_ok,
|
|
@@ -18,6 +18,7 @@ from typing import TYPE_CHECKING, Any
|
|
|
18
18
|
|
|
19
19
|
from toolgraph import config
|
|
20
20
|
from toolgraph.models import TOOL_HINT_FIELDS
|
|
21
|
+
from toolgraph.redaction import display_path
|
|
21
22
|
|
|
22
23
|
if TYPE_CHECKING:
|
|
23
24
|
from neo4j import Driver, Session
|
|
@@ -349,7 +350,7 @@ def _not_a_database(path: Path) -> BackendConfigurationError:
|
|
|
349
350
|
# Not retryable: waiting does not turn a foreign or corrupt file into a
|
|
350
351
|
# database, so this must not read as an outage.
|
|
351
352
|
return BackendConfigurationError(
|
|
352
|
-
f"BACKEND_CORRUPT: {path} is not a valid Ladybug database file; "
|
|
353
|
+
f"BACKEND_CORRUPT: {display_path(path)} is not a valid Ladybug database file; "
|
|
353
354
|
"check file integrity and format",
|
|
354
355
|
cause=ConfigurationCause.BACKEND_CORRUPT,
|
|
355
356
|
)
|
|
@@ -357,7 +358,8 @@ def _not_a_database(path: Path) -> BackendConfigurationError:
|
|
|
357
358
|
|
|
358
359
|
def _missing_database(path: Path) -> BackendUnavailableError:
|
|
359
360
|
return BackendUnavailableError(
|
|
360
|
-
f"BACKEND_UNAVAILABLE: Ladybug database {path}
|
|
361
|
+
f"BACKEND_UNAVAILABLE: Ladybug database {display_path(path)} "
|
|
362
|
+
"does not exist or is not a file"
|
|
361
363
|
)
|
|
362
364
|
|
|
363
365
|
|
|
@@ -381,7 +383,7 @@ def _open_ladybug_database(path: Path, *, create: bool) -> Any:
|
|
|
381
383
|
message = str(exc)
|
|
382
384
|
if "lock" in message.lower() or "another process" in message.lower():
|
|
383
385
|
raise BackendLockedError(
|
|
384
|
-
f"BACKEND_LOCKED: Ladybug database {path} already has an owner"
|
|
386
|
+
f"BACKEND_LOCKED: Ladybug database {display_path(path)} already has an owner"
|
|
385
387
|
) from exc
|
|
386
388
|
if _NOT_A_DATABASE in message:
|
|
387
389
|
raise _not_a_database(path) from exc
|
|
@@ -535,11 +537,12 @@ def acquire_readonly_session() -> Iterator[Session | LadybugSession]:
|
|
|
535
537
|
except RuntimeError as exc:
|
|
536
538
|
if "lock" in str(exc).lower() or "another process" in str(exc).lower():
|
|
537
539
|
raise BackendLockedError(
|
|
538
|
-
f"BACKEND_LOCKED: Ladybug database {path}
|
|
540
|
+
f"BACKEND_LOCKED: Ladybug database {display_path(path)} "
|
|
541
|
+
"already has an owner"
|
|
539
542
|
) from exc
|
|
540
543
|
if not path.is_file():
|
|
541
544
|
raise BackendUnavailableError(
|
|
542
|
-
f"BACKEND_UNAVAILABLE: Ladybug database {path} does not exist"
|
|
545
|
+
f"BACKEND_UNAVAILABLE: Ladybug database {display_path(path)} does not exist"
|
|
543
546
|
) from exc
|
|
544
547
|
if _NOT_A_DATABASE in str(exc):
|
|
545
548
|
raise _not_a_database(path) from exc
|
|
@@ -16,7 +16,12 @@ from toolgraph.artifact_safety import validate_identity
|
|
|
16
16
|
from toolgraph.graph import driver, queries, selector
|
|
17
17
|
from toolgraph.graph.selector import PROFILES
|
|
18
18
|
from toolgraph.manifest.parser import Governance, governance_digest, load_governance
|
|
19
|
-
from toolgraph.redaction import
|
|
19
|
+
from toolgraph.redaction import (
|
|
20
|
+
display_path,
|
|
21
|
+
is_credential_shaped,
|
|
22
|
+
is_credential_shaped_identity,
|
|
23
|
+
redact_text,
|
|
24
|
+
)
|
|
20
25
|
|
|
21
26
|
|
|
22
27
|
class PolicyProposeError(Exception):
|
|
@@ -273,11 +278,12 @@ MAX_EXPORT_BYTES = 50_000_000
|
|
|
273
278
|
|
|
274
279
|
def load_export(path: Path) -> tuple[dict | None, list[dict]]:
|
|
275
280
|
# One open descriptor: a stat-then-read pair is a TOCTOU.
|
|
281
|
+
shown_path = display_path(path)
|
|
276
282
|
with path.open("rb") as handle:
|
|
277
283
|
raw = handle.read(MAX_EXPORT_BYTES + 1)
|
|
278
284
|
if len(raw) > MAX_EXPORT_BYTES:
|
|
279
285
|
raise PolicyProposeError(
|
|
280
|
-
f"refusing to load {
|
|
286
|
+
f"refusing to load {shown_path}: exceeds the {MAX_EXPORT_BYTES}-byte limit"
|
|
281
287
|
)
|
|
282
288
|
lines = raw.decode("utf-8").splitlines()
|
|
283
289
|
envelope: dict | None = None
|
|
@@ -292,10 +298,10 @@ def load_export(path: Path) -> tuple[dict | None, list[dict]]:
|
|
|
292
298
|
first = None
|
|
293
299
|
if isinstance(first, dict) and ENVELOPE_KEY in first:
|
|
294
300
|
if not isinstance(first[ENVELOPE_KEY], dict):
|
|
295
|
-
raise ProvenanceError(f"{
|
|
301
|
+
raise ProvenanceError(f"{shown_path}:{lineno}: envelope must be a JSON object")
|
|
296
302
|
if set(first) != {ENVELOPE_KEY}:
|
|
297
303
|
raise ProvenanceError(
|
|
298
|
-
f"{
|
|
304
|
+
f"{shown_path}:{lineno}: the envelope line carries only "
|
|
299
305
|
f"{ENVELOPE_KEY!r}; observation fields belong on their own rows"
|
|
300
306
|
)
|
|
301
307
|
envelope = first[ENVELOPE_KEY]
|
|
@@ -307,9 +313,10 @@ def load_export(path: Path) -> tuple[dict | None, list[dict]]:
|
|
|
307
313
|
def _load_rows(path: Path, lines: list[tuple[int, str]]) -> list[dict]:
|
|
308
314
|
rows: list[dict] = []
|
|
309
315
|
seen: set[tuple[str, str]] = set()
|
|
316
|
+
shown_path = display_path(path)
|
|
310
317
|
for lineno, raw in lines:
|
|
311
318
|
line = raw.strip()
|
|
312
|
-
where = f"{
|
|
319
|
+
where = f"{shown_path}:{lineno}"
|
|
313
320
|
try:
|
|
314
321
|
row = json.loads(line)
|
|
315
322
|
except json.JSONDecodeError as exc:
|
|
@@ -698,7 +705,7 @@ def run_propose(
|
|
|
698
705
|
"Re-export with an envelope line:\n"
|
|
699
706
|
' {"envelope": {"governance_digest": "<64 hex>", "graph_generation": 0, '
|
|
700
707
|
'"profile": "strict", "window_start": "...", "window_end": "..."}}\n'
|
|
701
|
-
f"The digest for {governance_path} right now is {expected_digest}.\n"
|
|
708
|
+
f"The digest for {display_path(governance_path)} right now is {expected_digest}.\n"
|
|
702
709
|
"Or pass --unverified-provenance to proceed without envelope verification."
|
|
703
710
|
)
|
|
704
711
|
|
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import ast
|
|
6
6
|
import re
|
|
7
7
|
import shlex
|
|
8
|
-
from collections.abc import Iterator
|
|
8
|
+
from collections.abc import Iterable, Iterator
|
|
9
9
|
from itertools import islice
|
|
10
10
|
from pathlib import Path, PureWindowsPath
|
|
11
11
|
from typing import TYPE_CHECKING
|
|
@@ -37,12 +37,13 @@ _AUTHORIZATION = re.compile(
|
|
|
37
37
|
r"(?i)\bauthorization['\"]?\s*[:=]\s*['\"]?"
|
|
38
38
|
r"(?:(?:bearer|basic|token)\s+)?[^\s,'\"}\]]+"
|
|
39
39
|
)
|
|
40
|
-
# The scrubbing form
|
|
41
|
-
#
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
40
|
+
# The scrubbing form hides everything after the name through the end of the
|
|
41
|
+
# text. Where an Authorization value ends cannot be told from prose: a scheme
|
|
42
|
+
# may take a parameter list (`Digest username=a, response=x failed`), a value
|
|
43
|
+
# may be malformed (`Basic user x`), and a header dump may hold another
|
|
44
|
+
# Authorization header after it. No `\b`, so `proxy_authorization` and
|
|
45
|
+
# `HTTP_AUTHORIZATION` count, as the credential names below do.
|
|
46
|
+
_AUTHORIZATION_SCRUB = re.compile(r"(?i)authorization['\"]?\s*[:=]\s*\S[\s\S]*")
|
|
46
47
|
# The names every credential detector treats as a secret when assigned. One
|
|
47
48
|
# list, so a name refused in one place is refused in artifact identities,
|
|
48
49
|
# review notes and control plans alike. Scrubbing (_CREDENTIAL below) keeps the
|
|
@@ -171,10 +172,31 @@ def _inspected(text: str) -> tuple[str, ...]:
|
|
|
171
172
|
# One percent-decoding pass, for inspection only: the value itself is
|
|
172
173
|
# never rewritten. `+` stays literal, and a doubly encoded key such as
|
|
173
174
|
# `%2574oken` is not decoded a second time.
|
|
175
|
+
if "%" not in text:
|
|
176
|
+
return (text,)
|
|
174
177
|
decoded = unquote(text)
|
|
175
178
|
return (text,) if decoded == text else (text, decoded)
|
|
176
179
|
|
|
177
180
|
|
|
181
|
+
def _form_is_credential_shaped(form: str) -> bool:
|
|
182
|
+
has_colon = ":" in form
|
|
183
|
+
has_equals = "=" in form
|
|
184
|
+
if has_colon and "://" in form and (at := form.rfind("@")) != -1:
|
|
185
|
+
if CREDENTIAL_URI.search(form):
|
|
186
|
+
return True
|
|
187
|
+
if ("'" in form or '"' in form) and any(
|
|
188
|
+
pattern.search(form, 0, at) for pattern in _QUOTED_USERINFO
|
|
189
|
+
):
|
|
190
|
+
return True
|
|
191
|
+
if has_equals and CREDENTIAL_QUERY.search(form):
|
|
192
|
+
return True
|
|
193
|
+
if (has_colon or has_equals) and _REFUSED_ASSIGNMENT.search(form):
|
|
194
|
+
return True
|
|
195
|
+
if has_colon and _REFUSED_AUTHORIZATION.search(form):
|
|
196
|
+
return True
|
|
197
|
+
return bool(_BEARER.search(form))
|
|
198
|
+
|
|
199
|
+
|
|
178
200
|
def is_credential_shaped(text: str) -> bool:
|
|
179
201
|
"""True when a value that is stored or emitted as given holds a credential.
|
|
180
202
|
|
|
@@ -192,15 +214,7 @@ def is_credential_shaped(text: str) -> bool:
|
|
|
192
214
|
userinfo whose unquoted part holds whitespace (``neo4j://u v:"p w"@h``
|
|
193
215
|
reads as a URL followed by text), so only identities refuse that.
|
|
194
216
|
"""
|
|
195
|
-
return any(
|
|
196
|
-
CREDENTIAL_URI.search(form)
|
|
197
|
-
or any(pattern.search(form, 0, form.rfind("@")) for pattern in _QUOTED_USERINFO)
|
|
198
|
-
or CREDENTIAL_QUERY.search(form)
|
|
199
|
-
or _REFUSED_ASSIGNMENT.search(form)
|
|
200
|
-
or _REFUSED_AUTHORIZATION.search(form)
|
|
201
|
-
or _BEARER.search(form)
|
|
202
|
-
for form in _inspected(text)
|
|
203
|
-
)
|
|
217
|
+
return any(_form_is_credential_shaped(form) for form in _inspected(text))
|
|
204
218
|
|
|
205
219
|
|
|
206
220
|
def is_credential_shaped_identity(text: str) -> bool:
|
|
@@ -223,6 +237,8 @@ def _uri_origin_keeping_prefix(value: str) -> str:
|
|
|
223
237
|
# stay as they are, so a credential name the URI's host position holds
|
|
224
238
|
# (`1https://password : x`) is still there for credential scrubbing. A
|
|
225
239
|
# scheme without a letter (`123://`) is not a URI and is left alone.
|
|
240
|
+
if value[0].isalpha():
|
|
241
|
+
return _diagnostic_uri_origin(value)
|
|
226
242
|
scheme = value[: value.index("://")]
|
|
227
243
|
first = next((i for i, char in enumerate(scheme) if char.isalpha()), None)
|
|
228
244
|
if first is None:
|
|
@@ -236,7 +252,11 @@ def _hide_quoted_userinfo(text: str) -> str:
|
|
|
236
252
|
# wherever the refusal rule refuses one. The hidden span starts at the
|
|
237
253
|
# beginning of the whitespace-delimited token: cutting at a nested scheme
|
|
238
254
|
# (`https://user:x://"pw"@host`) would leave a prefix that reads as a URI.
|
|
255
|
+
if "'" not in text and '"' not in text:
|
|
256
|
+
return text
|
|
239
257
|
end = text.rfind("@")
|
|
258
|
+
if end == -1:
|
|
259
|
+
return text
|
|
240
260
|
matches = [match for pattern in _QUOTED_USERINFO if (match := pattern.search(text, 0, end))]
|
|
241
261
|
if not matches:
|
|
242
262
|
return text
|
|
@@ -251,7 +271,16 @@ def _hide_quoted_userinfo(text: str) -> str:
|
|
|
251
271
|
# would go with the URI rewrite and leave its value readable. The search is
|
|
252
272
|
# zero-width, so names that share letters (`secretoken`) are each tried.
|
|
253
273
|
_NAME_START = re.compile(rf"(?i)(?=(?:{CREDENTIAL_KEYWORDS}|authorization|bearer))")
|
|
254
|
-
|
|
274
|
+
# What the scrubbers take as an assignment, up to where the value starts: the
|
|
275
|
+
# first group of _SCRUB_VALUE after any name. Matching no value keeps each try
|
|
276
|
+
# bounded. The refusal patterns accept fewer forms (`password :x`,
|
|
277
|
+
# `authorization=Basic x`), and every form the scrubbers accept beyond them
|
|
278
|
+
# leaked here.
|
|
279
|
+
_SCRUB_ASSIGNMENT = re.compile(rf"(?i)(?:{CREDENTIAL_KEYWORDS}|authorization)['\"]?\s*[:=]\s*")
|
|
280
|
+
# _AUTHORIZATION_SCRUB's value runs to the end of the text, so an Authorization
|
|
281
|
+
# name inside a URI assigns past it whenever any text follows the URI.
|
|
282
|
+
_AUTHORIZATION_NAME = re.compile(r"(?i)authorization['\"]?\s*[:=]")
|
|
283
|
+
_NAME_RULES = (_SCRUB_ASSIGNMENT, _BEARER)
|
|
255
284
|
_NON_SPACE = re.compile(r"\S")
|
|
256
285
|
|
|
257
286
|
|
|
@@ -259,8 +288,8 @@ def _assigns_past(text: str, uri_end: int, match: re.Match[str]) -> bool:
|
|
|
259
288
|
# Whether rewriting a URI that ends at `uri_end` would hide the name
|
|
260
289
|
# `match` starts with but not what it assigns.
|
|
261
290
|
end = match.end()
|
|
262
|
-
if match.re is
|
|
263
|
-
#
|
|
291
|
+
if match.re is _BEARER:
|
|
292
|
+
# A Bearer match holds the credential itself.
|
|
264
293
|
return end > uri_end
|
|
265
294
|
if end < uri_end:
|
|
266
295
|
# A bracketed value runs to the end of the text.
|
|
@@ -269,26 +298,73 @@ def _assigns_past(text: str, uri_end: int, match: re.Match[str]) -> bool:
|
|
|
269
298
|
return _NON_SPACE.search(text, end) is not None
|
|
270
299
|
|
|
271
300
|
|
|
272
|
-
def
|
|
273
|
-
|
|
274
|
-
#
|
|
275
|
-
|
|
301
|
+
def _crosses(text: str, spans: Iterable[tuple[int, int]]) -> bool:
|
|
302
|
+
# Names are found in text order, so one pointer walks the spans once. The
|
|
303
|
+
# end of the text without trailing whitespace is found once too: searching
|
|
304
|
+
# past each URI for every name would be quadratic.
|
|
305
|
+
uris = iter(spans)
|
|
276
306
|
uri = next(uris, None)
|
|
277
|
-
|
|
307
|
+
if uri is None:
|
|
308
|
+
return False
|
|
309
|
+
last = len(text.rstrip())
|
|
310
|
+
for name in _NAME_START.finditer(text, uri[0]):
|
|
278
311
|
at = name.start()
|
|
279
|
-
while uri is not None and uri
|
|
312
|
+
while uri is not None and uri[1] <= at:
|
|
280
313
|
uri = next(uris, None)
|
|
281
314
|
if uri is None:
|
|
282
315
|
return False
|
|
283
|
-
if at < uri
|
|
316
|
+
if at < uri[0]:
|
|
284
317
|
continue
|
|
285
|
-
|
|
286
|
-
match =
|
|
287
|
-
if match and _assigns_past(text, uri
|
|
318
|
+
if text[at] in "bB":
|
|
319
|
+
match = _BEARER.match(text, at)
|
|
320
|
+
if match and _assigns_past(text, uri[1], match):
|
|
321
|
+
return True
|
|
322
|
+
else:
|
|
323
|
+
match = _SCRUB_ASSIGNMENT.match(text, at)
|
|
324
|
+
if match and (
|
|
325
|
+
(last > uri[1] and text[at] in "aA" and _AUTHORIZATION_NAME.match(text, at))
|
|
326
|
+
or _assigns_past(text, uri[1], match)
|
|
327
|
+
):
|
|
288
328
|
return True
|
|
289
329
|
return False
|
|
290
330
|
|
|
291
331
|
|
|
332
|
+
def _decoded_keeping_uris(text: str) -> tuple[str, list[tuple[int, int]]]:
|
|
333
|
+
# Each URI and each gap between URIs is decoded on its own, so a URI keeps
|
|
334
|
+
# the bounds the rewrite will use: decoding the whole text would let a
|
|
335
|
+
# `%20` end a URI early and move a name out of it.
|
|
336
|
+
parts: list[str] = []
|
|
337
|
+
spans: list[tuple[int, int]] = []
|
|
338
|
+
length = consumed = 0
|
|
339
|
+
for uri in _URI.finditer(text):
|
|
340
|
+
gap = unquote(text[consumed : uri.start()])
|
|
341
|
+
value = unquote(uri.group(0))
|
|
342
|
+
start = length + len(gap)
|
|
343
|
+
parts += [gap, value]
|
|
344
|
+
spans.append((start, start + len(value)))
|
|
345
|
+
length = start + len(value)
|
|
346
|
+
consumed = uri.end()
|
|
347
|
+
parts.append(unquote(text[consumed:]))
|
|
348
|
+
return "".join(parts), spans
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _assignment_crosses_uri(text: str) -> bool:
|
|
352
|
+
"""True when a credential name inside a URI assigns a value past its end.
|
|
353
|
+
|
|
354
|
+
Checked on the text as given and on its percent-decoded form, with each
|
|
355
|
+
URI where the rewrite will find it.
|
|
356
|
+
"""
|
|
357
|
+
if "://" not in text:
|
|
358
|
+
return False
|
|
359
|
+
if _crosses(text, (uri.span() for uri in _URI.finditer(text))):
|
|
360
|
+
return True
|
|
361
|
+
if "%" not in text:
|
|
362
|
+
# Nothing to decode, and building the copy slows a URI-heavy text.
|
|
363
|
+
return False
|
|
364
|
+
decoded, spans = _decoded_keeping_uris(text)
|
|
365
|
+
return decoded != text and _crosses(decoded, spans)
|
|
366
|
+
|
|
367
|
+
|
|
292
368
|
# `repr`, `ascii`, `%r`, a container's `str()` and many library errors
|
|
293
369
|
# (`int('…')`) print a tab or a no-break space as an escape (`\t`, `\xa0`), so
|
|
294
370
|
# `password<TAB>=value` no longer reads as an assignment. Such output is a
|
|
@@ -299,6 +375,7 @@ def _assignment_crosses_uri(text: str) -> bool:
|
|
|
299
375
|
# quote. The repeat is possessive: the two branches never overlap, so giving
|
|
300
376
|
# characters back could not find another match, and a backtracking repeat
|
|
301
377
|
# keeps state for every character (about 180 MiB for a 1 MB literal).
|
|
378
|
+
_QUOTE_CHAR = re.compile(r"""['"]""")
|
|
302
379
|
_LITERAL = re.compile(r"""(['"])((?:\\.|(?!\1)[^\\\n])*+)\1""")
|
|
303
380
|
# The escapes repr and ascii print. A candidate holding any other backslash
|
|
304
381
|
# sequence is not their output and never reaches the parser, which would
|
|
@@ -350,8 +427,9 @@ def _repr_literals(text: str) -> Iterator[list[tuple[int, int, str]]]:
|
|
|
350
427
|
an odd run of backslashes (`C:\\` followed by repr output) is therefore
|
|
351
428
|
not read.
|
|
352
429
|
"""
|
|
353
|
-
for
|
|
354
|
-
|
|
430
|
+
for quote in _QUOTE_CHAR.finditer(text):
|
|
431
|
+
index = quote.start()
|
|
432
|
+
if _escaped(text, index):
|
|
355
433
|
continue
|
|
356
434
|
match = _LITERAL.match(text, index)
|
|
357
435
|
if not match or "\\" not in match[2]:
|
|
@@ -366,11 +444,14 @@ def _repr_reads_as_credential(text: str) -> bool:
|
|
|
366
444
|
# Counted first, so a text over the limit costs one scan and no checks.
|
|
367
445
|
# Each form is then built and checked on its own: holding them all would
|
|
368
446
|
# copy the whole text twice per reading.
|
|
369
|
-
if
|
|
447
|
+
if "\\" not in text:
|
|
448
|
+
return False
|
|
449
|
+
literals = list(islice(_repr_literals(text), _MAX_LITERALS + 1))
|
|
450
|
+
if len(literals) > _MAX_LITERALS:
|
|
370
451
|
return True
|
|
371
452
|
return any(
|
|
372
453
|
is_credential_shaped(value) or is_credential_shaped(text[:start] + value + text[end:])
|
|
373
|
-
for readings in
|
|
454
|
+
for readings in literals
|
|
374
455
|
for start, end, value in readings
|
|
375
456
|
)
|
|
376
457
|
|
|
@@ -398,25 +479,29 @@ def redact_text(value: object) -> str:
|
|
|
398
479
|
is credential-shaped, alone or in place in the message, and when it holds
|
|
399
480
|
more than ``_MAX_LITERALS`` such literals. Both checks read the message
|
|
400
481
|
as given, before anything rewrites it. A message where a credential name
|
|
401
|
-
inside a URI assigns a value after the
|
|
402
|
-
is withheld too: rewriting the URI to
|
|
403
|
-
and leave the value. After scrubbing,
|
|
404
|
-
the
|
|
405
|
-
it is still credential-shaped. A
|
|
406
|
-
shown as `\u2039redacted`.
|
|
482
|
+
inside a URI, as given or percent-decoded, assigns a value after the
|
|
483
|
+
URI's end (`https://h/password :x`) is withheld too: rewriting the URI to
|
|
484
|
+
its origin would remove the name and leave the value. After scrubbing,
|
|
485
|
+
the text without the placeholders the scrubbers inserted is checked with
|
|
486
|
+
the refusal rule, and withheld if it is still credential-shaped. A
|
|
487
|
+
placeholder the input itself carries is shown as `\u2039redacted`.
|
|
407
488
|
"""
|
|
408
489
|
raw = str(value)
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
text = _hide_quoted_userinfo(_INPUT_PLACEHOLDER.sub("\u2039", raw))
|
|
415
|
-
if _assignment_crosses_uri(text):
|
|
490
|
+
if "%" in raw:
|
|
491
|
+
decoded = unquote(raw)
|
|
492
|
+
if decoded != raw and is_credential_shaped(decoded):
|
|
493
|
+
return WITHHELD
|
|
494
|
+
if "\\" in raw and _repr_reads_as_credential(raw):
|
|
416
495
|
return WITHHELD
|
|
417
|
-
text =
|
|
496
|
+
text = _INPUT_PLACEHOLDER.sub("\u2039", raw) if "<" in raw else raw
|
|
497
|
+
if "://" in text:
|
|
498
|
+
text = _hide_quoted_userinfo(text)
|
|
499
|
+
if _assignment_crosses_uri(text):
|
|
500
|
+
return WITHHELD
|
|
501
|
+
text = _URI.sub(lambda match: _uri_origin_keeping_prefix(match.group(0)), text)
|
|
418
502
|
text = redact_credentials(text)
|
|
419
|
-
|
|
503
|
+
without_inserted = _INSERTED.sub(" ", text) if "<redacted" in text else text
|
|
504
|
+
if is_credential_shaped(without_inserted):
|
|
420
505
|
return WITHHELD
|
|
421
506
|
return text
|
|
422
507
|
|
|
@@ -7,6 +7,8 @@ from typing import Any
|
|
|
7
7
|
|
|
8
8
|
import yaml
|
|
9
9
|
|
|
10
|
+
from toolgraph.redaction import display_path
|
|
11
|
+
|
|
10
12
|
|
|
11
13
|
class UniqueKeySafeLoader(yaml.SafeLoader):
|
|
12
14
|
"""SafeLoader variant that rejects duplicate mapping keys at any depth."""
|
|
@@ -73,7 +75,9 @@ def load_yaml_mapping(path: Path) -> dict[str, Any]:
|
|
|
73
75
|
with path.open("rb") as handle:
|
|
74
76
|
raw = handle.read(MAX_YAML_BYTES + 1)
|
|
75
77
|
if len(raw) > MAX_YAML_BYTES:
|
|
76
|
-
raise ValueError(
|
|
78
|
+
raise ValueError(
|
|
79
|
+
f"refusing to load {display_path(path)}: exceeds the {MAX_YAML_BYTES}-byte limit"
|
|
80
|
+
)
|
|
77
81
|
try:
|
|
78
82
|
loader = UniqueKeySafeLoader(raw.decode("utf-8"))
|
|
79
83
|
try:
|
|
@@ -93,5 +97,5 @@ def load_yaml_mapping(path: Path) -> dict[str, Any]:
|
|
|
93
97
|
if value is None:
|
|
94
98
|
return {}
|
|
95
99
|
if not isinstance(value, dict):
|
|
96
|
-
raise ValueError(f"expected a YAML mapping at document root: {path}")
|
|
100
|
+
raise ValueError(f"expected a YAML mapping at document root: {display_path(path)}")
|
|
97
101
|
return value
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|