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 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
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
sharelint/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ """Single source of truth for the package version."""
2
+
3
+ __version__ = "0.1.0"
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
@@ -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
+ )