bearcli 1.1.0__tar.gz → 1.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {bearcli-1.1.0 → bearcli-1.2.1}/PKG-INFO +14 -4
- {bearcli-1.1.0 → bearcli-1.2.1}/README.md +13 -3
- {bearcli-1.1.0 → bearcli-1.2.1}/pyproject.toml +1 -1
- {bearcli-1.1.0 → bearcli-1.2.1}/src/bearcli/cli.py +23 -33
- {bearcli-1.1.0 → bearcli-1.2.1}/src/bearcli/db.py +55 -19
- {bearcli-1.1.0 → bearcli-1.2.1}/src/bearcli/export.py +24 -41
- bearcli-1.2.1/src/bearcli/markdown.py +25 -0
- bearcli-1.2.1/src/bearcli/secrets.py +203 -0
- bearcli-1.1.0/src/bearcli/secrets.py +0 -105
- {bearcli-1.1.0 → bearcli-1.2.1}/LICENSE +0 -0
- {bearcli-1.1.0 → bearcli-1.2.1}/src/bearcli/__init__.py +0 -0
- {bearcli-1.1.0 → bearcli-1.2.1}/src/bearcli/actions.py +0 -0
- {bearcli-1.1.0 → bearcli-1.2.1}/src/bearcli/gitsync.py +0 -0
- {bearcli-1.1.0 → bearcli-1.2.1}/src/bearcli/search.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: bearcli
|
|
3
|
-
Version: 1.1
|
|
3
|
+
Version: 1.2.1
|
|
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
|
|
@@ -74,9 +74,10 @@ bearcli note list --only pinned # or: encrypted, trashed, archived
|
|
|
74
74
|
bearcli note list --all --trashed --archived
|
|
75
75
|
bearcli note list --ids # only identifiers, one per line
|
|
76
76
|
|
|
77
|
-
bearcli get C44D09DC
|
|
77
|
+
bearcli get C44D09DC # a unique id prefix (4+ chars) works everywhere
|
|
78
78
|
bearcli get C44D09DC-... --meta # with YAML-style frontmatter
|
|
79
79
|
bearcli get C44D09DC-... -r # rewrite attachment refs to absolute paths
|
|
80
|
+
bearcli get C44D09DC-... --redact-secrets # secrets replaced by placeholders
|
|
80
81
|
bearcli open C44D09DC-... # open in the Bear app
|
|
81
82
|
```
|
|
82
83
|
|
|
@@ -119,12 +120,21 @@ attachments — with a generated index, so GitHub renders the whole export as a
|
|
|
119
120
|
browsable tree.
|
|
120
121
|
|
|
121
122
|
Before anything is written, the notes are scanned for potential secrets
|
|
122
|
-
(token formats, key blocks, credential assignments);
|
|
123
|
-
with a list of the affected notes
|
|
123
|
+
(token formats, key blocks, credential assignments, high-entropy strings);
|
|
124
|
+
findings block the export with a list of the affected notes. Override with
|
|
125
|
+
`--allow-secrets` (export as-is) or `--redact-secrets` (replace each secret
|
|
126
|
+
with a `[redacted: <rule>]` placeholder — notes in Bear are untouched).
|
|
127
|
+
|
|
128
|
+
> **⚠️ Warning** — detection and redaction are best-effort: a secret that
|
|
129
|
+
> reads like ordinary text (a password written as prose, an account number)
|
|
130
|
+
> will not be caught. Ideally, don't keep secrets in notes at all — use a
|
|
131
|
+
> password manager, or at least Bear's encrypted notes, which never leave
|
|
132
|
+
> the app.
|
|
124
133
|
|
|
125
134
|
```sh
|
|
126
135
|
bearcli export ~/bear-backup
|
|
127
136
|
bearcli export ~/bear-backup --sync # only rewrite notes that changed
|
|
137
|
+
bearcli export ~/bear-backup --redact-secrets # secrets become [redacted: <rule>]
|
|
128
138
|
|
|
129
139
|
# Mirror to a git repository (clone it first; use a *private* repo — these are
|
|
130
140
|
# your notes). Bear is the source of truth: remote or manual edits are kept in
|
|
@@ -51,9 +51,10 @@ bearcli note list --only pinned # or: encrypted, trashed, archived
|
|
|
51
51
|
bearcli note list --all --trashed --archived
|
|
52
52
|
bearcli note list --ids # only identifiers, one per line
|
|
53
53
|
|
|
54
|
-
bearcli get C44D09DC
|
|
54
|
+
bearcli get C44D09DC # a unique id prefix (4+ chars) works everywhere
|
|
55
55
|
bearcli get C44D09DC-... --meta # with YAML-style frontmatter
|
|
56
56
|
bearcli get C44D09DC-... -r # rewrite attachment refs to absolute paths
|
|
57
|
+
bearcli get C44D09DC-... --redact-secrets # secrets replaced by placeholders
|
|
57
58
|
bearcli open C44D09DC-... # open in the Bear app
|
|
58
59
|
```
|
|
59
60
|
|
|
@@ -96,12 +97,21 @@ attachments — with a generated index, so GitHub renders the whole export as a
|
|
|
96
97
|
browsable tree.
|
|
97
98
|
|
|
98
99
|
Before anything is written, the notes are scanned for potential secrets
|
|
99
|
-
(token formats, key blocks, credential assignments);
|
|
100
|
-
with a list of the affected notes
|
|
100
|
+
(token formats, key blocks, credential assignments, high-entropy strings);
|
|
101
|
+
findings block the export with a list of the affected notes. Override with
|
|
102
|
+
`--allow-secrets` (export as-is) or `--redact-secrets` (replace each secret
|
|
103
|
+
with a `[redacted: <rule>]` placeholder — notes in Bear are untouched).
|
|
104
|
+
|
|
105
|
+
> **⚠️ Warning** — detection and redaction are best-effort: a secret that
|
|
106
|
+
> reads like ordinary text (a password written as prose, an account number)
|
|
107
|
+
> will not be caught. Ideally, don't keep secrets in notes at all — use a
|
|
108
|
+
> password manager, or at least Bear's encrypted notes, which never leave
|
|
109
|
+
> the app.
|
|
101
110
|
|
|
102
111
|
```sh
|
|
103
112
|
bearcli export ~/bear-backup
|
|
104
113
|
bearcli export ~/bear-backup --sync # only rewrite notes that changed
|
|
114
|
+
bearcli export ~/bear-backup --redact-secrets # secrets become [redacted: <rule>]
|
|
105
115
|
|
|
106
116
|
# Mirror to a git repository (clone it first; use a *private* repo — these are
|
|
107
117
|
# your notes). Bear is the source of truth: remote or manual edits are kept in
|
|
@@ -11,7 +11,6 @@ from datetime import UTC, datetime, timedelta
|
|
|
11
11
|
from enum import StrEnum
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
from typing import Annotated
|
|
14
|
-
from urllib.parse import quote
|
|
15
14
|
|
|
16
15
|
import typer
|
|
17
16
|
from rich import box
|
|
@@ -20,11 +19,12 @@ from rich.markup import escape as rich_escape
|
|
|
20
19
|
from rich.table import Table
|
|
21
20
|
|
|
22
21
|
from bearcli import actions
|
|
23
|
-
from bearcli.db import DEFAULT_DB_PATH, BearDB, Note
|
|
22
|
+
from bearcli.db import DEFAULT_DB_PATH, AmbiguousNoteId, BearDB, Note, note_metadata
|
|
24
23
|
from bearcli.export import export_notes
|
|
25
24
|
from bearcli.gitsync import GitError, export_and_push
|
|
25
|
+
from bearcli.markdown import rewrite_attachment_refs
|
|
26
26
|
from bearcli.search import naive_search, search_notes
|
|
27
|
-
from bearcli.secrets import SecretFinding, redaction_map, scan_notes
|
|
27
|
+
from bearcli.secrets import SecretFinding, redact_text, redaction_map, scan_notes
|
|
28
28
|
|
|
29
29
|
app = typer.Typer(help="Read notes from the Bear note app.", no_args_is_help=True, add_completion=False)
|
|
30
30
|
note_app = typer.Typer(help="Create, read, and modify notes.", no_args_is_help=True)
|
|
@@ -48,17 +48,7 @@ class OnlyFilter(StrEnum):
|
|
|
48
48
|
|
|
49
49
|
|
|
50
50
|
def _note_to_dict(note: Note, with_text: bool = False) -> dict:
|
|
51
|
-
data =
|
|
52
|
-
"id": note.id,
|
|
53
|
-
"title": note.title,
|
|
54
|
-
"tags": note.tags,
|
|
55
|
-
"created": note.created.isoformat() if note.created else None,
|
|
56
|
-
"modified": note.modified.isoformat() if note.modified else None,
|
|
57
|
-
"pinned": note.pinned,
|
|
58
|
-
"encrypted": note.encrypted,
|
|
59
|
-
"archived": note.archived,
|
|
60
|
-
"trashed": note.trashed,
|
|
61
|
-
}
|
|
51
|
+
data = note_metadata(note)
|
|
62
52
|
if with_text:
|
|
63
53
|
data["text"] = note.text
|
|
64
54
|
data["attachments"] = [
|
|
@@ -84,17 +74,8 @@ def _note_status(note: Note) -> str:
|
|
|
84
74
|
|
|
85
75
|
|
|
86
76
|
def _resolve_attachments(note: Note) -> str:
|
|
87
|
-
"""Rewrite
|
|
88
|
-
|
|
89
|
-
Bear percent-encodes filenames in the note text (e.g. spaces as %20), so match
|
|
90
|
-
both the raw and encoded forms; emit encoded paths to keep the links valid.
|
|
91
|
-
"""
|
|
92
|
-
text = note.text or ""
|
|
93
|
-
for att in note.attachments:
|
|
94
|
-
target = quote(str(att.path))
|
|
95
|
-
for ref in {att.filename, quote(att.filename)}:
|
|
96
|
-
text = text.replace(f"]({ref})", f"]({target})")
|
|
97
|
-
return text
|
|
77
|
+
"""Rewrite attachment links to the files' absolute paths on disk."""
|
|
78
|
+
return rewrite_attachment_refs(note, lambda att: str(att.path))
|
|
98
79
|
|
|
99
80
|
|
|
100
81
|
DbPathOption = Annotated[
|
|
@@ -223,6 +204,10 @@ def get(
|
|
|
223
204
|
help="Rewrite attachment references in the content to absolute file paths.",
|
|
224
205
|
),
|
|
225
206
|
] = False,
|
|
207
|
+
redact_secrets: Annotated[
|
|
208
|
+
bool,
|
|
209
|
+
typer.Option("--redact-secrets", help="Replace detected secrets with a [redacted: <rule>] placeholder."),
|
|
210
|
+
] = False,
|
|
226
211
|
fmt: Annotated[
|
|
227
212
|
OutputFormat,
|
|
228
213
|
typer.Option(
|
|
@@ -236,18 +221,18 @@ def get(
|
|
|
236
221
|
"""Print the content of a note."""
|
|
237
222
|
db = _open_db(db_path)
|
|
238
223
|
try:
|
|
239
|
-
note = db
|
|
224
|
+
note = _require_note(db, note_id)
|
|
240
225
|
finally:
|
|
241
226
|
db.close()
|
|
242
227
|
|
|
243
|
-
if note is None:
|
|
244
|
-
console.print(f"[red]Error:[/red] no note with id {note_id!r}")
|
|
245
|
-
raise typer.Exit(1)
|
|
246
228
|
if note.encrypted or note.text is None:
|
|
247
229
|
console.print(f"[red]Error:[/red] note {note.id} is encrypted; its content is unavailable")
|
|
248
230
|
raise typer.Exit(1)
|
|
249
231
|
|
|
250
232
|
text = _resolve_attachments(note) if resolve_attachments else note.text
|
|
233
|
+
if redact_secrets and text is not None:
|
|
234
|
+
note_secrets = redaction_map(scan_notes([note])).get(note.id, {})
|
|
235
|
+
text = redact_text(text, note_secrets)
|
|
251
236
|
|
|
252
237
|
if fmt is OutputFormat.json:
|
|
253
238
|
data = _note_to_dict(note, with_text=True)
|
|
@@ -303,7 +288,8 @@ def _report_secrets(findings: list[SecretFinding]) -> None:
|
|
|
303
288
|
notes = len({f.note_id for f in findings})
|
|
304
289
|
console.print(
|
|
305
290
|
f"[red]Export blocked:[/red] {len(findings)} potential secret(s) in {notes} note(s). "
|
|
306
|
-
"Move them somewhere safe (or into an encrypted note),
|
|
291
|
+
"Move them somewhere safe (or into an encrypted note), re-run with --redact-secrets "
|
|
292
|
+
"to export with placeholders, or with --allow-secrets to export as-is."
|
|
307
293
|
)
|
|
308
294
|
|
|
309
295
|
|
|
@@ -383,8 +369,6 @@ def export(
|
|
|
383
369
|
secrets_count = sum(len(v) for v in redactions.values())
|
|
384
370
|
parts.append(f"{secrets_count} secret(s) redacted in {len(redactions)} note(s)")
|
|
385
371
|
console.print(f"Exported to {dest}: " + ", ".join(parts))
|
|
386
|
-
if result.index_skipped:
|
|
387
|
-
console.print("[yellow]Warning:[/yellow] README.md exists but was not generated by bearcli; left untouched")
|
|
388
372
|
|
|
389
373
|
|
|
390
374
|
def _text_or_stdin(text: str | None) -> str | None:
|
|
@@ -394,7 +378,13 @@ def _text_or_stdin(text: str | None) -> str | None:
|
|
|
394
378
|
|
|
395
379
|
|
|
396
380
|
def _require_note(db: BearDB, note_id: str) -> Note:
|
|
397
|
-
|
|
381
|
+
try:
|
|
382
|
+
note = db.get_note(note_id)
|
|
383
|
+
except AmbiguousNoteId as exc:
|
|
384
|
+
console.print(f"[red]Error:[/red] note id prefix {exc.prefix!r} matches several notes:")
|
|
385
|
+
for full_id, title in exc.matches:
|
|
386
|
+
console.print(f" {full_id} {title}")
|
|
387
|
+
raise typer.Exit(1) from None
|
|
398
388
|
if note is None:
|
|
399
389
|
console.print(f"[red]Error:[/red] no note with id {note_id!r}")
|
|
400
390
|
raise typer.Exit(1)
|
|
@@ -52,6 +52,33 @@ class Note:
|
|
|
52
52
|
attachments: list[Attachment] = field(default_factory=list)
|
|
53
53
|
|
|
54
54
|
|
|
55
|
+
def note_metadata(note: Note) -> dict:
|
|
56
|
+
"""The canonical serializable metadata for a note (everything but content)."""
|
|
57
|
+
return {
|
|
58
|
+
"id": note.id,
|
|
59
|
+
"title": note.title,
|
|
60
|
+
"tags": note.tags,
|
|
61
|
+
"created": note.created.isoformat() if note.created else None,
|
|
62
|
+
"modified": note.modified.isoformat() if note.modified else None,
|
|
63
|
+
"pinned": note.pinned,
|
|
64
|
+
"encrypted": note.encrypted,
|
|
65
|
+
"archived": note.archived,
|
|
66
|
+
"trashed": note.trashed,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class AmbiguousNoteId(Exception):
|
|
71
|
+
"""A note id prefix matched more than one note."""
|
|
72
|
+
|
|
73
|
+
def __init__(self, prefix: str, matches: list[tuple[str, str]]):
|
|
74
|
+
super().__init__(f"note id prefix {prefix!r} is ambiguous")
|
|
75
|
+
self.prefix = prefix
|
|
76
|
+
self.matches = matches # (id, title) pairs
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
MIN_ID_PREFIX = 4
|
|
80
|
+
|
|
81
|
+
|
|
55
82
|
class BearDB:
|
|
56
83
|
def __init__(self, path: Path = DEFAULT_DB_PATH):
|
|
57
84
|
if not path.exists():
|
|
@@ -215,25 +242,13 @@ class BearDB:
|
|
|
215
242
|
query += " LIMIT ?"
|
|
216
243
|
params.append(limit)
|
|
217
244
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
id=row["ZUNIQUEIDENTIFIER"],
|
|
223
|
-
title=row["ZTITLE"] or "(untitled)",
|
|
224
|
-
created=core_data_to_datetime(row["ZCREATIONDATE"]),
|
|
225
|
-
modified=core_data_to_datetime(row["ZMODIFICATIONDATE"]),
|
|
226
|
-
pinned=bool(row["ZPINNED"]),
|
|
227
|
-
encrypted=bool(row["ZENCRYPTED"]),
|
|
228
|
-
archived=bool(row["ZARCHIVED"]),
|
|
229
|
-
trashed=bool(row["ZTRASHED"]),
|
|
230
|
-
tags=self._tags_for_note(row["Z_PK"]),
|
|
231
|
-
text=row["ZTEXT"] if with_text else None,
|
|
232
|
-
)
|
|
233
|
-
)
|
|
234
|
-
return notes
|
|
245
|
+
return [
|
|
246
|
+
self._note_from_row(row, text=row["ZTEXT"] if with_text else None)
|
|
247
|
+
for row in self.conn.execute(query, params)
|
|
248
|
+
]
|
|
235
249
|
|
|
236
250
|
def get_note(self, note_id: str) -> Note | None:
|
|
251
|
+
"""Fetch a note by id; a unique prefix (>= 4 chars) works like a full id."""
|
|
237
252
|
row = self.conn.execute(
|
|
238
253
|
"""
|
|
239
254
|
SELECT Z_PK, ZUNIQUEIDENTIFIER, ZTITLE, ZTEXT, ZCREATIONDATE,
|
|
@@ -243,8 +258,29 @@ class BearDB:
|
|
|
243
258
|
""",
|
|
244
259
|
(note_id,),
|
|
245
260
|
).fetchone()
|
|
261
|
+
if row is None and len(note_id) >= MIN_ID_PREFIX:
|
|
262
|
+
escaped = note_id.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
263
|
+
matches = self.conn.execute(
|
|
264
|
+
"""
|
|
265
|
+
SELECT Z_PK, ZUNIQUEIDENTIFIER, ZTITLE, ZTEXT, ZCREATIONDATE,
|
|
266
|
+
ZMODIFICATIONDATE, ZPINNED, ZENCRYPTED, ZARCHIVED, ZTRASHED
|
|
267
|
+
FROM ZSFNOTE
|
|
268
|
+
WHERE ZUNIQUEIDENTIFIER LIKE ? ESCAPE '\\' COLLATE NOCASE AND ZPERMANENTLYDELETED = 0
|
|
269
|
+
LIMIT 6
|
|
270
|
+
""",
|
|
271
|
+
(f"{escaped}%",),
|
|
272
|
+
).fetchall()
|
|
273
|
+
if len(matches) > 1:
|
|
274
|
+
raise AmbiguousNoteId(note_id, [(r["ZUNIQUEIDENTIFIER"], r["ZTITLE"] or "(untitled)") for r in matches])
|
|
275
|
+
if matches:
|
|
276
|
+
row = matches[0]
|
|
246
277
|
if row is None:
|
|
247
278
|
return None
|
|
279
|
+
return self._note_from_row(row, text=row["ZTEXT"], attachments=self._attachments_for_note(row["Z_PK"]))
|
|
280
|
+
|
|
281
|
+
def _note_from_row(
|
|
282
|
+
self, row: sqlite3.Row, text: str | None = None, attachments: list[Attachment] | None = None
|
|
283
|
+
) -> Note:
|
|
248
284
|
return Note(
|
|
249
285
|
id=row["ZUNIQUEIDENTIFIER"],
|
|
250
286
|
title=row["ZTITLE"] or "(untitled)",
|
|
@@ -255,6 +291,6 @@ class BearDB:
|
|
|
255
291
|
archived=bool(row["ZARCHIVED"]),
|
|
256
292
|
trashed=bool(row["ZTRASHED"]),
|
|
257
293
|
tags=self._tags_for_note(row["Z_PK"]),
|
|
258
|
-
text=
|
|
259
|
-
attachments=
|
|
294
|
+
text=text,
|
|
295
|
+
attachments=attachments or [],
|
|
260
296
|
)
|
|
@@ -9,9 +9,10 @@ from collections import Counter
|
|
|
9
9
|
from collections.abc import Callable
|
|
10
10
|
from dataclasses import dataclass
|
|
11
11
|
from pathlib import Path
|
|
12
|
-
from urllib.parse import quote
|
|
13
12
|
|
|
14
|
-
from bearcli.db import BearDB, Note
|
|
13
|
+
from bearcli.db import BearDB, Note, note_metadata
|
|
14
|
+
from bearcli.markdown import rewrite_attachment_refs
|
|
15
|
+
from bearcli.secrets import redact_text
|
|
15
16
|
|
|
16
17
|
NOTE_FILENAME = "README.md"
|
|
17
18
|
ATTACHMENTS_DIRNAME = "attachments"
|
|
@@ -24,7 +25,6 @@ class ExportResult:
|
|
|
24
25
|
removed: int = 0
|
|
25
26
|
skipped_encrypted: int = 0
|
|
26
27
|
index_updated: bool = False
|
|
27
|
-
index_skipped: bool = False
|
|
28
28
|
|
|
29
29
|
|
|
30
30
|
def slugify(title: str, max_length: int = 60) -> str:
|
|
@@ -61,13 +61,6 @@ def _frontmatter(note: Note, redacted: bool = False) -> str:
|
|
|
61
61
|
return "\n".join(lines)
|
|
62
62
|
|
|
63
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
|
-
|
|
71
64
|
def _parse_frontmatter(path: Path) -> dict[str, str]:
|
|
72
65
|
fields: dict[str, str] = {}
|
|
73
66
|
try:
|
|
@@ -99,13 +92,20 @@ def _entry_flags(entry: dict) -> str:
|
|
|
99
92
|
return flags
|
|
100
93
|
|
|
101
94
|
|
|
95
|
+
_INDEX_TITLE_LENGTH = 48
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _ellipsize(value: str, length: int) -> str:
|
|
99
|
+
return value if len(value) <= length else value[: length - 1].rstrip() + "…"
|
|
100
|
+
|
|
101
|
+
|
|
102
102
|
def _index_rows(entries: list[dict]) -> list[str]:
|
|
103
|
-
rows = ["|
|
|
103
|
+
rows = ["| ID | Modified | | Note |", "|---|---|---|---|"]
|
|
104
104
|
for e in entries:
|
|
105
|
-
title = e["title"].replace("|", "\\|")
|
|
105
|
+
title = _ellipsize(e["title"].replace("|", "\\|"), _INDEX_TITLE_LENGTH)
|
|
106
106
|
link = f"[{title}]({e['path']})" if e["path"] else title
|
|
107
|
-
|
|
108
|
-
rows.append(f"| {
|
|
107
|
+
# The short id resolves anywhere a note id is accepted (git-style prefix).
|
|
108
|
+
rows.append(f"| `{e['id'][:8].lower()}` | {(e['modified'] or '')[:10]} | {_entry_flags(e)} | {link} |")
|
|
109
109
|
return rows
|
|
110
110
|
|
|
111
111
|
|
|
@@ -121,16 +121,20 @@ def _index_markdown(entries: list[dict]) -> str:
|
|
|
121
121
|
f"{len(entries)} notes · 📎 attachments · 🔒 encrypted · 🗄 archived",
|
|
122
122
|
"",
|
|
123
123
|
]
|
|
124
|
-
|
|
124
|
+
archived = [e for e in entries if e["archived"]]
|
|
125
|
+
active = [e for e in entries if not e["archived"]]
|
|
126
|
+
pinned = [e for e in active if e["pinned"]]
|
|
125
127
|
if pinned:
|
|
126
128
|
lines += ["## 📌 Pinned", ""] + _index_rows(pinned) + [""]
|
|
127
|
-
rest = [e for e in
|
|
129
|
+
rest = [e for e in active if not e["pinned"]]
|
|
128
130
|
by_year: dict[str, list[dict]] = {}
|
|
129
131
|
for e in rest:
|
|
130
132
|
year = e["modified"][:4] if e["modified"] else "Undated"
|
|
131
133
|
by_year.setdefault(year, []).append(e)
|
|
132
134
|
for year in sorted(by_year, reverse=True):
|
|
133
135
|
lines += [f"## {year}", ""] + _index_rows(by_year[year]) + [""]
|
|
136
|
+
if archived:
|
|
137
|
+
lines += ["## 🗄 Archived", ""] + _index_rows(archived) + [""]
|
|
134
138
|
return "\n".join(lines)
|
|
135
139
|
|
|
136
140
|
|
|
@@ -145,12 +149,7 @@ def _write_if_changed(path: Path, content: str) -> bool:
|
|
|
145
149
|
|
|
146
150
|
|
|
147
151
|
def _rewrite_refs(note: Note) -> str:
|
|
148
|
-
|
|
149
|
-
for att in note.attachments:
|
|
150
|
-
target = quote(f"{ATTACHMENTS_DIRNAME}/{att.filename}")
|
|
151
|
-
for ref in {att.filename, quote(att.filename)}:
|
|
152
|
-
text = text.replace(f"]({ref})", f"]({target})")
|
|
153
|
-
return text
|
|
152
|
+
return rewrite_attachment_refs(note, lambda att: f"{ATTACHMENTS_DIRNAME}/{att.filename}")
|
|
154
153
|
|
|
155
154
|
|
|
156
155
|
def export_notes(
|
|
@@ -181,20 +180,7 @@ def export_notes(
|
|
|
181
180
|
entries: list[dict] = []
|
|
182
181
|
|
|
183
182
|
def add_entry(note: Note, path: str | None, attachments: bool) -> None:
|
|
184
|
-
entries.append(
|
|
185
|
-
{
|
|
186
|
-
"id": note.id,
|
|
187
|
-
"title": note.title,
|
|
188
|
-
"path": path,
|
|
189
|
-
"tags": note.tags,
|
|
190
|
-
"created": note.created.isoformat() if note.created else None,
|
|
191
|
-
"modified": note.modified.isoformat() if note.modified else None,
|
|
192
|
-
"pinned": note.pinned,
|
|
193
|
-
"encrypted": note.encrypted,
|
|
194
|
-
"archived": note.archived,
|
|
195
|
-
"attachments": attachments,
|
|
196
|
-
}
|
|
197
|
-
)
|
|
183
|
+
entries.append({**note_metadata(note), "path": path, "attachments": attachments})
|
|
198
184
|
|
|
199
185
|
for position, summary in enumerate(summaries, start=1):
|
|
200
186
|
report(f"[{position}/{len(summaries)}] {summary.title}")
|
|
@@ -228,7 +214,7 @@ def export_notes(
|
|
|
228
214
|
|
|
229
215
|
text = _rewrite_refs(note)
|
|
230
216
|
if note_secrets:
|
|
231
|
-
text =
|
|
217
|
+
text = redact_text(text, note_secrets)
|
|
232
218
|
note_dir.mkdir(exist_ok=True)
|
|
233
219
|
note_path.write_text(f"{_frontmatter(note, redacted=bool(note_secrets))}\n{text}\n")
|
|
234
220
|
|
|
@@ -256,10 +242,7 @@ def export_notes(
|
|
|
256
242
|
|
|
257
243
|
report("Writing index…")
|
|
258
244
|
index_path = dest / "README.md"
|
|
259
|
-
|
|
260
|
-
result.index_skipped = True
|
|
261
|
-
else:
|
|
262
|
-
result.index_updated = _write_if_changed(index_path, _index_markdown(entries))
|
|
245
|
+
result.index_updated = _write_if_changed(index_path, _index_markdown(entries))
|
|
263
246
|
result.index_updated |= _write_if_changed(
|
|
264
247
|
dest / "index.json",
|
|
265
248
|
json.dumps(sorted(entries, key=lambda e: e["title"].lower()), indent=2, ensure_ascii=False) + "\n",
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Helpers for Bear's markdown conventions, shared by get and export."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
|
|
8
|
+
from bearcli.db import Attachment, Note
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def rewrite_attachment_refs(note: Note, target_for: Callable[[Attachment], str]) -> str:
|
|
12
|
+
"""Rewrite bare attachment filename links to per-attachment targets.
|
|
13
|
+
|
|
14
|
+
Bear references attachments by bare filename and percent-encodes it in the
|
|
15
|
+
note text (spaces as %20), so both the raw and encoded forms must be
|
|
16
|
+
matched; targets are emitted percent-encoded to keep the links valid.
|
|
17
|
+
Only filenames known from the attachment records are rewritten — regular
|
|
18
|
+
URLs in the text are never touched.
|
|
19
|
+
"""
|
|
20
|
+
text = note.text or ""
|
|
21
|
+
for att in note.attachments:
|
|
22
|
+
target = quote(target_for(att))
|
|
23
|
+
for ref in {att.filename, quote(att.filename)}:
|
|
24
|
+
text = text.replace(f"]({ref})", f"]({target})")
|
|
25
|
+
return text
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Secret detection over note text, powered by detect-secrets (Yelp) plus
|
|
2
|
+
note-specific detectors.
|
|
3
|
+
|
|
4
|
+
A first line of defense before notes leave the machine via export. Everything
|
|
5
|
+
runs offline — note content is never sent anywhere. detect-secrets provides
|
|
6
|
+
the format detectors (AWS, GitHub, Slack, Stripe, private keys, JWTs, keyword
|
|
7
|
+
assignments, …); two gaps matter for prose notes and are covered here:
|
|
8
|
+
|
|
9
|
+
- detect-secrets' entropy plugins only match *quoted* strings, which pasted
|
|
10
|
+
keys in notes never are — so unquoted token runs get their own entropy scan.
|
|
11
|
+
- Credentials in notes often sit on the line *below* a label ("Client
|
|
12
|
+
secret:" then the value, often inside a code fence) — a look-ahead rule
|
|
13
|
+
catches those.
|
|
14
|
+
|
|
15
|
+
Secrets written as prose are still undetectable; for deeper auditing run
|
|
16
|
+
gitleaks over an --allow-secrets export.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import math
|
|
22
|
+
import re
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
|
|
25
|
+
from detect_secrets.core.plugins.util import get_mapping_from_secret_type_to_class
|
|
26
|
+
from detect_secrets.filters import heuristic
|
|
27
|
+
from detect_secrets.plugins.base import BasePlugin
|
|
28
|
+
|
|
29
|
+
from bearcli.db import Note
|
|
30
|
+
|
|
31
|
+
# IPPublicDetector: an IP address in a note is not a credential.
|
|
32
|
+
# The entropy plugins only match quoted strings — replaced by _scan_entropy.
|
|
33
|
+
_EXCLUDED_PLUGINS = {"IPPublicDetector", "Base64HighEntropyString", "HexHighEntropyString"}
|
|
34
|
+
|
|
35
|
+
_BASE64_ENTROPY_LIMIT = 4.5 # detect-secrets' default
|
|
36
|
+
_HEX_ENTROPY_LIMIT = 3.5 # above default (3.0): notes are full of UUID/hash fragments
|
|
37
|
+
|
|
38
|
+
# A contiguous credential-looking run: base64/urlsafe chars plus the '.' that
|
|
39
|
+
# joins JWT segments. 20+ chars keeps ordinary words and short ids out.
|
|
40
|
+
_TOKEN_RUN = re.compile(r"[A-Za-z0-9+/\-_=.]{20,}")
|
|
41
|
+
_HEX_ONLY = re.compile(r"^[0-9a-fA-F]+$")
|
|
42
|
+
|
|
43
|
+
# "Client secret:" / "Access token" style label with nothing after it — the
|
|
44
|
+
# value is expected on the next content line (code fences skipped).
|
|
45
|
+
_LABEL_ONLY = re.compile(
|
|
46
|
+
r"(?i)^\s*\**(?:client\s+secret|secret\s+access\s+key|access\s+token|auth\s+token|refresh\s+token"
|
|
47
|
+
r"|api\s*-?\s*key|access\s+key|private\s+key|secret|password|passwd|pwd|token)\s*\**\s*[:=]?\s*$"
|
|
48
|
+
)
|
|
49
|
+
_FENCE = re.compile(r"^\s*(?:```|~~~)")
|
|
50
|
+
_TRIM_PUNCT = "()[]{}<>«»\"'`,;|"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _build_plugins() -> list[BasePlugin]:
|
|
54
|
+
return [cls() for cls in get_mapping_from_secret_type_to_class().values() if cls.__name__ not in _EXCLUDED_PLUGINS]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _entropy(value: str) -> float:
|
|
58
|
+
counts = {char: value.count(char) for char in set(value)}
|
|
59
|
+
return -sum(n / len(value) * math.log2(n / len(value)) for n in counts.values())
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _is_false_positive(value: str, line: str) -> bool:
|
|
63
|
+
return (
|
|
64
|
+
heuristic.is_potential_uuid(value)
|
|
65
|
+
or heuristic.is_sequential_string(value)
|
|
66
|
+
or heuristic.is_templated_secret(value)
|
|
67
|
+
or heuristic.is_likely_id_string(value, line)
|
|
68
|
+
or heuristic.is_not_alphanumeric_string(value)
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _expand_to_token(line: str, value: str) -> str:
|
|
73
|
+
"""Grow a detected value to the full unbroken token around it.
|
|
74
|
+
|
|
75
|
+
Format detectors can match only part of a longer credential (a JWT's
|
|
76
|
+
first two segments, say); redacting the partial match would leave the
|
|
77
|
+
rest behind.
|
|
78
|
+
"""
|
|
79
|
+
start = line.find(value)
|
|
80
|
+
if start < 0:
|
|
81
|
+
return value
|
|
82
|
+
end = start + len(value)
|
|
83
|
+
while start > 0 and not line[start - 1].isspace():
|
|
84
|
+
start -= 1
|
|
85
|
+
while end < len(line) and not line[end].isspace():
|
|
86
|
+
end += 1
|
|
87
|
+
return line[start:end].strip(_TRIM_PUNCT)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _in_url(line: str, start: int) -> bool:
|
|
91
|
+
"""Whether the token starting at `start` is part of a URL.
|
|
92
|
+
|
|
93
|
+
Long ids inside links (Google Docs, Notion, …) are high-entropy but are
|
|
94
|
+
shareable references, not credentials — flagging them would drown real
|
|
95
|
+
findings. Token-bearing URLs with known formats (Slack webhooks, …) are
|
|
96
|
+
still caught by their format detectors.
|
|
97
|
+
"""
|
|
98
|
+
token_start = start
|
|
99
|
+
while token_start > 0 and not line[token_start - 1].isspace():
|
|
100
|
+
token_start -= 1
|
|
101
|
+
return "://" in line[token_start : start + 3]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _scan_entropy(line: str) -> list[str]:
|
|
105
|
+
hits = []
|
|
106
|
+
for match in _TOKEN_RUN.finditer(line):
|
|
107
|
+
if _in_url(line, match.start()):
|
|
108
|
+
continue
|
|
109
|
+
value = match.group().strip(_TRIM_PUNCT + ".=")
|
|
110
|
+
limit = _HEX_ENTROPY_LIMIT if _HEX_ONLY.fullmatch(value) else _BASE64_ENTROPY_LIMIT
|
|
111
|
+
if len(value) >= 20 and _entropy(value) >= limit:
|
|
112
|
+
hits.append(match.group())
|
|
113
|
+
return hits
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _labeled_value(lines: list[str], label_index: int) -> tuple[int, str] | None:
|
|
117
|
+
"""The first content line after a bare credential label, if it looks like a value."""
|
|
118
|
+
for offset in range(1, 4):
|
|
119
|
+
index = label_index + offset
|
|
120
|
+
if index >= len(lines):
|
|
121
|
+
return None
|
|
122
|
+
candidate = lines[index].strip()
|
|
123
|
+
if not candidate or _FENCE.match(candidate):
|
|
124
|
+
continue
|
|
125
|
+
candidate = candidate.strip(_TRIM_PUNCT)
|
|
126
|
+
if " " not in candidate and 8 <= len(candidate) <= 200:
|
|
127
|
+
return index, candidate
|
|
128
|
+
return None
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass
|
|
133
|
+
class SecretFinding:
|
|
134
|
+
note_id: str
|
|
135
|
+
note_title: str
|
|
136
|
+
rule: str
|
|
137
|
+
line: int
|
|
138
|
+
excerpt: str
|
|
139
|
+
secret: str # raw value, for redaction only — never print this
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _redact(value: str) -> str:
|
|
143
|
+
"""Show enough to locate the secret in the note, never the secret itself."""
|
|
144
|
+
return f"{value[:8]}…" if len(value) > 12 else "…"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _scan_note(note: Note, plugins: list[BasePlugin]) -> list[SecretFinding]:
|
|
148
|
+
lines = list((note.text or "").splitlines())
|
|
149
|
+
seen: set[str] = set()
|
|
150
|
+
findings: list[SecretFinding] = []
|
|
151
|
+
|
|
152
|
+
def add(lineno: int, value: str, rule: str) -> None:
|
|
153
|
+
if value and value not in seen and not _is_false_positive(value, lines[lineno - 1]):
|
|
154
|
+
seen.add(value)
|
|
155
|
+
findings.append(
|
|
156
|
+
SecretFinding(
|
|
157
|
+
note_id=note.id,
|
|
158
|
+
note_title=note.title,
|
|
159
|
+
rule=rule,
|
|
160
|
+
line=lineno,
|
|
161
|
+
excerpt=_redact(value),
|
|
162
|
+
secret=value,
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
for lineno, line in enumerate(lines, start=1):
|
|
167
|
+
for plugin in plugins:
|
|
168
|
+
# The yaml filetype hint makes KeywordDetector accept unquoted
|
|
169
|
+
# `password: value` assignments, which is how notes write them.
|
|
170
|
+
for secret in plugin.analyze_line(filename="note.yaml", line=line, line_number=lineno):
|
|
171
|
+
add(lineno, _expand_to_token(line, secret.secret_value or ""), secret.type)
|
|
172
|
+
for value in _scan_entropy(line):
|
|
173
|
+
add(lineno, value, "High Entropy String")
|
|
174
|
+
if _LABEL_ONLY.match(line):
|
|
175
|
+
if labeled := _labeled_value(lines, lineno - 1):
|
|
176
|
+
value_index, value = labeled
|
|
177
|
+
add(value_index + 1, value, "Labeled Credential")
|
|
178
|
+
return findings
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def scan_notes(notes: list[Note]) -> list[SecretFinding]:
|
|
182
|
+
plugins = _build_plugins()
|
|
183
|
+
findings = []
|
|
184
|
+
for note in notes:
|
|
185
|
+
findings.extend(_scan_note(note, plugins))
|
|
186
|
+
return findings
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def redaction_map(findings: list[SecretFinding]) -> dict[str, dict[str, str]]:
|
|
190
|
+
"""Per note id, the secret values to replace and the rule that found them."""
|
|
191
|
+
by_note: dict[str, dict[str, str]] = {}
|
|
192
|
+
for finding in findings:
|
|
193
|
+
if finding.secret:
|
|
194
|
+
by_note.setdefault(finding.note_id, {}).setdefault(finding.secret, finding.rule)
|
|
195
|
+
return by_note
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def redact_text(text: str, secrets: dict[str, str]) -> str:
|
|
199
|
+
"""Replace each secret value with a placeholder naming the rule that found it."""
|
|
200
|
+
# Longest first, in case one detected value contains another.
|
|
201
|
+
for value in sorted(secrets, key=len, reverse=True):
|
|
202
|
+
text = text.replace(value, f"[redacted: {secrets[value]}]")
|
|
203
|
+
return text
|
|
@@ -1,105 +0,0 @@
|
|
|
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
|