Skip to content
Merged
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
9 changes: 6 additions & 3 deletions cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,19 @@
},
"main": "cli.js",
"scripts": {
"build": "tsc",
"build": "tsc && mkdir -p dist/audit/generated && cp src/audit/generated/station.did.js dist/audit/generated/",
"start": "node dist/cli.js",
"expose": "pnpm link --global",
"generate-types": "didc bind --target ts ../core/control-panel/api/spec.did > ./src/generated/control_panel.d.ts"
"test": "vitest run",
"generate-types": "didc bind --target ts ../core/control-panel/api/spec.did > ./src/generated/control_panel.d.ts",
"generate-station-types": "didc bind --target ts ../core/station/api/spec.did > ./src/audit/generated/station.did.d.ts && didc bind --target js ../core/station/api/spec.did > ./src/audit/generated/station.did.js && cp ../core/station/api/spec.did ./src/audit/generated/station.did"
},
"devDependencies": {
"commander": "12.1.0",
"@dfinity/agent": "1.4.0",
"@dfinity/candid": "1.4.0",
"@dfinity/identity": "1.4.0",
"@dfinity/principal": "1.4.0"
"@dfinity/principal": "1.4.0",
"vitest": "1.6.1"
}
}
111 changes: 111 additions & 0 deletions cli/src/audit/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# `orbit-cli audit`

Read-only sanity checks against an Orbit station's configuration. The command pulls live state via the station's `list_*` query methods using `agent-js`, runs a set of static checks against the configuration, and prints a severity-sorted report. Nothing is mutated — the audit is safe to run at any time, against any station the caller has read access to.

> **Identity sources.** The audit signs requests with either a dfx-managed PEM or an `icp-cli` identity:
>
> - `--identity-source dfx` (default) — reads `~/.config/dfx/identity/<name>/identity.pem`. The `dfx` CLI itself does not need to be on PATH at runtime, only the PEM. Passphrase-protected identities aren't supported; create a plaintext one for the audit if needed (`dfx identity new orbit-audit --storage-mode plaintext`).
> - `--identity-source icp` — reads the `icp-cli` identity store. Supports `kind: keyring` (Ed25519) via `icp identity export`, `kind: web-auth` / `kind: internet-identity` (browser logins via `icp identity link web --app <domain>`) via `icp identity delegation sign` (mints a 1-hour delegation for an ephemeral session key), and `kind: anonymous`. Requires `icp` on PATH. HSM and Secp256k1 keyring identities are not supported yet.

## Usage

```bash
orbit-cli audit --station <CANISTER_ID> [--network <ic|local>] [--identity <NAME>] [--output <PATH>]
```

### Options

| Flag | Default | Purpose |
| ----------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------- |
| `-s, --station <CANISTER_ID>` | **required** | The station canister id to audit. |
| `-n, --network <ic\|local>` | `ic` | The network the station lives on. |
| `-i, --identity <NAME>` | `default` | Identity to call the station with. Needs read access to the station's `list_*` query methods (admin-tier users have this by default). |
| `--identity-source <SOURCE>` | `dfx` | Where to load the identity from. `dfx` reads `~/.config/dfx/identity/<name>/identity.pem`; `icp` reads the icp-cli identity store. |
| `-o, --output <PATH>` | _(stdout)_ | Write the report to a file instead of stdout. Exit code is still set based on findings. |
| `-h, --help` | | Print help and exit. |

### Exit codes

| Code | Meaning |
| ---- | -------------------------------------- |
| `0` | No findings. |
| `1` | At least one `WARNING`, no `BLOCKER`s. |
| `2` | At least one `BLOCKER`. |

Useful for CI integration — `set -e` pipelines fail naturally when a blocker shows up.

### Examples

```bash
# Audit a mainnet station, print report to stdout.
orbit-cli audit --station rrkah-fqaaa-aaaaa-aaaaq-cai

# As an admin-tier identity, write the report to a file.
orbit-cli audit --station rrkah-fqaaa-aaaaa-aaaaq-cai \
--identity admin-readonly \
--output ./audit-report.txt

# Sign with an icp-cli identity (II / Okta / linked browser logins).
orbit-cli audit --station rrkah-fqaaa-aaaaa-aaaaq-cai \
--identity my-icp-identity \
--identity-source icp

# Local development station.
orbit-cli audit --station <local-canister-id> --network local
```

## Report format

The report is plain text, sorted by severity (`BLOCKER` → `WARNING` → `INFO`), with each finding showing the offending policy / asset / permission and a suggested fix. A `summary:` line ends the report with counts.

```
Orbit Station Audit Report
station: rrkah-fqaaa-aaaaa-aaaaq-cai
network: ic

==== BLOCKERS (1) ====

[quorum.empty-approver-set]
policy abc123... (Transfer(Any)) — Quorum
Quorum rule asks for 1 approval(s) but UserSpecifier::Id(1 user(s)) currently
resolves to 0 active users. Next matching request will auto-approve.
fix: Add eligible approvers to this specifier, or wrap in AnyOf with an
admin-group fallback before the next matching request is submitted.

==== Positive confirmations ====
- 12 request policies loaded.
- 5 users loaded.
- ...

summary: 1 blocker, 0 warning, 0 info
```

