i-repo 2.3.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/auth.js +27 -10
- package/dist/commands/config.js +6 -1
- package/dist/config/client.d.ts +53 -0
- package/dist/config/client.js +116 -1
- package/dist/config/scoped-store.d.ts +14 -0
- package/dist/config/scoped-store.js +65 -1
- package/dist/config/store.d.ts +7 -0
- package/dist/config/store.js +16 -1
- package/dist/core/logger.js +6 -1
- package/dist/i18n/locales/en.js +6 -1
- package/dist/i18n/locales/ja.js +6 -1
- package/dist/i18n/types.d.ts +2 -0
- package/dist/ink/components/DetailView.js +6 -4
- package/dist/ink/components/ErrorView.js +2 -1
- package/dist/ink/components/TableView.js +5 -5
- package/dist/ink/registry/executors.js +12 -7
- package/dist/ink/screens/AuthLoginScreen.js +19 -9
- package/dist/ink/screens/CommandScreen.js +3 -0
- package/dist/plugins/dispatch.js +17 -3
- package/dist/sdk/core/http-client.d.ts +11 -0
- package/dist/sdk/core/http-client.js +97 -19
- package/dist/ui/width.d.ts +11 -0
- package/dist/ui/width.js +15 -0
- package/package.json +1 -1
package/dist/commands/auth.js
CHANGED
|
@@ -6,19 +6,24 @@
|
|
|
6
6
|
import { Command } from "commander";
|
|
7
7
|
import { input, password as passwordPrompt, select, confirm } from "@inquirer/prompts";
|
|
8
8
|
import { IReporterClient } from "../sdk/index.js";
|
|
9
|
-
import { getConfig, getConfigWithSource,
|
|
10
|
-
import { getUserConfigPath, getProjectConfigPath, getLocalConfigPath,
|
|
9
|
+
import { getConfig, getConfigWithSource, clearStoredCredentials, } from "../config/store.js";
|
|
10
|
+
import { getUserConfigPath, getProjectConfigPath, getLocalConfigPath, findProjectRoot, } from "../config/scoped-store.js";
|
|
11
|
+
import { assertEndpointAllowed, persistCredentials } from "../config/client.js";
|
|
11
12
|
import { logHttp } from "../core/logger.js";
|
|
12
13
|
import { printSuccess, printError, printWarning, printInfo, printDetails, printInteractionHint, withSpinner, t, } from "../ui/index.js";
|
|
13
14
|
import { t as tr } from "../i18n/index.js";
|
|
14
15
|
function saveCredentials(endpoint, user, password, scope, savePassword) {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
const { savedPath, redirectedToUser, userPath } = persistCredentials({
|
|
17
|
+
endpoint,
|
|
18
|
+
user,
|
|
19
|
+
password,
|
|
20
|
+
scope,
|
|
21
|
+
savePassword,
|
|
22
|
+
});
|
|
21
23
|
printInfo(tr("auth.savedTo", { path: savedPath }));
|
|
24
|
+
if (redirectedToUser) {
|
|
25
|
+
printInfo(tr("auth.userOnlyRedirected", { path: userPath }));
|
|
26
|
+
}
|
|
22
27
|
if (!savePassword) {
|
|
23
28
|
printInfo(tr("auth.passwordNotStored"));
|
|
24
29
|
}
|
|
@@ -47,7 +52,15 @@ authCommand
|
|
|
47
52
|
if (!endpoint) {
|
|
48
53
|
endpoint = await input({
|
|
49
54
|
message: tr("auth.endpoint"),
|
|
50
|
-
validate: (v) =>
|
|
55
|
+
validate: (v) => {
|
|
56
|
+
try {
|
|
57
|
+
const u = new URL(v);
|
|
58
|
+
return u.protocol === "https:" || u.protocol === "http:" || tr("auth.validUrl");
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return tr("auth.validUrl");
|
|
62
|
+
}
|
|
63
|
+
},
|
|
51
64
|
});
|
|
52
65
|
}
|
|
53
66
|
if (!user) {
|
|
@@ -63,6 +76,8 @@ authCommand
|
|
|
63
76
|
validate: (v) => v.length > 0 || tr("auth.passwordRequired"),
|
|
64
77
|
});
|
|
65
78
|
}
|
|
79
|
+
// Block plain-http / non-https endpoints before sending credentials.
|
|
80
|
+
assertEndpointAllowed(endpoint);
|
|
66
81
|
// Authenticate via login() and verify with a lightweight API call
|
|
67
82
|
await withSpinner("authenticating", async () => {
|
|
68
83
|
const client = new IReporterClient({
|
|
@@ -124,7 +139,9 @@ authCommand
|
|
|
124
139
|
.description("Clear stored credentials")
|
|
125
140
|
.action(() => {
|
|
126
141
|
try {
|
|
127
|
-
|
|
142
|
+
// Remove BOTH user and password from every scope (shared with the Ink
|
|
143
|
+
// logout executor so the two surfaces never diverge).
|
|
144
|
+
clearStoredCredentials();
|
|
128
145
|
printSuccess(tr("auth.logoutSuccess"));
|
|
129
146
|
}
|
|
130
147
|
catch (error) {
|
package/dist/commands/config.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { Command } from "commander";
|
|
7
7
|
import { getConfigWithSource, setConfig, resetConfig, validKeys, isValidKey, } from "../config/store.js";
|
|
8
8
|
import { parseDisabledCommands, isConfigPath } from "../config/command-gate.js";
|
|
9
|
-
import { getAllConfigPaths, } from "../config/scoped-store.js";
|
|
9
|
+
import { getAllConfigPaths, USER_ONLY_KEYS, } from "../config/scoped-store.js";
|
|
10
10
|
import { setLocale, t as tr } from "../i18n/index.js";
|
|
11
11
|
import { printSuccess, printError, printWarning, printDetails, printInfo, out, t, } from "../ui/index.js";
|
|
12
12
|
const validScopes = ["user", "project", "local"];
|
|
@@ -82,6 +82,11 @@ configCommand
|
|
|
82
82
|
if (key === "password") {
|
|
83
83
|
printWarning(tr("config.passwordWarning"));
|
|
84
84
|
}
|
|
85
|
+
// A user-only key written to project/local is silently ignored at
|
|
86
|
+
// runtime (see USER_ONLY_KEYS) — warn so the value isn't trusted to apply.
|
|
87
|
+
if (USER_ONLY_KEYS.has(key) && opts.scope !== "user") {
|
|
88
|
+
printWarning(tr("config.userOnlyScopeWarning", { key, scope: opts.scope }));
|
|
89
|
+
}
|
|
85
90
|
const coerced = coerceValue(key, value);
|
|
86
91
|
if (coerced === null) {
|
|
87
92
|
process.exitCode = 1;
|
package/dist/config/client.d.ts
CHANGED
|
@@ -13,11 +13,64 @@
|
|
|
13
13
|
* 6. TTY prompt: interactive password input (if terminal is attached)
|
|
14
14
|
*/
|
|
15
15
|
import { IReporterClient } from "../sdk/index.js";
|
|
16
|
+
import type { ConfigScope } from "./scoped-store.js";
|
|
16
17
|
export interface CreateClientOptions {
|
|
17
18
|
endpoint?: string;
|
|
18
19
|
user?: string;
|
|
19
20
|
password?: string;
|
|
20
21
|
}
|
|
22
|
+
/** Env var that opts in to plain-http on ANY host (including public ones). */
|
|
23
|
+
export declare const INSECURE_ENV = "IREPO_ALLOW_INSECURE";
|
|
24
|
+
/** Whether the user has explicitly opted in to insecure (plain http) endpoints. */
|
|
25
|
+
export declare function isInsecureAllowed(): boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Whether a host is on the local machine or a private/internal network, where
|
|
28
|
+
* plain http is the common (and far lower risk) reality for on-prem i-Reporter
|
|
29
|
+
* servers. Covers loopback, RFC1918 private IPv4, link-local, IPv6
|
|
30
|
+
* loopback/unique-local/link-local, *.local (mDNS), *.localhost, and bare
|
|
31
|
+
* single-label intranet hostnames (which can't be reached from the public
|
|
32
|
+
* internet without a search domain).
|
|
33
|
+
*/
|
|
34
|
+
export declare function isPrivateOrLocalHost(hostname: string): boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Validate that an endpoint is safe to send credentials to.
|
|
37
|
+
*
|
|
38
|
+
* Plain http sends the Login user/password and the session cookie in clear
|
|
39
|
+
* text, readable by any passive eavesdropper on the path. Policy:
|
|
40
|
+
* - https → always allowed.
|
|
41
|
+
* - http to a local/private host (loopback, RFC1918, *.local, intranet name)
|
|
42
|
+
* → allowed with a warning. On-prem i-Reporter servers
|
|
43
|
+
* commonly run plain http on the internal LAN.
|
|
44
|
+
* - http to a public host → rejected, unless IREPO_ALLOW_INSECURE=1 (warned).
|
|
45
|
+
* - any other scheme (file:, data:, ...) → always rejected.
|
|
46
|
+
* Throws ConfigError so the failure is classified (config-error exit code).
|
|
47
|
+
*/
|
|
48
|
+
export declare function assertEndpointAllowed(endpoint: string): void;
|
|
49
|
+
export interface PersistCredentialsResult {
|
|
50
|
+
/** Path of the requested scope, where the shareable `user` was written. */
|
|
51
|
+
savedPath: string | undefined;
|
|
52
|
+
/** endpoint/password were forced to the user scope (requested scope ≠ user). */
|
|
53
|
+
redirectedToUser: boolean;
|
|
54
|
+
/** The user-scope config path (where endpoint/password actually live). */
|
|
55
|
+
userPath: string;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Persist login credentials, honoring USER_ONLY_KEYS. `endpoint` and `password`
|
|
59
|
+
* only take effect from the user scope at runtime, so they are ALWAYS written
|
|
60
|
+
* there — writing them to project/local would create config that silently does
|
|
61
|
+
* nothing, and a project-scoped password would risk a plaintext VCS leak. Only
|
|
62
|
+
* the shareable, non-sensitive `user` goes to the requested scope.
|
|
63
|
+
*
|
|
64
|
+
* Shared by the CLI `auth login` and the Ink AuthLoginScreen so both surfaces
|
|
65
|
+
* apply the same trust policy.
|
|
66
|
+
*/
|
|
67
|
+
export declare function persistCredentials(args: {
|
|
68
|
+
endpoint: string;
|
|
69
|
+
user: string;
|
|
70
|
+
password: string;
|
|
71
|
+
scope: ConfigScope;
|
|
72
|
+
savePassword: boolean;
|
|
73
|
+
}): PersistCredentialsResult;
|
|
21
74
|
/**
|
|
22
75
|
* Create an IReporterClient, resolving credentials from
|
|
23
76
|
* CLI flags -> environment variables -> config store (scoped) -> interactive prompt.
|
package/dist/config/client.js
CHANGED
|
@@ -16,7 +16,120 @@ import { IReporterClient } from "../sdk/index.js";
|
|
|
16
16
|
import { password as passwordPrompt } from "@inquirer/prompts";
|
|
17
17
|
import { logHttp } from "../core/logger.js";
|
|
18
18
|
import { ConfigError } from "../core/errors.js";
|
|
19
|
-
import { getConfig } from "./store.js";
|
|
19
|
+
import { getConfig, setConfig } from "./store.js";
|
|
20
|
+
import { getConfigPath, getUserConfigPath } from "./scoped-store.js";
|
|
21
|
+
/** Env var that opts in to plain-http on ANY host (including public ones). */
|
|
22
|
+
export const INSECURE_ENV = "IREPO_ALLOW_INSECURE";
|
|
23
|
+
/** Whether the user has explicitly opted in to insecure (plain http) endpoints. */
|
|
24
|
+
export function isInsecureAllowed() {
|
|
25
|
+
const v = process.env[INSECURE_ENV];
|
|
26
|
+
return v === "1" || v?.toLowerCase() === "true";
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Whether a host is on the local machine or a private/internal network, where
|
|
30
|
+
* plain http is the common (and far lower risk) reality for on-prem i-Reporter
|
|
31
|
+
* servers. Covers loopback, RFC1918 private IPv4, link-local, IPv6
|
|
32
|
+
* loopback/unique-local/link-local, *.local (mDNS), *.localhost, and bare
|
|
33
|
+
* single-label intranet hostnames (which can't be reached from the public
|
|
34
|
+
* internet without a search domain).
|
|
35
|
+
*/
|
|
36
|
+
export function isPrivateOrLocalHost(hostname) {
|
|
37
|
+
const h = hostname.toLowerCase().replace(/^\[|\]$/g, ""); // strip IPv6 [] brackets
|
|
38
|
+
if (h === "localhost" || h.endsWith(".localhost"))
|
|
39
|
+
return true;
|
|
40
|
+
if (h.endsWith(".local"))
|
|
41
|
+
return true;
|
|
42
|
+
// bare single-label hostname (no dots) — an intranet name, not a public FQDN
|
|
43
|
+
if (!h.includes(".") && !h.includes(":"))
|
|
44
|
+
return true;
|
|
45
|
+
if (h === "::1")
|
|
46
|
+
return true; // IPv6 loopback
|
|
47
|
+
if (/^f[cd][0-9a-f]{2}:/.test(h))
|
|
48
|
+
return true; // IPv6 unique-local fc00::/7
|
|
49
|
+
if (h.startsWith("fe80:"))
|
|
50
|
+
return true; // IPv6 link-local
|
|
51
|
+
const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
52
|
+
if (m) {
|
|
53
|
+
const a = Number(m[1]);
|
|
54
|
+
const b = Number(m[2]);
|
|
55
|
+
if (a === 127)
|
|
56
|
+
return true; // loopback 127/8
|
|
57
|
+
if (a === 10)
|
|
58
|
+
return true; // 10/8
|
|
59
|
+
if (a === 192 && b === 168)
|
|
60
|
+
return true; // 192.168/16
|
|
61
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
62
|
+
return true; // 172.16/12
|
|
63
|
+
if (a === 169 && b === 254)
|
|
64
|
+
return true; // link-local 169.254/16
|
|
65
|
+
}
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Validate that an endpoint is safe to send credentials to.
|
|
70
|
+
*
|
|
71
|
+
* Plain http sends the Login user/password and the session cookie in clear
|
|
72
|
+
* text, readable by any passive eavesdropper on the path. Policy:
|
|
73
|
+
* - https → always allowed.
|
|
74
|
+
* - http to a local/private host (loopback, RFC1918, *.local, intranet name)
|
|
75
|
+
* → allowed with a warning. On-prem i-Reporter servers
|
|
76
|
+
* commonly run plain http on the internal LAN.
|
|
77
|
+
* - http to a public host → rejected, unless IREPO_ALLOW_INSECURE=1 (warned).
|
|
78
|
+
* - any other scheme (file:, data:, ...) → always rejected.
|
|
79
|
+
* Throws ConfigError so the failure is classified (config-error exit code).
|
|
80
|
+
*/
|
|
81
|
+
export function assertEndpointAllowed(endpoint) {
|
|
82
|
+
let url;
|
|
83
|
+
try {
|
|
84
|
+
url = new URL(endpoint);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
throw new ConfigError(`Invalid API endpoint URL: ${endpoint}\n` +
|
|
88
|
+
" Expected an absolute URL, e.g. https://host/ConMasAPI/Rests/APIExecute.aspx");
|
|
89
|
+
}
|
|
90
|
+
if (url.protocol === "https:")
|
|
91
|
+
return;
|
|
92
|
+
if (url.protocol === "http:") {
|
|
93
|
+
if (isPrivateOrLocalHost(url.hostname)) {
|
|
94
|
+
process.stderr.write(`Warning: using plain HTTP to ${url.origin} — credentials are not encrypted. ` +
|
|
95
|
+
"Acceptable on a trusted local/private network; prefer https where available.\n");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (isInsecureAllowed()) {
|
|
99
|
+
process.stderr.write(`Warning: sending credentials over plain HTTP to the public host ${url.origin} ` +
|
|
100
|
+
`(${INSECURE_ENV} is set). Traffic can be intercepted on the network.\n`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
throw new ConfigError(`Refusing to send credentials over plain http to a public host: ${endpoint}\n` +
|
|
104
|
+
" They would be transmitted in clear text and can be intercepted.\n" +
|
|
105
|
+
" Use an https:// endpoint. Local/private hosts (localhost, 10.x, 192.168.x, *.local)\n" +
|
|
106
|
+
` are allowed over http; for a public http host set ${INSECURE_ENV}=1 to override (not recommended).`);
|
|
107
|
+
}
|
|
108
|
+
throw new ConfigError(`Unsupported endpoint scheme "${url.protocol}": ${endpoint}\n` +
|
|
109
|
+
` Only https:// (or http:// to a local/private host) is allowed.`);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Persist login credentials, honoring USER_ONLY_KEYS. `endpoint` and `password`
|
|
113
|
+
* only take effect from the user scope at runtime, so they are ALWAYS written
|
|
114
|
+
* there — writing them to project/local would create config that silently does
|
|
115
|
+
* nothing, and a project-scoped password would risk a plaintext VCS leak. Only
|
|
116
|
+
* the shareable, non-sensitive `user` goes to the requested scope.
|
|
117
|
+
*
|
|
118
|
+
* Shared by the CLI `auth login` and the Ink AuthLoginScreen so both surfaces
|
|
119
|
+
* apply the same trust policy.
|
|
120
|
+
*/
|
|
121
|
+
export function persistCredentials(args) {
|
|
122
|
+
setConfig("user", args.user, args.scope);
|
|
123
|
+
setConfig("endpoint", args.endpoint, "user");
|
|
124
|
+
if (args.savePassword) {
|
|
125
|
+
setConfig("password", args.password, "user");
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
savedPath: getConfigPath(args.scope),
|
|
129
|
+
redirectedToUser: args.scope !== "user",
|
|
130
|
+
userPath: getUserConfigPath(),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
20
133
|
// In-memory password cache for the CLI session lifetime.
|
|
21
134
|
let cachedPassword;
|
|
22
135
|
/**
|
|
@@ -34,6 +147,8 @@ export async function createClient(opts = {}) {
|
|
|
34
147
|
' Run "i-repo config set endpoint <url>"\n' +
|
|
35
148
|
" or set IREPO_ENDPOINT environment variable.");
|
|
36
149
|
}
|
|
150
|
+
// Block plain-http / non-https endpoints before any credential is sent.
|
|
151
|
+
assertEndpointAllowed(endpoint);
|
|
37
152
|
if (!user) {
|
|
38
153
|
throw new ConfigError("User is not configured.\n" +
|
|
39
154
|
' Run "i-repo config set user <name>"\n' +
|
|
@@ -5,6 +5,18 @@ export interface ConfigWithSource<K extends keyof IRepoConfig = keyof IRepoConfi
|
|
|
5
5
|
source: ConfigScope | undefined;
|
|
6
6
|
path: string | undefined;
|
|
7
7
|
}
|
|
8
|
+
/**
|
|
9
|
+
* Keys that route credentials or relax safety gates. These are read ONLY from
|
|
10
|
+
* the user scope (plus env vars / CLI flags handled at call sites). A
|
|
11
|
+
* checked-out repository's project/local config must NOT be able to silently:
|
|
12
|
+
* - redirect credentials to another endpoint (`endpoint`)
|
|
13
|
+
* - ship a shared plaintext password (`password`)
|
|
14
|
+
* - forward extra parent-env vars to plugin processes (`pluginPassEnv`)
|
|
15
|
+
* - loosen the destructive-delete gate (`allowDelete`)
|
|
16
|
+
* `disabledCommands` is intentionally NOT here: it can only ADD restrictions
|
|
17
|
+
* (never loosen), and team-shared command hiding is a legitimate use.
|
|
18
|
+
*/
|
|
19
|
+
export declare const USER_ONLY_KEYS: ReadonlySet<keyof IRepoConfig>;
|
|
8
20
|
/** ~/.i-repo/i-repo.json */
|
|
9
21
|
export declare function getUserConfigPath(): string;
|
|
10
22
|
/** ~/.i-repo/ */
|
|
@@ -34,6 +46,8 @@ export declare function writeScopedConfig(scope: ConfigScope, data: Partial<IRep
|
|
|
34
46
|
* Priority: local > project > user
|
|
35
47
|
*/
|
|
36
48
|
export declare function getMergedConfig(): Partial<IRepoConfig>;
|
|
49
|
+
/** Test seam: reset the per-process shadow-warning memo. */
|
|
50
|
+
export declare function __resetUserOnlyShadowMemo(): void;
|
|
37
51
|
/**
|
|
38
52
|
* Get a config value with source information.
|
|
39
53
|
* Checks scopes from highest to lowest priority.
|
|
@@ -11,6 +11,23 @@
|
|
|
11
11
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync, renameSync, unlinkSync } from "node:fs";
|
|
12
12
|
import { join, dirname, resolve } from "node:path";
|
|
13
13
|
import { homedir } from "node:os";
|
|
14
|
+
/**
|
|
15
|
+
* Keys that route credentials or relax safety gates. These are read ONLY from
|
|
16
|
+
* the user scope (plus env vars / CLI flags handled at call sites). A
|
|
17
|
+
* checked-out repository's project/local config must NOT be able to silently:
|
|
18
|
+
* - redirect credentials to another endpoint (`endpoint`)
|
|
19
|
+
* - ship a shared plaintext password (`password`)
|
|
20
|
+
* - forward extra parent-env vars to plugin processes (`pluginPassEnv`)
|
|
21
|
+
* - loosen the destructive-delete gate (`allowDelete`)
|
|
22
|
+
* `disabledCommands` is intentionally NOT here: it can only ADD restrictions
|
|
23
|
+
* (never loosen), and team-shared command hiding is a legitimate use.
|
|
24
|
+
*/
|
|
25
|
+
export const USER_ONLY_KEYS = new Set([
|
|
26
|
+
"endpoint",
|
|
27
|
+
"password",
|
|
28
|
+
"pluginPassEnv",
|
|
29
|
+
"allowDelete",
|
|
30
|
+
]);
|
|
14
31
|
// ── Paths ────────────────────────────────────────────────────
|
|
15
32
|
const CONFIG_DIR_NAME = ".i-repo";
|
|
16
33
|
const CONFIG_FILE_NAME = "i-repo.json";
|
|
@@ -191,13 +208,60 @@ export function getMergedConfig() {
|
|
|
191
208
|
const user = readScopedConfig("user");
|
|
192
209
|
const project = readScopedConfig("project");
|
|
193
210
|
const local = readScopedConfig("local");
|
|
194
|
-
|
|
211
|
+
const merged = { ...user, ...project, ...local };
|
|
212
|
+
// user-only keys: the effective value is the user-scope value alone, so the
|
|
213
|
+
// merged view (config list) reflects what actually takes effect at runtime.
|
|
214
|
+
for (const key of USER_ONLY_KEYS) {
|
|
215
|
+
if (key in user && user[key] !== undefined) {
|
|
216
|
+
merged[key] = user[key];
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
delete merged[key];
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return merged;
|
|
223
|
+
}
|
|
224
|
+
// A user-only key shadowed by project/local is reported once per key per
|
|
225
|
+
// process (getConfigWithSource is called many times per command).
|
|
226
|
+
const userOnlyShadowChecked = new Set();
|
|
227
|
+
/** Resolve a user-only key from the user scope alone, warning if a lower-trust
|
|
228
|
+
* scope tried (and failed) to set it. */
|
|
229
|
+
function getUserScopeOnly(key) {
|
|
230
|
+
if (!userOnlyShadowChecked.has(key)) {
|
|
231
|
+
userOnlyShadowChecked.add(key);
|
|
232
|
+
for (const scope of ["project", "local"]) {
|
|
233
|
+
const p = getConfigPath(scope);
|
|
234
|
+
if (!p)
|
|
235
|
+
continue;
|
|
236
|
+
const c = readJsonFile(p);
|
|
237
|
+
if (key in c && c[key] !== undefined) {
|
|
238
|
+
console.error(`Warning: ignoring "${key}" from ${scope} config (${p}) — for security this ` +
|
|
239
|
+
`key is only honored from your user config. A checked-out repository must not ` +
|
|
240
|
+
`redirect credentials or relax safety gates. ` +
|
|
241
|
+
`Set it with: i-repo config set ${key} <value> --scope user`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const userPath = getUserConfigPath();
|
|
246
|
+
const user = readJsonFile(userPath);
|
|
247
|
+
if (key in user && user[key] !== undefined) {
|
|
248
|
+
return { value: user[key], source: "user", path: userPath };
|
|
249
|
+
}
|
|
250
|
+
return { value: undefined, source: undefined, path: undefined };
|
|
251
|
+
}
|
|
252
|
+
/** Test seam: reset the per-process shadow-warning memo. */
|
|
253
|
+
export function __resetUserOnlyShadowMemo() {
|
|
254
|
+
userOnlyShadowChecked.clear();
|
|
195
255
|
}
|
|
196
256
|
/**
|
|
197
257
|
* Get a config value with source information.
|
|
198
258
|
* Checks scopes from highest to lowest priority.
|
|
199
259
|
*/
|
|
200
260
|
export function getConfigWithSource(key) {
|
|
261
|
+
// Credential-routing / safety-gate keys: user scope only (ignore project/local).
|
|
262
|
+
if (USER_ONLY_KEYS.has(key)) {
|
|
263
|
+
return getUserScopeOnly(key);
|
|
264
|
+
}
|
|
201
265
|
// Check local first (highest priority)
|
|
202
266
|
const localPath = getLocalConfigPath();
|
|
203
267
|
if (localPath) {
|
package/dist/config/store.d.ts
CHANGED
|
@@ -31,6 +31,13 @@ export declare function getConfigWithSource<K extends keyof IRepoConfig>(key: K)
|
|
|
31
31
|
export declare function setConfig<K extends keyof IRepoConfig>(key: K, value: IRepoConfig[K], scope?: ConfigScope): void;
|
|
32
32
|
export declare function deleteConfig<K extends keyof IRepoConfig>(key: K, scope?: ConfigScope): void;
|
|
33
33
|
export declare function resetConfig(scope?: ConfigScope): void;
|
|
34
|
+
/**
|
|
35
|
+
* Clear stored credentials (user AND password) from every scope that has a
|
|
36
|
+
* config file. Shared by the CLI `auth logout` and the Ink logout executor so
|
|
37
|
+
* both surfaces behave identically — leaving the password behind while
|
|
38
|
+
* reporting a successful logout is misleading and a credential-removal gap.
|
|
39
|
+
*/
|
|
40
|
+
export declare function clearStoredCredentials(): void;
|
|
34
41
|
export declare function getAllConfig(): Partial<IRepoConfig>;
|
|
35
42
|
export declare const validKeys: (keyof IRepoConfig)[];
|
|
36
43
|
export declare function isValidKey(key: string): key is keyof IRepoConfig;
|
package/dist/config/store.js
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* < Project (<repo>/.i-repo/i-repo.json)
|
|
7
7
|
* < Local (<repo>/.i-repo/i-repo.local.json)
|
|
8
8
|
*/
|
|
9
|
-
import {
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
10
|
+
import { getMergedConfig, getConfigWithSource as scopedGetConfigWithSource, setScopedConfig, deleteScopedConfig, resetScopedConfig, getAllConfigPaths, } from "./scoped-store.js";
|
|
10
11
|
export function getConfig(key) {
|
|
11
12
|
return scopedGetConfigWithSource(key).value;
|
|
12
13
|
}
|
|
@@ -22,6 +23,20 @@ export function deleteConfig(key, scope = "user") {
|
|
|
22
23
|
export function resetConfig(scope = "user") {
|
|
23
24
|
resetScopedConfig(scope);
|
|
24
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* Clear stored credentials (user AND password) from every scope that has a
|
|
28
|
+
* config file. Shared by the CLI `auth logout` and the Ink logout executor so
|
|
29
|
+
* both surfaces behave identically — leaving the password behind while
|
|
30
|
+
* reporting a successful logout is misleading and a credential-removal gap.
|
|
31
|
+
*/
|
|
32
|
+
export function clearStoredCredentials() {
|
|
33
|
+
for (const { scope, path } of getAllConfigPaths()) {
|
|
34
|
+
if (!path || !existsSync(path))
|
|
35
|
+
continue;
|
|
36
|
+
deleteScopedConfig("user", scope);
|
|
37
|
+
deleteScopedConfig("password", scope);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
25
40
|
export function getAllConfig() {
|
|
26
41
|
return getMergedConfig();
|
|
27
42
|
}
|
package/dist/core/logger.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { appendFileSync, chmodSync, mkdirSync, existsSync, readdirSync, unlinkSync } from "node:fs";
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
import { getUserConfigDir } from "../config/scoped-store.js";
|
|
13
|
+
import { sanitizeForLog } from "../ui/width.js";
|
|
13
14
|
const LOG_DIR_NAME = "logs";
|
|
14
15
|
const MAX_LOG_FILES = 7;
|
|
15
16
|
/** Get the log directory path: ~/.i-repo/logs/ */
|
|
@@ -63,7 +64,11 @@ function appendLines(lines) {
|
|
|
63
64
|
try {
|
|
64
65
|
ensureLogDir();
|
|
65
66
|
const logPath = getLogFilePath();
|
|
66
|
-
|
|
67
|
+
// Neutralize terminal escapes centrally so EVERY entry type (HTTP/ERROR/…),
|
|
68
|
+
// including server-derived messages/remarks/statusText/stacks, is inert when
|
|
69
|
+
// the log is viewed later. Keeps \n/\t so the log stays readable.
|
|
70
|
+
const safeLines = lines.map((line) => sanitizeForLog(line));
|
|
71
|
+
appendFileSync(logPath, safeLines.join("\n") + "\n", {
|
|
67
72
|
encoding: "utf-8",
|
|
68
73
|
mode: 0o600,
|
|
69
74
|
});
|
package/dist/i18n/locales/en.js
CHANGED
|
@@ -54,7 +54,10 @@ export const en = {
|
|
|
54
54
|
passwordRequired: "Password is required",
|
|
55
55
|
authenticatedSuccessfully: "Authenticated successfully",
|
|
56
56
|
authenticationFailed: "Authentication failed",
|
|
57
|
-
logoutSuccess: "Logged out successfully. Stored user cleared.",
|
|
57
|
+
logoutSuccess: "Logged out successfully. Stored user and password cleared.",
|
|
58
|
+
userOnlyRedirected: "Note: endpoint and password were saved to your user config ({path}).\n" +
|
|
59
|
+
" For security they only take effect from the user scope (a checked-out\n" +
|
|
60
|
+
" repository must not redirect credentials), so they are not written to project/local.",
|
|
58
61
|
statusTitle: "Connection Info",
|
|
59
62
|
notConfigured: 'Not configured. Run "i-repo auth login" to get started.',
|
|
60
63
|
notConfiguredInk: "Not configured. Run auth login to get started.",
|
|
@@ -159,6 +162,8 @@ export const en = {
|
|
|
159
162
|
invalidFormat: "Invalid format. Must be one of: table, json, csv, ndjson",
|
|
160
163
|
invalidTimeout: "Invalid timeout. Must be a positive number (seconds).",
|
|
161
164
|
invalidBoolean: "Invalid value for {key}. Must be true or false.",
|
|
165
|
+
userOnlyScopeWarning: '"{key}" is only read from the user scope for security; a value in {scope} ' +
|
|
166
|
+
"scope is ignored at runtime. Use --scope user to make it take effect.",
|
|
162
167
|
keyNotSet: "{key} is not set",
|
|
163
168
|
configTitle: "Configuration",
|
|
164
169
|
resetSuccess: "Configuration reset to defaults [{scope}]",
|
package/dist/i18n/locales/ja.js
CHANGED
|
@@ -54,7 +54,10 @@ export const ja = {
|
|
|
54
54
|
passwordRequired: "パスワードは必須です",
|
|
55
55
|
authenticatedSuccessfully: "認証に成功しました",
|
|
56
56
|
authenticationFailed: "認証に失敗しました",
|
|
57
|
-
logoutSuccess: "
|
|
57
|
+
logoutSuccess: "ログアウトしました。保存されたユーザー情報とパスワードをクリアしました。",
|
|
58
|
+
userOnlyRedirected: "注意: エンドポイントとパスワードは user 設定 ({path}) に保存しました。\n" +
|
|
59
|
+
" セキュリティ上これらは user スコープからのみ有効なため (clone したリポジトリが\n" +
|
|
60
|
+
" 認証情報をすり替えられないように)、project/local には書き込みません。",
|
|
58
61
|
statusTitle: "接続情報",
|
|
59
62
|
notConfigured: '未設定です。"i-repo auth login" を実行して開始してください。',
|
|
60
63
|
notConfiguredInk: "未設定です。auth login を実行して開始してください。",
|
|
@@ -159,6 +162,8 @@ export const ja = {
|
|
|
159
162
|
invalidFormat: "無効な形式です。table, json, csv, ndjson のいずれかを指定してください",
|
|
160
163
|
invalidTimeout: "無効なタイムアウト値です。正の数を指定してください(秒)。",
|
|
161
164
|
invalidBoolean: "{key} の値が無効です。true または false を指定してください。",
|
|
165
|
+
userOnlyScopeWarning: "セキュリティ上「{key}」は user スコープからのみ読み込まれます。{scope} スコープの値は" +
|
|
166
|
+
"実行時に無視されます。有効にするには --scope user を使用してください。",
|
|
162
167
|
keyNotSet: "{key} は未設定です",
|
|
163
168
|
configTitle: "設定",
|
|
164
169
|
resetSuccess: "設定をデフォルトにリセットしました [{scope}]",
|
package/dist/i18n/types.d.ts
CHANGED
|
@@ -61,6 +61,7 @@ export interface LocaleMessages {
|
|
|
61
61
|
authenticatedSuccessfully: string;
|
|
62
62
|
authenticationFailed: string;
|
|
63
63
|
logoutSuccess: string;
|
|
64
|
+
userOnlyRedirected: string;
|
|
64
65
|
statusTitle: string;
|
|
65
66
|
notConfigured: string;
|
|
66
67
|
notConfiguredInk: string;
|
|
@@ -161,6 +162,7 @@ export interface LocaleMessages {
|
|
|
161
162
|
invalidFormat: string;
|
|
162
163
|
invalidTimeout: string;
|
|
163
164
|
invalidBoolean: string;
|
|
165
|
+
userOnlyScopeWarning: string;
|
|
164
166
|
keyNotSet: string;
|
|
165
167
|
configTitle: string;
|
|
166
168
|
resetSuccess: string;
|
|
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { Box, Text } from "ink";
|
|
3
3
|
import { colors } from "../theme.js";
|
|
4
4
|
import { t as tr } from "../../i18n/index.js";
|
|
5
|
+
import { sanitizeForDisplay } from "../../ui/width.js";
|
|
5
6
|
export function DetailView({ data, title }) {
|
|
6
7
|
const entries = Object.entries(data);
|
|
7
8
|
const maxKeyLen = Math.max(...entries.map(([k]) => k.length), 0);
|
|
@@ -10,13 +11,14 @@ export function DetailView({ data, title }) {
|
|
|
10
11
|
const dots = dotLeaderLen - key.length;
|
|
11
12
|
const leader = dots > 2 ? " " + "\u00B7".repeat(dots - 1) + " " : " ";
|
|
12
13
|
const isNil = value === null || value === undefined;
|
|
13
|
-
return (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: colors.text, children: key }), _jsx(Text, { color: colors.border, children: leader }), isNil ? (_jsx(Text, { dimColor: true, children: tr("common.none") })) : (_jsx(Text, { color: colors.text, children: formatValue(value) }))] }, key));
|
|
14
|
+
return (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: colors.text, children: sanitizeForDisplay(key) }), _jsx(Text, { color: colors.border, children: leader }), isNil ? (_jsx(Text, { dimColor: true, children: tr("common.none") })) : (_jsx(Text, { color: colors.text, children: formatValue(value) }))] }, key));
|
|
14
15
|
})] }));
|
|
15
16
|
}
|
|
16
17
|
function formatValue(value) {
|
|
17
18
|
if (value === null || value === undefined)
|
|
18
19
|
return "(none)";
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
const s = typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
21
|
+
// Server-derived values may carry terminal escapes (title rewrite, screen
|
|
22
|
+
// clear); neutralize before rendering to the interactive TTY.
|
|
23
|
+
return sanitizeForDisplay(s);
|
|
22
24
|
}
|
|
@@ -2,6 +2,7 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
|
2
2
|
import { Box, Text } from "ink";
|
|
3
3
|
import { colors, icons } from "../theme.js";
|
|
4
4
|
import { t as tr } from "../../i18n/index.js";
|
|
5
|
+
import { sanitizeForDisplay } from "../../ui/width.js";
|
|
5
6
|
export function ErrorView({ message }) {
|
|
6
|
-
return (_jsxs(Box, { flexDirection: "column", marginY: 1, borderStyle: "round", borderColor: colors.error, paddingX: 2, paddingY: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { inverse: true, color: colors.error, bold: true, children: [" ", icons.error, " ", tr("errors.label"), " "] }) }), _jsx(Box, { marginLeft: 1, children: _jsx(Text, { color: colors.error, children: message }) })] }));
|
|
7
|
+
return (_jsxs(Box, { flexDirection: "column", marginY: 1, borderStyle: "round", borderColor: colors.error, paddingX: 2, paddingY: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { inverse: true, color: colors.error, bold: true, children: [" ", icons.error, " ", tr("errors.label"), " "] }) }), _jsx(Box, { marginLeft: 1, children: _jsx(Text, { color: colors.error, children: sanitizeForDisplay(message) }) })] }));
|
|
7
8
|
}
|
|
@@ -3,7 +3,7 @@ import { Box, Text, useInput } from "ink";
|
|
|
3
3
|
import { Fragment, useMemo, useState } from "react";
|
|
4
4
|
import { boxChars, colors, icons } from "../theme.js";
|
|
5
5
|
import { t as tr } from "../../i18n/index.js";
|
|
6
|
-
import { displayWidth, truncateToWidth } from "../../ui/width.js";
|
|
6
|
+
import { displayWidth, truncateToWidth, sanitizeForDisplay } from "../../ui/width.js";
|
|
7
7
|
import { decodeRawNav } from "../keymap.js";
|
|
8
8
|
/** 1 ページあたりの行数 (ActionBar のページ送りヒント表示判定にも使う) */
|
|
9
9
|
export const TABLE_PAGE_SIZE = 20;
|
|
@@ -33,10 +33,10 @@ export function TableView({ data, columns }) {
|
|
|
33
33
|
// Column widths by visual width (CJK-aware), cap to avoid overflow
|
|
34
34
|
const widths = useMemo(() => cols.map((col) => {
|
|
35
35
|
const maxData = data.reduce((max, row) => {
|
|
36
|
-
const w = displayWidth(String(row[col] ?? ""));
|
|
36
|
+
const w = displayWidth(sanitizeForDisplay(String(row[col] ?? "")));
|
|
37
37
|
return w > max ? w : max;
|
|
38
38
|
}, 0);
|
|
39
|
-
const headerW = displayWidth(col);
|
|
39
|
+
const headerW = displayWidth(sanitizeForDisplay(col));
|
|
40
40
|
const natural = Math.min(Math.max(headerW, maxData) + 2, MAX_COL_WIDTH);
|
|
41
41
|
return Math.max(natural, MIN_COL_WIDTHS[col] ?? 0);
|
|
42
42
|
}), [cols, data]);
|
|
@@ -45,13 +45,13 @@ export function TableView({ data, columns }) {
|
|
|
45
45
|
const v = boxChars.vertical;
|
|
46
46
|
const sep = boxChars.leftMid +
|
|
47
47
|
widths.map((w, i) => boxChars.horizontal.repeat(w) + (i < widths.length - 1 ? boxChars.midMid : boxChars.rightMid)).join("");
|
|
48
|
-
const headerCells = cols.map((col, i) => padToVisualWidth(" " + col, widths[i]));
|
|
48
|
+
const headerCells = cols.map((col, i) => padToVisualWidth(" " + sanitizeForDisplay(col), widths[i]));
|
|
49
49
|
const lines = [];
|
|
50
50
|
lines.push(v + headerCells.join(v) + v);
|
|
51
51
|
lines.push(sep);
|
|
52
52
|
for (const row of pageData) {
|
|
53
53
|
const cells = cols.map((col, i) => {
|
|
54
|
-
const raw = truncateToWidth(String(row[col] ?? "─"), widths[i] - 2);
|
|
54
|
+
const raw = truncateToWidth(sanitizeForDisplay(String(row[col] ?? "─")), widths[i] - 2);
|
|
55
55
|
return padToVisualWidth(" " + raw + " ", widths[i]);
|
|
56
56
|
});
|
|
57
57
|
lines.push(v + cells.join(v) + v);
|
|
@@ -8,9 +8,9 @@ import { readFile } from "node:fs/promises";
|
|
|
8
8
|
import { basename } from "node:path";
|
|
9
9
|
import { PublicStatus } from "../../sdk/index.js";
|
|
10
10
|
import { saveBinaryDownload, inferFilename, isTextContent } from "../../utils/file.js";
|
|
11
|
-
import { getConfig, getConfigWithSource, setConfig,
|
|
11
|
+
import { getConfig, getConfigWithSource, setConfig, clearStoredCredentials, resetConfig, isValidKey, validKeys, } from "../../config/store.js";
|
|
12
12
|
import { parseDisabledCommands, isConfigPath } from "../../config/command-gate.js";
|
|
13
|
-
import { getUserConfigPath, getProjectConfigPath, getLocalConfigPath, } from "../../config/scoped-store.js";
|
|
13
|
+
import { getUserConfigPath, getProjectConfigPath, getLocalConfigPath, USER_ONLY_KEYS, } from "../../config/scoped-store.js";
|
|
14
14
|
import { t as tr, setLocale } from "../../i18n/index.js";
|
|
15
15
|
import { assertNumericId } from "../../core/errors.js";
|
|
16
16
|
import { escapeCsv } from "../../ui/formatters/csv.js";
|
|
@@ -104,7 +104,7 @@ const authLogin = async () => {
|
|
|
104
104
|
return { type: "message", text: tr("executors.useAuthLoginScreen") };
|
|
105
105
|
};
|
|
106
106
|
const authLogout = async () => {
|
|
107
|
-
|
|
107
|
+
clearStoredCredentials();
|
|
108
108
|
return { type: "message", text: tr("auth.logoutSuccess") };
|
|
109
109
|
};
|
|
110
110
|
const authStatus = async () => {
|
|
@@ -144,18 +144,23 @@ const configSet = async (p) => {
|
|
|
144
144
|
return { type: "error", message: tr("config.invalidFormat") };
|
|
145
145
|
if (key === "language" && !["en", "ja"].includes(value))
|
|
146
146
|
return { type: "error", message: tr("config.invalidLanguage") };
|
|
147
|
+
// user-only keys set to project/local are ignored at runtime — warn, like the
|
|
148
|
+
// CLI config setter, so the TUI doesn't report unusable config as applied.
|
|
149
|
+
const userOnlyNote = USER_ONLY_KEYS.has(key) && scope !== "user"
|
|
150
|
+
? `${tr("config.userOnlyScopeWarning", { key, scope })}\n`
|
|
151
|
+
: "";
|
|
147
152
|
if (key === "timeout") {
|
|
148
153
|
const n = Number(value);
|
|
149
154
|
if (isNaN(n) || n <= 0)
|
|
150
155
|
return { type: "error", message: tr("config.invalidTimeout") };
|
|
151
156
|
setConfig(key, n, scope);
|
|
152
|
-
return { type: "message", text: `${key} = ${value} [${scope}]` };
|
|
157
|
+
return { type: "message", text: `${userOnlyNote}${key} = ${value} [${scope}]` };
|
|
153
158
|
}
|
|
154
159
|
if (key === "quiet" || key === "allowDelete") {
|
|
155
160
|
if (!["true", "false"].includes(value))
|
|
156
161
|
return { type: "error", message: tr("config.invalidBoolean", { key }) };
|
|
157
162
|
setConfig(key, value === "true", scope);
|
|
158
|
-
return { type: "message", text: `${key} = ${value} [${scope}]` };
|
|
163
|
+
return { type: "message", text: `${userOnlyNote}${key} = ${value} [${scope}]` };
|
|
159
164
|
}
|
|
160
165
|
if (key === "disabledCommands") {
|
|
161
166
|
// The config command can never be disabled (lockout prevention),
|
|
@@ -165,13 +170,13 @@ const configSet = async (p) => {
|
|
|
165
170
|
const joined = kept.join(", ");
|
|
166
171
|
setConfig(key, joined, scope);
|
|
167
172
|
const note = kept.length < paths.length ? `${tr("gate.cannotDisableConfig")}\n` : "";
|
|
168
|
-
return { type: "message", text: `${note}${key} = ${joined} [${scope}]` };
|
|
173
|
+
return { type: "message", text: `${userOnlyNote}${note}${key} = ${joined} [${scope}]` };
|
|
169
174
|
}
|
|
170
175
|
setConfig(key, value, scope);
|
|
171
176
|
if (key === "language")
|
|
172
177
|
setLocale(value);
|
|
173
178
|
const displayValue = key === "password" ? "****" : value;
|
|
174
|
-
return { type: "message", text: `${key} = ${displayValue} [${scope}]` };
|
|
179
|
+
return { type: "message", text: `${userOnlyNote}${key} = ${displayValue} [${scope}]` };
|
|
175
180
|
};
|
|
176
181
|
const configGet = async (p) => {
|
|
177
182
|
const key = p.key;
|
|
@@ -6,8 +6,9 @@ import { IReporterClient } from "../../sdk/index.js";
|
|
|
6
6
|
import { Header } from "../components/Layout.js";
|
|
7
7
|
import { SpinnerView } from "../components/SpinnerView.js";
|
|
8
8
|
import { colors, icons } from "../theme.js";
|
|
9
|
-
import { getConfig
|
|
10
|
-
import { findProjectRoot,
|
|
9
|
+
import { getConfig } from "../../config/store.js";
|
|
10
|
+
import { findProjectRoot, getUserConfigPath, getProjectConfigPath, getLocalConfigPath } from "../../config/scoped-store.js";
|
|
11
|
+
import { assertEndpointAllowed, persistCredentials } from "../../config/client.js";
|
|
11
12
|
import { logHttp, logInteractiveCommand } from "../../core/logger.js";
|
|
12
13
|
import { t as tr } from "../../i18n/index.js";
|
|
13
14
|
export function AuthLoginScreen({ onDone, onBack }) {
|
|
@@ -17,6 +18,7 @@ export function AuthLoginScreen({ onDone, onBack }) {
|
|
|
17
18
|
const [passwordValue, setPasswordValue] = useState("");
|
|
18
19
|
const [selectedScope, setSelectedScope] = useState(null);
|
|
19
20
|
const [savedPath, setSavedPath] = useState(null);
|
|
21
|
+
const [redirectedUserPath, setRedirectedUserPath] = useState(null);
|
|
20
22
|
const [errorMsg, setErrorMsg] = useState("");
|
|
21
23
|
const timerRef = useRef(undefined);
|
|
22
24
|
useEffect(() => {
|
|
@@ -49,6 +51,8 @@ export function AuthLoginScreen({ onDone, onBack }) {
|
|
|
49
51
|
setPasswordValue(pw);
|
|
50
52
|
logInteractiveCommand("auth login", { user });
|
|
51
53
|
try {
|
|
54
|
+
// Block plain-http to a public host (same HTTPS policy as the CLI path).
|
|
55
|
+
assertEndpointAllowed(endpoint);
|
|
52
56
|
const client = new IReporterClient({ apiEndpoint: endpoint, onHttp: logHttp });
|
|
53
57
|
await client.login({ user, password: pw });
|
|
54
58
|
await client.systems.getUrlScheme();
|
|
@@ -98,16 +102,22 @@ export function AuthLoginScreen({ onDone, onBack }) {
|
|
|
98
102
|
{ label: `${icons.success} ${tr("common.yes")}`, value: "yes" },
|
|
99
103
|
], onChange: (value) => {
|
|
100
104
|
if (selectedScope) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
105
|
+
// endpoint/password are user-only keys: persistCredentials
|
|
106
|
+
// redirects them to the user scope (only `user` goes to the
|
|
107
|
+
// chosen scope), matching the CLI path.
|
|
108
|
+
const { savedPath: sp, redirectedToUser, userPath } = persistCredentials({
|
|
109
|
+
endpoint,
|
|
110
|
+
user,
|
|
111
|
+
password: passwordValue,
|
|
112
|
+
scope: selectedScope,
|
|
113
|
+
savePassword: value === "yes",
|
|
114
|
+
});
|
|
115
|
+
setSavedPath(sp ?? null);
|
|
116
|
+
setRedirectedUserPath(redirectedToUser ? userPath : null);
|
|
107
117
|
}
|
|
108
118
|
setStep("done");
|
|
109
119
|
timerRef.current = setTimeout(() => onDone(passwordValue), 1500);
|
|
110
|
-
} })] })), step === "done" && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: colors.success, children: [icons.success, " ", tr("auth.authenticatedAs", { user })] }), savedPath && (_jsxs(Text, { color: colors.dim, children: [" ", tr("auth.savedTo", { path: savedPath })] }))] })), step === "error" && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: colors.error, children: [icons.error, " ", tr("auth.authFailed")] }), _jsx(Box, { marginLeft: 2, children: _jsx(Text, { color: colors.dim, children: errorMsg }) })] })), (step === "endpoint" || step === "user" || step === "password" || step === "error") && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: colors.dim, children: step === "endpoint"
|
|
120
|
+
} })] })), step === "done" && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: colors.success, children: [icons.success, " ", tr("auth.authenticatedAs", { user })] }), savedPath && (_jsxs(Text, { color: colors.dim, children: [" ", tr("auth.savedTo", { path: savedPath })] })), redirectedUserPath && (_jsxs(Text, { color: colors.dim, children: [" ", tr("auth.userOnlyRedirected", { path: redirectedUserPath })] }))] })), step === "error" && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: colors.error, children: [icons.error, " ", tr("auth.authFailed")] }), _jsx(Box, { marginLeft: 2, children: _jsx(Text, { color: colors.dim, children: errorMsg }) })] })), (step === "endpoint" || step === "user" || step === "password" || step === "error") && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: colors.dim, children: step === "endpoint"
|
|
111
121
|
? tr("common.escapeToGoBack")
|
|
112
122
|
: tr("optionForm.navPrev", { arrow: icons.arrow }) }) }))] }));
|
|
113
123
|
}
|
|
@@ -16,6 +16,7 @@ import { colors, icons } from "../theme.js";
|
|
|
16
16
|
import { commandGroups } from "../registry/commands.js";
|
|
17
17
|
import { executors } from "../registry/executors.js";
|
|
18
18
|
import { getConfig } from "../../config/store.js";
|
|
19
|
+
import { assertEndpointAllowed } from "../../config/client.js";
|
|
19
20
|
import { uiGateReason } from "../../config/command-gate.js";
|
|
20
21
|
import { logHttp, logInteractiveCommand } from "../../core/logger.js";
|
|
21
22
|
import { t as tr } from "../../i18n/index.js";
|
|
@@ -97,6 +98,8 @@ export function CommandScreen({ group, onBack, sessionPassword, onPasswordResolv
|
|
|
97
98
|
setPhase({ step: "password-prompt", command: cmd, params });
|
|
98
99
|
return;
|
|
99
100
|
}
|
|
101
|
+
// Block plain-http to a public host (same HTTPS policy as the CLI path).
|
|
102
|
+
assertEndpointAllowed(endpoint);
|
|
100
103
|
client = new IReporterClient({
|
|
101
104
|
apiEndpoint: endpoint,
|
|
102
105
|
credentials: { user, password: pw },
|
package/dist/plugins/dispatch.js
CHANGED
|
@@ -171,10 +171,24 @@ function parsePassEnv(raw) {
|
|
|
171
171
|
const p = part.trim().toUpperCase();
|
|
172
172
|
if (!p)
|
|
173
173
|
continue;
|
|
174
|
-
if (p.endsWith("*"))
|
|
175
|
-
|
|
176
|
-
|
|
174
|
+
if (p.endsWith("*")) {
|
|
175
|
+
const pfx = p.slice(0, -1);
|
|
176
|
+
// A bare "*" (empty prefix) or a 1-char prefix matches almost every
|
|
177
|
+
// variable name, which silently defeats the allow-list and forwards the
|
|
178
|
+
// whole parent environment (AWS_SECRET_ACCESS_KEY, DB URLs, ...) to an
|
|
179
|
+
// arbitrary PATH binary. Require at least 2 chars; drop and warn otherwise.
|
|
180
|
+
if (pfx.length >= 2) {
|
|
181
|
+
prefixes.push(pfx);
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
console.error(`Warning: ignoring pluginPassEnv pattern "${part.trim()}" — a wildcard ` +
|
|
185
|
+
`prefix shorter than 2 characters is too broad and would forward most ` +
|
|
186
|
+
`environment variables to plugins.`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
177
190
|
names.add(p);
|
|
191
|
+
}
|
|
178
192
|
}
|
|
179
193
|
return { names, prefixes };
|
|
180
194
|
}
|
|
@@ -7,11 +7,21 @@ export declare class HttpClient {
|
|
|
7
7
|
private readonly urlEncodedTransport;
|
|
8
8
|
private readonly urlEncodedFetchBodyMode;
|
|
9
9
|
private readonly onHttp?;
|
|
10
|
+
/** Node-like runtime (undici): manual redirects are readable, so the fetch
|
|
11
|
+
* path can enforce same-origin. In browsers manual redirects are opaque. */
|
|
12
|
+
private readonly isNodeRuntime;
|
|
10
13
|
private cookieJar;
|
|
11
14
|
private warmupPromise;
|
|
12
15
|
/** 複数の Set-Cookie ヘッダーを cookieJar に反映する */
|
|
13
16
|
private applySetCookies;
|
|
14
17
|
private applySetCookiesFromIncoming;
|
|
18
|
+
/**
|
|
19
|
+
* Reject a redirect target that leaves the originally-configured origin.
|
|
20
|
+
* `URL.origin` normalizes default ports (https://h:443 -> https://h), so an
|
|
21
|
+
* explicit-port redirect to the same host still passes; an https->http
|
|
22
|
+
* downgrade or a different host/port is blocked.
|
|
23
|
+
*/
|
|
24
|
+
private assertSameOrigin;
|
|
15
25
|
constructor(config: IReporterConfig);
|
|
16
26
|
/**
|
|
17
27
|
* リクエストの所要時間と成否を onHttp に通知しつつ fn を実行する。
|
|
@@ -80,6 +90,7 @@ export declare class HttpClient {
|
|
|
80
90
|
get hasSession(): boolean;
|
|
81
91
|
private createUrlEncodedHeaders;
|
|
82
92
|
private createUrlEncodedRequestBody;
|
|
93
|
+
private withCookieHeader;
|
|
83
94
|
private sendFetchRequest;
|
|
84
95
|
private readArrayBuffer;
|
|
85
96
|
}
|
|
@@ -10,6 +10,9 @@ export class HttpClient {
|
|
|
10
10
|
urlEncodedTransport;
|
|
11
11
|
urlEncodedFetchBodyMode;
|
|
12
12
|
onHttp;
|
|
13
|
+
/** Node-like runtime (undici): manual redirects are readable, so the fetch
|
|
14
|
+
* path can enforce same-origin. In browsers manual redirects are opaque. */
|
|
15
|
+
isNodeRuntime;
|
|
13
16
|
cookieJar = new Map();
|
|
14
17
|
warmupPromise = null;
|
|
15
18
|
/** 複数の Set-Cookie ヘッダーを cookieJar に反映する */
|
|
@@ -32,13 +35,26 @@ export class HttpClient {
|
|
|
32
35
|
const list = Array.isArray(raw) ? raw : [raw];
|
|
33
36
|
this.applySetCookies(list);
|
|
34
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Reject a redirect target that leaves the originally-configured origin.
|
|
40
|
+
* `URL.origin` normalizes default ports (https://h:443 -> https://h), so an
|
|
41
|
+
* explicit-port redirect to the same host still passes; an https->http
|
|
42
|
+
* downgrade or a different host/port is blocked.
|
|
43
|
+
*/
|
|
44
|
+
assertSameOrigin(target) {
|
|
45
|
+
const base = new URL(this.baseUrl);
|
|
46
|
+
if (target.origin !== base.origin) {
|
|
47
|
+
throw new NetworkError(`Refusing to follow cross-origin redirect from ${base.origin} to ${target.origin}: ` +
|
|
48
|
+
`session cookies and login credentials would be exposed to a different host.`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
35
51
|
constructor(config) {
|
|
36
52
|
this.baseUrl = config.apiEndpoint.replace(/\/$/, "");
|
|
37
53
|
this.timeout = config.timeout ?? 30_000;
|
|
38
54
|
this.userSignal = config.signal;
|
|
55
|
+
this.isNodeRuntime = typeof process !== "undefined" && process.versions?.node != null;
|
|
39
56
|
this.urlEncodedTransport =
|
|
40
|
-
config.urlEncodedTransport ??
|
|
41
|
-
(typeof process !== "undefined" && process.versions?.node != null ? "node" : "fetch");
|
|
57
|
+
config.urlEncodedTransport ?? (this.isNodeRuntime ? "node" : "fetch");
|
|
42
58
|
this.urlEncodedFetchBodyMode = config.urlEncodedFetchBodyMode ?? "string";
|
|
43
59
|
this.onHttp = config.onHttp;
|
|
44
60
|
}
|
|
@@ -238,7 +254,17 @@ export class HttpClient {
|
|
|
238
254
|
if (loc &&
|
|
239
255
|
[301, 302, 303, 307, 308].includes(result.statusCode) &&
|
|
240
256
|
redirectsLeft > 0) {
|
|
241
|
-
|
|
257
|
+
const next = new URL(loc, currentUrl);
|
|
258
|
+
// Cookies and the (credential-bearing) POST body are re-attached on every
|
|
259
|
+
// hop in singleNodePost. Unlike fetch's `redirect:"follow"`, which strips
|
|
260
|
+
// sensitive headers on a cross-origin redirect, this manual loop would
|
|
261
|
+
// re-send the session cookie and the Login user/password to wherever the
|
|
262
|
+
// server points. A malicious/compromised server (or MITM) returning
|
|
263
|
+
// `30x Location: http://attacker/` could harvest them — including an
|
|
264
|
+
// https->http downgrade. Only follow redirects that stay on the
|
|
265
|
+
// originally-configured origin.
|
|
266
|
+
this.assertSameOrigin(next);
|
|
267
|
+
currentUrl = next.href;
|
|
242
268
|
redirectsLeft--;
|
|
243
269
|
continue;
|
|
244
270
|
}
|
|
@@ -377,25 +403,77 @@ export class HttpClient {
|
|
|
377
403
|
? body
|
|
378
404
|
: body.toString();
|
|
379
405
|
}
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
body,
|
|
387
|
-
signal,
|
|
388
|
-
redirect: "follow",
|
|
389
|
-
});
|
|
406
|
+
withCookieHeader(headers) {
|
|
407
|
+
const out = { ...headers };
|
|
408
|
+
if (this.cookieJar.size > 0) {
|
|
409
|
+
out["Cookie"] = Array.from(this.cookieJar.entries())
|
|
410
|
+
.map(([name, value]) => `${name}=${value}`)
|
|
411
|
+
.join("; ");
|
|
390
412
|
}
|
|
391
|
-
|
|
392
|
-
|
|
413
|
+
return out;
|
|
414
|
+
}
|
|
415
|
+
async sendFetchRequest(body, headers, signal = createRequestSignal(this.timeout, this.userSignal)) {
|
|
416
|
+
// Browser: redirect: "manual" yields an opaque, unfollowable response (status
|
|
417
|
+
// 0, no readable Location), so we cannot inspect the target to enforce
|
|
418
|
+
// same-origin here. Defer to the browser's native redirect handling and its
|
|
419
|
+
// built-in cross-origin credential protections — a manually-set Cookie is a
|
|
420
|
+
// forbidden header the browser drops, so session cookies are never replayed
|
|
421
|
+
// cross-origin. (Cross-origin redirect enforcement on the security-critical
|
|
422
|
+
// path is handled by the Node transport, which the CLI uses by default.)
|
|
423
|
+
if (!this.isNodeRuntime) {
|
|
424
|
+
let response;
|
|
425
|
+
try {
|
|
426
|
+
response = await globalThis.fetch(this.baseUrl, {
|
|
427
|
+
method: "POST",
|
|
428
|
+
headers: this.withCookieHeader(headers),
|
|
429
|
+
body,
|
|
430
|
+
signal,
|
|
431
|
+
redirect: "follow",
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
catch (err) {
|
|
435
|
+
throw toNetworkError(err, this.timeout, this.baseUrl);
|
|
436
|
+
}
|
|
437
|
+
this.applySetCookies(response.headers.getSetCookie?.() ?? []);
|
|
438
|
+
if (!response.ok) {
|
|
439
|
+
throw new HttpError(response.status, response.statusText);
|
|
440
|
+
}
|
|
441
|
+
return response;
|
|
393
442
|
}
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
443
|
+
// Node (undici): follow redirects MANUALLY so we can enforce the same-origin
|
|
444
|
+
// check before replaying the request. With redirect: "follow", a cross-origin
|
|
445
|
+
// 307/308 preserves and re-sends the URL-encoded body — which for Login
|
|
446
|
+
// carries user/password — to the redirect target. Mirrors nodePostUrlEncoded.
|
|
447
|
+
let currentUrl = this.baseUrl;
|
|
448
|
+
let redirectsLeft = 5;
|
|
449
|
+
while (true) {
|
|
450
|
+
let response;
|
|
451
|
+
try {
|
|
452
|
+
response = await globalThis.fetch(currentUrl, {
|
|
453
|
+
method: "POST",
|
|
454
|
+
headers: this.withCookieHeader(headers),
|
|
455
|
+
body,
|
|
456
|
+
signal,
|
|
457
|
+
redirect: "manual",
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
catch (err) {
|
|
461
|
+
throw toNetworkError(err, this.timeout, currentUrl);
|
|
462
|
+
}
|
|
463
|
+
this.applySetCookies(response.headers.getSetCookie?.() ?? []);
|
|
464
|
+
const loc = response.headers.get("location");
|
|
465
|
+
if (loc && [301, 302, 303, 307, 308].includes(response.status) && redirectsLeft > 0) {
|
|
466
|
+
const next = new URL(loc, currentUrl);
|
|
467
|
+
this.assertSameOrigin(next); // throws on cross-origin / https->http downgrade
|
|
468
|
+
currentUrl = next.href;
|
|
469
|
+
redirectsLeft--;
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
if (!response.ok) {
|
|
473
|
+
throw new HttpError(response.status, response.statusText);
|
|
474
|
+
}
|
|
475
|
+
return response;
|
|
397
476
|
}
|
|
398
|
-
return response;
|
|
399
477
|
}
|
|
400
478
|
async readArrayBuffer(response) {
|
|
401
479
|
try {
|
package/dist/ui/width.d.ts
CHANGED
|
@@ -19,6 +19,17 @@ export declare function stripAnsi(str: string): string;
|
|
|
19
19
|
* raw 忠実を壊すべきでない)。色付けは本関数より後段で適用するため除去されない。
|
|
20
20
|
*/
|
|
21
21
|
export declare function sanitizeForDisplay(str: string): string;
|
|
22
|
+
/**
|
|
23
|
+
* ログファイル向けの無害化。`sanitizeForDisplay` と違い、ログの行構造
|
|
24
|
+
* (`\n`) とインデント (`\t`) は保持し、それ以外の端末乗っ取り/破壊しうる
|
|
25
|
+
* 制御文字 (ESC/BEL/CR ほか C0・DEL・C1) を可視の `\uXXXX` にエスケープする。
|
|
26
|
+
*
|
|
27
|
+
* サーバ由来の文字列 (エラーメッセージ・remarks・statusText・スタック等) が
|
|
28
|
+
* `~/.i-repo/logs/*.log` に生で書かれると、後で cat/less -R で開いたときに
|
|
29
|
+
* タイトル書換・画面消去・偽プロンプトが発動しうる。バイトは記録しつつ無効化
|
|
30
|
+
* する (JSON 出力経路と同じ「エスケープして残す」方針)。
|
|
31
|
+
*/
|
|
32
|
+
export declare function sanitizeForLog(str: string): string;
|
|
22
33
|
/** Terminal cell width of a single code point: 0, 1, or 2 */
|
|
23
34
|
export declare function charWidth(cp: number): number;
|
|
24
35
|
/** Visual display width of a string (ANSI codes stripped, CJK = 2 columns) */
|
package/dist/ui/width.js
CHANGED
|
@@ -27,6 +27,21 @@ export function sanitizeForDisplay(str) {
|
|
|
27
27
|
// eslint-disable-next-line no-control-regex
|
|
28
28
|
return str.replace(/[\x00-\x1f\x7f-\x9f]/g, "");
|
|
29
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* ログファイル向けの無害化。`sanitizeForDisplay` と違い、ログの行構造
|
|
32
|
+
* (`\n`) とインデント (`\t`) は保持し、それ以外の端末乗っ取り/破壊しうる
|
|
33
|
+
* 制御文字 (ESC/BEL/CR ほか C0・DEL・C1) を可視の `\uXXXX` にエスケープする。
|
|
34
|
+
*
|
|
35
|
+
* サーバ由来の文字列 (エラーメッセージ・remarks・statusText・スタック等) が
|
|
36
|
+
* `~/.i-repo/logs/*.log` に生で書かれると、後で cat/less -R で開いたときに
|
|
37
|
+
* タイトル書換・画面消去・偽プロンプトが発動しうる。バイトは記録しつつ無効化
|
|
38
|
+
* する (JSON 出力経路と同じ「エスケープして残す」方針)。
|
|
39
|
+
*/
|
|
40
|
+
export function sanitizeForLog(str) {
|
|
41
|
+
// 0x09(タブ) と 0x0a(改行) だけ残し、他の C0・DEL・C1 をエスケープ。
|
|
42
|
+
// eslint-disable-next-line no-control-regex
|
|
43
|
+
return str.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, (c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0"));
|
|
44
|
+
}
|
|
30
45
|
/** Terminal cell width of a single code point: 0, 1, or 2 */
|
|
31
46
|
export function charWidth(cp) {
|
|
32
47
|
// Zero-width: ZWSP, ZWJ, combining marks, Hangul jungseong/jongseong, variation selectors
|