@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 CHANGED
@@ -1,14 +1,26 @@
1
1
  {
2
2
  "name": "@frockbot/secret-shapes",
3
- "version": "0.0.0",
4
- "description": "Placeholder reserving this name for trusted publishing. Superseded by the first release.",
5
- "license": "UNLICENSED",
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
@@ -1,3 +0,0 @@
1
- # @frockbot/secret-shapes
2
-
3
- Placeholder reserving this name. See https://github.com/timoconnellaus/frockbot.