Findings include policy, user, group, canister, and method identifiers. When using `--output`, write to a location that's appropriate for that level of detail — the report is internal station metadata, not something you'd want to commit to a public repo or sync to a shared drive without thinking about it first.

## Tests

```bash
pnpm --filter orbit-cli test
```

Unit tests cover each check across positive and negative cases, including combinator descent and cycle detection on `NamedRule` references. Fixtures are in [checks/fixtures.ts](./checks/fixtures.ts).

## Extending

Each check is a function that takes the loaded station data and returns `Finding[]`:

```ts
export const myCheck = (
policies: RequestPolicy[],
users: User[],
/* ... */
): Finding[] => {
// ...
};
```

To add a new check:

1. Drop a new file in `checks/` returning `Finding[]`. Use a stable check id under a dot-namespace (`category.short-name`).
2. Spec it in `checks/<name>.spec.ts` with at least one positive and one negative case. Reuse the fixture builders in `checks/fixtures.ts`.
3. Wire it into `index.ts` alongside the existing checks.
47 changes: 47 additions & 0 deletions cli/src/audit/agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Actor, ActorSubclass, HttpAgent, Identity } from '@dfinity/agent';
import { readFile } from 'fs/promises';
import { join } from 'path';
import { ROOT_PATH } from '../utils';
// `idlFactory` is the runtime IDL constructor generated from
// `core/station/api/spec.did`. Both the .js and its companion .d.ts live in
// `./generated/`; regenerate them with `pnpm run generate-station-types`.
import { idlFactory } from './generated/station.did.js';

const MAINNET_HOSTS: Record<string, string> = {
ic: 'https://icp-api.io',
};

const resolveHost = async (network: string): Promise<string> => {
if (MAINNET_HOSTS[network]) return MAINNET_HOSTS[network];

// Fall back to dfx.json for local-replica style networks.
const dfxJsonPath = join(ROOT_PATH, 'dfx.json');
const raw = await readFile(dfxJsonPath, 'utf8').catch(() => null);
if (!raw) {
throw new Error(`Unknown network '${network}' and dfx.json not found at ${dfxJsonPath}.`);
}
const dfxJson = JSON.parse(raw) as {
networks?: Record<string, { providers?: string[]; bind?: string }>;
};
const cfg = dfxJson.networks?.[network];
if (!cfg) {
throw new Error(`Network '${network}' is not defined in dfx.json.`);
}
if (cfg.providers && cfg.providers.length > 0) return cfg.providers[0];
if (cfg.bind) return cfg.bind.startsWith('http') ? cfg.bind : `http://${cfg.bind}`;
throw new Error(`Network '${network}' has no providers or bind URL configured.`);
};

export const createStationActor = async (
canisterId: string,
network: string,
identity: Identity,
): Promise<ActorSubclass> => {
const host = await resolveHost(network);
const agent = new HttpAgent({ host, identity });
if (network !== 'ic') {
// Local replicas need their root key fetched to verify certificates.
await agent.fetchRootKey();
}
return Actor.createActor(idlFactory, { agent, canisterId });
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, expect, it, beforeEach } from 'vitest';
import { assetEditPolicyWeakerThanTransfer } from './asset-edit-policy-weaker-than-transfer';
import { makePolicy, makeUser, resetCounter } from './fixtures';

describe('asset.edit-policy-weaker-than-transfer', () => {
beforeEach(() => resetCounter());

const fiveActiveUsers = () => Array.from({ length: 5 }, (_, i) => makeUser({ id: `u-${i}` }));

it('does not fire when there are no EditAsset policies', () => {
const users = fiveActiveUsers();
const policy = makePolicy(
{ Transfer: { Any: null } },
{ Quorum: { approvers: { Any: null }, min_approved: 2 } },
);
const findings = assetEditPolicyWeakerThanTransfer([policy], users, []);
expect(findings).toHaveLength(0);
});

it('does not fire when there are no Transfer policies', () => {
const users = fiveActiveUsers();
const policy = makePolicy(
{ EditAsset: { Any: null } },
{ Quorum: { approvers: { Any: null }, min_approved: 2 } },
);
const findings = assetEditPolicyWeakerThanTransfer([policy], users, []);
expect(findings).toHaveLength(0);
});

it('does not fire when EditAsset is gated as strict as the strictest Transfer', () => {
const users = fiveActiveUsers();
const editPolicy = makePolicy(
{ EditAsset: { Any: null } },
{ Quorum: { approvers: { Any: null }, min_approved: 3 } },
);
const transferPolicy = makePolicy(
{ Transfer: { Any: null } },
{ Quorum: { approvers: { Any: null }, min_approved: 3 } },
);
const findings = assetEditPolicyWeakerThanTransfer([editPolicy, transferPolicy], users, []);
expect(findings).toHaveLength(0);
});

it('fires when EditAsset is gated more loosely than Transfer', () => {
const users = fiveActiveUsers();
const editPolicy = makePolicy(
{ EditAsset: { Any: null } },
{ Quorum: { approvers: { Any: null }, min_approved: 1 } },
);
const transferPolicy = makePolicy(
{ Transfer: { Any: null } },
{ Quorum: { approvers: { Any: null }, min_approved: 3 } },
);
const findings = assetEditPolicyWeakerThanTransfer([editPolicy, transferPolicy], users, []);
expect(findings).toHaveLength(1);
expect(findings[0].severity).toBe('warning');
expect(findings[0].message).toMatch(/Easiest EditAsset path requires at least 1/);
expect(findings[0].message).toMatch(/strictest Transfer path requires at least 3/);
});

it('uses minVotes including AutoApproved (treated as 0)', () => {
const users = fiveActiveUsers();
const editPolicy = makePolicy({ EditAsset: { Any: null } }, { AutoApproved: null });
const transferPolicy = makePolicy(
{ Transfer: { Any: null } },
{ Quorum: { approvers: { Any: null }, min_approved: 2 } },
);
const findings = assetEditPolicyWeakerThanTransfer([editPolicy, transferPolicy], users, []);
expect(findings).toHaveLength(1);
expect(findings[0].message).toMatch(/Easiest EditAsset path requires at least 0/);
});
});
49 changes: 49 additions & 0 deletions cli/src/audit/checks/asset-edit-policy-weaker-than-transfer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Finding } from '../report';
import { minVotesForRule } from '../resolver';
import { NamedRule, RequestPolicy, User, UUID } from '../types';

