@monster-orange/den 0.0.0-stage → 0.1.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 (231) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/LICENSE +21 -0
  3. package/README.md +166 -2
  4. package/bin/den +61 -0
  5. package/docs/distributions.md +256 -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 +104 -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 +406 -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 +108 -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 +729 -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/applications.ts +138 -0
  91. package/src/shared/auth/api-token.ts +416 -0
  92. package/src/shared/auth/auth0-helper.ts +284 -0
  93. package/src/shared/auth/headless-login.ts +265 -0
  94. package/src/shared/auth/jwt-helper.ts +186 -0
  95. package/src/shared/auth/sudo-vault.ts +69 -0
  96. package/src/shared/auth/test-accounts.ts +98 -0
  97. package/src/shared/auth/token-injection.ts +184 -0
  98. package/src/shared/auth/token-providers/auth0.ts +80 -0
  99. package/src/shared/auth/token-providers/index.ts +182 -0
  100. package/src/shared/auth/token-refresh.ts +273 -0
  101. package/src/shared/banner.ts +15 -0
  102. package/src/shared/brand.ts +79 -0
  103. package/src/shared/cli-tabs.ts +131 -0
  104. package/src/shared/constants.ts +128 -0
  105. package/src/shared/den-config.ts +566 -0
  106. package/src/shared/dev-metrics/emit.ts +354 -0
  107. package/src/shared/edge/config.ts +229 -0
  108. package/src/shared/edge/render.ts +221 -0
  109. package/src/shared/edge/runtime.ts +268 -0
  110. package/src/shared/edge/types.ts +62 -0
  111. package/src/shared/engine.ts +67 -0
  112. package/src/shared/environment/aws-profiles.ts +49 -0
  113. package/src/shared/environment/env-selector.ts +161 -0
  114. package/src/shared/environment/playwright-env.ts +82 -0
  115. package/src/shared/environment-manager/catalog.ts +77 -0
  116. package/src/shared/environment-manager/clone-guard.ts +163 -0
  117. package/src/shared/environment-manager/dag.ts +286 -0
  118. package/src/shared/environment-manager/default-workspace.ts +182 -0
  119. package/src/shared/environment-manager/derivation/alias.ts +90 -0
  120. package/src/shared/environment-manager/derivation/cache.ts +97 -0
  121. package/src/shared/environment-manager/derivation/derive-graph.ts +421 -0
  122. package/src/shared/environment-manager/derivation/derived-up.ts +354 -0
  123. package/src/shared/environment-manager/derivation/load-alias.ts +11 -0
  124. package/src/shared/environment-manager/derivation/rules-compose.ts +42 -0
  125. package/src/shared/environment-manager/derivation/rules-events.ts +50 -0
  126. package/src/shared/environment-manager/derivation/rules-http.ts +247 -0
  127. package/src/shared/environment-manager/derivation/rules-infra.ts +64 -0
  128. package/src/shared/environment-manager/derivation/rules-readiness.ts +96 -0
  129. package/src/shared/environment-manager/derivation/scan.ts +160 -0
  130. package/src/shared/environment-manager/derivation/text.ts +40 -0
  131. package/src/shared/environment-manager/derivation/types.ts +69 -0
  132. package/src/shared/environment-manager/health.ts +589 -0
  133. package/src/shared/environment-manager/index.ts +6 -0
  134. package/src/shared/environment-manager/load-applications.ts +78 -0
  135. package/src/shared/environment-manager/log-tail.ts +212 -0
  136. package/src/shared/environment-manager/make-targets.ts +78 -0
  137. package/src/shared/environment-manager/opctl.ts +334 -0
  138. package/src/shared/environment-manager/readiness.ts +93 -0
  139. package/src/shared/environment-manager/register-local.ts +72 -0
  140. package/src/shared/environment-manager/secrets-check.ts +260 -0
  141. package/src/shared/environment-manager/service-env.ts +110 -0
  142. package/src/shared/environment-manager/service-runner.ts +1588 -0
  143. package/src/shared/environment-manager/up-plan.ts +302 -0
  144. package/src/shared/environment-manager/workspace-manager.ts +905 -0
  145. package/src/shared/environment-manager/workspaces.ts +860 -0
  146. package/src/shared/manifest/deps.ts +27 -0
  147. package/src/shared/manifest/migrate-manifest.ts +101 -0
  148. package/src/shared/manifest/read-manifest.ts +160 -0
  149. package/src/shared/manifest/schema.ts +299 -0
  150. package/src/shared/manifest/service-links.ts +93 -0
  151. package/src/shared/manifest/validate-manifest.ts +140 -0
  152. package/src/shared/manifest/workspace-manifests.ts +92 -0
  153. package/src/shared/open-url.ts +122 -0
  154. package/src/shared/org.ts +44 -0
  155. package/src/shared/paths.ts +178 -0
  156. package/src/shared/perf/optional-fields.ts +105 -0
  157. package/src/shared/perf/report-normalizer.ts +404 -0
  158. package/src/shared/perf/report-paths.ts +19 -0
  159. package/src/shared/perf/report-store.ts +110 -0
  160. package/src/shared/perf/report-types.ts +70 -0
  161. package/src/shared/perf/test-paths.ts +25 -0
  162. package/src/shared/process/log-rotation.ts +147 -0
  163. package/src/shared/process/spawn-streaming.ts +229 -0
  164. package/src/shared/product.ts +92 -0
  165. package/src/shared/profile.ts +141 -0
  166. package/src/shared/repo-kind.ts +32 -0
  167. package/src/shared/reporting/jsonl-reporter.ts +131 -0
  168. package/src/shared/reporting/slack-reporter.ts +340 -0
  169. package/src/shared/results/result-reader.ts +266 -0
  170. package/src/shared/results/result-writer.ts +168 -0
  171. package/src/shared/secrets/doppler.ts +126 -0
  172. package/src/shared/secrets/env.ts +48 -0
  173. package/src/shared/secrets/index.ts +197 -0
  174. package/src/shared/secrets/keys.ts +33 -0
  175. package/src/shared/secrets/onepassword.ts +75 -0
  176. package/src/shared/secrets/types.ts +52 -0
  177. package/src/shared/service-url.ts +119 -0
  178. package/src/shared/smoke/branch-resolver.ts +141 -0
  179. package/src/shared/smoke/git-remote.ts +70 -0
  180. package/src/shared/smoke/repo-cloner.ts +151 -0
  181. package/src/shared/stacks/config.ts +137 -0
  182. package/src/shared/stacks/manifest-commands.ts +88 -0
  183. package/src/shared/stacks/render.ts +253 -0
  184. package/src/shared/stacks/runtime.ts +334 -0
  185. package/src/shared/stacks/types.ts +53 -0
  186. package/src/shared/terminal/tabs.ts +212 -0
  187. package/src/shared/test-discovery/module-scanner.ts +569 -0
  188. package/src/shared/types/index.ts +678 -0
  189. package/src/shared/ui-marker.ts +19 -0
  190. package/src/ui/cli/applications-command.ts +116 -0
  191. package/src/ui/cli/config-command.ts +68 -0
  192. package/src/ui/cli/create-main.ts +9 -0
  193. package/src/ui/cli/create.ts +360 -0
  194. package/src/ui/cli/edge-command.ts +91 -0
  195. package/src/ui/cli/flag-parser.ts +246 -0
  196. package/src/ui/cli/graph-command.ts +524 -0
  197. package/src/ui/cli/health-display.ts +63 -0
  198. package/src/ui/cli/index.ts +3656 -0
  199. package/src/ui/cli/init.ts +855 -0
  200. package/src/ui/cli/invocation-target.ts +175 -0
  201. package/src/ui/cli/logs-command.ts +104 -0
  202. package/src/ui/cli/module-selection.ts +31 -0
  203. package/src/ui/cli/prompts.ts +767 -0
  204. package/src/ui/cli/results-viewer.ts +436 -0
  205. package/src/ui/cli/secrets-preflight.ts +91 -0
  206. package/src/ui/cli/stack-command.ts +144 -0
  207. package/src/ui/cli/stalled-setup-recovery.ts +129 -0
  208. package/src/ui/cli/test-runner.ts +1312 -0
  209. package/src/ui/cli/web-command.ts +1073 -0
  210. package/src/ui/server/changelog-parser.ts +94 -0
  211. package/src/ui/server/daemon-reaper.ts +129 -0
  212. package/src/ui/server/editor-manager.ts +301 -0
  213. package/src/ui/server/github-service.ts +223 -0
  214. package/src/ui/server/http-helpers.ts +98 -0
  215. package/src/ui/server/index.ts +3236 -0
  216. package/src/ui/server/log-history.ts +452 -0
  217. package/src/ui/server/perf-report.ts +42 -0
  218. package/src/ui/server/public/environments.html +494 -0
  219. package/src/ui/server/public/favicon.ico +0 -0
  220. package/src/ui/server/public/header.js +1371 -0
  221. package/src/ui/server/public/index.html +2595 -0
  222. package/src/ui/server/public/local-dev.html +7405 -0
  223. package/src/ui/server/public/logs.html +1489 -0
  224. package/src/ui/server/public/perf-report.js +359 -0
  225. package/src/ui/server/public/release.html +2111 -0
  226. package/src/ui/server/public/services.html +1098 -0
  227. package/src/ui/server/public/workspace-picker.js +69 -0
  228. package/src/ui/server/release-deploy-session.ts +246 -0
  229. package/src/ui/server/release-service.ts +1125 -0
  230. package/src/ui/server/sudo-store.ts +111 -0
  231. package/src/ui/server/workspace-routes.ts +2154 -0
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Security scan target discovery (security pillar).
3
+ *
4
+ * Manifest-driven like journeys: each registered repo may declare a
5
+ * top-level `security:` record keyed by service name in its .den.yml.
6
+ * Discovery just flattens those blocks (sorted, deterministic) — scan
7
+ * policy (modes, hosts) is applied later in plan.ts/run.ts so discovery
8
+ * stays safe to call from interactive pickers.
9
+ */
10
+
11
+ import { discoverRepos } from "../smoke/discover.js";
12
+ import { effectiveLocalCommands } from "../../shared/stacks/manifest-commands.js";
13
+ import type { WorkspaceService } from "../../shared/manifest/workspace-manifests.js";
14
+ import type {
15
+ DiscoveredRepo,
16
+ SecurityServiceConfig,
17
+ } from "../../shared/types/index.js";
18
+
19
+ /** A declared security target with its source repo metadata. */
20
+ export interface DiscoveredSecurityTarget {
21
+ /** Manifest name of the repo that declared the target. */
22
+ repoName: string;
23
+ /** Absolute path of the registered repo checkout (OpenAPI files resolve here). */
24
+ repoPath: string;
25
+ /** Service key under `security:`. */
26
+ serviceName: string;
27
+ config: SecurityServiceConfig;
28
+ /** Derived by `security.auto_targets` from a health URL, not declared. */
29
+ auto?: true;
30
+ }
31
+
32
+ /** Repo-qualified name used by --service and the module picker. */
33
+ export function securityTargetId(
34
+ target: Pick<DiscoveredSecurityTarget, "repoName" | "serviceName">,
35
+ ): string {
36
+ return `${target.repoName}/${target.serviceName}`;
37
+ }
38
+
39
+ /**
40
+ * Discover security targets across registered repos.
41
+ *
42
+ * @param repos Repo list to scan; defaults to discoverRepos(). Injectable
43
+ * for tests.
44
+ */
45
+ export function discoverSecurityTargets(
46
+ repos: DiscoveredRepo[] = discoverRepos(),
47
+ ): DiscoveredSecurityTarget[] {
48
+ const results: DiscoveredSecurityTarget[] = [];
49
+
50
+ const sortedRepos = [...repos].sort((a, b) =>
51
+ a.repoName.localeCompare(b.repoName),
52
+ );
53
+
54
+ for (const repo of sortedRepos) {
55
+ const entries = Object.entries(repo.manifest.security ?? {}).sort(
56
+ ([a], [b]) => a.localeCompare(b),
57
+ );
58
+ for (const [serviceName, config] of entries) {
59
+ results.push({
60
+ repoName: repo.repoName,
61
+ repoPath: repo.repoPath,
62
+ serviceName,
63
+ config,
64
+ });
65
+ }
66
+ }
67
+
68
+ return results;
69
+ }
70
+
71
+ /**
72
+ * Targets `security.auto_targets` adds: each service that runs locally (it has
73
+ * local commands or compose) with an HTTP health URL, scanned at that URL's
74
+ * origin in each env that has one. Never a service its manifest marks
75
+ * `security.skip`, one already declared under `security:`, or one with no
76
+ * local runtime (a remote or shell-checked service). Host policy is applied
77
+ * later as for any target: every URL must be on the owned-host allowlist.
78
+ */
79
+ export function autoSecurityTargets(
80
+ services: WorkspaceService[],
81
+ declared: DiscoveredSecurityTarget[],
82
+ ): DiscoveredSecurityTarget[] {
83
+ const taken = new Set(declared.map(securityTargetId));
84
+ const out: DiscoveredSecurityTarget[] = [];
85
+ for (const s of services) {
86
+ const id = securityTargetId({ repoName: s.repo, serviceName: s.service });
87
+ if (taken.has(id) || s.config?.security?.skip) continue;
88
+ const runsLocally =
89
+ Object.keys(effectiveLocalCommands(s.manifest, s.service, s.repo, "auto"))
90
+ .length > 0;
91
+ if (!runsLocally) continue;
92
+ const hosts: SecurityServiceConfig["hosts"] = {};
93
+ for (const [env, h] of Object.entries(s.config?.health ?? {})) {
94
+ if (!h || h.type === "shell" || !/^https?:\/\//.test(h.url)) continue;
95
+ hosts[env as keyof typeof hosts] = [new URL(h.url).origin + "/"];
96
+ }
97
+ if (Object.keys(hosts).length === 0) continue;
98
+ taken.add(id);
99
+ out.push({
100
+ repoName: s.repo,
101
+ repoPath: s.repoPath,
102
+ serviceName: s.service,
103
+ config: { hosts },
104
+ auto: true,
105
+ });
106
+ }
107
+ return out;
108
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * ZAP findings and the "latest run" record (security pillar).
3
+ *
4
+ * Each run writes summary-<mode>.json in its run directory and points
5
+ * reports/security/<env>/latest-<mode>.json at it. The Tests page reads the
6
+ * pointer (/api/security/latest), and the next run of the same env and mode
7
+ * reads it to mark findings that are new since then.
8
+ */
9
+
10
+ import * as fs from "fs";
11
+ import * as path from "path";
12
+ import { getDistRoot } from "../../shared/paths.js";
13
+ import type { SecurityMode } from "../../shared/types/index.js";
14
+
15
+ export type Risk = "High" | "Medium" | "Low" | "Informational";
16
+ const RISKS: Record<string, Risk> = {
17
+ "3": "High",
18
+ "2": "Medium",
19
+ "1": "Low",
20
+ "0": "Informational",
21
+ };
22
+ export const RISK_ORDER: Risk[] = ["High", "Medium", "Low", "Informational"];
23
+
24
+ export interface Finding {
25
+ pluginId: string;
26
+ name: string;
27
+ risk: Risk;
28
+ confidence: string;
29
+ count: number;
30
+ /** A few URLs where it was seen. */
31
+ urls: string[];
32
+ solution?: string;
33
+ }
34
+
35
+ interface ZapJson {
36
+ site?: Array<{
37
+ alerts?: Array<{
38
+ pluginid?: string;
39
+ alert?: string;
40
+ name?: string;
41
+ riskcode?: string;
42
+ confidence?: string;
43
+ count?: string;
44
+ solution?: string;
45
+ instances?: Array<{ uri?: string }>;
46
+ }>;
47
+ }>;
48
+ }
49
+
50
+ const stripTags = (s: string | undefined): string | undefined =>
51
+ s
52
+ ?.replace(/<[^>]+>/g, " ")
53
+ .replace(/\s+/g, " ")
54
+ .trim() || undefined;
55
+
56
+ /** Findings from a ZAP JSON report, most severe first. */
57
+ export function parseZapReport(json: unknown): Finding[] {
58
+ const report = (json ?? {}) as ZapJson;
59
+ const byPlugin = new Map<string, Finding>();
60
+ for (const site of report.site ?? []) {
61
+ for (const a of site.alerts ?? []) {
62
+ const pluginId = String(a.pluginid ?? a.alert ?? "?");
63
+ const risk = RISKS[String(a.riskcode ?? "0")] ?? "Informational";
64
+ const urls = (a.instances ?? [])
65
+ .map((i) => i.uri)
66
+ .filter((u): u is string => Boolean(u));
67
+ const prev = byPlugin.get(pluginId);
68
+ if (prev) {
69
+ prev.count += Number(a.count ?? urls.length) || 0;
70
+ for (const u of urls)
71
+ if (prev.urls.length < 5 && !prev.urls.includes(u)) prev.urls.push(u);
72
+ continue;
73
+ }
74
+ byPlugin.set(pluginId, {
75
+ pluginId,
76
+ name: a.alert ?? a.name ?? pluginId,
77
+ risk,
78
+ confidence: a.confidence ?? "",
79
+ count: Number(a.count ?? urls.length) || 0,
80
+ urls: urls.slice(0, 5),
81
+ solution: stripTags(a.solution),
82
+ });
83
+ }
84
+ }
85
+ return [...byPlugin.values()].sort(
86
+ (x, y) =>
87
+ RISK_ORDER.indexOf(x.risk) - RISK_ORDER.indexOf(y.risk) ||
88
+ x.name.localeCompare(y.name),
89
+ );
90
+ }
91
+
92
+ export interface TargetResult {
93
+ id: string;
94
+ repo: string;
95
+ service: string;
96
+ org: string;
97
+ url: string;
98
+ /** null when the scan itself failed. */
99
+ findings: Finding[] | null;
100
+ error?: string;
101
+ /** pluginIds not present in the previous run of this target + mode + env. */
102
+ newSince?: string[];
103
+ report?: string;
104
+ }
105
+
106
+ export interface ScanSummary {
107
+ id: string;
108
+ env: string;
109
+ mode: SecurityMode;
110
+ startedAt: string;
111
+ finishedAt: string;
112
+ image?: string;
113
+ results: TargetResult[];
114
+ }
115
+
116
+ /** pluginIds in `now` that the previous run of the same target did not have. */
117
+ export function newFindings(
118
+ now: Finding[],
119
+ previous: Finding[] | null | undefined,
120
+ ): string[] {
121
+ if (!previous) return [];
122
+ const before = new Set(previous.map((f) => f.pluginId));
123
+ return now.filter((f) => !before.has(f.pluginId)).map((f) => f.pluginId);
124
+ }
125
+
126
+ export function securityReportsDir(): string {
127
+ return path.join(getDistRoot(), "reports", "security");
128
+ }
129
+
130
+ export function latestSummaryPath(
131
+ reportsRoot: string,
132
+ env: string,
133
+ mode: string,
134
+ ): string {
135
+ return path.join(reportsRoot, env, `latest-${mode}.json`);
136
+ }
137
+
138
+ /** The newest run of `env` + `mode`, or null when there is none. */
139
+ export function readLatestSummary(
140
+ env: string,
141
+ mode: string,
142
+ reportsRoot: string = securityReportsDir(),
143
+ ): ScanSummary | null {
144
+ try {
145
+ return JSON.parse(
146
+ fs.readFileSync(latestSummaryPath(reportsRoot, env, mode), "utf-8"),
147
+ ) as ScanSummary;
148
+ } catch {
149
+ return null;
150
+ }
151
+ }
@@ -0,0 +1,355 @@
1
+ /**
2
+ * Security scan planning (security pillar) — PURE policy + command building.
3
+ *
4
+ * Nothing here touches the network or docker: it decides WHAT a ZAP run may
5
+ * be (modes per env, target hosts, exclusions) and renders the `docker run`
6
+ * argv for it. The runner (run.ts) is the only thing that executes the
7
+ * result, which keeps every safety rule unit-testable. The one input read
8
+ * from disk is the owned-host allowlist (config/den.yml), and every
9
+ * function that uses it takes it as an injectable parameter.
10
+ *
11
+ * Safety posture, in order of precedence:
12
+ * 1. MODE_CEILING is code, not config: a manifest can narrow what an env
13
+ * allows but never widen it (production = passive `baseline` only;
14
+ * local, dev, sandbox & staging = full).
15
+ * 2. Targets must be http(s), https-only outside dev/sandbox/local, and the
16
+ * host must be on the owned-host allowlist in EVERY env, local included
17
+ * (config/den.yml `security.owned_hosts`, default: none) — a manifest typo or hostile `hosts:` entry can't point
18
+ * ZAP at a system we don't own.
19
+ * 3. Credentials never ride argv: the Authorization header value is passed
20
+ * through the child env (`-e NAME` with no value) so it can't leak via
21
+ * the process list.
22
+ */
23
+
24
+ import * as path from "path";
25
+ import { readSecurityOwnedHosts } from "../../shared/den-config.js";
26
+ import type {
27
+ Environment,
28
+ SecurityMode,
29
+ SecurityServiceConfig,
30
+ } from "../../shared/types/index.js";
31
+
32
+ import { envKey, product } from "../../shared/product.js";
33
+ // ── Mode policy ──────────────────────────────────────────────────────────────
34
+
35
+ const ALL_MODES: readonly SecurityMode[] = ["baseline", "api", "full"];
36
+
37
+ /** Hard per-env ceiling — see module header (1). */
38
+ export const MODE_CEILING: Readonly<
39
+ Record<Environment, readonly SecurityMode[]>
40
+ > = {
41
+ local: ALL_MODES,
42
+ dev: ALL_MODES,
43
+ sandbox: ALL_MODES,
44
+ staging: ALL_MODES,
45
+ // beta is passive only; prod is never scanned.
46
+ beta: ["baseline"],
47
+ prod: [],
48
+ production: ["baseline"],
49
+ };
50
+
51
+ /** Defaults when a service declares no `modes:` for the env. */
52
+ export const DEFAULT_MODES: Readonly<
53
+ Record<Environment, readonly SecurityMode[]>
54
+ > = MODE_CEILING;
55
+
56
+ /** Modes this service may run in `env`: manifest (or default) ∩ ceiling. */
57
+ export function allowedModes(
58
+ config: SecurityServiceConfig,
59
+ env: Environment,
60
+ ): SecurityMode[] {
61
+ const requested = config.modes?.[env] ?? DEFAULT_MODES[env];
62
+ return requested.filter((mode) => MODE_CEILING[env].includes(mode));
63
+ }
64
+
65
+ // ── Target resolution ────────────────────────────────────────────────────────
66
+
67
+ /** Subset of EnvironmentConfig the resolver reads (keeps tests light). */
68
+ export interface SecurityEnvConfigLike {
69
+ baseUrls: { web: string; api: string; services?: Record<string, string> };
70
+ }
71
+
72
+ /** Env var that extends the owned-host allowlist for one shell (comma-separated). */
73
+ export const EXTRA_HOSTS_ENV = `${envKey("SECURITY_EXTRA_HOSTS")}`;
74
+
75
+ /**
76
+ * The owned-host allowlist: config/den.yml `security.owned_hosts` plus
77
+ * DEN_SECURITY_EXTRA_HOSTS. An entry matches that exact host or any
78
+ * subdomain of it (a leading "." is ignored).
79
+ */
80
+ export function ownedHostSuffixes(
81
+ configured: readonly string[] = readSecurityOwnedHosts(),
82
+ extra: string | undefined = process.env[EXTRA_HOSTS_ENV],
83
+ ): string[] {
84
+ const added = (extra ?? "")
85
+ .split(",")
86
+ .map((h) => h.trim().toLowerCase())
87
+ .filter((h) => h.length > 0);
88
+ return [...new Set([...configured, ...added])];
89
+ }
90
+
91
+ export function isOwnedHost(
92
+ hostname: string,
93
+ suffixes: readonly string[] = ownedHostSuffixes(),
94
+ ): boolean {
95
+ const host = hostname.toLowerCase();
96
+ return suffixes.some((s) => {
97
+ const bare = s.replace(/^\./, "");
98
+ return bare.length > 0 && (host === bare || host.endsWith(`.${bare}`));
99
+ });
100
+ }
101
+
102
+ /** Normalize to an origin+path with no trailing slash; throws on bad input. */
103
+ function normalizeTarget(
104
+ raw: string,
105
+ env: Environment,
106
+ suffixes: readonly string[],
107
+ ): string {
108
+ let url: URL;
109
+ try {
110
+ url = new URL(raw);
111
+ } catch {
112
+ throw new Error(`Security target "${raw}" is not a valid URL.`);
113
+ }
114
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
115
+ throw new Error(`Security target "${raw}" must be http(s).`);
116
+ }
117
+ if (
118
+ url.protocol === "http:" &&
119
+ env !== "dev" &&
120
+ env !== "sandbox" &&
121
+ env !== "local"
122
+ ) {
123
+ throw new Error(
124
+ `Security target "${raw}" must be https outside dev/sandbox/local (env=${env}).`,
125
+ );
126
+ }
127
+ if (!isOwnedHost(url.hostname, suffixes)) {
128
+ throw new Error(
129
+ `Refusing to scan "${url.hostname}": not an owned host (add it to ` +
130
+ `security.owned_hosts in ${product().configFile}). Allowed: ` +
131
+ `${suffixes.length > 0 ? suffixes.join(", ") : "(none)"}.`,
132
+ );
133
+ }
134
+ return `${url.origin}${url.pathname}`.replace(/\/+$/, "");
135
+ }
136
+
137
+ /**
138
+ * Resolve the base URLs to scan for `env`. Explicit `hosts[env]` wins over
139
+ * `baseUrlKey`, which reads `baseUrls.api` / `baseUrls.web` from the
140
+ * den environment config (config/environments/<env>.json). No URL is
141
+ * ever invented.
142
+ */
143
+ export function resolveTargets(
144
+ config: SecurityServiceConfig,
145
+ env: Environment,
146
+ envConfig: SecurityEnvConfigLike,
147
+ suffixes: readonly string[] = ownedHostSuffixes(),
148
+ ): string[] {
149
+ const explicit = config.hosts?.[env];
150
+ let raw: string[];
151
+ if (explicit !== undefined && explicit.length > 0) {
152
+ raw = explicit;
153
+ } else if (config.baseUrlKey === "api") {
154
+ raw = [envConfig.baseUrls.api];
155
+ } else if (config.baseUrlKey === "web") {
156
+ raw = [envConfig.baseUrls.web];
157
+ } else {
158
+ throw new Error(`No security target resolvable for env "${env}".`);
159
+ }
160
+ return [...new Set(raw.map((r) => normalizeTarget(r, env, suffixes)))];
161
+ }
162
+
163
+ // ── ZAP command ──────────────────────────────────────────────────────────────
164
+
165
+ /** Pinned ZAP image; override with DEN_ZAP_IMAGE for a vetted digest. */
166
+ export const ZAP_IMAGE = "ghcr.io/zaproxy/zaproxy:stable";
167
+
168
+ /** Always excluded: spidering an authenticated session must not log it out. */
169
+ export const DEFAULT_EXCLUDES: readonly string[] = ["(?i).*/(log|sign)out.*"];
170
+
171
+ /** Wall-clock cap per scan; the runner kills the container past this. */
172
+ export const SCAN_TIMEOUT_MS: Readonly<Record<SecurityMode, number>> = {
173
+ baseline: 10 * 60_000,
174
+ api: 30 * 60_000,
175
+ full: 60 * 60_000,
176
+ };
177
+
178
+ const SCAN_SCRIPT: Readonly<Record<SecurityMode, string>> = {
179
+ baseline: "zap-baseline.py",
180
+ api: "zap-api-scan.py",
181
+ full: "zap-full-scan.py",
182
+ };
183
+
184
+ export const AUTH_HEADER_ENV = "ZAP_AUTH_HEADER_VALUE";
185
+
186
+ export interface ZapPlanInput {
187
+ mode: SecurityMode;
188
+ target: string;
189
+ /** Absolute dir mounted rw at /zap/wrk; ZAP writes report.json/html here. */
190
+ reportDir: string;
191
+ /** Absolute path of a local OpenAPI file (mounted ro), if the spec is a file. */
192
+ openapiFile?: string;
193
+ /** Resolved OpenAPI URL (already template-expanded), if the spec is a URL. */
194
+ openapiUrl?: string;
195
+ exclude?: readonly string[];
196
+ /** True when the run injects an Authorization header via AUTH_HEADER_ENV. */
197
+ authenticated: boolean;
198
+ }
199
+
200
+ export interface ZapPlan {
201
+ command: "docker";
202
+ args: string[];
203
+ /** Env var NAMES that must be present in the child env (values never in argv). */
204
+ envPassthrough: string[];
205
+ timeoutMs: number;
206
+ }
207
+
208
+ /**
209
+ * Resolve the manifest `openapi` value: a URL template (`{baseUrl}` expands
210
+ * to the target) or a repo-relative file path (resolved under `repoPath`).
211
+ */
212
+ export function resolveOpenapi(
213
+ openapi: string | undefined,
214
+ target: string,
215
+ repoPath: string,
216
+ ): { file?: string; url?: string } {
217
+ if (openapi === undefined) return {};
218
+ if (/^(https?:\/\/|\{baseUrl\})/.test(openapi)) {
219
+ return { url: openapi.replace(/\{baseUrl\}/g, target) };
220
+ }
221
+ const file = path.resolve(repoPath, openapi);
222
+ if (!file.startsWith(path.resolve(repoPath) + path.sep)) {
223
+ throw new Error(`openapi path "${openapi}" escapes the repo checkout.`);
224
+ }
225
+ return { file };
226
+ }
227
+
228
+ /**
229
+ * `*.localhost` names resolve to loopback on the engineer's machine, but
230
+ * inside the ZAP container they would resolve to the container itself (or
231
+ * not at all). Map each one onto the docker host so ZAP reaches the local
232
+ * edge. ZAP does not verify upstream TLS, so the edge's own CA is not needed.
233
+ */
234
+ function hostGatewayArgs(urls: readonly (string | undefined)[]): string[] {
235
+ const hosts = new Set<string>();
236
+ for (const raw of urls) {
237
+ if (raw === undefined) continue;
238
+ try {
239
+ const host = new URL(raw).hostname.toLowerCase();
240
+ if (host === "localhost" || host.endsWith(".localhost")) hosts.add(host);
241
+ } catch {
242
+ /* not a URL (e.g. a mounted spec path) */
243
+ }
244
+ }
245
+ return [...hosts].sort().flatMap((h) => ["--add-host", `${h}:host-gateway`]);
246
+ }
247
+
248
+ /** Build the `docker run` argv for one scan. Throws on an unrunnable plan. */
249
+ export function buildZapPlan(input: ZapPlanInput): ZapPlan {
250
+ const { mode, target } = input;
251
+ const args: string[] = [
252
+ "run",
253
+ "--rm",
254
+ "-v",
255
+ `${input.reportDir}:/zap/wrk:rw`,
256
+ ];
257
+ const envPassthrough: string[] = [];
258
+
259
+ args.push(
260
+ ...hostGatewayArgs([target, mode === "api" ? input.openapiUrl : undefined]),
261
+ );
262
+
263
+ if (input.authenticated) {
264
+ args.push("-e", "ZAP_AUTH_HEADER=Authorization", "-e", AUTH_HEADER_ENV);
265
+ envPassthrough.push(AUTH_HEADER_ENV);
266
+ }
267
+
268
+ let scanTarget = target;
269
+ if (mode === "api") {
270
+ if (input.openapiFile !== undefined) {
271
+ const mounted = `/zap/spec/${path.basename(input.openapiFile)}`;
272
+ args.push("-v", `${input.openapiFile}:${mounted}:ro`);
273
+ scanTarget = mounted;
274
+ } else if (input.openapiUrl !== undefined) {
275
+ scanTarget = input.openapiUrl;
276
+ } else {
277
+ throw new Error('mode "api" requires an openapi spec for the service.');
278
+ }
279
+ }
280
+
281
+ args.push(process.env.DEN_ZAP_IMAGE ?? ZAP_IMAGE, SCAN_SCRIPT[mode]);
282
+ args.push("-t", scanTarget);
283
+ if (mode === "api") {
284
+ // -O overrides the host in the spec so requests hit the resolved target.
285
+ args.push("-f", "openapi", "-O", target);
286
+ }
287
+ // -I: ZAP's own warn/fail exit codes are ignored; the verdict is computed
288
+ // from report.json by the runner. -m bounds spidering (minutes).
289
+ args.push("-J", "report.json", "-r", "report.html", "-I");
290
+ if (mode !== "api") args.push("-m", mode === "full" ? "10" : "3");
291
+
292
+ const excludes = [...DEFAULT_EXCLUDES, ...(input.exclude ?? [])];
293
+ const zapOpts = excludes
294
+ .map(
295
+ (re, i) =>
296
+ `-config globalexcludeurl.url_list.url(${i}).regex=${re} ` +
297
+ `-config globalexcludeurl.url_list.url(${i}).enabled=true`,
298
+ )
299
+ .join(" ");
300
+ args.push("-z", zapOpts);
301
+
302
+ return {
303
+ command: "docker",
304
+ args,
305
+ envPassthrough,
306
+ timeoutMs: SCAN_TIMEOUT_MS[mode],
307
+ };
308
+ }
309
+
310
+ // ── Report summary ───────────────────────────────────────────────────────────
311
+
312
+ export interface AlertCounts {
313
+ high: number;
314
+ medium: number;
315
+ low: number;
316
+ informational: number;
317
+ }
318
+
319
+ /**
320
+ * Count alerts by risk from a ZAP traditional-JSON report. Throws on a
321
+ * report that isn't ZAP's shape — a malformed report must read as an infra
322
+ * error, never as "zero findings".
323
+ */
324
+ export function summarizeZapReport(report: unknown): AlertCounts {
325
+ const sites = (report as { site?: unknown })?.site;
326
+ if (!Array.isArray(sites)) {
327
+ throw new Error("ZAP report has no `site` array — not a ZAP JSON report.");
328
+ }
329
+ const counts: AlertCounts = { high: 0, medium: 0, low: 0, informational: 0 };
330
+ for (const site of sites) {
331
+ const alerts = (site as { alerts?: unknown }).alerts;
332
+ if (!Array.isArray(alerts)) continue;
333
+ for (const alert of alerts) {
334
+ switch (String((alert as { riskcode?: unknown }).riskcode)) {
335
+ case "3":
336
+ counts.high += 1;
337
+ break;
338
+ case "2":
339
+ counts.medium += 1;
340
+ break;
341
+ case "1":
342
+ counts.low += 1;
343
+ break;
344
+ default:
345
+ counts.informational += 1;
346
+ }
347
+ }
348
+ }
349
+ return counts;
350
+ }
351
+
352
+ /** A scan FAILS on any High alert; lower risks are reported, not gated. */
353
+ export function scanFailed(counts: AlertCounts): boolean {
354
+ return counts.high > 0;
355
+ }