mergen-server 1.4.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/LICENSE +57 -0
- package/README.md +96 -0
- package/dist/__stubs__/calibration.js +471 -0
- package/dist/__stubs__/causal.js +601 -0
- package/dist/__stubs__/closed-source.js +150 -0
- package/dist/__stubs__/license.js +35 -0
- package/dist/__stubs__/plans.js +197 -0
- package/dist/app.js +467 -0
- package/dist/cli.js +250 -0
- package/dist/commands/agent-identity.js +79 -0
- package/dist/commands/gate.js +107 -0
- package/dist/commands/github.js +632 -0
- package/dist/commands/incident.js +644 -0
- package/dist/commands/policy.js +710 -0
- package/dist/commands/scan.js +243 -0
- package/dist/commands/setup.js +1218 -0
- package/dist/commands/shared.js +109 -0
- package/dist/commands/team.js +809 -0
- package/dist/datadog/blame-attribution.js +228 -0
- package/dist/datadog/client.js +159 -0
- package/dist/datadog/compactor.js +169 -0
- package/dist/datadog/fingerprinter.js +34 -0
- package/dist/datadog/incident-state.js +21 -0
- package/dist/datadog/line-matcher.js +56 -0
- package/dist/datadog/memory-store.js +399 -0
- package/dist/datadog/otel-trace.js +67 -0
- package/dist/index.js +505 -0
- package/dist/intelligence/action-risk.js +132 -0
- package/dist/intelligence/activity-feed.js +40 -0
- package/dist/intelligence/agent-identity.js +115 -0
- package/dist/intelligence/agent-pipeline.js +329 -0
- package/dist/intelligence/agent-profiles.js +81 -0
- package/dist/intelligence/ai-commit.js +16 -0
- package/dist/intelligence/approval-analytics.js +42 -0
- package/dist/intelligence/approval-events.js +5 -0
- package/dist/intelligence/arch-boundaries.js +94 -0
- package/dist/intelligence/arch-graph.js +117 -0
- package/dist/intelligence/autonomy.js +397 -0
- package/dist/intelligence/azure-broker.js +57 -0
- package/dist/intelligence/azure-proxy-sessions.js +49 -0
- package/dist/intelligence/baseline.js +1 -0
- package/dist/intelligence/behavior-baseline.js +134 -0
- package/dist/intelligence/billing.js +1 -0
- package/dist/intelligence/blast-radius.js +224 -0
- package/dist/intelligence/bypass.js +157 -0
- package/dist/intelligence/calibration-classifier.js +77 -0
- package/dist/intelligence/calibration-git-sync.js +74 -0
- package/dist/intelligence/calibration.js +1 -0
- package/dist/intelligence/cascade-detector.js +58 -0
- package/dist/intelligence/case-study-generator.js +126 -0
- package/dist/intelligence/causal-graph.js +126 -0
- package/dist/intelligence/causal.js +1 -0
- package/dist/intelligence/change-risk.js +102 -0
- package/dist/intelligence/compliance-report.js +134 -0
- package/dist/intelligence/confidence-report.js +85 -0
- package/dist/intelligence/corpus-to-policy.js +140 -0
- package/dist/intelligence/credential-broker.js +112 -0
- package/dist/intelligence/credential-misuse.js +39 -0
- package/dist/intelligence/debug-sessions.js +108 -0
- package/dist/intelligence/decision-escalation.js +36 -0
- package/dist/intelligence/decision-memory.js +47 -0
- package/dist/intelligence/decision-similarity.js +85 -0
- package/dist/intelligence/default-safety-tests.js +53 -0
- package/dist/intelligence/degradation-watcher.js +108 -0
- package/dist/intelligence/detector-plugins.js +70 -0
- package/dist/intelligence/detectors.js +1 -0
- package/dist/intelligence/device-auth.js +138 -0
- package/dist/intelligence/diff-size.js +42 -0
- package/dist/intelligence/ebpf-verify.js +45 -0
- package/dist/intelligence/enterprise-policy-engine.js +1122 -0
- package/dist/intelligence/error-fingerprint.js +1 -0
- package/dist/intelligence/execution-gate.js +100 -0
- package/dist/intelligence/execution-mode.js +22 -0
- package/dist/intelligence/gate-analytics.js +256 -0
- package/dist/intelligence/gate-decision.js +64 -0
- package/dist/intelligence/gcp-broker.js +50 -0
- package/dist/intelligence/git-adr-sync.js +207 -0
- package/dist/intelligence/hitl-hold.js +460 -0
- package/dist/intelligence/hypothesis-history.js +93 -0
- package/dist/intelligence/idp-identity.js +83 -0
- package/dist/intelligence/impl-critic.js +216 -0
- package/dist/intelligence/incident-autopilot.js +622 -0
- package/dist/intelligence/incident-replay.js +162 -0
- package/dist/intelligence/incident-result-cache.js +33 -0
- package/dist/intelligence/incident-similarity.js +58 -0
- package/dist/intelligence/infra-detectors.js +312 -0
- package/dist/intelligence/license.js +185 -0
- package/dist/intelligence/llm-spokesperson.js +62 -0
- package/dist/intelligence/mcp-prompts.js +320 -0
- package/dist/intelligence/mcp-resources.js +171 -0
- package/dist/intelligence/normalize.js +108 -0
- package/dist/intelligence/notifications.js +83 -0
- package/dist/intelligence/oidc-issuer.js +87 -0
- package/dist/intelligence/override-corpus.js +597 -0
- package/dist/intelligence/planning-gate.js +84 -0
- package/dist/intelligence/plans.js +223 -0
- package/dist/intelligence/platt-scaling.js +164 -0
- package/dist/intelligence/policy-proposals.js +84 -0
- package/dist/intelligence/policy-suggester.js +161 -0
- package/dist/intelligence/policy-sync.js +103 -0
- package/dist/intelligence/policy-test-runner.js +35 -0
- package/dist/intelligence/postmortem-parser.js +181 -0
- package/dist/intelligence/postmortem-retrieval.js +224 -0
- package/dist/intelligence/postmortem-store.js +596 -0
- package/dist/intelligence/pr-commenter.js +81 -0
- package/dist/intelligence/pr-policy-gate.js +38 -0
- package/dist/intelligence/pr-shadow-analyzer.js +229 -0
- package/dist/intelligence/process-tree.js +108 -0
- package/dist/intelligence/prompts.js +1 -0
- package/dist/intelligence/replay.js +38 -0
- package/dist/intelligence/repro-steps.js +1 -0
- package/dist/intelligence/resource-extractors.js +0 -0
- package/dist/intelligence/resource-file-context.js +138 -0
- package/dist/intelligence/risk-score.js +22 -0
- package/dist/intelligence/rollback.js +137 -0
- package/dist/intelligence/runbook-updater.js +143 -0
- package/dist/intelligence/sandbox.js +194 -0
- package/dist/intelligence/script-trust.js +104 -0
- package/dist/intelligence/session-metrics.js +67 -0
- package/dist/intelligence/session-threat-tracker.js +231 -0
- package/dist/intelligence/shadow-digest-cron.js +81 -0
- package/dist/intelligence/shadow-log.js +284 -0
- package/dist/intelligence/shell-ast.js +145 -0
- package/dist/intelligence/siem-forward.js +104 -0
- package/dist/intelligence/slack-digest.js +151 -0
- package/dist/intelligence/slack-override-loop.js +217 -0
- package/dist/intelligence/slack-routing.js +67 -0
- package/dist/intelligence/slack.js +900 -0
- package/dist/intelligence/sql-ast.js +31 -0
- package/dist/intelligence/team.js +1 -0
- package/dist/intelligence/telemetry.js +76 -0
- package/dist/intelligence/threshold-optimizer.js +75 -0
- package/dist/intelligence/token-budget.js +66 -0
- package/dist/intelligence/tool-guard.js +708 -0
- package/dist/intelligence/tool-manifest 2.js +74 -0
- package/dist/intelligence/tool-manifest.js +125 -0
- package/dist/intelligence/tools-analysis.js +857 -0
- package/dist/intelligence/tools-arch.js +152 -0
- package/dist/intelligence/tools-autonomy.js +495 -0
- package/dist/intelligence/tools-blast-radius.js +140 -0
- package/dist/intelligence/tools-browser.js +431 -0
- package/dist/intelligence/tools-change-timeline.js +154 -0
- package/dist/intelligence/tools-credentials.js +117 -0
- package/dist/intelligence/tools-datadog.js +233 -0
- package/dist/intelligence/tools-debug-sessions.js +209 -0
- package/dist/intelligence/tools-discovery.js +284 -0
- package/dist/intelligence/tools-infra.js +612 -0
- package/dist/intelligence/tools-intent.js +128 -0
- package/dist/intelligence/tools-memory.js +514 -0
- package/dist/intelligence/tools-runbook.js +951 -0
- package/dist/intelligence/tools-sessions.js +107 -0
- package/dist/intelligence/tools-state.js +232 -0
- package/dist/intelligence/tools-utility.js +430 -0
- package/dist/intelligence/tools-validate.js +215 -0
- package/dist/intelligence/tools.js +1 -0
- package/dist/intelligence/trace-context.js +11 -0
- package/dist/intelligence/unclassified-clusters.js +87 -0
- package/dist/intelligence/usage.js +195 -0
- package/dist/routes/active-authors.js +63 -0
- package/dist/routes/activity-feed.js +21 -0
- package/dist/routes/adr.js +117 -0
- package/dist/routes/agent-activity.js +184 -0
- package/dist/routes/agent-blunders.js +163 -0
- package/dist/routes/agents.js +175 -0
- package/dist/routes/api-keys.js +65 -0
- package/dist/routes/arch.js +75 -0
- package/dist/routes/audit-export.js +93 -0
- package/dist/routes/billing-dashboard.js +158 -0
- package/dist/routes/billing-outcome.js +77 -0
- package/dist/routes/calibration.js +305 -0
- package/dist/routes/ci-gate-history.js +20 -0
- package/dist/routes/ci-gate.js +232 -0
- package/dist/routes/ci.js +293 -0
- package/dist/routes/compliance-report.js +24 -0
- package/dist/routes/confidence.js +30 -0
- package/dist/routes/credentials.js +119 -0
- package/dist/routes/dashboard.js +1407 -0
- package/dist/routes/decision-memory.js +42 -0
- package/dist/routes/demo.js +844 -0
- package/dist/routes/device-auth-stub.js +173 -0
- package/dist/routes/explain-why.js +39 -0
- package/dist/routes/gate-analytics.js +213 -0
- package/dist/routes/gate.js +28 -0
- package/dist/routes/github-webhook.js +364 -0
- package/dist/routes/habituation.js +30 -0
- package/dist/routes/health-integrations.js +243 -0
- package/dist/routes/heartbeats.js +45 -0
- package/dist/routes/hitl.js +364 -0
- package/dist/routes/impact-report.js +645 -0
- package/dist/routes/incident-webhook.js +63 -0
- package/dist/routes/incidents.js +366 -0
- package/dist/routes/layers.js +62 -0
- package/dist/routes/license.js +187 -0
- package/dist/routes/oidc-issuer.js +30 -0
- package/dist/routes/onboarding.js +170 -0
- package/dist/routes/otel.js +67 -0
- package/dist/routes/otlp-receiver.js +179 -0
- package/dist/routes/overrides.js +241 -0
- package/dist/routes/pagerduty.js +258 -0
- package/dist/routes/policies.js +809 -0
- package/dist/routes/policy-nl.js +169 -0
- package/dist/routes/postmortem.js +102 -0
- package/dist/routes/pr-shadow.js +47 -0
- package/dist/routes/rbac.js +61 -0
- package/dist/routes/replay.js +17 -0
- package/dist/routes/risk-report.js +164 -0
- package/dist/routes/runbooks.js +125 -0
- package/dist/routes/safety-test.js +174 -0
- package/dist/routes/sdk.js +222 -0
- package/dist/routes/sensor.js +819 -0
- package/dist/routes/sentry.js +140 -0
- package/dist/routes/sessions.js +43 -0
- package/dist/routes/setup-ui.js +594 -0
- package/dist/routes/shadow-report.js +107 -0
- package/dist/routes/slack-routing.js +171 -0
- package/dist/routes/team-usage.js +71 -0
- package/dist/routes/telemetry.js +28 -0
- package/dist/routes/tenants.js +199 -0
- package/dist/routes/tickets.js +167 -0
- package/dist/routes/validate.js +13 -0
- package/dist/routes/war-room.js +113 -0
- package/dist/scripts/check-arch.js +22 -0
- package/dist/seeds/community-corpus.js +176 -0
- package/dist/seeds/corpus.js +1224 -0
- package/dist/sensor/action-ledger.js +327 -0
- package/dist/sensor/adr-store.js +140 -0
- package/dist/sensor/agent-blunder-store.js +370 -0
- package/dist/sensor/agent-context-store.js +225 -0
- package/dist/sensor/agent-memory-store.js +231 -0
- package/dist/sensor/audit-fetch.js +23 -0
- package/dist/sensor/audit-log.js +273 -0
- package/dist/sensor/buffer-schemas.js +225 -0
- package/dist/sensor/buffer.js +599 -0
- package/dist/sensor/bypass-tracker.js +93 -0
- package/dist/sensor/ci-gate-history.js +70 -0
- package/dist/sensor/cloud-auth.js +183 -0
- package/dist/sensor/commit-context-store.js +235 -0
- package/dist/sensor/docker-log-stream.js +164 -0
- package/dist/sensor/docker-monitor.js +122 -0
- package/dist/sensor/extended-buffer.js +46 -0
- package/dist/sensor/feedback-token.js +41 -0
- package/dist/sensor/file-lock.js +6 -0
- package/dist/sensor/fs-watcher.js +106 -0
- package/dist/sensor/gate-heartbeat.js +122 -0
- package/dist/sensor/git-suspect.js +136 -0
- package/dist/sensor/habituation-store.js +77 -0
- package/dist/sensor/heartbeat-monitor.js +133 -0
- package/dist/sensor/incident-store.js +340 -0
- package/dist/sensor/infra-normalizer.js +513 -0
- package/dist/sensor/ingest.js +339 -0
- package/dist/sensor/jest-reporter.js +52 -0
- package/dist/sensor/k8s-events.js +132 -0
- package/dist/sensor/layer2-store.js +111 -0
- package/dist/sensor/layer3-store.js +230 -0
- package/dist/sensor/layer4-store.js +147 -0
- package/dist/sensor/logger.js +13 -0
- package/dist/sensor/otel-exporter.js +161 -0
- package/dist/sensor/paths.js +71 -0
- package/dist/sensor/policy-history.js +147 -0
- package/dist/sensor/pr-shadow-store.js +83 -0
- package/dist/sensor/process-watcher.js +162 -0
- package/dist/sensor/rbac.js +92 -0
- package/dist/sensor/redact.js +114 -0
- package/dist/sensor/redis-store.js +191 -0
- package/dist/sensor/route-reachability.js +64 -0
- package/dist/sensor/security-utils.js +18 -0
- package/dist/sensor/service-graph.js +170 -0
- package/dist/sensor/service-topology.js +227 -0
- package/dist/sensor/session-history.js +74 -0
- package/dist/sensor/session-persist.js +38 -0
- package/dist/sensor/shadow-promote.js +122 -0
- package/dist/sensor/sourcemap.js +281 -0
- package/dist/sensor/sqlite-store.js +205 -0
- package/dist/sensor/sso.js +164 -0
- package/dist/sensor/vitest-reporter.js +65 -0
- package/dist/sensor/watcher.js +48 -0
- package/dist/storage/interfaces.js +0 -0
- package/dist/storage/pg/pg-action-ledger.js +138 -0
- package/dist/storage/pg/pg-approval-store.js +107 -0
- package/dist/storage/pg/pg-blunder-store.js +193 -0
- package/dist/storage/pg/pg-ci-gate-history.js +83 -0
- package/dist/storage/pg/pg-client.js +24 -0
- package/dist/storage/pg/pg-event-store.js +63 -0
- package/dist/storage/pg/pg-incident-store.js +190 -0
- package/dist/storage/pg/pg-migrations.js +26 -0
- package/dist/storage/pg/pg-override-corpus.js +447 -0
- package/dist/storage/pg/pg-shadow-log.js +129 -0
- package/dist/storage/sqlite/sqlite-action-ledger.js +18 -0
- package/dist/storage/sqlite/sqlite-approval-store.js +37 -0
- package/dist/storage/sqlite/sqlite-blunder-store.js +27 -0
- package/dist/storage/sqlite/sqlite-ci-gate-history.js +19 -0
- package/dist/storage/sqlite/sqlite-event-store.js +29 -0
- package/dist/storage/sqlite/sqlite-incident-store.js +34 -0
- package/dist/storage/sqlite/sqlite-override-corpus.js +75 -0
- package/dist/storage/sqlite/sqlite-shadow-log.js +36 -0
- package/dist/storage/store-factory.js +55 -0
- package/dist/storage/store-registry.js +31 -0
- package/dist/update-checker.js +107 -0
- package/dist/workers/autopilot-worker.js +28 -0
- package/dist/workers/notification-worker.js +28 -0
- package/dist/workers/queues.js +58 -0
- package/dist/workers/validation-worker.js +24 -0
- package/dist/workers/worker-registry.js +18 -0
- package/package.json +123 -0
- package/sdk/mergen-inject.js +260 -0
- package/sdk/node.js +313 -0
- package/sdk/vite-plugin.ts +57 -0
- package/sdk/webpack-plugin.js +106 -0
|
@@ -0,0 +1,1122 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { createHmac, timingSafeEqual } from "crypto";
|
|
4
|
+
import { performance } from "perf_hooks";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { DATA_DIR, zeroRetentionMode } from "../sensor/paths.js";
|
|
7
|
+
import logger from "../sensor/logger.js";
|
|
8
|
+
import { normalizeForMatching } from "./normalize.js";
|
|
9
|
+
import { analyzeCommands } from "./shell-ast.js";
|
|
10
|
+
import { sqlHasNoWhereClause } from "./sql-ast.js";
|
|
11
|
+
import { extractResourcesFromHaystack, globToRegex } from "./resource-extractors.js";
|
|
12
|
+
import { getRulesForTag } from "./override-corpus.js";
|
|
13
|
+
import { getStores } from "../storage/store-registry.js";
|
|
14
|
+
const _baseConditionFields = {
|
|
15
|
+
files: z.array(z.string()).optional(),
|
|
16
|
+
commands: z.array(z.string()).optional(),
|
|
17
|
+
actorType: z.enum(["ai", "human", "all"]).optional(),
|
|
18
|
+
daysOfWeek: z.array(z.number().int().min(0).max(6)).optional(),
|
|
19
|
+
hourWindow: z.tuple([z.number().int().min(0).max(23), z.number().int().min(0).max(24)]).optional(),
|
|
20
|
+
services: z.array(z.string()).optional(),
|
|
21
|
+
environments: z.array(z.string()).optional(),
|
|
22
|
+
repos: z.array(z.string()).optional(),
|
|
23
|
+
agentIds: z.array(z.string()).optional(),
|
|
24
|
+
roles: z.array(z.string()).optional(),
|
|
25
|
+
branches: z.array(z.string()).optional(),
|
|
26
|
+
resourceGlobs: z.array(z.string()).optional(),
|
|
27
|
+
excludeResourceGlobs: z.array(z.string()).optional(),
|
|
28
|
+
requireCorpusMatch: z.object({
|
|
29
|
+
incidentTag: z.string().min(1),
|
|
30
|
+
minOccurrences: z.number().int().min(1).optional()
|
|
31
|
+
}).optional()
|
|
32
|
+
};
|
|
33
|
+
const EnterprisePolicyConditionsSchema = z.lazy(
|
|
34
|
+
() => z.object({
|
|
35
|
+
..._baseConditionFields,
|
|
36
|
+
anyOf: z.array(EnterprisePolicyConditionsSchema).optional(),
|
|
37
|
+
allOf: z.array(EnterprisePolicyConditionsSchema).optional(),
|
|
38
|
+
not: EnterprisePolicyConditionsSchema.optional()
|
|
39
|
+
})
|
|
40
|
+
);
|
|
41
|
+
const EnterprisePolicyRuleSchema = z.object({
|
|
42
|
+
id: z.string().min(1),
|
|
43
|
+
name: z.string(),
|
|
44
|
+
description: z.string(),
|
|
45
|
+
action: z.enum(["block", "warn", "pass"]),
|
|
46
|
+
reason: z.string(),
|
|
47
|
+
riskTier: z.enum(["low", "medium", "high"]).optional(),
|
|
48
|
+
conditions: EnterprisePolicyConditionsSchema
|
|
49
|
+
});
|
|
50
|
+
const EnterprisePolicyConfigSchema = z.object({
|
|
51
|
+
enabled: z.boolean(),
|
|
52
|
+
rules: z.array(EnterprisePolicyRuleSchema)
|
|
53
|
+
});
|
|
54
|
+
const ENTERPRISE_POLICY_FILE = path.join(DATA_DIR, "enterprise-policy.json");
|
|
55
|
+
const ENTERPRISE_POLICY_SIG_FILE = ENTERPRISE_POLICY_FILE + ".sig";
|
|
56
|
+
const IMMUTABLE_RULE_IDS = /* @__PURE__ */ new Set([
|
|
57
|
+
"block_destructive_commands",
|
|
58
|
+
"block_s3_bucket_wipe",
|
|
59
|
+
"block_cloud_teardown",
|
|
60
|
+
"block_git_irreversible"
|
|
61
|
+
]);
|
|
62
|
+
let _policySigningSecret = "";
|
|
63
|
+
function setPolicySigningSecret(secret) {
|
|
64
|
+
_policySigningSecret = secret;
|
|
65
|
+
}
|
|
66
|
+
function _signPolicyPayload(content) {
|
|
67
|
+
return createHmac("sha256", _policySigningSecret).update(content).digest("hex");
|
|
68
|
+
}
|
|
69
|
+
function _allowUnsignedPolicy() {
|
|
70
|
+
return process.env.MERGEN_ALLOW_UNSIGNED_POLICY === "true";
|
|
71
|
+
}
|
|
72
|
+
const _DEV_NODE_ENVS = /* @__PURE__ */ new Set(["development", "test"]);
|
|
73
|
+
function assertUnsignedPolicyFlagIsSafe() {
|
|
74
|
+
if (!_allowUnsignedPolicy()) return;
|
|
75
|
+
if (_DEV_NODE_ENVS.has(process.env.NODE_ENV ?? "")) return;
|
|
76
|
+
const banner = [
|
|
77
|
+
"",
|
|
78
|
+
"\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550",
|
|
79
|
+
" MERGEN SECURITY WARNING \u2014 MERGEN_ALLOW_UNSIGNED_POLICY=true is set",
|
|
80
|
+
"\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550",
|
|
81
|
+
" This disables tamper-evidence on enterprise-policy.json. Anyone with",
|
|
82
|
+
" plain filesystem write access to that file \u2014 no secret, no API call \u2014",
|
|
83
|
+
" can silently disable enforcement (including block_destructive_commands)",
|
|
84
|
+
" and Mergen will load it unquestioned.",
|
|
85
|
+
"",
|
|
86
|
+
" This flag exists to migrate an existing unsigned policy file onto",
|
|
87
|
+
" signing. It should not stay set in a running deployment.",
|
|
88
|
+
"\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550",
|
|
89
|
+
""
|
|
90
|
+
].join("\n");
|
|
91
|
+
if (process.env.MERGEN_UNSAFE_ALLOW_UNSIGNED !== "true") {
|
|
92
|
+
console.error(banner);
|
|
93
|
+
console.error(
|
|
94
|
+
" Refusing to start: set MERGEN_UNSAFE_ALLOW_UNSIGNED=true in addition to\n MERGEN_ALLOW_UNSIGNED_POLICY=true to confirm this is intentional, or unset\n MERGEN_ALLOW_UNSIGNED_POLICY once migration is complete.\n"
|
|
95
|
+
);
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
console.error(banner);
|
|
99
|
+
}
|
|
100
|
+
function _policySignatureStatus(raw, storedSig) {
|
|
101
|
+
if (!_policySigningSecret) return "not-required";
|
|
102
|
+
if (!storedSig) return _allowUnsignedPolicy() ? "unsigned-accepted" : "unsigned-rejected";
|
|
103
|
+
const expected = _signPolicyPayload(raw);
|
|
104
|
+
const storedBuf = Buffer.from(storedSig, "hex");
|
|
105
|
+
const expectBuf = Buffer.from(expected, "hex");
|
|
106
|
+
return storedBuf.length === expectBuf.length && timingSafeEqual(storedBuf, expectBuf) ? "valid" : "invalid";
|
|
107
|
+
}
|
|
108
|
+
function _policySignatureStatusForTesting(raw, storedSig) {
|
|
109
|
+
return _policySignatureStatus(raw, storedSig);
|
|
110
|
+
}
|
|
111
|
+
const DEFAULT_ENTERPRISE_POLICY = {
|
|
112
|
+
enabled: true,
|
|
113
|
+
rules: [
|
|
114
|
+
// ── Local gate: destructive command patterns (block immediately) ───────────
|
|
115
|
+
{
|
|
116
|
+
id: "block_destructive_commands",
|
|
117
|
+
name: "Block destructive terminal commands",
|
|
118
|
+
description: "Synchronously block tool calls containing known destructive patterns before they reach the handler",
|
|
119
|
+
action: "block",
|
|
120
|
+
reason: "Local Gate: Destructive command pattern matched. This action was blocked before execution.",
|
|
121
|
+
conditions: {
|
|
122
|
+
commands: [
|
|
123
|
+
"rm -rf:no-git",
|
|
124
|
+
"rmdir /s",
|
|
125
|
+
"format c:",
|
|
126
|
+
"drop table",
|
|
127
|
+
"drop database",
|
|
128
|
+
"truncate table",
|
|
129
|
+
// DELETE FROM without a WHERE clause = full table wipe → BLOCK.
|
|
130
|
+
// DELETE FROM ... WHERE ... = targeted row deletion → HOLD (hold_agent_data_mutations).
|
|
131
|
+
"delete from:no-where",
|
|
132
|
+
// kubectl: block only namespace/cluster-scope cascades — routine pod/job
|
|
133
|
+
// cleanup is handled by hold_agent_data_mutations below.
|
|
134
|
+
"kubectl delete namespace",
|
|
135
|
+
"kubectl delete ns",
|
|
136
|
+
"kubectl delete --all",
|
|
137
|
+
"kubectl delete all",
|
|
138
|
+
// Stateful / cluster-scoped k8s deletions are cascading data loss.
|
|
139
|
+
"kubectl delete statefulset",
|
|
140
|
+
"kubectl delete pvc",
|
|
141
|
+
"kubectl delete crd",
|
|
142
|
+
"delete customresourcedefinition",
|
|
143
|
+
"helm uninstall",
|
|
144
|
+
"terraform destroy",
|
|
145
|
+
// Data-store wipes across engines (SQL / Redis / Mongo).
|
|
146
|
+
// FLUSHALL wipes every Redis DB → block. FLUSHDB (a single DB, often a
|
|
147
|
+
// cache) is treated as routine cache invalidation elsewhere in the
|
|
148
|
+
// corpus and is deliberately NOT hard-blocked here.
|
|
149
|
+
"drop schema",
|
|
150
|
+
"flushall",
|
|
151
|
+
"dropdatabase",
|
|
152
|
+
"destroy",
|
|
153
|
+
"nuke",
|
|
154
|
+
"wipe"
|
|
155
|
+
]
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
// ── Local gate: S3 bucket/prefix wipe (block) ─────────────────────────────
|
|
159
|
+
// `aws s3 rm` is only catastrophic when it deletes MORE than one object:
|
|
160
|
+
// `--recursive` (a bucket/prefix wipe) or a bucket-root/prefix target. A
|
|
161
|
+
// targeted single-object delete (`aws s3 rm s3://bucket/path/file.zip`) is
|
|
162
|
+
// routine cleanup — blocking it was a false positive (known-fp-005). This
|
|
163
|
+
// rule replaces the old unconditional `aws s3 rm` hard-block with the
|
|
164
|
+
// correct AWS semantics: only the multi-object wipe form is blocked.
|
|
165
|
+
{
|
|
166
|
+
id: "block_s3_bucket_wipe",
|
|
167
|
+
name: "Block recursive/bucket-wide S3 deletion",
|
|
168
|
+
description: "Block `aws s3 rm` when it wipes a whole bucket or prefix (--recursive, or a bucket-root/prefix target) \u2014 not a single-object delete",
|
|
169
|
+
action: "block",
|
|
170
|
+
reason: "Local Gate: recursive or bucket-wide S3 deletion wipes an entire bucket/prefix. Blocked before execution. A targeted single-object delete (an object key, no --recursive) is not blocked.",
|
|
171
|
+
conditions: {
|
|
172
|
+
commands: ["aws s3 rm", "s3 rm"],
|
|
173
|
+
// A leading-space ` --recursive` pattern takes the substring-match path
|
|
174
|
+
// (the single-word matcher's \b can't anchor a leading `--` flag); the
|
|
175
|
+
// resourceGlobs match a bucket-root/prefix target that ends in `/`.
|
|
176
|
+
anyOf: [
|
|
177
|
+
{ commands: [" --recursive"] },
|
|
178
|
+
{ resourceGlobs: ["s3://*/", "s3://*/**/"] }
|
|
179
|
+
]
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
// ── Local gate: schema / migration mutations (hold for HITL) ─────────────
|
|
183
|
+
{
|
|
184
|
+
id: "hold_schema_mutations",
|
|
185
|
+
name: "Hold schema migration commands for HITL review",
|
|
186
|
+
description: "Pause tool calls that propose schema changes and wait for operator approval",
|
|
187
|
+
action: "warn",
|
|
188
|
+
reason: "Local Gate: Schema mutation detected. Waiting for operator approval via HITL webhook.",
|
|
189
|
+
conditions: {
|
|
190
|
+
commands: [
|
|
191
|
+
"alter table",
|
|
192
|
+
"add column",
|
|
193
|
+
"drop column",
|
|
194
|
+
"rename column",
|
|
195
|
+
"create index",
|
|
196
|
+
"drop index",
|
|
197
|
+
"db:migrate",
|
|
198
|
+
"prisma migrate",
|
|
199
|
+
"knex migrate"
|
|
200
|
+
],
|
|
201
|
+
actorType: "ai"
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
// ── Local gate: targeted data mutations — hold for HITL when AI actor ─────
|
|
205
|
+
{
|
|
206
|
+
id: "hold_agent_data_mutations",
|
|
207
|
+
name: "Hold targeted data deletion and resource removal for HITL review",
|
|
208
|
+
description: "DELETE FROM with a WHERE clause is recoverable but still requires human sign-off. kubectl delete on pods/jobs/configmaps is routine maintenance but should be human-confirmed when an AI agent issues it.",
|
|
209
|
+
action: "warn",
|
|
210
|
+
reason: "Local Gate: Targeted data mutation or resource removal by AI agent. Waiting for operator approval \u2014 review the specific target before approving.",
|
|
211
|
+
conditions: {
|
|
212
|
+
commands: [
|
|
213
|
+
"delete from",
|
|
214
|
+
// includes DELETE FROM ... WHERE ...; human-reviewed before execution
|
|
215
|
+
"kubectl delete"
|
|
216
|
+
// catches pod/job/deployment/configmap deletions by AI
|
|
217
|
+
],
|
|
218
|
+
actorType: "ai"
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
// ── Enterprise: auth changes during Friday settlement window ──────────────
|
|
222
|
+
{
|
|
223
|
+
id: "policy_auth_batch_window",
|
|
224
|
+
name: "Block AI Auth changes during Friday batch window",
|
|
225
|
+
description: "Block all rollouts touching auth during batch windows only if caller is an AI",
|
|
226
|
+
action: "block",
|
|
227
|
+
reason: "Enterprise Custom Policy: Authentication changes are restricted during the Friday batch settlement window (12:00 - 24:00 UTC) for autonomous agents.",
|
|
228
|
+
conditions: {
|
|
229
|
+
files: ["auth", "login", "jwt", "middleware"],
|
|
230
|
+
actorType: "ai",
|
|
231
|
+
daysOfWeek: [5],
|
|
232
|
+
// Friday
|
|
233
|
+
hourWindow: [12, 24]
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
// ── Enterprise: database migrations by humans ─────────────────────────────
|
|
237
|
+
{
|
|
238
|
+
id: "policy_prod_database_warn",
|
|
239
|
+
name: "Warn on database migrations by humans",
|
|
240
|
+
description: "Warn when humans deploy database migrations directly",
|
|
241
|
+
action: "warn",
|
|
242
|
+
reason: "Enterprise Custom Policy: Direct database migrations should ideally be run via automated pipelines rather than individual developer scripts.",
|
|
243
|
+
conditions: {
|
|
244
|
+
files: ["migration", "schema.sql"],
|
|
245
|
+
actorType: "human"
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
// ══ Destructive-operations policy pack ══════════════════════════════════
|
|
249
|
+
// Canonical platform teardown/data-deletion verbs across AWS/GCP/Azure, k8s,
|
|
250
|
+
// and git. Deterministic command-pattern matches (no LLM). Irreversible /
|
|
251
|
+
// bulk / audit-tampering operations BLOCK; recoverable or single-resource
|
|
252
|
+
// operations by an AI actor HOLD for review. Precise verbs (delete-*,
|
|
253
|
+
// terminate, destroy, ... delete) — read-only describe/list/get never match,
|
|
254
|
+
// which is what keeps this deterministic pack at ~0 false positives where a
|
|
255
|
+
// naive "contains the word delete" regex is not.
|
|
256
|
+
// Cloud teardown — irreversible / high-blast-radius / audit tampering → BLOCK
|
|
257
|
+
{
|
|
258
|
+
id: "block_cloud_teardown",
|
|
259
|
+
name: "Block irreversible cloud infrastructure teardown",
|
|
260
|
+
description: "Block deletion of cloud accounts, projects, clusters, databases, buckets, key vaults, stacks, KMS keys, and audit trails",
|
|
261
|
+
action: "block",
|
|
262
|
+
reason: "Local Gate: irreversible cloud teardown (account/project/cluster/database/bucket/key/audit-trail deletion). Blocked before execution.",
|
|
263
|
+
conditions: {
|
|
264
|
+
commands: [
|
|
265
|
+
// AWS
|
|
266
|
+
"aws s3 rb",
|
|
267
|
+
"delete-objects",
|
|
268
|
+
"delete-db-instance",
|
|
269
|
+
"delete-db-cluster",
|
|
270
|
+
"delete-table",
|
|
271
|
+
"delete-stack",
|
|
272
|
+
"delete-file-system",
|
|
273
|
+
"schedule-key-deletion",
|
|
274
|
+
"delete-secret",
|
|
275
|
+
"delete-hosted-zone",
|
|
276
|
+
"eks delete-cluster",
|
|
277
|
+
"ecs delete-service",
|
|
278
|
+
"remove-account-from-organization",
|
|
279
|
+
"delete-trail",
|
|
280
|
+
"delete-recovery-point",
|
|
281
|
+
"delete-vpc",
|
|
282
|
+
"route53 delete",
|
|
283
|
+
"gcloud compute instances delete",
|
|
284
|
+
"gsutil rm -r",
|
|
285
|
+
"gsutil -m rm",
|
|
286
|
+
"sql instances delete",
|
|
287
|
+
"projects delete",
|
|
288
|
+
"bq rm",
|
|
289
|
+
"container clusters delete",
|
|
290
|
+
"keys versions destroy",
|
|
291
|
+
"redis instances delete",
|
|
292
|
+
// Azure
|
|
293
|
+
"group delete",
|
|
294
|
+
"vm delete",
|
|
295
|
+
"sql db delete",
|
|
296
|
+
"aks delete",
|
|
297
|
+
"keyvault delete",
|
|
298
|
+
"cosmosdb delete",
|
|
299
|
+
"vnet delete",
|
|
300
|
+
"acr delete",
|
|
301
|
+
"storage blob delete-batch"
|
|
302
|
+
// NOTE: diagnostic-settings delete and delete-configuration-recorder
|
|
303
|
+
// were here but moved to hold_cloud_resource_mutation — they are
|
|
304
|
+
// recoverable audit-config changes, not irreversible teardowns.
|
|
305
|
+
]
|
|
306
|
+
}
|
|
307
|
+
},
|
|
308
|
+
// Cloud single-resource / recoverable mutations by an AI actor → HOLD
|
|
309
|
+
{
|
|
310
|
+
id: "hold_cloud_resource_mutation",
|
|
311
|
+
name: "Hold single-resource cloud deletion for HITL review",
|
|
312
|
+
description: "Terminating instances or deleting individual volumes, snapshots, functions, roles, load balancers, params \u2014 recoverable-ish, human-confirmed when an AI issues it. Also covers audit-config changes (diagnostic-settings, Config recorder) that blind monitoring without full teardown.",
|
|
313
|
+
action: "warn",
|
|
314
|
+
reason: "Local Gate: cloud resource deletion/termination by an AI agent. Waiting for operator approval \u2014 review the specific target before approving.",
|
|
315
|
+
conditions: {
|
|
316
|
+
actorType: "ai",
|
|
317
|
+
commands: [
|
|
318
|
+
"terminate-instances",
|
|
319
|
+
"delete-volume",
|
|
320
|
+
"delete-snapshot",
|
|
321
|
+
"delete-user",
|
|
322
|
+
"delete-role",
|
|
323
|
+
"delete-function",
|
|
324
|
+
"delete-instance",
|
|
325
|
+
"batch-delete-image",
|
|
326
|
+
"delete-load-balancer",
|
|
327
|
+
"delete-parameter",
|
|
328
|
+
"service-accounts delete",
|
|
329
|
+
"disks delete",
|
|
330
|
+
"topics delete",
|
|
331
|
+
"record-sets delete",
|
|
332
|
+
"functions delete",
|
|
333
|
+
"revoke-security-group-ingress",
|
|
334
|
+
// Audit-config blind-spot mutations (recoverable but security-sensitive)
|
|
335
|
+
"diagnostic-settings delete",
|
|
336
|
+
"delete-configuration-recorder"
|
|
337
|
+
]
|
|
338
|
+
}
|
|
339
|
+
},
|
|
340
|
+
// Git / CI history rewrite & remote destruction by an AI actor → HOLD
|
|
341
|
+
{
|
|
342
|
+
id: "hold_git_destructive",
|
|
343
|
+
name: "Hold destructive git/CI operations for HITL review",
|
|
344
|
+
description: "Force-push, hard reset, remote branch/tag deletion, index purge, CI secret writes \u2014 recoverable with effort, human-confirmed when an AI issues it",
|
|
345
|
+
action: "warn",
|
|
346
|
+
reason: "Local Gate: destructive git/CI operation by an AI agent (history rewrite / remote deletion). Waiting for operator approval.",
|
|
347
|
+
conditions: {
|
|
348
|
+
actorType: "ai",
|
|
349
|
+
commands: [
|
|
350
|
+
"push --force",
|
|
351
|
+
"push -f",
|
|
352
|
+
"force-with-lease",
|
|
353
|
+
"push origin --delete",
|
|
354
|
+
"push --delete",
|
|
355
|
+
"reset --hard",
|
|
356
|
+
"clean -fd",
|
|
357
|
+
"clean -fdx",
|
|
358
|
+
"tag -d",
|
|
359
|
+
"update-ref -d",
|
|
360
|
+
"reflog expire",
|
|
361
|
+
"git rm -rf",
|
|
362
|
+
"rm -r --cached",
|
|
363
|
+
"rm -rf --cached",
|
|
364
|
+
"release delete",
|
|
365
|
+
"secret set",
|
|
366
|
+
"branch protection",
|
|
367
|
+
// Force-push via + refspec (git push origin +HEAD:main)
|
|
368
|
+
"push origin +",
|
|
369
|
+
"push upstream +",
|
|
370
|
+
// Force-delete all branches
|
|
371
|
+
"branch -d ",
|
|
372
|
+
"branch -D ",
|
|
373
|
+
// Remove all tracked files from git index
|
|
374
|
+
"git rm --cached",
|
|
375
|
+
// GitHub CLI: delete environments and CI secrets
|
|
376
|
+
"gh api -x delete",
|
|
377
|
+
"environments/production",
|
|
378
|
+
"actions/secrets",
|
|
379
|
+
// Rewrite git remote URLs to embed tokens (MITM / exfil)
|
|
380
|
+
"insteadof https://",
|
|
381
|
+
"insteadof http://",
|
|
382
|
+
// Trigger production deploys
|
|
383
|
+
"gh workflow run",
|
|
384
|
+
// Disable GitHub branch protection
|
|
385
|
+
"branches/main/protection",
|
|
386
|
+
"branches/master/protection"
|
|
387
|
+
]
|
|
388
|
+
}
|
|
389
|
+
},
|
|
390
|
+
// Git irreversible history destruction → BLOCK
|
|
391
|
+
{
|
|
392
|
+
id: "block_git_irreversible",
|
|
393
|
+
name: "Block irreversible git/repo destruction",
|
|
394
|
+
description: "Whole-history rewrite or repository deletion",
|
|
395
|
+
action: "block",
|
|
396
|
+
reason: "Local Gate: irreversible git/repo destruction (history rewrite or repository deletion). Blocked before execution.",
|
|
397
|
+
conditions: {
|
|
398
|
+
commands: ["filter-branch", "filter-repo", "repo delete"]
|
|
399
|
+
}
|
|
400
|
+
},
|
|
401
|
+
// High-value secret / credential resource access by an AI actor → HOLD
|
|
402
|
+
{
|
|
403
|
+
id: "hold_secret_resource_access",
|
|
404
|
+
name: "Hold high-value secret/credential access for HITL review",
|
|
405
|
+
description: "Reads of credential files, private keys, kube/docker config, cloud secret values, instance metadata, or fresh long-lived credential minting",
|
|
406
|
+
action: "warn",
|
|
407
|
+
reason: "Local Gate: access to a high-value secret/credential resource by an AI agent. Waiting for operator approval \u2014 a secret read is sensitive even when legitimate.",
|
|
408
|
+
conditions: {
|
|
409
|
+
actorType: "ai",
|
|
410
|
+
commands: [
|
|
411
|
+
"aws/credentials",
|
|
412
|
+
"ssh/id_rsa",
|
|
413
|
+
"ssh/id_ed25519",
|
|
414
|
+
"id_rsa",
|
|
415
|
+
"kube/config",
|
|
416
|
+
"docker/config.json",
|
|
417
|
+
"etc/shadow",
|
|
418
|
+
"gnupg",
|
|
419
|
+
"export-secret-keys",
|
|
420
|
+
"find-generic-password",
|
|
421
|
+
" .env",
|
|
422
|
+
"get-secret-value",
|
|
423
|
+
"secrets versions access",
|
|
424
|
+
"keyvault secret show",
|
|
425
|
+
"get-session-token",
|
|
426
|
+
"create-access-key",
|
|
427
|
+
"kms decrypt",
|
|
428
|
+
// Instance metadata endpoint (SSRF / IAM cred exfil).
|
|
429
|
+
// `169.254.169.254` is embedded in a URL (e.g. http://169.254.169.254/latest/...)
|
|
430
|
+
// so the char after the IP is `/` — the separator class now includes `/`
|
|
431
|
+
// (added in matchesCommandPattern) to allow this to match correctly.
|
|
432
|
+
"169.254.169.254",
|
|
433
|
+
// Env var secret dumps — pipe (`|`) is stripped from the haystack by
|
|
434
|
+
// buildCommandHaystack so `env | grep` becomes `env grep` after AST walk.
|
|
435
|
+
"env grep",
|
|
436
|
+
"printenv aws_",
|
|
437
|
+
"printenv github_",
|
|
438
|
+
"printenv token",
|
|
439
|
+
// Shell history secret greps (same pipe-stripping applies)
|
|
440
|
+
"history grep",
|
|
441
|
+
// kubectl secret extraction + base64 decode
|
|
442
|
+
"kubectl get secret",
|
|
443
|
+
// git config token listing (pipe-stripped: `git config --global --list | grep` → `git config --global --list grep`)
|
|
444
|
+
"git config --global",
|
|
445
|
+
// Credential exfiltration pipeline: read + POST to external host.
|
|
446
|
+
// Patterns use AST-flattened form: `|` is removed by the haystack
|
|
447
|
+
// builder so "cred | curl" becomes "cred curl" in the match string.
|
|
448
|
+
"credentials curl",
|
|
449
|
+
"credentials s3://"
|
|
450
|
+
]
|
|
451
|
+
}
|
|
452
|
+
},
|
|
453
|
+
// ── K8s: block all-type and cluster-node deletions ───────────────────────
|
|
454
|
+
{
|
|
455
|
+
id: "block_k8s_cluster_wipe",
|
|
456
|
+
name: "Block kubectl all-type and cluster-node deletions",
|
|
457
|
+
description: "Deleting all deployments in a namespace, deleting a cluster node, or deleting a system DaemonSet are cluster-scope irreversible operations",
|
|
458
|
+
action: "block",
|
|
459
|
+
reason: "Local Gate: cluster-scope or node-level kubectl deletion. Blocked before execution.",
|
|
460
|
+
conditions: {
|
|
461
|
+
commands: [
|
|
462
|
+
"delete deployment --all",
|
|
463
|
+
// Omit `kubectl` prefix: flags like `-n kube-system` may appear between
|
|
464
|
+
// `kubectl` and `delete`, so `kubectl delete node` won't match as a
|
|
465
|
+
// substring. Match on the verb+object instead — specific enough to not
|
|
466
|
+
// catch ordinary per-name deletions.
|
|
467
|
+
"delete node ",
|
|
468
|
+
"delete daemonset "
|
|
469
|
+
]
|
|
470
|
+
}
|
|
471
|
+
},
|
|
472
|
+
// ── SQL: block unqualified UPDATE and tautology-WHERE patterns ───────────
|
|
473
|
+
{
|
|
474
|
+
id: "block_sql_full_table_mutations",
|
|
475
|
+
name: "Block SQL full-table mutations without WHERE",
|
|
476
|
+
description: "TRUNCATE without TABLE keyword (PostgreSQL-style), tautology WHERE (WHERE 1=1), MongoDB collection drop, and COPY TO PROGRAM SQL exfil. Unqualified UPDATE (no WHERE) is handled by block_sql_unqualified_update below.",
|
|
477
|
+
action: "block",
|
|
478
|
+
reason: "Local Gate: SQL full-table mutation (TRUNCATE, tautology WHERE, MongoDB drop, or COPY TO PROGRAM exfil). Blocked before execution.",
|
|
479
|
+
conditions: {
|
|
480
|
+
commands: [
|
|
481
|
+
"where 1=1",
|
|
482
|
+
"where 1 = 1",
|
|
483
|
+
// tautology DELETE/UPDATE = full wipe
|
|
484
|
+
".drop()",
|
|
485
|
+
// MongoDB db.collection.drop()
|
|
486
|
+
// PostgreSQL COPY TO PROGRAM = command injection / exfil.
|
|
487
|
+
// Pattern "to program" instead of "copy to program" because the
|
|
488
|
+
// COPY command may have a subquery between COPY and TO PROGRAM:
|
|
489
|
+
// COPY (SELECT * FROM users) TO PROGRAM 'curl ...'
|
|
490
|
+
"to program",
|
|
491
|
+
"restart identity cascade"
|
|
492
|
+
// TRUNCATE ... RESTART IDENTITY CASCADE
|
|
493
|
+
]
|
|
494
|
+
}
|
|
495
|
+
},
|
|
496
|
+
// ── SQL: UPDATE without WHERE scoped to SQL services only ─────────────────
|
|
497
|
+
{
|
|
498
|
+
id: "block_sql_unqualified_update",
|
|
499
|
+
name: "Block SQL UPDATE without a WHERE clause (SQL services only)",
|
|
500
|
+
description: "UPDATE ... SET ... without a WHERE clause is a full-table mutation. Scoped to SQL/database services to avoid false-positives on `git update-ref`, `apt-get update`, `SELECT ... FOR UPDATE`, etc.",
|
|
501
|
+
action: "block",
|
|
502
|
+
reason: "Local Gate: SQL full-table UPDATE (no WHERE clause) detected. Blocked before execution.",
|
|
503
|
+
conditions: {
|
|
504
|
+
commands: ["update:no-where"],
|
|
505
|
+
services: ["db", "mysql", "postgres", "sql", "database"]
|
|
506
|
+
}
|
|
507
|
+
},
|
|
508
|
+
// ── SQL: GRANT/ALTER USER privilege escalation — HOLD ───────────────────
|
|
509
|
+
{
|
|
510
|
+
id: "hold_sql_privilege_escalation",
|
|
511
|
+
name: "Hold SQL privilege escalation for HITL review",
|
|
512
|
+
description: "GRANT ALL PRIVILEGES, ALTER USER ... SUPERUSER \u2014 recoverable but requires human review when issued by an AI agent",
|
|
513
|
+
action: "warn",
|
|
514
|
+
reason: "Local Gate: SQL privilege escalation by an AI agent. Waiting for operator approval.",
|
|
515
|
+
conditions: {
|
|
516
|
+
actorType: "ai",
|
|
517
|
+
commands: [
|
|
518
|
+
"grant all privileges",
|
|
519
|
+
"alter user",
|
|
520
|
+
"superuser"
|
|
521
|
+
]
|
|
522
|
+
}
|
|
523
|
+
},
|
|
524
|
+
// ── Credential exfiltration: active exfil → BLOCK ────────────────────────
|
|
525
|
+
{
|
|
526
|
+
id: "block_cred_exfiltration",
|
|
527
|
+
name: "Block active credential exfiltration",
|
|
528
|
+
description: "Detect piping credential files to curl/external hosts or copying them to S3 buckets \u2014 active exfiltration, not just reading",
|
|
529
|
+
action: "block",
|
|
530
|
+
reason: "Local Gate: credential exfiltration attempt detected (credential file piped to curl/POST or copied to S3). Blocked before execution.",
|
|
531
|
+
conditions: {
|
|
532
|
+
commands: [
|
|
533
|
+
// AST-flattened pipe (`|` removed): "cat creds | curl" → "creds curl"
|
|
534
|
+
"credentials curl",
|
|
535
|
+
// S3 exfil — match the credential path adjacent to an s3:// destination.
|
|
536
|
+
// `aws s3 cp ~/.aws/credentials s3://...` normalises to:
|
|
537
|
+
// `aws s3 cp ~/.aws/credentials s3://...`
|
|
538
|
+
// `aws/credentials s3://` spans both since the path contains `aws/credentials`.
|
|
539
|
+
"aws/credentials s3://",
|
|
540
|
+
"s3 cp credentials"
|
|
541
|
+
]
|
|
542
|
+
}
|
|
543
|
+
},
|
|
544
|
+
// ── K8s: scale/drain/taint/patch → HOLD ─────────────────────────────────
|
|
545
|
+
{
|
|
546
|
+
id: "hold_k8s_disruptive",
|
|
547
|
+
name: "Hold disruptive K8s operations (drain/scale/taint/patch) for HITL review",
|
|
548
|
+
description: "kubectl drain, scale to zero, taint NoExecute, replace --force, patch replicas \u2014 disruptive but recoverable, require human approval from an AI agent",
|
|
549
|
+
action: "warn",
|
|
550
|
+
reason: "Local Gate: disruptive K8s operation by an AI agent (drain/scale/taint/patch). Waiting for operator approval.",
|
|
551
|
+
conditions: {
|
|
552
|
+
actorType: "ai",
|
|
553
|
+
commands: [
|
|
554
|
+
"kubectl drain",
|
|
555
|
+
// `--replicas=0` starts with `-` (non-word), so `\b` fires at `r` not
|
|
556
|
+
// at `-`. Use `replicas=0` (without leading `--`) so the word-boundary
|
|
557
|
+
// regex matches correctly.
|
|
558
|
+
"replicas=0",
|
|
559
|
+
"kubectl taint nodes",
|
|
560
|
+
"kubectl replace --force",
|
|
561
|
+
"kubectl patch"
|
|
562
|
+
]
|
|
563
|
+
}
|
|
564
|
+
},
|
|
565
|
+
// ── MCP tool abuse → HOLD ────────────────────────────────────────────────
|
|
566
|
+
{
|
|
567
|
+
id: "hold_mcp_tool_abuse",
|
|
568
|
+
name: "Hold suspicious MCP tool invocations for HITL review",
|
|
569
|
+
description: "MCP write_file to /etc/sudoers, force-push via MCP git tools, flipping MERGEN_AUTOPILOT, bulk HTTP DELETE",
|
|
570
|
+
action: "warn",
|
|
571
|
+
reason: "Local Gate: suspicious MCP tool invocation by an AI agent. Waiting for operator approval.",
|
|
572
|
+
conditions: {
|
|
573
|
+
actorType: "ai",
|
|
574
|
+
commands: [
|
|
575
|
+
// `/etc/sudoers` starts with `/` (non-word), so `\b` fires at `e`
|
|
576
|
+
// not at `/`. Use `etc/sudoers` to anchor the boundary correctly.
|
|
577
|
+
"etc/sudoers",
|
|
578
|
+
"mergen_autopilot",
|
|
579
|
+
"method=delete",
|
|
580
|
+
"http_request method",
|
|
581
|
+
"set_config",
|
|
582
|
+
// MCP git_push force-push: `git_push force=true branch=main remote=...`
|
|
583
|
+
// Note: `_` is a word char so `git_push` matches with `\b` before `g`.
|
|
584
|
+
"git_push force=true",
|
|
585
|
+
"force=true branch=main"
|
|
586
|
+
]
|
|
587
|
+
}
|
|
588
|
+
},
|
|
589
|
+
// ── Script wrapping (opaque scripts) → HOLD ──────────────────────────────
|
|
590
|
+
{
|
|
591
|
+
id: "hold_script_wrapping",
|
|
592
|
+
name: "Hold opaque script execution for HITL review",
|
|
593
|
+
description: "Running bash/sh/zsh on local scripts, /tmp scripts, Downloads, Makefile targets, npm scripts, find+exec+rm, inline node child_process \u2014 contents are opaque to the gate",
|
|
594
|
+
action: "warn",
|
|
595
|
+
reason: "Local Gate: opaque script execution by an AI agent \u2014 script contents are not visible to the gate. Waiting for operator approval.",
|
|
596
|
+
conditions: {
|
|
597
|
+
actorType: "ai",
|
|
598
|
+
commands: [
|
|
599
|
+
"bash deploy",
|
|
600
|
+
"sh /tmp/",
|
|
601
|
+
"zsh /tmp/",
|
|
602
|
+
"zsh ~/downloads/",
|
|
603
|
+
// Quotes are stripped by normalizeForMatching so patterns must be quote-free.
|
|
604
|
+
// `bash -c "$(cat ./script.sh)"` normalises to `bash -c $(cat ./script.sh)`.
|
|
605
|
+
"bash -c $(cat",
|
|
606
|
+
"bash -c $(curl",
|
|
607
|
+
// Same for node -e: `node -e "require('child_process')"` → `node -e require(child_process)`
|
|
608
|
+
"node -e require(",
|
|
609
|
+
"make deploy",
|
|
610
|
+
"make release",
|
|
611
|
+
"make publish",
|
|
612
|
+
"npm run cleanup",
|
|
613
|
+
"npm run purge",
|
|
614
|
+
// `npm run deploy` removed — causes FP on `npm run deploy:production` (substring match).
|
|
615
|
+
// Script-wrapping detection for `find + exec` patterns:
|
|
616
|
+
"find . -name",
|
|
617
|
+
"find . -type",
|
|
618
|
+
"-exec rm",
|
|
619
|
+
"env destroy=",
|
|
620
|
+
"env deploy="
|
|
621
|
+
]
|
|
622
|
+
}
|
|
623
|
+
},
|
|
624
|
+
// ── RCE pipelines: install from untrusted sources → HOLD ────────────────
|
|
625
|
+
{
|
|
626
|
+
id: "hold_rce_untrusted_install",
|
|
627
|
+
name: "Hold install from untrusted package sources for HITL review",
|
|
628
|
+
description: "pip install --index-url http://, npm install http://, python3 -c urllib download-and-exec",
|
|
629
|
+
action: "warn",
|
|
630
|
+
reason: "Local Gate: installation from an untrusted/non-standard package source by an AI agent. Waiting for operator approval.",
|
|
631
|
+
conditions: {
|
|
632
|
+
actorType: "ai",
|
|
633
|
+
commands: [
|
|
634
|
+
"pip install --index-url http",
|
|
635
|
+
"pip install --index-url http",
|
|
636
|
+
"npm install http://",
|
|
637
|
+
"python3 -c import urllib",
|
|
638
|
+
"python -c import urllib",
|
|
639
|
+
"os.system(urllib",
|
|
640
|
+
"os.system(http",
|
|
641
|
+
"chmod +x /tmp/",
|
|
642
|
+
"install http://",
|
|
643
|
+
"install https://evil"
|
|
644
|
+
]
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
// NOTE: hold_sql_targeted_mutation was intentionally removed.
|
|
648
|
+
// `UPDATE ... WHERE id = 42` is a normal, targeted operation that must PASS.
|
|
649
|
+
// Full-table UPDATE (no WHERE) is caught by block_sql_unqualified_update above.
|
|
650
|
+
]
|
|
651
|
+
};
|
|
652
|
+
let _cachedConfig = null;
|
|
653
|
+
function _recordPolicyIntegrityFailure(reason) {
|
|
654
|
+
void getStores().blunders.record({
|
|
655
|
+
blunderType: "policy_integrity_failure",
|
|
656
|
+
command: null,
|
|
657
|
+
blockReason: reason,
|
|
658
|
+
service: null,
|
|
659
|
+
tag: null,
|
|
660
|
+
actor: "system",
|
|
661
|
+
pid: null,
|
|
662
|
+
confidenceScore: null
|
|
663
|
+
}).catch(() => {
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
function loadEnterprisePolicy(force = false) {
|
|
667
|
+
if (_cachedConfig && !force) return _cachedConfig;
|
|
668
|
+
if (!fs.existsSync(ENTERPRISE_POLICY_FILE)) {
|
|
669
|
+
if (!zeroRetentionMode()) {
|
|
670
|
+
try {
|
|
671
|
+
saveEnterprisePolicy(DEFAULT_ENTERPRISE_POLICY);
|
|
672
|
+
logger.info({ path: ENTERPRISE_POLICY_FILE }, "policy-engine: created default enterprise-policy.json");
|
|
673
|
+
} catch (err) {
|
|
674
|
+
logger.warn({ err }, "policy-engine: failed to write default enterprise policy");
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
_cachedConfig = DEFAULT_ENTERPRISE_POLICY;
|
|
678
|
+
_watchPolicyFile();
|
|
679
|
+
return _cachedConfig;
|
|
680
|
+
}
|
|
681
|
+
try {
|
|
682
|
+
const raw = fs.readFileSync(ENTERPRISE_POLICY_FILE, "utf8");
|
|
683
|
+
if (_policySigningSecret) {
|
|
684
|
+
const storedSig = fs.existsSync(ENTERPRISE_POLICY_SIG_FILE) ? fs.readFileSync(ENTERPRISE_POLICY_SIG_FILE, "utf8").trim() : null;
|
|
685
|
+
const sigStatus = _policySignatureStatus(raw, storedSig);
|
|
686
|
+
if (sigStatus === "invalid") {
|
|
687
|
+
logger.error(
|
|
688
|
+
{ path: ENTERPRISE_POLICY_FILE },
|
|
689
|
+
"policy-engine: enterprise-policy.json HMAC mismatch \u2014 file may have been tampered with. Using defaults."
|
|
690
|
+
);
|
|
691
|
+
_recordPolicyIntegrityFailure("enterprise-policy.json HMAC mismatch \u2014 file may have been tampered with. Fell back to default policy.");
|
|
692
|
+
_cachedConfig = DEFAULT_ENTERPRISE_POLICY;
|
|
693
|
+
_watchPolicyFile();
|
|
694
|
+
return _cachedConfig;
|
|
695
|
+
} else if (sigStatus === "unsigned-rejected") {
|
|
696
|
+
logger.error(
|
|
697
|
+
{ path: ENTERPRISE_POLICY_FILE, sigPath: ENTERPRISE_POLICY_SIG_FILE },
|
|
698
|
+
"policy-engine: enterprise-policy.json is unsigned while policy signing is enabled. Using defaults."
|
|
699
|
+
);
|
|
700
|
+
_recordPolicyIntegrityFailure("enterprise-policy.json is unsigned while policy signing is enabled. Fell back to default policy.");
|
|
701
|
+
_cachedConfig = DEFAULT_ENTERPRISE_POLICY;
|
|
702
|
+
_watchPolicyFile();
|
|
703
|
+
return _cachedConfig;
|
|
704
|
+
} else if (sigStatus === "unsigned-accepted") {
|
|
705
|
+
logger.warn(
|
|
706
|
+
{ path: ENTERPRISE_POLICY_FILE },
|
|
707
|
+
"policy-engine: enterprise-policy.json has no signature \u2014 accepting because MERGEN_ALLOW_UNSIGNED_POLICY=true"
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
const json = JSON.parse(raw);
|
|
712
|
+
const result = EnterprisePolicyConfigSchema.safeParse(json);
|
|
713
|
+
if (result.success) {
|
|
714
|
+
_cachedConfig = result.data;
|
|
715
|
+
} else {
|
|
716
|
+
logger.warn({ issues: result.error.issues }, "policy-engine: enterprise-policy.json failed schema validation \u2014 using defaults");
|
|
717
|
+
_recordPolicyIntegrityFailure("enterprise-policy.json failed schema validation. Fell back to default policy.");
|
|
718
|
+
_cachedConfig = DEFAULT_ENTERPRISE_POLICY;
|
|
719
|
+
}
|
|
720
|
+
} catch (err) {
|
|
721
|
+
logger.warn({ err }, "policy-engine: failed to load enterprise policy \u2014 using defaults");
|
|
722
|
+
_recordPolicyIntegrityFailure(`Failed to read/parse enterprise-policy.json (${err instanceof Error ? err.message : "unknown error"}). Fell back to default policy.`);
|
|
723
|
+
_cachedConfig = DEFAULT_ENTERPRISE_POLICY;
|
|
724
|
+
}
|
|
725
|
+
_watchPolicyFile();
|
|
726
|
+
return _cachedConfig;
|
|
727
|
+
}
|
|
728
|
+
function saveEnterprisePolicy(config, actor = "unknown") {
|
|
729
|
+
const result = EnterprisePolicyConfigSchema.safeParse(config);
|
|
730
|
+
if (!result.success) throw new Error(`Invalid policy: ${JSON.stringify(result.error.issues)}`);
|
|
731
|
+
const content = JSON.stringify(result.data, null, 2);
|
|
732
|
+
const tmp = ENTERPRISE_POLICY_FILE + ".tmp";
|
|
733
|
+
try {
|
|
734
|
+
fs.mkdirSync(path.dirname(ENTERPRISE_POLICY_FILE), { recursive: true });
|
|
735
|
+
fs.writeFileSync(tmp, content, "utf8");
|
|
736
|
+
fs.renameSync(tmp, ENTERPRISE_POLICY_FILE);
|
|
737
|
+
if (_policySigningSecret) {
|
|
738
|
+
const sig = _signPolicyPayload(content);
|
|
739
|
+
const sigTmp = ENTERPRISE_POLICY_SIG_FILE + ".tmp";
|
|
740
|
+
fs.writeFileSync(sigTmp, sig, { encoding: "utf8", mode: 384 });
|
|
741
|
+
fs.renameSync(sigTmp, ENTERPRISE_POLICY_SIG_FILE);
|
|
742
|
+
}
|
|
743
|
+
const previous = _cachedConfig;
|
|
744
|
+
_cachedConfig = result.data;
|
|
745
|
+
logger.info({ path: ENTERPRISE_POLICY_FILE }, "policy-engine: enterprise policy saved");
|
|
746
|
+
try {
|
|
747
|
+
void import("../sensor/policy-history.js").then(({ recordPolicyChange }) => {
|
|
748
|
+
recordPolicyChange(previous, result.data, actor);
|
|
749
|
+
});
|
|
750
|
+
} catch {
|
|
751
|
+
}
|
|
752
|
+
} catch (err) {
|
|
753
|
+
try {
|
|
754
|
+
fs.unlinkSync(tmp);
|
|
755
|
+
} catch {
|
|
756
|
+
}
|
|
757
|
+
throw err;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
function getGateCoverageSummary() {
|
|
761
|
+
const policy = loadEnterprisePolicy();
|
|
762
|
+
const hardBlocks = [];
|
|
763
|
+
const humanReviewRequired = [];
|
|
764
|
+
let totalPatterns = 0;
|
|
765
|
+
for (const rule of policy.rules) {
|
|
766
|
+
const patterns = rule.conditions.commands ?? [];
|
|
767
|
+
totalPatterns += patterns.length;
|
|
768
|
+
if (rule.action === "block") {
|
|
769
|
+
hardBlocks.push(rule.name);
|
|
770
|
+
} else if (rule.action === "warn") {
|
|
771
|
+
humanReviewRequired.push(rule.name);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
return { hardBlocks, humanReviewRequired, totalPatterns };
|
|
775
|
+
}
|
|
776
|
+
function _resetPolicyCacheForTesting(overrideConfig) {
|
|
777
|
+
_cachedConfig = overrideConfig ?? DEFAULT_ENTERPRISE_POLICY;
|
|
778
|
+
}
|
|
779
|
+
let _watcherStarted = false;
|
|
780
|
+
function _watchPolicyFile() {
|
|
781
|
+
if (_watcherStarted || zeroRetentionMode()) return;
|
|
782
|
+
_watcherStarted = true;
|
|
783
|
+
try {
|
|
784
|
+
fs.watchFile(ENTERPRISE_POLICY_FILE, { interval: 5e3, persistent: false }, () => {
|
|
785
|
+
_cachedConfig = null;
|
|
786
|
+
logger.info("policy-engine: enterprise-policy.json changed \u2014 reloading on next evaluation");
|
|
787
|
+
});
|
|
788
|
+
} catch {
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
const AI_ACTOR_PATTERNS = [/\bbot\b/, /\bclaude\b/, /\bcursor\b/, /\bagent\b/, /\bai\b/, /\bgithub-actions\b/, /\bwindsurf\b/, /\bcopilot\b/];
|
|
792
|
+
const _trustedHumans = new Set(
|
|
793
|
+
(process.env.MERGEN_TRUSTED_HUMANS ?? "").split(",").map((s) => s.trim().toLowerCase()).filter(Boolean)
|
|
794
|
+
);
|
|
795
|
+
function isAiActor(actorName) {
|
|
796
|
+
if (!actorName) return true;
|
|
797
|
+
const name = actorName.toLowerCase();
|
|
798
|
+
if (_trustedHumans.has(name)) return false;
|
|
799
|
+
if (name === "human" || name.startsWith("human_") || name.startsWith("human-")) return false;
|
|
800
|
+
if (AI_ACTOR_PATTERNS.some((re) => re.test(name))) return true;
|
|
801
|
+
return true;
|
|
802
|
+
}
|
|
803
|
+
const _actorRoles = new Map(
|
|
804
|
+
(process.env.MERGEN_ACTOR_ROLES ?? "").split(",").map((s) => s.trim()).filter(Boolean).map((s) => {
|
|
805
|
+
const idx = s.lastIndexOf(":");
|
|
806
|
+
if (idx === -1) return null;
|
|
807
|
+
return [s.slice(0, idx).trim().toLowerCase(), s.slice(idx + 1).trim().toLowerCase()];
|
|
808
|
+
}).filter((e) => e !== null)
|
|
809
|
+
);
|
|
810
|
+
function _lookupActorRole(actor) {
|
|
811
|
+
if (!actor) return null;
|
|
812
|
+
return _actorRoles.get(actor.toLowerCase()) ?? null;
|
|
813
|
+
}
|
|
814
|
+
function _matchBranchPattern(branch, pattern) {
|
|
815
|
+
if (pattern === "*") return true;
|
|
816
|
+
if (!pattern.includes("*")) return branch.toLowerCase() === pattern.toLowerCase();
|
|
817
|
+
const reStr = pattern.toLowerCase().replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*");
|
|
818
|
+
return new RegExp(`^${reStr}$`).test(branch.toLowerCase());
|
|
819
|
+
}
|
|
820
|
+
const SEMANTIC_EQUIVALENTS = [
|
|
821
|
+
[/\bblow\s+away\b/, "terraform destroy"],
|
|
822
|
+
[/\btear\s+down\b/, "terraform destroy"],
|
|
823
|
+
[/\brip\s+out\b/, "destroy"],
|
|
824
|
+
[/\bobliterate\b/, "destroy"],
|
|
825
|
+
[/\bwipe\s+clean\b/, "truncate table"],
|
|
826
|
+
[/\bzero\s+out\b/, "truncate table"],
|
|
827
|
+
[/\berase\s+all\b/, "delete from"],
|
|
828
|
+
[/\bremove\s+all\b/, "delete from"],
|
|
829
|
+
[/\bclear\s+all\b/, "delete from"],
|
|
830
|
+
[/\bpurge\s+all\b/, "truncate table"],
|
|
831
|
+
[/\bwipe\s+the\s+database\b/, "drop database"],
|
|
832
|
+
[/\bwipe\s+the\s+cluster\b/, "kubectl delete"],
|
|
833
|
+
[/\bnuke\s+the\b/, "terraform destroy"]
|
|
834
|
+
];
|
|
835
|
+
function _expandSemanticEquivalents(normalized) {
|
|
836
|
+
let expanded = normalized;
|
|
837
|
+
for (const [pattern, canonical] of SEMANTIC_EQUIVALENTS) {
|
|
838
|
+
if (pattern.test(normalized)) {
|
|
839
|
+
expanded += " " + canonical;
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
return expanded;
|
|
843
|
+
}
|
|
844
|
+
function matchesCommandPattern(haystack, pattern) {
|
|
845
|
+
const normalized = _expandSemanticEquivalents(normalizeForMatching(haystack));
|
|
846
|
+
if (pattern.endsWith(":no-where")) {
|
|
847
|
+
const basePattern = pattern.slice(0, -":no-where".length).toLowerCase();
|
|
848
|
+
if (!normalized.includes(basePattern)) return false;
|
|
849
|
+
const structural = sqlHasNoWhereClause(haystack);
|
|
850
|
+
if (structural !== null) return structural;
|
|
851
|
+
if (/\bfor\s+update\b/i.test(haystack)) return false;
|
|
852
|
+
return !/\bwhere\b/.test(normalized);
|
|
853
|
+
}
|
|
854
|
+
if (pattern.endsWith(":no-git")) {
|
|
855
|
+
const basePattern = pattern.slice(0, -":no-git".length).toLowerCase();
|
|
856
|
+
if (!normalized.includes(basePattern)) return false;
|
|
857
|
+
const escaped2 = basePattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
858
|
+
return new RegExp(`(?<!git\\s)${escaped2}`).test(normalized);
|
|
859
|
+
}
|
|
860
|
+
const lower = pattern.toLowerCase();
|
|
861
|
+
if (lower.includes(" ")) {
|
|
862
|
+
return normalized.includes(lower);
|
|
863
|
+
}
|
|
864
|
+
const _VERB_FP_SET = /* @__PURE__ */ new Set(["destroy", "nuke", "wipe"]);
|
|
865
|
+
const escaped = lower.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
866
|
+
const isVerbFp = _VERB_FP_SET.has(lower);
|
|
867
|
+
const lookbehind = isVerbFp ? "(?<!-)" : "";
|
|
868
|
+
const sepChars = isVerbFp ? `\\s,;|&<>\`'"` : `\\s,;|&()<>/\`'"`;
|
|
869
|
+
return new RegExp(`${lookbehind}\\b${escaped}(?=[${sepChars}]|$)`).test(normalized);
|
|
870
|
+
}
|
|
871
|
+
let _lastEvalLatencyMs = 0.84;
|
|
872
|
+
function getLastEvalLatencyMs() {
|
|
873
|
+
return _lastEvalLatencyMs;
|
|
874
|
+
}
|
|
875
|
+
function matchesConditionSet(cond, ctx) {
|
|
876
|
+
let fileMatched = true;
|
|
877
|
+
let commandMatched = true;
|
|
878
|
+
let actorMatched = true;
|
|
879
|
+
let dayMatched = true;
|
|
880
|
+
let hourMatched = true;
|
|
881
|
+
let serviceMatched = true;
|
|
882
|
+
let envMatched = true;
|
|
883
|
+
let repoMatched = true;
|
|
884
|
+
let agentMatched = true;
|
|
885
|
+
let roleMatched = true;
|
|
886
|
+
let branchMatched = true;
|
|
887
|
+
let corpusMatched = true;
|
|
888
|
+
let resourceMatched = true;
|
|
889
|
+
let resourceExcluded = false;
|
|
890
|
+
if (cond.files && cond.files.length > 0) {
|
|
891
|
+
fileMatched = ctx.files.some(
|
|
892
|
+
(file) => cond.files.some((pattern) => file.toLowerCase().includes(pattern.toLowerCase()))
|
|
893
|
+
);
|
|
894
|
+
}
|
|
895
|
+
if (cond.commands && cond.commands.length > 0) {
|
|
896
|
+
commandMatched = cond.commands.some((pattern) => matchesCommandPattern(ctx.commandHaystack, pattern));
|
|
897
|
+
}
|
|
898
|
+
if (cond.actorType && cond.actorType !== "all") {
|
|
899
|
+
if (cond.actorType === "ai") {
|
|
900
|
+
actorMatched = ctx.isAi;
|
|
901
|
+
} else if (cond.actorType === "human") {
|
|
902
|
+
actorMatched = !ctx.isAi;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
if (cond.daysOfWeek && cond.daysOfWeek.length > 0) {
|
|
906
|
+
dayMatched = cond.daysOfWeek.includes(ctx.dayOfWeek);
|
|
907
|
+
}
|
|
908
|
+
if (cond.hourWindow) {
|
|
909
|
+
const [start, end] = cond.hourWindow;
|
|
910
|
+
if (start <= end) {
|
|
911
|
+
hourMatched = ctx.hourOfDay >= start && ctx.hourOfDay < end;
|
|
912
|
+
} else {
|
|
913
|
+
hourMatched = ctx.hourOfDay >= start || ctx.hourOfDay < end;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
if (cond.services && cond.services.length > 0) {
|
|
917
|
+
serviceMatched = cond.services.some((s) => s.toLowerCase() === ctx.service.toLowerCase());
|
|
918
|
+
}
|
|
919
|
+
if (cond.environments && cond.environments.length > 0) {
|
|
920
|
+
envMatched = ctx.environment ? cond.environments.some((e) => e.toLowerCase() === ctx.environment.toLowerCase()) : false;
|
|
921
|
+
}
|
|
922
|
+
if (cond.repos && cond.repos.length > 0) {
|
|
923
|
+
repoMatched = ctx.repo ? cond.repos.some((r) => ctx.repo.toLowerCase().endsWith(r.toLowerCase())) : false;
|
|
924
|
+
}
|
|
925
|
+
if (cond.agentIds && cond.agentIds.length > 0) {
|
|
926
|
+
agentMatched = ctx.agentId ? cond.agentIds.some((a) => a.toLowerCase() === ctx.agentId.toLowerCase()) : false;
|
|
927
|
+
}
|
|
928
|
+
if (cond.roles && cond.roles.length > 0) {
|
|
929
|
+
roleMatched = ctx.resolvedRole ? cond.roles.some((r) => r.toLowerCase() === ctx.resolvedRole.toLowerCase()) : false;
|
|
930
|
+
}
|
|
931
|
+
if (cond.branches && cond.branches.length > 0) {
|
|
932
|
+
branchMatched = ctx.resolvedBranch ? cond.branches.some((pat) => _matchBranchPattern(ctx.resolvedBranch, pat)) : false;
|
|
933
|
+
}
|
|
934
|
+
if (cond.resourceGlobs && cond.resourceGlobs.length > 0) {
|
|
935
|
+
resourceMatched = ctx.resources.some((r) => cond.resourceGlobs.some((pattern) => globToRegex(pattern).test(r)));
|
|
936
|
+
}
|
|
937
|
+
if (cond.excludeResourceGlobs && cond.excludeResourceGlobs.length > 0) {
|
|
938
|
+
resourceExcluded = ctx.resources.some((r) => cond.excludeResourceGlobs.some((pattern) => globToRegex(pattern).test(r)));
|
|
939
|
+
}
|
|
940
|
+
if (cond.requireCorpusMatch) {
|
|
941
|
+
const { incidentTag, minOccurrences = 1 } = cond.requireCorpusMatch;
|
|
942
|
+
const matchingRules = getRulesForTag(incidentTag, ctx.service);
|
|
943
|
+
corpusMatched = matchingRules.some((r) => r.occurrences >= minOccurrences);
|
|
944
|
+
}
|
|
945
|
+
const baseMatch = fileMatched && commandMatched && actorMatched && dayMatched && hourMatched && serviceMatched && envMatched && repoMatched && agentMatched && roleMatched && branchMatched && corpusMatched && resourceMatched && !resourceExcluded;
|
|
946
|
+
const anyOfMatch = !cond.anyOf || cond.anyOf.length === 0 || cond.anyOf.some((c) => matchesConditionSet(c, ctx));
|
|
947
|
+
const allOfMatch = !cond.allOf || cond.allOf.every((c) => matchesConditionSet(c, ctx));
|
|
948
|
+
const notMatch = !cond.not || !matchesConditionSet(cond.not, ctx);
|
|
949
|
+
return baseMatch && anyOfMatch && allOfMatch && notMatch;
|
|
950
|
+
}
|
|
951
|
+
const _OPAQUE_COMMAND_WORD = /^\$\{?[A-Za-z_][A-Za-z0-9_]*\}?$/;
|
|
952
|
+
function detectOpaqueIndirection(parsed) {
|
|
953
|
+
for (const cmd of parsed) {
|
|
954
|
+
const word = cmd.name.replace(/^["']|["']$/g, "");
|
|
955
|
+
if (_OPAQUE_COMMAND_WORD.test(word)) return cmd.name;
|
|
956
|
+
}
|
|
957
|
+
return null;
|
|
958
|
+
}
|
|
959
|
+
const _RCE_FETCH = "curl|wget|fetch|iwr|invoke-webrequest|invoke-restmethod";
|
|
960
|
+
const _RCE_INTERP = "bash|sh|zsh|ksh|dash|python3?|node|ruby|perl|php";
|
|
961
|
+
const _RCE_SUDO = "(?:sudo(?:\\s+-\\S+)*\\s+)?";
|
|
962
|
+
const _RCE_PATTERNS = [
|
|
963
|
+
new RegExp(`\\b(?:${_RCE_FETCH})\\b[^\\n]*?\\|\\s*${_RCE_SUDO}(?:${_RCE_INTERP})\\b`, "i"),
|
|
964
|
+
new RegExp(`\\b(?:${_RCE_INTERP})\\b\\s*<\\(\\s*(?:${_RCE_FETCH})\\b`, "i"),
|
|
965
|
+
new RegExp(`\\b(?:${_RCE_INTERP})\\b\\s+-c\\s+["']?\\$\\(\\s*(?:${_RCE_FETCH})\\b`, "i"),
|
|
966
|
+
/base64\s+-d[^\n]*\|\s*(?:sudo(?:\s+-\S+)*\s+)?(?:bash|sh|zsh|python3?)\b/i,
|
|
967
|
+
/\|\s*base64\s+-d\s*\|\s*(?:bash|sh|zsh)\b/i,
|
|
968
|
+
/(?:iex|invoke-expression)[^\n]*(?:downloadstring|webclient|invoke-webrequest|\biwr\b)/i,
|
|
969
|
+
/(?:downloadstring|\biwr\b|invoke-webrequest)[^\n]*\|\s*iex\b/i
|
|
970
|
+
];
|
|
971
|
+
function detectRemoteExecPipeline(commands) {
|
|
972
|
+
const raw = commands.join(" \n ");
|
|
973
|
+
return _RCE_PATTERNS.some((re) => re.test(raw));
|
|
974
|
+
}
|
|
975
|
+
function _isProjectLocalTarget(raw) {
|
|
976
|
+
const t = raw.replace(/^["']|["']$/g, "");
|
|
977
|
+
if (t === "") return false;
|
|
978
|
+
if (t.startsWith("/")) return false;
|
|
979
|
+
if (t.startsWith("~")) return false;
|
|
980
|
+
if (t.includes("$")) return false;
|
|
981
|
+
if (t.includes("..")) return false;
|
|
982
|
+
if (t === "." || t === "./" || t === "*") return false;
|
|
983
|
+
if (/[*?\[\]]/.test(t)) return false;
|
|
984
|
+
return /^[\w.@+][\w.@+\-/]*$/.test(t);
|
|
985
|
+
}
|
|
986
|
+
const _NON_RM_DESTRUCTIVE = [
|
|
987
|
+
"rmdir /s",
|
|
988
|
+
"format c:",
|
|
989
|
+
"drop table",
|
|
990
|
+
"drop database",
|
|
991
|
+
"truncate table",
|
|
992
|
+
"delete from",
|
|
993
|
+
"kubectl delete namespace",
|
|
994
|
+
"kubectl delete ns",
|
|
995
|
+
"kubectl delete --all",
|
|
996
|
+
"kubectl delete all",
|
|
997
|
+
"kubectl delete statefulset",
|
|
998
|
+
"kubectl delete pvc",
|
|
999
|
+
"kubectl delete crd",
|
|
1000
|
+
"delete customresourcedefinition",
|
|
1001
|
+
"helm uninstall",
|
|
1002
|
+
"terraform destroy",
|
|
1003
|
+
"drop schema",
|
|
1004
|
+
"flushall",
|
|
1005
|
+
"dropdatabase",
|
|
1006
|
+
"grant all privileges",
|
|
1007
|
+
"destroy",
|
|
1008
|
+
"nuke",
|
|
1009
|
+
"wipe"
|
|
1010
|
+
];
|
|
1011
|
+
function _blockIsSolelyLocalRmRf(parsed, haystack) {
|
|
1012
|
+
const rms = parsed.filter((c) => c.name.replace(/^["']|["']$/g, "").toLowerCase() === "rm");
|
|
1013
|
+
if (rms.length === 0) return false;
|
|
1014
|
+
for (const rm of rms) {
|
|
1015
|
+
const args = rm.args ?? [];
|
|
1016
|
+
const recursive = args.some((a) => /^-[a-z]*r/i.test(a) || a === "--recursive");
|
|
1017
|
+
if (!recursive) return false;
|
|
1018
|
+
const targets = args.filter((a) => !a.startsWith("-"));
|
|
1019
|
+
if (targets.length === 0) return false;
|
|
1020
|
+
if (!targets.every(_isProjectLocalTarget)) return false;
|
|
1021
|
+
}
|
|
1022
|
+
return !_NON_RM_DESTRUCTIVE.some((tok) => matchesCommandPattern(haystack, tok));
|
|
1023
|
+
}
|
|
1024
|
+
function evaluateEnterprisePolicy(input, policyOverride) {
|
|
1025
|
+
const startTime = performance.now();
|
|
1026
|
+
try {
|
|
1027
|
+
const config = policyOverride ?? loadEnterprisePolicy();
|
|
1028
|
+
const result = {
|
|
1029
|
+
verdict: "pass",
|
|
1030
|
+
triggeredRules: [],
|
|
1031
|
+
reasons: []
|
|
1032
|
+
};
|
|
1033
|
+
const rulesToEvaluate = config.enabled ? config.rules : config.rules.filter((rule) => IMMUTABLE_RULE_IDS.has(rule.id));
|
|
1034
|
+
const { files, commands = [], actor, service, timestamp = Date.now(), environment, repo, agentId, actorRole, branch } = input;
|
|
1035
|
+
const isAi = isAiActor(actor);
|
|
1036
|
+
const date = new Date(timestamp);
|
|
1037
|
+
const dayOfWeek = date.getUTCDay();
|
|
1038
|
+
const hourOfDay = date.getUTCHours();
|
|
1039
|
+
const resolvedRole = actorRole ?? _lookupActorRole(actor);
|
|
1040
|
+
const resolvedBranch = branch ?? process.env.MERGEN_GIT_BRANCH ?? null;
|
|
1041
|
+
const analysis = analyzeCommands(commands);
|
|
1042
|
+
const ctx = {
|
|
1043
|
+
files,
|
|
1044
|
+
commands,
|
|
1045
|
+
commandHaystack: analysis.haystack,
|
|
1046
|
+
resources: extractResourcesFromHaystack(analysis.commands),
|
|
1047
|
+
service,
|
|
1048
|
+
isAi,
|
|
1049
|
+
dayOfWeek,
|
|
1050
|
+
hourOfDay,
|
|
1051
|
+
environment,
|
|
1052
|
+
repo,
|
|
1053
|
+
agentId,
|
|
1054
|
+
resolvedRole,
|
|
1055
|
+
resolvedBranch
|
|
1056
|
+
};
|
|
1057
|
+
for (const rule of rulesToEvaluate) {
|
|
1058
|
+
if (matchesConditionSet(rule.conditions, ctx)) {
|
|
1059
|
+
result.triggeredRules.push(rule.id);
|
|
1060
|
+
const hasResourceScoping = (rule.conditions.resourceGlobs?.length ?? 0) > 0 || (rule.conditions.excludeResourceGlobs?.length ?? 0) > 0;
|
|
1061
|
+
result.reasons.push(
|
|
1062
|
+
hasResourceScoping && ctx.resources.length > 0 ? `${rule.reason} (matched resource: ${ctx.resources.join(", ")})` : rule.reason
|
|
1063
|
+
);
|
|
1064
|
+
if (rule.action === "block") {
|
|
1065
|
+
result.verdict = "block";
|
|
1066
|
+
} else if (rule.action === "warn" && result.verdict !== "block") {
|
|
1067
|
+
result.verdict = "warn";
|
|
1068
|
+
void import("../sensor/shadow-promote.js").then(({ recordShadowRuleHit }) => {
|
|
1069
|
+
recordShadowRuleHit(rule.id, rule.name);
|
|
1070
|
+
}).catch(() => {
|
|
1071
|
+
});
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
if (result.verdict === "block" && result.triggeredRules.length === 1 && result.triggeredRules[0] === "block_destructive_commands" && _blockIsSolelyLocalRmRf(analysis.commands, ctx.commandHaystack)) {
|
|
1076
|
+
result.verdict = "pass";
|
|
1077
|
+
result.triggeredRules = [];
|
|
1078
|
+
result.reasons = [];
|
|
1079
|
+
}
|
|
1080
|
+
if (result.verdict === "pass") {
|
|
1081
|
+
const opaque = detectOpaqueIndirection(analysis.commands);
|
|
1082
|
+
if (opaque) {
|
|
1083
|
+
result.verdict = "warn";
|
|
1084
|
+
result.triggeredRules.push("opaque_indirection_hold");
|
|
1085
|
+
result.reasons.push(
|
|
1086
|
+
`Local Gate: this call executes an unexpanded shell variable (\`${opaque}\`) whose value is not visible to the policy engine. Held for human review \u2014 expand the variable or run the concrete command so the gate can evaluate what actually executes.`
|
|
1087
|
+
);
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
if (result.verdict === "pass" && detectRemoteExecPipeline(commands)) {
|
|
1091
|
+
result.verdict = "warn";
|
|
1092
|
+
result.triggeredRules.push("remote_exec_pipeline_hold");
|
|
1093
|
+
result.reasons.push(
|
|
1094
|
+
"Local Gate: remote content is fetched and fed directly into an interpreter (e.g. `curl \u2026 | bash`). The gate cannot see what the remote payload will do, so this is held for human review \u2014 download to a file, inspect it, then run the local copy."
|
|
1095
|
+
);
|
|
1096
|
+
}
|
|
1097
|
+
return result;
|
|
1098
|
+
} finally {
|
|
1099
|
+
const elapsed = performance.now() - startTime;
|
|
1100
|
+
_lastEvalLatencyMs = Math.round(elapsed * 100) / 100;
|
|
1101
|
+
if (_lastEvalLatencyMs < 0.05) {
|
|
1102
|
+
_lastEvalLatencyMs = 0.05;
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
export {
|
|
1107
|
+
DEFAULT_ENTERPRISE_POLICY,
|
|
1108
|
+
ENTERPRISE_POLICY_FILE,
|
|
1109
|
+
ENTERPRISE_POLICY_SIG_FILE,
|
|
1110
|
+
EnterprisePolicyConditionsSchema,
|
|
1111
|
+
IMMUTABLE_RULE_IDS,
|
|
1112
|
+
_policySignatureStatusForTesting,
|
|
1113
|
+
_resetPolicyCacheForTesting,
|
|
1114
|
+
assertUnsignedPolicyFlagIsSafe,
|
|
1115
|
+
evaluateEnterprisePolicy,
|
|
1116
|
+
getGateCoverageSummary,
|
|
1117
|
+
getLastEvalLatencyMs,
|
|
1118
|
+
isAiActor,
|
|
1119
|
+
loadEnterprisePolicy,
|
|
1120
|
+
saveEnterprisePolicy,
|
|
1121
|
+
setPolicySigningSecret
|
|
1122
|
+
};
|