/**
* Warns when the *easiest path* to passing an `EditAsset` request requires
* fewer approvals than the *strictest* `Transfer` policy in the station.
*
* Background: an approved transfer's destination ledger is resolved live from
* `asset.metadata["ledger_canister_id"]` at execute time. A successful
* `EditAsset` between approval and execution can therefore redirect funds. If
* `EditAsset` is gated more loosely than `Transfer`, an attacker who can pass
* the lower bar can subvert the higher one.
*
* Per-asset / per-account scoping would be more precise; the MVP compares the
* easiest EditAsset path against the strictest Transfer path across the entire
* station, which catches the typical misconfiguration without requiring an
* account-to-asset cross-reference.
*/
export const assetEditPolicyWeakerThanTransfer = (
policies: RequestPolicy[],
users: User[],
namedRules: NamedRule[],
): Finding[] => {
const namedByUUID = new Map<UUID, NamedRule>(namedRules.map(r => [r.id, r]));
const editPolicies = policies.filter(p => 'EditAsset' in p.specifier);
const transferPolicies = policies.filter(p => 'Transfer' in p.specifier);

if (editPolicies.length === 0 || transferPolicies.length === 0) return [];

const editVotes = editPolicies.map(p => minVotesForRule(p.rule, users, namedByUUID));
const transferVotes = transferPolicies.map(p => minVotesForRule(p.rule, users, namedByUUID));

const easiestEdit = Math.min(...editVotes);
const strictestTransfer = Math.max(...transferVotes);

if (!Number.isFinite(easiestEdit) || !Number.isFinite(strictestTransfer)) return [];
if (easiestEdit >= strictestTransfer) return [];

return [
{
checkId: 'asset.edit-policy-weaker-than-transfer',
severity: 'warning',
location: `station-wide (${editPolicies.length} EditAsset policy/policies vs ${transferPolicies.length} Transfer policy/policies)`,
message: `Easiest EditAsset path requires at least ${easiestEdit} approval(s); strictest Transfer path requires at least ${strictestTransfer} (estimates — AllOf combinators are reduced via max-of-children, giving a lower bound). An actor who can pass the EditAsset bar can mutate asset.metadata["ledger_canister_id"] and redirect an approved Transfer between approval and execution.`,
fix: 'Gate EditAsset at the same approval level as Transfer for the affected assets. Until v0.2 supports per-asset scoping, treat this as a station-wide signal to tighten the loosest EditAsset policy.',
},
];
};
26 changes: 26 additions & 0 deletions cli/src/audit/checks/describe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { RequestSpecifier, ResourceIds, UserSpecifier } from '../types';

export const describeSpecifier = (specifier: UserSpecifier): string => {
if ('Any' in specifier) return 'UserSpecifier::Any';
if ('Id' in specifier) return `UserSpecifier::Id(${specifier.Id.length} user(s))`;
if ('Group' in specifier) return `UserSpecifier::Group(${specifier.Group.length} group(s))`;
return 'UserSpecifier::?';
};

const describeResourceIds = (ids: ResourceIds): string => {
if ('Any' in ids) return 'Any';
return `[${ids.Ids.length} id(s)]`;
};

export const describeRequestSpecifier = (specifier: RequestSpecifier): string => {
const [variant] = Object.keys(specifier);
const value = (specifier as Record<string, unknown>)[variant];
if (
value &&
typeof value === 'object' &&
('Any' in (value as object) || 'Ids' in (value as object))
) {
return `${variant}(${describeResourceIds(value as ResourceIds)})`;
}
return variant;
};
Loading
Loading