engrm 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/.mcp.json +9 -0
- package/AUTH-DESIGN.md +436 -0
- package/BRIEF.md +197 -0
- package/CLAUDE.md +44 -0
- package/COMPETITIVE.md +174 -0
- package/CONTEXT-OPTIMIZATION.md +305 -0
- package/INFRASTRUCTURE.md +252 -0
- package/LICENSE +105 -0
- package/MARKET.md +230 -0
- package/PLAN.md +278 -0
- package/README.md +121 -0
- package/SENTINEL.md +293 -0
- package/SERVER-API-PLAN.md +553 -0
- package/SPEC.md +843 -0
- package/SWOT.md +148 -0
- package/SYNC-ARCHITECTURE.md +294 -0
- package/VIBE-CODER-STRATEGY.md +250 -0
- package/bun.lock +375 -0
- package/hooks/post-tool-use.ts +144 -0
- package/hooks/session-start.ts +64 -0
- package/hooks/stop.ts +131 -0
- package/mem-page.html +1305 -0
- package/package.json +30 -0
- package/src/capture/dedup.test.ts +103 -0
- package/src/capture/dedup.ts +76 -0
- package/src/capture/extractor.test.ts +245 -0
- package/src/capture/extractor.ts +330 -0
- package/src/capture/quality.test.ts +168 -0
- package/src/capture/quality.ts +104 -0
- package/src/capture/retrospective.test.ts +115 -0
- package/src/capture/retrospective.ts +121 -0
- package/src/capture/scanner.test.ts +131 -0
- package/src/capture/scanner.ts +100 -0
- package/src/capture/scrubber.test.ts +144 -0
- package/src/capture/scrubber.ts +181 -0
- package/src/cli.ts +517 -0
- package/src/config.ts +238 -0
- package/src/context/inject.test.ts +940 -0
- package/src/context/inject.ts +382 -0
- package/src/embeddings/backfill.ts +50 -0
- package/src/embeddings/embedder.test.ts +76 -0
- package/src/embeddings/embedder.ts +139 -0
- package/src/lifecycle/aging.test.ts +103 -0
- package/src/lifecycle/aging.ts +36 -0
- package/src/lifecycle/compaction.test.ts +264 -0
- package/src/lifecycle/compaction.ts +190 -0
- package/src/lifecycle/purge.test.ts +100 -0
- package/src/lifecycle/purge.ts +37 -0
- package/src/lifecycle/scheduler.test.ts +120 -0
- package/src/lifecycle/scheduler.ts +101 -0
- package/src/provisioning/browser-auth.ts +172 -0
- package/src/provisioning/provision.test.ts +198 -0
- package/src/provisioning/provision.ts +94 -0
- package/src/register.test.ts +167 -0
- package/src/register.ts +178 -0
- package/src/server.ts +436 -0
- package/src/storage/migrations.test.ts +244 -0
- package/src/storage/migrations.ts +261 -0
- package/src/storage/outbox.test.ts +229 -0
- package/src/storage/outbox.ts +131 -0
- package/src/storage/projects.test.ts +137 -0
- package/src/storage/projects.ts +184 -0
- package/src/storage/sqlite.test.ts +798 -0
- package/src/storage/sqlite.ts +934 -0
- package/src/storage/vec.test.ts +198 -0
- package/src/sync/auth.test.ts +76 -0
- package/src/sync/auth.ts +68 -0
- package/src/sync/client.ts +183 -0
- package/src/sync/engine.test.ts +94 -0
- package/src/sync/engine.ts +127 -0
- package/src/sync/pull.test.ts +279 -0
- package/src/sync/pull.ts +170 -0
- package/src/sync/push.test.ts +117 -0
- package/src/sync/push.ts +230 -0
- package/src/tools/get.ts +34 -0
- package/src/tools/pin.ts +47 -0
- package/src/tools/save.test.ts +301 -0
- package/src/tools/save.ts +231 -0
- package/src/tools/search.test.ts +69 -0
- package/src/tools/search.ts +181 -0
- package/src/tools/timeline.ts +64 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { extractRetrospective } from "./retrospective.js";
|
|
3
|
+
import type { ObservationRow } from "../storage/sqlite.js";
|
|
4
|
+
|
|
5
|
+
function makeObs(overrides: Partial<ObservationRow>): ObservationRow {
|
|
6
|
+
return {
|
|
7
|
+
id: 1,
|
|
8
|
+
session_id: "sess-001",
|
|
9
|
+
project_id: 1,
|
|
10
|
+
type: "change",
|
|
11
|
+
title: "Test observation",
|
|
12
|
+
narrative: null,
|
|
13
|
+
facts: null,
|
|
14
|
+
concepts: null,
|
|
15
|
+
files_read: null,
|
|
16
|
+
files_modified: null,
|
|
17
|
+
quality: 0.5,
|
|
18
|
+
lifecycle: "active",
|
|
19
|
+
sensitivity: "shared",
|
|
20
|
+
user_id: "david",
|
|
21
|
+
device_id: "laptop-abc",
|
|
22
|
+
agent: "claude-code",
|
|
23
|
+
created_at: new Date().toISOString(),
|
|
24
|
+
created_at_epoch: Math.floor(Date.now() / 1000),
|
|
25
|
+
archived_at_epoch: null,
|
|
26
|
+
compacted_into: null,
|
|
27
|
+
superseded_by: null,
|
|
28
|
+
remote_source_id: null,
|
|
29
|
+
...overrides,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("extractRetrospective", () => {
|
|
34
|
+
test("returns null for empty observations", () => {
|
|
35
|
+
const result = extractRetrospective([], "sess-001", 1, "david");
|
|
36
|
+
expect(result).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("extracts request from first observation", () => {
|
|
40
|
+
const obs = [makeObs({ title: "Fix auth bug", type: "bugfix" })];
|
|
41
|
+
const result = extractRetrospective(obs, "sess-001", 1, "david");
|
|
42
|
+
expect(result).not.toBeNull();
|
|
43
|
+
expect(result!.request).toBe("Fix auth bug");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("groups discoveries into investigated", () => {
|
|
47
|
+
const obs = [
|
|
48
|
+
makeObs({ id: 1, type: "discovery", title: "Found memory leak" }),
|
|
49
|
+
makeObs({ id: 2, type: "discovery", title: "Traced to connection pool" }),
|
|
50
|
+
];
|
|
51
|
+
const result = extractRetrospective(obs, "sess-001", 1, "david");
|
|
52
|
+
expect(result!.investigated).toContain("Found memory leak");
|
|
53
|
+
expect(result!.investigated).toContain("Traced to connection pool");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("groups bugfix/decision/pattern into learned", () => {
|
|
57
|
+
const obs = [
|
|
58
|
+
makeObs({ id: 1, type: "bugfix", title: "Fixed timeout" }),
|
|
59
|
+
makeObs({ id: 2, type: "decision", title: "Use retry logic" }),
|
|
60
|
+
makeObs({ id: 3, type: "pattern", title: "Exponential backoff" }),
|
|
61
|
+
];
|
|
62
|
+
const result = extractRetrospective(obs, "sess-001", 1, "david");
|
|
63
|
+
expect(result!.learned).toContain("Fixed timeout");
|
|
64
|
+
expect(result!.learned).toContain("Use retry logic");
|
|
65
|
+
expect(result!.learned).toContain("Exponential backoff");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("groups change/feature/refactor into completed", () => {
|
|
69
|
+
const obs = [
|
|
70
|
+
makeObs({ id: 1, type: "change", title: "Modified config.ts" }),
|
|
71
|
+
makeObs({ id: 2, type: "feature", title: "Added auth" }),
|
|
72
|
+
makeObs({ id: 3, type: "refactor", title: "Cleaned up router" }),
|
|
73
|
+
];
|
|
74
|
+
const result = extractRetrospective(obs, "sess-001", 1, "david");
|
|
75
|
+
expect(result!.completed).toContain("Modified config.ts");
|
|
76
|
+
expect(result!.completed).toContain("Added auth");
|
|
77
|
+
expect(result!.completed).toContain("Cleaned up router");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("extracts next steps from late bugfix errors", () => {
|
|
81
|
+
const obs = [
|
|
82
|
+
makeObs({ id: 1, type: "change", title: "Step 1" }),
|
|
83
|
+
makeObs({ id: 2, type: "change", title: "Step 2" }),
|
|
84
|
+
makeObs({ id: 3, type: "change", title: "Step 3" }),
|
|
85
|
+
makeObs({
|
|
86
|
+
id: 4,
|
|
87
|
+
type: "bugfix",
|
|
88
|
+
title: "Build failure",
|
|
89
|
+
narrative: "Error: module not found",
|
|
90
|
+
}),
|
|
91
|
+
];
|
|
92
|
+
const result = extractRetrospective(obs, "sess-001", 1, "david");
|
|
93
|
+
expect(result!.next_steps).toContain("Investigate: Build failure");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("sets session metadata correctly", () => {
|
|
97
|
+
const obs = [makeObs({ type: "change", title: "Something" })];
|
|
98
|
+
const result = extractRetrospective(obs, "sess-abc", 42, "alice");
|
|
99
|
+
expect(result!.session_id).toBe("sess-abc");
|
|
100
|
+
expect(result!.project_id).toBe(42);
|
|
101
|
+
expect(result!.user_id).toBe("alice");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("returns null when no meaningful content extracted", () => {
|
|
105
|
+
// digest type doesn't map to any category
|
|
106
|
+
const obs = [makeObs({ type: "digest", title: "" })];
|
|
107
|
+
const result = extractRetrospective(obs, "sess-001", 1, "david");
|
|
108
|
+
// request will be empty string, no other fields populated
|
|
109
|
+
// extractRequest returns "" which is falsy but not null
|
|
110
|
+
// This should still produce a summary with just the request
|
|
111
|
+
// Actually, first.title is "" which is falsy, so request will be ""
|
|
112
|
+
// The function checks !request which is true for ""
|
|
113
|
+
expect(result).toBeNull();
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session retrospective extraction (heuristic, client-side).
|
|
3
|
+
*
|
|
4
|
+
* Groups session observations by type to generate a structured summary
|
|
5
|
+
* of what was requested, investigated, learned, completed, and what's next.
|
|
6
|
+
* No LLM needed — works entirely from observation metadata.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ObservationRow } from "../storage/sqlite.js";
|
|
10
|
+
import type { InsertSessionSummary } from "../storage/sqlite.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Extract a retrospective summary from a session's observations.
|
|
14
|
+
* Returns null if there are no observations to summarize.
|
|
15
|
+
*/
|
|
16
|
+
export function extractRetrospective(
|
|
17
|
+
observations: ObservationRow[],
|
|
18
|
+
sessionId: string,
|
|
19
|
+
projectId: number | null,
|
|
20
|
+
userId: string
|
|
21
|
+
): InsertSessionSummary | null {
|
|
22
|
+
if (observations.length === 0) return null;
|
|
23
|
+
|
|
24
|
+
const request = extractRequest(observations);
|
|
25
|
+
const investigated = extractInvestigated(observations);
|
|
26
|
+
const learned = extractLearned(observations);
|
|
27
|
+
const completed = extractCompleted(observations);
|
|
28
|
+
const nextSteps = extractNextSteps(observations);
|
|
29
|
+
|
|
30
|
+
// Don't create empty summaries
|
|
31
|
+
if (!request && !investigated && !learned && !completed && !nextSteps) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
session_id: sessionId,
|
|
37
|
+
project_id: projectId,
|
|
38
|
+
user_id: userId,
|
|
39
|
+
request,
|
|
40
|
+
investigated,
|
|
41
|
+
learned,
|
|
42
|
+
completed,
|
|
43
|
+
next_steps: nextSteps,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Derive the session request from the first observation's context.
|
|
49
|
+
*/
|
|
50
|
+
function extractRequest(observations: ObservationRow[]): string | null {
|
|
51
|
+
const first = observations[0];
|
|
52
|
+
if (!first) return null;
|
|
53
|
+
return first.title;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Summarize what was investigated (discovery-type observations).
|
|
58
|
+
*/
|
|
59
|
+
function extractInvestigated(observations: ObservationRow[]): string | null {
|
|
60
|
+
const discoveries = observations.filter((o) => o.type === "discovery");
|
|
61
|
+
if (discoveries.length === 0) return null;
|
|
62
|
+
|
|
63
|
+
return discoveries
|
|
64
|
+
.map((o) => `- ${o.title}`)
|
|
65
|
+
.slice(0, 5)
|
|
66
|
+
.join("\n");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Summarize what was learned (bugfix, decision, pattern observations).
|
|
71
|
+
*/
|
|
72
|
+
function extractLearned(observations: ObservationRow[]): string | null {
|
|
73
|
+
const learnTypes = new Set(["bugfix", "decision", "pattern"]);
|
|
74
|
+
const learned = observations.filter((o) => learnTypes.has(o.type));
|
|
75
|
+
if (learned.length === 0) return null;
|
|
76
|
+
|
|
77
|
+
return learned
|
|
78
|
+
.map((o) => `- ${o.title}`)
|
|
79
|
+
.slice(0, 5)
|
|
80
|
+
.join("\n");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Summarize what was completed (change, feature, refactor observations).
|
|
85
|
+
*/
|
|
86
|
+
function extractCompleted(observations: ObservationRow[]): string | null {
|
|
87
|
+
const completeTypes = new Set(["change", "feature", "refactor"]);
|
|
88
|
+
const completed = observations.filter((o) => completeTypes.has(o.type));
|
|
89
|
+
if (completed.length === 0) return null;
|
|
90
|
+
|
|
91
|
+
return completed
|
|
92
|
+
.map((o) => `- ${o.title}`)
|
|
93
|
+
.slice(0, 5)
|
|
94
|
+
.join("\n");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Extract next steps from error observations without resolution.
|
|
99
|
+
* Bugfix observations that appear late in the session (last 25%)
|
|
100
|
+
* and reference errors suggest unfinished work.
|
|
101
|
+
*/
|
|
102
|
+
function extractNextSteps(observations: ObservationRow[]): string | null {
|
|
103
|
+
if (observations.length < 2) return null;
|
|
104
|
+
|
|
105
|
+
const lastQuarterStart = Math.floor(observations.length * 0.75);
|
|
106
|
+
const lastQuarter = observations.slice(lastQuarterStart);
|
|
107
|
+
|
|
108
|
+
const unresolved = lastQuarter.filter(
|
|
109
|
+
(o) =>
|
|
110
|
+
o.type === "bugfix" &&
|
|
111
|
+
o.narrative &&
|
|
112
|
+
/error|fail|exception/i.test(o.narrative)
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
if (unresolved.length === 0) return null;
|
|
116
|
+
|
|
117
|
+
return unresolved
|
|
118
|
+
.map((o) => `- Investigate: ${o.title}`)
|
|
119
|
+
.slice(0, 3)
|
|
120
|
+
.join("\n");
|
|
121
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { scanForSecrets } from "./scanner.js";
|
|
3
|
+
|
|
4
|
+
describe("scanForSecrets", () => {
|
|
5
|
+
test("returns empty for clean text", () => {
|
|
6
|
+
const findings = scanForSecrets("Hello world, nothing secret here");
|
|
7
|
+
expect(findings).toEqual([]);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("returns empty for empty text", () => {
|
|
11
|
+
expect(scanForSecrets("")).toEqual([]);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("detects OpenAI API keys", () => {
|
|
15
|
+
const text = "Using key sk-abcdefghijklmnopqrstuv12345 for API";
|
|
16
|
+
const findings = scanForSecrets(text);
|
|
17
|
+
expect(findings.length).toBe(1);
|
|
18
|
+
expect(findings[0]!.finding_type).toBe("api_key");
|
|
19
|
+
expect(findings[0]!.severity).toBe("critical");
|
|
20
|
+
expect(findings[0]!.pattern_name).toBe("OpenAI API keys");
|
|
21
|
+
expect(findings[0]!.snippet).not.toContain("sk-abcdefghijklmnopqrstuv12345");
|
|
22
|
+
expect(findings[0]!.snippet).toContain("[REDACTED_API_KEY]");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("detects AWS access keys", () => {
|
|
26
|
+
const text = "aws_key = AKIAIOSFODNN7EXAMPLE";
|
|
27
|
+
const findings = scanForSecrets(text);
|
|
28
|
+
expect(findings.length).toBe(1);
|
|
29
|
+
expect(findings[0]!.finding_type).toBe("api_key");
|
|
30
|
+
expect(findings[0]!.severity).toBe("critical");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("detects PostgreSQL connection strings", () => {
|
|
34
|
+
const text = "DATABASE_URL=postgresql://user:pass@host:5432/db";
|
|
35
|
+
const findings = scanForSecrets(text);
|
|
36
|
+
expect(findings.length).toBeGreaterThanOrEqual(1);
|
|
37
|
+
const dbFinding = findings.find((f) => f.finding_type === "db_url");
|
|
38
|
+
expect(dbFinding).toBeDefined();
|
|
39
|
+
expect(dbFinding!.severity).toBe("high");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("detects MongoDB connection strings", () => {
|
|
43
|
+
const text = "MONGO=mongodb://user:pass@host:27017/db";
|
|
44
|
+
const findings = scanForSecrets(text);
|
|
45
|
+
const dbFinding = findings.find((f) => f.finding_type === "db_url");
|
|
46
|
+
expect(dbFinding).toBeDefined();
|
|
47
|
+
expect(dbFinding!.severity).toBe("high");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("detects MySQL connection strings", () => {
|
|
51
|
+
const text = "mysql://root:password@localhost/mydb";
|
|
52
|
+
const findings = scanForSecrets(text);
|
|
53
|
+
const dbFinding = findings.find((f) => f.finding_type === "db_url");
|
|
54
|
+
expect(dbFinding).toBeDefined();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("detects passwords in config", () => {
|
|
58
|
+
const text = "password=supersecret123";
|
|
59
|
+
const findings = scanForSecrets(text);
|
|
60
|
+
expect(findings.length).toBe(1);
|
|
61
|
+
expect(findings[0]!.finding_type).toBe("password");
|
|
62
|
+
expect(findings[0]!.severity).toBe("high");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("detects Bearer tokens", () => {
|
|
66
|
+
const text = 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0=';
|
|
67
|
+
const findings = scanForSecrets(text);
|
|
68
|
+
expect(findings.length).toBe(1);
|
|
69
|
+
expect(findings[0]!.finding_type).toBe("token");
|
|
70
|
+
expect(findings[0]!.severity).toBe("medium");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("detects GitHub personal access tokens", () => {
|
|
74
|
+
const text = "token: ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij";
|
|
75
|
+
const findings = scanForSecrets(text);
|
|
76
|
+
expect(findings.length).toBe(1);
|
|
77
|
+
expect(findings[0]!.finding_type).toBe("token");
|
|
78
|
+
expect(findings[0]!.severity).toBe("high");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("detects GitHub fine-grained PATs", () => {
|
|
82
|
+
const text = "GITHUB_TOKEN=github_pat_ABCDEFGHIJKLMNOPQRSTUVx";
|
|
83
|
+
const findings = scanForSecrets(text);
|
|
84
|
+
expect(findings.length).toBe(1);
|
|
85
|
+
expect(findings[0]!.finding_type).toBe("token");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("detects Candengo API keys", () => {
|
|
89
|
+
const text = "key=cvk_" + "a".repeat(64);
|
|
90
|
+
const findings = scanForSecrets(text);
|
|
91
|
+
expect(findings.length).toBe(1);
|
|
92
|
+
expect(findings[0]!.finding_type).toBe("api_key");
|
|
93
|
+
expect(findings[0]!.severity).toBe("critical");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("detects Slack tokens", () => {
|
|
97
|
+
const text = "SLACK_TOKEN=xoxb-1234567890-abcdef";
|
|
98
|
+
const findings = scanForSecrets(text);
|
|
99
|
+
expect(findings.length).toBe(1);
|
|
100
|
+
expect(findings[0]!.finding_type).toBe("token");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("detects multiple secrets in same text", () => {
|
|
104
|
+
const text = "API=sk-abcdefghijklmnopqrstuv12345 DB=postgresql://user:pass@host/db";
|
|
105
|
+
const findings = scanForSecrets(text);
|
|
106
|
+
expect(findings.length).toBeGreaterThanOrEqual(2);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("redacted snippet does not contain actual secret", () => {
|
|
110
|
+
const secret = "sk-" + "a".repeat(30);
|
|
111
|
+
const text = `The key is ${secret} and more text`;
|
|
112
|
+
const findings = scanForSecrets(text);
|
|
113
|
+
expect(findings.length).toBe(1);
|
|
114
|
+
expect(findings[0]!.snippet).not.toContain(secret);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("custom patterns work", () => {
|
|
118
|
+
const text = "MY_SECRET_VALUE_12345";
|
|
119
|
+
const findings = scanForSecrets(text, ["MY_SECRET_VALUE_\\d+"]);
|
|
120
|
+
expect(findings.length).toBe(1);
|
|
121
|
+
expect(findings[0]!.finding_type).toBe("custom");
|
|
122
|
+
expect(findings[0]!.severity).toBe("medium");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("invalid custom patterns are skipped", () => {
|
|
126
|
+
const text = "sk-abcdefghijklmnopqrstuv12345";
|
|
127
|
+
// Invalid regex should not crash
|
|
128
|
+
const findings = scanForSecrets(text, ["[invalid"]);
|
|
129
|
+
expect(findings.length).toBe(1); // still finds the OpenAI key
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L1 regex-based secret scanner.
|
|
3
|
+
*
|
|
4
|
+
* Reuses the same pattern definitions from scrubber.ts but instead of
|
|
5
|
+
* replacing secrets, it detects and reports them as security findings.
|
|
6
|
+
* The snippet is redacted to avoid storing the actual secret.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { DEFAULT_PATTERNS, type ScrubPatternDef } from "./scrubber.js";
|
|
10
|
+
|
|
11
|
+
export interface SecurityFinding {
|
|
12
|
+
finding_type: string;
|
|
13
|
+
severity: string;
|
|
14
|
+
pattern_name: string;
|
|
15
|
+
snippet: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Scan text for secrets using scrubber patterns.
|
|
20
|
+
* Returns findings with redacted context snippets.
|
|
21
|
+
*/
|
|
22
|
+
export function scanForSecrets(
|
|
23
|
+
text: string,
|
|
24
|
+
customPatterns: string[] = []
|
|
25
|
+
): SecurityFinding[] {
|
|
26
|
+
if (!text) return [];
|
|
27
|
+
|
|
28
|
+
const allPatterns: ScrubPatternDef[] = [
|
|
29
|
+
...DEFAULT_PATTERNS,
|
|
30
|
+
...compileCustomScanPatterns(customPatterns),
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const findings: SecurityFinding[] = [];
|
|
34
|
+
|
|
35
|
+
for (const pattern of allPatterns) {
|
|
36
|
+
const regex = new RegExp(pattern.source, pattern.flags);
|
|
37
|
+
let match: RegExpExecArray | null;
|
|
38
|
+
|
|
39
|
+
while ((match = regex.exec(text)) !== null) {
|
|
40
|
+
const snippet = buildRedactedSnippet(text, match.index, match[0].length, pattern.replacement);
|
|
41
|
+
findings.push({
|
|
42
|
+
finding_type: pattern.category,
|
|
43
|
+
severity: pattern.severity,
|
|
44
|
+
pattern_name: pattern.description,
|
|
45
|
+
snippet,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Avoid infinite loop on zero-length matches
|
|
49
|
+
if (match[0].length === 0) {
|
|
50
|
+
regex.lastIndex++;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return findings;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Build a context snippet around a match, replacing the actual secret
|
|
60
|
+
* with the redacted placeholder.
|
|
61
|
+
*/
|
|
62
|
+
function buildRedactedSnippet(
|
|
63
|
+
text: string,
|
|
64
|
+
matchStart: number,
|
|
65
|
+
matchLength: number,
|
|
66
|
+
replacement: string
|
|
67
|
+
): string {
|
|
68
|
+
const CONTEXT_CHARS = 30;
|
|
69
|
+
const start = Math.max(0, matchStart - CONTEXT_CHARS);
|
|
70
|
+
const end = Math.min(text.length, matchStart + matchLength + CONTEXT_CHARS);
|
|
71
|
+
|
|
72
|
+
const before = text.slice(start, matchStart);
|
|
73
|
+
const after = text.slice(matchStart + matchLength, end);
|
|
74
|
+
|
|
75
|
+
let snippet = before + replacement + after;
|
|
76
|
+
if (start > 0) snippet = "..." + snippet;
|
|
77
|
+
if (end < text.length) snippet = snippet + "...";
|
|
78
|
+
|
|
79
|
+
return snippet;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function compileCustomScanPatterns(patterns: string[]): ScrubPatternDef[] {
|
|
83
|
+
const compiled: ScrubPatternDef[] = [];
|
|
84
|
+
for (const pattern of patterns) {
|
|
85
|
+
try {
|
|
86
|
+
new RegExp(pattern);
|
|
87
|
+
compiled.push({
|
|
88
|
+
source: pattern,
|
|
89
|
+
flags: "g",
|
|
90
|
+
replacement: "[REDACTED_CUSTOM]",
|
|
91
|
+
description: `Custom pattern: ${pattern}`,
|
|
92
|
+
category: "custom",
|
|
93
|
+
severity: "medium",
|
|
94
|
+
});
|
|
95
|
+
} catch {
|
|
96
|
+
// Skip invalid regex
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return compiled;
|
|
100
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { scrubSecrets, containsSecrets } from "./scrubber.js";
|
|
3
|
+
|
|
4
|
+
describe("scrubSecrets", () => {
|
|
5
|
+
test("scrubs OpenAI API keys", () => {
|
|
6
|
+
const input = "key is sk-abc123def456ghi789jkl012mno";
|
|
7
|
+
const result = scrubSecrets(input);
|
|
8
|
+
expect(result).toBe("key is [REDACTED_API_KEY]");
|
|
9
|
+
expect(result).not.toContain("sk-");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("scrubs Bearer tokens", () => {
|
|
13
|
+
const input = "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.test";
|
|
14
|
+
const result = scrubSecrets(input);
|
|
15
|
+
expect(result).toContain("[REDACTED_BEARER]");
|
|
16
|
+
expect(result).not.toContain("eyJhbG");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("scrubs passwords", () => {
|
|
20
|
+
const input = "password=super_secret_123";
|
|
21
|
+
const result = scrubSecrets(input);
|
|
22
|
+
expect(result).toContain("[REDACTED]");
|
|
23
|
+
expect(result).not.toContain("super_secret");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("scrubs password with colon separator", () => {
|
|
27
|
+
const input = "Password: mysecretpass";
|
|
28
|
+
const result = scrubSecrets(input);
|
|
29
|
+
expect(result).toContain("[REDACTED]");
|
|
30
|
+
expect(result).not.toContain("mysecretpass");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("scrubs PostgreSQL connection strings", () => {
|
|
34
|
+
const input = "db: postgresql://user:pass@localhost:5432/mydb";
|
|
35
|
+
const result = scrubSecrets(input);
|
|
36
|
+
expect(result).toContain("[REDACTED_DB_URL]");
|
|
37
|
+
expect(result).not.toContain("user:pass");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("scrubs MongoDB connection strings", () => {
|
|
41
|
+
const input = "mongodb://admin:pass@mongo.example.com/db";
|
|
42
|
+
const result = scrubSecrets(input);
|
|
43
|
+
expect(result).toBe("[REDACTED_DB_URL]");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("scrubs MySQL connection strings", () => {
|
|
47
|
+
const input = "mysql://root:password@localhost/mydb";
|
|
48
|
+
const result = scrubSecrets(input);
|
|
49
|
+
expect(result).toBe("[REDACTED_DB_URL]");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("scrubs AWS access keys", () => {
|
|
53
|
+
const input = "aws_key=AKIAIOSFODNN7EXAMPLE";
|
|
54
|
+
const result = scrubSecrets(input);
|
|
55
|
+
expect(result).toContain("[REDACTED_AWS_KEY]");
|
|
56
|
+
expect(result).not.toContain("AKIAIOSFODNN");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("scrubs GitHub personal access tokens", () => {
|
|
60
|
+
const input = "token: ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij";
|
|
61
|
+
const result = scrubSecrets(input);
|
|
62
|
+
expect(result).toContain("[REDACTED_GH_TOKEN]");
|
|
63
|
+
expect(result).not.toContain("ghp_");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("scrubs GitHub OAuth tokens", () => {
|
|
67
|
+
const input = "gho_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij";
|
|
68
|
+
const result = scrubSecrets(input);
|
|
69
|
+
expect(result).toBe("[REDACTED_GH_TOKEN]");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("scrubs GitHub fine-grained PATs", () => {
|
|
73
|
+
const input = "github_pat_abcdefghijklmnopqrstuv1234";
|
|
74
|
+
const result = scrubSecrets(input);
|
|
75
|
+
expect(result).toBe("[REDACTED_GH_TOKEN]");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("scrubs Candengo API keys", () => {
|
|
79
|
+
const key = "cvk_" + "a".repeat(64);
|
|
80
|
+
const result = scrubSecrets(`key=${key}`);
|
|
81
|
+
expect(result).toContain("[REDACTED_CANDENGO_KEY]");
|
|
82
|
+
expect(result).not.toContain("cvk_");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("scrubs Slack tokens", () => {
|
|
86
|
+
const input = "slack: xoxb-123456789-abcdef";
|
|
87
|
+
const result = scrubSecrets(input);
|
|
88
|
+
expect(result).toContain("[REDACTED_SLACK_TOKEN]");
|
|
89
|
+
expect(result).not.toContain("xoxb-");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("leaves clean text unchanged", () => {
|
|
93
|
+
const input = "This is a normal observation about fixing a bug";
|
|
94
|
+
expect(scrubSecrets(input)).toBe(input);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("scrubs multiple secrets in one string", () => {
|
|
98
|
+
const input =
|
|
99
|
+
"Used sk-abc123def456ghi789jkl012mno to connect to postgresql://user:pass@db/app";
|
|
100
|
+
const result = scrubSecrets(input);
|
|
101
|
+
expect(result).toContain("[REDACTED_API_KEY]");
|
|
102
|
+
expect(result).toContain("[REDACTED_DB_URL]");
|
|
103
|
+
expect(result).not.toContain("sk-");
|
|
104
|
+
expect(result).not.toContain("postgresql://");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("custom patterns are applied", () => {
|
|
108
|
+
const input = "internal_token_ABC123XYZ";
|
|
109
|
+
const result = scrubSecrets(input, ["internal_token_[A-Z0-9]+"]);
|
|
110
|
+
expect(result).toBe("[REDACTED_CUSTOM]");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("invalid custom patterns are skipped gracefully", () => {
|
|
114
|
+
const input = "normal text";
|
|
115
|
+
const result = scrubSecrets(input, ["[invalid"]);
|
|
116
|
+
expect(result).toBe("normal text");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("empty text returns empty", () => {
|
|
120
|
+
expect(scrubSecrets("")).toBe("");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("containsSecrets", () => {
|
|
125
|
+
test("detects OpenAI key", () => {
|
|
126
|
+
expect(
|
|
127
|
+
containsSecrets("has sk-abc123def456ghi789jkl012mno inside")
|
|
128
|
+
).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("detects AWS key", () => {
|
|
132
|
+
expect(containsSecrets("AKIAIOSFODNN7EXAMPLE")).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("returns false for clean text", () => {
|
|
136
|
+
expect(containsSecrets("no secrets here")).toBe(false);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("detects custom patterns", () => {
|
|
140
|
+
expect(
|
|
141
|
+
containsSecrets("has INTERNAL_SECRET_123", ["INTERNAL_SECRET_\\d+"])
|
|
142
|
+
).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
});
|