@frockbot/secret-shapes 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +18 -6
- package/src/index.test.ts +57 -0
- package/src/index.ts +134 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
package/package.json
CHANGED
|
@@ -1,14 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/secret-shapes",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts",
|
|
8
|
+
"./package.json": "./package.json"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "bun test src",
|
|
12
|
+
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"@types/bun": "1.4.0",
|
|
16
|
+
"typescript": "^7.0.2"
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
6
21
|
"repository": {
|
|
7
22
|
"type": "git",
|
|
8
23
|
"url": "git+https://github.com/timoconnellaus/frockbot.git",
|
|
9
24
|
"directory": "packages/secret-shapes"
|
|
10
|
-
},
|
|
11
|
-
"publishConfig": {
|
|
12
|
-
"access": "public"
|
|
13
25
|
}
|
|
14
26
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
SECRET_SHAPES_V1,
|
|
4
|
+
matchSecretShapeV1,
|
|
5
|
+
redactSecretShapesV1,
|
|
6
|
+
} from "./index.ts";
|
|
7
|
+
|
|
8
|
+
describe("the credential-shape table", () => {
|
|
9
|
+
test("every shape has a distinct id and a global pattern", () => {
|
|
10
|
+
const ids = SECRET_SHAPES_V1.map((shape) => shape.id);
|
|
11
|
+
expect(new Set(ids).size).toBe(ids.length);
|
|
12
|
+
expect(SECRET_SHAPES_V1.every((shape) => shape.pattern.global)).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("names the shape it matched", () => {
|
|
16
|
+
expect(
|
|
17
|
+
matchSecretShapeV1("Authorization: Bearer abcdefghijklmnopqrstuvwx"),
|
|
18
|
+
).toMatchObject({ id: "bearer-token" });
|
|
19
|
+
expect(
|
|
20
|
+
matchSecretShapeV1(
|
|
21
|
+
"token eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U",
|
|
22
|
+
),
|
|
23
|
+
).toMatchObject({ id: "jwt" });
|
|
24
|
+
expect(matchSecretShapeV1("sk-abcdefghijklmnopqrstuvwxyz")).toMatchObject({
|
|
25
|
+
id: "api-key",
|
|
26
|
+
});
|
|
27
|
+
expect(matchSecretShapeV1("nothing interesting here")).toBeUndefined();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("redaction keeps the surrounding text and names the shape", () => {
|
|
31
|
+
expect(
|
|
32
|
+
redactSecretShapesV1(
|
|
33
|
+
"curl -H 'Authorization: Bearer abcdefghijklmnopqrstuvwx' https://api",
|
|
34
|
+
),
|
|
35
|
+
).toBe("curl -H 'Authorization: [redacted:bearer-token]' https://api");
|
|
36
|
+
expect(redactSecretShapesV1("export KEY=sk-abcdefghijklmnopqrstuv")).toBe(
|
|
37
|
+
"export KEY=[redacted:api-key]",
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("redaction replaces every occurrence, and is stable", () => {
|
|
42
|
+
const text = "sk-aaaaaaaaaaaaaaaaaa and sk-bbbbbbbbbbbbbbbbbb";
|
|
43
|
+
const once = redactSecretShapesV1(text);
|
|
44
|
+
expect(once).toBe("[redacted:api-key] and [redacted:api-key]");
|
|
45
|
+
// The patterns are shared objects; a stale `lastIndex` would make the
|
|
46
|
+
// second call differ from the first.
|
|
47
|
+
expect(redactSecretShapesV1(text)).toBe(once);
|
|
48
|
+
expect(matchSecretShapeV1(text)).toMatchObject({ id: "api-key" });
|
|
49
|
+
expect(matchSecretShapeV1(text)).toMatchObject({ id: "api-key" });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("is total and bounded", () => {
|
|
53
|
+
expect(matchSecretShapeV1("")).toBeUndefined();
|
|
54
|
+
const long = "a".repeat(200_000);
|
|
55
|
+
expect(redactSecretShapesV1(long).length).toBeLessThanOrEqual(8_192);
|
|
56
|
+
});
|
|
57
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// The credential-shape table, in one place.
|
|
2
|
+
//
|
|
3
|
+
// Two Packages need the same list for two different refusals. Memory refuses a
|
|
4
|
+
// fact that looks like a credential outright ("Memory contains no secrets and
|
|
5
|
+
// no credential references", `AGENTS.md` § Memory). Audit keeps a bounded
|
|
6
|
+
// preview of a tool argument in a durable table and must not carry a
|
|
7
|
+
// credential into it ("No secrets in durable logs"). A copied list would drift,
|
|
8
|
+
// and the copy that drifted would be the one holding the secret, so the table
|
|
9
|
+
// lives here and both import it.
|
|
10
|
+
//
|
|
11
|
+
// WHAT THIS IS NOT. It is not a secret scanner. It recognises *obvious
|
|
12
|
+
// credential shapes* — the ones a model pastes because it just read them out
|
|
13
|
+
// of a config file — and makes no claim about entropy. A determined encoding
|
|
14
|
+
// gets through. That is the honest bound, and it is stated at both call sites
|
|
15
|
+
// as well as here.
|
|
16
|
+
//
|
|
17
|
+
// Bounded in the other sense too: every pattern is anchored and linear, so a
|
|
18
|
+
// hostile input cannot make the check itself expensive.
|
|
19
|
+
|
|
20
|
+
/** One recognised credential shape. */
|
|
21
|
+
export interface SecretShapeV1 {
|
|
22
|
+
/**
|
|
23
|
+
* A stable slug. It is written into durable audit previews as
|
|
24
|
+
* `[redacted:<id>]`, so renaming one changes bytes already on disk: treat it
|
|
25
|
+
* as part of the wire contract, not as a label.
|
|
26
|
+
*/
|
|
27
|
+
id: string;
|
|
28
|
+
/** The shape, as a sentence that completes "… because it …". */
|
|
29
|
+
reason: string;
|
|
30
|
+
/**
|
|
31
|
+
* Global so a redaction can replace every occurrence. `lastIndex` is reset
|
|
32
|
+
* by every function here before use, so sharing one object between callers
|
|
33
|
+
* is safe.
|
|
34
|
+
*/
|
|
35
|
+
pattern: RegExp;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The table. Order is significant only in that the first match wins for
|
|
40
|
+
* `matchSecretShapeV1`; redaction applies every pattern.
|
|
41
|
+
*/
|
|
42
|
+
export const SECRET_SHAPES_V1: readonly SecretShapeV1[] = [
|
|
43
|
+
{
|
|
44
|
+
id: "private-key",
|
|
45
|
+
reason: "it contains a PEM private key or certificate block",
|
|
46
|
+
pattern: /-----BEGIN [A-Z ]{0,40}(PRIVATE KEY|CERTIFICATE)-----/g,
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
id: "api-key",
|
|
50
|
+
reason: 'it contains an "sk-" style API key',
|
|
51
|
+
pattern: /\bsk-[A-Za-z0-9_-]{16,}/g,
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: "github-token",
|
|
55
|
+
reason: "it contains a GitHub token",
|
|
56
|
+
pattern: /\b(gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})\b/g,
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: "slack-token",
|
|
60
|
+
reason: "it contains a Slack token",
|
|
61
|
+
pattern: /\bxox[baprs]-[A-Za-z0-9-]{10,}/g,
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: "aws-access-key-id",
|
|
65
|
+
reason: "it contains an AWS access key id",
|
|
66
|
+
pattern: /\bAKIA[0-9A-Z]{16}\b/g,
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: "bearer-token",
|
|
70
|
+
reason: "it contains a bearer token",
|
|
71
|
+
pattern: /\b[Bb]earer\s+[A-Za-z0-9._~+/-]{20,}/g,
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
id: "jwt",
|
|
75
|
+
reason: "it contains a JSON Web Token",
|
|
76
|
+
pattern: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
id: "credential-assignment",
|
|
80
|
+
reason: "it assigns a value to a credential-shaped name",
|
|
81
|
+
pattern:
|
|
82
|
+
/\b(api[_-]?key|secret|password|passwd|access[_-]?token|client[_-]?secret)\b\s*[:=]\s*\S{12,}/gi,
|
|
83
|
+
},
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
/** The bound every caller's input is cut to before a pattern sees it. */
|
|
87
|
+
export const SECRET_SHAPE_MAX_INPUT_V1 = 8_192;
|
|
88
|
+
|
|
89
|
+
/** One match, named by the table. */
|
|
90
|
+
export interface SecretShapeMatchV1 {
|
|
91
|
+
id: string;
|
|
92
|
+
reason: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The first credential shape `text` carries, or `undefined`.
|
|
97
|
+
*
|
|
98
|
+
* Pure and total: the same text always gets the same answer, and no input
|
|
99
|
+
* throws.
|
|
100
|
+
*/
|
|
101
|
+
export function matchSecretShapeV1(
|
|
102
|
+
text: string,
|
|
103
|
+
): SecretShapeMatchV1 | undefined {
|
|
104
|
+
const candidate = text.slice(0, SECRET_SHAPE_MAX_INPUT_V1);
|
|
105
|
+
for (const shape of SECRET_SHAPES_V1) {
|
|
106
|
+
shape.pattern.lastIndex = 0;
|
|
107
|
+
if (shape.pattern.test(candidate)) {
|
|
108
|
+
return { id: shape.id, reason: shape.reason };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Every matched substring replaced by `[redacted:<id>]`.
|
|
116
|
+
*
|
|
117
|
+
* The replacement is deliberately *not* the whole string: a redaction that
|
|
118
|
+
* discarded the surrounding text would make a preview useless, and a preview
|
|
119
|
+
* that survives is what makes an audit row worth keeping. The marker names
|
|
120
|
+
* which shape matched, so a person reading the row can tell a redacted bearer
|
|
121
|
+
* token from a redacted private key without the row carrying either.
|
|
122
|
+
*
|
|
123
|
+
* Deterministic: the same input always produces the same output, which is what
|
|
124
|
+
* lets a settlement-time projection and a rebuild months later agree byte for
|
|
125
|
+
* byte.
|
|
126
|
+
*/
|
|
127
|
+
export function redactSecretShapesV1(text: string): string {
|
|
128
|
+
let redacted = text.slice(0, SECRET_SHAPE_MAX_INPUT_V1);
|
|
129
|
+
for (const shape of SECRET_SHAPES_V1) {
|
|
130
|
+
shape.pattern.lastIndex = 0;
|
|
131
|
+
redacted = redacted.replace(shape.pattern, `[redacted:${shape.id}]`);
|
|
132
|
+
}
|
|
133
|
+
return redacted;
|
|
134
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2023",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"allowImportingTsExtensions": true,
|
|
7
|
+
"resolveJsonModule": true,
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"lib": ["ES2023"],
|
|
12
|
+
"types": ["bun"]
|
|
13
|
+
},
|
|
14
|
+
"include": ["src/**/*.ts"]
|
|
15
|
+
}
|
package/README.md
DELETED