@crewhaus/tool-secure 0.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 ADDED
@@ -0,0 +1,273 @@
1
+ # @crewhaus/tool-secure
2
+
3
+ Deterministic security, privacy and policy tools. Same input, same bytes: no
4
+ clock, no randomness, no network. Most are pure; three read files, always
5
+ through workspace containment.
6
+
7
+ ## Read this first
8
+
9
+ **Every detector in this package is a heuristic.** Pattern matching can show
10
+ that something *is* present. It cannot show that nothing is.
11
+
12
+ The failure mode that actually hurts people is not a missed match. It is a
13
+ harness that reads `{"findings":[]}` as "this document is safe to publish"
14
+ and ships a spreadsheet of customer records, or reads a low injection score
15
+ as permission to let a fetched page steer an agent.
16
+
17
+ So every scanning tool here returns two things beyond its findings:
18
+
19
+ - `rulesRun` / `typesRun` / `checked` — what was actually looked for, so an
20
+ empty result reads as a *scope* rather than a verdict.
21
+ - `note` — a sentence saying what the absence of findings does not mean.
22
+
23
+ Those fields are not decoration. They are the difference between a tool that
24
+ helps a reviewer and a tool that replaces one.
25
+
26
+ Two things in this package are stronger than heuristics, and they are called
27
+ out as such: the IBAN check digits (ISO 7064 mod-97-10) and the credit-card
28
+ check digit (Luhn) are real arithmetic, verifiable offline. They prove a
29
+ string is a well-formed instance of its format. They do not prove the account
30
+ exists, or that it belongs to anyone in particular.
31
+
32
+ ```yaml
33
+ tools:
34
+ - all-secure # every tool below
35
+ - -Depseudonymize # ...except this one
36
+ ```
37
+
38
+ | Tool | What it does |
39
+ |---|---|
40
+ | `AllowlistCheck` | Is this URL, email domain or path inside the operator's declared list, and which rule matched |
41
+ | `ContentPolicyCheck` | Required phrases present, forbidden phrases absent, claim patterns queued for review |
42
+ | `Depseudonymize` | Put original values back, listing tokens the mapping did not cover |
43
+ | `EntropyScore` | Shannon entropy in bits per character, with the alphabet's ceiling beside it |
44
+ | `HashChainVerify` | Verify a hash-linked record sequence and report the first break |
45
+ | `HomoglyphNormalize` | Fold confusable Unicode to ASCII, reporting every change and every mixed-script run |
46
+ | `InvisibleCharScan` | Zero-width, bidi, tag, control and unusual-space characters, plus unbalanced bidi per line |
47
+ | `PiiRedact` | Replace detected personal data with a placeholder or a keyed HMAC pseudonym |
48
+ | `PiiScan` | Find likely personal data with a type, a location, a confidence and the rule that matched |
49
+ | `PromptInjectionScan` | Score untrusted content for instruction-override attempts |
50
+ | `Pseudonymize` | Replace values with consistent tokens from a caller-supplied mapping |
51
+ | `RedactForExport` | PII redaction and secret masking together, with an evidence record |
52
+ | `SecretScan` | Credentials in text, a file or a tree — reported masked, never echoed |
53
+ | `SignPayload` | HMAC a payload with a key named by environment variable |
54
+ | `UrlSafetyCheck` | Structural analysis of a URL without fetching it |
55
+ | `VerifyPayload` | Constant-time HMAC verification |
56
+
57
+ ## Masking is a hard requirement
58
+
59
+ `SecretScan` never puts a credential in its result. Every value is masked to
60
+ at most two leading and two trailing characters, and only when the value is
61
+ long enough that those four characters are a small fraction of it; anything
62
+ shorter is masked whole. A scanner that prints what it found has moved the
63
+ secret from a file into a transcript, a log and a model's context window —
64
+ three more places it has to be rotated out of.
65
+
66
+ `src/index.test.ts` asserts that scanning a known credential produces a
67
+ result that does not contain it, and `src/lib.test.ts` pins the masking
68
+ function itself.
69
+
70
+ ## What each detector actually proves
71
+
72
+ ### Personal data
73
+
74
+ | rule | proves |
75
+ |---|---|
76
+ | `email.addr-spec-subset` | dot-atom local part, dotted domain, TLD of 2+ letters. Not quoted local parts, not IP-literal domains, not internationalized addresses |
77
+ | `phone.e164` | `+` then 8–15 digits with common separators, taken as the longest whole-group run in that range. Where the next number is grouped the same way, the two are genuinely indistinguishable and the span runs long |
78
+ | `phone.national.XX` | a national dialling shape for AU, CA, DE, FR, GB, IN or US — the only countries implemented. An unsupported hint is refused, not approximated |
79
+ | `ssn.us-format` | `NNN-NN-NNNN`, excluding groups the SSA has never issued. **Not validity**: an SSN has no public check digit, and this package does not pretend otherwise |
80
+ | `iban.iso7064-mod97` | the check digits are correct and, where the country is known, so is the length. A real check |
81
+ | `card.luhn` | the Luhn check digit is correct. A real check. A digit run that fails it is not reported at all, because it is an order number far more often than a mistyped card. Candidates are whole digit GROUPS of 12–19 digits, so `4111111111111111 123-45-6789` finds the card instead of failing Luhn on the 19 digits a greedy run produces and reporting nothing |
82
+ | `ip.v4-dotted` / `ip.v6-parsed` | a parseable address literal. Ranges from the IANA special-purpose registry are labelled by the prefix they come from — `private`, `loopback`, `link-local`, `shared-address-space` (100.64/10), `documentation` (the three TEST-NETs), `benchmarking` (198.18/15), `reserved` — because those are usually not personal data |
83
+ | `dob.labelled` / `date.calendar` | a calendar date. Only a nearby birth-date label, or a `referenceDate` that makes the age plausible, raises it above `possible` |
84
+ | `address.us-street-suffix` / `address.us-city-state-zip` | a US-style street line or city/state/ZIP tail. US conventions only |
85
+
86
+ Names, free-text detail, medical or financial narrative, and every non-US
87
+ identifier format are **not covered at all**.
88
+
89
+ ### Secrets
90
+
91
+ Two kinds of rule. **Vendor** rules key on a published prefix (`AKIA…`,
92
+ `ghp_…`, `sk-ant-…`); precise, and they go stale when a provider changes its
93
+ format. **Structural** rules key on shape: a PEM block, a JWT, a URL with a
94
+ password in its authority, an assignment to something called `password`.
95
+
96
+ The high-entropy rule is context-gated by default — a high-entropy string
97
+ with no credential-ish word near it is not reported, because commit hashes,
98
+ UUIDs, base64 images and minified code are all high entropy and a scanner
99
+ that cries wolf on them gets switched off. Pass `requireContext: false` to
100
+ see them anyway.
101
+
102
+ One scan produces both the report and the redaction spans. `SecretScan`'s
103
+ masked findings and the spans `RedactForExport` removes come from the same
104
+ function with the same options, so the set of rules named in `rulesRun` is
105
+ exactly the set that runs — a finding cannot be reported and then left in the
106
+ document. Where a rule captures the secret in a group (`scheme://user:pass@`,
107
+ `password = "…"`), the group's offset comes from the match indices, never
108
+ from searching the match for the group's own text: that search finds the
109
+ username's copy of the password and redacts the wrong eight characters.
110
+
111
+ ### Prompt injection
112
+
113
+ A smoke detector, not a defence. The rules are in this repo, which is public,
114
+ so an attacker can write around every one of them in a minute. The score sums
115
+ each *distinct* rule's weight once, capped at 100 — repeating a phrase forty
116
+ times does not make a document forty times more suspicious, and rewarding
117
+ repetition would make the score trivially inflatable.
118
+
119
+ Base64 blobs are decoded once and rescanned. Depth is one, by construction:
120
+ content inside a decoded blob is never decoded again.
121
+
122
+ Hit excerpts are attacker-controlled text. Show them to a person; do not feed
123
+ them back to a model as if they were instructions.
124
+
125
+ The actual defences are architectural: untrusted content stays data, tool
126
+ calls need permission, side-effectful actions are confirmed, and a model's
127
+ instructions never come from the content it is reading.
128
+
129
+ ### Confusables
130
+
131
+ The fold is a four-step pipeline, per code point, with source offsets
132
+ preserved: ASCII passes through, then a hand-curated confusable table
133
+ (Cyrillic, Greek and a few symbol blocks), then NFKD with combining marks
134
+ dropped when what remains is printable ASCII, then anything left is reported
135
+ as `unfolded` and passed through.
136
+
137
+ This is an approximation of UTS #39, not an implementation of it. Whole-script
138
+ imitations — a domain written entirely in Cyrillic — fold to nothing and show
139
+ up only in `mixedScriptRuns`.
140
+
141
+ ### URLs
142
+
143
+ Structural only. No DNS, no request, no reputation data. A URL with no
144
+ findings is structurally unremarkable, which is all. `host.numeric` is
145
+ checked against the URL *as written*, because the parser has already turned
146
+ `http://2130706433/` into `127.0.0.1` by the time `hostname` is readable.
147
+
148
+ `checked` lists rule ids in the same vocabulary as `issues[].rule`, so the
149
+ two can be joined: an empty `issues` beside a `checked` naming
150
+ `scheme.active` means that check ran and did not fire.
151
+
152
+ ### Evidence
153
+
154
+ The hash-chain convention is stated in every result:
155
+
156
+ ```
157
+ hash = H(prevHash + separator + data)
158
+ ```
159
+
160
+ with `H` defaulting to SHA-256, `separator` to `"\n"` and the genesis
161
+ `prevHash` to `""`. All three are inputs, because chains in the wild differ on
162
+ all of them and a verifier that guesses is a verifier that returns a
163
+ confidently wrong "valid". `data` must already be canonical: this package
164
+ will not serialize an object for you, because two JSON encoders disagree
165
+ about key order and a chain that verifies under one and not the other is
166
+ worse than no chain.
167
+
168
+ An intact chain proves the records are internally consistent. It does not
169
+ prove who wrote them or when, and whoever can rewrite the whole sequence can
170
+ produce a valid chain.
171
+
172
+ `VerifyPayload` compares `HMAC(key, expected)` with `HMAC(key, provided)`
173
+ rather than the signatures themselves, so the comparison is constant time
174
+ even when the supplied signature is the wrong length.
175
+
176
+ ## Keys are named, never passed
177
+
178
+ Every tool that needs a key takes `keyEnvVar` — the *name* of an environment
179
+ variable — and reads it itself. The key never appears in an argument, a
180
+ result, an error message or a transcript. An unset variable is refused by
181
+ name, loudly.
182
+
183
+ Pseudonym tokens come in two flavours, and the difference is reported in
184
+ every result that uses them. **Keyed** tokens are `HMAC-SHA256(key,
185
+ "<type>:<canonical>")` truncated: stable across documents, and unlinkable
186
+ without the key. **Unkeyed** tokens are a SHA-256 prefix: stable, convenient,
187
+ and reversible by enumeration for any identifier space small enough to hash
188
+ exhaustively. Unkeyed results carry `reversibleByEnumeration: true` and a
189
+ warning, so nobody mistakes them for anonymization.
190
+
191
+ ## Bounds
192
+
193
+ Every entry point is bounded, and every bound is a *memory* bound rather than
194
+ a complaint after the fact.
195
+
196
+ | Bound | Value |
197
+ |---|---|
198
+ | Text scanned per call | 2,000,000 characters |
199
+ | File read | size checked on the open descriptor *before* any bytes are buffered; 1 MiB default, 16 MiB ceiling |
200
+ | Tree walk | 2,000 files, 12 levels, both reported when they bite |
201
+ | Mapping | 5,000 entries, 200,000 key characters |
202
+ | Policy rules | 200, with 20 reported locations per rule and an exact count |
203
+ | Chain | 50,000 records |
204
+ | Base64 decode | 100,000 candidate characters, depth 1 |
205
+ | Reported hits | 200 per result, with the true total alongside |
206
+
207
+ ## A tree scan says what it did not open
208
+
209
+ `SecretScan` on a directory does not read everything under it, and the result
210
+ says so in the same breath as the findings. Skipped by default:
211
+
212
+ | reason | what it covers |
213
+ |---|---|
214
+ | `hidden` | every dot-file and dot-directory — **including `.env`**. Pass `includeHidden: true` to scan them |
215
+ | `excluded-directory` | `.git`, `.hg`, `.svn`, `node_modules`, `dist`, `build`, `coverage`, `.next`, `.turbo`, `.venv`, `__pycache__` |
216
+ | `symlink` | never followed, for containment and to make cycles impossible |
217
+ | `binary` | a NUL byte in the first 8 KiB |
218
+ | `too-large` | over the per-file cap |
219
+ | `unreadable` | a permission error, or not a regular file |
220
+
221
+ Every one is named in `skipped` with its reason, `skippedByReason` tallies
222
+ them, and `skippedTotal` is exact even when the list is truncated. A scanner
223
+ that quietly drops `.env` and `node_modules/` and then reports `skipped: []`
224
+ is claiming coverage it does not have, and that is the failure in this package
225
+ that would actually get someone hurt.
226
+
227
+ ## Containment
228
+
229
+ Every caller-supplied path goes through `src/paths.ts`'s `resolveSafe`, which
230
+ refuses anything resolving outside `process.cwd()` — including via a symlink
231
+ inside the workspace. Files are opened `O_NOFOLLOW`, closing the window
232
+ between the check and the open. Directory symlinks are never followed, which
233
+ makes cycles impossible rather than merely unlikely.
234
+
235
+ ## What is deliberately not here
236
+
237
+ Anything whose core is judgement. There is no "is this content harmful", no
238
+ "is this person's data sensitive", no classifier and no model call. `RuleClassify`-style
239
+ decisions belong to the operator: `ContentPolicyCheck` evaluates rules a
240
+ person wrote, and `review_pattern` is the rule kind that says "a human looks
241
+ at this". A rule whose regex does not compile is reported as `error` and
242
+ **fails** the check — an unevaluated rule is not a passed one. Those regexes
243
+ are operator-supplied and run untimed, so a pattern with a nested unbounded
244
+ quantifier can stall on text it fails to match; write them anchored.
245
+
246
+ There is also no network. Nothing here resolves a domain, checks a
247
+ reputation feed, or calls a breach database — which is why `UrlSafetyCheck`
248
+ is honest about being structural, and why the whole package can be run on
249
+ content that must not leave the machine.
250
+
251
+ ## Determinism
252
+
253
+ Listings are sorted with plain string comparison; `localeCompare` would make
254
+ the same tree list differently on two hosts. Every comparator is a total
255
+ order, because `sort` may reorder elements a comparator calls unequal in both
256
+ directions. Nothing samples a clock or a random source — `PiiScan`'s age
257
+ plausibility test takes `referenceDate` as an input for exactly that reason.
258
+ Entropy is rounded to four decimal places, half-up towards positive infinity.
259
+ `src/integration.test.ts` asserts that every one of the sixteen tools returns
260
+ identical bytes for identical input.
261
+
262
+ ## Layout
263
+
264
+ `src/lib/` holds the pure functions and is where the behaviour is tested;
265
+ `src/index.ts` wraps them as tools. A bug in the Luhn implementation reads
266
+ better as a failing unit than as a tool call that returned one fewer finding.
267
+
268
+ ## Safety flags
269
+
270
+ All sixteen are `readOnly`, non-destructive, `scope: "internal"`, and declare
271
+ no io capability: nothing here writes, spawns a process or opens a socket.
272
+ `src/index.test.ts` asserts that for every tool, so a future addition that
273
+ breaks the property fails the suite rather than the deployment.
@@ -0,0 +1,23 @@
1
+ import type { RegisteredTool } from "@crewhaus/tool-catalog";
2
+ import { PII_TYPES, SUPPORTED_PHONE_COUNTRIES } from "./lib/pii";
3
+ import { SECRET_RULES } from "./lib/secrets";
4
+ import { MAX_TEXT_CHARS } from "./lib/text";
5
+ export declare const piiScan: RegisteredTool;
6
+ export declare const piiRedact: RegisteredTool;
7
+ export declare const pseudonymize: RegisteredTool;
8
+ export declare const depseudonymize: RegisteredTool;
9
+ export declare const secretScan: RegisteredTool;
10
+ export declare const entropyScore: RegisteredTool;
11
+ export declare const promptInjectionScan: RegisteredTool;
12
+ export declare const invisibleCharScan: RegisteredTool;
13
+ export declare const homoglyphNormalize: RegisteredTool;
14
+ export declare const urlSafetyCheck: RegisteredTool;
15
+ export declare const allowlistCheck: RegisteredTool;
16
+ export declare const contentPolicyCheck: RegisteredTool;
17
+ export declare const hashChainVerify: RegisteredTool;
18
+ export declare const signPayload: RegisteredTool;
19
+ export declare const verifyPayload: RegisteredTool;
20
+ export declare const redactForExport: RegisteredTool;
21
+ /** Every tool this package registers, in the order a catalog should list them. */
22
+ export declare const SECURE_TOOLS: ReadonlyArray<RegisteredTool>;
23
+ export { SECRET_RULES, PII_TYPES, SUPPORTED_PHONE_COUNTRIES, MAX_TEXT_CHARS };