@lapismd/agent-redaction 0.0.0-stage → 0.1.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/LICENSE.md +4 -0
- package/README.md +9 -2
- package/dist/deno.d.ts +3 -0
- package/dist/deno.js +4 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/node-worker.d.ts +1 -0
- package/dist/node-worker.js +13 -0
- package/dist/node.d.ts +18 -0
- package/dist/node.js +131 -0
- package/dist/paths.d.ts +7 -0
- package/dist/paths.js +122 -0
- package/dist/sanitizer.d.ts +11 -0
- package/dist/sanitizer.js +186 -0
- package/dist/secretlint.d.ts +3 -0
- package/dist/secretlint.js +128 -0
- package/dist/secrets.d.ts +13 -0
- package/dist/secrets.js +126 -0
- package/dist/stdio.d.ts +3 -0
- package/dist/stdio.js +126 -0
- package/dist/stream.d.ts +10 -0
- package/dist/stream.js +62 -0
- package/dist/sync-worker.d.ts +1 -0
- package/dist/sync-worker.js +36 -0
- package/dist/sync.d.ts +14 -0
- package/dist/sync.js +142 -0
- package/dist/types.d.ts +68 -0
- package/dist/types.js +18 -0
- package/package.json +96 -3
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type Span } from "./types.js";
|
|
2
|
+
export declare class SecretRegistry {
|
|
3
|
+
#private;
|
|
4
|
+
get valid(): boolean;
|
|
5
|
+
add(values: readonly string[]): boolean;
|
|
6
|
+
spans(text: string): Span[];
|
|
7
|
+
clear(): void;
|
|
8
|
+
dispose(): void;
|
|
9
|
+
}
|
|
10
|
+
export declare function maskSpans(text: string, spans: readonly Span[]): string;
|
|
11
|
+
export declare function formatSpans(text: string): Span[];
|
|
12
|
+
export declare function sensitiveKey(key: string): boolean;
|
|
13
|
+
export declare function stripTerminalControls(text: string): string;
|
package/dist/secrets.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { EXTERNAL, LIMITS, REDACTED, utf8Size, validUnicode, } from "./types.js";
|
|
2
|
+
const markers = [REDACTED, EXTERNAL, "$WORKSPACE[", "$HOME", "$TMP"];
|
|
3
|
+
export class SecretRegistry {
|
|
4
|
+
#values = new Set();
|
|
5
|
+
#variants = [];
|
|
6
|
+
#valid = true;
|
|
7
|
+
get valid() {
|
|
8
|
+
return this.#valid;
|
|
9
|
+
}
|
|
10
|
+
add(values) {
|
|
11
|
+
if (!this.#valid)
|
|
12
|
+
return false;
|
|
13
|
+
try {
|
|
14
|
+
const next = new Set(this.#values);
|
|
15
|
+
for (const value of values) {
|
|
16
|
+
if (typeof value !== "string" ||
|
|
17
|
+
!value ||
|
|
18
|
+
!validUnicode(value) ||
|
|
19
|
+
markers.some((m) => m.includes(value) || value.includes(m)))
|
|
20
|
+
throw 0;
|
|
21
|
+
next.add(value);
|
|
22
|
+
if (next.size > LIMITS.secrets)
|
|
23
|
+
throw 0;
|
|
24
|
+
}
|
|
25
|
+
if ([...next].reduce((n, value) => n + utf8Size(value), 0) >
|
|
26
|
+
LIMITS.secretBytes)
|
|
27
|
+
throw 0;
|
|
28
|
+
const variants = new Set();
|
|
29
|
+
for (const value of next) {
|
|
30
|
+
const bytes = new TextEncoder().encode(value);
|
|
31
|
+
let binary = "";
|
|
32
|
+
for (const byte of bytes)
|
|
33
|
+
binary += String.fromCharCode(byte);
|
|
34
|
+
const encoded = btoa(binary);
|
|
35
|
+
for (const candidate of [
|
|
36
|
+
value,
|
|
37
|
+
encodeURIComponent(value),
|
|
38
|
+
JSON.stringify(value).slice(1, -1),
|
|
39
|
+
encoded,
|
|
40
|
+
encoded.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""),
|
|
41
|
+
])
|
|
42
|
+
variants.add(candidate);
|
|
43
|
+
}
|
|
44
|
+
this.#values = next;
|
|
45
|
+
this.#variants = [...variants].sort((a, b) => b.length - a.length);
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
this.clear();
|
|
50
|
+
this.#valid = false;
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
spans(text) {
|
|
55
|
+
const spans = [];
|
|
56
|
+
const uriNormalized = text.replace(/%[a-f0-9]{2}/gi, (match) => match.toUpperCase());
|
|
57
|
+
for (const value of this.#variants) {
|
|
58
|
+
const source = /%[A-F0-9]{2}/.test(value) ? uriNormalized : text;
|
|
59
|
+
let start = source.indexOf(value);
|
|
60
|
+
while (start !== -1) {
|
|
61
|
+
spans.push({ start, end: start + value.length });
|
|
62
|
+
if (spans.length > LIMITS.text)
|
|
63
|
+
throw 0;
|
|
64
|
+
start = source.indexOf(value, start + 1);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return spans;
|
|
68
|
+
}
|
|
69
|
+
clear() {
|
|
70
|
+
this.#values.clear();
|
|
71
|
+
this.#variants = [];
|
|
72
|
+
}
|
|
73
|
+
dispose() {
|
|
74
|
+
this.clear();
|
|
75
|
+
this.#valid = false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export function maskSpans(text, spans) {
|
|
79
|
+
const sorted = [...spans].sort((a, b) => a.start - b.start || b.end - a.end);
|
|
80
|
+
const merged = [];
|
|
81
|
+
for (const span of sorted) {
|
|
82
|
+
if (!Number.isInteger(span.start) ||
|
|
83
|
+
!Number.isInteger(span.end) ||
|
|
84
|
+
span.start < 0 ||
|
|
85
|
+
span.end > text.length ||
|
|
86
|
+
span.end <= span.start)
|
|
87
|
+
throw 0;
|
|
88
|
+
const previous = merged.at(-1);
|
|
89
|
+
if (previous && span.start < previous.end)
|
|
90
|
+
previous.end = Math.max(previous.end, span.end);
|
|
91
|
+
else
|
|
92
|
+
merged.push({ ...span });
|
|
93
|
+
}
|
|
94
|
+
let cursor = 0, output = "";
|
|
95
|
+
for (const span of merged) {
|
|
96
|
+
output += text.slice(cursor, span.start) + REDACTED;
|
|
97
|
+
cursor = span.end;
|
|
98
|
+
}
|
|
99
|
+
return output + text.slice(cursor);
|
|
100
|
+
}
|
|
101
|
+
// These are context rules, not entropy detection. Public identifiers remain intact.
|
|
102
|
+
const patterns = [
|
|
103
|
+
/-----BEGIN (?:[A-Z0-9 ]+ )?PRIVATE KEY-----[\s\S]*?(?:-----END (?:[A-Z0-9 ]+ )?PRIVATE KEY-----|$)/g,
|
|
104
|
+
/\bnsec1[023456789acdefghjklmnpqrstuvwxyz]{20,}\b/gi,
|
|
105
|
+
/\b(?:Bearer|Basic)\s+[^\s"'<>]+/gi,
|
|
106
|
+
/\b(?:password|passwd|secret|token|api[_-]?key|private[_-]?key|authorization|cookie)\s*[=:]\s*(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;]+)/gi,
|
|
107
|
+
/\b(?:bunker|nostrconnect):\/\/[^\s<>"']+/gi,
|
|
108
|
+
/\b[a-z][a-z0-9+.-]*:\/\/[^\s/@:]+:[^\s/@]+@/gi,
|
|
109
|
+
/[?&](?:access_token|refresh_token|api_key|token|secret|password)=[^&#\s"']+/gi,
|
|
110
|
+
];
|
|
111
|
+
export function formatSpans(text) {
|
|
112
|
+
return patterns.flatMap((pattern) => [...text.matchAll(pattern)].map((match) => ({
|
|
113
|
+
start: match.index,
|
|
114
|
+
end: match.index + match[0].length,
|
|
115
|
+
})));
|
|
116
|
+
}
|
|
117
|
+
export function sensitiveKey(key) {
|
|
118
|
+
return (/(?:^|[_\-.])(?:password|passwd|secret|secrets|token|credentials?|authorization|cookie|cookies|env|environment|privatekey|apikey|accesskey|signingkey)(?:$|[_\-.])/i.test(key.replace(/([a-z])([A-Z])/g, "$1_$2")) ||
|
|
119
|
+
/^(?:api|private|access|refresh|signing)[_\-.]?(?:key|token)$/i.test(key));
|
|
120
|
+
}
|
|
121
|
+
export function stripTerminalControls(text) {
|
|
122
|
+
return text
|
|
123
|
+
.replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\|$)/g, "")
|
|
124
|
+
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
|
|
125
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g, "");
|
|
126
|
+
}
|
package/dist/stdio.d.ts
ADDED
package/dist/stdio.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import { createNodeRedaction } from "./node.js";
|
|
4
|
+
import { omit, } from "./types.js";
|
|
5
|
+
const MAX_INPUT_BYTES = 6 * 1024 * 1024;
|
|
6
|
+
function record(value) {
|
|
7
|
+
return (value !== null &&
|
|
8
|
+
typeof value === "object" &&
|
|
9
|
+
!Array.isArray(value) &&
|
|
10
|
+
Object.getPrototypeOf(value) === Object.prototype);
|
|
11
|
+
}
|
|
12
|
+
function roots(value) {
|
|
13
|
+
if (value === undefined)
|
|
14
|
+
return undefined;
|
|
15
|
+
if (!Array.isArray(value))
|
|
16
|
+
return null;
|
|
17
|
+
const result = [];
|
|
18
|
+
for (const item of value) {
|
|
19
|
+
if (!record(item))
|
|
20
|
+
return null;
|
|
21
|
+
const allowed = new Set([
|
|
22
|
+
"kind",
|
|
23
|
+
"path",
|
|
24
|
+
"id",
|
|
25
|
+
"repositoryId",
|
|
26
|
+
"aliases",
|
|
27
|
+
"caseSensitive",
|
|
28
|
+
]);
|
|
29
|
+
if (Object.keys(item).some((key) => !allowed.has(key)))
|
|
30
|
+
return null;
|
|
31
|
+
if (!["workspace", "home", "temp"].includes(String(item.kind)) ||
|
|
32
|
+
typeof item.path !== "string" ||
|
|
33
|
+
(item.id !== undefined && typeof item.id !== "string") ||
|
|
34
|
+
(item.repositoryId !== undefined &&
|
|
35
|
+
typeof item.repositoryId !== "string") ||
|
|
36
|
+
(item.caseSensitive !== undefined &&
|
|
37
|
+
typeof item.caseSensitive !== "boolean") ||
|
|
38
|
+
(item.aliases !== undefined &&
|
|
39
|
+
(!Array.isArray(item.aliases) ||
|
|
40
|
+
item.aliases.some((alias) => typeof alias !== "string"))))
|
|
41
|
+
return null;
|
|
42
|
+
result.push(item);
|
|
43
|
+
}
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
function policy(value) {
|
|
47
|
+
if (value === undefined)
|
|
48
|
+
return {};
|
|
49
|
+
if (!record(value))
|
|
50
|
+
return undefined;
|
|
51
|
+
if (Object.keys(value).some((key) => !["secrets", "roots", "destination"].includes(key)))
|
|
52
|
+
return undefined;
|
|
53
|
+
const trusted = roots(value.roots);
|
|
54
|
+
if (trusted === null)
|
|
55
|
+
return undefined;
|
|
56
|
+
if (value.secrets !== undefined &&
|
|
57
|
+
(!Array.isArray(value.secrets) ||
|
|
58
|
+
value.secrets.some((secret) => typeof secret !== "string")))
|
|
59
|
+
return undefined;
|
|
60
|
+
if (value.destination !== undefined &&
|
|
61
|
+
value.destination !== "public" &&
|
|
62
|
+
value.destination !== "private-portable")
|
|
63
|
+
return undefined;
|
|
64
|
+
return {
|
|
65
|
+
...(value.secrets ? { secrets: value.secrets } : {}),
|
|
66
|
+
...(trusted ? { roots: trusted } : {}),
|
|
67
|
+
...(value.destination ? { destination: value.destination } : {}),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
export async function sanitizeStdioRequest(input) {
|
|
71
|
+
if (!record(input) || input.version !== 1 || !("value" in input))
|
|
72
|
+
return omit("invalid_input");
|
|
73
|
+
if (Object.keys(input).some((key) => !["version", "value", "policy"].includes(key)))
|
|
74
|
+
return omit("invalid_input");
|
|
75
|
+
const selected = policy(input.policy);
|
|
76
|
+
if (!selected)
|
|
77
|
+
return omit("policy_invalid");
|
|
78
|
+
let runtime;
|
|
79
|
+
try {
|
|
80
|
+
runtime = createNodeRedaction(selected);
|
|
81
|
+
return await runtime.scope.value(input.value);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return omit("invalid_input");
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
runtime?.dispose();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async function readInput() {
|
|
91
|
+
const chunks = [];
|
|
92
|
+
let bytes = 0;
|
|
93
|
+
for await (const chunk of process.stdin) {
|
|
94
|
+
const next = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
95
|
+
bytes += next.byteLength;
|
|
96
|
+
if (bytes > MAX_INPUT_BYTES) {
|
|
97
|
+
chunks.length = 0;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
chunks.push(next);
|
|
101
|
+
}
|
|
102
|
+
if (bytes > MAX_INPUT_BYTES)
|
|
103
|
+
return { ok: false, result: omit("limit_exceeded") };
|
|
104
|
+
try {
|
|
105
|
+
return {
|
|
106
|
+
ok: true,
|
|
107
|
+
value: JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks))),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return { ok: false, result: omit("invalid_input") };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
async function main() {
|
|
115
|
+
const input = await readInput();
|
|
116
|
+
const result = input.ok
|
|
117
|
+
? await sanitizeStdioRequest(input.value)
|
|
118
|
+
: input.result;
|
|
119
|
+
process.stdout.write(JSON.stringify(result));
|
|
120
|
+
}
|
|
121
|
+
if (process.argv[1] &&
|
|
122
|
+
import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
123
|
+
void main().catch(() => {
|
|
124
|
+
process.stdout.write(JSON.stringify(omit("invalid_input")));
|
|
125
|
+
});
|
|
126
|
+
}
|
package/dist/stream.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { RedactionScope } from "./sanitizer.js";
|
|
2
|
+
import { type Result, type Omission, type OmissionReason } from "./types.js";
|
|
3
|
+
export declare class CompleteBlock {
|
|
4
|
+
#private;
|
|
5
|
+
private readonly scope;
|
|
6
|
+
constructor(scope: RedactionScope);
|
|
7
|
+
append(chunk: string | Uint8Array): void;
|
|
8
|
+
cancel(reason?: OmissionReason): Omission;
|
|
9
|
+
finish(): Promise<Result<string>>;
|
|
10
|
+
}
|
package/dist/stream.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { LIMITS, omit, utf8Size, } from "./types.js";
|
|
2
|
+
export class CompleteBlock {
|
|
3
|
+
scope;
|
|
4
|
+
#decoder = new TextDecoder("utf-8", { fatal: true });
|
|
5
|
+
#parts = [];
|
|
6
|
+
#bytes = 0;
|
|
7
|
+
#result;
|
|
8
|
+
#pending;
|
|
9
|
+
#mode;
|
|
10
|
+
constructor(scope) {
|
|
11
|
+
this.scope = scope;
|
|
12
|
+
}
|
|
13
|
+
append(chunk) {
|
|
14
|
+
if (this.#result || this.#pending)
|
|
15
|
+
return;
|
|
16
|
+
try {
|
|
17
|
+
const mode = typeof chunk === "string" ? "text" : "bytes";
|
|
18
|
+
if (this.#mode && this.#mode !== mode) {
|
|
19
|
+
this.cancel("invalid_input");
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
this.#mode = mode;
|
|
23
|
+
this.#bytes +=
|
|
24
|
+
typeof chunk === "string" ? utf8Size(chunk) : chunk.byteLength;
|
|
25
|
+
if (this.#bytes > LIMITS.text || this.#parts.length >= 65536) {
|
|
26
|
+
this.cancel("limit_exceeded");
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
this.#parts.push(typeof chunk === "string"
|
|
30
|
+
? chunk
|
|
31
|
+
: this.#decoder.decode(chunk, { stream: true }));
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
this.cancel("invalid_input");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
cancel(reason = "cancelled") {
|
|
38
|
+
this.#parts = [];
|
|
39
|
+
this.#decoder = new TextDecoder("utf-8", { fatal: true });
|
|
40
|
+
const result = omit(reason);
|
|
41
|
+
if (!this.#result)
|
|
42
|
+
this.#result = result;
|
|
43
|
+
return this.#result.status === "omitted" ? this.#result : result;
|
|
44
|
+
}
|
|
45
|
+
async finish() {
|
|
46
|
+
if (this.#result)
|
|
47
|
+
return this.#result;
|
|
48
|
+
if (this.#pending)
|
|
49
|
+
return this.#pending;
|
|
50
|
+
try {
|
|
51
|
+
const text = this.#parts.join("") + this.#decoder.decode();
|
|
52
|
+
this.#parts = [];
|
|
53
|
+
this.#pending = this.scope
|
|
54
|
+
.text(text)
|
|
55
|
+
.then((result) => (this.#result ??= result));
|
|
56
|
+
return await this.#pending;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return this.cancel("invalid_input");
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { parentPort, workerData } from "node:worker_threads";
|
|
2
|
+
import { RedactionScope } from "./sanitizer.js";
|
|
3
|
+
import { secretlintDetector } from "./secretlint.js";
|
|
4
|
+
import { omit } from "./types.js";
|
|
5
|
+
const scope = new RedactionScope({
|
|
6
|
+
...workerData.policy,
|
|
7
|
+
detector: secretlintDetector,
|
|
8
|
+
});
|
|
9
|
+
parentPort.on("message", async ({ operation, input, buffer, }) => {
|
|
10
|
+
const state = new Int32Array(buffer, 0, 2);
|
|
11
|
+
const bytes = new Uint8Array(buffer, 8);
|
|
12
|
+
try {
|
|
13
|
+
const result = operation === "register"
|
|
14
|
+
? scope.registerSecrets(input)
|
|
15
|
+
: operation === "text"
|
|
16
|
+
? await scope.text(input)
|
|
17
|
+
: operation === "path"
|
|
18
|
+
? await scope.path(input)
|
|
19
|
+
: await scope.value(input);
|
|
20
|
+
const encoded = new TextEncoder().encode(JSON.stringify(result));
|
|
21
|
+
if (encoded.byteLength > bytes.length)
|
|
22
|
+
throw 0;
|
|
23
|
+
bytes.set(encoded);
|
|
24
|
+
Atomics.store(state, 1, encoded.byteLength);
|
|
25
|
+
Atomics.store(state, 0, 1);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
const encoded = new TextEncoder().encode(JSON.stringify(omit("detector_failure")));
|
|
29
|
+
bytes.set(encoded);
|
|
30
|
+
Atomics.store(state, 1, encoded.byteLength);
|
|
31
|
+
Atomics.store(state, 0, 1);
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
Atomics.notify(state, 0);
|
|
35
|
+
}
|
|
36
|
+
});
|
package/dist/sync.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type Json, type Policy, type Result, type LogicalPath } from "./types.js";
|
|
2
|
+
/** For synchronous database adapters; ordinary capture should use async scopes. */
|
|
3
|
+
export declare class SynchronousRedaction {
|
|
4
|
+
#private;
|
|
5
|
+
private readonly policy;
|
|
6
|
+
private readonly timeoutMs;
|
|
7
|
+
constructor(policy?: Omit<Policy, "detector">, timeoutMs?: number);
|
|
8
|
+
get policyId(): string;
|
|
9
|
+
text(value: string): Result<string>;
|
|
10
|
+
value(value: unknown): Result<Json>;
|
|
11
|
+
path(value: string): Result<LogicalPath>;
|
|
12
|
+
registerSecrets(values: readonly string[]): boolean;
|
|
13
|
+
dispose(): void;
|
|
14
|
+
}
|
package/dist/sync.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { Worker } from "node:worker_threads";
|
|
2
|
+
import { LIMITS, omit, utf8Size, } from "./types.js";
|
|
3
|
+
/** Clone only plain DTO data before structured-clone could invoke accessors. */
|
|
4
|
+
function snapshot(input) {
|
|
5
|
+
let bytes = 0, nodes = 0;
|
|
6
|
+
const seen = new Set();
|
|
7
|
+
const visit = (value, depth) => {
|
|
8
|
+
if (++nodes > 100_000 || depth > LIMITS.depth)
|
|
9
|
+
throw 0;
|
|
10
|
+
if (typeof value === "string") {
|
|
11
|
+
bytes += utf8Size(value);
|
|
12
|
+
if (bytes > LIMITS.record)
|
|
13
|
+
throw 0;
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
if (value === null ||
|
|
17
|
+
typeof value === "boolean" ||
|
|
18
|
+
(typeof value === "number" && Number.isFinite(value)))
|
|
19
|
+
return value;
|
|
20
|
+
if (typeof value !== "object" ||
|
|
21
|
+
seen.has(value) ||
|
|
22
|
+
Object.getOwnPropertySymbols(value).length)
|
|
23
|
+
throw 0;
|
|
24
|
+
const array = Array.isArray(value), prototype = Object.getPrototypeOf(value);
|
|
25
|
+
if (array
|
|
26
|
+
? prototype !== Array.prototype
|
|
27
|
+
: prototype !== Object.prototype && prototype !== null)
|
|
28
|
+
throw 0;
|
|
29
|
+
seen.add(value);
|
|
30
|
+
const result = Object.create(null), items = [];
|
|
31
|
+
for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(value))) {
|
|
32
|
+
if (array && key === "length")
|
|
33
|
+
continue;
|
|
34
|
+
bytes += utf8Size(key) + 8;
|
|
35
|
+
if (bytes > LIMITS.record ||
|
|
36
|
+
!descriptor.enumerable ||
|
|
37
|
+
!("value" in descriptor) ||
|
|
38
|
+
["toJSON", "__proto__", "constructor", "prototype"].includes(key))
|
|
39
|
+
throw 0;
|
|
40
|
+
if (array && key !== String(items.length))
|
|
41
|
+
throw 0;
|
|
42
|
+
const next = visit(descriptor.value, depth + 1);
|
|
43
|
+
if (array)
|
|
44
|
+
items.push(next);
|
|
45
|
+
else
|
|
46
|
+
result[key] = next;
|
|
47
|
+
}
|
|
48
|
+
if (array && items.length !== value.length)
|
|
49
|
+
throw 0;
|
|
50
|
+
seen.delete(value);
|
|
51
|
+
return array ? items : result;
|
|
52
|
+
};
|
|
53
|
+
return visit(input, 0);
|
|
54
|
+
}
|
|
55
|
+
function freeze(value) {
|
|
56
|
+
if (value && typeof value === "object") {
|
|
57
|
+
for (const item of Object.values(value))
|
|
58
|
+
freeze(item);
|
|
59
|
+
Object.freeze(value);
|
|
60
|
+
}
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
/** For synchronous database adapters; ordinary capture should use async scopes. */
|
|
64
|
+
export class SynchronousRedaction {
|
|
65
|
+
policy;
|
|
66
|
+
timeoutMs;
|
|
67
|
+
#worker;
|
|
68
|
+
#closed = false;
|
|
69
|
+
#buffer = new SharedArrayBuffer(LIMITS.record + 4104);
|
|
70
|
+
constructor(policy = {}, timeoutMs = 10_000) {
|
|
71
|
+
this.policy = policy;
|
|
72
|
+
this.timeoutMs = timeoutMs;
|
|
73
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 60_000)
|
|
74
|
+
throw new Error("policy_invalid");
|
|
75
|
+
}
|
|
76
|
+
get policyId() {
|
|
77
|
+
return `agent-redaction/1/${this.policy.destination ?? "public"}/secretlint-13.0.5-explicit-27-v1`;
|
|
78
|
+
}
|
|
79
|
+
#call(operation, input) {
|
|
80
|
+
if (this.#closed)
|
|
81
|
+
return omit("policy_invalid");
|
|
82
|
+
let safeInput;
|
|
83
|
+
try {
|
|
84
|
+
safeInput = snapshot(input);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return omit("invalid_input");
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
if (!this.#worker) {
|
|
91
|
+
this.#worker = new Worker(new URL("./sync-worker.js", import.meta.url), {
|
|
92
|
+
workerData: { policy: this.policy },
|
|
93
|
+
env: {},
|
|
94
|
+
stdout: true,
|
|
95
|
+
stderr: true,
|
|
96
|
+
resourceLimits: { maxOldGenerationSizeMb: 128 },
|
|
97
|
+
});
|
|
98
|
+
this.#worker.stdout?.resume();
|
|
99
|
+
this.#worker.stderr?.resume();
|
|
100
|
+
this.#worker.on("error", () => this.dispose());
|
|
101
|
+
this.#worker.unref();
|
|
102
|
+
}
|
|
103
|
+
const buffer = this.#buffer;
|
|
104
|
+
const state = new Int32Array(buffer, 0, 2);
|
|
105
|
+
Atomics.store(state, 0, 0);
|
|
106
|
+
Atomics.store(state, 1, 0);
|
|
107
|
+
this.#worker.postMessage({ operation, input: safeInput, buffer });
|
|
108
|
+
if (Atomics.wait(state, 0, 0, this.timeoutMs) === "timed-out" ||
|
|
109
|
+
Atomics.load(state, 0) !== 1) {
|
|
110
|
+
this.dispose();
|
|
111
|
+
return omit("detector_failure");
|
|
112
|
+
}
|
|
113
|
+
return freeze(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(new Uint8Array(buffer, 8, Atomics.load(state, 1)))));
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
this.dispose();
|
|
117
|
+
return omit("detector_failure");
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
text(value) {
|
|
121
|
+
return this.#call("text", value);
|
|
122
|
+
}
|
|
123
|
+
value(value) {
|
|
124
|
+
return this.#call("value", value);
|
|
125
|
+
}
|
|
126
|
+
path(value) {
|
|
127
|
+
return this.#call("path", value);
|
|
128
|
+
}
|
|
129
|
+
registerSecrets(values) {
|
|
130
|
+
const result = this.#call("register", values);
|
|
131
|
+
if (result !== true) {
|
|
132
|
+
this.dispose();
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
dispose() {
|
|
138
|
+
this.#closed = true;
|
|
139
|
+
void this.#worker?.terminate();
|
|
140
|
+
this.#worker = undefined;
|
|
141
|
+
}
|
|
142
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export type Json = null | boolean | number | string | readonly Json[] | {
|
|
2
|
+
readonly [key: string]: Json;
|
|
3
|
+
};
|
|
4
|
+
export type OmissionReason = "invalid_input" | "limit_exceeded" | "detector_failure" | "policy_invalid" | "cancelled" | "incomplete";
|
|
5
|
+
export interface SanitizationSummary {
|
|
6
|
+
readonly version: 1;
|
|
7
|
+
readonly policy: string;
|
|
8
|
+
readonly coverage: "portable" | "credential-detectors";
|
|
9
|
+
readonly redacted: boolean;
|
|
10
|
+
readonly pathsNormalized: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface Sanitized<T> {
|
|
13
|
+
readonly status: "sanitized";
|
|
14
|
+
readonly value: T;
|
|
15
|
+
readonly summary: SanitizationSummary;
|
|
16
|
+
}
|
|
17
|
+
export interface Omission {
|
|
18
|
+
readonly status: "omitted";
|
|
19
|
+
readonly reason: OmissionReason;
|
|
20
|
+
}
|
|
21
|
+
export type Result<T> = Sanitized<T> | Omission;
|
|
22
|
+
export interface Span {
|
|
23
|
+
readonly start: number;
|
|
24
|
+
readonly end: number;
|
|
25
|
+
}
|
|
26
|
+
export interface Detector {
|
|
27
|
+
/** Static reviewed inventory identity, never source or runtime diagnostics. */
|
|
28
|
+
readonly id: string;
|
|
29
|
+
scan(text: string): Promise<readonly Span[]>;
|
|
30
|
+
}
|
|
31
|
+
export interface TrustedRoot {
|
|
32
|
+
readonly kind: "workspace" | "home" | "temp";
|
|
33
|
+
readonly path: string;
|
|
34
|
+
readonly id?: string;
|
|
35
|
+
readonly repositoryId?: string;
|
|
36
|
+
readonly aliases?: readonly string[];
|
|
37
|
+
readonly caseSensitive?: boolean;
|
|
38
|
+
}
|
|
39
|
+
export type LogicalPath = Readonly<{
|
|
40
|
+
kind: "workspace";
|
|
41
|
+
workspaceId: string;
|
|
42
|
+
repositoryId?: string;
|
|
43
|
+
relativePath: string;
|
|
44
|
+
display: string;
|
|
45
|
+
resolvable: true;
|
|
46
|
+
}> | Readonly<{
|
|
47
|
+
kind: "external";
|
|
48
|
+
display: string;
|
|
49
|
+
resolvable: false;
|
|
50
|
+
}>;
|
|
51
|
+
export interface Policy {
|
|
52
|
+
readonly secrets?: readonly string[];
|
|
53
|
+
readonly roots?: readonly TrustedRoot[];
|
|
54
|
+
readonly destination?: "private-portable" | "public";
|
|
55
|
+
readonly detector?: Detector;
|
|
56
|
+
}
|
|
57
|
+
export declare const LIMITS: Readonly<{
|
|
58
|
+
text: number;
|
|
59
|
+
record: number;
|
|
60
|
+
depth: 32;
|
|
61
|
+
secrets: 1024;
|
|
62
|
+
secretBytes: number;
|
|
63
|
+
}>;
|
|
64
|
+
export declare const REDACTED = "[REDACTED]";
|
|
65
|
+
export declare const EXTERNAL = "[EXTERNAL_PATH]";
|
|
66
|
+
export declare function omit(reason: OmissionReason): Omission;
|
|
67
|
+
export declare function utf8Size(text: string): number;
|
|
68
|
+
export declare function validUnicode(text: string): boolean;
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export const LIMITS = Object.freeze({
|
|
2
|
+
text: 2 * 1024 * 1024,
|
|
3
|
+
record: 4 * 1024 * 1024,
|
|
4
|
+
depth: 32,
|
|
5
|
+
secrets: 1024,
|
|
6
|
+
secretBytes: 1024 * 1024,
|
|
7
|
+
});
|
|
8
|
+
export const REDACTED = "[REDACTED]";
|
|
9
|
+
export const EXTERNAL = "[EXTERNAL_PATH]";
|
|
10
|
+
export function omit(reason) {
|
|
11
|
+
return Object.freeze({ status: "omitted", reason });
|
|
12
|
+
}
|
|
13
|
+
export function utf8Size(text) {
|
|
14
|
+
return new TextEncoder().encode(text).byteLength;
|
|
15
|
+
}
|
|
16
|
+
export function validUnicode(text) {
|
|
17
|
+
return !/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u.test(text);
|
|
18
|
+
}
|