@teemops/cli 0.1.0 → 0.2.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/README.md +113 -0
- package/dist/teemops.js +221 -46
- package/package.json +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# teemops CLI
|
|
2
|
+
|
|
3
|
+
`@teemops/cli` is the command line for [teemops](https://teemops.com), Teem's cloud security
|
|
4
|
+
scanner. People use it from a terminal, and agents and CI jobs use it to start scans, read
|
|
5
|
+
findings and fail a build on a new critical. Every command prints JSON when its output is not a
|
|
6
|
+
terminal, so you don't need to scrape anything.
|
|
7
|
+
|
|
8
|
+
Requires Node.js 22 or later.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install -g @teemops/cli
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Or run it without installing:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npx @teemops/cli help
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Sign in
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
teemops login
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
This signs you in with your Teem account. It prints a link and a code; open the link in any
|
|
27
|
+
browser and enter the code. `teemops whoami` shows who you are signed in as and which
|
|
28
|
+
organization you are acting in. `teemops logout` signs out.
|
|
29
|
+
|
|
30
|
+
## Agents and CI
|
|
31
|
+
|
|
32
|
+
An agent is an identity in your organization with its own role and credential, so it doesn't
|
|
33
|
+
use a person's sign-in. An owner or administrator creates one:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
teemops agents create --name ci --role auditor
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The credential is shown once. Store it in your CI's secret store or your agent's environment
|
|
40
|
+
as `TEEMOPS_TOKEN`, and every command uses it:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
teemops findings list --fail-on high
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Roles are `viewer` (read only), `auditor` (also runs scans and resolves or ignores findings) and
|
|
47
|
+
`administrator` (also connects accounts and manages agents). Give an agent the smallest role that
|
|
48
|
+
does its job. Billing and other owner-only actions need a person signed in as the owner. Credentials expire after 30, 90 or 365 days
|
|
49
|
+
(`--expires-days`, default 90). To rotate one without downtime, add a second secret, switch
|
|
50
|
+
over, then revoke the old one:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
teemops agents add-secret --id <agent-id>
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
teemops agents revoke-secret --id <agent-id> --secret-id <old-secret-id>
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`teemops agents list` shows each agent and its secrets, and `teemops agents revoke --id <agent-id>`
|
|
61
|
+
deletes an agent. Personal access tokens (`teemops tokens …`) work the same way for a person.
|
|
62
|
+
|
|
63
|
+
## Common commands
|
|
64
|
+
|
|
65
|
+
| Command | What it does |
|
|
66
|
+
| --- | --- |
|
|
67
|
+
| `teemops accounts list` | Connected cloud accounts |
|
|
68
|
+
| `teemops accounts connect` | Start connecting an AWS account; returns a link for a person to open |
|
|
69
|
+
| `teemops scan start --account <id> --wait` | Scan an account and wait for the result |
|
|
70
|
+
| `teemops findings list` | Open findings, most severe first. Filter with `--severity`, `--account`, `--service`, `--region` |
|
|
71
|
+
| `teemops findings get --id <id>` | One finding and how to fix it |
|
|
72
|
+
| `teemops insights` | Health score, the factors behind it, and KPIs |
|
|
73
|
+
| `teemops members list` | Your organization's members |
|
|
74
|
+
| `teemops billing …` | Your plan |
|
|
75
|
+
|
|
76
|
+
`teemops help` lists every command. `teemops help --json` returns the full command tree, with
|
|
77
|
+
flags, environment variables and exit codes, for an agent to read.
|
|
78
|
+
|
|
79
|
+
## Gating a build
|
|
80
|
+
|
|
81
|
+
`--fail-on <severity>` exits with code 5 when any open finding is at or above that severity:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
teemops scan start --account 123456789012 --wait && teemops findings list --fail-on critical
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Exit codes
|
|
88
|
+
|
|
89
|
+
| Code | Meaning |
|
|
90
|
+
| --- | --- |
|
|
91
|
+
| 0 | Success |
|
|
92
|
+
| 1 | Error, or a scan that did not succeed |
|
|
93
|
+
| 2 | Usage error |
|
|
94
|
+
| 3 | Not signed in, or the token was refused |
|
|
95
|
+
| 4 | Not found |
|
|
96
|
+
| 5 | `--fail-on` matched findings |
|
|
97
|
+
| 6 | Conflict: no plan, over a plan limit, or a billing conflict |
|
|
98
|
+
|
|
99
|
+
## Environment
|
|
100
|
+
|
|
101
|
+
| Variable | Purpose |
|
|
102
|
+
| --- | --- |
|
|
103
|
+
| `TEEMOPS_TOKEN` | An agent credential or personal access token. Takes precedence over `teemops login` |
|
|
104
|
+
| `TEEMOPS_API_URL` | API base URL. Default `https://api.teemops.com` |
|
|
105
|
+
|
|
106
|
+
## MCP
|
|
107
|
+
|
|
108
|
+
The same API is available to MCP clients at `https://api.teemops.com/mcp`, with
|
|
109
|
+
`TEEMOPS_TOKEN` as the bearer token.
|
|
110
|
+
|
|
111
|
+
## License
|
|
112
|
+
|
|
113
|
+
GPL-3.0-only
|
package/dist/teemops.js
CHANGED
|
@@ -19739,6 +19739,8 @@ var MeResponse = external_exports.object({
|
|
|
19739
19739
|
/** auth.teem.cloud subject. Null for an agent, which acts for the org, not a person. */
|
|
19740
19740
|
userId: external_exports.string().nullable(),
|
|
19741
19741
|
email: external_exports.email().nullable(),
|
|
19742
|
+
/** The person's name, for "[First name]'s organization". Filled for a person with no active org; null when not known. Optional so a newer client still parses an older API. */
|
|
19743
|
+
name: external_exports.string().nullable().optional(),
|
|
19742
19744
|
/** Null when signed in but no active org yet (auth.teem.cloud sends no org claim). */
|
|
19743
19745
|
org: external_exports.object({
|
|
19744
19746
|
id: external_exports.string(),
|
|
@@ -19817,7 +19819,10 @@ var Account = external_exports.object({
|
|
|
19817
19819
|
connectedAt: external_exports.iso.datetime(),
|
|
19818
19820
|
/** When the next daily scan starts (UTC); null while paused or when the account has no slot yet. Defaults so a newer CLI parses an older API. */
|
|
19819
19821
|
nextScanAt: external_exports.iso.datetime().nullable().default(null),
|
|
19820
|
-
/**
|
|
19822
|
+
/**
|
|
19823
|
+
* Why scans are off for this account: the org has no plan (and the account's free scan is used or unavailable),
|
|
19824
|
+
* or the plan covers only older accounts.
|
|
19825
|
+
*/
|
|
19821
19826
|
scanPaused: external_exports.enum(["no_plan", "over_limit"]).nullable().default(null)
|
|
19822
19827
|
});
|
|
19823
19828
|
var AccountListResponse = external_exports.object({ data: external_exports.array(Account) });
|
|
@@ -19852,16 +19857,18 @@ var Finding = external_exports.object({
|
|
|
19852
19857
|
provider: external_exports.enum(["aws"]),
|
|
19853
19858
|
/** The cloud account's own id (12 digits for AWS). */
|
|
19854
19859
|
accountId: external_exports.string(),
|
|
19855
|
-
/** `global` for global services. */
|
|
19856
|
-
region: external_exports.string(),
|
|
19860
|
+
/** `global` for global services. `null` while the response is locked (X-11). */
|
|
19861
|
+
region: external_exports.string().nullable(),
|
|
19857
19862
|
service: external_exports.string(),
|
|
19858
|
-
|
|
19859
|
-
|
|
19863
|
+
/** `null` while the response is locked (X-11). */
|
|
19864
|
+
resourceType: external_exports.string().nullable(),
|
|
19865
|
+
/** `null` while the response is locked (X-11). */
|
|
19866
|
+
resourceId: external_exports.string().nullable(),
|
|
19860
19867
|
firstSeenAt: external_exports.iso.datetime(),
|
|
19861
19868
|
lastSeenAt: external_exports.iso.datetime(),
|
|
19862
19869
|
resolvedAt: external_exports.iso.datetime().nullable(),
|
|
19863
|
-
/** `skill` names the AR-1 remediation skill, once those exist. */
|
|
19864
|
-
remediation: external_exports.object({ text: external_exports.string(), skill: external_exports.string().optional() }),
|
|
19870
|
+
/** `skill` names the AR-1 remediation skill, once those exist. `null` while the response is locked (X-11). */
|
|
19871
|
+
remediation: external_exports.object({ text: external_exports.string(), skill: external_exports.string().optional() }).nullable(),
|
|
19865
19872
|
frameworks: external_exports.array(external_exports.string())
|
|
19866
19873
|
});
|
|
19867
19874
|
var FindingsQuery = external_exports.object({
|
|
@@ -19875,8 +19882,8 @@ var FindingsQuery = external_exports.object({
|
|
|
19875
19882
|
/** Opaque, from the previous page's `nextCursor`; only valid with the same filters. */
|
|
19876
19883
|
cursor: external_exports.string().min(1).optional()
|
|
19877
19884
|
});
|
|
19878
|
-
var FindingListResponse = external_exports.object({ data: external_exports.array(Finding), nextCursor: external_exports.string().nullable() });
|
|
19879
|
-
var FindingResponse = external_exports.object({ data: Finding });
|
|
19885
|
+
var FindingListResponse = external_exports.object({ data: external_exports.array(Finding), nextCursor: external_exports.string().nullable(), locked: external_exports.boolean().default(false) });
|
|
19886
|
+
var FindingResponse = external_exports.object({ data: Finding, locked: external_exports.boolean().default(false) });
|
|
19880
19887
|
|
|
19881
19888
|
// ../../packages/contracts/src/scans.ts
|
|
19882
19889
|
var ScanStatus = external_exports.enum(["queued", "running", "succeeded", "partial", "failed"]);
|
|
@@ -19938,7 +19945,7 @@ var InsightsResponse = external_exports.object({
|
|
|
19938
19945
|
score: external_exports.number().int().min(0).max(100).nullable(),
|
|
19939
19946
|
band: HealthBand.nullable(),
|
|
19940
19947
|
weights: HealthWeights,
|
|
19941
|
-
factors: external_exports.array(Factor),
|
|
19948
|
+
factors: external_exports.array(Factor).nullable(),
|
|
19942
19949
|
kpis: external_exports.object({
|
|
19943
19950
|
openNow: external_exports.number().int(),
|
|
19944
19951
|
/** Open at the period start, reconstructed from firstSeenAt and resolvedAt. */
|
|
@@ -19951,22 +19958,21 @@ var InsightsResponse = external_exports.object({
|
|
|
19951
19958
|
coverage: external_exports.number().int().min(0).max(100).nullable(),
|
|
19952
19959
|
/** Days, to one decimal, for criticals resolved in the period; null when none were. */
|
|
19953
19960
|
medianDaysToFixCritical: external_exports.number().nullable()
|
|
19954
|
-
}),
|
|
19955
|
-
frameworks: external_exports.array(FrameworkScore),
|
|
19961
|
+
}).nullable(),
|
|
19962
|
+
frameworks: external_exports.array(FrameworkScore).nullable(),
|
|
19956
19963
|
/** 12 equal buckets across the period, oldest first. `end` is exclusive except for the last bucket. */
|
|
19957
|
-
trend: external_exports.array(
|
|
19958
|
-
external_exports.object({ start: external_exports.iso.datetime(), end: external_exports.iso.datetime(), raised: external_exports.number().int(), resolved: external_exports.number().int() })
|
|
19959
|
-
),
|
|
19964
|
+
trend: external_exports.array(external_exports.object({ start: external_exports.iso.datetime(), end: external_exports.iso.datetime(), raised: external_exports.number().int(), resolved: external_exports.number().int() })).nullable(),
|
|
19960
19965
|
/** Open now, ignoring the period, so it matches Findings. */
|
|
19961
19966
|
openBy: external_exports.object({
|
|
19962
19967
|
severity: external_exports.record(Severity, external_exports.number().int()),
|
|
19963
|
-
service: external_exports.array(external_exports.object({ service: external_exports.string(), ...bySeverity })),
|
|
19964
|
-
framework: external_exports.array(external_exports.object({ id: external_exports.string(), ...bySeverity }))
|
|
19968
|
+
service: external_exports.array(external_exports.object({ service: external_exports.string(), ...bySeverity })).nullable(),
|
|
19969
|
+
framework: external_exports.array(external_exports.object({ id: external_exports.string(), ...bySeverity })).nullable()
|
|
19965
19970
|
}),
|
|
19966
19971
|
period: InsightsPeriod,
|
|
19967
19972
|
account: external_exports.string().nullable(),
|
|
19968
19973
|
generatedAt: external_exports.iso.datetime()
|
|
19969
|
-
})
|
|
19974
|
+
}),
|
|
19975
|
+
locked: external_exports.boolean().default(false)
|
|
19970
19976
|
});
|
|
19971
19977
|
var WeightsResponse = external_exports.object({ data: HealthWeights });
|
|
19972
19978
|
|
|
@@ -19994,6 +20000,12 @@ var Plan = external_exports.enum(["solo", "team", "business"]);
|
|
|
19994
20000
|
var PlanOrNone = external_exports.enum(["none", ...Plan.options]);
|
|
19995
20001
|
var BillingStatus = external_exports.enum(["none", "trialing", "active", "past_due", "ended"]);
|
|
19996
20002
|
var Currency = external_exports.enum(["usd", "nzd", "aud"]);
|
|
20003
|
+
var BillingCountry = external_exports.enum(["nz", "au", "other"]);
|
|
20004
|
+
var COUNTRY_CURRENCIES = {
|
|
20005
|
+
nz: ["nzd"],
|
|
20006
|
+
au: ["aud", "usd"],
|
|
20007
|
+
other: ["usd"]
|
|
20008
|
+
};
|
|
19997
20009
|
var PlanLimits = external_exports.object({ users: external_exports.number().int(), cloudAccounts: external_exports.number().int() });
|
|
19998
20010
|
var Entitlements = external_exports.object({
|
|
19999
20011
|
plan: PlanOrNone,
|
|
@@ -20017,6 +20029,13 @@ var PlanOffer = external_exports.object({
|
|
|
20017
20029
|
})
|
|
20018
20030
|
)
|
|
20019
20031
|
});
|
|
20032
|
+
var FreeScanStatus = external_exports.enum(["running", "succeeded", "partial", "expired"]);
|
|
20033
|
+
var FreeScan = external_exports.object({
|
|
20034
|
+
status: FreeScanStatus,
|
|
20035
|
+
finishedAt: external_exports.iso.datetime().nullable(),
|
|
20036
|
+
/** `finishedAt` plus 14 days: when the free results are (or were) deleted unless a plan is chosen. Null while running. */
|
|
20037
|
+
deletesAt: external_exports.iso.datetime().nullable()
|
|
20038
|
+
});
|
|
20020
20039
|
var BillingResponse = external_exports.object({
|
|
20021
20040
|
data: external_exports.object({
|
|
20022
20041
|
entitlements: Entitlements,
|
|
@@ -20025,12 +20044,109 @@ var BillingResponse = external_exports.object({
|
|
|
20025
20044
|
canManage: external_exports.boolean(),
|
|
20026
20045
|
/** Whether Checkout would offer the $1, 30-day start. */
|
|
20027
20046
|
introOffer: external_exports.boolean(),
|
|
20028
|
-
plans: external_exports.array(PlanOffer)
|
|
20047
|
+
plans: external_exports.array(PlanOffer),
|
|
20048
|
+
/** Null until the org's free scan starts, and again if it failed (it can be retried). */
|
|
20049
|
+
freeScan: FreeScan.nullable().default(null),
|
|
20050
|
+
/** An owner of the org, for "Only [owner] can choose a plan". Null when none has a known email. Memberships store no name. */
|
|
20051
|
+
owner: external_exports.object({ email: external_exports.email() }).nullable().default(null)
|
|
20029
20052
|
})
|
|
20030
20053
|
});
|
|
20031
|
-
var CheckoutRequest = external_exports.strictObject({ plan: Plan, currency: Currency });
|
|
20054
|
+
var CheckoutRequest = external_exports.strictObject({ plan: Plan, country: BillingCountry, currency: Currency }).refine((r) => COUNTRY_CURRENCIES[r.country].includes(r.currency), { message: "That currency is not available in that country", path: ["currency"] });
|
|
20032
20055
|
var SessionUrlResponse = external_exports.object({ data: external_exports.object({ url: external_exports.url({ protocol: /^https$/ }) }) });
|
|
20033
20056
|
|
|
20057
|
+
// ../../packages/contracts/src/notifications.ts
|
|
20058
|
+
var SLACK_WEBHOOK_PATTERN = /^https:\/\/hooks\.slack\.com\/services\/[A-Za-z0-9/_-]+$/;
|
|
20059
|
+
var NotificationSettings = external_exports.object({
|
|
20060
|
+
email: external_exports.object({ enabled: external_exports.boolean() }),
|
|
20061
|
+
slack: external_exports.object({
|
|
20062
|
+
configured: external_exports.boolean(),
|
|
20063
|
+
last4: external_exports.string().nullable(),
|
|
20064
|
+
/** Slack answered 404 or 410: the webhook is gone, and teemops has stopped posting until it is replaced. */
|
|
20065
|
+
broken: external_exports.boolean()
|
|
20066
|
+
})
|
|
20067
|
+
});
|
|
20068
|
+
var NotificationSettingsResponse = external_exports.object({ data: NotificationSettings });
|
|
20069
|
+
var UpdateNotificationsRequest = external_exports.object({
|
|
20070
|
+
emailEnabled: external_exports.boolean().optional(),
|
|
20071
|
+
slackWebhookUrl: external_exports.string().max(300).regex(SLACK_WEBHOOK_PATTERN).nullable().optional()
|
|
20072
|
+
});
|
|
20073
|
+
var TestNotificationRequest = external_exports.object({ channel: external_exports.enum(["email", "slack"]) });
|
|
20074
|
+
var TestNotificationResponse = external_exports.object({ data: external_exports.object({ sent: external_exports.literal(true) }) });
|
|
20075
|
+
|
|
20076
|
+
// ../../packages/contracts/src/events.ts
|
|
20077
|
+
var MemberJoinedRole = external_exports.enum(["owner", "admin", "member"]);
|
|
20078
|
+
var OrgMemberJoinedV1 = external_exports.object({
|
|
20079
|
+
orgId: external_exports.string().min(1),
|
|
20080
|
+
orgName: external_exports.string(),
|
|
20081
|
+
userId: external_exports.string().min(1),
|
|
20082
|
+
email: external_exports.email(),
|
|
20083
|
+
role: MemberJoinedRole,
|
|
20084
|
+
/** Epoch milliseconds. */
|
|
20085
|
+
at: external_exports.number().int().nonnegative()
|
|
20086
|
+
});
|
|
20087
|
+
var BillingSubscriptionChangedV1 = external_exports.object({
|
|
20088
|
+
orgId: external_exports.string().min(1),
|
|
20089
|
+
orgName: external_exports.string().nullable(),
|
|
20090
|
+
/**
|
|
20091
|
+
* IdP org owners as last seen by teemops. May omit owners who never signed in to teemops or whose email
|
|
20092
|
+
* isn't filled yet, and may include recently demoted or removed owners. The paying owner is always
|
|
20093
|
+
* covered by `stripeCustomerEmail`.
|
|
20094
|
+
*/
|
|
20095
|
+
ownerEmails: external_exports.array(external_exports.email()),
|
|
20096
|
+
status: BillingStatus,
|
|
20097
|
+
plan: PlanOrNone,
|
|
20098
|
+
trialEndsAt: external_exports.iso.datetime().nullable(),
|
|
20099
|
+
currentPeriodEnd: external_exports.iso.datetime().nullable(),
|
|
20100
|
+
stripeCustomerEmail: external_exports.email().nullable(),
|
|
20101
|
+
/** The most recently opened Checkout session, not necessarily the one that was paid. */
|
|
20102
|
+
checkoutSessionId: external_exports.string().nullable(),
|
|
20103
|
+
/** Epoch milliseconds of the Stripe read this snapshot reflects; a consumer keeps the latest. */
|
|
20104
|
+
asOf: external_exports.number().int().nonnegative()
|
|
20105
|
+
});
|
|
20106
|
+
|
|
20107
|
+
// ../../packages/contracts/src/orgs.ts
|
|
20108
|
+
var Org = external_exports.object({
|
|
20109
|
+
id: external_exports.string(),
|
|
20110
|
+
name: external_exports.string(),
|
|
20111
|
+
role: Role,
|
|
20112
|
+
active: external_exports.boolean()
|
|
20113
|
+
});
|
|
20114
|
+
var OrgListResponse = external_exports.object({ data: external_exports.array(Org) });
|
|
20115
|
+
var OrgResponse = external_exports.object({ data: Org });
|
|
20116
|
+
var CreateOrgRequest = external_exports.object({ name: external_exports.string().trim().min(1).max(80) });
|
|
20117
|
+
var SetActiveOrgRequest = external_exports.object({ organizationId: external_exports.string().min(1).max(128) });
|
|
20118
|
+
var OnboardingState = external_exports.object({ hidden: external_exports.boolean() });
|
|
20119
|
+
var OnboardingResponse = external_exports.object({ data: OnboardingState });
|
|
20120
|
+
|
|
20121
|
+
// ../../packages/contracts/src/members.ts
|
|
20122
|
+
var MemberRole = external_exports.enum(["viewer", "auditor", "administrator"]);
|
|
20123
|
+
var Member = external_exports.object({
|
|
20124
|
+
userId: external_exports.string(),
|
|
20125
|
+
name: external_exports.string().nullable(),
|
|
20126
|
+
email: external_exports.string().nullable(),
|
|
20127
|
+
role: Role,
|
|
20128
|
+
joinedAt: external_exports.string().nullable(),
|
|
20129
|
+
/** The caller. */
|
|
20130
|
+
you: external_exports.boolean()
|
|
20131
|
+
});
|
|
20132
|
+
var Seats = external_exports.object({ used: external_exports.number().int().nonnegative(), limit: external_exports.number().int().nonnegative().nullable() });
|
|
20133
|
+
var MemberListResponse = external_exports.object({ data: external_exports.array(Member), seats: Seats.nullable() });
|
|
20134
|
+
var MemberResponse = external_exports.object({ data: Member });
|
|
20135
|
+
var UpdateMemberRequest = external_exports.object({ role: MemberRole });
|
|
20136
|
+
var Invitation = external_exports.object({
|
|
20137
|
+
id: external_exports.string(),
|
|
20138
|
+
email: external_exports.string(),
|
|
20139
|
+
role: MemberRole,
|
|
20140
|
+
invitedBy: external_exports.object({ userId: external_exports.string(), name: external_exports.string().nullable(), email: external_exports.string().nullable() }),
|
|
20141
|
+
expiresAt: external_exports.string()
|
|
20142
|
+
});
|
|
20143
|
+
var InvitationListResponse = external_exports.object({ data: external_exports.array(Invitation) });
|
|
20144
|
+
var InvitationResponse = external_exports.object({ data: Invitation });
|
|
20145
|
+
var CreateInvitationRequest = external_exports.object({
|
|
20146
|
+
email: external_exports.string().trim().toLowerCase().pipe(external_exports.email().max(254)),
|
|
20147
|
+
role: MemberRole
|
|
20148
|
+
});
|
|
20149
|
+
|
|
20034
20150
|
// ../../packages/contracts/src/index.ts
|
|
20035
20151
|
var ErrorBody = external_exports.object({
|
|
20036
20152
|
error: external_exports.object({
|
|
@@ -20070,16 +20186,27 @@ var EXPIRY_MARGIN_MS = 6e4;
|
|
|
20070
20186
|
var LOCK_POLL_MS = 250;
|
|
20071
20187
|
var LOCK_WAIT_MS = 1e4;
|
|
20072
20188
|
var LOCK_STALE_MS = 3e4;
|
|
20073
|
-
|
|
20189
|
+
var PRODUCTION_API = "https://api.teemops.com";
|
|
20190
|
+
var STAGING_AUDIENCE = "https://api-staging.teemops.com";
|
|
20191
|
+
var isProduction = (apiUrl) => {
|
|
20192
|
+
try {
|
|
20193
|
+
return new URL(apiUrl).origin === PRODUCTION_API;
|
|
20194
|
+
} catch {
|
|
20195
|
+
return false;
|
|
20196
|
+
}
|
|
20197
|
+
};
|
|
20198
|
+
function authConfig(env, apiUrl = PRODUCTION_API) {
|
|
20074
20199
|
return {
|
|
20075
20200
|
issuer: (env.TEEMOPS_AUTH_ISSUER || "https://auth.teem.cloud/api/auth").replace(/\/$/, ""),
|
|
20076
20201
|
clientId: env.TEEMOPS_CLIENT_ID ?? DEFAULT_CLI_CLIENT_ID,
|
|
20077
|
-
audience: env.TEEMOPS_API_AUDIENCE ||
|
|
20202
|
+
audience: env.TEEMOPS_API_AUDIENCE || (isProduction(apiUrl) ? PRODUCTION_API : STAGING_AUDIENCE)
|
|
20078
20203
|
};
|
|
20079
20204
|
}
|
|
20080
20205
|
var Credentials = external_exports.object({
|
|
20081
20206
|
issuer: external_exports.string(),
|
|
20082
20207
|
clientId: external_exports.string(),
|
|
20208
|
+
/** The API the tokens are for. Files saved before stages had their own audience are production's. */
|
|
20209
|
+
audience: external_exports.string().default(PRODUCTION_API),
|
|
20083
20210
|
accessToken: external_exports.string(),
|
|
20084
20211
|
refreshToken: external_exports.string(),
|
|
20085
20212
|
expiresAt: external_exports.string()
|
|
@@ -20183,7 +20310,7 @@ async function login(io, cfg) {
|
|
|
20183
20310
|
});
|
|
20184
20311
|
const token = TokenResponse.safeParse(res.body);
|
|
20185
20312
|
if (res.ok && token.success) {
|
|
20186
|
-
const creds = toCredentials(token.data, { issuer: cfg.issuer, clientId: cfg.clientId });
|
|
20313
|
+
const creds = toCredentials(token.data, { issuer: cfg.issuer, clientId: cfg.clientId, audience: cfg.audience });
|
|
20187
20314
|
await saveCredentials(io, creds);
|
|
20188
20315
|
return { signedIn: true, expiresAt: creds.expiresAt };
|
|
20189
20316
|
}
|
|
@@ -20220,6 +20347,13 @@ async function tokenSource(io, cfg) {
|
|
|
20220
20347
|
if (fixed) return { token: async () => fixed, refresh: async () => null };
|
|
20221
20348
|
let creds = await readCredentials(io);
|
|
20222
20349
|
if (!creds) throw new CliError("unauthenticated", "Not signed in \u2014 run `teemops login` or set TEEMOPS_TOKEN", ExitCode.auth);
|
|
20350
|
+
if (creds.audience !== cfg.audience) {
|
|
20351
|
+
throw new CliError(
|
|
20352
|
+
"wrong_stage",
|
|
20353
|
+
`Signed in for ${creds.audience}, but this API takes tokens for ${cfg.audience} \u2014 run \`teemops login\` with the same API URL`,
|
|
20354
|
+
ExitCode.auth
|
|
20355
|
+
);
|
|
20356
|
+
}
|
|
20223
20357
|
let refreshed = false;
|
|
20224
20358
|
const fresh = (c) => Date.parse(c.expiresAt) - Date.now() >= EXPIRY_MARGIN_MS;
|
|
20225
20359
|
const refresh = () => withLock(io, async () => {
|
|
@@ -20312,14 +20446,17 @@ var billingCommands = {
|
|
|
20312
20446
|
auth: true,
|
|
20313
20447
|
flags: {
|
|
20314
20448
|
plan: str("Plan: solo | team | business"),
|
|
20315
|
-
|
|
20449
|
+
country: str("Where the organization is based: nz | au | other. Decides the currency and GST"),
|
|
20450
|
+
currency: str("nz: nzd. au: aud (default) or usd. other: usd")
|
|
20316
20451
|
},
|
|
20317
20452
|
run: (ctx) => {
|
|
20318
|
-
const
|
|
20453
|
+
const country = BillingCountry.safeParse(ctx.flags.country);
|
|
20454
|
+
const currency = ctx.flags.currency ?? (country.success ? COUNTRY_CURRENCIES[country.data][0] : void 0);
|
|
20455
|
+
const parsed = CheckoutRequest.safeParse({ plan: ctx.flags.plan, country: ctx.flags.country, currency });
|
|
20319
20456
|
if (!parsed.success) {
|
|
20320
20457
|
const flag = String(parsed.error.issues[0]?.path[0]);
|
|
20321
|
-
const options = flag === "plan" ? "solo | team | business" :
|
|
20322
|
-
throw new CliError("usage", `Missing or invalid --${flag}: use ${options}`, ExitCode.usage);
|
|
20458
|
+
const options = flag === "plan" ? "solo | team | business" : flag === "country" || !country.success ? BillingCountry.options.join(" | ") : `${COUNTRY_CURRENCIES[country.data].join(" | ")} with --country ${country.data}`;
|
|
20459
|
+
throw new CliError("usage", `Missing or invalid --${flag === "currency" && !country.success ? "country" : flag}: use ${options}`, ExitCode.usage);
|
|
20323
20460
|
}
|
|
20324
20461
|
return session(ctx, "/v1/billing/checkout", parsed.data);
|
|
20325
20462
|
},
|
|
@@ -20367,17 +20504,22 @@ function insightsArgs(flags) {
|
|
|
20367
20504
|
throw new CliError("usage", `Missing or invalid --${String(parsed.error.issues[0]?.path[0] ?? "input")}`, ExitCode.usage);
|
|
20368
20505
|
}
|
|
20369
20506
|
var dash = (v, suffix = "") => v === null ? "\u2014" : `${v}${suffix}`;
|
|
20370
|
-
|
|
20507
|
+
var LOCKED_HINT = "Results are locked. Choose a plan: teemops billing checkout --plan <solo|team|business> --country <nz|au|other>";
|
|
20508
|
+
var LOCKED = "locked";
|
|
20509
|
+
var nothing = (locked) => locked ? LOCKED : "\u2014";
|
|
20510
|
+
function renderInsights({ data: d, locked }) {
|
|
20371
20511
|
const score = d.score === null ? "Not scored yet" : `${d.score}${d.band ? ` (${d.band.replace("_", " ")})` : ""}`;
|
|
20372
20512
|
const k = d.kpis;
|
|
20373
20513
|
return [
|
|
20374
20514
|
plain(`Health score: ${score} \xB7 ${d.period} \xB7 ${d.account ? `account ${d.account}` : "all accounts"}`),
|
|
20375
20515
|
"",
|
|
20376
|
-
table(["FACTOR", "WEIGHT", "SCORE", "DETAIL"], d.factors.map((f) => [f.key, `${f.weight}%`, dash(f.score), f.detail]))
|
|
20516
|
+
d.factors ? table(["FACTOR", "WEIGHT", "SCORE", "DETAIL"], d.factors.map((f) => [f.key, `${f.weight}%`, dash(f.score), f.detail])) : `Factors: ${LOCKED}`,
|
|
20377
20517
|
"",
|
|
20378
|
-
|
|
20379
|
-
|
|
20380
|
-
|
|
20518
|
+
...k ? [
|
|
20519
|
+
`Open now ${k.openNow} (was ${k.openAtStart}) \xB7 raised ${k.raised} \xB7 resolved ${k.resolved} \xB7 remediation ${dash(k.remediationRate, "%")}`,
|
|
20520
|
+
`Coverage ${dash(k.coverage, "%")} \xB7 median days to fix a critical ${dash(k.medianDaysToFixCritical)}`
|
|
20521
|
+
] : [`Key numbers: ${LOCKED}`, `Open by severity: ${[...SEVERITIES].reverse().map((s) => `${s} ${d.openBy.severity[s] ?? 0}`).join(" \xB7 ")}`],
|
|
20522
|
+
...!d.frameworks ? ["", `Frameworks: ${LOCKED}`] : !d.frameworks.length ? [] : [
|
|
20381
20523
|
"",
|
|
20382
20524
|
table(
|
|
20383
20525
|
["FRAMEWORK", "SCORE", "PASSING", "OPEN C/H/M/L"],
|
|
@@ -20388,7 +20530,8 @@ function renderInsights(d) {
|
|
|
20388
20530
|
`${f.open.critical}/${f.open.high}/${f.open.medium}/${f.open.low}`
|
|
20389
20531
|
])
|
|
20390
20532
|
)
|
|
20391
|
-
]
|
|
20533
|
+
],
|
|
20534
|
+
...locked ? ["", LOCKED_HINT] : []
|
|
20392
20535
|
].join("\n");
|
|
20393
20536
|
}
|
|
20394
20537
|
var SEVERITIES = Severity.options;
|
|
@@ -20537,7 +20680,7 @@ var commands = {
|
|
|
20537
20680
|
account ${plain(d.accountRef)}` : d.status
|
|
20538
20681
|
},
|
|
20539
20682
|
remove: {
|
|
20540
|
-
summary: "Disconnect a cloud account",
|
|
20683
|
+
summary: "Disconnect a cloud account (its findings and scans are deleted after 7 days unless it is reconnected)",
|
|
20541
20684
|
auth: true,
|
|
20542
20685
|
flags: { id: str2("Account id from `accounts list`") },
|
|
20543
20686
|
run: async ({ api, flags }) => {
|
|
@@ -20648,27 +20791,38 @@ ${showOnce(d.credential)}`
|
|
|
20648
20791
|
render: (d) => [
|
|
20649
20792
|
d.data.length === 0 ? "No findings match." : table(
|
|
20650
20793
|
["SEVERITY", "TITLE", "ACCOUNT", "REGION", "RESOURCE", "ID"],
|
|
20651
|
-
d.data.map((f) => [
|
|
20794
|
+
d.data.map((f) => [
|
|
20795
|
+
upper(f.severity),
|
|
20796
|
+
f.title,
|
|
20797
|
+
f.accountId,
|
|
20798
|
+
f.region ?? nothing(d.locked),
|
|
20799
|
+
f.resourceId ? `${f.resourceType} ${f.resourceId}` : nothing(d.locked),
|
|
20800
|
+
f.id
|
|
20801
|
+
])
|
|
20652
20802
|
),
|
|
20653
|
-
...d.nextCursor ? [`More: --cursor ${d.nextCursor}`] : []
|
|
20803
|
+
...d.nextCursor ? [`More: --cursor ${d.nextCursor}`] : [],
|
|
20804
|
+
...d.locked ? ["", LOCKED_HINT] : []
|
|
20654
20805
|
].join("\n")
|
|
20655
20806
|
},
|
|
20656
20807
|
get: {
|
|
20657
20808
|
summary: "Show one finding with how to fix it",
|
|
20658
20809
|
auth: true,
|
|
20659
20810
|
flags: { id: str2("Finding id from `findings list`") },
|
|
20811
|
+
envelope: true,
|
|
20812
|
+
// `locked` is beside `data`.
|
|
20660
20813
|
run: async ({ api, flags }) => {
|
|
20661
20814
|
const parsed = FindingsGetArgs.safeParse({ id: flags.id });
|
|
20662
20815
|
if (!parsed.success) throw new CliError("usage", "Missing --id", ExitCode.usage);
|
|
20663
|
-
return FindingResponse.parse(await api(`/v1/findings/${encodeURIComponent(parsed.data.id)}`))
|
|
20816
|
+
return FindingResponse.parse(await api(`/v1/findings/${encodeURIComponent(parsed.data.id)}`));
|
|
20664
20817
|
},
|
|
20665
|
-
render: (f) => [
|
|
20818
|
+
render: ({ data: f, locked }) => [
|
|
20666
20819
|
`${upper(f.severity)} ${f.title}`,
|
|
20667
|
-
`Resource: ${f.resourceType} ${f.resourceId} (${f.accountId}, ${f.region})`,
|
|
20820
|
+
`Resource: ${f.resourceId ? `${f.resourceType} ${f.resourceId} (${f.accountId}, ${f.region})` : `${nothing(locked)} (${f.accountId})`}`,
|
|
20668
20821
|
`Rule: ${f.ruleId}`,
|
|
20669
20822
|
`Status: ${f.status}, first seen ${day(f.firstSeenAt)}`,
|
|
20670
20823
|
"",
|
|
20671
|
-
`Fix: ${f.remediation.text}
|
|
20824
|
+
`Fix: ${f.remediation ? f.remediation.text : nothing(locked)}`,
|
|
20825
|
+
...locked ? ["", LOCKED_HINT] : []
|
|
20672
20826
|
].map(plain).join("\n")
|
|
20673
20827
|
}
|
|
20674
20828
|
}
|
|
@@ -20680,13 +20834,34 @@ ${showOnce(d.credential)}`
|
|
|
20680
20834
|
period: str2("Period: 30d | 90d | 365d (default 30d)"),
|
|
20681
20835
|
account: str2("Cloud account id, the same value as `findings list --account` (default: all accounts)")
|
|
20682
20836
|
},
|
|
20837
|
+
envelope: true,
|
|
20838
|
+
// `locked` is beside `data`.
|
|
20683
20839
|
run: async ({ api, flags }) => {
|
|
20684
20840
|
const args = insightsArgs(flags);
|
|
20685
20841
|
const query = new URLSearchParams({ period: args.period ?? "30d", ...args.account ? { account: args.account } : {} });
|
|
20686
|
-
return InsightsResponse.parse(await api(`/v1/insights?${query}`))
|
|
20842
|
+
return InsightsResponse.parse(await api(`/v1/insights?${query}`));
|
|
20687
20843
|
},
|
|
20688
20844
|
render: renderInsights
|
|
20689
20845
|
},
|
|
20846
|
+
members: {
|
|
20847
|
+
summary: "List your organization's members",
|
|
20848
|
+
subcommands: {
|
|
20849
|
+
list: {
|
|
20850
|
+
summary: "List members with their role and when they joined, and the seats used (owners and administrators)",
|
|
20851
|
+
auth: true,
|
|
20852
|
+
envelope: true,
|
|
20853
|
+
// `seats` is beside `data`.
|
|
20854
|
+
run: async ({ api }) => MemberListResponse.parse(await api("/v1/members")),
|
|
20855
|
+
render: ({ data, seats }) => [
|
|
20856
|
+
table(
|
|
20857
|
+
["NAME", "EMAIL", "ROLE", "JOINED"],
|
|
20858
|
+
data.map((m) => [`${m.name ?? "\u2014"}${m.you ? " (you)" : ""}`, m.email ?? "\u2014", m.role, m.joinedAt ? day(m.joinedAt) : "\u2014"])
|
|
20859
|
+
),
|
|
20860
|
+
...seats ? ["", seats.limit === null ? `${seats.used} users` : `${seats.used} of ${seats.limit} users`] : []
|
|
20861
|
+
].join("\n")
|
|
20862
|
+
}
|
|
20863
|
+
}
|
|
20864
|
+
},
|
|
20690
20865
|
scan: {
|
|
20691
20866
|
summary: "Scan a connected cloud account and wait for the result",
|
|
20692
20867
|
subcommands: {
|
|
@@ -20793,10 +20968,10 @@ function describe3() {
|
|
|
20793
20968
|
TEEMOPS_API_URL: "API base URL, default https://api.teemops.com",
|
|
20794
20969
|
TEEMOPS_AUTH_ISSUER: "Sign-in issuer, default https://auth.teem.cloud/api/auth",
|
|
20795
20970
|
TEEMOPS_CLIENT_ID: "Client ID of the teemops CLI at the issuer",
|
|
20796
|
-
TEEMOPS_API_AUDIENCE: "Audience (resource) of tokens
|
|
20971
|
+
TEEMOPS_API_AUDIENCE: "Audience (resource) of tokens. Default: https://api.teemops.com for the production API, https://api-staging.teemops.com for any other API URL"
|
|
20797
20972
|
},
|
|
20798
20973
|
exitCodes: ExitCode,
|
|
20799
|
-
exitCodeNotes: { conflict: "409 conflict
|
|
20974
|
+
exitCodeNotes: { conflict: "409 conflict: no plan, over a plan limit, or a billing conflict" },
|
|
20800
20975
|
flags: globalFlags,
|
|
20801
20976
|
commands: Object.fromEntries(
|
|
20802
20977
|
Object.entries(commands).map(([k, c]) => [
|
|
@@ -20848,9 +21023,9 @@ async function main(io) {
|
|
|
20848
21023
|
throw new CliError("usage", `--${k} is not valid for "${positionals.slice(0, isGroup(entry) ? 2 : 1).join(" ")}"`, ExitCode.usage);
|
|
20849
21024
|
}
|
|
20850
21025
|
}
|
|
20851
|
-
const cfg = authConfig(io.env);
|
|
20852
|
-
const tokens = command.auth ? await tokenSource(io, cfg) : null;
|
|
20853
21026
|
const baseUrl = String(values["api-url"] || io.env.TEEMOPS_API_URL || "https://api.teemops.com").replace(/\/$/, "");
|
|
21027
|
+
const cfg = authConfig(io.env, baseUrl);
|
|
21028
|
+
const tokens = command.auth ? await tokenSource(io, cfg) : null;
|
|
20854
21029
|
const api = async (path, init = {}) => {
|
|
20855
21030
|
const send = async (token) => {
|
|
20856
21031
|
const headers = {};
|
|
@@ -20879,7 +21054,7 @@ async function main(io) {
|
|
|
20879
21054
|
const err = ErrorBody.safeParse(parsed);
|
|
20880
21055
|
const message = err.success ? err.data.error.message : `HTTP ${res.status}`;
|
|
20881
21056
|
const code = err.success ? err.data.error.code : "http_error";
|
|
20882
|
-
const exit = res.status === 401 || res.status === 403 ? ExitCode.auth : res.status === 404 ? ExitCode.notFound : ExitCode.error;
|
|
21057
|
+
const exit = res.status === 401 || res.status === 403 ? ExitCode.auth : res.status === 404 ? ExitCode.notFound : res.status === 409 ? ExitCode.conflict : ExitCode.error;
|
|
20883
21058
|
throw new CliError(code, message, exit, res.status);
|
|
20884
21059
|
};
|
|
20885
21060
|
const result = await command.run({ api, flags, io, cfg });
|