Skip to content

Commit 0ec0148

Browse files
Merge pull request #3 from ethanolivertroy/feat/investigation-workflows
feat: add curated investigation workflows
2 parents cfad650 + b76f981 commit 0ec0148

5 files changed

Lines changed: 338 additions & 15 deletions

File tree

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,10 +220,15 @@ In addition to raw OpenAPI operations, the CLI includes read-only workflow comma
220220
```bash
221221
drata summary --json --compact
222222
drata controls failing --json --compact
223+
drata controls get DCF-71 --json --compact
223224
drata monitors failing --json --compact
225+
drata monitors for-control DCF-71 --json --compact
226+
drata monitors get 31 --workspace-id 12 --json --compact
224227
drata connections list --status DISCONNECTED --json --compact
225228
drata personnel issues --json --compact
226-
drata evidence expiring --days 60 --json --compact
229+
drata personnel get --email alice@example.com --json --compact
230+
drata evidence list --workspace-id 12 --json --compact
231+
drata evidence expiring --days 60 --workspace-id 12 --json --compact
227232
```
228233

229234
These workflows use v1 list endpoints where they provide workspace-independent compliance rollups and automatically follow page/limit pagination. `--limit N` caps displayed items in workflow outputs without changing the underlying summary counts or API page size. Use `--max-pages N` to bound collection work for very large tenants.

src/cli.mjs

