@trackunit/iris-app-playwright 0.2.53-alpha-037fb409571.0 → 0.2.55
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/package.json +8 -2
- package/src/credentials/fixtureAccounts.d.ts +19 -0
- package/src/credentials/fixtureAccounts.js +69 -0
- package/src/credentials/globalSetup.d.ts +68 -0
- package/src/credentials/globalSetup.js +91 -0
- package/src/credentials/guards.d.ts +2 -0
- package/src/credentials/guards.js +7 -0
- package/src/credentials/keys.d.ts +28 -0
- package/src/credentials/keys.js +38 -0
- package/src/credentials/resolveE2EPassword.d.ts +35 -0
- package/src/credentials/resolveE2EPassword.js +124 -0
- package/src/fixtures/resolveCredentials.d.ts +3 -1
- package/src/fixtures/resolveCredentials.js +44 -3
- package/src/index.d.ts +5 -0
- package/src/index.js +11 -0
- package/src/plugins/logsReporter.d.ts +13 -0
- package/src/plugins/logsReporter.js +27 -0
- package/src/plugins/redactPatterns.d.ts +30 -0
- package/src/plugins/redactPatterns.js +71 -0
- package/src/plugins/redactSensitive.d.ts +8 -5
- package/src/plugins/redactSensitive.js +16 -11
- package/src/plugins/redactTrace.d.ts +22 -8
- package/src/plugins/redactTrace.js +81 -16
- package/src/plugins/scanArtifactsForSecrets.d.ts +51 -0
- package/src/plugins/scanArtifactsForSecrets.js +178 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trackunit/iris-app-playwright",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.55",
|
|
4
4
|
"repository": "https://github.com/Trackunit/manager",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.txt",
|
|
6
6
|
"generators": "./generators.json",
|
|
@@ -37,7 +37,13 @@
|
|
|
37
37
|
"tslib": "^2.6.2"
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
40
|
-
"@playwright/test": "^1.60.0"
|
|
40
|
+
"@playwright/test": "^1.60.0",
|
|
41
|
+
"node-vault": "^0.10.2"
|
|
42
|
+
},
|
|
43
|
+
"peerDependenciesMeta": {
|
|
44
|
+
"node-vault": {
|
|
45
|
+
"optional": true
|
|
46
|
+
}
|
|
41
47
|
},
|
|
42
48
|
"migrations": "./migrations.json",
|
|
43
49
|
"type": "commonjs"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns a fixture's `username` when the file is a **credential** fixture — a
|
|
3
|
+
* JSON object with a non-blank string `username` that is not the `REPLACE_ME`
|
|
4
|
+
* placeholder. Data fixtures (no `username`) and unparseable files yield
|
|
5
|
+
* `undefined`. The `password` field is intentionally NOT required, so this keeps
|
|
6
|
+
* working after passwords are removed from the repo.
|
|
7
|
+
*/
|
|
8
|
+
export declare const readFixtureAccount: (fixturePath: string) => string | undefined;
|
|
9
|
+
/** Lists the `*.json` files directly under a fixtures directory (non-recursive, sorted). */
|
|
10
|
+
export declare const listFixtureFiles: (fixturesDir: string) => Array<string>;
|
|
11
|
+
/**
|
|
12
|
+
* Discovers the **distinct** account identifiers referenced by **all** credential
|
|
13
|
+
* fixtures directly under `fixturesDir` — every `*.json` with a `username`, not
|
|
14
|
+
* just `auth.json`. A project's `playwright/fixtures/` can hold many credential
|
|
15
|
+
* files (`managere2e.json`, `managere2e-admin.json`, `reporte2e.json`, …), each
|
|
16
|
+
* a different account; this returns one entry per distinct account so every one
|
|
17
|
+
* can be resolved.
|
|
18
|
+
*/
|
|
19
|
+
export declare const discoverFixtureAccounts: (fixturesDir: string) => Array<string>;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.discoverFixtureAccounts = exports.listFixtureFiles = exports.readFixtureAccount = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const fs_1 = require("fs");
|
|
6
|
+
const path = tslib_1.__importStar(require("path"));
|
|
7
|
+
const guards_1 = require("./guards");
|
|
8
|
+
/** Fixtures still holding this placeholder are unconfigured and ignored. */
|
|
9
|
+
const PLACEHOLDER = "REPLACE_ME";
|
|
10
|
+
/**
|
|
11
|
+
* Returns a fixture's `username` when the file is a **credential** fixture — a
|
|
12
|
+
* JSON object with a non-blank string `username` that is not the `REPLACE_ME`
|
|
13
|
+
* placeholder. Data fixtures (no `username`) and unparseable files yield
|
|
14
|
+
* `undefined`. The `password` field is intentionally NOT required, so this keeps
|
|
15
|
+
* working after passwords are removed from the repo.
|
|
16
|
+
*/
|
|
17
|
+
const readFixtureAccount = (fixturePath) => {
|
|
18
|
+
let parsed;
|
|
19
|
+
try {
|
|
20
|
+
parsed = JSON.parse((0, fs_1.readFileSync)(fixturePath, "utf-8"));
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
if (!(0, guards_1.isRecord)(parsed)) {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
const username = parsed.username;
|
|
29
|
+
if (typeof username !== "string" || username.trim() === "" || username === PLACEHOLDER) {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
return username;
|
|
33
|
+
};
|
|
34
|
+
exports.readFixtureAccount = readFixtureAccount;
|
|
35
|
+
/** Lists the `*.json` files directly under a fixtures directory (non-recursive, sorted). */
|
|
36
|
+
const listFixtureFiles = (fixturesDir) => {
|
|
37
|
+
let entries;
|
|
38
|
+
try {
|
|
39
|
+
entries = (0, fs_1.readdirSync)(fixturesDir);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
return entries
|
|
45
|
+
.filter(name => name.endsWith(".json"))
|
|
46
|
+
.map(name => path.join(fixturesDir, name))
|
|
47
|
+
.sort();
|
|
48
|
+
};
|
|
49
|
+
exports.listFixtureFiles = listFixtureFiles;
|
|
50
|
+
/**
|
|
51
|
+
* Discovers the **distinct** account identifiers referenced by **all** credential
|
|
52
|
+
* fixtures directly under `fixturesDir` — every `*.json` with a `username`, not
|
|
53
|
+
* just `auth.json`. A project's `playwright/fixtures/` can hold many credential
|
|
54
|
+
* files (`managere2e.json`, `managere2e-admin.json`, `reporte2e.json`, …), each
|
|
55
|
+
* a different account; this returns one entry per distinct account so every one
|
|
56
|
+
* can be resolved.
|
|
57
|
+
*/
|
|
58
|
+
const discoverFixtureAccounts = (fixturesDir) => {
|
|
59
|
+
const seen = new Set();
|
|
60
|
+
for (const file of (0, exports.listFixtureFiles)(fixturesDir)) {
|
|
61
|
+
const username = (0, exports.readFixtureAccount)(file);
|
|
62
|
+
if (username !== undefined) {
|
|
63
|
+
seen.add(username);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return [...seen];
|
|
67
|
+
};
|
|
68
|
+
exports.discoverFixtureAccounts = discoverFixtureAccounts;
|
|
69
|
+
//# sourceMappingURL=fixtureAccounts.js.map
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads the account name from a name-only `auth.json` fixture (`{ "username": ... }`).
|
|
3
|
+
* The password field has been removed from the repo; only the identifier remains.
|
|
4
|
+
*/
|
|
5
|
+
export declare const readFixtureUsername: (fixturePath: string) => string;
|
|
6
|
+
export interface InjectE2ECredentialsOptions {
|
|
7
|
+
fixturePath: string;
|
|
8
|
+
env?: NodeJS.ProcessEnv;
|
|
9
|
+
/** Password resolver seam (defaults to the real `resolveE2EPassword`). */
|
|
10
|
+
resolve?: (username: string, options: {
|
|
11
|
+
env: NodeJS.ProcessEnv;
|
|
12
|
+
}) => Promise<string>;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Resolves the fixture account's password (env-first, else Vault) and injects
|
|
16
|
+
* `PLAYWRIGHT_USERNAME`/`PLAYWRIGHT_PASSWORD` into the process env. Playwright
|
|
17
|
+
* spawns workers after `globalSetup`, so the published `resolveCredentials`
|
|
18
|
+
* env-var path picks these up unchanged — no change to the login fixture or the
|
|
19
|
+
* `await login()` call sites.
|
|
20
|
+
*/
|
|
21
|
+
export declare const injectE2ECredentials: (options: InjectE2ECredentialsOptions) => Promise<void>;
|
|
22
|
+
export interface InjectAllE2ECredentialsOptions {
|
|
23
|
+
/**
|
|
24
|
+
* Directory holding the project's credential fixtures. **Every** `*.json` with
|
|
25
|
+
* a `username` is loaded (not just `auth.json`). Ignored when `usernames` is given.
|
|
26
|
+
*/
|
|
27
|
+
fixturesDir?: string;
|
|
28
|
+
/** Explicit account list, overriding the directory scan. */
|
|
29
|
+
usernames?: ReadonlyArray<string>;
|
|
30
|
+
env?: NodeJS.ProcessEnv;
|
|
31
|
+
/** Password resolver seam (defaults to the real `resolveE2EPassword`). */
|
|
32
|
+
resolve?: (username: string, options: {
|
|
33
|
+
env: NodeJS.ProcessEnv;
|
|
34
|
+
}) => Promise<string>;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Resolves **every** account referenced by the project's credential fixtures and
|
|
38
|
+
* injects one env var per account: `E2E_PW_<SLUG> = password`.
|
|
39
|
+
*
|
|
40
|
+
* A project's `playwright/fixtures/` can reference many accounts across many
|
|
41
|
+
* files (e.g. `apps/manager/playwright/fixtures`), and a single test can log in
|
|
42
|
+
* as more than one. A single `PLAYWRIGHT_USERNAME`/`PLAYWRIGHT_PASSWORD` pair
|
|
43
|
+
* can't carry those, so we set a per-account variable instead; the published
|
|
44
|
+
* `resolveCredentials` then resolves each fixture's `username` to its
|
|
45
|
+
* `E2E_PW_<SLUG>` value at `login()` time. In CI the variables are already
|
|
46
|
+
* present (the CircleCI context), so `resolveE2EPassword` returns them without
|
|
47
|
+
* touching Vault; locally they are resolved from Vault.
|
|
48
|
+
*/
|
|
49
|
+
export declare const injectAllE2ECredentials: (options: InjectAllE2ECredentialsOptions) => Promise<void>;
|
|
50
|
+
/**
|
|
51
|
+
* Minimal shape of the Playwright `FullConfig` fields we use — avoids a runtime
|
|
52
|
+
* dependency on `@playwright/test` in this lib. `rootDir` is the config's base
|
|
53
|
+
* directory (the project directory, e.g. `libs/auth/main`), so the fixture sits
|
|
54
|
+
* at `<rootDir>/playwright/fixtures/auth.json`.
|
|
55
|
+
*/
|
|
56
|
+
interface PlaywrightConfigLike {
|
|
57
|
+
rootDir?: string;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Playwright `globalSetup` entry point. Resolves credentials for **all** account
|
|
61
|
+
* fixtures under the running project's `playwright/fixtures/` (every `*.json`,
|
|
62
|
+
* not just `auth.json`), located relative to the config's `rootDir`.
|
|
63
|
+
* `process.cwd()` is NOT used as the base: when the `e2e` target runs
|
|
64
|
+
* `playwright test --config=<project>`, cwd is the nx workspace root, not the
|
|
65
|
+
* project.
|
|
66
|
+
*/
|
|
67
|
+
declare const globalSetup: (config?: PlaywrightConfigLike) => Promise<void>;
|
|
68
|
+
export default globalSetup;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.injectAllE2ECredentials = exports.injectE2ECredentials = exports.readFixtureUsername = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const fs_1 = require("fs");
|
|
6
|
+
const path = tslib_1.__importStar(require("path"));
|
|
7
|
+
const fixtureAccounts_1 = require("./fixtureAccounts");
|
|
8
|
+
const guards_1 = require("./guards");
|
|
9
|
+
const keys_1 = require("./keys");
|
|
10
|
+
const resolveE2EPassword_1 = require("./resolveE2EPassword");
|
|
11
|
+
/**
|
|
12
|
+
* Reads the account name from a name-only `auth.json` fixture (`{ "username": ... }`).
|
|
13
|
+
* The password field has been removed from the repo; only the identifier remains.
|
|
14
|
+
*/
|
|
15
|
+
const readFixtureUsername = (fixturePath) => {
|
|
16
|
+
if (!(0, fs_1.existsSync)(fixturePath)) {
|
|
17
|
+
throw new Error(`E2E credentials: fixture not found at "${fixturePath}".`);
|
|
18
|
+
}
|
|
19
|
+
const parsed = JSON.parse((0, fs_1.readFileSync)(fixturePath, "utf-8"));
|
|
20
|
+
if (!(0, guards_1.isRecord)(parsed) || typeof parsed.username !== "string" || parsed.username === "") {
|
|
21
|
+
throw new Error(`E2E credentials: fixture "${fixturePath}" has no "username".`);
|
|
22
|
+
}
|
|
23
|
+
return parsed.username;
|
|
24
|
+
};
|
|
25
|
+
exports.readFixtureUsername = readFixtureUsername;
|
|
26
|
+
/**
|
|
27
|
+
* Resolves the fixture account's password (env-first, else Vault) and injects
|
|
28
|
+
* `PLAYWRIGHT_USERNAME`/`PLAYWRIGHT_PASSWORD` into the process env. Playwright
|
|
29
|
+
* spawns workers after `globalSetup`, so the published `resolveCredentials`
|
|
30
|
+
* env-var path picks these up unchanged — no change to the login fixture or the
|
|
31
|
+
* `await login()` call sites.
|
|
32
|
+
*/
|
|
33
|
+
const injectE2ECredentials = async (options) => {
|
|
34
|
+
const env = options.env ?? process.env;
|
|
35
|
+
const resolve = options.resolve ?? resolveE2EPassword_1.resolveE2EPassword;
|
|
36
|
+
const username = (0, exports.readFixtureUsername)(options.fixturePath);
|
|
37
|
+
const password = await resolve(username, { env });
|
|
38
|
+
env.PLAYWRIGHT_USERNAME = username;
|
|
39
|
+
env.PLAYWRIGHT_PASSWORD = password;
|
|
40
|
+
};
|
|
41
|
+
exports.injectE2ECredentials = injectE2ECredentials;
|
|
42
|
+
/**
|
|
43
|
+
* Resolves **every** account referenced by the project's credential fixtures and
|
|
44
|
+
* injects one env var per account: `E2E_PW_<SLUG> = password`.
|
|
45
|
+
*
|
|
46
|
+
* A project's `playwright/fixtures/` can reference many accounts across many
|
|
47
|
+
* files (e.g. `apps/manager/playwright/fixtures`), and a single test can log in
|
|
48
|
+
* as more than one. A single `PLAYWRIGHT_USERNAME`/`PLAYWRIGHT_PASSWORD` pair
|
|
49
|
+
* can't carry those, so we set a per-account variable instead; the published
|
|
50
|
+
* `resolveCredentials` then resolves each fixture's `username` to its
|
|
51
|
+
* `E2E_PW_<SLUG>` value at `login()` time. In CI the variables are already
|
|
52
|
+
* present (the CircleCI context), so `resolveE2EPassword` returns them without
|
|
53
|
+
* touching Vault; locally they are resolved from Vault.
|
|
54
|
+
*/
|
|
55
|
+
const injectAllE2ECredentials = async (options) => {
|
|
56
|
+
const env = options.env ?? process.env;
|
|
57
|
+
const resolve = options.resolve ?? resolveE2EPassword_1.resolveE2EPassword;
|
|
58
|
+
const usernames = options.usernames ?? (options.fixturesDir !== undefined ? (0, fixtureAccounts_1.discoverFixtureAccounts)(options.fixturesDir) : []);
|
|
59
|
+
// Resolve every account before mutating `env`. Two distinct accounts can slug
|
|
60
|
+
// to the same `E2E_PW_<SLUG>` key; silently overwriting would run tests with
|
|
61
|
+
// the wrong account's password, so reject the collision (mirrors the publish
|
|
62
|
+
// side's `buildFields`). Resolving up front also keeps injection atomic: a
|
|
63
|
+
// late resolver failure never leaves a partially-injected environment.
|
|
64
|
+
const resolved = new Map();
|
|
65
|
+
for (const username of usernames) {
|
|
66
|
+
const key = (0, keys_1.deriveEnvKey)(username);
|
|
67
|
+
const existing = resolved.get(key);
|
|
68
|
+
if (existing !== undefined) {
|
|
69
|
+
throw new Error(`E2E credentials: accounts "${existing.username}" and "${username}" both map to ${key} — cannot inject both.`);
|
|
70
|
+
}
|
|
71
|
+
resolved.set(key, { username, password: await resolve(username, { env }) });
|
|
72
|
+
}
|
|
73
|
+
for (const [key, { password }] of resolved) {
|
|
74
|
+
env[key] = password;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
exports.injectAllE2ECredentials = injectAllE2ECredentials;
|
|
78
|
+
/**
|
|
79
|
+
* Playwright `globalSetup` entry point. Resolves credentials for **all** account
|
|
80
|
+
* fixtures under the running project's `playwright/fixtures/` (every `*.json`,
|
|
81
|
+
* not just `auth.json`), located relative to the config's `rootDir`.
|
|
82
|
+
* `process.cwd()` is NOT used as the base: when the `e2e` target runs
|
|
83
|
+
* `playwright test --config=<project>`, cwd is the nx workspace root, not the
|
|
84
|
+
* project.
|
|
85
|
+
*/
|
|
86
|
+
const globalSetup = async (config) => {
|
|
87
|
+
const baseDir = config?.rootDir ?? process.cwd();
|
|
88
|
+
await (0, exports.injectAllE2ECredentials)({ fixturesDir: path.join(baseDir, "playwright", "fixtures") });
|
|
89
|
+
};
|
|
90
|
+
exports.default = globalSetup;
|
|
91
|
+
//# sourceMappingURL=globalSetup.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isRecord = void 0;
|
|
4
|
+
/** Narrows `unknown` to a plain object without a type assertion. */
|
|
5
|
+
const isRecord = (value) => typeof value === "object" && value !== null;
|
|
6
|
+
exports.isRecord = isRecord;
|
|
7
|
+
//# sourceMappingURL=guards.js.map
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential-key derivation for the manager E2E password lookup (GLU-1451 / GLU-1452).
|
|
3
|
+
*
|
|
4
|
+
* An `auth.json` fixture identifies its account by `username` only — the password
|
|
5
|
+
* is resolved at runtime by a stable key derived from the name: from a CircleCI
|
|
6
|
+
* env variable in CI and from Vault locally.
|
|
7
|
+
*
|
|
8
|
+
* The `E2E_PW_<SLUG>` convention MUST stay in sync with the publish side in
|
|
9
|
+
* `devtools/e2e-okta-rotation/slug.ts` (which writes the CircleCI/Vault values).
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Uppercase slug of an account name: non-alphanumeric runs collapse to a single
|
|
13
|
+
* `_`, with leading/trailing separators trimmed. Keeps account names with spaces
|
|
14
|
+
* and dashes valid as env-var/Vault-field keys.
|
|
15
|
+
*
|
|
16
|
+
* `Team Helios - E2E Test` -> `TEAM_HELIOS_E2E_TEST`
|
|
17
|
+
*/
|
|
18
|
+
export declare const slugifyUsername: (username: string) => string;
|
|
19
|
+
/** The environment-variable key for an account's password: `E2E_PW_<SLUG>`. */
|
|
20
|
+
export declare const deriveEnvKey: (username: string) => string;
|
|
21
|
+
/**
|
|
22
|
+
* The Vault field key for an account's password. This is the **same** key as the
|
|
23
|
+
* env variable (`E2E_PW_<SLUG>`): the publish side writes each account's password
|
|
24
|
+
* to a Vault field named by `devtools/e2e-okta-rotation/slug.ts`'s `envKey()`
|
|
25
|
+
* (`E2E_PW_<SLUG>`), so the resolver must read it back under that exact key. A
|
|
26
|
+
* lowercased-slug field would never be found once Vault is seeded.
|
|
27
|
+
*/
|
|
28
|
+
export declare const deriveVaultField: (username: string) => string;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Credential-key derivation for the manager E2E password lookup (GLU-1451 / GLU-1452).
|
|
4
|
+
*
|
|
5
|
+
* An `auth.json` fixture identifies its account by `username` only — the password
|
|
6
|
+
* is resolved at runtime by a stable key derived from the name: from a CircleCI
|
|
7
|
+
* env variable in CI and from Vault locally.
|
|
8
|
+
*
|
|
9
|
+
* The `E2E_PW_<SLUG>` convention MUST stay in sync with the publish side in
|
|
10
|
+
* `devtools/e2e-okta-rotation/slug.ts` (which writes the CircleCI/Vault values).
|
|
11
|
+
*/
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.deriveVaultField = exports.deriveEnvKey = exports.slugifyUsername = void 0;
|
|
14
|
+
/**
|
|
15
|
+
* Uppercase slug of an account name: non-alphanumeric runs collapse to a single
|
|
16
|
+
* `_`, with leading/trailing separators trimmed. Keeps account names with spaces
|
|
17
|
+
* and dashes valid as env-var/Vault-field keys.
|
|
18
|
+
*
|
|
19
|
+
* `Team Helios - E2E Test` -> `TEAM_HELIOS_E2E_TEST`
|
|
20
|
+
*/
|
|
21
|
+
const slugifyUsername = (username) => username
|
|
22
|
+
.toUpperCase()
|
|
23
|
+
.replace(/[^A-Z0-9]+/g, "_")
|
|
24
|
+
.replace(/^_+|_+$/g, "");
|
|
25
|
+
exports.slugifyUsername = slugifyUsername;
|
|
26
|
+
/** The environment-variable key for an account's password: `E2E_PW_<SLUG>`. */
|
|
27
|
+
const deriveEnvKey = (username) => `E2E_PW_${(0, exports.slugifyUsername)(username)}`;
|
|
28
|
+
exports.deriveEnvKey = deriveEnvKey;
|
|
29
|
+
/**
|
|
30
|
+
* The Vault field key for an account's password. This is the **same** key as the
|
|
31
|
+
* env variable (`E2E_PW_<SLUG>`): the publish side writes each account's password
|
|
32
|
+
* to a Vault field named by `devtools/e2e-okta-rotation/slug.ts`'s `envKey()`
|
|
33
|
+
* (`E2E_PW_<SLUG>`), so the resolver must read it back under that exact key. A
|
|
34
|
+
* lowercased-slug field would never be found once Vault is seeded.
|
|
35
|
+
*/
|
|
36
|
+
const deriveVaultField = (username) => (0, exports.deriveEnvKey)(username);
|
|
37
|
+
exports.deriveVaultField = deriveVaultField;
|
|
38
|
+
//# sourceMappingURL=keys.js.map
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export type VaultKvVersion = 1 | 2;
|
|
2
|
+
/**
|
|
3
|
+
* Minimal structural view of the `node-vault` client's KV read: it returns
|
|
4
|
+
* `{ data: ... }` (KV v1) or `{ data: { data: ... } }` (KV v2). Kept as an
|
|
5
|
+
* interface so tests inject a fake and neither `node-vault` nor a real
|
|
6
|
+
* Vault/VPN is needed. `read` returns `unknown` so callers narrow structurally
|
|
7
|
+
* (no `as` cast at the boundary).
|
|
8
|
+
*/
|
|
9
|
+
export interface VaultReader {
|
|
10
|
+
read(secretPath: string): Promise<unknown>;
|
|
11
|
+
}
|
|
12
|
+
export interface ResolveE2EPasswordOptions {
|
|
13
|
+
/** Process env to read (env-first precedence). Defaults to `process.env`. */
|
|
14
|
+
env?: NodeJS.ProcessEnv;
|
|
15
|
+
/**
|
|
16
|
+
* Vault client factory (local path). Returns `undefined` when Vault can't be
|
|
17
|
+
* reached/authenticated (e.g. no `~/.vault-token`). Defaults to a `node-vault`
|
|
18
|
+
* client using the local token, matching `libs/server/vault`.
|
|
19
|
+
*/
|
|
20
|
+
createVault?: (env: NodeJS.ProcessEnv) => Promise<VaultReader | undefined>;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Resolves an account's E2E password by name.
|
|
24
|
+
*
|
|
25
|
+
* Order: env variable `E2E_PW_<SLUG>` (CI path, never touches Vault) -> Vault
|
|
26
|
+
* field `<slug>` (local path). Throws a clear, account-named, redacted error on
|
|
27
|
+
* miss. The password is never included in any error/diagnostic (on failure there
|
|
28
|
+
* is no resolved value to leak).
|
|
29
|
+
*
|
|
30
|
+
* @param username - Account identifier from the fixture (`auth.json` `username`).
|
|
31
|
+
* @param options - Process env and Vault-client seams.
|
|
32
|
+
* @returns {Promise<string>} The resolved password.
|
|
33
|
+
* @throws when the fixture is a placeholder, or neither env nor Vault yields a value.
|
|
34
|
+
*/
|
|
35
|
+
export declare const resolveE2EPassword: (username: string, options?: ResolveE2EPasswordOptions) => Promise<string>;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.resolveE2EPassword = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const fs_1 = require("fs");
|
|
6
|
+
const os = tslib_1.__importStar(require("os"));
|
|
7
|
+
const path = tslib_1.__importStar(require("path"));
|
|
8
|
+
const redactSensitive_1 = require("../plugins/redactSensitive");
|
|
9
|
+
const guards_1 = require("./guards");
|
|
10
|
+
const keys_1 = require("./keys");
|
|
11
|
+
/** Logical KV path the publish side writes to (see devtools/e2e-okta-rotation). */
|
|
12
|
+
const DEFAULT_VAULT_PATH = "secret/manager/e2e-playwright";
|
|
13
|
+
/** KV v2 stores secrets at `mount/data/rest`; KV v1 uses the logical path verbatim. */
|
|
14
|
+
const toKvV2DataPath = (logicalPath) => {
|
|
15
|
+
const segments = logicalPath.replace(/^\/+|\/+$/g, "").split("/");
|
|
16
|
+
const [mount, ...rest] = segments;
|
|
17
|
+
return [mount, "data", ...rest].join("/");
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Reads a field from a node-vault KV read result. KV v2 nests the fields under
|
|
21
|
+
* `data.data`; KV v1 puts them directly on `data`. Narrows structurally.
|
|
22
|
+
*/
|
|
23
|
+
const readVaultField = (result, field) => {
|
|
24
|
+
if (!(0, guards_1.isRecord)(result)) {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
const outer = result.data;
|
|
28
|
+
if (!(0, guards_1.isRecord)(outer)) {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
const fields = (0, guards_1.isRecord)(outer.data) ? outer.data : outer;
|
|
32
|
+
const value = fields[field];
|
|
33
|
+
return typeof value === "string" && value !== "" ? value : undefined;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Default Vault client: reads the local `~/.vault-token` and points `node-vault`
|
|
37
|
+
* at the endpoint from `E2E_VAULT_ENDPOINT` (or `VAULT_ENDPOINT`).
|
|
38
|
+
*
|
|
39
|
+
* The Vault address is **never hardcoded** — it is supplied by the environment,
|
|
40
|
+
* so any consumer of this package can opt into their own Vault. `node-vault` is
|
|
41
|
+
* an optional peer dependency, imported lazily and only on the local path, so
|
|
42
|
+
* consumers that only use the env-var (CI) path never load it.
|
|
43
|
+
*/
|
|
44
|
+
const defaultCreateVault = async (env) => {
|
|
45
|
+
const home = env.HOME ?? os.homedir();
|
|
46
|
+
let token;
|
|
47
|
+
try {
|
|
48
|
+
token = (0, fs_1.readFileSync)(path.join(home, ".vault-token"), "utf-8").trim();
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return undefined; // not logged in / not on VPN
|
|
52
|
+
}
|
|
53
|
+
if (token === "") {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
const endpoint = env.E2E_VAULT_ENDPOINT ?? env.VAULT_ENDPOINT;
|
|
57
|
+
if (endpoint === undefined || endpoint === "") {
|
|
58
|
+
throw new Error("E2E credentials: a Vault token was found but no endpoint — set E2E_VAULT_ENDPOINT (or VAULT_ENDPOINT) to your Vault address.");
|
|
59
|
+
}
|
|
60
|
+
// Imported lazily so consumers on the env-var path (and unit tests, which
|
|
61
|
+
// inject `createVault`) never load the optional `node-vault` peer. Its absence
|
|
62
|
+
// is an expected configuration state, so fail with the resolver's actionable,
|
|
63
|
+
// redacted style instead of leaking a raw `ERR_MODULE_NOT_FOUND` stack.
|
|
64
|
+
const nodeVault = await Promise.resolve().then(() => tslib_1.__importStar(require("node-vault"))).catch(() => undefined);
|
|
65
|
+
if (nodeVault === undefined) {
|
|
66
|
+
return fail("E2E credentials: install the optional `node-vault` peer dependency for local Vault lookup, " +
|
|
67
|
+
"or set the E2E_PW_<SLUG> env variable.");
|
|
68
|
+
}
|
|
69
|
+
const client = nodeVault.default({ endpoint, token, requestOptions: { strictSSL: true } });
|
|
70
|
+
return { read: secretPath => client.read(secretPath) };
|
|
71
|
+
};
|
|
72
|
+
/** Throws an error whose message is routed through `redactSensitive` (defence in depth). */
|
|
73
|
+
const fail = (message) => {
|
|
74
|
+
throw new Error((0, redactSensitive_1.redactSensitive)(message));
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* Resolves an account's E2E password by name.
|
|
78
|
+
*
|
|
79
|
+
* Order: env variable `E2E_PW_<SLUG>` (CI path, never touches Vault) -> Vault
|
|
80
|
+
* field `<slug>` (local path). Throws a clear, account-named, redacted error on
|
|
81
|
+
* miss. The password is never included in any error/diagnostic (on failure there
|
|
82
|
+
* is no resolved value to leak).
|
|
83
|
+
*
|
|
84
|
+
* @param username - Account identifier from the fixture (`auth.json` `username`).
|
|
85
|
+
* @param options - Process env and Vault-client seams.
|
|
86
|
+
* @returns {Promise<string>} The resolved password.
|
|
87
|
+
* @throws when the fixture is a placeholder, or neither env nor Vault yields a value.
|
|
88
|
+
*/
|
|
89
|
+
const resolveE2EPassword = async (username, options = {}) => {
|
|
90
|
+
if (username === "" || username === "REPLACE_ME") {
|
|
91
|
+
return fail(`E2E credentials: fixture account name is unresolved ("${username}").`);
|
|
92
|
+
}
|
|
93
|
+
const env = options.env ?? process.env;
|
|
94
|
+
const key = (0, keys_1.deriveEnvKey)(username);
|
|
95
|
+
// CI path: an injected env variable wins and Vault is never contacted.
|
|
96
|
+
const fromEnv = env[key];
|
|
97
|
+
if (fromEnv !== undefined && fromEnv !== "") {
|
|
98
|
+
return fromEnv;
|
|
99
|
+
}
|
|
100
|
+
// Local path: read the password from Vault by the same key.
|
|
101
|
+
const createVault = options.createVault ?? defaultCreateVault;
|
|
102
|
+
const vault = await createVault(env);
|
|
103
|
+
if (vault === undefined) {
|
|
104
|
+
return fail(`E2E credentials for "${username}": no env variable (${key}) and Vault is unavailable ` +
|
|
105
|
+
`(not authenticated / not on VPN). Run \`vault login -method=oidc -path=okta\` on VPN, or set the env variable.`);
|
|
106
|
+
}
|
|
107
|
+
const kvVersion = env.E2E_VAULT_KV_VERSION === "1" ? 1 : 2;
|
|
108
|
+
const logicalPath = env.E2E_VAULT_PATH ?? DEFAULT_VAULT_PATH;
|
|
109
|
+
const readPath = kvVersion === 2 ? toKvV2DataPath(logicalPath) : logicalPath;
|
|
110
|
+
let result;
|
|
111
|
+
try {
|
|
112
|
+
result = await vault.read(readPath);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return fail(`E2E credentials for "${username}": Vault read of "${readPath}" failed.`);
|
|
116
|
+
}
|
|
117
|
+
const fromVault = readVaultField(result, (0, keys_1.deriveVaultField)(username));
|
|
118
|
+
if (fromVault === undefined) {
|
|
119
|
+
return fail(`E2E credentials for "${username}": no field "${(0, keys_1.deriveVaultField)(username)}" at Vault path "${readPath}".`);
|
|
120
|
+
}
|
|
121
|
+
return fromVault;
|
|
122
|
+
};
|
|
123
|
+
exports.resolveE2EPassword = resolveE2EPassword;
|
|
124
|
+
//# sourceMappingURL=resolveE2EPassword.js.map
|
|
@@ -8,7 +8,9 @@ export interface ResolveCredentialsOptions {
|
|
|
8
8
|
fixturePath?: string;
|
|
9
9
|
}
|
|
10
10
|
/**
|
|
11
|
-
* Resolves auth credentials from (in order): explicit options,
|
|
11
|
+
* Resolves auth credentials from (in order): explicit options, a full JSON
|
|
12
|
+
* fixture (`{ username, password }`), a **name-only** fixture whose password
|
|
13
|
+
* comes from its `E2E_PW_<SLUG>` env var, then `PLAYWRIGHT_USERNAME`/`PLAYWRIGHT_PASSWORD`.
|
|
12
14
|
* Throws if none yield a complete credential pair.
|
|
13
15
|
*/
|
|
14
16
|
export declare const resolveCredentials: (options?: ResolveCredentialsOptions) => AuthCredentials;
|
|
@@ -4,6 +4,7 @@ exports.resolveCredentials = void 0;
|
|
|
4
4
|
const tslib_1 = require("tslib");
|
|
5
5
|
const fs_1 = require("fs");
|
|
6
6
|
const path = tslib_1.__importStar(require("path"));
|
|
7
|
+
const keys_1 = require("../credentials/keys");
|
|
7
8
|
const isAuthCredentials = (value) => {
|
|
8
9
|
if (typeof value !== "object" || value === null) {
|
|
9
10
|
return false;
|
|
@@ -25,7 +26,38 @@ const readFixtureCredentials = (fixturePath) => {
|
|
|
25
26
|
return parsed;
|
|
26
27
|
};
|
|
27
28
|
/**
|
|
28
|
-
*
|
|
29
|
+
* Reads a fixture's `username` when present and usable (non-blank, not the
|
|
30
|
+
* `REPLACE_ME` placeholder), even if the fixture has no `password` — i.e. a
|
|
31
|
+
* name-only fixture, which is what remains once passwords leave the repo.
|
|
32
|
+
*/
|
|
33
|
+
const readFixtureUsername = (fixturePath) => {
|
|
34
|
+
if (!(0, fs_1.existsSync)(fixturePath)) {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
const parsed = JSON.parse((0, fs_1.readFileSync)(fixturePath, "utf-8"));
|
|
38
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
const record = { ...parsed };
|
|
42
|
+
const username = record.username;
|
|
43
|
+
if (typeof username !== "string" || username === "" || username === "REPLACE_ME") {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
return username;
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Resolves the password for a name-only fixture from the per-account env var set
|
|
50
|
+
* by the credential `globalSetup` (locally, from Vault) or by the CircleCI
|
|
51
|
+
* context (CI): `E2E_PW_<SLUG>`. Returns `undefined` when it is not set.
|
|
52
|
+
*/
|
|
53
|
+
const resolvePasswordFromEnv = (username) => {
|
|
54
|
+
const password = process.env[(0, keys_1.deriveEnvKey)(username)];
|
|
55
|
+
return password !== undefined && password !== "" ? password : undefined;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Resolves auth credentials from (in order): explicit options, a full JSON
|
|
59
|
+
* fixture (`{ username, password }`), a **name-only** fixture whose password
|
|
60
|
+
* comes from its `E2E_PW_<SLUG>` env var, then `PLAYWRIGHT_USERNAME`/`PLAYWRIGHT_PASSWORD`.
|
|
29
61
|
* Throws if none yield a complete credential pair.
|
|
30
62
|
*/
|
|
31
63
|
const resolveCredentials = (options) => {
|
|
@@ -37,13 +69,22 @@ const resolveCredentials = (options) => {
|
|
|
37
69
|
if (fromFixture !== undefined) {
|
|
38
70
|
return fromFixture;
|
|
39
71
|
}
|
|
72
|
+
// Name-only fixture: the password lives in the per-account env var.
|
|
73
|
+
const username = readFixtureUsername(fixturePath);
|
|
74
|
+
if (username !== undefined) {
|
|
75
|
+
const password = resolvePasswordFromEnv(username);
|
|
76
|
+
if (password !== undefined) {
|
|
77
|
+
return { username, password };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
40
80
|
const envUsername = process.env.PLAYWRIGHT_USERNAME;
|
|
41
81
|
const envPassword = process.env.PLAYWRIGHT_PASSWORD;
|
|
42
82
|
if (envUsername !== undefined && envUsername !== "" && envPassword !== undefined && envPassword !== "") {
|
|
43
83
|
return { username: envUsername, password: envPassword };
|
|
44
84
|
}
|
|
45
|
-
throw new Error(`No credentials provided to login(). Tried: options.credentials, ${fixturePath}
|
|
46
|
-
`
|
|
85
|
+
throw new Error(`No credentials provided to login(). Tried: options.credentials, ${fixturePath} ` +
|
|
86
|
+
`(incl. its E2E_PW_<slug> env var for a name-only fixture), PLAYWRIGHT_USERNAME/PLAYWRIGHT_PASSWORD env vars. ` +
|
|
87
|
+
`Fill in the fixture, set the env vars, or pass credentials to login().`);
|
|
47
88
|
};
|
|
48
89
|
exports.resolveCredentials = resolveCredentials;
|
|
49
90
|
//# sourceMappingURL=resolveCredentials.js.map
|
package/src/index.d.ts
CHANGED
|
@@ -8,6 +8,11 @@ export * from "./plugins/writeFileWithPrettier";
|
|
|
8
8
|
export * from "./utils/Codeowner";
|
|
9
9
|
export * from "./utils/fileNameBuilder";
|
|
10
10
|
export * from "./utils/fileUpdater";
|
|
11
|
+
export * from "./credentials/fixtureAccounts";
|
|
12
|
+
export * from "./credentials/globalSetup";
|
|
13
|
+
export { default as e2eCredentialsGlobalSetup } from "./credentials/globalSetup";
|
|
14
|
+
export * from "./credentials/keys";
|
|
15
|
+
export * from "./credentials/resolveE2EPassword";
|
|
11
16
|
export type { FeatureFlag, FeatureFlagFixtures } from "./fixtures/featureFlags.fixture";
|
|
12
17
|
export type { CachedStorageState } from "./fixtures/hydrateStorageState";
|
|
13
18
|
export type { IrisAppFixtures, IrisAppOptions, StorybookPreviewOptions } from "./fixtures/irisApp.fixture";
|
package/src/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.e2eCredentialsGlobalSetup = void 0;
|
|
3
4
|
const tslib_1 = require("tslib");
|
|
4
5
|
tslib_1.__exportStar(require("./plugins/createLogFile"), exports);
|
|
5
6
|
tslib_1.__exportStar(require("./plugins/defaultPlaywrightConfig"), exports);
|
|
@@ -11,4 +12,14 @@ tslib_1.__exportStar(require("./plugins/writeFileWithPrettier"), exports);
|
|
|
11
12
|
tslib_1.__exportStar(require("./utils/Codeowner"), exports);
|
|
12
13
|
tslib_1.__exportStar(require("./utils/fileNameBuilder"), exports);
|
|
13
14
|
tslib_1.__exportStar(require("./utils/fileUpdater"), exports);
|
|
15
|
+
// Opt-in E2E credential resolution: resolve an account's password at runtime by
|
|
16
|
+
// name (env-var first, then Vault). Vault access uses the optional `node-vault`
|
|
17
|
+
// peer and an env-supplied endpoint — never hardcoded — so any consumer can wire
|
|
18
|
+
// their own Vault. See ./credentials.
|
|
19
|
+
tslib_1.__exportStar(require("./credentials/fixtureAccounts"), exports);
|
|
20
|
+
tslib_1.__exportStar(require("./credentials/globalSetup"), exports);
|
|
21
|
+
var globalSetup_1 = require("./credentials/globalSetup");
|
|
22
|
+
Object.defineProperty(exports, "e2eCredentialsGlobalSetup", { enumerable: true, get: function () { return tslib_1.__importDefault(globalSetup_1).default; } });
|
|
23
|
+
tslib_1.__exportStar(require("./credentials/keys"), exports);
|
|
24
|
+
tslib_1.__exportStar(require("./credentials/resolveE2EPassword"), exports);
|
|
14
25
|
//# sourceMappingURL=index.js.map
|
|
@@ -42,6 +42,19 @@ export declare class LogsReporter implements Reporter {
|
|
|
42
42
|
onEnd(_result: FullResult): Promise<void>;
|
|
43
43
|
/** Returns false so Playwright adds its own terminal reporter alongside this one. */
|
|
44
44
|
printsToStdio(): boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Scrubs Playwright's `error-context.md` failure attachment in place.
|
|
47
|
+
*
|
|
48
|
+
* On failure Playwright writes an `error-context.md` under `test-results/`
|
|
49
|
+
* (uploaded as a CI artifact) that embeds the failing assertion's *received*
|
|
50
|
+
* value and a page/aria snapshot. The `login()` slow path navigates to
|
|
51
|
+
* `/auth/manager-classic#session_token=...`, so this file carries a live Okta
|
|
52
|
+
* session token — and would carry the password if it ever surfaced in an
|
|
53
|
+
* assertion's received value. It is neither a trace, HAR, nor a stdout log, so
|
|
54
|
+
* the other redaction passes miss it entirely; scrub it here. Best-effort:
|
|
55
|
+
* redaction never fails the reporter.
|
|
56
|
+
*/
|
|
57
|
+
private redactErrorContext;
|
|
45
58
|
private getResultTracePath;
|
|
46
59
|
/**
|
|
47
60
|
* Persists (on failure) or cleans up (always) the per-test HAR recorded by the
|
|
@@ -66,6 +66,7 @@ class LogsReporter {
|
|
|
66
66
|
if (tracePath !== undefined && (0, fs_1.existsSync)(tracePath)) {
|
|
67
67
|
this.pendingWrites.push((0, redactTrace_1.redactTraceFile)(tracePath));
|
|
68
68
|
}
|
|
69
|
+
this.redactErrorContext(result);
|
|
69
70
|
}
|
|
70
71
|
this.persistHar(fullTitle, test, result, isFailure);
|
|
71
72
|
}
|
|
@@ -82,6 +83,32 @@ class LogsReporter {
|
|
|
82
83
|
printsToStdio() {
|
|
83
84
|
return false;
|
|
84
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Scrubs Playwright's `error-context.md` failure attachment in place.
|
|
88
|
+
*
|
|
89
|
+
* On failure Playwright writes an `error-context.md` under `test-results/`
|
|
90
|
+
* (uploaded as a CI artifact) that embeds the failing assertion's *received*
|
|
91
|
+
* value and a page/aria snapshot. The `login()` slow path navigates to
|
|
92
|
+
* `/auth/manager-classic#session_token=...`, so this file carries a live Okta
|
|
93
|
+
* session token — and would carry the password if it ever surfaced in an
|
|
94
|
+
* assertion's received value. It is neither a trace, HAR, nor a stdout log, so
|
|
95
|
+
* the other redaction passes miss it entirely; scrub it here. Best-effort:
|
|
96
|
+
* redaction never fails the reporter.
|
|
97
|
+
*/
|
|
98
|
+
redactErrorContext(result) {
|
|
99
|
+
for (const attachment of result.attachments) {
|
|
100
|
+
const attachmentPath = attachment.path;
|
|
101
|
+
if (attachmentPath === undefined || !attachmentPath.endsWith("error-context.md") || !(0, fs_1.existsSync)(attachmentPath)) {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
(0, fs_1.writeFileSync)(attachmentPath, (0, redactSensitive_1.redactSensitive)((0, fs_1.readFileSync)(attachmentPath, "utf-8")));
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
/* Redaction is best-effort; never fail the reporter over it. */
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
85
112
|
getResultTracePath(result) {
|
|
86
113
|
const attachment = result.attachments.find(a => a.name === "trace" && a.path !== undefined);
|
|
87
114
|
return attachment?.path;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redaction/detection primitives shared by the redactor (`redactSensitive`) and
|
|
3
|
+
* the structural scanner (`scanArtifactsForUnredactedSecrets`) so the two
|
|
4
|
+
* recognise the same shapes and cannot drift. In particular, browser-cookie
|
|
5
|
+
* detection is centralised here: it must be property-order independent (a
|
|
6
|
+
* value-last cookie is still a cookie) and must not misfire on ordinary
|
|
7
|
+
* `{ name, value, path }` structures.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Matches any run of backslashes preceding a quote. A JSON string nested inside
|
|
11
|
+
* another escapes its quotes once per level of nesting (`"` → `\"` → `\\\"` →
|
|
12
|
+
* …), so the escape prefix grows without bound; matching an arbitrary run makes
|
|
13
|
+
* the patterns escape-depth agnostic.
|
|
14
|
+
*/
|
|
15
|
+
export declare const ESC = "(?:\\\\)*";
|
|
16
|
+
/** A JSON double-quote at any escape depth. */
|
|
17
|
+
export declare const QUOTE = "(?:\\\\)*\"";
|
|
18
|
+
/** The marker a redacted value is reduced to. */
|
|
19
|
+
export declare const REDACTED = "***";
|
|
20
|
+
/** Redacts the `value` of every browser-cookie object found in `text`. */
|
|
21
|
+
export declare const redactCookieValues: (text: string) => string;
|
|
22
|
+
/**
|
|
23
|
+
* The `value` of every browser-cookie object in `text` (after any redaction has
|
|
24
|
+
* run). The structural scanner treats a cookie whose value is not `REDACTED` as
|
|
25
|
+
* a leak.
|
|
26
|
+
*
|
|
27
|
+
* @param text - Text to inspect (a trace stream, resource body, or plain file).
|
|
28
|
+
* @returns {Array<string>} One entry per cookie `value` found.
|
|
29
|
+
*/
|
|
30
|
+
export declare const cookieValues: (text: string) => Array<string>;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Redaction/detection primitives shared by the redactor (`redactSensitive`) and
|
|
4
|
+
* the structural scanner (`scanArtifactsForUnredactedSecrets`) so the two
|
|
5
|
+
* recognise the same shapes and cannot drift. In particular, browser-cookie
|
|
6
|
+
* detection is centralised here: it must be property-order independent (a
|
|
7
|
+
* value-last cookie is still a cookie) and must not misfire on ordinary
|
|
8
|
+
* `{ name, value, path }` structures.
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.cookieValues = exports.redactCookieValues = exports.REDACTED = exports.QUOTE = exports.ESC = void 0;
|
|
12
|
+
/**
|
|
13
|
+
* Matches any run of backslashes preceding a quote. A JSON string nested inside
|
|
14
|
+
* another escapes its quotes once per level of nesting (`"` → `\"` → `\\\"` →
|
|
15
|
+
* …), so the escape prefix grows without bound; matching an arbitrary run makes
|
|
16
|
+
* the patterns escape-depth agnostic.
|
|
17
|
+
*/
|
|
18
|
+
exports.ESC = "(?:\\\\)*";
|
|
19
|
+
/** A JSON double-quote at any escape depth. */
|
|
20
|
+
exports.QUOTE = `${exports.ESC}"`;
|
|
21
|
+
/** The marker a redacted value is reduced to. */
|
|
22
|
+
exports.REDACTED = "***";
|
|
23
|
+
/**
|
|
24
|
+
* Strong, cookie-specific marker keys. A Playwright `storageState` cookie always
|
|
25
|
+
* carries these; generic keys like `path`/`expires` are deliberately excluded so
|
|
26
|
+
* an ordinary `{ name, value, path }` object is not mistaken for a cookie
|
|
27
|
+
* (which would redact useful debugging data and produce false positives).
|
|
28
|
+
*/
|
|
29
|
+
const COOKIE_MARKERS = ["domain", "httpOnly", "sameSite", "secure"];
|
|
30
|
+
/**
|
|
31
|
+
* Matches a flat JSON object literal (no nested braces) — one `addCookies`
|
|
32
|
+
* entry. JSON only escapes `"` and `\`, never braces, so this still isolates a
|
|
33
|
+
* cookie object at any escape depth.
|
|
34
|
+
*/
|
|
35
|
+
const flatObjectPattern = () => /\{[^{}]*\}/g;
|
|
36
|
+
const valueFieldPattern = new RegExp(`${exports.QUOTE}value${exports.QUOTE}\\s*:\\s*${exports.QUOTE}`, "i");
|
|
37
|
+
const cookieMarkerPattern = new RegExp(`${exports.QUOTE}(?:${COOKIE_MARKERS.join("|")})${exports.QUOTE}\\s*:`, "i");
|
|
38
|
+
/** Captures the `value` string of an object (group 2), for redaction or inspection. */
|
|
39
|
+
const cookieValuePattern = () => new RegExp(`(${exports.QUOTE}value${exports.QUOTE}\\s*:\\s*${exports.QUOTE})(.*?)(${exports.QUOTE})`, "gi");
|
|
40
|
+
/**
|
|
41
|
+
* A flat JSON object is treated as a browser cookie when it carries a `value`
|
|
42
|
+
* field together with a strong cookie marker — evaluated over the whole object,
|
|
43
|
+
* so property order does not matter and a value-last cookie is still detected.
|
|
44
|
+
*/
|
|
45
|
+
const isCookieObject = (flatObject) => valueFieldPattern.test(flatObject) && cookieMarkerPattern.test(flatObject);
|
|
46
|
+
/** Redacts the `value` of every browser-cookie object found in `text`. */
|
|
47
|
+
const redactCookieValues = (text) => text.replace(flatObjectPattern(), flatObject => isCookieObject(flatObject) ? flatObject.replace(cookieValuePattern(), `$1${exports.REDACTED}$3`) : flatObject);
|
|
48
|
+
exports.redactCookieValues = redactCookieValues;
|
|
49
|
+
/**
|
|
50
|
+
* The `value` of every browser-cookie object in `text` (after any redaction has
|
|
51
|
+
* run). The structural scanner treats a cookie whose value is not `REDACTED` as
|
|
52
|
+
* a leak.
|
|
53
|
+
*
|
|
54
|
+
* @param text - Text to inspect (a trace stream, resource body, or plain file).
|
|
55
|
+
* @returns {Array<string>} One entry per cookie `value` found.
|
|
56
|
+
*/
|
|
57
|
+
const cookieValues = (text) => {
|
|
58
|
+
const values = [];
|
|
59
|
+
for (const objectMatch of text.matchAll(flatObjectPattern())) {
|
|
60
|
+
const flatObject = objectMatch[0];
|
|
61
|
+
if (!isCookieObject(flatObject)) {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
for (const valueMatch of flatObject.matchAll(cookieValuePattern())) {
|
|
65
|
+
values.push(valueMatch[2] ?? "");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return values;
|
|
69
|
+
};
|
|
70
|
+
exports.cookieValues = cookieValues;
|
|
71
|
+
//# sourceMappingURL=redactPatterns.js.map
|
|
@@ -15,13 +15,16 @@ export declare const SENSITIVE_KEYS: string[];
|
|
|
15
15
|
export declare const SENSITIVE_HEADERS: string[];
|
|
16
16
|
/**
|
|
17
17
|
* Redacts sensitive key-value pairs from a string in JSON, escaped-JSON,
|
|
18
|
-
* URL-encoded, and `Header: value` formats.
|
|
18
|
+
* URL-encoded, cookie, and `Header: value` formats.
|
|
19
19
|
*
|
|
20
20
|
* Replaces:
|
|
21
|
-
* - `"key": "<anything>"` → `"key": "***"` (case-insensitive)
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
21
|
+
* - `"key": "<anything>"` → `"key": "***"` (case-insensitive), at any JSON
|
|
22
|
+
* escape depth — `"key":"v"`, `\"key\":\"v\"`, `\\\"key\\\":\\\"v\\\"`, … —
|
|
23
|
+
* which covers request/response bodies and doubly-nested localStorage values
|
|
24
|
+
* stored in Playwright trace entries.
|
|
25
|
+
* - `"value": "<anything>"` → `"value": "***"` when the enclosing object is a
|
|
26
|
+
* browser cookie (see `redactCookieValues` — property-order independent),
|
|
27
|
+
* e.g. `addCookies` params carrying an Okta session cookie.
|
|
25
28
|
* - `key=<anything>` → `key=***` (URL-encoded, value terminates at `&`/whitespace/`"`)
|
|
26
29
|
* - `Header-Name: <rest-of-line>` → `Header-Name: ***` (case-insensitive, line-scoped)
|
|
27
30
|
*
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.redactSensitive = exports.SENSITIVE_HEADERS = exports.SENSITIVE_KEYS = void 0;
|
|
4
|
+
const redactPatterns_1 = require("./redactPatterns");
|
|
4
5
|
/**
|
|
5
6
|
* Keys whose values must be redacted when they appear as JSON properties
|
|
6
7
|
* (`"key": "value"`) or URL-encoded params (`key=value`). Both camelCase and
|
|
@@ -38,13 +39,16 @@ exports.SENSITIVE_HEADERS = [
|
|
|
38
39
|
];
|
|
39
40
|
/**
|
|
40
41
|
* Redacts sensitive key-value pairs from a string in JSON, escaped-JSON,
|
|
41
|
-
* URL-encoded, and `Header: value` formats.
|
|
42
|
+
* URL-encoded, cookie, and `Header: value` formats.
|
|
42
43
|
*
|
|
43
44
|
* Replaces:
|
|
44
|
-
* - `"key": "<anything>"` → `"key": "***"` (case-insensitive)
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
45
|
+
* - `"key": "<anything>"` → `"key": "***"` (case-insensitive), at any JSON
|
|
46
|
+
* escape depth — `"key":"v"`, `\"key\":\"v\"`, `\\\"key\\\":\\\"v\\\"`, … —
|
|
47
|
+
* which covers request/response bodies and doubly-nested localStorage values
|
|
48
|
+
* stored in Playwright trace entries.
|
|
49
|
+
* - `"value": "<anything>"` → `"value": "***"` when the enclosing object is a
|
|
50
|
+
* browser cookie (see `redactCookieValues` — property-order independent),
|
|
51
|
+
* e.g. `addCookies` params carrying an Okta session cookie.
|
|
48
52
|
* - `key=<anything>` → `key=***` (URL-encoded, value terminates at `&`/whitespace/`"`)
|
|
49
53
|
* - `Header-Name: <rest-of-line>` → `Header-Name: ***` (case-insensitive, line-scoped)
|
|
50
54
|
*
|
|
@@ -54,15 +58,16 @@ exports.SENSITIVE_HEADERS = [
|
|
|
54
58
|
const redactSensitive = (message) => {
|
|
55
59
|
let redacted = message;
|
|
56
60
|
for (const key of exports.SENSITIVE_KEYS) {
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
// object is serialized as a string field of another JSON object, which
|
|
61
|
-
// is exactly what Playwright traces store for request/response bodies.
|
|
62
|
-
redacted = redacted.replace(new RegExp(`(\\\\"${key}\\\\"\\s*:\\s*)\\\\"[^"\\\\]*\\\\"`, "gi"), `$1\\"***\\"`);
|
|
61
|
+
// JSON string value at any escape depth: "key":"v" / \"key\":\"v\" / … .
|
|
62
|
+
// The value is matched lazily up to the next (equally escaped) quote.
|
|
63
|
+
redacted = redacted.replace(new RegExp(`(${redactPatterns_1.QUOTE}${key}${redactPatterns_1.QUOTE}\\s*:\\s*${redactPatterns_1.QUOTE}).*?(${redactPatterns_1.QUOTE})`, "gi"), "$1***$2");
|
|
63
64
|
// URL-encoded: key=value (value terminates at &, whitespace, or quote)
|
|
64
65
|
redacted = redacted.replace(new RegExp(`(${key}=)[^&\\s"]+`, "gi"), "$1***");
|
|
65
66
|
}
|
|
67
|
+
// Cookie value under the generic `value` key (the cached Okta session cookie
|
|
68
|
+
// replayed by `addCookies`). Handled via the shared, order-independent
|
|
69
|
+
// cookie-shape helper so redaction and the structural scanner cannot drift.
|
|
70
|
+
redacted = (0, redactPatterns_1.redactCookieValues)(redacted);
|
|
66
71
|
for (const header of exports.SENSITIVE_HEADERS) {
|
|
67
72
|
redacted = redacted.replace(new RegExp(`(^|[\\r\\n])(${header}\\s*:\\s*)[^\\r\\n]*`, "gim"), "$1$2***");
|
|
68
73
|
}
|
|
@@ -1,12 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Rewrites a Playwright trace.zip in place with sensitive values stripped
|
|
3
|
-
* the action/network metadata. The `login()` fixture issues a `page.goto`
|
|
4
|
-
* against `/auth/manager-classic#session_token=...`; Playwright records the
|
|
5
|
-
* full URL as the action argument, so without this pass a retained trace zip
|
|
6
|
-
* would carry a live Okta session token alongside the test artifacts.
|
|
2
|
+
* Rewrites a Playwright trace.zip in place with sensitive values stripped.
|
|
7
3
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
4
|
+
* Two surfaces carry secrets in a retained trace:
|
|
5
|
+
*
|
|
6
|
+
* 1. The `.trace`/`.network`/`.stacks` JSONL streams — e.g. the `page.goto`
|
|
7
|
+
* against `/auth/manager-classic#session_token=...` records the full URL as
|
|
8
|
+
* the action argument, and API-call params are inlined as `jsonData`. The
|
|
9
|
+
* `login()` cache-hit path also replays the cached storageState here via
|
|
10
|
+
* `addCookies` (the Okta session cookie, under the generic `value` key) and
|
|
11
|
+
* `addInitScript` (the `okta-token-storage` access/id/refresh tokens, nested
|
|
12
|
+
* two escape levels deep) — both handled by `redactSensitive`'s cookie and
|
|
13
|
+
* escape-depth-agnostic rules.
|
|
14
|
+
* 2. The hashed `resources/<sha1>` entries — Playwright stores network
|
|
15
|
+
* request/response *bodies* here. The `login()` slow path POSTs the account
|
|
16
|
+
* password to `/api/v1/authn`, so that body — carrying the cleartext
|
|
17
|
+
* password — is retained as a resource and rendered verbatim in the Trace
|
|
18
|
+
* Viewer's request panel. Redacting only the streams (the previous behaviour)
|
|
19
|
+
* left this copy readable.
|
|
20
|
+
*
|
|
21
|
+
* Streams are always redacted as text. Resource entries are redacted only when
|
|
22
|
+
* they are lossless UTF-8 text (see `asLosslessUtf8`), so binary resources
|
|
23
|
+
* (screenshots, fonts) are never touched. Entries with no sensitive substring
|
|
24
|
+
* are left as-is to avoid needless rewrites.
|
|
11
25
|
*/
|
|
12
26
|
export declare const redactTraceFile: (filePath: string) => Promise<void>;
|
|
@@ -8,23 +8,73 @@ const redactSensitive_1 = require("./redactSensitive");
|
|
|
8
8
|
/**
|
|
9
9
|
* File suffixes inside a Playwright trace.zip whose contents are JSON Lines
|
|
10
10
|
* with action/network metadata — i.e. the places where a `page.goto(url)`
|
|
11
|
-
* call's URL argument is recorded verbatim.
|
|
12
|
-
* screenshots, network bodies under `sha1/`) are left alone; their content is
|
|
13
|
-
* referenced by hash so redacting the metadata is sufficient to suppress the
|
|
14
|
-
* sensitive string from any human-readable trace view.
|
|
11
|
+
* call's URL argument is recorded verbatim.
|
|
15
12
|
*/
|
|
16
13
|
const TEXT_TRACE_SUFFIXES = [".trace", ".network", ".stacks"];
|
|
17
14
|
const isTextTraceEntry = (relPath) => TEXT_TRACE_SUFFIXES.some(s => relPath.endsWith(s));
|
|
18
15
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
16
|
+
* Lower-cased substrings whose presence is a necessary precondition for
|
|
17
|
+
* `redactSensitive` to alter a buffer: it only rewrites JSON/URL matches keyed
|
|
18
|
+
* on a `SENSITIVE_KEYS`/`SENSITIVE_HEADERS` name, or a cookie `value`. Keep this
|
|
19
|
+
* a superset of `redactSensitive`'s triggers so the prefilter never skips an
|
|
20
|
+
* entry it would change.
|
|
21
|
+
*/
|
|
22
|
+
const SENSITIVE_MARKERS = [
|
|
23
|
+
...new Set([...redactSensitive_1.SENSITIVE_KEYS, ...redactSensitive_1.SENSITIVE_HEADERS, "value"].map(s => s.toLowerCase())),
|
|
24
|
+
];
|
|
25
|
+
/**
|
|
26
|
+
* True when `buf` may contain a value `redactSensitive` would redact, decided by
|
|
27
|
+
* a case-insensitive byte scan — no UTF-8 decode. A `false` result guarantees
|
|
28
|
+
* redaction is a no-op, letting the caller skip the decode/round-trip on the
|
|
29
|
+
* binary bulk of a trace (screencast frames, screenshots, fonts) that dominates
|
|
30
|
+
* a real `trace.zip`, with byte-identical output.
|
|
31
|
+
*/
|
|
32
|
+
const mightContainSensitive = (buf) => {
|
|
33
|
+
const lower = Buffer.from(buf);
|
|
34
|
+
for (let i = 0; i < lower.length; i++) {
|
|
35
|
+
const byte = lower[i];
|
|
36
|
+
if (byte !== undefined && byte >= 0x41 && byte <= 0x5a) {
|
|
37
|
+
lower[i] = byte + 0x20; // ASCII upper-case → lower-case
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return SENSITIVE_MARKERS.some(marker => lower.includes(marker));
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Returns the UTF-8 decoding of `buf` only when it round-trips losslessly (the
|
|
44
|
+
* re-encoded text is byte-identical to the input). This is how we tell a textual
|
|
45
|
+
* resource body (a network request/response the Trace Viewer renders) from a
|
|
46
|
+
* binary one (screenshot, font): a JPEG/PNG/woff decodes to replacement chars
|
|
47
|
+
* and fails the round-trip, so we never re-encode — and therefore never corrupt —
|
|
48
|
+
* a binary resource. Returns `undefined` for binary content.
|
|
49
|
+
*/
|
|
50
|
+
const asLosslessUtf8 = (buf) => {
|
|
51
|
+
const text = buf.toString("utf8");
|
|
52
|
+
return Buffer.from(text, "utf8").equals(buf) ? text : undefined;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Rewrites a Playwright trace.zip in place with sensitive values stripped.
|
|
56
|
+
*
|
|
57
|
+
* Two surfaces carry secrets in a retained trace:
|
|
24
58
|
*
|
|
25
|
-
* The
|
|
26
|
-
*
|
|
27
|
-
*
|
|
59
|
+
* 1. The `.trace`/`.network`/`.stacks` JSONL streams — e.g. the `page.goto`
|
|
60
|
+
* against `/auth/manager-classic#session_token=...` records the full URL as
|
|
61
|
+
* the action argument, and API-call params are inlined as `jsonData`. The
|
|
62
|
+
* `login()` cache-hit path also replays the cached storageState here via
|
|
63
|
+
* `addCookies` (the Okta session cookie, under the generic `value` key) and
|
|
64
|
+
* `addInitScript` (the `okta-token-storage` access/id/refresh tokens, nested
|
|
65
|
+
* two escape levels deep) — both handled by `redactSensitive`'s cookie and
|
|
66
|
+
* escape-depth-agnostic rules.
|
|
67
|
+
* 2. The hashed `resources/<sha1>` entries — Playwright stores network
|
|
68
|
+
* request/response *bodies* here. The `login()` slow path POSTs the account
|
|
69
|
+
* password to `/api/v1/authn`, so that body — carrying the cleartext
|
|
70
|
+
* password — is retained as a resource and rendered verbatim in the Trace
|
|
71
|
+
* Viewer's request panel. Redacting only the streams (the previous behaviour)
|
|
72
|
+
* left this copy readable.
|
|
73
|
+
*
|
|
74
|
+
* Streams are always redacted as text. Resource entries are redacted only when
|
|
75
|
+
* they are lossless UTF-8 text (see `asLosslessUtf8`), so binary resources
|
|
76
|
+
* (screenshots, fonts) are never touched. Entries with no sensitive substring
|
|
77
|
+
* are left as-is to avoid needless rewrites.
|
|
28
78
|
*/
|
|
29
79
|
const redactTraceFile = async (filePath) => {
|
|
30
80
|
const buf = (0, fs_1.readFileSync)(filePath);
|
|
@@ -33,11 +83,26 @@ const redactTraceFile = async (filePath) => {
|
|
|
33
83
|
zip.forEach((relPath, file) => {
|
|
34
84
|
if (file.dir)
|
|
35
85
|
return;
|
|
36
|
-
if (!isTextTraceEntry(relPath))
|
|
37
|
-
return;
|
|
38
86
|
rewrites.push((async () => {
|
|
39
|
-
|
|
40
|
-
|
|
87
|
+
if (isTextTraceEntry(relPath)) {
|
|
88
|
+
const text = await file.async("text");
|
|
89
|
+
zip.file(relPath, (0, redactSensitive_1.redactSensitive)(text));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
// Any other entry is a resource. Redact it only when it is textual, and
|
|
93
|
+
// only when redaction actually changes it — binary bytes are preserved.
|
|
94
|
+
const bytes = await file.async("nodebuffer");
|
|
95
|
+
// Skip the UTF-8 round-trip for the binary bulk that cannot carry a
|
|
96
|
+
// redactable value; `redactSensitive` would be a no-op on it anyway.
|
|
97
|
+
if (!mightContainSensitive(bytes))
|
|
98
|
+
return;
|
|
99
|
+
const decoded = asLosslessUtf8(bytes);
|
|
100
|
+
if (decoded === undefined)
|
|
101
|
+
return;
|
|
102
|
+
const redacted = (0, redactSensitive_1.redactSensitive)(decoded);
|
|
103
|
+
if (redacted !== decoded) {
|
|
104
|
+
zip.file(relPath, redacted);
|
|
105
|
+
}
|
|
41
106
|
})());
|
|
42
107
|
});
|
|
43
108
|
await Promise.all(rewrites);
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A cleartext-secret occurrence found while scanning produced test artifacts.
|
|
3
|
+
* `entry` is set when the hit is inside a zip (e.g. a Playwright `trace.zip`
|
|
4
|
+
* resource body); otherwise the secret was found directly in `file`.
|
|
5
|
+
*/
|
|
6
|
+
export interface ArtifactSecretHit {
|
|
7
|
+
readonly file: string;
|
|
8
|
+
readonly entry?: string;
|
|
9
|
+
readonly secret: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Recursively scans a produced-artifacts directory for cleartext `secrets`,
|
|
13
|
+
* looking *inside* `trace.zip` archives (including their hashed `resources/*`
|
|
14
|
+
* network-body entries) as well as plain files (logs, HAR, `error-context.md`,
|
|
15
|
+
* screenshots, video). Intended as a regression guard: after redaction the
|
|
16
|
+
* returned list must be empty for both passing and failing runs.
|
|
17
|
+
*
|
|
18
|
+
* Only catches secrets known verbatim (e.g. the account password from config).
|
|
19
|
+
* For per-run-minted credentials whose value is not known in advance (the Okta
|
|
20
|
+
* session cookie, `okta-token-storage` tokens) use
|
|
21
|
+
* {@link scanArtifactsForUnredactedSecrets}.
|
|
22
|
+
*
|
|
23
|
+
* @param dir - Root artifacts directory to walk.
|
|
24
|
+
* @param secrets - Cleartext values that must not appear anywhere.
|
|
25
|
+
* @returns {Promise<Array<ArtifactSecretHit>>} Every occurrence found.
|
|
26
|
+
*/
|
|
27
|
+
export declare const scanArtifactsForSecrets: (dir: string, secrets: ReadonlyArray<string>) => Promise<Array<ArtifactSecretHit>>;
|
|
28
|
+
/**
|
|
29
|
+
* A *structural* leak: a value that should have been redacted (it sits under a
|
|
30
|
+
* sensitive JSON key, a URL-encoded sensitive param, or a browser cookie
|
|
31
|
+
* `value`) but is not `***`. Needs no verbatim secret, so it catches
|
|
32
|
+
* per-run-minted credentials whose value CI cannot know in advance.
|
|
33
|
+
*/
|
|
34
|
+
export interface StructuralSecretHit {
|
|
35
|
+
readonly file: string;
|
|
36
|
+
readonly entry?: string;
|
|
37
|
+
readonly key: string;
|
|
38
|
+
readonly kind: "json" | "url" | "cookie";
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Recursively scans a produced-artifacts directory for *structural* leaks —
|
|
42
|
+
* sensitive values that were not reduced to `***`. Unlike
|
|
43
|
+
* {@link scanArtifactsForSecrets} it needs no verbatim secret, so it catches the
|
|
44
|
+
* Okta session cookie and `okta-token-storage` tokens replayed on the `login()`
|
|
45
|
+
* cache-hit path, whose values are minted per run. Intended as a CI guard
|
|
46
|
+
* alongside the verbatim scan: the returned list must be empty after redaction.
|
|
47
|
+
*
|
|
48
|
+
* @param dir - Root artifacts directory to walk.
|
|
49
|
+
* @returns {Promise<Array<StructuralSecretHit>>} Every unredacted sensitive value found.
|
|
50
|
+
*/
|
|
51
|
+
export declare const scanArtifactsForUnredactedSecrets: (dir: string) => Promise<Array<StructuralSecretHit>>;
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.scanArtifactsForUnredactedSecrets = exports.scanArtifactsForSecrets = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const fs_1 = require("fs");
|
|
6
|
+
const jszip_1 = tslib_1.__importDefault(require("jszip"));
|
|
7
|
+
const path = tslib_1.__importStar(require("path"));
|
|
8
|
+
const redactPatterns_1 = require("./redactPatterns");
|
|
9
|
+
const redactSensitive_1 = require("./redactSensitive");
|
|
10
|
+
/**
|
|
11
|
+
* Searches `buf`'s raw bytes for each secret. `Buffer.prototype.includes`
|
|
12
|
+
* matches the UTF-8 bytes of the (ASCII) secret directly, so we never decode
|
|
13
|
+
* the buffer to a string: binary artifacts (video, screenshots) simply yield no
|
|
14
|
+
* hits, and an oversized artifact can't trip `Cannot create a string longer
|
|
15
|
+
* than 0x1fffffe8 characters` and abort the scan.
|
|
16
|
+
*/
|
|
17
|
+
const findSecrets = (buf, secrets) => secrets.filter(secret => secret.length > 0 && buf.includes(secret));
|
|
18
|
+
const scanZip = async (zipPath, secrets) => {
|
|
19
|
+
const zip = await jszip_1.default.loadAsync((0, fs_1.readFileSync)(zipPath));
|
|
20
|
+
const entries = [];
|
|
21
|
+
zip.forEach((relPath, file) => {
|
|
22
|
+
if (!file.dir) {
|
|
23
|
+
entries.push([relPath, file]);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
const hits = [];
|
|
27
|
+
for (const [relPath, file] of entries) {
|
|
28
|
+
for (const secret of findSecrets(await file.async("nodebuffer"), secrets)) {
|
|
29
|
+
hits.push({ file: zipPath, entry: relPath, secret });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return hits;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Recursively scans a produced-artifacts directory for cleartext `secrets`,
|
|
36
|
+
* looking *inside* `trace.zip` archives (including their hashed `resources/*`
|
|
37
|
+
* network-body entries) as well as plain files (logs, HAR, `error-context.md`,
|
|
38
|
+
* screenshots, video). Intended as a regression guard: after redaction the
|
|
39
|
+
* returned list must be empty for both passing and failing runs.
|
|
40
|
+
*
|
|
41
|
+
* Only catches secrets known verbatim (e.g. the account password from config).
|
|
42
|
+
* For per-run-minted credentials whose value is not known in advance (the Okta
|
|
43
|
+
* session cookie, `okta-token-storage` tokens) use
|
|
44
|
+
* {@link scanArtifactsForUnredactedSecrets}.
|
|
45
|
+
*
|
|
46
|
+
* @param dir - Root artifacts directory to walk.
|
|
47
|
+
* @param secrets - Cleartext values that must not appear anywhere.
|
|
48
|
+
* @returns {Promise<Array<ArtifactSecretHit>>} Every occurrence found.
|
|
49
|
+
*/
|
|
50
|
+
const scanArtifactsForSecrets = async (dir, secrets) => {
|
|
51
|
+
const hits = [];
|
|
52
|
+
const walk = async (current) => {
|
|
53
|
+
// `withFileTypes` gives the entry type without a per-entry `statSync` — and
|
|
54
|
+
// without following symlinks, so a dangling link can't throw and abort the
|
|
55
|
+
// walk. Only descend directories and read regular files.
|
|
56
|
+
for (const dirent of (0, fs_1.readdirSync)(current, { withFileTypes: true })) {
|
|
57
|
+
const full = path.join(current, dirent.name);
|
|
58
|
+
if (dirent.isDirectory()) {
|
|
59
|
+
await walk(full);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (!dirent.isFile()) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (full.endsWith(".zip")) {
|
|
66
|
+
hits.push(...(await scanZip(full, secrets)));
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
for (const secret of findSecrets((0, fs_1.readFileSync)(full), secrets)) {
|
|
70
|
+
hits.push({ file: full, secret });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
await walk(dir);
|
|
75
|
+
return hits;
|
|
76
|
+
};
|
|
77
|
+
exports.scanArtifactsForSecrets = scanArtifactsForSecrets;
|
|
78
|
+
/**
|
|
79
|
+
* Text artifacts that carry redactable structure (trace streams, `error-context`,
|
|
80
|
+
* HAR, logs) are small; media (video, screenshots) is large binary that would
|
|
81
|
+
* waste work and risk the `Cannot create a string longer than 0x1fffffe8
|
|
82
|
+
* characters` throw. Cap the bytes we decode for structural scanning.
|
|
83
|
+
*/
|
|
84
|
+
const MAX_STRUCTURAL_SCAN_BYTES = 32 * 1024 * 1024;
|
|
85
|
+
/**
|
|
86
|
+
* Decodes `buf` as UTF-8 only when it round-trips losslessly (so binary
|
|
87
|
+
* resources are skipped) and is within the size cap. Returns `undefined`
|
|
88
|
+
* otherwise.
|
|
89
|
+
*/
|
|
90
|
+
const asScannableText = (buf) => {
|
|
91
|
+
if (buf.length > MAX_STRUCTURAL_SCAN_BYTES) {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
const text = buf.toString("utf8");
|
|
95
|
+
return Buffer.from(text, "utf8").equals(buf) ? text : undefined;
|
|
96
|
+
};
|
|
97
|
+
const findStructuralLeaks = (text) => {
|
|
98
|
+
const leaks = [];
|
|
99
|
+
for (const key of redactSensitive_1.SENSITIVE_KEYS) {
|
|
100
|
+
for (const match of text.matchAll(new RegExp(`${redactPatterns_1.QUOTE}${key}${redactPatterns_1.QUOTE}\\s*:\\s*${redactPatterns_1.QUOTE}(.*?)${redactPatterns_1.QUOTE}`, "gi"))) {
|
|
101
|
+
if (match[1] !== redactPatterns_1.REDACTED) {
|
|
102
|
+
leaks.push({ key, kind: "json" });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
for (const match of text.matchAll(new RegExp(`${key}=([^&\\s"]+)`, "gi"))) {
|
|
106
|
+
if (match[1] !== redactPatterns_1.REDACTED) {
|
|
107
|
+
leaks.push({ key, kind: "url" });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// Cookie detection uses the same order-independent shape helper as redaction,
|
|
112
|
+
// so a value-last cookie cannot be leaked by the redactor yet reported clean.
|
|
113
|
+
for (const value of (0, redactPatterns_1.cookieValues)(text)) {
|
|
114
|
+
if (value !== redactPatterns_1.REDACTED) {
|
|
115
|
+
leaks.push({ key: "value", kind: "cookie" });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return leaks;
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* Recursively scans a produced-artifacts directory for *structural* leaks —
|
|
122
|
+
* sensitive values that were not reduced to `***`. Unlike
|
|
123
|
+
* {@link scanArtifactsForSecrets} it needs no verbatim secret, so it catches the
|
|
124
|
+
* Okta session cookie and `okta-token-storage` tokens replayed on the `login()`
|
|
125
|
+
* cache-hit path, whose values are minted per run. Intended as a CI guard
|
|
126
|
+
* alongside the verbatim scan: the returned list must be empty after redaction.
|
|
127
|
+
*
|
|
128
|
+
* @param dir - Root artifacts directory to walk.
|
|
129
|
+
* @returns {Promise<Array<StructuralSecretHit>>} Every unredacted sensitive value found.
|
|
130
|
+
*/
|
|
131
|
+
const scanArtifactsForUnredactedSecrets = async (dir) => {
|
|
132
|
+
const hits = [];
|
|
133
|
+
const scanZipStructural = async (zipPath) => {
|
|
134
|
+
const zip = await jszip_1.default.loadAsync((0, fs_1.readFileSync)(zipPath));
|
|
135
|
+
const entries = [];
|
|
136
|
+
zip.forEach((relPath, file) => {
|
|
137
|
+
if (!file.dir) {
|
|
138
|
+
entries.push([relPath, file]);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
for (const [relPath, file] of entries) {
|
|
142
|
+
const text = asScannableText(await file.async("nodebuffer"));
|
|
143
|
+
if (text === undefined) {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
for (const leak of findStructuralLeaks(text)) {
|
|
147
|
+
hits.push({ file: zipPath, entry: relPath, ...leak });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
const walk = async (current) => {
|
|
152
|
+
for (const dirent of (0, fs_1.readdirSync)(current, { withFileTypes: true })) {
|
|
153
|
+
const full = path.join(current, dirent.name);
|
|
154
|
+
if (dirent.isDirectory()) {
|
|
155
|
+
await walk(full);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (!dirent.isFile()) {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (full.endsWith(".zip")) {
|
|
162
|
+
await scanZipStructural(full);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const text = asScannableText((0, fs_1.readFileSync)(full));
|
|
166
|
+
if (text === undefined) {
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
for (const leak of findStructuralLeaks(text)) {
|
|
170
|
+
hits.push({ file: full, ...leak });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
await walk(dir);
|
|
175
|
+
return hits;
|
|
176
|
+
};
|
|
177
|
+
exports.scanArtifactsForUnredactedSecrets = scanArtifactsForUnredactedSecrets;
|
|
178
|
+
//# sourceMappingURL=scanArtifactsForSecrets.js.map
|