Skip to content

Commit 8e1b8f9

Browse files
authored
Merge pull request #512 from manojmallick/feat/redact-command-511
feat(security): sigmap redact — standalone secret masking (G3a, v8.24)
2 parents 71a12cb + 2ffb64e commit 8e1b8f9

6 files changed

Lines changed: 276 additions & 1 deletion

File tree

docs-vp/public/llms-full.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ sigmap memory List cross-session stores (.context/)
127127
sigmap memory --clear <store> Clear one store: session|notes|weights|evidence|all (--json supported)
128128
sigmap budget Session spend ledger — estimated SigMap-emitted tokens, budget, context age (--json)
129129
sigmap budget --budget <tokens> One-off budget override (config: sessionBudgetTokens, contextTtlDays)
130+
sigmap redact [file] Mask secrets in a file or stdin (10-pattern bank); redacted text to stdout (--json)
130131
sigmap note "<text>" Append a note to the cross-session decision log
131132
sigmap note List recent notes (also: note --list <N>)
132133
sigmap status Show repo state — branch, dirty files, index freshness, notes

gen-context.js

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17528,6 +17528,66 @@ __factories["./src/security/patterns"] = function(module, exports) {
1752817528

1752917529
};
1753017530

17531+
// ── ./src/security/redact ──
17532+
__factories["./src/security/redact"] = function(module, exports) {
17533+
17534+
/**
17535+
* Standalone text redaction (v8.24 G3a) — the same pattern bank the
17536+
* generation-time scanner uses (security/patterns.js), applied to arbitrary
17537+
* text. Unlike scanner.js (which replaces whole signature lines), this masks
17538+
* only the matched secret substring so surrounding text stays readable.
17539+
*
17540+
* Zero dependencies; never throws — on any error the original text is
17541+
* returned unredacted.
17542+
*/
17543+
17544+
const { PATTERNS } = __require('./src/security/patterns');
17545+
17546+
// Global variants of the pattern regexes (needed for replace-all per line).
17547+
const GLOBAL_PATTERNS = PATTERNS.map((p) => ({
17548+
name: p.name,
17549+
regex: new RegExp(p.regex.source, p.regex.flags.includes('g') ? p.regex.flags : p.regex.flags + 'g'),
17550+
}));
17551+
17552+
/**
17553+
* Redact secrets in arbitrary text.
17554+
* @param {string} text
17555+
* @returns {{
17556+
* text: string, redacted: boolean,
17557+
* findings: Array<{ line: number, pattern: string }>,
17558+
* counts: Object<string, number>
17559+
* }}
17560+
*/
17561+
function redactText(text) {
17562+
if (typeof text !== 'string' || text.length === 0) {
17563+
return { text: typeof text === 'string' ? text : '', redacted: false, findings: [], counts: {} };
17564+
}
17565+
try {
17566+
const findings = [];
17567+
const counts = {};
17568+
const lines = text.split('\n');
17569+
const out = lines.map((line, i) => {
17570+
let masked = line;
17571+
for (const p of GLOBAL_PATTERNS) {
17572+
p.regex.lastIndex = 0;
17573+
if (!p.regex.test(masked)) continue;
17574+
p.regex.lastIndex = 0;
17575+
masked = masked.replace(p.regex, `[REDACTED:${p.name}]`);
17576+
findings.push({ line: i + 1, pattern: p.name });
17577+
counts[p.name] = (counts[p.name] || 0) + 1;
17578+
}
17579+
return masked;
17580+
});
17581+
return { text: out.join('\n'), redacted: findings.length > 0, findings, counts };
17582+
} catch (_) {
17583+
return { text, redacted: false, findings: [], counts: {} };
17584+
}
17585+
}
17586+
17587+
module.exports = { redactText };
17588+
17589+
};
17590+
1753117591
// ── ./src/security/scanner ──
1753217592
__factories["./src/security/scanner"] = function(module, exports) {
1753317593

@@ -22234,6 +22294,7 @@ Usage:
2223422294
${cmd} memory --clear <store> Clear one store: session|notes|weights|evidence|all (--json supported)
2223522295
${cmd} budget Session spend ledger — estimated SigMap-emitted tokens, budget, context age (--json)
2223622296
${cmd} budget --budget <tokens> One-off budget override (config: sessionBudgetTokens, contextTtlDays)
22297+
${cmd} redact [file] Mask secrets in a file or stdin (10-pattern bank); redacted text to stdout (--json)
2223722298
${cmd} note "<text>" Append a note to the cross-session decision log
2223822299
${cmd} note List recent notes (also: note --list <N>)
2223922300
${cmd} status Show repo state — branch, dirty files, index freshness, notes
@@ -23715,6 +23776,42 @@ function main() {
2371523776
process.exit(0);
2371623777
}
2371723778

23779+
if (args[0] === 'redact') {
23780+
const jsonOut = args.includes('--json');
23781+
const { redactText } = requireSourceOrBundled('./src/security/redact');
23782+
const fileArg = args.slice(1).find((a) => !a.startsWith('--'));
23783+
let input;
23784+
if (fileArg) {
23785+
try {
23786+
input = fs.readFileSync(path.resolve(cwd, fileArg), 'utf8');
23787+
} catch (err) {
23788+
console.error(`[sigmap] redact: cannot read ${fileArg}: ${err.message}`);
23789+
process.exit(1);
23790+
}
23791+
} else if (!process.stdin.isTTY) {
23792+
try {
23793+
input = fs.readFileSync(0, 'utf8');
23794+
} catch (err) {
23795+
console.error(`[sigmap] redact: cannot read stdin: ${err.message}`);
23796+
process.exit(1);
23797+
}
23798+
} else {
23799+
console.error('[sigmap] usage: sigmap redact <file> or <cmd> | sigmap redact');
23800+
process.exit(1);
23801+
}
23802+
const r = redactText(input);
23803+
if (jsonOut) {
23804+
process.stdout.write(JSON.stringify(r) + '\n');
23805+
process.exit(0);
23806+
}
23807+
process.stdout.write(r.text);
23808+
const parts = Object.entries(r.counts).map(([k, v]) => `${k}×${v}`);
23809+
console.error(r.redacted
23810+
? `[sigmap] redact: masked ${r.findings.length} secret(s) — ${parts.join(', ')}`
23811+
: '[sigmap] redact: no secrets found');
23812+
process.exit(0);
23813+
}
23814+
2371823815
if (args[0] === 'note') {
2371923816
const jsonOut = args.includes('--json');
2372023817
const { addNote, readNotes, formatNotes } = requireSourceOrBundled('./src/session/notes');

llms-full.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ sigmap memory List cross-session stores (.context/)
127127
sigmap memory --clear <store> Clear one store: session|notes|weights|evidence|all (--json supported)
128128
sigmap budget Session spend ledger — estimated SigMap-emitted tokens, budget, context age (--json)
129129
sigmap budget --budget <tokens> One-off budget override (config: sessionBudgetTokens, contextTtlDays)
130+
sigmap redact [file] Mask secrets in a file or stdin (10-pattern bank); redacted text to stdout (--json)
130131
sigmap note "<text>" Append a note to the cross-session decision log
131132
sigmap note List recent notes (also: note --list <N>)
132133
sigmap status Show repo state — branch, dirty files, index freshness, notes

src/security/redact.js

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
'use strict';
2+
3+
/**
4+
* Standalone text redaction (v8.24 G3a) — the same pattern bank the
5+
* generation-time scanner uses (security/patterns.js), applied to arbitrary
6+
* text. Unlike scanner.js (which replaces whole signature lines), this masks
7+
* only the matched secret substring so surrounding text stays readable.
8+
*
9+
* Zero dependencies; never throws — on any error the original text is
10+
* returned unredacted.
11+
*/
12+
13+
const { PATTERNS } = require('./patterns');
14+
15+
// Global variants of the pattern regexes (needed for replace-all per line).
16+
const GLOBAL_PATTERNS = PATTERNS.map((p) => ({
17+
name: p.name,
18+
regex: new RegExp(p.regex.source, p.regex.flags.includes('g') ? p.regex.flags : p.regex.flags + 'g'),
19+
}));
20+
21+
/**
22+
* Redact secrets in arbitrary text.
23+
* @param {string} text
24+
* @returns {{
25+
* text: string, redacted: boolean,
26+
* findings: Array<{ line: number, pattern: string }>,
27+
* counts: Object<string, number>
28+
* }}
29+
*/
30+
function redactText(text) {
31+
if (typeof text !== 'string' || text.length === 0) {
32+
return { text: typeof text === 'string' ? text : '', redacted: false, findings: [], counts: {} };
33+
}
34+
try {
35+
const findings = [];
36+
const counts = {};
37+
const lines = text.split('\n');
38+
const out = lines.map((line, i) => {
39+
let masked = line;
40+
for (const p of GLOBAL_PATTERNS) {
41+
p.regex.lastIndex = 0;
42+
if (!p.regex.test(masked)) continue;
43+
p.regex.lastIndex = 0;
44+
masked = masked.replace(p.regex, `[REDACTED:${p.name}]`);
45+
findings.push({ line: i + 1, pattern: p.name });
46+
counts[p.name] = (counts[p.name] || 0) + 1;
47+
}
48+
return masked;
49+
});
50+
return { text: out.join('\n'), redacted: findings.length > 0, findings, counts };
51+
} catch (_) {
52+
return { text, redacted: false, findings: [], counts: {} };
53+
}
54+
}
55+
56+
module.exports = { redactText };

test/integration/redact.test.js

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
'use strict';
2+
3+
/**
4+
* Integration tests for `sigmap redact` (v8.24 G3a).
5+
*
6+
* Tests:
7+
* 1. redactText masks only the matched substring (surrounding text kept)
8+
* 2. every pattern in security/patterns.js is masked by redactText
9+
* 3. findings carry 1-based line numbers; counts aggregate per pattern
10+
* 4. clean text passes through byte-identical with redacted: false
11+
* 5. CLI redact <file>: redacted text on stdout, summary on stderr
12+
* 6. CLI stdin + --json returns the result object
13+
*/
14+
15+
const assert = require('assert');
16+
const fs = require('fs');
17+
const os = require('os');
18+
const path = require('path');
19+
const { spawnSync } = require('child_process');
20+
21+
const ROOT = path.resolve(__dirname, '../..');
22+
const SCRIPT = path.join(ROOT, 'gen-context.js');
23+
24+
let passed = 0;
25+
let failed = 0;
26+
27+
function test(name, fn) {
28+
try {
29+
fn();
30+
console.log(` PASS ${name}`);
31+
passed++;
32+
} catch (err) {
33+
console.log(` FAIL ${name}: ${err.message}`);
34+
failed++;
35+
}
36+
}
37+
38+
const { redactText } = require(path.join(ROOT, 'src', 'security', 'redact'));
39+
const { PATTERNS } = require(path.join(ROOT, 'src', 'security', 'patterns'));
40+
41+
// One realistic sample per pattern name.
42+
// Samples are assembled at runtime so no secret-shaped literal exists in this
43+
// file — GitHub Push Protection scans committed blobs and rejects otherwise.
44+
const AL = 'abcdefghijklmnopqrstuvwxyz';
45+
const SAMPLES = {
46+
'AWS Access Key': `key ${'AK' + 'IA'}1234567890ABCDEF end`,
47+
'AWS Secret Key': `aws_secret = "${AL + AL.toUpperCase().slice(0, 10)}0123`.padEnd(52, '4') + '"',
48+
'GCP API Key': `k ${'AI' + 'za'}AbCdEfGhIjKlMnOpQrStUvWxYz0123456789 z`,
49+
'GitHub Token': 'gh' + 'p_' + AL + '0123456789',
50+
'JWT Token': `bearer ${'ey' + 'J'}hbGciOiJIUzI1NiJ9.${'ey' + 'J'}zdWIiOiIxIn0.abc-123_x`,
51+
'DB Connection String': `url ${'postgres' + '://'}admin:hunter2@db.example.com/prod`,
52+
'SSH Private Key': ['-----BEGIN', 'RSA PRIVATE', 'KEY-----'].join(' '),
53+
'Stripe Key': 'sk_' + 'live_' + AL.slice(0, 24),
54+
'Twilio Key': `sid ${'S' + 'K'}0123456789abcdef0123456789abcdef done`,
55+
'Generic Secret': `password = ${'"correct-horse-battery"'}`,
56+
};
57+
58+
console.log('[redact.test.js] v8.24 G3a standalone redaction');
59+
console.log('');
60+
61+
test('masks only the matched substring, keeps surrounding text', () => {
62+
const r = redactText('before AKIA1234567890ABCDEF after');
63+
assert.strictEqual(r.text, 'before [REDACTED:AWS Access Key] after');
64+
assert.strictEqual(r.redacted, true);
65+
});
66+
67+
test('every pattern in patterns.js is masked', () => {
68+
for (const p of PATTERNS) {
69+
const sample = SAMPLES[p.name];
70+
assert.ok(sample, `no sample for pattern "${p.name}" — add one`);
71+
const r = redactText(sample);
72+
assert.ok(r.text.includes(`[REDACTED:${p.name}]`),
73+
`pattern "${p.name}" not masked; got: ${r.text}`);
74+
}
75+
});
76+
77+
test('findings carry 1-based line numbers; counts aggregate', () => {
78+
const r = redactText('clean\nAKIA1234567890ABCDEF\nAKIAABCDEFGHIJKLMNOP x');
79+
assert.deepStrictEqual(r.findings.map((f) => f.line), [2, 3]);
80+
assert.strictEqual(r.findings[0].pattern, 'AWS Access Key');
81+
assert.deepStrictEqual(r.counts, { 'AWS Access Key': 2 });
82+
});
83+
84+
test('clean text passes through byte-identical', () => {
85+
const input = 'function hello(name) {\n return `hi ${name}`;\n}\n';
86+
const r = redactText(input);
87+
assert.strictEqual(r.text, input);
88+
assert.strictEqual(r.redacted, false);
89+
assert.deepStrictEqual(r.findings, []);
90+
});
91+
92+
test('CLI redact <file>: stdout redacted, stderr summary', () => {
93+
const tmp = path.join(os.tmpdir(), `sigmap-redact-${process.pid}.txt`);
94+
fs.writeFileSync(tmp, 'x AKIA1234567890ABCDEF y\n');
95+
try {
96+
const r = spawnSync(process.execPath, [SCRIPT, 'redact', tmp], { encoding: 'utf8' });
97+
assert.strictEqual(r.status, 0, r.stderr);
98+
assert.strictEqual(r.stdout, 'x [REDACTED:AWS Access Key] y\n');
99+
assert.ok(r.stderr.includes('masked 1 secret'), r.stderr);
100+
} finally {
101+
fs.unlinkSync(tmp);
102+
}
103+
});
104+
105+
test('CLI stdin + --json returns the result object', () => {
106+
const r = spawnSync(process.execPath, [SCRIPT, 'redact', '--json'],
107+
{ encoding: 'utf8', input: `token ${'gh' + 'p_'}${AL}0123456789\n` });
108+
assert.strictEqual(r.status, 0, r.stderr);
109+
const j = JSON.parse(r.stdout);
110+
assert.strictEqual(j.redacted, true);
111+
assert.deepStrictEqual(j.counts, { 'GitHub Token': 1 });
112+
assert.ok(j.text.includes('[REDACTED:GitHub Token]'));
113+
});
114+
115+
// ---------------------------------------------------------------------------
116+
// Summary
117+
// ---------------------------------------------------------------------------
118+
console.log('');
119+
console.log(`[redact.test.js] ${passed} passed, ${failed} failed`);
120+
if (failed > 0) process.exit(1);

version.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"languages": 33,
66
"extractors": 42,
77
"mcp_tools": 21,
8-
"tests": 129,
8+
"tests": 130,
99
"metrics": {
1010
"hit_at_5": 0.822,
1111
"baseline_hit_at_5": 0.136,

0 commit comments

Comments
 (0)