agent-sanitizer 2.5.0 → 2.7.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/README.md +27 -10
- package/package.json +5 -1
- package/src/credential-names.mjs +226 -0
- package/types/credential-names.d.mts +48 -0
package/README.md
CHANGED
|
@@ -219,21 +219,38 @@ await rehydrateRedacted("Edit", toolInput, {
|
|
|
219
219
|
}); // { updatedInput, context } | { deny } | null — a deny never exposes a secret
|
|
220
220
|
```
|
|
221
221
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
222
|
+
Ask whether a variable NAME holds a credential — don't render the noun list into
|
|
223
|
+
a pattern of your own. Sharing the words but not the rule re-derives the same
|
|
224
|
+
bugs: matching only when the noun ENDS the name misses `DEPLOY_TOKEN_ORG`, a
|
|
225
|
+
case-sensitive match misses npm's lower-case `npm_config__authToken` channel, and
|
|
226
|
+
one alternation of the nouns backtracks polynomially on a long name.
|
|
227
|
+
|
|
228
|
+
`scope` is the choice that is genuinely yours: `trailing` for a redactor, which
|
|
229
|
+
must not mangle text a human reads; `any-segment` for an env scrub, where an
|
|
230
|
+
unstripped credential leaks silently but an over-stripped one breaks loudly.
|
|
227
231
|
|
|
228
232
|
```js
|
|
229
|
-
import {
|
|
230
|
-
|
|
231
|
-
|
|
233
|
+
import { credentialNameMatcher } from "agent-sanitizer/credential-names-matcher";
|
|
234
|
+
const holds = credentialNameMatcher({ scope: "any-segment" }); // build once
|
|
235
|
+
holds("TEMPLATE_SYNC_TOKEN_ORG"); // true
|
|
236
|
+
holds("AWS_ACCESS_KEY_ID"); // false — an identifier, not a secret
|
|
232
237
|
```
|
|
233
238
|
|
|
234
239
|
```python
|
|
235
|
-
from agent_sanitizer.secrets import
|
|
236
|
-
|
|
240
|
+
from agent_sanitizer.secrets import credential_name_matcher
|
|
241
|
+
holds = credential_name_matcher(scope="any-segment")
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
The vocabulary stays published as data for a consumer that needs the words rather
|
|
245
|
+
than the predicate (a generated config, an alternation for a different matcher).
|
|
246
|
+
Each noun's `uses` marks where it is valid: `env-name` inspects a variable NAME
|
|
247
|
+
only, `field-value` also redacts what follows `noun = ` (too broad for `key` and
|
|
248
|
+
`pat`, which stay name-only).
|
|
249
|
+
|
|
250
|
+
```js
|
|
251
|
+
import { createRequire } from "node:module";
|
|
252
|
+
createRequire(import.meta.url)("agent-sanitizer/credential-names").nouns;
|
|
253
|
+
// [{ parts: ["api", "key"], uses: ["env-name", "field-value"] }, …]
|
|
237
254
|
```
|
|
238
255
|
|
|
239
256
|
## Limits
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.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": {
|
|
@@ -113,6 +113,10 @@
|
|
|
113
113
|
"types": "./types/output.d.mts",
|
|
114
114
|
"default": "./src/output.mjs"
|
|
115
115
|
},
|
|
116
|
+
"./credential-names-matcher": {
|
|
117
|
+
"types": "./types/credential-names.d.mts",
|
|
118
|
+
"default": "./src/credential-names.mjs"
|
|
119
|
+
},
|
|
116
120
|
"./view-map": {
|
|
117
121
|
"types": "./types/view-map.d.mts",
|
|
118
122
|
"default": "./src/view-map.mjs"
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The credential-noun vocabulary, and the NAME matcher built from it.
|
|
3
|
+
*
|
|
4
|
+
* `agent-sanitizer/credential-names` publishes the vocabulary as data so a
|
|
5
|
+
* consumer derives its matcher from one list instead of forking one. That shares
|
|
6
|
+
* the words but not the RULE, and the rule is where the mistakes are: whether a
|
|
7
|
+
* noun must be the name's trailing segment or may sit anywhere in it, whether the
|
|
8
|
+
* comparison folds case, whether a multi-word noun is compared as one run, and
|
|
9
|
+
* whether the walk stays linear in the name's length. A consumer that renders the
|
|
10
|
+
* vocabulary into one alternation regex gets a pattern with polynomial
|
|
11
|
+
* backtracking on a long name; one that anchors on the trailing segment alone
|
|
12
|
+
* matches nothing at all for `DEPLOY_TOKEN_ORG` or `OAUTH_TOKEN_FALLBACK_4`.
|
|
13
|
+
*
|
|
14
|
+
* So the mechanics live here and the POLICY stays the caller's, selected by
|
|
15
|
+
* `scope`. The two scopes are not interchangeable and neither is a better
|
|
16
|
+
* default:
|
|
17
|
+
*
|
|
18
|
+
* * `"trailing"` — the noun is the name's last underscore-delimited run. What a
|
|
19
|
+
* REDACTOR wants: it decides what to cut out of text a human will read, where
|
|
20
|
+
* over-matching mangles legitimate output.
|
|
21
|
+
* * `"any-segment"` — the noun is any whole run of the name's segments. What an
|
|
22
|
+
* env-var SCRUB wants: it decides what a subprocess may inherit, where the two
|
|
23
|
+
* error directions are not symmetric — an unstripped credential leaks
|
|
24
|
+
* silently, an over-stripped variable breaks the command loudly.
|
|
25
|
+
*
|
|
26
|
+
* Matching is set membership over the name's underscore-delimited runs, never an
|
|
27
|
+
* alternation of the nouns: 28 prefix-sharing renderings (`API_KEY`, `APIKEY`,
|
|
28
|
+
* `ACCESS_KEY`, …) compile to a pattern a redos analyzer measures as polynomial,
|
|
29
|
+
* and a matcher a hostile variable NAME can stall has a denial-of-service in
|
|
30
|
+
* front of it. The run length is bounded by the longest noun, so the walk is
|
|
31
|
+
* linear in the name's segment count rather than quadratic.
|
|
32
|
+
*
|
|
33
|
+
* The vocabulary itself is `python/agent_sanitizer/secrets/data/credential-names.json`
|
|
34
|
+
* — the same file the Python renderings read and the same file the
|
|
35
|
+
* `agent-sanitizer/credential-names` subpath exports, so the ecosystems cannot
|
|
36
|
+
* drift apart on the words either.
|
|
37
|
+
*/
|
|
38
|
+
import { readFileSync } from "node:fs";
|
|
39
|
+
import { dirname, join } from "node:path";
|
|
40
|
+
import { fileURLToPath } from "node:url";
|
|
41
|
+
|
|
42
|
+
const DATA_FILE = join(
|
|
43
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
44
|
+
"..",
|
|
45
|
+
"python",
|
|
46
|
+
"agent_sanitizer",
|
|
47
|
+
"secrets",
|
|
48
|
+
"data",
|
|
49
|
+
"credential-names.json",
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const FILE_LABEL = "credential-names.json";
|
|
53
|
+
const ENV_NAME_USE = "env-name";
|
|
54
|
+
const FIELD_VALUE_USE = "field-value";
|
|
55
|
+
const KNOWN_USES = new Set([ENV_NAME_USE, FIELD_VALUE_USE]);
|
|
56
|
+
|
|
57
|
+
// a-z0-9 only is what lets a part interpolate into a consumer's pattern
|
|
58
|
+
// unescaped. Anchored with ^…$ over a single character class, so a part carrying
|
|
59
|
+
// a newline is rejected here rather than accepted and then rejected by the Python
|
|
60
|
+
// validator applying the same rule to the same file.
|
|
61
|
+
const PART_RE = /^[a-z0-9]+$/;
|
|
62
|
+
|
|
63
|
+
/** @param {string[]} values @returns {string[]} `values` without duplicates, first-occurrence order kept. */
|
|
64
|
+
function dedupe(values) {
|
|
65
|
+
return [...new Set(values)];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The whole-name forms of `parts`: underscore-joined and bare-joined, upper-cased.
|
|
69
|
+
*
|
|
70
|
+
* Both are emitted because both spellings occur in the wild (`API_KEY` and
|
|
71
|
+
* `APIKEY`) and a matcher comparing underscore-delimited runs sees them as
|
|
72
|
+
* different tokens. A single-part noun collapses to one form.
|
|
73
|
+
* @param {string[]} parts @returns {string[]} */
|
|
74
|
+
function segmentForms(parts) {
|
|
75
|
+
return dedupe([parts.join("_").toUpperCase(), parts.join("").toUpperCase()]);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** `value` as a validated array of noun parts, or throw naming `field`.
|
|
79
|
+
* @param {unknown} value @param {string} field @returns {string[]} */
|
|
80
|
+
function parts(value, field) {
|
|
81
|
+
if (!Array.isArray(value) || value.length === 0)
|
|
82
|
+
throw new Error(`${FILE_LABEL}: ${field} is empty or missing`);
|
|
83
|
+
const bad = value.filter(
|
|
84
|
+
(part) => typeof part !== "string" || !PART_RE.test(part),
|
|
85
|
+
);
|
|
86
|
+
if (bad.length)
|
|
87
|
+
throw new Error(
|
|
88
|
+
`${FILE_LABEL}: bad part(s) ${JSON.stringify(bad)} in ${field}`,
|
|
89
|
+
);
|
|
90
|
+
return value;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** `value` as a validated non-empty subset of the known uses, or throw.
|
|
94
|
+
*
|
|
95
|
+
* An unknown use is a refusal, not a skip: silently ignoring it would drop the
|
|
96
|
+
* noun from every rendering, which is how a credential noun becomes inert.
|
|
97
|
+
* @param {unknown} value @param {string} field @returns {Set<string>} */
|
|
98
|
+
function uses(value, field) {
|
|
99
|
+
if (!Array.isArray(value) || value.length === 0)
|
|
100
|
+
throw new Error(`${FILE_LABEL}: ${field} is empty or missing`);
|
|
101
|
+
const unknown = value.filter((use) => !KNOWN_USES.has(use)).sort();
|
|
102
|
+
if (unknown.length)
|
|
103
|
+
throw new Error(
|
|
104
|
+
`${FILE_LABEL}: unknown use(s) ${JSON.stringify(unknown)} in ${field}`,
|
|
105
|
+
);
|
|
106
|
+
return new Set(value);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** @typedef {{ segments: string[], fieldNamePatterns: string[], nonSecretSegments: string[] }} CredentialNames */
|
|
110
|
+
|
|
111
|
+
/** Validate `spec` and return its renderings — the JavaScript twin of
|
|
112
|
+
* `agent_sanitizer.secrets.parse_credential_names`, over the same file.
|
|
113
|
+
*
|
|
114
|
+
* A malformed spec throws rather than degrading. An empty list would render an
|
|
115
|
+
* alternation that matches nothing (every credential forwarded verbatim) and a
|
|
116
|
+
* part carrying a regex metacharacter one that matches everything (all output
|
|
117
|
+
* blanked), so neither may reach a consumer's matcher.
|
|
118
|
+
* @param {Record<string, unknown>} spec @returns {CredentialNames} */
|
|
119
|
+
export function parseCredentialNames(spec) {
|
|
120
|
+
const nouns = spec?.nouns;
|
|
121
|
+
if (!Array.isArray(nouns) || nouns.length === 0)
|
|
122
|
+
throw new Error(`${FILE_LABEL}: nouns is empty or missing`);
|
|
123
|
+
/** @type {string[]} */
|
|
124
|
+
const segments = [];
|
|
125
|
+
/** @type {string[]} */
|
|
126
|
+
const fieldNamePatterns = [];
|
|
127
|
+
nouns.forEach((noun, index) => {
|
|
128
|
+
if (typeof noun !== "object" || noun === null || Array.isArray(noun))
|
|
129
|
+
throw new Error(`${FILE_LABEL}: nouns[${index}] is not an object`);
|
|
130
|
+
const nounParts = parts(noun.parts, `nouns[${index}].parts`);
|
|
131
|
+
const nounUses = uses(noun.uses, `nouns[${index}].uses`);
|
|
132
|
+
if (nounUses.has(ENV_NAME_USE)) segments.push(...segmentForms(nounParts));
|
|
133
|
+
if (nounUses.has(FIELD_VALUE_USE))
|
|
134
|
+
fieldNamePatterns.push(nounParts.join("[_-]?"));
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const suffixes = spec?.nonSecretSuffixes;
|
|
138
|
+
if (!Array.isArray(suffixes) || suffixes.length === 0)
|
|
139
|
+
throw new Error(`${FILE_LABEL}: nonSecretSuffixes is empty or missing`);
|
|
140
|
+
const nonSecretSegments = suffixes.flatMap((suffix, index) =>
|
|
141
|
+
segmentForms(parts(suffix, `nonSecretSuffixes[${index}]`)),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
// A vocabulary that renders nothing for one matcher would hand that consumer an
|
|
145
|
+
// empty set, which matches nothing and forwards every credential.
|
|
146
|
+
if (!segments.length)
|
|
147
|
+
throw new Error(`${FILE_LABEL}: no noun is marked ${ENV_NAME_USE}`);
|
|
148
|
+
if (!fieldNamePatterns.length)
|
|
149
|
+
throw new Error(`${FILE_LABEL}: no noun is marked ${FIELD_VALUE_USE}`);
|
|
150
|
+
return {
|
|
151
|
+
segments: dedupe(segments),
|
|
152
|
+
fieldNamePatterns: dedupe(fieldNamePatterns),
|
|
153
|
+
nonSecretSegments: dedupe(nonSecretSegments),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** @type {CredentialNames | undefined} */
|
|
158
|
+
let _packaged;
|
|
159
|
+
/** The validated renderings of the packaged vocabulary, memoized.
|
|
160
|
+
*
|
|
161
|
+
* Read lazily, never at module load: a static importer that crashed at LOAD would
|
|
162
|
+
* abort before its own fail-closed catch installs, and a guardrail that fails to
|
|
163
|
+
* load is a guardrail that fails OPEN. Deferring to first use routes a missing or
|
|
164
|
+
* corrupt data file into the caller's catch instead.
|
|
165
|
+
* @returns {CredentialNames} */
|
|
166
|
+
export function credentialNames() {
|
|
167
|
+
return (_packaged ??= parseCredentialNames(
|
|
168
|
+
JSON.parse(readFileSync(DATA_FILE, "utf8")),
|
|
169
|
+
));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** @typedef {"trailing" | "any-segment"} CredentialNameScope */
|
|
173
|
+
|
|
174
|
+
/** A predicate: does this env-var NAME hold a credential?
|
|
175
|
+
*
|
|
176
|
+
* `scope` selects the rule — `"trailing"` for a redactor (the noun must be the
|
|
177
|
+
* name's last run), `"any-segment"` for an env scrub (the noun may be any run).
|
|
178
|
+
* See this module's header for why that choice belongs to the caller.
|
|
179
|
+
*
|
|
180
|
+
* `declineNonSecret` applies the vocabulary's `nonSecretSuffixes`: a name ending
|
|
181
|
+
* in one holds an identifier or a public key (`AWS_ACCESS_KEY_ID`), not a secret.
|
|
182
|
+
* Leave it on for a redactor, where redacting an identifier out of output is a
|
|
183
|
+
* visible defect; turn it off for a scrub whose failure to strip is the worse
|
|
184
|
+
* error. It is applied to the name's trailing run under both scopes, because a
|
|
185
|
+
* non-secret marker only means anything at the end of a name.
|
|
186
|
+
*
|
|
187
|
+
* The returned predicate closes over the parsed vocabulary, so build it once and
|
|
188
|
+
* reuse it — the parse and validation are not repeated per name.
|
|
189
|
+
*
|
|
190
|
+
* @param {{ scope?: CredentialNameScope, declineNonSecret?: boolean, spec?: Record<string, unknown> }} [options]
|
|
191
|
+
* @returns {(name: string) => boolean} */
|
|
192
|
+
export function credentialNameMatcher(options = {}) {
|
|
193
|
+
const { scope = "trailing", declineNonSecret = true, spec } = options;
|
|
194
|
+
if (scope !== "trailing" && scope !== "any-segment")
|
|
195
|
+
throw new Error(
|
|
196
|
+
`credentialNameMatcher: unknown scope ${JSON.stringify(scope)}`,
|
|
197
|
+
);
|
|
198
|
+
const vocabulary = spec ? parseCredentialNames(spec) : credentialNames();
|
|
199
|
+
const nouns = new Set(vocabulary.segments);
|
|
200
|
+
const nonSecret = new Set(vocabulary.nonSecretSegments);
|
|
201
|
+
// The longest noun's run length. A run longer than this can match no noun, so
|
|
202
|
+
// bounding the walk here is what keeps it linear in the name's segment count —
|
|
203
|
+
// without it a 2000-underscore name costs quadratic time.
|
|
204
|
+
const maxRun = Math.max(
|
|
205
|
+
...vocabulary.segments.map((noun) => noun.split("_").length),
|
|
206
|
+
);
|
|
207
|
+
/** @param {string[]} words @returns {string[]} */
|
|
208
|
+
const trailingRuns = (words) =>
|
|
209
|
+
Array.from({ length: Math.min(maxRun, words.length) }, (_, i) =>
|
|
210
|
+
words.slice(words.length - (i + 1)).join("_"),
|
|
211
|
+
);
|
|
212
|
+
return (name) => {
|
|
213
|
+
const words = name.toUpperCase().split("_");
|
|
214
|
+
if (
|
|
215
|
+
declineNonSecret &&
|
|
216
|
+
trailingRuns(words).some((run) => nonSecret.has(run))
|
|
217
|
+
)
|
|
218
|
+
return false;
|
|
219
|
+
if (scope === "trailing")
|
|
220
|
+
return trailingRuns(words).some((run) => nouns.has(run));
|
|
221
|
+
for (let start = 0; start < words.length; start++)
|
|
222
|
+
for (let span = 1; span <= maxRun && start + span <= words.length; span++)
|
|
223
|
+
if (nouns.has(words.slice(start, start + span).join("_"))) return true;
|
|
224
|
+
return false;
|
|
225
|
+
};
|
|
226
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** @typedef {{ segments: string[], fieldNamePatterns: string[], nonSecretSegments: string[] }} CredentialNames */
|
|
2
|
+
/** Validate `spec` and return its renderings — the JavaScript twin of
|
|
3
|
+
* `agent_sanitizer.secrets.parse_credential_names`, over the same file.
|
|
4
|
+
*
|
|
5
|
+
* A malformed spec throws rather than degrading. An empty list would render an
|
|
6
|
+
* alternation that matches nothing (every credential forwarded verbatim) and a
|
|
7
|
+
* part carrying a regex metacharacter one that matches everything (all output
|
|
8
|
+
* blanked), so neither may reach a consumer's matcher.
|
|
9
|
+
* @param {Record<string, unknown>} spec @returns {CredentialNames} */
|
|
10
|
+
export function parseCredentialNames(spec: Record<string, unknown>): CredentialNames;
|
|
11
|
+
/** The validated renderings of the packaged vocabulary, memoized.
|
|
12
|
+
*
|
|
13
|
+
* Read lazily, never at module load: a static importer that crashed at LOAD would
|
|
14
|
+
* abort before its own fail-closed catch installs, and a guardrail that fails to
|
|
15
|
+
* load is a guardrail that fails OPEN. Deferring to first use routes a missing or
|
|
16
|
+
* corrupt data file into the caller's catch instead.
|
|
17
|
+
* @returns {CredentialNames} */
|
|
18
|
+
export function credentialNames(): CredentialNames;
|
|
19
|
+
/** @typedef {"trailing" | "any-segment"} CredentialNameScope */
|
|
20
|
+
/** A predicate: does this env-var NAME hold a credential?
|
|
21
|
+
*
|
|
22
|
+
* `scope` selects the rule — `"trailing"` for a redactor (the noun must be the
|
|
23
|
+
* name's last run), `"any-segment"` for an env scrub (the noun may be any run).
|
|
24
|
+
* See this module's header for why that choice belongs to the caller.
|
|
25
|
+
*
|
|
26
|
+
* `declineNonSecret` applies the vocabulary's `nonSecretSuffixes`: a name ending
|
|
27
|
+
* in one holds an identifier or a public key (`AWS_ACCESS_KEY_ID`), not a secret.
|
|
28
|
+
* Leave it on for a redactor, where redacting an identifier out of output is a
|
|
29
|
+
* visible defect; turn it off for a scrub whose failure to strip is the worse
|
|
30
|
+
* error. It is applied to the name's trailing run under both scopes, because a
|
|
31
|
+
* non-secret marker only means anything at the end of a name.
|
|
32
|
+
*
|
|
33
|
+
* The returned predicate closes over the parsed vocabulary, so build it once and
|
|
34
|
+
* reuse it — the parse and validation are not repeated per name.
|
|
35
|
+
*
|
|
36
|
+
* @param {{ scope?: CredentialNameScope, declineNonSecret?: boolean, spec?: Record<string, unknown> }} [options]
|
|
37
|
+
* @returns {(name: string) => boolean} */
|
|
38
|
+
export function credentialNameMatcher(options?: {
|
|
39
|
+
scope?: CredentialNameScope;
|
|
40
|
+
declineNonSecret?: boolean;
|
|
41
|
+
spec?: Record<string, unknown>;
|
|
42
|
+
}): (name: string) => boolean;
|
|
43
|
+
export type CredentialNames = {
|
|
44
|
+
segments: string[];
|
|
45
|
+
fieldNamePatterns: string[];
|
|
46
|
+
nonSecretSegments: string[];
|
|
47
|
+
};
|
|
48
|
+
export type CredentialNameScope = "trailing" | "any-segment";
|