codegate-ai 0.9.1 → 0.11.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 (28) hide show
  1. package/README.md +39 -6
  2. package/dist/cli.js +24 -0
  3. package/dist/commands/clawhub-wrapper.d.ts +8 -1
  4. package/dist/commands/clawhub-wrapper.js +106 -4
  5. package/dist/commands/skills-wrapper.d.ts +8 -1
  6. package/dist/commands/skills-wrapper.js +106 -4
  7. package/dist/layer2-static/detectors/dependabot-auto-merge.d.ts +7 -0
  8. package/dist/layer2-static/detectors/dependabot-auto-merge.js +118 -0
  9. package/dist/layer2-static/detectors/workflow-artifact-trust-chain.d.ts +7 -0
  10. package/dist/layer2-static/detectors/workflow-artifact-trust-chain.js +89 -0
  11. package/dist/layer2-static/detectors/workflow-call-boundary.d.ts +7 -0
  12. package/dist/layer2-static/detectors/workflow-call-boundary.js +92 -0
  13. package/dist/layer2-static/detectors/workflow-dynamic-matrix-injection.d.ts +7 -0
  14. package/dist/layer2-static/detectors/workflow-dynamic-matrix-injection.js +149 -0
  15. package/dist/layer2-static/detectors/workflow-local-action-mutation.d.ts +7 -0
  16. package/dist/layer2-static/detectors/workflow-local-action-mutation.js +125 -0
  17. package/dist/layer2-static/detectors/workflow-oidc-untrusted-context.d.ts +7 -0
  18. package/dist/layer2-static/detectors/workflow-oidc-untrusted-context.js +166 -0
  19. package/dist/layer2-static/detectors/workflow-pr-target-checkout-head.d.ts +7 -0
  20. package/dist/layer2-static/detectors/workflow-pr-target-checkout-head.js +99 -0
  21. package/dist/layer2-static/detectors/workflow-secret-exfiltration.d.ts +8 -0
  22. package/dist/layer2-static/detectors/workflow-secret-exfiltration.js +97 -0
  23. package/dist/layer2-static/engine.js +89 -0
  24. package/dist/layer2-static/workflow/analysis.d.ts +24 -0
  25. package/dist/layer2-static/workflow/analysis.js +201 -0
  26. package/dist/layer2-static/workflow/parser.js +30 -10
  27. package/dist/layer2-static/workflow/types.d.ts +6 -0
  28. package/package.json +1 -1
