logshield-cli 0.4.0 → 0.4.2

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,34 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.4.2
4
+
5
+ ### Fixed
6
+
7
+ - CLI errors are now written to stderr (CI-safe piping)
8
+ - JSON output is newline-terminated
9
+ - URL redaction is no longer overly aggressive; only credentials and sensitive parameters are redacted
10
+ - PASSWORD redaction preserves original delimiter and spacing
11
+ - Improved dry-run reporting consistency
12
+ - Added contract tests for CLI output and URL behavior
13
+
14
+ ## v0.4.1
15
+
16
+ ### Fixed
17
+
18
+ - Prevented secret leakage in `--json` output by removing raw match values from the public result shape
19
+ - Forwarded `--dry-run` into the engine to ensure consistent, future-proof behavior
20
+
21
+ ### Improved
22
+
23
+ - Expanded credential detection for common API key variants (`api_key`, `api-key`, `apikey`) and `Authorization: Bearer ...`
24
+ - Hardened AWS secret key strict detection to reduce false positives while keeping strict mode safe
25
+
26
+ ### Notes
27
+
28
+ - No breaking changes
29
+ - No new features
30
+ - Stability and safety hardening release
31
+
3
32
  ## v0.4.0
4
33
 
5
34
  ### Changed
@@ -21,8 +50,6 @@
21
50
  ### Improved
22
51
 
23
52
  - CLI documentation clarity
24
- - Blog and docs structure consistency
25
- - Shared `styles.css` and `main.js` across site pages
26
53
 
27
54
  ### Notes
28
55
 
package/README.md CHANGED
@@ -1,18 +1,59 @@
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
+ ```
15
38
 
39
+ After LogShield, the same logs are safe to share.
40
+
41
+ ---
42
+
43
+ ## When should I use LogShield?
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
58
  echo "email=test@example.com token=sk_live_123" | logshield scan --dry-run
18
59
  ```
@@ -35,7 +76,9 @@ echo "email=test@example.com token=sk_live_123" | logshield scan
35
76
  - Prefer `--dry-run` first in CI to verify you are not over-redacting.
36
77
  - Then switch to enforced mode once you are satisfied with the preview.
37
78
 
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.
79
+ LogShield is a CLI tool that scans logs and redacts **real secrets**
80
+ (API keys, tokens, credentials) before logs are shared with others,
81
+ AI tools, CI systems, or public channels.
39
82
 
40
83
  It is designed to be **predictable, conservative, and safe for production pipelines**.
41
84
 
@@ -148,28 +191,28 @@ If a file is not provided and input is piped, LogShield automatically reads from
148
191
 
149
192
  ## CLI Flags
150
193
 
151
- - `--strict`
194
+ - `--strict`
152
195
  Aggressive, security-first redaction
153
196
 
154
- - `--stdin`
197
+ - `--stdin`
155
198
  Explicitly force reading from STDIN
156
199
 
157
- - `--dry-run`
200
+ - `--dry-run`
158
201
  Detect sensitive data without modifying output
159
202
 
160
- - `--fail-on-detect`
203
+ - `--fail-on-detect`
161
204
  Exit with code `1` if any redaction is detected (CI-friendly)
162
205
 
163
- - `--summary`
206
+ - `--summary`
164
207
  Print a compact redaction summary
165
208
 
166
- - `--json`
209
+ - `--json`
167
210
  JSON output (cannot be combined with `--dry-run`)
168
211
 
169
- - `--version`
212
+ - `--version`
170
213
  Print CLI version
171
214
 
172
- - `--help`
215
+ - `--help`
173
216
  Show help
174
217
 
175
218
  ---
@@ -204,10 +247,10 @@ cat app.log | logshield scan --dry-run
204
247
 
