residoo 0.16.0 → 0.18.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 +13 -3
- package/package.json +1 -1
- package/src/bip39wordlist.js +270 -0
- package/src/cli.js +124 -23
- package/src/guard.js +195 -25
- package/src/mcpTools.js +1 -1
- package/src/notify.js +53 -0
- package/src/pii.js +116 -13
- package/src/rotation.js +17 -0
- package/src/scan.js +14 -4
- package/src/sources/agent-configs.js +26 -1
- package/src/watch.js +13 -1
package/README.md
CHANGED
|
@@ -72,14 +72,24 @@ observed, not just documented. All 8, not just the closest one:
|
|
|
72
72
|
| tool | distinct credentials found | precision | egress during the scan |
|
|
73
73
|
|---|---|---|---|
|
|
74
74
|
| **residoo** | **45/45 (100%)** | **100%** | **none-observed** |
|
|
75
|
-
| agentsweep | 33/42 (79%) |
|
|
75
|
+
| agentsweep | 33/42 (79%) | 89% | none-observed |
|
|
76
76
|
| gitleaks | 32/45 (71%) | 100% | none-observed |
|
|
77
|
-
| betterleaks | 32/45 (71%) |
|
|
77
|
+
| betterleaks | 32/45 (71%) | 95% | none-observed |
|
|
78
78
|
| whatileaked | 28/42 (67%) | 100% | none-observed |
|
|
79
|
-
| kingfisher | 29/45 (64%) |
|
|
79
|
+
| kingfisher | 29/45 (64%) | 97% | attempts calls in default mode |
|
|
80
80
|
| trufflehog | 29/45 (64%) | 97% | attempts calls in default mode |
|
|
81
81
|
| detect-secrets | 25/45 (56%) | 2% | attempts calls in default mode |
|
|
82
82
|
|
|
83
|
+
Precision here counts a flagged vendor-documented example key (a real,
|
|
84
|
+
deliberate suppress-placeholder in the corpus) as a false positive, the
|
|
85
|
+
stricter of the two measures `bench/RESULTS.md` reports throughout —
|
|
86
|
+
several tools above score better on the looser "excluding suppress flags"
|
|
87
|
+
measure (e.g. agentsweep and betterleaks both reach 100% there), but this
|
|
88
|
+
is the one that matches what a user actually experiences: a tool that
|
|
89
|
+
flags AWS's own published example key on every run trains people to
|
|
90
|
+
ignore its output. residoo and gitleaks are the only two tools that hit
|
|
91
|
+
100% either way.
|
|
92
|
+
|
|
83
93
|
"none-observed" is a measured result, not a default assumption: every run
|
|
84
94
|
sits under a live proxy trap and process-tree polling, and a deliberate
|
|
85
95
|
canary connection is fired and confirmed caught *before* each real
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "residoo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Find secrets leaking through your AI coding agent's session history. Zero network calls in the scan path, zero dependencies.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "CloudRoam (https://cloudroam.io)",
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The canonical BIP-39 English wordlist (2048 words), verbatim from the
|
|
5
|
+
* spec's own reference file: github.com/bitcoin/bips, bip-0039/english.txt
|
|
6
|
+
* (fetched directly, not retyped from memory -- transcription errors here
|
|
7
|
+
* would silently break every checksum computation in src/pii.js). Order
|
|
8
|
+
* matters: word N's position in this array IS its 11-bit index per the
|
|
9
|
+
* spec, so this list must never be re-sorted or edited.
|
|
10
|
+
*/
|
|
11
|
+
const BIP39_ENGLISH_WORDLIST = [
|
|
12
|
+
"abandon", "ability", "able", "about", "above", "absent", "absorb", "abstract",
|
|
13
|
+
"absurd", "abuse", "access", "accident", "account", "accuse", "achieve", "acid",
|
|
14
|
+
"acoustic", "acquire", "across", "act", "action", "actor", "actress", "actual",
|
|
15
|
+
"adapt", "add", "addict", "address", "adjust", "admit", "adult", "advance",
|
|
16
|
+
"advice", "aerobic", "affair", "afford", "afraid", "again", "age", "agent",
|
|
17
|
+
"agree", "ahead", "aim", "air", "airport", "aisle", "alarm", "album",
|
|
18
|
+
"alcohol", "alert", "alien", "all", "alley", "allow", "almost", "alone",
|
|
19
|
+
"alpha", "already", "also", "alter", "always", "amateur", "amazing", "among",
|
|
20
|
+
"amount", "amused", "analyst", "anchor", "ancient", "anger", "angle", "angry",
|
|
21
|
+
"animal", "ankle", "announce", "annual", "another", "answer", "antenna", "antique",
|
|
22
|
+
"anxiety", "any", "apart", "apology", "appear", "apple", "approve", "april",
|
|
23
|
+
"arch", "arctic", "area", "arena", "argue", "arm", "armed", "armor",
|
|
24
|
+
"army", "around", "arrange", "arrest", "arrive", "arrow", "art", "artefact",
|
|
25
|
+
"artist", "artwork", "ask", "aspect", "assault", "asset", "assist", "assume",
|
|
26
|
+
"asthma", "athlete", "atom", "attack", "attend", "attitude", "attract", "auction",
|
|
27
|
+
"audit", "august", "aunt", "author", "auto", "autumn", "average", "avocado",
|
|
28
|
+
"avoid", "awake", "aware", "away", "awesome", "awful", "awkward", "axis",
|
|
29
|
+
"baby", "bachelor", "bacon", "badge", "bag", "balance", "balcony", "ball",
|
|
30
|
+
"bamboo", "banana", "banner", "bar", "barely", "bargain", "barrel", "base",
|
|
31
|
+
"basic", "basket", "battle", "beach", "bean", "beauty", "because", "become",
|
|
32
|
+
"beef", "before", "begin", "behave", "behind", "believe", "below", "belt",
|
|
33
|
+
"bench", "benefit", "best", "betray", "better", "between", "beyond", "bicycle",
|
|
34
|
+
"bid", "bike", "bind", "biology", "bird", "birth", "bitter", "black",
|
|
35
|
+
"blade", "blame", "blanket", "blast", "bleak", "bless", "blind", "blood",
|
|
36
|
+
"blossom", "blouse", "blue", "blur", "blush", "board", "boat", "body",
|
|
37
|
+
"boil", "bomb", "bone", "bonus", "book", "boost", "border", "boring",
|
|
38
|
+
"borrow", "boss", "bottom", "bounce", "box", "boy", "bracket", "brain",
|
|
39
|
+
"brand", "brass", "brave", "bread", "breeze", "brick", "bridge", "brief",
|
|
40
|
+
"bright", "bring", "brisk", "broccoli", "broken", "bronze", "broom", "brother",
|
|
41
|
+
"brown", "brush", "bubble", "buddy", "budget", "buffalo", "build", "bulb",
|
|
42
|
+
"bulk", "bullet", "bundle", "bunker", "burden", "burger", "burst", "bus",
|
|
43
|
+
"business", "busy", "butter", "buyer", "buzz", "cabbage", "cabin", "cable",
|
|
44
|
+
"cactus", "cage", "cake", "call", "calm", "camera", "camp", "can",
|
|
45
|
+
"canal", "cancel", "candy", "cannon", "canoe", "canvas", "canyon", "capable",
|
|
46
|
+
"capital", "captain", "car", "carbon", "card", "cargo", "carpet", "carry",
|
|
47
|
+
"cart", "case", "cash", "casino", "castle", "casual", "cat", "catalog",
|
|
48
|
+
"catch", "category", "cattle", "caught", "cause", "caution", "cave", "ceiling",
|
|
49
|
+
"celery", "cement", "census", "century", "cereal", "certain", "chair", "chalk",
|
|
50
|
+
"champion", "change", "chaos", "chapter", "charge", "chase", "chat", "cheap",
|
|
51
|
+
"check", "cheese", "chef", "cherry", "chest", "chicken", "chief", "child",
|
|
52
|
+
"chimney", "choice", "choose", "chronic", "chuckle", "chunk", "churn", "cigar",
|
|
53
|
+
"cinnamon", "circle", "citizen", "city", "civil", "claim", "clap", "clarify",
|
|
54
|
+
"claw", "clay", "clean", "clerk", "clever", "click", "client", "cliff",
|
|
55
|
+
"climb", "clinic", "clip", "clock", "clog", "close", "cloth", "cloud",
|
|
56
|
+
"clown", "club", "clump", "cluster", "clutch", "coach", "coast", "coconut",
|
|
57
|
+
"code", "coffee", "coil", "coin", "collect", "color", "column", "combine",
|
|
58
|
+
"come", "comfort", "comic", "common", "company", "concert", "conduct", "confirm",
|
|
59
|
+
"congress", "connect", "consider", "control", "convince", "cook", "cool", "copper",
|
|
60
|
+
"copy", "coral", "core", "corn", "correct", "cost", "cotton", "couch",
|
|
61
|
+
"country", "couple", "course", "cousin", "cover", "coyote", "crack", "cradle",
|
|
62
|
+
"craft", "cram", "crane", "crash", "crater", "crawl", "crazy", "cream",
|
|
63
|
+
"credit", "creek", "crew", "cricket", "crime", "crisp", "critic", "crop",
|
|
64
|
+
"cross", "crouch", "crowd", "crucial", "cruel", "cruise", "crumble", "crunch",
|
|
65
|
+
"crush", "cry", "crystal", "cube", "culture", "cup", "cupboard", "curious",
|
|
66
|
+
"current", "curtain", "curve", "cushion", "custom", "cute", "cycle", "dad",
|
|
67
|
+
"damage", "damp", "dance", "danger", "daring", "dash", "daughter", "dawn",
|
|
68
|
+
"day", "deal", "debate", "debris", "decade", "december", "decide", "decline",
|
|
69
|
+
"decorate", "decrease", "deer", "defense", "define", "defy", "degree", "delay",
|
|
70
|
+
"deliver", "demand", "demise", "denial", "dentist", "deny", "depart", "depend",
|
|
71
|
+
"deposit", "depth", "deputy", "derive", "describe", "desert", "design", "desk",
|
|
72
|
+
"despair", "destroy", "detail", "detect", "develop", "device", "devote", "diagram",
|
|
73
|
+
"dial", "diamond", "diary", "dice", "diesel", "diet", "differ", "digital",
|
|
74
|
+
"dignity", "dilemma", "dinner", "dinosaur", "direct", "dirt", "disagree", "discover",
|
|
75
|
+
"disease", "dish", "dismiss", "disorder", "display", "distance", "divert", "divide",
|
|
76
|
+
"divorce", "dizzy", "doctor", "document", "dog", "doll", "dolphin", "domain",
|
|
77
|
+
"donate", "donkey", "donor", "door", "dose", "double", "dove", "draft",
|
|
78
|
+
"dragon", "drama", "drastic", "draw", "dream", "dress", "drift", "drill",
|
|
79
|
+
"drink", "drip", "drive", "drop", "drum", "dry", "duck", "dumb",
|
|
80
|
+
"dune", "during", "dust", "dutch", "duty", "dwarf", "dynamic", "eager",
|
|
81
|
+
"eagle", "early", "earn", "earth", "easily", "east", "easy", "echo",
|
|
82
|
+
"ecology", "economy", "edge", "edit", "educate", "effort", "egg", "eight",
|
|
83
|
+
"either", "elbow", "elder", "electric", "elegant", "element", "elephant", "elevator",
|
|
84
|
+
"elite", "else", "embark", "embody", "embrace", "emerge", "emotion", "employ",
|
|
85
|
+
"empower", "empty", "enable", "enact", "end", "endless", "endorse", "enemy",
|
|
86
|
+
"energy", "enforce", "engage", "engine", "enhance", "enjoy", "enlist", "enough",
|
|
87
|
+
"enrich", "enroll", "ensure", "enter", "entire", "entry", "envelope", "episode",
|
|
88
|
+
"equal", "equip", "era", "erase", "erode", "erosion", "error", "erupt",
|
|
89
|
+
"escape", "essay", "essence", "estate", "eternal", "ethics", "evidence", "evil",
|
|
90
|
+
"evoke", "evolve", "exact", "example", "excess", "exchange", "excite", "exclude",
|
|
91
|
+
"excuse", "execute", "exercise", "exhaust", "exhibit", "exile", "exist", "exit",
|
|
92
|
+
"exotic", "expand", "expect", "expire", "explain", "expose", "express", "extend",
|
|
93
|
+
"extra", "eye", "eyebrow", "fabric", "face", "faculty", "fade", "faint",
|
|
94
|
+
"faith", "fall", "false", "fame", "family", "famous", "fan", "fancy",
|
|
95
|
+
"fantasy", "farm", "fashion", "fat", "fatal", "father", "fatigue", "fault",
|
|
96
|
+
"favorite", "feature", "february", "federal", "fee", "feed", "feel", "female",
|
|
97
|
+
"fence", "festival", "fetch", "fever", "few", "fiber", "fiction", "field",
|
|
98
|
+
"figure", "file", "film", "filter", "final", "find", "fine", "finger",
|
|
99
|
+
"finish", "fire", "firm", "first", "fiscal", "fish", "fit", "fitness",
|
|
100
|
+
"fix", "flag", "flame", "flash", "flat", "flavor", "flee", "flight",
|
|
101
|
+
"flip", "float", "flock", "floor", "flower", "fluid", "flush", "fly",
|
|
102
|
+
"foam", "focus", "fog", "foil", "fold", "follow", "food", "foot",
|
|
103
|
+
"force", "forest", "forget", "fork", "fortune", "forum", "forward", "fossil",
|
|
104
|
+
"foster", "found", "fox", "fragile", "frame", "frequent", "fresh", "friend",
|
|
105
|
+
"fringe", "frog", "front", "frost", "frown", "frozen", "fruit", "fuel",
|
|
106
|
+
"fun", "funny", "furnace", "fury", "future", "gadget", "gain", "galaxy",
|
|
107
|
+
"gallery", "game", "gap", "garage", "garbage", "garden", "garlic", "garment",
|
|
108
|
+
"gas", "gasp", "gate", "gather", "gauge", "gaze", "general", "genius",
|
|
109
|
+
"genre", "gentle", "genuine", "gesture", "ghost", "giant", "gift", "giggle",
|
|
110
|
+
"ginger", "giraffe", "girl", "give", "glad", "glance", "glare", "glass",
|
|
111
|
+
"glide", "glimpse", "globe", "gloom", "glory", "glove", "glow", "glue",
|
|
112
|
+
"goat", "goddess", "gold", "good", "goose", "gorilla", "gospel", "gossip",
|
|
113
|
+
"govern", "gown", "grab", "grace", "grain", "grant", "grape", "grass",
|
|
114
|
+
"gravity", "great", "green", "grid", "grief", "grit", "grocery", "group",
|
|
115
|
+
"grow", "grunt", "guard", "guess", "guide", "guilt", "guitar", "gun",
|
|
116
|
+
"gym", "habit", "hair", "half", "hammer", "hamster", "hand", "happy",
|
|
117
|
+
"harbor", "hard", "harsh", "harvest", "hat", "have", "hawk", "hazard",
|
|
118
|
+
"head", "health", "heart", "heavy", "hedgehog", "height", "hello", "helmet",
|
|
119
|
+
"help", "hen", "hero", "hidden", "high", "hill", "hint", "hip",
|
|
120
|
+
"hire", "history", "hobby", "hockey", "hold", "hole", "holiday", "hollow",
|
|
121
|
+
"home", "honey", "hood", "hope", "horn", "horror", "horse", "hospital",
|
|
122
|
+
"host", "hotel", "hour", "hover", "hub", "huge", "human", "humble",
|
|
123
|
+
"humor", "hundred", "hungry", "hunt", "hurdle", "hurry", "hurt", "husband",
|
|
124
|
+
"hybrid", "ice", "icon", "idea", "identify", "idle", "ignore", "ill",
|
|
125
|
+
"illegal", "illness", "image", "imitate", "immense", "immune", "impact", "impose",
|
|
126
|
+
"improve", "impulse", "inch", "include", "income", "increase", "index", "indicate",
|
|
127
|
+
"indoor", "industry", "infant", "inflict", "inform", "inhale", "inherit", "initial",
|
|
128
|
+
"inject", "injury", "inmate", "inner", "innocent", "input", "inquiry", "insane",
|
|
129
|
+
"insect", "inside", "inspire", "install", "intact", "interest", "into", "invest",
|
|
130
|
+
"invite", "involve", "iron", "island", "isolate", "issue", "item", "ivory",
|
|
131
|
+
"jacket", "jaguar", "jar", "jazz", "jealous", "jeans", "jelly", "jewel",
|
|
132
|
+
"job", "join", "joke", "journey", "joy", "judge", "juice", "jump",
|
|
133
|
+
"jungle", "junior", "junk", "just", "kangaroo", "keen", "keep", "ketchup",
|
|
134
|
+
"key", "kick", "kid", "kidney", "kind", "kingdom", "kiss", "kit",
|
|
135
|
+
"kitchen", "kite", "kitten", "kiwi", "knee", "knife", "knock", "know",
|
|
136
|
+
"lab", "label", "labor", "ladder", "lady", "lake", "lamp", "language",
|
|
137
|
+
"laptop", "large", "later", "latin", "laugh", "laundry", "lava", "law",
|
|
138
|
+
"lawn", "lawsuit", "layer", "lazy", "leader", "leaf", "learn", "leave",
|
|
139
|
+
"lecture", "left", "leg", "legal", "legend", "leisure", "lemon", "lend",
|
|
140
|
+
"length", "lens", "leopard", "lesson", "letter", "level", "liar", "liberty",
|
|
141
|
+
"library", "license", "life", "lift", "light", "like", "limb", "limit",
|
|
142
|
+
"link", "lion", "liquid", "list", "little", "live", "lizard", "load",
|
|
143
|
+
"loan", "lobster", "local", "lock", "logic", "lonely", "long", "loop",
|
|
144
|
+
"lottery", "loud", "lounge", "love", "loyal", "lucky", "luggage", "lumber",
|
|
145
|
+
"lunar", "lunch", "luxury", "lyrics", "machine", "mad", "magic", "magnet",
|
|
146
|
+
"maid", "mail", "main", "major", "make", "mammal", "man", "manage",
|
|
147
|
+
"mandate", "mango", "mansion", "manual", "maple", "marble", "march", "margin",
|
|
148
|
+
"marine", "market", "marriage", "mask", "mass", "master", "match", "material",
|
|
149
|
+
"math", "matrix", "matter", "maximum", "maze", "meadow", "mean", "measure",
|
|
150
|
+
"meat", "mechanic", "medal", "media", "melody", "melt", "member", "memory",
|
|
151
|
+
"mention", "menu", "mercy", "merge", "merit", "merry", "mesh", "message",
|
|
152
|
+
"metal", "method", "middle", "midnight", "milk", "million", "mimic", "mind",
|
|
153
|
+
"minimum", "minor", "minute", "miracle", "mirror", "misery", "miss", "mistake",
|
|
154
|
+
"mix", "mixed", "mixture", "mobile", "model", "modify", "mom", "moment",
|
|
155
|
+
"monitor", "monkey", "monster", "month", "moon", "moral", "more", "morning",
|
|
156
|
+
"mosquito", "mother", "motion", "motor", "mountain", "mouse", "move", "movie",
|
|
157
|
+
"much", "muffin", "mule", "multiply", "muscle", "museum", "mushroom", "music",
|
|
158
|
+
"must", "mutual", "myself", "mystery", "myth", "naive", "name", "napkin",
|
|
159
|
+
"narrow", "nasty", "nation", "nature", "near", "neck", "need", "negative",
|
|
160
|
+
"neglect", "neither", "nephew", "nerve", "nest", "net", "network", "neutral",
|
|
161
|
+
"never", "news", "next", "nice", "night", "noble", "noise", "nominee",
|
|
162
|
+
"noodle", "normal", "north", "nose", "notable", "note", "nothing", "notice",
|
|
163
|
+
"novel", "now", "nuclear", "number", "nurse", "nut", "oak", "obey",
|
|
164
|
+
"object", "oblige", "obscure", "observe", "obtain", "obvious", "occur", "ocean",
|
|
165
|
+
"october", "odor", "off", "offer", "office", "often", "oil", "okay",
|
|
166
|
+
"old", "olive", "olympic", "omit", "once", "one", "onion", "online",
|
|
167
|
+
"only", "open", "opera", "opinion", "oppose", "option", "orange", "orbit",
|
|
168
|
+
"orchard", "order", "ordinary", "organ", "orient", "original", "orphan", "ostrich",
|
|
169
|
+
"other", "outdoor", "outer", "output", "outside", "oval", "oven", "over",
|
|
170
|
+
"own", "owner", "oxygen", "oyster", "ozone", "pact", "paddle", "page",
|
|
171
|
+
"pair", "palace", "palm", "panda", "panel", "panic", "panther", "paper",
|
|
172
|
+
"parade", "parent", "park", "parrot", "party", "pass", "patch", "path",
|
|
173
|
+
"patient", "patrol", "pattern", "pause", "pave", "payment", "peace", "peanut",
|
|
174
|
+
"pear", "peasant", "pelican", "pen", "penalty", "pencil", "people", "pepper",
|
|
175
|
+
"perfect", "permit", "person", "pet", "phone", "photo", "phrase", "physical",
|
|
176
|
+
"piano", "picnic", "picture", "piece", "pig", "pigeon", "pill", "pilot",
|
|
177
|
+
"pink", "pioneer", "pipe", "pistol", "pitch", "pizza", "place", "planet",
|
|
178
|
+
"plastic", "plate", "play", "please", "pledge", "pluck", "plug", "plunge",
|
|
179
|
+
"poem", "poet", "point", "polar", "pole", "police", "pond", "pony",
|
|
180
|
+
"pool", "popular", "portion", "position", "possible", "post", "potato", "pottery",
|
|
181
|
+
"poverty", "powder", "power", "practice", "praise", "predict", "prefer", "prepare",
|
|
182
|
+
"present", "pretty", "prevent", "price", "pride", "primary", "print", "priority",
|
|
183
|
+
"prison", "private", "prize", "problem", "process", "produce", "profit", "program",
|
|
184
|
+
"project", "promote", "proof", "property", "prosper", "protect", "proud", "provide",
|
|
185
|
+
"public", "pudding", "pull", "pulp", "pulse", "pumpkin", "punch", "pupil",
|
|
186
|
+
"puppy", "purchase", "purity", "purpose", "purse", "push", "put", "puzzle",
|
|
187
|
+
"pyramid", "quality", "quantum", "quarter", "question", "quick", "quit", "quiz",
|
|
188
|
+
"quote", "rabbit", "raccoon", "race", "rack", "radar", "radio", "rail",
|
|
189
|
+
"rain", "raise", "rally", "ramp", "ranch", "random", "range", "rapid",
|
|
190
|
+
"rare", "rate", "rather", "raven", "raw", "razor", "ready", "real",
|
|
191
|
+
"reason", "rebel", "rebuild", "recall", "receive", "recipe", "record", "recycle",
|
|
192
|
+
"reduce", "reflect", "reform", "refuse", "region", "regret", "regular", "reject",
|
|
193
|
+
"relax", "release", "relief", "rely", "remain", "remember", "remind", "remove",
|
|
194
|
+
"render", "renew", "rent", "reopen", "repair", "repeat", "replace", "report",
|
|
195
|
+
"require", "rescue", "resemble", "resist", "resource", "response", "result", "retire",
|
|
196
|
+
"retreat", "return", "reunion", "reveal", "review", "reward", "rhythm", "rib",
|
|
197
|
+
"ribbon", "rice", "rich", "ride", "ridge", "rifle", "right", "rigid",
|
|
198
|
+
"ring", "riot", "ripple", "risk", "ritual", "rival", "river", "road",
|
|
199
|
+
"roast", "robot", "robust", "rocket", "romance", "roof", "rookie", "room",
|
|
200
|
+
"rose", "rotate", "rough", "round", "route", "royal", "rubber", "rude",
|
|
201
|
+
"rug", "rule", "run", "runway", "rural", "sad", "saddle", "sadness",
|
|
202
|
+
"safe", "sail", "salad", "salmon", "salon", "salt", "salute", "same",
|
|
203
|
+
"sample", "sand", "satisfy", "satoshi", "sauce", "sausage", "save", "say",
|
|
204
|
+
"scale", "scan", "scare", "scatter", "scene", "scheme", "school", "science",
|
|
205
|
+
"scissors", "scorpion", "scout", "scrap", "screen", "script", "scrub", "sea",
|
|
206
|
+
"search", "season", "seat", "second", "secret", "section", "security", "seed",
|
|
207
|
+
"seek", "segment", "select", "sell", "seminar", "senior", "sense", "sentence",
|
|
208
|
+
"series", "service", "session", "settle", "setup", "seven", "shadow", "shaft",
|
|
209
|
+
"shallow", "share", "shed", "shell", "sheriff", "shield", "shift", "shine",
|
|
210
|
+
"ship", "shiver", "shock", "shoe", "shoot", "shop", "short", "shoulder",
|
|
211
|
+
"shove", "shrimp", "shrug", "shuffle", "shy", "sibling", "sick", "side",
|
|
212
|
+
"siege", "sight", "sign", "silent", "silk", "silly", "silver", "similar",
|
|
213
|
+
"simple", "since", "sing", "siren", "sister", "situate", "six", "size",
|
|
214
|
+
"skate", "sketch", "ski", "skill", "skin", "skirt", "skull", "slab",
|
|
215
|
+
"slam", "sleep", "slender", "slice", "slide", "slight", "slim", "slogan",
|
|
216
|
+
"slot", "slow", "slush", "small", "smart", "smile", "smoke", "smooth",
|
|
217
|
+
"snack", "snake", "snap", "sniff", "snow", "soap", "soccer", "social",
|
|
218
|
+
"sock", "soda", "soft", "solar", "soldier", "solid", "solution", "solve",
|
|
219
|
+
"someone", "song", "soon", "sorry", "sort", "soul", "sound", "soup",
|
|
220
|
+
"source", "south", "space", "spare", "spatial", "spawn", "speak", "special",
|
|
221
|
+
"speed", "spell", "spend", "sphere", "spice", "spider", "spike", "spin",
|
|
222
|
+
"spirit", "split", "spoil", "sponsor", "spoon", "sport", "spot", "spray",
|
|
223
|
+
"spread", "spring", "spy", "square", "squeeze", "squirrel", "stable", "stadium",
|
|
224
|
+
"staff", "stage", "stairs", "stamp", "stand", "start", "state", "stay",
|
|
225
|
+
"steak", "steel", "stem", "step", "stereo", "stick", "still", "sting",
|
|
226
|
+
"stock", "stomach", "stone", "stool", "story", "stove", "strategy", "street",
|
|
227
|
+
"strike", "strong", "struggle", "student", "stuff", "stumble", "style", "subject",
|
|
228
|
+
"submit", "subway", "success", "such", "sudden", "suffer", "sugar", "suggest",
|
|
229
|
+
"suit", "summer", "sun", "sunny", "sunset", "super", "supply", "supreme",
|
|
230
|
+
"sure", "surface", "surge", "surprise", "surround", "survey", "suspect", "sustain",
|
|
231
|
+
"swallow", "swamp", "swap", "swarm", "swear", "sweet", "swift", "swim",
|
|
232
|
+
"swing", "switch", "sword", "symbol", "symptom", "syrup", "system", "table",
|
|
233
|
+
"tackle", "tag", "tail", "talent", "talk", "tank", "tape", "target",
|
|
234
|
+
"task", "taste", "tattoo", "taxi", "teach", "team", "tell", "ten",
|
|
235
|
+
"tenant", "tennis", "tent", "term", "test", "text", "thank", "that",
|
|
236
|
+
"theme", "then", "theory", "there", "they", "thing", "this", "thought",
|
|
237
|
+
"three", "thrive", "throw", "thumb", "thunder", "ticket", "tide", "tiger",
|
|
238
|
+
"tilt", "timber", "time", "tiny", "tip", "tired", "tissue", "title",
|
|
239
|
+
"toast", "tobacco", "today", "toddler", "toe", "together", "toilet", "token",
|
|
240
|
+
"tomato", "tomorrow", "tone", "tongue", "tonight", "tool", "tooth", "top",
|
|
241
|
+
"topic", "topple", "torch", "tornado", "tortoise", "toss", "total", "tourist",
|
|
242
|
+
"toward", "tower", "town", "toy", "track", "trade", "traffic", "tragic",
|
|
243
|
+
"train", "transfer", "trap", "trash", "travel", "tray", "treat", "tree",
|
|
244
|
+
"trend", "trial", "tribe", "trick", "trigger", "trim", "trip", "trophy",
|
|
245
|
+
"trouble", "truck", "true", "truly", "trumpet", "trust", "truth", "try",
|
|
246
|
+
"tube", "tuition", "tumble", "tuna", "tunnel", "turkey", "turn", "turtle",
|
|
247
|
+
"twelve", "twenty", "twice", "twin", "twist", "two", "type", "typical",
|
|
248
|
+
"ugly", "umbrella", "unable", "unaware", "uncle", "uncover", "under", "undo",
|
|
249
|
+
"unfair", "unfold", "unhappy", "uniform", "unique", "unit", "universe", "unknown",
|
|
250
|
+
"unlock", "until", "unusual", "unveil", "update", "upgrade", "uphold", "upon",
|
|
251
|
+
"upper", "upset", "urban", "urge", "usage", "use", "used", "useful",
|
|
252
|
+
"useless", "usual", "utility", "vacant", "vacuum", "vague", "valid", "valley",
|
|
253
|
+
"valve", "van", "vanish", "vapor", "various", "vast", "vault", "vehicle",
|
|
254
|
+
"velvet", "vendor", "venture", "venue", "verb", "verify", "version", "very",
|
|
255
|
+
"vessel", "veteran", "viable", "vibrant", "vicious", "victory", "video", "view",
|
|
256
|
+
"village", "vintage", "violin", "virtual", "virus", "visa", "visit", "visual",
|
|
257
|
+
"vital", "vivid", "vocal", "voice", "void", "volcano", "volume", "vote",
|
|
258
|
+
"voyage", "wage", "wagon", "wait", "walk", "wall", "walnut", "want",
|
|
259
|
+
"warfare", "warm", "warrior", "wash", "wasp", "waste", "water", "wave",
|
|
260
|
+
"way", "wealth", "weapon", "wear", "weasel", "weather", "web", "wedding",
|
|
261
|
+
"weekend", "weird", "welcome", "west", "wet", "whale", "what", "wheat",
|
|
262
|
+
"wheel", "when", "where", "whip", "whisper", "wide", "width", "wife",
|
|
263
|
+
"wild", "will", "win", "window", "wine", "wing", "wink", "winner",
|
|
264
|
+
"winter", "wire", "wisdom", "wise", "wish", "witness", "wolf", "woman",
|
|
265
|
+
"wonder", "wood", "wool", "word", "work", "world", "worry", "worth",
|
|
266
|
+
"wrap", "wreck", "wrestle", "wrist", "write", "wrong", "yard", "year",
|
|
267
|
+
"yellow", "you", "young", "youth", "zebra", "zero", "zone", "zoo",
|
|
268
|
+
];
|
|
269
|
+
|
|
270
|
+
module.exports = { BIP39_ENGLISH_WORDLIST };
|
package/src/cli.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const path = require("path");
|
|
4
4
|
const fs = require("fs");
|
|
5
|
+
const os = require("os");
|
|
5
6
|
const crypto = require("crypto");
|
|
6
7
|
const { availableSources, ALL_SOURCES } = require("./sources");
|
|
7
8
|
const { scan, emptyResult } = require("./scan");
|
|
@@ -13,7 +14,7 @@ const {
|
|
|
13
14
|
const { startWatch, isTailable } = require("./watch");
|
|
14
15
|
const { startMcpServer } = require("./mcp");
|
|
15
16
|
const { buildTools } = require("./mcpTools");
|
|
16
|
-
const { runGuard: runGuardEngine } = require("./guard");
|
|
17
|
+
const { runGuard: runGuardEngine, buildHookConfig } = require("./guard");
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* A source is unavailable for the ordinary reason (not installed — nothing
|
|
@@ -150,13 +151,19 @@ Scan options:
|
|
|
150
151
|
similar characters (0/O, Y/*) can be misread,
|
|
151
152
|
which breaks an exact-format match, so this is
|
|
152
153
|
best-effort additional coverage, not a guarantee.
|
|
153
|
-
--include-pii also scan for PII: US Social
|
|
154
|
-
(dashed format only), credit
|
|
155
|
-
(Luhn-validated),
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
154
|
+
--include-pii also scan for PII and adjacent secrets: US Social
|
|
155
|
+
Security Numbers (dashed format only), credit
|
|
156
|
+
card numbers (Luhn-validated), IBANs (checksum-
|
|
157
|
+
validated), and crypto wallet seed phrases
|
|
158
|
+
(BIP-39, SHA-256-checksum-validated -- a
|
|
159
|
+
CREDENTIAL, not personal data, kept in this flag
|
|
160
|
+
rather than a new one to avoid flag proliferation
|
|
161
|
+
for the same "opt in, then checksum-validate"
|
|
162
|
+
shape). A different RISK CATEGORY, not a lower
|
|
163
|
+
confidence bar -- residoo is deliberately
|
|
164
|
+
credentials-only by default. Deliberately
|
|
165
|
+
excludes bare email/phone (too common in
|
|
166
|
+
ordinary text to meet this
|
|
160
167
|
project's own high-confidence bar even opt-in).
|
|
161
168
|
|
|
162
169
|
Watch:
|
|
@@ -177,6 +184,15 @@ Watch:
|
|
|
177
184
|
never to one already seen
|
|
178
185
|
--include-noisy, --include-suppressed, --include-pii, --no-color
|
|
179
186
|
same meaning as scan
|
|
187
|
+
--no-notify skip the OS desktop notification watch fires for
|
|
188
|
+
each genuinely new finding (macOS via osascript,
|
|
189
|
+
Linux via notify-send if installed; no built-in
|
|
190
|
+
mechanism on Windows -- disclosed, not attempted).
|
|
191
|
+
On by default in human-readable mode: watch's own
|
|
192
|
+
purpose is alerting you, and a background process
|
|
193
|
+
nobody is watching a terminal for needs more than
|
|
194
|
+
a printed line. Never fires for a re-exposure of
|
|
195
|
+
something already seen, and never in --json mode.
|
|
180
196
|
Ctrl+C stops cleanly and prints a session summary (skipped with --json,
|
|
181
197
|
where the same information is one final NDJSON event).
|
|
182
198
|
|
|
@@ -227,22 +243,20 @@ Cred:
|
|
|
227
243
|
tool, present only when RESIDOO_CRED_ALLOWED_COMMANDS is configured.
|
|
228
244
|
|
|
229
245
|
Guard:
|
|
230
|
-
residoo guard one binary,
|
|
231
|
-
the payload's own hook_event_name:
|
|
246
|
+
residoo guard one binary, three Claude Code hooks, dispatched
|
|
247
|
+
on the payload's own hook_event_name:
|
|
232
248
|
|
|
233
249
|
PreToolUse blocks an obviously-sensitive file read
|
|
234
250
|
(.env, id_rsa, .aws/credentials, and similar)
|
|
235
251
|
before it can be written to the session
|
|
236
|
-
transcript at all. Narrower than it sounds
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
checks the user's own typed prompt against
|
|
245
|
-
residoo's 79 high-confidence rules (never
|
|
252
|
+
transcript at all. Narrower than it sounds on its
|
|
253
|
+
own: this only sees a proposed Bash command or
|
|
254
|
+
Read path before it runs, based on WHAT you're
|
|
255
|
+
about to touch, not what actually comes back.
|
|
256
|
+
|
|
257
|
+
UserPromptSubmit closes the "typed into a prompt"
|
|
258
|
+
gap: it checks the user's own typed prompt against
|
|
259
|
+
residoo's 84 high-confidence rules (never
|
|
246
260
|
--verify, never --ocr -- this hook has no matcher
|
|
247
261
|
and fires on every single prompt, so it must stay
|
|
248
262
|
fast) and can block it before Claude processes it
|
|
@@ -258,17 +272,49 @@ Guard:
|
|
|
258
272
|
consistent with this hook never blocking on
|
|
259
273
|
anything it doesn't recognize.
|
|
260
274
|
|
|
275
|
+
PostToolUse closes the "arriving through a
|
|
276
|
+
command's output" gap for Bash specifically: it
|
|
277
|
+
scans the command's actual stdout/stderr against
|
|
278
|
+
the same 84 rules and, if one matches, replaces
|
|
279
|
+
the output with a redacted version before Claude
|
|
280
|
+
ever sees it (Claude Code's own updatedToolOutput
|
|
281
|
+
decision field). Bash only, disclosed rather than
|
|
282
|
+
silently partial: every other built-in tool's
|
|
283
|
+
exact output shape isn't documented precisely
|
|
284
|
+
enough to reconstruct a replacement safely, and a
|
|
285
|
+
wrong shape is silently ignored by Claude Code,
|
|
286
|
+
not reported as a failure. The command has
|
|
287
|
+
already run by this point -- this cannot undo a
|
|
288
|
+
file write or network call, only keep the raw
|
|
289
|
+
value out of the model's context.
|
|
290
|
+
|
|
261
291
|
scan/watch/mcp remain the real safety net either
|
|
262
292
|
way -- this is prevention on top of detection,
|
|
263
|
-
not a replacement for it. Add
|
|
293
|
+
not a replacement for it. Add all three to
|
|
264
294
|
.claude/settings.json:
|
|
265
295
|
{"hooks":{
|
|
266
296
|
"PreToolUse":[{"matcher":"Bash|Read",
|
|
267
297
|
"hooks":[{"type":"command",
|
|
268
298
|
"command":"residoo guard"}]}],
|
|
269
299
|
"UserPromptSubmit":[{"hooks":[{"type":"command",
|
|
300
|
+
"command":"residoo guard"}]}],
|
|
301
|
+
"PostToolUse":[{"matcher":"Bash",
|
|
302
|
+
"hooks":[{"type":"command",
|
|
270
303
|
"command":"residoo guard"}]}]}}
|
|
271
304
|
|
|
305
|
+
residoo guard --print-config
|
|
306
|
+
print that same merged JSON to stdout instead of
|
|
307
|
+
hand-writing it -- reads your existing
|
|
308
|
+
~/.claude/settings.json if present, adds
|
|
309
|
+
whatever residoo guard hooks are missing (never
|
|
310
|
+
duplicates one already there), and prints the
|
|
311
|
+
result. Writes NOTHING to disk: residoo never
|
|
312
|
+
writes any file but its own rotation ledger and
|
|
313
|
+
an explicit --seal vault, so save it yourself:
|
|
314
|
+
residoo guard --print-config > ~/.claude/settings.json
|
|
315
|
+
--project targets ./.claude/settings.json (the
|
|
316
|
+
repo-local config) instead of the home-level one.
|
|
317
|
+
|
|
272
318
|
Rotation:
|
|
273
319
|
residoo explain <rule-id> full rotation runbook for one detection rule
|
|
274
320
|
(where to revoke, steps, what revocation does)
|
|
@@ -665,6 +711,7 @@ async function runWatch(args) {
|
|
|
665
711
|
const verify = args.includes("--verify");
|
|
666
712
|
const noColor = args.includes("--no-color");
|
|
667
713
|
const includePii = args.includes("--include-pii");
|
|
714
|
+
const noNotify = args.includes("--no-notify");
|
|
668
715
|
|
|
669
716
|
let intervalSeconds = 5;
|
|
670
717
|
const intervalArg = argValue(args, "--interval");
|
|
@@ -690,7 +737,7 @@ async function runWatch(args) {
|
|
|
690
737
|
|
|
691
738
|
const { promise, stop } = startWatch({
|
|
692
739
|
sources,
|
|
693
|
-
options: { includeNoisy, includeSuppressed, verify, noColor, includePii, json: wantsJson, pollMs: intervalSeconds * 1000 },
|
|
740
|
+
options: { includeNoisy, includeSuppressed, verify, noColor, includePii, noNotify, json: wantsJson, pollMs: intervalSeconds * 1000 },
|
|
694
741
|
});
|
|
695
742
|
|
|
696
743
|
const printFinalSummary = (stats) => {
|
|
@@ -720,6 +767,57 @@ async function runWatch(args) {
|
|
|
720
767
|
return 0;
|
|
721
768
|
}
|
|
722
769
|
|
|
770
|
+
/**
|
|
771
|
+
* `residoo guard --print-config`: print the merged .claude/settings.json
|
|
772
|
+
* a user would need to register all three guard hooks, without ever
|
|
773
|
+
* writing it -- see guard.js's own buildHookConfig docstring for why this
|
|
774
|
+
* is print-only rather than an auto-installer. Reads the existing file
|
|
775
|
+
* (home-level by default, `--project` for the repo-local one) if present,
|
|
776
|
+
* merges in whatever hook groups are missing, and writes the result to
|
|
777
|
+
* STDOUT ONLY -- every instructional line goes to stderr, so
|
|
778
|
+
* `residoo guard --print-config > ~/.claude/settings.json` redirects
|
|
779
|
+
* exactly the JSON and nothing else.
|
|
780
|
+
*/
|
|
781
|
+
function runGuardPrintConfig(args) {
|
|
782
|
+
const wantsProject = args.includes("--project");
|
|
783
|
+
const targetPath = wantsProject
|
|
784
|
+
? path.join(process.cwd(), ".claude", "settings.json")
|
|
785
|
+
: path.join(os.homedir(), ".claude", "settings.json");
|
|
786
|
+
|
|
787
|
+
let existing = {};
|
|
788
|
+
let existedAlready = false;
|
|
789
|
+
try {
|
|
790
|
+
const raw = fs.readFileSync(targetPath, "utf-8");
|
|
791
|
+
existedAlready = true;
|
|
792
|
+
try { existing = JSON.parse(raw); }
|
|
793
|
+
catch {
|
|
794
|
+
process.stderr.write(
|
|
795
|
+
`residoo guard --print-config: ${targetPath} exists but is not valid JSON. ` +
|
|
796
|
+
`Fix it first -- printing a merge against unparseable JSON would risk losing whatever is already there.\n`
|
|
797
|
+
);
|
|
798
|
+
return 1;
|
|
799
|
+
}
|
|
800
|
+
} catch (err) {
|
|
801
|
+
if (!(err && err.code === "ENOENT")) {
|
|
802
|
+
process.stderr.write(`residoo guard --print-config: could not read ${targetPath}: ${err.message}\n`);
|
|
803
|
+
return 1;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
const merged = buildHookConfig(existing);
|
|
808
|
+
process.stderr.write(
|
|
809
|
+
(existedAlready
|
|
810
|
+
? `residoo guard --print-config: merged residoo's three hooks into your existing ${targetPath} below.\n`
|
|
811
|
+
: `residoo guard --print-config: ${targetPath} doesn't exist yet -- here's a new one with residoo's three hooks.\n`) +
|
|
812
|
+
`Nothing was written to disk -- residoo never writes any file but its own rotation ledger and an explicit ` +
|
|
813
|
+
`--seal vault. Save this yourself, e.g.:\n` +
|
|
814
|
+
` residoo guard --print-config${wantsProject ? " --project" : ""} > ${targetPath}\n` +
|
|
815
|
+
(wantsProject ? "" : "(add --project to target ./.claude/settings.json instead of the home-level one)\n")
|
|
816
|
+
);
|
|
817
|
+
process.stdout.write(JSON.stringify(merged, null, 2) + "\n");
|
|
818
|
+
return 0;
|
|
819
|
+
}
|
|
820
|
+
|
|
723
821
|
/**
|
|
724
822
|
* `residoo mcp`: run residoo as an MCP server over stdio. See src/mcp.js
|
|
725
823
|
* for the protocol engine and src/mcpTools.js for the tool catalog; this
|
|
@@ -902,7 +1000,10 @@ async function main(argv) {
|
|
|
902
1000
|
if (cmd === "watch") return runWatch(args);
|
|
903
1001
|
if (cmd === "mcp") return runMcp(args);
|
|
904
1002
|
if (cmd === "cred") return runCred(args);
|
|
905
|
-
if (cmd === "guard")
|
|
1003
|
+
if (cmd === "guard") {
|
|
1004
|
+
if (args.includes("--print-config")) return runGuardPrintConfig(args.slice(1));
|
|
1005
|
+
return runGuardEngine();
|
|
1006
|
+
}
|
|
906
1007
|
if (cmd !== "scan") {
|
|
907
1008
|
process.stderr.write(`Unknown command "${cmd}". Try "residoo --help".\n`);
|
|
908
1009
|
return 2;
|
package/src/guard.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* `residoo guard`:
|
|
4
|
+
* `residoo guard`: three Claude Code hooks in one binary, dispatched on the
|
|
5
5
|
* payload's own `hook_event_name` field.
|
|
6
6
|
*
|
|
7
7
|
* PreToolUse blocks an obviously-sensitive file read before it happens,
|
|
@@ -11,14 +11,18 @@
|
|
|
11
11
|
* from leaking": Claude Code's hooks API gives a PreToolUse hook the
|
|
12
12
|
* PROPOSED tool input (a Bash command string, a Read file_path) before the
|
|
13
13
|
* tool runs, and lets it deny the call outright -- but it never sees the
|
|
14
|
-
* tool's OUTPUT
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
14
|
+
* tool's OUTPUT. So on its own, this can only block INPUT that matches a
|
|
15
|
+
* known-sensitive file path pattern (.env, id_rsa, .aws/credentials, and
|
|
16
|
+
* similar) -- it cannot catch a secret typed directly into a prompt, or one
|
|
17
|
+
* arriving in the output of an otherwise-unremarkable command (curl, `git
|
|
18
|
+
* log`, a build log), or any file path this pattern list does not name.
|
|
19
|
+
* UserPromptSubmit (below) closes the first gap; PostToolUse (below) closes
|
|
20
|
+
* the second, corrected from an earlier version of this comment that
|
|
21
|
+
* called it uncatchable -- Claude Code's own `updatedToolOutput` decision
|
|
22
|
+
* field (confirmed directly against code.claude.com/docs/en/hooks, not
|
|
23
|
+
* assumed) can in fact replace what the model sees, discovered during a
|
|
24
|
+
* later competitive-research pass after GitGuardian's ggshield was found to
|
|
25
|
+
* already scan tool output at this exact stage.
|
|
22
26
|
*
|
|
23
27
|
* UserPromptSubmit closes exactly the "typed directly into a prompt" gap
|
|
24
28
|
* named above: Claude Code's own docs (code.claude.com/docs/en/hooks,
|
|
@@ -66,6 +70,56 @@
|
|
|
66
70
|
const { PATTERNS, redact } = require("./patterns");
|
|
67
71
|
const { VENDOR_EXAMPLE_VALUES, zeroEntropyTail } = require("./scan");
|
|
68
72
|
|
|
73
|
+
const GUARD_COMMAND = "residoo guard";
|
|
74
|
+
|
|
75
|
+
// matcher: null means Claude Code's own hook-config schema omits the field
|
|
76
|
+
// entirely for an event with no matcher support (UserPromptSubmit) --
|
|
77
|
+
// distinct from an empty string, which would mean "match everything" for
|
|
78
|
+
// an event that DOES support matchers.
|
|
79
|
+
const HOOK_EVENT_SPECS = [
|
|
80
|
+
{ event: "PreToolUse", matcher: "Bash|Read" },
|
|
81
|
+
{ event: "UserPromptSubmit", matcher: null },
|
|
82
|
+
{ event: "PostToolUse", matcher: "Bash" },
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Pure function: given a parsed Claude Code settings.json object (or `{}`
|
|
87
|
+
* for a fresh one), returns a NEW object with a "residoo guard" hook group
|
|
88
|
+
* appended to each of PreToolUse/UserPromptSubmit/PostToolUse's hook
|
|
89
|
+
* arrays -- unless a "residoo guard" command already exists ANYWHERE in
|
|
90
|
+
* that event's groups, in which case that event is left untouched
|
|
91
|
+
* (idempotent: running this against its own prior output changes
|
|
92
|
+
* nothing). Never mutates `existing`; every other key, every other tool's
|
|
93
|
+
* hook, every unrelated setting is carried through byte-for-byte.
|
|
94
|
+
*
|
|
95
|
+
* This function only COMPUTES a value -- it never touches a file, on
|
|
96
|
+
* purpose. CONTRIBUTING.md's own hard rule (rule 3) names
|
|
97
|
+
* `~/.residoo/rotations.json` as "the only file residoo ever writes
|
|
98
|
+
* outside an explicit --seal ... nothing else may claim this carve-out."
|
|
99
|
+
* An auto-installing `guard --install` that edited `.claude/settings.json`
|
|
100
|
+
* directly would violate that rule outright, so the CLI wraps this in
|
|
101
|
+
* `--print-config` instead (see cli.js's runGuardPrintConfig): print the
|
|
102
|
+
* merged JSON to stdout, let the human decide whether and where to save it.
|
|
103
|
+
*/
|
|
104
|
+
function buildHookConfig(existing) {
|
|
105
|
+
const settings = existing && typeof existing === "object" && !Array.isArray(existing)
|
|
106
|
+
? JSON.parse(JSON.stringify(existing)) : {};
|
|
107
|
+
if (!settings.hooks || typeof settings.hooks !== "object" || Array.isArray(settings.hooks)) settings.hooks = {};
|
|
108
|
+
|
|
109
|
+
for (const { event, matcher } of HOOK_EVENT_SPECS) {
|
|
110
|
+
const groups = Array.isArray(settings.hooks[event]) ? settings.hooks[event] : [];
|
|
111
|
+
const alreadyPresent = groups.some((g) =>
|
|
112
|
+
g && typeof g === "object" && Array.isArray(g.hooks) &&
|
|
113
|
+
g.hooks.some((h) => h && typeof h === "object" && h.type === "command" && h.command === GUARD_COMMAND));
|
|
114
|
+
if (alreadyPresent) { settings.hooks[event] = groups; continue; }
|
|
115
|
+
const newGroup = matcher
|
|
116
|
+
? { matcher, hooks: [{ type: "command", command: GUARD_COMMAND }] }
|
|
117
|
+
: { hooks: [{ type: "command", command: GUARD_COMMAND }] };
|
|
118
|
+
settings.hooks[event] = [...groups, newGroup];
|
|
119
|
+
}
|
|
120
|
+
return settings;
|
|
121
|
+
}
|
|
122
|
+
|
|
69
123
|
// A matched path fragment must be preceded by a path separator or the start
|
|
70
124
|
// of the string, and followed by either the end of the string (the common
|
|
71
125
|
// case for Read's file_path) or a shell metacharacter/whitespace (the case
|
|
@@ -204,8 +258,86 @@ function evaluatePromptText(promptText) {
|
|
|
204
258
|
}
|
|
205
259
|
|
|
206
260
|
/**
|
|
207
|
-
*
|
|
208
|
-
*
|
|
261
|
+
* Redacts every high-confidence match in `text`, tracking the first REAL
|
|
262
|
+
* (non-suppressed) hit for the audit trail/preview. Same suppression rule
|
|
263
|
+
* as evaluatePromptText (a documented vendor-example key or an obvious
|
|
264
|
+
* zero-entropy placeholder is never redacted) and the same rule set
|
|
265
|
+
* (PROMPT_GUARD_RULES) -- one bar for everything this module scans text
|
|
266
|
+
* content with, whether that content is a typed prompt or a command's
|
|
267
|
+
* output. `String.prototype.replace` with each rule's own global regex
|
|
268
|
+
* redacts every occurrence in one pass per rule; a value already inside a
|
|
269
|
+
* PREVIOUS rule's redaction (unlikely given how distinct these vendor
|
|
270
|
+
* prefixes are, but not impossible) is walked past unchanged rather than
|
|
271
|
+
* double-redacted, since only literal ORIGINAL text can still match a
|
|
272
|
+
* pattern from patterns.js.
|
|
273
|
+
*/
|
|
274
|
+
function redactSecretsInText(text) {
|
|
275
|
+
if (typeof text !== "string" || !text) return { text: typeof text === "string" ? text : "", hit: null };
|
|
276
|
+
let result = text;
|
|
277
|
+
let hit = null;
|
|
278
|
+
for (const rule of PROMPT_GUARD_RULES) {
|
|
279
|
+
rule.re.lastIndex = 0;
|
|
280
|
+
result = result.replace(rule.re, (match) => {
|
|
281
|
+
if (VENDOR_EXAMPLE_VALUES.has(match) || zeroEntropyTail(match)) return match;
|
|
282
|
+
if (!hit) hit = { label: rule.label, preview: redact(match) };
|
|
283
|
+
return redact(match);
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
return { text: result, hit };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Pure decision function: given a PostToolUse hook payload's tool_name and
|
|
291
|
+
* tool_response, decide whether to redact. No I/O, fully unit-testable.
|
|
292
|
+
*
|
|
293
|
+
* Bash only, and stated why rather than left implicit: Claude Code's own
|
|
294
|
+
* docs (code.claude.com/docs/en/hooks, "PostToolUse decision control",
|
|
295
|
+
* fetched directly) state `updatedToolOutput` "must match the tool's
|
|
296
|
+
* output shape" and "a value that doesn't match the tool's output schema
|
|
297
|
+
* is ignored and the original output is used" -- a SILENT no-op, not an
|
|
298
|
+
* error residoo could detect and report. Bash's shape ({stdout, stderr,
|
|
299
|
+
* interrupted, isImage}) is the one built-in tool output shape those same
|
|
300
|
+
* docs actually publish; every other built-in tool's tool_response shape
|
|
301
|
+
* is not documented at this level of precision, so reconstructing one
|
|
302
|
+
* would risk exactly the silent-no-op failure this project's own
|
|
303
|
+
* discipline exists to avoid. A stated, narrow scope limit, not full
|
|
304
|
+
* "every tool's output" coverage -- and still a real, new defense layer:
|
|
305
|
+
* PreToolUse's matchSensitivePath blocks based on WHAT you're about to
|
|
306
|
+
* read (a path), this blocks based on WHAT ACTUALLY CAME BACK (content),
|
|
307
|
+
* catching a secret in `git log`, `env`, a build log, or any other Bash
|
|
308
|
+
* output PreToolUse's path-based check was never going to see coming.
|
|
309
|
+
*
|
|
310
|
+
* The tool has ALREADY run by the time this fires (Claude Code's own docs,
|
|
311
|
+
* same section) -- this cannot undo the read/write/network call the
|
|
312
|
+
* command made, and does not try to. It only keeps the raw value out of
|
|
313
|
+
* the model's context (and so out of the transcript this project's own
|
|
314
|
+
* `scan` exists to check), a narrower guarantee than PreToolUse's denial,
|
|
315
|
+
* disclosed as such rather than oversold.
|
|
316
|
+
*/
|
|
317
|
+
function evaluatePostToolUse(toolName, toolResponse) {
|
|
318
|
+
if (toolName !== "Bash" || !toolResponse || typeof toolResponse !== "object") {
|
|
319
|
+
return { act: false, label: null, preview: null, updatedToolOutput: null };
|
|
320
|
+
}
|
|
321
|
+
const stdout = redactSecretsInText(toolResponse.stdout);
|
|
322
|
+
const stderr = redactSecretsInText(toolResponse.stderr);
|
|
323
|
+
const hit = stdout.hit || stderr.hit;
|
|
324
|
+
if (!hit) return { act: false, label: null, preview: null, updatedToolOutput: null };
|
|
325
|
+
return {
|
|
326
|
+
act: true,
|
|
327
|
+
label: hit.label,
|
|
328
|
+
preview: hit.preview,
|
|
329
|
+
updatedToolOutput: {
|
|
330
|
+
stdout: stdout.text,
|
|
331
|
+
stderr: stderr.text,
|
|
332
|
+
interrupted: !!toolResponse.interrupted,
|
|
333
|
+
isImage: !!toolResponse.isImage,
|
|
334
|
+
},
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Writes one structured audit line to stderr for a block OR redact decision
|
|
340
|
+
* -- CONTRIBUTING.md's own hard rule (rule 3) names `~/.residoo/rotations.json`
|
|
209
341
|
* as "the only file residoo ever writes outside an explicit --seal...
|
|
210
342
|
* nothing else may claim this carve-out," so this is NOT a new file, the
|
|
211
343
|
* same choice `cred`'s own audit trail already made for the same reason
|
|
@@ -213,28 +345,45 @@ function evaluatePromptText(promptText) {
|
|
|
213
345
|
* hook's own stderr at launch if you want it kept, same as `cred`.
|
|
214
346
|
* Never the raw matched value -- `preview` is already redact()'d by the
|
|
215
347
|
* caller (rule 4: no raw value in any log line, ever), and PreToolUse
|
|
216
|
-
* decisions carry no value at all, only a path-pattern label.
|
|
348
|
+
* decisions carry no value at all, only a path-pattern label. `decision`
|
|
349
|
+
* defaults to "block" (every call site before PostToolUse existed) --
|
|
350
|
+
* PostToolUse passes "redact" since nothing is actually blocked there, the
|
|
351
|
+
* tool already ran; the log should say what really happened.
|
|
217
352
|
*/
|
|
218
|
-
function logAuditLine(errOutput, { event, label, preview, sessionId, cwd }) {
|
|
353
|
+
function logAuditLine(errOutput, { event, label, preview, sessionId, cwd, decision = "block" }) {
|
|
219
354
|
try {
|
|
220
355
|
errOutput.write(JSON.stringify({
|
|
221
|
-
ts: new Date().toISOString(), tool: "residoo guard", event, decision
|
|
356
|
+
ts: new Date().toISOString(), tool: "residoo guard", event, decision,
|
|
222
357
|
label, ...(preview ? { preview } : {}), sessionId: sessionId || null, cwd: cwd || null,
|
|
223
358
|
}) + "\n");
|
|
224
359
|
} catch { /* stderr write failing is never a reason to fail the hook decision itself */ }
|
|
225
360
|
}
|
|
226
361
|
|
|
227
362
|
/**
|
|
228
|
-
* Reads one PreToolUse OR
|
|
229
|
-
* (default stdin) -- distinguished by the payload's own
|
|
230
|
-
* common field, a single binary handling
|
|
231
|
-
* hook registration allows -- decides, and writes
|
|
232
|
-
* response protocol to `output` (default stdout) --
|
|
233
|
-
* caller's job (bin/residoo.js), this returns the
|
|
234
|
-
* code instead of calling process.exit itself,
|
|
235
|
-
* function in cli.js. Every BLOCK
|
|
236
|
-
* on `errOutput` (default stderr)
|
|
237
|
-
* why stderr, never a file.
|
|
363
|
+
* Reads one PreToolUse, UserPromptSubmit, OR PostToolUse hook payload from
|
|
364
|
+
* `input` (default stdin) -- distinguished by the payload's own
|
|
365
|
+
* `hook_event_name` common field, a single binary handling all three the
|
|
366
|
+
* way Claude Code's own hook registration allows -- decides, and writes
|
|
367
|
+
* the hook's own JSON response protocol to `output` (default stdout) --
|
|
368
|
+
* exit code is the caller's job (bin/residoo.js), this returns the
|
|
369
|
+
* intended process exit code instead of calling process.exit itself,
|
|
370
|
+
* matching every other run* function in cli.js. Every BLOCK or REDACT
|
|
371
|
+
* decision also gets one structured line on `errOutput` (default stderr)
|
|
372
|
+
* -- see logAuditLine's own docstring for why stderr, never a file.
|
|
373
|
+
*
|
|
374
|
+
* The final `hook_event_name !== "PreToolUse"` guard fixes a real latent
|
|
375
|
+
* bug, not a hypothetical one: before PostToolUse existed here, anything
|
|
376
|
+
* OTHER than UserPromptSubmit fell through unconditionally to
|
|
377
|
+
* evaluateToolInput -- harmless while only PreToolUse and UserPromptSubmit
|
|
378
|
+
* were ever registered, but a PostToolUse payload also carries a
|
|
379
|
+
* `tool_input` field (Claude Code's own docs confirm both `tool_input` and
|
|
380
|
+
* `tool_response` are present), so without this guard a PostToolUse event
|
|
381
|
+
* would have been silently re-evaluated as a PreToolUse decision and could
|
|
382
|
+
* have emitted a `permissionDecision: "deny"` response for a tool call
|
|
383
|
+
* that had already finished executing -- a response Claude Code has no
|
|
384
|
+
* defined behavior for. Explicit is safer than "everything else falls
|
|
385
|
+
* through," the same fail-safe posture this whole module already commits
|
|
386
|
+
* to elsewhere.
|
|
238
387
|
*/
|
|
239
388
|
async function runGuard({ input = process.stdin, output = process.stdout, errOutput = process.stderr } = {}) {
|
|
240
389
|
const chunks = [];
|
|
@@ -266,6 +415,24 @@ async function runGuard({ input = process.stdin, output = process.stdout, errOut
|
|
|
266
415
|
return 0;
|
|
267
416
|
}
|
|
268
417
|
|
|
418
|
+
if (payload.hook_event_name === "PostToolUse") {
|
|
419
|
+
const decision = evaluatePostToolUse(payload.tool_name, payload.tool_response);
|
|
420
|
+
if (!decision.act) return 0;
|
|
421
|
+
logAuditLine(errOutput, {
|
|
422
|
+
event: "PostToolUse", label: decision.label, preview: decision.preview,
|
|
423
|
+
sessionId: payload.session_id, cwd: payload.cwd, decision: "redact",
|
|
424
|
+
});
|
|
425
|
+
output.write(JSON.stringify({
|
|
426
|
+
hookSpecificOutput: {
|
|
427
|
+
hookEventName: "PostToolUse",
|
|
428
|
+
updatedToolOutput: decision.updatedToolOutput,
|
|
429
|
+
},
|
|
430
|
+
}) + "\n");
|
|
431
|
+
return 0;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (payload.hook_event_name !== "PreToolUse") return 0;
|
|
435
|
+
|
|
269
436
|
const decision = evaluateToolInput(payload.tool_name, payload.tool_input);
|
|
270
437
|
if (!decision.block) return 0;
|
|
271
438
|
|
|
@@ -283,4 +450,7 @@ async function runGuard({ input = process.stdin, output = process.stdout, errOut
|
|
|
283
450
|
return 0;
|
|
284
451
|
}
|
|
285
452
|
|
|
286
|
-
module.exports = {
|
|
453
|
+
module.exports = {
|
|
454
|
+
evaluateToolInput, matchSensitivePath, evaluatePromptText, evaluatePostToolUse,
|
|
455
|
+
runGuard, buildHookConfig, SENSITIVE_PATH_PATTERNS,
|
|
456
|
+
};
|
package/src/mcpTools.js
CHANGED
|
@@ -365,7 +365,7 @@ function buildTools({ sources }) {
|
|
|
365
365
|
projectDir: { type: "string", description: "Absolute path to a project/repo directory to scan instead of the machine-wide transcript stores (same as `residoo scan --project <dir>`). Omit for the default machine-wide scan." },
|
|
366
366
|
includeNoisy: { type: "boolean", default: false, description: "Also run residoo's two low-confidence heuristic rules (generic password/secret assignments) -- catches more, false-positives more. Off by default." },
|
|
367
367
|
includeSuppressed: { type: "boolean", default: false, description: "Include matches normally hidden because they look like vendor-documented example values or placeholder text. Off by default." },
|
|
368
|
-
includePii: { type: "boolean", default: false, description: "Also scan for PII (US Social Security Numbers, Luhn-validated credit card numbers, checksum-validated IBANs) -- a different risk category from a credential, not a lower confidence bar. Off by default; residoo is deliberately credentials-only otherwise." },
|
|
368
|
+
includePii: { type: "boolean", default: false, description: "Also scan for PII and adjacent secrets (US Social Security Numbers, Luhn-validated credit card numbers, checksum-validated IBANs, BIP-39 checksum-validated crypto wallet seed phrases) -- a different risk category from a vendor credential, not a lower confidence bar. Off by default; residoo is deliberately credentials-only otherwise." },
|
|
369
369
|
maxEntries: { type: "integer", minimum: 1, maximum: 200, default: 25, description: "Cap on distinct findings returned in full detail, pending-first. Counts in the response are always exact even when the entry list is truncated." },
|
|
370
370
|
},
|
|
371
371
|
required: [],
|
package/src/notify.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const cp = require("child_process");
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Best-effort OS desktop notification for `residoo watch`. macOS via
|
|
7
|
+
* `osascript` (always present, no new dependency -- the same shell-out
|
|
8
|
+
* precedent `keychain.js`'s `security` and `ocr.js`'s `tesseract` already
|
|
9
|
+
* set), Linux via `notify-send` (commonly present on a desktop session,
|
|
10
|
+
* NOT guaranteed -- `watch` also runs on headless/server machines with no
|
|
11
|
+
* notification daemon at all).
|
|
12
|
+
*
|
|
13
|
+
* Windows: no built-in, dependency-free mechanism was found that doesn't
|
|
14
|
+
* either need an external module (BurntToast) or pop a blocking, modal
|
|
15
|
+
* MessageBox in front of a background process -- a disclosed scope limit,
|
|
16
|
+
* not silently assumed covered, the same posture `keychain.js` already
|
|
17
|
+
* takes for its own Windows refusal.
|
|
18
|
+
*
|
|
19
|
+
* Decoration, never the report itself: `watch`'s own `emit()` already
|
|
20
|
+
* writes every finding to stdout/stderr before this is ever called, so a
|
|
21
|
+
* missing binary, no display server, or a spawn error here must never
|
|
22
|
+
* throw, block, or affect the caller in any way -- it can only make an
|
|
23
|
+
* already-reported finding easier to notice sooner.
|
|
24
|
+
*
|
|
25
|
+
* `cp.spawn` (not destructured at module load) so a test can monkey-patch
|
|
26
|
+
* `require("child_process").spawn` directly and restore it after, without
|
|
27
|
+
* this module needing its own injectable-dependency parameter.
|
|
28
|
+
*/
|
|
29
|
+
function notifyDesktop(title, message) {
|
|
30
|
+
try {
|
|
31
|
+
if (process.platform === "darwin") {
|
|
32
|
+
// osascript's -e takes one AppleScript source string; spawn (no
|
|
33
|
+
// shell:true) passes it as a single argv entry, so there is no shell
|
|
34
|
+
// to inject into -- but the string still has to be valid AppleScript
|
|
35
|
+
// source, so its own quote/backslash characters need escaping or a
|
|
36
|
+
// stray one just breaks the script into a harmless no-op.
|
|
37
|
+
const esc = (s) => String(s).replace(/[\\"]/g, "\\$&");
|
|
38
|
+
const script = `display notification "${esc(message)}" with title "${esc(title)}"`;
|
|
39
|
+
const child = cp.spawn("osascript", ["-e", script], { stdio: "ignore" });
|
|
40
|
+
child.on("error", () => {}); // binary missing or spawn failed: never throw
|
|
41
|
+
child.unref();
|
|
42
|
+
} else if (process.platform === "linux") {
|
|
43
|
+
const child = cp.spawn("notify-send", [String(title), String(message)], { stdio: "ignore" });
|
|
44
|
+
child.on("error", () => {});
|
|
45
|
+
child.unref();
|
|
46
|
+
}
|
|
47
|
+
// Windows and anything else: no-op, disclosed above, not attempted.
|
|
48
|
+
} catch {
|
|
49
|
+
// Never let a notification failure affect the caller.
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = { notifyDesktop };
|
package/src/pii.js
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
+
const crypto = require("crypto");
|
|
4
|
+
const { BIP39_ENGLISH_WORDLIST } = require("./bip39wordlist");
|
|
5
|
+
|
|
3
6
|
/**
|
|
4
|
-
* Opt-in PII detection (--include-pii). Named directly by this
|
|
5
|
-
* own competitive research into funded AI-DLP vendors (Strac,
|
|
6
|
-
* Nightfall) as one of the few concrete, buildable things
|
|
7
|
-
* adopt without becoming a hosted service -- and
|
|
8
|
-
* corroborated by two direct competitors' own shipped
|
|
9
|
-
* (DidILeak, Medusa), both of which cover PII alongside
|
|
7
|
+
* Opt-in PII-and-adjacent detection (--include-pii). Named directly by this
|
|
8
|
+
* session's own competitive research into funded AI-DLP vendors (Strac,
|
|
9
|
+
* Cyberhaven, Nightfall) as one of the few concrete, buildable things
|
|
10
|
+
* residoo could adopt without becoming a hosted service -- and
|
|
11
|
+
* independently corroborated by two direct competitors' own shipped
|
|
12
|
+
* detector lists (DidILeak, Medusa), both of which cover PII alongside
|
|
13
|
+
* credentials.
|
|
10
14
|
*
|
|
11
15
|
* Kept entirely separate from PATTERNS/NOISY_PATTERNS in patterns.js on
|
|
12
16
|
* purpose: residoo's stated identity elsewhere in this project is
|
|
@@ -17,19 +21,34 @@
|
|
|
17
21
|
* for a different reason (a different RISK CATEGORY, not a lower
|
|
18
22
|
* confidence bar).
|
|
19
23
|
*
|
|
20
|
-
*
|
|
24
|
+
* "PII" is the name of the flag, not a perfectly accurate label for
|
|
25
|
+
* everything in it: a BIP-39 crypto wallet seed phrase (added after a
|
|
26
|
+
* competitive-feature-parity pass found AgentSweep ships seed-phrase
|
|
27
|
+
* detection and residoo didn't) is a CREDENTIAL, not personal data --
|
|
28
|
+
* arguably it belongs in the default, always-on set the way a vendor API
|
|
29
|
+
* key does. It lives here instead for the same architectural reason as
|
|
30
|
+
* the other three: no single-vendor prefix to anchor on (a seed phrase is
|
|
31
|
+
* 12-24 plain English words, structurally unlike every PATTERNS.js rule),
|
|
32
|
+
* and a real, own risk-category case for asking first, the same
|
|
33
|
+
* "different shape, not a lower bar" framing already applied to SSN/card/
|
|
34
|
+
* IBAN. Kept in the same flag rather than a new one to avoid flag
|
|
35
|
+
* proliferation for what is, mechanically, the same "opt in, then
|
|
36
|
+
* checksum-validate before ever reporting" pattern.
|
|
37
|
+
*
|
|
38
|
+
* Four detectors, deliberately not more: DidILeak's own shipped list also
|
|
21
39
|
* includes bare email addresses and phone numbers, but both are far too
|
|
22
40
|
* common in ordinary, non-sensitive text (a support email in a comment, a
|
|
23
41
|
* phone number in an error message) to meet this project's own
|
|
24
42
|
* "high-confidence only, a security tool that cries wolf gets
|
|
25
43
|
* uninstalled" bar, opt-in or not -- DidILeak itself rates them "low"/
|
|
26
|
-
* "info" severity for the same reason.
|
|
44
|
+
* "info" severity for the same reason. Every detector here instead has a
|
|
27
45
|
* REAL mathematical validator, not just a shape match, which is what
|
|
28
46
|
* keeps false-positive risk low enough to ship even as an additive
|
|
29
|
-
* category: Luhn for card numbers, ISO 7064 MOD 97-10 for IBAN,
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* "
|
|
47
|
+
* category: Luhn for card numbers, ISO 7064 MOD 97-10 for IBAN, the BIP-39
|
|
48
|
+
* spec's own SHA-256 checksum for seed phrases, and the Social Security
|
|
49
|
+
* Administration's own published invalid-range rules for SSNs (no
|
|
50
|
+
* checksum exists for SSNs, hence "medium" confidence there, not "high"
|
|
51
|
+
* -- disclosed, not smoothed over).
|
|
33
52
|
*/
|
|
34
53
|
|
|
35
54
|
/** Luhn checksum (ISO/IEC 7812-1): the standard validator for payment card numbers. `digits` must already be digits-only. */
|
|
@@ -72,6 +91,75 @@ function ibanValid(iban) {
|
|
|
72
91
|
return mod === 1;
|
|
73
92
|
}
|
|
74
93
|
|
|
94
|
+
const BIP39_INDEX = new Map(BIP39_ENGLISH_WORDLIST.map((w, i) => [w, i]));
|
|
95
|
+
const BIP39_VALID_LENGTHS = new Set([12, 15, 18, 21, 24]);
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* BIP-39 mnemonic checksum (github.com/bitcoin/bips/blob/master/bip-0039.mediawiki,
|
|
99
|
+
* fetched directly, cross-checked against the spec's own canonical all-zero
|
|
100
|
+
* test vector -- "abandon" x11 + "about" -- rather than trusted from memory):
|
|
101
|
+
* a mnemonic of MS words (12/15/18/21/24) encodes ENT bits of entropy plus a
|
|
102
|
+
* CS = ENT/32-bit checksum, each word an 11-bit index into the wordlist. The
|
|
103
|
+
* checksum is the first CS bits of SHA-256(entropy); this recomputes it and
|
|
104
|
+
* compares. English wordlist only -- BIP-39 also defines Japanese, Korean,
|
|
105
|
+
* Spanish, Chinese, French, Italian, Czech, and Portuguese wordlists this
|
|
106
|
+
* does not check, a disclosed scope limit rather than a silent one, the same
|
|
107
|
+
* shape as ibanValid's own per-country-length gap above.
|
|
108
|
+
*
|
|
109
|
+
* BigInt throughout: a 24-word phrase packs 264 bits, far past a safe JS
|
|
110
|
+
* integer, and a mis-sized intermediate here would silently corrupt every
|
|
111
|
+
* checksum it touches rather than throw.
|
|
112
|
+
*/
|
|
113
|
+
function bip39ChecksumValid(words) {
|
|
114
|
+
if (!Array.isArray(words) || !BIP39_VALID_LENGTHS.has(words.length)) return false;
|
|
115
|
+
const indices = [];
|
|
116
|
+
for (const w of words) {
|
|
117
|
+
const idx = BIP39_INDEX.get(w);
|
|
118
|
+
if (idx === undefined) return false;
|
|
119
|
+
indices.push(idx);
|
|
120
|
+
}
|
|
121
|
+
const csBits = words.length / 3; // MS/3, derived from CS=ENT/32 and MS=(ENT+CS)/11
|
|
122
|
+
const entBits = words.length * 11 - csBits;
|
|
123
|
+
|
|
124
|
+
let combined = 0n;
|
|
125
|
+
for (const idx of indices) combined = (combined << 11n) | BigInt(idx);
|
|
126
|
+
|
|
127
|
+
const checksumMask = (1n << BigInt(csBits)) - 1n;
|
|
128
|
+
const checksumBits = combined & checksumMask;
|
|
129
|
+
const entropyBits = combined >> BigInt(csBits);
|
|
130
|
+
|
|
131
|
+
const entHex = entropyBits.toString(16).padStart(entBits / 8 * 2, "0");
|
|
132
|
+
const hash = crypto.createHash("sha256").update(Buffer.from(entHex, "hex")).digest();
|
|
133
|
+
const hashBits = BigInt("0x" + hash.toString("hex"));
|
|
134
|
+
const expectedChecksum = (hashBits >> BigInt(256 - csBits)) & checksumMask;
|
|
135
|
+
|
|
136
|
+
return checksumBits === expectedChecksum;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* A real seed phrase is rarely the WHOLE candidate span: "here's my wallet
|
|
141
|
+
* seed: <12 words> keep it safe" is a plausible, ordinary way to paste one,
|
|
142
|
+
* and the candidate regex below (deliberately wide, to not miss a phrase
|
|
143
|
+
* that isn't sentence-initial) captures the surrounding words too. Slides
|
|
144
|
+
* every valid length (24 down to 12, longest first so a real 15+-word
|
|
145
|
+
* phrase is reported whole rather than as a coincidentally-checksum-valid
|
|
146
|
+
* 12-word prefix of it) across every starting offset in the captured run,
|
|
147
|
+
* returning the first exact substring whose checksum validates, or null.
|
|
148
|
+
* O(words x 5) checksum computations worst case -- cheap, since the regex
|
|
149
|
+
* itself already bounds "words" to at most 100.
|
|
150
|
+
*/
|
|
151
|
+
function findBip39Phrase(candidateRun) {
|
|
152
|
+
const words = candidateRun.split(" ");
|
|
153
|
+
for (let start = 0; start < words.length; start++) {
|
|
154
|
+
for (const len of [24, 21, 18, 15, 12]) {
|
|
155
|
+
if (start + len > words.length) continue;
|
|
156
|
+
const slice = words.slice(start, start + len);
|
|
157
|
+
if (bip39ChecksumValid(slice)) return slice.join(" ");
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
75
163
|
const PII_PATTERNS = [
|
|
76
164
|
// Dashed format only -- a bare 9-digit run is indistinguishable from
|
|
77
165
|
// countless other numbers in a coding-agent transcript (ports, PIDs,
|
|
@@ -91,6 +179,21 @@ const PII_PATTERNS = [
|
|
|
91
179
|
{ id: "iban", label: "IBAN (checksum-validated)", confidence: "high",
|
|
92
180
|
re: /\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b/g,
|
|
93
181
|
validate: (m) => ibanValid(m) },
|
|
182
|
+
// Candidate: a run of 12-100 lowercase words, single-space-separated (how
|
|
183
|
+
// a seed phrase actually appears -- pasted as plain text, no punctuation
|
|
184
|
+
// breaking it up). Deliberately wider than the 12-24 a phrase itself can
|
|
185
|
+
// be: a real phrase is rarely the WHOLE sentence ("here's my seed: <12
|
|
186
|
+
// words> keep it safe"), so the candidate must be free to capture
|
|
187
|
+
// leading/trailing prose too -- findBip39Phrase (below) narrows it back
|
|
188
|
+
// down. validate() returns the narrowed substring, not a boolean: common
|
|
189
|
+
// English function words like "the"/"and"/"of"/"is" are NOT in the
|
|
190
|
+
// 2048-word list (confirmed by direct check against the fetched
|
|
191
|
+
// wordlist), so ordinary prose almost never survives even the membership
|
|
192
|
+
// test, let alone the checksum, but the narrowing still matters whenever
|
|
193
|
+
// it does.
|
|
194
|
+
{ id: "crypto_seed_phrase", label: "Crypto wallet seed phrase (BIP-39, checksum-validated)", confidence: "high",
|
|
195
|
+
re: /\b[a-z]+(?: [a-z]+){11,99}\b/g,
|
|
196
|
+
validate: (m) => findBip39Phrase(m) },
|
|
94
197
|
];
|
|
95
198
|
|
|
96
|
-
module.exports = { PII_PATTERNS, luhnValid, ibanValid };
|
|
199
|
+
module.exports = { PII_PATTERNS, luhnValid, ibanValid, bip39ChecksumValid, findBip39Phrase };
|
package/src/rotation.js
CHANGED
|
@@ -1181,6 +1181,23 @@ const ROTATION_GUIDANCE = {
|
|
|
1181
1181
|
],
|
|
1182
1182
|
revokeNote: "This passed the real ISO 7064 MOD 97-10 checksum every valid IBAN must satisfy, so it is very unlikely to be a random-looking placeholder.",
|
|
1183
1183
|
},
|
|
1184
|
+
// Framed like every credential rule above, not like the two PII entries
|
|
1185
|
+
// just above it: unlike an SSN or an IBAN, a seed phrase is a bearer
|
|
1186
|
+
// credential with an actual "rotation" action available -- move the
|
|
1187
|
+
// funds to a new wallet -- so it belongs with "revoke and replace," not
|
|
1188
|
+
// "there is no console for this."
|
|
1189
|
+
crypto_seed_phrase: {
|
|
1190
|
+
label: "Crypto wallet seed phrase (BIP-39, checksum-validated)",
|
|
1191
|
+
consolePath: "No vendor console -- this is a self-custodied wallet's own master key, not an account credential a company can revoke for you.",
|
|
1192
|
+
steps: [
|
|
1193
|
+
"Treat the wallet as fully compromised the moment this leaves your control, even if you don't see funds move immediately -- anyone with the phrase can derive every key and address it controls",
|
|
1194
|
+
"Create a brand-new wallet from a freshly-generated seed phrase, on a device you trust, never by reusing or deriving from the exposed one",
|
|
1195
|
+
"Move all funds and any NFTs/tokens to the new wallet as soon as possible, paying attention to network fees so a move doesn't get front-run",
|
|
1196
|
+
"Update every dApp, exchange, or service that had the old wallet's address on file",
|
|
1197
|
+
"Remove the phrase from the transcript file if it doesn't need to be there -- residoo scan --seal quarantines the whole file without deleting anything, if you want a reversible first step",
|
|
1198
|
+
],
|
|
1199
|
+
revokeNote: "This passed the real BIP-39 SHA-256 checksum every valid seed phrase must satisfy, so it is very unlikely to be a random-looking placeholder -- treat it as a real, live wallet key until shown otherwise.",
|
|
1200
|
+
},
|
|
1184
1201
|
|
|
1185
1202
|
// ── NOISY_PATTERNS (only reachable via --include-noisy) ───────────────
|
|
1186
1203
|
generic_password_assignment: {
|
package/src/scan.js
CHANGED
|
@@ -609,21 +609,31 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
|
|
|
609
609
|
// at all, so this mirrors decodeLine/ocrLine's simpler shape, not
|
|
610
610
|
// matchLine's. `validate` (Luhn, IBAN's MOD 97-10) runs before a
|
|
611
611
|
// candidate is even considered for suppression -- an invalid checksum
|
|
612
|
-
// is not a "placeholder," it's simply not a match.
|
|
612
|
+
// is not a "placeholder," it's simply not a match. `validate` may return
|
|
613
|
+
// `true` (record the regex's own match verbatim -- Luhn/IBAN) or a
|
|
614
|
+
// narrower STRING (record that instead -- crypto_seed_phrase's
|
|
615
|
+
// findBip39Phrase, since the regex candidate deliberately over-captures
|
|
616
|
+
// surrounding prose and the checksum-valid phrase is usually a
|
|
617
|
+
// sub-string of it, not the whole match).
|
|
613
618
|
const piiLine = (line, file, relFile, lineNo, mtimeMs) => {
|
|
614
619
|
if (!includePii) return;
|
|
615
620
|
for (const rule of PII_PATTERNS) {
|
|
616
621
|
rule.re.lastIndex = 0;
|
|
617
622
|
let m;
|
|
618
623
|
while ((m = rule.re.exec(line)) !== null) {
|
|
619
|
-
|
|
624
|
+
let value = m[0];
|
|
625
|
+
if (rule.validate) {
|
|
626
|
+
const v = rule.validate(m[0]);
|
|
627
|
+
if (!v) continue;
|
|
628
|
+
if (typeof v === "string") value = v;
|
|
629
|
+
}
|
|
620
630
|
const before = line.slice(Math.max(0, m.index - CONTEXT_WINDOW), m.index);
|
|
621
|
-
const suppressedReason = suppressionReason(
|
|
631
|
+
const suppressedReason = suppressionReason(value, before, rule.id);
|
|
622
632
|
if (suppressedReason && !includeSuppressed) {
|
|
623
633
|
suppressedCount++;
|
|
624
634
|
continue;
|
|
625
635
|
}
|
|
626
|
-
record(rule,
|
|
636
|
+
record(rule, value, relFile, file, lineNo, mtimeMs,
|
|
627
637
|
suppressedReason ? "low" : rule.confidence, suppressedReason, { pii: true });
|
|
628
638
|
}
|
|
629
639
|
}
|
|
@@ -107,6 +107,18 @@ const { createInterface } = require("readline/promises");
|
|
|
107
107
|
* (global config; their own security page recommends `chmod 600` on
|
|
108
108
|
* it, a vendor admission it holds secrets) + named verbatim in JFrog's
|
|
109
109
|
* Bitwarden-CLI target list.
|
|
110
|
+
* - `~/.mcp.json` (bare, directly under HOME, distinct from every
|
|
111
|
+
* subdirectory-nested path above) — Visual Studio (the full Windows
|
|
112
|
+
* IDE, not VS Code; VS Code's own MCP config is `.vscode/mcp.json`,
|
|
113
|
+
* already project-scoped below) documents this exact path as its
|
|
114
|
+
* global, all-solutions MCP config: Microsoft's own docs repo
|
|
115
|
+
* (github.com/MicrosoftDocs/visualstudio-docs, docs/ide/mcp-servers.md,
|
|
116
|
+
* fetched directly), "Serves as a global MCP server configuration for
|
|
117
|
+
* a specific user. Adding an MCP server here makes it load for all
|
|
118
|
+
* Visual Studio solutions." Visual Studio's remote-MCP OAuth tokens go
|
|
119
|
+
* through "the Visual Studio keychain" per the same doc -- an OS-backed
|
|
120
|
+
* store, not a plaintext file, so there is no separate credential
|
|
121
|
+
* vault to name here the way `.codex/auth.json` needed one.
|
|
110
122
|
*
|
|
111
123
|
* DELIBERATELY NOT READ, and why:
|
|
112
124
|
* - `~/.claude/projects/**` AS SCAN CONTENT — claude-code.js's territory;
|
|
@@ -194,6 +206,7 @@ const CANDIDATES = [
|
|
|
194
206
|
path.join(GEMINI_DIR, "settings.json"),
|
|
195
207
|
path.join(CODEX_HOME, "config.toml"),
|
|
196
208
|
path.join(KIRO_DIR, "settings", "mcp.json"),
|
|
209
|
+
path.join(HOME, ".mcp.json"),
|
|
197
210
|
];
|
|
198
211
|
|
|
199
212
|
// Configs are KB-scale in every real observation this source's research
|
|
@@ -229,7 +242,12 @@ function available() {
|
|
|
229
242
|
dirExists(CURSOR_DIR) ||
|
|
230
243
|
dirExists(GEMINI_DIR) ||
|
|
231
244
|
dirExists(CODEX_HOME) ||
|
|
232
|
-
dirExists(KIRO_DIR)
|
|
245
|
+
dirExists(KIRO_DIR) ||
|
|
246
|
+
// Visual Studio (the full IDE) has no dedicated root directory the way
|
|
247
|
+
// every other tool above does -- its one machine-level artifact is this
|
|
248
|
+
// bare file, so it needs its own direct existence check rather than a
|
|
249
|
+
// dirExists() on some ~/.visualstudio root that doesn't exist.
|
|
250
|
+
fileExists(path.join(HOME, ".mcp.json"))
|
|
233
251
|
);
|
|
234
252
|
}
|
|
235
253
|
|
|
@@ -319,6 +337,13 @@ const PROJECT_CONFIG_RELPATHS = [
|
|
|
319
337
|
".mcp.json",
|
|
320
338
|
path.join(".claude", "settings.json"),
|
|
321
339
|
path.join(".claude", "settings.local.json"),
|
|
340
|
+
// Visual Studio's per-solution, VS-only MCP config: Microsoft's own docs
|
|
341
|
+
// (same source cited for ~/.mcp.json above) list this as a distinct
|
|
342
|
+
// location from <SOLUTIONDIR>/.mcp.json (source-controlled) --
|
|
343
|
+
// "Specific to Visual Studio and loads the specified MCP servers only
|
|
344
|
+
// for a specific user, for the specified solution," living inside the
|
|
345
|
+
// hidden, not-source-controlled .vs/ folder Visual Studio already owns.
|
|
346
|
+
path.join(".vs", "mcp.json"),
|
|
322
347
|
];
|
|
323
348
|
|
|
324
349
|
// Probe bounds: transcript first-records are KB-scale; 256KB and 20 lines is
|
package/src/watch.js
CHANGED
|
@@ -6,6 +6,7 @@ const crypto = require("crypto");
|
|
|
6
6
|
const { scan } = require("./scan");
|
|
7
7
|
const { guidanceFor, fingerprintFinding, loadAcks, loadDismissed, statePath } = require("./rotation");
|
|
8
8
|
const { c, makePaint } = require("./color");
|
|
9
|
+
const { notifyDesktop } = require("./notify");
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* `residoo watch`: continuous, near-real-time scanning of the same
|
|
@@ -563,7 +564,7 @@ function reloadLedgerIfChanged(prev) {
|
|
|
563
564
|
* backstop and nothing riding on top of it. `options.fsWatch` is accepted
|
|
564
565
|
* and ignored, reserved for when a future version adds real hints.
|
|
565
566
|
*/
|
|
566
|
-
function startWatch({ sources, options = {}, out = process.stdout, errOut = process.stderr } = {}) {
|
|
567
|
+
function startWatch({ sources, options = {}, out = process.stdout, errOut = process.stderr, notify = notifyDesktop } = {}) {
|
|
567
568
|
const paint = makePaint(options.noColor, out);
|
|
568
569
|
const tracked = new Map();
|
|
569
570
|
const seen = new Map();
|
|
@@ -582,6 +583,17 @@ function startWatch({ sources, options = {}, out = process.stdout, errOut = proc
|
|
|
582
583
|
}
|
|
583
584
|
const line = renderHumanLine(event, paint);
|
|
584
585
|
if (line !== null) out.write(line + "\n");
|
|
586
|
+
// Desktop notification is decoration on top of the line just written
|
|
587
|
+
// above, never a substitute for it -- JSON mode is for programmatic
|
|
588
|
+
// consumption (a pipe, a log shipper), not a human sitting in front of
|
|
589
|
+
// the terminal, so it's excluded the same way `options.noColor` only
|
|
590
|
+
// applies to the human-line path. Only a genuinely NEW finding notifies
|
|
591
|
+
// -- a `reexposure` (the same secret seen again) or a `watch-error`
|
|
592
|
+
// would otherwise turn a quiet, healthy watch into a notification
|
|
593
|
+
// spamming machine.
|
|
594
|
+
if (event.type === "finding" && !options.json && !options.noNotify) {
|
|
595
|
+
notify("residoo: new secret found", `${event.label} in ${event.relFile}:${event.line} (${event.preview})`);
|
|
596
|
+
}
|
|
585
597
|
}
|
|
586
598
|
|
|
587
599
|
async function tick() {
|