codegate-ai 0.8.0 → 0.9.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 (118) hide show
  1. package/README.md +48 -15
  2. package/dist/cli.js +41 -7
  3. package/dist/commands/scan-command.d.ts +6 -1
  4. package/dist/commands/scan-command.js +1 -1
  5. package/dist/commands/scan-content-command.js +5 -1
  6. package/dist/config/inline-ignore.d.ts +11 -0
  7. package/dist/config/inline-ignore.js +57 -0
  8. package/dist/config/suppression-policy.d.ts +8 -0
  9. package/dist/config/suppression-policy.js +66 -1
  10. package/dist/config.d.ts +19 -1
  11. package/dist/config.js +160 -1
  12. package/dist/layer2-static/action/parser.d.ts +3 -0
  13. package/dist/layer2-static/action/parser.js +165 -0
  14. package/dist/layer2-static/action/types.d.ts +39 -0
  15. package/dist/layer2-static/action/types.js +1 -0
  16. package/dist/layer2-static/advisories/cache.d.ts +6 -0
  17. package/dist/layer2-static/advisories/cache.js +44 -0
  18. package/dist/layer2-static/advisories/cache.ts +62 -0
  19. package/dist/layer2-static/advisories/gha-advisory-client.d.ts +7 -0
  20. package/dist/layer2-static/advisories/gha-advisory-client.js +4 -0
  21. package/dist/layer2-static/advisories/gha-advisory-client.ts +13 -0
  22. package/dist/layer2-static/advisories/gha-known-vulnerable-actions.json +4 -0
  23. package/dist/layer2-static/audits/registry.d.ts +3 -0
  24. package/dist/layer2-static/audits/registry.js +29 -0
  25. package/dist/layer2-static/audits/types.d.ts +12 -0
  26. package/dist/layer2-static/audits/types.js +1 -0
  27. package/dist/layer2-static/dependabot/parser.d.ts +3 -0
  28. package/dist/layer2-static/dependabot/parser.js +189 -0
  29. package/dist/layer2-static/dependabot/types.d.ts +50 -0
  30. package/dist/layer2-static/dependabot/types.js +1 -0
  31. package/dist/layer2-static/detectors/dependabot-cooldown.d.ts +7 -0
  32. package/dist/layer2-static/detectors/dependabot-cooldown.js +54 -0
  33. package/dist/layer2-static/detectors/dependabot-execution.d.ts +7 -0
  34. package/dist/layer2-static/detectors/dependabot-execution.js +68 -0
  35. package/dist/layer2-static/detectors/workflow-anonymous-definition.d.ts +7 -0
  36. package/dist/layer2-static/detectors/workflow-anonymous-definition.js +48 -0
  37. package/dist/layer2-static/detectors/workflow-archived-uses.d.ts +7 -0
  38. package/dist/layer2-static/detectors/workflow-archived-uses.js +147 -0
  39. package/dist/layer2-static/detectors/workflow-artipacked.d.ts +7 -0
  40. package/dist/layer2-static/detectors/workflow-artipacked.js +62 -0
  41. package/dist/layer2-static/detectors/workflow-bot-conditions.d.ts +7 -0
  42. package/dist/layer2-static/detectors/workflow-bot-conditions.js +108 -0
  43. package/dist/layer2-static/detectors/workflow-cache-poisoning.d.ts +7 -0
  44. package/dist/layer2-static/detectors/workflow-cache-poisoning.js +67 -0
  45. package/dist/layer2-static/detectors/workflow-concurrency-limits.d.ts +7 -0
  46. package/dist/layer2-static/detectors/workflow-concurrency-limits.js +76 -0
  47. package/dist/layer2-static/detectors/workflow-dangerous-triggers.d.ts +6 -0
  48. package/dist/layer2-static/detectors/workflow-dangerous-triggers.js +38 -0
  49. package/dist/layer2-static/detectors/workflow-excessive-permissions.d.ts +6 -0
  50. package/dist/layer2-static/detectors/workflow-excessive-permissions.js +65 -0
  51. package/dist/layer2-static/detectors/workflow-forbidden-uses.d.ts +12 -0
  52. package/dist/layer2-static/detectors/workflow-forbidden-uses.js +148 -0
  53. package/dist/layer2-static/detectors/workflow-github-env.d.ts +7 -0
  54. package/dist/layer2-static/detectors/workflow-github-env.js +49 -0
  55. package/dist/layer2-static/detectors/workflow-hardcoded-container-credentials.d.ts +7 -0
  56. package/dist/layer2-static/detectors/workflow-hardcoded-container-credentials.js +90 -0
  57. package/dist/layer2-static/detectors/workflow-impostor-commit.d.ts +9 -0
  58. package/dist/layer2-static/detectors/workflow-impostor-commit.js +127 -0
  59. package/dist/layer2-static/detectors/workflow-insecure-commands.d.ts +7 -0
  60. package/dist/layer2-static/detectors/workflow-insecure-commands.js +72 -0
  61. package/dist/layer2-static/detectors/workflow-known-vuln-action.d.ts +8 -0
  62. package/dist/layer2-static/detectors/workflow-known-vuln-action.js +70 -0
  63. package/dist/layer2-static/detectors/workflow-misfeature.d.ts +7 -0
  64. package/dist/layer2-static/detectors/workflow-misfeature.js +101 -0
  65. package/dist/layer2-static/detectors/workflow-obfuscation.d.ts +7 -0
  66. package/dist/layer2-static/detectors/workflow-obfuscation.js +79 -0
  67. package/dist/layer2-static/detectors/workflow-overprovisioned-secrets.d.ts +7 -0
  68. package/dist/layer2-static/detectors/workflow-overprovisioned-secrets.js +81 -0
  69. package/dist/layer2-static/detectors/workflow-ref-confusion.d.ts +7 -0
  70. package/dist/layer2-static/detectors/workflow-ref-confusion.js +69 -0
  71. package/dist/layer2-static/detectors/workflow-ref-version-mismatch.d.ts +9 -0
  72. package/dist/layer2-static/detectors/workflow-ref-version-mismatch.js +179 -0
  73. package/dist/layer2-static/detectors/workflow-secrets-inherit.d.ts +7 -0
  74. package/dist/layer2-static/detectors/workflow-secrets-inherit.js +59 -0
  75. package/dist/layer2-static/detectors/workflow-secrets-outside-env.d.ts +7 -0
  76. package/dist/layer2-static/detectors/workflow-secrets-outside-env.js +99 -0
  77. package/dist/layer2-static/detectors/workflow-self-hosted-runner.d.ts +7 -0
  78. package/dist/layer2-static/detectors/workflow-self-hosted-runner.js +67 -0
  79. package/dist/layer2-static/detectors/workflow-stale-action-refs.d.ts +7 -0
  80. package/dist/layer2-static/detectors/workflow-stale-action-refs.js +178 -0
  81. package/dist/layer2-static/detectors/workflow-superfluous-actions.d.ts +7 -0
  82. package/dist/layer2-static/detectors/workflow-superfluous-actions.js +70 -0
  83. package/dist/layer2-static/detectors/workflow-template-injection.d.ts +6 -0
  84. package/dist/layer2-static/detectors/workflow-template-injection.js +94 -0
  85. package/dist/layer2-static/detectors/workflow-undocumented-permissions.d.ts +7 -0
  86. package/dist/layer2-static/detectors/workflow-undocumented-permissions.js +101 -0
  87. package/dist/layer2-static/detectors/workflow-unpinned-images.d.ts +7 -0
  88. package/dist/layer2-static/detectors/workflow-unpinned-images.js +108 -0
  89. package/dist/layer2-static/detectors/workflow-unpinned-uses.d.ts +7 -0
  90. package/dist/layer2-static/detectors/workflow-unpinned-uses.js +60 -0
  91. package/dist/layer2-static/detectors/workflow-unredacted-secrets.d.ts +7 -0
  92. package/dist/layer2-static/detectors/workflow-unredacted-secrets.js +92 -0
  93. package/dist/layer2-static/detectors/workflow-unsound-condition.d.ts +7 -0
  94. package/dist/layer2-static/detectors/workflow-unsound-condition.js +90 -0
  95. package/dist/layer2-static/detectors/workflow-unsound-contains.d.ts +7 -0
  96. package/dist/layer2-static/detectors/workflow-unsound-contains.js +88 -0
  97. package/dist/layer2-static/detectors/workflow-use-trusted-publishing.d.ts +7 -0
  98. package/dist/layer2-static/detectors/workflow-use-trusted-publishing.js +66 -0
  99. package/dist/layer2-static/engine.d.ts +9 -1
  100. package/dist/layer2-static/engine.js +490 -50
  101. package/dist/layer2-static/github/cache.d.ts +8 -0
  102. package/dist/layer2-static/github/cache.js +41 -0
  103. package/dist/layer2-static/github/client.d.ts +17 -0
  104. package/dist/layer2-static/github/client.js +43 -0
  105. package/dist/layer2-static/workflow/injection-sinks.json +5 -0
  106. package/dist/layer2-static/workflow/parser.d.ts +3 -0
  107. package/dist/layer2-static/workflow/parser.js +88 -0
  108. package/dist/layer2-static/workflow/types.d.ts +15 -0
  109. package/dist/layer2-static/workflow/types.js +1 -0
  110. package/dist/pipeline.d.ts +1 -1
  111. package/dist/pipeline.js +3 -3
  112. package/dist/reporter/sarif.js +23 -5
  113. package/dist/reporter/terminal.js +12 -0
  114. package/dist/scan.d.ts +7 -1
  115. package/dist/scan.js +154 -40
  116. package/dist/types/finding.d.ts +1 -1
  117. package/dist/types/finding.js +5 -0
  118. package/package.json +1 -1
