@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 +273 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +1006 -0
- package/dist/lib/entropy.d.ts +65 -0
- package/dist/lib/entropy.js +99 -0
- package/dist/lib/evidence.d.ts +55 -0
- package/dist/lib/evidence.js +134 -0
- package/dist/lib/files.d.ts +71 -0
- package/dist/lib/files.js +227 -0
- package/dist/lib/injection.d.ts +38 -0
- package/dist/lib/injection.js +396 -0
- package/dist/lib/pii.d.ts +91 -0
- package/dist/lib/pii.js +757 -0
- package/dist/lib/policy.d.ts +42 -0
- package/dist/lib/policy.js +138 -0
- package/dist/lib/redact.d.ts +41 -0
- package/dist/lib/redact.js +121 -0
- package/dist/lib/secrets.d.ts +111 -0
- package/dist/lib/secrets.js +329 -0
- package/dist/lib/text.d.ts +129 -0
- package/dist/lib/text.js +191 -0
- package/dist/lib/unicode.d.ts +106 -0
- package/dist/lib/unicode.js +431 -0
- package/dist/lib/url.d.ts +35 -0
- package/dist/lib/url.js +363 -0
- package/dist/paths.d.ts +42 -0
- package/dist/paths.js +158 -0
- package/package.json +45 -0
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.
|
package/dist/index.d.ts
ADDED
|
@@ -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 };
|