agent-sanitizer 2.62.2 → 2.63.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.62.2",
3
+ "version": "2.63.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": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "$comment": "The credential-noun vocabulary: the words that make an identifier name a secret. Published so every consumer derives its own matcher from ONE list — a newly recognized noun reaches them all through a version bump instead of N hand edits. Read it from Python via agent_sanitizer.secrets (credential_name_segments / credential_field_name_patterns / non_secret_name_segments) or from JavaScript via the npm subpath export `agent-sanitizer/credential-names`. `parts` are the lowercase words of the noun; a consumer renders them for its own matcher (underscore-joined `API_KEY` and bare-joined `APIKEY` for an env-var NAME, `api[_-]?key` for a `field = value` regex). `uses` says which matcher may use the noun, because the two are not interchangeable: `env-name` matches a variable NAME and never inspects a value, so a broad noun there costs nothing, while `field-value` redacts whatever follows `noun = ` and a broad noun there mangles ordinary text — `key = <20 chars>` is a false-positive flood, so `key`, `pat`, `credential`, `credentials` and `secrets` are env-name only — `credential`/`credentials` are everyday variable names, and an attribute chain (`credentials = service_account.Credentials.from_service_account_info(info)`) escapes every value-shape skip, so a field-value rendering would redact ordinary source lines. `passphrase` is the exception among them: it is as shape-specific as `password`, and detect-secrets' own KeywordDetector DENYLIST omits it, so name-only would let `passphrase = <secret>` reach the model in cleartext. `nonSecretSuffixes` are the trailing words that make a credential-shaped name hold a NON-secret (a key's identifier, the public half of a keypair), which a consumer must not redact. Every part is restricted to a-z0-9 so it carries no regex metacharacter; the accessors enforce that and fail closed on a violation, an empty list, or an unknown `uses` value. It sits inside the Python package because a wheel can only ship data under its package directory, while npm's `files`/`exports` can name any path — so ONE physical file backs both ecosystems and there is no copy to drift.",
2
+ "$comment": "The credential-noun vocabulary: the words that make an identifier name a secret. Published so every consumer derives its own matcher from ONE list — a newly recognized noun reaches them all through a version bump instead of N hand edits. Read it from Python via agent_sanitizer.secrets (credential_name_segments / credential_field_name_patterns / non_secret_name_segments) or from JavaScript via the npm subpath export `agent-sanitizer/credential-names`. `parts` are the lowercase words of the noun; a consumer renders them for its own matcher (underscore-joined `API_KEY` and bare-joined `APIKEY` for an env-var NAME, `api[_-]?key` for a `field = value` regex). `uses` says which matcher may use the noun, because the two are not interchangeable: `env-name` matches a variable NAME and never inspects a value, so a broad noun there costs nothing, while `field-value` redacts whatever follows `noun = ` and a broad noun there mangles ordinary text — `key = <20 chars>` is a false-positive flood, so `key`, `pat`, `credential`, `credentials` and `secrets` are env-name only — `credential`/`credentials` are everyday variable names, and an attribute chain (`credentials = service_account.Credentials.from_service_account_info(info)`) escapes every value-shape skip, so a field-value rendering would redact ordinary source lines. `passphrase` is the exception among them: it is as shape-specific as `password`, and detect-secrets' own KeywordDetector DENYLIST omits it, so name-only would let `passphrase = <secret>` reach the model in cleartext. `nonSecretSuffixes` are the trailing words that make a credential-shaped name hold a NON-secret — a key's identifier, the public half of a keypair, or metadata ABOUT a secret (its type, display name, label, kind, or the path/file it lives at) — which a consumer must not redact. A bare `id` is deliberately absent: Vault's `secret_id` is itself a credential. Every part is restricted to a-z0-9 so it carries no regex metacharacter; the accessors enforce that and fail closed on a violation, an empty list, or an unknown `uses` value. It sits inside the Python package because a wheel can only ship data under its package directory, while npm's `files`/`exports` can name any path — so ONE physical file backs both ecosystems and there is no copy to drift.",
3
3
  "nouns": [
4
4
  { "parts": ["api", "key"], "uses": ["env-name", "field-value"] },
5
5
  { "parts": ["access", "key"], "uses": ["env-name", "field-value"] },
@@ -24,6 +24,13 @@
24
24
  ],
