@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.
Files changed (38) hide show
  1. package/README.md +862 -807
  2. package/dist/{apply-KYFPWI5U.js → apply-HJ5GAKHW.js} +7 -7
  3. package/dist/{automation-RMAIBNVC.js → automation-MKCJ46I6.js} +3 -3
  4. package/dist/{bootstrap-HNGKY6WW.js → bootstrap-NN647RCY.js} +2 -2
  5. package/dist/{chunk-HSPHXG6W.js → chunk-3Z5TDXCJ.js} +2 -2
  6. package/dist/{chunk-CX5SL5HP.js → chunk-64PONSA5.js} +10 -1
  7. package/dist/{chunk-STLRHLLV.js → chunk-A2DS5SB7.js} +12 -9
  8. package/dist/{chunk-KC3GC3PE.js → chunk-AYUX4FOF.js} +127 -13
  9. package/dist/{chunk-LEVIW2PV.js → chunk-BELTQGHT.js} +16 -2
  10. package/dist/{preflight-MWWM5JT3.js → chunk-BTW7NHOH.js} +3 -2
  11. package/dist/{chunk-A2I76FTA.js → chunk-ELU3XQEA.js} +1 -1
  12. package/dist/{chunk-IVWP4QUJ.js → chunk-GFUK6KDR.js} +10 -10
  13. package/dist/{chunk-W3OLNNJW.js → chunk-PSOSBJVO.js} +1 -1
  14. package/dist/{chunk-5XT5IAJK.js → chunk-QDNFADJI.js} +12 -5
  15. package/dist/{chunk-LBE6COC4.js → chunk-R3DTA35B.js} +22 -3
  16. package/dist/{preflight-EF52S3WW.js → chunk-TC2VYPLH.js} +3 -2
  17. package/dist/chunk-X56CQ6GB.js +157 -0
  18. package/dist/{chunk-BSLIYB5Q.js → chunk-XFN3YDZE.js} +1 -1
  19. package/dist/{cli-WLD3EBEM.js → cli-NH7VUNOU.js} +1 -1
  20. package/dist/cli.js +156 -82
  21. package/dist/{context-7EVC6W45.js → context-7VI3R6EK.js} +4 -4
  22. package/dist/flows-RI7FGHAM.js +120 -0
  23. package/dist/{generate-I6YQ5V2T.js → generate-YEM73KVB.js} +6 -6
  24. package/dist/{harden-72JMRT4E.js → harden-3KKCR7BL.js} +541 -3
  25. package/dist/{mcp-wire-3SWBM7D6.js → mcp-wire-TE2ZL6HD.js} +10 -10
  26. package/dist/{package-5BCMEBTE.js → package-JWDMWPV3.js} +47 -10
  27. package/dist/preflight-7W23GP4R.js +19 -0
  28. package/dist/preflight-IE2YWBAC.js +13 -0
  29. package/dist/private-only-DIA26I2E.js +74 -0
  30. package/dist/{server-GBA7BJGK.js → server-ZJPXPRZT.js} +10 -10
  31. package/dist/{stack-config-R4SPSUQZ.js → stack-config-LUTS2XAD.js} +2 -2
  32. package/dist/{state-PRBIIN7I.js → state-AOAYLYY5.js} +2 -2
  33. package/dist/{store-SDUR52Z5.js → store-OVB3DAJR.js} +1 -1
  34. package/dist/tailscale-cutover-6YP2QKFM.js +113 -0
  35. package/dist/{validate-YVI3BJ55.js → validate-JXKN5WD6.js} +1 -1
  36. package/package.json +39 -8
  37. package/spec/deploy-plan.schema.json +16 -1
  38. 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 = (command, execOpts) => session.exec(command, execOpts?.signal ?? opts.signal);
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((cmd, opts) => session.exec(cmd, opts?.signal ?? signal));
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-KC3GC3PE.js";
5
- import "./chunk-LBE6COC4.js";
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-HSPHXG6W.js";
10
+ import "./chunk-3Z5TDXCJ.js";
11
11
  import "./chunk-AEEMRFZV.js";
12
- import "./chunk-STLRHLLV.js";
13
- import "./chunk-BSLIYB5Q.js";
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-5XT5IAJK.js";
18
- import "./chunk-IVWP4QUJ.js";
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-LEVIW2PV.js";
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-A2I76FTA.js";
35
- import "./chunk-CX5SL5HP.js";
34
+ import "./chunk-ELU3XQEA.js";
35
+ import "./chunk-64PONSA5.js";
36
36
  import "./chunk-KGXPLI7W.js";
37
37
  import "./chunk-VUFMZEIM.js";
38
38