@intentius/chant-lexicon-temporal 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.
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/package.json +1 -1
- package/src/composites/pipeline-audit-op.test.ts +23 -0
- package/src/composites/pipeline-audit-op.ts +100 -0
- package/src/composites/workflow-audit-op.test.ts +24 -0
- package/src/composites/workflow-audit-op.ts +108 -0
- package/src/index.ts +4 -0
- package/src/op/activities/index.ts +23 -0
- package/src/op/activities/pipeline-audit.test.ts +67 -0
- package/src/op/activities/pipeline-audit.ts +201 -0
- package/src/op/activities/workflow-audit.test.ts +90 -0
- package/src/op/activities/workflow-audit.ts +203 -0
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
4
|
+
"manifest.json": "00501339bf04a016acd73f1c72f824939d582205f76136151c07b57f369fe563",
|
|
5
5
|
"meta.json": "c77a3c415993bed3865e07fa6db4fb7b9e87de0afa8d8675f64eadf50f914077",
|
|
6
6
|
"types/index.d.ts": "5216f5256321f084a7c8211ef66ab599f621c74751cc3485cfc2a62502b81e2f",
|
|
7
7
|
"rules/tmp001.ts": "689222211a93716a7e4432f6dd6a2a2ab54020b232049fb602893872585f9978",
|
|
@@ -11,5 +11,5 @@
|
|
|
11
11
|
"skills/chant-temporal.md": "ff929983b33bb420c49ab61851f3799c5610256cb972d6c584792bb98b0cfb22",
|
|
12
12
|
"skills/chant-temporal-ops.md": "7e83bc3e6e63af4ab14b8dc07aa00132d51991cf35b1bca29560d48934bff6dc"
|
|
13
13
|
},
|
|
14
|
-
"composite": "
|
|
14
|
+
"composite": "b5882b2c1e4f6a745e759e3263027720abe90f077b9293df7c85688f0d0754ee"
|
|
15
15
|
}
|
package/dist/manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-temporal",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Temporal lexicon for chant — server deployment, namespaces, search attributes, and schedules",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://intentius.io/chant",
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { PipelineAuditOp } from "./pipeline-audit-op";
|
|
3
|
+
|
|
4
|
+
describe("PipelineAuditOp composite (#303)", () => {
|
|
5
|
+
test("one-shot form: op only, no schedule", () => {
|
|
6
|
+
const { op, schedule } = PipelineAuditOp({ name: "pipeline-audit" });
|
|
7
|
+
expect(op).toBeDefined();
|
|
8
|
+
expect(schedule).toBeUndefined();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("scheduled form: op + TemporalSchedule with merge-request finding mode", () => {
|
|
12
|
+
const { op, schedule } = PipelineAuditOp({
|
|
13
|
+
name: "pipeline-audit",
|
|
14
|
+
schedule: "0 6 * * *",
|
|
15
|
+
onFinding: "merge-request",
|
|
16
|
+
});
|
|
17
|
+
expect(op).toBeDefined();
|
|
18
|
+
expect(schedule).toBeDefined();
|
|
19
|
+
const props = (schedule as unknown as { props: Record<string, unknown> }).props;
|
|
20
|
+
expect(props.scheduleId).toBe("pipeline-audit-schedule");
|
|
21
|
+
expect((props.action as { workflowType: string }).workflowType).toBe("pipelineAuditWorkflow");
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PipelineAuditOp composite — the live include/component audit of GitLab
|
|
3
|
+
* pipelines as a chant Op + an optional TemporalSchedule (#303).
|
|
4
|
+
*
|
|
5
|
+
* The gitlab post-synth checks (#297) own everything answerable from the
|
|
6
|
+
* deterministic build. This Op owns *only* the checks that require live
|
|
7
|
+
* resolution against a moving upstream truth — a pinned component/include ref
|
|
8
|
+
* that no longer resolves, an archived or moved upstream project, or a new
|
|
9
|
+
* advisory covering a component/image in use. It sits at **observe** on the
|
|
10
|
+
* lifecycle dial, with a finding-mode (`report | issue | merge-request`) as the
|
|
11
|
+
* **reconcile** step — for GitLab the PR mode is a merge request.
|
|
12
|
+
*
|
|
13
|
+
* Runs one-shot on the local Op executor via `chant run`; on Temporal when a
|
|
14
|
+
* `schedule` is given.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```typescript
|
|
18
|
+
* export const { op, schedule } = PipelineAuditOp({
|
|
19
|
+
* name: "pipeline-audit",
|
|
20
|
+
* schedule: "0 6 * * *",
|
|
21
|
+
* onFinding: "merge-request",
|
|
22
|
+
* });
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* @see #297 — the deterministic counterpart this freshens.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { Op, phase, OpResource } from "@intentius/chant/op";
|
|
29
|
+
import { TemporalSchedule } from "../resources";
|
|
30
|
+
import type { PipelineAuditMode } from "../op/activities/pipeline-audit";
|
|
31
|
+
|
|
32
|
+
function kebabToCamel(s: string): string {
|
|
33
|
+
return s.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PipelineAuditOpConfig {
|
|
37
|
+
/** Op name (kebab-case). Used as workflow function name, task queue, schedule id base. */
|
|
38
|
+
name: string;
|
|
39
|
+
/** Cron expression. When set, a TemporalSchedule fires the workflow; omit for one-shot. */
|
|
40
|
+
schedule?: string;
|
|
41
|
+
/**
|
|
42
|
+
* Path to the emitted `.gitlab-ci.yml` to audit at run time.
|
|
43
|
+
* @default ".gitlab-ci.yml"
|
|
44
|
+
*/
|
|
45
|
+
pipelineFile?: string;
|
|
46
|
+
/**
|
|
47
|
+
* What to produce on findings. Default: "report".
|
|
48
|
+
* @default "report"
|
|
49
|
+
*/
|
|
50
|
+
onFinding?: PipelineAuditMode;
|
|
51
|
+
/** Override the task queue. Defaults to `name`. */
|
|
52
|
+
taskQueue?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface PipelineAuditOpResources {
|
|
56
|
+
/** Op resource — generates the audit workflow on `chant build`. */
|
|
57
|
+
op: InstanceType<typeof OpResource>;
|
|
58
|
+
/** Temporal schedule, present only when `schedule` was given. */
|
|
59
|
+
schedule?: InstanceType<typeof TemporalSchedule>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function PipelineAuditOp(config: PipelineAuditOpConfig): PipelineAuditOpResources {
|
|
63
|
+
const taskQueue = config.taskQueue ?? config.name;
|
|
64
|
+
const onFinding = config.onFinding ?? "report";
|
|
65
|
+
|
|
66
|
+
const op = Op({
|
|
67
|
+
name: config.name,
|
|
68
|
+
overview: "Resolve pipeline include/component/image references against live upstreams and report drift",
|
|
69
|
+
taskQueue,
|
|
70
|
+
searchAttributes: {
|
|
71
|
+
Audit: "true",
|
|
72
|
+
Surface: "gitlab-pipeline",
|
|
73
|
+
},
|
|
74
|
+
phases: [
|
|
75
|
+
phase("Audit", [
|
|
76
|
+
{
|
|
77
|
+
kind: "activity",
|
|
78
|
+
fn: "pipelineSupplyChainAudit",
|
|
79
|
+
args: { pipelineFile: config.pipelineFile ?? ".gitlab-ci.yml", mode: onFinding },
|
|
80
|
+
outcomeAttribute: { name: "Findings", from: "findings" },
|
|
81
|
+
},
|
|
82
|
+
]),
|
|
83
|
+
],
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
if (!config.schedule) {
|
|
87
|
+
return { op };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const schedule = new TemporalSchedule({
|
|
91
|
+
scheduleId: `${config.name}-schedule`,
|
|
92
|
+
spec: { cronExpressions: [config.schedule] },
|
|
93
|
+
action: {
|
|
94
|
+
workflowType: kebabToCamel(config.name) + "Workflow",
|
|
95
|
+
taskQueue,
|
|
96
|
+
},
|
|
97
|
+
} as Record<string, unknown>);
|
|
98
|
+
|
|
99
|
+
return { op, schedule };
|
|
100
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { WorkflowAuditOp } from "./workflow-audit-op";
|
|
3
|
+
|
|
4
|
+
describe("WorkflowAuditOp composite (#292)", () => {
|
|
5
|
+
test("one-shot form: op only, no schedule", () => {
|
|
6
|
+
const { op, schedule } = WorkflowAuditOp({ name: "actions-audit" });
|
|
7
|
+
expect(op).toBeDefined();
|
|
8
|
+
expect(schedule).toBeUndefined();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("scheduled form: op + TemporalSchedule on the given cron", () => {
|
|
12
|
+
const { op, schedule } = WorkflowAuditOp({
|
|
13
|
+
name: "actions-audit",
|
|
14
|
+
schedule: "0 6 * * *",
|
|
15
|
+
onFinding: "pull-request",
|
|
16
|
+
});
|
|
17
|
+
expect(op).toBeDefined();
|
|
18
|
+
expect(schedule).toBeDefined();
|
|
19
|
+
const props = (schedule as unknown as { props: Record<string, unknown> }).props;
|
|
20
|
+
expect(props.scheduleId).toBe("actions-audit-schedule");
|
|
21
|
+
expect((props.spec as { cronExpressions: string[] }).cronExpressions).toEqual(["0 6 * * *"]);
|
|
22
|
+
expect((props.action as { workflowType: string }).workflowType).toBe("actionsAuditWorkflow");
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WorkflowAuditOp composite — the live supply-chain audit of GitHub workflows
|
|
3
|
+
* as a chant Op + an optional TemporalSchedule (#292).
|
|
4
|
+
*
|
|
5
|
+
* The github post-synth checks (#286-#291) own everything answerable from the
|
|
6
|
+
* deterministic build. This Op owns *only* the checks that require live
|
|
7
|
+
* resolution against a moving upstream truth — stale SHA pins, impostor refs,
|
|
8
|
+
* symbolic-ref confusion, pin/comment mismatch, advisories, archived upstreams.
|
|
9
|
+
* It sits at **observe** on the lifecycle dial, with a finding-mode (mirroring
|
|
10
|
+
* `ReconcileOp`: `report | issue | pull-request`) as the **reconcile** step.
|
|
11
|
+
*
|
|
12
|
+
* Runs one-shot on the local Op executor via `chant run`; on Temporal when a
|
|
13
|
+
* `schedule` is given, for continuous re-audit between change windows.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```typescript
|
|
17
|
+
* // one-shot, local executor
|
|
18
|
+
* export const { op } = WorkflowAuditOp({ name: "actions-audit" });
|
|
19
|
+
*
|
|
20
|
+
* // scheduled daily on Temporal, open a PR that bumps a stale pin
|
|
21
|
+
* export const { op, schedule } = WorkflowAuditOp({
|
|
22
|
+
* name: "actions-audit",
|
|
23
|
+
* schedule: "0 6 * * *",
|
|
24
|
+
* onFinding: "pull-request",
|
|
25
|
+
* });
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* @see #286 — the deterministic counterpart this freshens.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { Op, phase, activity, OpResource } from "@intentius/chant/op";
|
|
32
|
+
import { TemporalSchedule } from "../resources";
|
|
33
|
+
import type { WorkflowAuditMode } from "../op/activities/workflow-audit";
|
|
34
|
+
|
|
35
|
+
function kebabToCamel(s: string): string {
|
|
36
|
+
return s.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface WorkflowAuditOpConfig {
|
|
40
|
+
/** Op name (kebab-case). Used as workflow function name, task queue, schedule id base. */
|
|
41
|
+
name: string;
|
|
42
|
+
/**
|
|
43
|
+
* Cron expression. When set, a TemporalSchedule fires the workflow for
|
|
44
|
+
* continuous re-audit; omit for one-shot `chant run` on the local executor.
|
|
45
|
+
*/
|
|
46
|
+
schedule?: string;
|
|
47
|
+
/**
|
|
48
|
+
* Directory of emitted workflow files to audit at run time.
|
|
49
|
+
* @default ".github/workflows"
|
|
50
|
+
*/
|
|
51
|
+
workflowsDir?: string;
|
|
52
|
+
/**
|
|
53
|
+
* What to produce on findings. Default: "report".
|
|
54
|
+
* @default "report"
|
|
55
|
+
*/
|
|
56
|
+
onFinding?: WorkflowAuditMode;
|
|
57
|
+
/** Override the task queue. Defaults to `name`. */
|
|
58
|
+
taskQueue?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface WorkflowAuditOpResources {
|
|
62
|
+
/** Op resource — generates the audit workflow on `chant build`. */
|
|
63
|
+
op: InstanceType<typeof OpResource>;
|
|
64
|
+
/** Temporal schedule, present only when `schedule` was given. */
|
|
65
|
+
schedule?: InstanceType<typeof TemporalSchedule>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function WorkflowAuditOp(config: WorkflowAuditOpConfig): WorkflowAuditOpResources {
|
|
69
|
+
const taskQueue = config.taskQueue ?? config.name;
|
|
70
|
+
const onFinding = config.onFinding ?? "report";
|
|
71
|
+
|
|
72
|
+
const op = Op({
|
|
73
|
+
name: config.name,
|
|
74
|
+
overview: "Resolve workflow action references against live upstreams and report supply-chain drift",
|
|
75
|
+
taskQueue,
|
|
76
|
+
searchAttributes: {
|
|
77
|
+
Audit: "true",
|
|
78
|
+
Surface: "github-workflows",
|
|
79
|
+
},
|
|
80
|
+
phases: [
|
|
81
|
+
phase("Audit", [
|
|
82
|
+
{
|
|
83
|
+
kind: "activity",
|
|
84
|
+
fn: "workflowSupplyChainAudit",
|
|
85
|
+
args: { workflowsDir: config.workflowsDir ?? ".github/workflows", mode: onFinding },
|
|
86
|
+
// Surface the finding count as a workflow-level search attribute so
|
|
87
|
+
// "show me audits that found drift" is a one-filter UI query.
|
|
88
|
+
outcomeAttribute: { name: "Findings", from: "findings" },
|
|
89
|
+
},
|
|
90
|
+
]),
|
|
91
|
+
],
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
if (!config.schedule) {
|
|
95
|
+
return { op };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const schedule = new TemporalSchedule({
|
|
99
|
+
scheduleId: `${config.name}-schedule`,
|
|
100
|
+
spec: { cronExpressions: [config.schedule] },
|
|
101
|
+
action: {
|
|
102
|
+
workflowType: kebabToCamel(config.name) + "Workflow",
|
|
103
|
+
taskQueue,
|
|
104
|
+
},
|
|
105
|
+
} as Record<string, unknown>);
|
|
106
|
+
|
|
107
|
+
return { op, schedule };
|
|
108
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -31,6 +31,10 @@ export { WatchOp } from "./composites/watch-op";
|
|
|
31
31
|
export type { WatchOpConfig, WatchOpResources } from "./composites/watch-op";
|
|
32
32
|
export { ReconcileOp } from "./composites/reconcile-op";
|
|
33
33
|
export type { ReconcileOpConfig, ReconcileOpResources } from "./composites/reconcile-op";
|
|
34
|
+
export { WorkflowAuditOp } from "./composites/workflow-audit-op";
|
|
35
|
+
export type { WorkflowAuditOpConfig, WorkflowAuditOpResources } from "./composites/workflow-audit-op";
|
|
36
|
+
export { PipelineAuditOp } from "./composites/pipeline-audit-op";
|
|
37
|
+
export type { PipelineAuditOpConfig, PipelineAuditOpResources } from "./composites/pipeline-audit-op";
|
|
34
38
|
export { ApplyOp } from "./composites/apply-op";
|
|
35
39
|
export type { ApplyOpConfig, ApplyOpResources } from "./composites/apply-op";
|
|
36
40
|
|
|
@@ -33,3 +33,26 @@ export type { WaitForArgoSyncArgs, ArgoAppStatus, ArgoStatusFetcher } from "./ar
|
|
|
33
33
|
|
|
34
34
|
export { policyGate } from "./policy";
|
|
35
35
|
export type { PolicyGateArgs } from "./policy";
|
|
36
|
+
|
|
37
|
+
export { workflowSupplyChainAudit, collectAuditRefs, defaultActionRefResolver } from "./workflow-audit";
|
|
38
|
+
export type {
|
|
39
|
+
WorkflowAuditArgs,
|
|
40
|
+
WorkflowAuditResult,
|
|
41
|
+
WorkflowAuditMode,
|
|
42
|
+
WorkflowAuditFinding,
|
|
43
|
+
WorkflowAuditFindingKind,
|
|
44
|
+
ActionRefResolver,
|
|
45
|
+
ActionRefResolution,
|
|
46
|
+
} from "./workflow-audit";
|
|
47
|
+
|
|
48
|
+
export { pipelineSupplyChainAudit, collectPipelineRefs, defaultGitlabRefResolver } from "./pipeline-audit";
|
|
49
|
+
export type {
|
|
50
|
+
PipelineAuditArgs,
|
|
51
|
+
PipelineAuditResult,
|
|
52
|
+
PipelineAuditMode,
|
|
53
|
+
PipelineAuditFinding,
|
|
54
|
+
PipelineAuditFindingKind,
|
|
55
|
+
PipelineRefKind,
|
|
56
|
+
GitlabRefResolver,
|
|
57
|
+
PipelineRefResolution,
|
|
58
|
+
} from "./pipeline-audit";
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
pipelineSupplyChainAudit,
|
|
4
|
+
collectPipelineRefs,
|
|
5
|
+
type PipelineRefResolution,
|
|
6
|
+
type GitlabRefResolver,
|
|
7
|
+
} from "./pipeline-audit";
|
|
8
|
+
|
|
9
|
+
const PIPELINE = `include:
|
|
10
|
+
- project: my-group/ci-templates
|
|
11
|
+
ref: v1.2.3
|
|
12
|
+
file: /build.yml
|
|
13
|
+
- component: gitlab.example.com/my-group/my-comp@1.0.0
|
|
14
|
+
|
|
15
|
+
build:
|
|
16
|
+
image:
|
|
17
|
+
name: node:20
|
|
18
|
+
script:
|
|
19
|
+
- npm ci
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
function recordedResolver(records: Record<string, PipelineRefResolution>): GitlabRefResolver {
|
|
23
|
+
return async (kind, identifier, ref) =>
|
|
24
|
+
records[`${kind}:${identifier}@${ref}`] ?? { exists: true, archived: false, advisories: [] };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("collectPipelineRefs (#303)", () => {
|
|
28
|
+
test("collects include:project, component, and image refs", () => {
|
|
29
|
+
const refs = collectPipelineRefs(PIPELINE);
|
|
30
|
+
const ids = refs.map((r) => `${r.refKind}:${r.identifier}`);
|
|
31
|
+
expect(ids).toContain("include:my-group/ci-templates");
|
|
32
|
+
expect(ids).toContain("component:gitlab.example.com/my-group/my-comp");
|
|
33
|
+
expect(ids).toContain("image:node:20");
|
|
34
|
+
expect(refs.find((r) => r.refKind === "include")?.ref).toBe("v1.2.3");
|
|
35
|
+
expect(refs.find((r) => r.refKind === "component")?.ref).toBe("1.0.0");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("skips variable-based images", () => {
|
|
39
|
+
const refs = collectPipelineRefs(`build:\n image:\n name: $CI_REGISTRY_IMAGE:latest\n`);
|
|
40
|
+
expect(refs.filter((r) => r.refKind === "image")).toHaveLength(0);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("pipelineSupplyChainAudit (#303)", () => {
|
|
45
|
+
test("flags unresolved, archived, and moved references", async () => {
|
|
46
|
+
const resolver = recordedResolver({
|
|
47
|
+
"include:my-group/ci-templates@v1.2.3": { exists: true, archived: true, advisories: [] },
|
|
48
|
+
"component:gitlab.example.com/my-group/my-comp@1.0.0": { exists: false, archived: false, advisories: [] },
|
|
49
|
+
"image:node:20@20": { exists: true, archived: false, advisories: ["GHSA-img"], movedTo: undefined },
|
|
50
|
+
});
|
|
51
|
+
const result = await pipelineSupplyChainAudit({ yaml: PIPELINE, resolver, mode: "merge-request" });
|
|
52
|
+
const kinds = result.findings.map((f) => `${f.identifier}:${f.kind}`);
|
|
53
|
+
|
|
54
|
+
expect(kinds).toContain("my-group/ci-templates:archived");
|
|
55
|
+
expect(kinds).toContain("gitlab.example.com/my-group/my-comp:unresolved");
|
|
56
|
+
expect(kinds).toContain("node:20:advisory");
|
|
57
|
+
expect(result.mode).toBe("merge-request");
|
|
58
|
+
expect(result.summary).toContain("Pipeline include/component audit");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("clean pipeline yields no findings", async () => {
|
|
62
|
+
const resolver = recordedResolver({});
|
|
63
|
+
const result = await pipelineSupplyChainAudit({ yaml: PIPELINE, resolver });
|
|
64
|
+
expect(result.findings).toHaveLength(0);
|
|
65
|
+
expect(result.summary).toContain("No live drift");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pipelineSupplyChainAudit — the live counterpart to the gitlab post-synth
|
|
3
|
+
* include/component/image checks (#297).
|
|
4
|
+
*
|
|
5
|
+
* Some include/component checks can only be answered against a moving external
|
|
6
|
+
* truth: whether a pinned component/include ref still resolves upstream, whether
|
|
7
|
+
* the upstream project was archived or moved, or whether a new advisory now
|
|
8
|
+
* covers a component/image already in use. These change independent of the
|
|
9
|
+
* source, so they cannot live in the deterministic build — this activity owns
|
|
10
|
+
* *only* the checks that require live resolution.
|
|
11
|
+
*
|
|
12
|
+
* Dependency-free and primitives-only: it reads the emitted `.gitlab-ci.yml`
|
|
13
|
+
* (passed as a string) and resolves references through an injectable
|
|
14
|
+
* `GitlabRefResolver`, so Temporal/`chant run` can schedule it and tests can run
|
|
15
|
+
* it against recorded responses with no live network.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** What the audit produces on findings. For GitLab the PR mode is a merge request. */
|
|
19
|
+
export type PipelineAuditMode = "report" | "issue" | "merge-request";
|
|
20
|
+
|
|
21
|
+
/** The kind of reference being resolved. */
|
|
22
|
+
export type PipelineRefKind = "include" | "component" | "image";
|
|
23
|
+
|
|
24
|
+
/** Live resolution of a single pipeline reference against its upstream. */
|
|
25
|
+
export interface PipelineRefResolution {
|
|
26
|
+
/** Does the project/component/image (at this ref/version) still resolve? */
|
|
27
|
+
exists: boolean;
|
|
28
|
+
/** Has the upstream project been archived? */
|
|
29
|
+
archived: boolean;
|
|
30
|
+
/** Has the upstream project moved (new path), if known. */
|
|
31
|
+
movedTo?: string;
|
|
32
|
+
/** Disclosed advisory identifiers affecting this reference. */
|
|
33
|
+
advisories: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Pluggable upstream resolver — overridden in tests with recorded responses. */
|
|
37
|
+
export type GitlabRefResolver = (kind: PipelineRefKind, identifier: string, ref: string) => Promise<PipelineRefResolution>;
|
|
38
|
+
|
|
39
|
+
export type PipelineAuditFindingKind = "unresolved" | "archived" | "moved" | "advisory";
|
|
40
|
+
|
|
41
|
+
export interface PipelineAuditFinding {
|
|
42
|
+
kind: PipelineAuditFindingKind;
|
|
43
|
+
refKind: PipelineRefKind;
|
|
44
|
+
/** project path / component address / image. */
|
|
45
|
+
identifier: string;
|
|
46
|
+
/** ref / version / tag. */
|
|
47
|
+
ref: string;
|
|
48
|
+
detail: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface PipelineAuditArgs {
|
|
52
|
+
/** One emitted `.gitlab-ci.yml` document. */
|
|
53
|
+
yaml?: string;
|
|
54
|
+
/** Several emitted documents. */
|
|
55
|
+
yamls?: string[];
|
|
56
|
+
/** Path to an emitted `.gitlab-ci.yml` to read at run time (default `.gitlab-ci.yml`). */
|
|
57
|
+
pipelineFile?: string;
|
|
58
|
+
/** What to produce. Default: report. */
|
|
59
|
+
mode?: PipelineAuditMode;
|
|
60
|
+
/** Injectable upstream resolver. Defaults to the live GitLab REST resolver. */
|
|
61
|
+
resolver?: GitlabRefResolver;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface PipelineAuditResult {
|
|
65
|
+
mode: PipelineAuditMode;
|
|
66
|
+
findings: PipelineAuditFinding[];
|
|
67
|
+
summary: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface CollectedRef {
|
|
71
|
+
refKind: PipelineRefKind;
|
|
72
|
+
identifier: string;
|
|
73
|
+
ref: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Collect `include:project` (+ ref), `component:` (with @version), and `image:`
|
|
78
|
+
* references from a `.gitlab-ci.yml`.
|
|
79
|
+
*/
|
|
80
|
+
export function collectPipelineRefs(yaml: string): CollectedRef[] {
|
|
81
|
+
const out: CollectedRef[] = [];
|
|
82
|
+
|
|
83
|
+
// include:\n - project: group/proj\n ref: main
|
|
84
|
+
const projectRe = /-\s+project:\s*['"]?([^\s'"]+)['"]?[\s\S]*?ref:\s*['"]?([^\s'"]+)['"]?/g;
|
|
85
|
+
let m: RegExpExecArray | null;
|
|
86
|
+
while ((m = projectRe.exec(yaml)) !== null) {
|
|
87
|
+
out.push({ refKind: "include", identifier: m[1], ref: m[2] });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// - component: host/group/comp@1.0.0
|
|
91
|
+
const componentRe = /-\s+component:\s*['"]?([^\s'"@]+)@([^\s'"]+)['"]?/g;
|
|
92
|
+
while ((m = componentRe.exec(yaml)) !== null) {
|
|
93
|
+
out.push({ refKind: "component", identifier: m[1], ref: m[2] });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Images: inline `image: name` or block `image:\n name: ...` (and services).
|
|
97
|
+
const lines = yaml.split("\n");
|
|
98
|
+
const imgSeen = new Set<string>();
|
|
99
|
+
const addImage = (raw: string) => {
|
|
100
|
+
const image = raw.trim().replace(/^['"]|['"]$/g, "");
|
|
101
|
+
if (!image || image.includes("$") || imgSeen.has(image)) return;
|
|
102
|
+
imgSeen.add(image);
|
|
103
|
+
const at = image.lastIndexOf("@");
|
|
104
|
+
const colon = image.lastIndexOf(":");
|
|
105
|
+
const ref = at !== -1 ? image.slice(at + 1) : colon > image.lastIndexOf("/") ? image.slice(colon + 1) : "latest";
|
|
106
|
+
out.push({ refKind: "image", identifier: image, ref });
|
|
107
|
+
};
|
|
108
|
+
for (let i = 0; i < lines.length; i++) {
|
|
109
|
+
const inline = lines[i].match(/^\s+image:[ \t]+(\S.*)$/);
|
|
110
|
+
if (inline) { addImage(inline[1]); continue; }
|
|
111
|
+
if (/^\s+image:\s*$/.test(lines[i]) || /^\s+-\s+name:[ \t]+(\S.*)$/.test(lines[i])) {
|
|
112
|
+
const svcName = lines[i].match(/^\s+-\s+name:[ \t]+(\S.*)$/);
|
|
113
|
+
if (svcName) { addImage(svcName[1]); continue; }
|
|
114
|
+
const nameLine = lines.slice(i + 1, i + 4).find((l) => /^\s+name:[ \t]+/.test(l));
|
|
115
|
+
if (nameLine) addImage(nameLine.replace(/^\s+name:[ \t]+/, ""));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Default resolver: query the GitLab REST API for project existence / archive /
|
|
124
|
+
* move status. Best-effort and resilient — errors yield an "unknown" resolution
|
|
125
|
+
* (exists=true, no findings) rather than a false positive. Component/image
|
|
126
|
+
* advisory lookups are left to a configured resolver.
|
|
127
|
+
*/
|
|
128
|
+
export const defaultGitlabRefResolver: GitlabRefResolver = async (kind, identifier) => {
|
|
129
|
+
const unknown: PipelineRefResolution = { exists: true, archived: false, advisories: [] };
|
|
130
|
+
if (kind === "image") return unknown; // image advisory feeds are resolver-specific
|
|
131
|
+
const host = "https://gitlab.com";
|
|
132
|
+
const project = kind === "component" ? identifier.split("/").slice(1, 3).join("/") : identifier;
|
|
133
|
+
try {
|
|
134
|
+
const resp = await fetch(`${host}/api/v4/projects/${encodeURIComponent(project)}`, {
|
|
135
|
+
headers: process.env.GITLAB_TOKEN ? { "PRIVATE-TOKEN": process.env.GITLAB_TOKEN } : {},
|
|
136
|
+
});
|
|
137
|
+
if (resp.status === 404) return { exists: false, archived: false, advisories: [] };
|
|
138
|
+
if (!resp.ok) return unknown;
|
|
139
|
+
const p = (await resp.json()) as { archived?: boolean };
|
|
140
|
+
return { exists: true, archived: !!p.archived, advisories: [] };
|
|
141
|
+
} catch {
|
|
142
|
+
return unknown;
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
function renderSummary(findings: PipelineAuditFinding[]): string {
|
|
147
|
+
if (findings.length === 0) return "## Pipeline include/component audit\n\nNo live drift detected against upstream references.\n";
|
|
148
|
+
let out = `## Pipeline include/component audit\n\n${findings.length} finding(s) against live upstream truth:\n\n`;
|
|
149
|
+
out += "| Reference | Ref | Finding | Detail |\n|---|---|---|---|\n";
|
|
150
|
+
for (const f of findings) out += `| ${f.identifier} (${f.refKind}) | ${f.ref} | ${f.kind} | ${f.detail} |\n`;
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function readFileBestEffort(path: string): Promise<string[]> {
|
|
155
|
+
try {
|
|
156
|
+
const { readFile } = await import("node:fs/promises");
|
|
157
|
+
return [await readFile(path, "utf-8")];
|
|
158
|
+
} catch {
|
|
159
|
+
return [];
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Resolve each include/component/image reference in the emitted pipeline against
|
|
165
|
+
* live upstreams and report drift.
|
|
166
|
+
*/
|
|
167
|
+
export async function pipelineSupplyChainAudit(args: PipelineAuditArgs): Promise<PipelineAuditResult> {
|
|
168
|
+
const resolver = args.resolver ?? defaultGitlabRefResolver;
|
|
169
|
+
const mode = args.mode ?? "report";
|
|
170
|
+
let yamls = args.yamls ?? (args.yaml ? [args.yaml] : []);
|
|
171
|
+
if (yamls.length === 0) {
|
|
172
|
+
yamls = await readFileBestEffort(args.pipelineFile ?? ".gitlab-ci.yml");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const findings: PipelineAuditFinding[] = [];
|
|
176
|
+
const seen = new Set<string>();
|
|
177
|
+
for (const yaml of yamls) {
|
|
178
|
+
for (const { refKind, identifier, ref } of collectPipelineRefs(yaml)) {
|
|
179
|
+
const key = `${refKind}:${identifier}@${ref}`;
|
|
180
|
+
if (seen.has(key)) continue;
|
|
181
|
+
seen.add(key);
|
|
182
|
+
|
|
183
|
+
const res = await resolver(refKind, identifier, ref);
|
|
184
|
+
if (!res.exists) {
|
|
185
|
+
findings.push({ kind: "unresolved", refKind, identifier, ref, detail: `${refKind} "${identifier}@${ref}" no longer resolves upstream` });
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (res.archived) {
|
|
189
|
+
findings.push({ kind: "archived", refKind, identifier, ref, detail: `${identifier} has been archived upstream` });
|
|
190
|
+
}
|
|
191
|
+
if (res.movedTo) {
|
|
192
|
+
findings.push({ kind: "moved", refKind, identifier, ref, detail: `${identifier} moved to ${res.movedTo}` });
|
|
193
|
+
}
|
|
194
|
+
for (const adv of res.advisories) {
|
|
195
|
+
findings.push({ kind: "advisory", refKind, identifier, ref, detail: `disclosed advisory ${adv} covers ${identifier}` });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return { mode, findings, summary: renderSummary(findings) };
|
|
201
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
workflowSupplyChainAudit,
|
|
4
|
+
collectAuditRefs,
|
|
5
|
+
type ActionRefResolution,
|
|
6
|
+
type ActionRefResolver,
|
|
7
|
+
} from "./workflow-audit";
|
|
8
|
+
|
|
9
|
+
const SHA = "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b";
|
|
10
|
+
|
|
11
|
+
const WORKFLOW = `name: CI
|
|
12
|
+
on:
|
|
13
|
+
push:
|
|
14
|
+
jobs:
|
|
15
|
+
build:
|
|
16
|
+
runs-on: ubuntu-latest
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@${SHA} # v4.1.0
|
|
19
|
+
- uses: evil/typo-action@v1
|
|
20
|
+
- uses: archived/old-action@v2
|
|
21
|
+
`;
|
|
22
|
+
|
|
23
|
+
/** Recorded upstream responses — no live network. */
|
|
24
|
+
function recordedResolver(records: Record<string, ActionRefResolution>): ActionRefResolver {
|
|
25
|
+
return async (slug, ref) =>
|
|
26
|
+
records[`${slug}@${ref}`] ?? { exists: true, tags: [], archived: false, advisories: [] };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("collectAuditRefs (#292)", () => {
|
|
30
|
+
test("collects external refs with version comments, skipping local/docker", () => {
|
|
31
|
+
const yaml = `jobs:
|
|
32
|
+
a:
|
|
33
|
+
steps:
|
|
34
|
+
- uses: actions/checkout@${SHA} # v4.1.0
|
|
35
|
+
- uses: ./.github/actions/local
|
|
36
|
+
- uses: docker://alpine:3.19
|
|
37
|
+
- uses: org/reusable/.github/workflows/x.yml@v1
|
|
38
|
+
`;
|
|
39
|
+
const refs = collectAuditRefs(yaml);
|
|
40
|
+
const slugs = refs.map((r) => r.slug);
|
|
41
|
+
expect(slugs).toContain("actions/checkout");
|
|
42
|
+
expect(slugs).toContain("org/reusable");
|
|
43
|
+
expect(slugs).not.toContain("./.github");
|
|
44
|
+
expect(refs.find((r) => r.slug === "actions/checkout")?.comment).toBe("v4.1.0");
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("workflowSupplyChainAudit (#292)", () => {
|
|
49
|
+
test("flags stale pin, impostor, archived, and pin/comment mismatch", async () => {
|
|
50
|
+
const resolver = recordedResolver({
|
|
51
|
+
// SHA no longer on any tag → stale; actual tag differs from comment → mismatch
|
|
52
|
+
[`actions/checkout@${SHA}`]: { exists: true, tags: ["v4.2.0"], archived: false, advisories: [] },
|
|
53
|
+
"evil/typo-action@v1": { exists: false, tags: [], archived: false, advisories: [] },
|
|
54
|
+
"archived/old-action@v2": { exists: true, tags: ["v2"], archived: true, advisories: ["GHSA-xxxx"] },
|
|
55
|
+
});
|
|
56
|
+
const result = await workflowSupplyChainAudit({ yaml: WORKFLOW, resolver, mode: "issue" });
|
|
57
|
+
const kinds = result.findings.map((f) => `${f.slug}:${f.kind}`);
|
|
58
|
+
|
|
59
|
+
expect(kinds).toContain("evil/typo-action:impostor");
|
|
60
|
+
expect(kinds).toContain("archived/old-action:archived");
|
|
61
|
+
expect(kinds).toContain("archived/old-action:advisory");
|
|
62
|
+
expect(kinds).toContain("actions/checkout:pin-comment-mismatch");
|
|
63
|
+
expect(result.mode).toBe("issue");
|
|
64
|
+
expect(result.summary).toContain("Workflow supply-chain audit");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("flags a stale SHA pin with no upstream tag", async () => {
|
|
68
|
+
const resolver = recordedResolver({
|
|
69
|
+
[`actions/checkout@${SHA}`]: { exists: true, tags: [], archived: false, advisories: [] },
|
|
70
|
+
"evil/typo-action@v1": { exists: true, tags: ["v1"], archived: false, advisories: [] },
|
|
71
|
+
"archived/old-action@v2": { exists: true, tags: ["v2"], archived: false, advisories: [] },
|
|
72
|
+
});
|
|
73
|
+
const result = await workflowSupplyChainAudit({ yaml: WORKFLOW, resolver });
|
|
74
|
+
expect(result.findings.some((f) => f.slug === "actions/checkout" && f.kind === "stale-pin")).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("clean workflow yields no findings", async () => {
|
|
78
|
+
const yaml = `jobs:
|
|
79
|
+
build:
|
|
80
|
+
steps:
|
|
81
|
+
- uses: actions/checkout@${SHA}
|
|
82
|
+
`;
|
|
83
|
+
const resolver = recordedResolver({
|
|
84
|
+
[`actions/checkout@${SHA}`]: { exists: true, tags: ["v4"], archived: false, advisories: [] },
|
|
85
|
+
});
|
|
86
|
+
const result = await workflowSupplyChainAudit({ yaml, resolver });
|
|
87
|
+
expect(result.findings).toHaveLength(0);
|
|
88
|
+
expect(result.summary).toContain("No live drift");
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* workflowSupplyChainAudit — the live counterpart to the github post-synth
|
|
3
|
+
* supply-chain checks (#286-#291).
|
|
4
|
+
*
|
|
5
|
+
* Some workflow-reference checks can only be answered against a moving external
|
|
6
|
+
* truth: whether a pinned SHA still corresponds to a real tag/release upstream,
|
|
7
|
+
* whether a referenced commit exists in the repo it claims, whether a ref is
|
|
8
|
+
* confusable as both a tag and a branch, whether a pin matches its version
|
|
9
|
+
* comment, or whether a new advisory now covers an action already in use. These
|
|
10
|
+
* change over time independent of the source, so they cannot live in the
|
|
11
|
+
* deterministic build — this activity owns *only* the checks that require live
|
|
12
|
+
* resolution.
|
|
13
|
+
*
|
|
14
|
+
* It is dependency-free and primitives-only: it reads the emitted workflow YAML
|
|
15
|
+
* (passed as a string) and resolves references through an injectable
|
|
16
|
+
* `ActionRefResolver`, so Temporal/`chant run` can schedule it and tests can run
|
|
17
|
+
* it against recorded responses with no live network.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** What the audit produces on findings, mirroring ReconcileOp's modes. */
|
|
21
|
+
export type WorkflowAuditMode = "report" | "issue" | "pull-request";
|
|
22
|
+
|
|
23
|
+
/** Live resolution of a single action reference against its upstream. */
|
|
24
|
+
export interface ActionRefResolution {
|
|
25
|
+
/** Does the ref/SHA exist in the claimed repo? */
|
|
26
|
+
exists: boolean;
|
|
27
|
+
/** Tag/release names that point at this commit (empty for a SHA ⇒ stale pin). */
|
|
28
|
+
tags: string[];
|
|
29
|
+
/** Is the upstream repository archived? */
|
|
30
|
+
archived: boolean;
|
|
31
|
+
/** Disclosed advisory identifiers affecting this action. */
|
|
32
|
+
advisories: string[];
|
|
33
|
+
/** Is the ref resolvable as BOTH a tag and a branch upstream? */
|
|
34
|
+
ambiguousRef?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Pluggable upstream resolver — overridden in tests with recorded responses. */
|
|
38
|
+
export type ActionRefResolver = (slug: string, ref: string) => Promise<ActionRefResolution>;
|
|
39
|
+
|
|
40
|
+
export type WorkflowAuditFindingKind =
|
|
41
|
+
| "stale-pin"
|
|
42
|
+
| "impostor"
|
|
43
|
+
| "ambiguous-ref"
|
|
44
|
+
| "pin-comment-mismatch"
|
|
45
|
+
| "advisory"
|
|
46
|
+
| "archived";
|
|
47
|
+
|
|
48
|
+
export interface WorkflowAuditFinding {
|
|
49
|
+
/** owner/repo slug. */
|
|
50
|
+
slug: string;
|
|
51
|
+
/** The pinned ref/SHA. */
|
|
52
|
+
ref: string;
|
|
53
|
+
kind: WorkflowAuditFindingKind;
|
|
54
|
+
detail: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface WorkflowAuditArgs {
|
|
58
|
+
/** One emitted workflow YAML document. */
|
|
59
|
+
yaml?: string;
|
|
60
|
+
/** Several emitted workflow YAML documents. */
|
|
61
|
+
yamls?: string[];
|
|
62
|
+
/**
|
|
63
|
+
* Directory of emitted workflow files (`*.yml`/`*.yaml`) to read at run time
|
|
64
|
+
* when no `yaml`/`yamls` is given — the form used inside a scheduled Op.
|
|
65
|
+
* Default `.github/workflows`.
|
|
66
|
+
*/
|
|
67
|
+
workflowsDir?: string;
|
|
68
|
+
/** What to produce. Default: report. */
|
|
69
|
+
mode?: WorkflowAuditMode;
|
|
70
|
+
/** Injectable upstream resolver. Defaults to the live GitHub REST resolver. */
|
|
71
|
+
resolver?: ActionRefResolver;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Read `*.yml`/`*.yaml` files from a directory (best-effort). */
|
|
75
|
+
async function readWorkflowDir(dir: string): Promise<string[]> {
|
|
76
|
+
try {
|
|
77
|
+
const { readdir, readFile } = await import("node:fs/promises");
|
|
78
|
+
const { join } = await import("node:path");
|
|
79
|
+
const files = (await readdir(dir)).filter((f) => f.endsWith(".yml") || f.endsWith(".yaml"));
|
|
80
|
+
return Promise.all(files.map((f) => readFile(join(dir, f), "utf-8")));
|
|
81
|
+
} catch {
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface WorkflowAuditResult {
|
|
87
|
+
mode: WorkflowAuditMode;
|
|
88
|
+
findings: WorkflowAuditFinding[];
|
|
89
|
+
/** Markdown summary used as the PR/issue body or printed in report mode. */
|
|
90
|
+
summary: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface CollectedRef {
|
|
94
|
+
slug: string;
|
|
95
|
+
ref: string;
|
|
96
|
+
/** Adjacent `# vX.Y.Z` comment, when present. */
|
|
97
|
+
comment?: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const SHA_RE = /^[0-9a-f]{40}$/;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Collect external action references (`uses: owner/repo@ref`, with an optional
|
|
104
|
+
* trailing version comment) from a workflow YAML. Local (`./`) and `docker://`
|
|
105
|
+
* references are excluded.
|
|
106
|
+
*/
|
|
107
|
+
export function collectAuditRefs(yaml: string): CollectedRef[] {
|
|
108
|
+
const out: CollectedRef[] = [];
|
|
109
|
+
const re = /uses:\s*['"]?([\w.-]+\/[\w.-]+(?:\/[\w./-]+)?)@([\w.-]+)['"]?\s*(?:#\s*(\S+))?/g;
|
|
110
|
+
let m: RegExpExecArray | null;
|
|
111
|
+
while ((m = re.exec(yaml)) !== null) {
|
|
112
|
+
out.push({ slug: m[1].split("/").slice(0, 2).join("/"), ref: m[2], comment: m[3] });
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Default resolver: query the GitHub REST API for whether the ref exists, what
|
|
119
|
+
* tags point at it, archive status, and advisories. Best-effort and resilient —
|
|
120
|
+
* a network/HTTP error yields an "unknown" resolution (exists=true, no findings)
|
|
121
|
+
* rather than a false positive.
|
|
122
|
+
*/
|
|
123
|
+
export const defaultActionRefResolver: ActionRefResolver = async (slug, ref) => {
|
|
124
|
+
const base = `https://api.github.com/repos/${slug}`;
|
|
125
|
+
const headers: Record<string, string> = { Accept: "application/vnd.github+json" };
|
|
126
|
+
if (process.env.GITHUB_TOKEN) headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
|
|
127
|
+
const unknown: ActionRefResolution = { exists: true, tags: [], archived: false, advisories: [] };
|
|
128
|
+
try {
|
|
129
|
+
const repoResp = await fetch(base, { headers });
|
|
130
|
+
if (repoResp.status === 404) return { exists: false, tags: [], archived: false, advisories: [] };
|
|
131
|
+
if (!repoResp.ok) return unknown;
|
|
132
|
+
const repo = (await repoResp.json()) as { archived?: boolean };
|
|
133
|
+
|
|
134
|
+
const refResp = await fetch(`${base}/commits/${ref}`, { headers });
|
|
135
|
+
if (refResp.status === 404) return { exists: false, tags: [], archived: !!repo.archived, advisories: [] };
|
|
136
|
+
|
|
137
|
+
let tags: string[] = [];
|
|
138
|
+
if (SHA_RE.test(ref)) {
|
|
139
|
+
const tagsResp = await fetch(`${base}/tags?per_page=100`, { headers });
|
|
140
|
+
if (tagsResp.ok) {
|
|
141
|
+
const tagList = (await tagsResp.json()) as Array<{ name: string; commit?: { sha?: string } }>;
|
|
142
|
+
tags = tagList.filter((t) => t.commit?.sha === ref).map((t) => t.name);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return { exists: refResp.ok, tags, archived: !!repo.archived, advisories: [] };
|
|
146
|
+
} catch {
|
|
147
|
+
return unknown;
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
function renderSummary(findings: WorkflowAuditFinding[]): string {
|
|
152
|
+
if (findings.length === 0) return "## Workflow supply-chain audit\n\nNo live drift detected against upstream references.\n";
|
|
153
|
+
let out = `## Workflow supply-chain audit\n\n${findings.length} finding(s) against live upstream truth:\n\n`;
|
|
154
|
+
out += "| Action | Ref | Finding | Detail |\n|---|---|---|---|\n";
|
|
155
|
+
for (const f of findings) out += `| ${f.slug} | ${f.ref} | ${f.kind} | ${f.detail} |\n`;
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Resolve each pinned external reference in the emitted workflows against live
|
|
161
|
+
* upstreams and report drift.
|
|
162
|
+
*/
|
|
163
|
+
export async function workflowSupplyChainAudit(args: WorkflowAuditArgs): Promise<WorkflowAuditResult> {
|
|
164
|
+
const resolver = args.resolver ?? defaultActionRefResolver;
|
|
165
|
+
const mode = args.mode ?? "report";
|
|
166
|
+
let yamls = args.yamls ?? (args.yaml ? [args.yaml] : []);
|
|
167
|
+
if (yamls.length === 0) {
|
|
168
|
+
yamls = await readWorkflowDir(args.workflowsDir ?? ".github/workflows");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const findings: WorkflowAuditFinding[] = [];
|
|
172
|
+
const seen = new Set<string>();
|
|
173
|
+
for (const yaml of yamls) {
|
|
174
|
+
for (const { slug, ref, comment } of collectAuditRefs(yaml)) {
|
|
175
|
+
const key = `${slug}@${ref}`;
|
|
176
|
+
if (seen.has(key)) continue;
|
|
177
|
+
seen.add(key);
|
|
178
|
+
|
|
179
|
+
const res = await resolver(slug, ref);
|
|
180
|
+
if (!res.exists) {
|
|
181
|
+
findings.push({ slug, ref, kind: "impostor", detail: `ref does not exist in ${slug} — possible impostor or deleted ref` });
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (SHA_RE.test(ref) && res.tags.length === 0) {
|
|
185
|
+
findings.push({ slug, ref, kind: "stale-pin", detail: "pinned SHA is no longer on any tag/release upstream" });
|
|
186
|
+
}
|
|
187
|
+
if (res.ambiguousRef) {
|
|
188
|
+
findings.push({ slug, ref, kind: "ambiguous-ref", detail: "ref resolves as both a tag and a branch upstream (symbolic-ref confusion)" });
|
|
189
|
+
}
|
|
190
|
+
if (comment && res.tags.length > 0 && !res.tags.includes(comment)) {
|
|
191
|
+
findings.push({ slug, ref, kind: "pin-comment-mismatch", detail: `pin comment "${comment}" does not match the SHA's actual tag(s): ${res.tags.join(", ")}` });
|
|
192
|
+
}
|
|
193
|
+
if (res.archived) {
|
|
194
|
+
findings.push({ slug, ref, kind: "archived", detail: `${slug} has been archived upstream since pinning` });
|
|
195
|
+
}
|
|
196
|
+
for (const adv of res.advisories) {
|
|
197
|
+
findings.push({ slug, ref, kind: "advisory", detail: `disclosed advisory ${adv} covers ${slug}` });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return { mode, findings, summary: renderSummary(findings) };
|
|
203
|
+
}
|