deepdebug-local-agent 1.0.23 → 1.2.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/.claude/context.md +76 -0
- package/.claude/skills/README.md +25 -0
- package/.claude/skills/tdd-debugging/SKILL.md +53 -0
- package/.claude/skills/tdd-debugging/references/anti-patterns.md +43 -0
- package/.claude/skills/tdd-debugging/references/command-reference.md +46 -0
- package/.claude/standards/README.md +51 -0
- package/.claude/standards/clean-code.md +75 -0
- package/.claude/standards/code-review-checklist.md +61 -0
- package/.claude/standards/layered-architecture.md +163 -0
- package/.claude/standards/node.md +45 -0
- package/.claude/standards/security-owasp.md +114 -0
- package/.claude/standards/simplicity.md +42 -0
- package/.claude/standards/solid.md +64 -0
- package/.claude/standards/testing.md +59 -0
- package/.cursor/rules/standards.mdc +17 -0
- package/.github/workflows/build-byoc-image.yml +315 -0
- package/.github/workflows/deploy-gcp.yml +102 -0
- package/.github/workflows/npm-publish.yml +76 -8
- package/AGENTS.md +21 -0
- package/CHANGELOG.md +76 -0
- package/CLAUDE.md +13 -0
- package/CLAUDE_CODE_AGENT_INFINITE.md +5 -3
- package/Dockerfile +4 -0
- package/Dockerfile.full +55 -3
- package/bin/install.js +16 -5
- package/cloudbuild-full.yaml +57 -0
- package/docker-compose.yml +5 -3
- package/docs/OBSERVABILITY.md +164 -0
- package/docs/byoc-agent-image.md +70 -0
- package/docs/dlp.md +78 -0
- package/docs/graphify-packaging-plan.md +133 -0
- package/docs/graphify-validation-run-2026-10-28.md +243 -0
- package/docs/releasing.md +37 -0
- package/docs/signed-commands.md +163 -0
- package/env.example +34 -0
- package/helm/Chart.yaml +2 -2
- package/helm/templates/deployment.yaml +13 -3
- package/helm/templates/networkpolicy.yaml +22 -0
- package/helm/templates/secret.yaml +1 -0
- package/helm/values.yaml +13 -14
- package/mcp-server.js +4 -1
- package/package.json +9 -4
- package/scripts/next-version.mjs +75 -0
- package/scripts/upgrade-observability-push.sh +227 -0
- package/src/ai-engine.js +22 -22
- package/src/config/trusted-signing-keys.json +3 -0
- package/src/detectors/build-tool-detector.js +50 -0
- package/src/detectors/multi-project-detector.js +22 -22
- package/src/detectors/service-detector.js +24 -24
- package/src/dlp/engine.js +111 -0
- package/src/dlp/index.js +137 -0
- package/src/dlp/rules.js +97 -0
- package/src/dlp/validators.js +123 -0
- package/src/exec-utils.js +26 -4
- package/src/git/base-git-provider.js +117 -117
- package/src/git/bitbucket-provider.js +2 -2
- package/src/git/git-provider-registry.js +18 -18
- package/src/git/github-provider.js +13 -13
- package/src/git/multi-repo-pr.js +161 -0
- package/src/mcp-http-server.js +32 -16
- package/src/observability/bundle-files.js +418 -0
- package/src/observability/detect.js +151 -0
- package/src/observability/provision.js +212 -0
- package/src/observability/render-cli.js +59 -0
- package/src/observability/routes.js +88 -0
- package/src/patch/patch-engine.js +28 -28
- package/src/patch-manager.js +85 -85
- package/src/patch_bkp.js +14 -14
- package/src/ports.js +4 -4
- package/src/routes/workspace.route.js +47 -16
- package/src/runtimes/base-runtime.js +75 -75
- package/src/runtimes/java/gradle-runtime.js +17 -17
- package/src/runtimes/java/java-integrations.js +25 -25
- package/src/runtimes/java/maven-runtime.js +34 -34
- package/src/runtimes/node/node-integrations.js +1 -1
- package/src/runtimes/node/npm-runtime.js +14 -14
- package/src/runtimes/node/yarn-runtime.js +4 -4
- package/src/runtimes/runtime-registry.js +25 -25
- package/src/server-local.js +4 -4
- package/src/server.js +1366 -333
- package/src/siblings/burst-suppressor.js +89 -0
- package/src/siblings/capabilities.js +273 -0
- package/src/siblings/env-compat.cjs +58 -0
- package/src/siblings/incident-forwarder.js +322 -0
- package/src/siblings/logger.js +15 -0
- package/src/siblings/metrics.js +94 -0
- package/src/utils/agent-auth.js +184 -0
- package/src/utils/approval-token.js +56 -0
- package/src/utils/bind-host.js +29 -0
- package/src/utils/branch-refspec.js +98 -0
- package/src/utils/child-env.js +34 -0
- package/src/utils/environment-diagnostics.js +55 -55
- package/src/utils/exec-policy.js +962 -0
- package/src/utils/exec-utils.js +15 -15
- package/src/utils/file-count.js +46 -0
- package/src/utils/fs-utils.js +10 -10
- package/src/utils/multi-repo-open.js +132 -0
- package/src/utils/open-gate.js +183 -0
- package/src/utils/ranged-read.js +65 -0
- package/src/utils/request-guards.js +77 -0
- package/src/utils/request-signing.js +342 -0
- package/src/utils/root-recovery.js +244 -0
- package/src/utils/safe-exec-args.js +58 -0
- package/src/utils/sandbox.js +182 -0
- package/src/utils/search-outcome.js +56 -0
- package/src/utils/workspace-root.js +80 -0
- package/src/validators/syntax-validator.js +149 -0
- package/src/workspace/detect-port.js +16 -16
- package/src/workspace/file-reader.js +1 -1
- package/src/workspace/process-manager.js +46 -46
- package/src/workspace-manager.js +86 -24
- package/test/adapters/registry.test.js +9 -2
- package/test/agent-auth.test.js +194 -0
- package/test/bind-host.test.js +26 -0
- package/test/branch-refspec.test.js +224 -0
- package/test/burst-suppressor.test.js +60 -0
- package/test/capabilities-access.test.js +47 -0
- package/test/capabilities.test.js +87 -0
- package/test/child-env.test.js +23 -0
- package/test/dlp/engine.test.js +173 -0
- package/test/dlp/fixtures.js +126 -0
- package/test/dlp/index.test.js +166 -0
- package/test/env-compat.test.js +71 -0
- package/test/exec-policy.test.js +463 -0
- package/test/file-count.test.js +64 -0
- package/test/incident-forwarder.test.js +231 -0
- package/test/multi-repo-open.test.js +180 -0
- package/test/multi-repo-pr.test.js +129 -0
- package/test/next-version.test.js +48 -0
- package/test/observability-detect.test.js +199 -0
- package/test/open-gate.test.js +341 -0
- package/test/ranged-read.test.js +60 -0
- package/test/request-guards.test.js +91 -0
- package/test/request-signing.test.js +284 -0
- package/test/root-recovery-sidecar.test.js +171 -0
- package/test/root-recovery.test.js +274 -0
- package/test/safe-exec-args.test.js +58 -0
- package/test/sandbox.test.js +98 -0
- package/test/search-outcome.test.js +50 -0
- package/test/workspace-root.test.js +64 -0
- package/tools/graphify-validation/README.md +92 -0
- package/tools/graphify-validation/config/questions.json +31 -0
- package/tools/graphify-validation/config/repos.json +9 -0
- package/tools/graphify-validation/config/symbols.json +6 -0
- package/tools/graphify-validation/fixtures/spring/expected.json +34 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/AppConfig.java +29 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/AuditLogger.java +15 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/Cache.java +7 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/CheckoutService.java +25 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/InMemoryCache.java +18 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/OrderController.java +22 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/PaymentGateway.java +5 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/PaypalGateway.java +11 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/RefundService.java +14 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/StripeGateway.java +11 -0
- package/tools/graphify-validation/lib/affected_vs_rg.py +81 -0
- package/tools/graphify-validation/lib/audit_hook/sitecustomize.py +22 -0
- package/tools/graphify-validation/lib/graph_metrics.py +96 -0
- package/tools/graphify-validation/lib/gv_common.py +130 -0
- package/tools/graphify-validation/lib/licenses.py +47 -0
- package/tools/graphify-validation/lib/pip_audit_summary.py +18 -0
- package/tools/graphify-validation/lib/recall.py +115 -0
- package/tools/graphify-validation/lib/report.py +226 -0
- package/tools/graphify-validation/lib/run_all.py +237 -0
- package/tools/graphify-validation/lib/run_graphify.py +144 -0
- package/tools/graphify-validation/lib/rusage_helper.py +17 -0
- package/tools/graphify-validation/lib/sample_edges.py +246 -0
- package/tools/graphify-validation/lib/spring_fixture.py +81 -0
- package/tools/graphify-validation/requirements-py311.lock +508 -0
- package/tools/graphify-validation/requirements-tools.in +1 -0
- package/tools/graphify-validation/requirements-tools.lock +371 -0
- package/tools/graphify-validation/requirements.in +1 -0
- package/tools/graphify-validation/requirements.lock +502 -0
- package/tools/graphify-validation/run.sh +43 -0
- package/tools/graphify-validation/tests/test_harness.py +151 -0
- package/cloudbuild-agent-qa.yaml +0 -43
- package/cloudbuild-agent-staging.yaml +0 -40
- package/cloudbuild.yaml.deprecated +0 -42
|
@@ -9,24 +9,24 @@ import { FileReader } from "../workspace/file-reader.js";
|
|
|
9
9
|
/**
|
|
10
10
|
* MultiProjectDetector
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* Detects multiple projects within a workspace
|
|
13
|
+
* Scans subfolders looking for independent projects (pom.xml, package.json, etc)
|
|
14
14
|
*/
|
|
15
15
|
export class MultiProjectDetector {
|
|
16
16
|
constructor(workspaceRoot) {
|
|
17
17
|
this.workspaceRoot = workspaceRoot;
|
|
18
|
-
this.maxDepth = 2; //
|
|
18
|
+
this.maxDepth = 2; // Scan up to 2 levels deep
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
|
-
*
|
|
22
|
+
* Detects all projects in the workspace
|
|
23
23
|
*/
|
|
24
24
|
async detectProjects() {
|
|
25
25
|
console.log(`🔍 Scanning workspace for multiple projects: ${this.workspaceRoot}`);
|
|
26
26
|
|
|
27
27
|
const projects = [];
|
|
28
28
|
|
|
29
|
-
// 1️⃣
|
|
29
|
+
// 1️⃣ Check whether the workspace root itself is a project
|
|
30
30
|
if (await this.isProject(this.workspaceRoot)) {
|
|
31
31
|
console.log(`✅ Root is a project`);
|
|
32
32
|
projects.push({
|
|
@@ -36,13 +36,13 @@ export class MultiProjectDetector {
|
|
|
36
36
|
});
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
// 2️⃣
|
|
39
|
+
// 2️⃣ Scan subfolders looking for projects
|
|
40
40
|
const subProjects = await this.scanSubdirectories(this.workspaceRoot, 0);
|
|
41
41
|
projects.push(...subProjects);
|
|
42
42
|
|
|
43
43
|
console.log(`✅ Found ${projects.length} project(s) in workspace`);
|
|
44
44
|
|
|
45
|
-
// 3️⃣
|
|
45
|
+
// 3️⃣ Detect services for each project
|
|
46
46
|
const allServices = [];
|
|
47
47
|
for (const project of projects) {
|
|
48
48
|
console.log(`🔎 Detecting services in: ${project.name}`);
|
|
@@ -55,7 +55,7 @@ export class MultiProjectDetector {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
/**
|
|
58
|
-
*
|
|
58
|
+
* Checks whether a directory is a project
|
|
59
59
|
*/
|
|
60
60
|
async isProject(dir) {
|
|
61
61
|
const markers = [
|
|
@@ -67,12 +67,12 @@ export class MultiProjectDetector {
|
|
|
67
67
|
'pyproject.toml', // Python Poetry
|
|
68
68
|
'go.mod', // Go
|
|
69
69
|
'Cargo.toml', // Rust
|
|
70
|
-
'.csproj', // .NET (
|
|
70
|
+
'.csproj', // .NET (any .csproj file)
|
|
71
71
|
'.sln' // .NET solution
|
|
72
72
|
];
|
|
73
73
|
|
|
74
74
|
for (const marker of markers) {
|
|
75
|
-
//
|
|
75
|
+
// For .csproj and .sln, look for any file with that extension
|
|
76
76
|
if (marker.startsWith('.')) {
|
|
77
77
|
try {
|
|
78
78
|
const files = await readdir(dir);
|
|
@@ -83,7 +83,7 @@ export class MultiProjectDetector {
|
|
|
83
83
|
continue;
|
|
84
84
|
}
|
|
85
85
|
} else {
|
|
86
|
-
//
|
|
86
|
+
// For specific markers, check whether they exist
|
|
87
87
|
if (await exists(path.join(dir, marker))) {
|
|
88
88
|
return true;
|
|
89
89
|
}
|
|
@@ -94,7 +94,7 @@ export class MultiProjectDetector {
|
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
/**
|
|
97
|
-
*
|
|
97
|
+
* Scans subdirectories recursively
|
|
98
98
|
*/
|
|
99
99
|
async scanSubdirectories(dir, depth) {
|
|
100
100
|
if (depth >= this.maxDepth) {
|
|
@@ -109,7 +109,7 @@ export class MultiProjectDetector {
|
|
|
109
109
|
for (const entry of entries) {
|
|
110
110
|
if (!entry.isDirectory()) continue;
|
|
111
111
|
|
|
112
|
-
//
|
|
112
|
+
// Ignore common folders that are not projects
|
|
113
113
|
const ignoreDirs = [
|
|
114
114
|
'node_modules', 'target', 'build', 'dist', 'out',
|
|
115
115
|
'.git', '.idea', '.vscode', '__pycache__',
|
|
@@ -123,7 +123,7 @@ export class MultiProjectDetector {
|
|
|
123
123
|
|
|
124
124
|
const fullPath = path.join(dir, entry.name);
|
|
125
125
|
|
|
126
|
-
//
|
|
126
|
+
// Check whether it is a project
|
|
127
127
|
if (await this.isProject(fullPath)) {
|
|
128
128
|
console.log(`✅ Found project: ${entry.name}`);
|
|
129
129
|
projects.push({
|
|
@@ -131,11 +131,11 @@ export class MultiProjectDetector {
|
|
|
131
131
|
path: fullPath,
|
|
132
132
|
isRoot: false
|
|
133
133
|
});
|
|
134
|
-
//
|
|
134
|
+
// Do not scan inside projects that were found
|
|
135
135
|
continue;
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
-
//
|
|
138
|
+
// Keep scanning recursively
|
|
139
139
|
const subProjects = await this.scanSubdirectories(fullPath, depth + 1);
|
|
140
140
|
projects.push(...subProjects);
|
|
141
141
|
}
|
|
@@ -147,19 +147,19 @@ export class MultiProjectDetector {
|
|
|
147
147
|
}
|
|
148
148
|
|
|
149
149
|
/**
|
|
150
|
-
*
|
|
150
|
+
* Detects services in a specific project
|
|
151
151
|
*/
|
|
152
152
|
async detectServicesInProject(project) {
|
|
153
153
|
try {
|
|
154
|
-
//
|
|
154
|
+
// Use the scanner to analyze the project
|
|
155
155
|
const scanner = new WorkspaceScanner(project.path);
|
|
156
156
|
const structure = await scanner.scan();
|
|
157
157
|
|
|
158
|
-
//
|
|
158
|
+
// Detect the language
|
|
159
159
|
const languageDetector = new LanguageDetector(structure.files);
|
|
160
160
|
const languageInfo = languageDetector.detect();
|
|
161
161
|
|
|
162
|
-
//
|
|
162
|
+
// Detect the framework
|
|
163
163
|
const fileReader = new FileReader(project.path);
|
|
164
164
|
const frameworkDetector = new FrameworkDetector(
|
|
165
165
|
languageInfo.primary,
|
|
@@ -168,7 +168,7 @@ export class MultiProjectDetector {
|
|
|
168
168
|
);
|
|
169
169
|
const frameworkInfo = await frameworkDetector.detect();
|
|
170
170
|
|
|
171
|
-
//
|
|
171
|
+
// Detect the services
|
|
172
172
|
const serviceDetector = new ServiceDetector(
|
|
173
173
|
project.path,
|
|
174
174
|
languageInfo,
|
|
@@ -177,7 +177,7 @@ export class MultiProjectDetector {
|
|
|
177
177
|
|
|
178
178
|
const services = await serviceDetector.detect();
|
|
179
179
|
|
|
180
|
-
//
|
|
180
|
+
// Add the workspace name to each service
|
|
181
181
|
return services.map(service => ({
|
|
182
182
|
...service,
|
|
183
183
|
workspaceName: project.name,
|
|
@@ -4,8 +4,8 @@ import { exists, readFile } from "../fs-utils.js";
|
|
|
4
4
|
/**
|
|
5
5
|
* ServiceDetector
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* Detects services/microservices in a workspace
|
|
8
|
+
* Identifies: name, port, type, start command
|
|
9
9
|
*/
|
|
10
10
|
export class ServiceDetector {
|
|
11
11
|
constructor(root, languageInfo, frameworkInfo) {
|
|
@@ -16,12 +16,12 @@ export class ServiceDetector {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
|
-
*
|
|
19
|
+
* Detects all services in the workspace
|
|
20
20
|
*/
|
|
21
21
|
async detect() {
|
|
22
22
|
const services = [];
|
|
23
23
|
|
|
24
|
-
//
|
|
24
|
+
// Detect by project type
|
|
25
25
|
if (this.language === "java" && this.framework === "spring-boot") {
|
|
26
26
|
const service = await this.detectSpringBootService();
|
|
27
27
|
if (service) services.push(service);
|
|
@@ -41,20 +41,20 @@ export class ServiceDetector {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
/**
|
|
44
|
-
*
|
|
44
|
+
* Detects a Spring Boot service
|
|
45
45
|
*/
|
|
46
46
|
async detectSpringBootService() {
|
|
47
47
|
const pomPath = path.join(this.root, "pom.xml");
|
|
48
48
|
const gradlePath = path.join(this.root, "build.gradle");
|
|
49
49
|
|
|
50
|
-
let serviceName = path.basename(this.root); // Fallback:
|
|
50
|
+
let serviceName = path.basename(this.root); // Fallback: folder name
|
|
51
51
|
let port = 8080;
|
|
52
52
|
|
|
53
|
-
//
|
|
53
|
+
// Try to extract the name from pom.xml (ignoring the parent)
|
|
54
54
|
if (await exists(pomPath)) {
|
|
55
55
|
const content = await readFile(pomPath, "utf8");
|
|
56
56
|
|
|
57
|
-
// Remove
|
|
57
|
+
// Remove the <parent>...</parent> block so we do not pick the wrong artifactId
|
|
58
58
|
const contentWithoutParent = content.replace(/<parent>[\s\S]*?<\/parent>/gi, '');
|
|
59
59
|
const artifactIdMatch = contentWithoutParent.match(/<artifactId>([^<]+)<\/artifactId>/);
|
|
60
60
|
|
|
@@ -63,7 +63,7 @@ export class ServiceDetector {
|
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
//
|
|
66
|
+
// Try to extract it from build.gradle
|
|
67
67
|
if (await exists(gradlePath)) {
|
|
68
68
|
const content = await readFile(gradlePath, "utf8");
|
|
69
69
|
const nameMatch = content.match(/rootProject\.name\s*=\s*['"]([^'"]+)['"]/);
|
|
@@ -72,7 +72,7 @@ export class ServiceDetector {
|
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
//
|
|
75
|
+
// Try to detect the port from application.yml/properties
|
|
76
76
|
port = await this.detectSpringBootPort();
|
|
77
77
|
|
|
78
78
|
return {
|
|
@@ -89,7 +89,7 @@ export class ServiceDetector {
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
/**
|
|
92
|
-
*
|
|
92
|
+
* Detects the Spring Boot port
|
|
93
93
|
*/
|
|
94
94
|
async detectSpringBootPort() {
|
|
95
95
|
const candidates = [
|
|
@@ -103,21 +103,21 @@ export class ServiceDetector {
|
|
|
103
103
|
if (await exists(fullPath)) {
|
|
104
104
|
const content = await readFile(fullPath, "utf8");
|
|
105
105
|
|
|
106
|
-
// YAML -
|
|
106
|
+
// YAML - multiple patterns
|
|
107
107
|
if (rel.endsWith(".yml") || rel.endsWith(".yaml")) {
|
|
108
|
-
//
|
|
108
|
+
// Pattern 1: server:\n port: 8080
|
|
109
109
|
let match = content.match(/server:\s*\n\s*port:\s*(\d+)/);
|
|
110
110
|
if (match) return parseInt(match[1]);
|
|
111
111
|
|
|
112
|
-
//
|
|
112
|
+
// Pattern 2: server.port: 8080
|
|
113
113
|
match = content.match(/server\.port:\s*(\d+)/);
|
|
114
114
|
if (match) return parseInt(match[1]);
|
|
115
115
|
|
|
116
|
-
//
|
|
116
|
+
// Pattern 3: port: ${PORT:8080}
|
|
117
117
|
match = content.match(/port:\s*\$\{[^}]*:(\d+)\}/);
|
|
118
118
|
if (match) return parseInt(match[1]);
|
|
119
119
|
|
|
120
|
-
//
|
|
120
|
+
// Pattern 4: server:\n port: 8080 (with more spaces)
|
|
121
121
|
match = content.match(/server:\s*[\r\n]+\s*port:\s*(\d+)/);
|
|
122
122
|
if (match) return parseInt(match[1]);
|
|
123
123
|
}
|
|
@@ -134,7 +134,7 @@ export class ServiceDetector {
|
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
/**
|
|
137
|
-
*
|
|
137
|
+
* Detects a Node.js service
|
|
138
138
|
*/
|
|
139
139
|
async detectNodeService() {
|
|
140
140
|
const pkgPath = path.join(this.root, "package.json");
|
|
@@ -157,10 +157,10 @@ export class ServiceDetector {
|
|
|
157
157
|
}
|
|
158
158
|
|
|
159
159
|
/**
|
|
160
|
-
*
|
|
160
|
+
* Detects the Node.js port
|
|
161
161
|
*/
|
|
162
162
|
async detectNodePort() {
|
|
163
|
-
//
|
|
163
|
+
// Try .env
|
|
164
164
|
const envPath = path.join(this.root, ".env");
|
|
165
165
|
if (await exists(envPath)) {
|
|
166
166
|
const content = await readFile(envPath, "utf8");
|
|
@@ -168,7 +168,7 @@ export class ServiceDetector {
|
|
|
168
168
|
if (match) return parseInt(match[1]);
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
-
// Defaults
|
|
171
|
+
// Defaults per framework
|
|
172
172
|
const defaults = {
|
|
173
173
|
"next": 3000,
|
|
174
174
|
"express": 3000,
|
|
@@ -179,7 +179,7 @@ export class ServiceDetector {
|
|
|
179
179
|
}
|
|
180
180
|
|
|
181
181
|
/**
|
|
182
|
-
*
|
|
182
|
+
* Detects a Python service
|
|
183
183
|
*/
|
|
184
184
|
async detectPythonService() {
|
|
185
185
|
return {
|
|
@@ -196,7 +196,7 @@ export class ServiceDetector {
|
|
|
196
196
|
}
|
|
197
197
|
|
|
198
198
|
/**
|
|
199
|
-
*
|
|
199
|
+
* Detects the Python port
|
|
200
200
|
*/
|
|
201
201
|
async detectPythonPort() {
|
|
202
202
|
const defaults = {
|
|
@@ -209,7 +209,7 @@ export class ServiceDetector {
|
|
|
209
209
|
}
|
|
210
210
|
|
|
211
211
|
/**
|
|
212
|
-
*
|
|
212
|
+
* Detects a .NET service
|
|
213
213
|
*/
|
|
214
214
|
detectDotNetService() {
|
|
215
215
|
return {
|
|
@@ -226,7 +226,7 @@ export class ServiceDetector {
|
|
|
226
226
|
}
|
|
227
227
|
|
|
228
228
|
/**
|
|
229
|
-
*
|
|
229
|
+
* Detects a Go service
|
|
230
230
|
*/
|
|
231
231
|
detectGoService() {
|
|
232
232
|
return {
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DLP engine: pure functions, no env, no I/O, no module state.
|
|
3
|
+
*
|
|
4
|
+
* maskSensitive(text, opts) -> { text, redactions, total, blocked, blockedClasses, ... }
|
|
5
|
+
* mode 'off' text returned untouched, nothing scanned.
|
|
6
|
+
* mode 'warn' text returned untouched; redactions/blocked report what enforce WOULD do.
|
|
7
|
+
* mode 'enforce' text returned with every hit replaced by [REDACTED:<rule_id>];
|
|
8
|
+
* when a hit belongs to a class in blockClasses the whole text is
|
|
9
|
+
* replaced by [BLOCKED:<class>] and `blocked` is true.
|
|
10
|
+
* The matched value is never returned, logged or counted: only rule ids and counts.
|
|
11
|
+
*/
|
|
12
|
+
import { RULES, RULESET_VERSION } from './rules.js';
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
|
|
15
|
+
export const MODES = Object.freeze(['off', 'warn', 'enforce']);
|
|
16
|
+
export const DEFAULT_MAX_BYTES = 2_000_000;
|
|
17
|
+
|
|
18
|
+
/** @param {unknown} v @returns {'off'|'warn'|'enforce'} unknown values fall back to 'off'. */
|
|
19
|
+
export function normalizeMode(v) {
|
|
20
|
+
const m = typeof v === 'string' ? v.trim().toLowerCase() : '';
|
|
21
|
+
return MODES.includes(m) ? m : 'off';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Stable hash of the active ruleset (sent with config.update / heartbeat). */
|
|
25
|
+
export function rulesetHash(rules = RULES) {
|
|
26
|
+
const h = createHash('sha256');
|
|
27
|
+
h.update(RULESET_VERSION);
|
|
28
|
+
for (const rule of rules) h.update(`\n${rule.id}|${rule.class}|${rule.re.source}|${rule.re.flags}|${rule.group ?? 0}`);
|
|
29
|
+
return h.digest('hex');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const REDACTED = (id) => `[REDACTED:${id}]`;
|
|
33
|
+
export const BLOCKED = (cls) => `[BLOCKED:${cls}]`;
|
|
34
|
+
export const OVERSIZE = '[REDACTED:dlp_oversize]';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {string} text
|
|
38
|
+
* @param {{mode?: string, rules?: ReadonlyArray<object>, blockClasses?: Iterable<string>, maxBytes?: number}} [opts]
|
|
39
|
+
*/
|
|
40
|
+
export function maskSensitive(text, opts = {}) {
|
|
41
|
+
const mode = normalizeMode(opts.mode);
|
|
42
|
+
const empty = { text, mode, redactions: {}, total: 0, blocked: false, blockedClasses: [], oversize: false, rulesetVersion: RULESET_VERSION };
|
|
43
|
+
if (mode === 'off' || typeof text !== 'string' || text.length === 0) return empty;
|
|
44
|
+
|
|
45
|
+
const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
46
|
+
if (text.length > maxBytes) {
|
|
47
|
+
// Cannot scan safely in bounded time: enforce fails closed, warn only reports.
|
|
48
|
+
return { ...empty, text: mode === 'enforce' ? OVERSIZE : text, oversize: true };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const rules = opts.rules ?? RULES;
|
|
52
|
+
const blockSet = new Set(opts.blockClasses ?? []);
|
|
53
|
+
const redactions = {};
|
|
54
|
+
const hitClasses = new Set();
|
|
55
|
+
let out = text;
|
|
56
|
+
let total = 0;
|
|
57
|
+
|
|
58
|
+
for (const rule of rules) {
|
|
59
|
+
const re = new RegExp(rule.re.source, rule.re.flags.includes('g') ? rule.re.flags : rule.re.flags + 'g');
|
|
60
|
+
out = out.replace(re, (...args) => {
|
|
61
|
+
const match = args[0];
|
|
62
|
+
const groupIdx = rule.group ?? 0;
|
|
63
|
+
const target = groupIdx === 0 ? match : args[groupIdx];
|
|
64
|
+
if (typeof target !== 'string' || target.length === 0) return match;
|
|
65
|
+
if (target.includes('[REDACTED:')) return match; // never re-mask a placeholder (idempotence)
|
|
66
|
+
if (rule.validate && !rule.validate(target)) return match;
|
|
67
|
+
redactions[rule.id] = (redactions[rule.id] || 0) + 1;
|
|
68
|
+
hitClasses.add(rule.class);
|
|
69
|
+
total++;
|
|
70
|
+
if (groupIdx === 0) return REDACTED(rule.id);
|
|
71
|
+
const at = match.lastIndexOf(target);
|
|
72
|
+
return match.slice(0, at) + REDACTED(rule.id) + match.slice(at + target.length);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const blockedClasses = [...hitClasses].filter((c) => blockSet.has(c));
|
|
77
|
+
const blocked = blockedClasses.length > 0;
|
|
78
|
+
if (mode === 'warn') return { ...empty, redactions, total, blocked, blockedClasses };
|
|
79
|
+
return { ...empty, text: blocked ? BLOCKED(blockedClasses[0]) : out, redactions, total, blocked, blockedClasses };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Apply maskSensitive to every string in a JSON-like value (objects, arrays).
|
|
84
|
+
* Keys are not touched. Returns a new value plus the merged report.
|
|
85
|
+
*/
|
|
86
|
+
export function maskDeep(value, opts = {}) {
|
|
87
|
+
const report = { redactions: {}, total: 0, blocked: false, blockedClasses: [], oversize: false };
|
|
88
|
+
const seen = new WeakSet();
|
|
89
|
+
const walk = (v) => {
|
|
90
|
+
if (typeof v === 'string') {
|
|
91
|
+
const res = maskSensitive(v, opts);
|
|
92
|
+
for (const [k, n] of Object.entries(res.redactions)) report.redactions[k] = (report.redactions[k] || 0) + n;
|
|
93
|
+
report.total += res.total;
|
|
94
|
+
report.blocked ||= res.blocked;
|
|
95
|
+
report.oversize ||= res.oversize;
|
|
96
|
+
for (const c of res.blockedClasses) if (!report.blockedClasses.includes(c)) report.blockedClasses.push(c);
|
|
97
|
+
return res.text;
|
|
98
|
+
}
|
|
99
|
+
if (v === null || typeof v !== 'object') return v;
|
|
100
|
+
if (seen.has(v)) return '[circular]';
|
|
101
|
+
seen.add(v);
|
|
102
|
+
if (Array.isArray(v)) return v.map(walk);
|
|
103
|
+
const o = {};
|
|
104
|
+
for (const [k, x] of Object.entries(v)) o[k] = walk(x);
|
|
105
|
+
return o;
|
|
106
|
+
};
|
|
107
|
+
const mode = normalizeMode(opts.mode);
|
|
108
|
+
if (mode === 'off') return { value, mode, ...report };
|
|
109
|
+
const masked = walk(value);
|
|
110
|
+
return { value: mode === 'warn' ? value : masked, mode, ...report };
|
|
111
|
+
}
|
package/src/dlp/index.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DLP for the Local Agent (T4-09): public API.
|
|
3
|
+
*
|
|
4
|
+
* Pure, reusable core (no env, no state): maskSensitive, maskDeep (engine.js)
|
|
5
|
+
* Configured wrappers (env + counters): protectEgress, protectEgressAsync, protectDeep
|
|
6
|
+
*
|
|
7
|
+
* Flag: LOCAL_AGENT_DLP_MODE=off|warn|enforce (default off: output byte-identical to before)
|
|
8
|
+
* LOCAL_AGENT_DLP_BLOCK_CLASSES=private-key,cloud-credential (default none; enforce only)
|
|
9
|
+
* LOCAL_AGENT_DLP_MAX_BYTES=2000000 (enforce fails closed above it)
|
|
10
|
+
*
|
|
11
|
+
* Fail-closed (D19): in enforce, an internal DLP error, an oversize input or a
|
|
12
|
+
* PolicyGate decision that is not an explicit ALLOW (including offline/timeout)
|
|
13
|
+
* withholds the content ([DLP_DENIED]) instead of letting it out.
|
|
14
|
+
*
|
|
15
|
+
* Reports carry rule ids and counts only; never the matched value.
|
|
16
|
+
*/
|
|
17
|
+
import { maskSensitive, maskDeep, normalizeMode, rulesetHash, DEFAULT_MAX_BYTES } from './engine.js';
|
|
18
|
+
import { RULES, RULESET_VERSION, RULE_CLASSES } from './rules.js';
|
|
19
|
+
|
|
20
|
+
export { maskSensitive, maskDeep, normalizeMode, rulesetHash, RULES, RULESET_VERSION, RULE_CLASSES };
|
|
21
|
+
|
|
22
|
+
export const DENIED = '[DLP_DENIED]';
|
|
23
|
+
const NOOP = () => {};
|
|
24
|
+
|
|
25
|
+
/** Read the DLP configuration from an env-like object. */
|
|
26
|
+
export function dlpConfigFromEnv(env = process.env) {
|
|
27
|
+
const maxRaw = Number.parseInt(env.LOCAL_AGENT_DLP_MAX_BYTES ?? '', 10);
|
|
28
|
+
const blockClasses = String(env.LOCAL_AGENT_DLP_BLOCK_CLASSES ?? '')
|
|
29
|
+
.split(',').map((s) => s.trim()).filter((c) => RULE_CLASSES.includes(c));
|
|
30
|
+
return {
|
|
31
|
+
mode: normalizeMode(env.LOCAL_AGENT_DLP_MODE),
|
|
32
|
+
blockClasses,
|
|
33
|
+
maxBytes: Number.isFinite(maxRaw) && maxRaw > 0 ? maxRaw : DEFAULT_MAX_BYTES,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ---- counters (for heartbeat / dlp_redactions_total) -------------------------
|
|
38
|
+
const counters = { scanned: 0, redactedTotal: 0, blocked: 0, denied: 0, byRule: new Map() };
|
|
39
|
+
|
|
40
|
+
function record(res) {
|
|
41
|
+
counters.scanned++;
|
|
42
|
+
counters.redactedTotal += res.total;
|
|
43
|
+
if (res.blocked) counters.blocked++;
|
|
44
|
+
for (const [id, n] of Object.entries(res.redactions)) counters.byRule.set(id, (counters.byRule.get(id) || 0) + n);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Snapshot for the heartbeat: counts only. `dlp_redactions_total` is per rule id. */
|
|
48
|
+
export function getDlpStats(env = process.env) {
|
|
49
|
+
const cfg = dlpConfigFromEnv(env);
|
|
50
|
+
return {
|
|
51
|
+
mode: cfg.mode,
|
|
52
|
+
ruleset_version: RULESET_VERSION,
|
|
53
|
+
ruleset_hash: rulesetHash(),
|
|
54
|
+
scanned: counters.scanned,
|
|
55
|
+
blocked: counters.blocked,
|
|
56
|
+
denied: counters.denied,
|
|
57
|
+
dlp_redactions_total: Object.fromEntries(counters.byRule),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function resetDlpStats() {
|
|
62
|
+
counters.scanned = 0; counters.redactedTotal = 0; counters.blocked = 0; counters.denied = 0;
|
|
63
|
+
counters.byRule.clear();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function localLog(log, source, res) {
|
|
67
|
+
if (res.total === 0 && !res.oversize) return;
|
|
68
|
+
try {
|
|
69
|
+
// Counts per rule id only. The masked value is never part of the record.
|
|
70
|
+
log({ dlp: true, source: source || 'unknown', mode: res.mode, total: res.total, rules: res.redactions, blocked: res.blocked, oversize: res.oversize }, '[dlp] sensitive content detected');
|
|
71
|
+
} catch { /* logging must never break the data path */ }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Mask one string leaving the agent.
|
|
76
|
+
* @param {string} text
|
|
77
|
+
* @param {{env?: object, source?: string, log?: Function}} [opts]
|
|
78
|
+
* @returns {{text: string, mode: string, redactions: Record<string, number>, total: number, blocked: boolean, denied: boolean}}
|
|
79
|
+
*/
|
|
80
|
+
export function protectEgress(text, opts = {}) {
|
|
81
|
+
const cfg = dlpConfigFromEnv(opts.env ?? process.env);
|
|
82
|
+
if (cfg.mode === 'off') return { text, mode: 'off', redactions: {}, total: 0, blocked: false, denied: false };
|
|
83
|
+
try {
|
|
84
|
+
const res = maskSensitive(text, cfg);
|
|
85
|
+
record(res);
|
|
86
|
+
localLog(opts.log ?? NOOP, opts.source, res);
|
|
87
|
+
return { text: res.text, mode: cfg.mode, redactions: res.redactions, total: res.total, blocked: res.blocked, denied: false };
|
|
88
|
+
} catch {
|
|
89
|
+
if (cfg.mode !== 'enforce') return { text, mode: cfg.mode, redactions: {}, total: 0, blocked: false, denied: false };
|
|
90
|
+
counters.denied++;
|
|
91
|
+
return { text: DENIED, mode: cfg.mode, redactions: {}, total: 0, blocked: false, denied: true };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Same as protectEgress, plus a per-action PolicyGate decision (D19).
|
|
97
|
+
* `policyDecision` is optional; when given, enforce requires an explicit 'ALLOW'.
|
|
98
|
+
* Offline, throwing, timing out or answering anything else = DENY.
|
|
99
|
+
* @param {string} text
|
|
100
|
+
* @param {{policyDecision?: () => Promise<string>|string, timeoutMs?: number, env?: object, source?: string, log?: Function}} [opts]
|
|
101
|
+
*/
|
|
102
|
+
export async function protectEgressAsync(text, opts = {}) {
|
|
103
|
+
const cfg = dlpConfigFromEnv(opts.env ?? process.env);
|
|
104
|
+
if (cfg.mode === 'enforce' && typeof opts.policyDecision === 'function') {
|
|
105
|
+
let verdict;
|
|
106
|
+
let timer;
|
|
107
|
+
try {
|
|
108
|
+
const timeout = new Promise((_, rej) => { timer = setTimeout(() => rej(new Error('policy timeout')), opts.timeoutMs ?? 3000); });
|
|
109
|
+
verdict = await Promise.race([Promise.resolve().then(opts.policyDecision), timeout]);
|
|
110
|
+
} catch {
|
|
111
|
+
verdict = undefined;
|
|
112
|
+
} finally {
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
}
|
|
115
|
+
if (verdict !== 'ALLOW') {
|
|
116
|
+
counters.denied++;
|
|
117
|
+
return { text: DENIED, mode: cfg.mode, redactions: {}, total: 0, blocked: false, denied: true };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return protectEgress(text, opts);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Mask every string in a JSON-like payload. Returns { value, ... } with the same report fields. */
|
|
124
|
+
export function protectDeep(value, opts = {}) {
|
|
125
|
+
const cfg = dlpConfigFromEnv(opts.env ?? process.env);
|
|
126
|
+
if (cfg.mode === 'off') return { value, mode: 'off', redactions: {}, total: 0, blocked: false, denied: false };
|
|
127
|
+
try {
|
|
128
|
+
const res = maskDeep(value, cfg);
|
|
129
|
+
record(res);
|
|
130
|
+
localLog(opts.log ?? NOOP, opts.source, { ...res, mode: cfg.mode });
|
|
131
|
+
return { ...res, denied: false };
|
|
132
|
+
} catch {
|
|
133
|
+
if (cfg.mode !== 'enforce') return { value, mode: cfg.mode, redactions: {}, total: 0, blocked: false, denied: false };
|
|
134
|
+
counters.denied++;
|
|
135
|
+
return { value: DENIED, mode: cfg.mode, redactions: {}, total: 0, blocked: false, denied: true };
|
|
136
|
+
}
|
|
137
|
+
}
|