205
248
  ```
206
249
  logshield (dry-run)
207
- Detected 3 redactions:
208
- OAUTH_ACCESS_TOKEN x1
250
+ Detected 5 redactions:
209
251
  AUTH_BEARER x2
210
252
  EMAIL x1
253
+ OAUTH_ACCESS_TOKEN x1
211
254
  PASSWORD x1
212
255
 
213
256
  No output was modified.
@@ -319,7 +362,7 @@ logshield scan --json < logs.txt
319
362
  Notes:
320
363
 
321
364
  - `--json` **cannot** be combined with `--dry-run`
322
- - Output schema is stable within v0.3.x
365
+ - Output schema is stable within v0.4.x
323
366
 
324
367
  ---
325
368
 
@@ -370,7 +413,7 @@ Depending on rules and mode:
370
413
  LogShield guarantees:
371
414
 
372
415
  - Deterministic output
373
- - Stable behavior within **v0.3.x**
416
+ - Stable behavior within **v0.4.x**
374
417
  - No runtime dependencies
375
418
  - Snapshot-tested and contract-tested
376
419
  - No telemetry
@@ -401,5 +444,3 @@ It is a **last-line safety net**, not a primary defense.
401
444
  ## License
402
445
 
403
446
  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
  }
@@ -126,8 +127,7 @@ function applyRules(input, rules, ctx, matches) {
126
127
  const replaced = rule.replace(match, ctx, groups);
127
128
  if (replaced !== match) {
128
129
  matches.push({
129
- rule: rule.name,
130
- value: match
130
+ rule: rule.name
131
131
  });
132
132
  }
133
133
  if (ctx.dryRun) {
@@ -185,8 +185,12 @@ var init_tokens = __esm({
185
185
  },
186
186
  {
187
187
  name: "EMAIL",
188
- pattern: /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi,
189
- replace: () => "<REDACTED_EMAIL>"
188
+ // Avoid corrupting URLs with embedded credentials like:
189
+ // https://user:pass@host
190
+ // In those cases, `pass@host` can look like an email.
191
+ // We therefore require a safe delimiter (whitespace/quotes/brackets/`=` or `: `) before the email.
192
+ pattern: /(^|[\s"'\(\[\{<>,;]|=|:\s)([A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,})/gim,
193
+ replace: (_match, _ctx, groups) => `${groups[0]}<REDACTED_EMAIL>`
190
194
  }
191
195
  ];
192
196
  }
@@ -197,10 +201,15 @@ var credentialRules;
197
201
  var init_credentials = __esm({
198
202
  "src/rules/credentials.ts"() {
199
203
  credentialRules = [
204
+ // password=... or password: ...
200
205
  {
201
206
  name: "PASSWORD",
202
- pattern: /\bpassword=([^\s]+)/gi,
203
- replace: () => "password=<REDACTED_PASSWORD>"
207
+ // Preserve delimiter and spacing so logs remain readable and diff-friendly.
208
+ // Examples:
209
+ // password=secret -> password=<REDACTED_PASSWORD>
210
+ // Password : 123 -> Password : <REDACTED_PASSWORD>
211
+ pattern: /\b(password)(\s*[:=]\s*)([^\s]+)/gi,
212
+ replace: (_match, _ctx, groups) => `${groups[0]}${groups[1]}<REDACTED_PASSWORD>`
204
213
  },
205
214
  // DB URL credential: postgres://user:pass@host
206
215
  {
@@ -208,17 +217,30 @@ var init_credentials = __esm({
208
217
  pattern: /\b(postgres|mysql|mongodb):\/\/([^:\s]+):([^@\s]+)@/gi,
209
218
  replace: (_match, _ctx, groups) => `${groups[0]}://${groups[1]}:<REDACTED_PASSWORD>@`
210
219
  },
