probeowl 0.1.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.
Files changed (232) hide show
  1. plugins/__init__.py +2 -0
  2. plugins/compare_files/__init__.py +2 -0
  3. plugins/compare_files/backend/__init__.py +361 -0
  4. plugins/compare_files/backend/parsers.py +310 -0
  5. plugins/compare_files/backend/secrets.py +155 -0
  6. plugins/compare_files/plugin.yaml +7 -0
  7. plugins/compare_files/web/CompareFilesPage.tsx +191 -0
  8. plugins/compare_files/web/DifferencesTable.tsx +121 -0
  9. plugins/compare_files/web/FileInput.tsx +118 -0
  10. plugins/compare_files/web/SideBySide.tsx +86 -0
  11. plugins/compare_files/web/compare-files.css +45 -0
  12. plugins/compare_files/web/exports.ts +59 -0
  13. plugins/compare_files/web/index.ts +2 -0
  14. plugins/compare_files/web/types.ts +21 -0
  15. probeowl/__init__.py +2 -0
  16. probeowl/api/__init__.py +2 -0
  17. probeowl/api/app.py +230 -0
  18. probeowl/api/guide_site.py +118 -0
  19. probeowl/api/local_only.py +122 -0
  20. probeowl/api/models.py +352 -0
  21. probeowl/api/openapi.py +32 -0
  22. probeowl/api/redaction.py +149 -0
  23. probeowl/api/routes.py +787 -0
  24. probeowl/api/titles.py +33 -0
  25. probeowl/api/web_app.py +87 -0
  26. probeowl/catalog/__init__.py +2 -0
  27. probeowl/catalog/drift.py +69 -0
  28. probeowl/catalog/edit.py +140 -0
  29. probeowl/catalog/review.py +80 -0
  30. probeowl/catalog/service.py +191 -0
  31. probeowl/catalog/suggest.py +179 -0
  32. probeowl/catalog/usage.py +55 -0
  33. probeowl/checks/__init__.py +2 -0
  34. probeowl/checks/plans.py +209 -0
  35. probeowl/checks/rules.py +582 -0
  36. probeowl/checks/saving.py +403 -0
  37. probeowl/checks/service.py +125 -0
  38. probeowl/checks/sql_shape.py +278 -0
  39. probeowl/checks/time_offsets.py +429 -0
  40. probeowl/cli/__init__.py +2 -0
  41. probeowl/cli/catalog.py +70 -0
  42. probeowl/cli/connector.py +353 -0
  43. probeowl/cli/connector_setup.py +242 -0
  44. probeowl/cli/defaults.py +20 -0
  45. probeowl/cli/init.py +251 -0
  46. probeowl/cli/inspect.py +127 -0
  47. probeowl/cli/kill.py +91 -0
  48. probeowl/cli/main.py +324 -0
  49. probeowl/cli/reload.py +49 -0
  50. probeowl/cli/runbook.py +25 -0
  51. probeowl/cli/server.py +174 -0
  52. probeowl/cli/validate.py +68 -0
  53. probeowl/comparison.py +409 -0
  54. probeowl/config_folder.py +223 -0
  55. probeowl/connector_status.py +333 -0
  56. probeowl/connectors/__init__.py +2 -0
  57. probeowl/connectors/factory.py +102 -0
  58. probeowl/connectors/failures.py +74 -0
  59. probeowl/connectors/lookup.py +31 -0
  60. probeowl/connectors/mariadb.py +236 -0
  61. probeowl/connectors/oracle.py +378 -0
  62. probeowl/connectors/plan.py +31 -0
  63. probeowl/connectors/rest.py +297 -0
  64. probeowl/connectors/schema.py +77 -0
  65. probeowl/connectors/sql.py +354 -0
  66. probeowl/connectors/sqlserver.py +354 -0
  67. probeowl/engine/__init__.py +2 -0
  68. probeowl/engine/expressions.py +504 -0
  69. probeowl/engine/identifiers.py +277 -0
  70. probeowl/engine/locations.py +367 -0
  71. probeowl/engine/masking.py +51 -0
  72. probeowl/engine/runner.py +391 -0
  73. probeowl/engine/typed_values.py +68 -0
  74. probeowl/errors.py +79 -0
  75. probeowl/guards/__init__.py +2 -0
  76. probeowl/guards/breaker.py +80 -0
  77. probeowl/guards/dialects.py +95 -0
  78. probeowl/guards/kill_switch.py +146 -0
  79. probeowl/guards/result.py +30 -0
  80. probeowl/guards/statement.py +341 -0
  81. probeowl/guide.py +15 -0
  82. probeowl/guide_site/404.html +883 -0
  83. probeowl/guide_site/assets/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  84. probeowl/guide_site/assets/fonts/ibm-plex-sans-latin-400-italic.woff2 +0 -0
  85. probeowl/guide_site/assets/fonts/ibm-plex-sans-latin-400-normal.woff2 +0 -0
  86. probeowl/guide_site/assets/fonts/ibm-plex-sans-latin-700-italic.woff2 +0 -0
  87. probeowl/guide_site/assets/fonts/ibm-plex-sans-latin-700-normal.woff2 +0 -0
  88. probeowl/guide_site/assets/images/favicon.png +0 -0
  89. probeowl/guide_site/assets/javascripts/bundle.a5f9d56c.min.js +16 -0
  90. probeowl/guide_site/assets/javascripts/workers/search.a7633da7.min.js +42 -0
  91. probeowl/guide_site/assets/owl.svg +1 -0
  92. probeowl/guide_site/assets/plex.css +6 -0
  93. probeowl/guide_site/assets/probeowl.css +13 -0
  94. probeowl/guide_site/assets/screens/compare-files.png +0 -0
  95. probeowl/guide_site/assets/screens/connectors.png +0 -0
  96. probeowl/guide_site/assets/screens/inspect.png +0 -0
  97. probeowl/guide_site/assets/screens/knowledge-base.png +0 -0
  98. probeowl/guide_site/assets/screens/probes.png +0 -0
  99. probeowl/guide_site/assets/screens/tables.png +0 -0
  100. probeowl/guide_site/assets/screens/validate.png +0 -0
  101. probeowl/guide_site/assets/stylesheets/main.57a0d79d.min.css +6 -0
  102. probeowl/guide_site/assets/stylesheets/palette.fb2fffe2.min.css +4 -0
  103. probeowl/guide_site/cli/index.html +1489 -0
  104. probeowl/guide_site/concepts/index.html +1351 -0
  105. probeowl/guide_site/connectors/index.html +1337 -0
  106. probeowl/guide_site/first-application/index.html +1215 -0
  107. probeowl/guide_site/getting-started/index.html +1277 -0
  108. probeowl/guide_site/index.html +1014 -0
  109. probeowl/guide_site/licenses/MKDOCS_MATERIAL_LICENSE.txt +167 -0
  110. probeowl/guide_site/safety/index.html +1302 -0
  111. probeowl/guide_site/search/search_index.json +1 -0
  112. probeowl/guide_site/several-systems/index.html +1288 -0
  113. probeowl/guide_site/sitemap.xml +79 -0
  114. probeowl/guide_site/sitemap.xml.gz +0 -0
  115. probeowl/guide_site/troubleshooting/index.html +1332 -0
  116. probeowl/guide_site/web-app/compare-files/index.html +930 -0
  117. probeowl/guide_site/web-app/connectors/index.html +1018 -0
  118. probeowl/guide_site/web-app/index.html +968 -0
  119. probeowl/guide_site/web-app/inspect/index.html +948 -0
  120. probeowl/guide_site/web-app/knowledge-base/index.html +966 -0
  121. probeowl/guide_site/web-app/probes/index.html +1049 -0
  122. probeowl/guide_site/web-app/tables/index.html +943 -0
  123. probeowl/guide_site/web-app/validate/index.html +1041 -0
  124. probeowl/guide_site/writing-probes/index.html +1315 -0
  125. probeowl/guide_site/writing-runbooks/index.html +1328 -0
  126. probeowl/healthy.py +294 -0
  127. probeowl/model/__init__.py +70 -0
  128. probeowl/model/application.py +28 -0
  129. probeowl/model/catalog.py +124 -0
  130. probeowl/model/check.py +312 -0
  131. probeowl/model/common.py +97 -0
  132. probeowl/model/connector.py +183 -0
  133. probeowl/model/connector_status.py +88 -0
  134. probeowl/model/entity.py +290 -0
  135. probeowl/model/healthy.py +58 -0
  136. probeowl/model/kill_switch.py +59 -0
  137. probeowl/model/load.py +110 -0
  138. probeowl/model/probe.py +104 -0
  139. probeowl/model/reload.py +31 -0
  140. probeowl/model/review.py +56 -0
  141. probeowl/model/run.py +345 -0
  142. probeowl/model/runbook.py +138 -0
  143. probeowl/model/runbook_step.py +54 -0
  144. probeowl/model/save_check.py +53 -0
  145. probeowl/model/schema.py +55 -0
  146. probeowl/model/schemas.py +43 -0
  147. probeowl/plugins/api.py +127 -0
  148. probeowl/plugins/registry.py +125 -0
  149. probeowl/reload.py +65 -0
  150. probeowl/runbook_steps.py +490 -0
  151. probeowl/runbooks.py +203 -0
  152. probeowl/runs.py +1217 -0
  153. probeowl/secrets.py +86 -0
  154. probeowl/server_log.py +46 -0
  155. probeowl/store/__init__.py +2 -0
  156. probeowl/store/audit.py +67 -0
  157. probeowl/store/engine.py +47 -0
  158. probeowl/store/kill_switches.py +66 -0
  159. probeowl/store/migrate.py +55 -0
  160. probeowl/store/migrations/__init__.py +1 -0
  161. probeowl/store/migrations/env.py +29 -0
  162. probeowl/store/migrations/script.py.mako +26 -0
  163. probeowl/store/migrations/versions/0001_initial_store_schema.py +177 -0
  164. probeowl/store/migrations/versions/0002_run_stop_reason.py +26 -0
  165. probeowl/store/migrations/versions/0003_run_identifiers.py +30 -0
  166. probeowl/store/migrations/versions/0004_run_warnings.py +26 -0
  167. probeowl/store/migrations/versions/0005_schema_snapshots.py +43 -0
  168. probeowl/store/migrations/versions/0006_kill_switches.py +41 -0
  169. probeowl/store/migrations/versions/0007_healthy_comparison.py +31 -0
  170. probeowl/store/migrations/versions/0008_runbook_steps.py +35 -0
  171. probeowl/store/migrations/versions/0009_caveats.py +30 -0
  172. probeowl/store/runbook_steps.py +54 -0
  173. probeowl/store/runs.py +254 -0
  174. probeowl/store/schemas.py +85 -0
  175. probeowl/store/tables.py +264 -0
  176. probeowl/webapp/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
  177. probeowl/webapp/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
  178. probeowl/webapp/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
  179. probeowl/webapp/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
  180. probeowl/webapp/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
  181. probeowl/webapp/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  182. probeowl/webapp/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
  183. probeowl/webapp/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
  184. probeowl/webapp/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
  185. probeowl/webapp/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
  186. probeowl/webapp/assets/ibm-plex-sans-cyrillic-400-normal-BTotfTJu.woff +0 -0
  187. probeowl/webapp/assets/ibm-plex-sans-cyrillic-400-normal-DZqxrq2p.woff2 +0 -0
  188. probeowl/webapp/assets/ibm-plex-sans-cyrillic-500-normal-ByOcLdNv.woff +0 -0
  189. probeowl/webapp/assets/ibm-plex-sans-cyrillic-500-normal-CocWQlwt.woff2 +0 -0
  190. probeowl/webapp/assets/ibm-plex-sans-cyrillic-600-normal-71GNu3SW.woff2 +0 -0
  191. probeowl/webapp/assets/ibm-plex-sans-cyrillic-600-normal-BGq0mW3O.woff +0 -0
  192. probeowl/webapp/assets/ibm-plex-sans-cyrillic-ext-400-normal-Dsrv2Tcn.woff +0 -0
  193. probeowl/webapp/assets/ibm-plex-sans-cyrillic-ext-400-normal-g30qAdWV.woff2 +0 -0
  194. probeowl/webapp/assets/ibm-plex-sans-cyrillic-ext-500-normal-Cs5J6C77.woff2 +0 -0
  195. probeowl/webapp/assets/ibm-plex-sans-cyrillic-ext-500-normal-DB5PtV2g.woff +0 -0
  196. probeowl/webapp/assets/ibm-plex-sans-cyrillic-ext-600-normal-Bz0x94Yp.woff +0 -0
  197. probeowl/webapp/assets/ibm-plex-sans-cyrillic-ext-600-normal-DUMzJB7m.woff2 +0 -0
  198. probeowl/webapp/assets/ibm-plex-sans-greek-400-normal-D9ESIMu3.woff +0 -0
  199. probeowl/webapp/assets/ibm-plex-sans-greek-400-normal-_efipK4i.woff2 +0 -0
  200. probeowl/webapp/assets/ibm-plex-sans-greek-500-normal-CuWXN6rf.woff +0 -0
  201. probeowl/webapp/assets/ibm-plex-sans-greek-500-normal-JMMifIXV.woff2 +0 -0
  202. probeowl/webapp/assets/ibm-plex-sans-greek-600-normal-D-CqTdkO.woff +0 -0
  203. probeowl/webapp/assets/ibm-plex-sans-greek-600-normal-DzTrcv_p.woff2 +0 -0
  204. probeowl/webapp/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
  205. probeowl/webapp/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
  206. probeowl/webapp/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
  207. probeowl/webapp/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
  208. probeowl/webapp/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
  209. probeowl/webapp/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
  210. probeowl/webapp/assets/ibm-plex-sans-latin-ext-400-normal-C5H60-Va.woff2 +0 -0
  211. probeowl/webapp/assets/ibm-plex-sans-latin-ext-400-normal-RBey6euL.woff +0 -0
  212. probeowl/webapp/assets/ibm-plex-sans-latin-ext-500-normal-D0aIdm-b.woff +0 -0
  213. probeowl/webapp/assets/ibm-plex-sans-latin-ext-500-normal-DakdToA3.woff2 +0 -0
  214. probeowl/webapp/assets/ibm-plex-sans-latin-ext-600-normal-DIrixKbi.woff +0 -0
  215. probeowl/webapp/assets/ibm-plex-sans-latin-ext-600-normal-DOrvGEcy.woff2 +0 -0
  216. probeowl/webapp/assets/ibm-plex-sans-vietnamese-400-normal-DG4YqDda.woff2 +0 -0
  217. probeowl/webapp/assets/ibm-plex-sans-vietnamese-400-normal-fK1oJ5dG.woff +0 -0
  218. probeowl/webapp/assets/ibm-plex-sans-vietnamese-500-normal-BEb3_waV.woff +0 -0
  219. probeowl/webapp/assets/ibm-plex-sans-vietnamese-500-normal-e4dixQRQ.woff2 +0 -0
  220. probeowl/webapp/assets/ibm-plex-sans-vietnamese-600-normal-DgdngZtN.woff +0 -0
  221. probeowl/webapp/assets/ibm-plex-sans-vietnamese-600-normal-DpPYBSTl.woff2 +0 -0
  222. probeowl/webapp/assets/index-CBGLumXh.css +1 -0
  223. probeowl/webapp/assets/index-CG1KqICH.js +175 -0
  224. probeowl/webapp/index.html +14 -0
  225. probeowl/webapp/licenses/THIRD_PARTY_NOTICES.txt +1867 -0
  226. probeowl/webapp/licenses/ibm-plex-mono-OFL.txt +93 -0
  227. probeowl/webapp/licenses/ibm-plex-sans-OFL.txt +93 -0
  228. probeowl/webapp/owl.svg +1 -0
  229. probeowl-0.1.0.dist-info/METADATA +23 -0
  230. probeowl-0.1.0.dist-info/RECORD +232 -0
  231. probeowl-0.1.0.dist-info/WHEEL +4 -0
  232. probeowl-0.1.0.dist-info/entry_points.txt +2 -0
