@clawops/cli 2.0.1 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +862 -807
- package/dist/{apply-KYFPWI5U.js → apply-HJ5GAKHW.js} +7 -7
- package/dist/{automation-RMAIBNVC.js → automation-MKCJ46I6.js} +3 -3
- package/dist/{bootstrap-HNGKY6WW.js → bootstrap-NN647RCY.js} +2 -2
- package/dist/{chunk-HSPHXG6W.js → chunk-3Z5TDXCJ.js} +2 -2
- package/dist/{chunk-CX5SL5HP.js → chunk-64PONSA5.js} +10 -1
- package/dist/{chunk-STLRHLLV.js → chunk-A2DS5SB7.js} +12 -9
- package/dist/{chunk-KC3GC3PE.js → chunk-AYUX4FOF.js} +127 -13
- package/dist/{chunk-LEVIW2PV.js → chunk-BELTQGHT.js} +16 -2
- package/dist/{preflight-MWWM5JT3.js → chunk-BTW7NHOH.js} +3 -2
- package/dist/{chunk-A2I76FTA.js → chunk-ELU3XQEA.js} +1 -1
- package/dist/{chunk-IVWP4QUJ.js → chunk-GFUK6KDR.js} +10 -10
- package/dist/{chunk-W3OLNNJW.js → chunk-PSOSBJVO.js} +1 -1
- package/dist/{chunk-5XT5IAJK.js → chunk-QDNFADJI.js} +12 -5
- package/dist/{chunk-LBE6COC4.js → chunk-R3DTA35B.js} +22 -3
- package/dist/{preflight-EF52S3WW.js → chunk-TC2VYPLH.js} +3 -2
- package/dist/chunk-X56CQ6GB.js +157 -0
- package/dist/{chunk-BSLIYB5Q.js → chunk-XFN3YDZE.js} +1 -1
- package/dist/{cli-WLD3EBEM.js → cli-NH7VUNOU.js} +1 -1
- package/dist/cli.js +156 -82
- package/dist/{context-7EVC6W45.js → context-7VI3R6EK.js} +4 -4
- package/dist/flows-RI7FGHAM.js +120 -0
- package/dist/{generate-I6YQ5V2T.js → generate-YEM73KVB.js} +6 -6
- package/dist/{harden-72JMRT4E.js → harden-3KKCR7BL.js} +541 -3
- package/dist/{mcp-wire-3SWBM7D6.js → mcp-wire-TE2ZL6HD.js} +10 -10
- package/dist/{package-5BCMEBTE.js → package-JWDMWPV3.js} +47 -10
- package/dist/preflight-7W23GP4R.js +19 -0
- package/dist/preflight-IE2YWBAC.js +13 -0
- package/dist/private-only-DIA26I2E.js +74 -0
- package/dist/{server-GBA7BJGK.js → server-ZJPXPRZT.js} +10 -10
- package/dist/{stack-config-R4SPSUQZ.js → stack-config-LUTS2XAD.js} +2 -2
- package/dist/{state-PRBIIN7I.js → state-AOAYLYY5.js} +2 -2
- package/dist/{store-SDUR52Z5.js → store-OVB3DAJR.js} +1 -1
- package/dist/tailscale-cutover-6YP2QKFM.js +113 -0
- package/dist/{validate-YVI3BJ55.js → validate-JXKN5WD6.js} +1 -1
- package/package.json +39 -8
- package/spec/deploy-plan.schema.json +16 -1
- package/spec/mcp-tools.yaml +67 -9
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
makeTailscaleModule,
|
|
4
|
+
tailscaleModule
|
|
5
|
+
} from "./chunk-X56CQ6GB.js";
|
|
2
6
|
import {
|
|
3
7
|
acquireSession,
|
|
4
8
|
drainPool
|
|
5
9
|
} from "./chunk-2Z7J3ERQ.js";
|
|
6
10
|
import "./chunk-E4NJSJ54.js";
|
|
7
11
|
import "./chunk-MC5ATCXS.js";
|
|
12
|
+
import {
|
|
13
|
+
managementToken
|
|
14
|
+
} from "./chunk-BTW7NHOH.js";
|
|
8
15
|
import {
|
|
9
16
|
PUBLISH_INSPECT_CMD,
|
|
10
17
|
publishForRestart,
|
|
@@ -14,12 +21,24 @@ import {
|
|
|
14
21
|
GATEWAY_PORT
|
|
15
22
|
} from "./chunk-VOEJYRIK.js";
|
|
16
23
|
import "./chunk-EI7CQQ7D.js";
|
|
24
|
+
import "./chunk-FEK7VJAY.js";
|
|
25
|
+
import {
|
|
26
|
+
resolveSubscriptionId
|
|
27
|
+
} from "./chunk-RHOWYIWT.js";
|
|
28
|
+
import {
|
|
29
|
+
resolveProjectId
|
|
30
|
+
} from "./chunk-TC2VYPLH.js";
|
|
31
|
+
import "./chunk-64PONSA5.js";
|
|
17
32
|
import "./chunk-KGXPLI7W.js";
|
|
18
33
|
|
|
19
34
|
// src/harden/types.ts
|
|
20
35
|
var SENTINEL_DIR = "/etc/clawops/hardening";
|
|
21
36
|
|
|
22
37
|
// src/harden/runner.ts
|
|
38
|
+
import { Readable } from "stream";
|
|
39
|
+
function remoteExecFor(session, signal) {
|
|
40
|
+
return (command, execOpts) => execOpts?.stdin === void 0 ? session.exec(command, execOpts?.signal ?? signal) : session.execWithInput(command, Readable.from([execOpts.stdin]), execOpts.signal ?? signal);
|
|
41
|
+
}
|
|
23
42
|
async function runHardening(conn, opts) {
|
|
24
43
|
const { session, release } = await acquireSession({
|
|
25
44
|
host: conn.host,
|
|
@@ -29,7 +48,7 @@ async function runHardening(conn, opts) {
|
|
|
29
48
|
knownHostsPath: conn.knownHostsPath,
|
|
30
49
|
signal: opts.signal
|
|
31
50
|
});
|
|
32
|
-
const exec = (
|
|
51
|
+
const exec = remoteExecFor(session, opts.signal);
|
|
33
52
|
const results = [];
|
|
34
53
|
try {
|
|
35
54
|
for (const mod of opts.modules) {
|
|
@@ -73,7 +92,7 @@ async function withRemoteExec(conn, signal, fn) {
|
|
|
73
92
|
signal
|
|
74
93
|
});
|
|
75
94
|
try {
|
|
76
|
-
return await fn((
|
|
95
|
+
return await fn(remoteExecFor(session, signal));
|
|
77
96
|
} finally {
|
|
78
97
|
release();
|
|
79
98
|
}
|
|
@@ -747,6 +766,505 @@ var awsGuardDutyModule = {
|
|
|
747
766
|
}
|
|
748
767
|
};
|
|
749
768
|
|
|
769
|
+
// src/harden/gcp-api.ts
|
|
770
|
+
async function gcpContext() {
|
|
771
|
+
const project = resolveProjectId();
|
|
772
|
+
if (!project) return void 0;
|
|
773
|
+
try {
|
|
774
|
+
const { GoogleAuth } = await import("google-auth-library");
|
|
775
|
+
const auth = new GoogleAuth({ scopes: ["https://www.googleapis.com/auth/cloud-platform"] });
|
|
776
|
+
const client = await auth.getClient();
|
|
777
|
+
const raw = await client.getAccessToken();
|
|
778
|
+
const token = typeof raw === "string" ? raw : raw.token ?? void 0;
|
|
779
|
+
return token ? { project, token } : void 0;
|
|
780
|
+
} catch {
|
|
781
|
+
return void 0;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
async function computeGet(ctx, pathAndQuery, signal) {
|
|
785
|
+
try {
|
|
786
|
+
const res = await fetch(
|
|
787
|
+
`https://compute.googleapis.com/compute/v1/projects/${ctx.project}/${pathAndQuery}`,
|
|
788
|
+
{ headers: { authorization: `Bearer ${ctx.token}` }, signal }
|
|
789
|
+
);
|
|
790
|
+
if (!res.ok) return void 0;
|
|
791
|
+
return await res.json();
|
|
792
|
+
} catch {
|
|
793
|
+
return void 0;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
async function findClawopsInstance(ctx, signal) {
|
|
797
|
+
const body = await computeGet(ctx, "aggregated/instances", signal);
|
|
798
|
+
for (const scope of Object.values(body?.items ?? {})) {
|
|
799
|
+
for (const inst of scope.instances ?? []) {
|
|
800
|
+
if (isClawopsInstance(inst.name)) return inst;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
return void 0;
|
|
804
|
+
}
|
|
805
|
+
function isClawopsInstance(name) {
|
|
806
|
+
return typeof name === "string" && name.startsWith("clawops-instance");
|
|
807
|
+
}
|
|
808
|
+
function metadataValue(inst, key) {
|
|
809
|
+
return inst.metadata?.items?.find((i) => i.key === key)?.value;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// src/harden/modules/gcp-firewall-audit.ts
|
|
813
|
+
var WORLD2 = /* @__PURE__ */ new Set(["0.0.0.0/0", "::/0"]);
|
|
814
|
+
var NAMED_PORTS2 = { "22": "SSH", [String(GATEWAY_PORT)]: "the OpenClaw gateway" };
|
|
815
|
+
function describePorts(allowed) {
|
|
816
|
+
const parts = [];
|
|
817
|
+
for (const rule of allowed ?? []) {
|
|
818
|
+
const proto = rule.IPProtocol ?? "all";
|
|
819
|
+
if (!rule.ports || rule.ports.length === 0) {
|
|
820
|
+
parts.push(`all ${proto} ports`);
|
|
821
|
+
continue;
|
|
822
|
+
}
|
|
823
|
+
for (const p of rule.ports) {
|
|
824
|
+
const named = NAMED_PORTS2[p];
|
|
825
|
+
parts.push(named ? `port ${p} (${named})` : `port ${p}`);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
return parts.length > 0 ? parts.join(", ") : "no ports";
|
|
829
|
+
}
|
|
830
|
+
function networkName(selfLink) {
|
|
831
|
+
return selfLink.split("/").pop() ?? "";
|
|
832
|
+
}
|
|
833
|
+
function openFindings(firewalls) {
|
|
834
|
+
const out = [];
|
|
835
|
+
for (const fw of firewalls) {
|
|
836
|
+
if (fw.disabled) continue;
|
|
837
|
+
if ((fw.direction ?? "INGRESS") !== "INGRESS") continue;
|
|
838
|
+
if (!networkName(fw.network ?? "").startsWith("clawops-")) continue;
|
|
839
|
+
const world = (fw.sourceRanges ?? []).filter((r) => WORLD2.has(r));
|
|
840
|
+
if (world.length === 0) continue;
|
|
841
|
+
out.push(`${fw.name ?? "unnamed rule"} admits ${world.join(" and ")} on ${describePorts(fw.allowed)}`);
|
|
842
|
+
}
|
|
843
|
+
return out;
|
|
844
|
+
}
|
|
845
|
+
var gcpFirewallAuditModule = {
|
|
846
|
+
id: "gcp-firewall-audit",
|
|
847
|
+
label: "GCP firewall audit (check-only)",
|
|
848
|
+
defaultOn: true,
|
|
849
|
+
providers: ["gcp"],
|
|
850
|
+
async check(_exec) {
|
|
851
|
+
const ctx = await gcpContext();
|
|
852
|
+
if (!ctx) {
|
|
853
|
+
return {
|
|
854
|
+
status: "skipped",
|
|
855
|
+
detail: "No GCP credentials or project resolved, so the firewall could not be read. This says nothing about the rules themselves."
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
const body = await computeGet(ctx, "global/firewalls");
|
|
859
|
+
if (!body) {
|
|
860
|
+
return {
|
|
861
|
+
status: "skipped",
|
|
862
|
+
detail: `Could not list firewall rules in ${ctx.project}. The identity needs compute.firewalls.list; nothing else clawops does requires it.`
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
const findings = openFindings(body.items ?? []);
|
|
866
|
+
return findings.length === 0 ? { status: "applied", detail: `No clawops firewall rule admits the internet in ${ctx.project}.` } : { status: "drifted", detail: `Open to the internet: ${findings.join("; ")}.` };
|
|
867
|
+
},
|
|
868
|
+
async apply(_exec) {
|
|
869
|
+
return {
|
|
870
|
+
changed: false,
|
|
871
|
+
detail: "The firewall audit only reports. Narrow the rule in your plan with --ssh-cidr or --gateway-cidr and re-apply, so the change survives the next deploy."
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
};
|
|
875
|
+
|
|
876
|
+
// src/harden/modules/gcp-shielded-vm.ts
|
|
877
|
+
function missingProtections(s) {
|
|
878
|
+
const out = [];
|
|
879
|
+
if (!s.secureBoot) out.push("Secure Boot");
|
|
880
|
+
if (!s.vtpm) out.push("vTPM");
|
|
881
|
+
if (!s.integrityMonitoring) out.push("integrity monitoring");
|
|
882
|
+
return out;
|
|
883
|
+
}
|
|
884
|
+
var gcpShieldedVmModule = {
|
|
885
|
+
id: "gcp-shielded-vm",
|
|
886
|
+
label: "GCP Shielded VM check (check-only)",
|
|
887
|
+
defaultOn: true,
|
|
888
|
+
providers: ["gcp"],
|
|
889
|
+
async check(_exec) {
|
|
890
|
+
const ctx = await gcpContext();
|
|
891
|
+
if (!ctx) {
|
|
892
|
+
return { status: "skipped", detail: "No GCP credentials or project resolved, so the instance could not be read." };
|
|
893
|
+
}
|
|
894
|
+
const inst = await findClawopsInstance(ctx);
|
|
895
|
+
if (!inst) {
|
|
896
|
+
return {
|
|
897
|
+
status: "skipped",
|
|
898
|
+
detail: `No instance named clawops-instance found in ${ctx.project}. Either the stack is not deployed there or the identity cannot list instances.`
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
const cfg = inst.shieldedInstanceConfig ?? {};
|
|
902
|
+
const state = {
|
|
903
|
+
secureBoot: cfg.enableSecureBoot === true,
|
|
904
|
+
vtpm: cfg.enableVtpm === true,
|
|
905
|
+
integrityMonitoring: cfg.enableIntegrityMonitoring === true
|
|
906
|
+
};
|
|
907
|
+
const missing = missingProtections(state);
|
|
908
|
+
return missing.length === 0 ? { status: "applied", detail: "Secure Boot, vTPM and integrity monitoring are all on." } : {
|
|
909
|
+
status: "missing",
|
|
910
|
+
detail: `Not enabled: ${missing.join(", ")}. Turning these on requires stopping the instance, so clawops does not do it to a running gateway.`
|
|
911
|
+
};
|
|
912
|
+
},
|
|
913
|
+
async apply(_exec) {
|
|
914
|
+
return {
|
|
915
|
+
changed: false,
|
|
916
|
+
detail: "Shielded VM settings cannot be changed on a running instance. Stop the instance and set them, accepting the downtime, or recreate the stack with them enabled."
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
};
|
|
920
|
+
|
|
921
|
+
// src/harden/modules/gcp-os-login.ts
|
|
922
|
+
function osLoginEnabled(value) {
|
|
923
|
+
if (value === void 0) return false;
|
|
924
|
+
const v = value.trim().toLowerCase();
|
|
925
|
+
return v === "true" || v === "1" || v === "yes";
|
|
926
|
+
}
|
|
927
|
+
var gcpOsLoginModule = {
|
|
928
|
+
id: "gcp-os-login",
|
|
929
|
+
label: "GCP OS Login check (check-only)",
|
|
930
|
+
defaultOn: false,
|
|
931
|
+
providers: ["gcp"],
|
|
932
|
+
async check(_exec) {
|
|
933
|
+
const ctx = await gcpContext();
|
|
934
|
+
if (!ctx) {
|
|
935
|
+
return { status: "skipped", detail: "No GCP credentials or project resolved, so OS Login could not be read." };
|
|
936
|
+
}
|
|
937
|
+
const inst = await findClawopsInstance(ctx);
|
|
938
|
+
if (!inst) {
|
|
939
|
+
return { status: "skipped", detail: `No instance named clawops-instance found in ${ctx.project}.` };
|
|
940
|
+
}
|
|
941
|
+
const enabled = osLoginEnabled(metadataValue(inst, "enable-oslogin"));
|
|
942
|
+
return enabled ? {
|
|
943
|
+
status: "drifted",
|
|
944
|
+
detail: "OS Login is enabled, so the instance ignores metadata SSH keys. clawops authenticates with a metadata key, so its day-two commands will not connect unless your identity has roles/compute.osLogin and you reach the host another way."
|
|
945
|
+
} : {
|
|
946
|
+
status: "applied",
|
|
947
|
+
detail: "OS Login is off, which is what clawops key-based access requires. Enabling it is a stronger posture and would cut clawops off from this host."
|
|
948
|
+
};
|
|
949
|
+
},
|
|
950
|
+
async apply(_exec) {
|
|
951
|
+
return {
|
|
952
|
+
changed: false,
|
|
953
|
+
detail: "clawops will not enable OS Login: the instance would stop accepting the metadata key clawops connects with, and every day-two command would fail."
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
};
|
|
957
|
+
|
|
958
|
+
// src/harden/azure-api.ts
|
|
959
|
+
var ARM = "https://management.azure.com";
|
|
960
|
+
async function azureContext(signal) {
|
|
961
|
+
const subscriptionId = resolveSubscriptionId();
|
|
962
|
+
if (!subscriptionId) return void 0;
|
|
963
|
+
const token = await managementToken(signal);
|
|
964
|
+
return token ? { subscriptionId, token } : void 0;
|
|
965
|
+
}
|
|
966
|
+
async function armGet(ctx, pathAndQuery, signal) {
|
|
967
|
+
let res;
|
|
968
|
+
try {
|
|
969
|
+
res = await fetch(`${ARM}/subscriptions/${ctx.subscriptionId}${pathAndQuery}`, {
|
|
970
|
+
headers: { authorization: `Bearer ${ctx.token}` },
|
|
971
|
+
signal: signal ?? AbortSignal.timeout(2e4)
|
|
972
|
+
});
|
|
973
|
+
} catch {
|
|
974
|
+
return { ok: false, reason: "error" };
|
|
975
|
+
}
|
|
976
|
+
if (res.ok) return { ok: true, body: await res.json() };
|
|
977
|
+
if (res.status === 403) return { ok: false, reason: "forbidden" };
|
|
978
|
+
if (res.status === 404) {
|
|
979
|
+
const body = await res.text().catch(() => "");
|
|
980
|
+
if (/not registered/i.test(body)) {
|
|
981
|
+
const ns = /providers\/([^/?]+)/.exec(pathAndQuery)?.[1] ?? "the resource provider";
|
|
982
|
+
return { ok: false, reason: "unregistered", namespace: ns };
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
return { ok: false, reason: "error", status: res.status };
|
|
986
|
+
}
|
|
987
|
+
function explainFailure(f, subject, permission) {
|
|
988
|
+
if (f.reason === "unregistered") {
|
|
989
|
+
return `The ${f.namespace} resource provider is not registered on this subscription, so ${subject} could not be read. Register it with \`az provider register --namespace ${f.namespace}\`, which costs nothing and turns nothing on by itself.`;
|
|
990
|
+
}
|
|
991
|
+
if (f.reason === "forbidden") {
|
|
992
|
+
return `The identity is not allowed to read ${subject}. It needs ${permission}; nothing else clawops does requires it.`;
|
|
993
|
+
}
|
|
994
|
+
return `Could not read ${subject}${f.status ? ` (HTTP ${f.status})` : ""}.`;
|
|
995
|
+
}
|
|
996
|
+
async function providerRegistered(ctx, namespace, signal) {
|
|
997
|
+
const r = await armGet(
|
|
998
|
+
ctx,
|
|
999
|
+
`/providers/${namespace}?api-version=2021-04-01`,
|
|
1000
|
+
signal
|
|
1001
|
+
);
|
|
1002
|
+
if (!r.ok) return void 0;
|
|
1003
|
+
return (r.body.registrationState ?? "").toLowerCase() === "registered";
|
|
1004
|
+
}
|
|
1005
|
+
async function armGetById(ctx, id, apiVersion, signal) {
|
|
1006
|
+
try {
|
|
1007
|
+
const res = await fetch(`${ARM}${id}?api-version=${apiVersion}`, {
|
|
1008
|
+
headers: { authorization: `Bearer ${ctx.token}` },
|
|
1009
|
+
signal: signal ?? AbortSignal.timeout(2e4)
|
|
1010
|
+
});
|
|
1011
|
+
if (!res.ok) return void 0;
|
|
1012
|
+
return await res.json();
|
|
1013
|
+
} catch {
|
|
1014
|
+
return void 0;
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
function isClawopsResource(name, prefix) {
|
|
1018
|
+
return typeof name === "string" && name.startsWith(prefix);
|
|
1019
|
+
}
|
|
1020
|
+
async function findClawopsVm(ctx, signal) {
|
|
1021
|
+
const r = await armGet(
|
|
1022
|
+
ctx,
|
|
1023
|
+
"/providers/Microsoft.Compute/virtualMachines?api-version=2023-09-01",
|
|
1024
|
+
signal
|
|
1025
|
+
);
|
|
1026
|
+
if (!r.ok) return void 0;
|
|
1027
|
+
return (r.body.value ?? []).find((vm) => isClawopsResource(vm.name, "clawops-vm"));
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// src/harden/modules/azure-nsg-audit.ts
|
|
1031
|
+
var WORLD3 = /* @__PURE__ */ new Set(["*", "internet", "any", "0.0.0.0/0", "::/0"]);
|
|
1032
|
+
var NAMED_PORTS3 = {
|
|
1033
|
+
"22": "SSH",
|
|
1034
|
+
[String(GATEWAY_PORT)]: "the OpenClaw gateway"
|
|
1035
|
+
};
|
|
1036
|
+
function worldSources(rule) {
|
|
1037
|
+
const p = rule.properties ?? {};
|
|
1038
|
+
const all = [...p.sourceAddressPrefix ? [p.sourceAddressPrefix] : [], ...p.sourceAddressPrefixes ?? []];
|
|
1039
|
+
return all.filter((s) => WORLD3.has(s.trim().toLowerCase()));
|
|
1040
|
+
}
|
|
1041
|
+
function describePorts2(rule) {
|
|
1042
|
+
const p = rule.properties ?? {};
|
|
1043
|
+
const ranges = [
|
|
1044
|
+
...p.destinationPortRange ? [p.destinationPortRange] : [],
|
|
1045
|
+
...p.destinationPortRanges ?? []
|
|
1046
|
+
];
|
|
1047
|
+
if (ranges.length === 0) return "no ports";
|
|
1048
|
+
const proto = (p.protocol ?? "*") === "*" ? "any protocol" : p.protocol;
|
|
1049
|
+
const parts = ranges.map((r) => {
|
|
1050
|
+
if (r === "*") return `every port on ${proto}`;
|
|
1051
|
+
const named = NAMED_PORTS3[r];
|
|
1052
|
+
if (named) return `port ${r} (${named})`;
|
|
1053
|
+
return r.includes("-") ? `ports ${r}` : `port ${r}`;
|
|
1054
|
+
});
|
|
1055
|
+
return parts.join(", ");
|
|
1056
|
+
}
|
|
1057
|
+
function openFindings2(groups) {
|
|
1058
|
+
const out = [];
|
|
1059
|
+
for (const nsg of groups) {
|
|
1060
|
+
if (!isClawopsResource(nsg.name, "clawops-nsg")) continue;
|
|
1061
|
+
for (const rule of nsg.properties?.securityRules ?? []) {
|
|
1062
|
+
const p = rule.properties ?? {};
|
|
1063
|
+
if ((p.direction ?? "").toLowerCase() !== "inbound") continue;
|
|
1064
|
+
if ((p.access ?? "").toLowerCase() !== "allow") continue;
|
|
1065
|
+
const world = worldSources(rule);
|
|
1066
|
+
if (world.length === 0) continue;
|
|
1067
|
+
out.push(
|
|
1068
|
+
`${nsg.name}/${rule.name ?? "unnamed rule"} admits ${world.join(" and ")} on ${describePorts2(rule)}`
|
|
1069
|
+
);
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
return out;
|
|
1073
|
+
}
|
|
1074
|
+
var azureNsgAuditModule = {
|
|
1075
|
+
id: "azure-nsg-audit",
|
|
1076
|
+
label: "Azure NSG audit (check-only)",
|
|
1077
|
+
defaultOn: true,
|
|
1078
|
+
providers: ["azure"],
|
|
1079
|
+
async check(_exec) {
|
|
1080
|
+
const ctx = await azureContext();
|
|
1081
|
+
if (!ctx) {
|
|
1082
|
+
return {
|
|
1083
|
+
status: "skipped",
|
|
1084
|
+
detail: "No Azure credentials or subscription resolved, so the network security groups could not be read. This says nothing about the rules themselves."
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
const r = await armGet(
|
|
1088
|
+
ctx,
|
|
1089
|
+
"/providers/Microsoft.Network/networkSecurityGroups?api-version=2023-05-01"
|
|
1090
|
+
);
|
|
1091
|
+
if (!r.ok) {
|
|
1092
|
+
return {
|
|
1093
|
+
status: "skipped",
|
|
1094
|
+
detail: explainFailure(r, "the network security groups", "Microsoft.Network/networkSecurityGroups/read") + " This says nothing about the rules themselves."
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
const findings = openFindings2(r.body.value ?? []);
|
|
1098
|
+
return findings.length === 0 ? { status: "applied", detail: "No clawops NSG rule admits the internet in this subscription." } : { status: "drifted", detail: `Open to the internet: ${findings.join("; ")}.` };
|
|
1099
|
+
},
|
|
1100
|
+
async apply(_exec) {
|
|
1101
|
+
return {
|
|
1102
|
+
changed: false,
|
|
1103
|
+
detail: "The NSG audit only reports. Narrow the rule in your plan with --ssh-cidr or --gateway-cidr and re-apply, so the change survives the next deploy."
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
};
|
|
1107
|
+
|
|
1108
|
+
// src/harden/modules/azure-disk-encryption.ts
|
|
1109
|
+
function posture(vm, disk) {
|
|
1110
|
+
const type = disk?.properties?.encryption?.type ?? "";
|
|
1111
|
+
return {
|
|
1112
|
+
atRest: true,
|
|
1113
|
+
customerKey: type.includes("CustomerKey"),
|
|
1114
|
+
atHost: vm.properties?.securityProfile?.encryptionAtHost === true
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
function describe2(p) {
|
|
1118
|
+
const gaps = [];
|
|
1119
|
+
if (!p.atHost) gaps.push("encryption at host is off, so the temp disk and the OS disk cache are written to the host unencrypted");
|
|
1120
|
+
if (!p.customerKey) gaps.push("the disk uses a platform-managed key rather than one of yours");
|
|
1121
|
+
return gaps;
|
|
1122
|
+
}
|
|
1123
|
+
var azureDiskEncryptionModule = {
|
|
1124
|
+
id: "azure-disk-encryption",
|
|
1125
|
+
label: "Azure disk encryption check (check-only)",
|
|
1126
|
+
defaultOn: true,
|
|
1127
|
+
providers: ["azure"],
|
|
1128
|
+
async check(_exec) {
|
|
1129
|
+
const ctx = await azureContext();
|
|
1130
|
+
if (!ctx) {
|
|
1131
|
+
return {
|
|
1132
|
+
status: "skipped",
|
|
1133
|
+
detail: "No Azure credentials or subscription resolved, so the disk could not be read."
|
|
1134
|
+
};
|
|
1135
|
+
}
|
|
1136
|
+
const vm = await findClawopsVm(ctx);
|
|
1137
|
+
if (!vm) {
|
|
1138
|
+
return {
|
|
1139
|
+
status: "skipped",
|
|
1140
|
+
detail: `No VM named clawops-vm found in ${ctx.subscriptionId}. Either the stack is not deployed there or the identity cannot list virtual machines.`
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
const diskId = vm.properties?.storageProfile?.osDisk?.managedDisk?.id;
|
|
1144
|
+
const disk = diskId ? await armGetById(ctx, diskId, "2023-04-02") : void 0;
|
|
1145
|
+
const gaps = describe2(posture(vm, disk));
|
|
1146
|
+
return gaps.length === 0 ? {
|
|
1147
|
+
status: "applied",
|
|
1148
|
+
detail: "Encrypted at rest with a customer-managed key, and encryption at host is on."
|
|
1149
|
+
} : {
|
|
1150
|
+
status: "missing",
|
|
1151
|
+
detail: `The OS disk is encrypted at rest, which Azure does for every managed disk. Beyond that: ${gaps.join("; ")}.`
|
|
1152
|
+
};
|
|
1153
|
+
},
|
|
1154
|
+
async apply(_exec) {
|
|
1155
|
+
return {
|
|
1156
|
+
changed: false,
|
|
1157
|
+
detail: "Encryption at host cannot be turned on while the VM is running, and moving to a customer-managed key means a disk encryption set and a Key Vault that outlive the stack. Both belong in the deploy rather than in a running host."
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1160
|
+
};
|
|
1161
|
+
|
|
1162
|
+
// src/harden/modules/azure-defender.ts
|
|
1163
|
+
var RELEVANT_PLANS = ["VirtualMachines", "KeyVaults"];
|
|
1164
|
+
function unprotectedPlans(pricings) {
|
|
1165
|
+
const wanted = new Set(RELEVANT_PLANS);
|
|
1166
|
+
return pricings.filter((p) => p.name && wanted.has(p.name)).filter((p) => (p.properties?.pricingTier ?? "Free").toLowerCase() !== "standard").map((p) => p.name);
|
|
1167
|
+
}
|
|
1168
|
+
var azureDefenderModule = {
|
|
1169
|
+
id: "azure-defender",
|
|
1170
|
+
label: "Azure Defender for Cloud check (check-only)",
|
|
1171
|
+
defaultOn: true,
|
|
1172
|
+
providers: ["azure"],
|
|
1173
|
+
async check(_exec) {
|
|
1174
|
+
const ctx = await azureContext();
|
|
1175
|
+
if (!ctx) {
|
|
1176
|
+
return {
|
|
1177
|
+
status: "skipped",
|
|
1178
|
+
detail: "No Azure credentials or subscription resolved, so Defender could not be read."
|
|
1179
|
+
};
|
|
1180
|
+
}
|
|
1181
|
+
const r = await armGet(
|
|
1182
|
+
ctx,
|
|
1183
|
+
"/providers/Microsoft.Security/pricings?api-version=2023-01-01"
|
|
1184
|
+
);
|
|
1185
|
+
if (!r.ok) {
|
|
1186
|
+
return {
|
|
1187
|
+
status: "skipped",
|
|
1188
|
+
detail: explainFailure(r, "Defender pricing", "Microsoft.Security/pricings/read")
|
|
1189
|
+
};
|
|
1190
|
+
}
|
|
1191
|
+
const off = unprotectedPlans(r.body.value ?? []);
|
|
1192
|
+
return off.length === 0 ? { status: "applied", detail: "Defender covers virtual machines and Key Vault on this subscription." } : {
|
|
1193
|
+
status: "missing",
|
|
1194
|
+
detail: `Defender is on the free tier for: ${off.join(", ")}. The free tier reports recommendations and does not protect anything.`
|
|
1195
|
+
};
|
|
1196
|
+
},
|
|
1197
|
+
async apply(_exec) {
|
|
1198
|
+
return {
|
|
1199
|
+
changed: false,
|
|
1200
|
+
detail: "Defender is billed per resource per month, so clawops will not turn it on for you. Enable it per plan in Defender for Cloud once you have priced it for this subscription."
|
|
1201
|
+
};
|
|
1202
|
+
}
|
|
1203
|
+
};
|
|
1204
|
+
|
|
1205
|
+
// src/harden/modules/azure-jit.ts
|
|
1206
|
+
function resourceName(id) {
|
|
1207
|
+
if (!id) return void 0;
|
|
1208
|
+
const last = id.split("/").pop();
|
|
1209
|
+
return last === "" ? void 0 : last;
|
|
1210
|
+
}
|
|
1211
|
+
function coversClawopsVm(policies) {
|
|
1212
|
+
return policies.some(
|
|
1213
|
+
(p) => (p.properties?.virtualMachines ?? []).some(
|
|
1214
|
+
(vm) => isClawopsResource(resourceName(vm.id), "clawops-vm")
|
|
1215
|
+
)
|
|
1216
|
+
);
|
|
1217
|
+
}
|
|
1218
|
+
var azureJitModule = {
|
|
1219
|
+
id: "azure-jit",
|
|
1220
|
+
label: "Azure JIT VM access check (check-only)",
|
|
1221
|
+
defaultOn: false,
|
|
1222
|
+
providers: ["azure"],
|
|
1223
|
+
async check(_exec) {
|
|
1224
|
+
const ctx = await azureContext();
|
|
1225
|
+
if (!ctx) {
|
|
1226
|
+
return {
|
|
1227
|
+
status: "skipped",
|
|
1228
|
+
detail: "No Azure credentials or subscription resolved, so JIT policies could not be read."
|
|
1229
|
+
};
|
|
1230
|
+
}
|
|
1231
|
+
const vm = await findClawopsVm(ctx);
|
|
1232
|
+
if (!vm) {
|
|
1233
|
+
return {
|
|
1234
|
+
status: "skipped",
|
|
1235
|
+
detail: `No VM named clawops-vm found in ${ctx.subscriptionId}, so there is nothing for a JIT policy to cover.`
|
|
1236
|
+
};
|
|
1237
|
+
}
|
|
1238
|
+
const registered = await providerRegistered(ctx, "Microsoft.Security");
|
|
1239
|
+
if (registered === false) {
|
|
1240
|
+
return {
|
|
1241
|
+
status: "skipped",
|
|
1242
|
+
detail: "The Microsoft.Security resource provider is not registered on this subscription, so it has no Defender and therefore no JIT. Register it with `az provider register --namespace Microsoft.Security` if you want these checks to report."
|
|
1243
|
+
};
|
|
1244
|
+
}
|
|
1245
|
+
const r = await armGet(
|
|
1246
|
+
ctx,
|
|
1247
|
+
"/providers/Microsoft.Security/jitNetworkAccessPolicies?api-version=2020-01-01"
|
|
1248
|
+
);
|
|
1249
|
+
if (!r.ok) {
|
|
1250
|
+
return {
|
|
1251
|
+
status: "skipped",
|
|
1252
|
+
detail: explainFailure(r, "JIT policies", "Microsoft.Security/jitNetworkAccessPolicies/read") + " JIT also requires Defender for Servers Plan 2, which this subscription may not have."
|
|
1253
|
+
};
|
|
1254
|
+
}
|
|
1255
|
+
return coversClawopsVm(r.body.value ?? []) ? { status: "applied", detail: "A JIT policy covers the clawops VM." } : {
|
|
1256
|
+
status: "missing",
|
|
1257
|
+
detail: "No JIT policy covers the clawops VM. SSH and the gateway stay open to whatever CIDRs the plan named, rather than opening per request."
|
|
1258
|
+
};
|
|
1259
|
+
},
|
|
1260
|
+
async apply(_exec) {
|
|
1261
|
+
return {
|
|
1262
|
+
changed: false,
|
|
1263
|
+
detail: "JIT needs Defender for Servers Plan 2, which is billed per VM per month, and it takes the NSG rules over from the plan that wrote them. Enable it in Defender for Cloud if you want it, and expect clawops plan to report the rules as drifted afterwards."
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
};
|
|
1267
|
+
|
|
750
1268
|
// src/harden/index.ts
|
|
751
1269
|
var MODULE_CATALOG = [
|
|
752
1270
|
// ON by default — common
|
|
@@ -763,7 +1281,18 @@ var MODULE_CATALOG = [
|
|
|
763
1281
|
awsSgAuditModule,
|
|
764
1282
|
awsSsmCheckModule,
|
|
765
1283
|
awsFlowLogsModule,
|
|
766
|
-
awsGuardDutyModule
|
|
1284
|
+
awsGuardDutyModule,
|
|
1285
|
+
// GCP-specific
|
|
1286
|
+
gcpFirewallAuditModule,
|
|
1287
|
+
gcpShieldedVmModule,
|
|
1288
|
+
gcpOsLoginModule,
|
|
1289
|
+
// Azure-specific
|
|
1290
|
+
azureNsgAuditModule,
|
|
1291
|
+
azureDiskEncryptionModule,
|
|
1292
|
+
azureDefenderModule,
|
|
1293
|
+
azureJitModule,
|
|
1294
|
+
// Every provider, off by default
|
|
1295
|
+
tailscaleModule
|
|
767
1296
|
];
|
|
768
1297
|
export {
|
|
769
1298
|
MODULE_CATALOG,
|
|
@@ -773,15 +1302,24 @@ export {
|
|
|
773
1302
|
awsGuardDutyModule,
|
|
774
1303
|
awsSgAuditModule,
|
|
775
1304
|
awsSsmCheckModule,
|
|
1305
|
+
azureDefenderModule,
|
|
1306
|
+
azureDiskEncryptionModule,
|
|
1307
|
+
azureJitModule,
|
|
1308
|
+
azureNsgAuditModule,
|
|
776
1309
|
dockerSocketModule,
|
|
777
1310
|
fail2banModule,
|
|
778
1311
|
formatHardenSummary,
|
|
1312
|
+
gcpFirewallAuditModule,
|
|
1313
|
+
gcpOsLoginModule,
|
|
1314
|
+
gcpShieldedVmModule,
|
|
779
1315
|
lynisModule,
|
|
1316
|
+
makeTailscaleModule,
|
|
780
1317
|
makeUfwModule,
|
|
781
1318
|
resolveModules,
|
|
782
1319
|
runHardening,
|
|
783
1320
|
sshModule,
|
|
784
1321
|
sysctlModule,
|
|
1322
|
+
tailscaleModule,
|
|
785
1323
|
ufwModule,
|
|
786
1324
|
unattendedUpgradesModule,
|
|
787
1325
|
withRemoteExec
|
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
MCP_HTTP_PORT
|
|
4
|
-
} from "./chunk-
|
|
5
|
-
import "./chunk-
|
|
4
|
+
} from "./chunk-AYUX4FOF.js";
|
|
5
|
+
import "./chunk-R3DTA35B.js";
|
|
6
6
|
import "./chunk-BOPSG2LI.js";
|
|
7
7
|
import "./chunk-6ZFIFDBJ.js";
|
|
8
8
|
import "./chunk-3QJBNAHW.js";
|
|
9
9
|
import "./chunk-2I7KCMXZ.js";
|
|
10
|
-
import "./chunk-
|
|
10
|
+
import "./chunk-3Z5TDXCJ.js";
|
|
11
11
|
import "./chunk-AEEMRFZV.js";
|
|
12
|
-
import "./chunk-
|
|
13
|
-
import "./chunk-
|
|
12
|
+
import "./chunk-A2DS5SB7.js";
|
|
13
|
+
import "./chunk-XFN3YDZE.js";
|
|
14
14
|
import "./chunk-2Z7J3ERQ.js";
|
|
15
15
|
import "./chunk-E4NJSJ54.js";
|
|
16
16
|
import "./chunk-MC5ATCXS.js";
|
|
17
|
-
import "./chunk-
|
|
18
|
-
import "./chunk-
|
|
17
|
+
import "./chunk-QDNFADJI.js";
|
|
18
|
+
import "./chunk-GFUK6KDR.js";
|
|
19
19
|
import "./chunk-NUL5F6YV.js";
|
|
20
20
|
import "./chunk-3OLUJPCB.js";
|
|
21
21
|
import {
|
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
} from "./chunk-NUDIHZEH.js";
|
|
24
24
|
import "./chunk-JW5A7E3X.js";
|
|
25
25
|
import "./chunk-LT4XMO5D.js";
|
|
26
|
-
import "./chunk-
|
|
26
|
+
import "./chunk-BELTQGHT.js";
|
|
27
27
|
import "./chunk-GIEVAGEK.js";
|
|
28
28
|
import "./chunk-VOEJYRIK.js";
|
|
29
29
|
import "./chunk-EI7CQQ7D.js";
|
|
@@ -31,8 +31,8 @@ import "./chunk-FEK7VJAY.js";
|
|
|
31
31
|
import "./chunk-RHOWYIWT.js";
|
|
32
32
|
import "./chunk-Q7NQY5HV.js";
|
|
33
33
|
import "./chunk-N4WZLWJ4.js";
|
|
34
|
-
import "./chunk-
|
|
35
|
-
import "./chunk-
|
|
34
|
+
import "./chunk-ELU3XQEA.js";
|
|
35
|
+
import "./chunk-64PONSA5.js";
|
|
36
36
|
import "./chunk-KGXPLI7W.js";
|
|
37
37
|
import "./chunk-VUFMZEIM.js";
|
|
38
38
|
|