@compforge/harness-toolbox 0.5.6 → 0.6.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/README.md +32 -0
- package/dist/kubernetes/apparmor.d.ts +24 -0
- package/dist/kubernetes/apparmor.js +60 -0
- package/dist/kubernetes/client.js +2 -2
- package/dist/kubernetes/environment-inspection.d.ts +36 -0
- package/dist/kubernetes/environment-inspection.js +107 -0
- package/dist/kubernetes/environment.js +2 -2
- package/dist/kubernetes/pod-log-client.js +2 -2
- package/dist/kubernetes/pod-log-datasource.js +2 -2
- package/package.json +2 -2
- package/dist/{chunk-mnad4zkp.js → chunk-010339ga.js} +3 -3
- package/dist/{chunk-erbehkcz.js → chunk-7mc2jevr.js} +3 -3
package/README.md
CHANGED
|
@@ -469,3 +469,35 @@ IP literals and Kubernetes `service.namespace` names use the same API. There is
|
|
|
469
469
|
implicit execution on the caller host or ICMP requirement. Network failure returns
|
|
470
470
|
evidence; missing Python, execution failure and incomplete output throw. TLS success
|
|
471
471
|
does not establish HTTP authorization, object access or application download policy.
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
## Kubernetes environment and AppArmor admission
|
|
475
|
+
|
|
476
|
+
`@compforge/harness-toolbox/kubernetes/environment-inspection` exports
|
|
477
|
+
`inspectKubernetesEnvironment(access, instance, {timeoutMs, signal?})`. Pass an existing Common
|
|
478
|
+
`WorkloadInstance`; the caller discovers and selects it (for example with `KubernetesClient.resolveWorkload`).
|
|
479
|
+
The function reads that Pod, verifies its UID, reads `ip_forward` in the selected running container,
|
|
480
|
+
and gets only its associated Node's `operatingSystem`, `osImage`, `kernelVersion` and `architecture`.
|
|
481
|
+
It does not enumerate Nodes, select workloads or infer a host forwarding setting from the Pod namespace.
|
|
482
|
+
|
|
483
|
+
```ts
|
|
484
|
+
const result = await inspectKubernetesEnvironment({
|
|
485
|
+
resources: client.clusterResources(),
|
|
486
|
+
exec: client.exec.bind(client),
|
|
487
|
+
}, selectedInstance, { timeoutMs: 5_000, signal });
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
Use a cluster-scoped resource view: Pod GET supplies its namespace explicitly; Node GET has none.
|
|
491
|
+
The positive timeout is a shared request budget. Resource/exec adapters must honor the supplied timeout
|
|
492
|
+
and cancellation signal. Node and forwarding reads retain independent successes and sanitized issues;
|
|
493
|
+
`complete`, `partial` and `unavailable` describe coverage, not Service health. Cancellation throws.
|
|
494
|
+
A missing or changed Pod UID prevents exec and Node attribution. A same-name replacement during the later
|
|
495
|
+
name-addressed exec cannot be excluded atomically by Kubernetes; results describe a best-effort sample.
|
|
496
|
+
|
|
497
|
+
`@compforge/harness-toolbox/kubernetes/apparmor` exports
|
|
498
|
+
`probeAppArmorUnconfinedAdmission(executor, {namespace, serviceAccountName, image}, {timeoutMs, signal?})`.
|
|
499
|
+
The caller supplies an authorized `Executor.run` channel. The function uses an explicit namespace and
|
|
500
|
+
ServiceAccount impersonation with `create --dry-run=server`; it never persists a Pod. Results are
|
|
501
|
+
`allowed`, `denied` (explicit AppArmor Unconfined admission rejection), or `unknown` (including generic
|
|
502
|
+
RBAC, impersonation, timeout and transport failures). Acceptance does not verify Node runtime support.
|
|
503
|
+
The caller owns permissions, Service semantics, scheduling and borrowed client/executor cleanup.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type ErrorKind } from "../errors";
|
|
2
|
+
import type { Executor } from "./executor";
|
|
3
|
+
export interface AppArmorUnconfinedAdmissionInput {
|
|
4
|
+
namespace: string;
|
|
5
|
+
serviceAccountName: string;
|
|
6
|
+
image: string;
|
|
7
|
+
}
|
|
8
|
+
export interface AppArmorUnconfinedAdmissionResult {
|
|
9
|
+
target: AppArmorUnconfinedAdmissionInput;
|
|
10
|
+
observedAt: string;
|
|
11
|
+
status: "allowed" | "denied" | "unknown";
|
|
12
|
+
reason?: string;
|
|
13
|
+
errorKind?: ErrorKind;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* @spec Probe AppArmor Unconfined admission as the supplied ServiceAccount using server-side dry-run only.
|
|
17
|
+
* @rule Admission acceptance does not prove Node support. Generic RBAC/auth/transport failures remain unknown.
|
|
18
|
+
* @rule Caller owns target selection, authorization and executor lifecycle; no workload or Service discovery occurs.
|
|
19
|
+
* @link docs/toolbox.md
|
|
20
|
+
*/
|
|
21
|
+
export declare function probeAppArmorUnconfinedAdmission(executor: Pick<Executor, "run">, input: AppArmorUnconfinedAdmissionInput, options: {
|
|
22
|
+
timeoutMs: number;
|
|
23
|
+
signal?: AbortSignal;
|
|
24
|
+
}): Promise<AppArmorUnconfinedAdmissionResult>;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import {
|
|
2
|
+
KubernetesError,
|
|
3
|
+
kubernetesCommandError
|
|
4
|
+
} from "../chunk-68zz17hs.js";
|
|
5
|
+
|
|
6
|
+
// src/kubernetes/apparmor.ts
|
|
7
|
+
async function probeAppArmorUnconfinedAdmission(executor, input, options) {
|
|
8
|
+
if (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs <= 0 || !input.image.trim() || !/^[a-z0-9][a-z0-9-]*$/.test(input.namespace) || !/^[a-z0-9][a-z0-9.-]*$/.test(input.serviceAccountName)) {
|
|
9
|
+
throw new KubernetesError("AppArmor admission requires namespace, ServiceAccount, image and positive timeout", { kind: "invalid_argument" });
|
|
10
|
+
}
|
|
11
|
+
options.signal?.throwIfAborted();
|
|
12
|
+
const result = { target: { ...input }, observedAt: new Date().toISOString(), status: "unknown" };
|
|
13
|
+
const manifest = {
|
|
14
|
+
apiVersion: "v1",
|
|
15
|
+
kind: "Pod",
|
|
16
|
+
metadata: { generateName: "apparmor-unconfined-probe-", namespace: input.namespace },
|
|
17
|
+
spec: { serviceAccountName: input.serviceAccountName, restartPolicy: "Never", containers: [{
|
|
18
|
+
name: "probe",
|
|
19
|
+
image: input.image,
|
|
20
|
+
securityContext: {
|
|
21
|
+
appArmorProfile: { type: "Unconfined" },
|
|
22
|
+
allowPrivilegeEscalation: false,
|
|
23
|
+
capabilities: { drop: ["ALL"] },
|
|
24
|
+
runAsNonRoot: true,
|
|
25
|
+
runAsUser: 65532,
|
|
26
|
+
seccompProfile: { type: "RuntimeDefault" }
|
|
27
|
+
}
|
|
28
|
+
}] }
|
|
29
|
+
};
|
|
30
|
+
try {
|
|
31
|
+
const execution = await executor.run([
|
|
32
|
+
"create",
|
|
33
|
+
"-f",
|
|
34
|
+
"-",
|
|
35
|
+
"--dry-run=server",
|
|
36
|
+
"-o",
|
|
37
|
+
"json",
|
|
38
|
+
"--namespace",
|
|
39
|
+
input.namespace,
|
|
40
|
+
"--as",
|
|
41
|
+
`system:serviceaccount:${input.namespace}:${input.serviceAccountName}`
|
|
42
|
+
], { ...options, stdin: JSON.stringify(manifest) });
|
|
43
|
+
options.signal?.throwIfAborted();
|
|
44
|
+
if (execution.ok)
|
|
45
|
+
return { ...result, status: "allowed" };
|
|
46
|
+
const output = `${execution.stderr}
|
|
47
|
+
${execution.stdout}`;
|
|
48
|
+
if (!execution.timedOut && /apparmor/i.test(output) && /unconfined/i.test(output) && /(?:admission|violates\s+podsecurity)/i.test(output) && /(denied|forbidden|violates\s+podsecurity)/i.test(output)) {
|
|
49
|
+
return { ...result, status: "denied", reason: "Admission rejected AppArmor Unconfined" };
|
|
50
|
+
}
|
|
51
|
+
throw kubernetesCommandError(execution);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
options.signal?.throwIfAborted();
|
|
54
|
+
const kind = error instanceof KubernetesError ? error.kind : "operation_failed";
|
|
55
|
+
return { ...result, errorKind: kind, reason: `AppArmor admission could not be determined: ${kind}` };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export {
|
|
59
|
+
probeAppArmorUnconfinedAdmission
|
|
60
|
+
};
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
KubernetesClient
|
|
3
|
-
} from "../chunk-
|
|
4
|
-
import"../chunk-d2y5ewbb.js";
|
|
3
|
+
} from "../chunk-7mc2jevr.js";
|
|
5
4
|
import"../chunk-361x998p.js";
|
|
5
|
+
import"../chunk-d2y5ewbb.js";
|
|
6
6
|
import"../chunk-jt8fn3wg.js";
|
|
7
7
|
import"../chunk-dfpqr0r9.js";
|
|
8
8
|
import"../chunk-a9htb6cr.js";
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { WorkloadInstance } from "@compforge/harness-common";
|
|
2
|
+
import { type ErrorKind } from "../errors";
|
|
3
|
+
import type { KubernetesClient } from "./client";
|
|
4
|
+
import type { ResourceAccess } from "./resources";
|
|
5
|
+
export interface KubernetesEnvironmentAccess {
|
|
6
|
+
/** Explicit cluster-scoped resource view; Pod requests supply their namespace, Node requests do not. */
|
|
7
|
+
resources: Pick<ResourceAccess, "get">;
|
|
8
|
+
/** Borrow the caller's authorized exec channel; this function never creates or disposes a client. */
|
|
9
|
+
exec: KubernetesClient["exec"];
|
|
10
|
+
}
|
|
11
|
+
export interface KubernetesEnvironmentIssue {
|
|
12
|
+
source: "pod" | "pod.ip_forward" | "node" | `node.${string}`;
|
|
13
|
+
kind: ErrorKind;
|
|
14
|
+
message: string;
|
|
15
|
+
}
|
|
16
|
+
export interface KubernetesEnvironmentInspection {
|
|
17
|
+
instance: WorkloadInstance;
|
|
18
|
+
observedAt: string;
|
|
19
|
+
status: "complete" | "partial" | "unavailable";
|
|
20
|
+
/** Value in the sampled Pod's network namespace, not proof of the host setting. */
|
|
21
|
+
podIpForward?: 0 | 1;
|
|
22
|
+
container?: string;
|
|
23
|
+
nodeName?: string;
|
|
24
|
+
nodeInfo: Partial<Record<"operatingSystem" | "osImage" | "kernelVersion" | "architecture", string>>;
|
|
25
|
+
issues: KubernetesEnvironmentIssue[];
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* @spec Inspect only the supplied WorkloadInstance and its Node. Discovery and sampling belong to the caller.
|
|
29
|
+
* @rule Reject a Pod already replaced at the identity read; later name-addressed exec is not atomic with that read.
|
|
30
|
+
* @rule ip_forward is Pod-netns scoped; OS metadata comes from Node status.nodeInfo, never the container image.
|
|
31
|
+
* @link docs/toolbox.md
|
|
32
|
+
*/
|
|
33
|
+
export declare function inspectKubernetesEnvironment(access: KubernetesEnvironmentAccess, instance: WorkloadInstance, options: {
|
|
34
|
+
timeoutMs: number;
|
|
35
|
+
signal?: AbortSignal;
|
|
36
|
+
}): Promise<KubernetesEnvironmentInspection>;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import {
|
|
2
|
+
KubernetesError
|
|
3
|
+
} from "../chunk-68zz17hs.js";
|
|
4
|
+
|
|
5
|
+
// src/kubernetes/environment-inspection.ts
|
|
6
|
+
function object(value) {
|
|
7
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
8
|
+
}
|
|
9
|
+
function text(value) {
|
|
10
|
+
return typeof value === "string" && value.trim() ? value : undefined;
|
|
11
|
+
}
|
|
12
|
+
function issue(source, error) {
|
|
13
|
+
const kind = error instanceof KubernetesError ? error.kind : "operation_failed";
|
|
14
|
+
return { source, kind, message: `Kubernetes environment read failed: ${kind}` };
|
|
15
|
+
}
|
|
16
|
+
async function inspectKubernetesEnvironment(access, instance, options) {
|
|
17
|
+
if (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs <= 0 || instance.platform !== "kubernetes" || [instance.environment, instance.workload, instance.namespace, instance.pod, instance.uid].some((value) => !value?.trim())) {
|
|
18
|
+
throw new KubernetesError("Environment inspection requires a WorkloadInstance and positive timeout", { kind: "invalid_argument" });
|
|
19
|
+
}
|
|
20
|
+
const deadline = performance.now() + options.timeoutMs;
|
|
21
|
+
const request = () => {
|
|
22
|
+
options.signal?.throwIfAborted();
|
|
23
|
+
const timeoutMs = Math.ceil(deadline - performance.now());
|
|
24
|
+
if (timeoutMs <= 0)
|
|
25
|
+
throw new KubernetesError("Environment inspection budget exhausted", { kind: "timeout" });
|
|
26
|
+
return { timeoutMs, signal: options.signal };
|
|
27
|
+
};
|
|
28
|
+
const result = {
|
|
29
|
+
instance: { ...instance },
|
|
30
|
+
observedAt: new Date().toISOString(),
|
|
31
|
+
status: "unavailable",
|
|
32
|
+
nodeInfo: {},
|
|
33
|
+
issues: []
|
|
34
|
+
};
|
|
35
|
+
let pod;
|
|
36
|
+
try {
|
|
37
|
+
pod = await access.resources.get({ apiVersion: "v1", resource: "pods", namespace: instance.namespace, name: instance.pod }, request());
|
|
38
|
+
options.signal?.throwIfAborted();
|
|
39
|
+
const meta = object(pod.metadata);
|
|
40
|
+
if (meta.uid !== instance.uid || meta.name !== instance.pod || meta.namespace !== instance.namespace) {
|
|
41
|
+
throw new KubernetesError("Pod identity changed or is missing", { kind: "invalid_response" });
|
|
42
|
+
}
|
|
43
|
+
} catch (error) {
|
|
44
|
+
options.signal?.throwIfAborted();
|
|
45
|
+
result.issues.push(issue("pod", error));
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
const spec = object(pod.spec), status = object(pod.status);
|
|
49
|
+
const containers = Array.isArray(spec.containers) ? spec.containers.map(object) : [];
|
|
50
|
+
const container = instance.container ? containers.find((item) => item.name === instance.container) : containers.length === 1 ? containers[0] : undefined;
|
|
51
|
+
result.container = text(container?.name);
|
|
52
|
+
result.nodeName = text(spec.nodeName);
|
|
53
|
+
const [forwardingIssues, nodeIssues] = await Promise.all([
|
|
54
|
+
(async () => {
|
|
55
|
+
try {
|
|
56
|
+
if (!result.container)
|
|
57
|
+
throw new KubernetesError("Target container is absent or ambiguous", { kind: "invalid_argument" });
|
|
58
|
+
const states = Array.isArray(status.containerStatuses) ? status.containerStatuses.map(object) : [];
|
|
59
|
+
if (status.phase !== "Running" || !object(states.find((item) => item.name === result.container)?.state).running) {
|
|
60
|
+
throw new KubernetesError("Target container is not running", { kind: "unsupported_operation" });
|
|
61
|
+
}
|
|
62
|
+
const execution = await access.exec(instance.namespace, { pod: instance.pod, container: result.container }, ["cat", "/proc/sys/net/ipv4/ip_forward"], request());
|
|
63
|
+
options.signal?.throwIfAborted();
|
|
64
|
+
if (!execution.ok)
|
|
65
|
+
throw new KubernetesError("Could not read ip_forward", { kind: execution.timedOut ? "timeout" : "operation_failed" });
|
|
66
|
+
const value = execution.stdout.trim();
|
|
67
|
+
if (value !== "0" && value !== "1")
|
|
68
|
+
throw new KubernetesError("Invalid ip_forward response", { kind: "invalid_response" });
|
|
69
|
+
result.podIpForward = value === "1" ? 1 : 0;
|
|
70
|
+
return [];
|
|
71
|
+
} catch (error) {
|
|
72
|
+
options.signal?.throwIfAborted();
|
|
73
|
+
return [issue("pod.ip_forward", error)];
|
|
74
|
+
}
|
|
75
|
+
})(),
|
|
76
|
+
(async () => {
|
|
77
|
+
try {
|
|
78
|
+
if (!result.nodeName)
|
|
79
|
+
throw new KubernetesError("Pod nodeName is absent", { kind: "resource_not_found" });
|
|
80
|
+
const node = await access.resources.get({ apiVersion: "v1", resource: "nodes", name: result.nodeName }, request());
|
|
81
|
+
options.signal?.throwIfAborted();
|
|
82
|
+
if (node.metadata?.name !== result.nodeName)
|
|
83
|
+
throw new KubernetesError("Node identity is missing or mismatched", { kind: "invalid_response" });
|
|
84
|
+
const info = object(object(node.status).nodeInfo);
|
|
85
|
+
const issues = [];
|
|
86
|
+
for (const field of ["operatingSystem", "osImage", "kernelVersion", "architecture"]) {
|
|
87
|
+
const value = text(info[field]);
|
|
88
|
+
if (value)
|
|
89
|
+
result.nodeInfo[field] = value;
|
|
90
|
+
else
|
|
91
|
+
issues.push({ source: `node.${field}`, kind: "invalid_response", message: `Node status.nodeInfo.${field} is missing` });
|
|
92
|
+
}
|
|
93
|
+
return issues;
|
|
94
|
+
} catch (error) {
|
|
95
|
+
options.signal?.throwIfAborted();
|
|
96
|
+
return [issue("node", error)];
|
|
97
|
+
}
|
|
98
|
+
})()
|
|
99
|
+
]);
|
|
100
|
+
options.signal?.throwIfAborted();
|
|
101
|
+
result.issues.push(...forwardingIssues, ...nodeIssues);
|
|
102
|
+
result.status = !result.issues.length ? "complete" : result.podIpForward !== undefined || Object.keys(result.nodeInfo).length ? "partial" : "unavailable";
|
|
103
|
+
return result;
|
|
104
|
+
}
|
|
105
|
+
export {
|
|
106
|
+
inspectKubernetesEnvironment
|
|
107
|
+
};
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
KubernetesClient
|
|
3
|
-
} from "../chunk-
|
|
4
|
-
import"../chunk-d2y5ewbb.js";
|
|
3
|
+
} from "../chunk-7mc2jevr.js";
|
|
5
4
|
import"../chunk-361x998p.js";
|
|
5
|
+
import"../chunk-d2y5ewbb.js";
|
|
6
6
|
import"../chunk-jt8fn3wg.js";
|
|
7
7
|
import"../chunk-dfpqr0r9.js";
|
|
8
8
|
import {
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
2
|
PodLogClient
|
|
3
|
-
} from "../chunk-
|
|
3
|
+
} from "../chunk-010339ga.js";
|
|
4
4
|
import"../chunk-8nm9f0hh.js";
|
|
5
|
-
import"../chunk-mp88s7r7.js";
|
|
6
5
|
import"../chunk-1vfwvgkj.js";
|
|
6
|
+
import"../chunk-mp88s7r7.js";
|
|
7
7
|
import"../chunk-a9htb6cr.js";
|
|
8
8
|
import"../chunk-v3hxvnrc.js";
|
|
9
9
|
export {
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
2
|
PodLogClient
|
|
3
|
-
} from "../chunk-
|
|
3
|
+
} from "../chunk-010339ga.js";
|
|
4
4
|
import"../chunk-8nm9f0hh.js";
|
|
5
|
-
import"../chunk-mp88s7r7.js";
|
|
6
5
|
import"../chunk-1vfwvgkj.js";
|
|
6
|
+
import"../chunk-mp88s7r7.js";
|
|
7
7
|
import"../chunk-a9htb6cr.js";
|
|
8
8
|
import {
|
|
9
9
|
clientKey
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@compforge/harness-toolbox",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "Shared infrastructure clients, transports and bounded data capture",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -112,7 +112,7 @@
|
|
|
112
112
|
],
|
|
113
113
|
"dependencies": {
|
|
114
114
|
"@aws-sdk/client-s3": "3.1133.0",
|
|
115
|
-
"@compforge/harness-common": "0.
|
|
115
|
+
"@compforge/harness-common": "0.6.0",
|
|
116
116
|
"@kubernetes/client-node": "1.4.0",
|
|
117
117
|
"@opensearch-project/opensearch": "3.6.0",
|
|
118
118
|
"@redis/client": "6.1.0",
|
|
@@ -2,12 +2,12 @@ import {
|
|
|
2
2
|
PodLogByteBudget,
|
|
3
3
|
runPodLogCapturePlan
|
|
4
4
|
} from "./chunk-8nm9f0hh.js";
|
|
5
|
-
import {
|
|
6
|
-
logTimestampNanos
|
|
7
|
-
} from "./chunk-mp88s7r7.js";
|
|
8
5
|
import {
|
|
9
6
|
LogCaptureFile
|
|
10
7
|
} from "./chunk-1vfwvgkj.js";
|
|
8
|
+
import {
|
|
9
|
+
logTimestampNanos
|
|
10
|
+
} from "./chunk-mp88s7r7.js";
|
|
11
11
|
import {
|
|
12
12
|
clientKey
|
|
13
13
|
} from "./chunk-a9htb6cr.js";
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
resolveWorkload
|
|
3
|
-
} from "./chunk-d2y5ewbb.js";
|
|
4
1
|
import {
|
|
5
2
|
KubernetesResourceTransport
|
|
6
3
|
} from "./chunk-361x998p.js";
|
|
4
|
+
import {
|
|
5
|
+
resolveWorkload
|
|
6
|
+
} from "./chunk-d2y5ewbb.js";
|
|
7
7
|
import {
|
|
8
8
|
KubernetesExecTransport
|
|
9
9
|
} from "./chunk-dfpqr0r9.js";
|