logshield-cli 0.4.1 → 0.4.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,36 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.4.3
4
+
5
+ ### Fixed
6
+
7
+ - Prevented API key redaction from corrupting header names (`x-api-key`)
8
+ - Preserved key labels when redacting `api_key=...` values
9
+ - Corrected CLI exit code for invalid flag combinations (`--json --dry-run` now exits with code 2)
10
+
11
+ ### Improved
12
+
13
+ - Deterministic and aligned `--summary` output (alphabetical, indented)
14
+ - Hardened CLI behavior with end-to-end golden tests
15
+ - Strengthened regression coverage for rule overlap and precedence
16
+
17
+ ### Notes
18
+
19
+ - No breaking changes
20
+ - No new features
21
+ - Hardening and correctness release
22
+
23
+ ## v0.4.2
24
+
25
+ ### Fixed
26
+
27
+ - CLI errors are now written to stderr (CI-safe piping)
28
+ - JSON output is newline-terminated
29
+ - URL redaction is no longer overly aggressive; only credentials and sensitive parameters are redacted
30
+ - PASSWORD redaction preserves original delimiter and spacing
31
+ - Improved dry-run reporting consistency
32
+ - Added contract tests for CLI output and URL behavior
33
+
3
34
  ## v0.4.1
4
35
 
5
36
  ### Fixed
package/README.md CHANGED
@@ -1,41 +1,90 @@
1
- ---
2
1
  # LogShield
3
2
 
