claude-nomad 0.32.0 → 0.32.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 +19 -0
- package/package.json +1 -1
- package/src/commands.redact.core.ts +81 -30
- package/src/commands.redact.ts +0 -1
- package/src/config.sharedDirs.guard.ts +33 -4
- package/src/extras-sync.guards.ts +1 -24
- package/src/remap.ts +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.32.2](https://github.com/funkadelic/claude-nomad/compare/v0.32.1...v0.32.2) (2026-05-30)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Fixed
|
|
7
|
+
|
|
8
|
+
* **remap:** reject path-traversal in path-map logical keys ([#190](https://github.com/funkadelic/claude-nomad/issues/190)) ([1526fbb](https://github.com/funkadelic/claude-nomad/commit/1526fbbbb7c6beb258d882c1c26cd45447ab226b))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
* **sonar:** source projectVersion from package.json at scan time ([#188](https://github.com/funkadelic/claude-nomad/issues/188)) ([c00dd6a](https://github.com/funkadelic/claude-nomad/commit/c00dd6a5135a8753d56cf4165cf5b9782c9bde3e))
|
|
14
|
+
|
|
15
|
+
## [0.32.1](https://github.com/funkadelic/claude-nomad/compare/v0.32.0...v0.32.1) (2026-05-30)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
* **redact:** make applyRedactions overlap-safe ([#186](https://github.com/funkadelic/claude-nomad/issues/186)) ([8da07d1](https://github.com/funkadelic/claude-nomad/commit/8da07d1ef58b7f6596ca479a3c57863fc75f35bb))
|
|
21
|
+
|
|
3
22
|
## [0.32.0](https://github.com/funkadelic/claude-nomad/compare/v0.31.0...v0.32.0) (2026-05-30)
|
|
4
23
|
|
|
5
24
|
|
package/package.json
CHANGED
|
@@ -3,25 +3,6 @@ import { join } from 'node:path';
|
|
|
3
3
|
|
|
4
4
|
import { REPO_HOME } from './config.ts';
|
|
5
5
|
|
|
6
|
-
/**
|
|
7
|
-
* Replace every occurrence of a literal secret value in a raw line. Uses
|
|
8
|
-
* split/join to avoid regex escaping and to replace all occurrences. Pure,
|
|
9
|
-
* no I/O.
|
|
10
|
-
*
|
|
11
|
-
* The replacement token `[REDACTED:<ruleId>]` contains no JSON-special
|
|
12
|
-
* characters, so the result remains valid JSON when the value sits inside a
|
|
13
|
-
* JSON string token.
|
|
14
|
-
*
|
|
15
|
-
* @param line Raw JSONL line text.
|
|
16
|
-
* @param match Literal secret value to replace (empty string is a no-op).
|
|
17
|
-
* @param ruleId Gitleaks rule identifier included in the replacement token.
|
|
18
|
-
* @returns Line with all occurrences of `match` replaced by `[REDACTED:<ruleId>]`.
|
|
19
|
-
*/
|
|
20
|
-
export function redactValue(line: string, match: string, ruleId: string): string {
|
|
21
|
-
if (match === '') return line;
|
|
22
|
-
return line.split(match).join(`[REDACTED:${ruleId}]`);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
6
|
/** Minimal finding shape consumed by `applyRedactions`. */
|
|
26
7
|
export type RedactFinding = {
|
|
27
8
|
StartLine: number;
|
|
@@ -29,25 +10,95 @@ export type RedactFinding = {
|
|
|
29
10
|
RuleID: string;
|
|
30
11
|
};
|
|
31
12
|
|
|
13
|
+
/** A half-open byte interval `[start, end)` derived from a `Match` value. */
|
|
14
|
+
type MatchInterval = {
|
|
15
|
+
start: number;
|
|
16
|
+
end: number;
|
|
17
|
+
ruleId: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
32
20
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
21
|
+
* Locate every occurrence of each finding's `Match` value in `content` using
|
|
22
|
+
* `indexOf`. Findings with an empty `Match` are skipped. Multiple
|
|
23
|
+
* non-overlapping occurrences of the same value are each recorded as a
|
|
24
|
+
* separate interval. Offsets are value-derived, not column-derived, so they
|
|
25
|
+
* are always correct regardless of gitleaks column alignment.
|
|
26
|
+
*
|
|
27
|
+
* @param content Full file content as a single string.
|
|
28
|
+
* @param findings Array of finding descriptors.
|
|
29
|
+
* @returns Array of `{start, end, ruleId}` intervals (unmerged, unsorted).
|
|
30
|
+
*/
|
|
31
|
+
export function collectMatchIntervals(
|
|
32
|
+
content: string,
|
|
33
|
+
findings: readonly RedactFinding[],
|
|
34
|
+
): MatchInterval[] {
|
|
35
|
+
const intervals: MatchInterval[] = [];
|
|
36
|
+
for (const f of findings) {
|
|
37
|
+
const match = f.Match;
|
|
38
|
+
if (match === '') continue;
|
|
39
|
+
let from = 0;
|
|
40
|
+
let pos = content.indexOf(match, from);
|
|
41
|
+
while (pos !== -1) {
|
|
42
|
+
intervals.push({ start: pos, end: pos + match.length, ruleId: f.RuleID });
|
|
43
|
+
from = pos + match.length;
|
|
44
|
+
pos = content.indexOf(match, from);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return intervals;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Sort and merge a list of (possibly overlapping or adjacent) intervals into a
|
|
52
|
+
* minimal list of non-overlapping spans. Sort order is start ascending, then
|
|
53
|
+
* end descending so that a longer interval at the same start position wins its
|
|
54
|
+
* `ruleId`. Overlapping or adjacent intervals are folded into one span that
|
|
55
|
+
* extends to the maximum end seen, keeping the first span's `ruleId`.
|
|
56
|
+
*
|
|
57
|
+
* @param intervals Unmerged intervals from `collectMatchIntervals`.
|
|
58
|
+
* @returns Sorted, merged, non-overlapping intervals.
|
|
59
|
+
*/
|
|
60
|
+
export function mergeIntervals(intervals: MatchInterval[]): MatchInterval[] {
|
|
61
|
+
if (intervals.length === 0) return [];
|
|
62
|
+
const sorted = [...intervals].sort((a, b) => a.start - b.start || b.end - a.end);
|
|
63
|
+
let last = { ...sorted[0] };
|
|
64
|
+
const merged: MatchInterval[] = [last];
|
|
65
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
66
|
+
const cur = sorted[i];
|
|
67
|
+
if (cur.start <= last.end) {
|
|
68
|
+
if (cur.end > last.end) last.end = cur.end;
|
|
69
|
+
} else {
|
|
70
|
+
last = { ...cur };
|
|
71
|
+
merged.push(last);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return merged;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Apply all findings for one file in memory. Collects every occurrence of each
|
|
79
|
+
* finding's `Match` value via `indexOf`, merges overlapping and adjacent spans
|
|
80
|
+
* into non-overlapping intervals, then replaces each interval right-to-left so
|
|
81
|
+
* earlier offsets remain valid. Findings with an empty `Match` are silently
|
|
82
|
+
* skipped. Returns `content` unchanged when there are no findings or no
|
|
83
|
+
* occurrences are found.
|
|
84
|
+
*
|
|
85
|
+
* The replacement token `[REDACTED:<ruleId>]` is byte-identical to the previous
|
|
86
|
+
* format. Right-to-left replacement guarantees that overlapping matches (e.g.
|
|
87
|
+
* two findings whose `Match` values share a middle span) are replaced by a
|
|
88
|
+
* single token covering the full union, leaving no fragment. Pure, no I/O.
|
|
40
89
|
*
|
|
41
90
|
* @param content Full file content as a single string.
|
|
42
91
|
* @param findings Array of finding descriptors.
|
|
43
92
|
* @returns Redacted file content.
|
|
44
93
|
*/
|
|
45
94
|
export function applyRedactions(content: string, findings: readonly RedactFinding[]): string {
|
|
46
|
-
const
|
|
95
|
+
const raw = collectMatchIntervals(content, findings);
|
|
96
|
+
if (raw.length === 0) return content;
|
|
97
|
+
const merged = mergeIntervals(raw);
|
|
47
98
|
let result = content;
|
|
48
|
-
for (
|
|
49
|
-
|
|
50
|
-
result = result.
|
|
99
|
+
for (let i = merged.length - 1; i >= 0; i--) {
|
|
100
|
+
const { start, end, ruleId } = merged[i];
|
|
101
|
+
result = result.slice(0, start) + `[REDACTED:${ruleId}]` + result.slice(end);
|
|
51
102
|
}
|
|
52
103
|
return result;
|
|
53
104
|
}
|
package/src/commands.redact.ts
CHANGED
|
@@ -1,11 +1,40 @@
|
|
|
1
1
|
import { NEVER_SYNC } from './config.ts';
|
|
2
|
+
import { NomadFatal } from './utils.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `logical` keys in `path-map.json` are project identifiers (e.g. `ha-acwd`,
|
|
6
|
+
* `foo`), never path fragments. A crafted key like `../escape` or `foo/bar`
|
|
7
|
+
* would escape `shared/projects/` (or `shared/extras/`) via `join()` (which
|
|
8
|
+
* normalizes `..`) and land content somewhere unexpected on the filesystem.
|
|
9
|
+
* The push allow-list catches such commits at the `git add` boundary, but the
|
|
10
|
+
* filesystem mutation has already happened by then. This check fails fast
|
|
11
|
+
* before any write. The pattern matches what every reasonable project name
|
|
12
|
+
* looks like and rejects everything else.
|
|
13
|
+
*/
|
|
14
|
+
const SAFE_LOGICAL = /^[A-Za-z0-9._-]+$/;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Throw `NomadFatal` unless `logical` is a path-separator-free project
|
|
18
|
+
* identifier (see `SAFE_LOGICAL`). Path-traversal defense-in-depth; called
|
|
19
|
+
* before any filesystem mutation by every remap and extras op that joins
|
|
20
|
+
* `logical` into a filesystem path.
|
|
21
|
+
*
|
|
22
|
+
* @param logical - A `path-map.json` projects key to validate.
|
|
23
|
+
*/
|
|
24
|
+
export function assertSafeLogical(logical: string): void {
|
|
25
|
+
if (!SAFE_LOGICAL.test(logical) || logical === '.' || logical === '..') {
|
|
26
|
+
throw new NomadFatal(
|
|
27
|
+
`invalid logical name in path-map.json: ${JSON.stringify(logical)} (must match [A-Za-z0-9._-]+; no path separators or '..')`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
2
31
|
|
|
3
32
|
/**
|
|
4
33
|
* Single-segment path characters allowed in a `sharedDirs` entry. Mirrors
|
|
5
|
-
* `SAFE_LOGICAL`
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
34
|
+
* `SAFE_LOGICAL` above but applied to global support directory names rather
|
|
35
|
+
* than per-project logical names. Must match `^[A-Za-z0-9._-]+$` so no path
|
|
36
|
+
* separator, no shell-special character, no leading dot that would collide
|
|
37
|
+
* with a hidden state directory.
|
|
9
38
|
*/
|
|
10
39
|
const SAFE_SEGMENT = /^[A-Za-z0-9._-]+$/;
|
|
11
40
|
|
|
@@ -2,30 +2,7 @@ import { isAbsolute, normalize } from 'node:path';
|
|
|
2
2
|
|
|
3
3
|
import { NomadFatal } from './utils.ts';
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
* `logical` keys in `path-map.json` are project identifiers (e.g. `ha-acwd`,
|
|
7
|
-
* `foo`), never path fragments. A crafted key like `../escape` or `foo/bar`
|
|
8
|
-
* would escape `shared/extras/` via `join()` (which normalizes `..`) and land
|
|
9
|
-
* content somewhere unexpected on the filesystem. The push allow-list catches
|
|
10
|
-
* such commits at the `git add` boundary, but the filesystem mutation has
|
|
11
|
-
* already happened by then. This check fails fast before any write. The
|
|
12
|
-
* pattern matches what every reasonable project name looks like and rejects
|
|
13
|
-
* everything else; tighten only if a real project needs broader characters.
|
|
14
|
-
*/
|
|
15
|
-
const SAFE_LOGICAL = /^[A-Za-z0-9._-]+$/;
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Throw `NomadFatal` unless `logical` is a path-separator-free project
|
|
19
|
-
* identifier (see `SAFE_LOGICAL`). Path-traversal defense-in-depth; called
|
|
20
|
-
* before any filesystem mutation by every extras op.
|
|
21
|
-
*/
|
|
22
|
-
export function assertSafeLogical(logical: string): void {
|
|
23
|
-
if (!SAFE_LOGICAL.test(logical) || logical === '.' || logical === '..') {
|
|
24
|
-
throw new NomadFatal(
|
|
25
|
-
`invalid logical name in path-map.json extras: ${JSON.stringify(logical)} (must match [A-Za-z0-9._-]+; no path separators or '..')`,
|
|
26
|
-
);
|
|
27
|
-
}
|
|
28
|
-
}
|
|
5
|
+
export { assertSafeLogical } from './config.sharedDirs.guard.ts';
|
|
29
6
|
|
|
30
7
|
/**
|
|
31
8
|
* Reject `localRoot` values that contain unnormalized segments (`..`,
|
package/src/remap.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs';
|
|
2
2
|
import { join, relative, sep } from 'node:path';
|
|
3
3
|
|
|
4
|
+
import { assertSafeLogical } from './config.sharedDirs.guard.ts';
|
|
4
5
|
import { CLAUDE_HOME, HOST, REPO_HOME, type PathMap } from './config.ts';
|
|
5
6
|
import { die, log } from './utils.ts';
|
|
6
7
|
import { backupBeforeWrite, backupRepoWrite } from './utils.fs.ts';
|
|
@@ -81,6 +82,7 @@ export function remapPull(
|
|
|
81
82
|
if (!dryRun) mkdirSync(localProjects, { recursive: true });
|
|
82
83
|
|
|
83
84
|
for (const [logical, hosts] of Object.entries(map.projects)) {
|
|
85
|
+
assertSafeLogical(logical);
|
|
84
86
|
const localPath = hosts[HOST];
|
|
85
87
|
if (!localPath || localPath === 'TBD') {
|
|
86
88
|
unmapped++;
|
|
@@ -126,6 +128,7 @@ function buildReverseMap(map: PathMap): Map<string, string> {
|
|
|
126
128
|
const reverse = new Map<string, string>();
|
|
127
129
|
const encodedPaths = new Map<string, string>();
|
|
128
130
|
for (const [logical, hosts] of Object.entries(map.projects)) {
|
|
131
|
+
assertSafeLogical(logical);
|
|
129
132
|
const p = hosts[HOST];
|
|
130
133
|
if (!p || p === 'TBD') continue;
|
|
131
134
|
const encoded = encodePath(p);
|