Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/see-every-logged-in-cli.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@memnox/core': patch
'@memnox/proxy': patch
'@memnox/interceptors': patch
'memnox': patch
---

A scan reports every CLI that is logged in, not only aws. A CLI counted only when a credential file it found ended with the path its verb table named, and the tables named directories (`~/.config/gcloud`, `~/.netlify`), a path that holds nothing (`~/.heroku`), or files the scan never read (`~/.pgpass`, `~/.my.cnf`). With all six present, `authenticatedClis` reported aws alone. A table's credential now matches the file itself or the directory it sits in, Heroku is read from `~/.netrc` where its CLI writes the token, and the scan reads `.pgpass`, `.my.cnf`, gcloud's application default credentials, Azure's token cache and Pulumi's credentials. A test now holds every table's credential to a file the scan reads, so the two lists cannot drift apart again. Structure only, as before: no value is read out.
33 changes: 28 additions & 5 deletions packages/core/src/discovery/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,14 @@ const SPECS: readonly CredentialSpec[] = [
{ kind: 'SSH key', paths: ['.ssh/id_ed25519', '.ssh/id_rsa', '.ssh/id_ecdsa'] },
{ kind: 'AWS', paths: ['.aws/credentials'], detail: buildSectionDetail('profiles') },
{ kind: 'AWS config', paths: ['.aws/config'], detail: buildSectionDetail('profiles') },
{ kind: 'Google Cloud', paths: ['.config/gcloud/credentials.db'] },
{ kind: 'Azure', paths: ['.azure/azureProfile.json'] },
{
kind: 'Google Cloud',
paths: [
'.config/gcloud/credentials.db',
'.config/gcloud/application_default_credentials.json',
],
},
{ kind: 'Azure', paths: ['.azure/azureProfile.json', '.azure/msal_token_cache.json'] },
{ kind: 'GitHub CLI', paths: ['.config/gh/hosts.yml'], detail: yamlHosts },
{ kind: 'Vercel', paths: ['.vercel/auth.json'] },
{ kind: 'Railway', paths: ['.railway/config.json'] },
Expand All @@ -121,6 +127,9 @@ const SPECS: readonly CredentialSpec[] = [
{ kind: 'PyPI', paths: ['.pypirc'] },
{ kind: 'Cargo', paths: ['.cargo/credentials', '.cargo/credentials.toml'] },
{ kind: 'Stripe', paths: ['.config/stripe/config.toml'] },
{ kind: 'PostgreSQL', paths: ['.pgpass'] },
{ kind: 'MySQL', paths: ['.my.cnf'] },
{ kind: 'Pulumi', paths: ['.pulumi/credentials.json'] },
{ kind: 'Git credentials', paths: ['.git-credentials'] },
];

Expand Down Expand Up @@ -253,6 +262,22 @@ export function productionLooking(detail: string | undefined): string | undefine
.find((word) => PRODUCTION_NAMES.some((name) => word.toLowerCase().includes(name)));
}

/**
* Whether a table's `~` credential names this found file, as the file itself or as the
* directory it sits in: `~/.config/gcloud` is where gcloud keeps `credentials.db`, and a
* suffix match alone never saw it.
*/
function coversPath(source: string, path: string): boolean {
if (!source.startsWith('~')) return false;
const relative = source.slice(1);
return path.endsWith(relative) || path.includes(`${relative}/`);
}

/** Every file the scan reads as a credential, relative to home, for the tables to name. */
export const SCANNED_CREDENTIAL_FILES: readonly string[] = SPECS.flatMap(
(spec) => spec.paths,
);