211
- // apiKey=...
220
+ /**
221
+ * API key (common variants):
222
+ * - apiKey=...
223
+ * - api_key=...
224
+ * - api-key: ...
225
+ * - apikey=...
226
+ * Supports '=' or ':' and optional quotes/spaces.
227
+ */
212
228
  {
213
229
  name: "API_KEY",
214
- pattern: /\bapiKey=([A-Za-z0-9_\-]{16,})\b/g,
230
+ pattern: /\bapi(?:[_-]?key)\s*[:=]\s*["']?([A-Za-z0-9_\-]{16,})["']?\b/gi,
215
231
  replace: () => "<REDACTED_API_KEY>"
216
232
  },
217
233
  // x-api-key: ....
218
234
  {
219
235
  name: "API_KEY_HEADER",
220
- pattern: /\bx-api-key:\s*[A-Za-z0-9_\-]{16,}\b/gi,
236
+ pattern: /\bx-api-key\s*:\s*["']?[A-Za-z0-9_\-]{16,}["']?\b/gi,
221
237
  replace: () => "x-api-key: <REDACTED_API_KEY>"
238
+ },
239
+ // authorization: Bearer ...
240
+ {
241
+ name: "AUTHORIZATION_BEARER",
242
+ pattern: /\bauthorization\s*:\s*bearer\s+([A-Za-z0-9._\-]{16,})\b/gi,
243
+ replace: () => "authorization: Bearer <REDACTED_TOKEN>"
222
244
  }
223
245
  ];
224
246
  }