@@ -0,0 +1,99 @@
1
+ import { buildFindingEvidence } from "../evidence.js";
2
+ import { collectUntrustedReachableJobIds } from "../workflow/analysis.js";
3
+ import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";
4
+ function hasWritePermission(value) {
5
+ if (typeof value === "string") {
6
+ return value.trim().toLowerCase() === "write-all";
7
+ }
8
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
9
+ return false;
10
+ }
11
+ return Object.values(value).some((permission) => {
12
+ if (typeof permission !== "string") {
13
+ return false;
14
+ }
15
+ return permission.trim().toLowerCase() === "write";
16
+ });
17
+ }
18
+ function isCheckoutStep(uses) {
19
+ if (!uses) {
20
+ return false;
21
+ }
22
+ return /^actions\/checkout(?:@.+)?$/iu.test(uses.trim());
23
+ }
24
+ function isRiskyCheckoutRef(ref) {
25
+ if (!ref) {
26
+ return false;
27
+ }
28
+ const normalized = ref.toLowerCase();
29
+ return (normalized.includes("github.event.pull_request.head.") || normalized.includes("github.head_ref"));
30
+ }
31
+ function hasInheritedSecrets(secrets) {
32
+ return typeof secrets === "string" && secrets.trim().toLowerCase() === "inherit";
33
+ }
34
+ export function detectWorkflowPrTargetCheckoutHead(input) {
35
+ if (!isGitHubWorkflowPath(input.filePath)) {
36
+ return [];
37
+ }
38
+ const facts = extractWorkflowFacts(input.parsed);
39
+ if (!facts) {
40
+ return [];
41
+ }
42
+ const hasPullRequestTarget = facts.triggers.some((trigger) => trigger.trim().toLowerCase() === "pull_request_target");
43
+ if (!hasPullRequestTarget) {
44
+ return [];
45
+ }
46
+ const reachableJobIds = collectUntrustedReachableJobIds(facts);
47
+ if (reachableJobIds.size === 0) {
48
+ return [];
49
+ }
50
+ const findings = [];
51
+ const workflowPrivileged = hasWritePermission(facts.workflowPermissions);
52
+ facts.jobs.forEach((job, jobIndex) => {
53
+ if (!reachableJobIds.has(job.id)) {
54
+ return;
55
+ }
56
+ const jobPrivileged = workflowPrivileged || hasWritePermission(job.permissions) || hasInheritedSecrets(job.secrets);
57
+ job.steps.forEach((step, stepIndex) => {
58
+ if (!isCheckoutStep(step.uses)) {
59
+ return;
60
+ }
61
+ if (!isRiskyCheckoutRef(step.with?.ref)) {
62
+ return;
63
+ }
64
+ const evidence = buildFindingEvidence({
65
+ textContent: input.textContent,
66
+ searchTerms: [
67
+ "pull_request_target",
68
+ "actions/checkout",
69
+ step.with?.ref ?? "github.event.pull_request.head",
70
+ ],
71
+ fallbackValue: "pull_request_target workflow checks out untrusted PR head ref",
72
+ });
73
+ findings.push({
74
+ rule_id: "workflow-pr-target-checkout-head",
75
+ finding_id: `WORKFLOW_PR_TARGET_CHECKOUT_HEAD-${input.filePath}-${jobIndex}-${stepIndex}`,
76
+ severity: jobPrivileged ? "CRITICAL" : "HIGH",
77
+ category: "CI_TRIGGER",
78
+ layer: "L2",
79
+ file_path: input.filePath,
80
+ location: { field: `jobs.${job.id}.steps[${stepIndex}].with.ref` },
81
+ description: "pull_request_target job checks out pull request head ref, enabling untrusted code execution in privileged context",
82
+ affected_tools: ["github-actions"],
83
+ cve: null,
84
+ owasp: ["ASI02"],
85
+ cwe: "CWE-284",
86
+ confidence: "HIGH",
87
+ fixable: false,
88
+ remediation_actions: [
89
+ "Avoid checking out pull request head refs in pull_request_target workflows",
90
+ "Use pull_request for untrusted code validation and keep privileged operations isolated",
91
+ "Enforce least-privilege token scopes and avoid inherited secrets for untrusted paths",
92
+ ],
93
+ evidence: evidence?.evidence ?? null,
94
+ suppressed: false,
95
+ });
96
+ });
97
+ });
98
+ return findings;
99
+ }
@@ -0,0 +1,8 @@
1
+ import type { Finding } from "../../types/finding.js";
2
+ export interface WorkflowSecretExfiltrationInput {
3
+ filePath: string;
4
+ parsed: unknown;
5
+ textContent: string;
6
+ trustedApiDomains: string[];
7
+ }
8
+ export declare function detectWorkflowSecretExfiltration(input: WorkflowSecretExfiltrationInput): Finding[];
@@ -0,0 +1,97 @@
1
+ import { buildFindingEvidence } from "../evidence.js";
2
+ import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";
3
+ const OUTBOUND_COMMAND_PATTERN = /\b(curl|wget|invoke-webrequest|httpie)\b/iu;
4
+ const SECRET_REFERENCE_PATTERN = /\$\{\{\s*secrets\.([a-zA-Z0-9_]+)\s*\}\}/giu;
5
+ const URL_PATTERN = /https?:\/\/[^\s"')]+/giu;
6
+ function extractSecretReferences(value) {
7
+ const references = new Set();
8
+ for (const match of value.matchAll(SECRET_REFERENCE_PATTERN)) {
9
+ const key = match[1]?.trim();
10
+ if (!key) {
11
+ continue;
12
+ }
13
+ references.add(key);
14
+ }
15
+ return Array.from(references);
16
+ }
17
+ function extractUrls(value) {
18
+ return Array.from(value.matchAll(URL_PATTERN), (match) => match[0] ?? "").filter(Boolean);
19
+ }
20
+ function isTrustedHost(hostname, trustedApiDomains) {
21
+ const normalizedHost = hostname.toLowerCase();
22
+ return trustedApiDomains.some((domain) => {
23
+ const normalizedDomain = domain.toLowerCase();
24
+ return normalizedHost === normalizedDomain || normalizedHost.endsWith(`.${normalizedDomain}`);
25
+ });
26
+ }
27
+ function hasOnlyTrustedUrls(run, trustedApiDomains) {
28
+ const urls = extractUrls(run);
29
+ if (urls.length === 0) {
30
+ return false;
31
+ }
32
+ return urls.every((url) => {
33
+ try {
34
+ const parsed = new URL(url);
35
+ return isTrustedHost(parsed.hostname, trustedApiDomains);
36
+ }
37
+ catch {
38
+ return false;
39
+ }
40
+ });
41
+ }
42
+ export function detectWorkflowSecretExfiltration(input) {
43
+ if (!isGitHubWorkflowPath(input.filePath)) {
44
+ return [];
45
+ }
46
+ const facts = extractWorkflowFacts(input.parsed);
47
+ if (!facts) {
48
+ return [];
49
+ }
50
+ const findings = [];
51
+ facts.jobs.forEach((job, jobIndex) => {
52
+ job.steps.forEach((step, stepIndex) => {
53
+ if (!step.run || !OUTBOUND_COMMAND_PATTERN.test(step.run)) {
54
+ return;
55
+ }
56
+ const referencedSecrets = extractSecretReferences(step.run);
57
+ if (referencedSecrets.length === 0) {
58
+ return;
59
+ }
60
+ if (hasOnlyTrustedUrls(step.run, input.trustedApiDomains)) {
61
+ return;
62
+ }
63
+ const evidence = buildFindingEvidence({
64
+ textContent: input.textContent,
65
+ searchTerms: ["secrets.", "curl", "wget", "http://", "https://"],
66
+ fallbackValue: `${job.id} step references secrets in outbound network command`,
67
+ });
68
+ findings.push({
69
+ rule_id: "workflow-secret-exfiltration",
70
+ finding_id: `WORKFLOW_SECRET_EXFILTRATION-${input.filePath}-${jobIndex}-${stepIndex}`,
71
+ severity: "CRITICAL",
72
+ category: "CI_PERMISSIONS",
73
+ layer: "L2",
74
+ file_path: input.filePath,
75
+ location: { field: `jobs.${job.id}.steps[${stepIndex}].run` },
76
+ description: "Workflow step sends secret context through outbound network command",
77
+ affected_tools: ["github-actions"],
78
+ cve: null,
79
+ owasp: ["ASI02"],
80
+ cwe: "CWE-200",
81
+ confidence: "HIGH",
82
+ fixable: false,
83
+ remediation_actions: [
84
+ "Avoid sending secrets through outbound shell commands",
85
+ "Use trusted first-party actions with scoped credentials instead of ad-hoc exfil-prone scripts",
86
+ "Restrict outbound domains and sanitize command arguments in privileged workflows",
87
+ ],
88
+ metadata: {
89
+ referenced_secrets: referencedSecrets,
90
+ },
91
+ evidence: evidence?.evidence ?? null,
92
+ suppressed: false,
93
+ });
94
+ });
95
+ });
96
+ return findings;
97
+ }
@@ -41,6 +41,14 @@ import { detectDependabotExecution } from "./detectors/dependabot-execution.js";
41
41
  import { detectWorkflowHardcodedContainerCredentials } from "./detectors/workflow-hardcoded-container-credentials.js";
42
42
  import { detectWorkflowUnredactedSecrets } from "./detectors/workflow-unredacted-secrets.js";
43
43
  import { detectWorkflowBotConditions } from "./detectors/workflow-bot-conditions.js";
44
+ import { detectWorkflowPrTargetCheckoutHead } from "./detectors/workflow-pr-target-checkout-head.js";
45
+ import { detectWorkflowArtifactTrustChain } from "./detectors/workflow-artifact-trust-chain.js";
46
+ import { detectWorkflowCallBoundary } from "./detectors/workflow-call-boundary.js";
47
+ import { detectWorkflowSecretExfiltration } from "./detectors/workflow-secret-exfiltration.js";
48
+ import { detectWorkflowOidcUntrustedContext } from "./detectors/workflow-oidc-untrusted-context.js";
49
+ import { detectWorkflowDynamicMatrixInjection } from "./detectors/workflow-dynamic-matrix-injection.js";
50
+ import { detectDependabotAutoMerge } from "./detectors/dependabot-auto-merge.js";
51
+ import { detectWorkflowLocalActionMutation } from "./detectors/workflow-local-action-mutation.js";
44
52
  import { filterRegisteredAudits } from "./audits/registry.js";
45
53
  import { FINDING_CATEGORIES } from "../types/finding.js";
46
54
  import { buildFindingEvidence } from "./evidence.js";
@@ -536,6 +544,87 @@ function buildFileAudits() {
536
544
  })
