knodin 0.10.6 → 0.10.7

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.
@@ -0,0 +1,90 @@
1
+ /**
2
+ * One credential-pattern set, shared by every redactor.
3
+ *
4
+ * knodin previously carried two independent lists — one in `diagnostics.ts` for
5
+ * recorded failures, one in `output-compression.ts` for captured command output
6
+ * — and they drifted apart in OPPOSITE directions. Diagnostics knew Slack and
7
+ * OpenAI but not AWS or JWTs; output compression knew AWS and JWTs but missed
8
+ * Slack, OpenAI and GitHub's fine-grained tokens. Neither list was a superset of
9
+ * the other, so a credential redacted on one path was disclosed on the other,
10
+ * and patching either one alone would only let them drift again (KNODIN-11).
11
+ *
12
+ * Callers keep their own replacement text: diagnostics collapses a match to a
13
+ * single marker, output compression names the provider. `preservedGroups` is
14
+ * what makes that possible — the leading captures a caller may echo back
15
+ * verbatim, so `password=` survives while its value does not.
16
+ *
17
+ * Fresh `RegExp` objects are built per call. A shared global regex carries
18
+ * `lastIndex` between uses, which turns a redactor into something that depends
19
+ * on what it scrubbed previously.
20
+ */
21
+ export function credentialPatterns() {
22
+ return [
23
+ {
24
+ // `name=value` in any of its spellings. An optional auth scheme is
25
+ // consumed BEFORE the value, because `authorization: Bearer <token>`
26
+ // otherwise matches only the word "Bearer" — redacting the label and
27
+ // preserving the credential, exactly inverted (KNODIN-10).
28
+ label: "credential",
29
+ pattern: /\b(authorization|password|passwd|secret|token|api[_-]?key)(\s*[:=]\s*)(?:(?:bearer|basic|digest|token)\s+)?[^\s,;]+/gi,
30
+ preservedGroups: 2,
31
+ },
32
+ {
33
+ // GitHub issues every format with an UNDERSCORE. A hyphen-only pattern
34
+ // matched a shape GitHub never mints and missed every shape it does, so
35
+ // both separators are accepted. `github_pat` is listed explicitly: it
36
+ // does not fit the gh[pousr] shape.
37
+ label: "github-token",
38
+ pattern: /\b(?:gh[pousr]|github_pat)[-_]\w{20,}\b/g,
39
+ preservedGroups: 0,
40
+ },
41
+ {
42
+ label: "slack-token",
43
+ pattern: /\bxox[baprse]-[A-Za-z0-9-]{10,}\b/g,
44
+ preservedGroups: 0,
45
+ },
46
+ {
47
+ label: "openai-key",
48
+ pattern: /\bsk-[A-Za-z0-9_-]{20,}\b/g,
49
+ preservedGroups: 0,
50
+ },
51
+ {
52
+ label: "aws-access-key",
53
+ pattern: /\bAKIA[A-Z0-9]{16}\b/g,
54
+ preservedGroups: 0,
55
+ },
56
+ {
57
+ label: "jwt",
58
+ pattern: /\beyJ[\w-]{8,}\.[\w-]{8,}\.[\w-]{8,}\b/g,
59
+ preservedGroups: 0,
60
+ },
61
+ {
62
+ // Bearer outside an `authorization:` assignment — a bare header line, or
63
+ // a curl command echoed into a log.
64
+ label: "bearer-token",
65
+ pattern: /\b(Bearer\s+)[-\w.~+/]{12,}=*/gi,
66
+ preservedGroups: 1,
67
+ },
68
+ ];
69
+ }
70
+ /**
71
+ * Apply every shared pattern, letting the caller render the replacement.
72
+ *
73
+ * `marker` receives the provider label; preserved groups are prepended to
74
+ * whatever it returns.
75
+ */
76
+ export function redactCredentials(text, marker) {
77
+ let result = text;
78
+ let count = 0;
79
+ for (const { label, pattern, preservedGroups } of credentialPatterns()) {
80
+ result = result.replace(pattern, (...values) => {
81
+ count++;
82
+ const kept = values
83
+ .slice(1, 1 + preservedGroups)
84
+ .map((value) => (typeof value === "string" ? value : ""))
85
+ .join("");
86
+ return `${kept}${marker(label)}`;
87
+ });
88
+ }
89
+ return { text: result, count };
90
+ }
@@ -6,6 +6,7 @@ import path from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import zlib from "node:zlib";
8
8
  import { compareBytes } from "./compare.js";
9
+ import { redactCredentials } from "./credential-patterns.js";
9
10
  const CONFIG_PATH = ".knodin/diagnostics/config.json";
10
11
  const JOURNAL_PATH = ".knodin/diagnostics/events.jsonl";
11
12
  const JOURNAL_LOCK_PATH = ".knodin/diagnostics/events.lock";
