sharelint 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.
- sharelint/__init__.py +15 -0
- sharelint/__main__.py +3 -0
- sharelint/_version.py +3 -0
- sharelint/cli.py +269 -0
- sharelint/context.py +133 -0
- sharelint/demo.py +98 -0
- sharelint/filesystem.py +58 -0
- sharelint/models.py +226 -0
- sharelint/packing.py +823 -0
- sharelint/privacy.py +66 -0
- sharelint/py.typed +1 -0
- sharelint/reporters.py +306 -0
- sharelint/rules.py +382 -0
- sharelint/scanner.py +463 -0
- sharelint/scanners/__init__.py +1 -0
- sharelint/scanners/archive.py +415 -0
- sharelint/scanners/detect.py +95 -0
- sharelint/scanners/image.py +219 -0
- sharelint/scanners/ooxml.py +253 -0
- sharelint/scanners/path.py +73 -0
- sharelint/scanners/pdf.py +69 -0
- sharelint/scanners/text.py +164 -0
- sharelint-0.1.0.dist-info/METADATA +277 -0
- sharelint-0.1.0.dist-info/RECORD +28 -0
- sharelint-0.1.0.dist-info/WHEEL +5 -0
- sharelint-0.1.0.dist-info/entry_points.txt +2 -0
- sharelint-0.1.0.dist-info/licenses/LICENSE +21 -0
- sharelint-0.1.0.dist-info/top_level.txt +1 -0
sharelint/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""ShareLint public package API."""
|
|
2
|
+
|
|
3
|
+
from ._version import __version__
|
|
4
|
+
from .models import Finding, ScanLimits, ScanReport, ScanSurface, Severity
|
|
5
|
+
from .scanner import scan
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"Finding",
|
|
9
|
+
"ScanLimits",
|
|
10
|
+
"ScanReport",
|
|
11
|
+
"ScanSurface",
|
|
12
|
+
"Severity",
|
|
13
|
+
"__version__",
|
|
14
|
+
"scan",
|
|
15
|
+
]
|
sharelint/__main__.py
ADDED
sharelint/_version.py
ADDED
sharelint/cli.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Command-line interface for ShareLint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from contextlib import suppress
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
from .demo import create_demo_bundle
|
|
15
|
+
from .models import Severity
|
|
16
|
+
from .packing import PackBlocked, PackError, pack
|
|
17
|
+
from .reporters import render, render_console
|
|
18
|
+
from .rules import RULES
|
|
19
|
+
from .scanner import ScanInputError, scan
|
|
20
|
+
|
|
21
|
+
FORMATS = ("console", "json", "sarif", "html")
|
|
22
|
+
_UNSAFE_TERMINAL_CODEPOINTS = {
|
|
23
|
+
0x061C,
|
|
24
|
+
0x200E,
|
|
25
|
+
0x200F,
|
|
26
|
+
*range(0x202A, 0x202F),
|
|
27
|
+
*range(0x2066, 0x206A),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _severity(value: str) -> Severity:
|
|
32
|
+
try:
|
|
33
|
+
return Severity.parse(value)
|
|
34
|
+
except ValueError as exc:
|
|
35
|
+
raise argparse.ArgumentTypeError(str(exc)) from exc
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _parser() -> argparse.ArgumentParser:
|
|
39
|
+
parser = argparse.ArgumentParser(
|
|
40
|
+
prog="sharelint",
|
|
41
|
+
description="See what your files reveal before they leave your machine.",
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument("--version", action="version", version=f"ShareLint {__version__}")
|
|
44
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
45
|
+
|
|
46
|
+
scan_parser = subparsers.add_parser("scan", help="scan a file, folder, ZIP, or OOXML package")
|
|
47
|
+
scan_parser.add_argument("target", type=Path)
|
|
48
|
+
scan_parser.add_argument("--format", choices=FORMATS, default="console")
|
|
49
|
+
scan_parser.add_argument("-o", "--output", type=Path)
|
|
50
|
+
scan_parser.add_argument("--fail-on", type=_severity, default=Severity.HIGH)
|
|
51
|
+
scan_parser.add_argument(
|
|
52
|
+
"--strict",
|
|
53
|
+
action="store_true",
|
|
54
|
+
help="also fail when coverage is partial, skipped, or errored",
|
|
55
|
+
)
|
|
56
|
+
scan_parser.add_argument("--no-color", action="store_true")
|
|
57
|
+
|
|
58
|
+
pack_parser = subparsers.add_parser(
|
|
59
|
+
"pack", help="create a deterministic ZIP only when the policy passes"
|
|
60
|
+
)
|
|
61
|
+
pack_parser.add_argument("target", type=Path)
|
|
62
|
+
pack_parser.add_argument("-o", "--output", type=Path, required=True)
|
|
63
|
+
receipt_group = pack_parser.add_mutually_exclusive_group()
|
|
64
|
+
receipt_group.add_argument("--receipt", type=Path)
|
|
65
|
+
receipt_group.add_argument("--no-receipt", action="store_true")
|
|
66
|
+
pack_parser.add_argument(
|
|
67
|
+
"--report",
|
|
68
|
+
type=Path,
|
|
69
|
+
help="write the hash-bound JSON report here (requires --receipt)",
|
|
70
|
+
)
|
|
71
|
+
pack_parser.add_argument("--fail-on", type=_severity, default=Severity.HIGH)
|
|
72
|
+
pack_parser.add_argument("--no-color", action="store_true")
|
|
73
|
+
|
|
74
|
+
demo_parser = subparsers.add_parser("demo", help="scan a synthetic nested handoff bundle")
|
|
75
|
+
demo_parser.add_argument("-o", "--output", type=Path)
|
|
76
|
+
demo_parser.add_argument("--format", choices=FORMATS, default="console")
|
|
77
|
+
demo_parser.add_argument("--no-color", action="store_true")
|
|
78
|
+
|
|
79
|
+
subparsers.add_parser("rules", help="list stable rule identifiers")
|
|
80
|
+
explain_parser = subparsers.add_parser("explain", help="explain one rule")
|
|
81
|
+
explain_parser.add_argument("rule_id")
|
|
82
|
+
return parser
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _validate_scan_output(target: Path, output: Path | None) -> None:
|
|
86
|
+
if output is None:
|
|
87
|
+
return
|
|
88
|
+
if output.exists() or output.is_symlink():
|
|
89
|
+
raise ScanInputError("refusing to overwrite an existing report output")
|
|
90
|
+
target_resolved = target.resolve()
|
|
91
|
+
output_resolved = output.resolve()
|
|
92
|
+
if output_resolved == target_resolved:
|
|
93
|
+
raise ScanInputError("report output must differ from the scan target")
|
|
94
|
+
if target.is_dir() and not target.is_symlink():
|
|
95
|
+
try:
|
|
96
|
+
output_resolved.relative_to(target_resolved)
|
|
97
|
+
except ValueError:
|
|
98
|
+
pass
|
|
99
|
+
else:
|
|
100
|
+
raise ScanInputError("report output must be outside the scanned directory")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _write_or_print(content: str, output: Path | None) -> None:
|
|
104
|
+
if output is None:
|
|
105
|
+
sys.stdout.write(content)
|
|
106
|
+
return
|
|
107
|
+
if not output.parent.is_dir():
|
|
108
|
+
raise ScanInputError("report output directory does not exist")
|
|
109
|
+
temporary: Path | None = None
|
|
110
|
+
try:
|
|
111
|
+
with tempfile.NamedTemporaryFile(
|
|
112
|
+
prefix=f".{output.name}.",
|
|
113
|
+
suffix=".tmp",
|
|
114
|
+
dir=output.parent,
|
|
115
|
+
delete=False,
|
|
116
|
+
mode="wb",
|
|
117
|
+
) as handle:
|
|
118
|
+
temporary = Path(handle.name)
|
|
119
|
+
handle.write(content.encode("utf-8"))
|
|
120
|
+
handle.flush()
|
|
121
|
+
os.fsync(handle.fileno())
|
|
122
|
+
os.link(temporary, output)
|
|
123
|
+
except FileExistsError as exc:
|
|
124
|
+
raise ScanInputError("refusing to overwrite an existing report output") from exc
|
|
125
|
+
except OSError as exc:
|
|
126
|
+
raise ScanInputError("report output could not be written atomically") from exc
|
|
127
|
+
finally:
|
|
128
|
+
if temporary is not None:
|
|
129
|
+
with suppress(OSError):
|
|
130
|
+
temporary.unlink(missing_ok=True)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _use_color(disabled: bool) -> bool:
|
|
134
|
+
return not disabled and sys.stdout.isatty() and os.environ.get("NO_COLOR") is None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _display_path(path: Path) -> str:
|
|
138
|
+
return "".join(
|
|
139
|
+
f"\\u{code:04x}"
|
|
140
|
+
if code < 0x20
|
|
141
|
+
or code == 0x7F
|
|
142
|
+
or 0xD800 <= code <= 0xDFFF
|
|
143
|
+
or code in _UNSAFE_TERMINAL_CODEPOINTS
|
|
144
|
+
else character
|
|
145
|
+
for character in str(path)
|
|
146
|
+
for code in (ord(character),)
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _scan_command(arguments: argparse.Namespace) -> int:
|
|
151
|
+
_validate_scan_output(arguments.target, arguments.output)
|
|
152
|
+
report = scan(arguments.target)
|
|
153
|
+
rendered = render(
|
|
154
|
+
report,
|
|
155
|
+
arguments.format,
|
|
156
|
+
arguments.fail_on,
|
|
157
|
+
color=_use_color(arguments.no_color) and arguments.output is None,
|
|
158
|
+
)
|
|
159
|
+
_write_or_print(rendered, arguments.output)
|
|
160
|
+
blocked = report.count_at_or_above(arguments.fail_on) > 0
|
|
161
|
+
if arguments.strict and report.has_coverage_gaps:
|
|
162
|
+
blocked = True
|
|
163
|
+
return 1 if blocked else 0
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _pack_command(arguments: argparse.Namespace) -> int:
|
|
167
|
+
if arguments.report is not None and arguments.receipt is None:
|
|
168
|
+
raise PackError("--report requires an explicit --receipt path")
|
|
169
|
+
try:
|
|
170
|
+
artifact = pack(
|
|
171
|
+
arguments.target,
|
|
172
|
+
arguments.output,
|
|
173
|
+
receipt=arguments.receipt,
|
|
174
|
+
report_output=arguments.report,
|
|
175
|
+
write_receipt=not arguments.no_receipt,
|
|
176
|
+
fail_on=arguments.fail_on,
|
|
177
|
+
)
|
|
178
|
+
except PackBlocked as exc:
|
|
179
|
+
sys.stdout.write(
|
|
180
|
+
render_console(exc.report, arguments.fail_on, color=_use_color(arguments.no_color))
|
|
181
|
+
)
|
|
182
|
+
messages = {
|
|
183
|
+
"blocking_findings": "one or more findings met the blocking threshold",
|
|
184
|
+
"coverage_incomplete": "one or more required surfaces were not fully inspected",
|
|
185
|
+
"artifact_rescan_failed": "the completed archive did not pass its rescan",
|
|
186
|
+
"source_changed": "the source changed during packing",
|
|
187
|
+
}
|
|
188
|
+
for reason in exc.reasons:
|
|
189
|
+
sys.stderr.write(f"pack blocked: {messages.get(reason, reason)}\n")
|
|
190
|
+
if exc.report_output is not None:
|
|
191
|
+
sys.stdout.write(f"Wrote {_display_path(exc.report_output)}\n")
|
|
192
|
+
if exc.receipt is not None:
|
|
193
|
+
sys.stdout.write(f"Wrote {_display_path(exc.receipt)}\n")
|
|
194
|
+
return 1
|
|
195
|
+
sys.stdout.write(
|
|
196
|
+
f"PACKED · {len(artifact.files)} file(s) · sha256:{artifact.archive_sha256}\n"
|
|
197
|
+
f"Wrote {_display_path(artifact.output)}\n"
|
|
198
|
+
)
|
|
199
|
+
if artifact.report_output is not None:
|
|
200
|
+
sys.stdout.write(f"Wrote {_display_path(artifact.report_output)}\n")
|
|
201
|
+
if artifact.receipt is not None:
|
|
202
|
+
sys.stdout.write(f"Wrote {_display_path(artifact.receipt)}\n")
|
|
203
|
+
return 0
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _demo_command(arguments: argparse.Namespace) -> int:
|
|
207
|
+
if arguments.output is not None:
|
|
208
|
+
if arguments.output.exists() or arguments.output.is_symlink():
|
|
209
|
+
raise ScanInputError("refusing to overwrite an existing demo bundle")
|
|
210
|
+
try:
|
|
211
|
+
create_demo_bundle(arguments.output)
|
|
212
|
+
except FileExistsError as exc:
|
|
213
|
+
raise ScanInputError("refusing to overwrite an existing demo bundle") from exc
|
|
214
|
+
path = arguments.output
|
|
215
|
+
report = scan(path)
|
|
216
|
+
_write_or_print(
|
|
217
|
+
render(report, arguments.format, Severity.HIGH, color=_use_color(arguments.no_color)),
|
|
218
|
+
None,
|
|
219
|
+
)
|
|
220
|
+
return 0
|
|
221
|
+
with tempfile.TemporaryDirectory(prefix="sharelint-demo-") as directory:
|
|
222
|
+
path = create_demo_bundle(Path(directory) / "client-handoff.zip")
|
|
223
|
+
report = scan(path)
|
|
224
|
+
_write_or_print(
|
|
225
|
+
render(report, arguments.format, Severity.HIGH, color=_use_color(arguments.no_color)),
|
|
226
|
+
None,
|
|
227
|
+
)
|
|
228
|
+
return 0
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _rules_command() -> int:
|
|
232
|
+
for rule_id in sorted(RULES):
|
|
233
|
+
rule = RULES[rule_id]
|
|
234
|
+
sys.stdout.write(f"{rule.severity.value.upper():8} {rule.rule_id:40} {rule.title}\n")
|
|
235
|
+
return 0
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _explain_command(rule_id: str) -> int:
|
|
239
|
+
rule = RULES.get(rule_id.upper())
|
|
240
|
+
if rule is None:
|
|
241
|
+
sys.stderr.write(f"unknown rule: {rule_id}\n")
|
|
242
|
+
return 2
|
|
243
|
+
sys.stdout.write(
|
|
244
|
+
f"{rule.rule_id}\nSeverity: {rule.severity.value}\nTitle: {rule.title}\n"
|
|
245
|
+
f"Tags: {', '.join(rule.tags)}\nRemediation: {rule.remediation}\n"
|
|
246
|
+
)
|
|
247
|
+
return 0
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
251
|
+
parser = _parser()
|
|
252
|
+
arguments = parser.parse_args(argv)
|
|
253
|
+
try:
|
|
254
|
+
if arguments.command == "scan":
|
|
255
|
+
return _scan_command(arguments)
|
|
256
|
+
if arguments.command == "pack":
|
|
257
|
+
return _pack_command(arguments)
|
|
258
|
+
if arguments.command == "demo":
|
|
259
|
+
return _demo_command(arguments)
|
|
260
|
+
if arguments.command == "rules":
|
|
261
|
+
return _rules_command()
|
|
262
|
+
if arguments.command == "explain":
|
|
263
|
+
return _explain_command(arguments.rule_id)
|
|
264
|
+
except (ScanInputError, PackError, OSError) as exc:
|
|
265
|
+
message = "operating system operation failed" if isinstance(exc, OSError) else str(exc)
|
|
266
|
+
sys.stderr.write(f"sharelint: {message}\n")
|
|
267
|
+
return 2
|
|
268
|
+
parser.error("unknown command")
|
|
269
|
+
return 2
|
sharelint/context.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Mutable scan session state with centralized privacy and resource limits."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from .models import Finding, ScanError, ScanLimits, ScanSurface, SurfaceStatus
|
|
8
|
+
from .privacy import EvidenceProtector, mask_evidence
|
|
9
|
+
from .rules import get_rule
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(slots=True)
|
|
13
|
+
class ScanContext:
|
|
14
|
+
limits: ScanLimits
|
|
15
|
+
protector: EvidenceProtector = field(default_factory=EvidenceProtector)
|
|
16
|
+
findings: list[Finding] = field(default_factory=list)
|
|
17
|
+
surfaces: list[ScanSurface] = field(default_factory=list)
|
|
18
|
+
errors: list[ScanError] = field(default_factory=list)
|
|
19
|
+
total_bytes: int = 0
|
|
20
|
+
archive_members: int = 0
|
|
21
|
+
finding_limit_reached: bool = False
|
|
22
|
+
_finding_keys: set[tuple[str, tuple[str, ...], str, str]] = field(default_factory=set)
|
|
23
|
+
_path_aliases: dict[str, str] = field(default_factory=dict)
|
|
24
|
+
|
|
25
|
+
def display_path(self, name: str) -> str:
|
|
26
|
+
"""Redact a path while keeping colliding redactions distinguishable."""
|
|
27
|
+
|
|
28
|
+
from .scanners.path import redact_path
|
|
29
|
+
|
|
30
|
+
redacted = redact_path(name)
|
|
31
|
+
if redacted == name:
|
|
32
|
+
return redacted
|
|
33
|
+
return f"{redacted} [path-ref:{self.path_reference(name)}]"
|
|
34
|
+
|
|
35
|
+
def path_reference(self, name: str) -> str:
|
|
36
|
+
"""Return a report-local opaque ordinal for a raw logical path."""
|
|
37
|
+
|
|
38
|
+
alias = self._path_aliases.get(name)
|
|
39
|
+
if alias is None:
|
|
40
|
+
alias = f"p{len(self._path_aliases) + 1:04d}"
|
|
41
|
+
self._path_aliases[name] = alias
|
|
42
|
+
return alias
|
|
43
|
+
|
|
44
|
+
def add_finding(
|
|
45
|
+
self,
|
|
46
|
+
rule_id: str,
|
|
47
|
+
source_chain: tuple[str, ...],
|
|
48
|
+
location: str,
|
|
49
|
+
evidence: str,
|
|
50
|
+
*,
|
|
51
|
+
evidence_kind: str = "generic",
|
|
52
|
+
) -> bool:
|
|
53
|
+
if self.finding_limit_reached:
|
|
54
|
+
return False
|
|
55
|
+
if len(self.findings) >= self.limits.max_findings:
|
|
56
|
+
self.finding_limit_reached = True
|
|
57
|
+
self.errors.append(
|
|
58
|
+
ScanError(
|
|
59
|
+
source_chain,
|
|
60
|
+
"SL.ARCHIVE.LIMIT_EXCEEDED",
|
|
61
|
+
"Finding count limit reached; remaining matches were not recorded",
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
self.add_surface(
|
|
65
|
+
source_chain,
|
|
66
|
+
"finding-ledger",
|
|
67
|
+
SurfaceStatus.PARTIAL,
|
|
68
|
+
0,
|
|
69
|
+
"reporting",
|
|
70
|
+
"finding count limit reached",
|
|
71
|
+
)
|
|
72
|
+
return False
|
|
73
|
+
rule = get_rule(rule_id)
|
|
74
|
+
fingerprint = self.protector.fingerprint(rule_id, evidence)
|
|
75
|
+
key = (rule_id, source_chain, location, fingerprint)
|
|
76
|
+
if key in self._finding_keys:
|
|
77
|
+
return True
|
|
78
|
+
self._finding_keys.add(key)
|
|
79
|
+
self.findings.append(
|
|
80
|
+
Finding(
|
|
81
|
+
rule_id=rule.rule_id,
|
|
82
|
+
severity=rule.severity,
|
|
83
|
+
title=rule.title,
|
|
84
|
+
source_chain=source_chain,
|
|
85
|
+
location=location,
|
|
86
|
+
masked_preview=mask_evidence(evidence, evidence_kind),
|
|
87
|
+
evidence_fingerprint=fingerprint,
|
|
88
|
+
remediation=rule.remediation,
|
|
89
|
+
tags=rule.tags,
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
return True
|
|
93
|
+
|
|
94
|
+
def add_surface(
|
|
95
|
+
self,
|
|
96
|
+
source_chain: tuple[str, ...],
|
|
97
|
+
kind: str,
|
|
98
|
+
status: SurfaceStatus,
|
|
99
|
+
bytes_inspected: int,
|
|
100
|
+
scanner: str,
|
|
101
|
+
note: str = "",
|
|
102
|
+
) -> None:
|
|
103
|
+
self.surfaces.append(
|
|
104
|
+
ScanSurface(
|
|
105
|
+
source_chain=source_chain,
|
|
106
|
+
kind=kind,
|
|
107
|
+
status=status,
|
|
108
|
+
bytes_inspected=max(0, bytes_inspected),
|
|
109
|
+
scanner=scanner,
|
|
110
|
+
note=note,
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def add_error(self, source_chain: tuple[str, ...], code: str, message: str) -> None:
|
|
115
|
+
# Parser exception strings can contain raw content. Callers provide bounded,
|
|
116
|
+
# content-free messages instead of forwarding exception text.
|
|
117
|
+
self.errors.append(ScanError(source_chain, code, message[:240]))
|
|
118
|
+
|
|
119
|
+
def consume(self, amount: int) -> bool:
|
|
120
|
+
if amount < 0 or self.total_bytes + amount > self.limits.max_total_bytes:
|
|
121
|
+
return False
|
|
122
|
+
self.total_bytes += amount
|
|
123
|
+
return True
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def remaining_bytes(self) -> int:
|
|
127
|
+
return max(0, self.limits.max_total_bytes - self.total_bytes)
|
|
128
|
+
|
|
129
|
+
def consume_member(self) -> bool:
|
|
130
|
+
if self.archive_members + 1 > self.limits.max_archive_members:
|
|
131
|
+
return False
|
|
132
|
+
self.archive_members += 1
|
|
133
|
+
return True
|
sharelint/demo.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""A reproducible synthetic bundle used by the README and `sharelint demo`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
import zipfile
|
|
9
|
+
from contextlib import suppress
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _zip_bytes(files: dict[str, bytes]) -> bytes:
|
|
14
|
+
output = io.BytesIO()
|
|
15
|
+
with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
|
16
|
+
for name in sorted(files):
|
|
17
|
+
info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0))
|
|
18
|
+
info.compress_type = zipfile.ZIP_DEFLATED
|
|
19
|
+
info.create_system = 3
|
|
20
|
+
info.external_attr = 0o100644 << 16
|
|
21
|
+
info.flag_bits |= 0x800
|
|
22
|
+
archive.writestr(info, files[name], compress_type=zipfile.ZIP_DEFLATED, compresslevel=9)
|
|
23
|
+
return output.getvalue()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _xlsx() -> bytes:
|
|
27
|
+
return _zip_bytes(
|
|
28
|
+
{
|
|
29
|
+
"[Content_Types].xml": b"<Types/>",
|
|
30
|
+
"xl/workbook.xml": (
|
|
31
|
+
b'<workbook xmlns="urn:demo"><sheets>'
|
|
32
|
+
b'<sheet name="Clients" state="veryHidden" sheetId="1"/>'
|
|
33
|
+
b"</sheets></workbook>"
|
|
34
|
+
),
|
|
35
|
+
"xl/sharedStrings.xml": (
|
|
36
|
+
b'<sst xmlns="urn:demo"><si><t>AKIAIOSFODNN7EXAMPLE</t></si></sst>'
|
|
37
|
+
),
|
|
38
|
+
}
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _pptx() -> bytes:
|
|
43
|
+
return _zip_bytes(
|
|
44
|
+
{
|
|
45
|
+
"[Content_Types].xml": b"<Types/>",
|
|
46
|
+
"docProps/core.xml": (
|
|
47
|
+
b'<cp:coreProperties xmlns:cp="urn:cp" xmlns:dc="urn:dc">'
|
|
48
|
+
b"<dc:creator>Casey Example</dc:creator></cp:coreProperties>"
|
|
49
|
+
),
|
|
50
|
+
"ppt/presentation.xml": b'<p:presentation xmlns:p="urn:demo"/>',
|
|
51
|
+
"ppt/slides/slide1.xml": b'<p:sld xmlns:p="urn:demo"><a:t xmlns:a="urn:a">Public demo</a:t></p:sld>',
|
|
52
|
+
"ppt/notesSlides/notesSlide1.xml": (
|
|
53
|
+
b'<p:notes xmlns:p="urn:demo" xmlns:a="urn:a">'
|
|
54
|
+
b"<a:t>Follow up with reviewer@example.test</a:t></p:notes>"
|
|
55
|
+
),
|
|
56
|
+
"ppt/embeddings/clients.xlsx": _xlsx(),
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def create_demo_bundle(path: Path) -> Path:
|
|
62
|
+
"""Atomically create a bundle containing only documented synthetic evidence."""
|
|
63
|
+
|
|
64
|
+
report = (
|
|
65
|
+
b"%PDF-1.4\n"
|
|
66
|
+
b"1 0 obj << /Author (Casey Example) /Producer (Synthetic Demo) "
|
|
67
|
+
b"/OpenAction 2 0 R >> endobj\n"
|
|
68
|
+
b"2 0 obj << /S /JavaScript /JS (app.alert('demo')) >> endobj\n"
|
|
69
|
+
b"%%EOF\n"
|
|
70
|
+
)
|
|
71
|
+
payload = _zip_bytes(
|
|
72
|
+
{
|
|
73
|
+
"README.txt": b"Synthetic ShareLint demo. No real identities or credentials.\n",
|
|
74
|
+
"deck.pptx": _pptx(),
|
|
75
|
+
"report.pdf": report,
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
if path.exists() or path.is_symlink():
|
|
79
|
+
raise FileExistsError("refusing to overwrite an existing demo bundle")
|
|
80
|
+
temporary: Path | None = None
|
|
81
|
+
try:
|
|
82
|
+
with tempfile.NamedTemporaryFile(
|
|
83
|
+
prefix=f".{path.name}.",
|
|
84
|
+
suffix=".tmp",
|
|
85
|
+
dir=path.parent,
|
|
86
|
+
delete=False,
|
|
87
|
+
mode="wb",
|
|
88
|
+
) as handle:
|
|
89
|
+
temporary = Path(handle.name)
|
|
90
|
+
handle.write(payload)
|
|
91
|
+
handle.flush()
|
|
92
|
+
os.fsync(handle.fileno())
|
|
93
|
+
os.link(temporary, path)
|
|
94
|
+
finally:
|
|
95
|
+
if temporary is not None:
|
|
96
|
+
with suppress(OSError):
|
|
97
|
+
temporary.unlink(missing_ok=True)
|
|
98
|
+
return path
|
sharelint/filesystem.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Bounded filesystem enumeration shared by scanning and packing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def bounded_directory_items(root: Path, limit: int) -> tuple[list[Path], int, bool]:
|
|
10
|
+
"""Return non-directory entries without enumerating more than ``limit`` names.
|
|
11
|
+
|
|
12
|
+
The result is globally stable-sorted. Directory and metadata failures are
|
|
13
|
+
counted without forwarding platform exception text, and ``limited`` is true
|
|
14
|
+
when another entry existed beyond the configured ceiling.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
result: list[Path] = []
|
|
18
|
+
pending = [root]
|
|
19
|
+
error_count = 0
|
|
20
|
+
entry_count = 0
|
|
21
|
+
|
|
22
|
+
while pending:
|
|
23
|
+
current = pending.pop()
|
|
24
|
+
current_entries: list[tuple[str, Path, bool]] = []
|
|
25
|
+
try:
|
|
26
|
+
with os.scandir(current) as entries:
|
|
27
|
+
for entry in entries:
|
|
28
|
+
entry_count += 1
|
|
29
|
+
if entry_count > limit:
|
|
30
|
+
return (
|
|
31
|
+
sorted(result, key=lambda item: item.relative_to(root).as_posix()),
|
|
32
|
+
error_count,
|
|
33
|
+
True,
|
|
34
|
+
)
|
|
35
|
+
try:
|
|
36
|
+
is_directory = entry.is_dir(follow_symlinks=False)
|
|
37
|
+
except OSError:
|
|
38
|
+
error_count += 1
|
|
39
|
+
continue
|
|
40
|
+
current_entries.append((entry.name, Path(entry.path), is_directory))
|
|
41
|
+
except OSError:
|
|
42
|
+
error_count += 1
|
|
43
|
+
continue
|
|
44
|
+
|
|
45
|
+
current_entries.sort(key=lambda item: item[0])
|
|
46
|
+
directories: list[Path] = []
|
|
47
|
+
for _name, item, is_directory in current_entries:
|
|
48
|
+
if is_directory:
|
|
49
|
+
directories.append(item)
|
|
50
|
+
else:
|
|
51
|
+
result.append(item)
|
|
52
|
+
pending.extend(reversed(directories))
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
sorted(result, key=lambda item: item.relative_to(root).as_posix()),
|
|
56
|
+
error_count,
|
|
57
|
+
False,
|
|
58
|
+
)
|