25
25
  "nonSecretSuffixes": [
26
26
  ["key", "id"],
27
- ["public", "key"]
27
+ ["public", "key"],
28
+ ["type"],
29
+ ["name"],
30
+ ["label"],
31
+ ["keyword"],
32
+ ["kind"],
33
+ ["path"],
34
+ ["file"]
28
35
  ]
29
36
  }
@@ -178,7 +178,8 @@ export function credentialNames() {
178
178
  * See this module's header for why that choice belongs to the caller.
179
179
  *
180
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.
181
+ * in one holds an identifier, a public key, or metadata about a secret
182
+ * (`AWS_ACCESS_KEY_ID`, `SECRET_TYPE`, `TOKEN_FILE`), not the secret itself.
182
183
  * Leave it on for a redactor, where redacting an identifier out of output is a
183
184
  * visible defect; turn it off for a scrub whose failure to strip is the worse
184
185
  * error. It is applied to the name's trailing run under both scopes, because a
@@ -198,26 +199,29 @@ export function credentialNameMatcher(options = {}) {
198
199
  const vocabulary = spec ? parseCredentialNames(spec) : credentialNames();
199
200
  const nouns = new Set(vocabulary.segments);
200
201
  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) =>
202
+ // Each walk is bounded by the longest entry of the set it probes: a run longer
203
+ // than that matches nothing there, and the bound is what keeps the walk linear
204
+ // in the name's segment count. The bounds are separate because a two-word
205
+ // non-secret suffix (`PUBLIC_KEY`) must still be seen when every noun is one word.
206
+ /** @param {Iterable<string>} segments */
207
+ const longestRun = (segments) =>
208
+ Math.max(...Array.from(segments, (s) => s.split("_").length));
209
+ const maxRun = longestRun(nouns);
210
+ const maxNonSecretRun = longestRun(nonSecret);
211
+ /** @param {string[]} words @param {number} bound @returns {string[]} */
212
+ const trailingRuns = (words, bound) =>
213
+ Array.from({ length: Math.min(bound, words.length) }, (_, i) =>
210
214
  words.slice(words.length - (i + 1)).join("_"),
211
215
  );
212
216
  return (name) => {
213
217
  const words = name.toUpperCase().split("_");
214
218
  if (
215
219
  declineNonSecret &&
216
- trailingRuns(words).some((run) => nonSecret.has(run))
220
+ trailingRuns(words, maxNonSecretRun).some((run) => nonSecret.has(run))
217
221
  )
218
222
  return false;
219
223
  if (scope === "trailing")
220
- return trailingRuns(words).some((run) => nouns.has(run));
224
+ return trailingRuns(words, maxRun).some((run) => nouns.has(run));
221
225
  for (let start = 0; start < words.length; start++)
222
226
  for (let span = 1; span <= maxRun && start + span <= words.length; span++)
223
227
  if (nouns.has(words.slice(start, start + span).join("_"))) return true;
@@ -24,7 +24,8 @@ export function credentialNames(): CredentialNames;
24
24
  * See this module's header for why that choice belongs to the caller.
25
25
  *
26
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.
27
+ * in one holds an identifier, a public key, or metadata about a secret
28
+ * (`AWS_ACCESS_KEY_ID`, `SECRET_TYPE`, `TOKEN_FILE`), not the secret itself.
28
29
  * Leave it on for a redactor, where redacting an identifier out of output is a
29
30
  * visible defect; turn it off for a scrub whose failure to strip is the worse
30
31
  * error. It is applied to the name's trailing run under both scopes, because a