@faable/faable 3.1.1 → 3.3.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/api/auth.js CHANGED
@@ -1,8 +1,41 @@
1
- import axios from 'axios';
1
+ import { FaableAuthApi, authBearer } from '@faable/auth-sdk';
2
2
  import os from 'os';
3
+ import { version } from '../config.js';
3
4
 
4
- const api = axios.create({ baseURL: "https://faable.auth.faable.link" });
5
+ // The CLI talks to the Faable tenant's auth server through the same SDK the
6
+ // rest of the platform uses. It used to be a bare axios instance, which meant
7
+ // every call landed in the tenant's traffic as "not identified" — the SDK
8
+ // stamps `x-faable-client`, and the value below makes it say who this really
9
+ // is instead of `auth-sdk`.
10
+ //
11
+ // Two clients, because there are two kinds of call:
12
+ // - anonymous: the device flow and the refresh grant. A public client, no
13
+ // secret, no token yet. POSTs are never retried by the fetcher, which is
14
+ // what the device polling needs — every `authorization_pending` is an
15
+ // error the loop expects, not one to replay.
16
+ // - bearer: `/me` and everything `faable auth` does, with the token the user
17
+ // already holds. The strategy asks for the token on every request, so
18
+ // whatever `loadLiveCredentials` refreshed is what goes on the wire.
19
+ const AUTH_DOMAIN = "https://faable.auth.faable.link";
5
20
  const CLIENT_ID = "c879023b-e34f-4b0c-a262-210e556bc2e4";
