bearcli 1.0.0__tar.gz → 1.1.0__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.
- {bearcli-1.0.0 → bearcli-1.1.0}/PKG-INFO +12 -6
- {bearcli-1.0.0 → bearcli-1.1.0}/README.md +10 -5
- {bearcli-1.0.0 → bearcli-1.1.0}/pyproject.toml +2 -1
- {bearcli-1.0.0 → bearcli-1.1.0}/src/bearcli/cli.py +66 -1
- {bearcli-1.0.0 → bearcli-1.1.0}/src/bearcli/export.py +21 -3
- bearcli-1.1.0/src/bearcli/gitsync.py +106 -0
- bearcli-1.1.0/src/bearcli/secrets.py +105 -0
- {bearcli-1.0.0 → bearcli-1.1.0}/LICENSE +0 -0
- {bearcli-1.0.0 → bearcli-1.1.0}/src/bearcli/__init__.py +0 -0
- {bearcli-1.0.0 → bearcli-1.1.0}/src/bearcli/actions.py +0 -0
- {bearcli-1.0.0 → bearcli-1.1.0}/src/bearcli/db.py +0 -0
- {bearcli-1.0.0 → bearcli-1.1.0}/src/bearcli/search.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: bearcli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.1.0
|
|
4
4
|
Summary: The missing CLI for Bear notes — read, search, export, and manage your notes from the terminal
|
|
5
5
|
Keywords: bear,notes,cli,markdown,macos
|
|
6
6
|
Author: Michel Tricot
|
|
@@ -12,6 +12,7 @@ Classifier: Environment :: Console
|
|
|
12
12
|
Classifier: Operating System :: MacOS
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.13
|
|
14
14
|
Classifier: Topic :: Utilities
|
|
15
|
+
Requires-Dist: detect-secrets>=1.5.0
|
|
15
16
|
Requires-Dist: rapidfuzz>=3.14.5
|
|
16
17
|
Requires-Dist: typer>=0.27.0
|
|
17
18
|
Requires-Python: >=3.13
|
|
@@ -40,11 +41,6 @@ manage your notes from the terminal.
|
|
|
40
41
|
|
|
41
42
|
---
|
|
42
43
|
|
|
43
|
-
Bear stores your notes in a local SQLite database. bearcli reads it directly in
|
|
44
|
-
**read-only mode** — Bear doesn't even need to be running — and performs every
|
|
45
|
-
write through Bear's own x-callback-url API, verifying each change against the
|
|
46
|
-
database. Your notes are never touched behind Bear's back.
|
|
47
|
-
|
|
48
44
|
## Install
|
|
49
45
|
|
|
50
46
|
```sh
|
|
@@ -122,9 +118,19 @@ Every note becomes a self-contained directory — `<slug>/README.md` plus its
|
|
|
122
118
|
attachments — with a generated index, so GitHub renders the whole export as a
|
|
123
119
|
browsable tree.
|
|
124
120
|
|
|
121
|
+
Before anything is written, the notes are scanned for potential secrets
|
|
122
|
+
(token formats, key blocks, credential assignments); findings block the export
|
|
123
|
+
with a list of the affected notes (`--allow-secrets` overrides).
|
|
124
|
+
|
|
125
125
|
```sh
|
|
126
126
|
bearcli export ~/bear-backup
|
|
127
127
|
bearcli export ~/bear-backup --sync # only rewrite notes that changed
|
|
128
|
+
|
|
129
|
+
# Mirror to a git repository (clone it first; use a *private* repo — these are
|
|
130
|
+
# your notes). Bear is the source of truth: remote or manual edits are kept in
|
|
131
|
+
# git history but overwritten in HEAD. Never force-pushes, never gets stuck.
|
|
132
|
+
git clone git@github.com:you/bear-notes.git ~/bear-notes
|
|
133
|
+
bearcli export ~/bear-notes --sync --push
|
|
128
134
|
```
|
|
129
135
|
|
|
130
136
|
## Scripting
|
|
@@ -18,11 +18,6 @@ manage your notes from the terminal.
|
|
|
18
18
|
|
|
19
19
|
---
|
|
20
20
|
|
|
21
|
-
Bear stores your notes in a local SQLite database. bearcli reads it directly in
|
|
22
|
-
**read-only mode** — Bear doesn't even need to be running — and performs every
|
|
23
|
-
write through Bear's own x-callback-url API, verifying each change against the
|
|
24
|
-
database. Your notes are never touched behind Bear's back.
|
|
25
|
-
|
|
26
21
|
## Install
|
|
27
22
|
|
|
28
23
|
```sh
|
|
@@ -100,9 +95,19 @@ Every note becomes a self-contained directory — `<slug>/README.md` plus its
|
|
|
100
95
|
attachments — with a generated index, so GitHub renders the whole export as a
|
|
101
96
|
browsable tree.
|
|
102
97
|
|
|
98
|
+
Before anything is written, the notes are scanned for potential secrets
|
|
99
|
+
(token formats, key blocks, credential assignments); findings block the export
|
|
100
|
+
with a list of the affected notes (`--allow-secrets` overrides).
|
|
101
|
+
|
|
103
102
|
```sh
|
|
104
103
|
bearcli export ~/bear-backup
|
|
105
104
|
bearcli export ~/bear-backup --sync # only rewrite notes that changed
|
|
105
|
+
|
|
106
|
+
# Mirror to a git repository (clone it first; use a *private* repo — these are
|
|
107
|
+
# your notes). Bear is the source of truth: remote or manual edits are kept in
|
|
108
|
+
# git history but overwritten in HEAD. Never force-pushes, never gets stuck.
|
|
109
|
+
git clone git@github.com:you/bear-notes.git ~/bear-notes
|
|
110
|
+
bearcli export ~/bear-notes --sync --push
|
|
106
111
|
```
|
|
107
112
|
|
|
108
113
|
## Scripting
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "bearcli"
|
|
3
|
-
version = "1.
|
|
3
|
+
version = "1.1.0"
|
|
4
4
|
description = "The missing CLI for Bear notes — read, search, export, and manage your notes from the terminal"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
keywords = ["bear", "notes", "cli", "markdown", "macos"]
|
|
@@ -18,6 +18,7 @@ license = "MIT"
|
|
|
18
18
|
license-files = ["LICENSE"]
|
|
19
19
|
requires-python = ">=3.13"
|
|
20
20
|
dependencies = [
|
|
21
|
+
"detect-secrets>=1.5.0",
|
|
21
22
|
"rapidfuzz>=3.14.5",
|
|
22
23
|
"typer>=0.27.0",
|
|
23
24
|
]
|
|
@@ -22,7 +22,9 @@ from rich.table import Table
|
|
|
22
22
|
from bearcli import actions
|
|
23
23
|
from bearcli.db import DEFAULT_DB_PATH, BearDB, Note
|
|
24
24
|
from bearcli.export import export_notes
|
|
25
|
+
from bearcli.gitsync import GitError, export_and_push
|
|
25
26
|
from bearcli.search import naive_search, search_notes
|
|
27
|
+
from bearcli.secrets import SecretFinding, redaction_map, scan_notes
|
|
26
28
|
|
|
27
29
|
app = typer.Typer(help="Read notes from the Bear note app.", no_args_is_help=True, add_completion=False)
|
|
28
30
|
note_app = typer.Typer(help="Create, read, and modify notes.", no_args_is_help=True)
|
|
@@ -288,6 +290,23 @@ def get(
|
|
|
288
290
|
print(text)
|
|
289
291
|
|
|
290
292
|
|
|
293
|
+
def _report_secrets(findings: list[SecretFinding]) -> None:
|
|
294
|
+
table = Table(box=box.ROUNDED, header_style="bold")
|
|
295
|
+
table.add_column("Note", overflow="ellipsis", max_width=30)
|
|
296
|
+
table.add_column("ID", style="dim", no_wrap=True)
|
|
297
|
+
table.add_column("Rule", style="yellow")
|
|
298
|
+
table.add_column("Line", justify="right")
|
|
299
|
+
table.add_column("Match", style="red")
|
|
300
|
+
for f in findings:
|
|
301
|
+
table.add_row(f.note_title, f.note_id, f.rule, str(f.line), f.excerpt)
|
|
302
|
+
console.print(table)
|
|
303
|
+
notes = len({f.note_id for f in findings})
|
|
304
|
+
console.print(
|
|
305
|
+
f"[red]Export blocked:[/red] {len(findings)} potential secret(s) in {notes} note(s). "
|
|
306
|
+
"Move them somewhere safe (or into an encrypted note), or re-run with --allow-secrets."
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
291
310
|
@app.command()
|
|
292
311
|
def export(
|
|
293
312
|
dest: Annotated[Path, typer.Argument(help="Destination directory for the markdown files.")],
|
|
@@ -298,17 +317,60 @@ def export(
|
|
|
298
317
|
help="Only rewrite notes that changed since the last export instead of everything.",
|
|
299
318
|
),
|
|
300
319
|
] = False,
|
|
320
|
+
push: Annotated[
|
|
321
|
+
bool,
|
|
322
|
+
typer.Option(
|
|
323
|
+
"--push",
|
|
324
|
+
help="Treat DEST as a git clone: commit the export and push. Bear is the source of "
|
|
325
|
+
"truth — remote or manual edits are kept in history but overwritten in HEAD.",
|
|
326
|
+
),
|
|
327
|
+
] = False,
|
|
328
|
+
allow_secrets: Annotated[
|
|
329
|
+
bool,
|
|
330
|
+
typer.Option("--allow-secrets", help="Export even if the secret scan finds potential credentials."),
|
|
331
|
+
] = False,
|
|
332
|
+
redact_secrets: Annotated[
|
|
333
|
+
bool,
|
|
334
|
+
typer.Option(
|
|
335
|
+
"--redact-secrets",
|
|
336
|
+
help="Export with detected secrets replaced by a [redacted: <rule>] placeholder "
|
|
337
|
+
"(notes in Bear are untouched).",
|
|
338
|
+
),
|
|
339
|
+
] = False,
|
|
301
340
|
db_path: DbPathOption = DEFAULT_DB_PATH,
|
|
302
341
|
) -> None:
|
|
303
342
|
"""Export all notes as markdown files with frontmatter and attachments."""
|
|
343
|
+
if allow_secrets and redact_secrets:
|
|
344
|
+
console.print("[red]Error:[/red] --allow-secrets and --redact-secrets are mutually exclusive")
|
|
345
|
+
raise typer.Exit(2)
|
|
304
346
|
db = _open_db(db_path)
|
|
305
347
|
try:
|
|
348
|
+
redactions: dict[str, dict[str, str]] | None = None
|
|
349
|
+
if not allow_secrets:
|
|
350
|
+
with console.status("Scanning notes for secrets…", spinner="dots"):
|
|
351
|
+
candidates = db.list_notes(limit=None, include_archived=True, with_text=True)
|
|
352
|
+
findings = scan_notes(candidates)
|
|
353
|
+
if findings and not redact_secrets:
|
|
354
|
+
_report_secrets(findings)
|
|
355
|
+
raise typer.Exit(1)
|
|
356
|
+
if findings:
|
|
357
|
+
redactions = redaction_map(findings)
|
|
306
358
|
with console.status("Exporting…", spinner="dots") as status:
|
|
307
|
-
|
|
359
|
+
update = lambda msg: status.update(rich_escape(msg)) # noqa: E731
|
|
360
|
+
if push:
|
|
361
|
+
try:
|
|
362
|
+
result, outcome = export_and_push(db, dest, sync=sync, progress=update, redactions=redactions)
|
|
363
|
+
except GitError as exc:
|
|
364
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
365
|
+
raise typer.Exit(1) from None
|
|
366
|
+
else:
|
|
367
|
+
result = export_notes(db, dest, sync=sync, progress=update, redactions=redactions)
|
|
308
368
|
finally:
|
|
309
369
|
db.close()
|
|
310
370
|
|
|
311
371
|
parts = [f"{result.written} written"]
|
|
372
|
+
if push:
|
|
373
|
+
parts.append(outcome)
|
|
312
374
|
if sync:
|
|
313
375
|
parts.append(f"{result.unchanged} unchanged")
|
|
314
376
|
if result.removed:
|
|
@@ -317,6 +379,9 @@ def export(
|
|
|
317
379
|
parts.append(f"{result.skipped_encrypted} encrypted skipped")
|
|
318
380
|
if result.index_updated:
|
|
319
381
|
parts.append("index updated")
|
|
382
|
+
if redactions:
|
|
383
|
+
secrets_count = sum(len(v) for v in redactions.values())
|
|
384
|
+
parts.append(f"{secrets_count} secret(s) redacted in {len(redactions)} note(s)")
|
|
320
385
|
console.print(f"Exported to {dest}: " + ", ".join(parts))
|
|
321
386
|
if result.index_skipped:
|
|
322
387
|
console.print("[yellow]Warning:[/yellow] README.md exists but was not generated by bearcli; left untouched")
|
|
@@ -44,7 +44,7 @@ def _dirnames(notes: list[Note]) -> dict[str, str]:
|
|
|
44
44
|
return {n.id: short[n.id] if counts[short[n.id]] == 1 else f"{slugify(n.title)}-{n.id.lower()}" for n in notes}
|
|
45
45
|
|
|
46
46
|
|
|
47
|
-
def _frontmatter(note: Note) -> str:
|
|
47
|
+
def _frontmatter(note: Note, redacted: bool = False) -> str:
|
|
48
48
|
lines = [
|
|
49
49
|
"---",
|
|
50
50
|
f"id: {note.id}",
|
|
@@ -55,10 +55,19 @@ def _frontmatter(note: Note) -> str:
|
|
|
55
55
|
lines.append(f"created: {note.created.isoformat()}")
|
|
56
56
|
if note.modified:
|
|
57
57
|
lines.append(f"modified: {note.modified.isoformat()}")
|
|
58
|
+
if redacted:
|
|
59
|
+
lines.append("redacted: true")
|
|
58
60
|
lines.append("---")
|
|
59
61
|
return "\n".join(lines)
|
|
60
62
|
|
|
61
63
|
|
|
64
|
+
def _apply_redactions(text: str, secrets: dict[str, str]) -> str:
|
|
65
|
+
# Longest first, in case one detected value contains another.
|
|
66
|
+
for value in sorted(secrets, key=len, reverse=True):
|
|
67
|
+
text = text.replace(value, f"[redacted: {secrets[value]}]")
|
|
68
|
+
return text
|
|
69
|
+
|
|
70
|
+
|
|
62
71
|
def _parse_frontmatter(path: Path) -> dict[str, str]:
|
|
63
72
|
fields: dict[str, str] = {}
|
|
64
73
|
try:
|
|
@@ -149,6 +158,7 @@ def export_notes(
|
|
|
149
158
|
dest: Path,
|
|
150
159
|
sync: bool = False,
|
|
151
160
|
progress: Callable[[str], None] | None = None,
|
|
161
|
+
redactions: dict[str, dict[str, str]] | None = None,
|
|
152
162
|
) -> ExportResult:
|
|
153
163
|
"""Write every non-trashed note as dest/<slug>/README.md plus attachments/.
|
|
154
164
|
|
|
@@ -197,10 +207,15 @@ def export_notes(
|
|
|
197
207
|
note_dir = dest / slug
|
|
198
208
|
note_path = note_dir / NOTE_FILENAME
|
|
199
209
|
|
|
210
|
+
note_secrets = (redactions or {}).get(summary.id, {})
|
|
200
211
|
modified_iso = summary.modified.isoformat() if summary.modified else ""
|
|
201
212
|
if sync and note_path.exists():
|
|
202
213
|
existing = _parse_frontmatter(note_path)
|
|
203
|
-
|
|
214
|
+
# A change in redaction state must rewrite the file even though the
|
|
215
|
+
# note itself is unchanged — otherwise a previously exported secret
|
|
216
|
+
# would survive a later --redact-secrets run (and vice versa).
|
|
217
|
+
same_redaction = (existing.get("redacted") == "true") == bool(note_secrets)
|
|
218
|
+
if existing.get("id") == summary.id and existing.get("modified") == modified_iso and same_redaction:
|
|
204
219
|
result.unchanged += 1
|
|
205
220
|
add_entry(summary, f"{slug}/", (note_dir / ATTACHMENTS_DIRNAME).exists())
|
|
206
221
|
continue
|
|
@@ -211,8 +226,11 @@ def export_notes(
|
|
|
211
226
|
add_entry(summary, None, False)
|
|
212
227
|
continue
|
|
213
228
|
|
|
229
|
+
text = _rewrite_refs(note)
|
|
230
|
+
if note_secrets:
|
|
231
|
+
text = _apply_redactions(text, note_secrets)
|
|
214
232
|
note_dir.mkdir(exist_ok=True)
|
|
215
|
-
note_path.write_text(f"{_frontmatter(note)}\n{
|
|
233
|
+
note_path.write_text(f"{_frontmatter(note, redacted=bool(note_secrets))}\n{text}\n")
|
|
216
234
|
|
|
217
235
|
attach_dir = note_dir / ATTACHMENTS_DIRNAME
|
|
218
236
|
if attach_dir.exists():
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Export into a git worktree and push, converging without manual intervention.
|
|
2
|
+
|
|
3
|
+
The repository is a one-way mirror owned by bearcli: Bear is the source of
|
|
4
|
+
truth, and HEAD always converges to Bear's state. Nothing is ever lost —
|
|
5
|
+
local or remote edits are committed/merged before being overwritten, so they
|
|
6
|
+
remain in history — and nothing is ever force-pushed. Every failure mode
|
|
7
|
+
either self-heals (merge preferring local, reset to origin as last resort,
|
|
8
|
+
bounded push retries) or raises a clear error; there is no state that
|
|
9
|
+
requires manual git surgery to resume.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import subprocess
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from bearcli.db import BearDB
|
|
19
|
+
from bearcli.export import ExportResult, export_notes
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class GitError(RuntimeError):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _git(dest: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
|
27
|
+
return subprocess.run(["git", "-C", str(dest), *args], capture_output=True, text=True, check=False)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _run(dest: Path, *args: str) -> str:
|
|
31
|
+
proc = _git(dest, *args)
|
|
32
|
+
if proc.returncode != 0:
|
|
33
|
+
raise GitError(f"git {' '.join(args)} failed: {proc.stderr.strip()}")
|
|
34
|
+
return proc.stdout.strip()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _ok(dest: Path, *args: str) -> bool:
|
|
38
|
+
return _git(dest, *args).returncode == 0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _commit_if_dirty(dest: Path, message: str) -> None:
|
|
42
|
+
_run(dest, "add", "-A")
|
|
43
|
+
if not _ok(dest, "diff", "--cached", "--quiet"):
|
|
44
|
+
_run(dest, "commit", "-m", message)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def export_and_push(
|
|
48
|
+
db: BearDB,
|
|
49
|
+
dest: Path,
|
|
50
|
+
sync: bool = True,
|
|
51
|
+
progress: Callable[[str], None] | None = None,
|
|
52
|
+
redactions: dict[str, dict[str, str]] | None = None,
|
|
53
|
+
attempts: int = 3,
|
|
54
|
+
) -> tuple[ExportResult, str]:
|
|
55
|
+
def report(message: str) -> None:
|
|
56
|
+
if progress is not None:
|
|
57
|
+
progress(message)
|
|
58
|
+
|
|
59
|
+
if not _ok(dest, "rev-parse", "--is-inside-work-tree"):
|
|
60
|
+
raise GitError(f"{dest} is not a git repository — clone your notes repo (or `git init`) first")
|
|
61
|
+
branch = _run(dest, "symbolic-ref", "--short", "HEAD")
|
|
62
|
+
has_remote = _ok(dest, "remote", "get-url", "origin")
|
|
63
|
+
|
|
64
|
+
last_error = "unknown"
|
|
65
|
+
result = ExportResult()
|
|
66
|
+
for _ in range(attempts):
|
|
67
|
+
# Anything lying around (manual edits, a previous crashed run) gets
|
|
68
|
+
# committed first: merges start from a clean tree and nothing is lost.
|
|
69
|
+
_commit_if_dirty(dest, "bear export: local changes")
|
|
70
|
+
|
|
71
|
+
full = not sync
|
|
72
|
+
if has_remote:
|
|
73
|
+
report("Fetching origin…")
|
|
74
|
+
_run(dest, "fetch", "origin")
|
|
75
|
+
if (
|
|
76
|
+
_ok(dest, "rev-parse", "--verify", f"origin/{branch}")
|
|
77
|
+
and _run(dest, "rev-list", "--count", f"HEAD..origin/{branch}") != "0"
|
|
78
|
+
):
|
|
79
|
+
report("Integrating remote changes…")
|
|
80
|
+
if not (
|
|
81
|
+
_ok(dest, "merge", "--ff-only", f"origin/{branch}")
|
|
82
|
+
or _ok(dest, "merge", "-X", "ours", "--no-edit", f"origin/{branch}")
|
|
83
|
+
):
|
|
84
|
+
_git(dest, "merge", "--abort")
|
|
85
|
+
_run(dest, "reset", "--hard", f"origin/{branch}")
|
|
86
|
+
# Whatever was pulled, Bear's state must win in the worktree:
|
|
87
|
+
# a full export re-asserts every note, not just changed ones.
|
|
88
|
+
full = True
|
|
89
|
+
|
|
90
|
+
result = export_notes(db, dest, sync=not full, progress=progress, redactions=redactions)
|
|
91
|
+
|
|
92
|
+
report("Committing…")
|
|
93
|
+
parts = [f"{result.written} written"]
|
|
94
|
+
if result.removed:
|
|
95
|
+
parts.append(f"{result.removed} removed")
|
|
96
|
+
_commit_if_dirty(dest, "bear export: " + ", ".join(parts))
|
|
97
|
+
|
|
98
|
+
if not has_remote:
|
|
99
|
+
return result, "committed (no remote configured)"
|
|
100
|
+
report("Pushing…")
|
|
101
|
+
push = _git(dest, "push", "-u", "origin", branch)
|
|
102
|
+
if push.returncode == 0:
|
|
103
|
+
return result, "pushed"
|
|
104
|
+
last_error = push.stderr.strip() # racing push; refetch and retry
|
|
105
|
+
|
|
106
|
+
raise GitError(f"push failed after {attempts} attempts: {last_error}")
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Secret detection over note text, powered by detect-secrets (Yelp).
|
|
2
|
+
|
|
3
|
+
A first line of defense before notes leave the machine via export. Everything
|
|
4
|
+
runs offline — note content is never sent anywhere. Format detectors cover
|
|
5
|
+
known token shapes (AWS, GitHub, Slack, Stripe, OpenAI, private keys, JWTs,
|
|
6
|
+
keyword assignments, …) and the entropy detectors catch random-looking
|
|
7
|
+
strings with no known format. Secrets written as prose are undetectable; for
|
|
8
|
+
deeper auditing run gitleaks over an --allow-secrets export.
|
|
9
|
+
|
|
10
|
+
Implementation note: detect-secrets' `scan_line` helper is unsuitable — it
|
|
11
|
+
enables eager search, which makes entropy plugins report every token — so
|
|
12
|
+
plugins are instantiated and run directly, with the heuristic false-positive
|
|
13
|
+
filters applied by hand.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
|
|
20
|
+
from detect_secrets.core.plugins.util import get_mapping_from_secret_type_to_class
|
|
21
|
+
from detect_secrets.filters import heuristic
|
|
22
|
+
from detect_secrets.plugins.base import BasePlugin
|
|
23
|
+
|
|
24
|
+
from bearcli.db import Note
|
|
25
|
+
|
|
26
|
+
# An IP address in a note is not a credential.
|
|
27
|
+
_EXCLUDED_PLUGINS = {"IPPublicDetector"}
|
|
28
|
+
_BASE64_ENTROPY_LIMIT = 4.5 # detect-secrets' default
|
|
29
|
+
_HEX_ENTROPY_LIMIT = 3.5 # above default (3.0): notes are full of UUID/hash fragments
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _build_plugins() -> list[BasePlugin]:
|
|
33
|
+
plugins: list[BasePlugin] = []
|
|
34
|
+
for cls in get_mapping_from_secret_type_to_class().values():
|
|
35
|
+
name = cls.__name__
|
|
36
|
+
if name in _EXCLUDED_PLUGINS:
|
|
37
|
+
continue
|
|
38
|
+
if name == "Base64HighEntropyString":
|
|
39
|
+
plugins.append(cls(_BASE64_ENTROPY_LIMIT))
|
|
40
|
+
elif name == "HexHighEntropyString":
|
|
41
|
+
plugins.append(cls(_HEX_ENTROPY_LIMIT))
|
|
42
|
+
else:
|
|
43
|
+
plugins.append(cls())
|
|
44
|
+
return plugins
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _is_false_positive(value: str, line: str) -> bool:
|
|
48
|
+
return (
|
|
49
|
+
heuristic.is_potential_uuid(value)
|
|
50
|
+
or heuristic.is_sequential_string(value)
|
|
51
|
+
or heuristic.is_templated_secret(value)
|
|
52
|
+
or heuristic.is_likely_id_string(value, line)
|
|
53
|
+
or heuristic.is_not_alphanumeric_string(value)
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class SecretFinding:
|
|
59
|
+
note_id: str
|
|
60
|
+
note_title: str
|
|
61
|
+
rule: str
|
|
62
|
+
line: int
|
|
63
|
+
excerpt: str
|
|
64
|
+
secret: str # raw value, for redaction only — never print this
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _redact(value: str) -> str:
|
|
68
|
+
"""Show enough to locate the secret in the note, never the secret itself."""
|
|
69
|
+
return f"{value[:8]}…" if len(value) > 12 else "…"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def scan_notes(notes: list[Note]) -> list[SecretFinding]:
|
|
73
|
+
plugins = _build_plugins()
|
|
74
|
+
findings = []
|
|
75
|
+
for note in notes:
|
|
76
|
+
seen: set[tuple[int, str]] = set()
|
|
77
|
+
for lineno, line in enumerate((note.text or "").splitlines(), start=1):
|
|
78
|
+
for plugin in plugins:
|
|
79
|
+
# The yaml filetype hint makes KeywordDetector accept unquoted
|
|
80
|
+
# `password: value` assignments, which is how notes write them.
|
|
81
|
+
for secret in plugin.analyze_line(filename="note.yaml", line=line, line_number=lineno):
|
|
82
|
+
value = secret.secret_value or ""
|
|
83
|
+
if _is_false_positive(value, line) or (lineno, value) in seen:
|
|
84
|
+
continue
|
|
85
|
+
seen.add((lineno, value))
|
|
86
|
+
findings.append(
|
|
87
|
+
SecretFinding(
|
|
88
|
+
note_id=note.id,
|
|
89
|
+
note_title=note.title,
|
|
90
|
+
rule=secret.type,
|
|
91
|
+
line=lineno,
|
|
92
|
+
excerpt=_redact(value),
|
|
93
|
+
secret=value,
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
return findings
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def redaction_map(findings: list[SecretFinding]) -> dict[str, dict[str, str]]:
|
|
100
|
+
"""Per note id, the secret values to replace and the rule that found them."""
|
|
101
|
+
by_note: dict[str, dict[str, str]] = {}
|
|
102
|
+
for finding in findings:
|
|
103
|
+
if finding.secret:
|
|
104
|
+
by_note.setdefault(finding.note_id, {}).setdefault(finding.secret, finding.rule)
|
|
105
|
+
return by_note
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|