@monster-orange/den 0.0.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.
Files changed (255) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/LICENSE +21 -0
  3. package/README.md +165 -1
  4. package/bin/den +68 -0
  5. package/docs/distributions.md +499 -0
  6. package/make/demo.mk +82 -0
  7. package/make/den.mk +80 -0
  8. package/make/journeys.mk +57 -0
  9. package/make/local_dev.mk +154 -0
  10. package/make/security.mk +59 -0
  11. package/make/test.mk +405 -0
  12. package/package.json +101 -4
  13. package/scripts/auth-configure.ts +415 -0
  14. package/scripts/heal-config.ts +92 -0
  15. package/scripts/jsonl-playwright-reporter.js +70 -0
  16. package/scripts/manifest-read.cjs +91 -0
  17. package/scripts/setup-1password.ts +570 -0
  18. package/scripts/setup-secrets.ts +149 -0
  19. package/scripts/view-results.js +156 -0
  20. package/service/install.sh +112 -0
  21. package/src/pillars/api/fixtures/index.ts +128 -0
  22. package/src/pillars/api/fixtures/openapi-validator.ts +111 -0
  23. package/src/pillars/api/global-setup.ts +103 -0
  24. package/src/pillars/api/playwright.config.ts +105 -0
  25. package/src/pillars/api/schemas/example-api.json +110 -0
  26. package/src/pillars/api/tests/health/health.spec.ts +19 -0
  27. package/src/pillars/api/tests/health/manifest.yml +6 -0
  28. package/src/pillars/browser/fixtures/index.ts +232 -0
  29. package/src/pillars/browser/fixtures/package.json +10 -0
  30. package/src/pillars/browser/global-setup.ts +140 -0
  31. package/src/pillars/browser/global-teardown.ts +48 -0
  32. package/src/pillars/browser/playwright.config.ts +212 -0
  33. package/src/pillars/browser/setup.ts +219 -0
  34. package/src/pillars/journeys/app-config.ts +237 -0
  35. package/src/pillars/journeys/discover.ts +160 -0
  36. package/src/pillars/journeys/hooks/deny-shell.cjs +44 -0
  37. package/src/pillars/journeys/observability/events.ts +288 -0
  38. package/src/pillars/journeys/observability/sdk-mapper.ts +306 -0
  39. package/src/pillars/journeys/report/friction.ts +304 -0
  40. package/src/pillars/journeys/report/junit.ts +105 -0
  41. package/src/pillars/journeys/report/render.ts +273 -0
  42. package/src/pillars/journeys/report/results.ts +323 -0
  43. package/src/pillars/journeys/run.ts +673 -0
  44. package/src/pillars/journeys/runner.ts +1025 -0
  45. package/src/pillars/journeys/sandbox.ts +403 -0
  46. package/src/pillars/journeys/spec/loader.ts +152 -0
  47. package/src/pillars/journeys/spec/schema.ts +208 -0
  48. package/src/pillars/journeys/teardown.ts +98 -0
  49. package/src/pillars/journeys/tools/browser-session.ts +219 -0
  50. package/src/pillars/journeys/tools/browser-tool.ts +914 -0
  51. package/src/pillars/journeys/tools/http-tool.ts +564 -0
  52. package/src/pillars/journeys/tools/ledger.ts +278 -0
  53. package/src/pillars/journeys/tools/snapshot-store.ts +117 -0
  54. package/src/pillars/journeys/verify/assertions.ts +502 -0
  55. package/src/pillars/journeys/verify/verdict.ts +71 -0
  56. package/src/pillars/perf/build-report.ts +36 -0
  57. package/src/pillars/perf/k6.config.js +112 -0
  58. package/src/pillars/perf/run.ts +345 -0
  59. package/src/pillars/perf/scenarios/load-profile.js +97 -0
  60. package/src/pillars/perf/staging.ts +73 -0
  61. package/src/pillars/security/discover.ts +124 -0
  62. package/src/pillars/security/findings.ts +151 -0
  63. package/src/pillars/security/plan.ts +355 -0
  64. package/src/pillars/security/run.ts +757 -0
  65. package/src/pillars/smoke/admin-probe.ts +68 -0
  66. package/src/pillars/smoke/base-url.ts +56 -0
  67. package/src/pillars/smoke/cleanup.ts +8 -0
  68. package/src/pillars/smoke/discover.ts +237 -0
  69. package/src/pillars/smoke/ensure-smoke-teardown.ts +40 -0
  70. package/src/pillars/smoke/fixture-manager.ts +217 -0
  71. package/src/pillars/smoke/fixtures.ts +468 -0
  72. package/src/pillars/smoke/global-setup-all.ts +262 -0
  73. package/src/pillars/smoke/global-setup-global.ts +262 -0
  74. package/src/pillars/smoke/global-setup.ts +273 -0
  75. package/src/pillars/smoke/global-teardown-all.ts +8 -0
  76. package/src/pillars/smoke/global-teardown-global.ts +8 -0
  77. package/src/pillars/smoke/global-teardown.ts +139 -0
  78. package/src/pillars/smoke/playwright-all.config.ts +147 -0
  79. package/src/pillars/smoke/playwright-global.config.ts +141 -0
  80. package/src/pillars/smoke/playwright.config.ts +162 -0
  81. package/src/pillars/smoke/setup-env.ts +132 -0
  82. package/src/pillars/smoke/setup-global.ts +126 -0
  83. package/src/pillars/smoke/setup.ts +115 -0
  84. package/src/pillars/smoke/smoke-repo.ts +16 -0
  85. package/src/pillars/smoke/staging.ts +49 -0
  86. package/src/pillars/suites/run.ts +147 -0
  87. package/src/scripts/configure-aws-profiles.ts +114 -0
  88. package/src/scripts/register-repos.ts +307 -0
  89. package/src/scripts/setup-default-workspace.ts +203 -0
  90. package/src/shared/agent-actor.ts +77 -0
  91. package/src/shared/applications.ts +138 -0
  92. package/src/shared/auth/api-token.ts +416 -0
  93. package/src/shared/auth/auth0-helper.ts +284 -0
  94. package/src/shared/auth/headless-login.ts +265 -0
  95. package/src/shared/auth/jwt-helper.ts +186 -0
  96. package/src/shared/auth/sudo-vault.ts +69 -0
  97. package/src/shared/auth/test-accounts.ts +98 -0
  98. package/src/shared/auth/token-injection.ts +184 -0
  99. package/src/shared/auth/token-providers/auth0.ts +80 -0
  100. package/src/shared/auth/token-providers/index.ts +182 -0
  101. package/src/shared/auth/token-refresh.ts +273 -0
  102. package/src/shared/banner.ts +15 -0
  103. package/src/shared/brand.ts +79 -0
  104. package/src/shared/cli-tabs.ts +131 -0
  105. package/src/shared/constants.ts +128 -0
  106. package/src/shared/den-config.ts +688 -0
  107. package/src/shared/dev-metrics/emit.ts +354 -0
  108. package/src/shared/edge/config.ts +229 -0
  109. package/src/shared/edge/render.ts +221 -0
  110. package/src/shared/edge/runtime.ts +268 -0
  111. package/src/shared/edge/status.ts +74 -0
  112. package/src/shared/edge/types.ts +62 -0
  113. package/src/shared/engine.ts +67 -0
  114. package/src/shared/environment/aws-profiles.ts +49 -0
  115. package/src/shared/environment/env-selector.ts +161 -0
  116. package/src/shared/environment/playwright-env.ts +82 -0
  117. package/src/shared/environment-manager/catalog.ts +77 -0
  118. package/src/shared/environment-manager/clone-guard.ts +163 -0
  119. package/src/shared/environment-manager/dag.ts +286 -0
  120. package/src/shared/environment-manager/default-workspace.ts +182 -0
  121. package/src/shared/environment-manager/derivation/alias.ts +90 -0
  122. package/src/shared/environment-manager/derivation/cache.ts +97 -0
  123. package/src/shared/environment-manager/derivation/derive-graph.ts +421 -0
  124. package/src/shared/environment-manager/derivation/derived-up.ts +354 -0
  125. package/src/shared/environment-manager/derivation/load-alias.ts +11 -0
  126. package/src/shared/environment-manager/derivation/rules-compose.ts +42 -0
  127. package/src/shared/environment-manager/derivation/rules-events.ts +50 -0
  128. package/src/shared/environment-manager/derivation/rules-http.ts +247 -0
  129. package/src/shared/environment-manager/derivation/rules-infra.ts +64 -0
  130. package/src/shared/environment-manager/derivation/rules-readiness.ts +96 -0
  131. package/src/shared/environment-manager/derivation/scan.ts +160 -0
  132. package/src/shared/environment-manager/derivation/text.ts +40 -0
  133. package/src/shared/environment-manager/derivation/types.ts +69 -0
  134. package/src/shared/environment-manager/health.ts +635 -0
  135. package/src/shared/environment-manager/index.ts +6 -0
  136. package/src/shared/environment-manager/load-applications.ts +108 -0
  137. package/src/shared/environment-manager/log-tail.ts +213 -0
  138. package/src/shared/environment-manager/make-targets.ts +78 -0
  139. package/src/shared/environment-manager/opctl.ts +335 -0
  140. package/src/shared/environment-manager/readiness.ts +93 -0
  141. package/src/shared/environment-manager/register-local.ts +72 -0
  142. package/src/shared/environment-manager/secrets-check.ts +260 -0
  143. package/src/shared/environment-manager/service-container.ts +47 -0
  144. package/src/shared/environment-manager/service-env.ts +110 -0
  145. package/src/shared/environment-manager/service-runner.ts +1603 -0
  146. package/src/shared/environment-manager/sync-targets.ts +66 -0
  147. package/src/shared/environment-manager/up-plan.ts +326 -0
  148. package/src/shared/environment-manager/workspace-manager.ts +905 -0
  149. package/src/shared/environment-manager/workspaces.ts +860 -0
  150. package/src/shared/manifest/deps.ts +27 -0
  151. package/src/shared/manifest/merge-override.ts +75 -0
  152. package/src/shared/manifest/migrate-manifest.ts +101 -0
  153. package/src/shared/manifest/placement.ts +321 -0
  154. package/src/shared/manifest/read-manifest.ts +278 -0
  155. package/src/shared/manifest/schema.ts +313 -0
  156. package/src/shared/manifest/service-links.ts +93 -0
  157. package/src/shared/manifest/validate-layers.ts +229 -0
  158. package/src/shared/manifest/validate-manifest.ts +200 -0
  159. package/src/shared/manifest/workspace-manifests.ts +92 -0
  160. package/src/shared/open-url.ts +122 -0
  161. package/src/shared/org.ts +44 -0
  162. package/src/shared/paths.ts +190 -0
  163. package/src/shared/perf/optional-fields.ts +105 -0
  164. package/src/shared/perf/report-normalizer.ts +404 -0
  165. package/src/shared/perf/report-paths.ts +19 -0
  166. package/src/shared/perf/report-store.ts +110 -0
  167. package/src/shared/perf/report-types.ts +70 -0
  168. package/src/shared/perf/test-paths.ts +25 -0
  169. package/src/shared/process/log-rotation.ts +147 -0
  170. package/src/shared/process/spawn-streaming.ts +229 -0
  171. package/src/shared/product.ts +92 -0
  172. package/src/shared/profile.ts +141 -0
  173. package/src/shared/redact.ts +107 -0
  174. package/src/shared/repo-kind.ts +32 -0
  175. package/src/shared/reporting/jsonl-reporter.ts +131 -0
  176. package/src/shared/reporting/slack-reporter.ts +340 -0
  177. package/src/shared/results/result-reader.ts +266 -0
  178. package/src/shared/results/result-writer.ts +168 -0
  179. package/src/shared/secrets/doppler.ts +126 -0
  180. package/src/shared/secrets/env.ts +48 -0
  181. package/src/shared/secrets/index.ts +197 -0
  182. package/src/shared/secrets/keys.ts +33 -0
  183. package/src/shared/secrets/onepassword.ts +75 -0
  184. package/src/shared/secrets/types.ts +52 -0
  185. package/src/shared/service-url.ts +119 -0
  186. package/src/shared/smoke/branch-resolver.ts +141 -0
  187. package/src/shared/smoke/git-remote.ts +70 -0
  188. package/src/shared/smoke/repo-cloner.ts +151 -0
  189. package/src/shared/stacks/config.ts +137 -0
  190. package/src/shared/stacks/manifest-commands.ts +118 -0
  191. package/src/shared/stacks/render.ts +253 -0
  192. package/src/shared/stacks/runtime.ts +334 -0
  193. package/src/shared/stacks/status.ts +99 -0
  194. package/src/shared/stacks/types.ts +53 -0
  195. package/src/shared/terminal/tabs.ts +212 -0
  196. package/src/shared/test-discovery/module-scanner.ts +569 -0
  197. package/src/shared/types/index.ts +701 -0
  198. package/src/shared/ui-marker.ts +19 -0
  199. package/src/ui/cli/applications-command.ts +148 -0
  200. package/src/ui/cli/config-command.ts +68 -0
  201. package/src/ui/cli/create-main.ts +9 -0
  202. package/src/ui/cli/create.ts +360 -0
  203. package/src/ui/cli/edge-command.ts +81 -0
  204. package/src/ui/cli/flag-parser.ts +246 -0
  205. package/src/ui/cli/graph-command.ts +570 -0
  206. package/src/ui/cli/health-display.ts +63 -0
  207. package/src/ui/cli/index.ts +3742 -0
  208. package/src/ui/cli/init.ts +855 -0
  209. package/src/ui/cli/invocation-target.ts +175 -0
  210. package/src/ui/cli/logs-command.ts +104 -0
  211. package/src/ui/cli/mcp-main.ts +13 -0
  212. package/src/ui/cli/mcp-stdout-guard.ts +10 -0
  213. package/src/ui/cli/module-selection.ts +31 -0
  214. package/src/ui/cli/prompts.ts +767 -0
  215. package/src/ui/cli/results-viewer.ts +436 -0
  216. package/src/ui/cli/secrets-preflight.ts +91 -0
  217. package/src/ui/cli/stack-command.ts +117 -0
  218. package/src/ui/cli/stalled-setup-recovery.ts +129 -0
  219. package/src/ui/cli/test-runner.ts +1319 -0
  220. package/src/ui/cli/web-command.ts +1073 -0
  221. package/src/ui/mcp/api.ts +284 -0
  222. package/src/ui/mcp/guards.ts +141 -0
  223. package/src/ui/mcp/server.ts +147 -0
  224. package/src/ui/mcp/tools.ts +565 -0
  225. package/src/ui/server/audit.ts +71 -0
  226. package/src/ui/server/changelog-parser.ts +105 -0
  227. package/src/ui/server/daemon-reaper.ts +129 -0
  228. package/src/ui/server/editor-manager.ts +301 -0
  229. package/src/ui/server/github-service.ts +223 -0
  230. package/src/ui/server/graph-prefs.ts +89 -0
  231. package/src/ui/server/health-row.ts +15 -0
  232. package/src/ui/server/http-helpers.ts +98 -0
  233. package/src/ui/server/index.ts +3350 -0
  234. package/src/ui/server/log-history.ts +453 -0
  235. package/src/ui/server/perf-report.ts +42 -0
  236. package/src/ui/server/public/environments.html +496 -0
  237. package/src/ui/server/public/favicon.ico +0 -0
  238. package/src/ui/server/public/graph-model.js +416 -0
  239. package/src/ui/server/public/graph.css +976 -0
  240. package/src/ui/server/public/graph.js +986 -0
  241. package/src/ui/server/public/header.js +1373 -0
  242. package/src/ui/server/public/index.html +2595 -0
  243. package/src/ui/server/public/local-dev.html +5344 -0
  244. package/src/ui/server/public/logs.html +1496 -0
  245. package/src/ui/server/public/panel.js +787 -0
  246. package/src/ui/server/public/perf-report.js +359 -0
  247. package/src/ui/server/public/release.html +2111 -0
  248. package/src/ui/server/public/services.html +1103 -0
  249. package/src/ui/server/public/workspace-picker.js +69 -0
  250. package/src/ui/server/readonly-routes.ts +200 -0
  251. package/src/ui/server/release-deploy-session.ts +246 -0
  252. package/src/ui/server/release-service.ts +1125 -0
  253. package/src/ui/server/sudo-store.ts +111 -0
  254. package/src/ui/server/ui-assets.ts +73 -0
  255. package/src/ui/server/workspace-routes.ts +2185 -0