@@ -0,0 +1,70 @@
1
+ import { buildFindingEvidence } from "../evidence.js";
2
+ import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";
3
+ function normalizeUses(value) {
4
+ return value.trim().toLowerCase();
5
+ }
6
+ function isExternalUses(value) {
7
+ const normalized = value.trim();
8
+ return (normalized.length > 0 &&
9
+ !normalized.startsWith("./") &&
10
+ !normalized.startsWith("../") &&
11
+ !normalized.startsWith("docker://"));
12
+ }
13
+ export function detectWorkflowSuperfluousActions(input) {
14
+ if (!isGitHubWorkflowPath(input.filePath)) {
15
+ return [];
16
+ }
17
+ const facts = extractWorkflowFacts(input.parsed);
18
+ if (!facts) {
19
+ return [];
20
+ }
21
+ const findings = [];
22
+ facts.jobs.forEach((job, jobIndex) => {
23
+ const usesCounts = new Map();
24
+ job.steps.forEach((step, stepIndex) => {
25
+ const uses = step.uses?.trim();
26
+ if (!uses || !isExternalUses(uses)) {
27
+ return;
28
+ }
29
+ const key = normalizeUses(uses);
30
+ const current = usesCounts.get(key);
31
+ if (!current) {
32
+ usesCounts.set(key, { count: 1, firstStep: stepIndex, rawUses: uses });
33
+ return;
34
+ }
35
+ current.count += 1;
36
+ });
37
+ for (const [key, value] of usesCounts.entries()) {
38
+ if (value.count < 2) {
39
+ continue;
40
+ }
41
+ const evidence = buildFindingEvidence({
42
+ textContent: input.textContent,
43
+ searchTerms: [value.rawUses],
44
+ fallbackValue: `${value.rawUses} repeated ${value.count} times`,
45
+ });
46
+ findings.push({
47
+ rule_id: "workflow-superfluous-actions",
48
+ finding_id: `WORKFLOW_SUPERFLUOUS_ACTIONS-${input.filePath}-${jobIndex}-${key}`,
49
+ severity: "LOW",
50
+ category: "CI_SUPPLY_CHAIN",
51
+ layer: "L2",
52
+ file_path: input.filePath,
53
+ location: { field: `jobs.${job.id}.steps[${value.firstStep}].uses` },
54
+ description: "Workflow repeats the same external action in a single job",
55
+ affected_tools: ["github-actions"],
56
+ cve: null,
57
+ owasp: ["ASI02"],
58
+ cwe: "CWE-1059",
59
+ confidence: "MEDIUM",
60
+ fixable: false,
61
+ remediation_actions: [
62
+ "Remove duplicate external action invocations unless repetition is explicitly required",
63
+ ],
64
+ evidence: evidence?.evidence ?? null,
65
+ suppressed: false,
66
+ });
67
+ }
68
+ });
69
+ return findings;
70
+ }
@@ -0,0 +1,6 @@
1
+ import type { Finding } from "../../types/finding.js";
2
+ export interface WorkflowTemplateInjectionInput {
3
+ filePath: string;
4
+ parsed: unknown;
5
+ }
6
+ export declare function detectWorkflowTemplateInjection(input: WorkflowTemplateInjectionInput): Finding[];
@@ -0,0 +1,94 @@
1
+ import sinkMap from "../workflow/injection-sinks.json" with { type: "json" };
2
+ import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";
3
+ const UNTRUSTED_TRIGGERS = new Set([
4
+ "pull_request",
5
+ "pull_request_target",
6
+ "issue_comment",
7
+ "discussion_comment",
8
+ "workflow_run",
9
+ ]);
10
+ function hasTemplateExpression(value) {
11
+ return typeof value === "string" && value.includes("${{");
12
+ }
13
+ function normalizeUsesSlug(value) {
14
+ const beforeRef = value.split("@")[0] ?? value;
15
+ return beforeRef.replace(/\/+$/u, "").toLowerCase();
16
+ }
17
+ export function detectWorkflowTemplateInjection(input) {
18
+ if (!isGitHubWorkflowPath(input.filePath)) {
19
+ return [];
20
+ }
21
+ const facts = extractWorkflowFacts(input.parsed);
22
+ if (!facts) {
23
+ return [];
24
+ }
25
+ const hasUntrustedTrigger = facts.triggers.some((trigger) => UNTRUSTED_TRIGGERS.has(trigger));
26
+ if (!hasUntrustedTrigger) {
27
+ return [];
28
+ }
29
+ const findings = [];
30
+ facts.jobs.forEach((job, jobIndex) => {
31
+ job.steps.forEach((step, stepIndex) => {
32
+ if (hasTemplateExpression(step.run)) {
33
+ findings.push({
34
+ rule_id: "workflow-template-injection",
35
+ finding_id: `WORKFLOW_TEMPLATE_INJECTION-RUN-${input.filePath}-${jobIndex}-${stepIndex}`,
36
+ severity: "HIGH",
37
+ category: "CI_TEMPLATE_INJECTION",
38
+ layer: "L2",
39
+ file_path: input.filePath,
40
+ location: { field: `jobs.${job.id}.steps[${stepIndex}].run` },
41
+ description: "Template expression in run step may allow untrusted input to reach shell execution",
42
+ affected_tools: ["github-actions"],
43
+ cve: null,
44
+ owasp: ["ASI02"],
45
+ cwe: "CWE-94",
46
+ confidence: "HIGH",
47
+ fixable: false,
48
+ remediation_actions: [
49
+ "Move untrusted template expressions into validated environment variables before execution",
50
+ ],
51
+ evidence: step.run ?? null,
52
+ suppressed: false,
53
+ });
54
+ }
55
+ const uses = step.uses?.trim();
56
+ if (!uses || !step.with) {
57
+ return;
58
+ }
59
+ const slug = normalizeUsesSlug(uses);
60
+ const sinkFields = sinkMap[slug];
61
+ if (!sinkFields || sinkFields.length === 0) {
62
+ return;
63
+ }
64
+ for (const sinkField of sinkFields) {
65
+ const sinkValue = step.with[sinkField];
66
+ if (!hasTemplateExpression(sinkValue)) {
67
+ continue;
68
+ }
69
+ findings.push({
70
+ rule_id: "workflow-template-injection",
71
+ finding_id: `WORKFLOW_TEMPLATE_INJECTION-SINK-${input.filePath}-${jobIndex}-${stepIndex}-${sinkField}`,
72
+ severity: "HIGH",
73
+ category: "CI_TEMPLATE_INJECTION",
74
+ layer: "L2",
75
+ file_path: input.filePath,
76
+ location: { field: `jobs.${job.id}.steps[${stepIndex}].with.${sinkField}` },
77
+ description: "Template expression reaches an action input known to execute code or evaluate scripts",
78
+ affected_tools: ["github-actions"],
79
+ cve: null,
80
+ owasp: ["ASI02"],
81
+ cwe: "CWE-94",
82
+ confidence: "HIGH",
83
+ fixable: false,
84
+ remediation_actions: [
85
+ "Avoid passing untrusted template expressions into code execution sink inputs",
86
+ ],
87
+ evidence: sinkValue,
88
+ suppressed: false,
89
+ });
90
+ }
91
+ });
92
+ });
93
+ return findings;
94
+ }
@@ -0,0 +1,7 @@
1
+ import type { Finding } from "../../types/finding.js";
2
+ export interface WorkflowUndocumentedPermissionsInput {
3
+ filePath: string;
4
+ parsed: unknown;
5
+ textContent: string;
6
+ }
7
+ export declare function detectWorkflowUndocumentedPermissions(input: WorkflowUndocumentedPermissionsInput): Finding[];
@@ -0,0 +1,101 @@
1
+ import { buildFindingEvidence } from "../evidence.js";
2
+ import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";
3
+ function isWritePermission(value) {
4
+ if (typeof value === "string") {
5
+ return value === "write" || value === "write-all";
6
+ }
7
+ return false;
8
+ }
9
+ function lineContaining(textContent, term) {
10
+ const lines = textContent.split(/\r?\n/u);
11
+ for (const line of lines) {
12
+ if (line.includes(term)) {
13
+ return line;
14
+ }
15
+ }
16
+ return null;
17
+ }
18
+ function hasLineComment(textContent, term) {
19
+ const line = lineContaining(textContent, term);
20
+ return line ? line.includes("#") : false;
21
+ }
22
+ function pushPermissionFinding(findings, input, field, evidenceTerm, description, permissionValue) {
23
+ const evidence = buildFindingEvidence({
24
+ textContent: input.textContent,
25
+ searchTerms: [evidenceTerm, permissionValue],
26
+ fallbackValue: evidenceTerm,
27
+ });
28
+ findings.push({
29
+ rule_id: "workflow-undocumented-permissions",
30
+ finding_id: `WORKFLOW_UNDOCUMENTED_PERMISSIONS-${input.filePath}-${field}`,
31
+ severity: "HIGH",
32
+ category: "CI_PERMISSIONS",
33
+ layer: "L2",
34
+ file_path: input.filePath,
35
+ location: { field },
36
+ description,
37
+ affected_tools: ["github-actions"],
38
+ cve: null,
39
+ owasp: ["ASI02"],
40
+ cwe: "CWE-732",
41
+ confidence: "MEDIUM",
42
+ fixable: false,
43
+ remediation_actions: [
44
+ "Document why elevated permissions are required or reduce the permission scope",
45
+ ],
46
+ evidence: evidence?.evidence ?? null,
47
+ suppressed: false,
48
+ });
49
+ }
50
+ export function detectWorkflowUndocumentedPermissions(input) {
51
+ if (!isGitHubWorkflowPath(input.filePath)) {
52
+ return [];
53
+ }
54
+ const facts = extractWorkflowFacts(input.parsed);
55
+ if (!facts) {
56
+ return [];
57
+ }
58
+ const findings = [];
59
+ if (typeof facts.workflowPermissions === "string") {
60
+ const value = facts.workflowPermissions.trim().toLowerCase();
61
+ if ((value === "write-all" || value === "write") &&
62
+ !hasLineComment(input.textContent, "permissions:")) {
63
+ pushPermissionFinding(findings, input, "permissions", `permissions: ${facts.workflowPermissions}`, "Workflow defines elevated permissions without documenting why they are needed", `permissions: ${facts.workflowPermissions}`);
64
+ }
65
+ }
66
+ else if (facts.workflowPermissions && typeof facts.workflowPermissions === "object") {
67
+ for (const [scope, value] of Object.entries(facts.workflowPermissions)) {
68
+ if (!isWritePermission(value)) {
69
+ continue;
70
+ }
71
+ const term = `${scope}: ${value}`;
72
+ if (hasLineComment(input.textContent, term)) {
73
+ continue;
74
+ }
75
+ pushPermissionFinding(findings, input, `permissions.${scope}`, term, "Workflow defines elevated permissions without documenting why they are needed", term);
76
+ }
77
+ }
78
+ facts.jobs.forEach((job) => {
79
+ if (typeof job.permissions === "string") {
80
+ const value = job.permissions.trim().toLowerCase();
81
+ if (value === "write-all" && !hasLineComment(input.textContent, "permissions:")) {
82
+ pushPermissionFinding(findings, input, `jobs.${job.id}.permissions`, `permissions: ${job.permissions}`, "Job defines elevated permissions without documenting why they are needed", `permissions: ${job.permissions}`);
83
+ }
84
+ return;
85
+ }
86
+ if (!job.permissions || typeof job.permissions !== "object") {
87
+ return;
88
+ }
89
+ for (const [scope, value] of Object.entries(job.permissions)) {
90
+ if (!isWritePermission(value)) {
91
+ continue;
92
+ }
93
+ const term = `${scope}: ${value}`;
94
+ if (hasLineComment(input.textContent, term)) {
95
+ continue;
96
+ }
97
+ pushPermissionFinding(findings, input, `jobs.${job.id}.permissions.${scope}`, term, "Job defines elevated permissions without documenting why they are needed", term);
98
+ }
99
+ });
100
+ return findings;
101
+ }
@@ -0,0 +1,7 @@
1
+ import type { Finding } from "../../types/finding.js";
2
+ export interface WorkflowUnpinnedImagesInput {
3
+ filePath: string;
4
+ parsed: unknown;
5
+ textContent: string;
6
+ }
7
+ export declare function detectWorkflowUnpinnedImages(input: WorkflowUnpinnedImagesInput): Finding[];
@@ -0,0 +1,108 @@
1
+ import { buildFindingEvidence } from "../evidence.js";
2
+ import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";
3
+ const SHA256_DIGEST_RE = /@sha256:[a-f0-9]{64}$/iu;
4
+ const MUTABLE_LATEST_TAG = "latest";
5
+ function asRecord(value) {
6
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
7
+ return null;
8
+ }
9
+ return value;
10
+ }
11
+ function getImageTag(image) {
12
+ const trimmed = image.trim();
13
+ if (trimmed.length === 0 || trimmed.includes("@")) {
14
+ return null;
15
+ }
16
+ const lastSlash = trimmed.lastIndexOf("/");
17
+ const lastColon = trimmed.lastIndexOf(":");
18
+ if (lastColon <= lastSlash) {
19
+ return null;
20
+ }
21
+ const tag = trimmed.slice(lastColon + 1).trim();
22
+ return tag.length > 0 ? tag : null;
23
+ }
24
+ function isShaPinned(image) {
25
+ return SHA256_DIGEST_RE.test(image.trim());
26
+ }
27
+ function gatherImageTargets(parsed) {
28
+ const root = asRecord(parsed);
29
+ const jobsRecord = asRecord(root?.jobs);
30
+ if (!jobsRecord) {
31
+ return [];
32
+ }
33
+ const targets = [];
34
+ for (const [jobId, jobValue] of Object.entries(jobsRecord)) {
35
+ const jobRecord = asRecord(jobValue);
36
+ if (!jobRecord) {
37
+ continue;
38
+ }
39
+ const containerRecord = asRecord(jobRecord.container);
40
+ if (typeof containerRecord?.image === "string") {
41
+ targets.push({
42
+ field: `jobs.${jobId}.container.image`,
43
+ image: containerRecord.image,
44
+ });
45
+ }
46
+ const servicesRecord = asRecord(jobRecord.services);
47
+ if (servicesRecord) {
48
+ for (const [serviceName, serviceValue] of Object.entries(servicesRecord)) {
49
+ const serviceRecord = asRecord(serviceValue);
50
+ if (typeof serviceRecord?.image === "string") {
51
+ targets.push({
52
+ field: `jobs.${jobId}.services.${serviceName}.image`,
53
+ image: serviceRecord.image,
54
+ });
55
+ }
56
+ }
57
+ }
58
+ }
59
+ return targets;
60
+ }
61
+ export function detectWorkflowUnpinnedImages(input) {
62
+ if (!isGitHubWorkflowPath(input.filePath)) {
63
+ return [];
64
+ }
65
+ if (!extractWorkflowFacts(input.parsed)) {
66
+ return [];
67
+ }
68
+ const findings = [];
69
+ for (const target of gatherImageTargets(input.parsed)) {
70
+ const trimmedImage = target.image.trim();
71
+ if (trimmedImage.length === 0 || isShaPinned(trimmedImage)) {
72
+ continue;
73
+ }
74
+ const tag = getImageTag(trimmedImage);
75
+ if (tag !== null && tag !== MUTABLE_LATEST_TAG) {
76
+ continue;
77
+ }
78
+ const evidence = buildFindingEvidence({
79
+ textContent: input.textContent,
80
+ searchTerms: [trimmedImage, "image:"],
81
+ fallbackValue: trimmedImage,
82
+ });
83
+ findings.push({
84
+ rule_id: "workflow-unpinned-images",
85
+ finding_id: `WORKFLOW_UNPINNED_IMAGES-${input.filePath}-${target.field}`,
86
+ severity: "HIGH",
87
+ category: "CI_SUPPLY_CHAIN",
88
+ layer: "L2",
89
+ file_path: input.filePath,
90
+ location: { field: target.field },
91
+ description: tag === MUTABLE_LATEST_TAG
92
+ ? "Workflow container image uses the mutable latest tag"
93
+ : "Workflow container image is not pinned to a SHA256 digest",
94
+ affected_tools: ["github-actions"],
95
+ cve: null,
96
+ owasp: ["ASI02"],
97
+ cwe: "CWE-829",
98
+ confidence: "HIGH",
99
+ fixable: false,
100
+ remediation_actions: [
101
+ "Pin container images to immutable sha256 digests instead of mutable tags",
102
+ ],
103
+ evidence: evidence?.evidence ?? null,
104
+ suppressed: false,
105
+ });
106
+ }
107
+ return findings;
108
+ }
@@ -0,0 +1,7 @@
1
+ import type { Finding } from "../../types/finding.js";
2
+ export interface WorkflowUnpinnedUsesInput {
3
+ filePath: string;
4
+ parsed: unknown;
5
+ textContent: string;
6
+ }
7
+ export declare function detectWorkflowUnpinnedUses(input: WorkflowUnpinnedUsesInput): Finding[];
@@ -0,0 +1,60 @@
1
+ import { buildFindingEvidence } from "../evidence.js";
2
+ import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";
3
+ function isPinnedToCommit(ref) {
4
+ return /^[a-f0-9]{40}$/iu.test(ref.trim());
5
+ }
6
+ function isRepositoryUses(value) {
7
+ return /^[a-z0-9._-]+\/[a-z0-9._-]+(?:\/[^@]+)?@[^\s]+$/iu.test(value.trim());
8
+ }
9
+ export function detectWorkflowUnpinnedUses(input) {
10
+ if (!isGitHubWorkflowPath(input.filePath)) {
11
+ return [];
12
+ }
13
+ const facts = extractWorkflowFacts(input.parsed);
14
+ if (!facts) {
15
+ return [];
16
+ }
17
+ const findings = [];
18
+ facts.jobs.forEach((job, jobIndex) => {
19
+ job.steps.forEach((step, stepIndex) => {
20
+ const uses = step.uses?.trim();
21
+ if (!uses || uses.startsWith("./") || uses.startsWith("docker://")) {
22
+ return;
23
+ }
24
+ if (!isRepositoryUses(uses)) {
25
+ return;
26
+ }
27
+ const ref = uses.split("@").slice(1).join("@").trim();
28
+ if (ref.length === 0 || isPinnedToCommit(ref)) {
29
+ return;
30
+ }
31
+ const evidence = buildFindingEvidence({
32
+ textContent: input.textContent,
33
+ searchTerms: [uses],
34
+ fallbackValue: `uses: ${uses}`,
35
+ });
36
+ findings.push({
37
+ rule_id: "workflow-unpinned-uses",
38
+ finding_id: `WORKFLOW_UNPINNED_USES-${input.filePath}-${jobIndex}-${stepIndex}`,
39
+ severity: "HIGH",
40
+ category: "CI_SUPPLY_CHAIN",
41
+ layer: "L2",
42
+ file_path: input.filePath,
43
+ location: { field: `jobs.${job.id}.steps[${stepIndex}].uses` },
44
+ description: "Workflow action reference is not pinned to an immutable commit hash",
45
+ affected_tools: ["github-actions"],
46
+ cve: null,
47
+ owasp: ["ASI02"],
48
+ cwe: "CWE-829",
49
+ confidence: "HIGH",
50
+ fixable: false,
51
+ remediation_actions: [
52
+ "Pin external actions to a full commit SHA and track tag intent in comments",
53
+ ],
54
+ evidence: evidence?.evidence ?? null,
55
+ suppressed: false,
56
+ });
57
+ });
58
+ });
59
+ return findings;
60
+ }
@@ -0,0 +1,7 @@
1
+ import type { Finding } from "../../types/finding.js";
2
+ export interface WorkflowUnredactedSecretsInput {
3
+ filePath: string;
4
+ parsed: unknown;
5
+ textContent: string;
6
+ }
7
+ export declare function detectWorkflowUnredactedSecrets(input: WorkflowUnredactedSecretsInput): Finding[];
@@ -0,0 +1,92 @@
1
+ import { buildFindingEvidence } from "../evidence.js";
2
+ import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";
3
+ const SECRET_KEY_PATTERN = /(token|password|secret|api[_-]?key|private[_-]?key|access[_-]?key)/iu;
4
+ const REDACTED_VALUE_PATTERN = /^\s*\$\{\{\s*secrets\.[^}]+\}\}\s*$/iu;
5
+ function asRecord(value) {
6
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
7
+ return null;
8
+ }
9
+ return value;
10
+ }
11
+ function collectEnvSecrets(envValue, baseField, candidates) {
12
+ const env = asRecord(envValue);
13
+ if (!env) {
14
+ return;
15
+ }
16
+ for (const [key, value] of Object.entries(env)) {
17
+ if (!SECRET_KEY_PATTERN.test(key) || typeof value !== "string") {
18
+ continue;
19
+ }
20
+ const trimmed = value.trim();
21
+ if (trimmed.length < 8 || REDACTED_VALUE_PATTERN.test(trimmed)) {
22
+ continue;
23
+ }
24
+ candidates.push({
25
+ field: `${baseField}.${key}`,
26
+ key,
27
+ value: trimmed,
28
+ });
29
+ }
30
+ }
31
+ function gatherSecretCandidates(parsed) {
32
+ const root = asRecord(parsed);
33
+ const candidates = [];
34
+ collectEnvSecrets(root?.env, "env", candidates);
35
+ const jobs = asRecord(root?.jobs);
36
+ if (!jobs) {
37
+ return candidates;
38
+ }
39
+ for (const [jobId, jobValue] of Object.entries(jobs)) {
40
+ const job = asRecord(jobValue);
41
+ if (!job) {
42
+ continue;
43
+ }
44
+ collectEnvSecrets(job.env, `jobs.${jobId}.env`, candidates);
45
+ const steps = Array.isArray(job.steps) ? job.steps : [];
46
+ steps.forEach((stepValue, stepIndex) => {
47
+ const step = asRecord(stepValue);
48
+ if (!step) {
49
+ return;
50
+ }
51
+ collectEnvSecrets(step.env, `jobs.${jobId}.steps[${stepIndex}].env`, candidates);
52
+ });
53
+ }
54
+ return candidates;
55
+ }
56
+ export function detectWorkflowUnredactedSecrets(input) {
57
+ if (!isGitHubWorkflowPath(input.filePath)) {
58
+ return [];
59
+ }
60
+ const facts = extractWorkflowFacts(input.parsed);
61
+ if (!facts) {
62
+ return [];
63
+ }
64
+ return gatherSecretCandidates(input.parsed).map((candidate) => {
65
+ const evidence = buildFindingEvidence({
66
+ textContent: input.textContent,
67
+ searchTerms: [candidate.key, candidate.value],
68
+ fallbackValue: `${candidate.key} set to plaintext value`,
69
+ });
70
+ return {
71
+ rule_id: "unredacted-secrets",
72
+ finding_id: `UNREDACTED_SECRETS-${input.filePath}-${candidate.field}`,
73
+ severity: "HIGH",
74
+ category: "CI_PERMISSIONS",
75
+ layer: "L2",
76
+ file_path: input.filePath,
77
+ location: { field: candidate.field },
78
+ description: "Workflow exposes a plaintext secret-like value in environment configuration",
79
+ affected_tools: ["github-actions"],
80
+ cve: null,
81
+ owasp: ["ASI02"],
82
+ cwe: "CWE-798",
83
+ confidence: "HIGH",
84
+ fixable: false,
85
+ remediation_actions: [
86
+ "Move secret material to GitHub encrypted secrets and reference it via ${{ secrets.* }}",
87
+ ],
88
+ evidence: evidence?.evidence ?? null,
89
+ suppressed: false,
90
+ };
91
+ });
92
+ }
@@ -0,0 +1,7 @@
1
+ import type { Finding } from "../../types/finding.js";
2
+ export interface WorkflowUnsoundConditionInput {
3
+ filePath: string;
4
+ parsed: unknown;
5
+ textContent: string;
6
+ }
7
+ export declare function detectWorkflowUnsoundCondition(input: WorkflowUnsoundConditionInput): Finding[];
@@ -0,0 +1,90 @@
1
+ import { buildFindingEvidence } from "../evidence.js";
2
+ import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";
3
+ const SENSITIVE_COMMANDS = ["publish", "deploy", "release", "push"];
4
+ function asRecord(value) {
5
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
6
+ return null;
7
+ }
8
+ return value;
9
+ }
10
+ function gatherStepConditions(parsed) {
11
+ const root = asRecord(parsed);
12
+ const jobs = asRecord(root?.jobs);
13
+ if (!jobs) {
14
+ return [];
15
+ }
16
+ const candidates = [];
17
+ for (const [jobId, jobValue] of Object.entries(jobs)) {
18
+ const job = asRecord(jobValue);
19
+ const steps = Array.isArray(job?.steps) ? job.steps : [];
20
+ steps.forEach((stepValue, stepIndex) => {
21
+ const step = asRecord(stepValue);
22
+ const condition = typeof step?.if === "string" ? step.if : undefined;
23
+ if (!condition) {
24
+ return;
25
+ }
26
+ candidates.push({
27
+ jobId,
28
+ stepIndex,
29
+ condition,
30
+ run: typeof step?.run === "string" ? step.run : undefined,
31
+ uses: typeof step?.uses === "string" ? step.uses : undefined,
32
+ });
33
+ });
34
+ }
35
+ return candidates;
36
+ }
37
+ function isAlwaysCondition(condition) {
38
+ const normalized = condition.toLowerCase();
39
+ return normalized.includes("always()");
40
+ }
41
+ function isSensitiveExecution(run, uses) {
42
+ const runValue = run?.toLowerCase() ?? "";
43
+ if (SENSITIVE_COMMANDS.some((token) => runValue.includes(token))) {
44
+ return true;
45
+ }
46
+ return typeof uses === "string" && uses.trim().length > 0;
47
+ }
48
+ export function detectWorkflowUnsoundCondition(input) {
49
+ if (!isGitHubWorkflowPath(input.filePath)) {
50
+ return [];
51
+ }
52
+ const facts = extractWorkflowFacts(input.parsed);
53
+ if (!facts) {
54
+ return [];
55
+ }
56
+ const findings = [];
57
+ for (const candidate of gatherStepConditions(input.parsed)) {
58
+ if (!isAlwaysCondition(candidate.condition) ||
59
+ !isSensitiveExecution(candidate.run, candidate.uses)) {
60
+ continue;
61
+ }
62
+ const evidence = buildFindingEvidence({
63
+ textContent: input.textContent,
64
+ searchTerms: [candidate.condition, candidate.run ?? "", candidate.uses ?? ""],
65
+ fallbackValue: candidate.condition,
66
+ });
67
+ findings.push({
68
+ rule_id: "workflow-unsound-condition",
69
+ finding_id: `WORKFLOW_UNSOUND_CONDITION-${input.filePath}-${candidate.jobId}-${candidate.stepIndex}`,
70
+ severity: "MEDIUM",
71
+ category: "CI_TRIGGER",
72
+ layer: "L2",
73
+ file_path: input.filePath,
74
+ location: { field: `jobs.${candidate.jobId}.steps[${candidate.stepIndex}].if` },
75
+ description: "Workflow uses always() on a sensitive step, which can bypass expected failure gating",
76
+ affected_tools: ["github-actions"],
77
+ cve: null,
78
+ owasp: ["ASI02"],
79
+ cwe: "CWE-754",
80
+ confidence: "HIGH",
81
+ fixable: false,
82
+ remediation_actions: [
83
+ "Replace always() with explicit success and trust-boundary checks for sensitive steps",
84
+ ],
85
+ evidence: evidence?.evidence ?? null,
86
+ suppressed: false,
87
+ });
88
+ }
89
+ return findings;
90
+ }
@@ -0,0 +1,7 @@
1
+ import type { Finding } from "../../types/finding.js";
2
+ export interface WorkflowUnsoundContainsInput {
3
+ filePath: string;
4
+ parsed: unknown;
5
+ textContent: string;
6
+ }
7
+ export declare function detectWorkflowUnsoundContains(input: WorkflowUnsoundContainsInput): Finding[];