@@ -234,9 +256,24 @@ var init_cloud = __esm({
234
256
  pattern: /\bAKIA[0-9A-Z]{16,20}\b/g,
235
257
  replace: (match, { strict }) => strict ? "<REDACTED_AWS_KEY>" : match
236
258
  },
259
+ /**
260
+ * Prefer contextual AWS secret key detection first:
261
+ * - AWS_SECRET_ACCESS_KEY=...
262
+ * - aws_secret_access_key: ...
263
+ * - secretAccessKey=...
264
+ */
265
+ {
266
+ name: "AWS_SECRET_ACCESS_KEY",
267
+ pattern: /\b(?:AWS_SECRET_ACCESS_KEY|aws_secret_access_key|secretAccessKey|awsSecretAccessKey)\s*[:=]\s*["']?([A-Za-z0-9\/+=]{40})["']?\b/g,
268
+ replace: (_match, { strict }, groups) => strict ? "<REDACTED_AWS_SECRET>" : _match.replace(groups[0], "<REDACTED_AWS_SECRET>")
269
+ },
270
+ /**
271
+ * Strict-only broad fallback, but require at least one of / + = inside the 40 chars
272
+ * to reduce false positives on purely alphanumeric 40-char strings.
273
+ */
237
274
  {
238
275
  name: "AWS_SECRET_KEY",
239
- pattern: /\b[A-Za-z0-9\/+=]{40}\b/g,
276
+ pattern: /\b(?=[A-Za-z0-9\/+=]{40}\b)(?=[A-Za-z0-9\/+=]*[\/+=])[A-Za-z0-9\/+=]{40}\b/g,
240
277
  replace: (match, { strict }) => strict ? "<REDACTED_AWS_SECRET>" : match
241
278
  },
242
279
  {
@@ -288,14 +325,100 @@ var init_creditCard = __esm({
288
325
  });
289
326
 
290
327
  // src/rules/urls.ts
291
- var urlRules;
328
+ function redactQueryLike(segment) {
329
+ if (segment.length < 2) return segment;
330
+ const prefix = segment[0];
331
+ const raw = segment.slice(1);
332
+ if (!raw.includes("=")) return segment;
333
+ const parts = raw.split("&");
334
+ const redacted = parts.map((p) => {
335
+ const eq = p.indexOf("=");
336
+ if (eq === -1) return p;
337
+ const key = p.slice(0, eq);
338
+ const value = p.slice(eq + 1);
339
+ const normalized = key.trim().toLowerCase();
340
+ if (!SENSITIVE_PARAM_KEYS.has(normalized)) return p;
341
+ if (value.length === 0) return `${key}=`;
342
+ return `${key}=<REDACTED_URL_PARAM>`;
343
+ });
344
+ return `${prefix}${redacted.join("&")}`;
345
+ }
346
+ function redactUrl(match) {
347
+ const schemeIdx = match.indexOf("://");
348
+ if (schemeIdx === -1) return match;
349
+ const scheme = match.slice(0, schemeIdx + 3);
350
+ const rest = match.slice(schemeIdx + 3);
351
+ const authorityEnd = (() => {
352
+ const slash = rest.indexOf("/");
353
+ const q = rest.indexOf("?");
354
+ const h = rest.indexOf("#");
355
+ const candidates = [slash, q, h].filter((i) => i !== -1);
356
+ return candidates.length === 0 ? rest.length : Math.min(...candidates);
357
+ })();
358
+ let authority = rest.slice(0, authorityEnd);
359
+ let tail = rest.slice(authorityEnd);
360
+ const at = authority.lastIndexOf("@");
361
+ if (at !== -1) {
362
+ const userinfo = authority.slice(0, at);
363
+ const host = authority.slice(at + 1);
364
+ const colon = userinfo.indexOf(":");
365
+ if (colon !== -1) {
366
+ const user = userinfo.slice(0, colon);
367
+ authority = `${user}:<REDACTED_PASSWORD>@${host}`;
368
+ } else {
369
+ authority = `<REDACTED_PASSWORD>@${host}`;
370
+ }
371
+ }
372
+ const hashIdx = tail.indexOf("#");
373
+ const queryIdx = tail.indexOf("?");
374
+ if (queryIdx !== -1 && (hashIdx === -1 || queryIdx < hashIdx)) {
375
+ const before = tail.slice(0, queryIdx);
376
+ const after = tail.slice(queryIdx);
377
+ const hashInside = after.indexOf("#");
378
+ if (hashInside === -1) {
379
+ tail = `${before}${redactQueryLike(after)}`;
380
+ } else {
381
+ const qPart = after.slice(0, hashInside);
382
+ const hPart = after.slice(hashInside);
383
+ tail = `${before}${redactQueryLike(qPart)}${redactQueryLike(hPart)}`;
384
+ }
385
+ } else if (hashIdx !== -1) {
386
+ const before = tail.slice(0, hashIdx);
387
+ const hPart = tail.slice(hashIdx);
388
+ tail = `${before}${redactQueryLike(hPart)}`;
389
+ }
390
+ return `${scheme}${authority}${tail}`;
391
+ }
392
+ var SENSITIVE_PARAM_KEYS, urlRules;
292
393
  var init_urls = __esm({
293
394
  "src/rules/urls.ts"() {
395
+ SENSITIVE_PARAM_KEYS = new Set(
396
+ [
397
+ "access_token",
398
+ "token",
399
+ "id_token",
400
+ "refresh_token",
401
+ "auth",
402
+ "authorization",
403
+ "api_key",
404
+ "apikey",
405
+ "api-key",
406
+ "key",
407
+ "secret",
408
+ "password",
409
+ "passwd",
410
+ "signature",
411
+ "sig",
412
+ "session"
413
+ ].map((k) => k.toLowerCase())
414
+ );
294
415
  urlRules = [
295
416
  {
296
417
  name: "URL",
297
- pattern: /\bhttps?:\/\/[^\s/$.?#].[^\s]*\b/gi,
298
- replace: () => "<REDACTED_URL>"
418
+ // Match HTTP(S) URLs, stopping at whitespace.
419
+ // (Conservative: avoids attempting to be a full RFC URL parser.)
420
+ pattern: /\bhttps?:\/\/[^\s]+/gi,
421
+ replace: (match) => redactUrl(match)
299
422
  }
300
423
  ];
301
424
  }
@@ -389,7 +512,7 @@ var { printSummary: printSummary2 } = (init_summary(), __toCommonJS(summary_expo
389
512
  var { sanitizeLog: sanitizeLog2 } = (init_sanitizeLog(), __toCommonJS(sanitizeLog_exports));
390
513
  var rawArgs = process.argv.slice(2).map((arg) => arg === "-h" ? "--help" : arg);
391
514
  function getVersion() {
392
- return true ? "0.4.0" : "unknown";
515
+ return true ? "0.4.2" : "unknown";
393
516
  }
394
517
  function printHelp() {
395
518
  process.stdout.write(`Usage: logshield scan [file]
@@ -410,6 +533,9 @@ Options:
410
533
  --help Show help
411
534
  `);
412
535
  }
536
+ function writeErr(message) {
537
+ process.stderr.write(message);
538
+ }
413
539
  function parseArgs(args) {
414
540
  const flags = /* @__PURE__ */ new Set();
415
541
  const positionals = [];
@@ -447,10 +573,8 @@ function renderDryRunReport(matches) {
447
573
  process.stdout.write(`Detected ${total} ${label}:
448
574
  `);
449
575
  for (const { rule, count } of entries) {
450
- process.stdout.write(
451
- ` ${rule.padEnd(maxLen)} x${count}
452
- `
453
- );
576
+ process.stdout.write(` ${rule.padEnd(maxLen)} x${count}
577
+ `);
454
578
  }
455
579
  process.stdout.write("\n");
456
580
  process.stdout.write("No output was modified.\n");
@@ -469,7 +593,7 @@ async function main() {
469
593
  const { flags, positionals } = parseArgs(rawArgs);
470
594
  const command = positionals[0];
471
595
  if (command !== "scan") {
472
- process.stdout.write("Unknown command\n");
596
+ writeErr("Unknown command\n");
473
597
  process.exit(1);
474
598
  }
475
599
  const file = positionals[1];
@@ -482,20 +606,20 @@ async function main() {
482
606
  const stdinAuto = isStdinPiped();
483
607
  const useStdin = stdinFlag || stdinAuto;
484
608
  if (useStdin && file) {
485
- process.stdout.write("Cannot read from both STDIN and file\n");
609
+ writeErr("Cannot read from both STDIN and file\n");
486
610
  process.exit(1);
487
611
  }
488
612
  if (dryRun && json) {
489
- process.stdout.write("--dry-run cannot be used with --json\n");
613
+ writeErr("--dry-run cannot be used with --json\n");
490
614
  process.exit(1);
491
615
  }
492
616
  if (json && summary) {
493
- process.stdout.write("--summary cannot be used with --json\n");
617
+ writeErr("--summary cannot be used with --json\n");
494
618
  process.exit(1);
495
619
  }
496
620
  try {
497
621
  const input = await readInput2(useStdin ? void 0 : file);
498
- const result = sanitizeLog2(input, { strict });
622
+ const result = sanitizeLog2(input, { strict, dryRun });
499
623
  if (dryRun) {
500
624
  renderDryRunReport(result.matches);
501
625
  if (failOnDetect && result.matches.length > 0) {
@@ -512,8 +636,7 @@ async function main() {
512
636
  }
513
637
  process.exit(0);
514
638
  } catch (err) {
515
- process.stdout.write(err?.message || "Unexpected error");
516
- process.stdout.write("\n");
639
+ writeErr((err?.message || "Unexpected error") + "\n");
517
640
  process.exit(2);
518
641
  }
519
642
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "logshield-cli",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "license": "Apache-2.0",
5
5
  "type": "commonjs",
6
6
  "bin": {
@@ -17,6 +17,7 @@
17
17
  "build:web": "vite build --outDir dist-web",
18
18
  "build:blog": "node scripts/build-blog.js",
19
19
  "dev:web": "vite",
20
+ "typecheck": "tsc -p tsconfig.core.json && tsc -p tsconfig.cli.json --noEmit",
20
21
  "pretest": "npm run build",
21
22
  "test": "vitest",
22
23
  "prepublishOnly": "npm run build"
@@ -27,6 +28,7 @@
27
28
  "esbuild": "^0.25.0",
28
29
  "postcss": "^8.5.6",
29
30
  "tailwindcss": "^3.4.19",
31
+ "typescript": "^5.9.3",
30
32
  "vitest": "^4.0.0"
31
33
  }
32
34
  }