537
545
  : [],
538
546
  },
547
+ {
548
+ id: "workflow-pr-target-checkout-head",
549
+ run: ({ file, input }) => input.config.workflowAuditsEnabled
550
+ ? detectWorkflowPrTargetCheckoutHead({
551
+ filePath: file.filePath,
552
+ parsed: file.parsed,
553
+ textContent: file.textContent,
554
+ })
555
+ : [],
556
+ },
557
+ {
558
+ id: "workflow-artifact-trust-chain",
559
+ run: ({ file, input }) => input.config.workflowAuditsEnabled
560
+ ? detectWorkflowArtifactTrustChain({
561
+ filePath: file.filePath,
562
+ parsed: file.parsed,
563
+ textContent: file.textContent,
564
+ })
565
+ : [],
566
+ },
567
+ {
568
+ id: "workflow-call-boundary",
569
+ run: ({ file, input }) => input.config.workflowAuditsEnabled
570
+ ? detectWorkflowCallBoundary({
571
+ filePath: file.filePath,
572
+ parsed: file.parsed,
573
+ textContent: file.textContent,
574
+ })
575
+ : [],
576
+ },
577
+ {
578
+ id: "workflow-secret-exfiltration",
579
+ run: ({ file, input }) => input.config.workflowAuditsEnabled
580
+ ? detectWorkflowSecretExfiltration({
581
+ filePath: file.filePath,
582
+ parsed: file.parsed,
583
+ textContent: file.textContent,
584
+ trustedApiDomains: input.config.trustedApiDomains,
585
+ })
586
+ : [],
587
+ },
588
+ {
589
+ id: "workflow-oidc-untrusted-context",
590
+ run: ({ file, input }) => input.config.workflowAuditsEnabled
591
+ ? detectWorkflowOidcUntrustedContext({
592
+ filePath: file.filePath,
593
+ parsed: file.parsed,
594
+ textContent: file.textContent,
595
+ })
596
+ : [],
597
+ },
598
+ {
599
+ id: "workflow-dynamic-matrix-injection",
600
+ run: ({ file, input }) => input.config.workflowAuditsEnabled
601
+ ? detectWorkflowDynamicMatrixInjection({
602
+ filePath: file.filePath,
603
+ parsed: file.parsed,
604
+ textContent: file.textContent,
605
+ })
606
+ : [],
607
+ },
608
+ {
609
+ id: "dependabot-auto-merge",
610
+ run: ({ file, input }) => input.config.workflowAuditsEnabled
611
+ ? detectDependabotAutoMerge({
612
+ filePath: file.filePath,
613
+ parsed: file.parsed,
614
+ textContent: file.textContent,
615
+ })
616
+ : [],
617
+ },
618
+ {
619
+ id: "workflow-local-action-mutation",
620
+ run: ({ file, input }) => input.config.workflowAuditsEnabled
621
+ ? detectWorkflowLocalActionMutation({
622
+ filePath: file.filePath,
623
+ parsed: file.parsed,
624
+ textContent: file.textContent,
625
+ })
626
+ : [],
627
+ },
539
628
  {
540
629
  id: "hardcoded-container-credentials",
541
630
  run: ({ file, input }) => input.config.workflowAuditsEnabled
@@ -0,0 +1,24 @@
1
+ import type { WorkflowFacts } from "./types.js";
2
+ export interface WorkflowArtifactTransferEdge {
3
+ artifactName: string;
4
+ producerJobId: string;
5
+ producerStepIndex: number;
6
+ consumerJobId: string;
7
+ consumerStepIndex: number;
8
+ consumerDownloadsAll: boolean;
9
+ }
10
+ export interface WorkflowCallBoundaryContext {
11
+ hasWorkflowCall: boolean;
12
+ declaredInputKeys: string[];
13
+ requiredInputKeys: string[];
14
+ declaredSecretKeys: string[];
15
+ requiredSecretKeys: string[];
16
+ jobsWithInheritedSecrets: string[];
17
+ jobsCallingReusableWorkflow: string[];
18
+ }
19
+ export declare function buildWorkflowNeedsGraph(facts: WorkflowFacts): Map<string, string[]>;
20
+ export declare function collectTransitiveDependencies(facts: WorkflowFacts, seedJobIds: Iterable<string>): Set<string>;
21
+ export declare function collectTransitiveDependents(facts: WorkflowFacts, seedJobIds: Iterable<string>): Set<string>;
22
+ export declare function collectArtifactTransferEdges(facts: WorkflowFacts): WorkflowArtifactTransferEdge[];
23
+ export declare function collectUntrustedReachableJobIds(facts: WorkflowFacts): Set<string>;
24
+ export declare function extractWorkflowCallBoundaryContext(parsed: unknown, facts: WorkflowFacts): WorkflowCallBoundaryContext;
@@ -0,0 +1,201 @@
1
+ const UNTRUSTED_TRIGGER_SET = new Set([
2
+ "pull_request",
3
+ "pull_request_target",
4
+ "workflow_run",
5
+ "issue_comment",
6
+ "pull_request_review_comment",
7
+ "discussion_comment",
8
+ ]);
9
+ const BOT_ONLY_CONDITION_PATTERNS = [
10
+ /github\.actor\s*==\s*['"]dependabot\[bot\]['"]/iu,
11
+ /github\.actor\s*==\s*['"]github-actions\[bot\]['"]/iu,
12
+ /github\.event\.pull_request\.head\.repo\.fork\s*==\s*false/iu,
13
+ ];
14
+ const UPLOAD_ARTIFACT_ACTIONS = new Set([
15
+ "actions/upload-artifact",
16
+ "actions/upload-artifact/merge",
17
+ ]);
18
+ const DOWNLOAD_ARTIFACT_ACTIONS = new Set(["actions/download-artifact"]);
19
+ function asRecord(value) {
20
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
21
+ return null;
22
+ }
23
+ return value;
24
+ }
25
+ function normalizeUses(uses) {
26
+ if (!uses) {
27
+ return null;
28
+ }
29
+ const trimmed = uses.trim().toLowerCase();
30
+ if (trimmed.length === 0) {
31
+ return null;
32
+ }
33
+ const atIndex = trimmed.indexOf("@");
34
+ if (atIndex === -1) {
35
+ return trimmed;
36
+ }
37
+ return trimmed.slice(0, atIndex);
38
+ }
39
+ function isWorkflowTriggerUntrusted(trigger) {
40
+ return UNTRUSTED_TRIGGER_SET.has(trigger.trim().toLowerCase());
41
+ }
42
+ function isUntrustedRestrictedCondition(condition) {
43
+ if (!condition) {
44
+ return false;
45
+ }
46
+ return BOT_ONLY_CONDITION_PATTERNS.some((pattern) => pattern.test(condition));
47
+ }
48
+ function normalizeArtifactName(value) {
49
+ if (!value) {
50
+ return null;
51
+ }
52
+ const normalized = value.trim();
53
+ return normalized.length > 0 ? normalized : null;
54
+ }
55
+ export function buildWorkflowNeedsGraph(facts) {
56
+ return new Map(facts.jobs.map((job) => [job.id, [...job.needs]]));
57
+ }
58
+ export function collectTransitiveDependencies(facts, seedJobIds) {
59
+ const graph = buildWorkflowNeedsGraph(facts);
60
+ const visited = new Set();
61
+ const queue = [...seedJobIds];
62
+ while (queue.length > 0) {
63
+ const current = queue.shift();
64
+ if (!current) {
65
+ continue;
66
+ }
67
+ const dependencies = graph.get(current) ?? [];
68
+ for (const dependency of dependencies) {
69
+ if (visited.has(dependency)) {
70
+ continue;
71
+ }
72
+ visited.add(dependency);
73
+ queue.push(dependency);
74
+ }
75
+ }
76
+ return visited;
77
+ }
78
+ export function collectTransitiveDependents(facts, seedJobIds) {
79
+ const reverseGraph = new Map();
80
+ for (const job of facts.jobs) {
81
+ for (const dependency of job.needs) {
82
+ const dependents = reverseGraph.get(dependency) ?? [];
83
+ dependents.push(job.id);
84
+ reverseGraph.set(dependency, dependents);
85
+ }
86
+ }
87
+ const visited = new Set();
88
+ const queue = [...seedJobIds];
89
+ while (queue.length > 0) {
90
+ const current = queue.shift();
91
+ if (!current) {
92
+ continue;
93
+ }
94
+ const dependents = reverseGraph.get(current) ?? [];
95
+ for (const dependent of dependents) {
96
+ if (visited.has(dependent)) {
97
+ continue;
98
+ }
99
+ visited.add(dependent);
100
+ queue.push(dependent);
101
+ }
102
+ }
103
+ return visited;
104
+ }
105
+ export function collectArtifactTransferEdges(facts) {
106
+ const producersByArtifact = new Map();
107
+ const edges = [];
108
+ const dedupe = new Set();
109
+ for (const job of facts.jobs) {
110
+ for (const [stepIndex, step] of job.steps.entries()) {
111
+ const normalizedUses = normalizeUses(step.uses);
112
+ if (!normalizedUses || !UPLOAD_ARTIFACT_ACTIONS.has(normalizedUses)) {
113
+ continue;
114
+ }
115
+ const artifactName = normalizeArtifactName(step.with?.name) ?? "__unnamed__";
116
+ const producers = producersByArtifact.get(artifactName) ?? [];
117
+ producers.push({ jobId: job.id, stepIndex });
118
+ producersByArtifact.set(artifactName, producers);
119
+ }
120
+ }
121
+ for (const job of facts.jobs) {
122
+ for (const [stepIndex, step] of job.steps.entries()) {
123
+ const normalizedUses = normalizeUses(step.uses);
124
+ if (!normalizedUses || !DOWNLOAD_ARTIFACT_ACTIONS.has(normalizedUses)) {
125
+ continue;
126
+ }
127
+ const requestedName = normalizeArtifactName(step.with?.name);
128
+ const consumerDownloadsAll = !requestedName;
129
+ const artifactNames = requestedName
130
+ ? [requestedName]
131
+ : Array.from(producersByArtifact.keys());
132
+ for (const artifactName of artifactNames) {
133
+ const producers = producersByArtifact.get(artifactName) ?? [];
134
+ for (const producer of producers) {
135
+ const key = [
136
+ artifactName,
137
+ producer.jobId,
138
+ producer.stepIndex,
139
+ job.id,
140
+ stepIndex,
141
+ consumerDownloadsAll ? "all" : "named",
142
+ ].join("|");
143
+ if (dedupe.has(key)) {
144
+ continue;
145
+ }
146
+ dedupe.add(key);
147
+ edges.push({
148
+ artifactName,
149
+ producerJobId: producer.jobId,
150
+ producerStepIndex: producer.stepIndex,
151
+ consumerJobId: job.id,
152
+ consumerStepIndex: stepIndex,
153
+ consumerDownloadsAll,
154
+ });
155
+ }
156
+ }
157
+ }
158
+ }
159
+ return edges;
160
+ }
161
+ export function collectUntrustedReachableJobIds(facts) {
162
+ const hasUntrustedTrigger = facts.triggers.some((trigger) => isWorkflowTriggerUntrusted(trigger));
163
+ if (!hasUntrustedTrigger) {
164
+ return new Set();
165
+ }
166
+ return new Set(facts.jobs.filter((job) => !isUntrustedRestrictedCondition(job.if)).map((job) => job.id));
167
+ }
168
+ export function extractWorkflowCallBoundaryContext(parsed, facts) {
169
+ const root = asRecord(parsed);
170
+ const onRecord = root ? asRecord(root.on) : null;
171
+ const workflowCall = onRecord ? asRecord(onRecord.workflow_call) : null;
172
+ const inputsRecord = workflowCall ? asRecord(workflowCall.inputs) : null;
173
+ const declaredInputKeys = inputsRecord ? Object.keys(inputsRecord) : [];
174
+ const requiredInputKeys = inputsRecord
175
+ ? Object.entries(inputsRecord)
176
+ .filter(([, value]) => asRecord(value)?.required === true)
177
+ .map(([key]) => key)
178
+ : [];
179
+ const secretsRecord = workflowCall ? asRecord(workflowCall.secrets) : null;
180
+ const declaredSecretKeys = secretsRecord ? Object.keys(secretsRecord) : [];
181
+ const requiredSecretKeys = secretsRecord
182
+ ? Object.entries(secretsRecord)
183
+ .filter(([, value]) => asRecord(value)?.required === true)
184
+ .map(([key]) => key)
185
+ : [];
186
+ const jobsWithInheritedSecrets = facts.jobs
187
+ .filter((job) => typeof job.secrets === "string" && job.secrets.trim().toLowerCase() === "inherit")
188
+ .map((job) => job.id);
189
+ const jobsCallingReusableWorkflow = facts.jobs
190
+ .filter((job) => typeof job.uses === "string" && job.uses.trim().length > 0)
191
+ .map((job) => job.id);
192
+ return {
193
+ hasWorkflowCall: workflowCall !== null,
194
+ declaredInputKeys,
195
+ requiredInputKeys,
196
+ declaredSecretKeys,
197
+ requiredSecretKeys,
198
+ jobsWithInheritedSecrets,
199
+ jobsCallingReusableWorkflow,
200
+ };
201
+ }
@@ -7,6 +7,29 @@ function asRecord(value) {
7
7
  function asString(value) {
8
8
  return typeof value === "string" ? value : undefined;
9
9
  }
10
+ function toStringMap(value) {
11
+ const record = asRecord(value);
12
+ if (!record) {
13
+ return undefined;
14
+ }
15
+ const entries = {};
16
+ for (const [key, entry] of Object.entries(record)) {
17
+ if (typeof entry === "string") {
18
+ entries[key] = entry;
19
+ }
20
+ }
21
+ return Object.keys(entries).length > 0 ? entries : undefined;
22
+ }
23
+ function extractNeeds(value) {
24
+ if (typeof value === "string") {
25
+ const normalized = value.trim();
26
+ return normalized.length > 0 ? [normalized] : [];
27
+ }
28
+ if (!Array.isArray(value)) {
29
+ return [];
30
+ }
31
+ return value.filter((entry) => typeof entry === "string" && entry.trim().length > 0);
32
+ }
10
33
  function normalizeWorkflowPath(value) {
11
34
  return value.replaceAll("\\", "/");
12
35
  }
@@ -31,19 +54,11 @@ function extractStepFacts(step) {
31
54
  if (!stepRecord) {
32
55
  return null;
33
56
  }
34
- const withValues = asRecord(stepRecord.with);
35
- const withEntries = {};
36
- if (withValues) {
37
- for (const [key, value] of Object.entries(withValues)) {
38
- if (typeof value === "string") {
39
- withEntries[key] = value;
40
- }
41
- }
42
- }
43
57
  const stepFacts = {
58
+ if: asString(stepRecord.if),
44
59
  uses: asString(stepRecord.uses),
45
60
  run: asString(stepRecord.run),
46
- with: Object.keys(withEntries).length > 0 ? withEntries : undefined,
61
+ with: toStringMap(stepRecord.with),
47
62
  };
48
63
  if (!stepFacts.uses && !stepFacts.run) {
49
64
  return null;
@@ -61,6 +76,11 @@ function extractJobFacts(id, value) {
61
76
  .filter((step) => step !== null);
62
77
  return {
63
78
  id,
79
+ if: asString(jobRecord.if),
80
+ uses: asString(jobRecord.uses),
81
+ with: toStringMap(jobRecord.with),
82
+ needs: extractNeeds(jobRecord.needs),
83
+ secrets: jobRecord.secrets,
64
84
  permissions: jobRecord.permissions,
65
85
  steps,
66
86
  };
@@ -1,10 +1,16 @@
1
1
  export interface WorkflowStepFacts {
2
+ if?: string;
2
3
  uses?: string;
3
4
  run?: string;
4
5
  with?: Record<string, string>;
5
6
  }
6
7
  export interface WorkflowJobFacts {
7
8
  id: string;
9
+ if?: string;
10
+ uses?: string;
11
+ with?: Record<string, string>;
12
+ needs: string[];
13
+ secrets?: unknown;
8
14
  permissions?: unknown;
9
15
  steps: WorkflowStepFacts[];
10
16
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codegate-ai",
3
- "version": "0.9.1",
3
+ "version": "0.11.0",
4
4
  "description": "Pre-flight security scanner for AI coding tool configurations.",
5
5
  "license": "MIT",
6
6
  "type": "module",