redac---n 0.0.0-stage → 0.0.2
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/LICENSE +21 -0
- package/README.md +323 -2
- package/THIRD_PARTY_NOTICES.md +59 -0
- package/dist/aho.d.ts +25 -0
- package/dist/aho.js +116 -0
- package/dist/base64.d.ts +10 -0
- package/dist/base64.js +29 -0
- package/dist/bip39-data.d.ts +2 -0
- package/dist/bip39-data.js +2 -0
- package/dist/cache.d.ts +14 -0
- package/dist/cache.js +40 -0
- package/dist/calls.d.ts +64 -0
- package/dist/calls.js +269 -0
- package/dist/charlm-data.d.ts +2 -0
- package/dist/charlm-data.js +3 -0
- package/dist/charlm.d.ts +7 -0
- package/dist/charlm.js +65 -0
- package/dist/checksums.d.ts +10 -0
- package/dist/checksums.js +98 -0
- package/dist/commands.d.ts +72 -0
- package/dist/commands.js +787 -0
- package/dist/context.d.ts +98 -0
- package/dist/context.js +469 -0
- package/dist/encoded.d.ts +11 -0
- package/dist/encoded.js +336 -0
- package/dist/environment.d.ts +10 -0
- package/dist/environment.js +62 -0
- package/dist/formats-data.d.ts +2 -0
- package/dist/formats-data.js +405 -0
- package/dist/formats.d.ts +5 -0
- package/dist/formats.js +470 -0
- package/dist/hmac.d.ts +3 -0
- package/dist/hmac.js +87 -0
- package/dist/index.d.ts +101 -0
- package/dist/index.js +488 -0
- package/dist/json.d.ts +82 -0
- package/dist/json.js +392 -0
- package/dist/known.d.ts +34 -0
- package/dist/known.js +340 -0
- package/dist/lines.d.ts +28 -0
- package/dist/lines.js +69 -0
- package/dist/lookup.d.ts +5 -0
- package/dist/lookup.js +7 -0
- package/dist/model-data.d.ts +3 -0
- package/dist/model-data.js +4 -0
- package/dist/model.d.ts +2 -0
- package/dist/model.js +19 -0
- package/dist/named.d.ts +20 -0
- package/dist/named.js +3257 -0
- package/dist/names.d.ts +106 -0
- package/dist/names.js +1517 -0
- package/dist/normalize.d.ts +25 -0
- package/dist/normalize.js +529 -0
- package/dist/pem.d.ts +32 -0
- package/dist/pem.js +269 -0
- package/dist/policy.d.ts +3 -0
- package/dist/policy.js +470 -0
- package/dist/public.d.ts +44 -0
- package/dist/public.js +362 -0
- package/dist/publickeys.d.ts +41 -0
- package/dist/publickeys.js +232 -0
- package/dist/random.d.ts +29 -0
- package/dist/random.js +165 -0
- package/dist/repeats.d.ts +21 -0
- package/dist/repeats.js +80 -0
- package/dist/rules.d.ts +42 -0
- package/dist/rules.js +58 -0
- package/dist/runs.d.ts +10 -0
- package/dist/runs.js +36 -0
- package/dist/secretfile.d.ts +52 -0
- package/dist/secretfile.js +722 -0
- package/dist/shell.d.ts +127 -0
- package/dist/shell.js +703 -0
- package/dist/spans.d.ts +61 -0
- package/dist/spans.js +287 -0
- package/dist/sql.d.ts +20 -0
- package/dist/sql.js +66 -0
- package/dist/stream.d.ts +46 -0
- package/dist/stream.js +150 -0
- package/dist/structure.d.ts +24 -0
- package/dist/structure.js +1625 -0
- package/dist/syntax.d.ts +156 -0
- package/dist/syntax.js +446 -0
- package/dist/transcript.d.ts +30 -0
- package/dist/transcript.js +713 -0
- package/dist/types.d.ts +81 -0
- package/dist/types.js +1 -0
- package/dist/unnamed.d.ts +59 -0
- package/dist/unnamed.js +939 -0
- package/dist/urls.d.ts +70 -0
- package/dist/urls.js +303 -0
- package/dist/values.d.ts +178 -0
- package/dist/values.js +1621 -0
- package/dist/yaml.d.ts +40 -0
- package/dist/yaml.js +174 -0
- package/docs/creddata.svg +318 -0
- package/docs/guessing.md +139 -0
- package/docs/holdout.svg +85 -0
- package/docs/layers.md +80 -0
- package/docs/models.svg +81 -0
- package/docs/real-text.svg +112 -0
- package/package.json +57 -5
package/dist/random.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Measures of how random a token looks, from the token alone, for the random layer's model (unnamed.ts): the
|
|
3
|
+
* alphabet it is drawn from, its entropy as a z-score against a uniformly random string of the same length over that
|
|
4
|
+
* alphabet (so a 20-character hex string and a 60-character base64 one are judged on one scale), and its character
|
|
5
|
+
* classes and how often they switch. A random key, a hash and an encrypted blob all look random here; telling them
|
|
6
|
+
* apart is the context's job.
|
|
7
|
+
*/
|
|
8
|
+
/** Sizes of the alphabets a token is drawn from. (`other` is the 62 letters and digits and about 8 of the symbols keys use; the model's features depend on it.) */
|
|
9
|
+
const ALPHABET_SIZE = {
|
|
10
|
+
hex: 16,
|
|
11
|
+
HEX: 16,
|
|
12
|
+
b32: 32,
|
|
13
|
+
b36: 36,
|
|
14
|
+
B36: 36,
|
|
15
|
+
b62: 62,
|
|
16
|
+
b64: 64,
|
|
17
|
+
b64url: 64,
|
|
18
|
+
other: 70,
|
|
19
|
+
};
|
|
20
|
+
/** The smallest common alphabet that contains every character of the token. (`=` is base64's padding, in none.) */
|
|
21
|
+
export function alphabetOf(token) {
|
|
22
|
+
let lower = false;
|
|
23
|
+
let upper = false;
|
|
24
|
+
let digit = false;
|
|
25
|
+
let hexOnly = true;
|
|
26
|
+
let b64 = false;
|
|
27
|
+
let url = false;
|
|
28
|
+
let other = false;
|
|
29
|
+
let b32 = true;
|
|
30
|
+
for (let i = 0; i < token.length; i++) {
|
|
31
|
+
const c = token.charCodeAt(i);
|
|
32
|
+
if (c >= 97 && c <= 122) {
|
|
33
|
+
lower = true;
|
|
34
|
+
if (c > 102)
|
|
35
|
+
hexOnly = false;
|
|
36
|
+
}
|
|
37
|
+
else if (c >= 65 && c <= 90) {
|
|
38
|
+
upper = true;
|
|
39
|
+
if (c > 70)
|
|
40
|
+
hexOnly = false;
|
|
41
|
+
}
|
|
42
|
+
else if (c >= 48 && c <= 57) {
|
|
43
|
+
digit = true;
|
|
44
|
+
if (c < 50 || c > 55)
|
|
45
|
+
b32 = false;
|
|
46
|
+
}
|
|
47
|
+
else if (c === 43 || c === 47)
|
|
48
|
+
b64 = true;
|
|
49
|
+
else if (c === 45 || c === 95)
|
|
50
|
+
url = true;
|
|
51
|
+
else if (c !== 61)
|
|
52
|
+
other = true;
|
|
53
|
+
}
|
|
54
|
+
if (other || (b64 && url))
|
|
55
|
+
return "other";
|
|
56
|
+
if (b64)
|
|
57
|
+
return "b64";
|
|
58
|
+
if (url)
|
|
59
|
+
return "b64url";
|
|
60
|
+
if (hexOnly && !(lower && upper))
|
|
61
|
+
return upper ? "HEX" : "hex";
|
|
62
|
+
if (upper && !lower && b32)
|
|
63
|
+
return "b32";
|
|
64
|
+
if (lower && upper)
|
|
65
|
+
return "b62";
|
|
66
|
+
if (upper)
|
|
67
|
+
return "B36";
|
|
68
|
+
return digit || lower ? "b36" : "other";
|
|
69
|
+
}
|
|
70
|
+
/** Shannon entropy of the characters, in bits per character. */
|
|
71
|
+
export function entropy(token) {
|
|
72
|
+
const counts = new Map();
|
|
73
|
+
for (let i = 0; i < token.length; i++) {
|
|
74
|
+
const c = token.charCodeAt(i);
|
|
75
|
+
counts.set(c, (counts.get(c) ?? 0) + 1);
|
|
76
|
+
}
|
|
77
|
+
let bits = 0;
|
|
78
|
+
for (const n of counts.values()) {
|
|
79
|
+
const p = n / token.length;
|
|
80
|
+
bits -= p * Math.log2(p);
|
|
81
|
+
}
|
|
82
|
+
return bits;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The expected entropy and its standard deviation for a uniformly random string of length `n` (at most 512) over
|
|
86
|
+
* `k` characters, estimated once by simulation and cached. Lets a threshold be a z-score instead of a
|
|
87
|
+
* fixed number of bits, which is too strict for short tokens and too loose for long ones.
|
|
88
|
+
*/
|
|
89
|
+
const entropyStats = new Map();
|
|
90
|
+
function randomEntropy(k, n) {
|
|
91
|
+
const key = k * 10_000 + Math.min(n, 512);
|
|
92
|
+
let s = entropyStats.get(key);
|
|
93
|
+
if (s)
|
|
94
|
+
return s;
|
|
95
|
+
n = Math.min(n, 512);
|
|
96
|
+
// Fixed seed, so results never vary between runs.
|
|
97
|
+
let seed = key;
|
|
98
|
+
const next = () => {
|
|
99
|
+
seed = (seed + 0x6d2b79f5) | 0;
|
|
100
|
+
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
|
101
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
102
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
103
|
+
};
|
|
104
|
+
const trials = 400;
|
|
105
|
+
const counts = new Uint16Array(k);
|
|
106
|
+
let sum = 0;
|
|
107
|
+
let sumSq = 0;
|
|
108
|
+
for (let t = 0; t < trials; t++) {
|
|
109
|
+
counts.fill(0);
|
|
110
|
+
for (let i = 0; i < n; i++)
|
|
111
|
+
counts[Math.floor(next() * k)]++;
|
|
112
|
+
let bits = 0;
|
|
113
|
+
for (let j = 0; j < k; j++)
|
|
114
|
+
if (counts[j])
|
|
115
|
+
bits -= (counts[j] / n) * Math.log2(counts[j] / n);
|
|
116
|
+
sum += bits;
|
|
117
|
+
sumSq += bits * bits;
|
|
118
|
+
}
|
|
119
|
+
const mean = sum / trials;
|
|
120
|
+
s = [mean, Math.sqrt(Math.max(1e-6, sumSq / trials - mean * mean))];
|
|
121
|
+
entropyStats.set(key, s);
|
|
122
|
+
return s;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* How many standard deviations the token's entropy sits from a random string's. Random strings score near 0.
|
|
126
|
+
* (Measured over the first 512 characters, the longest string randomEntropy simulates.)
|
|
127
|
+
*/
|
|
128
|
+
export function entropyZ(token, alphabet = alphabetOf(token)) {
|
|
129
|
+
const t = token.slice(0, 512);
|
|
130
|
+
const [mean, sd] = randomEntropy(ALPHABET_SIZE[alphabet], t.length);
|
|
131
|
+
return (entropy(t) - mean) / sd;
|
|
132
|
+
}
|
|
133
|
+
function classOf(c) {
|
|
134
|
+
if (c >= 97 && c <= 122)
|
|
135
|
+
return 0;
|
|
136
|
+
if (c >= 65 && c <= 90)
|
|
137
|
+
return 1;
|
|
138
|
+
if (c >= 48 && c <= 57)
|
|
139
|
+
return 2;
|
|
140
|
+
return 3;
|
|
141
|
+
}
|
|
142
|
+
export function shape(token) {
|
|
143
|
+
const n = token.length;
|
|
144
|
+
const cls = [0, 0, 0, 0];
|
|
145
|
+
let switches = 0;
|
|
146
|
+
let prev = -1;
|
|
147
|
+
for (let i = 0; i < n; i++) {
|
|
148
|
+
const c = classOf(token.charCodeAt(i));
|
|
149
|
+
cls[c]++;
|
|
150
|
+
if (prev >= 0 && c !== prev)
|
|
151
|
+
switches++;
|
|
152
|
+
prev = c;
|
|
153
|
+
}
|
|
154
|
+
let same = 0;
|
|
155
|
+
for (const k of cls)
|
|
156
|
+
same += (k / n) ** 2;
|
|
157
|
+
return {
|
|
158
|
+
length: n,
|
|
159
|
+
lower: cls[0],
|
|
160
|
+
upper: cls[1],
|
|
161
|
+
digit: cls[2],
|
|
162
|
+
switches: switches / Math.max(1, n - 1),
|
|
163
|
+
expectedSwitches: n === 0 ? 0 : 1 - same,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Finding } from "./types.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Other occurrences of confidently found values: a webhook token found in its URL is also hidden in
|
|
4
|
+
* the `POST /api/webhooks/…/<token>` line below it, a password found under its name also where it's
|
|
5
|
+
* echoed without one.
|
|
6
|
+
*/
|
|
7
|
+
export declare function repeats(text: string, found: Finding[]): Finding[];
|
|
8
|
+
/** A value worth looking for elsewhere: what its finding called it, and the secret it is (for the placeholder's tag). */
|
|
9
|
+
export interface Remembered {
|
|
10
|
+
kind: string;
|
|
11
|
+
value: string;
|
|
12
|
+
/** Whether the random layer guessed it: its other occurrences are proposed, not vouched for (rules.ts). */
|
|
13
|
+
guess?: boolean;
|
|
14
|
+
}
|
|
15
|
+
/** The values of confident findings worth looking for elsewhere, each as written (one secret may be written two ways). */
|
|
16
|
+
export declare function repeatable(text: string, found: Finding[]): Map<string, Remembered>;
|
|
17
|
+
/**
|
|
18
|
+
* Where `values` occur in `text` as whole tokens, outside what `found` covers: each under its first finding's kind and
|
|
19
|
+
* with its secret, so it has the placeholder that finding has.
|
|
20
|
+
*/
|
|
21
|
+
export declare function occurrences(text: string, values: ReadonlyMap<string, Remembered>, found: Finding[]): Finding[];
|
package/dist/repeats.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/** Values found once with confidence, hidden wherever else they appear in the same text. */
|
|
2
|
+
import { KeywordSet } from "./aho.js";
|
|
3
|
+
import { continuesToken } from "./syntax.js";
|
|
4
|
+
import { isBenignValue } from "./values.js";
|
|
5
|
+
import { covered, loginPassword, secretValue } from "./spans.js";
|
|
6
|
+
import { isGuess, layerOf, PLACEHOLDER_RULE, REPEATED_GUESS } from "./rules.js";
|
|
7
|
+
/**
|
|
8
|
+
* Other occurrences of confidently found values: a webhook token found in its URL is also hidden in
|
|
9
|
+
* the `POST /api/webhooks/…/<token>` line below it, a password found under its name also where it's
|
|
10
|
+
* echoed without one.
|
|
11
|
+
*/
|
|
12
|
+
export function repeats(text, found) {
|
|
13
|
+
return occurrences(text, repeatable(text, found), found);
|
|
14
|
+
}
|
|
15
|
+
/** The values of confident findings worth looking for elsewhere, each as written (one secret may be written two ways). */
|
|
16
|
+
export function repeatable(text, found) {
|
|
17
|
+
const values = new Map();
|
|
18
|
+
for (const f of found) {
|
|
19
|
+
if (f.confidence < 0.9 || f.end - f.start < 10 || f.end - f.start > 4096 || f.rule === PLACEHOLDER_RULE)
|
|
20
|
+
continue;
|
|
21
|
+
const v = secretValue(text, f);
|
|
22
|
+
// (A hash's shape, a commit SHA or a SHA-256, spreads only from a rule that knows what it hid: one read by its
|
|
23
|
+
// name alone (`'key': '<sha>/dist/…'`) would otherwise hide that commit in every line of a CI log.)
|
|
24
|
+
if (/^[0-9a-f]{40}$|^[0-9a-f]{64}$/.test(v) &&
|
|
25
|
+
!["format", "known", "file", "command", "structure"].includes(layerOf(f.rule)) &&
|
|
26
|
+
!(layerOf(f.rule) === "named" && f.name && !/^(?:key|keys|hash|sha|etag|id|value|data)$/i.test(f.name)))
|
|
27
|
+
continue;
|
|
28
|
+
// (Only a value that can be a secret wherever it is: not a version range, a date, a count.)
|
|
29
|
+
if (isBenignValue(v))
|
|
30
|
+
continue;
|
|
31
|
+
const remembered = { kind: f.kind, value: v, ...(isGuess(f.rule) ? { guess: true } : {}) };
|
|
32
|
+
values.set(v, remembered);
|
|
33
|
+
// (And a login's password, `NEO4J_AUTH=neo4j/<password>`, where it turns up alone.)
|
|
34
|
+
const password = loginPassword(f, v);
|
|
35
|
+
if (password && !isBenignValue(password) && !values.has(password))
|
|
36
|
+
values.set(password, { ...remembered, value: password });
|
|
37
|
+
// (As it is written here too, when that's another form of it, and as a URL writes it: `Delta2018%26` in a URL,
|
|
38
|
+
// `Delta2018&` in prose, and the other way round. Only for 8 or more printable characters and no blank: half a
|
|
39
|
+
// surrogate pair has no URL form.)
|
|
40
|
+
if (/^[!-~]{8,}$/.test(v))
|
|
41
|
+
for (const form of [text.slice(f.start, f.end), encodeURIComponent(v)])
|
|
42
|
+
if (form !== v)
|
|
43
|
+
values.set(form, remembered);
|
|
44
|
+
}
|
|
45
|
+
return values;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Where `values` occur in `text` as whole tokens, outside what `found` covers: each under its first finding's kind and
|
|
49
|
+
* with its secret, so it has the placeholder that finding has.
|
|
50
|
+
*/
|
|
51
|
+
export function occurrences(text, values, found) {
|
|
52
|
+
const out = [];
|
|
53
|
+
// (The most recent 50,000 values, past any secrets dump: an automaton over more grows without bound.)
|
|
54
|
+
const list = [...values.keys()].filter((v) => !(/\s/.test(v) && v.length < 16)).slice(-50_000);
|
|
55
|
+
if (!list.length)
|
|
56
|
+
return out;
|
|
57
|
+
const add = (at, v) => {
|
|
58
|
+
// Whole tokens only: not a prefix or suffix of a longer run.
|
|
59
|
+
const dash = v.includes("-");
|
|
60
|
+
if (continuesToken(text, at - 1, dash) ||
|
|
61
|
+
continuesToken(text, at + v.length, dash) ||
|
|
62
|
+
covered(found, at, at + v.length))
|
|
63
|
+
return;
|
|
64
|
+
const { kind, value, guess } = values.get(v);
|
|
65
|
+
out.push({ start: at, end: at + v.length, kind, rule: guess ? REPEATED_GUESS : "repeat", confidence: 0.9, value });
|
|
66
|
+
};
|
|
67
|
+
// A few values: a scan each. Many in a long text: every value in one pass.
|
|
68
|
+
// (Overlapping occurrences too, as the automaton finds them. Each scan is a fast `indexOf`; past about 8 M character comparisons the automaton's one pass is cheaper.)
|
|
69
|
+
if (list.length * text.length <= 8_000_000) {
|
|
70
|
+
for (const v of list)
|
|
71
|
+
for (let at = text.indexOf(v); at >= 0; at = text.indexOf(v, at + 1))
|
|
72
|
+
add(at, v);
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
const set = new KeywordSet(list, true);
|
|
76
|
+
for (const [at, k] of set.matches(text))
|
|
77
|
+
add(at, list[k]);
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
package/dist/rules.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the pipeline knows about a finding's rule beyond its name. A rule is named `layer:name` (`format:GitHub
|
|
3
|
+
* token`, `named:assign`, `file:opaque`), and the stages that treat findings differently by where they came from ask
|
|
4
|
+
* here, by property, rather than each matching rule names of its own.
|
|
5
|
+
*/
|
|
6
|
+
/** The detectors and the other sources of findings. */
|
|
7
|
+
export type RuleLayer = "format" | "structure" | "named" | "unnamed" | "file" | "command" | "encoded" | "known" | "repeat" | "safety" | "placeholder" | "transcript";
|
|
8
|
+
/** A placeholder of an earlier pass, found so it's replaced by itself and nothing overlapping it is hidden twice. */
|
|
9
|
+
export declare const PLACEHOLDER_RULE = "placeholder";
|
|
10
|
+
/** The layer a rule belongs to, its name before the colon. */
|
|
11
|
+
export declare function layerOf(rule: string): RuleLayer;
|
|
12
|
+
/**
|
|
13
|
+
* Whether a finding is vouched for, so the decision stage keeps it whatever it looks like: a value the caller or an
|
|
14
|
+
* earlier text said is secret (`known`, `repeat`), everything in a secret file or a secret command's output, except
|
|
15
|
+
* what is there under no credential's name (`file:opaque`, see needsJudging), a text a detector failed on (`safety`),
|
|
16
|
+
* and an earlier pass's placeholder.
|
|
17
|
+
*/
|
|
18
|
+
export declare function isVouched(rule: string): boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Proposed, not vouched for: a secret file's value under no credential's name (isOpaqueSetting), and another occurrence
|
|
21
|
+
* of a guess (a repeat of the random layer's finding), each judged in its own place like any proposal.
|
|
22
|
+
*/
|
|
23
|
+
export declare function needsJudging(rule: string): boolean;
|
|
24
|
+
/** Another occurrence of a value the random layer guessed (repeats.ts). */
|
|
25
|
+
export declare const REPEATED_GUESS = "repeat:guess";
|
|
26
|
+
/**
|
|
27
|
+
* A guess about one place (the random-token layer's, and its repeats): never learned, judged again wherever it's
|
|
28
|
+
* repeated, and no reason on its own to call decoded text a secret.
|
|
29
|
+
*/
|
|
30
|
+
export declare function isGuess(rule: string): boolean;
|
|
31
|
+
/** Rules that take a whole line as the secret, which give way to a narrower finding inside it. */
|
|
32
|
+
export declare function isWholeLine(rule: string): boolean;
|
|
33
|
+
/** A private key or a seed phrase, by its own structure: secret whatever the text around it says. */
|
|
34
|
+
export declare function isKeyMaterial(rule: string): boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Found by where the value stands (a name, a structure's slot, a guess about its place), not by what the value is: the
|
|
37
|
+
* decision stage's "never a secret" values (counts, versions, words) apply. Not structures that say what the value is
|
|
38
|
+
* (a private key, a CI mask, a JWK, a password hash, a seed phrase).
|
|
39
|
+
*/
|
|
40
|
+
export declare function readsByPlace(rule: string): boolean;
|
|
41
|
+
/** A token that a terminal wrapping at a fixed width may cut and continue on the next line: a format's, a bearer token, a URL parameter. */
|
|
42
|
+
export declare function continuesAcrossWraps(rule: string): boolean;
|
package/dist/rules.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the pipeline knows about a finding's rule beyond its name. A rule is named `layer:name` (`format:GitHub
|
|
3
|
+
* token`, `named:assign`, `file:opaque`), and the stages that treat findings differently by where they came from ask
|
|
4
|
+
* here, by property, rather than each matching rule names of its own.
|
|
5
|
+
*/
|
|
6
|
+
/** A placeholder of an earlier pass, found so it's replaced by itself and nothing overlapping it is hidden twice. */
|
|
7
|
+
export const PLACEHOLDER_RULE = "placeholder";
|
|
8
|
+
/** The layer a rule belongs to, its name before the colon. */
|
|
9
|
+
export function layerOf(rule) {
|
|
10
|
+
const colon = rule.indexOf(":");
|
|
11
|
+
return (colon < 0 ? rule : rule.slice(0, colon));
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Whether a finding is vouched for, so the decision stage keeps it whatever it looks like: a value the caller or an
|
|
15
|
+
* earlier text said is secret (`known`, `repeat`), everything in a secret file or a secret command's output, except
|
|
16
|
+
* what is there under no credential's name (`file:opaque`, see needsJudging), a text a detector failed on (`safety`),
|
|
17
|
+
* and an earlier pass's placeholder.
|
|
18
|
+
*/
|
|
19
|
+
export function isVouched(rule) {
|
|
20
|
+
return ["known", "repeat", "file", "command", "safety", "placeholder"].includes(layerOf(rule)) && !needsJudging(rule);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Proposed, not vouched for: a secret file's value under no credential's name (isOpaqueSetting), and another occurrence
|
|
24
|
+
* of a guess (a repeat of the random layer's finding), each judged in its own place like any proposal.
|
|
25
|
+
*/
|
|
26
|
+
export function needsJudging(rule) {
|
|
27
|
+
return rule === "file:opaque" || rule === REPEATED_GUESS;
|
|
28
|
+
}
|
|
29
|
+
/** Another occurrence of a value the random layer guessed (repeats.ts). */
|
|
30
|
+
export const REPEATED_GUESS = "repeat:guess";
|
|
31
|
+
/**
|
|
32
|
+
* A guess about one place (the random-token layer's, and its repeats): never learned, judged again wherever it's
|
|
33
|
+
* repeated, and no reason on its own to call decoded text a secret.
|
|
34
|
+
*/
|
|
35
|
+
export function isGuess(rule) {
|
|
36
|
+
return layerOf(rule) === "unnamed" || rule === REPEATED_GUESS;
|
|
37
|
+
}
|
|
38
|
+
/** Rules that take a whole line as the secret, which give way to a narrower finding inside it. */
|
|
39
|
+
export function isWholeLine(rule) {
|
|
40
|
+
return rule === "file:whole" || rule === "file:bare" || rule === "command:prints-secret";
|
|
41
|
+
}
|
|
42
|
+
/** A private key or a seed phrase, by its own structure: secret whatever the text around it says. */
|
|
43
|
+
export function isKeyMaterial(rule) {
|
|
44
|
+
return /^structure:(?:private-key|mnemonic)|^format:private key/.test(rule);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Found by where the value stands (a name, a structure's slot, a guess about its place), not by what the value is: the
|
|
48
|
+
* decision stage's "never a secret" values (counts, versions, words) apply. Not structures that say what the value is
|
|
49
|
+
* (a private key, a CI mask, a JWK, a password hash, a seed phrase).
|
|
50
|
+
*/
|
|
51
|
+
export function readsByPlace(rule) {
|
|
52
|
+
return (["named", "structure", "unnamed"].includes(layerOf(rule)) &&
|
|
53
|
+
!/^structure:(?:private-key|ci-mask|jwk|password-hash|mnemonic)/.test(rule));
|
|
54
|
+
}
|
|
55
|
+
/** A token that a terminal wrapping at a fixed width may cut and continue on the next line: a format's, a bearer token, a URL parameter. */
|
|
56
|
+
export function continuesAcrossWraps(rule) {
|
|
57
|
+
return /^(?:format:|structure:(?:bearer|authorization|url-param))/.test(rule);
|
|
58
|
+
}
|
package/dist/runs.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Long runs of a character set, found without looking at every character: any `min` characters in a row hold exactly one
|
|
3
|
+
* position that is a multiple of `min` (less one), so only those are read until one is in the set, and the run around it
|
|
4
|
+
* is walked. A regex like `[A-Za-z0-9+/=_-]{16,}` tries every position; in prose, logs and pretty-printed JSON, where the
|
|
5
|
+
* runs are short, this reads a sixteenth of the text.
|
|
6
|
+
*/
|
|
7
|
+
/** A table of the ASCII characters in `chars`, for `longRuns`. */
|
|
8
|
+
export declare function asciiSet(chars: string): Uint8Array;
|
|
9
|
+
/** The maximal runs of `set`'s characters at least `min` long, in order, as [start, end]. */
|
|
10
|
+
export declare function longRuns(text: string, set: Uint8Array, min: number): Generator<[start: number, end: number]>;
|
package/dist/runs.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Long runs of a character set, found without looking at every character: any `min` characters in a row hold exactly one
|
|
3
|
+
* position that is a multiple of `min` (less one), so only those are read until one is in the set, and the run around it
|
|
4
|
+
* is walked. A regex like `[A-Za-z0-9+/=_-]{16,}` tries every position; in prose, logs and pretty-printed JSON, where the
|
|
5
|
+
* runs are short, this reads a sixteenth of the text.
|
|
6
|
+
*/
|
|
7
|
+
/** A table of the ASCII characters in `chars`, for `longRuns`. */
|
|
8
|
+
export function asciiSet(chars) {
|
|
9
|
+
const set = new Uint8Array(128);
|
|
10
|
+
for (let i = 0; i < chars.length; i++)
|
|
11
|
+
set[chars.charCodeAt(i)] = 1;
|
|
12
|
+
return set;
|
|
13
|
+
}
|
|
14
|
+
/** The maximal runs of `set`'s characters at least `min` long, in order, as [start, end]. */
|
|
15
|
+
export function* longRuns(text, set, min) {
|
|
16
|
+
const inSet = (i) => text.charCodeAt(i) < 128 && set[text.charCodeAt(i)] === 1;
|
|
17
|
+
let from = 0;
|
|
18
|
+
let i = min - 1;
|
|
19
|
+
while (i < text.length) {
|
|
20
|
+
if (!inSet(i)) {
|
|
21
|
+
i += min;
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
let start = i;
|
|
25
|
+
while (start > from && inSet(start - 1))
|
|
26
|
+
start--;
|
|
27
|
+
let end = i + 1;
|
|
28
|
+
while (end < text.length && inSet(end))
|
|
29
|
+
end++;
|
|
30
|
+
if (end - start >= min)
|
|
31
|
+
yield [start, end];
|
|
32
|
+
// (The next position to read: the first one past the run that is a multiple of `min`, less one.)
|
|
33
|
+
from = end;
|
|
34
|
+
i = end + ((((min - 1 - end) % min) + min) % min);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Finding } from "./types.ts";
|
|
2
|
+
/**
|
|
3
|
+
* In a secret file, every value under a credential name is hidden whatever it looks like, and so is a
|
|
4
|
+
* line that is a single token (a bare token file, `cut -d= -f2` output); see isOpaqueSetting for other names.
|
|
5
|
+
* `path` is the file's, when known: a file that holds only secrets (`secrets.yml`) hides a generated token under any name.
|
|
6
|
+
*/
|
|
7
|
+
export declare function findInSecretFile(text: string, { part, path }?: {
|
|
8
|
+
part?: boolean | undefined;
|
|
9
|
+
path?: string | undefined;
|
|
10
|
+
}): Finding[];
|
|
11
|
+
/**
|
|
12
|
+
* The credential in the output of a command that prints one: its first line, whole (a passphrase of words, a PIN, a
|
|
13
|
+
* password that starts with `[`), unless the line is a message (`Error: …`), a record's field (`ID: …`), a reference a
|
|
14
|
+
* variable holds instead (`op://vault/item/field`), a plain URL or address, or a JSON document whole (its pairs'
|
|
15
|
+
* readers judge it); and later lines that are one token, as `pass show` prints its metadata under the password.
|
|
16
|
+
* `--output text` rows of AWS's.
|
|
17
|
+
*/
|
|
18
|
+
export declare function findPrintedSecret(text: string): Finding[];
|
|
19
|
+
/**
|
|
20
|
+
* Where the first prompt line inside a command's output starts (a later command), or its length if none. (Not a `!`
|
|
21
|
+
* line: in a command's output that is a value or an option file's directive, not a notebook's command.)
|
|
22
|
+
*/
|
|
23
|
+
export declare function firstPrompt(text: string): number;
|
|
24
|
+
/**
|
|
25
|
+
* Command transcripts inside a text (a notebook's `!cat .env`, a pasted terminal session's
|
|
26
|
+
* `$ cat ~/.aws/credentials`): the output under a prompt that prints a secret file, up to the next
|
|
27
|
+
* prompt or a closing fence, is treated as that file.
|
|
28
|
+
*/
|
|
29
|
+
export declare function findEmbeddedOutputs(text: string): Finding[];
|
|
30
|
+
export interface FileSection {
|
|
31
|
+
start: number;
|
|
32
|
+
end: number;
|
|
33
|
+
/** Lines added to a file (`>>`, `tee -a`, one of a group's writes) are part of it, not all of it. */
|
|
34
|
+
part: boolean;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Where a secret file's content sits inside a text, as [start, end) spans, in order and apart:
|
|
38
|
+
* - a diff of one (`git diff` of `.env.production`): its hunks, once normalization has removed their markers;
|
|
39
|
+
* - what a command line writes into one (commands.ts commandsRun reads it, through `sudo sh -c`, `ssh`, a prompt): a
|
|
40
|
+
* heredoc's body (`cat > .env <<'EOF'`, `sudo tee .env > /dev/null <<EOF`, `cat <<EOF | tee .env`), a here-string
|
|
41
|
+
* (`tee .env <<< "…"`), or `echo`'s and `printf`'s operands (`echo 'Harbor#2291x' > ~/.vault_pass`,
|
|
42
|
+
* `{ echo A=1; echo B=2; } > .env`).
|
|
43
|
+
* Not a SOPS file, encrypted by design. A command line inside an earlier heredoc's body is that body's text.
|
|
44
|
+
*/
|
|
45
|
+
export declare function secretFileSections(text: string): FileSection[];
|
|
46
|
+
/**
|
|
47
|
+
* The scopes still open at the end of a text, as the lines that open them, in order: a secret file's diff that runs to
|
|
48
|
+
* the end (its `+++` line and a hunk header), heredocs that write one and haven't ended (their command lines, made minimal, with any
|
|
49
|
+
* heredoc before them that is open too), or else the last prompt's command line, while no code fence has closed its
|
|
50
|
+
* output. Text that continues this one (a stream's next chunk) is read as it would be with these lines in front of it.
|
|
51
|
+
*/
|
|
52
|
+
export declare function openScopes(text: string): string[];
|