ssh-mcp 2.3.1 → 2.3.2
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 +28 -14
- package/build/config/loader.js +13 -11
- package/build/config/windows-acl.js +171 -47
- package/build/index.js +7 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -60,18 +60,32 @@ which is why `chmod 700` is in that command, since `mkdir -p` under the default
|
|
|
60
60
|
leaves the directory 0755. The server refuses to start otherwise. "Only the owner" is
|
|
61
61
|
unambiguous here and `chmod` is a one-line fix.
|
|
62
62
|
|
|
63
|
-
**Windows:
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
config
|
|
63
|
+
**Windows: split by what the ACL actually allows.** There are no mode bits, so the ACL is
|
|
64
|
+
read instead — and read exposure and write exposure are not treated alike, because Windows
|
|
65
|
+
is much clearer about one of them than the other.
|
|
66
|
+
|
|
67
|
+
| The ACL lets another account… | Default |
|
|
68
|
+
|---|---|
|
|
69
|
+
| only **read** the config | reported, and the server starts |
|
|
70
|
+
| **change** the config | refused |
|
|
71
|
+
| nothing (no ACL at all) | refused — that is full control for everyone |
|
|
72
|
+
| …and if the ACL could not be read | refused, except when `icacls` is absent or the check timed out |
|
|
73
|
+
|
|
74
|
+
A config under `%APPDATA%` inherits access for you, `SYSTEM` and `Administrators` and needs
|
|
75
|
+
nothing done to it. One created elsewhere does not: a file under `C:\` inherits *read* for
|
|
76
|
+
every local account and *modify* for every authenticated one. The message names the two
|
|
77
|
+
`icacls` commands that fix it either way.
|
|
78
|
+
|
|
79
|
+
Read exposure is reported rather than refused because that is where Windows is genuinely
|
|
80
|
+
muddier than POSIX, and refusing over it blocked a config at the documented location
|
|
81
|
+
([#138](https://github.com/tufantunc/ssh-mcp/issues/138)). Write exposure is refused
|
|
82
|
+
because it is not muddy at all: another account being able to rewrite the file that decides
|
|
83
|
+
which hosts, roles and approval policy this server honours is an authorization bypass, not
|
|
84
|
+
a disclosure.
|
|
85
|
+
|
|
86
|
+
Two flags move the whole thing: `--strictConfigAcl` refuses everything the check objects
|
|
87
|
+
to, read-only grants included; `--allowUncheckedConfigAcl` reports everything and refuses
|
|
88
|
+
nothing. Neither combination leaves you without an exit, which is the lesson of #138.
|
|
75
89
|
|
|
76
90
|
### Exit statuses
|
|
77
91
|
|
|
@@ -604,8 +618,8 @@ Secrets are **never** passed as CLI arguments.
|
|
|
604
618
|
| `--rateLimit` | 0 | HTTP requests per minute on the MCP route (0 = unlimited) |
|
|
605
619
|
| `--allowedHosts` | bind address + localhost | Comma-separated Host headers accepted by the DNS-rebinding guard |
|
|
606
620
|
| `--insecureHostKey` | false | Disable host key verification (test only!) |
|
|
607
|
-
| `--allowUncheckedConfigAcl` | false | Windows:
|
|
608
|
-
| `--strictConfigAcl` | Windows: refuse
|
|
621
|
+
| `--allowUncheckedConfigAcl` | false | Windows: report every ACL finding and refuse none |
|
|
622
|
+
| `--strictConfigAcl` | false | Windows: refuse on every ACL finding, including a read-only over-grant |
|
|
609
623
|
| `--disableApproval` | false | Skip the approval gate (quick start profile only) |
|
|
610
624
|
| `--opaUrl` | — | OPA sidecar URL for external policy |
|
|
611
625
|
| `--commandQuota` | 0 (off) | Max commands per rolling 24h per profile |
|
package/build/config/loader.js
CHANGED
|
@@ -20,19 +20,21 @@ export function getConfigPath(customPath) {
|
|
|
20
20
|
return join(xdgConfig, 'ssh-mcp', 'config.toml');
|
|
21
21
|
}
|
|
22
22
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
23
|
+
* Check that nobody but the owner can reach a config file, because it holds the hosts,
|
|
24
|
+
* roles and policy rules that decide what this server will run.
|
|
25
25
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* rejected. The `chmod 600` it prescribed could not lift that rejection —
|
|
31
|
-
* measured on Windows 11, `chmod(path, 0o600)` leaves the mode at `0o666`,
|
|
32
|
-
* because `fs.chmod` there only toggles the read-only bit. Following the
|
|
33
|
-
* instruction exactly returned the operator to where they started.
|
|
26
|
+
* The two platforms answer differently on purpose. POSIX refuses: "only the owner" is
|
|
27
|
+
* unambiguous there and `chmod` is a one-line fix. Windows refuses a config another
|
|
28
|
+
* account can *change* and reports one it can only *read*, because read exposure is where
|
|
29
|
+
* the platform is genuinely muddier — see `waived` in windows-acl.ts.
|
|
34
30
|
*
|
|
35
|
-
* Windows
|
|
31
|
+
* Mode bits are POSIX's answer and are meaningless on Windows, which is the whole of #138:
|
|
32
|
+
* Node synthesises `0o666` for every readable file there, so `& 0o077` was non-zero
|
|
33
|
+
* unconditionally and every Windows config that has ever existed was rejected. The
|
|
34
|
+
* `chmod 600` it prescribed could not lift that rejection — measured on Windows 11,
|
|
35
|
+
* `chmod(path, 0o600)` leaves the mode at `0o666`, because `fs.chmod` there only toggles
|
|
36
|
+
* the read-only bit. Following the instruction exactly returned the operator to where
|
|
37
|
+
* they started.
|
|
36
38
|
*/
|
|
37
39
|
export async function checkPermissions(filePath, opts = {}) {
|
|
38
40
|
return platform() === 'win32'
|
|
@@ -68,7 +68,7 @@ const CHILD_ENV = { SystemRoot: join(SYSTEM32, '..'), Path: SYSTEM32 };
|
|
|
68
68
|
*
|
|
69
69
|
* Generous, because running out is not evidence about the file: five seconds across
|
|
70
70
|
* three process creations plus two temp-directory round trips, so that on-access
|
|
71
|
-
* virus scanning or a loaded host does not turn a private config into a
|
|
71
|
+
* virus scanning or a loaded host does not turn a private config into a finding.
|
|
72
72
|
* Expiry gets its own verdict for the same reason — see `timed-out`.
|
|
73
73
|
*/
|
|
74
74
|
const ACL_TIMEOUT_MS = 5_000;
|
|
@@ -202,6 +202,56 @@ const DACL_CONTROL_FLAGS = /^(?:P|AI|AR)*$/;
|
|
|
202
202
|
const ACE_FLAG_CODES = new Set([
|
|
203
203
|
'CI', 'OI', 'IO', 'NP', 'ID', 'SA', 'FA', 'TP', 'CR',
|
|
204
204
|
]);
|
|
205
|
+
/**
|
|
206
|
+
* Rights that let a trustee change the object, as SDDL letter codes. `WD` here is
|
|
207
|
+
* WRITE_DAC, not Everyone — the rights field and the trustee field have separate
|
|
208
|
+
* vocabularies that happen to share spellings.
|
|
209
|
+
*/
|
|
210
|
+
const WRITE_RIGHT_CODES = new Set([
|
|
211
|
+
'FA', 'FW', 'KA', 'KW', 'GA', 'GW', 'SD', 'WD', 'WO', 'CC', 'DC', 'SW', 'WP', 'DT',
|
|
212
|
+
]);
|
|
213
|
+
/** Rights that only let a trustee look. Anything unrecognised counts as write. */
|
|
214
|
+
const READ_RIGHT_CODES = new Set([
|
|
215
|
+
'FR', 'FX', 'GR', 'GX', 'KR', 'RC', 'RP', 'LC', 'LO',
|
|
216
|
+
]);
|
|
217
|
+
/**
|
|
218
|
+
* FILE_WRITE_DATA, FILE_APPEND_DATA, FILE_WRITE_EA, FILE_WRITE_ATTRIBUTES, DELETE,
|
|
219
|
+
* WRITE_DAC, WRITE_OWNER, GENERIC_WRITE, GENERIC_ALL.
|
|
220
|
+
*/
|
|
221
|
+
const WRITE_MASK = 0x2 | 0x4 | 0x10 | 0x100 | 0x10000 | 0x40000 | 0x80000 | 0x40000000 | 0x10000000;
|
|
222
|
+
/**
|
|
223
|
+
* Whether a rights field lets its trustee *change* the object rather than only read it.
|
|
224
|
+
*
|
|
225
|
+
* This distinction is the basis of the default posture, and the field used to be parsed
|
|
226
|
+
* and discarded. Read exposure on Windows is genuinely muddier than under POSIX — a
|
|
227
|
+
* shared volume grants `BUILTIN\Users` read, and refusing over that stranded the reporter
|
|
228
|
+
* of #138 — but write exposure is not muddy at all: "an ACE grants a non-owner
|
|
229
|
+
* FILE_WRITE_DATA or WRITE_DAC" is exactly as unambiguous as `0o022`, and a config another
|
|
230
|
+
* account can rewrite decides which hosts, roles and approval policy this server honours.
|
|
231
|
+
* That is an authorization bypass, not a disclosure, and it was being reported as one.
|
|
232
|
+
*
|
|
233
|
+
* Measured from real inheritance: `0x1200a9` is read-and-execute, `0x1301bf` is modify.
|
|
234
|
+
* Anything this cannot classify counts as write, because refusing is the safe direction.
|
|
235
|
+
*/
|
|
236
|
+
function grantsWrite(rights) {
|
|
237
|
+
const field = rights.trim().toUpperCase();
|
|
238
|
+
if (field === '')
|
|
239
|
+
return true;
|
|
240
|
+
if (/^0X[0-9A-F]+$/.test(field))
|
|
241
|
+
return (Number.parseInt(field.slice(2), 16) & WRITE_MASK) !== 0;
|
|
242
|
+
if (/^[0-9]+$/.test(field))
|
|
243
|
+
return (Number.parseInt(field, 10) & WRITE_MASK) !== 0;
|
|
244
|
+
if (field.length % 2 !== 0)
|
|
245
|
+
return true;
|
|
246
|
+
for (let i = 0; i < field.length; i += 2) {
|
|
247
|
+
const code = field.slice(i, i + 2);
|
|
248
|
+
if (WRITE_RIGHT_CODES.has(code))
|
|
249
|
+
return true;
|
|
250
|
+
if (!READ_RIGHT_CODES.has(code))
|
|
251
|
+
return true;
|
|
252
|
+
}
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
205
255
|
/**
|
|
206
256
|
* The SID of the account this process runs as.
|
|
207
257
|
*
|
|
@@ -265,7 +315,8 @@ async function currentUserSid(signal) {
|
|
|
265
315
|
* executed by nothing.
|
|
266
316
|
*
|
|
267
317
|
* Two failures say nothing about the file — icacls being absent from the machine, and
|
|
268
|
-
* the check running out of time — and those are the two the caller loads anyway
|
|
318
|
+
* the check running out of time — and those are the two the caller loads anyway even
|
|
319
|
+
* under `--strictConfigAcl`.
|
|
269
320
|
* Everything else (a non-zero exit, output we could not read) is about this path.
|
|
270
321
|
*
|
|
271
322
|
* Which makes "absent" worth being sure of: absent *here* is not enough, because a
|
|
@@ -520,7 +571,9 @@ export function parseDacl(descriptor, identity) {
|
|
|
520
571
|
const sid = resolveTrustee(trustee, accountDomain);
|
|
521
572
|
if (sid !== null && allowedSids.has(sid))
|
|
522
573
|
continue;
|
|
523
|
-
grants.set(sid ?? trustee, {
|
|
574
|
+
grants.set(sid ?? trustee, {
|
|
575
|
+
name: nameFor(trustee), trustee, sid, writes: grantsWrite(fields[2]),
|
|
576
|
+
});
|
|
524
577
|
}
|
|
525
578
|
return grants.size === 0 ? { status: 'restricted' } : { status: 'broad', grants: [...grants.values()] };
|
|
526
579
|
}
|
|
@@ -567,7 +620,7 @@ export function aclIdentity(sid) {
|
|
|
567
620
|
// RID 500 is the built-in Administrator. In a default configuration it belongs
|
|
568
621
|
// to the Administrators group, which is already allowed, so naming the account
|
|
569
622
|
// directly grants nobody new access — while refusing it is a false positive,
|
|
570
|
-
// and here that means refusing
|
|
623
|
+
// and here that means refusing, or at best warning, about a config that is fine. (On a
|
|
571
624
|
// domain-joined host the account domain is the domain, so this authorises
|
|
572
625
|
// DOMAIN\Administrator, whose local-admin status comes from Domain Admins
|
|
573
626
|
// nesting — a GPO default rather than a law.)
|
|
@@ -699,7 +752,26 @@ function remediation(path, kind, grants = []) {
|
|
|
699
752
|
// reaches icacls literally, which reports "Failed processing 1 files" and
|
|
700
753
|
// changes nothing. Measured. An instruction that only works in one shell is
|
|
701
754
|
// the same defect as the `chmod 600` this whole change replaced.
|
|
702
|
-
|
|
755
|
+
//
|
|
756
|
+
// Guarded because this is now on the *reporting* path, not only the throwing one. A
|
|
757
|
+
// Windows service under a virtual account, or a container with no loaded profile, makes
|
|
758
|
+
// `userInfo()` throw ENOENT — which escaped as a raw stack and cost the operator their
|
|
759
|
+
// config, which is #138 one environment over. A message that cannot name the account is
|
|
760
|
+
// still worth printing.
|
|
761
|
+
//
|
|
762
|
+
// A visible placeholder rather than `process.env.USERNAME`, which was the first spelling
|
|
763
|
+
// of this fallback. In the environments where `userInfo()` actually throws the env var is
|
|
764
|
+
// either unset or holds something icacls will not accept — a virtual service account, or
|
|
765
|
+
// `MACHINE$` — so it would print a command that fails, which is the defect class this
|
|
766
|
+
// whole change exists to remove. A placeholder the operator has to fill in cannot be
|
|
767
|
+
// followed by mistake.
|
|
768
|
+
let account;
|
|
769
|
+
try {
|
|
770
|
+
account = userInfo().username;
|
|
771
|
+
}
|
|
772
|
+
catch {
|
|
773
|
+
account = '<your account>';
|
|
774
|
+
}
|
|
703
775
|
return ('Restrict it with these two commands, in this order:\n' +
|
|
704
776
|
` icacls "${path}" /inheritance:d\n` +
|
|
705
777
|
` icacls "${path}" /grant:r "${account}:${rights}"${removals}\n` +
|
|
@@ -729,15 +801,91 @@ function relocation(path, kind, grants) {
|
|
|
729
801
|
remediation(path, kind, grants));
|
|
730
802
|
}
|
|
731
803
|
/**
|
|
732
|
-
*
|
|
733
|
-
*
|
|
734
|
-
*
|
|
735
|
-
*
|
|
736
|
-
*
|
|
737
|
-
*
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
804
|
+
* What a verdict means for one subject, as text and as data — and nothing else.
|
|
805
|
+
*
|
|
806
|
+
* Pure, so every message is assertable without a subprocess or a `console.error` spy, and
|
|
807
|
+
* so the refusal and the report cannot drift apart: they sat fifteen lines apart and had
|
|
808
|
+
* already diverged, one calling the same condition "was not checked" and the other "could
|
|
809
|
+
* not be checked".
|
|
810
|
+
*/
|
|
811
|
+
function describe(path, kind, verdict) {
|
|
812
|
+
if (verdict.status === 'restricted')
|
|
813
|
+
return null;
|
|
814
|
+
if (verdict.status === 'unknown') {
|
|
815
|
+
return {
|
|
816
|
+
path,
|
|
817
|
+
kind,
|
|
818
|
+
status: 'unchecked',
|
|
819
|
+
reason: verdict.reason,
|
|
820
|
+
detail: verdict.detail,
|
|
821
|
+
message: `Config ${kind} ${path} could not be checked for access by other accounts ` +
|
|
822
|
+
`(${verdict.reason}: ${verdict.detail}).\n` +
|
|
823
|
+
'Either fix the cause, move the config under %APPDATA%\\ssh-mcp, or pass ' +
|
|
824
|
+
'--allowUncheckedConfigAcl to accept it unverified.',
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
// An empty grants list for a NULL DACL is deliberate: there is nothing to `/remove:g`,
|
|
828
|
+
// and the fix is to give the object an ACL at all. `remediation` reads that emptiness.
|
|
829
|
+
const grants = verdict.status === 'broad' ? verdict.grants : [];
|
|
830
|
+
const writers = grants.filter((g) => g.writes);
|
|
831
|
+
const readers = grants.filter((g) => !g.writes);
|
|
832
|
+
const what = verdict.status === 'no-dacl'
|
|
833
|
+
? 'has no access control list at all, which on Windows means full control for every ' +
|
|
834
|
+
'account on the machine.'
|
|
835
|
+
: writers.length > 0
|
|
836
|
+
// Named as modifiable, not "readable". Calling a modify grant readable understated an
|
|
837
|
+
// integrity failure as a disclosure — and the two now decide different postures, so
|
|
838
|
+
// the wording has to tell them apart.
|
|
839
|
+
? `can be modified by accounts other than its owner: ${writers.map((g) => g.name).join(', ')}` +
|
|
840
|
+
`${readers.length ? `, and read by ${readers.map((g) => g.name).join(', ')}` : ''}. ` +
|
|
841
|
+
'Required: this account, SYSTEM and Administrators only.'
|
|
842
|
+
: `is readable beyond its owner: access is granted to ${grants.map((g) => g.name).join(', ')}. ` +
|
|
843
|
+
'Required: this account, SYSTEM and Administrators only.';
|
|
844
|
+
return {
|
|
845
|
+
path,
|
|
846
|
+
kind,
|
|
847
|
+
status: verdict.status,
|
|
848
|
+
grants,
|
|
849
|
+
message: `Config ${kind} ${path} ${what}\n` +
|
|
850
|
+
(isTightenable(path, kind) ? remediation(path, kind, grants) : relocation(path, kind, grants)),
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
/**
|
|
854
|
+
* Whether a finding is reported rather than refused.
|
|
855
|
+
*
|
|
856
|
+
* Three postures, two flags, and deliberately no combination that leaves an operator with
|
|
857
|
+
* no exit — the lesson of #138, and why this is a function rather than one boolean.
|
|
858
|
+
*
|
|
859
|
+
* By default: a grant that only lets another account *read* is reported, because that is
|
|
860
|
+
* where Windows is muddier than POSIX and refusing over it stranded a real operator. A
|
|
861
|
+
* grant that lets another account *change* the config is refused, because that is an
|
|
862
|
+
* authorization bypass and Windows is not muddy about it. An ACL that could not be
|
|
863
|
+
* determined is refused too — #138 was a known-bad verdict, never an undeterminable one,
|
|
864
|
+
* and flipping those open would let an attacker swap a loud finding for a vague one for
|
|
865
|
+
* free. The two exceptions stay: `icacls` absent, and the check running out of time, both
|
|
866
|
+
* statements about the machine rather than about the file.
|
|
867
|
+
*
|
|
868
|
+
* `--allowUncheckedConfigAcl` reports everything and refuses nothing — the single exit.
|
|
869
|
+
* `--strictConfigAcl` refuses everything the check objects to, read-only grants included.
|
|
870
|
+
*/
|
|
871
|
+
function waived(finding, opts) {
|
|
872
|
+
if (opts.allowUnchecked)
|
|
873
|
+
return true;
|
|
874
|
+
if (opts.enforce)
|
|
875
|
+
return false;
|
|
876
|
+
if (finding.status === 'unchecked') {
|
|
877
|
+
return finding.reason === 'tool-missing' || finding.reason === 'timed-out';
|
|
878
|
+
}
|
|
879
|
+
// A NULL DACL is full control for everyone, so it is never read-only.
|
|
880
|
+
return finding.status === 'broad' && !finding.grants.some((g) => g.writes);
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* Report what a config's ACL grants beyond its owner, and refuse when it is not merely
|
|
884
|
+
* readable — the Windows half of `checkPermissions`.
|
|
885
|
+
*
|
|
886
|
+
* Both the file and its directory are examined. What happens to a finding is
|
|
887
|
+
* `waived`'s decision; see it for the three postures and why the default is not to refuse
|
|
888
|
+
* a read-only over-grant.
|
|
741
889
|
*/
|
|
742
890
|
export async function assertPrivateOnWindows(filePath, opts = {}, inspect) {
|
|
743
891
|
// One deadline for the whole check, shared by every subprocess it starts.
|
|
@@ -748,44 +896,20 @@ export async function assertPrivateOnWindows(filePath, opts = {}, inspect) {
|
|
|
748
896
|
const signal = AbortSignal.timeout(ACL_TIMEOUT_MS);
|
|
749
897
|
return raceDeadline(inspectAcl(p, signal), signal);
|
|
750
898
|
});
|
|
751
|
-
const report = opts.
|
|
752
|
-
console.error(`Warning: the ACL of ${e.path} was not checked (${e.reason}: ${e.detail}), so whether ` +
|
|
753
|
-
'other accounts can read your config is unverified. Loading it anyway.');
|
|
754
|
-
});
|
|
899
|
+
const report = opts.onFinding ?? ((f) => console.error(`Warning: ${f.message}`));
|
|
755
900
|
const subjects = [
|
|
756
901
|
{ path: filePath, kind: 'file' },
|
|
757
902
|
{ path: parsePath(filePath).dir || '.', kind: 'directory' },
|
|
758
903
|
];
|
|
759
904
|
for (const { path, kind } of subjects) {
|
|
760
|
-
const
|
|
761
|
-
if (
|
|
905
|
+
const finding = describe(path, kind, await look(path));
|
|
906
|
+
if (finding === null)
|
|
762
907
|
continue;
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
'every account on the machine.';
|
|
770
|
-
const message = `Config ${kind} ${path} ${what}\n` +
|
|
771
|
-
(isTightenable(path, kind) ? remediation(path, kind, grants) : relocation(path, kind, grants));
|
|
772
|
-
if (opts.strict)
|
|
773
|
-
throw new OperatorError(message);
|
|
774
|
-
// Advisory by default; see AclOptions.strict for why.
|
|
775
|
-
console.error(`Warning: ${message}`);
|
|
776
|
-
continue;
|
|
777
|
-
}
|
|
778
|
-
// status === 'unknown'
|
|
779
|
-
if (!opts.strict || verdict.reason === 'tool-missing' || verdict.reason === 'timed-out'
|
|
780
|
-
|| opts.allowUnchecked) {
|
|
781
|
-
report({ path, reason: verdict.reason, detail: verdict.detail });
|
|
782
|
-
continue;
|
|
783
|
-
}
|
|
784
|
-
throw new OperatorError(`Config ${kind} ${path} could not be checked for access by other accounts ` +
|
|
785
|
-
`(${verdict.reason}: ${verdict.detail}).\n` +
|
|
786
|
-
'Refused because --strictConfigAcl was given, and the config decides which hosts, ' +
|
|
787
|
-
'roles and policy rules this server honours.\n' +
|
|
788
|
-
'Either fix the cause, move the config under %APPDATA%\\ssh-mcp, or pass ' +
|
|
789
|
-
'--allowUncheckedConfigAcl to load it unverified.');
|
|
908
|
+
// One decision, read once. Refusing and reporting used to be decided in three places —
|
|
909
|
+
// twice on the same flag for the same question — with two `continue`s and two throw
|
|
910
|
+
// sites, and the advisory path wrote to stderr directly rather than through the sink.
|
|
911
|
+
if (!waived(finding, opts))
|
|
912
|
+
throw new OperatorError(finding.message);
|
|
913
|
+
report(finding);
|
|
790
914
|
}
|
|
791
915
|
}
|
package/build/index.js
CHANGED
|
@@ -115,12 +115,13 @@ export function resolveHostGroup(argv) {
|
|
|
115
115
|
throw new OperatorError(`Invalid --group=${group}. Expected one of: ${HOST_GROUPS.join(', ')}.`);
|
|
116
116
|
}
|
|
117
117
|
/**
|
|
118
|
-
* ACL
|
|
118
|
+
* Every ACL finding, in the order it happened — both "readable beyond its owner" and
|
|
119
|
+
* "could not be checked".
|
|
119
120
|
*
|
|
120
121
|
* Collected rather than only printed so `main()` can act on them once there is somewhere
|
|
121
122
|
* durable to put them; see the note at the sink below.
|
|
122
123
|
*/
|
|
123
|
-
const
|
|
124
|
+
const aclFindings = [];
|
|
124
125
|
async function buildAppConfig(argv) {
|
|
125
126
|
// The way past an ACL that could not be read, rather than an operator with no
|
|
126
127
|
// exit. See assertPrivateOnWindows.
|
|
@@ -130,12 +131,11 @@ async function buildAppConfig(argv) {
|
|
|
130
131
|
// the audit store as well needs an AuditRecord variant for a startup event — that type
|
|
131
132
|
// is hash-chained and tamper-evident, so it is its own change, and it is queued.
|
|
132
133
|
const aclOpts = {
|
|
133
|
-
|
|
134
|
+
enforce: flagEnabled(argv, 'strictConfigAcl'),
|
|
134
135
|
allowUnchecked: flagEnabled(argv, 'allowUncheckedConfigAcl'),
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
console.error(`Warning:
|
|
138
|
-
'whether other accounts can read your config is unverified. Loading it anyway.');
|
|
136
|
+
onFinding: (f) => {
|
|
137
|
+
aclFindings.push(f);
|
|
138
|
+
console.error(`Warning: ${f.message}`);
|
|
139
139
|
},
|
|
140
140
|
};
|
|
141
141
|
if (argv.config !== undefined) {
|
package/package.json
CHANGED