@shield-agent/kya 0.32.9 → 0.33.1
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/.cursor-plugin/plugin.json +1 -1
- package/.mcp.json +1 -1
- package/LIMITATIONS.md +2 -2
- package/README.md +178 -12
- package/amp/settings.example.json +1 -1
- package/claude/claude_desktop_config.example.json +1 -1
- package/cline/cline_mcp_settings.example.json +1 -1
- package/copilot/mcp-config.example.json +1 -1
- package/data/iocs.json +17 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +149 -16
- package/dist/cli.js.map +1 -1
- package/dist/commands/connect.d.ts +1 -1
- package/dist/commands/connect.d.ts.map +1 -1
- package/dist/commands/connect.js +11 -3
- package/dist/commands/connect.js.map +1 -1
- package/dist/commands/dash.d.ts.map +1 -1
- package/dist/commands/dash.js +25 -1
- package/dist/commands/dash.js.map +1 -1
- package/dist/commands/doctor.d.ts +8 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +26 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/eval-tool.d.ts +3 -0
- package/dist/commands/eval-tool.d.ts.map +1 -1
- package/dist/commands/eval-tool.js +2 -1
- package/dist/commands/eval-tool.js.map +1 -1
- package/dist/commands/export.d.ts +33 -0
- package/dist/commands/export.d.ts.map +1 -0
- package/dist/commands/export.js +121 -0
- package/dist/commands/export.js.map +1 -0
- package/dist/commands/hook.d.ts.map +1 -1
- package/dist/commands/hook.js +120 -26
- package/dist/commands/hook.js.map +1 -1
- package/dist/commands/hosted-hook.d.ts +8 -1
- package/dist/commands/hosted-hook.d.ts.map +1 -1
- package/dist/commands/hosted-hook.js +15 -5
- package/dist/commands/hosted-hook.js.map +1 -1
- package/dist/commands/map.d.ts +21 -0
- package/dist/commands/map.d.ts.map +1 -0
- package/dist/commands/map.js +76 -0
- package/dist/commands/map.js.map +1 -0
- package/dist/commands/notify-flush.d.ts.map +1 -1
- package/dist/commands/notify-flush.js +7 -1
- package/dist/commands/notify-flush.js.map +1 -1
- package/dist/commands/posture.d.ts +21 -0
- package/dist/commands/posture.d.ts.map +1 -0
- package/dist/commands/posture.js +177 -0
- package/dist/commands/posture.js.map +1 -0
- package/dist/commands/receipt-serve.d.ts +7 -0
- package/dist/commands/receipt-serve.d.ts.map +1 -1
- package/dist/commands/receipt-serve.js +50 -0
- package/dist/commands/receipt-serve.js.map +1 -1
- package/dist/commands/start.d.ts.map +1 -1
- package/dist/commands/start.js +5 -1
- package/dist/commands/start.js.map +1 -1
- package/dist/commands/team.d.ts.map +1 -1
- package/dist/commands/team.js +30 -11
- package/dist/commands/team.js.map +1 -1
- package/dist/commands/wire-hooks.d.ts +5 -1
- package/dist/commands/wire-hooks.d.ts.map +1 -1
- package/dist/commands/wire-hooks.js +23 -8
- package/dist/commands/wire-hooks.js.map +1 -1
- package/dist/commands/wrap.d.ts +38 -1
- package/dist/commands/wrap.d.ts.map +1 -1
- package/dist/commands/wrap.js +82 -6
- package/dist/commands/wrap.js.map +1 -1
- package/dist/daily-slot.d.ts +2 -0
- package/dist/daily-slot.d.ts.map +1 -0
- package/dist/daily-slot.js +29 -0
- package/dist/daily-slot.js.map +1 -0
- package/dist/dash/dash.d.ts.map +1 -1
- package/dist/dash/dash.js +10 -0
- package/dist/dash/dash.js.map +1 -1
- package/dist/dash/entitlement.d.ts +1 -1
- package/dist/dash/entitlement.d.ts.map +1 -1
- package/dist/dash/entitlement.js +1 -0
- package/dist/dash/entitlement.js.map +1 -1
- package/dist/dash/input.d.ts +1 -1
- package/dist/dash/input.d.ts.map +1 -1
- package/dist/dash/input.js +3 -0
- package/dist/dash/input.js.map +1 -1
- package/dist/dash/render.js +1 -1
- package/dist/dash/render.js.map +1 -1
- package/dist/dash/screens/free.js +1 -1
- package/dist/dash/screens/free.js.map +1 -1
- package/dist/device-id.d.ts +5 -0
- package/dist/device-id.d.ts.map +1 -0
- package/dist/device-id.js +78 -0
- package/dist/device-id.js.map +1 -0
- package/dist/fs-atomic.d.ts +9 -3
- package/dist/fs-atomic.d.ts.map +1 -1
- package/dist/fs-atomic.js +17 -11
- package/dist/fs-atomic.js.map +1 -1
- package/dist/gate/config-gen.d.ts +26 -2
- package/dist/gate/config-gen.d.ts.map +1 -1
- package/dist/gate/config-gen.js +53 -4
- package/dist/gate/config-gen.js.map +1 -1
- package/dist/gate/otlp-receiver.d.ts.map +1 -1
- package/dist/gate/otlp-receiver.js +3 -1
- package/dist/gate/otlp-receiver.js.map +1 -1
- package/dist/guard-pack.d.ts +58 -0
- package/dist/guard-pack.d.ts.map +1 -0
- package/dist/guard-pack.js +753 -0
- package/dist/guard-pack.js.map +1 -0
- package/dist/ioc/exec.d.ts +19 -0
- package/dist/ioc/exec.d.ts.map +1 -0
- package/dist/ioc/exec.js +49 -0
- package/dist/ioc/exec.js.map +1 -0
- package/dist/ioc/format.d.ts +4 -0
- package/dist/ioc/format.d.ts.map +1 -0
- package/dist/ioc/format.js +23 -0
- package/dist/ioc/format.js.map +1 -0
- package/dist/ioc/list.d.ts +29 -0
- package/dist/ioc/list.d.ts.map +1 -0
- package/dist/ioc/list.js +25 -0
- package/dist/ioc/list.js.map +1 -0
- package/dist/ioc/result.d.ts +18 -0
- package/dist/ioc/result.d.ts.map +1 -0
- package/dist/ioc/result.js +26 -0
- package/dist/ioc/result.js.map +1 -0
- package/dist/ioc/scan.d.ts +21 -0
- package/dist/ioc/scan.d.ts.map +1 -0
- package/dist/ioc/scan.js +247 -0
- package/dist/ioc/scan.js.map +1 -0
- package/dist/ioc/spawn.d.ts +26 -0
- package/dist/ioc/spawn.d.ts.map +1 -0
- package/dist/ioc/spawn.js +52 -0
- package/dist/ioc/spawn.js.map +1 -0
- package/dist/map/build.d.ts +58 -0
- package/dist/map/build.d.ts.map +1 -0
- package/dist/map/build.js +439 -0
- package/dist/map/build.js.map +1 -0
- package/dist/map/heat.d.ts +44 -0
- package/dist/map/heat.d.ts.map +1 -0
- package/dist/map/heat.js +96 -0
- package/dist/map/heat.js.map +1 -0
- package/dist/map/load.d.ts +38 -0
- package/dist/map/load.d.ts.map +1 -0
- package/dist/map/load.js +38 -0
- package/dist/map/load.js.map +1 -0
- package/dist/map/page.d.ts +90 -0
- package/dist/map/page.d.ts.map +1 -0
- package/dist/map/page.js +374 -0
- package/dist/map/page.js.map +1 -0
- package/dist/map/target-class.d.ts +22 -0
- package/dist/map/target-class.d.ts.map +1 -0
- package/dist/map/target-class.js +151 -0
- package/dist/map/target-class.js.map +1 -0
- package/dist/map/types.d.ts +102 -0
- package/dist/map/types.d.ts.map +1 -0
- package/dist/map/types.js +3 -0
- package/dist/map/types.js.map +1 -0
- package/dist/mcp/protocol.d.ts.map +1 -1
- package/dist/mcp/protocol.js +16 -5
- package/dist/mcp/protocol.js.map +1 -1
- package/dist/never-code.d.ts +7 -0
- package/dist/never-code.d.ts.map +1 -0
- package/dist/never-code.js +9 -0
- package/dist/never-code.js.map +1 -0
- package/dist/never-list-input.d.ts +48 -12
- package/dist/never-list-input.d.ts.map +1 -1
- package/dist/never-list-input.js +696 -29
- package/dist/never-list-input.js.map +1 -1
- package/dist/notify/dispatch.d.ts +4 -0
- package/dist/notify/dispatch.d.ts.map +1 -1
- package/dist/notify/dispatch.js +3 -1
- package/dist/notify/dispatch.js.map +1 -1
- package/dist/offline-evaluate.d.ts +26 -1
- package/dist/offline-evaluate.d.ts.map +1 -1
- package/dist/offline-evaluate.js +33 -4
- package/dist/offline-evaluate.js.map +1 -1
- package/dist/otel/exporter.d.ts +15 -2
- package/dist/otel/exporter.d.ts.map +1 -1
- package/dist/otel/exporter.js +52 -6
- package/dist/otel/exporter.js.map +1 -1
- package/dist/otel/map-attrs-optin.d.ts +39 -0
- package/dist/otel/map-attrs-optin.d.ts.map +1 -0
- package/dist/otel/map-attrs-optin.js +109 -0
- package/dist/otel/map-attrs-optin.js.map +1 -0
- package/dist/parse-args.d.ts +2 -0
- package/dist/parse-args.d.ts.map +1 -1
- package/dist/parse-args.js +4 -2
- package/dist/parse-args.js.map +1 -1
- package/dist/posture/card.d.ts +26 -0
- package/dist/posture/card.d.ts.map +1 -0
- package/dist/posture/card.js +46 -0
- package/dist/posture/card.js.map +1 -0
- package/dist/posture/collect.d.ts +19 -0
- package/dist/posture/collect.d.ts.map +1 -0
- package/dist/posture/collect.js +182 -0
- package/dist/posture/collect.js.map +1 -0
- package/dist/posture/send.d.ts +24 -0
- package/dist/posture/send.d.ts.map +1 -0
- package/dist/posture/send.js +162 -0
- package/dist/posture/send.js.map +1 -0
- package/dist/posture/spawn.d.ts +20 -0
- package/dist/posture/spawn.d.ts.map +1 -0
- package/dist/posture/spawn.js +33 -0
- package/dist/posture/spawn.js.map +1 -0
- package/dist/posture/store.d.ts +34 -0
- package/dist/posture/store.d.ts.map +1 -0
- package/dist/posture/store.js +107 -0
- package/dist/posture/store.js.map +1 -0
- package/dist/posture/validate.d.ts +33 -0
- package/dist/posture/validate.d.ts.map +1 -0
- package/dist/posture/validate.js +232 -0
- package/dist/posture/validate.js.map +1 -0
- package/dist/project-policy.d.ts +23 -0
- package/dist/project-policy.d.ts.map +1 -0
- package/dist/project-policy.js +95 -0
- package/dist/project-policy.js.map +1 -0
- package/dist/receipt/daemon.d.ts +9 -9
- package/dist/receipt/daemon.d.ts.map +1 -1
- package/dist/receipt/daemon.js +26 -11
- package/dist/receipt/daemon.js.map +1 -1
- package/dist/receipt/embeddings.d.ts +28 -10
- package/dist/receipt/embeddings.d.ts.map +1 -1
- package/dist/receipt/embeddings.js +221 -35
- package/dist/receipt/embeddings.js.map +1 -1
- package/dist/receipt/gate-page.d.ts.map +1 -1
- package/dist/receipt/gate-page.js +5 -22
- package/dist/receipt/gate-page.js.map +1 -1
- package/dist/receipt/live-server.d.ts +8 -0
- package/dist/receipt/live-server.d.ts.map +1 -1
- package/dist/receipt/live-server.js +136 -4
- package/dist/receipt/live-server.js.map +1 -1
- package/dist/receipt/render-receipt.d.ts +7 -1
- package/dist/receipt/render-receipt.d.ts.map +1 -1
- package/dist/receipt/render-receipt.js +101 -13
- package/dist/receipt/render-receipt.js.map +1 -1
- package/dist/receipt/search.d.ts +5 -0
- package/dist/receipt/search.d.ts.map +1 -1
- package/dist/receipt/search.js +23 -2
- package/dist/receipt/search.js.map +1 -1
- package/dist/receipt/semantic-setup.d.ts +21 -0
- package/dist/receipt/semantic-setup.d.ts.map +1 -0
- package/dist/receipt/semantic-setup.js +111 -0
- package/dist/receipt/semantic-setup.js.map +1 -0
- package/dist/receipt/semantic-status.d.ts +7 -0
- package/dist/receipt/semantic-status.d.ts.map +1 -0
- package/dist/receipt/semantic-status.js +26 -0
- package/dist/receipt/semantic-status.js.map +1 -0
- package/dist/sdk/govern.d.ts.map +1 -1
- package/dist/sdk/govern.js +7 -3
- package/dist/sdk/govern.js.map +1 -1
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +3 -23
- package/dist/telemetry.js.map +1 -1
- package/dist/trail-map-fields.d.ts +10 -0
- package/dist/trail-map-fields.d.ts.map +1 -0
- package/dist/trail-map-fields.js +53 -0
- package/dist/trail-map-fields.js.map +1 -0
- package/dist/trail-summary.d.ts.map +1 -1
- package/dist/trail-summary.js +11 -3
- package/dist/trail-summary.js.map +1 -1
- package/dist/trail.d.ts +9 -1
- package/dist/trail.d.ts.map +1 -1
- package/dist/trail.js +24 -1
- package/dist/trail.js.map +1 -1
- package/droid/README.md +1 -1
- package/droid/mcp.example.json +1 -1
- package/gemini/settings.example.json +1 -1
- package/grok/README.md +1 -1
- package/kilo/kilo.example.json +1 -1
- package/kimi/mcp.example.json +1 -1
- package/kiro/mcp.example.json +1 -1
- package/manifest.json +1 -1
- package/mastracode/mcp.example.json +1 -1
- package/mcp.json +1 -1
- package/openai/codex.config.example.toml +1 -1
- package/opencode/opencode.example.json +1 -1
- package/package.json +8 -4
- package/qwen/settings.example.json +1 -1
- package/server.json +2 -2
- package/vendor/cytoscape/LICENSE +19 -0
- package/vendor/cytoscape/README.md +7 -0
- package/vendor/cytoscape/cytoscape.min.js +31 -0
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guard pack: a second layer of local denies on top of the never-list input rules
|
|
3
|
+
* (never-list-input.ts, owner approved, ids unchanged). It adds destructive shell commands that
|
|
4
|
+
* list does not know (terraform destroy, SQL drops, docker volumes, cloud deletes, ...), reads of
|
|
5
|
+
* more credential files, secret-shaped writes and secret exfiltration.
|
|
6
|
+
*
|
|
7
|
+
* Same stance as the never-list: a seatbelt, not a sandbox. Reuses its scanner, so wrappers,
|
|
8
|
+
* `bash -c`, substitutions and same-command `NAME=value` are already unwrapped. Never throws: any
|
|
9
|
+
* error is "no hit" (the local hook fails open).
|
|
10
|
+
*
|
|
11
|
+
* Config (`shellNeverList`, `secretsGuard`, `exfilGuard`, `guardAllow`) is read from the USER-level
|
|
12
|
+
* `<home>/.kya/config.json` only. A project `.kya/config.json` ships with the repo and the agent can
|
|
13
|
+
* write it, so it can never lift a deny.
|
|
14
|
+
*/
|
|
15
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { posix } from "node:path";
|
|
18
|
+
import { commandStrings, fieldsOf, gitSubInfo, hasCommand, isSecretPath, isShellTool, isWriteLike, normalizePath, parseShell, pathStrings, windowsOf, } from "./never-list-input.js";
|
|
19
|
+
export const GUARD_DEFAULTS = { shellNeverList: true, secretsGuard: true, exfilGuard: true, guardAllow: [] };
|
|
20
|
+
/** Rule ids of the shell family, in match order. Listed in docs/guard-pack.md. */
|
|
21
|
+
export const GUARD_SHELL_RULES = [
|
|
22
|
+
"rm-root-home", "terraform-destroy", "sql-drop", "git-force-push", "git-delete-default-branch", "git-hard-reset-clean",
|
|
23
|
+
"docker-volumes", "k8s-delete-ns", "cloud-delete", "railway-volume-delete",
|
|
24
|
+
];
|
|
25
|
+
// ---------------------------------------------------------------------------------------------
|
|
26
|
+
// Config
|
|
27
|
+
// ---------------------------------------------------------------------------------------------
|
|
28
|
+
/** Home for the guard: an absolute `HOME` from the given env, else the OS home. */
|
|
29
|
+
export function guardContextFrom(cwd, env = process.env) {
|
|
30
|
+
const h = env.HOME?.trim();
|
|
31
|
+
let home = h && h.startsWith("/") ? h : undefined;
|
|
32
|
+
if (!home) {
|
|
33
|
+
try {
|
|
34
|
+
const o = homedir();
|
|
35
|
+
home = o && o.startsWith("/") ? o : undefined;
|
|
36
|
+
}
|
|
37
|
+
catch { /* no home */ }
|
|
38
|
+
}
|
|
39
|
+
return { cwd, home, projectRoot: cwd, env };
|
|
40
|
+
}
|
|
41
|
+
const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
42
|
+
/**
|
|
43
|
+
* Read the guard keys from `<home>/.kya/config.json`. A missing file is the defaults. A malformed
|
|
44
|
+
* file or key falls back to the default for that key and reports `problem` (the hook writes one
|
|
45
|
+
* trail line). Never throws.
|
|
46
|
+
*/
|
|
47
|
+
export function readGuardConfig(home) {
|
|
48
|
+
if (!home)
|
|
49
|
+
return { config: GUARD_DEFAULTS };
|
|
50
|
+
let text;
|
|
51
|
+
try {
|
|
52
|
+
text = readFileSync(`${home}/.kya/config.json`, "utf8");
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return { config: GUARD_DEFAULTS };
|
|
56
|
+
}
|
|
57
|
+
let raw;
|
|
58
|
+
try {
|
|
59
|
+
raw = JSON.parse(text);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return { config: GUARD_DEFAULTS, problem: "unreadable config" };
|
|
63
|
+
}
|
|
64
|
+
if (!isObj(raw))
|
|
65
|
+
return { config: GUARD_DEFAULTS, problem: "config is not an object" };
|
|
66
|
+
const bad = [];
|
|
67
|
+
const flag = (k) => {
|
|
68
|
+
const v = raw[k];
|
|
69
|
+
if (v === undefined)
|
|
70
|
+
return true;
|
|
71
|
+
if (typeof v === "boolean")
|
|
72
|
+
return v;
|
|
73
|
+
bad.push(k);
|
|
74
|
+
return true;
|
|
75
|
+
};
|
|
76
|
+
const allow = [];
|
|
77
|
+
const list = raw.guardAllow;
|
|
78
|
+
if (list !== undefined && !Array.isArray(list))
|
|
79
|
+
bad.push("guardAllow");
|
|
80
|
+
else {
|
|
81
|
+
for (const e of (list ?? [])) {
|
|
82
|
+
if (isObj(e) && typeof e.rule === "string" && e.rule && (typeof e.path === "string") !== (typeof e.command === "string")) {
|
|
83
|
+
allow.push({ rule: e.rule, ...(typeof e.path === "string" ? { path: e.path } : { command: e.command }) });
|
|
84
|
+
}
|
|
85
|
+
else
|
|
86
|
+
bad.push("guardAllow entry");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const config = { shellNeverList: flag("shellNeverList"), secretsGuard: flag("secretsGuard"), exfilGuard: flag("exfilGuard"), guardAllow: allow };
|
|
90
|
+
return bad.length ? { config, problem: `invalid ${[...new Set(bad)].join(", ")}` } : { config };
|
|
91
|
+
}
|
|
92
|
+
function globToRegExp(glob) {
|
|
93
|
+
let re = "";
|
|
94
|
+
for (let i = 0; i < glob.length; i++) {
|
|
95
|
+
const c = glob[i];
|
|
96
|
+
if (c === "*") {
|
|
97
|
+
if (glob[i + 1] === "*") {
|
|
98
|
+
re += ".*";
|
|
99
|
+
i++;
|
|
100
|
+
}
|
|
101
|
+
else
|
|
102
|
+
re += "[^/]*";
|
|
103
|
+
}
|
|
104
|
+
else if (c === "?")
|
|
105
|
+
re += "[^/]";
|
|
106
|
+
else
|
|
107
|
+
re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
108
|
+
}
|
|
109
|
+
return new RegExp(`^${re}$`);
|
|
110
|
+
}
|
|
111
|
+
/** Shell glob match, segment by segment: a leading `*`, `?` or `[` does not match a name that starts with a dot. */
|
|
112
|
+
function dotGlobMatch(pattern, path) {
|
|
113
|
+
const ps = pattern.split("/");
|
|
114
|
+
const ts = path.split("/");
|
|
115
|
+
if (ps.length !== ts.length)
|
|
116
|
+
return false;
|
|
117
|
+
return ps.every((p, i) => {
|
|
118
|
+
const t = ts[i];
|
|
119
|
+
if (t.startsWith(".") && /^[*?[]/.test(p))
|
|
120
|
+
return false;
|
|
121
|
+
return globToRegExp(p).test(t);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
function allowed(hit, cfg, ctx, command) {
|
|
125
|
+
if (hit.rule === "guard-config-write")
|
|
126
|
+
return false;
|
|
127
|
+
for (const a of cfg.guardAllow) {
|
|
128
|
+
if (a.rule !== hit.rule)
|
|
129
|
+
continue;
|
|
130
|
+
if (a.command !== undefined && command !== undefined && a.command === command.trim())
|
|
131
|
+
return true;
|
|
132
|
+
if (a.path !== undefined && hit.target !== undefined) {
|
|
133
|
+
const glob = normalizePath(a.path, ctx.home);
|
|
134
|
+
if (globToRegExp(glob).test(hit.target))
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
/** Split args into operands and options. An option in `valueOpts` takes the next word as its value. */
|
|
141
|
+
function words(args, valueOpts = new Set()) {
|
|
142
|
+
const pos = [];
|
|
143
|
+
const flags = [];
|
|
144
|
+
for (let i = 0; i < args.length; i++) {
|
|
145
|
+
const a = args[i];
|
|
146
|
+
if (a === "--") {
|
|
147
|
+
pos.push(...args.slice(i + 1));
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
if (a.length > 1 && a.startsWith("-")) {
|
|
151
|
+
flags.push(a);
|
|
152
|
+
// A value option takes the next word, unless that word is itself an option (`prune -f --volumes`).
|
|
153
|
+
if (valueOpts.has(a) && i + 1 < args.length && !args[i + 1].startsWith("-"))
|
|
154
|
+
i++;
|
|
155
|
+
}
|
|
156
|
+
else
|
|
157
|
+
pos.push(a);
|
|
158
|
+
}
|
|
159
|
+
return { pos, flags };
|
|
160
|
+
}
|
|
161
|
+
const hasLong = (flags, ...names) => flags.some((f) => names.some((n) => f === n || f.startsWith(`${n}=`)));
|
|
162
|
+
/** A short flag letter in any cluster: `-fdx` has f, d and x. */
|
|
163
|
+
const hasShort = (flags, letter) => flags.some((f) => /^-[A-Za-z]+$/.test(f) && f.includes(letter));
|
|
164
|
+
function absPath(word, ctx) {
|
|
165
|
+
const w = expandVars(word, ctx).text;
|
|
166
|
+
if (w.startsWith("/") || w === "~" || w.startsWith("~/") || /^~[A-Za-z]/.test(w))
|
|
167
|
+
return normalizePath(w, ctx.home);
|
|
168
|
+
return normalizePath(posix.join(ctx.cwd, w), ctx.home);
|
|
169
|
+
}
|
|
170
|
+
/** `$NAME` and `${NAME}` from the hook's environment (HOME from the context). `unset` is true when one was missing or empty. */
|
|
171
|
+
function expandVars(word, ctx) {
|
|
172
|
+
let unset = false;
|
|
173
|
+
const env = ctx.env ?? process.env;
|
|
174
|
+
const text = word.replace(/\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))/g, (_m, a, b) => {
|
|
175
|
+
const name = (a ?? b);
|
|
176
|
+
const v = name === "HOME" ? ctx.home : env[name];
|
|
177
|
+
if (v === undefined || v === "") {
|
|
178
|
+
unset = true;
|
|
179
|
+
return "";
|
|
180
|
+
}
|
|
181
|
+
return v;
|
|
182
|
+
});
|
|
183
|
+
return { text, unset };
|
|
184
|
+
}
|
|
185
|
+
const NO = undefined;
|
|
186
|
+
const isRecursiveFlags = (flags) => hasLong(flags, "--recursive") || hasShort(flags, "r") || hasShort(flags, "R");
|
|
187
|
+
const isForceFlags = (flags) => hasLong(flags, "--force") || hasShort(flags, "f");
|
|
188
|
+
/** Root, home or one of its ancestors, or an ancestor of the project root. `/*` and `/.*` count as the directory. */
|
|
189
|
+
function rmTargetHit(word, ctx, assigned) {
|
|
190
|
+
if (word.includes("$(") || word.includes("`"))
|
|
191
|
+
return NO;
|
|
192
|
+
// A variable the same command assigns at run time (`for d in a b; do rm -rf "$d"`, `read f`) has a value we cannot see.
|
|
193
|
+
for (const m of word.matchAll(/\$(?:\{([A-Za-z_][A-Za-z0-9_]*)[^}]*\}|([A-Za-z_][A-Za-z0-9_]*))/g)) {
|
|
194
|
+
if (assigned.has((m[1] ?? m[2])))
|
|
195
|
+
return NO;
|
|
196
|
+
}
|
|
197
|
+
const { text, unset } = expandVars(word, ctx);
|
|
198
|
+
// `rm -rf "$X"` with X unset runs `rm -rf ""`; `"$X/"` runs `rm -rf /`. Both are the bug this rule is for.
|
|
199
|
+
if (unset && text === "")
|
|
200
|
+
return "";
|
|
201
|
+
let p = absPath(word, ctx);
|
|
202
|
+
p = p.replace(/\/(\.\*|\*)$/, "") || "/";
|
|
203
|
+
const root = ctx.projectRoot ?? ctx.cwd;
|
|
204
|
+
const home = ctx.home;
|
|
205
|
+
if (p === "/")
|
|
206
|
+
return "/";
|
|
207
|
+
if (home && (p === home || home.startsWith(`${p}/`)))
|
|
208
|
+
return p;
|
|
209
|
+
if (p !== root && root.startsWith(`${p}/`))
|
|
210
|
+
return p;
|
|
211
|
+
return NO;
|
|
212
|
+
}
|
|
213
|
+
/** Names bound by `for NAME in`, `select NAME in` and `read [-opts] NAME...` in this command. */
|
|
214
|
+
function runtimeVars(raw) {
|
|
215
|
+
const out = new Set();
|
|
216
|
+
for (const m of raw.matchAll(/\b(?:for|select)\s+([A-Za-z_][A-Za-z0-9_]*)\s+in\b/g))
|
|
217
|
+
out.add(m[1]);
|
|
218
|
+
for (const m of raw.matchAll(/\bread\s+((?:-\S+\s+)*)((?:[A-Za-z_][A-Za-z0-9_]*\s*)+)/g))
|
|
219
|
+
for (const n of m[2].trim().split(/\s+/))
|
|
220
|
+
out.add(n);
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
223
|
+
const rmRoot = (sh, ctx) => {
|
|
224
|
+
const assigned = runtimeVars(sh.raw);
|
|
225
|
+
const targets = [];
|
|
226
|
+
hasCommand(sh, (c) => {
|
|
227
|
+
if (c.name !== "rm")
|
|
228
|
+
return false;
|
|
229
|
+
const { pos, flags } = words(c.args);
|
|
230
|
+
if (!isRecursiveFlags(flags) || !isForceFlags(flags))
|
|
231
|
+
return false;
|
|
232
|
+
for (const t of pos) {
|
|
233
|
+
const hit = rmTargetHit(t, ctx, assigned);
|
|
234
|
+
if (hit !== undefined)
|
|
235
|
+
targets.push(hit);
|
|
236
|
+
}
|
|
237
|
+
return false; // keep scanning: every matching target is judged by guardAllow
|
|
238
|
+
});
|
|
239
|
+
return targets.length ? targets : NO;
|
|
240
|
+
};
|
|
241
|
+
const named = (...names) => (c) => names.includes(c.name);
|
|
242
|
+
const terraformDestroy = (sh) => hasCommand(sh, (c) => {
|
|
243
|
+
if (!named("terraform", "tofu")(c))
|
|
244
|
+
return false;
|
|
245
|
+
const { pos, flags } = words(c.args);
|
|
246
|
+
return pos[0] === "destroy" || (pos[0] === "apply" && flags.some((f) => /^-destroy(=true)?$/.test(f)));
|
|
247
|
+
}) ? "" : NO;
|
|
248
|
+
const DB_CLIENTS = new Set(["psql", "mysql", "mariadb", "sqlite3", "mongosh", "clickhouse-client"]);
|
|
249
|
+
const SQL_DROP = /\b(?:drop\s+(?:table|database|schema)|truncate\s+(?:table\s+)?[\w"`[.])/i;
|
|
250
|
+
const sqlDrop = (sh) => (hasCommand(sh, (c) => DB_CLIENTS.has(c.name)) && SQL_DROP.test(sh.raw) ? "" : NO);
|
|
251
|
+
const gitSub = (c) => {
|
|
252
|
+
const { idx } = gitSubInfo(c.args);
|
|
253
|
+
return { sub: c.args[idx] ?? "", rest: c.args.slice(idx + 1) };
|
|
254
|
+
};
|
|
255
|
+
const gitForcePush = (sh) => hasCommand(sh, (c) => {
|
|
256
|
+
if (c.name !== "git")
|
|
257
|
+
return false;
|
|
258
|
+
const { sub, rest } = gitSub(c);
|
|
259
|
+
if (sub !== "push")
|
|
260
|
+
return false;
|
|
261
|
+
const { pos, flags } = words(rest, new Set(["-o", "--push-option", "--repo", "--receive-pack", "--exec"]));
|
|
262
|
+
// Force is `--force`, or a short cluster with f. `--force-with-lease` and `--force-if-includes` are the safe forms.
|
|
263
|
+
const force = flags.some((f) => f === "--force" || /^-[A-Za-z]*f[A-Za-z]*$/.test(f));
|
|
264
|
+
return force || pos.slice(1).some((r) => r.startsWith("+"));
|
|
265
|
+
}) ? "" : NO;
|
|
266
|
+
const DEFAULT_BRANCH = /^(main|master)$/;
|
|
267
|
+
const branchName = (r) => r.replace(/^\+/, "").replace(/^refs\/heads\//, "");
|
|
268
|
+
const gitDeleteDefaultBranch = (sh) => hasCommand(sh, (c) => {
|
|
269
|
+
if (c.name !== "git")
|
|
270
|
+
return false;
|
|
271
|
+
const { sub, rest } = gitSub(c);
|
|
272
|
+
const { pos, flags } = words(rest);
|
|
273
|
+
if (sub === "push") {
|
|
274
|
+
const del = flags.some((f) => f === "--delete" || /^-[A-Za-z]*d[A-Za-z]*$/.test(f));
|
|
275
|
+
return pos.slice(1).some((r) => (r.startsWith(":") && DEFAULT_BRANCH.test(branchName(r.slice(1)))) || (del && DEFAULT_BRANCH.test(branchName(r))));
|
|
276
|
+
}
|
|
277
|
+
if (sub === "branch") {
|
|
278
|
+
const forced = hasShort(flags, "D") || (flags.some((f) => f === "--delete" || /^-[A-Za-z]*d[A-Za-z]*$/.test(f)) && isForceFlags(flags));
|
|
279
|
+
return forced && pos.some((b) => DEFAULT_BRANCH.test(b));
|
|
280
|
+
}
|
|
281
|
+
return false;
|
|
282
|
+
}) ? "" : NO;
|
|
283
|
+
const gitHardResetClean = (sh) => {
|
|
284
|
+
const hard = hasCommand(sh, (c) => c.name === "git" && gitSub(c).sub === "reset" && c.args.includes("--hard"));
|
|
285
|
+
if (!hard)
|
|
286
|
+
return NO;
|
|
287
|
+
return hasCommand(sh, (c) => {
|
|
288
|
+
if (c.name !== "git")
|
|
289
|
+
return false;
|
|
290
|
+
const { sub, rest } = gitSub(c);
|
|
291
|
+
if (sub !== "clean")
|
|
292
|
+
return false;
|
|
293
|
+
const { flags } = words(rest);
|
|
294
|
+
return hasShort(flags, "f") && hasShort(flags, "d") && hasShort(flags, "x");
|
|
295
|
+
}) ? "" : NO;
|
|
296
|
+
};
|
|
297
|
+
const DOCKER_VALUE_OPTS = new Set(["-H", "--host", "--context", "-c", "-l", "--log-level", "--config", "-f", "--file", "-p", "--project-name", "--profile", "--env-file", "--project-directory"]);
|
|
298
|
+
const dockerVolumes = (sh) => hasCommand(sh, (c) => {
|
|
299
|
+
if (!named("docker", "podman", "docker-compose")(c))
|
|
300
|
+
return false;
|
|
301
|
+
const { pos, flags } = words(c.args, DOCKER_VALUE_OPTS);
|
|
302
|
+
if (c.name === "docker-compose")
|
|
303
|
+
return pos[0] === "down" && (hasLong(flags, "--volumes") || hasShort(flags, "v"));
|
|
304
|
+
if (pos[0] === "volume")
|
|
305
|
+
return pos[1] === "rm" || pos[1] === "remove" || pos[1] === "prune";
|
|
306
|
+
if (pos[0] === "system" && pos[1] === "prune")
|
|
307
|
+
return hasLong(flags, "--volumes", "--all") || hasShort(flags, "a");
|
|
308
|
+
if (pos[0] === "compose" && pos[1] === "down")
|
|
309
|
+
return hasLong(flags, "--volumes") || hasShort(flags, "v");
|
|
310
|
+
return false;
|
|
311
|
+
}) ? "" : NO;
|
|
312
|
+
const KUBECTL_VALUE_OPTS = new Set(["-n", "--namespace", "--context", "--cluster", "--kubeconfig", "-s", "--server", "--user", "-l", "--selector", "-f", "--filename", "-o", "--output"]);
|
|
313
|
+
const k8sDeleteNs = (sh) => hasCommand(sh, (c) => {
|
|
314
|
+
if (c.name !== "kubectl")
|
|
315
|
+
return false;
|
|
316
|
+
const { pos, flags } = words(c.args, KUBECTL_VALUE_OPTS);
|
|
317
|
+
if (pos[0] !== "delete")
|
|
318
|
+
return false;
|
|
319
|
+
if ((pos[1] ?? "").split(",").some((r) => /^(namespaces?|ns)$/.test(r)))
|
|
320
|
+
return true;
|
|
321
|
+
return hasLong(flags, "--all") && (flags.includes("-A") || hasLong(flags, "--all-namespaces"));
|
|
322
|
+
}) ? "" : NO;
|
|
323
|
+
const AWS_VALUE_OPTS = new Set(["--profile", "--region", "--output", "--endpoint-url", "--query", "--color", "--ca-bundle", "--cli-read-timeout", "--cli-connect-timeout"]);
|
|
324
|
+
const GCLOUD_VALUE_OPTS = new Set(["--project", "--account", "--format", "--configuration", "--impersonate-service-account", "--billing-project", "--verbosity"]);
|
|
325
|
+
const cloudDelete = (sh) => hasCommand(sh, (c) => {
|
|
326
|
+
if (c.name === "aws") {
|
|
327
|
+
const { pos, flags } = words(c.args, AWS_VALUE_OPTS);
|
|
328
|
+
const [svc, op] = pos;
|
|
329
|
+
if (svc === "ec2")
|
|
330
|
+
return op === "terminate-instances";
|
|
331
|
+
if (svc === "s3")
|
|
332
|
+
return (op === "rb" && hasLong(flags, "--force")) || (op === "rm" && hasLong(flags, "--recursive"));
|
|
333
|
+
if (svc === "rds")
|
|
334
|
+
return op === "delete-db-instance" || op === "delete-db-cluster";
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
if (c.name === "gcloud") {
|
|
338
|
+
const { pos } = words(c.args, GCLOUD_VALUE_OPTS);
|
|
339
|
+
const p = pos[0] === "alpha" || pos[0] === "beta" ? pos.slice(1) : pos;
|
|
340
|
+
return (p[0] === "projects" && p[1] === "delete") || (p[0] === "sql" && p[1] === "instances" && p[2] === "delete");
|
|
341
|
+
}
|
|
342
|
+
if (c.name === "az") {
|
|
343
|
+
const { pos } = words(c.args);
|
|
344
|
+
return pos[0] === "group" && pos[1] === "delete";
|
|
345
|
+
}
|
|
346
|
+
return false;
|
|
347
|
+
}) ? "" : NO;
|
|
348
|
+
const HTTP_CLIENTS = new Set(["curl", "wget", "http", "https", "xh", "httpie"]);
|
|
349
|
+
const railwayVolumeDelete = (sh) => (hasCommand(sh, (c) => HTTP_CLIENTS.has(c.name)) && sh.raw.includes("volumeDelete")) ||
|
|
350
|
+
hasCommand(sh, (c) => {
|
|
351
|
+
if (c.name !== "railway")
|
|
352
|
+
return false;
|
|
353
|
+
const { pos } = words(c.args);
|
|
354
|
+
return pos[0] === "volume" && pos[1] === "delete";
|
|
355
|
+
}) ? "" : NO;
|
|
356
|
+
const SHELL_MATCHERS = [
|
|
357
|
+
["rm-root-home", rmRoot],
|
|
358
|
+
["terraform-destroy", terraformDestroy],
|
|
359
|
+
["sql-drop", sqlDrop],
|
|
360
|
+
["git-force-push", gitForcePush],
|
|
361
|
+
["git-delete-default-branch", gitDeleteDefaultBranch],
|
|
362
|
+
["git-hard-reset-clean", gitHardResetClean],
|
|
363
|
+
["docker-volumes", dockerVolumes],
|
|
364
|
+
["k8s-delete-ns", k8sDeleteNs],
|
|
365
|
+
["cloud-delete", cloudDelete],
|
|
366
|
+
["railway-volume-delete", railwayVolumeDelete],
|
|
367
|
+
];
|
|
368
|
+
// ---------------------------------------------------------------------------------------------
|
|
369
|
+
// Secrets: reads, writes, exfiltration
|
|
370
|
+
// ---------------------------------------------------------------------------------------------
|
|
371
|
+
/**
|
|
372
|
+
* Credential files the never-list does not already cover (it has ssh keys, `~/.aws/credentials`, gcloud
|
|
373
|
+
* credential files, npmrc, netrc, git-credentials, `.env`, `*.pem`, `*.key`). Returns the kind for the rule id.
|
|
374
|
+
*/
|
|
375
|
+
function secretKind(p, home) {
|
|
376
|
+
if (home) {
|
|
377
|
+
const rel = p.startsWith(`${home}/`) ? p.slice(home.length + 1) : undefined;
|
|
378
|
+
if (rel !== undefined) {
|
|
379
|
+
if (rel === ".railway/config.json")
|
|
380
|
+
return "railway";
|
|
381
|
+
if (rel === ".docker/config.json")
|
|
382
|
+
return "docker";
|
|
383
|
+
if (rel === ".kube/config")
|
|
384
|
+
return "kube";
|
|
385
|
+
if (rel === ".pypirc")
|
|
386
|
+
return "pypirc";
|
|
387
|
+
if (rel === ".config/gh/hosts.yml")
|
|
388
|
+
return "gh";
|
|
389
|
+
if (rel.startsWith(".azure/"))
|
|
390
|
+
return "azure";
|
|
391
|
+
if (rel.startsWith(".config/gcloud/"))
|
|
392
|
+
return "gcloud";
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
const base = p.slice(p.lastIndexOf("/") + 1);
|
|
396
|
+
if (/^id_(rsa|ed25519|ecdsa|dsa)/.test(base) && !base.endsWith(".pub"))
|
|
397
|
+
return "key";
|
|
398
|
+
if (/^\/proc\/[^/]+\/environ$/.test(p))
|
|
399
|
+
return "environ";
|
|
400
|
+
return NO;
|
|
401
|
+
}
|
|
402
|
+
function sensitive(word, ctx) {
|
|
403
|
+
if (word.length === 0 || word.startsWith("-"))
|
|
404
|
+
return false;
|
|
405
|
+
const abs = absPath(word, ctx);
|
|
406
|
+
return isSecretPath(word, ctx.home) || isSecretPath(abs, ctx.home) || secretKind(abs, ctx.home) !== undefined;
|
|
407
|
+
}
|
|
408
|
+
const READ_CMDS = new Set([
|
|
409
|
+
"cat", "less", "more", "head", "tail", "cp", "scp", "rsync", "base64", "xxd", "strings", "od", "hexdump", "tac", "nl", "bat",
|
|
410
|
+
"jq", "yq", "sed", "awk", "cut", "sort", "tar", "zip",
|
|
411
|
+
]);
|
|
412
|
+
const COPY_CMDS = new Set(["cp", "scp", "rsync"]);
|
|
413
|
+
/** A read verb with a credential file the never-list does not know (or a redirect from one). */
|
|
414
|
+
function secretReadHits(sh, ctx) {
|
|
415
|
+
const hits = [];
|
|
416
|
+
for (const seg of sh.segments) {
|
|
417
|
+
const toks = seg.tokens;
|
|
418
|
+
for (let i = 0; i + 1 < toks.length; i++) {
|
|
419
|
+
if (toks[i] === "<") {
|
|
420
|
+
const abs = absPath(toks[i + 1], ctx);
|
|
421
|
+
const k = secretKind(abs, ctx.home);
|
|
422
|
+
if (k)
|
|
423
|
+
hits.push({ rule: `secret-read-${k}`, target: abs });
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
for (const c of seg.cmds) {
|
|
427
|
+
if (!READ_CMDS.has(c.name))
|
|
428
|
+
continue;
|
|
429
|
+
const args = COPY_CMDS.has(c.name) ? c.args.slice(0, -1) : c.args;
|
|
430
|
+
for (const a of args) {
|
|
431
|
+
if (a.startsWith("-") || a.includes(":") && !a.startsWith("/") && !a.startsWith("~") && !a.startsWith("."))
|
|
432
|
+
continue;
|
|
433
|
+
const abs = absPath(a, ctx);
|
|
434
|
+
const k = secretKind(abs, ctx.home);
|
|
435
|
+
if (k)
|
|
436
|
+
hits.push({ rule: `secret-read-${k}`, target: abs });
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return hits;
|
|
441
|
+
}
|
|
442
|
+
// Built from parts so this source file never contains a literal key header.
|
|
443
|
+
const KEY_HEADER = "-----BEGIN (RSA |OPENSSH |EC |DSA )?" + "PRIVATE KEY-----";
|
|
444
|
+
const SECRET_WRITE_PATTERNS = [
|
|
445
|
+
["aws", /(AKIA|ASIA)[A-Z0-9]{16}/],
|
|
446
|
+
["privkey", new RegExp(KEY_HEADER)],
|
|
447
|
+
["github", /ghp_[A-Za-z0-9]{36}/],
|
|
448
|
+
["github-pat", /github_pat_[A-Za-z0-9_]{60,}/],
|
|
449
|
+
["slack", /xox[abpr]-[A-Za-z0-9-]{10,}/],
|
|
450
|
+
["stripe", /sk_live_[A-Za-z0-9]{20,}/],
|
|
451
|
+
];
|
|
452
|
+
function secretShape(text) {
|
|
453
|
+
for (const w of windowsOf(text)) {
|
|
454
|
+
for (const [kind, re] of SECRET_WRITE_PATTERNS)
|
|
455
|
+
if (re.test(w))
|
|
456
|
+
return kind;
|
|
457
|
+
}
|
|
458
|
+
return NO;
|
|
459
|
+
}
|
|
460
|
+
const OLD_TEXT_KEYS = new Set(["old_string", "old_str", "oldString", "old_text", "oldText"]);
|
|
461
|
+
/** Every string in the input except the text an edit removes and its path. Bounded depth and count. */
|
|
462
|
+
function writtenStrings(v, out = [], depth = 0) {
|
|
463
|
+
if (out.length > 64 || depth > 4)
|
|
464
|
+
return out;
|
|
465
|
+
if (typeof v === "string")
|
|
466
|
+
out.push(v);
|
|
467
|
+
else if (Array.isArray(v))
|
|
468
|
+
for (const x of v)
|
|
469
|
+
writtenStrings(x, out, depth + 1);
|
|
470
|
+
else if (isObj(v))
|
|
471
|
+
for (const [k, x] of Object.entries(v))
|
|
472
|
+
if (!OLD_TEXT_KEYS.has(k) && !/path$/i.test(k))
|
|
473
|
+
writtenStrings(x, out, depth + 1);
|
|
474
|
+
return out;
|
|
475
|
+
}
|
|
476
|
+
/** `echo`, `printf`, `cat` or `tee` that puts secret-shaped text into a file (redirect, heredoc or tee). */
|
|
477
|
+
function shellSecretWrite(sh) {
|
|
478
|
+
if (!hasCommand(sh, (c) => ["echo", "printf", "cat", "tee"].includes(c.name)))
|
|
479
|
+
return NO;
|
|
480
|
+
if (!/>|\btee\b/.test(sh.raw))
|
|
481
|
+
return NO;
|
|
482
|
+
return secretShape(sh.raw);
|
|
483
|
+
}
|
|
484
|
+
const NET_SINKS = new Set(["curl", "wget", "nc", "ncat", "socat", "scp", "rsync"]);
|
|
485
|
+
const LOOPBACK = /^(localhost|.*\.localhost|127(\.\d+){3}|\[?::1\]?|0\.0\.0\.0)$/i;
|
|
486
|
+
function hostOf(token) {
|
|
487
|
+
const url = /^[a-z][a-z0-9+.-]*:\/\/(?:[^@/?#]*@)?(\[[^\]]+\]|[^:/?#]+)/i.exec(token);
|
|
488
|
+
if (url)
|
|
489
|
+
return url[1];
|
|
490
|
+
return NO;
|
|
491
|
+
}
|
|
492
|
+
const SINK_VALUE_OPTS = new Set([
|
|
493
|
+
"-p", "-s", "-w", "-i", "-o", "-e", "-u", "-X", "-H", "-d", "-F", "-T", "-A", "-x", "-b", "-c", "-m",
|
|
494
|
+
"--data", "--data-binary", "--data-raw", "--data-urlencode", "--header", "--output", "--request", "--form", "--upload-file",
|
|
495
|
+
"--user", "--user-agent", "--referer", "--cookie", "--cookie-jar", "--proxy", "--write-out", "--max-time", "--connect-timeout",
|
|
496
|
+
"--post-data", "--post-file", "--body-data", "--body-file", "--output-document",
|
|
497
|
+
]);
|
|
498
|
+
/**
|
|
499
|
+
* Destination hosts named in a sink command. Empty when none could be read. For curl and wget every operand
|
|
500
|
+
* counts (scheme URLs, `--url X` and bare hostnames), so one loopback URL cannot vouch for a second target.
|
|
501
|
+
*/
|
|
502
|
+
function sinkHosts(c) {
|
|
503
|
+
const hosts = [];
|
|
504
|
+
const { pos } = words(c.args, SINK_VALUE_OPTS);
|
|
505
|
+
for (const a of c.args) {
|
|
506
|
+
const h = hostOf(a);
|
|
507
|
+
if (h)
|
|
508
|
+
hosts.push(h);
|
|
509
|
+
}
|
|
510
|
+
if (c.name === "curl" || c.name === "wget") {
|
|
511
|
+
for (const p of pos)
|
|
512
|
+
if (!hostOf(p))
|
|
513
|
+
hosts.push(p.replace(/[:/].*$/, ""));
|
|
514
|
+
return hosts;
|
|
515
|
+
}
|
|
516
|
+
if (hosts.length)
|
|
517
|
+
return hosts;
|
|
518
|
+
if (c.name === "nc" || c.name === "ncat")
|
|
519
|
+
return pos[0] ? [pos[0]] : [];
|
|
520
|
+
if (c.name === "socat") {
|
|
521
|
+
for (const a of pos) {
|
|
522
|
+
const m = /^(?:TCP|TCP4|TCP6|UDP|SSL)[:-]([^:]+)/i.exec(a);
|
|
523
|
+
if (m)
|
|
524
|
+
hosts.push(m[1]);
|
|
525
|
+
}
|
|
526
|
+
return hosts;
|
|
527
|
+
}
|
|
528
|
+
if (c.name === "scp" || c.name === "rsync") {
|
|
529
|
+
for (const a of pos) {
|
|
530
|
+
const m = /^(?:[^@/:]+@)?([^:/@]+):/.exec(a);
|
|
531
|
+
if (m && !a.startsWith("/") && !a.startsWith("."))
|
|
532
|
+
hosts.push(m[1]);
|
|
533
|
+
}
|
|
534
|
+
return hosts;
|
|
535
|
+
}
|
|
536
|
+
return hosts;
|
|
537
|
+
}
|
|
538
|
+
/** Options that send the traffic somewhere other than the URL's host. Their presence voids the loopback exception. */
|
|
539
|
+
const REDIRECTING_OPT = /^(-x|--proxy|--preproxy|--connect-to|--resolve|--socks[45]?(-hostname)?|--proxy-.*|--no-check-certificate|-e|--execute)(=|$)/;
|
|
540
|
+
const isLoopbackOnly = (c) => {
|
|
541
|
+
if (c.args.some((a) => REDIRECTING_OPT.test(a)))
|
|
542
|
+
return false;
|
|
543
|
+
const hosts = sinkHosts(c);
|
|
544
|
+
return hosts.length > 0 && hosts.every((h) => LOOPBACK.test(h));
|
|
545
|
+
};
|
|
546
|
+
/** A segment that starts a pipeline with the process environment: `env`, `printenv`, bare `set`. */
|
|
547
|
+
function envSource(toks) {
|
|
548
|
+
const first = toks.find((t) => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(t));
|
|
549
|
+
if (first === "printenv")
|
|
550
|
+
return true;
|
|
551
|
+
if (first === "env" || first === "set")
|
|
552
|
+
return toks.slice(toks.indexOf(first) + 1).every((t) => t.startsWith("-"));
|
|
553
|
+
return false;
|
|
554
|
+
}
|
|
555
|
+
const ENV_SUBST = /\$\(\s*(?:env|printenv)\b|`\s*(?:env|printenv)\b/;
|
|
556
|
+
/** A sensitive file passed to a sink as data: `-d @f`, `-F f=@f`, `-T f`, `< f`, or the source of scp/rsync. */
|
|
557
|
+
function sinkTakesFile(c, toks, ctx) {
|
|
558
|
+
const a = c.args;
|
|
559
|
+
for (let i = 0; i < a.length; i++) {
|
|
560
|
+
const t = a[i];
|
|
561
|
+
const at = t.indexOf("@");
|
|
562
|
+
if ((c.name === "curl" || c.name === "wget") && at >= 0 && t.slice(at + 1) !== "-" && sensitive(t.slice(at + 1), ctx))
|
|
563
|
+
return true;
|
|
564
|
+
if ((t === "-T" || t === "--upload-file") && i + 1 < a.length && sensitive(a[i + 1], ctx))
|
|
565
|
+
return true;
|
|
566
|
+
}
|
|
567
|
+
for (let i = 0; i + 1 < toks.length; i++)
|
|
568
|
+
if (toks[i] === "<" && sensitive(toks[i + 1], ctx))
|
|
569
|
+
return true;
|
|
570
|
+
if (c.name === "scp" || c.name === "rsync") {
|
|
571
|
+
return a.slice(0, -1).some((t) => !t.startsWith("-") && !/^[^/.~][^/]*:/.test(t) && sensitive(t, ctx));
|
|
572
|
+
}
|
|
573
|
+
return false;
|
|
574
|
+
}
|
|
575
|
+
function exfilHit(sh, ctx) {
|
|
576
|
+
let tainted = false;
|
|
577
|
+
for (const seg of sh.segments) {
|
|
578
|
+
if (!seg.pipeIn)
|
|
579
|
+
tainted = false;
|
|
580
|
+
const toks = seg.tokens;
|
|
581
|
+
const sink = seg.cmds.find((c) => NET_SINKS.has(c.name));
|
|
582
|
+
if (sink) {
|
|
583
|
+
const flowing = tainted || sinkTakesFile(sink, toks, ctx) || toks.some((t) => ENV_SUBST.test(t));
|
|
584
|
+
if (flowing && !isLoopbackOnly(sink))
|
|
585
|
+
return sink.name;
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
if (envSource(toks))
|
|
589
|
+
tainted = true;
|
|
590
|
+
else if (seg.cmds.some((c) => READ_CMDS.has(c.name) && c.args.some((a) => sensitive(a, ctx))))
|
|
591
|
+
tainted = true;
|
|
592
|
+
}
|
|
593
|
+
return NO;
|
|
594
|
+
}
|
|
595
|
+
// ---------------------------------------------------------------------------------------------
|
|
596
|
+
// Entry points
|
|
597
|
+
// ---------------------------------------------------------------------------------------------
|
|
598
|
+
const WRITE_CMDS = new Set(["tee", "cp", "mv", "rm", "ln", "dd", "install", "truncate", "touch", "rsync", "scp", "chmod", "chown"]);
|
|
599
|
+
/** The user-level guard config. An agent that can edit it can lift its own deny, so writes to it are always denied. */
|
|
600
|
+
function guardConfigPath(ctx) {
|
|
601
|
+
return ctx.home ? `${ctx.home}/.kya/config.json` : undefined;
|
|
602
|
+
}
|
|
603
|
+
/** A shell command that writes, moves, deletes or redirects into the user-level guard config (or its folder). */
|
|
604
|
+
function shellWritesGuardConfig(sh, ctx) {
|
|
605
|
+
const target = guardConfigPath(ctx);
|
|
606
|
+
if (!target)
|
|
607
|
+
return false;
|
|
608
|
+
const dir = target.slice(0, target.lastIndexOf("/"));
|
|
609
|
+
for (const seg of sh.segments) {
|
|
610
|
+
const toks = seg.tokens;
|
|
611
|
+
const writes = seg.cmds.some((c) => WRITE_CMDS.has(c.name) || (c.name === "sed" && c.args.some((a) => /^-[A-Za-z]*i/.test(a) || a.startsWith("--in-place"))));
|
|
612
|
+
for (let i = 0; i < toks.length; i++) {
|
|
613
|
+
if (!writes && toks[i - 1] !== ">" && toks[i - 1] !== ">>")
|
|
614
|
+
continue;
|
|
615
|
+
const t = toks[i];
|
|
616
|
+
const abs = absPath(t.startsWith("of=") ? t.slice(3) : t, ctx);
|
|
617
|
+
// A glob that can expand to the config or its folder counts (`~/.k*/config.json`).
|
|
618
|
+
const hit = /[*?[]/.test(abs) ? dotGlobMatch(abs, target) || dotGlobMatch(abs, dir) : abs === target || abs === dir;
|
|
619
|
+
if (hit)
|
|
620
|
+
return true;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
return false;
|
|
624
|
+
}
|
|
625
|
+
function* shellHits(sh, ctx, cfg) {
|
|
626
|
+
// Always on, no switch and no allowlist: the config cannot be used to relax itself.
|
|
627
|
+
if (shellWritesGuardConfig(sh, ctx))
|
|
628
|
+
yield { rule: "guard-config-write", code: "NEVER_SHELL" };
|
|
629
|
+
if (cfg.shellNeverList) {
|
|
630
|
+
for (const [rule, m] of SHELL_MATCHERS) {
|
|
631
|
+
const t = m(sh, ctx);
|
|
632
|
+
if (t === undefined)
|
|
633
|
+
continue;
|
|
634
|
+
const targets = typeof t === "string" ? [t] : t;
|
|
635
|
+
for (const target of targets)
|
|
636
|
+
yield { rule, code: "NEVER_SHELL", ...(target ? { target } : {}) };
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
if (cfg.secretsGuard) {
|
|
640
|
+
for (const r of secretReadHits(sh, ctx))
|
|
641
|
+
yield { rule: r.rule, code: "NEVER_SECRET_READ", target: r.target };
|
|
642
|
+
const kind = shellSecretWrite(sh);
|
|
643
|
+
if (kind)
|
|
644
|
+
yield { rule: `secret-write-${kind}`, code: "NEVER_SECRET_WRITE" };
|
|
645
|
+
}
|
|
646
|
+
if (cfg.exfilGuard) {
|
|
647
|
+
const tool = exfilHit(sh, ctx);
|
|
648
|
+
if (tool)
|
|
649
|
+
yield { rule: `exfil-${tool}`, code: "NEVER_EXFIL" };
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* First guard-pack hit for this tool call that `guardAllow` does not skip, or undefined (allow).
|
|
654
|
+
* Shell tools: the command. Tools that read: the path. Tools that write: the written text.
|
|
655
|
+
* Never throws.
|
|
656
|
+
*/
|
|
657
|
+
export function guardPackMatch(toolId, toolInput, ctx, cfg = GUARD_DEFAULTS) {
|
|
658
|
+
try {
|
|
659
|
+
const f = fieldsOf(toolInput);
|
|
660
|
+
if (!f)
|
|
661
|
+
return NO;
|
|
662
|
+
if (isShellTool(toolId)) {
|
|
663
|
+
for (const cmd of commandStrings(f)) {
|
|
664
|
+
const sh = parseShell(cmd, ctx.home);
|
|
665
|
+
for (const hit of shellHits(sh, ctx, cfg))
|
|
666
|
+
if (!allowed(hit, cfg, ctx, cmd))
|
|
667
|
+
return hit;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
const path = typeof f.file_path === "string" ? f.file_path : typeof f.path === "string" ? f.path : undefined;
|
|
671
|
+
const gcp = guardConfigPath(ctx);
|
|
672
|
+
if (gcp && isWriteLike(toolId)) {
|
|
673
|
+
// Every path field, and the symlink-resolved path when the file exists.
|
|
674
|
+
for (const p of pathStrings(f, toolId)) {
|
|
675
|
+
const abs = absPath(p, ctx);
|
|
676
|
+
let real = abs;
|
|
677
|
+
try {
|
|
678
|
+
real = realpathSync(abs);
|
|
679
|
+
}
|
|
680
|
+
catch { /* not there yet */ }
|
|
681
|
+
if (abs === gcp || real === gcp)
|
|
682
|
+
return { rule: "guard-config-write", code: "NEVER_SHELL", target: gcp };
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
if (!cfg.secretsGuard)
|
|
686
|
+
return NO;
|
|
687
|
+
if (isWriteLike(toolId)) {
|
|
688
|
+
for (const text of writtenStrings(f)) {
|
|
689
|
+
const kind = secretShape(text);
|
|
690
|
+
if (!kind)
|
|
691
|
+
continue;
|
|
692
|
+
const hit = { rule: `secret-write-${kind}`, code: "NEVER_SECRET_WRITE", ...(path ? { target: absPath(path, ctx) } : {}) };
|
|
693
|
+
if (!allowed(hit, cfg, ctx, undefined))
|
|
694
|
+
return hit;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
else {
|
|
698
|
+
for (const p of pathStrings(f, toolId)) {
|
|
699
|
+
const abs = absPath(p, ctx);
|
|
700
|
+
const kind = secretKind(abs, ctx.home);
|
|
701
|
+
if (!kind)
|
|
702
|
+
continue;
|
|
703
|
+
const hit = { rule: `secret-read-${kind}`, code: "NEVER_SECRET_READ", target: abs };
|
|
704
|
+
if (!allowed(hit, cfg, ctx, undefined))
|
|
705
|
+
return hit;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
catch {
|
|
710
|
+
// Fail open: a parse problem never adds a deny.
|
|
711
|
+
}
|
|
712
|
+
return NO;
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Rule id of the first shell-family rule that matches a command, or null. Shell rules only
|
|
716
|
+
* (no secrets, no allowlist), so a caller can ask the one question the spec names.
|
|
717
|
+
*/
|
|
718
|
+
export function matchesShellNeverList(command, ctx) {
|
|
719
|
+
try {
|
|
720
|
+
const sh = parseShell(command, ctx.home);
|
|
721
|
+
for (const hit of shellHits(sh, ctx, { ...GUARD_DEFAULTS, secretsGuard: false, exfilGuard: false }))
|
|
722
|
+
return hit.rule;
|
|
723
|
+
}
|
|
724
|
+
catch { /* fail open */ }
|
|
725
|
+
return null;
|
|
726
|
+
}
|
|
727
|
+
const HINTS = {
|
|
728
|
+
"rm-root-home": "Delete the specific project folder instead, with an exact path.",
|
|
729
|
+
"terraform-destroy": "Run terraform plan -destroy and ask the user to run the destroy themselves.",
|
|
730
|
+
"sql-drop": "Do not drop or truncate through the agent. Ask the user to run it, after a backup.",
|
|
731
|
+
"git-force-push": "Use git push --force-with-lease, or ask the user to force push.",
|
|
732
|
+
"git-delete-default-branch": "Never delete main or master through the agent. Ask the user.",
|
|
733
|
+
"git-hard-reset-clean": "git reset --hard with git clean -fdx wipes uncommitted work. Ask the user, or stash first.",
|
|
734
|
+
"docker-volumes": "Volumes hold data. List them with docker volume ls and ask the user to remove one.",
|
|
735
|
+
"k8s-delete-ns": "Deleting a namespace or everything in all namespaces needs the user to run it.",
|
|
736
|
+
"cloud-delete": "Cloud deletes are not reversible. Ask the user to run this.",
|
|
737
|
+
"railway-volume-delete": "Volume deletes are not reversible. Ask the user to run this.",
|
|
738
|
+
"guard-config-write": "Do not edit the KYA guard settings. Ask the user.",
|
|
739
|
+
};
|
|
740
|
+
/** Actionable text for a deny, from the rule id alone (no command text, no matched secret). */
|
|
741
|
+
export function guardHint(rule) {
|
|
742
|
+
const fixed = HINTS[rule];
|
|
743
|
+
if (fixed)
|
|
744
|
+
return fixed;
|
|
745
|
+
if (rule.startsWith("secret-read-"))
|
|
746
|
+
return "Do not read credential files. Ask the user for the specific value you need.";
|
|
747
|
+
if (rule.startsWith("secret-write-"))
|
|
748
|
+
return "Do not write secrets into files. Read the value from an environment variable instead.";
|
|
749
|
+
if (rule.startsWith("exfil-"))
|
|
750
|
+
return "Do not send environment variables or credential files over the network.";
|
|
751
|
+
return "";
|
|
752
|
+
}
|
|
753
|
+
//# sourceMappingURL=guard-pack.js.map
|