@knowttl/mg-axi 0.1.3 → 0.1.6
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 +304 -1250
- package/axi-credential.schema.json +65 -0
- package/dist/app-auth.js +13 -4
- package/dist/auth-status.js +20 -0
- package/dist/auth.js +24 -10
- package/dist/catalogue.js +17 -6
- package/dist/cli.js +60 -27
- package/dist/credentials.js +78 -0
- package/dist/docs.js +14 -12
- package/dist/doctor.js +10 -8
- package/dist/graph-session.js +15 -11
- package/dist/hook.js +9 -32
- package/dist/msal-app-provider.js +32 -23
- package/dist/msal-provider.js +67 -54
- package/dist/mutations.js +42 -53
- package/dist/paths.js +17 -0
- package/dist/private-file.js +227 -0
- package/dist/profiles.js +181 -89
- package/dist/redact.js +26 -0
- package/dist/secrets.js +37 -0
- package/dist/setup.js +2 -2
- package/docs/coverage.md +1 -1
- package/package.json +4 -6
- package/skills/mg-axi/SKILL.md +18 -12
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { chmodSync, closeSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { dirname, isAbsolute, win32 } from "node:path";
|
|
5
|
+
import { AxiError } from "axi-sdk-js";
|
|
6
|
+
// LiteralPath and single-quoted literals keep file names out of PowerShell syntax.
|
|
7
|
+
export const windowsAcl = (path, directory, action) => {
|
|
8
|
+
const literal = `'${path.replace(/'/g, "''")}'`;
|
|
9
|
+
const script = `$ErrorActionPreference='Stop'; $p=${literal};
|
|
10
|
+
$item=Get-Item -LiteralPath $p -Force;
|
|
11
|
+
if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'Reparse point' };
|
|
12
|
+
${action === "inspect" ? "" : `
|
|
13
|
+
$user=[Security.Principal.WindowsIdentity]::GetCurrent().User;
|
|
14
|
+
$ids=@($user.Value,'S-1-5-18','S-1-5-32-544');
|
|
15
|
+
${action === "restrict" ? `
|
|
16
|
+
$acl=[Security.AccessControl.${directory ? "DirectorySecurity" : "FileSecurity"}]::new();
|
|
17
|
+
$acl.SetOwner($user); $acl.SetAccessRuleProtection($true,$false);
|
|
18
|
+
foreach ($id in $ids) {
|
|
19
|
+
$sid=[Security.Principal.SecurityIdentifier]::new($id);
|
|
20
|
+
$rule=[Security.AccessControl.FileSystemAccessRule]::new($sid,'FullControl','${directory ? "ContainerInherit,ObjectInherit" : "None"}','None','Allow');
|
|
21
|
+
$acl.AddAccessRule($rule);
|
|
22
|
+
}; Set-Acl -LiteralPath $p -AclObject $acl;` : `
|
|
23
|
+
$acl=Get-Acl -LiteralPath $p;
|
|
24
|
+
if (!$acl.AreAccessRulesProtected -or $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value -ne $user.Value) { throw 'Unprotected ACL' };
|
|
25
|
+
$rules=@($acl.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier]));
|
|
26
|
+
if ($rules.Count -ne 3) { throw 'Unexpected ACL' };
|
|
27
|
+
foreach ($id in $ids) {
|
|
28
|
+
$matches=@($rules | Where-Object { $_.IdentityReference.Value -eq $id -and $_.AccessControlType -eq 'Allow' -and $_.FileSystemRights -eq 'FullControl' -and !$_.IsInherited -and $_.InheritanceFlags -eq '${directory ? "ContainerInherit, ObjectInherit" : "None"}' -and $_.PropagationFlags -eq 'None' });
|
|
29
|
+
if ($matches.Count -ne 1) { throw 'Unexpected grant' };
|
|
30
|
+
};`}`}`;
|
|
31
|
+
execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], { stdio: "pipe", windowsHide: true });
|
|
32
|
+
};
|
|
33
|
+
// All private-file permissions live here; Windows branches are injectable on Linux.
|
|
34
|
+
export class PrivateFiles {
|
|
35
|
+
platform;
|
|
36
|
+
acl;
|
|
37
|
+
uid;
|
|
38
|
+
constructor(platform = process.platform, acl = windowsAcl, uid = process.getuid?.()) {
|
|
39
|
+
this.platform = platform;
|
|
40
|
+
this.acl = acl;
|
|
41
|
+
this.uid = uid;
|
|
42
|
+
}
|
|
43
|
+
absolute(path) {
|
|
44
|
+
return this.platform === "win32" ? /^[A-Za-z]:[\\/]/.test(path) && win32.isAbsolute(path) : isAbsolute(path);
|
|
45
|
+
}
|
|
46
|
+
insecure(path) {
|
|
47
|
+
throw new AxiError(`Insecure file ${path}`, "CONFIG_INSECURE", [this.platform === "win32" ? `Re-run the tool's write to protect ${path}` : `chmod 600 '${path.replace(/'/g, "'\\''")}'`]);
|
|
48
|
+
}
|
|
49
|
+
check(path, secret = true, verifyAcl = false) {
|
|
50
|
+
try {
|
|
51
|
+
const stat = lstatSync(path);
|
|
52
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
53
|
+
this.insecure(path);
|
|
54
|
+
if (this.platform === "win32") {
|
|
55
|
+
try {
|
|
56
|
+
this.acl(path, false, verifyAcl ? "verify" : "inspect");
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
if (error instanceof AxiError)
|
|
60
|
+
throw error;
|
|
61
|
+
this.insecure(path);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
else if (stat.uid !== this.uid || (stat.mode & (secret ? 0o077 : 0o022)) !== 0)
|
|
65
|
+
this.insecure(path);
|
|
66
|
+
return stat;
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
const code = error.code;
|
|
70
|
+
if (code === "ENOENT")
|
|
71
|
+
throw error;
|
|
72
|
+
if (code === "EACCES" || code === "EPERM" || code === "ELOOP")
|
|
73
|
+
return this.insecure(path);
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
read(path, secret = true, verifyAcl = false) {
|
|
78
|
+
const checked = this.check(path, secret, verifyAcl);
|
|
79
|
+
let fd;
|
|
80
|
+
try {
|
|
81
|
+
fd = openSync(path, constants.O_RDONLY | (this.platform === "win32" ? 0 : constants.O_NOFOLLOW));
|
|
82
|
+
const opened = fstatSync(fd);
|
|
83
|
+
if (opened.dev !== checked.dev || opened.ino !== checked.ino)
|
|
84
|
+
this.insecure(path);
|
|
85
|
+
return readFileSync(fd, "utf8").replace(/^\uFEFF/, "");
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
if (error instanceof AxiError)
|
|
89
|
+
throw error;
|
|
90
|
+
const code = error.code;
|
|
91
|
+
if (code === "EACCES" || code === "EPERM" || code === "ELOOP")
|
|
92
|
+
this.insecure(path);
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
if (fd !== undefined)
|
|
97
|
+
try {
|
|
98
|
+
closeSync(fd);
|
|
99
|
+
}
|
|
100
|
+
catch { /* original error preserved */ }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
directory(path) {
|
|
104
|
+
try {
|
|
105
|
+
let firstCreated;
|
|
106
|
+
if (!existsSync(path)) {
|
|
107
|
+
const parent = dirname(path);
|
|
108
|
+
if (!existsSync(parent))
|
|
109
|
+
firstCreated = this.directory(parent);
|
|
110
|
+
mkdirSync(path, { mode: 0o700 });
|
|
111
|
+
firstCreated ??= path;
|
|
112
|
+
}
|
|
113
|
+
const stat = lstatSync(path);
|
|
114
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
115
|
+
this.insecure(path);
|
|
116
|
+
if (this.platform === "win32") {
|
|
117
|
+
try {
|
|
118
|
+
this.acl(path, true, "restrict");
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
if (error instanceof AxiError)
|
|
122
|
+
throw error;
|
|
123
|
+
this.insecure(path);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
if (stat.uid !== this.uid)
|
|
128
|
+
this.insecure(path);
|
|
129
|
+
chmodSync(path, 0o700);
|
|
130
|
+
}
|
|
131
|
+
return firstCreated;
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
if (error instanceof AxiError)
|
|
135
|
+
throw error;
|
|
136
|
+
const code = error.code;
|
|
137
|
+
if (code === "EACCES" || code === "EPERM" || code === "ELOOP")
|
|
138
|
+
this.insecure(path);
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
protect(path) {
|
|
143
|
+
try {
|
|
144
|
+
const stat = lstatSync(path);
|
|
145
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
146
|
+
this.insecure(path);
|
|
147
|
+
if (this.platform === "win32") {
|
|
148
|
+
try {
|
|
149
|
+
this.acl(path, false, "restrict");
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
if (error instanceof AxiError)
|
|
153
|
+
throw error;
|
|
154
|
+
this.insecure(path);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
if (stat.uid !== this.uid)
|
|
159
|
+
this.insecure(path);
|
|
160
|
+
chmodSync(path, 0o600);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
if (error instanceof AxiError)
|
|
165
|
+
throw error;
|
|
166
|
+
const code = error.code;
|
|
167
|
+
if (code === "EACCES" || code === "EPERM" || code === "ELOOP")
|
|
168
|
+
this.insecure(path);
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
write(path, content) {
|
|
173
|
+
this.directory(dirname(path));
|
|
174
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
175
|
+
let fd;
|
|
176
|
+
try {
|
|
177
|
+
try {
|
|
178
|
+
fd = openSync(temporary, "wx", this.platform === "win32" ? undefined : 0o600);
|
|
179
|
+
try {
|
|
180
|
+
if (this.platform === "win32")
|
|
181
|
+
this.acl(temporary, false, "restrict");
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
if (error instanceof AxiError)
|
|
185
|
+
throw error;
|
|
186
|
+
this.insecure(path);
|
|
187
|
+
}
|
|
188
|
+
writeFileSync(fd, content);
|
|
189
|
+
fsyncSync(fd);
|
|
190
|
+
closeSync(fd);
|
|
191
|
+
fd = undefined;
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
if (error instanceof AxiError)
|
|
195
|
+
throw error;
|
|
196
|
+
const code = error.code;
|
|
197
|
+
if (code === "EACCES" || code === "EPERM" || code === "ELOOP")
|
|
198
|
+
this.insecure(path);
|
|
199
|
+
throw error;
|
|
200
|
+
}
|
|
201
|
+
const deadline = Date.now() + 1000;
|
|
202
|
+
let delay = 10;
|
|
203
|
+
for (;;) {
|
|
204
|
+
try {
|
|
205
|
+
renameSync(temporary, path);
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
if (error.code === "ELOOP")
|
|
210
|
+
this.insecure(path);
|
|
211
|
+
if (this.platform !== "win32" || !["EPERM", "EBUSY", "EACCES"].includes(error.code ?? "") || Date.now() >= deadline)
|
|
212
|
+
throw error;
|
|
213
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.min(delay, deadline - Date.now()));
|
|
214
|
+
delay = Math.min(delay * 2, 100);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
finally {
|
|
219
|
+
if (fd !== undefined)
|
|
220
|
+
try {
|
|
221
|
+
closeSync(fd);
|
|
222
|
+
}
|
|
223
|
+
catch { /* original error preserved */ }
|
|
224
|
+
rmSync(temporary, { force: true });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
package/dist/profiles.js
CHANGED
|
@@ -1,84 +1,161 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
3
2
|
import { homedir } from "node:os";
|
|
4
|
-
import { dirname, join } from "node:path";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
4
|
import { AxiError } from "axi-sdk-js";
|
|
5
|
+
import { configPath, toolDirectory } from "./paths.js";
|
|
6
|
+
import { PrivateFiles } from "./private-file.js";
|
|
7
|
+
import { validateSecretRef } from "./secrets.js";
|
|
8
|
+
const names = new WeakMap();
|
|
6
9
|
const guid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
7
|
-
const namePattern = /^[
|
|
10
|
+
const namePattern = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
8
11
|
const object = (value) => !!value && typeof value === "object" && !Array.isArray(value);
|
|
9
12
|
const exact = (value, keys) => Object.keys(value).every(key => keys.includes(key));
|
|
10
13
|
function invalid(message) {
|
|
11
|
-
throw new AxiError(message, "
|
|
14
|
+
throw new AxiError(message, "CONFIG_INVALID", ["mg-axi profile create --help", "Correct the version-1 profile configuration; no automatic migration is performed"]);
|
|
12
15
|
}
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
16
|
+
export function booleanEnv(name, env = process.env) {
|
|
17
|
+
const value = env[name]?.toLowerCase();
|
|
18
|
+
if (value === undefined || value === "0" || value === "false")
|
|
19
|
+
return false;
|
|
20
|
+
if (value === "1" || value === "true")
|
|
21
|
+
return true;
|
|
22
|
+
return invalid(`Invalid boolean for ${name}; use 1, true, 0 or false`);
|
|
23
|
+
}
|
|
24
|
+
// Names belong to the selected configuration, never to its serialized profile schema.
|
|
25
|
+
export function profileName(profile) { return names.get(profile); }
|
|
26
|
+
function validated(source, profile) {
|
|
27
|
+
const name = names.get(source);
|
|
28
|
+
if (name !== undefined)
|
|
29
|
+
names.set(profile, name);
|
|
30
|
+
return Object.freeze(profile);
|
|
31
|
+
}
|
|
32
|
+
function shared(value) {
|
|
33
|
+
if (typeof value.tenantId !== "string" || !guid.test(value.tenantId) || typeof value.clientId !== "string" || !guid.test(value.clientId))
|
|
34
|
+
invalid("Explicit tenant and client UUIDs are required");
|
|
35
|
+
if (value.cloud !== undefined && value.cloud !== "commercial")
|
|
36
|
+
invalid("Only commercial cloud is supported");
|
|
37
|
+
const packs = value.enabledPacks === undefined ? ["entra"] : value.enabledPacks;
|
|
38
|
+
const sensitive = value.sensitiveAreas === undefined ? [] : value.sensitiveAreas;
|
|
39
|
+
const preview = value.preview === undefined ? false : value.preview;
|
|
40
|
+
if (!Array.isArray(packs) || packs.some(pack => pack !== "entra") || new Set(packs).size !== packs.length ||
|
|
41
|
+
!Array.isArray(sensitive) || sensitive.length || typeof preview !== "boolean")
|
|
42
|
+
invalid("Invalid profile policy");
|
|
43
|
+
let writes;
|
|
44
|
+
if (value.writes !== undefined) {
|
|
45
|
+
const policy = value.writes;
|
|
46
|
+
if (!object(policy) || !exact(policy, ["allowWrites", "operations"]) || typeof policy.allowWrites !== "boolean" ||
|
|
47
|
+
!Array.isArray(policy.operations) || !policy.operations.length || policy.operations.length > 64 ||
|
|
48
|
+
policy.operations.some(entry => typeof entry !== "string" || !entry.length || entry.length > 256))
|
|
49
|
+
invalid("Invalid write policy; use boolean allowWrites and a nonempty operations array");
|
|
50
|
+
writes = Object.freeze({ allowWrites: policy.allowWrites, operations: Object.freeze([...policy.operations]) });
|
|
24
51
|
}
|
|
25
|
-
return
|
|
52
|
+
return { tenantId: value.tenantId, clientId: value.clientId, cloud: "commercial", enabledPacks: Object.freeze([...packs]),
|
|
53
|
+
preview: preview, sensitiveAreas: Object.freeze([]), ...(writes === undefined ? {} : { writes }) };
|
|
26
54
|
}
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
!object(value.credentialRef) || !exact(value.credentialRef, ["provider", "key"]) || value.credentialRef.provider !== "os-or-session" ||
|
|
34
|
-
typeof value.credentialRef.key !== "string" || !guid.test(value.credentialRef.key))
|
|
35
|
-
invalid("Invalid delegated profile; explicit tenant/client IDs and commercial cloud are required, with credentials only by reference");
|
|
36
|
-
const profile = value;
|
|
37
|
-
const writes = validateWrites(value.writes);
|
|
38
|
-
return Object.freeze({ mode: "delegated", tenantId: profile.tenantId, clientId: profile.clientId, cloud: "commercial", preview: profile.preview,
|
|
39
|
-
allowDeviceCode: profile.allowDeviceCode, enabledPacks: Object.freeze([...profile.enabledPacks]), sensitiveAreas: Object.freeze([]),
|
|
40
|
-
credentialRef: Object.freeze({ provider: "os-or-session", key: profile.credentialRef.key }), ...(writes === undefined ? {} : { writes }) });
|
|
55
|
+
const sharedKeys = ["mode", "tenantId", "clientId", "cloud", "enabledPacks", "preview", "sensitiveAreas", "allowDeviceCode", "writes"];
|
|
56
|
+
export function validateDelegatedProfile(value, files) {
|
|
57
|
+
if (!object(value) || value.mode !== "delegated" || !exact(value, [...sharedKeys, "token"]) ||
|
|
58
|
+
(value.allowDeviceCode !== undefined && typeof value.allowDeviceCode !== "boolean"))
|
|
59
|
+
invalid("Invalid delegated profile; token must be a SecretRef");
|
|
60
|
+
return validated(value, { ...shared(value), mode: "delegated", allowDeviceCode: value.allowDeviceCode === true, token: validateSecretRef(value.token, "token", true, files) });
|
|
41
61
|
}
|
|
42
|
-
export function validateApplicationProfile(value) {
|
|
43
|
-
if (!object(value) ||
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
return
|
|
53
|
-
preview: profile.preview, allowDeviceCode: false, enabledPacks: Object.freeze([...profile.enabledPacks]), sensitiveAreas: Object.freeze([]),
|
|
54
|
-
credentialRef: Object.freeze({ provider: "certificate", key: ref.key, thumbprint: ref.thumbprint.toLowerCase() }), ...writeFields });
|
|
62
|
+
export function validateApplicationProfile(value, files) {
|
|
63
|
+
if (!object(value) || value.mode !== "application" || (value.allowDeviceCode !== undefined && value.allowDeviceCode !== false))
|
|
64
|
+
invalid("Invalid application profile; device code is unavailable");
|
|
65
|
+
const keys = value.auth === "certificate" ? ["privateKey", "thumbprintSha256"] : value.auth === "client-secret" ? ["clientSecret"] : value.auth === "federated" ? ["token"] : [];
|
|
66
|
+
if (!keys.length || !exact(value, [...sharedKeys, "auth", ...keys]))
|
|
67
|
+
invalid("Invalid application profile; auth must be certificate, client-secret or federated with only its credential fields");
|
|
68
|
+
const base = { ...shared(value), mode: "application", allowDeviceCode: false };
|
|
69
|
+
if (value.auth === "certificate") {
|
|
70
|
+
if (typeof value.thumbprintSha256 !== "string" || !/^[0-9a-f]{64}$/i.test(value.thumbprintSha256))
|
|
71
|
+
invalid("thumbprintSha256 must contain 64 hex digits");
|
|
72
|
+
return validated(value, { ...base, auth: "certificate", thumbprintSha256: value.thumbprintSha256.toLowerCase(), privateKey: validateSecretRef(value.privateKey, "privateKey", false, files) });
|
|
55
73
|
}
|
|
56
|
-
if (
|
|
57
|
-
return
|
|
58
|
-
|
|
59
|
-
|
|
74
|
+
if (value.auth === "client-secret")
|
|
75
|
+
return validated(value, { ...base, auth: "client-secret", clientSecret: validateSecretRef(value.clientSecret, "clientSecret", false, files) });
|
|
76
|
+
return validated(value, { ...base, auth: "federated", token: validateSecretRef(value.token, "token", false, files) });
|
|
77
|
+
}
|
|
78
|
+
export function validateProfile(value, files) {
|
|
79
|
+
return object(value) && value.mode === "application" ? validateApplicationProfile(value, files) : validateDelegatedProfile(value, files);
|
|
80
|
+
}
|
|
81
|
+
function envProfile(env) {
|
|
82
|
+
const fields = ["MODE", "TENANT_ID", "CLIENT_ID", "AUTH", "CLOUD", "PREVIEW", "ALLOW_DEVICE_CODE", "ENABLED_PACKS", "SENSITIVE_AREAS", "WRITES"];
|
|
83
|
+
if (!fields.some(field => env[`MG_AXI_${field}`] !== undefined))
|
|
84
|
+
return undefined;
|
|
85
|
+
if (!env.MG_AXI_MODE || !env.MG_AXI_TENANT_ID || !env.MG_AXI_CLIENT_ID)
|
|
86
|
+
invalid("Partial env profile; MG_AXI_MODE, MG_AXI_TENANT_ID and MG_AXI_CLIENT_ID are required");
|
|
87
|
+
const json = (name) => {
|
|
88
|
+
if (env[name] === undefined)
|
|
89
|
+
return undefined;
|
|
90
|
+
try {
|
|
91
|
+
return JSON.parse(env[name]);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return invalid(`Invalid JSON field ${name}`);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
const base = { mode: env.MG_AXI_MODE, tenantId: env.MG_AXI_TENANT_ID, clientId: env.MG_AXI_CLIENT_ID, cloud: env.MG_AXI_CLOUD,
|
|
98
|
+
preview: booleanEnv("MG_AXI_PREVIEW", env), allowDeviceCode: booleanEnv("MG_AXI_ALLOW_DEVICE_CODE", env),
|
|
99
|
+
enabledPacks: json("MG_AXI_ENABLED_PACKS"), sensitiveAreas: json("MG_AXI_SENSITIVE_AREAS"), writes: json("MG_AXI_WRITES"),
|
|
100
|
+
...(env.MG_AXI_AUTH === undefined ? {} : { auth: env.MG_AXI_AUTH }),
|
|
101
|
+
...(env.MG_AXI_THUMBPRINT_SHA256 === undefined ? {} : { thumbprintSha256: env.MG_AXI_THUMBPRINT_SHA256 }),
|
|
102
|
+
...(env.MG_AXI_CLIENT_SECRET === undefined ? {} : { clientSecret: { env: "MG_AXI_CLIENT_SECRET" } }),
|
|
103
|
+
...(env.MG_AXI_PRIVATE_KEY === undefined ? {} : { privateKey: { env: "MG_AXI_PRIVATE_KEY" } }),
|
|
104
|
+
...(env.MG_AXI_TOKEN === undefined ? {} : { token: { env: "MG_AXI_TOKEN" } }) };
|
|
105
|
+
if (base.mode === "delegated") {
|
|
106
|
+
if (!env.MG_AXI_TOKEN)
|
|
107
|
+
invalid("Partial env profile; MG_AXI_TOKEN must supply a delegated cache");
|
|
108
|
+
return validateDelegatedProfile(base);
|
|
60
109
|
}
|
|
61
|
-
|
|
110
|
+
const auth = env.MG_AXI_AUTH;
|
|
111
|
+
const required = auth === "certificate" ? ["PRIVATE_KEY", "THUMBPRINT_SHA256"] : auth === "client-secret" ? ["CLIENT_SECRET"] : auth === "federated" ? ["TOKEN"] : [];
|
|
112
|
+
if (!required.length || required.some(field => !env[`MG_AXI_${field}`]))
|
|
113
|
+
invalid("Partial env profile; MG_AXI_AUTH and its required credential fields must be set");
|
|
114
|
+
return validateApplicationProfile(base);
|
|
62
115
|
}
|
|
63
|
-
export function
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
116
|
+
export function movedError(kind, legacy, target, guidance) {
|
|
117
|
+
const quote = (path) => `'${path.replace(/'/g, "'\\''")}'`;
|
|
118
|
+
const move = process.platform === "win32"
|
|
119
|
+
? `New-Item -ItemType Directory -Force -Path '${dirname(target).replace(/'/g, "''")}'; Move-Item -LiteralPath '${legacy.replace(/'/g, "''")}' -Destination '${target.replace(/'/g, "''")}'`
|
|
120
|
+
: `mkdir -p ${quote(dirname(target))} && mv ${quote(legacy)} ${quote(target)}`;
|
|
121
|
+
throw new AxiError(`${kind} moved from ${legacy} to ${target}`, "CONFIG_MOVED", [move, guidance]);
|
|
67
122
|
}
|
|
68
123
|
export class Profiles {
|
|
124
|
+
files;
|
|
69
125
|
path;
|
|
70
|
-
constructor(path =
|
|
71
|
-
this.
|
|
126
|
+
constructor(path, files = new PrivateFiles()) {
|
|
127
|
+
this.files = files;
|
|
128
|
+
this.path = path?.trim() ? path.trim() : configPath();
|
|
129
|
+
}
|
|
130
|
+
moved() {
|
|
131
|
+
const legacy = join(homedir(), ".mg-axi", "config.json");
|
|
132
|
+
const target = resolve(this.path) === resolve(legacy) ? join(toolDirectory("config"), "config.json") : this.path;
|
|
133
|
+
movedError("Configuration", legacy, target, "Update credentialRef to the flattened SecretRef format described in the AXI credential standard");
|
|
72
134
|
}
|
|
73
135
|
load() {
|
|
74
|
-
|
|
136
|
+
const legacy = join(homedir(), ".mg-axi", "config.json");
|
|
137
|
+
if (resolve(this.path) === resolve(legacy) && existsSync(legacy))
|
|
138
|
+
this.moved();
|
|
139
|
+
let text;
|
|
75
140
|
try {
|
|
76
|
-
|
|
141
|
+
text = this.files.read(this.path, false);
|
|
77
142
|
}
|
|
78
143
|
catch (error) {
|
|
79
|
-
if (error
|
|
144
|
+
if (error instanceof AxiError)
|
|
145
|
+
throw error;
|
|
146
|
+
if (error.code === "ENOENT") {
|
|
147
|
+
if (existsSync(legacy))
|
|
148
|
+
this.moved();
|
|
80
149
|
return { version: 1, profiles: {} };
|
|
81
|
-
|
|
150
|
+
}
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
let value;
|
|
154
|
+
try {
|
|
155
|
+
value = JSON.parse(text);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
invalid("Unable to read profile configuration; check the version-1 JSON schema");
|
|
82
159
|
}
|
|
83
160
|
if (!object(value) || value.version !== 1 || !exact(value, ["version", "defaultProfile", "profiles"]) || !object(value.profiles))
|
|
84
161
|
invalid("Unsupported profile configuration; explicitly migrate to version 1");
|
|
@@ -86,54 +163,69 @@ export class Profiles {
|
|
|
86
163
|
for (const [name, profile] of Object.entries(value.profiles)) {
|
|
87
164
|
if (!namePattern.test(name))
|
|
88
165
|
invalid("Invalid profile name");
|
|
89
|
-
profiles[name] = validateProfile(profile);
|
|
166
|
+
profiles[name] = validateProfile(profile, this.files);
|
|
167
|
+
names.set(profiles[name], name);
|
|
90
168
|
}
|
|
91
169
|
if (value.defaultProfile !== undefined && (typeof value.defaultProfile !== "string" || !Object.hasOwn(profiles, value.defaultProfile)))
|
|
92
170
|
invalid("Default profile does not exist");
|
|
93
|
-
if (new Set(Object.values(profiles).map(profile => profile.credentialRef.key)).size !== Object.keys(profiles).length)
|
|
94
|
-
invalid("Each profile must have a separate credential reference");
|
|
95
171
|
return { version: 1, profiles, defaultProfile: value.defaultProfile };
|
|
96
172
|
}
|
|
97
173
|
list() { return Object.entries(this.load().profiles).map(([name, profile]) => ({ name, mode: profile.mode, tenant: profile.tenantId, cloud: profile.cloud })); }
|
|
98
174
|
resolve(name) {
|
|
175
|
+
const flag = name !== undefined && name.trim() !== "" ? name.trim() : undefined;
|
|
176
|
+
const envName = process.env.MG_AXI_PROFILE !== undefined && process.env.MG_AXI_PROFILE.trim() !== "" ? process.env.MG_AXI_PROFILE.trim() : undefined;
|
|
177
|
+
const explicit = flag ?? envName;
|
|
178
|
+
if (explicit === undefined) {
|
|
179
|
+
const profile = envProfile(process.env);
|
|
180
|
+
if (profile) {
|
|
181
|
+
names.set(profile, "env");
|
|
182
|
+
return { name: "env", profile };
|
|
183
|
+
}
|
|
184
|
+
}
|
|
99
185
|
const config = this.load();
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
186
|
+
const configured = Object.keys(config.profiles);
|
|
187
|
+
const selected = explicit ?? config.defaultProfile ?? (configured.length === 1 ? configured[0] : undefined);
|
|
188
|
+
if (!selected)
|
|
189
|
+
throw new AxiError("No configured profile selected", configured.length ? "PROFILE_AMBIGUOUS" : "PROFILE_REQUIRED", ["mg-axi profile list", "mg-axi profile create --help"]);
|
|
190
|
+
if (!Object.hasOwn(config.profiles, selected))
|
|
191
|
+
throw new AxiError(`Profile ${selected} does not exist`, "PROFILE_NOT_FOUND", ["mg-axi profile list"]);
|
|
103
192
|
return { name: selected, profile: config.profiles[selected] };
|
|
104
193
|
}
|
|
194
|
+
verifyPermissions() {
|
|
195
|
+
try {
|
|
196
|
+
this.files.check(this.path, false, true);
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
if (error.code !== "ENOENT")
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
105
203
|
create(name, tenantId, clientId, cloud, allowDeviceCode, application) {
|
|
106
204
|
if (!namePattern.test(name))
|
|
107
205
|
invalid("Profile name must contain only letters, digits, underscores or hyphens");
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
if (application
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
206
|
+
const base = { tenantId, clientId, cloud, allowDeviceCode };
|
|
207
|
+
let profile;
|
|
208
|
+
if (!application)
|
|
209
|
+
profile = validateDelegatedProfile({ ...base, mode: "delegated", token: { stored: true } }, this.files);
|
|
210
|
+
else {
|
|
211
|
+
const { certificateThumbprint, federated, clientSecretEnv, clientSecretFile, certificateKeyFile, certificateKeyEnv, federatedTokenFile } = application;
|
|
212
|
+
if (clientSecretEnv !== undefined && clientSecretFile !== undefined || certificateKeyEnv !== undefined && certificateKeyFile !== undefined)
|
|
213
|
+
invalid("Use exactly one env or file reference for each credential");
|
|
214
|
+
if ([certificateThumbprint !== undefined, federated === true, clientSecretEnv !== undefined || clientSecretFile !== undefined].filter(Boolean).length !== 1)
|
|
215
|
+
invalid("Select exactly one application credential kind");
|
|
216
|
+
if ((certificateKeyEnv !== undefined || certificateKeyFile !== undefined) && certificateThumbprint === undefined || federatedTokenFile !== undefined && !federated)
|
|
217
|
+
invalid("Credential holder does not match the selected application auth kind");
|
|
218
|
+
profile = validateApplicationProfile({ ...base, mode: "application",
|
|
219
|
+
...(certificateThumbprint !== undefined ? { auth: "certificate", thumbprintSha256: certificateThumbprint, privateKey: certificateKeyFile !== undefined ? { file: certificateKeyFile } : { env: certificateKeyEnv ?? "MG_AXI_PRIVATE_KEY" } }
|
|
220
|
+
: federated ? { auth: "federated", ...(federatedTokenFile === undefined ? {} : { token: { file: federatedTokenFile } }) }
|
|
221
|
+
: { auth: "client-secret", clientSecret: clientSecretFile !== undefined ? { file: clientSecretFile } : { env: clientSecretEnv } }) }, this.files);
|
|
222
|
+
}
|
|
120
223
|
const config = this.load();
|
|
121
224
|
if (Object.hasOwn(config.profiles, name))
|
|
122
225
|
invalid("Profile already exists; creation never replaces identity");
|
|
123
226
|
config.profiles[name] = profile;
|
|
124
227
|
config.defaultProfile ??= name;
|
|
125
|
-
|
|
126
|
-
const temporary = `${this.path}.${randomUUID()}.tmp`;
|
|
127
|
-
try {
|
|
128
|
-
writeFileSync(temporary, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600, flag: "wx" });
|
|
129
|
-
renameSync(temporary, this.path);
|
|
130
|
-
}
|
|
131
|
-
catch {
|
|
132
|
-
throw new AxiError("Unable to save profile configuration", "CONFIG_ERROR", ["Check configuration directory permissions"]);
|
|
133
|
-
}
|
|
134
|
-
finally {
|
|
135
|
-
rmSync(temporary, { force: true });
|
|
136
|
-
}
|
|
228
|
+
this.files.write(this.path, `${JSON.stringify(config, null, 2)}\n`);
|
|
137
229
|
return { name, mode: profile.mode, tenant: profile.tenantId, cloud: profile.cloud };
|
|
138
230
|
}
|
|
139
231
|
}
|
package/dist/redact.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const secrets = new Set();
|
|
2
|
+
export function registerSecret(value) {
|
|
3
|
+
if (!value)
|
|
4
|
+
return value;
|
|
5
|
+
for (const form of [value, JSON.stringify(value).slice(1, -1), encodeURIComponent(value)])
|
|
6
|
+
secrets.add(form);
|
|
7
|
+
return value;
|
|
8
|
+
}
|
|
9
|
+
export function redactSecrets(value) {
|
|
10
|
+
for (const secret of [...secrets].sort((a, b) => b.length - a.length))
|
|
11
|
+
value = value.split(secret).join("***redacted***");
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
// MSAL caches contain opaque refresh, access and ID tokens under secret fields.
|
|
15
|
+
export function registerCache(value) {
|
|
16
|
+
registerSecret(value);
|
|
17
|
+
const visit = (entry, key = "") => {
|
|
18
|
+
if (typeof entry === "string" && /^(secret|accessToken|refreshToken|idToken)$/i.test(key))
|
|
19
|
+
registerSecret(entry);
|
|
20
|
+
else if (entry && typeof entry === "object")
|
|
21
|
+
for (const [name, child] of Object.entries(entry))
|
|
22
|
+
visit(child, name);
|
|
23
|
+
};
|
|
24
|
+
visit(JSON.parse(value));
|
|
25
|
+
return value;
|
|
26
|
+
}
|
package/dist/secrets.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { AxiError } from "axi-sdk-js";
|
|
2
|
+
import { PrivateFiles } from "./private-file.js";
|
|
3
|
+
import { registerSecret } from "./redact.js";
|
|
4
|
+
export function validateSecretRef(value, field, stored = true, files = new PrivateFiles()) {
|
|
5
|
+
if (value === undefined)
|
|
6
|
+
return Object.freeze({ env: `MG_AXI_${field.replace(/[A-Z]/g, letter => `_${letter}`).toUpperCase()}` });
|
|
7
|
+
if (value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === 1) {
|
|
8
|
+
const ref = value;
|
|
9
|
+
if (typeof ref.env === "string" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(ref.env))
|
|
10
|
+
return Object.freeze({ env: ref.env });
|
|
11
|
+
if (typeof ref.file === "string" && files.absolute(ref.file))
|
|
12
|
+
return Object.freeze({ file: ref.file });
|
|
13
|
+
if (stored && ref.stored === true)
|
|
14
|
+
return Object.freeze({ stored: true });
|
|
15
|
+
}
|
|
16
|
+
throw new AxiError(`Invalid ${field}; use a SecretRef, never an inline secret`, "CONFIG_INVALID", ["Use exactly one validated env name, absolute local file path, or stored: true for a delegated login"]);
|
|
17
|
+
}
|
|
18
|
+
export function secretSource(ref) { return "env" in ref ? `env:${ref.env}` : "file" in ref ? `file:${ref.file}` : "stored"; }
|
|
19
|
+
export function resolveSecret(ref, files = new PrivateFiles(), verifyAcl = false) {
|
|
20
|
+
let value;
|
|
21
|
+
if ("env" in ref)
|
|
22
|
+
value = process.env[ref.env];
|
|
23
|
+
else if ("file" in ref) {
|
|
24
|
+
try {
|
|
25
|
+
value = files.read(ref.file, true, verifyAcl).trimEnd();
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
if (error instanceof AxiError)
|
|
29
|
+
throw error;
|
|
30
|
+
if (error?.code !== "ENOENT")
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (!value?.trim())
|
|
35
|
+
throw new AxiError(`Credential unavailable from ${secretSource(ref)}`, "AUTH_REQUIRED", ["Supply the named credential reference; no interactive fallback was attempted"]);
|
|
36
|
+
return registerSecret(value);
|
|
37
|
+
}
|
package/dist/setup.js
CHANGED
|
@@ -105,9 +105,9 @@ export function setupView(store) {
|
|
|
105
105
|
"node dist/bin/mg-axi.js --version",
|
|
106
106
|
],
|
|
107
107
|
guidance: [
|
|
108
|
-
`Hand-edit ${store.path} or set MG_AXI_CONFIG to a separate configuration file;
|
|
108
|
+
`Hand-edit ${store.path} or set MG_AXI_CONFIG to a separate configuration file; a profile stores only a SecretRef - an environment variable name, an absolute file path, or stored: true for delegated login - and never the secret value, never in argv or config`,
|
|
109
109
|
"node dist/bin/mg-axi.js profile create --name soc --tenant <tenant-id> --client <client-id> --cloud commercial",
|
|
110
|
-
"node dist/bin/mg-axi.js login --profile soc --scopes https://graph.microsoft.com/User.Read.All",
|
|
110
|
+
"node dist/bin/mg-axi.js auth login --profile soc --scopes https://graph.microsoft.com/User.Read.All",
|
|
111
111
|
"node dist/bin/mg-axi.js doctor",
|
|
112
112
|
],
|
|
113
113
|
capabilities: {
|