@tikoci/centrs 0.1.9-next.0 → 0.1.9-next.23
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/CHANGELOG.md +177 -40
- package/GLOSSARY.txt +32 -0
- package/README.md +12 -8
- package/SECURITY.md +11 -3
- package/commands/api/README.md +39 -15
- package/commands/api/examples.md +44 -6
- package/commands/execute/README.md +108 -14
- package/commands/execute/examples.md +137 -3
- package/commands/explain/README.md +430 -129
- package/commands/explain/examples.md +260 -1
- package/commands/mcp/README.md +11 -0
- package/commands/mcp/examples.md +6 -0
- package/commands/retrieve/README.md +30 -13
- package/commands/retrieve/examples.md +10 -6
- package/commands/transfer/examples.md +9 -0
- package/docs/CLI.md +8 -8
- package/docs/CONSTITUTION.md +29 -2
- package/docs/MATRIX.md +26 -13
- package/docs/errors/cdb/empty-group.md +19 -3
- package/docs/errors/cdb/empty-selection.md +9 -3
- package/docs/errors/cmr/caller-policy-not-enforced.md +46 -0
- package/docs/errors/cmr/empty-selection.md +17 -3
- package/docs/errors/cmr/rule-selects-every-device.md +32 -0
- package/docs/errors/cmr/selection-includes-controller.md +27 -0
- package/docs/errors/usage/flag-after-separator.md +34 -0
- package/package.json +10 -5
- package/src/api-fanout.ts +11 -1
- package/src/api.ts +205 -65
- package/src/btest.ts +7 -2
- package/src/cli/api.ts +30 -16
- package/src/cli/common.ts +180 -1
- package/src/cli/devices.ts +0 -2
- package/src/cli/discover.ts +0 -1
- package/src/cli/execute.ts +44 -20
- package/src/cli/explain.ts +2 -0
- package/src/cli/mcp.ts +0 -1
- package/src/cli/retrieve.ts +40 -18
- package/src/cli/settings.ts +0 -3
- package/src/cli/terminal.ts +0 -2
- package/src/cli/transfer.ts +10 -8
- package/src/cli/version.ts +87 -0
- package/src/cli.ts +5 -3
- package/src/core/cmr-fleet.ts +486 -0
- package/src/core/cmr.ts +78 -0
- package/src/core/envelope.ts +32 -3
- package/src/core/error-catalog.ts +25 -3
- package/src/core/execute-command.ts +144 -19
- package/src/core/fanout.ts +130 -6
- package/src/errors.ts +2 -1
- package/src/execute-cmr.ts +95 -78
- package/src/execute-fanout.ts +11 -1
- package/src/execute.ts +105 -23
- package/src/explain/args.ts +69 -8
- package/src/explain/blocks.ts +3 -4
- package/src/explain/brace-slots.ts +5 -0
- package/src/explain/catalog.ts +13 -6
- package/src/explain/defects.ts +1 -0
- package/src/explain/group-faults.ts +927 -0
- package/src/explain/highlight-projection.ts +10 -8
- package/src/explain/operators.ts +20 -19
- package/src/explain/pathresolve.ts +102 -20
- package/src/explain/quoted-string.ts +41 -6
- package/src/explain/run-tokens.ts +223 -0
- package/src/explain/scope-brace.ts +103 -6
- package/src/explain/segment.ts +41 -3
- package/src/explain/spaced-args.ts +116 -0
- package/src/explain/symbols.ts +17 -1
- package/src/explain/transport.ts +13 -0
- package/src/explain/verbsplit.ts +14 -188
- package/src/explain/write.ts +8 -1
- package/src/explain.ts +378 -18
- package/src/index.ts +18 -1
- package/src/mcp/safety.ts +11 -0
- package/src/mcp/tools.ts +4 -1
- package/src/offline-gate.ts +30 -9
- package/src/protocols/btest-session.ts +30 -10
- package/src/protocols/mac-telnet-console.ts +16 -0
- package/src/resolver/cdb.ts +13 -1
- package/src/resolver/index.ts +4 -0
- package/src/resolver/selection.ts +37 -1
- package/src/resolver/target.ts +96 -8
- package/src/retrieve-fanout.ts +4 -1
- package/src/retrieve-follow.ts +29 -33
- package/src/retrieve-sample.ts +14 -8
- package/src/retrieve-stream.ts +38 -6
- package/src/retrieve.ts +73 -59
- package/src/transfer-fanout.ts +8 -1
- package/src/transfer.ts +15 -19
package/src/mcp/safety.ts
CHANGED
|
@@ -19,6 +19,11 @@ import {
|
|
|
19
19
|
import { CentrsError } from "../errors.ts";
|
|
20
20
|
import type { McpWritePolicy } from "../mcp.ts";
|
|
21
21
|
import { parseCommentKv } from "../resolver/comment-kv.ts";
|
|
22
|
+
import {
|
|
23
|
+
assertNoTargetCredentials,
|
|
24
|
+
assertNoTargetPassword,
|
|
25
|
+
redactTargetInput,
|
|
26
|
+
} from "../resolver/target.ts";
|
|
22
27
|
import type { CentrsMcpConfig } from "./config.ts";
|
|
23
28
|
|
|
24
29
|
export interface RegisteredTarget {
|
|
@@ -68,10 +73,16 @@ export function resolveRegisteredTarget(
|
|
|
68
73
|
cdb: LoadedCdb,
|
|
69
74
|
target: string,
|
|
70
75
|
): RegisteredTarget {
|
|
76
|
+
// Before the lookup, whose refusal echoes the target (#478).
|
|
77
|
+
assertNoTargetPassword(target);
|
|
71
78
|
let entry: DevicesShowItem;
|
|
72
79
|
try {
|
|
73
80
|
entry = showDevice({ cdb, target }).data.entry;
|
|
74
81
|
} catch (error) {
|
|
82
|
+
// Preserve registered identities; mask credential-shaped lookup failures.
|
|
83
|
+
if (redactTargetInput(target) !== target) {
|
|
84
|
+
assertNoTargetCredentials(target);
|
|
85
|
+
}
|
|
75
86
|
if (error instanceof CentrsError && error.code === "cdb/not-found-target") {
|
|
76
87
|
throw new CentrsError({
|
|
77
88
|
code: "cdb/target-not-registered",
|
package/src/mcp/tools.ts
CHANGED
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
import type { CommentKvUpdate } from "../resolver/comment-kv.ts";
|
|
37
37
|
import { parseBbox, parseNear } from "../resolver/geo.ts";
|
|
38
38
|
import { parseDuration } from "../resolver/settings.ts";
|
|
39
|
+
import { redactTargetInput } from "../resolver/target.ts";
|
|
39
40
|
import { retrieveEnvelope } from "../retrieve.ts";
|
|
40
41
|
import {
|
|
41
42
|
buildRetrieveFanoutErrorEnvelope,
|
|
@@ -89,7 +90,9 @@ function mcpErrorEnvelope(
|
|
|
89
90
|
warnings: [],
|
|
90
91
|
tips: [],
|
|
91
92
|
meta: {
|
|
92
|
-
target: context.target
|
|
93
|
+
target: context.target
|
|
94
|
+
? { input: redactTargetInput(context.target) }
|
|
95
|
+
: {},
|
|
93
96
|
via: null,
|
|
94
97
|
settings: {},
|
|
95
98
|
operation: { kind: "mcp", tool },
|
package/src/offline-gate.ts
CHANGED
|
@@ -25,6 +25,15 @@
|
|
|
25
25
|
* module never reports success on its behalf. `verdict: "warn"` is an
|
|
26
26
|
* ABSTENTION, not a rejection, and passes through for the same reason.
|
|
27
27
|
*
|
|
28
|
+
* **One warning is refused by code: {@link REFUSED_WARNING_CODES}.** It is not
|
|
29
|
+
* an abstention but a known device behaviour. A `}` or `]` that closes nothing
|
|
30
|
+
* at the top level makes RouterOS accept the script and silently stop reading
|
|
31
|
+
* there, so `/system identity set name=a}` sets `a` and `:put 1};:put 2` runs
|
|
32
|
+
* one command (#504, CHR 7.24.5 and 7.23.8). `explain` reports it as a warning
|
|
33
|
+
* because nothing is rejected; the gate refuses it because the script the
|
|
34
|
+
* device would run is not the one that was written. The list is explicit and
|
|
35
|
+
* stays small: a code joins it only for the same reason.
|
|
36
|
+
*
|
|
28
37
|
* `--validate=false` disables BOTH stages — one flag, one meaning. That was
|
|
29
38
|
* settled with the `--raw` precedence layer on GH#154 and is why callers gate
|
|
30
39
|
* this on the same resolved `validate` setting as the device stage; the escape
|
|
@@ -39,11 +48,20 @@
|
|
|
39
48
|
*/
|
|
40
49
|
|
|
41
50
|
import { CentrsError } from "./errors.ts";
|
|
42
|
-
import {
|
|
51
|
+
import {
|
|
52
|
+
DISCARDED_CLOSE_CODE,
|
|
53
|
+
type ExplainDiagnostic,
|
|
54
|
+
explainCommand,
|
|
55
|
+
} from "./explain.ts";
|
|
43
56
|
|
|
44
57
|
/** Validator identity for `meta.validation` and error context. */
|
|
45
58
|
export const OFFLINE_GATE_SOURCE = "offline explain (canonicalizer)";
|
|
46
59
|
|
|
60
|
+
/** Warning codes the gate refuses as it refuses an error (module header). */
|
|
61
|
+
export const REFUSED_WARNING_CODES: ReadonlySet<string> = new Set([
|
|
62
|
+
DISCARDED_CLOSE_CODE,
|
|
63
|
+
]);
|
|
64
|
+
|
|
47
65
|
/** Which stage of the two-stage gate produced a verdict. */
|
|
48
66
|
export type ValidationStageName = "offline" | "device";
|
|
49
67
|
|
|
@@ -61,8 +79,8 @@ export interface OfflineGateFinding {
|
|
|
61
79
|
export interface OfflineGateResult {
|
|
62
80
|
/**
|
|
63
81
|
* `pass` — the analyzer read the whole input. `warn` — it ABSTAINED on part
|
|
64
|
-
* of it (an unresolved menu, say). Both proceed to the device stage;
|
|
65
|
-
* `fail
|
|
82
|
+
* of it (an unresolved menu, say). Both proceed to the device stage; a
|
|
83
|
+
* `fail`, or a warning in {@link REFUSED_WARNING_CODES}, throws.
|
|
66
84
|
*/
|
|
67
85
|
verdict: "pass" | "warn";
|
|
68
86
|
/** Warning-severity diagnostics, kept so a caller can surface the abstention. */
|
|
@@ -95,25 +113,28 @@ export function assertOfflineSyntax(
|
|
|
95
113
|
): OfflineGateResult {
|
|
96
114
|
const analysis = explainCommand(command);
|
|
97
115
|
const errors = analysis.diagnostics.filter(
|
|
98
|
-
(diagnostic) =>
|
|
116
|
+
(diagnostic) =>
|
|
117
|
+
diagnostic.severity === "error" ||
|
|
118
|
+
REFUSED_WARNING_CODES.has(diagnostic.code),
|
|
99
119
|
);
|
|
100
120
|
const warnings = analysis.diagnostics
|
|
101
121
|
.filter((diagnostic) => diagnostic.severity === "warning")
|
|
102
122
|
.map(findingOf);
|
|
103
123
|
|
|
104
|
-
if (
|
|
124
|
+
if (errors.length === 0) {
|
|
105
125
|
return { verdict: analysis.verdict === "warn" ? "warn" : "pass", warnings };
|
|
106
126
|
}
|
|
107
127
|
|
|
108
|
-
// A `fail` verdict always carries at least one error diagnostic, but read it
|
|
109
|
-
// defensively: an empty list would otherwise produce an error that says
|
|
110
|
-
// nothing about what was wrong, which is worse than a generic summary.
|
|
111
128
|
const findings = errors.map(findingOf);
|
|
112
129
|
const first = findings[0];
|
|
113
130
|
throw new CentrsError({
|
|
114
131
|
code: "validation/syntax",
|
|
115
132
|
summary: first
|
|
116
|
-
?
|
|
133
|
+
? `${
|
|
134
|
+
REFUSED_WARNING_CODES.has(first.code)
|
|
135
|
+
? "Refused by offline analysis"
|
|
136
|
+
: "RouterOS syntax rejected by offline analysis"
|
|
137
|
+
}: ${first.message}`
|
|
117
138
|
: "RouterOS syntax rejected by offline analysis.",
|
|
118
139
|
remediation:
|
|
119
140
|
"Run `centrs explain '<command>'` to see the offending bytes in context, " +
|
|
@@ -72,6 +72,7 @@ import {
|
|
|
72
72
|
} from "./btest.ts";
|
|
73
73
|
import {
|
|
74
74
|
bytesToBigIntBE,
|
|
75
|
+
type EcSrp5Shared,
|
|
75
76
|
ecSrp5ClientConfirm,
|
|
76
77
|
ecSrp5ClientShared,
|
|
77
78
|
ecSrp5Id,
|
|
@@ -676,11 +677,13 @@ async function runClientEcSrp5(
|
|
|
676
677
|
|
|
677
678
|
const challenge = parseServerChallenge(await readAuthFrame(channel));
|
|
678
679
|
const validator = ecSrp5Id(username, password, challenge.salt);
|
|
679
|
-
const shared =
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
680
|
+
const shared = btestShared(
|
|
681
|
+
ecSrp5ClientShared(
|
|
682
|
+
keypair.privateKey,
|
|
683
|
+
challenge.serverPublicKey,
|
|
684
|
+
keypair.publicKey,
|
|
685
|
+
validator,
|
|
686
|
+
),
|
|
684
687
|
);
|
|
685
688
|
const clientCc = ecSrp5ClientConfirm(shared);
|
|
686
689
|
await channel.write(encodeConfirmation(clientCc));
|
|
@@ -782,11 +785,13 @@ async function runServerEcSrp5(
|
|
|
782
785
|
);
|
|
783
786
|
await channel.write(encodeServerChallenge(serverPublicKey, salt));
|
|
784
787
|
|
|
785
|
-
const shared =
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
788
|
+
const shared = btestShared(
|
|
789
|
+
ecSrp5ServerShared(
|
|
790
|
+
keypair.privateKey,
|
|
791
|
+
validatorScalar,
|
|
792
|
+
hello.publicKey,
|
|
793
|
+
serverPublicKey,
|
|
794
|
+
),
|
|
790
795
|
);
|
|
791
796
|
const clientCc = await readAuthFrame(channel);
|
|
792
797
|
const expectedCc = ecSrp5ClientConfirm(shared);
|
|
@@ -799,6 +804,21 @@ async function runServerEcSrp5(
|
|
|
799
804
|
return hello.username;
|
|
800
805
|
}
|
|
801
806
|
|
|
807
|
+
/**
|
|
808
|
+
* The shared secret as btest hashes it: `z` without its leading zero bytes
|
|
809
|
+
* (OpenSSL `BN_bn2bin`), where MAC-Telnet's `mtwei.c` pads it to 32
|
|
810
|
+
* (`BN_bn2binpad`), so the shared math in `ec-srp5.ts` keeps 32 bytes.
|
|
811
|
+
*
|
|
812
|
+
* Grounded on CHR 7.24.5 `/tool/bandwidth-server`: over 800 client handshakes,
|
|
813
|
+
* every one whose `z` began with 0x00 (9) failed with the padded form and no
|
|
814
|
+
* other did. Stripped, 600 of 600 verified, 4 of them with a leading zero.
|
|
815
|
+
*/
|
|
816
|
+
export function btestShared(shared: EcSrp5Shared): EcSrp5Shared {
|
|
817
|
+
let start = 0;
|
|
818
|
+
while (start < shared.z.length - 1 && shared.z[start] === 0) start++;
|
|
819
|
+
return start === 0 ? shared : { j: shared.j, z: shared.z.subarray(start) };
|
|
820
|
+
}
|
|
821
|
+
|
|
802
822
|
function ecSrp5RandomSalt(): Uint8Array {
|
|
803
823
|
return crypto.getRandomValues(new Uint8Array(16));
|
|
804
824
|
}
|
|
@@ -639,6 +639,22 @@ const BARE_BAD_PARAMETER =
|
|
|
639
639
|
const BARE_SYNTAX =
|
|
640
640
|
/^[ \t]*(?:syntax error|bad command name|(?:expected|missing) [a-z]+(?: [a-z]+)*)[^\n]*\(line \d+ column \d+\)/im;
|
|
641
641
|
|
|
642
|
+
/**
|
|
643
|
+
* Whether the printed value of `:parse` carries a rejection, by the same
|
|
644
|
+
* position-anchored grammar {@link classifyParseResult} throws on. For probes
|
|
645
|
+
* and censuses that need the verdict as a boolean. On the schema-5 corpus it
|
|
646
|
+
* agrees with the upstream `response_class` on every one of 5,640 channel and
|
|
647
|
+
* legacy observations (#498).
|
|
648
|
+
*/
|
|
649
|
+
export function isParseRejection(output: string): boolean {
|
|
650
|
+
return (
|
|
651
|
+
WRAPPED_BAD_PARAMETER.test(output) ||
|
|
652
|
+
BARE_BAD_PARAMETER.test(output) ||
|
|
653
|
+
WRAPPED_BAD_COMMAND.test(output) ||
|
|
654
|
+
BARE_SYNTAX.test(output)
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
|
|
642
658
|
/**
|
|
643
659
|
* Classify the printed value of `:put [:parse "<cli>"]`. Grounded on CHR 7.23.3
|
|
644
660
|
* (see GH#230): `:parse` *never* throws — it returns a value whose text carries
|
package/src/resolver/cdb.ts
CHANGED
|
@@ -44,6 +44,11 @@ import {
|
|
|
44
44
|
parseDuration,
|
|
45
45
|
type ResolvedSetting,
|
|
46
46
|
} from "./settings.ts";
|
|
47
|
+
import {
|
|
48
|
+
assertNoTargetCredentials,
|
|
49
|
+
assertNoTargetPassword,
|
|
50
|
+
redactTargetInput,
|
|
51
|
+
} from "./target.ts";
|
|
47
52
|
|
|
48
53
|
const ENV_CDB_FILE = "CENTRS_CDB_FILE";
|
|
49
54
|
const ENV_CDB_PASSWORD = "CENTRS_CDB_PASSWORD";
|
|
@@ -161,6 +166,8 @@ export async function resolveCdb(
|
|
|
161
166
|
if (!input.targetInput) {
|
|
162
167
|
return undefined;
|
|
163
168
|
}
|
|
169
|
+
// Before any lookup: a CDB miss echoes the target it was given (#478).
|
|
170
|
+
assertNoTargetPassword(input.targetInput);
|
|
164
171
|
|
|
165
172
|
const settings = resolveDevicesSettings({
|
|
166
173
|
cdbFile: input.cdbFile,
|
|
@@ -225,6 +232,10 @@ export async function resolveCdb(
|
|
|
225
232
|
...(defaults ? { defaults } : {}),
|
|
226
233
|
};
|
|
227
234
|
} catch (error) {
|
|
235
|
+
// Preserve registered identities; mask credential-shaped lookup failures.
|
|
236
|
+
if (redactTargetInput(input.targetInput) !== input.targetInput) {
|
|
237
|
+
assertNoTargetCredentials(input.targetInput);
|
|
238
|
+
}
|
|
228
239
|
if (error instanceof CentrsError && error.code === "cdb/not-found-target") {
|
|
229
240
|
// A `__default__` record supplies fallback credentials even when the
|
|
230
241
|
// target has no record of its own. On the CLI/API this fills creds for
|
|
@@ -366,7 +377,8 @@ export function resolutionFromEntry(
|
|
|
366
377
|
* ambiguity path). See `commands/devices/README.md` (Identity model).
|
|
367
378
|
*/
|
|
368
379
|
export function identityFromComment(comment: string, target: string): string {
|
|
369
|
-
|
|
380
|
+
// The record's target is shown as its handle; never with a password (#478).
|
|
381
|
+
return parseCommentKv(comment).lookups.identity ?? redactTargetInput(target);
|
|
370
382
|
}
|
|
371
383
|
|
|
372
384
|
/**
|
package/src/resolver/index.ts
CHANGED
|
@@ -97,6 +97,7 @@ export {
|
|
|
97
97
|
SUPPORTED_DESCRIPTOR_VERSION,
|
|
98
98
|
} from "./quickchr-provider.ts";
|
|
99
99
|
export {
|
|
100
|
+
emptyWriteSelectionError,
|
|
100
101
|
expandSelection,
|
|
101
102
|
isDefaultRecordTarget,
|
|
102
103
|
type SelectionExpansion,
|
|
@@ -130,10 +131,13 @@ export {
|
|
|
130
131
|
toCoreSource,
|
|
131
132
|
} from "./settings.ts";
|
|
132
133
|
export {
|
|
134
|
+
assertNoTargetCredentials,
|
|
135
|
+
assertNoTargetPassword,
|
|
133
136
|
defaultPortForScheme,
|
|
134
137
|
parseHostCandidate,
|
|
135
138
|
type ResolvedAuth,
|
|
136
139
|
type ResolvedTarget,
|
|
140
|
+
redactTargetInput,
|
|
137
141
|
resolveAuth,
|
|
138
142
|
resolveTarget,
|
|
139
143
|
type TargetResolveInput,
|
|
@@ -47,6 +47,11 @@ import {
|
|
|
47
47
|
matchesNear,
|
|
48
48
|
type NearPredicate,
|
|
49
49
|
} from "./geo.ts";
|
|
50
|
+
import {
|
|
51
|
+
assertNoTargetCredentials,
|
|
52
|
+
assertNoTargetPassword,
|
|
53
|
+
redactTargetInput,
|
|
54
|
+
} from "./target.ts";
|
|
50
55
|
|
|
51
56
|
/** One `--where attr=value` device-class clause (exact-match, AND-combined). */
|
|
52
57
|
export interface SelectionWhereClause {
|
|
@@ -106,10 +111,36 @@ export interface SelectionExpansion {
|
|
|
106
111
|
/** Members ordered by record index; literals appended in positional order. */
|
|
107
112
|
targets: readonly SelectionMember[];
|
|
108
113
|
warnings: readonly ResolverWarning[];
|
|
109
|
-
/** True when nothing matched (an
|
|
114
|
+
/** True when nothing matched (an error only for a write; see {@link emptyWriteSelectionError}). */
|
|
110
115
|
empty: boolean;
|
|
111
116
|
}
|
|
112
117
|
|
|
118
|
+
/**
|
|
119
|
+
* An empty selection for a write-shaped fan-out (#460). A read over it is
|
|
120
|
+
* `ok: true` with the expansion's warning, so a poller over an occasionally
|
|
121
|
+
* empty group stays quiet. A write is an error, as RouterOS refuses an empty
|
|
122
|
+
* CMR selection (`no devices selected`): a misspelled group must not look like
|
|
123
|
+
* a successful bulk write. The code is the expansion's own empty warning.
|
|
124
|
+
*/
|
|
125
|
+
export function emptyWriteSelectionError(
|
|
126
|
+
expansion: Pick<SelectionExpansion, "warnings">,
|
|
127
|
+
action: string,
|
|
128
|
+
): CentrsError {
|
|
129
|
+
const warning = expansion.warnings.findLast(
|
|
130
|
+
(w) => w.code === "cdb/empty-group" || w.code === "cdb/empty-selection",
|
|
131
|
+
);
|
|
132
|
+
return new CentrsError({
|
|
133
|
+
code:
|
|
134
|
+
warning?.code === "cdb/empty-group"
|
|
135
|
+
? "cdb/empty-group"
|
|
136
|
+
: "cdb/empty-selection",
|
|
137
|
+
summary: `${warning?.message ?? "The target selection matched no CDB records."} Nothing ran: a write-shaped ${action} must select at least one router.`,
|
|
138
|
+
remediation:
|
|
139
|
+
"Check the selector against `centrs devices list` and `centrs devices groups` (groups and `--where` values match as written), then re-run. A read over the same selection succeeds with a warning instead.",
|
|
140
|
+
context: { ...warning?.context, write: true },
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
113
144
|
/** True when a resolved target is the reserved credential-fallback record. */
|
|
114
145
|
export function isDefaultRecordTarget(target: string): boolean {
|
|
115
146
|
return target === DEFAULT_RECORD_TARGET;
|
|
@@ -249,10 +280,15 @@ export async function expandSelection(
|
|
|
249
280
|
const literals: SelectionMember[] = [];
|
|
250
281
|
const seenLiteral = new Set<string>();
|
|
251
282
|
for (const positional of selection.positionals) {
|
|
283
|
+
assertNoTargetPassword(positional);
|
|
252
284
|
try {
|
|
253
285
|
const shown = showDevice({ cdb, target: positional });
|
|
254
286
|
indices.add(shown.data.entry.cdbRecordIndex);
|
|
255
287
|
} catch (error) {
|
|
288
|
+
// Preserve registered identities; mask credential-shaped lookup failures.
|
|
289
|
+
if (redactTargetInput(positional) !== positional) {
|
|
290
|
+
assertNoTargetCredentials(positional);
|
|
291
|
+
}
|
|
256
292
|
if (
|
|
257
293
|
error instanceof CentrsError &&
|
|
258
294
|
error.code === "cdb/not-found-target"
|
package/src/resolver/target.ts
CHANGED
|
@@ -104,11 +104,12 @@ export function resolveTarget(
|
|
|
104
104
|
summary: "No host could be resolved for `retrieve`.",
|
|
105
105
|
remediation:
|
|
106
106
|
"Pass a target positional like `centrs retrieve 192.0.2.10 /system/resource --via rest-api` or set `--host` / `CENTRS_HOST`.",
|
|
107
|
-
context: { targetInput: input.targetInput },
|
|
107
|
+
context: { targetInput: redactTargetInput(input.targetInput) },
|
|
108
108
|
});
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
const rawCandidate = hostSetting.value.trim();
|
|
112
|
+
assertNoTargetCredentials(rawCandidate);
|
|
112
113
|
const macCandidate = normalizeMac(rawCandidate);
|
|
113
114
|
|
|
114
115
|
// L2 transports (mac-telnet) address the device by **MAC** directly — no IP
|
|
@@ -127,11 +128,13 @@ export function resolveTarget(
|
|
|
127
128
|
if (!mac) {
|
|
128
129
|
throw new CentrsError({
|
|
129
130
|
code: "target/mac-required",
|
|
130
|
-
summary: `mac-telnet needs a MAC address target; got "${input.targetInput ?? cdb?.target ?? rawCandidate}".`,
|
|
131
|
+
summary: `mac-telnet needs a MAC address target; got "${redactTargetInput(input.targetInput ?? cdb?.target ?? rawCandidate)}".`,
|
|
131
132
|
remediation:
|
|
132
133
|
"Pass the device MAC (aa:bb:cc:dd:ee:ff), or pin an IP transport with `--via native-api`/`rest-api` (add `--resolve arp` for a MAC).",
|
|
133
134
|
context: {
|
|
134
|
-
target:
|
|
135
|
+
target: redactTargetInput(
|
|
136
|
+
input.targetInput ?? cdb?.target ?? rawCandidate,
|
|
137
|
+
),
|
|
135
138
|
via,
|
|
136
139
|
},
|
|
137
140
|
});
|
|
@@ -178,7 +181,7 @@ export function resolveTarget(
|
|
|
178
181
|
const deliveryHost = deliveryHostSetting?.value.trim() || "255.255.255.255";
|
|
179
182
|
const port = macPortSetting?.value ?? MAC_TELNET_PORT;
|
|
180
183
|
return {
|
|
181
|
-
input: input.targetInput,
|
|
184
|
+
input: redactTargetInput(input.targetInput),
|
|
182
185
|
host: deliveryHost,
|
|
183
186
|
mac,
|
|
184
187
|
port,
|
|
@@ -290,7 +293,7 @@ export function resolveTarget(
|
|
|
290
293
|
};
|
|
291
294
|
|
|
292
295
|
return {
|
|
293
|
-
input: input.targetInput,
|
|
296
|
+
input: redactTargetInput(input.targetInput),
|
|
294
297
|
// WHATWG keeps an IPv6 hostname bracketed; sockets and ssh need the bare
|
|
295
298
|
// address (`baseUrl` re-brackets it).
|
|
296
299
|
host: parsedUrl.hostname.replace(/^\[(.*)\]$/, "$1"),
|
|
@@ -471,6 +474,91 @@ export function resolveAuth(
|
|
|
471
474
|
};
|
|
472
475
|
}
|
|
473
476
|
|
|
477
|
+
/** An authority with userinfo: `user@host`, `user:pass@host`, with or without a scheme. */
|
|
478
|
+
const URL_USERINFO = /^\s*([a-z][a-z0-9+.-]*:\/\/)?[^/@\s]*@/i;
|
|
479
|
+
/** Leading whitespace and an optional `scheme://`, kept by redaction. */
|
|
480
|
+
const TARGET_PREFIX = /^\s*(?:[a-z][a-z0-9+.-]*:\/\/)?/i;
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Where credentials could sit: a `:` before the LAST `@`, or explicit URL
|
|
484
|
+
* userinfo. Display also masks a percent-encoded colon; lookup validation
|
|
485
|
+
* allows that ambiguous spelling until CDB lookup has run. Bare `user@host`
|
|
486
|
+
* remains a CDB identity candidate. Index arithmetic, so a password holding `/`, `?` or
|
|
487
|
+
* `#` (which ends a URL authority early, CodeRabbit on PR #479) still counts,
|
|
488
|
+
* and nothing can backtrack (CodeQL js/polynomial-redos).
|
|
489
|
+
*/
|
|
490
|
+
function targetCredentialsSpan(
|
|
491
|
+
value: string,
|
|
492
|
+
includeEncodedColon = true,
|
|
493
|
+
): { start: number; at: number } | undefined {
|
|
494
|
+
const at = value.lastIndexOf("@");
|
|
495
|
+
if (at < 0) return undefined;
|
|
496
|
+
const prefix = TARGET_PREFIX.exec(value)?.[0] ?? "";
|
|
497
|
+
const start = prefix.length;
|
|
498
|
+
const urlUserinfo = prefix.includes("://") && URL_USERINFO.test(value);
|
|
499
|
+
const userinfo = value.slice(start, at);
|
|
500
|
+
return userinfo.includes(":") ||
|
|
501
|
+
(includeEncodedColon && /%3a/i.test(userinfo)) ||
|
|
502
|
+
urlUserinfo
|
|
503
|
+
? { start, at }
|
|
504
|
+
: undefined;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* A target string with any `user:pass@` or explicit URL userinfo replaced by
|
|
509
|
+
* `***@`, for display (#478).
|
|
510
|
+
* A target is echoed in `meta.target.input` and in error messages, so the
|
|
511
|
+
* password part must never reach them, not even when the target is refused.
|
|
512
|
+
* Everything up to the last `@` goes, so an odd password cannot slip past.
|
|
513
|
+
* Other targets, including a CDB identity like `rtr@site`, pass unchanged.
|
|
514
|
+
* An encoded colon is masked conservatively without changing lookup input.
|
|
515
|
+
*/
|
|
516
|
+
export function redactTargetInput(value: string): string;
|
|
517
|
+
export function redactTargetInput(
|
|
518
|
+
value: string | undefined,
|
|
519
|
+
): string | undefined;
|
|
520
|
+
export function redactTargetInput(
|
|
521
|
+
value: string | undefined,
|
|
522
|
+
): string | undefined {
|
|
523
|
+
if (value === undefined) return value;
|
|
524
|
+
const span = targetCredentialsSpan(value);
|
|
525
|
+
return span
|
|
526
|
+
? `${value.slice(0, span.start)}***${value.slice(span.at)}`
|
|
527
|
+
: value;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function targetCredentialsError(value: string): CentrsError {
|
|
531
|
+
const target = redactTargetInput(value);
|
|
532
|
+
return new CentrsError({
|
|
533
|
+
code: "input/invalid-target",
|
|
534
|
+
summary: `A target must not carry credentials (\`user[:password]@\`): ${target}`,
|
|
535
|
+
remediation:
|
|
536
|
+
"Drop the `user:password@` part and pass the credentials with `--username` and `--password` (or `CENTRS_USERNAME`/`CENTRS_PASSWORD`, or a CDB record); give the target as `host[:port]` or `http(s)://host[:port]`.",
|
|
537
|
+
context: { target },
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Refuse a target that may hold a password, BEFORE any lookup whose miss would
|
|
543
|
+
* echo it (#478): `resolveCdb`, the MCP allowlist, the fan-out selection. A
|
|
544
|
+
* bare `user@host` is left to the lookup; a CDB identity may contain `@`.
|
|
545
|
+
* Explicit URL userinfo is always refused, including percent-encoded forms.
|
|
546
|
+
*/
|
|
547
|
+
export function assertNoTargetPassword(value: string): void {
|
|
548
|
+
if (targetCredentialsSpan(value, false)) throw targetCredentialsError(value);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* centrs never reads credentials from a target (#478): `user:pass@host` used
|
|
553
|
+
* to be accepted with its credentials ignored, and echoed the password back.
|
|
554
|
+
* After lookup, refuse any userinfo in the host to dial, from any source.
|
|
555
|
+
*/
|
|
556
|
+
export function assertNoTargetCredentials(value: string): void {
|
|
557
|
+
if (targetCredentialsSpan(value) || URL_USERINFO.test(value)) {
|
|
558
|
+
throw targetCredentialsError(value);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
474
562
|
export function parseHostCandidate(value: string): URL {
|
|
475
563
|
const hasScheme = /^https?:\/\//i.test(value);
|
|
476
564
|
// A bare IPv6 literal (`2001:db8::1`) has no port; URL needs it bracketed.
|
|
@@ -484,7 +572,7 @@ export function parseHostCandidate(value: string): URL {
|
|
|
484
572
|
if (hasScheme && url.pathname !== "/" && url.pathname !== "") {
|
|
485
573
|
throw new CentrsError({
|
|
486
574
|
code: "input/invalid-target",
|
|
487
|
-
summary: `Target URL must not include a path. Received: ${value}`,
|
|
575
|
+
summary: `Target URL must not include a path. Received: ${redactTargetInput(value)}`,
|
|
488
576
|
remediation:
|
|
489
577
|
"Pass only the RouterOS host or base URL, then provide the RouterOS menu path as the second positional argument.",
|
|
490
578
|
});
|
|
@@ -500,12 +588,12 @@ function invalidTargetError(value: string, cause: unknown): CentrsError {
|
|
|
500
588
|
const quickchrGuess = /^quickchr:(.+)$/i.exec(value)?.[1];
|
|
501
589
|
return new CentrsError({
|
|
502
590
|
code: "input/invalid-target",
|
|
503
|
-
summary: `Target is not a known device or a valid host: ${value}`,
|
|
591
|
+
summary: `Target is not a known device or a valid host: ${redactTargetInput(value)}`,
|
|
504
592
|
remediation:
|
|
505
593
|
quickchrGuess !== undefined
|
|
506
594
|
? `Select a quickchr VM with the flag: \`--quickchr ${quickchrGuess}\` (there is no \`quickchr:\` target prefix).`
|
|
507
595
|
: "Pass a CDB target or identity (see `centrs devices list`), an IP or hostname with optional `:port`, or an `http(s)://host[:port]` base URL.",
|
|
508
|
-
context: { target: value },
|
|
596
|
+
context: { target: redactTargetInput(value) },
|
|
509
597
|
cause,
|
|
510
598
|
});
|
|
511
599
|
}
|
package/src/retrieve-fanout.ts
CHANGED
|
@@ -59,6 +59,7 @@ import {
|
|
|
59
59
|
expandSelection,
|
|
60
60
|
isDefaultRecordTarget,
|
|
61
61
|
loadEnvFileDefaults,
|
|
62
|
+
redactTargetInput,
|
|
62
63
|
type SelectionExpansion,
|
|
63
64
|
type SelectionMember,
|
|
64
65
|
type SelectionResolveInput,
|
|
@@ -97,6 +98,8 @@ export interface RetrieveFanoutOperationMeta {
|
|
|
97
98
|
concurrency: number;
|
|
98
99
|
summary: FanoutSummary;
|
|
99
100
|
request: RetrieveRequestSummary;
|
|
101
|
+
/** Hoisted from the targets when every target used the same login (#461). */
|
|
102
|
+
auth?: { username?: string; passwordProvided: boolean };
|
|
100
103
|
selection: RetrieveSelectionSummary;
|
|
101
104
|
}
|
|
102
105
|
|
|
@@ -143,7 +146,7 @@ function summarizeSelection(
|
|
|
143
146
|
where: selection.where.map((clause) => `${clause.key}=${clause.value}`),
|
|
144
147
|
all: selection.all,
|
|
145
148
|
default: selection.default,
|
|
146
|
-
positionals: selection.positionals,
|
|
149
|
+
positionals: selection.positionals.map((value) => redactTargetInput(value)),
|
|
147
150
|
quickchr: selection.quickchr,
|
|
148
151
|
};
|
|
149
152
|
}
|