@@ -0,0 +1,757 @@
1
+ /**
2
+ * Security CLI entrypoint (security pillar).
3
+ *
4
+ * npx tsx src/pillars/security/run.ts --env=<env> [--service=<repo/name>]
5
+ * [--mode=<baseline|api|full|all>] [--dry-run]
6
+ *
7
+ * Runs OWASP ZAP (docker) against our OWN services declared in their
8
+ * .den.yml `security:` blocks (or den's held manifests in
9
+ * manifests/). Conventions mirror the journeys pillar:
10
+ *
11
+ * - `--env` is REQUIRED and exactly one per invocation (the make targets
12
+ * add the command-line-origin guard on top). `production` additionally
13
+ * needs DEN_ALLOW_PROD=1.
14
+ * - Every target host must be on the owned-host allowlist
15
+ * (config/den.yml `security.owned_hosts`, default: none), in every
16
+ * env.
17
+ * - Mode policy lives in plan.ts (MODE_CEILING): production is passive
18
+ * `baseline` only and no manifest can widen that. `--mode` defaults to
19
+ * `baseline`; `all` runs every mode the service allows in the env. An
20
+ * explicit mode above the env's ceiling is REFUSED, never downgraded.
21
+ * - Production never injects credentials (unauthenticated passive scan),
22
+ * even if the manifest lists `auth:` roles.
23
+ * - `--dry-run` prints each docker command and exits 0 without minting
24
+ * tokens, creating directories, or starting anything.
25
+ * - Sequential in v1. Exit codes: 0 no High alerts / 1 infra or usage error
26
+ * / 2 at least one scan found a High alert / 3 nothing was scanned.
27
+ */
28
+
29
+ import * as fs from "fs";
30
+ import * as path from "path";
31
+ import { spawn } from "child_process";
32
+ import type {
33
+ Environment,
34
+ SecurityMode,
35
+ TestResult,
36
+ } from "../../shared/types/index.js";
37
+ import { getDistRoot } from "../../shared/paths.js";
38
+ import {
39
+ isProductionEnv,
40
+ readSecurityAutoTargets,
41
+ } from "../../shared/den-config.js";
42
+ import { isProtectedRemote } from "../../shared/manifest/placement.js";
43
+ import { listServicesWithManifests } from "../../shared/manifest/workspace-manifests.js";
44
+ import {
45
+ autoSecurityTargets,
46
+ discoverSecurityTargets,
47
+ securityTargetId,
48
+ type DiscoveredSecurityTarget,
49
+ } from "./discover.js";
50
+ import {
51
+ latestSummaryPath,
52
+ newFindings,
53
+ parseZapReport,
54
+ type Finding,
55
+ type ScanSummary,
56
+ type TargetResult,
57
+ } from "./findings.js";
58
+ import {
59
+ AUTH_HEADER_ENV,
60
+ allowedModes,
61
+ buildZapPlan,
62
+ MODE_CEILING,
63
+ resolveOpenapi,
64
+ resolveTargets,
65
+ scanFailed,
66
+ summarizeZapReport,
67
+ type AlertCounts,
68
+ ownedHostSuffixes,
69
+ type SecurityEnvConfigLike,
70
+ type ZapPlan,
71
+ } from "./plan.js";
72
+
73
+ import { envKey, product } from "../../shared/product.js";
74
+ import { containsValue, redactValues } from "../../shared/redact.js";
75
+ // ── Exit codes ───────────────────────────────────────────────────────────────
76
+
77
+ export const EXIT_CLEAN = 0;
78
+ export const EXIT_INFRA_ERROR = 1;
79
+ export const EXIT_FINDINGS = 2;
80
+ /** Nothing was scanned — loud and distinct, never silent green. */
81
+ export const EXIT_NO_TARGETS = 3;
82
+
83
+ // ── CLI args ─────────────────────────────────────────────────────────────────
84
+
85
+ const KNOWN_ENVIRONMENTS: readonly Environment[] = [
86
+ "local",
87
+ "dev",
88
+ "sandbox",
89
+ "staging",
90
+ "production",
91
+ ];
92
+ const KNOWN_MODES: readonly string[] = ["baseline", "api", "full", "all"];
93
+
94
+ const USAGE =
95
+ "Usage: run.ts --env=<env> [--service=<name>] " +
96
+ "[--mode=<baseline|api|full|all>] [--dry-run]";
97
+
98
+ /** Operator/usage mistake — maps to {@link EXIT_INFRA_ERROR}. */
99
+ export class CliUsageError extends Error {
100
+ constructor(message: string) {
101
+ super(message);
102
+ this.name = "CliUsageError";
103
+ }
104
+ }
105
+
106
+ export interface ParsedCliArgs {
107
+ env: Environment;
108
+ service?: string;
109
+ mode: SecurityMode | "all";
110
+ dryRun: boolean;
111
+ }
112
+
113
+ /**
114
+ * Parse the CLI args. `--env` is required exactly once; unknown arguments
115
+ * are rejected loudly rather than ignored.
116
+ */
117
+ export function parseCliArgs(argv: readonly string[]): ParsedCliArgs {
118
+ const envValues: string[] = [];
119
+ let service: string | undefined;
120
+ let mode = "baseline";
121
+ let dryRun = false;
122
+
123
+ for (const arg of argv) {
124
+ if (arg.startsWith("--env=")) {
125
+ envValues.push(arg.slice("--env=".length));
126
+ } else if (arg.startsWith("--service=")) {
127
+ service = arg.slice("--service=".length);
128
+ } else if (arg.startsWith("--mode=")) {
129
+ mode = arg.slice("--mode=".length);
130
+ } else if (arg === "--dry-run") {
131
+ dryRun = true;
132
+ } else {
133
+ throw new CliUsageError(`Unrecognized argument "${arg}". ${USAGE}`);
134
+ }
135
+ }
136
+
137
+ if (envValues.length === 0) {
138
+ throw new CliUsageError(
139
+ `--env is required (exactly one environment per invocation). ${USAGE}`,
140
+ );
141
+ }
142
+ if (envValues.length > 1) {
143
+ throw new CliUsageError(
144
+ "Exactly one --env per invocation — scans never fan out across " +
145
+ `environments (got: ${envValues.join(", ")}).`,
146
+ );
147
+ }
148
+ const env = envValues[0];
149
+ if (!(KNOWN_ENVIRONMENTS as readonly string[]).includes(env)) {
150
+ throw new CliUsageError(
151
+ `Unknown environment "${env}". Known: ${KNOWN_ENVIRONMENTS.join(", ")}.`,
152
+ );
153
+ }
154
+ if (!KNOWN_MODES.includes(mode)) {
155
+ throw new CliUsageError(
156
+ `Unknown mode "${mode}". Known: ${KNOWN_MODES.join(", ")}.`,
157
+ );
158
+ }
159
+
160
+ const parsed: ParsedCliArgs = {
161
+ env: env as Environment,
162
+ mode: mode as SecurityMode | "all",
163
+ dryRun,
164
+ };
165
+ if (service !== undefined) parsed.service = service;
166
+ return parsed;
167
+ }
168
+
169
+ // ── Injectable dependencies ──────────────────────────────────────────────────
170
+
171
+ export interface ZapRunResult {
172
+ exitCode: number;
173
+ timedOut: boolean;
174
+ }
175
+
176
+ export interface EntrypointDeps {
177
+ discoverTargets: () => DiscoveredSecurityTarget[];
178
+ /** Owned-host allowlist (config/den.yml + DEN_SECURITY_EXTRA_HOSTS). */
179
+ ownedHosts: () => string[];
180
+ loadEnvironment: (env: Environment) => Promise<SecurityEnvConfigLike>;
181
+ /** Bearer token for an auth role (never called in dry-run or production). */
182
+ getToken: (
183
+ role: "admin" | "user" | "beta",
184
+ env: Environment,
185
+ ) => Promise<string>;
186
+ /** Execute the plan; `childEnv` carries any credential (never argv). */
187
+ runZap: (plan: ZapPlan, childEnv: NodeJS.ProcessEnv) => Promise<ZapRunResult>;
188
+ mkdirp: (dir: string) => void;
189
+ readFileIfExists: (filePath: string) => string | undefined;
190
+ writeFile: (filePath: string, contents: string) => void;
191
+ writeResult: (row: TestResult) => Promise<void>;
192
+ /** Root under which reports/security/<env>/<run>/ is created. */
193
+ reportsRoot: string;
194
+ now: () => Date;
195
+ log: (message: string) => void;
196
+ logError: (message: string) => void;
197
+ /** Whether the org counts an env as production (default: org config). */
198
+ isProduction?: (env: string) => boolean;
199
+ }
200
+
201
+ /** Production wiring: real docker, real token minting, real result writer. */
202
+ export function defaultEntrypointDeps(): EntrypointDeps {
203
+ return {
204
+ discoverTargets: () => {
205
+ const declared = discoverSecurityTargets();
206
+ return readSecurityAutoTargets()
207
+ ? [
208
+ ...declared,
209
+ ...autoSecurityTargets(listServicesWithManifests(), declared),
210
+ ]
211
+ : declared;
212
+ },
213
+ ownedHosts: () => ownedHostSuffixes(),
214
+ loadEnvironment: async (env) => {
215
+ const { envSelector } =
216
+ await import("../../shared/environment/env-selector.js");
217
+ return envSelector.loadEnvironment(env);
218
+ },
219
+ getToken: async (role, env) => {
220
+ const { getApiToken } = await import("../../shared/auth/api-token.js");
221
+ return getApiToken(role, env);
222
+ },
223
+ runZap: (plan, childEnv) =>
224
+ new Promise<ZapRunResult>((resolve, reject) => {
225
+ const child = spawn(plan.command, plan.args, {
226
+ env: childEnv,
227
+ stdio: "inherit",
228
+ });
229
+ let timedOut = false;
230
+ const timer = setTimeout(() => {
231
+ timedOut = true;
232
+ child.kill("SIGTERM");
233
+ }, plan.timeoutMs);
234
+ child.on("error", (error) => {
235
+ clearTimeout(timer);
236
+ reject(error);
237
+ });
238
+ child.on("close", (code) => {
239
+ clearTimeout(timer);
240
+ resolve({ exitCode: code ?? -1, timedOut });
241
+ });
242
+ }),
243
+ mkdirp: (dir) => {
244
+ fs.mkdirSync(dir, { recursive: true });
245
+ },
246
+ readFileIfExists: (filePath) =>
247
+ fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : undefined,
248
+ writeFile: (filePath, contents) => fs.writeFileSync(filePath, contents),
249
+ writeResult: async (row) => {
250
+ const { resultWriter } =
251
+ await import("../../shared/results/result-writer.js");
252
+ await resultWriter.writeResult(row);
253
+ },
254
+ reportsRoot: path.join(getDistRoot(), "reports", "security"),
255
+ now: () => new Date(),
256
+ log: (message) => console.log(message),
257
+ logError: (message) => console.error(message),
258
+ };
259
+ }
260
+
261
+ // ── Per-scan execution ───────────────────────────────────────────────────────
262
+
263
+ type ScanStatus = "clean" | "findings" | "infra";
264
+
265
+ interface ScanOutcome {
266
+ status: ScanStatus;
267
+ /** null when the scan itself failed. */
268
+ findings: Finding[] | null;
269
+ error?: string;
270
+ reportDir: string;
271
+ }
272
+
273
+ /** ZAP exits 0/1/2 when the scan completed (1/2 = its own fail/warn). */
274
+ const ZAP_COMPLETED_EXIT_CODES: readonly number[] = [0, 1, 2];
275
+
276
+ /** One planned scan: a (service, mode, target, role) combination. */
277
+ interface ScanUnit {
278
+ target: DiscoveredSecurityTarget;
279
+ mode: SecurityMode;
280
+ baseUrl: string;
281
+ /** Undefined = unauthenticated. */
282
+ role?: "admin" | "user" | "beta";
283
+ }
284
+
285
+ function scanLabel(unit: ScanUnit): string {
286
+ return (
287
+ `${securityTargetId(unit.target)} ${unit.mode} ${unit.baseUrl}` +
288
+ (unit.role ? ` as ${unit.role}` : "") +
289
+ (unit.target.auto ? " (auto: health URL)" : "")
290
+ );
291
+ }
292
+
293
+ /** Replace any occurrence of `secret` in a persisted report file. */
294
+ function redactFile(
295
+ filePath: string,
296
+ secret: string | undefined,
297
+ deps: EntrypointDeps,
298
+ ): void {
299
+ if (secret === undefined || secret.length === 0) return;
300
+ const contents = deps.readFileIfExists(filePath);
301
+ if (contents !== undefined && containsValue(contents, [secret])) {
302
+ deps.writeFile(filePath, redactValues(contents, [secret]));
303
+ }
304
+ }
305
+
306
+ async function executeScan(
307
+ unit: ScanUnit,
308
+ args: ParsedCliArgs,
309
+ runDir: string,
310
+ index: number,
311
+ deps: EntrypointDeps,
312
+ ): Promise<ScanOutcome> {
313
+ const started = deps.now();
314
+ const reportDir = path.join(
315
+ runDir,
316
+ `${String(index).padStart(2, "0")}-${unit.target.repoName}-` +
317
+ `${unit.target.serviceName}-${unit.mode}${unit.role ? `-${unit.role}` : ""}`,
318
+ );
319
+ deps.log(`▶ scan ${scanLabel(unit)} — env=${args.env}`);
320
+
321
+ let token: string | undefined;
322
+ let counts: AlertCounts | undefined;
323
+ let parsed: Finding[] | null = null;
324
+ let status: ScanStatus = "infra";
325
+ let error: string | undefined;
326
+
327
+ try {
328
+ deps.mkdirp(reportDir);
329
+ if (unit.role !== undefined) {
330
+ token = await deps.getToken(unit.role, args.env);
331
+ }
332
+ const spec = resolveOpenapi(
333
+ unit.target.config.openapi,
334
+ unit.baseUrl,
335
+ unit.target.repoPath,
336
+ );
337
+ const plan = buildZapPlan({
338
+ mode: unit.mode,
339
+ target: unit.baseUrl,
340
+ reportDir,
341
+ ...(spec.file !== undefined ? { openapiFile: spec.file } : {}),
342
+ ...(spec.url !== undefined ? { openapiUrl: spec.url } : {}),
343
+ ...(unit.target.config.exclude !== undefined
344
+ ? { exclude: unit.target.config.exclude }
345
+ : {}),
346
+ authenticated: token !== undefined,
347
+ });
348
+ const childEnv: NodeJS.ProcessEnv = { ...process.env };
349
+ if (token !== undefined) childEnv[AUTH_HEADER_ENV] = `Bearer ${token}`;
350
+
351
+ const result = await deps.runZap(plan, childEnv);
352
+ if (result.timedOut) {
353
+ throw new Error(`ZAP exceeded the ${plan.timeoutMs / 60_000}m cap.`);
354
+ }
355
+ if (!ZAP_COMPLETED_EXIT_CODES.includes(result.exitCode)) {
356
+ throw new Error(`ZAP exited with code ${result.exitCode}.`);
357
+ }
358
+
359
+ // Belt and suspenders: ZAP shouldn't echo request headers into reports,
360
+ // but a credential must never persist if it ever does.
361
+ const jsonPath = path.join(reportDir, "report.json");
362
+ const raw = deps.readFileIfExists(jsonPath);
363
+ if (raw === undefined) throw new Error("ZAP wrote no report.json.");
364
+ const report: unknown = JSON.parse(raw);
365
+ counts = summarizeZapReport(report);
366
+ parsed = parseZapReport(report);
367
+ redactFile(jsonPath, token, deps);
368
+ redactFile(path.join(reportDir, "report.html"), token, deps);
369
+ status = scanFailed(counts) ? "findings" : "clean";
370
+ } catch (caught) {
371
+ error = caught instanceof Error ? caught.message : String(caught);
372
+ }
373
+
374
+ const duration = deps.now().getTime() - started.getTime();
375
+ const row: TestResult = {
376
+ timestamp: started.toISOString(),
377
+ pillar: "security",
378
+ environment: args.env,
379
+ test: `${securityTargetId(unit.target)}:${unit.mode}${unit.role ? `:${unit.role}` : ""}`,
380
+ status: status === "clean" ? "passed" : "failed",
381
+ duration,
382
+ user: process.env.USER ?? "unknown",
383
+ metadata: {
384
+ mode: unit.mode,
385
+ target: unit.baseUrl,
386
+ authenticated: unit.role !== undefined,
387
+ ...(unit.role !== undefined ? { role: unit.role } : {}),
388
+ report_dir: reportDir,
389
+ ...(counts !== undefined ? { alerts: counts } : {}),
390
+ ...(status === "infra" ? { failure_kind: "infra_error" } : {}),
391
+ },
392
+ };
393
+ if (error !== undefined) row.error = error;
394
+ try {
395
+ await deps.writeResult(row);
396
+ } catch (writeError) {
397
+ deps.logError(
398
+ `Failed to persist result row: ` +
399
+ `${writeError instanceof Error ? writeError.message : String(writeError)}`,
400
+ );
401
+ status = "infra";
402
+ }
403
+
404
+ deps.log(
405
+ status === "clean"
406
+ ? `✔ ${scanLabel(unit)} — no High alerts`
407
+ : status === "findings"
408
+ ? `✖ ${scanLabel(unit)} — ${counts?.high ?? 0} High, ` +
409
+ `${counts?.medium ?? 0} Medium alert(s); report: ${reportDir}`
410
+ : `✖ ${scanLabel(unit)} — infra error: ${error ?? "unknown"}`,
411
+ );
412
+ return {
413
+ status,
414
+ findings: status === "infra" ? null : parsed,
415
+ ...(error !== undefined ? { error } : {}),
416
+ reportDir,
417
+ };
418
+ }
419
+
420
+ /** The id a scan unit has in a run summary (one row per URL and role). */
421
+ function unitId(unit: ScanUnit): string {
422
+ return securityTargetId(unit.target) + (unit.role ? ` as ${unit.role}` : "");
423
+ }
424
+
425
+ /**
426
+ * Write each mode's run summary and point <env>/latest-<mode>.json at it,
427
+ * marking the findings the previous run of the same row did not have.
428
+ */
429
+ function writeRunSummaries(
430
+ units: ScanUnit[],
431
+ outcomes: ScanOutcome[],
432
+ env: Environment,
433
+ runDir: string,
434
+ startedAt: Date,
435
+ deps: EntrypointDeps,
436
+ ): void {
437
+ const modes = [...new Set(units.map((u) => u.mode))];
438
+ for (const mode of modes) {
439
+ const latest = latestSummaryPath(deps.reportsRoot, env, mode);
440
+ let previous: ScanSummary | null = null;
441
+ try {
442
+ const raw = deps.readFileIfExists(latest);
443
+ previous = raw === undefined ? null : (JSON.parse(raw) as ScanSummary);
444
+ } catch {
445
+ previous = null;
446
+ }
447
+ const results: TargetResult[] = [];
448
+ units.forEach((unit, i) => {
449
+ if (unit.mode !== mode) return;
450
+ const o = outcomes[i];
451
+ const id = unitId(unit);
452
+ const prev = previous?.results.find(
453
+ (r) => r.id === id && r.url === unit.baseUrl,
454
+ )?.findings;
455
+ results.push({
456
+ id,
457
+ repo: unit.target.repoName,
458
+ service: unit.target.serviceName,
459
+ org: "",
460
+ url: unit.baseUrl,
461
+ findings: o.findings,
462
+ ...(o.error !== undefined ? { error: o.error } : {}),
463
+ ...(o.findings ? { newSince: newFindings(o.findings, prev) } : {}),
464
+ ...(o.findings
465
+ ? {
466
+ report: path.relative(
467
+ deps.reportsRoot,
468
+ path.join(o.reportDir, "report.html"),
469
+ ),
470
+ }
471
+ : {}),
472
+ });
473
+ });
474
+ const summary: ScanSummary = {
475
+ id: `${path.basename(runDir)}-${env}-${mode}`,
476
+ env,
477
+ mode,
478
+ startedAt: startedAt.toISOString(),
479
+ finishedAt: deps.now().toISOString(),
480
+ results,
481
+ };
482
+ const body = JSON.stringify(summary, null, 2);
483
+ try {
484
+ deps.writeFile(path.join(runDir, `summary-${mode}.json`), body);
485
+ deps.writeFile(latest, body);
486
+ } catch (error) {
487
+ deps.logError(
488
+ `Failed to write the run summary: ` +
489
+ `${error instanceof Error ? error.message : String(error)}`,
490
+ );
491
+ }
492
+ }
493
+ }
494
+
495
+ // ── CLI orchestration ────────────────────────────────────────────────────────
496
+
497
+ /** Filter discovered targets by --service ("<repo>/<name>" or bare name). */
498
+ function filterTargets(
499
+ targets: DiscoveredSecurityTarget[],
500
+ service: string,
501
+ ): DiscoveredSecurityTarget[] {
502
+ return service.includes("/")
503
+ ? targets.filter((t) => securityTargetId(t) === service)
504
+ : targets.filter((t) => t.serviceName === service);
505
+ }
506
+
507
+ /**
508
+ * Run the security pillar once against one environment. Returns the
509
+ * process exit code (see the EXIT_* constants).
510
+ */
511
+ export async function runSecurityCli(
512
+ argv: readonly string[],
513
+ deps: EntrypointDeps = defaultEntrypointDeps(),
514
+ ): Promise<number> {
515
+ let args: ParsedCliArgs;
516
+ try {
517
+ args = parseCliArgs(argv);
518
+ } catch (error) {
519
+ deps.logError(error instanceof Error ? error.message : String(error));
520
+ return EXIT_INFRA_ERROR;
521
+ }
522
+
523
+ const ceiling = MODE_CEILING[args.env];
524
+ if (ceiling.length === 0) {
525
+ deps.logError(`Security scans are not supported for env "${args.env}".`);
526
+ return EXIT_INFRA_ERROR;
527
+ }
528
+ if (args.env === "production" && process.env.DEN_ALLOW_PROD !== "1") {
529
+ deps.logError(
530
+ `Refusing to scan production without ${envKey("ALLOW_PROD")}=1. ` +
531
+ "Production scans are passive (baseline) only — set it to confirm " +
532
+ "an authorized run.",
533
+ );
534
+ return EXIT_INFRA_ERROR;
535
+ }
536
+ if (args.mode !== "all" && !ceiling.includes(args.mode)) {
537
+ deps.logError(
538
+ `Mode "${args.mode}" is not permitted in ${args.env} ` +
539
+ `(allowed: ${ceiling.join(", ")}). Refusing rather than downgrading.`,
540
+ );
541
+ return EXIT_INFRA_ERROR;
542
+ }
543
+
544
+ let targets: DiscoveredSecurityTarget[];
545
+ try {
546
+ targets = deps.discoverTargets();
547
+ } catch (error) {
548
+ deps.logError(
549
+ `Security target discovery failed: ` +
550
+ `${error instanceof Error ? error.message : String(error)}`,
551
+ );
552
+ return EXIT_INFRA_ERROR;
553
+ }
554
+ if (args.service !== undefined) {
555
+ targets = filterTargets(targets, args.service);
556
+ }
557
+ if (targets.length === 0) {
558
+ deps.logError(
559
+ "No security targets discovered" +
560
+ (args.service ? ` matching "${args.service}"` : "") +
561
+ ". Add the repo to the active workspace and check the security: " +
562
+ `block in its ${product().manifestYml} (or manifests/<repo>${product().manifestYml}) — ` +
563
+ "zero targets is never a pass.",
564
+ );
565
+ return EXIT_NO_TARGETS;
566
+ }
567
+
568
+ let envConfig: SecurityEnvConfigLike;
569
+ try {
570
+ envConfig = await deps.loadEnvironment(args.env);
571
+ } catch (error) {
572
+ deps.logError(
573
+ `Failed to load environment "${args.env}": ` +
574
+ `${error instanceof Error ? error.message : String(error)}`,
575
+ );
576
+ return EXIT_INFRA_ERROR;
577
+ }
578
+
579
+ // Expand to concrete scans. Target/host resolution errors are config
580
+ // mistakes (e.g. a non-owned host) and abort the whole run loudly.
581
+ const units: ScanUnit[] = [];
582
+ try {
583
+ const suffixes = deps.ownedHosts();
584
+ const isProduction =
585
+ deps.isProduction ?? ((e: string) => isProductionEnv(e));
586
+ for (const target of targets) {
587
+ // R13: a remote service whose target env counts as production is never
588
+ // scanned; any other remote one is scanned only in its own env, never
589
+ // as if it ran locally.
590
+ const placed = target.placement;
591
+ if (placed?.runs === "remote" && placed.target) {
592
+ const where = placed.target.env;
593
+ if (isProtectedRemote(placed, isProduction)) {
594
+ deps.log(
595
+ `↷ skipping ${securityTargetId(target)}: runs in ${where}, which counts as production`,
596
+ );
597
+ continue;
598
+ }
599
+ if (args.env === "local") {
600
+ deps.log(
601
+ `↷ skipping ${securityTargetId(target)}: runs in ${where}, not on this machine`,
602
+ );
603
+ continue;
604
+ }
605
+ }
606
+ // A target that declares no host for THIS env (e.g. only staging/prod)
607
+ // is simply not in scope here — skip it, never abort the batch.
608
+ const inEnv =
609
+ (target.config.hosts?.[args.env]?.length ?? 0) > 0 ||
610
+ target.config.baseUrlKey !== undefined;
611
+ if (!inEnv) {
612
+ deps.log(
613
+ `↷ skipping ${securityTargetId(target)}: not deployed in ${args.env}`,
614
+ );
615
+ continue;
616
+ }
617
+ const modes = allowedModes(target.config, args.env).filter(
618
+ (m) => args.mode === "all" || m === args.mode,
619
+ );
620
+ if (modes.length === 0) {
621
+ deps.log(
622
+ `↷ skipping ${securityTargetId(target)}: mode "${args.mode}" is ` +
623
+ `not allowed for it in ${args.env}`,
624
+ );
625
+ continue;
626
+ }
627
+ const baseUrls = resolveTargets(
628
+ target.config,
629
+ args.env,
630
+ envConfig,
631
+ suffixes,
632
+ );
633
+ // Production: unauthenticated passive scan only (see module header).
634
+ const roles =
635
+ args.env === "production" || !target.config.auth?.length
636
+ ? [undefined]
637
+ : target.config.auth;
638
+ for (const mode of modes) {
639
+ if (mode === "api" && target.config.openapi === undefined) {
640
+ deps.log(
641
+ `↷ skipping ${securityTargetId(target)} api: no openapi declared`,
642
+ );
643
+ continue;
644
+ }
645
+ for (const baseUrl of baseUrls) {
646
+ for (const role of roles) {
647
+ units.push({
648
+ target,
649
+ mode,
650
+ baseUrl,
651
+ ...(role !== undefined ? { role } : {}),
652
+ });
653
+ }
654
+ }
655
+ }
656
+ }
657
+ } catch (error) {
658
+ deps.logError(
659
+ `Security plan refused: ${error instanceof Error ? error.message : String(error)}`,
660
+ );
661
+ return EXIT_INFRA_ERROR;
662
+ }
663
+
664
+ if (units.length === 0) {
665
+ // An explicitly-named service with nothing to run here (not deployed in
666
+ // this env, or every mode filtered out) is a benign skip — exit clean so a
667
+ // per-service batch does not abort on it. "All targets" resolving to
668
+ // nothing stays loud (never silent green).
669
+ if (args.service !== undefined) {
670
+ deps.log(
671
+ `↷ ${args.service}: nothing to scan in env "${args.env}" — skipped.`,
672
+ );
673
+ return EXIT_CLEAN;
674
+ }
675
+ deps.logError(
676
+ `0 scans runnable for env "${args.env}" — nothing ran (never silent green).`,
677
+ );
678
+ return EXIT_NO_TARGETS;
679
+ }
680
+
681
+ if (args.dryRun) {
682
+ for (const unit of units) {
683
+ try {
684
+ const spec = resolveOpenapi(
685
+ unit.target.config.openapi,
686
+ unit.baseUrl,
687
+ unit.target.repoPath,
688
+ );
689
+ const plan = buildZapPlan({
690
+ mode: unit.mode,
691
+ target: unit.baseUrl,
692
+ reportDir: "<report-dir>",
693
+ ...(spec.file !== undefined ? { openapiFile: spec.file } : {}),
694
+ ...(spec.url !== undefined ? { openapiUrl: spec.url } : {}),
695
+ ...(unit.target.config.exclude !== undefined
696
+ ? { exclude: unit.target.config.exclude }
697
+ : {}),
698
+ authenticated: unit.role !== undefined,
699
+ });
700
+ deps.log(
701
+ `[dry-run] ${scanLabel(unit)}\n ${plan.command} ${plan.args.join(" ")}`,
702
+ );
703
+ } catch (error) {
704
+ deps.logError(
705
+ `[dry-run] ${scanLabel(unit)} unrunnable: ` +
706
+ `${error instanceof Error ? error.message : String(error)}`,
707
+ );
708
+ return EXIT_INFRA_ERROR;
709
+ }
710
+ }
711
+ deps.log(`[dry-run] ${units.length} scan(s) planned — nothing executed.`);
712
+ return EXIT_CLEAN;
713
+ }
714
+
715
+ const startedAt = deps.now();
716
+ const stamp = startedAt.toISOString().replace(/[:.]/g, "-");
717
+ const runDir = path.join(deps.reportsRoot, args.env, stamp);
718
+ const outcomes: ScanOutcome[] = [];
719
+ for (const [index, unit] of units.entries()) {
720
+ outcomes.push(await executeScan(unit, args, runDir, index, deps));
721
+ }
722
+ writeRunSummaries(units, outcomes, args.env, runDir, startedAt, deps);
723
+ const statuses = outcomes.map((o) => o.status);
724
+
725
+ const infra = statuses.filter((s) => s === "infra").length;
726
+ const findings = statuses.filter((s) => s === "findings").length;
727
+ deps.log(
728
+ `security: ${statuses.length - infra - findings} clean, ${findings} ` +
729
+ `with High alerts, ${infra} infra error(s) — env=${args.env}`,
730
+ );
731
+
732
+ // Infra errors dominate: the result set is unreliable, not merely red.
733
+ if (infra > 0) return EXIT_INFRA_ERROR;
734
+ if (findings > 0) return EXIT_FINDINGS;
735
+ return EXIT_CLEAN;
736
+ }
737
+
738
+ // ── Main-module gate ─────────────────────────────────────────────────────────
739
+
740
+ // No import.meta here: ts-jest compiles this module to CJS for the unit
741
+ // tests, where import.meta is a syntax error (same constraint as journeys).
742
+ const isMainModule = /[\\/]pillars[\\/]security[\\/]run\.(ts|js)$/.test(
743
+ process.argv[1] ?? "",
744
+ );
745
+
746
+ if (isMainModule) {
747
+ runSecurityCli(process.argv.slice(2))
748
+ .then((exitCode) => {
749
+ process.exitCode = exitCode;
750
+ })
751
+ .catch((error: unknown) => {
752
+ console.error(
753
+ error instanceof Error ? (error.stack ?? error.message) : String(error),
754
+ );
755
+ process.exitCode = EXIT_INFRA_ERROR;
756
+ });
757
+ }