4
3
  [![npm version](https://img.shields.io/npm/v/logshield-cli)](https://www.npmjs.com/package/logshield-cli)
5
4
  [![npm downloads](https://img.shields.io/npm/dm/logshield-cli)](https://www.npmjs.com/package/logshield-cli)
6
5
  [![CI](https://github.com/afria85/LogShield/actions/workflows/ci.yml/badge.svg)](https://github.com/afria85/LogShield/actions/workflows/ci.yml)
7
6
 
8
- Deterministic log sanitization for developers.
7
+ Your logs already contain secrets. You just don't see them.
8
+
9
+ LogShield is a small CLI that automatically redacts secrets from logs **before**
10
+ you paste them into CI, GitHub issues, Slack, or send them to third-party support.
11
+
12
+ No configuration. No cloud. Deterministic output.
13
+
14
+ ---
9
15
 
10
16
  ## Quick start (30 seconds)
11
17
 
12
18
  ```bash
13
- # Install (CLI command: logshield)
14
- npm install -g logshield-cli
19
+ # Sanitize logs before sharing them
20
+ cat app.log | logshield scan
21
+ ```
22
+
23
+ **Example input**
24
+
25
+ ```txt
26
+ POSTGRES_URL=postgres://user:supersecret@db.internal
27
+ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
28
+ ```
29
+
30
+ These are typical raw logs -- with secrets -- before you share them.
31
+
32
+ **Output**
33
+
34
+ ```txt
35
+ POSTGRES_URL=postgres://user:<REDACTED_PASSWORD>@db.internal
36
+ Authorization: Bearer <REDACTED_TOKEN>
37
+ ```
38
+
39
+ After LogShield, the same logs are safe to share.
40
+
41
+ ---
42
+
43
+ ## When should I use LogShield?
15
44
 
45
+ Use LogShield whenever logs leave your system:
46
+
47
+ - Before pasting logs into CI
48
+ - Before attaching logs to GitHub issues
49
+ - Before sending logs to third-party support
50
+ - Before sharing logs in Slack or email
51
+
52
+ ---
53
+
54
+ ## Preview before enforcing (dry-run)
55
+
56
+ ```bash
16
57
  # Preview what would be redacted (does not modify output)
17
- echo "email=test@example.com token=sk_live_123" | logshield scan --dry-run
58
+ echo "email=test@example.com Authorization: Bearer abcdefghijklmnop" | logshield scan --dry-run
18
59
  ```
19
60
 
20
61
  ```
21
62
  logshield (dry-run)
22
63
  Detected 2 redactions:
23
- EMAIL x1
24
- STRIPE_SECRET_KEY x1
64
+ AUTH_BEARER x1
65
+ EMAIL x1
25
66
 
26
67
  No output was modified.
27
68
  Use without --dry-run to apply.
28
69
  ```
29
70
 
71
+ Notes:
72
+
73
+ - The report is printed to stdout
74
+ - No log content is echoed
75
+ - Output is deterministic and CI-safe
76
+
30
77
  ```bash
31
78
  # Enforce redaction (sanitized output)
32
- echo "email=test@example.com token=sk_live_123" | logshield scan
79
+ echo "email=test@example.com Authorization: Bearer abcdefghijklmnop" | logshield scan
33
80
  ```
34
81
 
35
82
  - Prefer `--dry-run` first in CI to verify you are not over-redacting.
36
83
  - Then switch to enforced mode once you are satisfied with the preview.
37
84
 
38
- LogShield is a CLI tool that scans logs and redacts **real secrets** (API keys, tokens, credentials) before logs are shared with others, AI tools, CI systems, or public channels.
85
+ LogShield is a CLI tool that scans logs and redacts **real secrets**
86
+ (API keys, tokens, credentials) before logs are shared with others,
87
+ AI tools, CI systems, or public channels.
39
88
 
40
89
  It is designed to be **predictable, conservative, and safe for production pipelines**.
41
90
 
@@ -97,8 +146,8 @@ Examples:
97
146
 
98
147
  ```
99
148
  <REDACTED_PASSWORD>
100
- <REDACTED_API_KEY_HEADER>
101
- <REDACTED_AUTH_BEARER>
149
+ <REDACTED_API_KEY>
150
+ <REDACTED_TOKEN>
102
151
  <REDACTED_EMAIL>
103
152
  ```
104
153
 
@@ -148,28 +197,28 @@ If a file is not provided and input is piped, LogShield automatically reads from
148
197
 
149
198
  ## CLI Flags
150
199
 
151
- - `--strict`
200
+ - `--strict`
152
201
  Aggressive, security-first redaction
153
202
 
154
- - `--stdin`
203
+ - `--stdin`
155
204
  Explicitly force reading from STDIN
156
205
 
157
- - `--dry-run`
206
+ - `--dry-run`
158
207
  Detect sensitive data without modifying output
159
208
 
160
- - `--fail-on-detect`
209
+ - `--fail-on-detect`
161
210
  Exit with code `1` if any redaction is detected (CI-friendly)
162
211
 
163
- - `--summary`
212
+ - `--summary`
164
213
  Print a compact redaction summary
165
214
 
166
- - `--json`
215
+ - `--json`
167
216
  JSON output (cannot be combined with `--dry-run`)
168
217
 
169
- - `--version`
218
+ - `--version`
170
219
  Print CLI version
171
220
 
172
- - `--help`
221
+ - `--help`
173
222
  Show help
174
223
 
175
224
  ---
@@ -204,10 +253,10 @@ cat app.log | logshield scan --dry-run
204
253
 
205
254
  ```
206
255
  logshield (dry-run)
207
- Detected 3 redactions:
208
- OAUTH_ACCESS_TOKEN x1
209
- AUTH_BEARER x2
256
+ Detected 4 redactions:
257
+ AUTH_BEARER x1
210
258
  EMAIL x1
259
+ OAUTH_ACCESS_TOKEN x1
211
260
  PASSWORD x1
212
261
 
213
262
  No output was modified.
@@ -302,10 +351,16 @@ Example:
302
351
 
303
352
  ```
304
353
  LogShield Summary
305
- PASSWORD: 2
306
- API_KEY_HEADER: 1
354
+ API_KEY_HEADER: 1
355
+ PASSWORD: 2
307
356
  ```
308
357
 
358
+ Notes:
359
+
360
+ - Sanitized log output is written to stdout
361
+ - The summary is written to stderr
362
+ - Rules are sorted alphabetically
363
+
309
364
  ---
310
365
 
311
366
  ## JSON output
@@ -319,7 +374,8 @@ logshield scan --json < logs.txt
319
374
  Notes:
320
375
 
321
376
  - `--json` **cannot** be combined with `--dry-run`
322
- - Output schema is stable within v0.3.x
377
+ - Usage errors exit with code `2`
378
+ - Output is always newline-terminated
323
379
 
324
380
  ---
325
381
 
@@ -370,7 +426,7 @@ Depending on rules and mode:
370
426
  LogShield guarantees:
371
427
 
372
428
  - Deterministic output
373
- - Stable behavior within **v0.3.x**
429
+ - Stable behavior within **v0.4.x**
374
430
  - No runtime dependencies
375
431
  - Snapshot-tested and contract-tested
376
432
  - No telemetry
@@ -401,5 +457,3 @@ It is a **last-line safety net**, not a primary defense.
401
457
  ## License
402
458
 
403
459
  Apache-2.0
404
-
405
- ---
@@ -70,7 +70,8 @@ __export(writeOutput_exports, {
70
70
  });
71
71
  function writeOutput(result, opts) {
72
72
  if (opts.json) {
73
- process.stdout.write(JSON.stringify(result));
73
+ process.stdout.write(`${JSON.stringify(result)}
74
+ `);
74
75
  } else {
75
76
  process.stdout.write(result.output);
76
77
  }
@@ -86,33 +87,25 @@ __export(summary_exports, {
86
87
  printSummary: () => printSummary
87
88
  });
88
89
  function printSummary(matches) {
89
- if (!matches || matches.length === 0) {
90
- process.stderr.write("logshield: no redactions detected\n");
90
+ if (!matches.length) {
91
+ process.stderr.write("LogShield Summary\n(no redactions detected)\n");
91
92
  return;
92
93
  }
93
- const counter = {};
94
+ const counts = {};
94
95
  for (const m of matches) {
95
- counter[m.rule] = (counter[m.rule] || 0) + 1;
96
+ counts[m.rule] = (counts[m.rule] ?? 0) + 1;
96
97
  }
97
- const entries = Object.entries(counter).map(([rule, count]) => ({ rule, count })).sort((a, b) => {
98
- if (b.count !== a.count) return b.count - a.count;
99
- return a.rule.localeCompare(b.rule);
100
- });
101
- const maxLen = Math.max(...entries.map((e) => e.rule.length));
102
- const total = matches.length;
103
- const label = total === 1 ? "redaction" : "redactions";
104
- process.stderr.write(`logshield summary: ${total} ${label}
98
+ const rules = Object.keys(counts).sort((a, b) => a.localeCompare(b));
99
+ const maxNameLen = Math.max(...rules.map((r) => r.length));
100
+ process.stderr.write("LogShield Summary\n");
101
+ for (const rule of rules) {
102
+ const padded = rule.padEnd(maxNameLen, " ");
103
+ process.stderr.write(` ${padded} x${counts[rule]}
105
104
  `);
106
- for (const { rule, count } of entries) {
107
- process.stderr.write(
108
- ` ${rule.padEnd(maxLen)} x${count}
109
- `
110
- );
111
105
  }
112
106
  }
113
107
  var init_summary = __esm({
114
108
  "src/cli/summary.ts"() {
115
- "use strict";
116
109
  }
117
110
  });
118
111
 
@@ -184,8 +177,12 @@ var init_tokens = __esm({
184
177
  },
185
178
  {
186
179
  name: "EMAIL",
187
- pattern: /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi,
188
- replace: () => "<REDACTED_EMAIL>"
180
+ // Avoid corrupting URLs with embedded credentials like:
181
+ // https://user:pass@host
182
+ // In those cases, `pass@host` can look like an email.
183
+ // We therefore require a safe delimiter (whitespace/quotes/brackets/`=` or `: `) before the email.
184
+ pattern: /(^|[\s"'\(\[\{<>,;]|=|:\s)([A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,})/gim,
185
+ replace: (_match, _ctx, groups) => `${groups[0]}<REDACTED_EMAIL>`
189
186
  }
190
187
  ];
191
188
  }
@@ -199,8 +196,12 @@ var init_credentials = __esm({
199
196
  // password=... or password: ...
200
197
  {
201
198
  name: "PASSWORD",
202
- pattern: /\bpassword\s*[:=]\s*([^\s]+)/gi,
203
- replace: () => "password=<REDACTED_PASSWORD>"
199
+ // Preserve delimiter and spacing so logs remain readable and diff-friendly.
200
+ // Examples:
201
+ // password=secret -> password=<REDACTED_PASSWORD>
202
+ // Password : 123 -> Password : <REDACTED_PASSWORD>
203
+ pattern: /\b(password)(\s*[:=]\s*)([^\s]+)/gi,
204
+ replace: (_match, _ctx, groups) => `${groups[0]}${groups[1]}<REDACTED_PASSWORD>`
204
205
  },
205
206
  // DB URL credential: postgres://user:pass@host
206
207
  {
@@ -208,6 +209,15 @@ var init_credentials = __esm({
208
209
  pattern: /\b(postgres|mysql|mongodb):\/\/([^:\s]+):([^@\s]+)@/gi,
209
210
  replace: (_match, _ctx, groups) => `${groups[0]}://${groups[1]}:<REDACTED_PASSWORD>@`
210
211
  },
212
+ // x-api-key: ....
213
+ // IMPORTANT: this must run BEFORE the generic API_KEY rule. Otherwise the
214
+ // generic API_KEY rule can match the "api-key: <value>" substring first and
215
+ // corrupt the header name (e.g. "x-api-key" -> "x-").
216
+ {
217
+ name: "API_KEY_HEADER",
218
+ pattern: /\bx-api-key\s*:\s*["']?[A-Za-z0-9_\-]{16,}["']?\b/gi,
219
+ replace: () => "x-api-key: <REDACTED_API_KEY>"
220
+ },
211
221
  /**
212
222
  * API key (common variants):
213
223
  * - apiKey=...
@@ -215,23 +225,19 @@ var init_credentials = __esm({
215
225
  * - api-key: ...
216
226
  * - apikey=...
217
227
  * Supports '=' or ':' and optional quotes/spaces.
228
+ *
229
+ * NOTE:
230
+ * Do NOT try to handle "Authorization: Bearer ..." here; that causes overlap
231
+ * with token rules. Token redaction is handled in tokens.ts.
218
232
  */
219
233
  {
220
234
  name: "API_KEY",
221
- pattern: /\bapi(?:[_-]?key)\s*[:=]\s*["']?([A-Za-z0-9_\-]{16,})["']?\b/gi,
222
- replace: () => "<REDACTED_API_KEY>"
223
- },
224
- // x-api-key: ....
225
- {
226
- name: "API_KEY_HEADER",
227
- pattern: /\bx-api-key\s*:\s*["']?[A-Za-z0-9_\-]{16,}["']?\b/gi,
228
- replace: () => "x-api-key: <REDACTED_API_KEY>"
229
- },
230
- // authorization: Bearer ...
231
- {
232
- name: "AUTHORIZATION_BEARER",
233
- pattern: /\bauthorization\s*:\s*bearer\s+([A-Za-z0-9._\-]{16,})\b/gi,
234
- replace: () => "authorization: Bearer <REDACTED_TOKEN>"
235
+ // Preserve the key label + delimiter, redact only the value.
236
+ // Examples:
237
+ // api_key=abcdef... -> api_key=<REDACTED_API_KEY>
238
+ // api-key: "abcdef..." -> api-key: "<REDACTED_API_KEY>"
239
+ pattern: /\b(api(?:[_-]?key)\s*[:=]\s*["']?)([A-Za-z0-9_\-]{16,})(["']?)\b/gi,
240
+ replace: (_match, _ctx, groups) => `${groups[0]}<REDACTED_API_KEY>${groups[2]}`
235
241
  }
236
242
  ];
237
243
  }
@@ -316,14 +322,100 @@ var init_creditCard = __esm({
316
322
  });
317
323
 
318
324
  // src/rules/urls.ts
319
- var urlRules;
325
+ function redactQueryLike(segment) {
326
+ if (segment.length < 2) return segment;
327
+ const prefix = segment[0];
328
+ const raw = segment.slice(1);
329
+ if (!raw.includes("=")) return segment;
330
+ const parts = raw.split("&");
331
+ const redacted = parts.map((p) => {
332
+ const eq = p.indexOf("=");
333
+ if (eq === -1) return p;
334
+ const key = p.slice(0, eq);
335
+ const value = p.slice(eq + 1);
336
+ const normalized = key.trim().toLowerCase();
337
+ if (!SENSITIVE_PARAM_KEYS.has(normalized)) return p;
338
+ if (value.length === 0) return `${key}=`;
339
+ return `${key}=<REDACTED_URL_PARAM>`;
340
+ });
341
+ return `${prefix}${redacted.join("&")}`;
342
+ }
343
+ function redactUrl(match) {
344
+ const schemeIdx = match.indexOf("://");
345
+ if (schemeIdx === -1) return match;
346
+ const scheme = match.slice(0, schemeIdx + 3);
347
+ const rest = match.slice(schemeIdx + 3);
348
+ const authorityEnd = (() => {
349
+ const slash = rest.indexOf("/");
350
+ const q = rest.indexOf("?");
351
+ const h = rest.indexOf("#");
352
+ const candidates = [slash, q, h].filter((i) => i !== -1);
353
+ return candidates.length === 0 ? rest.length : Math.min(...candidates);
354
+ })();
355
+ let authority = rest.slice(0, authorityEnd);
356
+ let tail = rest.slice(authorityEnd);
357
+ const at = authority.lastIndexOf("@");
358
+ if (at !== -1) {
359
+ const userinfo = authority.slice(0, at);
360
+ const host = authority.slice(at + 1);
361
+ const colon = userinfo.indexOf(":");
362
+ if (colon !== -1) {
363
+ const user = userinfo.slice(0, colon);
364
+ authority = `${user}:<REDACTED_PASSWORD>@${host}`;
365
+ } else {
366
+ authority = `<REDACTED_PASSWORD>@${host}`;
367
+ }
368
+ }
369
+ const hashIdx = tail.indexOf("#");
370
+ const queryIdx = tail.indexOf("?");
371
+ if (queryIdx !== -1 && (hashIdx === -1 || queryIdx < hashIdx)) {
372
+ const before = tail.slice(0, queryIdx);
373
+ const after = tail.slice(queryIdx);
374
+ const hashInside = after.indexOf("#");
375
+ if (hashInside === -1) {
376
+ tail = `${before}${redactQueryLike(after)}`;
377
+ } else {
378
+ const qPart = after.slice(0, hashInside);
379
+ const hPart = after.slice(hashInside);
380
+ tail = `${before}${redactQueryLike(qPart)}${redactQueryLike(hPart)}`;
381
+ }
382
+ } else if (hashIdx !== -1) {
383
+ const before = tail.slice(0, hashIdx);
384
+ const hPart = tail.slice(hashIdx);
385
+ tail = `${before}${redactQueryLike(hPart)}`;
386
+ }
387
+ return `${scheme}${authority}${tail}`;
388
+ }
389
+ var SENSITIVE_PARAM_KEYS, urlRules;
320
390
  var init_urls = __esm({
321
391
  "src/rules/urls.ts"() {
392
+ SENSITIVE_PARAM_KEYS = new Set(
393
+ [
394
+ "access_token",
395
+ "token",
396
+ "id_token",
397
+ "refresh_token",
398
+ "auth",
399
+ "authorization",
400
+ "api_key",
401
+ "apikey",
402
+ "api-key",
403
+ "key",
404
+ "secret",
405
+ "password",
406
+ "passwd",
407
+ "signature",
408
+ "sig",
409
+ "session"
410
+ ].map((k) => k.toLowerCase())
411
+ );
322
412
  urlRules = [
323
413
  {
324
414
  name: "URL",
325
- pattern: /\bhttps?:\/\/[^\s/$.?#].[^\s]*\b/gi,
326
- replace: () => "<REDACTED_URL>"
415
+ // Match HTTP(S) URLs, stopping at whitespace.
416
+ // (Conservative: avoids attempting to be a full RFC URL parser.)
417
+ pattern: /\bhttps?:\/\/[^\s]+/gi,
418
+ replace: (match) => redactUrl(match)
327
419
  }
328
420
  ];
329
421
  }
@@ -417,7 +509,7 @@ var { printSummary: printSummary2 } = (init_summary(), __toCommonJS(summary_expo
417
509
  var { sanitizeLog: sanitizeLog2 } = (init_sanitizeLog(), __toCommonJS(sanitizeLog_exports));
418
510
  var rawArgs = process.argv.slice(2).map((arg) => arg === "-h" ? "--help" : arg);
419
511
  function getVersion() {
420
- return true ? "0.4.1" : "unknown";
512
+ return true ? "0.4.3" : "unknown";
421
513
  }
422
514
  function printHelp() {
423
515
  process.stdout.write(`Usage: logshield scan [file]
@@ -438,6 +530,9 @@ Options:
438
530
  --help Show help
439
531
  `);
440
532
  }
533
+ function writeErr(message) {
534
+ process.stderr.write(message);
535
+ }
441
536
  function parseArgs(args) {
442
537
  const flags = /* @__PURE__ */ new Set();
443
538
  const positionals = [];
@@ -482,6 +577,10 @@ function renderDryRunReport(matches) {
482
577
  process.stdout.write("No output was modified.\n");
483
578
  process.stdout.write("Use without --dry-run to apply.\n");
484
579
  }
580
+ function exitUsageError(message) {
581
+ writeErr(message.endsWith("\n") ? message : message + "\n");
582
+ process.exit(2);
583
+ }
485
584
  async function main() {
486
585
  if (rawArgs.length === 0 || rawArgs.includes("--help")) {
487
586
  printHelp();
@@ -495,8 +594,7 @@ async function main() {
495
594
  const { flags, positionals } = parseArgs(rawArgs);
496
595
  const command = positionals[0];
497
596
  if (command !== "scan") {
498
- process.stdout.write("Unknown command\n");
499
- process.exit(1);
597
+ exitUsageError("Unknown command");
500
598
  }
501
599
  const file = positionals[1];
502
600
  const strict = flags.has("--strict");
@@ -508,16 +606,13 @@ async function main() {
508
606
  const stdinAuto = isStdinPiped();
509
607
  const useStdin = stdinFlag || stdinAuto;
510
608
  if (useStdin && file) {
511
- process.stdout.write("Cannot read from both STDIN and file\n");
512
- process.exit(1);
609
+ exitUsageError("Cannot read from both STDIN and file");
513
610
  }
514
611
  if (dryRun && json) {
515
- process.stdout.write("--dry-run cannot be used with --json\n");
516
- process.exit(1);
612
+ exitUsageError("--dry-run cannot be used with --json");
517
613
  }
518
614
  if (json && summary) {
519
- process.stdout.write("--summary cannot be used with --json\n");
520
- process.exit(1);
615
+ exitUsageError("--summary cannot be used with --json");
521
616
  }
522
617
  try {
523
618
  const input = await readInput2(useStdin ? void 0 : file);
@@ -538,8 +633,7 @@ async function main() {
538
633
  }
539
634
  process.exit(0);
540
635
  } catch (err) {
541
- process.stdout.write(err?.message || "Unexpected error");
542
- process.stdout.write("\n");
636
+ writeErr((err?.message || "Unexpected error") + "\n");
543
637
  process.exit(2);
544
638
  }
545
639
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "logshield-cli",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "license": "Apache-2.0",
5
5
  "type": "commonjs",
6
6
  "bin": {
@@ -20,7 +20,8 @@
20
20
  "typecheck": "tsc -p tsconfig.core.json && tsc -p tsconfig.cli.json --noEmit",
21
21
  "pretest": "npm run build",
22
22
  "test": "vitest",
23
- "prepublishOnly": "npm run build"
23
+ "prepublish:check": "npm run typecheck && npm test",
24
+ "prepublishOnly": "npm run prepublish:check"
24
25
  },
25
26
  "devDependencies": {
26
27
  "@types/node": "^25.0.3",