confdiff 0.14.0

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/redact.js ADDED
@@ -0,0 +1,172 @@
1
+ import { matchAnyGlob } from "./diff.js";
2
+ /**
3
+ * Secret-safe diffs. When enabled, values at "sensitive" paths (passwords,
4
+ * tokens, API keys, …) are replaced in the output with a stable, non-reversible
5
+ * fingerprint — `«redacted:ab12cd»` — instead of the raw value. This lets you
6
+ * paste a config diff into a PR, a Slack thread or a CI log to prove *what
7
+ * drifted* without ever leaking the credential itself. Because the fingerprint
8
+ * is derived from the value, a reader can still see that old and new differ (the
9
+ * two fingerprints won't match), but can't recover either one.
10
+ *
11
+ * No other config-diff tool does this; it's the reason `confdiff --redact` is
12
+ * safe to run in shared/automated contexts on real secrets-bearing files
13
+ * (`.env`, `application.properties`, Helm values, k8s Secrets, …).
14
+ */
15
+ /**
16
+ * Built-in heuristics for "this key holds a secret". Matched against the LAST
17
+ * path segment (the key name), case-insensitively, after stripping separators.
18
+ * Deliberately conservative: whole-token indicators so `key` matches but
19
+ * `keyboard`/`monkey` don't, and `pass`-family words are anchored.
20
+ */
21
+ const SECRET_TOKENS = [
22
+ "password",
23
+ "passwd",
24
+ "passphrase",
25
+ "pwd",
26
+ "secret",
27
+ "token",
28
+ "apikey",
29
+ "accesskey",
30
+ "secretkey",
31
+ "privatekey",
32
+ "signingkey",
33
+ "encryptionkey",
34
+ "credential",
35
+ "credentials",
36
+ "clientsecret",
37
+ "authtoken",
38
+ "accesstoken",
39
+ "refreshtoken",
40
+ "bearer",
41
+ "dsn",
42
+ ];
43
+ /** A separator-insensitive view of a key: `DB_PASSWORD` / `db-password` / `dbPassword` -> tokens. */
44
+ function keyTokens(seg) {
45
+ return seg
46
+ // split camelCase / PascalCase into words
47
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
48
+ .toLowerCase()
49
+ .split(/[^a-z0-9]+/)
50
+ .filter(Boolean);
51
+ }
52
+ /** Does a key name look like it holds a secret, by built-in heuristics? */
53
+ export function looksSecret(seg) {
54
+ const words = keyTokens(seg);
55
+ if (words.length === 0)
56
+ return false;
57
+ const joined = words.join("");
58
+ // Multi-word combined forms: apikey, accesstoken, clientsecret, privatekey…
59
+ if (SECRET_TOKENS.includes(joined))
60
+ return true;
61
+ for (const w of words) {
62
+ if (SECRET_TOKENS.includes(w))
63
+ return true;
64
+ // standalone "key" is a secret indicator only next to auth-ish words
65
+ if (w === "key" && words.some((x) => ["api", "access", "secret", "private", "signing", "encryption"].includes(x)))
66
+ return true;
67
+ }
68
+ return false;
69
+ }
70
+ /**
71
+ * Shannon entropy (bits per character) of a string. A uniformly random
72
+ * high-entropy string (API key, JWT, base64 token) scores high (~4–6);
73
+ * repetitive or natural-language text scores low.
74
+ */
75
+ export function shannonEntropy(s) {
76
+ if (s.length === 0)
77
+ return 0;
78
+ const freq = new Map();
79
+ for (const ch of s)
80
+ freq.set(ch, (freq.get(ch) ?? 0) + 1);
81
+ let e = 0;
82
+ for (const n of freq.values()) {
83
+ const p = n / s.length;
84
+ e -= p * Math.log2(p);
85
+ }
86
+ return e;
87
+ }
88
+ /**
89
+ * Content-based secret heuristic: does a VALUE *look* like a random credential,
90
+ * regardless of its key name? Catches secrets stored under non-obvious keys
91
+ * (`x`, `data`, `value`) that the key-name heuristics miss.
92
+ *
93
+ * Deliberately conservative to avoid masking ordinary config: only long,
94
+ * whitespace-free, tokenish strings with high per-character entropy qualify.
95
+ * This *complements* the key-name heuristics — it does NOT replace them: a
96
+ * short weak password like `Letmein` under a `password:` key has low entropy
97
+ * and is only caught by the key-name check, while a 40-char API token under a
98
+ * bland key is only caught here. Enable both for the widest coverage.
99
+ */
100
+ export function looksHighEntropy(v) {
101
+ if (typeof v !== "string")
102
+ return false;
103
+ const s = v.trim();
104
+ // Secrets are long and contiguous; prose/paths/URLs with spaces are not.
105
+ if (s.length < 20 || /\s/.test(s))
106
+ return false;
107
+ // Restrict to the character set of tokens/keys/base64/hex (avoids flagging
108
+ // long prose-y identifiers, sentences joined by punctuation, etc.).
109
+ if (!/^[A-Za-z0-9+/=_.\-:]+$/.test(s))
110
+ return false;
111
+ // Require a mix of character classes so a long all-lowercase word or a run of
112
+ // digits (phone/id) isn't mistaken for a random secret.
113
+ const classes = Number(/[a-z]/.test(s)) + Number(/[A-Z]/.test(s)) + Number(/[0-9]/.test(s)) + Number(/[+/=_.\-:]/.test(s));
114
+ if (classes < 2)
115
+ return false;
116
+ return shannonEntropy(s) >= 3.5;
117
+ }
118
+ /**
119
+ * Build a predicate deciding whether a given path's VALUE should be redacted.
120
+ * @param builtins use the built-in secret-key heuristics
121
+ * @param globs extra key-name substrings / path globs (matched via matchAnyGlob)
122
+ * @param entropy also redact values that *look* like high-entropy secrets,
123
+ * regardless of key name (complements, doesn't replace, the above)
124
+ */
125
+ export function makeRedactMatcher(builtins, globs, entropy = false) {
126
+ return (path, value) => {
127
+ if (globs.length && matchAnyGlob(path, globs))
128
+ return true;
129
+ if (builtins && path.length > 0) {
130
+ const last = path[path.length - 1];
131
+ if (typeof last === "string" && looksSecret(last))
132
+ return true;
133
+ }
134
+ if (entropy && looksHighEntropy(value))
135
+ return true;
136
+ return false;
137
+ };
138
+ }
139
+ /**
140
+ * cyrb53 — a fast, well-distributed 53-bit string hash. Pure JS with no
141
+ * dependencies, so the CLI, the GitHub Action bundle and the browser playground
142
+ * all compute the SAME fingerprint (node:crypto isn't available in the browser).
143
+ * We surface only the low 24 bits (6 hex chars): enough to make a *changed*
144
+ * value obvious (the two tokens differ) while truncation keeps it
145
+ * non-reversible — you can't recover the value from the fingerprint.
146
+ */
147
+ function cyrb53(str, seed = 0x9e3779b9) {
148
+ let h1 = 0xdeadbeef ^ seed;
149
+ let h2 = 0x41c6ce57 ^ seed;
150
+ for (let i = 0; i < str.length; i++) {
151
+ const ch = str.charCodeAt(i);
152
+ h1 = Math.imul(h1 ^ ch, 2654435761);
153
+ h2 = Math.imul(h2 ^ ch, 1597334677);
154
+ }
155
+ h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
156
+ h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
157
+ return 4294967296 * (2097151 & h2) + (h1 >>> 0);
158
+ }
159
+ /**
160
+ * Stable, non-reversible fingerprint of a value. Equal values -> equal token,
161
+ * so an *unchanged* redacted value never shows up as a spurious diff, while a
162
+ * *changed* one shows two visibly different tokens.
163
+ */
164
+ export function redactToken(v) {
165
+ if (v === undefined)
166
+ return "«redacted»";
167
+ const s = typeof v === "string"
168
+ ? v
169
+ : JSON.stringify(v, (_k, val) => (typeof val === "bigint" ? val.toString() : val));
170
+ const h = (cyrb53(s ?? "null") & 0xffffff).toString(16).padStart(6, "0");
171
+ return `«redacted:${h}»`;
172
+ }
@@ -0,0 +1,13 @@
1
+ import { type Change } from "./diff.js";
2
+ import { type RedactMatcher } from "./redact.js";
3
+ export interface RenderOptions {
4
+ color?: boolean;
5
+ labelA?: string;
6
+ labelB?: string;
7
+ /** If set, values at matching paths are replaced with a non-reversible fingerprint. */
8
+ redact?: RedactMatcher;
9
+ }
10
+ export declare function renderText(changes: Change[], opts?: RenderOptions): string;
11
+ export declare function renderJson(changes: Change[], opts?: {
12
+ redact?: RedactMatcher;
13
+ }): string;
package/dist/render.js ADDED
@@ -0,0 +1,105 @@
1
+ import pc from "picocolors";
2
+ import { formatPath, segStr } from "./diff.js";
3
+ import { redactToken } from "./redact.js";
4
+ function preview(v) {
5
+ if (typeof v === "string")
6
+ return JSON.stringify(v);
7
+ if (v === null)
8
+ return "null";
9
+ if (v === undefined)
10
+ return "undefined";
11
+ if (typeof v === "object") {
12
+ const json = JSON.stringify(v, (_k, val) => (typeof val === "bigint" ? val.toString() : val));
13
+ if (json.length <= 60)
14
+ return json;
15
+ return json.slice(0, 57) + "...";
16
+ }
17
+ return String(v);
18
+ }
19
+ export function renderText(changes, opts = {}) {
20
+ const useColor = opts.color ?? true;
21
+ const c = useColor ? pc : passthrough();
22
+ const redact = opts.redact;
23
+ // Redact a change if its key looks secret OR either side's value does; mask
24
+ // both sides together so drift stays visible (differing fingerprints).
25
+ const isMasked = (ch) => !!redact && (redact(ch.path, ch.oldValue) || redact(ch.path, ch.newValue));
26
+ const show = (ch, v) => isMasked(ch) ? redactToken(v) : preview(v);
27
+ if (changes.length === 0) {
28
+ return c.dim("no semantic differences");
29
+ }
30
+ const lines = [];
31
+ let added = 0;
32
+ let removed = 0;
33
+ let changed = 0;
34
+ // Pad the path column so values line up in a clean second column.
35
+ const width = Math.min(40, changes.reduce((m, ch) => Math.max(m, formatPath(ch.path).length), 0));
36
+ const pad = (s) => (s.length >= width ? s : s + " ".repeat(width - s.length));
37
+ for (const ch of changes) {
38
+ const p = formatPath(ch.path);
39
+ if (ch.kind === "add") {
40
+ added++;
41
+ lines.push(`${c.green("+")} ${c.green(pad(p))} ${c.dim("=")} ${c.green(show(ch, ch.newValue))}`);
42
+ }
43
+ else if (ch.kind === "remove") {
44
+ removed++;
45
+ lines.push(`${c.red("-")} ${c.red(pad(p))} ${c.dim("=")} ${c.red(show(ch, ch.oldValue))}`);
46
+ }
47
+ else {
48
+ changed++;
49
+ const tag = ch.typeChanged ? c.dim(" (type)") : "";
50
+ lines.push(`${c.yellow("~")} ${c.yellow(pad(p))}${tag} ${c.red(show(ch, ch.oldValue))} ${c.dim("=>")} ${c.green(show(ch, ch.newValue))}`);
51
+ }
52
+ }
53
+ const parts = [];
54
+ if (added)
55
+ parts.push(c.green(`${added} added`));
56
+ if (removed)
57
+ parts.push(c.red(`${removed} removed`));
58
+ if (changed)
59
+ parts.push(c.yellow(`${changed} changed`));
60
+ lines.push("");
61
+ lines.push(c.bold(`${changes.length} change${changes.length === 1 ? "" : "s"}: `) + parts.join(", "));
62
+ return lines.join("\n");
63
+ }
64
+ /**
65
+ * Build an RFC 6901 JSON Pointer from a path. Keys are escaped so that a `/`
66
+ * inside a key (e.g. the k8s annotation `app.kubernetes.io/name`) becomes `~1`
67
+ * and a literal `~` becomes `~0` — otherwise the pointer would be ambiguous and
68
+ * break any downstream JSON Pointer consumer. An empty path is the whole
69
+ * document, whose pointer is the empty string.
70
+ */
71
+ function toJsonPointer(path) {
72
+ return path
73
+ .map((s) => "/" + segStr(s).replace(/~/g, "~0").replace(/\//g, "~1"))
74
+ .join("");
75
+ }
76
+ export function renderJson(changes, opts = {}) {
77
+ // Large integers are preserved as BigInt (lossless); emit them as decimal
78
+ // strings so the JSON stays valid and precise (a raw number would round).
79
+ const bigIntSafe = (_k, v) => (typeof v === "bigint" ? v.toString() : v);
80
+ const redact = opts.redact;
81
+ return JSON.stringify({
82
+ changed: changes.length > 0,
83
+ count: changes.length,
84
+ changes: changes.map((ch) => {
85
+ const masked = !!(redact && (redact(ch.path, ch.oldValue) || redact(ch.path, ch.newValue)));
86
+ const old = masked ? redactToken(ch.oldValue) : ch.oldValue;
87
+ const nw = masked ? redactToken(ch.newValue) : ch.newValue;
88
+ return {
89
+ path: ch.path,
90
+ pointer: toJsonPointer(ch.path),
91
+ kind: ch.kind,
92
+ ...(ch.oldValue !== undefined || ch.kind !== "add" ? { oldValue: old } : {}),
93
+ ...(ch.newValue !== undefined || ch.kind !== "remove" ? { newValue: nw } : {}),
94
+ ...(ch.typeChanged ? { typeChanged: true } : {}),
95
+ ...(masked ? { redacted: true } : {}),
96
+ };
97
+ }),
98
+ }, bigIntSafe, 2);
99
+ }
100
+ function passthrough() {
101
+ const id = (s) => s;
102
+ return new Proxy({}, {
103
+ get: () => id,
104
+ });
105
+ }
package/package.json ADDED
@@ -0,0 +1,72 @@
1
+ {
2
+ "name": "confdiff",
3
+ "version": "0.14.0",
4
+ "description": "Semantic, format-aware diff for config & structured data files (JSON, YAML, TOML, INI, .env, CSV, XML). See what actually changed — the meaning, not the text.",
5
+ "type": "module",
6
+ "bin": {
7
+ "confdiff": "dist/cli.js"
8
+ },
9
+ "main": "dist/index.js",
10
+ "types": "dist/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "import": "./dist/index.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "scripts": {
23
+ "build": "tsc -p tsconfig.json",
24
+ "test": "node --test --import tsx test/*.test.ts",
25
+ "prepare": "npm run build",
26
+ "prepublishOnly": "npm run build",
27
+ "build:action": "esbuild src/cli.ts --bundle --platform=node --target=node20 --format=cjs --outfile=action/confdiff.cjs",
28
+ "build:web": "esbuild web/entry.ts --bundle --format=iife --platform=browser --target=es2020 --alias:node:path=./web/shims/path.js --outfile=docs/playground.js --minify && cp web/index.html docs/index.html && cp web/app.js docs/app.js && cp web/og.png docs/og.png && cp web/robots.txt docs/robots.txt && cp web/sitemap.xml docs/sitemap.xml && cp web/secrets-in-config-diffs.html docs/secrets-in-config-diffs.html && cp web/diff-kubernetes-manifests.html docs/diff-kubernetes-manifests.html && cp web/diff-docker-compose.html docs/diff-docker-compose.html && cp web/cross-format-config-diff.html docs/cross-format-config-diff.html && cp web/confdiff-preview.png docs/confdiff-preview.png"
29
+ },
30
+ "keywords": [
31
+ "diff",
32
+ "config",
33
+ "yaml",
34
+ "json",
35
+ "toml",
36
+ "ini",
37
+ "dotenv",
38
+ "semantic-diff",
39
+ "structured-diff",
40
+ "cli",
41
+ "ci",
42
+ "config-drift",
43
+ "xml",
44
+ "csv"
45
+ ],
46
+ "author": "Esperanza Volkov",
47
+ "license": "MIT",
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "git+https://github.com/esperanza-volkov/confdiff.git"
51
+ },
52
+ "bugs": {
53
+ "url": "https://github.com/esperanza-volkov/confdiff/issues"
54
+ },
55
+ "homepage": "https://github.com/esperanza-volkov/confdiff#readme",
56
+ "engines": {
57
+ "node": ">=18"
58
+ },
59
+ "dependencies": {
60
+ "fast-xml-parser": "^5.10.1",
61
+ "ini": "^5.0.0",
62
+ "picocolors": "^1.1.1",
63
+ "smol-toml": "^1.3.1",
64
+ "yaml": "^2.6.1"
65
+ },
66
+ "devDependencies": {
67
+ "@types/node": "^22.10.0",
68
+ "tsx": "^4.19.2",
69
+ "typescript": "^5.7.2",
70
+ "esbuild": "^0.24.0"
71
+ }
72
+ }