Lines changed: 68 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,13 @@ import {
1313
printWorkflowPayload,
1414
runConnectionsList,
1515
runControlsFailing,
16+
runControlsGet,
1617
runEvidenceExpiring,
18+
runEvidenceList,
1719
runMonitorsFailing,
20+
runMonitorsForControl,
21+
runMonitorsGet,
22+
runPersonnelGet,
1823
runPersonnelIssues,
1924
runSummary,
2025
runWorkflowOperation,
@@ -51,10 +56,15 @@ Usage:
5156
drata auth <login|status|check|logout>
5257
drata summary [--json] [--compact]
5358
drata controls failing [--json] [--compact]
59+
drata controls get <code> [--json] [--compact]
5460
drata monitors failing [--json] [--compact]
61+
drata monitors for-control <code> [--json] [--compact]
62+
drata monitors get <id> [--workspace-id ID] [--json] [--compact]
5563
drata connections list [--status STATUS] [--json] [--compact]
5664
drata personnel issues [--json] [--compact]
57-
drata evidence expiring [--days N] [--json] [--compact]
65+
drata personnel get <id>|--email EMAIL [--json] [--compact]
66+
drata evidence list [--workspace-id ID] [--json] [--compact]
67+
drata evidence expiring [--days N] [--workspace-id ID] [--json] [--compact]
5868
drata completion <bash|zsh|fish>
5969
drata agent-schema [v1|v2] [--tag TAG] [--search TEXT]
6070
drata <operation> [flags]
@@ -95,7 +105,9 @@ Examples:
95105
drata auth check --json
96106
drata summary --json --compact
97107
drata controls failing --json --compact
108+
drata controls get DCF-71 --json --compact
98109
drata monitors failing --json --compact
110+
drata monitors for-control DCF-71 --json --compact
99111
drata connections list --status DISCONNECTED --json --compact
100112
drata completion zsh
101113
drata agent-schema v2 --search controls
@@ -519,13 +531,24 @@ async function handleControlsWorkflow(args) {
519531
return;
520532
}
521533

522-
const flags = await parseWorkflowRequestFlags(rest);
523534
if (subcommand === "failing") {
535+
const flags = await parseWorkflowRequestFlags(rest);
524536
printWorkflowPayload(await runControlsFailing(flags), flags);
525537
return;
526538
}
527539

528-
fail("unknown_controls_command", `Unknown controls command "${subcommand}". Expected failing.`, {
540+
if (subcommand === "get") {
541+
const [code, ...flagArgs] = rest;
542+
if (!code || code === "--help" || code === "help") {
543+
printUsage();
544+
return;
545+
}
546+
const flags = await parseWorkflowRequestFlags(flagArgs);
547+
printWorkflowPayload(await runControlsGet(flags, { code }), flags);
548+
return;
549+
}
550+
551+
fail("unknown_controls_command", `Unknown controls command "${subcommand}". Expected failing or get.`, {
529552
command: subcommand,
530553
});
531554
}
@@ -537,13 +560,36 @@ async function handleMonitorsWorkflow(args) {
537560
return;
538561
}
539562

540-
const flags = await parseWorkflowRequestFlags(rest);
541563
if (subcommand === "failing") {
564+
const flags = await parseWorkflowRequestFlags(rest);
542565
printWorkflowPayload(await runMonitorsFailing(flags), flags);
543566
return;
544567
}
545568

546-
fail("unknown_monitors_command", `Unknown monitors command "${subcommand}". Expected failing.`, {
569+
if (subcommand === "for-control") {
570+
const [code, ...flagArgs] = rest;
571+
if (!code || code === "--help" || code === "help") {
572+
printUsage();
573+
return;
574+
}
575+
const flags = await parseWorkflowRequestFlags(flagArgs);
576+
printWorkflowPayload(await runMonitorsForControl(flags, { code }), flags);
577+
return;
578+
}
579+
580+
if (subcommand === "get") {
581+
const [id, ...flagArgs] = rest;
582+
if (!id || id === "--help" || id === "help") {
583+
printUsage();
584+
return;
585+
}
586+
const flags = await parseWorkflowRequestFlags(flagArgs);
587+
const workspaceId = takeWorkflowNamedFlag(flags, "workspace-id");
588+
printWorkflowPayload(await runMonitorsGet(flags, { id, workspaceId }), flags);
589+
return;
590+
}
591+
592+
fail("unknown_monitors_command", `Unknown monitors command "${subcommand}". Expected failing, for-control, or get.`, {
547593
command: subcommand,
548594
});
549595
}
@@ -574,13 +620,22 @@ async function handlePersonnelWorkflow(args) {
574620
return;
575621
}
576622

577-
const flags = await parseWorkflowRequestFlags(rest);
578623
if (subcommand === "issues") {
624+
const flags = await parseWorkflowRequestFlags(rest);
579625
printWorkflowPayload(await runPersonnelIssues(flags), flags);
580626
return;
581627
}
582628

583-
fail("unknown_personnel_command", `Unknown personnel command "${subcommand}". Expected issues.`, {
629+
if (subcommand === "get") {
630+
const positional = rest[0]?.startsWith("--") ? null : rest[0];
631+
const flagArgs = positional ? rest.slice(1) : rest;
632+
const flags = await parseWorkflowRequestFlags(flagArgs);
633+
const email = takeWorkflowNamedFlag(flags, "email");
634+
printWorkflowPayload(await runPersonnelGet(flags, { id: positional, email }), flags);
635+
return;
636+
}
637+
638+
fail("unknown_personnel_command", `Unknown personnel command "${subcommand}". Expected issues or get.`, {
584639
command: subcommand,
585640
});
586641
}
@@ -599,12 +654,17 @@ async function handleEvidenceWorkflow(args) {
599654
fail("invalid_days", `--days must be a non-negative integer`, { days });
600655
}
601656

657+
if (subcommand === "list") {
658+
printWorkflowPayload(await runEvidenceList(flags, { workspaceId }), flags);
659+
return;
660+
}
661+
602662
if (subcommand === "expiring") {
603663
printWorkflowPayload(await runEvidenceExpiring(flags, { days, workspaceId }), flags);
604664
return;
605665
}
606666

607-
fail("unknown_evidence_command", `Unknown evidence command "${subcommand}". Expected expiring.`, {
667+
fail("unknown_evidence_command", `Unknown evidence command "${subcommand}". Expected list or expiring.`, {
608668
command: subcommand,
609669
});
610670
}

src/lib/completion.mjs

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ const WORKFLOW_FLAGS = [
4444
"--retry",
4545
"--timeout-ms",
4646
"--max-pages",
47+
"--workspace-id",
4748
"--help",
4849
];
4950
const CONNECTION_STATUS_VALUES = ["CONNECTED", "DISCONNECTED", "FAILED", "NEVER_CONNECTED"];
@@ -97,6 +98,7 @@ const FLAGS_REQUIRING_VALUES = new Set([
9798
"--status",
9899
"--days",
99100
"--workspace-id",
101+
"--email",
100102
"--tag",
101103
"--search",
102104
]);
@@ -292,14 +294,14 @@ async function completeWords(index, words) {
292294

293295
if (first === "controls") {
294296
if (beforeWords.length <= 1 && !current.startsWith("--")) {
295-
return filterByPrefix(["failing"], current);
297+
return filterByPrefix(["failing", "get"], current);
296298
}
297299
return filterByPrefix(WORKFLOW_FLAGS, current);
298300
}
299301

300302
if (first === "monitors") {
301303
if (beforeWords.length <= 1 && !current.startsWith("--")) {
302-
return filterByPrefix(["failing"], current);
304+
return filterByPrefix(["failing", "for-control", "get"], current);
303305
}
304306
return filterByPrefix(WORKFLOW_FLAGS, current);
305307
}
@@ -313,16 +315,16 @@ async function completeWords(index, words) {
313315

314316
if (first === "personnel") {
315317
if (beforeWords.length <= 1 && !current.startsWith("--")) {
316-
return filterByPrefix(["issues"], current);
318+
return filterByPrefix(["issues", "get"], current);
317319
}
318-
return filterByPrefix(WORKFLOW_FLAGS, current);
320+
return filterByPrefix([...WORKFLOW_FLAGS, "--email"], current);
319321
}
320322

321323
if (first === "evidence") {
322324
if (beforeWords.length <= 1 && !current.startsWith("--")) {
323-
return filterByPrefix(["expiring"], current);
325+
return filterByPrefix(["list", "expiring"], current);
324326
}
325-
return filterByPrefix([...WORKFLOW_FLAGS, "--days", "--workspace-id"], current);
327+
return filterByPrefix([...WORKFLOW_FLAGS, "--days"], current);
326328
}
327329

328330
if (first === "ops") {

src/lib/workflows.mjs

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,11 +478,70 @@ export async function runControlsFailing(flags) {
478478
return buildControlsFailingPayload(data, flags);
479479
}
480480

481+
export async function runControlsGet(flags, options = {}) {
482+
const code = String(options.code ?? "");
483+
const queryFlags = cloneFlags(flags);
484+
pushNamed(queryFlags, "q", code);
485+
const { data } = await listV1("controls-get-controls", queryFlags);
486+
const control = dataItems(data).map(enrichControl).find((item) => item.code === code);
487+
if (!control) {
488+
fail("control_not_found", `Control ${code} was not found.`, { code });
489+
}
490+
491+
return flags.compact
492+
? { kind: "controls.get", control: compactControlsPayload({ controls: [control] }).controls[0] }
493+
: { kind: "controls.get", control };
494+
}
495+
481496
export async function runMonitorsFailing(flags) {
482497
const { data } = await listV1("list-monitors", flags);
483498
return buildMonitorsFailingPayload(data, flags);
484499
}
485500

501+
function controlIdentifierMatches(control, target) {
502+
return [control.code, control.id].filter((value) => value !== undefined && value !== null).some((value) => String(value) === target);
503+
}
504+
505+
export async function runMonitorsForControl(flags, options = {}) {
506+
const code = String(options.code ?? "");
507+
const { data } = await listV1("list-monitors", flags);
508+
// This uses controls embedded on the list response; use `monitors get --workspace-id` for detail-only fields.
509+
const monitors = dataItems(data).filter((monitor) => (monitor.controls ?? []).some((control) => controlIdentifierMatches(control, code)));
510+
const limitedMonitors = applyLimit(monitors, flags);
511+
const payload = {
512+
kind: "monitors.for-control",
513+
code,
514+
total: payloadTotal(data, dataItems(data)),
515+
matching: monitors.length,
516+
showing: limitedMonitors.length,
517+
monitors: limitedMonitors,
518+
};
519+
520+
return flags.compact ? compactMonitorsPayload(payload) : payload;
521+
}
522+
523+
export async function runMonitorsGet(flags, options = {}) {
524+
const id = String(options.id ?? "");
525+
let monitor = null;
526+
527+
if (options.workspaceId) {
528+
const detailFlags = withPath(flags, { workspaceId: options.workspaceId, testId: id });
529+
const { data } = await runWorkflowOperation("v1", "get-control-test-instance", detailFlags);
530+
monitor = data;
531+
} else {
532+
const { data } = await listV1("list-monitors", flags);
533+
monitor = dataItems(data).find((item) => String(item.id) === id);
534+
}
535+
536+
if (!monitor) {
537+
fail("monitor_not_found", `Monitor ${id} was not found.`, { id });
538+
}
539+
540+
return flags.compact
541+
? { kind: "monitors.get", monitor: compactMonitorsPayload({ monitors: [monitor] }).monitors[0] }
542+
: { kind: "monitors.get", monitor };
543+
}
544+
486545
export async function runConnectionsList(flags, options = {}) {
487546
const { data } = await listV1("get-connections", flags);
488547
return buildConnectionsListPayload(data, flags, options.status);
@@ -493,6 +552,43 @@ export async function runPersonnelIssues(flags) {
493552
return buildPersonnelIssuesPayload(data, flags);
494553
}
495554

555+
export async function runPersonnelGet(flags, options = {}) {
556+
if (options.email && options.id) {
557+
fail("conflicting_personnel_lookup", "Use either a personnel id or --email, not both.");
558+
}
559+
560+
if (options.email) {
561+
const detailFlags = withPath(flags, { email: options.email });
562+
const { data } = await runWorkflowOperation("v1", "get-personnel-details-by-email", detailFlags);
563+
return flags.compact ? { kind: "personnel.get", personnel: compactPersonnel(data) } : { kind: "personnel.get", personnel: data };
564+
}
565+
566+
if (options.id) {
567+
const detailFlags = withPath(flags, { id: options.id });
568+
const { data } = await runWorkflowOperation("v1", "get-personnel-details", detailFlags);
569+
return flags.compact ? { kind: "personnel.get", personnel: compactPersonnel(data) } : { kind: "personnel.get", personnel: data };
570+
}
571+
572+
fail("missing_personnel_lookup", "Provide a personnel id or --email.");
573+
}
574+
575+
export async function runEvidenceList(flags, options = {}) {
576+
const workspaceId = options.workspaceId || (await getFirstWorkspaceId(flags));
577+
const listFlags = withPath(withListDefaults(flags), { workspaceId });
578+
const { data } = await runWorkflowOperation("v1", "list-evidence", listFlags);
579+
const evidence = applyLimit(dataItems(data), flags);
580+
const payload = {
581+
kind: "evidence.list",
582+
workspaceId,
583+
total: payloadTotal(data, dataItems(data)),
584+
matching: dataItems(data).length,
585+
showing: evidence.length,
586+
evidence,
587+
};
588+
589+
return flags.compact ? compactEvidencePayload(payload) : payload;
590+
}
591+
496592
export async function runEvidenceExpiring(flags, options = {}) {
497593
// Defaulting to the first workspace keeps the command ergonomic for single-workspace tenants.
498594
// Multi-workspace tenants should pass --workspace-id explicitly for deterministic scope.
@@ -533,12 +629,22 @@ function formatWorkflowText(payload) {
533629
.join("\n");
534630
case "controls.failing":
535631
return [`Failing Controls: matching=${payload.matching} showing=${payload.showing}`, ...payload.controls.map((c) => `${c.code ?? c.id} ${c.status} ${c.name}`)].join("\n");
632+
case "controls.get":
633+
return `${payload.control.code ?? payload.control.id} ${payload.control.status} ${payload.control.name}`;
536634
case "monitors.failing":
537635
return [`Failing Monitors: matching=${payload.matching} showing=${payload.showing}`, ...payload.monitors.map((m) => `${m.id} ${monitorStatus(m)} ${m.name}`)].join("\n");
636+
case "monitors.for-control":
637+
return [`Monitors for ${payload.code}: matching=${payload.matching} showing=${payload.showing}`, ...payload.monitors.map((m) => `${m.id} ${monitorStatus(m)} ${m.name}`)].join("\n");
638+
case "monitors.get":
639+
return `${payload.monitor.id} ${payload.monitor.status ?? monitorStatus(payload.monitor)} ${payload.monitor.name}`;
538640
case "connections.list":
539641
return [`Connections: matching=${payload.matching} showing=${payload.showing}`, ...payload.connections.map((c) => `${c.id} ${connectionState(c)} ${c.clientAlias || c.clientType || ""}`)].join("\n");
540642
case "personnel.issues":
541643
return [`Personnel with device issues: matching=${payload.matching} showing=${payload.showing}`, ...payload.personnel.map((p) => `${p.id} ${p.user?.email ?? p.email ?? ""} failing_devices=${p.devicesFailingComplianceCount ?? 0}`)].join("\n");
644+
case "personnel.get":
645+
return `${payload.personnel.id} ${payload.personnel.user?.email ?? payload.personnel.email ?? ""}`;
646+
case "evidence.list":
647+
return [`Evidence: workspace=${payload.workspaceId} total=${payload.total} showing=${payload.showing}`, ...payload.evidence.map((e) => `${e.id} ${e.updatedAt ?? "unknown"} ${e.name ?? ""}`)].join("\n");
542648
case "evidence.expiring":
543649
return [`Stale Evidence: workspace=${payload.workspaceId ?? "auto"} days=${payload.days} matching=${payload.matching} showing=${payload.showing}`, ...payload.evidence.map((e) => `${e.id} ${e.updatedAt ?? "unknown"} ${e.name ?? ""}`)].join("\n");
544650
default:

0 commit comments

Comments
 (0)