plugins/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ # Author: @SaadZarook
2
+ """First-party plugins: one folder each, with a plugin.yaml. See docs/design.md, Plugins."""
@@ -0,0 +1,2 @@
1
+ # Author: @SaadZarook
2
+ """Compare two files a person uploads or pastes. See docs/design.md, First plugin: compare files."""
@@ -0,0 +1,361 @@
1
+ # Author: @SaadZarook
2
+ """The Compare files routes. Files are processed in memory and never stored.
3
+
4
+ The server always masks. Reveal happens in the browser, from the file the person supplied: a masked
5
+ difference says where the value sits, and nothing else about it.
6
+ """
7
+
8
+ from collections.abc import Callable, Coroutine
9
+ from decimal import Decimal
10
+ from enum import StrEnum
11
+ from typing import Any
12
+
13
+ from fastapi import APIRouter, Request, Response
14
+ from fastapi.routing import APIRoute
15
+ from pydantic import BaseModel, Field, JsonValue
16
+
17
+ from plugins.compare_files.backend.parsers import Format, Parsed, detect, parse
18
+ from plugins.compare_files.backend.secrets import (
19
+ Offsets,
20
+ contains_shared,
21
+ find_exactly,
22
+ line_masker,
23
+ looks_secret_line,
24
+ masker,
25
+ ranges_of,
26
+ secrets_in,
27
+ shared_secrets,
28
+ without_shared,
29
+ )
30
+ from probeowl.plugins.api import (
31
+ ArrayMatch,
32
+ Change,
33
+ Comparison,
34
+ InputTooLarge,
35
+ Leaf,
36
+ LocalInput,
37
+ Options,
38
+ PluginContext,
39
+ RequestError,
40
+ Side,
41
+ compare,
42
+ compare_lines,
43
+ )
44
+
45
+ MAX_FILE_BYTES = 10_000_000
46
+ MAX_REQUEST_BYTES = 25_000_000
47
+ # Beyond what anyone can read, and each one costs memory in the browser: the summary stays exact.
48
+ MAX_DIFFERENCES = 10_000
49
+
50
+
51
+ class FormatChoice(StrEnum):
52
+ """What the person asked for: detected from the content, or overridden."""
53
+
54
+ AUTO = "auto"
55
+ JSON = "json"
56
+ YAML = "yaml"
57
+ CSV = "csv"
58
+ XML = "xml"
59
+ PROPERTIES = "properties"
60
+ INI = "ini"
61
+ ENV = "env"
62
+ TEXT = "text"
63
+
64
+
65
+ class SuppliedFile(BaseModel):
66
+ """A file or text the person supplied."""
67
+
68
+ name: str = Field(min_length=1, max_length=255)
69
+ text: str
70
+
71
+
72
+ class CompareOptions(BaseModel):
73
+ """What counts as the same."""
74
+
75
+ ignore_paths: list[str] = Field(default_factory=list)
76
+ ignore_key_order: bool = True
77
+ arrays: ArrayMatch = ArrayMatch.BY_POSITION
78
+ array_key: str | None = Field(
79
+ default=None, description="With arrays by_key: the field, or the CSV column, to match on."
80
+ )
81
+ ignore_whitespace: bool = False
82
+ ignore_case: bool = Field(default=False, description="Applies to values, not to keys.")
83
+ number_tolerance: Decimal = Field(default=Decimal(0), ge=0, allow_inf_nan=False)
84
+
85
+
86
+ class CompareRequest(BaseModel):
87
+ """Two files to compare."""
88
+
89
+ left: SuppliedFile
90
+ right: SuppliedFile
91
+ format: FormatChoice = FormatChoice.AUTO
92
+ options: CompareOptions = Field(default_factory=CompareOptions)
93
+
94
+
95
+ class Range(BaseModel):
96
+ """Where text sits in the supplied file, in the units a browser's strings count."""
97
+
98
+ start: int
99
+ end: int
100
+
101
+
102
+ class FileSummary(BaseModel):
103
+ """What the audit also records. Never the content."""
104
+
105
+ name: str
106
+ size: int
107
+ sha256: str
108
+ format: Format
109
+
110
+
111
+ class FileDifference(BaseModel):
112
+ """One path that differs. left and right are `[masked]` if masked."""
113
+
114
+ path: str
115
+ change: Change
116
+ left: JsonValue = None
117
+ right: JsonValue = None
118
+ masked: bool = False
119
+ reveal_left: Range | None = Field(
120
+ default=None,
121
+ description="Masked only: where the value sits in the left file. Null if it cannot be "
122
+ "found exactly, so it has no Reveal.",
123
+ )
124
+ reveal_right: Range | None = None
125
+
126
+
127
+ class CompareSummary(BaseModel):
128
+ """How much differs."""
129
+
130
+ changed: int
131
+ added: int
132
+ removed: int
133
+ unchanged: int
134
+ ignored: int
135
+ masked: int
136
+
137
+
138
+ class CompareResult(BaseModel):
139
+ """What differs, masked. Everything here is safe to show and export."""
140
+
141
+ left: FileSummary
142
+ right: FileSummary
143
+ summary: CompareSummary
144
+ differences: list[FileDifference]
145
+ notes: list[str]
146
+ masked_left: list[Range] | None = Field(
147
+ description="Everything to hide in the left file's side-by-side view. Null if a secret "
148
+ "could not be located, so that view must not be shown."
149
+ )
150
+ masked_right: list[Range] | None = None
151
+
152
+
153
+ def _json_value(value: object) -> JsonValue:
154
+ if value is None or isinstance(value, bool | int | float | str):
155
+ return value
156
+ return str(value) # a Decimal, or a date from YAML
157
+
158
+
159
+ def _format(choice: FormatChoice, left: str, right: str, notes: list[str]) -> Format:
160
+ if choice is not FormatChoice.AUTO:
161
+ return Format(choice.value)
162
+ found_left, found_right = detect(left), detect(right)
163
+ if found_left is found_right:
164
+ return found_left
165
+ notes.append(
166
+ f"the files look like different formats ({found_left.value} and {found_right.value}), so "
167
+ "they were compared as plain text"
168
+ )
169
+ return Format.TEXT
170
+
171
+
172
+ def _secrets(file: LocalInput, parsed: Parsed) -> list[object]:
173
+ if parsed.format is Format.TEXT:
174
+ return [line for line in file.text.splitlines() if looks_secret_line(line)]
175
+ try:
176
+ return list(secrets_in(parsed.tree))
177
+ except RecursionError:
178
+ # Too deep to walk means its secrets cannot be vouched for anywhere, so nothing is shown.
179
+ raise RequestError(f"{file.name} is nested too deeply to compare") from None
180
+
181
+
182
+ def _masked_ranges(file: LocalInput, own: list[object], shared: set[str]) -> list[Range] | None:
183
+ found = ranges_of(file.text, own, shared)
184
+ if found is None:
185
+ return None
186
+ units = Offsets(file.text)
187
+ return [Range(start=units(start), end=units(end)) for start, end in found]
188
+
189
+
190
+ class _Recorder:
191
+ """Remembers masked values by path, to say where they sit. They go no further than this."""
192
+
193
+ def __init__(self, inner: Callable[[Leaf], bool]):
194
+ self._inner = inner
195
+ self.values: dict[tuple[Side, str], object] = {}
196
+
197
+ def __call__(self, leaf: Leaf) -> bool:
198
+ masked = self._inner(leaf)
199
+ if masked:
200
+ self.values[(leaf.side, leaf.path)] = leaf.value
201
+ return masked
202
+
203
+
204
+ def _reveal(
205
+ recorder: _Recorder, side: Side, path: str, file: LocalInput, units: Offsets
206
+ ) -> Range | None:
207
+ if (side, path) not in recorder.values:
208
+ return None
209
+ found = find_exactly(file.text, recorder.values[(side, path)])
210
+ return Range(start=units(found[0]), end=units(found[1])) if found else None
211
+
212
+
213
+ def _shown(value: object, shared: set[str]) -> JsonValue:
214
+ shown = _json_value(value)
215
+ return without_shared(shown, shared) if isinstance(shown, str) else shown
216
+
217
+
218
+ def _differences(
219
+ result: Comparison,
220
+ recorder: _Recorder,
221
+ left: LocalInput,
222
+ right: LocalInput,
223
+ shared: set[str],
224
+ ) -> list[FileDifference]:
225
+ left_units, right_units = Offsets(left.text), Offsets(right.text)
226
+ return [
227
+ FileDifference(
228
+ # A key can hold a secret too, and it reaches the path without passing the masker.
229
+ path=without_shared(d.path, shared),
230
+ change=d.change,
231
+ left=_shown(d.left, shared),
232
+ right=_shown(d.right, shared),
233
+ masked=d.masked,
234
+ reveal_left=_reveal(recorder, Side.LEFT, d.path, left, left_units)
235
+ if d.masked
236
+ else None,
237
+ reveal_right=_reveal(recorder, Side.RIGHT, d.path, right, right_units)
238
+ if d.masked
239
+ else None,
240
+ )
241
+ for d in result.differences[:MAX_DIFFERENCES]
242
+ ]
243
+
244
+
245
+ def run(body: CompareRequest, context: PluginContext) -> CompareResult:
246
+ """Compare two supplied files. Audits the names, sizes and hashes, and nothing else."""
247
+ left = context.local_input(body.left.name, body.left.text, MAX_FILE_BYTES)
248
+ right = context.local_input(body.right.name, body.right.text, MAX_FILE_BYTES)
249
+ notes: list[str] = []
250
+ format_ = _format(body.format, left.text, right.text, notes)
251
+ parsed_left, parsed_right = (
252
+ parse(left.name, left.text, format_),
253
+ parse(right.name, right.text, format_),
254
+ )
255
+ secrets_left, secrets_right = _secrets(left, parsed_left), _secrets(right, parsed_right)
256
+ # Found on either side, hidden on both: the same text under an ordinary key is the same secret.
257
+ shared = shared_secrets([*secrets_left, *secrets_right])
258
+ own_rule = line_masker if format_ is Format.TEXT else masker
259
+
260
+ def masked_here(leaf: Leaf) -> bool:
261
+ return own_rule(leaf) or contains_shared(leaf.value, shared)
262
+
263
+ recorder = _Recorder(masked_here)
264
+ options = Options(
265
+ ignore_paths=tuple(body.options.ignore_paths),
266
+ ignore_key_order=body.options.ignore_key_order,
267
+ arrays=body.options.arrays,
268
+ array_key=body.options.array_key,
269
+ ignore_whitespace=body.options.ignore_whitespace,
270
+ ignore_case=body.options.ignore_case,
271
+ number_tolerance=body.options.number_tolerance,
272
+ masker=recorder,
273
+ )
274
+ if format_ is Format.TEXT:
275
+ result = compare_lines(left.text, right.text, options)
276
+ else:
277
+ result = compare(parsed_left.tree, parsed_right.tree, options)
278
+ notes += result.notes
279
+ if len(result.differences) > MAX_DIFFERENCES:
280
+ notes.append(
281
+ f"showing the first {MAX_DIFFERENCES:,} of {len(result.differences):,} differences"
282
+ )
283
+ masked_left = _masked_ranges(left, secrets_left, shared)
284
+ masked_right = _masked_ranges(right, secrets_right, shared)
285
+ if masked_left is None or masked_right is None:
286
+ notes.append(
287
+ "the side-by-side view is hidden: a masked value could not be located in the file"
288
+ )
289
+ masked_left = masked_right = None
290
+ context.audit(
291
+ "files_compared",
292
+ {
293
+ "format": format_.value,
294
+ "left": {"name": left.name, "size": left.size, "sha256": left.sha256},
295
+ "right": {"name": right.name, "size": right.size, "sha256": right.sha256},
296
+ },
297
+ )
298
+ return CompareResult(
299
+ left=FileSummary(name=left.name, size=left.size, sha256=left.sha256, format=format_),
300
+ right=FileSummary(name=right.name, size=right.size, sha256=right.sha256, format=format_),
301
+ summary=CompareSummary(
302
+ changed=result.count(Change.CHANGED),
303
+ added=result.count(Change.ADDED),
304
+ removed=result.count(Change.REMOVED),
305
+ unchanged=result.unchanged,
306
+ ignored=result.ignored,
307
+ masked=result.masked,
308
+ ),
309
+ differences=_differences(result, recorder, left, right, shared),
310
+ notes=notes,
311
+ masked_left=masked_left,
312
+ masked_right=masked_right,
313
+ )
314
+
315
+
316
+ class _CappedRoute(APIRoute):
317
+ """Refuses a request past the limit while reading it, before anything is parsed."""
318
+
319
+ def get_route_handler(self) -> Callable[[Request], Coroutine[Any, Any, Response]]:
320
+ original = super().get_route_handler()
321
+
322
+ async def capped(request: Request) -> Response:
323
+ declared = request.headers.get("content-length", "")
324
+ if declared.isdigit() and int(declared) > MAX_REQUEST_BYTES:
325
+ raise _too_large(int(declared))
326
+ # Counted as it arrives too: a chunked request declares no length.
327
+ body = bytearray()
328
+ async for chunk in request.stream():
329
+ body.extend(chunk)
330
+ if len(body) > MAX_REQUEST_BYTES:
331
+ raise _too_large(len(body))
332
+ # What request.body() returns from here on; the stream can only be read once.
333
+ request._body = bytes(body)
334
+ return await original(request)
335
+
336
+ return capped
337
+
338
+
339
+ def _too_large(size: int) -> InputTooLarge:
340
+ return InputTooLarge(
341
+ f"the request was too large: at least {size:,} bytes, and the limit is "
342
+ f"{MAX_REQUEST_BYTES:,} bytes. Compare smaller files or excerpts."
343
+ )
344
+
345
+
346
+ def register(context: PluginContext) -> APIRouter:
347
+ """The plugin's routes."""
348
+ router = APIRouter(route_class=_CappedRoute)
349
+
350
+ @router.post("/compare", responses={413: {}, 422: {}})
351
+ def compare_files(body: CompareRequest) -> CompareResult:
352
+ """Compare two files a person uploaded or pasted. Nothing is stored.
353
+
354
+ Masked values never come back: only where each sits in the file supplied. A secret found
355
+ on either side is masked wherever its exact text appears in both, if 8 characters or more.
356
+ 413 past 10 MB per file or 25 MB per request; 422 if a file is not valid in the chosen
357
+ format, never quoting its content.
358
+ """
359
+ return run(body, context)
360
+
361
+ return router
@@ -0,0 +1,310 @@
1
+ # Author: @SaadZarook
2
+ """Read the eight formats into one shape: a tree of mappings, lists and scalars, or lines.
3
+
4
+ Everything here reads text a person supplied, which may not be what it claims to be. Errors never
5
+ quote the content, because a file may hold a secret: a line number at most.
6
+ """
7
+
8
+ import configparser
9
+ import csv
10
+ import io
11
+ import json
12
+ import re
13
+ from dataclasses import dataclass
14
+ from decimal import Decimal
15
+ from enum import StrEnum
16
+ from typing import Any
17
+ from xml.etree.ElementTree import Element, ParseError
18
+
19
+ import yaml
20
+ from defusedxml import ElementTree
21
+ from defusedxml.common import DefusedXmlException
22
+
23
+ from probeowl.plugins.api import RequestError
24
+
25
+ # YAML aliases can expand a small file into billions of nodes. Past this it is refused.
26
+ MAX_YAML_NODES = 2_000_000
27
+ MAX_CSV_FIELD = 10_000_000
28
+ _NUMBER = re.compile(r"^-?(0|[1-9]\d*)(\.\d+)?$")
29
+ _ENV_LINE = re.compile(r"^(export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$")
30
+ _INI_SECTION = re.compile(r"^\[[^\]\n]+\]\s*$", re.MULTILINE)
31
+
32
+
33
+ class Format(StrEnum):
34
+ """What a file is."""
35
+
36
+ JSON = "json"
37
+ YAML = "yaml"
38
+ CSV = "csv"
39
+ XML = "xml"
40
+ PROPERTIES = "properties"
41
+ INI = "ini"
42
+ ENV = "env"
43
+ TEXT = "text"
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class Parsed:
48
+ """A file read: a tree for a structured format, and None for plain text."""
49
+
50
+ format: Format
51
+ tree: object
52
+
53
+
54
+ def _refuse(name: str, what: str, detail: str = "") -> RequestError:
55
+ suffix = f" ({detail})" if detail else ""
56
+ return RequestError(f"{name} is not valid {what}{suffix}")
57
+
58
+
59
+ def _scalar(text: str) -> object:
60
+ # So a number tolerance works on formats where everything is text. Leading zeros stay text:
61
+ # 007 is an identifier, and 7 would hide a real difference.
62
+ return Decimal(text) if _NUMBER.match(text) else text
63
+
64
+
65
+ def _json(name: str, text: str) -> object:
66
+ try:
67
+ return json.loads(text, parse_float=Decimal)
68
+ except json.JSONDecodeError as err:
69
+ raise _refuse(name, "JSON", f"line {err.lineno}") from None
70
+ except (RecursionError, ValueError):
71
+ raise _refuse(name, "JSON", "nested too deeply, or a number too long") from None
72
+
73
+
74
+ def _yaml_nodes_within_limit(node: yaml.Node | None, name: str) -> None:
75
+ # Counts what walking the document would visit, aliases expanded, and stops at the limit.
76
+ pending = [node] if node is not None else []
77
+ seen = 0
78
+ while pending:
79
+ current = pending.pop()
80
+ seen += 1
81
+ if seen > MAX_YAML_NODES:
82
+ raise _refuse(name, "YAML", "it expands to too many values")
83
+ if isinstance(current, yaml.SequenceNode):
84
+ pending.extend(current.value)
85
+ elif isinstance(current, yaml.MappingNode):
86
+ pending.extend(item for pair in current.value for item in pair)
87
+
88
+
89
+ def _yaml(name: str, text: str) -> object:
90
+ try:
91
+ for node in yaml.compose_all(text, Loader=yaml.SafeLoader):
92
+ _yaml_nodes_within_limit(node, name)
93
+ documents = list(yaml.safe_load_all(text))
94
+ except yaml.MarkedYAMLError as err:
95
+ mark = err.problem_mark
96
+ raise _refuse(name, "YAML", f"line {mark.line + 1}" if mark else "") from None
97
+ except (yaml.YAMLError, RecursionError, ValueError):
98
+ raise _refuse(name, "YAML") from None
99
+ return documents[0] if len(documents) == 1 else documents
100
+
101
+
102
+ def _csv(name: str, text: str) -> object:
103
+ # The field limit is process-wide, and csv's default would refuse a long field in a legal file.
104
+ csv.field_size_limit(MAX_CSV_FIELD)
105
+ delimiter = (
106
+ ";" if text.split("\n", 1)[0].count(";") > text.split("\n", 1)[0].count(",") else ","
107
+ )
108
+ try:
109
+ reader = csv.reader(io.StringIO(text, newline=""), delimiter=delimiter, strict=True)
110
+ rows = [row for row in reader if row]
111
+ except csv.Error:
112
+ raise _refuse(name, "CSV") from None
113
+ if not rows:
114
+ return []
115
+ header, *body = rows
116
+ if len(set(header)) != len(header):
117
+ raise _refuse(name, "CSV", "a column name is repeated")
118
+ for number, row in enumerate(body, start=2):
119
+ if len(row) != len(header):
120
+ raise _refuse(name, "CSV", f"row {number} has {len(row)} of {len(header)} columns")
121
+ return [{k: _scalar(v) for k, v in zip(header, row, strict=True)} for row in body]
122
+
123
+
124
+ def _element(element: Element) -> object:
125
+ children: dict[str, object] = {f"@{k}": _scalar(v) for k, v in element.attrib.items()}
126
+ for child in element:
127
+ value = _element(child)
128
+ existing = children.get(child.tag)
129
+ if child.tag not in children:
130
+ children[child.tag] = value
131
+ elif isinstance(existing, list):
132
+ existing.append(value)
133
+ else:
134
+ children[child.tag] = [existing, value]
135
+ text = (element.text or "").strip()
136
+ if not children:
137
+ return _scalar(text) if text else None
138
+ if text:
139
+ children["#text"] = _scalar(text)
140
+ return children
141
+
142
+
143
+ def _xml(name: str, text: str) -> object:
144
+ # defusedxml, because entity expansion and external entities are refused rather than obeyed.
145
+ try:
146
+ root = ElementTree.fromstring(text)
147
+ except ParseError as err:
148
+ raise _refuse(name, "XML", f"line {err.position[0]}") from None
149
+ except DefusedXmlException:
150
+ raise _refuse(name, "XML", "it uses entities or a DTD, which are refused") from None
151
+ except RecursionError:
152
+ raise _refuse(name, "XML", "nested too deeply") from None
153
+ try:
154
+ return {root.tag: _element(root)}
155
+ except RecursionError:
156
+ raise _refuse(name, "XML", "nested too deeply") from None
157
+
158
+
159
+ def _nest(flat: dict[str, object]) -> dict[str, Any]:
160
+ # So a.b.c is the path it looks like. A key that is both a value and a parent stays flat.
161
+ tree: dict[str, Any] = {}
162
+ for key, value in flat.items():
163
+ parts = key.split(".")
164
+ node = tree
165
+ for part in parts[:-1]:
166
+ child = node.get(part)
167
+ if child is None:
168
+ child = node[part] = {}
169
+ elif not isinstance(child, dict):
170
+ tree[key] = value
171
+ break
172
+ node = child
173
+ else:
174
+ if isinstance(node.get(parts[-1]), dict):
175
+ tree[key] = value
176
+ else:
177
+ node[parts[-1]] = value
178
+ return tree
179
+
180
+
181
+ def _properties(text: str) -> object:
182
+ flat: dict[str, object] = {}
183
+ pending = ""
184
+ for raw in text.splitlines():
185
+ line = pending + raw.strip()
186
+ pending = ""
187
+ if line.endswith("\\") and not line.endswith("\\\\"):
188
+ pending = line[:-1]
189
+ continue
190
+ if not line or line[0] in "#!":
191
+ continue
192
+ match = re.match(r"^((?:\\.|[^\s=:\\])+)\s*[=:\s]\s*(.*)$", line)
193
+ key, value = (match.group(1), match.group(2)) if match else (line, "")
194
+ flat[key.replace("\\", "")] = _scalar(value)
195
+ return _nest(flat)
196
+
197
+
198
+ def _ini(name: str, text: str) -> object:
199
+ # No interpolation: a value containing % is data, and resolving it would change what it says.
200
+ parser = configparser.RawConfigParser(strict=False, interpolation=None, default_section="\0")
201
+ parser.optionxform = str # type: ignore[assignment, method-assign]
202
+ try:
203
+ parser.read_string(text)
204
+ except configparser.MissingSectionHeaderError as err:
205
+ # Its own case: it is a ParsingError with no errors list, and its text quotes the line.
206
+ raise _refuse(name, "INI", f"line {err.lineno}") from None
207
+ except configparser.ParsingError as err:
208
+ line = err.errors[0][0] if err.errors else 0
209
+ raise _refuse(name, "INI", f"line {line}") from None
210
+ except configparser.Error:
211
+ raise _refuse(name, "INI") from None
212
+ return {
213
+ section: {key: _scalar(value or "") for key, value in parser.items(section)}
214
+ for section in parser.sections()
215
+ }
216
+
217
+
218
+ def _env(name: str, text: str) -> object:
219
+ values: dict[str, object] = {}
220
+ for number, raw in enumerate(text.splitlines(), start=1):
221
+ line = raw.strip()
222
+ if not line or line.startswith("#"):
223
+ continue
224
+ match = _ENV_LINE.match(line)
225
+ if match is None:
226
+ raise _refuse(name, ".env", f"line {number}")
227
+ value = match.group(3).strip()
228
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"":
229
+ value = value[1:-1]
230
+ values[match.group(2)] = _scalar(value)
231
+ return values
232
+
233
+
234
+ def _looks_like(text: str) -> Format:
235
+ lines = [
236
+ ln.strip() for ln in text.splitlines() if ln.strip() and not ln.lstrip().startswith("#")
237
+ ]
238
+ if not lines:
239
+ return Format.TEXT
240
+ if all(
241
+ _ENV_LINE.match(line) and line.split("=", 1)[0].replace("export ", "").isupper()
242
+ for line in lines
243
+ ):
244
+ return Format.ENV
245
+ if _INI_SECTION.search(text) and all(
246
+ "=" in ln or ":" in ln or ln.startswith(("[", ";")) for ln in lines
247
+ ):
248
+ return Format.INI
249
+ if all(re.match(r"^[\w.\-]+\s*=", line) for line in lines):
250
+ return Format.PROPERTIES
251
+ return Format.TEXT
252
+
253
+
254
+ def detect(text: str) -> Format:
255
+ """What the content looks like. The person can override it."""
256
+ start = text.lstrip()[:1]
257
+ if start in {"{", "["}:
258
+ try:
259
+ json.loads(text)
260
+ return Format.JSON
261
+ except (ValueError, RecursionError):
262
+ pass # [section] or a YAML flow mapping: the checks below decide
263
+ if start == "<":
264
+ return Format.XML
265
+ found = _looks_like(text)
266
+ if found is not Format.TEXT:
267
+ return found
268
+ if _is_csv(text):
269
+ return Format.CSV
270
+ if _is_yaml_structure(text):
271
+ return Format.YAML
272
+ return Format.TEXT
273
+
274
+
275
+ def _is_csv(text: str) -> bool:
276
+ lines = [ln for ln in text.splitlines() if ln.strip()]
277
+ if len(lines) < 2:
278
+ return False
279
+ for delimiter in (",", ";"):
280
+ try:
281
+ widths = {
282
+ len(r) for r in csv.reader(io.StringIO(text, newline=""), delimiter=delimiter) if r
283
+ }
284
+ except csv.Error:
285
+ continue
286
+ if len(widths) == 1 and widths != {1}:
287
+ return True
288
+ return False
289
+
290
+
291
+ def _is_yaml_structure(text: str) -> bool:
292
+ try:
293
+ return isinstance(yaml.safe_load(text), dict | list)
294
+ except (yaml.YAMLError, RecursionError, ValueError):
295
+ return False
296
+
297
+
298
+ def parse(name: str, text: str, wanted: Format) -> Parsed:
299
+ """The file read as wanted; RequestError naming the file, never quoting it, if it cannot be."""
300
+ readers = {
301
+ Format.JSON: lambda: _json(name, text),
302
+ Format.YAML: lambda: _yaml(name, text),
303
+ Format.CSV: lambda: _csv(name, text),
304
+ Format.XML: lambda: _xml(name, text),
305
+ Format.PROPERTIES: lambda: _properties(text),
306
+ Format.INI: lambda: _ini(name, text),
307
+ Format.ENV: lambda: _env(name, text),
308
+ Format.TEXT: lambda: None,
309
+ }
310
+ return Parsed(wanted, readers[wanted]())