@intentius/chant-lexicon-gitlab 0.1.23 → 0.3.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 (86) hide show
  1. package/dist/integrity.json +25 -5
  2. package/dist/manifest.json +1 -1
  3. package/dist/rules/wgl029.ts +59 -0
  4. package/dist/rules/wgl030.ts +48 -0
  5. package/dist/rules/wgl031.ts +37 -0
  6. package/dist/rules/wgl032.ts +68 -0
  7. package/dist/rules/wgl033.ts +38 -0
  8. package/dist/rules/wgl034.ts +42 -0
  9. package/dist/rules/wgl035.ts +44 -0
  10. package/dist/rules/wgl036.ts +44 -0
  11. package/dist/rules/wgl037.ts +47 -0
  12. package/dist/rules/wgl038.ts +48 -0
  13. package/dist/rules/wgl039.ts +41 -0
  14. package/dist/rules/wgl040.ts +42 -0
  15. package/dist/rules/wgl041.ts +57 -0
  16. package/dist/rules/wgl042.ts +46 -0
  17. package/dist/rules/wgl043.ts +45 -0
  18. package/dist/rules/wgl044.ts +41 -0
  19. package/dist/rules/wgl045.ts +57 -0
  20. package/dist/rules/wgl046.ts +43 -0
  21. package/dist/rules/wgl047.ts +41 -0
  22. package/dist/rules/wgl048.ts +37 -0
  23. package/dist/rules/yaml-helpers.ts +214 -0
  24. package/dist/skills/chant-gitlab-migrate.md +10 -0
  25. package/dist/skills/chant-gitlab-patterns.md +15 -0
  26. package/package.json +1 -1
  27. package/src/codegen/docs.ts +251 -2
  28. package/src/composites/composites.test.ts +58 -0
  29. package/src/composites/index.ts +2 -0
  30. package/src/composites/mr-plan-report.ts +82 -0
  31. package/src/index.ts +2 -2
  32. package/src/lint/post-synth/wgl029.test.ts +77 -0
  33. package/src/lint/post-synth/wgl029.ts +59 -0
  34. package/src/lint/post-synth/wgl030.test.ts +57 -0
  35. package/src/lint/post-synth/wgl030.ts +48 -0
  36. package/src/lint/post-synth/wgl031.test.ts +66 -0
  37. package/src/lint/post-synth/wgl031.ts +37 -0
  38. package/src/lint/post-synth/wgl032.test.ts +53 -0
  39. package/src/lint/post-synth/wgl032.ts +68 -0
  40. package/src/lint/post-synth/wgl033.test.ts +58 -0
  41. package/src/lint/post-synth/wgl033.ts +38 -0
  42. package/src/lint/post-synth/wgl034.test.ts +60 -0
  43. package/src/lint/post-synth/wgl034.ts +42 -0
  44. package/src/lint/post-synth/wgl035.test.ts +42 -0
  45. package/src/lint/post-synth/wgl035.ts +44 -0
  46. package/src/lint/post-synth/wgl036.test.ts +49 -0
  47. package/src/lint/post-synth/wgl036.ts +44 -0
  48. package/src/lint/post-synth/wgl037.test.ts +45 -0
  49. package/src/lint/post-synth/wgl037.ts +47 -0
  50. package/src/lint/post-synth/wgl038.test.ts +54 -0
  51. package/src/lint/post-synth/wgl038.ts +48 -0
  52. package/src/lint/post-synth/wgl039.test.ts +39 -0
  53. package/src/lint/post-synth/wgl039.ts +41 -0
  54. package/src/lint/post-synth/wgl040.test.ts +48 -0
  55. package/src/lint/post-synth/wgl040.ts +42 -0
  56. package/src/lint/post-synth/wgl041.test.ts +49 -0
  57. package/src/lint/post-synth/wgl041.ts +57 -0
  58. package/src/lint/post-synth/wgl042.test.ts +45 -0
  59. package/src/lint/post-synth/wgl042.ts +46 -0
  60. package/src/lint/post-synth/wgl043.test.ts +43 -0
  61. package/src/lint/post-synth/wgl043.ts +45 -0
  62. package/src/lint/post-synth/wgl044.test.ts +46 -0
  63. package/src/lint/post-synth/wgl044.ts +41 -0
  64. package/src/lint/post-synth/wgl045.test.ts +48 -0
  65. package/src/lint/post-synth/wgl045.ts +57 -0
  66. package/src/lint/post-synth/wgl046.test.ts +67 -0
  67. package/src/lint/post-synth/wgl046.ts +43 -0
  68. package/src/lint/post-synth/wgl047.test.ts +40 -0
  69. package/src/lint/post-synth/wgl047.ts +41 -0
  70. package/src/lint/post-synth/wgl048.test.ts +56 -0
  71. package/src/lint/post-synth/wgl048.ts +37 -0
  72. package/src/lint/post-synth/yaml-helpers.ts +214 -0
  73. package/src/lint/rules/data/known-include-sources.ts +41 -0
  74. package/src/lint/rules/data/untrusted-variables.ts +31 -0
  75. package/src/mcp/context-tools.test.ts +178 -0
  76. package/src/mcp/context-tools.ts +282 -0
  77. package/src/migrate/from-github/diagnostics.ts +18 -0
  78. package/src/migrate/from-github/index.ts +20 -1
  79. package/src/migrate/from-github/provenance.ts +28 -0
  80. package/src/migrate/from-github/rules.ts +7 -0
  81. package/src/migrate/from-github/security.test.ts +98 -0
  82. package/src/migrate/from-github/security.ts +208 -0
  83. package/src/plugin.test.ts +30 -1
  84. package/src/plugin.ts +4 -0
  85. package/src/skills/chant-gitlab-migrate.md +10 -0
  86. package/src/skills/chant-gitlab-patterns.md +15 -0
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Read-only MCP tools that expose what `chant build` already computes about a
3
+ * GitLab pipeline — its stages and jobs, what it pulls in from outside, its
4
+ * security findings — so an agent can ask about a change *before it runs or
5
+ * merges* (#327, #328).
6
+ *
7
+ * Every tool here builds from source and returns data. None of them touch the
8
+ * live GitLab instance, read run history, or write anything — that boundary is
9
+ * what keeps chant a context producer and not a copy of the live tooling.
10
+ */
11
+
12
+ import { build, type BuildResult } from "@intentius/chant/build";
13
+ import { runPostSynthChecks, getPrimaryOutput } from "@intentius/chant/lint/post-synth";
14
+ import { discoverPostSynthChecks } from "@intentius/chant/lint/discover";
15
+ import { getProvenance } from "@intentius/chant/provenance";
16
+ import type { McpToolContribution } from "@intentius/chant/mcp/types";
17
+ import { dirname, join, relative, isAbsolute, resolve } from "path";
18
+ import { readFile } from "fs/promises";
19
+ import { fileURLToPath } from "url";
20
+ import { gitlabSerializer } from "../serializer";
21
+ import { transform, detectGitHubWorkflow } from "../migrate/from-github";
22
+ import {
23
+ extractStages,
24
+ extractJobs,
25
+ extractIncludes,
26
+ extractImageRefs,
27
+ isPinnedRef,
28
+ } from "../lint/post-synth/yaml-helpers";
29
+
30
+ /** Build the project and return the emitted GitLab YAML plus the raw result. */
31
+ async function buildGitlab(path: string): Promise<{ yaml: string; result: BuildResult }> {
32
+ const result = await build(path, [gitlabSerializer]);
33
+ const out = result.outputs.get("gitlab");
34
+ return { yaml: out ? getPrimaryOutput(out) : "", result };
35
+ }
36
+
37
+ /** Discover the lexicon's post-synth checks without depending on the plugin. */
38
+ function gitlabPostSynthChecks() {
39
+ const dir = join(dirname(fileURLToPath(import.meta.url)), "..", "lint", "post-synth");
40
+ return discoverPostSynthChecks(dir, import.meta.url);
41
+ }
42
+
43
+ /** Is a component address (`host/group/comp@version`) pinned to a fixed version? */
44
+ export function componentPinned(value: string): boolean {
45
+ const at = value.lastIndexOf("@");
46
+ return at !== -1 && isPinnedRef(value.slice(at + 1));
47
+ }
48
+
49
+ /** The YAML job name a build entity serializes to (camelCase → kebab-case). */
50
+ export function jobNameOf(entityName: string): string {
51
+ return entityName.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
52
+ }
53
+
54
+ /** Find the build entity whose serialized job name matches `job`. */
55
+ function entityForJob(result: BuildResult, job: string): { name: string; entity: object } | undefined {
56
+ for (const [name, entity] of result.entities) {
57
+ if (name === job || jobNameOf(name) === job) return { name, entity };
58
+ }
59
+ return undefined;
60
+ }
61
+
62
+ /** Render a source-file path relative to the project root, when possible. */
63
+ function relSource(root: string, file: string | undefined): string | undefined {
64
+ if (!file) return undefined;
65
+ if (!isAbsolute(file)) return file;
66
+ const rel = relative(root, file);
67
+ return rel.startsWith("..") ? file : rel;
68
+ }
69
+
70
+ /** Jobs that would re-run because they depend (transitively) on `job`. */
71
+ export function downstreamJobs(yaml: string, job: string): string[] {
72
+ const jobs = extractJobs(yaml);
73
+ // edge: upstream -> downstream (J depends on each of its needs)
74
+ const downstreamOf = new Map<string, string[]>();
75
+ for (const [name, j] of jobs) {
76
+ for (const dep of j.needs ?? []) {
77
+ const arr = downstreamOf.get(dep) ?? [];
78
+ arr.push(name);
79
+ downstreamOf.set(dep, arr);
80
+ }
81
+ }
82
+ const out = new Set<string>();
83
+ const queue = [job];
84
+ while (queue.length) {
85
+ const cur = queue.shift()!;
86
+ for (const next of downstreamOf.get(cur) ?? []) {
87
+ if (!out.has(next)) {
88
+ out.add(next);
89
+ queue.push(next);
90
+ }
91
+ }
92
+ }
93
+ return [...out];
94
+ }
95
+
96
+ const PATH_INPUT = {
97
+ type: "object" as const,
98
+ properties: {
99
+ path: { type: "string", description: "Path to the chant project directory (default: current directory)" },
100
+ },
101
+ };
102
+
103
+ /** The read-only context tools for the GitLab lexicon. */
104
+ export function gitlabContextTools(): McpToolContribution[] {
105
+ return [
106
+ {
107
+ name: "gitlab:checks",
108
+ description:
109
+ "Build the pipeline and return its security and correctness findings (the WGL checks) as JSON. Read-only — does not touch the live GitLab instance.",
110
+ inputSchema: PATH_INPUT,
111
+ async handler(params: Record<string, unknown>): Promise<unknown> {
112
+ const { yaml, result } = await buildGitlab((params.path as string) ?? ".");
113
+ if (!yaml) return { findings: [], note: "no GitLab pipeline produced from this project" };
114
+ const scoped = { ...result, outputs: new Map([["gitlab", result.outputs.get("gitlab")!]]) };
115
+ const diags = runPostSynthChecks(gitlabPostSynthChecks(), scoped);
116
+ return {
117
+ findings: diags.map((d) => ({
118
+ id: d.checkId,
119
+ severity: d.severity,
120
+ job: d.entity ?? null,
121
+ message: d.message,
122
+ })),
123
+ };
124
+ },
125
+ },
126
+ {
127
+ name: "gitlab:pipeline",
128
+ description:
129
+ "Build the pipeline and return its stages and jobs as written (name, stage, and what each job runs after) — before anything runs. Read-only.",
130
+ inputSchema: PATH_INPUT,
131
+ async handler(params: Record<string, unknown>): Promise<unknown> {
132
+ const { yaml } = await buildGitlab((params.path as string) ?? ".");
133
+ const jobs = [...extractJobs(yaml).values()].map((j) => ({
134
+ name: j.name,
135
+ stage: j.stage ?? null,
136
+ runsAfter: j.needs ?? [],
137
+ }));
138
+ return { stages: extractStages(yaml), jobs };
139
+ },
140
+ },
141
+ {
142
+ name: "gitlab:references",
143
+ description:
144
+ "Build the pipeline and list everything it pulls in from outside (includes, components, images) and whether each is pinned to a fixed version. Read-only.",
145
+ inputSchema: PATH_INPUT,
146
+ async handler(params: Record<string, unknown>): Promise<unknown> {
147
+ const { yaml } = await buildGitlab((params.path as string) ?? ".");
148
+ const includes = extractIncludes(yaml).map((e) => ({
149
+ kind: e.kind,
150
+ source: e.value,
151
+ ref: e.ref ?? null,
152
+ pinned:
153
+ e.kind === "project" ? (e.ref ? isPinnedRef(e.ref) : false) : e.kind === "component" ? componentPinned(e.value) : null,
154
+ }));
155
+ const images = extractImageRefs(yaml).map((i) => ({
156
+ kind: "image" as const,
157
+ source: i.image,
158
+ ref: null,
159
+ pinned: i.image.includes("@sha256:"),
160
+ }));
161
+ return [...includes, ...images];
162
+ },
163
+ },
164
+ {
165
+ name: "gitlab:affected",
166
+ description:
167
+ "Build the pipeline and, given a job name, list the jobs that would re-run because they depend on it (the needs chain). Read-only.",
168
+ inputSchema: {
169
+ type: "object" as const,
170
+ properties: {
171
+ path: { type: "string", description: "Path to the chant project directory (default: current directory)" },
172
+ job: { type: "string", description: "Job name to trace downstream from" },
173
+ },
174
+ required: ["job"],
175
+ },
176
+ async handler(params: Record<string, unknown>): Promise<unknown> {
177
+ const { yaml } = await buildGitlab((params.path as string) ?? ".");
178
+ const job = params.job as string;
179
+ return { job, wouldRerun: downstreamJobs(yaml, job) };
180
+ },
181
+ },
182
+ {
183
+ name: "gitlab:pipeline-yaml",
184
+ description: "Build the project and return the generated .gitlab-ci.yml as a string. Read-only.",
185
+ inputSchema: PATH_INPUT,
186
+ async handler(params: Record<string, unknown>): Promise<unknown> {
187
+ const { yaml } = await buildGitlab((params.path as string) ?? ".");
188
+ return { yaml };
189
+ },
190
+ },
191
+ {
192
+ name: "gitlab:source",
193
+ description:
194
+ "Build the project and, given a job name, say where it came from in the TypeScript source — the file that declared it and the composite that expanded it, if any. Entity-level provenance (file + composite), not a YAML-line source map. Read-only.",
195
+ inputSchema: {
196
+ type: "object" as const,
197
+ properties: {
198
+ path: { type: "string", description: "Path to the chant project directory (default: current directory)" },
199
+ job: { type: "string", description: "Job name (as it appears in the generated YAML) to trace back to source" },
200
+ },
201
+ required: ["job"],
202
+ },
203
+ async handler(params: Record<string, unknown>): Promise<unknown> {
204
+ const root = (params.path as string) ?? ".";
205
+ const { result } = await buildGitlab(root);
206
+ const job = params.job as string;
207
+ const found = entityForJob(result, job);
208
+ if (!found) return { job, found: false, note: "no build entity serializes to that job name" };
209
+ const prov = getProvenance(found.entity);
210
+ return {
211
+ job,
212
+ found: true,
213
+ entity: found.name,
214
+ from: relSource(root, prov?.sourceFile) ?? null,
215
+ via: prov?.composite ?? null,
216
+ };
217
+ },
218
+ },
219
+ {
220
+ name: "gitlab:owns",
221
+ description:
222
+ "Build the project and report whether a job is declared (owned) by chant in this project's source. For pipeline config, ownership means \"declared here\" — GitLab CI jobs are not taggable cloud resources, so there is no live ownership marker as there is for cloud lexicons. Read-only.",
223
+ inputSchema: {
224
+ type: "object" as const,
225
+ properties: {
226
+ path: { type: "string", description: "Path to the chant project directory (default: current directory)" },
227
+ job: { type: "string", description: "Job name (as it appears in the generated YAML) to check" },
228
+ },
229
+ required: ["job"],
230
+ },
231
+ async handler(params: Record<string, unknown>): Promise<unknown> {
232
+ const root = (params.path as string) ?? ".";
233
+ const { result } = await buildGitlab(root);
234
+ const job = params.job as string;
235
+ const found = entityForJob(result, job);
236
+ return {
237
+ job,
238
+ owned: Boolean(found),
239
+ basis: "declared-in-source",
240
+ note: "GitLab CI jobs are not taggable cloud resources; ownership here means the job is declared by chant in this project. Live ownership markers apply to cloud lexicons (aws/azure/k8s).",
241
+ };
242
+ },
243
+ },
244
+ {
245
+ name: "gitlab:compare",
246
+ description:
247
+ "Given a GitHub Actions workflow file, migrate it to GitLab CI and report which security properties survive the move and which weaken or are lost (the migration safety view). Returns a per-property fate (translated/approximated/needs-review/lost). Read-only — builds and analyzes, never writes.",
248
+ inputSchema: {
249
+ type: "object" as const,
250
+ properties: {
251
+ file: { type: "string", description: "Path to a .github/workflows/*.yml workflow file to migrate and compare" },
252
+ },
253
+ required: ["file"],
254
+ },
255
+ async handler(params: Record<string, unknown>): Promise<unknown> {
256
+ const file = resolve((params.file as string) ?? "");
257
+ let content: string;
258
+ try {
259
+ content = await readFile(file, "utf8");
260
+ } catch {
261
+ return { file: params.file ?? null, found: false, note: "could not read the workflow file" };
262
+ }
263
+ if (!detectGitHubWorkflow(content)) {
264
+ return { file: params.file ?? null, found: false, note: "file does not look like a GitHub Actions workflow" };
265
+ }
266
+ const migration = await transform(content, { security: true, sourceFile: file });
267
+ const properties = migration.provenance
268
+ .filter((r) => r.security)
269
+ .map((r) => ({
270
+ property: r.security!.property,
271
+ fate: r.security!.fate,
272
+ severity: r.security!.severity,
273
+ reestablish: r.security!.reestablish ?? null,
274
+ note: r.note ?? null,
275
+ }));
276
+ const summary = { translated: 0, approximated: 0, "needs-review": 0, lost: 0 } as Record<string, number>;
277
+ for (const p of properties) summary[p.fate] = (summary[p.fate] ?? 0) + 1;
278
+ return { found: true, properties, summary };
279
+ },
280
+ },
281
+ ];
282
+ }
@@ -22,6 +22,24 @@ export function provenanceToDiagnostics(
22
22
  ): LintDiagnostic[] {
23
23
  const out: LintDiagnostic[] = [];
24
24
  for (const r of records) {
25
+ // Security records always emit a diagnostic, at their own severity
26
+ // (escalated to error under --strict when the property was lost or needs
27
+ // review), independent of the functional category.
28
+ if (r.security) {
29
+ const sev =
30
+ opts.strict && (r.security.fate === "lost" || r.security.fate === "needs-review")
31
+ ? "error"
32
+ : r.security.severity;
33
+ out.push({
34
+ file: r.sourceFile ?? "<input>",
35
+ line: r.sourceLine ?? 1,
36
+ column: r.sourceColumn ?? 1,
37
+ ruleId: r.rule,
38
+ severity: sev,
39
+ message: r.note ?? r.rule,
40
+ });
41
+ continue;
42
+ }
25
43
  let severity: "error" | "warning" | "info" | undefined;
26
44
  if (r.category === "needs-review") {
27
45
  severity = opts.strict ? "error" : "warning";
@@ -12,6 +12,7 @@ import { ProvenanceAccumulator, type ProvenanceRecord } from "./provenance";
12
12
  import { transformIR } from "./transformer";
13
13
  import { emitGitlabYaml } from "./emit-yaml";
14
14
  import { provenanceToDiagnostics } from "./diagnostics";
15
+ import { analyzeSecurity, runSecurityChecks, renderSecurityPosture } from "./security";
15
16
  import { GitLabGenerator } from "../../import/generator";
16
17
  import { applyComposites } from "./composites/rewriter";
17
18
  import type { ActionMappingRegistry } from "./actions/registry";
@@ -31,6 +32,12 @@ export interface MigrateOptions {
31
32
  registry?: ActionMappingRegistry;
32
33
  /** Escalate needs-review diagnostics to errors. */
33
34
  strict?: boolean;
35
+ /**
36
+ * Security-aware migration (#306): classify each security property's fate
37
+ * across the edge and run the GitLab security post-synth checks against the
38
+ * migrated YAML. Enabled by the CLI's `--validate` path.
39
+ */
40
+ security?: boolean;
34
41
  }
35
42
 
36
43
  export interface MigrationResult {
@@ -44,6 +51,8 @@ export interface MigrationResult {
44
51
  diagnostics: LintDiagnostic[];
45
52
  /** Inferred stage list. */
46
53
  stages: string[];
54
+ /** Markdown "Security posture" section (#306). */
55
+ securityPosture: string;
47
56
  }
48
57
 
49
58
  /**
@@ -106,10 +115,20 @@ export async function transform(
106
115
  output = emitGitlabYaml(ir);
107
116
  }
108
117
 
118
+ // Security-aware migration (#306): classify each security property's fate
119
+ // across the edge, and run the GitLab security post-synth checks against the
120
+ // migrated YAML so weaknesses lost in translation surface on the target side.
121
+ if (opts.security) {
122
+ const yamlForSecurity = opts.emit === "ts" ? emitGitlabYaml(ir) : output;
123
+ provAcc.pushAll(analyzeSecurity(yamlContent, { sourceFile: opts.sourceFile }));
124
+ provAcc.pushAll(await runSecurityChecks(yamlForSecurity, { sourceFile: opts.sourceFile }));
125
+ }
126
+
109
127
  const provenance = provAcc.all();
110
128
  const diagnostics = provenanceToDiagnostics(provenance, { strict: opts.strict });
129
+ const securityPosture = renderSecurityPosture(provenance);
111
130
 
112
- return { ir, output, provenance, diagnostics, stages };
131
+ return { ir, output, provenance, diagnostics, stages, securityPosture };
113
132
  }
114
133
 
115
134
  /**
@@ -37,6 +37,29 @@ export interface ProvenanceRecord {
37
37
  actionRef?: string;
38
38
  /** Tier 1/2/3 for action-map records */
39
39
  mappingTier?: 1 | 2 | 3;
40
+ /** Security classification, when this record concerns a security property. */
41
+ security?: SecurityProvenance;
42
+ }
43
+
44
+ /**
45
+ * Fate of a security property as it crosses the GitHub → GitLab boundary,
46
+ * mirroring the functional provenance categories.
47
+ */
48
+ export type SecurityFate = "translated" | "approximated" | "needs-review" | "lost";
49
+
50
+ /**
51
+ * Security classification attached to a provenance record — tracks whether a
52
+ * security-relevant property survived migration, alongside the functional one.
53
+ */
54
+ export interface SecurityProvenance {
55
+ /** Human label for the property (e.g. "Pinned action SHA"). */
56
+ property: string;
57
+ /** What happened to the property across the migration edge. */
58
+ fate: SecurityFate;
59
+ /** Diagnostic severity for this finding. */
60
+ severity: "error" | "warning" | "info";
61
+ /** Cross-reference to the endpoint that re-establishes the property. */
62
+ reestablish?: string;
40
63
  }
41
64
 
42
65
  /**
@@ -65,4 +88,9 @@ export class ProvenanceAccumulator {
65
88
  needsReviewCount(): number {
66
89
  return this.records.filter((r) => r.category === "needs-review").length;
67
90
  }
91
+
92
+ /** Records carrying a security classification. */
93
+ securityRecords(): ProvenanceRecord[] {
94
+ return this.records.filter((r) => r.security !== undefined);
95
+ }
68
96
  }
@@ -79,4 +79,11 @@ export const MIGRATION_RULES: LintRule[] = [
79
79
 
80
80
  // Action mapping fallback
81
81
  rule("MIG-ACTION-UNKNOWN", "warning", "Marketplace action has no registered mapping"),
82
+
83
+ // ── Security posture across the migration edge (#306) ──────────────────
84
+ // Each cross-references the endpoint issue that re-establishes the property.
85
+ rule("MIG-PIN-LOST", "warning", "A pinned action SHA could not be carried to the GitLab include/image — re-pin on the GitLab side (#297)"),
86
+ rule("MIG-SECRET-UNSCOPED", "warning", "Migrated secret reference assumes a masked + protected GitLab variable (#300)"),
87
+ rule("MIG-INJECTION-CARRIED", "warning", "An untrusted-input injection site was translated verbatim across the edge (#299)"),
88
+ rule("MIG-TRUST-BOUNDARY", "warning", "Trigger/fork semantics shifted the trust boundary (pull_request_target → MR/fork pipeline) (#299)"),
82
89
  ];
@@ -0,0 +1,98 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { analyzeSecurity, runSecurityChecks, renderSecurityPosture } from "./security";
3
+ import { transform } from "./index";
4
+
5
+ const WORKFLOW = `name: CI
6
+ on:
7
+ pull_request_target:
8
+ permissions:
9
+ contents: write
10
+ jobs:
11
+ build:
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
15
+ - run: echo "title is \${{ github.event.pull_request.title }}"
16
+ - run: deploy --token \${{ secrets.DEPLOY_TOKEN }}
17
+ `;
18
+
19
+ describe("analyzeSecurity (#306)", () => {
20
+ test("classifies each security property's fate", () => {
21
+ const records = analyzeSecurity(WORKFLOW, { sourceFile: "ci.yml" });
22
+ const byRule = new Map(records.map((r) => [r.rule, r]));
23
+
24
+ expect(byRule.get("MIG-PIN-LOST")?.security?.fate).toBe("lost");
25
+ expect(byRule.get("MIG-SECRET-UNSCOPED")?.security?.fate).toBe("needs-review");
26
+ expect(byRule.get("MIG-INJECTION-CARRIED")?.security?.fate).toBe("translated");
27
+ expect(byRule.get("MIG-TRUST-BOUNDARY")?.security?.fate).toBe("needs-review");
28
+ expect(byRule.get("MIG-PERMISSIONS-001")?.security?.fate).toBe("needs-review");
29
+
30
+ // each cross-references the endpoint that re-establishes the property
31
+ expect(byRule.get("MIG-PIN-LOST")?.security?.reestablish).toBe("#297");
32
+ expect(byRule.get("MIG-SECRET-UNSCOPED")?.security?.reestablish).toBe("#300");
33
+ });
34
+
35
+ test("does not flag a clean workflow", () => {
36
+ const clean = `name: CI
37
+ on:
38
+ push:
39
+ jobs:
40
+ build:
41
+ runs-on: ubuntu-latest
42
+ steps:
43
+ - run: echo build
44
+ `;
45
+ expect(analyzeSecurity(clean)).toHaveLength(0);
46
+ });
47
+ });
48
+
49
+ describe("runSecurityChecks (#306)", () => {
50
+ test("runs the GitLab post-synth checks against migrated YAML", async () => {
51
+ // A migrated pipeline whose image is unpinned should trip WGL031.
52
+ const yaml = `stages:
53
+ - build
54
+
55
+ build:
56
+ stage: build
57
+ image:
58
+ name: node:20
59
+ script:
60
+ - npm ci
61
+ `;
62
+ const records = await runSecurityChecks(yaml);
63
+ expect(records.some((r) => r.rule === "WGL031")).toBe(true);
64
+ expect(records.every((r) => r.security !== undefined)).toBe(true);
65
+ });
66
+ });
67
+
68
+ describe("renderSecurityPosture (#306)", () => {
69
+ test("renders a posture table", () => {
70
+ const records = analyzeSecurity(WORKFLOW, { sourceFile: "ci.yml" });
71
+ const md = renderSecurityPosture(records);
72
+ expect(md).toContain("## Security posture");
73
+ expect(md).toContain("MIG-PIN-LOST");
74
+ expect(md).toContain("lost");
75
+ });
76
+
77
+ test("reports nothing for a clean workflow", () => {
78
+ expect(renderSecurityPosture([])).toContain("No security-relevant properties");
79
+ });
80
+ });
81
+
82
+ describe("transform with security: true (#306)", () => {
83
+ test("security findings flow into provenance + diagnostics + posture", async () => {
84
+ const result = await transform(WORKFLOW, { sourceFile: "ci.yml", security: true });
85
+ const ruleIds = new Set(result.diagnostics.map((d) => d.ruleId));
86
+ expect(ruleIds.has("MIG-PIN-LOST")).toBe(true);
87
+ expect(ruleIds.has("MIG-TRUST-BOUNDARY")).toBe(true);
88
+ expect(result.securityPosture).toContain("## Security posture");
89
+ expect(result.provenance.some((p) => p.security)).toBe(true);
90
+ });
91
+
92
+ test("security analysis is off by default", async () => {
93
+ const result = await transform(WORKFLOW, { sourceFile: "ci.yml" });
94
+ const ruleIds = new Set(result.diagnostics.map((d) => d.ruleId));
95
+ expect(ruleIds.has("MIG-PIN-LOST")).toBe(false);
96
+ expect(result.provenance.some((p) => p.security)).toBe(false);
97
+ });
98
+ });