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.
- package/dist/src/credential-patterns.js +90 -0
- package/dist/src/diagnostics.js +12 -13
- package/dist/src/engine/index.js +57 -16
- package/dist/src/output-compression.js +14 -13
- package/docs/releases/0.10.7.md +79 -0
- package/package.json +2 -1
|
@@ -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
|
+
}
|
package/dist/src/diagnostics.js
CHANGED
|
@@ -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
|
-
//
|
|
532
|
-
//
|
|
533
|
-
//
|
|
534
|
-
//
|
|
535
|
-
//
|
|
536
|
-
|
|
537
|
-
//
|
|
538
|
-
//
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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>");
|
package/dist/src/engine/index.js
CHANGED
|
@@ -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
|
|
8783
|
-
//
|
|
8784
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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",
|