21
+ // `<name>/<version>`. No commit: the CLI has no release SHA constant, and a
22
+ // dev build (`0.0.0-development`) is identifiable as such by the version
23
+ // alone. auth caps this segment at 32 chars.
24
+ const CLI_CLIENT = `faable-cli/${version}`;
25
+ // No `x-faable-instance`: the SDK's default is `${HOSTNAME}:${pid}`, which on
26
+ // a laptop is the user's machine name in our audit log. An empty string
27
+ // suppresses the header (the helper drops falsy parts).
28
+ const clientInfo = { client: CLI_CLIENT, instance: "" };
29
+ const createAnonymousAuthApi = ({ domain = AUTH_DOMAIN, fetcher, } = {}) => FaableAuthApi.create({ domain, clientInfo, ...(fetcher ? { fetcher } : {}) });
30
+ const createBearerAuthApi = (token, { domain = AUTH_DOMAIN, account, fetcher } = {}) => FaableAuthApi.create({
31
+ domain,
32
+ ...(account ? { headers: { account_id: account } } : {}),
33
+ authStrategy: authBearer,
34
+ auth: { token },
35
+ clientInfo,
36
+ ...(fetcher ? { fetcher } : {}),
37
+ });
38
+ const anonymous = createAnonymousAuthApi();
6
39
  const PLATFORM_LABELS = {
7
40
  darwin: "macOS",
8
41
  win32: "Windows",
@@ -15,35 +48,33 @@ function deviceName() {
15
48
  return `${os.hostname()} (${platform})`;
16
49
  }
17
50
  async function getDeviceCode() {
18
- const res = await api.post(`/oauth/device/code`, {
51
+ return anonymous.fetcher.post(`/oauth/device/code`, {
19
52
  client_id: CLIENT_ID,
20
53
  scope: "openid email profile offline_access",
21
54
  device_name: deviceName(),
22
55
  });
23
- return res.data;
24
56
  }
25
57
  async function getDeviceToken(device_code) {
26
- const res = await api.post(`/oauth/token`, { device_code, client_id: CLIENT_ID, grant_type: "urn:ietf:params:oauth:grant-type:device_code" });
27
- return res.data;
58
+ return anonymous.fetcher.post(`/oauth/token`, {
59
+ device_code,
60
+ client_id: CLIENT_ID,
61
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
62
+ });
28
63
  }
29
64
  // Validate a device-flow access token against the Faable Auth server. The token
30
65
  // is issued by the auth server, so it must be introspected there — NOT against
31
66
  // the deploy API (api.faable.com), which has no /me route and would 404.
32
67
  async function getMe(access_token) {
33
- const res = await api.get(`/me`, {
34
- headers: { Authorization: `Bearer ${access_token}` },
35
- });
36
- return res.data;
68
+ return createBearerAuthApi(access_token).fetcher.get(`/me`);
37
69
  }
38
70
  // Exchange a refresh token for a fresh access token (and a rotated refresh
39
71
  // token). The CLI is a public client, so no client_secret is required.
40
72
  async function refreshToken(refresh_token) {
41
- const res = await api.post(`/oauth/token`, {
73
+ return anonymous.fetcher.post(`/oauth/token`, {
42
74
  grant_type: "refresh_token",
43
75
  client_id: CLIENT_ID,
44
76
  refresh_token,
45
77
  });
46
- return res.data;
47
78
  }
48
79
 
49
- export { getDeviceCode, getDeviceToken, getMe, refreshToken };
80
+ export { AUTH_DOMAIN, CLIENT_ID, CLI_CLIENT, createAnonymousAuthApi, createBearerAuthApi, getDeviceCode, getDeviceToken, getMe, refreshToken };
@@ -1,6 +1,6 @@
1
- import { FaableAuthApi } from '@faable/auth-sdk';
2
1
  import { CredentialsStore } from '../lib/CredentialsStore.js';
3
2
  import { log } from '../log.js';
3
+ import { createBearerAuthApi } from './auth.js';
4
4
  import { loadLiveCredentials } from './session.js';
5
5
 
6
6
  // Default tenant host. `faable auth` is customer-facing: a customer targets
@@ -31,13 +31,10 @@ const requireAuthAdmin = async (opts = {}) => {
31
31
  }
32
32
  const domain = opts.authUrl || process.env.FAABLE_AUTH_URL || DEFAULT_AUTH_URL;
33
33
  const account = opts.account || process.env.FAABLE_AUTH_ACCOUNT;
34
- return new FaableAuthApi({
35
- domain,
36
- ...(account ? { headers: { account_id: account } } : {}),
37
- // Static bearer: passing no `auth` keeps sdk-base from attaching any token
38
- // strategy, so this header is used verbatim on every request.
39
- fetcher: { headers: { Authorization: `Bearer ${token}` } }
40
- });
34
+ // The session's bearer as a strategy — with the CLI's own identity on the
35
+ // wire instead of `auth-sdk` — scoped to the target tenant when one was
36
+ // named. The server decides whether this token may manage that tenant.
37
+ return createBearerAuthApi(token, { domain, account });
41
38
  };
42
39
  // Translate raw management-API failures into actionable CLI errors. Everything
43
40
  // else is rethrown untouched (FaableApiError messages already carry
@@ -49,7 +49,10 @@ const loadLiveCredentials = async (store = new CredentialsStore()) => {
49
49
  // token endpoint's RFC 6749 error body. "Run `faable login` again"
50
50
  // would be a lie here — login is denied too — so say what actually
51
51
  // happened and stop instead of letting a generic 401 mislead.
52
- const body = e?.response?.data;
52
+ // The SDK's error keeps the HTTP body under `response.data`, like the
53
+ // axios error it replaced.
54
+ const body = e
55
+ ?.response?.data;
53
56
  if (body?.error_code === "user_suspended") {
54
57
  log.error("❌ Your account has been suspended. Contact support@faable.com.");
55
58
  process.exit(1);
@@ -5,45 +5,59 @@ const describe_selector = (s) => {
5
5
  if (s.pattern && s.user_agent) {
6
6
  return `requests for ${s.pattern} from user-agents matching ${s.user_agent}`;
7
7
  }
8
+ if (s.pattern && s.query) {
9
+ return `requests for ${s.pattern} carrying ?${s.query}`;
10
+ }
8
11
  if (s.user_agent)
9
12
  return `requests from user-agents matching ${s.user_agent}`;
13
+ if (s.query)
14
+ return `requests carrying ?${s.query}`;
10
15
  return `requests for ${s.pattern}`;
11
16
  };
12
17
  /** Does this returned rule carry everything we asked for? */
13
18
  const rule_matches_request = (rule, want, action) => rule.action === action &&
14
19
  (rule.pattern ?? '') === (want.pattern ?? '') &&
15
- (rule.user_agent ?? '') === (want.user_agent ?? '');
20
+ (rule.user_agent ?? '') === (want.user_agent ?? '') &&
21
+ (rule.query ?? '') === (want.query ?? '');
16
22
  /**
17
23
  * Shared handler for `block` and `sink` — the two differ only in the action
18
24
  * they store and in what the edge answers, so the flow (resolve app, write,
19
25
  * verify, explain, tell the user how to check) lives here once.
20
26
  */
21
27
  const add_rule = async (opts) => {
22
- const { api, app_id, app_name, app_url, pattern, user_agent, action, description, force } = opts;
23
- const want = { pattern, user_agent };
28
+ const { api, app_id, app_name, app_url, pattern, user_agent, query, action, description, force } = opts;
29
+ const want = { pattern, user_agent, query };
24
30
  const waf = await api.addAppWafRule(app_id, {
25
31
  pattern,
26
32
  user_agent,
33
+ query,
27
34
  action,
28
35
  description,
29
36
  force
30
37
  });
31
38
  // Read the rule back before telling the user it worked.
32
39
  //
33
- // An older Faable server has no `user_agent` on this endpoint and drops it
34
- // silently, storing the path half on its own — which turns "block /login for
35
- // this bot" into "block /login for everyone". No server-side schema can
36
- // prevent that (the old server is the old code), so the client checks that
37
- // what came back is what it asked for, and undoes the write if it is not.
38
- if (user_agent &&
40
+ // An older Faable server has no `user_agent` (or no `query`) on this endpoint
41
+ // and drops it silently, storing the path half on its own — which turns
42
+ // "block /login for this bot" into "block /login for everyone", or "block /
43
+ // when it carries ?rest_route" into "block /", which is the entire app. No
44
+ // server-side schema can prevent that (the old server is the old code), so
45
+ // the client checks that what came back is what it asked for, and undoes the
46
+ // write if it is not.
47
+ //
48
+ // The query half is the more dangerous of the two, because the api LIFTS the
49
+ // site-root guard for a path that is ANDed with a parameter: `^/$` is a legal
50
+ // pattern to send, and an old server that drops the parameter stores exactly
51
+ // the rule the guard exists to refuse.
52
+ if ((user_agent || query) &&
39
53
  !waf.rules.some(r => rule_matches_request(r, want, action))) {
40
54
  await api
41
- .removeAppWafRule(app_id, { pattern, user_agent, action })
55
+ .removeAppWafRule(app_id, { pattern, user_agent, query, action })
42
56
  // If the rollback itself is unsupported, fall back to the path-only
43
57
  // shape the old server actually stored.
44
58
  .catch(() => api.removeAppWafRule(app_id, { pattern, action }))
45
59
  .catch(() => undefined);
46
- throw new Error(`This Faable server does not support user-agent rules yet, so the rule was not created.\n` +
60
+ throw new Error(`This Faable server does not support ${query ? 'query-parameter' : 'user-agent'} rules yet, so the rule was not created.\n` +
47
61
  ` Update the CLI and try again, or ask support to upgrade the platform.`);
48
62
  }
49
63
  const answer = action === 'deny' ? '403' : '404';
@@ -75,7 +89,8 @@ const add_rule = async (opts) => {
75
89
  /** The exact arguments that reverse this rule. */
76
90
  const undo_args = (s) => [
77
91
  s.pattern ? `'${s.pattern}'` : '',
78
- s.user_agent ? `--user-agent '${s.user_agent}'` : ''
92
+ s.user_agent ? `--user-agent '${s.user_agent}'` : '',
93
+ s.query ? `--query '${s.query}'` : ''
79
94
  ]
80
95
  .filter(Boolean)
81
96
  .join(' ');
@@ -14,6 +14,11 @@ const waf_block = {
14
14
  alias: 'u',
15
15
  type: 'string',
16
16
  description: 'RE2 fragment matched against User-Agent, e.g. YisouSpider. With a path, BOTH must match.'
17
+ })
18
+ .option('query', {
19
+ alias: 'q',
20
+ type: 'string',
21
+ description: 'Literal query parameter name, e.g. rest_route (not a regex). With a path, BOTH must match.'
17
22
  })
18
23
  .option('app', {
19
24
  alias: 'a',
@@ -28,19 +33,24 @@ const waf_block = {
28
33
  .option('force', {
29
34
  type: 'boolean',
30
35
  default: false,
31
- description: 'Accept a user-agent that also matches a known search/AI crawler or uptime monitor'
36
+ description: 'Accept a user-agent that also matches a known search/AI crawler or uptime monitor, or a query parameter that carries an auth flow'
32
37
  })
33
- .check(({ pattern, userAgent }) => {
34
- if (!pattern && !userAgent) {
35
- throw new Error('Give a path pattern, --user-agent, or both.\n' +
38
+ .check(({ pattern, userAgent, query }) => {
39
+ if (userAgent && query) {
40
+ throw new Error('A rule can combine a path with --user-agent OR with --query, not both.');
41
+ }
42
+ if (!pattern && !userAgent && !query) {
43
+ throw new Error('Give a path pattern, --user-agent, --query, or a path with one of them.\n' +
36
44
  " e.g. faable deploy waf block '^/wp-admin'\n" +
37
- ' faable deploy waf block --user-agent YisouSpider');
45
+ ' faable deploy waf block --user-agent YisouSpider\n' +
46
+ ' faable deploy waf block --query rest_route');
38
47
  }
39
48
  return true;
40
49
  })
41
50
  .example("$0 deploy waf block '^/\\.well-known/'", 'Stop scanner probes under /.well-known from waking the app')
42
51
  .example('$0 deploy waf block --user-agent YisouSpider', 'Refuse one crawler outright, on every path')
43
52
  .example("$0 deploy waf block '^/api/' --user-agent curl", 'Refuse /api/ to that user-agent only, leaving browsers untouched')
53
+ .example('$0 deploy waf block --query rest_route', 'Stop WordPress REST probes, whose payload rides in the query string')
44
54
  .showHelpOnFail(false),
45
55
  handler: async (args) => {
46
56
  const ctx = await requireApi();
@@ -53,6 +63,7 @@ const waf_block = {
53
63
  app_url: app.url,
54
64
  pattern: args.pattern,
55
65
  user_agent: args.userAgent,
66
+ query: args.query,
56
67
  action: 'deny',
57
68
  description: args.description,
58
69
  force: args.force
@@ -26,10 +26,18 @@ const format_waf = (waf) => {
26
26
  for (const p of waf.platform_profiles) {
27
27
  // Say what the ruleset matches ON, not just what it answers: without it a
28
28
  // `probe` profile of user-agents reads as if it blocked paths.
29
- const on = p.match === 'user_agent' ? ' by user-agent' : '';
29
+ const on = p.match === 'user_agent'
30
+ ? ' by user-agent'
31
+ : p.match === 'query'
32
+ ? ' by query parameter'
33
+ : '';
30
34
  out.push(` • ${p.name} — ${p.rule_count} rule(s)${on}, ${action_label(p.action)}`);
31
35
  for (const r of p.rules ?? []) {
32
- const what = p.match === 'user_agent' ? `UA ~ ${r.pattern}` : r.pattern;
36
+ const what = p.match === 'user_agent'
37
+ ? `UA ~ ${r.pattern}`
38
+ : p.match === 'query'
39
+ ? `?${r.pattern}`
40
+ : r.pattern;
33
41
  out.push(` ${what}${r.description ? ` # ${r.description}` : ''}`);
34
42
  }
35
43
  }
@@ -48,16 +56,22 @@ const format_waf = (waf) => {
48
56
  /**
49
57
  * What a rule selects, in one line.
50
58
  *
51
- * The three shapes have to be visibly different here: a user-agent rule has no
52
- * path restriction (it applies to the whole app) and a combined one is an AND,
53
- * so printing either as a bare pattern would misrepresent its reach.
59
+ * The five shapes have to be visibly different here: a user-agent or query rule
60
+ * has no path restriction (it applies to the whole app) and the combined ones
61
+ * are ANDs, so printing any of them as a bare pattern would misrepresent its
62
+ * reach. The `?` prefix marks a query parameter — `rest_route` on its own would
63
+ * read like a path.
54
64
  */
55
65
  const rule_subject = (r) => {
56
66
  if (r.pattern && r.user_agent) {
57
67
  return `${r.pattern} + UA ~ ${r.user_agent}`;
58
68
  }
69
+ if (r.pattern && r.query)
70
+ return `${r.pattern} + ?${r.query}`;
59
71
  if (r.user_agent)
60
72
  return `UA ~ ${r.user_agent} (any path)`;
73
+ if (r.query)
74
+ return `?${r.query} (any path)`;
61
75
  return r.pattern ?? '(unknown)';
62
76
  };
63
77
 
@@ -18,6 +18,11 @@ const waf_rm = {
18
18
  alias: 'u',
19
19
  type: 'string',
20
20
  description: 'The user-agent of the rule to remove'
21
+ })
22
+ .option('query', {
23
+ alias: 'q',
24
+ type: 'string',
25
+ description: 'The query parameter of the rule to remove'
21
26
  })
22
27
  .option('action', {
23
28
  type: 'string',
@@ -29,9 +34,9 @@ const waf_rm = {
29
34
  type: 'string',
30
35
  description: 'App Identifier (defaults to the linked app)'
31
36
  })
32
- .check(({ pattern, userAgent }) => {
33
- if (!pattern && !userAgent) {
34
- throw new Error('Give the path pattern, --user-agent, or both.');
37
+ .check(({ pattern, userAgent, query }) => {
38
+ if (!pattern && !userAgent && !query) {
39
+ throw new Error('Give the path pattern, --user-agent, --query, or a path with one of them.');
35
40
  }
36
41
  return true;
37
42
  })
@@ -45,11 +50,13 @@ const waf_rm = {
45
50
  await ctx.api.removeAppWafRule(app_id, {
46
51
  pattern: args.pattern,
47
52
  user_agent: args.userAgent,
53
+ query: args.query,
48
54
  action: args.action
49
55
  });
50
56
  log.info(`🗑️ Removed ${describe_selector({
51
57
  pattern: args.pattern,
52
- user_agent: args.userAgent
58
+ user_agent: args.userAgent,
59
+ query: args.query
53
60
  })} from ${app.name} (${app_id}).`);
54
61
  log.info(`They reach your app again within ~20s, once the edge picks up the change.`);
55
62
  }
@@ -14,6 +14,11 @@ const waf_sink = {
14
14
  alias: 'u',
15
15
  type: 'string',
16
16
  description: 'RE2 fragment matched against User-Agent, e.g. YisouSpider. With a path, BOTH must match.'
17
+ })
18
+ .option('query', {
19
+ alias: 'q',
20
+ type: 'string',
21
+ description: 'Literal query parameter name, e.g. rest_route (not a regex). With a path, BOTH must match.'
17
22
  })
18
23
  .option('app', {
19
24
  alias: 'a',
@@ -28,13 +33,17 @@ const waf_sink = {
28
33
  .option('force', {
29
34
  type: 'boolean',
30
35
  default: false,
31
- description: 'Accept a user-agent that also matches a known search/AI crawler or uptime monitor'
36
+ description: 'Accept a user-agent that also matches a known search/AI crawler or uptime monitor, or a query parameter that carries an auth flow'
32
37
  })
33
- .check(({ pattern, userAgent }) => {
34
- if (!pattern && !userAgent) {
35
- throw new Error('Give a path pattern, --user-agent, or both.\n' +
38
+ .check(({ pattern, userAgent, query }) => {
39
+ if (userAgent && query) {
40
+ throw new Error('A rule can combine a path with --user-agent OR with --query, not both.');
41
+ }
42
+ if (!pattern && !userAgent && !query) {
43
+ throw new Error('Give a path pattern, --user-agent, --query, or a path with one of them.\n' +
36
44
  " e.g. faable deploy waf sink '^/wp-admin'\n" +
37
- ' faable deploy waf sink --user-agent YisouSpider');
45
+ ' faable deploy waf sink --user-agent YisouSpider\n' +
46
+ ' faable deploy waf sink --query rest_route');
38
47
  }
39
48
  return true;
40
49
  })
@@ -55,6 +64,7 @@ const waf_sink = {
55
64
  app_url: app.url,
56
65
  pattern: args.pattern,
57
66
  user_agent: args.userAgent,
67
+ query: args.query,
58
68
  action: 'sink',
59
69
  description: args.description,
60
70
  force: args.force
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faable/faable",
3
- "version": "3.1.1",
3
+ "version": "3.3.0",
4
4
  "main": "dist/index.js",
5
5
  "license": "MIT",
6
6
  "author": "Marc Pomar <marc@faable.com>",
@@ -27,7 +27,7 @@
27
27
  ],
28
28
  "dependencies": {
29
29
  "@actions/core": "^3.0.0",
30
- "@faable/auth-sdk": "^2.5.39",
30
+ "@faable/auth-sdk": "^2.6.0",
31
31
  "axios": "^1.18.1",
32
32
  "fs-extra": "^11.3.2",
33
33
  "open": "^11.0.0",