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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bearcli
3
- Version: 1.1.0
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-7F0E-43BB-BEB8-67E3A389A448
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); findings block the export
123
- with a list of the affected notes (`--allow-secrets` overrides).
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-7F0E-43BB-BEB8-67E3A389A448
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); findings block the export
100
- with a list of the affected notes (`--allow-secrets` overrides).
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bearcli"
3
- version = "1.1.0"
3
+ version = "1.2.1"
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"]
@@ -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 bare attachment filenames in markdown links to absolute paths.
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.get_note(note_id)
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), or re-run with --allow-secrets."
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
- note = db.get_note(note_id)
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
- notes = []
219
- for row in self.conn.execute(query, params):
220
- notes.append(
221
- Note(
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=row["ZTEXT"],
259
- attachments=self._attachments_for_note(row["Z_PK"]),
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 = ["| Note | Tags | Modified | |", "|---|---|---|---|"]
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
- modified = (e["modified"] or "")[:10]
108
- rows.append(f"| {link} | {', '.join(e['tags'])} | {modified} | {_entry_flags(e)} |")
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
- pinned = [e for e in entries if e["pinned"]]
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 entries if not e["pinned"]]
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
- text = note.text or ""
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 = _apply_redactions(text, note_secrets)
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
- if index_path.exists() and _parse_frontmatter(index_path).get("generated-by") != "bearcli":
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