agent-sanitizer 2.11.0 → 2.13.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.
|
@@ -17,6 +17,10 @@
|
|
|
17
17
|
* rather than at module load where a throw would abort before that catch installs
|
|
18
18
|
* and let the harness pass the tool output through UNSANITIZED (fail OPEN).
|
|
19
19
|
*/
|
|
20
|
+
// The package's own subpath, so the bundle resolves it through the pinned
|
|
21
|
+
// engine alias and an external consumer resolves the same specifier.
|
|
22
|
+
import { credentialNameMatcher } from "agent-sanitizer/credential-names-matcher";
|
|
23
|
+
|
|
20
24
|
import credentialNames from "../../python/agent_sanitizer/secrets/data/credential-names.json" with { type: "json" };
|
|
21
25
|
import inferenceKeys from "../config/inference-key-vars.json" with { type: "json" };
|
|
22
26
|
import scrubbed from "../config/scrubbed-env-vars.json" with { type: "json" };
|
|
@@ -127,144 +131,29 @@ function hostExtraSecretVars() {
|
|
|
127
131
|
return vars;
|
|
128
132
|
}
|
|
129
133
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
// list, which would make the match regex accept nothing and leak every forwarded
|
|
133
|
-
// credential) fails closed here instead of silently under-matching. Digits are
|
|
134
|
-
// admitted because a noun part may legitimately carry one (the vocabulary's parts
|
|
135
|
-
// are `[a-z0-9]+`); a digit cannot be a metacharacter, so it is safe to embed.
|
|
136
|
-
const CRED_TOKEN_RE = /^[A-Z0-9_]+$/;
|
|
137
|
-
|
|
138
|
-
const VOCAB_LABEL = "credential-names.json";
|
|
139
|
-
|
|
140
|
-
// Names ending in a credential noun whose value is not a secret. SSH_AUTH_SOCK
|
|
141
|
-
// holds a filesystem path, and redacting it would strip the agent socket out of
|
|
142
|
-
// tool output. Site-independent (anyone running an ssh-agent has it), so it lives
|
|
143
|
-
// with the consumer rather than in the published vocabulary, which describes
|
|
144
|
-
// which WORDS name a secret and cannot know about a specific variable.
|
|
145
|
-
const EXCLUDE_NAMES = ["SSH_AUTH_SOCK"];
|
|
146
|
-
|
|
147
|
-
// Which noun renderings apply to a variable NAME (the other use, `field-value`,
|
|
148
|
-
// feeds the redactor's `field = value` matcher and is not a name matcher).
|
|
149
|
-
const ENV_NAME_USE = "env-name";
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* The whole-name forms of `parts`: underscore-joined and bare-joined, upper-cased.
|
|
153
|
-
* Both are emitted because both spellings occur in the wild (`API_KEY`, `APIKEY`)
|
|
154
|
-
* and a matcher anchored on underscore-delimited segments sees them as different
|
|
155
|
-
* tokens; a single-part noun collapses to one form. Mirrors the Python renderer
|
|
156
|
-
* (`_segment_forms` in agent_sanitizer/secrets/credential_names.py) so the two
|
|
157
|
-
* ecosystems derive the same vocabulary from the same file.
|
|
158
|
-
* @param {string[]} parts
|
|
159
|
-
* @returns {string[]}
|
|
160
|
-
*/
|
|
161
|
-
function segmentForms(parts) {
|
|
162
|
-
return [
|
|
163
|
-
...new Set([parts.join("_").toUpperCase(), parts.join("").toUpperCase()]),
|
|
164
|
-
];
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* Render the published credential-noun vocabulary into the name-matcher spec:
|
|
169
|
-
* the credential segments, and the trailing suffixes that mark a
|
|
170
|
-
* credential-shaped name as holding a non-secret.
|
|
171
|
-
*
|
|
172
|
-
* The vocabulary is the SINGLE source both ecosystems read — a curated second
|
|
173
|
-
* copy of these renderings is what let this matcher fall twelve segments behind
|
|
174
|
-
* the engine, so a variable named `…_ACCESS_TOKEN` was never recognized as
|
|
175
|
-
* credential-bearing and its value was never handed to the redactor.
|
|
176
|
-
* @param {Record<string, any>} spec
|
|
177
|
-
* @returns {{ segments: string[], excludeSuffixes: string[], excludeNames: string[] }}
|
|
178
|
-
*/
|
|
179
|
-
export function deriveCredentialVocabulary(spec) {
|
|
180
|
-
const nouns = spec?.nouns;
|
|
181
|
-
const nonSecret = spec?.nonSecretSuffixes;
|
|
182
|
-
if (!Array.isArray(nouns) || !Array.isArray(nonSecret))
|
|
183
|
-
throw new Error(`${VOCAB_LABEL}: nouns/nonSecretSuffixes missing`);
|
|
184
|
-
const segments = [];
|
|
185
|
-
for (const noun of nouns)
|
|
186
|
-
if (Array.isArray(noun?.uses) && noun.uses.includes(ENV_NAME_USE))
|
|
187
|
-
segments.push(...segmentForms(parts(noun.parts, "nouns[].parts")));
|
|
188
|
-
const excludeSuffixes = nonSecret.flatMap((suffix) =>
|
|
189
|
-
segmentForms(parts(suffix, "nonSecretSuffixes[]")).map(
|
|
190
|
-
(form) => `_${form}`,
|
|
191
|
-
),
|
|
192
|
-
);
|
|
193
|
-
return {
|
|
194
|
-
segments: [...new Set(segments)],
|
|
195
|
-
excludeSuffixes: [...new Set(excludeSuffixes)],
|
|
196
|
-
excludeNames: EXCLUDE_NAMES,
|
|
197
|
-
};
|
|
198
|
-
}
|
|
199
|
-
|
|
134
|
+
/** @type {((name: string) => boolean) | undefined} */
|
|
135
|
+
let _credentialRule;
|
|
200
136
|
/**
|
|
201
|
-
*
|
|
202
|
-
* malformed part must not render into a token that silently under-matches.
|
|
203
|
-
* @param {unknown} value
|
|
204
|
-
* @param {string} field
|
|
205
|
-
* @returns {string[]}
|
|
206
|
-
*/
|
|
207
|
-
function parts(value, field) {
|
|
208
|
-
if (!Array.isArray(value) || value.length === 0)
|
|
209
|
-
throw new Error(`${VOCAB_LABEL}: ${field} is empty or missing`);
|
|
210
|
-
for (const part of value)
|
|
211
|
-
if (typeof part !== "string" || !/^[a-z0-9]+$/u.test(part))
|
|
212
|
-
throw new Error(`${VOCAB_LABEL}: bad part ${part} in ${field}`);
|
|
213
|
-
return value;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* The validated token list under `field`, or throw. An absent, empty, or
|
|
218
|
-
* metacharacter-bearing list must not degrade into a pattern that matches nothing,
|
|
219
|
-
* which would leak every forwarded credential.
|
|
220
|
-
* @param {Record<string, unknown>} spec
|
|
221
|
-
* @param {string} field
|
|
222
|
-
* @returns {string[]}
|
|
223
|
-
*/
|
|
224
|
-
function credentialTokens(spec, field) {
|
|
225
|
-
const group = spec[field];
|
|
226
|
-
if (!Array.isArray(group) || group.length === 0)
|
|
227
|
-
throw new Error(`${VOCAB_LABEL}: ${field} is empty or missing`);
|
|
228
|
-
for (const token of group)
|
|
229
|
-
if (typeof token !== "string" || !CRED_TOKEN_RE.test(token))
|
|
230
|
-
throw new Error(`${VOCAB_LABEL}: bad token ${token} in ${field}`);
|
|
231
|
-
return group;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* Validate a rendered name-matcher spec and build its match/exclude regexes. Pure
|
|
236
|
-
* and exported so the fail-closed paths can be driven directly with a bad spec.
|
|
237
|
-
* @param {Record<string, unknown>} spec
|
|
238
|
-
* @returns {{ match: RegExp, exclude: RegExp }}
|
|
239
|
-
*/
|
|
240
|
-
export function buildCredentialNameRes(spec) {
|
|
241
|
-
const segments = credentialTokens(spec, "segments");
|
|
242
|
-
const excludeSuffixes = credentialTokens(spec, "excludeSuffixes");
|
|
243
|
-
const excludeNames = credentialTokens(spec, "excludeNames");
|
|
244
|
-
return {
|
|
245
|
-
match: new RegExp(`(?:^|_)(?:${segments.join("|")})$`, "i"),
|
|
246
|
-
exclude: new RegExp(
|
|
247
|
-
`(?:${excludeSuffixes.join("|")})$|^(?:${excludeNames.join("|")})$`,
|
|
248
|
-
"i",
|
|
249
|
-
),
|
|
250
|
-
};
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
/** @type {{ match: RegExp, exclude: RegExp } | undefined} */
|
|
254
|
-
let _credentialNameRes;
|
|
255
|
-
/**
|
|
256
|
-
* The credential-var-NAME regexes, memoized after the first build. Matching by
|
|
137
|
+
* The credential-var-NAME predicate, memoized after the first build. Matching by
|
|
257
138
|
* trailing segment lets the redaction set self-populate with any token the
|
|
258
139
|
* process actually holds; a curated list drifts. The curated sets
|
|
259
140
|
* (inferenceKeyVars + scrubbed vars) stay the guaranteed floor; this only ADDS
|
|
260
141
|
* lookalikes.
|
|
261
|
-
*
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
142
|
+
*
|
|
143
|
+
* credentialNameMatcher is the package's one credential-name decision. A second
|
|
144
|
+
* derivation here is how this module came to apply the vocabulary's non-secret
|
|
145
|
+
* runs only when something preceded them, so a variable named exactly
|
|
146
|
+
* `PUBLIC_KEY` was reported credential-bearing and its value cut out of every
|
|
147
|
+
* tool output carrying it. One decision means the two cannot disagree again.
|
|
148
|
+
* @returns {(name: string) => boolean}
|
|
149
|
+
*/
|
|
150
|
+
function credentialRule() {
|
|
151
|
+
if (_credentialRule !== undefined) return _credentialRule;
|
|
152
|
+
return (_credentialRule = credentialNameMatcher({
|
|
153
|
+
spec: credentialNames,
|
|
154
|
+
scope: "trailing",
|
|
155
|
+
declineNonSecret: true,
|
|
156
|
+
}));
|
|
268
157
|
}
|
|
269
158
|
|
|
270
159
|
/**
|
|
@@ -274,8 +163,7 @@ function credentialNameRes() {
|
|
|
274
163
|
* @returns {boolean}
|
|
275
164
|
*/
|
|
276
165
|
export function looksLikeCredentialVar(name) {
|
|
277
|
-
|
|
278
|
-
return res.match.test(name) && !res.exclude.test(name);
|
|
166
|
+
return credentialRule()(name);
|
|
279
167
|
}
|
|
280
168
|
|
|
281
169
|
/**
|
|
@@ -300,9 +188,9 @@ export function dynamicSecretVars(env = process.env) {
|
|
|
300
188
|
// the set without this.
|
|
301
189
|
const EXTRA_SECRET_VARS_ENV = "_AGENT_SANITIZER_EXTRA_SECRET_VARS";
|
|
302
190
|
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
//
|
|
191
|
+
// These are whole variable names an operator typed, and real ones carry digits
|
|
192
|
+
// (`AWS_S3_KEY2`); metacharacters stay excluded so a malformed entry fails
|
|
193
|
+
// closed rather than widening the set.
|
|
306
194
|
const EXTRA_TOKEN_RE = /^[A-Z0-9_]+$/;
|
|
307
195
|
|
|
308
196
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.13.0",
|
|
4
4
|
"description": "Defend an agent against hidden-content injection: strip payload-capable invisible Unicode and ANSI, splice out human-invisible HTML, and flag data-exfil URLs in untrusted text before any model sees it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
]
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
|
-
"sanitizer-engine": "npm:agent-sanitizer@2.
|
|
49
|
+
"sanitizer-engine": "npm:agent-sanitizer@2.12.0",
|
|
50
50
|
"@commitlint/cli": "^21.0.1",
|
|
51
51
|
"@commitlint/config-conventional": "^21.0.1",
|
|
52
52
|
"@eslint/js": "10.0.1",
|
|
@@ -34,33 +34,6 @@ export function configureEnvConfigSource(source: {
|
|
|
34
34
|
* @returns {number}
|
|
35
35
|
*/
|
|
36
36
|
export function minEnvSecretLen(): number;
|
|
37
|
-
/**
|
|
38
|
-
* Render the published credential-noun vocabulary into the name-matcher spec:
|
|
39
|
-
* the credential segments, and the trailing suffixes that mark a
|
|
40
|
-
* credential-shaped name as holding a non-secret.
|
|
41
|
-
*
|
|
42
|
-
* The vocabulary is the SINGLE source both ecosystems read — a curated second
|
|
43
|
-
* copy of these renderings is what let this matcher fall twelve segments behind
|
|
44
|
-
* the engine, so a variable named `…_ACCESS_TOKEN` was never recognized as
|
|
45
|
-
* credential-bearing and its value was never handed to the redactor.
|
|
46
|
-
* @param {Record<string, any>} spec
|
|
47
|
-
* @returns {{ segments: string[], excludeSuffixes: string[], excludeNames: string[] }}
|
|
48
|
-
*/
|
|
49
|
-
export function deriveCredentialVocabulary(spec: Record<string, any>): {
|
|
50
|
-
segments: string[];
|
|
51
|
-
excludeSuffixes: string[];
|
|
52
|
-
excludeNames: string[];
|
|
53
|
-
};
|
|
54
|
-
/**
|
|
55
|
-
* Validate a rendered name-matcher spec and build its match/exclude regexes. Pure
|
|
56
|
-
* and exported so the fail-closed paths can be driven directly with a bad spec.
|
|
57
|
-
* @param {Record<string, unknown>} spec
|
|
58
|
-
* @returns {{ match: RegExp, exclude: RegExp }}
|
|
59
|
-
*/
|
|
60
|
-
export function buildCredentialNameRes(spec: Record<string, unknown>): {
|
|
61
|
-
match: RegExp;
|
|
62
|
-
exclude: RegExp;
|
|
63
|
-
};
|
|
64
37
|
/**
|
|
65
38
|
* True when `name` looks like a credential-bearing variable (and isn't a known
|
|
66
39
|
* non-secret lookalike).
|