@@ -528,19 +529,17 @@ function scrubText(raw, repo) {
528
529
  };
529
530
  for (const exact of [repo, os.homedir()].filter(Boolean).sort((a, b) => b.length - a.length))
530
531
  replace(new RegExp(exact.replace(/[.*+?^${}()|[\]\\]/g, String.raw `\$&`), "g"), "<path>");
531
- // Either separator. GitHub issues its tokens with an UNDERSCORE after the
532
- // prefix, so a hyphen-only pattern matched a shape GitHub never mints and
533
- // missed every shape it does a real token in an error message survived
534
- // scrubbing and reached the bundle a user shares (KNODIN-10). Slack and
535
- // OpenAI really do use a hyphen, so both separators must be accepted.
536
- replace(/\b(?:ghp|github_pat|sk|xox[baprs])[-_][-A-Za-z0-9_]{10,}\b/g, "<secret>");
537
- // The value pattern stops at whitespace, so `authorization: Bearer <token>`
538
- // used to match only the word "Bearer" and leave the credential in place —
539
- // the standard header form leaked the one thing worth redacting (KNODIN-10).
540
- // An optional scheme is consumed first so the credential after it is the
541
- // part that gets replaced. Only known schemes are skipped, so an ordinary
542
- // `password=x next word` still redacts one value rather than eating prose.
543
- replace(/\b(?:password|passwd|token|secret|api[_-]?key|authorization)\s*[=:]\s*(?:(?:bearer|basic|digest|token)\s+)?[^\s,;]+/gi, "<secret>");
532
+ // Provider credentials, from the set shared with output-compression (see
533
+ // credential-patterns.ts). These used to be maintained here separately, and
534
+ // the two lists drifted: this one knew Slack and OpenAI but not AWS keys or
535
+ // JWTs, while output compression knew those and missed these. A credential
536
+ // safe on one path was disclosed on the other (KNODIN-11).
537
+ //
538
+ // `redactCredentials` counts its own replacements, so they are added to the
539
+ // running total rather than counted by the local `replace` helper.
540
+ const credentials = redactCredentials(value, () => "<secret>");
541
+ value = credentials.text;
542
+ redactions += credentials.count;
544
543
  replace(/\b[A-Z]:\\(?:[^\s<>:"|?*]+\\)*[^\s<>:"|?*]*/g, "<path>");
545
544
  replace(/(?:^|[\s('"`])\/(?:[^\s)'"`]+\/)*[^\s)'"`]*/g, "<path>");
546
545
  replace(/\b(?:[A-Za-z0-9_.-]+\/)+(?:[A-Za-z0-9_.-]+\.[A-Za-z0-9]{1,12})\b/g, "<path>");
@@ -8779,26 +8779,28 @@ export async function getOrInitDb(repoPath, options = {}) {
8779
8779
  else {
8780
8780
  const stateDir = resolveStateDir(normalizedPath);
8781
8781
  await fs.promises.mkdir(stateDir, { recursive: true });
8782
- // Ensure .knodin is in .gitignore. Skipped for mirrors: their state
8783
- // lives outside the clone, and the clone must stay byte-identical to
8784
- // the remote so a refetch has nothing of ours to discard.
8782
+ // Ensure .knodin is ignored, through `.git/info/exclude` rather than
8783
+ // `.gitignore`.
8784
+ //
8785
+ // `.gitignore` is a SHARED file: it is tracked by convention, so
8786
+ // writing it put a change into someone's working tree that they did
8787
+ // not ask for and would carry into their next commit. That is the
8788
+ // one thing `knodin init --scope personal` promises not to do, and
8789
+ // this ran regardless of scope — a bare `knodin index` did it too
8790
+ // (KNODIN-1).
8791
+ //
8792
+ // `.git/info/exclude` has the same effect and is repo-local: git
8793
+ // never commits it, so nothing reaches a diff or a teammate.
8794
+ //
8795
+ // Skipped for mirrors: their state lives outside the clone, and the
8796
+ // clone must stay byte-identical to the remote so a refetch has
8797
+ // nothing of ours to discard.
8785
8798
  if (mayWriteToRepository(normalizedPath)) {
8786
8799
  try {
8787
- const gitignorePath = path.join(normalizedPath, ".gitignore");
8788
- let gitignoreContent = "";
8789
- if (fs.existsSync(gitignorePath)) {
8790
- gitignoreContent = await fs.promises.readFile(gitignorePath, "utf-8");
8791
- }
8792
- const lines = gitignoreContent.split("\n").map((l) => l.trim());
8793
- if (!lines.includes(".knodin") &&
8794
- !lines.includes(".knodin/") &&
8795
- !lines.includes("/.knodin")) {
8796
- const prefix = gitignoreContent.length > 0 && !gitignoreContent.endsWith("\n") ? "\n" : "";
8797
- await fs.promises.appendFile(gitignorePath, `${prefix}\n# knodin\n.knodin\n`);
8798
- }
8800
+ await excludeStateDirectoryLocally(normalizedPath);
8799
8801
  }
8800
8802
  catch (e) {
8801
- console.error("Failed to update .gitignore", e);
8803
+ console.error("Failed to exclude .knodin from git", e);
8802
8804
  }
8803
8805
  }
8804
8806
  dbPath = path.join(stateDir, "db.sqlite");
@@ -10092,6 +10094,45 @@ function parseUnifiedDiff(stdout) {
10092
10094
  }
10093
10095
  return modifiedFiles;
10094
10096
  }
10097
+ /**
10098
+ * Ignore `.knodin/` through `.git/info/exclude`, which git never commits.
10099
+ *
10100
+ * Resolved with `git rev-parse --git-path` rather than assembled by hand:
10101
+ * inside a linked worktree `.git` is a FILE pointing elsewhere, so
10102
+ * `<repo>/.git/info/exclude` is not a path that exists. Idempotent — a second
10103
+ * call over an already-excluded repository writes nothing.
10104
+ */
10105
+ async function excludeStateDirectoryLocally(repoPath) {
10106
+ // A directory that is not a git repository has nothing to exclude, and git
10107
+ // exits non-zero there. That is an ordinary state — knodin indexes plain
10108
+ // directories — so it must not surface as an error. The previous
10109
+ // `.gitignore` write never had to ask git anything, which is why this case
10110
+ // only appears now.
10111
+ let raw;
10112
+ try {
10113
+ raw = runGit(repoPath, ["rev-parse", "--git-path", "info/exclude"]).trim();
10114
+ }
10115
+ catch {
10116
+ return;
10117
+ }
10118
+ if (!raw)
10119
+ return;
10120
+ const excludePath = path.isAbsolute(raw) ? raw : path.resolve(repoPath, raw);
10121
+ let existing = "";
10122
+ try {
10123
+ existing = await fs.promises.readFile(excludePath, "utf-8");
10124
+ }
10125
+ catch (error) {
10126
+ if (error.code !== "ENOENT")
10127
+ throw error;
10128
+ }
10129
+ const entries = new Set(existing.split(/\r?\n/).map((line) => line.trim()));
10130
+ if (entries.has(".knodin") || entries.has(".knodin/") || entries.has("/.knodin"))
10131
+ return;
10132
+ const prefix = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
10133
+ await fs.promises.mkdir(path.dirname(excludePath), { recursive: true });
10134
+ await fs.promises.appendFile(excludePath, `${prefix}.knodin/\n`, "utf-8");
10135
+ }
10095
10136
  function runGit(repoPath, args) {
10096
10137
  return child_process.execFileSync("git", args, {
10097
10138
  cwd: repoPath,
@@ -1,6 +1,7 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
+ import { redactCredentials } from "./credential-patterns.js";
4
5
  import { resolveStateDir } from "./engine/state-paths.js";
5
6
  const DEFAULT_LINE_BUDGET = 200;
6
7
  const DEFAULT_BYTE_BUDGET = 16_384;
@@ -90,20 +91,20 @@ function splitEvent(event) {
90
91
  lines.push({ raw: "", sourceBytes: 0 });
91
92
  return lines;
92
93
  }
94
+ /**
95
+ * Redact credentials from captured output.
96
+ *
97
+ * The pattern set is shared with `diagnostics.ts` (see credential-patterns.ts).
98
+ * It used to be maintained here separately, and the two lists drifted: this one
99
+ * knew AWS keys and JWTs but missed Slack, OpenAI and GitHub's fine-grained
100
+ * tokens, while diagnostics missed AWS and JWTs. A credential safe on one path
101
+ * was disclosed on the other (KNODIN-11).
102
+ *
103
+ * The provider-named markers are kept, because a reader of compressed output
104
+ * benefits from knowing WHAT was removed.
105
+ */
93
106
  function redact(text) {
94
- let count = 0;
95
- const replace = (pattern, replacement) => {
96
- text = text.replace(pattern, (...values) => {
97
- count++;
98
- return typeof replacement === "string" ? replacement : replacement(...values);
99
- });
100
- };
101
- replace(/\b(authorization|password|passwd|secret|token|api[_-]?key)\s*([:=])\s*([^\s,;]+)/gi, (_match, name, separator) => `${name}${separator}[REDACTED:credential]`);
102
- replace(/\bAKIA[A-Z0-9]{16}\b/g, "[REDACTED:aws-access-key]");
103
- replace(/\bgh[pousr]_\w{20,}\b/g, "[REDACTED:github-token]");
104
- replace(/\bBearer\s+[-\w.~+/]{12,}=*\b/gi, "Bearer [REDACTED:bearer-token]");
105
- replace(/\beyJ[\w-]{8,}\.[\w-]{8,}\.[\w-]{8,}\b/g, "[REDACTED:jwt]");
106
- return { text, count };
107
+ return redactCredentials(text, (label) => `[REDACTED:${label}]`);
107
108
  }
108
109
  function csiSequenceEnd(text, escapeIndex) {
109
110
  for (let index = escapeIndex + 2; index < text.length; index++) {
@@ -0,0 +1,79 @@
1
+ # knodin 0.10.7
2
+
3
+ Two fixes, both about knodin doing something to your repository or your
4
+ credentials that you did not ask for.
5
+
6
+ ## knodin no longer writes to your .gitignore
7
+
8
+ Indexing appended `.knodin` to `.gitignore`. That file is **shared**: tracked by
9
+ convention, so the edit landed in your working tree and would ride into your next
10
+ commit — and on a narrowly-scoped branch, `git add -A` swept it in.
11
+
12
+ It ran regardless of scope, so a bare `knodin index` did it too. That matters
13
+ because `knodin init --scope personal` exists precisely to promise that nothing
14
+ reaches git.
15
+
16
+ The same protection now goes to `.git/info/exclude`, which git never commits.
17
+ The rule is resolved through `git rev-parse --git-path` rather than assembled by
18
+ hand, because inside a linked worktree `.git` is a file and
19
+ `<repo>/.git/info/exclude` is not a path that exists. A directory that is not a
20
+ git repository has nothing to exclude and is left alone silently — knodin indexes
21
+ plain directories too.
22
+
23
+ ### What this changed about freshness reporting
24
+
25
+ Worth knowing if you have ever read the `staleness` field.
26
+
27
+ Because every repository knodin touched carried that untracked `.gitignore`, its
28
+ working tree was permanently dirty, and a dirty tree forces the reconcile path.
29
+ So a branch switch reported `reconciled`. With a genuinely clean tree the same
30
+ switch reports `fresh`.
31
+
32
+ Nothing about the answer changed — measured directly, the query returns the
33
+ symbols of the branch you are on, with no trace of the one you left. `fresh` is
34
+ accurate: the graph does match the tree. Only the label is different, and it is
35
+ different because the tree is finally clean.
36
+
37
+ Three internal assertions had been reading that side effect rather than the
38
+ behaviour they named, and now measure the returned symbols instead.
39
+
40
+ ## One credential-pattern set, not two
41
+
42
+ knodin had **two independent secret redactors** — one for diagnostics bundles,
43
+ one for compressed command output — each with its own list. They had drifted in
44
+ opposite directions:
45
+
46
+ | | diagnostics | output compression |
47
+ |---|---|---|
48
+ | GitHub classic | yes | yes |
49
+ | GitHub fine-grained | yes | **no** |
50
+ | Slack | yes | **no** |
51
+ | OpenAI | yes | **no** |
52
+ | AWS access key | **no** | yes |
53
+ | JWT | **no** | yes |
54
+
55
+ Neither list was a superset of the other, so the same log line was scrubbed on
56
+ one path and disclosed on the other. Redaction is on by default, so nothing
57
+ unusual had to be configured to hit this.
58
+
59
+ Both now use one shared set covering every row. Each caller keeps its own
60
+ replacement text: compressed output still names the provider, because a reader
61
+ benefits from knowing what was removed, while diagnostics collapses to a single
62
+ marker. Both preserve the key name and drop only the value, so `password=`
63
+ survives and its secret does not.
64
+
65
+ Tests assert every provider on **both** paths — a property no single-module test
66
+ could hold. What redaction must leave alone is pinned too: a 40-character hex
67
+ git SHA passes through untouched, because redacting commit SHAs would make diffs
68
+ and review output unreadable.
69
+
70
+ ## Coverage
71
+
72
+ Measured across all sixteen shards: 78.22% branches, 87.77% statements, 91.20%
73
+ functions, 90.33% lines.
74
+
75
+ The 77.16% recorded in 0.10.6 came from fifteen shards, the sixteenth having
76
+ failed to write its report. That number was described at the time as a floor. It
77
+ was not — a missing shard shrinks the denominator faster than the numerator, so
78
+ it flattered the result. A partial-shard measurement is unusable rather than
79
+ conservative.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "knodin",
3
- "version": "0.10.6",
3
+ "version": "0.10.7",
4
4
  "knodin": {
5
5
  "compatibility": "compatible"
6
6
  },
@@ -61,6 +61,7 @@
61
61
  "docs/releases/0.10.4.md",
62
62
  "docs/releases/0.10.5.md",
63
63
  "docs/releases/0.10.6.md",
64
+ "docs/releases/0.10.7.md",
64
65
  "docs/releases/0.3.0.md",
65
66
  "docs/releases/0.4.0.md",
66
67
  "docs/releases/0.4.1.md",