/**
* A binary on PATH plus a credential it can read is an authenticated CLI, and that
* pair is the whole finding: either alone is unremarkable.
Expand All @@ -269,9 +294,7 @@ export function authenticatedClis(
if (table === null) continue;

const match = credentials.find((credential) =>
table.credential.some(
(source) => source.startsWith('~') && credential.path.endsWith(source.slice(1)),
),
table.credential.some((source) => coversPath(source, credential.path)),
);
const variable = table.credential.find(
(source) => !source.startsWith('~') && envNames.includes(source),
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/verbs/tables/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export const DATA_TABLES: readonly VerbTable[] = [
},
{
name: 'mycli',
credential: ['~/.myclirc', '~/.my.cnf', 'MYSQL_PWD', 'DATABASE_URL'],
credential: ['~/.my.cnf', 'MYSQL_PWD', 'DATABASE_URL'],
headline: 'can reach a database',
verbs: [
{ match: '-e **', class: WRITE, note: 'statement is read from the argument' },
Expand All @@ -48,7 +48,7 @@ export const DATA_TABLES: readonly VerbTable[] = [
},
{
name: 'pgcli',
credential: ['~/.pgpass', '~/.config/pgcli/config', 'DATABASE_URL', 'PGPASSWORD'],
credential: ['~/.pgpass', 'DATABASE_URL', 'PGPASSWORD'],
headline: 'can reach a database',
verbs: [
{ match: '-c **', class: WRITE, note: 'statement is read from the argument' },
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/verbs/tables/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,8 @@ export const DEPLOY_TABLES: readonly VerbTable[] = [
},
{
name: 'heroku',
credential: ['~/.heroku'],
// The Heroku CLI writes its token to ~/.netrc; ~/.heroku holds no credential.
credential: ['~/.netrc'],
headline: 'can deploy',
verbs: [
{ match: 'apps:destroy **', class: GONE },
Expand Down
65 changes: 65 additions & 0 deletions packages/core/test/credentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import {
looksLikeCredential,
productionLooking,
readEnvFile,
SCANNED_CREDENTIAL_FILES,
} from '../src/discovery/credentials';
import { VERB_TABLES } from '../src/verbs/tables';
import { NodeMachineReader } from '../src/discovery/node-machine';
import { SENSITIVITY } from '../src/discovery/discovery.constants';
import { classifySensitivity } from '../src/discovery/resource';
Expand Down Expand Up @@ -226,3 +228,66 @@ describe('authenticated CLIs', () => {
expect(productionLooking('contexts: docker-desktop')).toBeUndefined();
});
});

/* A CLI counted as logged in only when a found file ended with the path its table named,
and the tables named directories and files the scan never read: on most machines only
aws was reported, though the headline of a scan is what is logged in. */
describe('which CLIs are logged in', () => {
const PG_SECRET = ['hunter', '2-pg'].join('');
const logged: Record<string, string> = {
[`${HOME}/.config/gcloud/credentials.db`]: 'SQLite format 3',
[`${HOME}/.netlify/config.json`]: '{"users":{"u1":{"auth":{"token":"nfp_secret"}}}}',
[`${HOME}/.netrc`]:
'machine api.heroku.com\n login a@b.c\n password herokusecret\n',
[`${HOME}/.pgpass`]: `db.example.com:5432:app:app:${PG_SECRET}\n`,
[`${HOME}/.my.cnf`]: '[client]\nuser=app\npassword=mysqlsecret\n',
};
const machine: MachineReader = {
exists: async (path) => logged[path] !== undefined,
read: async (path) => logged[path] ?? null,
list: async () => [],
homeDir: () => HOME,
userName: () => 'dev',
};

it.each(['gcloud', 'netlify', 'heroku', 'psql', 'mysql'])(
'reports %s when its credential file is there',
async (cli) => {
const found = authenticatedClis([cli], await findCredentials(machine));
expect(found.map((each) => each.name)).toEqual([cli]);
},
);

it('reads no value out of any of them', async () => {
const found = await findCredentials(machine);
const printed = JSON.stringify(authenticatedClis(['psql', 'mysql', 'heroku'], found));
for (const secret of [PG_SECRET, 'mysqlsecret', 'herokusecret', 'nfp_secret'])
expect(printed).not.toContain(secret);
});

it('does not take a sibling directory for the one a table names', async () => {
const sibling: MachineReader = {
...machine,
exists: async (path) => path === `${HOME}/.netlify-old/config.json`,
read: async () => null,
};
expect(authenticatedClis(['netlify'], await findCredentials(sibling))).toEqual([]);
});

/* So the two lists cannot drift apart again: every file a table says logs its CLI in is
one the scan reads, or the directory one sits in. */
it.each(
VERB_TABLES.flatMap((table) =>
table.credential
.filter((source) => source.startsWith('~/'))
.map((source) => [table.name, source]),
),
)('%s names %s, which the scan reads', (_cli, source) => {
const relative = source.slice('~/'.length);
expect(
SCANNED_CREDENTIAL_FILES.some(
(file) => file === relative || file.startsWith(`${relative}/`),
),
).toBe(true);
});
});
Loading